全新的 TP8+Workerman+BuildAdmin 整合方案,已有近 2000 次下载使用。

0 阅读8分钟

前言

这是一篇酝酿了将近两年的 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 的项目也能参考借鉴。

设计目标

  1. 基于 TP8 的常驻内存高性能 HTTP 服务,兼容 think-orm,兼容 TP8 框架的多应用模式等
  2. 整合完成后,已有应用只需注意常驻内存特有的特性,而不必对现有代码进行大规模改动
  3. 兼容 Windows 平台,并且启动方便
  4. 全平台支持文件热重载
  5. 极简整合,找到所有最关键的点进行两个框架的融合,避免任何冗余代码
  6. 完美兼容 BuildAdmin 的现有的所有接口

最终性能提升

实现整合后,我对性能进行了测试,相同的请求 php think run 耗时约 366msworkerman 启动的 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

  1. 一般 WebSocket 服务需要启动多个进程,分别是:Register、Gateway、Business,我们建立 /servers/ws/ 文件夹,里边分别建立 startBusiness.phpstartGateway.phpstartRegister.php 三个文件,并实现启动服务逻辑,如果你的 WS 服务需要其他更多进程,直接照格式自行建立即可。
  2. 在以上三个文件中,直接以 Workerman 的语法启动各类服务,并且其中可以使用 TP 的 Config 类,因为这些启动服务的代码,后续将通过 php think ... 调用。
  3. startRegister.phpstartBusiness.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;
}
  1. 创建一个 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();
  1. 我们在 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 命令,最终走 TPthink 命令行入口文件启动了全部的服务。

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;
}

完整代码免费下载