去年 Mix PHP V1 釋出時,我寫了一個多程式的郵件傳送例項: 使用 mixphp 打造多程式非同步郵件傳送,今年 Mix PHP V2 釋出,全面的協程支援讓我們可以使用一個程式就可達到之前多個程式都無法達到的更高 IO 效能,所以今天重寫一個協程池版本的郵件傳送例項。
郵件傳送是很常見的需求,由於傳送郵件的操作一般是比較耗時的,所以我們一般採用非同步處理來提升使用者體驗,而非同步通常我們使用訊息佇列來實現。
下面演示一個非同步郵件傳送系統的開發過程,涉及知識點:
- 非同步
- 訊息佇列
- 守護程式
- 協程池
如何使用訊息佇列實現非同步
PHP 使用訊息佇列通常是使用中介軟體來實現,常用的訊息中介軟體有:
- redis
- rabbitmq
- kafka
本次我們選用 Redis 來實現非同步郵件傳送,Redis 的資料型別中有一個 list 型別,可實現訊息佇列,使用以下命令:
// 入列
$redis->lpush($key, $data);
// 出列
$data = $redis->rpop($key);
// 阻塞出列
$data = $redis->brpop($key, 10);
複製程式碼
架構設計
本例項由傳統 MVC 框架投遞郵件傳送需求(生產者),Mix PHP 編寫的守護程式執行傳送任務(消費者)。
郵件傳送庫選型
以往我們通常使用框架提供的郵件傳送庫,或者網上下載別的使用者分享的庫,composer 出現後,packagist.org/ 上有大量優質的庫,我們只需選擇一個最好的即可,本例選擇 swiftmailer。
由於傳送任務是由 Mix PHP 執行,所以 swiftmailer 是安裝在 Mix PHP 專案中,在專案根目錄中執行以下命令安裝:
composer require swiftmailer/swiftmailer
複製程式碼
生產者開發
在郵件傳送這個需求中生產者是指投遞傳送任務的一方,這一方通常是一個介面或網頁,這個部分並不一定需 Mix PHP 開發,TP、CI、YII 這些都可以,只需在介面或網頁中把任務資訊投遞到訊息佇列中即可。
在傳統 MVC 框架的控制器中增加如下程式碼:
通常框架中使用 Redis 會安裝一個類庫來使用,本例使用原生程式碼,便於理解。
// 連線
$redis = new \Redis();
if (!$redis->connect('127.0.0.1', 6379)) {
throw new \Exception('Redis connect failed.');
}
$redis->auth('');
$redis->select(0);
// 投遞任務
$data = [
'to' => '***@qq.com',
'body' => 'The message content',
'subject' => 'The title content',
];
$redis->lpush('queue:email', serialize($data));
複製程式碼
通常非同步開發中,投遞完成後就會立即響應一個訊息給使用者,當然此時該任務並沒有在生產者中執行,而是待訊息被消費者獲取後才執行。
消費者開發
使用本例時,請確保你使用的 Swoole 編譯時開啟了 openssl
本例我們採用 Mix PHP V2 的守護程式、協程池來完成一個超高效能的郵件傳送程式。
因為我們是開發一個守護程式,所以我們在 applications/daemon
模組中開發,首先我們在配置 applications/daemon/config/main.php
中註冊一個命令:
// 命令
'commands' => [
'mailer' => ['Mailer', 'description' => 'Mailer daemon.'],
],
複製程式碼
註冊的命令中指定的 Mailer 命令類,接下來我們編寫一個 MailerCommand 類:
applications/daemon/src/Commands/MailerCommand.php
複製程式碼
<?php
namespace Daemon\Commands;
use Daemon\Libraries\MailerWorker;
use Mix\Concurrent\CoroutinePool\Dispatcher;
use Mix\Core\Coroutine\Channel;
use Mix\Helper\ProcessHelper;
/**
* Class MailerCommand
* @package Daemon\Commands
* @author liu,jian <coder.keda@gmail.com>
*/
class MailerCommand
{
/**
* 退出
* @var bool
*/
public $quit = false;
/**
* 主函式
*/
public function main()
{
// 捕獲訊號
ProcessHelper::signal([SIGHUP, SIGINT, SIGTERM, SIGQUIT], function ($signal) {
$this->quit = true;
ProcessHelper::signal([SIGHUP, SIGINT, SIGTERM, SIGQUIT], null);
});
// 協程池執行任務
xgo(function () {
$maxWorkers = 20;
$maxQueue = 20;
$jobQueue = new Channel($maxQueue);
$dispatch = new Dispatcher([
'jobQueue' => $jobQueue,
'maxWorkers' => $maxWorkers,
]);
$dispatch->start(MailerWorker::class);
// 投放任務
$redis = app()->redisPool->getConnection();
while (true) {
if ($this->quit) {
$dispatch->stop();
return;
}
try {
$data = $redis->brPop(['queue:email'], 3);
} catch (\Throwable $e) {
$dispatch->stop();
return;
}
if (!$data) {
continue;
}
$data = array_pop($data); // brPop命令最後一個鍵才是值
$jobQueue->push($data);
}
});
}
}
複製程式碼
從
$data = $redis->brPop(['queue:email'], 3);
外部的異常捕獲可得知,當 Redis 連線出錯時,比如 Redis 重啟、連線異常時協程池會安全退出,也就是說當程式異常退出後使用者需使用supervisor
、pm2
等工具重啟守護程式。
上面是一個 Mix PHP 協程池的使用程式碼,基本可以直接複製使用,框架預設包含了協程池的 Demo,本次例項只是修改了協程池的 Worker,本命令主要是完成從 Redis 佇列中獲取訊息然後 push 到 jobQueue 中,jobQueue 中的資料會被 20 個 Worker 例項中某一個搶佔後並行執行,本例的郵件傳送程式碼邏輯就在 MailerWorker 類中:
applications/daemon/src/Libraries/MailerWorker.php
複製程式碼
<?php
namespace Daemon\Libraries;
use Mix\Concurrent\CoroutinePool\AbstractWorker;
use Mix\Concurrent\CoroutinePool\WorkerInterface;
/**
* Class MailerWorker
* @package Daemon\Libraries
* @author liu,jian <coder.keda@gmail.com>
*/
class MailerWorker extends AbstractWorker implements WorkerInterface
{
/**
* 郵件傳送器
* @var Mailer
*/
public $mailer;
/**
* 初始化事件
*/
public function onInitialize()
{
parent::onInitialize(); // TODO: Change the autogenerated stub
// 例項化一些需重用的物件
$this->mailer = new Mailer();
}
/**
* 處理
* @param $data
*/
public function handle($data)
{
// TODO: Implement handle() method.
$data = unserialize($data);
if (empty($data)) {
return;
}
try {
$this->mailer->send($data['to'], $data['subject'], $data['body']);
app()->log->info("Mail sent successfully:to {to} subject {subject}", $data);
} catch (\Throwable $e) {
app()->log->error("Mail failed to send:to {to} subject {subject} error {error}", array_merge($data, ['error' => $e->getMessage()]));
}
}
}
複製程式碼
由以上程式碼可見,Worker 在初始化時,新增了一個 Mailer 類的屬性,當 jobQueue 訊息投遞過來時訊息會傳遞到 handle 方法,在該方法中使用 Mailer 類的例項完成郵件傳送任務,所以我們要編寫了一個 Mailer 傳送程式:
applications/daemon/src/Libraries/Mailer.php
複製程式碼
<?php
namespace Daemon\Libraries;
use Mix\Core\Coroutine;
/**
* Class Mailer
* @package Daemon\Libraries
* @author liu,jian <coder.keda@gmail.com>
*/
class Mailer
{
/**
* 配置資訊
*/
const HOST = 'smtpdm.aliyun.com';
const PORT = 465;
const SECURITY = 'ssl';
const USERNAME = '***';
const PASSWORD = '***';
/**
* Mailer constructor.
*/
public function __construct()
{
// 開啟協程鉤子
Coroutine::enableHook();
}
/**
* 傳送
* @param $to
* @param $subject
* @param $body
* @return int
*/
public function send($to, $subject, $body)
{
// Create the Transport
$transport = (new \Swift_SmtpTransport(self::HOST, self::PORT, self::SECURITY))
->setUsername(self::USERNAME)
->setPassword(self::PASSWORD);
// Create the Mailer using your created Transport
$mailer = new \Swift_Mailer($transport);
// Create a message
$message = (new \Swift_Message($subject))
->setFrom([self::USERNAME => '**網'])
->setTo($to)
->setBody($body);
// Send the message
return $mailer->send($message);
}
}
複製程式碼
在 Mailer 傳送程式中我們使用了前面 composer 安裝的 swiftmailer 庫來傳送郵件,以上就完成了全部的程式碼邏輯,現在我們開始測試。
先啟動消費者守護程式:
[root@localhost bin]# ./mix-daemon mailer
複製程式碼
將上文的生產者指令碼命名為 push.php
然後在 CLI 中執行 (開一個新終端):
[root@localhost bin]# php /tmp/push.php
複製程式碼
消費者守護程式結果:
[root@localhost bin]# ./mix-daemon mailer
[info] 2019-04-15 11:48:36 [message] Mail sent successfully:to ***@qq.com subject The title content
複製程式碼
命令列終端列印了傳送成功的日誌,傳送完成。