Laravel11 从0开发 Swoole-Reverb 扩展包(四) - 触发一个广播事件到reverb服务之后是如何转发给前端订阅的呢(下)?

前情提要

上一篇我们讲到了reverb服务的通信上下文和路由处理,路由实现了pusher关联的几种请求。那么这一篇我们主要来讲混响服务Server

混响 Server

负责基于 ReactPHP 的 SocketServer 和事件循环构建一个 HTTP 服务器(实现了一个轻量级、异步的 HTTP 服务器。通过注册事件、请求解析、异常捕获等机制,保证了请求的正确处理和异常情况下的优雅响应)。它主要实现了以下功能: 连接管理:通过监听新连接(__invoke 方法)来处理数据事件,并将其传递给请求处理逻辑。 请求解析与分发:将原始数据转换为 PSR-7 Request 对象,并交由路由器分发。 异常处理与错误响应:捕获请求调度过程中的异常,返回对应 HTTP 状态码和消息。 垃圾回收优化:通过定时器调用垃圾回收,降低内存碎片。 TLS 检测:判断服务器是否支持加密连接。

server启动

我们走到 vendor/laravel/reverb/src/Servers/Reverb/Http/Server.php文件,代码不多,我先贴出来

php 复制代码
<?php

namespace Laravel\Reverb\Servers\Reverb\Http;

use Illuminate\Support\Str;
use Laravel\Reverb\Loggers\Log;
use Laravel\Reverb\Servers\Reverb\Concerns\ClosesConnections;
use OverflowException;
use Psr\Http\Message\RequestInterface;
use React\EventLoop\Loop;
use React\EventLoop\LoopInterface;
use React\Socket\ConnectionInterface;
use React\Socket\ServerInterface;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Throwable;

class Server
{
    use ClosesConnections;

    /**
     * Create a new Http server instance.
     */
    public function __construct(protected ServerInterface $socket, protected Router $router, protected int $maxRequestSize, protected ?LoopInterface $loop = null)
    {
        gc_disable();

        $this->loop = $loop ?: Loop::get();

        $this->loop->addPeriodicTimer(30, fn () => gc_collect_cycles());

        $socket->on('connection', $this);
    }

    /**
     * Start the Http server
     */
    public function start(): void
    {
        $this->loop->run();
    }

    /**
     * Handle an incoming request.
     */
    protected function handleRequest(string $message, Connection $connection): void
    {
        if ($connection->isConnected()) {
            return;
        }

        if (($request = $this->createRequest($message, $connection)) === null) {
            return;
        }

        $connection->connect();

        try {
            $this->router->dispatch($request, $connection);
        } catch (HttpException $e) {
            $this->close($connection, $e->getStatusCode(), $e->getMessage());
        } catch (Throwable $e) {
            Log::error($e->getMessage());
            $this->close($connection, 500, 'Internal server error.');
        }
    }

    /**
     * Create a Psr7 request from the incoming message.
     */
    protected function createRequest(string $message, Connection $connection): ?RequestInterface
    {
        try {
            $request = Request::from($message, $connection, $this->maxRequestSize);
        } catch (OverflowException $e) {
            $this->close($connection, 413, 'Payload too large.');
        } catch (Throwable $e) {
            $this->close($connection, 400, 'Bad request.');
        }

        return $request ?? null;
    }

    /**
     * Stop the Http server
     */
    public function stop(): void
    {
        $this->loop->stop();

        $this->socket->close();
    }

    /**
     * Invoke the server with a new connection instance.
     */
    public function __invoke(ConnectionInterface $connection): void
    {
        $connection = new Connection($connection);

        $connection->on('data', function ($data) use ($connection) {
            $this->handleRequest($data, $connection);
        });
    }

    /**
     * Determine whether the server has TLS support.
     */
    public function isSecure(): bool
    {
        return Str::startsWith($this->socket->getAddress(), 'tls://');
    }
}

首先我们关注start方法,这个方法就是外层工厂调用服务启动的方法:启动服务

