前言
这是一篇酝酿了将近两年的 Thinkphp8 + Workerman4 整合文章,文章撰写时,整合方案已被下载超过 1700 次,共更新了 13 个版本,算是比较成熟的技术方案,方案实现了一个 HTTP 服务和一个 Websocket 服务。
为什么整合?
其实从 thinkphp5 开始,TP 官方就开始提供整合包了,也就是 topthink/think-worker,但该包体验下来还是有不少问题,比如不支持 Windows 平台,多应用模式下无法获取正确的 appName 等问题...
FastAdmin 的 Workerman 客服系统 整合方案,也是笔者完成的,但是只整合了一个 WS 服务,鉴于大多数系统还是运行于 HTTP 服务下的,只有即时通讯等场景才用一下 ws,所以本质上不是一个完整的整合。
从 TP8 发布,golang 和 rust 也开始流行,大家对性能的要求越来越高,所以我开始研究 tp8+workerman,这一次的出发点是为 BuildAdmin 开源后台 实现一个常驻内存的超高速 HTTP 服务,它是基于 TP8 的开源后台管理系统,所以技术方案上其他 TP8 的项目也能参考借鉴。
设计目标
- 基于 TP8 的常驻内存高性能 HTTP 服务,兼容
think-orm,兼容 TP8 框架的多应用模式等 - 整合完成后,已有应用只需注意常驻内存特有的特性,而不必对现有代码进行大规模改动
- 兼容 Windows 平台,并且启动方便
- 全平台支持文件热重载
- 极简整合,找到所有最关键的点进行两个框架的融合,避免任何冗余代码
- 完美兼容 BuildAdmin 的现有的所有接口
最终性能提升
实现整合后,我对性能进行了测试,相同的请求 php think run 耗时约 366ms 而 workerman 启动的 HTTP 服务仅需 9ms,结合前端可以快到看不清保存按钮的加载态!而且整个模块,只有 21kb 左右。
开始
在开始整合之前,我假设您已经熟悉 TP8 和 Workerman,了解 Workerman 如何启动 websocket 和 http 服务等,这不是一篇纯新手能看懂的文章。
配置文件
我一共设计了三个配置文件,读者略作了解即可,分别是:
一、config/worker_http.php HTTP 服务配置
可以配置协议的监听端口、监听地址、启动进程数等,基本对应 workerman 启动 HTTP 服务必要的参数配置项,额外添加了静态服务器相关:
'option' => [
'protocol' => 'http', // 协议,支持 tcp udp unix http websocket text
'ip' => '0.0.0.0', // 监听地址
'port' => '8000', // 监听端口
'name' => 'baHttpWorker', // Worker实例名称
'count' => 2, // 进程数
'pidFile' => Filesystem::fsFit(runtime_path() . 'worker/http.pid'), // 进程ID存储位置
'logFile' => $logFile, // 日志存储位置
],
// ...
// 静态服务器配置
'staticServer' => [
// 禁止访问的文件类型
'deny' => ['php', 'bat', 'lock', 'ini'],
// 要求浏览器下载而不是直接打开的文件类型(比如 pdf 文件内可能含有 xss 攻击代码)
'attachment' => ['pdf'],
],
二、config/worker_ws.php WebSocket 服务配置
可以配置 WS 服务的端口、监听地址等,基本对应 workerman 启动 WS 服务必要的参数配置项。
三、config/worker_monitor.php 文件监听配置
可以配置监听路径、监听间隔,内存达到某值自动重启服务等。
Websocket 服务启动实现
服务启动分为 Windows 和 Linux 两个平台,Linux 相对简单,而 Windows 下不能在一个文件中启动多个 Worker,可以通过 bat 批处理文件调用 php 命令同时执行多个服务的启动命令,具体实现如下:
Linux
- 一般 WebSocket 服务需要启动多个进程,分别是:
Register、Gateway、Business,我们建立/servers/ws/文件夹,里边分别建立startBusiness.php、startGateway.php、startRegister.php三个文件,并实现启动服务逻辑,如果你的 WS 服务需要其他更多进程,直接照格式自行建立即可。 - 在以上三个文件中,直接以 Workerman 的语法启动各类服务,并且其中可以使用 TP 的
Config类,因为这些启动服务的代码,后续将通过php think ...调用。 - 以
startRegister.php和startBusiness.php文件的代码举例
// startRegister.php
use GatewayWorker\Register;
// 获取 config/worker_ws.php
$config = config('worker_ws');
// 注册(Register)服务
new Register("text://{$config['register']['ip']}:{$config['register']['port']}");
// startBusiness.php
use GatewayWorker\BusinessWorker;
$config = config('worker_ws');
$business = new BusinessWorker();
// 设置 Business 的参数,以下有两个主要参数的示例值,存储于配置文件中
// 'eventHandler' => 'app\\worker\\events\\WsBusiness',
// 'registerAddress' => "{$register['ip']}:{$register['port']}",
foreach ($config['business'] as $key => $value) {
$business->$key = $value;
}
- 创建一个 TP 的自定义指令,名为 worker,然后在该指令文件内,导入以上建立的所有文件,然后启动 Workerman 服务。最终 Linux 平台下启动服务的命令为:
php think worker ...;这一步非常关键,php think表示使用使用 php 执行站点根目录下的 think 文件,该文件是 TP 命令行入口文件。不会建立 TP 自定义指令的,先去看 TP 文档,自定义指令的核心代码如下:
// 注册命令参数,其中 server 指要启动的服务,比如 ws、http
$this->setName('worker')
->addArgument('server', Argument::REQUIRED, "The server to start.")
->addArgument('action', Argument::REQUIRED, "start|stop|restart|reload|status")
->addOption('daemon', 'd', Option::VALUE_NONE, 'Start in daemon mode.')
->setDescription('Worker server');
// execute 方法中,加载所有 `start*` 开头的服务启动文件
$serverPath = Filesystem::fsFit(root_path() . "/servers/$server/start*.php");
$startFiles = glob($serverPath);
if (!$startFiles) {
$output->writeln("<error>$server server does not exist.</error>");
exit(1);
}
foreach (glob($serverPath) as $startFile) {
require_once $startFile;
}
// 启动所有服务
WorkerManWorker::runAll();
- 我们在
worker_ws.php中,已经配置了eventHandler类,位于app\\worker\\events\\WsBusiness,并在startBusiness.php对配置进行了载入,所以一旦执行了php think worker ws start命令,接下来只需要关注app\\worker\\events\\WsBusiness类了,根据 workerman 的文档,我们需要在事件处理类中实现onWorkerStart、onMessage等方法。
WS 的事件处理类
onWorkerStart 方法
推荐在此方法内启动文件监听,初始化 Db 类等,比如:
/**
* Worker子进程启动时的回调函数,每个子进程启动时都会执行。
*/
public static function onWorkerStart(BusinessWorker $worker): void
{
self::$worker = $worker;
if (!self::$monitorConfig) {
self::$monitorConfig = Config::get('worker_monitor');
}
if (!self::$db) {
try {
// 直接连接数据库,然后将 db 实例保存在静态变量中,方便后续使用
Db::execute("SELECT 1");
$app = App::getInstance();
self::$db = $app->db;
} catch (PDOException) {
}
}
// 启动文件监听
if (0 == $worker->id) {
new Monitor(self::$monitorConfig);
}
}
onMessage 方法
监听来自客户端的消息,您可以在其中 new 一个经过再次封装的 TP 的 App 类,示例如下:
// 首先是创建 WorkerWsApp.php 文件,即:继承于 think\App,实现了一个能够自定义一些东西的 WorkerWsApp 类
use think\App;
class WorkerWsApp extends App
{
// 实现 init 方法即可
public function init(array $server = []): void
{
// 输入过滤
array_walk_recursive($this->message, ['app\worker\library\Helper', 'cleanXss']);
array_walk_recursive($this->requestData, ['app\worker\library\Helper', 'cleanXss']);
$_GET = $this->requestData['get'] ?? [];
$_REQUEST = array_merge($_REQUEST, $_GET);
$scriptFilePath = public_path() . 'index.php';
$_SERVER = array_merge([
// 关键信息,向 tp 框架传递 PATH_INFO 等数据
'PATH_INFO' => $this->message['pathInfo'] ?? 'worker/WebSocket/index',
'SCRIPT_FILENAME' => $scriptFilePath,
'SCRIPT_NAME' => DIRECTORY_SEPARATOR . pathinfo($scriptFilePath, PATHINFO_BASENAME),
'DOCUMENT_ROOT' => dirname($scriptFilePath),
'HTTP_ACCEPT' => 'application/json, text/plain, */*',
], $server, $this->requestData['server'] ?? []);
$this->message['MESSAGE_TIME'] = time();
$this->initialize();
}
}
// eventHandler 类的 onMessage 方法
/**
* 当客户端发来消息时触发
* @param string $clientId 连接id
* @param mixed $message 具体消息
*/
public static function onMessage(string $clientId, mixed $message): bool
{
// new 以上创建的 WorkerWsApp 类,然后存储一些基础数据以便后续使用
$app = new WorkerWsApp(root_path());
$app->db = self::$db;
$app->worker = self::$worker;
$app->clientId = $clientId;
$app->requestData = $_SESSION['requestData'] ?? [];
$app->message = json_decode($message, true);
if (json_last_error() != JSON_ERROR_NONE) {
return $app->send('error', [
'message' => 'Message parsing error:' . json_last_error_msg(),
'code' => 500,
]);
}
$app->init(self::$serverData ?? []);
// 执行 $http->run() 等
// 在 WorkerWsApp 类的 init 方法中,我对 $_SERVER 变量进行了赋值,TP 会根据 `PATH_INFO` 值调用对应的控制器方法。
$http = $app->http;
$response = $http->run();
$code = $response->getCode();
if ($code >= 300) {
$content = $response->getContent();
$contentJson = json_decode($content, true);
$app->send('error', [
'code' => $code,
'content' => json_last_error() != JSON_ERROR_NONE ? $contentJson : $content,
'data' => $response->getData(),
'header' => $response->getHeader(),
]);
}
$http->end($response);
return true;
}
Windows
Windows 下的 WS 服务启动是基于 Linux 下已有的代码进行扩展的,所以需要先实现以上 Linux 的服务启动,两个平台的主要区别在于:Windows 下不能在一个文件中启动多个 Worker,所以我们首先需要额外注册一个 WorkerStartForWin 命令,因为 Linux 下注册的 worker 命令直接使用 WorkerManWorker::runAll() 启动了所有服务,在 Windows 下是不能正常运行的。
一、命令注册
WorkerStartForWin.php 的内容非常简单:
/**
* Windows 专用的启动 WorkerMan 服务命令
* 通过 commands 目录内的 bat 文件启动服务
*/
class WorkerStartForWin extends Command
{
protected function configure(): void
{
$this->setName('WorkerStartForWin')
->addArgument('server', Argument::REQUIRED, "The server to start.")
->setDescription('Worker server');
}
protected function execute(Input $input, Output $output): void
{
$server = trim($input->getArgument('server'));
$server = Filesystem::fsFit(root_path() . "/servers/$server.php");
if (!file_exists($server)) {
$output->writeln("<error>$server file does not exist.</error>");
}
require_once $server;
Worker::runAll();
}
}
二、进程启动和管理
然后建立 /commands/worker_start_for_win/start.php 文件,用于多个进程的启动和管理,核心内容如下:
ini_set('display_errors', 'on');
error_reporting(E_ALL);
function open_processes($processFiles)
{
$cmd = '"' . PHP_BINARY . '" ' . implode(' ', $processFiles);
$descriptorSpec = [STDIN, STDOUT, STDOUT];
$resource = proc_open($cmd, $descriptorSpec, $pipes, null, null, ['bypass_shell' => true]);
if (!$resource) {
exit("Can not execute $cmd\r\n");
}
return $resource;
}
$prefix = __DIR__ . '/';
$servers = explode(',', $argv[1]);
foreach ($servers as &$server) {
$server .= '.php';
$server = '"' . $prefix . $server . '"';
}
$resource = open_processes($servers);
可以看到,我们利用 proc_open 来创建子进程启动服务。
接下来,继续建立 /commands/worker_start_for_win/ws/ 文件夹,其中建立 startBusiness.php、startGateway.php、startRegister.php 三个文件,内容如下:
namespace think;
require_once __DIR__ . '/vendor/autoload.php';
// startBusiness.php
// (new App())->console->call('WorkerStartForWin', ['ws/startBusiness']);
// startGateway.php
// (new App())->console->call('WorkerStartForWin', ['ws/startGateway']);
// startRegister.php
// (new App())->console->call('WorkerStartForWin', ['ws/startRegister']);
三个文件的内容基本一样,都是先手动 require_once 到根目录的 /vendor/autoload.php 文件,然后调用 App 类的 console 方法,并传入之前已经注册好的 WorkerStartForWin 命令和 ws/startBusiness 等参数,这三个文件,分别能在 Windows 下启动它对应的服务。
三、服务启动命令的调用
接下来就是最终将以上的 命令注册、多进程启动和管理、服务启动命令的调用 三步都集合在一起的文件 /commands/ws_worker_start_for_win.bat 文件:
CHCP 65001
php ./worker_start_for_win/start.php ws/startBusiness,ws/startGateway,ws/startRegister
pause
调用 php 执行多进程管理 start.php 程序,传入 ws/startBusiness,ws/startGateway,ws/startRegister 参数,在多进程管理中,会根据参数执行多个 WorkerStartForWin 命令,最终走 TP 的 think 命令行入口文件启动了全部的服务。
HTTP 服务启动实现
服务的启动与 WS 的服务基本一致,还会更加简单,因为简单的 HTTP 服务只需要启动一个 Worker,此处不再赘述服务启动,仅对 WorkerHttpApp 的实现和 onMessage 进行介绍。
首先还是创建一个自定义的 App 类,继承于 think\App,用于在 onMessage 时创建 App 实例。
class WorkerHttpApp extends think\App
{
/**
* worker APP 初始化
* 一般实现这个一个方法即可
*/
public function init(TcpConnection $connection, Request $request, array $server = []): void
{
$this->woRequest = $request;
$this->connection = $connection;
$this->setRuntimePath(root_path() . 'runtime' . DIRECTORY_SEPARATOR);
$scriptFilePath = public_path() . 'index.php';
// 请注意 $_SERVER 的组装非常关键,比如少了 PATH_INFO,那么后续 TP 不知道调用那个控制器和方法
$_SERVER = array_merge($server, [
'QUERY_STRING' => $request->queryString(),
'REQUEST_TIME' => time(),
'REQUEST_METHOD' => $request->method(),
'REQUEST_URI' => $request->uri(),
'SERVER_NAME' => $request->host(true),
'SERVER_PROTOCOL' => 'HTTP/' . $request->protocolVersion(),
'SERVER_ADDR' => $connection->getLocalIp(),
'SERVER_PORT' => $connection->getLocalPort(),
'REMOTE_ADDR' => $connection->getRemoteIp(),
'REMOTE_PORT' => $connection->getRemotePort(),
'SCRIPT_FILENAME' => $scriptFilePath,
'SCRIPT_NAME' => DIRECTORY_SEPARATOR . pathinfo($scriptFilePath, PATHINFO_BASENAME),
'DOCUMENT_ROOT' => dirname($scriptFilePath),
'PATH_INFO' => $request->path(),
'SERVER_SOFTWARE' => 'WorkerMan Development Server',
]);
// 其他全局变量的赋值,否则 TP 框架的 Request 等类没数据
$headers = $request->header();
foreach ($headers as $key => $item) {
$hKey = str_replace('-', '_', $key);
if ($hKey == 'content_type') {
$_SERVER['CONTENT_TYPE'] = $item;
continue;
}
if ($hKey == 'content_length') {
$_SERVER['CONTENT_LENGTH'] = $item;
continue;
}
$hKey = strtoupper(str_starts_with($hKey, 'HTTP_') ? $hKey : 'HTTP_' . $hKey);
$_SERVER[$hKey] = $item;
}
$_GET = $request->get();
$_POST = $request->post();
$_FILES = $request->file();
$_REQUEST = array_merge($_REQUEST, $_GET, $_POST);
$this->initialize();
}
}
onWorkerStart 方法
HTTP 服务的 eventHandler 类的 onWorkerStart 方法,同 WS 服务一样,推荐在其中初始化 Db 类,文件监听等。
/**
* 需要绑定到 app 的类实例
*/
protected static array $bind = [];
/**
* Worker子进程启动时的回调函数,每个子进程启动时都会执行。
*/
public function onWorkerStart(Worker $worker): void
{
self::$bind['worker'] = $worker;
self::$serverData = $_SERVER;
// 文件监听配置
if (!self::$monitorConfig) {
self::$monitorConfig = Config::get('worker_monitor');
}
// 初始化 Db 类单例,并在所有进程中共用
if (!isset(self::$bind['db'])) {
try {
Db::execute("SELECT 1");
$app = App::getInstance();
self::$bind['db'] = $app->db;
self::$bind['cache'] = $app->cache;
} catch (PDOException) {
}
}
// 启动文件监听
if (0 == $worker->id) {
new Monitor(self::$monitorConfig);
}
}
onMessage 方法
/**
* 当客户端通过连接发来数据时(WorkerMan收到数据时)触发的回调函数
*/
public function onMessage(TcpConnection $connection, Request $request): void
{
$app = new WorkerHttpApp(root_path());
foreach (self::$bind as $key => $item) {
$app->$key = $item;
}
$app->init($connection, $request, self::$serverData);
$path = $request->path() ?: '/';
$file = Filesystem::fsFit(public_path() . urldecode($path));
if (!is_file($file)) {
// 访问控制器
// 避免输出到命令行窗口
while (ob_get_level() > 1) {
ob_end_clean();
}
ob_start();
$http = $app->http;
$response = $http->run();
$content = ob_get_clean();
ob_start();
$response->send();
$app->http->end($response);
$content .= ob_get_clean() ?: '';
$connection->send(new Response($response->getCode(), $response->getHeader(), $content));
} else {
// 访问静态文件
// 文件未修改,且存在 if-modified-since 则返回 304
if (!empty($ifModifiedSince = $request->header('if-modified-since'))) {
$modifiedTime = date('D, d M Y H:i:s', filemtime($file)) . ' ' . date_default_timezone_get();
if ($modifiedTime === $ifModifiedSince) {
$connection->send(new Response(304));
return;
}
}
$pathInfo = pathinfo($file);
$response = (new Response())->withFile($file);
// 已经检查过文件存在,无需担心后缀识别上的 /.(无后缀) 攻击
if (!empty($pathInfo['extension'])) {
$extension = strtolower($pathInfo['extension']);
// 禁止访问的文件
if (in_array($extension, Config::get('worker_http.staticServer.deny'))) {
$connection->send(new Response(404));
return;
}
// 要求浏览器下载而不是预览
if (in_array($extension, Config::get('worker_http.staticServer.attachment'))) {
$response->withHeader('Content-Disposition', "attachment; filename={$pathInfo['basename']}");
}
}
// 文件修改过或没有 if-modified-since 头则发送文件
$connection->send($response);
}
}
文件监听
原理非常简单,监听设定文件夹中的所有文件的 mtime,需要更新就重新载入就行了。篇幅有限,以下只贴核心代码,checkFilesChange 方法:
/**
* @param $monitorDir
* @return bool
*/
public function checkFilesChange($monitorDir): bool
{
static $lastMtime, $tooManyFilesCheck;
if (!$lastMtime) {
$lastMtime = time();
}
clearstatcache();
if (!is_dir($monitorDir)) {
if (!is_file($monitorDir)) {
return false;
}
$iterator = [new SplFileInfo($monitorDir)];
} else {
// recursive traversal directory
$dirIterator = new RecursiveDirectoryIterator($monitorDir, FilesystemIterator::SKIP_DOTS | FilesystemIterator::FOLLOW_SYMLINKS);
$iterator = new RecursiveIteratorIterator($dirIterator);
}
$count = 0;
foreach ($iterator as $file) {
$count++;
if (is_dir($file->getRealPath())) {
continue;
}
// check mtime
if (in_array($file->getExtension(), $this->options['extensions'], true) && $lastMtime < $file->getMTime()) {
$var = 0;
exec('"' . PHP_BINARY . '" -l ' . $file, $out, $var);
$lastMtime = $file->getMTime();
if ($var) continue;
echo "\n[Monitor] $file 已经更新并重新载入\n\n";
// send SIGUSR1 signal to master process for reload
if (DIRECTORY_SEPARATOR === '/') {
posix_kill(posix_getppid(), SIGUSR1);
} else {
return true;
}
break;
}
}
if (!$tooManyFilesCheck && $count > 1000) {
echo "Monitor: There are too many files ($count files) in $monitorDir which makes file monitoring very slow\n";
$tooManyFilesCheck = 1;
}
return false;
}