看懂這篇文章需要你有一定的SES使用基礎,如果你不明白,可以看這個問題裡的討論 http://segmentfault.com/q/1010000000095210
SES的全稱是Simple Email Service,它是亞馬遜公司推出的一個郵件基礎服務。作為AWS基礎服務的一部分,它繼承了AWS的傳統優勢 -- 便宜。 是的,真的非常便宜。這就是為什麼我沒用mailgun或者其它什麼更牛逼郵件服務的原因。如果每月你發10萬封郵件的話,基本也只需要支付十多美刀左右。這和其它那些動輒上百美刀起步的服務來說,價格優勢很大。所以,憑著這個我也能忍受它的諸多缺點。 但是隨著國內用SES的人增多,他在去年底的某一天突然被牆了,這可要了命了。於是,我開始嘗試在境外自己的伺服器上做一層代理來繼續使用這個服務。同時這也提供了一個契機,讓我可以有機會對它的api作出改進來實現一些更有價值的功能,比如郵件群發。 因此我沒有用境外伺服器直接做一個反向 Proxy來玩,這樣只是解決了表面上的問題,但我擴充功能的需求就不可能實現了。因此我為設計這個SES代理訂立了兩個基本目標 完全相容原有api介面,這意味著原有代碼基本不需要改變就可以用代理實現郵件群發功能實現第一點其實非常簡單,其實就是用php實現了一個反向 Proxy,把發送過來的參數接收到,然後組裝後使用curl組件發送給真正的SES伺服器,取得回執後再直接輸出給用戶端。這就是一個標準的代理流程,下面給出My Code,裡面重要的部分我都給出了注釋 需要注意的是這些代碼需要放在網域名稱的根目錄下,當然次層網域也可以
- 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_DATA;
- $data = empty($HTTP_RAW_POST_DATA) ? file_get_contents('php://input')
- : $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;
- case 'DELETE':
- curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE');
- break;
- default:
- break;
- }
- curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
- curl_setopt($ch, CURLOPT_HEADER, false);
- curl_setopt($ch, CURLOPT_URL, $url);
- curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
- curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
- $response = curl_exec($ch);
- $content_type = curl_getinfo($ch, CURLINFO_CONTENT_TYPE);
- $status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
- curl_close($ch);
- header('Content-Type: ' . $content_type, true, $status);
- echo $response;
複製代碼這段代碼非常簡單,但也有些技巧需要注意,其中我處理POST方法時使用了一個名為parse_data的私人函數,這個函數實際上是實現群發郵件的關鍵。 說到這裡我不得不提一下SES發郵件的API,SES只提供一個簡單的郵件發送API,其中它的發送對象支援多個,但當你發送給多個收件者時,它也會在收件者欄看到其他收件者的地址。當然它也支援cc或者bcc的抄送功能,但當你在使用這種抄送功能來實現群發郵件時,收件者會看到自己是在抄送對象中,而不是在接收人中。對於一個正規網站來說,這些顯然是不能容忍的。 因此我們需要真正的並發介面來發送郵件,要知道SES分配給我的配額是每秒鐘可以發送28封郵件(每人配額不同),要是完全利用的話每小時可以發送10萬封郵件,完全可以滿足中型網站的需求了。 因此我產生了一個想法,在完全不改變用戶端介面的情況下,我在Proxy 伺服器上將發送過來的有多個收件者的一封郵件拆包成一個一個單個收件者的多封郵件,然後再將這些郵件用非同步隊列的方式發送到SES上。這就是parse_data函數所做的事情,下面我直接給出includes.php裡的代碼,這裡包含了所有要用到的私人函數,前面的define定義請根據自己的需求修改
- define('REDIS_HOST', '127.0.0.1');
- define('REDIS_PORT', 6379);
- define('SES_HOST', 'email.us-east-1.amazonaws.com');
- define('SES_KEY', '');
- define('SES_SECRET', '');
- /**
- * get_header
- *
- * @param mixed $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 mixed $query
- * @param mixed $params
- * @access public
- * @return void
- */
- function my_parse_str($query, &$params) {
- if (empty($query)) {
- return;
- }
- $decode = function ($str) {
- return rawurldecode(str_replace('~', '%7E', $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 mixed $str
- * @access public
- * @return void
- */
- function my_urlencode($str) {
- return str_replace('%7E', '~', rawurlencode($str));
- }
- /**
- * my_build_query
- *
- * @param mixed $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 mixed $headers
- * @access public
- * @return void
- */
- function my_headers() {
- $date = gmdate('D, d M Y H:i:s e');
- $sig = base64_encode(hash_hmac('sha256', $date, SES_SECRET, true));
- $headers = array();
- $headers[] = 'Date: ' . $date;
- $headers[] = 'Host: ' . SES_HOST;
- $auth = 'AWS3-HTTPS AWSAccessKeyId=' . SES_KEY;
- $auth .= ',Algorithm=HmacSHA256,Signature=' . $sig;
- $headers[] = 'X-Amzn-Authorization: ' . $auth;
- $headers[] = 'Content-Type: application/x-www-form-urlencoded';
- return $headers;
- }
- /**
- * parse_data
- *
- * @param mixed $data
- * @access public
- * @return void
- */
- function parse_data(&$data) {
- my_parse_str($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, CURLOPT_CUSTOMREQUEST, 'POST');
- curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
- curl_setopt($ch, CURLOPT_POSTFIELDS, $data);
- curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
- curl_setopt($ch, CURLOPT_URL, $url);
- curl_setopt($ch, CURLOPT_TIMEOUT, 10);
- curl_exec($ch);
- curl_close($ch);
- unset($ch);
- unset($data);
- } while (true);
複製代碼以上就是我編寫SES郵件Proxy 伺服器的整個思路,歡迎大家一同來探討。 |