php 复制代码
    public function start(): void
    {
        $this->loop->run();
    }

start() 方法简单地启动事件循环。所有注册到事件循环的定时器、IO 事件和连接处理都将在 run() 方法调用后开始执行。 这是整个 HTTP 服务器的入口,调用后服务器进入阻塞状态等待事件发生。

在走到我们的构造函数:

php 复制代码
  public function __construct(protected ServerInterface $socket, protected Router $router, protected int $maxRequestSize, protected ?LoopInterface $loop = null)
    {
	  // 调用 gc_disable() 禁用 PHP 的自动垃圾回收
        gc_disable();
        $this->loop = $loop ?: Loop::get();
		//然后使用事件循环的定时器每 30 秒手动触发一次 gc_collect_cycles(),以便更好地控制内存管理,防止自动垃圾回收带来的性能波动
        $this->loop->addPeriodicTimer(30, fn () => gc_collect_cycles());
        $socket->on('connection', $this);
    }

大家在这里可以停下来🤔️下,我们会发现这里有两点是值得我们学习的,接下来我就和大家一起来学习以下两点:

垃圾回收机制

由于PHP 的垃圾回收不会立即执行,而是满足条件后触发,因此,php提供了手动操作gc的方法,那么通过reverb的案例,我们也有机会用到自己的项目代码中。

下面是AI的总结:

1. PHP 7 及以后的垃圾回收机制

PHP 7 继续使用 引用计数(Reference Counting) 作为主要的内存管理机制,并配合 循环引用检测(Cycle Detection) 进行垃圾回收。

1.1 主要组成部分

  1. 引用计数(Reference Counting, RC)

    • PHP 的变量是基于引用计数进行管理的,每个变量都有一个 引用计数器(refcount)
    • 当变量被赋值或传递时,引用计数增加。
    • 当变量的作用域结束或 unset() 释放变量时,引用计数减少。
    • 当引用计数归零时,变量占用的内存被立即释放。
  2. 循环引用检测(Cycle Collection)

    • PHP 7 及以后版本采用 三代垃圾回收机制(Generational GC) 解决循环引用问题(即两个或多个对象互相引用,导致引用计数永远不会归零)。
    • 采用 分代收集(Generational Collection) ,将变量分为 年轻代(young)、中生代(middle-aged)、老年代(old),减少不必要的垃圾回收操作。
  3. 分代垃圾回收(Generational Garbage Collection)

    • 年轻代(young):刚创建的变量,GC 触发频率高。
    • 中生代(middle-aged):已存活一段时间的变量,GC 触发频率较低。
    • 老年代(old):存活很久的变量,GC 触发最少。
    • 这种策略减少了垃圾回收对性能的影响,提高了执行效率。

2. PHP 7 及以后的垃圾回收策略

2.1 触发条件

PHP 的垃圾回收不会立即执行,而是满足以下条件时触发:

  1. 变量的引用计数降为 0,立即释放内存(适用于无循环引用的变量)。
  2. 垃圾回收阈值触发 ,即:
    • 当创建的新变量数量超过 GC 阈值(默认 10,000),GC 可能会运行。
    • 当 PHP 发现有循环引用,会进入 GC 过程,释放循环引用的内存。

2.2 关键优化点

  • 减少不必要的 GC 触发
    • 由于采用了分代垃圾回收 ,PHP 不会每次都扫描所有变量,而是优先回收年轻代变量,减少影响。
  • 提升 GC 执行效率
    • PHP 7 优化了 GC 算法,使得清理循环引用的操作更快。
  • 优化变量管理
    • PHP 7 对 zend_mm_heap(PHP 内存管理器)进行了改进,提高了变量分配和回收的效率。

3. 常见问题及优化建议

3.1 避免循环引用

