14: WebSocket

在 Phlo 中,实时功能通过 Phlo Realtime 运行,这是内置于 Phlo Daemon 的 WebSocket 服务器。一个 Node 进程拥有跨每个虚拟主机的套接字连接,并通过守护进程自己的工作池在您的 PHP 应用程序上运行每个事件。您的应用程序实现了四个钩子函数,并通过 wsCast() 从 PHP 广播。

14.1: 流媒体优先

并非所有实时更新都需要使用 WebSocket。对于单请求的一次性更新,chunk()chunk 资源)打开一个流响应,并立即将每个调用作为一行 JSON 通过普通 HTTP 刷新;phlo.js 在命令到达时立即应用它们:

route async POST report::generate {
	foreach ($this->steps AS $i => $step){
		$step->run
		chunk(inner: arr('#progress' => $i + 1 .'/'. count($this->steps)))
	}
	apply(toast: 'Done')
}

%res->streaming = true 使得一个 route 具有与普通 apply() 调用相同的行为,而经典的 SSE (text/event-stream) 填充了相同的单向插槽。所有这些在响应流的整个过程中都保持一个 PHP worker:对于一个客户端观察的有限工作(AI token streams、导入、报告)来说,这正是合适的,而对于一个静态连接来说,它则在等待事件时显得不合适。这就是 Phlo Realtime 的用途:守护进程保持打开的套接字,您的 PHP 仅在事件到达时运行,并且广播会到达每个连接的客户端,而不仅仅是请求的那个。流媒体跟随请求;广播则跟随连接的 fleet。

14.2: Phlo Realtime 是什么

Phlo Realtime 是守护进程的 WebSocket 层(基于 ws 库构建),而不是一个单独的进程。位于端口 3001 的单个守护进程服务于整个堆栈:它接受套接字升级,通过握手的 Host 头进行路由,维护客户端注册表,运行 /message 广播桥,并将每个套接字事件分发到同一工作池中的 PHP,守护进程已经用于其他所有操作。

由于该分发是在进程内进行的,因此“套接字层”和“PHP 层”之间没有任何连接:它们是一个进程。每个事件(auth、connect、receive、close)运行匹配的 websocket::<hook> 目标,执行模式是守护进程已经为该主机执行的内容:

无论哪种方式,每个事件都为处理程序提供完整的请求生命周期:数据库、会话、资源,一切都可以轻松访问。模式遵循主机的构建标志(在 config/daemon.js 中设置主机的条目,见下文);处理程序代码是相同的。

14.3: 安装

守护进程是一个位于 Phlo 框架外的 Node 服务。运行它,将你的反向代理指向它,这就是整个实时设置。

git clone https://github.com/q-ainl/phlo-daemon.git <daemon>
cd <daemon>
npm install

它需要一个端口、PHP 二进制文件和主机映射:

// <daemon>/config.js
require('./phlo-daemon.js')(3001, '/usr/bin/php-zts', {
	'demo.example.nl': { app: '/var/www/demo/www/app.php', build: true },
})

第三个参数是主机映射:它将每个 Host 固定到其 app.php 路径和一个 build 标志,并在 config/daemon.js 中声明。守护进程在启动时加载它,因此它始终知道哪些主机存在,以及每个主机是一次性还是池化的。没有条目的主机会导致调度失败,从而导致升级失败。

在进程管理器下运行它(systemd / pm2 / supervisord);phlo-daemon README 描述了 pm2 模式和 /message 桥接合同:

node <daemon>/config.js

对于生产环境,通过您的反向代理(Caddy、Nginx、FrankenPHP)将 wss:// 转发到 127.0.0.1:3001,路径为 /websocket。您的应用在 www/app.php 中声明了与 daemon 常量相同的端口:

phlo_app(
	app: __DIR__.'/../',
	daemon: 3001,
)

14.4: 应用钩子

在您的应用源代码中,您定义了四个函数;Phlo 的 websocket 资源会在它们存在时调用它们。将它们放在一个像 app.ws.phlo 的文件中:不要将文件命名为 websocket.phlo,因为该类名在加载时与引擎的 websocket 资源冲突。

function wsConnect($wsHost, $wsToken, $wsSocket){
	%log->info('ws connect', socket: $wsSocket)
	return true
}

function wsAuth($wsHost, $wsToken, $wsSocket){
	$user = %user->byToken($wsToken)
	if (!$user) error('unauthorized')
	%session->user = $user
	return true
}

