看懂這篇文章需要你有一定的SES使用基礎,如果你不明白,可以看這個問題裡的討論
http://segmentfault.com/q/1010000000095210
SES的全名是Simple Email Service,它是亞馬遜公司推出的一個郵件基礎服務。作為AWS基礎服務的一部分,它繼承了AWS的傳統優勢 -- 便宜。
是的,真的非常便宜。這就是為什麼我沒用mailgun或其它什麼更屌郵件服務的原因。如果每月你發10萬封郵件的話,基本上也只需要支付十多美刀左右。這和其它那些動輒上百美刀起步的服務來說,價格優勢很大。所以,憑著這個我也能忍受它的諸多缺點。
但是隨著國內用SES的人增多,他在去年底的某一天突然被牆了,這可要了命了。於是,我開始嘗試在境外自己的伺服器上做一層代理來繼續使用這個服務。同時這也提供了一個契機,讓我有機會對它的api作出改進來實現一些更有價值的功能,例如郵件群發。
因此我沒有用境外伺服器直接做一個反向代理來玩,這樣只是解決了表面上的問題,但我擴展功能的需求就不可能實現了。因此我為設計這個SES代理訂立了兩個基本目標
完全相容原有api接口,這意味著原有程式碼基本上不需要改變就可以用代理
實現郵件群發功能
實現第一點其實非常簡單,其實就是用php實作了一個反向代理,把發送過來的參數接收到,然後組裝後使用curl元件發送給真正的SES伺服器,取得回執後再直接輸出給客戶端。這就是一個標準的代理流程,下面給出我的程式碼,裡面重要的部分我都給了註解
要注意的是這些程式碼需要放在網域的根目錄下,當然二級網域也可以
- include __DIR__ . '/includes.php';
-
- // 這裡是幾個比較重要的header,其它不需要關注
- $headers = array(
- 'Date: ' . get_header('Date'),
- 'Host: ' . SES_HOST,
- 'X-Amzn-Authorization: ' . get_header(' X-Amzn-Authorization')
- );
-
- // 然後再次組裝url以請求這正的SES伺服器
- $url = 'https://' .SES_HOST . '/'
- . (empty($_SERVER['QUERY_STRING']) ? '' : '?' . $_SERVER['QUERY_STRING']);
-
- $ch = curl_init();
- curl_setopt($ ch, CURLOPT_USERAGENT, 'SimpleEmailService/php');
- curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 1);
- curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, 1); POST`和`DELETE`方法,`GET`方法比較繁多我就不一一實現了
- // 其實都是一些獲得當前資訊的方法,這些資訊你可以直接到後台看
- switch ($ _SERVER['REQUEST_METHOD']) {
- case 'GET':
- break;
- case 'POST':
- global $HTTP_RAW_POST_DATARA;
- $data = empgetty_DATARA; : $HTTP_RAW_POST_DATA;
- $headers[] = 'Content-Type: application/x-www-form-urlencoded';
- parse_data($data);
-
- curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'POST');
- curl_setopt($ch, CURLOPT_POSTFIELDS, $data);
- break;
- copSTFIELDS, $data);
- break;
- case 'DELETE': break;
- default:
- break;
- }
-
- curl_setopt($ch, CURPT_PT_PT_HTTPHEA, $.HTT); ch, CURLOPT_HEADER, false);
-
- curl_setopt($ch, CURLOPT_URL, $url);
- curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
- $response = curl_exec($ch);
- $content_type = curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
- $status = curl_getinfo($ch, CURLINFO_HTm_COLIN); ($ch);
-
- header('Content-Type: ' . $content_type, true, $status);
- echo $response;
-
-
-
- 複製程式碼
-
-
這段程式碼非常簡單,但也有些技巧需要注意,其中我處理POST方法時使用了一個名為parse_data的私有函數,這個函數實際上是實現群發郵件的關鍵。
說到這裡我不得不提一下SES發送郵件的API,SES只提供一個簡單的郵件發送API,其中它的發送對象支援多個,但當你發送給多個收件人時,它也會在收件者欄看到其他收件者的地址。當然它也支援cc或bcc的抄送功能,但當你在使用這種抄送功能來實現群發郵件時,收件者會看到自己是在抄送物件中,而不是在接收者中。對於一個正規網站來說,這些顯然是不能容忍的。
因此我們需要真正的並發介面來發送郵件,要知道SES分配給我的配額是每秒鐘可以發送28封郵件(每人配額不同),要是完全利用的話每小時可以發送10萬封郵件,完全可以滿足中型網站的需求了。
因此我產生了一個想法,在完全不改變客戶端介面的情況下,我在代理伺服器上將發送過來的有多個收件人的一封郵件拆包成一個一個單收件人的多封郵件,然後再將這些郵件用非同步佇列的方式傳送到SES上。這就是parse_data函數所做的事情,下面我直接給出includes.php裡的程式碼,這裡包含了所有要用到的私有函數,前面的define定義請依照自己的需求修改
- define('REDIS_HOST', '127.0.0.1');
- define('REDIS_PORT', 6379);define('SES_KEY', '');
- define('SES_SECRET', '');
-
- /**
- * get_header
- *
- * @param mix $name
- * @access public
- * @return void
- */
- function get_header($name) {
- $name = 'HTTP_' . strtoupper(str_replace('-', '_', $name));
- return isset($_SERVER[$name]) ? $_SERVER[$name] : '';
- }
-
- /**
- * my_parse_str
- *
- * @param mix $query
- * @param mix $params
- * @access public
- * @return void
- */
- function my_parse_str($query, &$params) {
- if (empty($查詢)) {
- return;
- }
-
- $decode = function ($str) {
- return rawurldecode(str_replace('~', '~', $str));
- };
-
- $data =explode('&', $query);
- $params = array();
- foreach ($data as $value) {
- list ( $key, $val) =explode('=', $value, 2);
- if (isset($params[$key])) {
- if (!is_array($params[$key]) ) {
- $params[$key] = array($params[$key]);
- }
- $params[$key][] = $val;
- } else {
- $params[$key] = $decode($val);
- }
- }
- }
-
- /**
- * my_urlencode
- *
- * @param mix $str
- * @access public
- * @return void
- */
- function my_urlencode($str) {
- return str_replace('~', '~', rawurlencode($str));
- }
-
- /**
- * my_build_query
- *
- * @param mix $params
- * @access public
- * @return void
- */
- function my_build_query($parameters) {
- $params = array();
- foreach ($parameters as $var => $value) {
- if (is_array($value)) {
- foreach ($value as $v) {
- $params[] = $var.'='.my_urlencode($v);
- }
- } else {
- $params[] = $var.'='.my_urlencode($value ) ;
- }
- }
-
- sort($params, SORT_STRING);
- return implode('&', $params);
- }
-
- /*
- * my_headers
- *
- * @param mix $headers
- * @access public
- * @return void
- */
- function my_headers() {
- $date = gmdate('D, d M Y H:i:s e');
- $sig = base64_encode(hash_hmac('sha256', $日期) , SES_SECRET, true));
-
- $headers = array();
- $headers[] = '日期:' 。 SES_HOST;
-
- $auth = 'AWS3-HTTPS AWSAccessKeyId=' 。 SES_KEY;
- $auth .= ',演算法=HmacSHA256,簽章=' 。 ] = 'X-Amzn-授權:' 。 $auth;
- $headers[] = '內容類型:application/x-www-form-urlencoded';
-
- return $headers;
- }
-
- /**
- * parse_data
- *
- * @param mix $data
- * @access public
- * @return void
- */
- function parse_data(&$data) {
- my_parse_str($data, $params);
-
- if (!empty($data, $params);
-
- if (!empty($ params)) {
- $redis = new Redis();
- $redis->connect(REDIS_HOST, REDIS_PORT);
-
- // 多個發送位址
- if (isset($params ) ['Destination.ToAddresses.member.2) '])) {
- $address = array();
- $mKey = uniqid();
-
- $i = 2;
- while (isset($params['Destination.ToAddresses) .member.' $i])) {
- $aKey = uniqid();
- $key = 'Destination.ToAddresses.member.' $i;
- $address[$aKey] = $params[$key];
- unset($params[$key]);
-
- $i ;
- }
-
- $data = my_build_query($params);
-
- unset($params['Destination.ToAddresses.member.1']);
- $redis->set('m:' . $ mKey , my_build_query($params));
- foreach ($address as $k => $a) {
- $redis->hSet('a:' . $mKey, $k, $a) ;
- $redis->lPush('mail', $k . '|' . $mKey);
- }
- }
- }
- }
-
-
-
複製程式碼
可以看到parse_data函數從第二個收件者開始,把它們組裝成一個單獨的郵件,放到redis佇列裡,供其他獨立進程讀取發送。
為什麼不從第一位收件人開始?
因為要相容原有協議,客戶端發過來一個發郵件請求你總要給它返回一個東西吧,我又懶得偽造,因此它的第一個收件人的發郵件請求是直接發出去了,而並沒有進入佇列,這樣我可以取得一個真實的SES伺服器回執回傳給客戶端,客戶端程式碼也不需要做任何修改,就可以處理這個回傳。
SES的郵件都是要簽名的怎麼辦?
是的,所有的SES郵件都需要簽名。因此在你解包以後,郵件資料改變了,因此簽章也必須改變。 my_build_query函數就是做這個事情的,它會對請求參數做重新簽章。
下面是這個代理系統的最後一個組成部分,郵件發送隊列實現,它也是一個php文件,你可以根據自己的配額大小,在後台用nohup php命令啟動若干個php進程,來實現並發郵件發送。它的結構也非常簡單,就是讀取佇列裡的郵件然後用curl發送請求
- include __DIR__ . '/includes.php';
-
- $redis = new Redis();
- $redis ->connect(REDIS_HOST, REDIS_PORT);
-
- do {
- $pop = $redis->brPop('mail', 10);
- if (empty($pop)) {
- continue;
- }
-
- list ($k, $id) = $pop;
- list($aKey, $mKey) = explode('|', $id);
-
- $address = $redis->hGet('a:' . $mKey, $aKey);
- if (empty($address)) {
- continue;
- }
-
- $data = $redis->get('m:' . $mKey);
- if (empty($data)) {
- continue;
- }
-
- my_parse_str($data , $params);
- $params['Destination.ToAddresses.member.1'] = $address;
- $data = my_build_query($params);
- $headers = my_headers();
- $url = 'https://' . SES_HOST . '/';
-
- $ch = curl_init();
- curl_setopt($ch, CURLOPT_USERAGENT, 'SimpleEmailService/php');
- curl_setopt ($ch, CURLOPT_SSL_VERIFYHOST, false);
- curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
-
- curl_setopt($ch, CURURPTPTS_cUR3(cUR3(cUR3,cUR. RANSFER, true);
- curl_setopt($ch, CURLOPT_POSTFIELDS, $data);
- curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
- curl_setopt($ch, CURLOPT, $ $ch, CURLOPT_TIMEOUT, 10);
-
- curl_exec($ch);
- curl_close($ch);
-
- unset($ch);
- unset($data);
-
- } while (true);
-
-
-
-
複製程式碼
以上就是我寫SES郵件代理伺服器的整個思路,歡迎大家一同來探討。
|
代理伺服器, PHP, Amazon