如果 PHP 代码中存在对象相互引用,GC 可能不会立即释放内存。解决方法:

  • 使用 WeakReference(PHP 7.4+)

    php 复制代码
    $obj1 = new stdClass();
    $obj2 = new stdClass();
    
    $obj1->ref = WeakReference::create($obj2);
    $obj2->ref = WeakReference::create($obj1);

    这样不会增加引用计数,GC 触发时可以正常回收。

  • 手动释放引用

    php 复制代码
    $obj1->ref = null;
    $obj2->ref = null;

3.2 手动触发垃圾回收

如果内存使用过高,可以手动调用 gc_collect_cycles() 进行垃圾回收:

php 复制代码
gc_collect_cycles();

但通常情况下,PHP 的 GC 机制已经足够智能,不需要手动调用。

3.3 关闭/调整 GC

  • 禁用 GC(在特定情况下提高性能):

    php 复制代码
    gc_disable();

    适用于短生命周期的脚本,例如 CLI 工具、任务队列等,避免 GC 影响执行速度。

  • 重新启用 GC

    php 复制代码
    gc_enable();
  • 调整 GC 阈值

    可以通过 gc_mem_caches() 调整回收策略,优化长时间运行的应用。


4. 总结

PHP 版本 垃圾回收机制 优化点
PHP 7+ 引入 分代垃圾回收(Generational GC) 提高 GC 效率,减少性能开销
PHP 7.4+ 引入 WeakReference 避免不必要的引用计数

__invoke使用

__invoke 是 PHP 的魔术方法之一,允许对象像函数一样被调用。它的主要作用是 让类的实例可以像函数一样执行,从而提供更灵活的代码设计

$socket->on('connection', $this);,connection 对应的是$this,新连接处理就在__invoke。因此我们关注的点就在__invoke上了,可以看到laravel框架有很多的地方使用了__invoke,那么对于我们来说也是有机会用到自己的代码里的。


新连接处理

我们现在重点关心__invoke里面的代码,当有数据到达时调用 handleRequest() 方法处理,深入到handleRequest里面。

handleRequest 处理流程

  • 连接状态判断: 开始检查连接是否已经建立(isConnected()),如果已经连接,则直接返回,避免重复处理
  • 调用 createRequest() 将原始消息转换为 PSR-7 Request 对象。如果请求创建失败(返回 null),则直接返回,不进行后续调度。
  • 连接激活: 成功创建请求后,调用 $connection->connect() 激活连接,表示已准备好处理请求。
  • 路由分发: 使用注入的 $router 对象,根据请求内容分发到对应的控制器或处理逻辑。
  • 异常处理: 如果调度过程中捕获到 HttpException,则根据异常状态码和消息关闭连接。捕获所有其他异常时,先记录错误日志,然后返回 500 状态码,告知客户端服务器内部错误。

路由分发

我们现在就来到了vendor/laravel/reverb/src/Servers/Reverb/Http/Router.php,路由类里面,继续看核心的dispatch

php 复制代码
 public function dispatch(RequestInterface $request, Connection $connection): mixed
    {
        $uri = $request->getUri();
        $context = $this->matcher->getContext();

        $context->setMethod($request->getMethod());
        $context->setHost($uri->getHost());

        try {
            $route = $this->matcher->match($uri->getPath());
        } catch (MethodNotAllowedException $e) {
            return $this->close($connection, 405, 'Method not allowed.', ['Allow' => $e->getAllowedMethods()]);
        } catch (ResourceNotFoundException $e) {
            return $this->close($connection, 404, 'Not found.');
        }

        $controller = $this->controller($route);

        if ($this->isWebSocketRequest($request)) {
            $wsConnection = $this->attemptUpgrade($request, $connection);

            return $controller($request, $wsConnection, ...Arr::except($route, ['_controller', '_route']));
        }

        $routeParameters = Arr::except($route, [
            '_controller',
            '_route',
        ]) + ['request' => $request, 'connection' => $connection];

        $response = $controller(
            ...$this->arguments($controller, $routeParameters)
        );

        return $response instanceof PromiseInterface ?
            $response->then(fn ($response) => $connection->send($response)->close()) :
            $connection->send($response)->close();
    }