function wsReceive($wsHost, $wsToken, $wsSocket, ...$data){
	$type = $data['type'] ?? null
	if ($type === 'ping') return wsCast(wsTarget: $wsSocket, pong: time())
	if ($type === 'chat.send') chat::send($data['text'], from: %session->user->id)
}

function wsClose($wsHost, $wsToken, $wsSocket){
	%log->info('ws close', socket: $wsSocket)
}
钩子 何时 备注
wsAuth 在握手时,接受套接字之前 验证 $wsToken;引发错误以拒绝连接
wsConnect 在套接字被接受后立即 设置(存在,记录);使用 wsCast() 广播
wsReceive 对于每个后续消息(JSON 解码并展开) 使用 wsCast() 响应;打印的行流回发送者
wsClose 连接关闭时 清理(存在);使用 wsCast() 广播

$wsSocket 是一个不透明的字符串标识符,您可以用它来精确地向这个客户端广播。

连接上下文参数按约定以 ws 为前缀$wsHost$wsToken$wsSocket),与 wsCast 完全相同。这不是装饰性的:wsReceive 将 JSON 负载展开为命名参数(...$data),因此未加前缀的 $host/$token/$socket 参数将与携带 hosttokensocket 键的负载发生致命冲突。保持前缀,您的负载键将保持自由。

14.5: 认证流程

守护进程在握手时进行身份验证,然后才接受套接字:

  1. 浏览器打开 wss://<host>/websocket。该来源的 cookies,包括 token cookie,随升级请求一起发送。
  2. 守护进程读取 token cookie。如果缺失,则以 401 拒绝升级。
  3. 守护进程在其池上运行 websocket::auth($wsHost, $wsToken, $wsSocket),该方法调用你的 wsAuth
  4. wsAuth 根据 %user%session->token 或自定义查找验证 token。抛出错误 (error('unauthorized')) 以拒绝:抛出的身份验证失败将导致升级失败。成功时,套接字打开并运行 wsConnect

token 通常来自 %user->token(每个登录用户)或 API 密钥,在页面提供时设置为 token cookie。浏览器在 WS 握手时会自动发送它;客户端不发送单独的身份验证消息。

14.6: 从 PHP 广播

wsCast() 是一个常规函数(资源 wsCast)。它向守护进程的内部 /message 桥发送 POST 请求,将其推送到正确的套接字上。

wsCast(wsTarget: 'all',                  toast: 'New message received')
wsCast(wsTarget: 'socket:'.$wsSocket,    path: '/inbox')
wsCast(wsTarget: 'token:'.$token,        inner: ['#count' => $newCount])
参数 默认值 说明
wsTarget 'all' 'all''token:<id>''token:not:<id>''socket:<id>'
wsHost host 广播适用的虚拟主机(默认:当前主机)
wsPort daemon(来自应用配置的常量) 守护进程的端口
...$data 命名参数成为有效负载,通常是 apply() 命令

有效负载通过 phlo.js 自动传递给客户端并应用于 DOM:与您在异步 routes 中所熟知的相同 apply() 协议。

没有重试,没有死信,没有确认。 如果守护进程宕机,POST 将静默失败。为了保证交付(金融事件):与数据库队列结合使用。

14.7: 客户端

客户端本身没有什么特别之处。在 data/app.json 中将 DOM/websocket 添加到你的资源中:

{
	"resources": [..., "DOM/websocket", "wsCast"]
}

DOM/websocket 注入一个脚本,该脚本:

如果你想从 JS 发送消息:app.websocket.send({type: 'chat.send', text: 'hi'})

14.8: 迷你示例:presence

显示“谁在线”而不进行轮询。

function wsConnect($wsHost, $wsToken, $wsSocket){
	%apcu->set("presence:$wsSocket", time(), 3600)
	wsCast(wsTarget: 'all', inner: ['#online-count' => static::count()])
	return true
}

function wsClose($wsHost, $wsToken, $wsSocket){
	%apcu->delete("presence:$wsSocket")
	wsCast(wsTarget: 'all', inner: ['#online-count' => static::count()])
}

static count(){
	$keys = %apcu->keys('presence:')
	return count($keys)
}

服务器不保留任何状态;APCu 计算每个主机的套接字数量。在 PHP 重启时,缓存会自动清空,这很好,因为空的存在是一个可接受的降级状态。

14.9: 已知限制

我们使用必要的cookie来使该网站正常工作。在您的许可下,我们还使用分析工具来改善网站。