这个路由分发写的也很好,很好的利用了Symfony 的Route 来实现,写到这里就让想起了上家公司框架里面也是大量使用symfony的route http command process 等核心组件来构建api服务,不得不说symfony才是精品。同时对于我们来说,也可以把symfony的好的组件用于自己的项目。

同时路由也共同处理着http request 以及 ws on message 的流程。

除了路由,我们这里还能学到的一个点就是:webscoket 协议的处理。因此,我们就重点来看下这个。

webscoket 协议的处理

协议升级

我们先关注核心的代码逻辑:

php 复制代码
        if ($this->isWebSocketRequest($request)) {
            $wsConnection = $this->attemptUpgrade($request, $connection);

            return $controller($request, $wsConnection, ...Arr::except($route, ['_controller', '_route']));
        }
  • 如果请求是一个 WebSocket 请求(通过 Upgrade: websocket 头部判断),就尝试进行协议升级。
  • 升级成功后,将请求 $request 和升级后的连接对象 $wsConnection 传递给控制器 $controller 处理。
  • Arr::except($route, ['_controller', '_route']) 是 Laravel 的辅助函数,表示排除控制器相关信息,传递剩余路由参数

也就是我们前端发起建立的ws连接后(比如是:ws://localhost:8083/app/2lza6dryoslsyxss6ub4?protocol=7&client=js&version=8.4.0&flash=false),就会走到协议升级的$controller处理。那具体处理在哪呢,这个就要回到我们上节提到的:pusherRoutes里定义的路由了。那么,我们快马加鞭的回到那里去,同时也说明下,这里对ws协议没有讲完。我准备在下面的内容在进行说明

业务具体处理

我们回到了vendor/laravel/reverb/src/Servers/Reverb/Factory.phppusherRoutes方法里面,然后就看第一条路由:

php 复制代码
$routes->add('sockets', Route::get('/app/{appKey}', new PusherController(app(PusherServer::class), app(ApplicationProvider::class))));

是不是一下子就破案了呢,这个路由匹配了ws连接地址/app/2lza6dryoslsyxss6ub4,因此PusherController 就是ws的处理。因此我们就继续走,来到:vendor/laravel/reverb/src/Protocols/Pusher/Http/Controllers/PusherController.php

同样的controller也是用到了__invoke,因此我们直接盘它。


Pusher 协议

1. 消息格式

Pusher 协议中的消息格式是 JSON,一般结构如下:

json 复制代码
{
  "event": "event-name",
  "data": "stringified JSON",
  "channel": "optional-channel-name"
}

例子:

json 复制代码
{
  "event": "pusher:subscribe",
  "data": {
    "channel": "private-chat.123"
  }
}

或者消息推送:

json 复制代码
{
  "event": "client-message",
  "data": {
    "text": "Hello"
  },
  "channel": "chat.123"
}

2. 控制器中处理的 WebSocket 生命周期

控制器里注册了三种 WebSocket 事件监听:

php 复制代码
$connection->onMessage(fn ($message) => ... );
$connection->onControl(fn (FrameInterface $message) => ... );
$connection->onClose(fn () => ... );

onMessage()

当浏览器发送 WebSocket 消息时:

php 复制代码
fn ($message) => $this->server->message($reverbConnection, (string) $message)
  • 将接收到的消息字符串传给 PusherServer::message() 处理。
  • 这个方法里会解析 JSON,判断 event 类型,比如:
    • pusher:subscribe:表示客户端订阅频道。
    • client-event:客户端发送自定义消息。
    • ping/pong:心跳检查。
  • 然后进行路由、鉴权、广播等逻辑。

onControl()

php 复制代码
fn (FrameInterface $message) => $this->server->control($reverbConnection, $message)
  • 控制帧,如 pingpongclose 等帧。
  • 比如,客户端发送 ping,这里可以回应 pong。
  • 这部分属于 WebSocket 协议的低层部分,保证连接活跃。

onClose()

php 复制代码
fn () => $this->server->close($reverbConnection)
  • 当连接关闭时,做清理,比如移除连接、取消订阅、广播离线消息等。

3. 连接初始化

php 复制代码
$this->server->open($reverbConnection);
  • 通知 PusherServer 有一个新的连接建立了。
  • 它可能会给客户端推送一个 pusher:connection_established 消息:
json 复制代码
{
  "event": "pusher:connection_established",
  "data": {
    "socket_id": "some-unique-id",
    "activity_timeout": 120
  }
}

这个是 Pusher 协议里约定的,客户端拿到 socket_id 后,才能订阅私有频道等。

当 appKey 无效时,会推送一个标准的错误:

php 复制代码
$connection->send('{"event":"pusher:error","data":"{\"code\":4001,\"message\":\"Application does not exist\"}"}');

  • 作为 WebSocket 路由的入口
  • 实现 Pusher 协议的握手、订阅、消息处理
  • 封装了低层的 Connection 和高层的 ApplicationReverbConnection
  • 注册了完整的 WebSocket 生命周期事件(消息、控制帧、关闭)

Pusher 协议的数据结构 是基于 JSON 的,所有通信事件都通过 event + data (+channel) 来传递和解析,保持了高度的灵活性和可扩展性。


为了一起学习,我们用gpt4来系统总结下websocket的知识。

一、WebSocket 是什么?

WebSocket 是一种 基于 TCP 的双向通信协议 ,它允许浏览器和服务器之间建立一个 持久的连接,双方可以随时互发数据,而无需每次都重新建立连接(像 HTTP 那样)。

它最初由 RFC 6455 规范定义。


二、WebSocket 建立过程

1. 握手阶段(HTTP 协议完成升级)

WebSocket 连接开始于一个 HTTP GET 请求,客户端发送如下请求:

http 复制代码
GET /app/abc123 HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13

关键字段说明:

  • Upgrade: websocket:告诉服务器要升级为 WebSocket 协议。
  • Connection: Upgrade:和上面的配套,表示连接要升级。
  • Sec-WebSocket-Key:一个随机的 base64 编码字符串,用于安全校验。
  • Sec-WebSocket-Version:WebSocket 协议版本,当前为 13

2. 服务器响应握手

服务器验证合法后,会响应如下内容:

http 复制代码
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
  • Sec-WebSocket-Accept 是通过客户端的 Sec-WebSocket-Key 计算得来的:

    php 复制代码
    base64_encode(sha1($clientKey . '258EAFA5-E914-47DA-95CA-C5AB0DC85B11', true));

一旦握手完成,HTTP 连接就升级为 WebSocket,之后传输的所有数据都不再是 HTTP 格式,而是 WebSocket 帧格式


三、WebSocket 数据帧结构

WebSocket 的通信是以 帧(frame) 为单位的,每一帧都包含:

帧结构(简化图)

0               1               2               3
+-------+-------+---------------+-------------------------------+
|FIN| RSV | OPCODE | MASK | Payload Len | Extended Len | MASK Key |
+-------+-------+---------------+-------------------------------+
|         Payload Data (possibly masked)                        |
+---------------------------------------------------------------+

重要字段:

字段名 描述
FIN 1 位,是否是消息最后一帧。通常为 1。
Opcode 表示帧的类型(文本、二进制、ping 等)。
MASK 1 位,是否启用掩码(客户端必须设置为 1,服务端返回为 0)。
Payload Length 数据长度(可能需要扩展长度字段)。
Masking Key 4 字节,客户端加密数据使用的密钥。
Payload Data 实际传输的数据,客户端发出时必须被掩码处理。

Opcode 类型

Opcode 描述
0x0 连续帧(后续帧)
0x1 文本帧(UTF-8)
0x2 二进制帧
0x8 关闭连接
0x9 Ping(心跳)
0xA Pong(回应)

示例:发送一条文本消息 "hi"(客户端 -> 服务端)

  • Opcode = 0x1(文本帧)
  • Payload = "hi",长度为 2 字节
  • MASK = 1(客户端发送时必须掩码)
  • 使用掩码 key 加密 payload

服务端接收到后会反掩码还原出原始文本。


四、控制帧

控制帧是管理连接用的:

  • Ping / Pong:用于心跳机制,确保连接活跃。
  • Close:通知对方关闭连接,可以携带关闭原因和状态码。

五、连接关闭

当任意一方想关闭连接,会发送一个 Opcode = 0x8 的帧,并可附带一个状态码(如 1000 表示正常关闭)。


六、与 Laravel Reverb 的对应关系

你之前看到的 onMessage, onControl, onClose 实际就是对上述底层帧的响应封装:

  • onMessage:处理 Opcode=0x1 的文本帧。
  • onControl:处理 Ping / Pong / Close 控制帧。
  • onClose:对应连接断开(可能是收到 Close 帧,或 TCP 断了)。

总结一句话

WebSocket 是在 TCP 上建立的持久双向通信协议,先通过 HTTP 升级,然后通过一套专门的二进制帧结构进行通信,帧可以是文本、二进制、Ping/Pong 或 Close。

WebSocket 帧结构图


各字段说明:

  1. FIN (1 bit):是否为消息最后一帧(1 表示是,0 表示后面还有)。
  2. RSV1, RSV2, RSV3 (各 1 bit):保留位,通常为 0。
  3. Opcode (4 bits)
    • 0x1:文本帧
    • 0x2:二进制帧
    • 0x8:关闭连接
    • 0x9:Ping
    • 0xA:Pong
  4. MASK (1 bit)
    • 客户端必须设置为 1(数据经过掩码处理)
    • 服务端必须设置为 0(不使用掩码)
  5. Payload Len (7 bits)
    • 小于 126:直接写长度
    • 等于 126:后面扩展 16 位表示长度
    • 等于 127:后面扩展 64 位表示长度
  6. Extended Payload Len:只有在 Payload 长度大于等于 126 时才出现。
  7. Masking Key (32 bits)
    • 客户端发送数据时用此 key 掩码实际内容
  8. Payload Data:真正的数据内容(可能是掩码处理的)

好,我们继续看一个真实的 WebSocket 抓包数据示例 ,演示一次客户端发送文本消息 "hi" 的原始帧数据,以及如何解析它。


场景:浏览器向服务器发送消息 "hi"

我们抓包看到 WebSocket 帧如下(十六进制):

81 82 37 fa 21 3d 5f 9f 44 52

逐字节解析:

字节 含义
81 FIN=1 , Opcode=1(文本帧)
82 MASK=1 , Payload length=2(表示2个字节的内容)
37 fa 21 3d 掩码 key(masking key)
5f 9f 被掩码处理过的 "hi" 数据

掩码还原 Payload

掩码算法(RFC6455 标准):
payload[i] = encoded[i] ^ masking_key[i % 4]

还原过程:

text 复制代码
原始数据(masked):   0x5f 0x9f
掩码 key:           0x37 0xfa 0x21 0x3d

还原:
byte 1: 0x5f ^ 0x37 = 0x68 = 'h'
byte 2: 0x9f ^ 0xfa = 0x69 = 'i'

→ 得出还原结果:"hi"


图解总结:

[81]     -> FIN + Opcode(0x1 = 文本帧)
[82]     -> MASK=1, Payload长度=2
[37 fa 21 3d] -> 掩码 key
[5f 9f]  -> 掩码后的 payload(hi)

还原后 payload: "hi"

再举个服务端发送回客户端的数据帧

假设服务端发回文本消息 "ok",不需要掩码:

81 02 6f 6b
字节 含义
81 FIN=1, Opcode=1(文本帧)
02 MASK=0, Payload 长度 = 2
6f 6b 字符 "o""k" 的 ASCII(0x6F 0x6B)

总结

这一节就到这里了,希望对你有用。同时,提前也提前吹下swoole的风:swoole底层处理了很多步骤,比如:

这样简化了上层开发,同时也放开了上层开发的处理,因此我们在使用技术框架、组件的时候应当更仔细阅读其文档。