本文使用「署名 4.0 国际 (CC BY 4.0)」许可协议,欢迎转载、或重新修改使用,但需要注明来源。 [署名 4.0 国际 (CC BY 4.0)](https://creativecommons.org/licenses/by/4.0/deed.zh) 本文作者: 苏洋 创建时间: 2026年09月07日 统计字数: 17535字 阅读时间: 35分钟阅读 本文链接: https://soulteary.com/2026/09/07/phorge-modernization-part-6-replace-realtime-notification-service.html ----- # Phorge 现代化改造实战(六):替换实时通知服务,为什么 HTTP 501 反而表示正常 本文是“Phorge 现代化改造实战”系列第六篇。上一篇把 diff 的替代实现迁入 Gorge,并区分了“服务端实现就绪”和“宿主已经切换”;这一篇转向一条真正完成部署接线的实时链路,检查有状态长连接服务怎样兼容 Aphlict。 ## 系列导航 1. [从没有官方镜像到 Docker Compose 跑起来:建立最小可运行基线](https://soulteary.com/2026/09/06/phorge-modernization-part-1-from-no-official-image-to-docker-compose.html); 2. [改进容器化的七个细节:补齐权限、持久化、依赖和探活](https://soulteary.com/2026/09/06/phorge-modernization-part-2-seven-containerization-details.html); 3. [接入 Stargate:把 Forward Auth 的信任边界做完整](https://soulteary.com/2026/09/06/phorge-modernization-part-3-stargate-forward-auth-trust-boundary.html); 4. [拆分模块到 Gorge:无侵入改造不等于不碰文件](https://soulteary.com/2026/09/06/phorge-modernization-part-4-split-modules-to-gorge.html); 5. [替换 diff 子进程:兼容不等于逐字一致](https://soulteary.com/2026/09/07/phorge-modernization-part-5-replace-diff-subprocess.html); 6. **替换实时通知服务:为什么 HTTP 501 反而表示正常**; 7. [迁移邮件服务:先分清哪些失败不该重试](https://soulteary.com/2026/09/07/phorge-modernization-part-7-migrate-mail-service.html); 8. [迁移搜索服务:写进索引不等于搜得到](https://soulteary.com/2026/09/07/phorge-modernization-part-8-migrate-search-service.html); 9. [迁移文件存储:写得进去也要读得回来](https://soulteary.com/2026/09/07/phorge-modernization-part-9-migrate-file-storage.html); 10. [迁移 Webhook 投递服务:先解决重复投递](https://soulteary.com/2026/09/07/phorge-modernization-part-10-migrate-webhook-delivery.html)。 ## 写在前面 与前一篇的纯计算模块不同,notification 有自己的状态、持续数小时的长连接,也不能与 `gorge-render` 共用安全边界;它同时满足了独立进程的三个判据。 在 [Gorge 2026.09.06-r6](https://github.com/soulteary/gorge/releases/tag/2026.09.06-r6) 版本中,新增了 `gorge-notification` 模块来替换 Phorge 自带的 Node.js 常驻服务 Aphlict,具体实现可以从 [Gorge r5…r6 的代码差异](https://github.com/soulteary/gorge/compare/2026.09.06-r5...2026.09.06-r6) 中查看;[Phorge 2026.09.06-r6](https://github.com/soulteary/phorge/releases/tag/2026.09.06-r6) 中,则补齐容器编排和配置下发,对应的宿主侧调整集中在 [Phorge r4…r6 的代码差异](https://github.com/soulteary/phorge/compare/2026.09.06-r4...2026.09.06-r6) 中。 与前面的语法高亮和 diff 不同,这次功能迁出的不是一次计算,而是一条同时连接 PHP、浏览器和集群节点的实时消息链路。 这也是 Gorge 第一次真正面对长连接、内存状态和多监听器。 整体工程实现本身并不神秘:admin 端口接收 PHP 投递,client 端口通过 WebSocket 向浏览器扇出。真正花时间的也不是重写那八百多行逻辑,而是回答另一个问题:**如果这东西坏了,我会知道吗?** 此外,Aphlict 留下的协议里混着一些很反直觉的事实:501 才表示 client 健康;成功响应不能使用 Gorge 统一的 `{data,error}` 信封;PHP 发的是 JSON,HTTP 头却说它是表单;而某些兼容性一旦破坏,系统仍然会返回 200,集群面板也仍然全绿。下面就从这条真实调用链开始拆开这些细节。 ### Aphlict 只是实时通知的传递层 Aphlict 的名字很容易让人误以为它就是 Phorge 的“通知系统”。更准确地说,它只是实时通知的传递层:业务事件发生在 Phorge,PHP 决定要发送什么消息、关联哪些订阅者,再把消息投递到 Aphlict 的 admin 端口;Aphlict 根据浏览器已经声明的订阅关系,通过 client 端口上的 WebSocket 把消息推给对应页面。 ```text Phorge 业务事件 → PHP 生成消息与订阅者列表 → admin HTTP 投递 → Aphlict / gorge-notification 路由与扇出 → client WebSocket → 浏览器里的 JX.Aphlict ``` 这条链路最终服务的是 Phorge 页面上的实时体验:某项状态在服务端发生变化后,浏览器不必等待下一次刷新,就能收到与当前用户或对象相关的消息。消息代表什么、哪些业务对象发生了变化、谁应当成为接收者,仍然由 Phorge 决定;Aphlict 不保存代码评审、任务或用户数据,也不理解这些对象的业务含义。 因此,替换 Aphlict 并不是重写 Phorge 的通知业务,更不是把权限判断迁到 Go。`gorge-notification` 接走的只有一段边界清楚的基础设施职责:接收 PHP 已经生成的消息,维护短期连接与订阅状态,再把消息实时送到浏览器。服务重启会断开连接并丢失短期重放历史,却不会损坏 Phorge 的持久业务数据;浏览器重新连接或刷新页面后,最终状态仍由 Phorge 提供。 选择替换它,主要有四个原因: 1. Aphlict 是 Phorge 部署中唯一需要单独维护的 Node.js/npm 常驻运行时,与现有容器基线和发布方式不同; 2. 它的上下游已经固定为 PHP 客户端与 `JX.Aphlict`,协议边界明确,适合在不修改业务代码的前提下替换实现; 3. 它只保存可丢弃的连接、订阅和短期历史状态,不需要复制 Phorge 的数据库模型; 4. 它第一次把双端口、WebSocket、内存状态和多节点转发带进 Gorge,能够检验共享平台是否真的支持纯计算之外的服务。 也正因为它位于实时体验而不是持久数据的主链路上,兼容错误往往不会让页面彻底报错。更常见的现象是数据已经写入、HTTP 也返回成功,用户却没有及时看到通知。Gorge 的 [notification 模块技术文档](https://github.com/soulteary/gorge/blob/2026.09.06-r6/docs/modules/notification.md) 描述新服务的结构,[notification 与 Phorge 的兼容约束](https://github.com/soulteary/gorge/blob/2026.09.06-r6/compat/phorge/README.md) 则专门记录这些容易静默失效的协议细节。 ### 这次替换掉的是一整条 Node.js 运行时 与前三个迁入 Gorge 的能力,收益并不相同。 | 域 | 原来的形态 | 迁移的主要收益 | | ------------ | ---------------------- | ------------------------------------------- | | highlight | 每次请求启动 Python/Pygments | 去掉反复创建解释器的成本和 Python 依赖 | | diff | PHP 内联计算或启动 GNU diff | Gorge 侧替代实现已就绪;完成宿主接线后再释放 PHP worker、移除命令依赖 | | notification | 常驻 Node.js Aphlict | 移除整条 Node.js/npm 运行时与独立进程管理方式 | Aphlict 原本位于 `support/aphlict/server/`。它不是 PHP 请求过程中临时启动的子进程,而是长期运行的 WebSocket 服务,还带着自己的 Node.js 依赖、pidfile 和日志文件。 换成 `gorge-notification` 后,Gorge 的交付方式统一成了同一个参数化 Dockerfile、同一套 `httpx` 引导、同样的健康检查和结构化日志。Aphlict 的 `bin/aphlict start`、pidfile 和单独的日志目录都不再需要,日志直接写到 stdout,交给容器运行时处理。 不过,运行时少了一套,协议兼容面却比前两次更复杂。因为这次同时有两类旧客户端: * PHP 里的 `PhabricatorNotificationClient` 负责投递消息和读取状态; * 浏览器里的 `JX.Aphlict` 负责建立 WebSocket、订阅 PHID 和重放短期历史。 这两端都不会认真读取新服务自定义的错误说明。换句话说,兼容做错之后,最常见的结果不是一个清楚的报错,而是“通知没有出现”。 ## 为什么它不能继续并进 `gorge-render` r5 把 diff 并进了 `gorge-render`,因为 highlight 和 diff 都是无外部依赖的纯计算:请求进来,结果出去,进程里没有必须保存的状态。 **notification 恰好相反。** ### 它有进程状态 `hub.Hub` 保存浏览器连接、订阅关系和短期消息历史。重启不会损坏持久数据,但会断开所有浏览器连接并清空可重放内容。这个进程显然不能像纯计算服务一样随意滚动。 ### 它有长连接 一个高亮请求通常很快结束,一条 WebSocket 却可能持续几个小时。它们的资源模型、故障表现和关闭语义都不一样。 ### 它不能使用 render 的服务令牌 严格兼容 Aphlict 意味着 notification 两个端口都不挂 `auth.Token`。PHP 的通知客户端没有发送 service token 的机制,浏览器里的 `JX.Aphlict` 也没有。把 notification 并进 `gorge-render`,就会得到一个“一半路由必须鉴权、一半路由必须不鉴权”的进程,安全边界很难再被准确描述。 所以 r6 版本,新增了独立二进制: ```text Gorge ├── gorge-render │ ├── /api/highlight/* │ └── /api/diff/* └── gorge-notification ├── client :22280 WebSocket └── admin :22281 HTTP ``` 这次不是为了追求“微服务化”而拆,而是状态、连接寿命和安全边界已经实实在在地分叉。 ## 一个独立进程,为什么仍然需要两个端口 `gorge-notification` 独占一个二进制,却同时监听两个端口: | 端口 | 调用方 | 职责 | | --------------- | ------------------------------- | ----------------------------- | | client `:22280` | 用户浏览器 | WebSocket 连接、订阅、取消订阅、重放和 ping | | admin `:22281` | Phorge PHP、其他 notification peer | 投递消息、读取运行状态 | 这不是为了把接口分得好看。 Phorge 的 `PhabricatorNotificationServersConfigType` 会校验 `notification.servers`:必须同时存在 `admin` 和 `client` 两类记录,而且两条记录的 `host:port` 不能重复。试图让一个端口同时扮演两种角色,配置在写入时就会被拒绝。 反过来,两个端口也不能拆成两个进程,因为它们共享同一份 `hub.Hub`:admin 把消息放进去,client 侧的连接从同一个 hub 中接收。拆开以后还得在两者之间新建一条 IPC 或消息队列,而那条链路需要重新解决的,正是 hub 已经解决的问题。 因此,r6 采用的是“两张路由表、两个 listener、一个进程、一份状态”。每条 server spec 建一个独立的 Echo 实例,最后由平台层新增的 `httpx.RunAll()` 一起启动: ```go cfg, err := notification.Load() messages := hub.New() peers := peer.NewList() for _, spec := range cfg.Servers { // 按 client / admin 创建不同的 httpx.Server 和路由表 } httpx.RunAll(servers...) ``` 不能简单地给每个端口各起一个 `Server.Run()` goroutine。那样会注册两套互不知情的信号处理;更危险的是,其中一个 listener 绑定失败后,另一个仍然存活,编排层看到的进程和探针甚至可能还是绿色。 只有 client 端口存活的通知服务会接受浏览器连接,却永远收不到 PHP 的消息。这种“半可达”比直接失败更难排查。因此 `RunAll()` 的策略是:任意 listener 停止,就关闭整组;关闭并发执行,等待时间取最长的 `ShutdownTimeout`,而不是把几个端口的超时逐个相加。 `RunAll()` 和稍后会提到的 `SkipRootProbe` 都长在平台层,却没有把 Aphlict、notification 或端口号写进去。现有的分层测试会用 `go/parser` 扫描平台包的 import,禁止它反向依赖业务域。这里仍然有一处已知脆弱点:每新增一个域,都要手工把包路径加进禁止列表;漏掉以后测试不会失败,只会悄悄停止保护新域。 ## 两个端口真正难配的不是数字,而是地址 admin 和 client 虽然在同一容器里,配置中的地址却通常不能写成同一个名字。 | 记录 | 谁解析地址 | 应当填写 | | ------ | --------------- | ------------------------------------------ | | admin | phorge 容器里的 PHP | Compose 内网服务名,如 `gorge-notification:22281` | | client | 用户机器上的浏览器 | 浏览器真正可达的域名或宿主地址 | client 配置最终会被 `PhabricatorNotificationServerRef::getWebsocketURI()` 拼成 WebSocket 地址,原样发到页面里的 JavaScript。若这里填写 `gorge-notification`,服务端内部的一切检查都可能成功:容器健康,admin 状态正常,Config → Cluster → Notification Servers 也显示绿色;但真实用户的浏览器无法解析 Compose 内网服务名。 这类错误不会出现在服务端日志里,因为服务端根本收不到连接。 所以 `docker-compose.gorge.yml` 只把 client 端口发布到宿主,admin 端口刻意留在容器网络内部。后者必须这样做还有一个安全原因:为了兼容 Aphlict,admin 口没有鉴权;把它暴露出去,等于允许任何能访问该端口的人代替 Phorge 推送任意通知。 默认配置只把 client 绑定到 `127.0.0.1`,适合在 Docker 宿主本机打开浏览器。需要从局域网或公网访问时,必须同时修改两个值: ```ini GORGE_NOTIFICATION_CLIENT_HOST=code.example.com GORGE_NOTIFICATION_CLIENT_BIND=0.0.0.0 ``` 只改前者,浏览器知道该连谁,但端口没有对外监听;只改后者,端口开放了,Phorge 发给浏览器的却仍然是错误地址。两种情况在服务端都可能表现正常。 ### 兼容 Aphlict,不等于建立了新的授权边界 client 端口同样没有 service token,WebSocket 升级还需要接受来自 Phorge 页面源站的跨源连接。服务只按消息里的 `subscribers` 与连接声明的 PHID 做集合过滤,并不验证“这个浏览器是否有权订阅这个 PHID”。PHID 在这里是路由键,不是访问令牌。 因此,直接把 client 端口公开到不受信网络,继承的是 Aphlict 原有的信任模型,不应被描述成已经具备用户级鉴权。默认绑定回环地址是一条必要的安全基线;需要公网入口时,应通过 TLS 反向代理,并根据实际域名、Cookie 和 WebSocket Upgrade 行为验证可兼容的访问控制。这里的重点不是强行改变线协议,而是不要把“协议兼容”误写成“暴露安全”。 ## 四条兼容约束,按“坏了以后谁能发现”排序 这次我没有按路由或包来写兼容文档,而是按破坏后的可见程度排序。 | 约束 | 破坏后的结果 | 谁能发现 | | -------------------------------- | ------------- | ------------------ | | admin/client 必须是两条不同记录 | 配置无法保存 | PHP 配置校验当场报错 | | client `GET /` 必须返回 501 | 集群面板报告连接错误 | `testClient()` 能发现 | | admin 成功响应不能套统一信封 | 面板计数变成 0 或空白 | 没有异常,只能看页面 | | admin 不能按 Content-Type 使用 binder | 消息被揉碎但仍返回 200 | 没有任何现成机制能发现 | 越往下,问题越危险。最后一条不仅没有异常,连错误状态码都没有。 ### 501 不是“尚未实现”,而是健康信号 普通 HTTP 请求访问 client 端口时,Aphlict 会返回: ```text HTTP/501 Use Websockets ``` 末尾还带一个换行。Phorge 的 `testClient()` 对这件事写得非常直接: ```php try { id(new HTTPSFuture($server_uri))->setTimeout(2)->resolvex(); } catch (HTTPFutureHTTPResponseStatus $ex) { // This is what we expect when things are working correctly. if ($ex->getStatusCode() == 501) { return true; } throw $ex; } throw new Exception( pht('Got HTTP 200, but expected HTTP 501 (WebSocket Upgrade)!')); ``` 这与 Gorge 平台层默认注册的 `GET / → 200` 健康探针正面冲突。r6 因此给 `httpx.Config` 增加 `SkipRootProbe`,且全仓库只有 notification 的 client 端口把它设为 `true`: ```go if !websocket.IsWebSocketUpgrade(req) { return c.String( http.StatusNotImplemented, "HTTP/501 Use Websockets\n", ) } ``` `/healthz` 和 `/readyz` 仍然存在,容器探针不需要拿 501 猜健康状态。这里保留 501 只是为了旧客户端的协议,而不是把它重新解释成现代 REST 语义。 一个很容易犯的错是“统一所有服务的根路径”。如果为了整齐把 client 的 `/` 改回 200,Kubernetes 或 Docker 可能觉得它更标准,Phorge 却会认为通知服务坏了。 ### 成功响应反而不能使用统一信封 Gorge 的 `/api/**` 习惯返回: ```json {"data": {...}, "error": null} ``` notification 的两个 admin 成功响应必须例外: ```json {"fingerprint":"..."} ``` 以及: ```json { "clients.active": 2, "clients.total": 9, "messages.in": 17, "messages.out": 24, "history.size": 4, "history.age": 1200 } ``` 这些点号不是对象路径,而是 JSON 键名的一部分。PHP 会直接执行类似 `idx($details, 'clients.active')` 的读取。若把结果套入 `{data,error}`,或者把 `clients.active` “整理”为嵌套的 `clients.active` 属性,解析不会报错,只是面板上的计数全部变成空白或 0。 因此,这两个 handler 刻意使用 `c.JSON()`,而不是 `httpx.OK()`。`history.age` 在没有历史消息时还必须是 `null`,不能用 0 代替,所以 Go 契约里的字段是指针。 错误响应继续走统一信封并不矛盾。PHP 对非 2xx 直接抛异常,从不解析错误 body;成功 body 才是它真正消费的旧协议。 ## 最危险的兼容问题:PHP 说自己发了表单 admin 的 `POST /` 接收一段 JSON。自然的 Echo 写法看起来是: ```go var msg hub.Message if err := c.Bind(&msg); err != nil { // 400 } ``` 这在普通测试中也很容易通过,因为测试通常会正确设置: ```http Content-Type: application/json ``` 但 Phorge 的真实请求不是这样。`HTTPSFuture` 把 `phutil_json_encode()` 的结果作为裸 body 交给 curl,却没有显式设置 JSON Content-Type,curl 最终把它标成: ```http Content-Type: application/x-www-form-urlencoded ``` 我最初以为这会直接落到 415:标签与内容不符,框架拒绝请求,handler 自己解 body 就行。这个判断甚至一度进了代码注释和兼容文档。 实测把它推翻了。Echo 认识 `application/x-www-form-urlencoded`,会认真按表单解析;而目标类型又是 `map[string]any`,恰好属于 binder 能写入的形状。结果不是稳定地失败,而是分成两条路: | body 内容 | binder 的结果 | | ------------------------- | --------------------------------------- | | 包含 `100% done` 这样的非法百分号转义 | 表单解析失败,返回 400 | | 普通 JSON | 整段 JSON 变成一个值为空的垃圾键,handler 继续运行并返回 200 | 第二种才是真正可怕的情况。实际拿到的 map 类似这样: ```text map[touched:[3zsJPqEVRDMcP9J5] {"type":"notification"}:] ``` `messages.in` 会增长,receipt 里有合法 fingerprint,PHP 认为消息投递成功,集群面板仍然全绿;只是 `msg["type"]`、`msg["subscribers"]` 等字段已经不存在,浏览器永远收不到正确内容。 415 确实存在,但来自 Echo 不认识的 mediatype 分支,比如 `text/plain` 或空 Content-Type;那不是 Phorge 会发送的形状。照着 415 排查真实问题,会从一开始就走错方向。 r6 最终不使用 binder,而是不理会 Content-Type,直接从 body 做 JSON 解码: ```go var msg hub.Message if err := json.NewDecoder(c.Request().Body).Decode(&msg); err != nil { return httpx.Fail( c, http.StatusBadRequest, httpx.CodeBadRequest, err.Error(), ) } ``` 这不是放松输入校验,而是在复现实际存在的客户端协议:内容是 JSON,只是标签错了。 ## WebSocket 升级后,框架已经不再拥有响应 client 端口确认 Upgrade 后,gorilla/websocket 会直接 hijack 底层连接并写入 101 握手。Echo 的 `Response` 并不知道这些字节已经发出,仍然认为响应没有提交。 如果随后读循环发生 panic,平台层的 Recover 会把它交给全局错误处理器。后者看到一个“尚未提交”的响应,可能尝试在已经属于 WebSocket 的连接上再写一份 JSON 错误信封。 所以升级成功后有两行看似只是补状态的代码: ```go c.Response().Committed = true c.Response().Status = http.StatusSwitchingProtocols ``` 第一行用于阻止 HTTP 错误处理器再次写连接,第二行主要让访问日志记录正确的 101。Upgrade 本身失败时也只记录日志并返回 `nil`,因为 gorilla 已经处理过响应,再返回错误只会引出第二次写入。 这是把新协议接进通用 HTTP 平台层时很容易忽略的一道边界:一旦连接被 hijack,框架的响应生命周期就结束了,不能再按普通 handler 的控制流推理。 ## 四条命令,以及三种刻意的沉默 浏览器连接建立后,线协议只处理四条命令: * `subscribe`:订阅一组 PHID; * `unsubscribe`:移除订阅; * `replay`:重放短期历史; * `ping`:返回 `pong`。 畸形 JSON 和未知命令会被忽略,而不是关闭连接。Aphlict 原本也是这种行为:浏览器没有办法根据一条协议错误做自愈,关闭连接反而会把前后端短暂版本不一致升级成通知完全不可用。 `replay` 默认回看 60000 毫秒。重放时必须过两道过滤:先按时间取 history,再按当前连接的订阅 PHID 过滤。第二道不能少,否则用户重连时可能收到不属于自己的通知。 这里的测试没有用 sleep 等待 `subscribe` “大概已经生效”,而是利用命令按序处理:发送订阅后再发 ping,等到 pong 就说明前面的命令已经处理完。测试“不应收到某条消息”时,也不是等一个容易抖动的超时,而是先故意发送那条不该收到的消息;过滤一旦失效,测试会立即读到错误内容。 这两种写法都比等待时间更接近真正的协议性质。 ## hub:短期状态可以丢,但不能没有边界 `Hub` 是这个服务的全部状态: * 按 instance 保存连接表; * 保存最多 4096 条、最长 60 秒的 history; * 统计连接数、输入消息数和实际投递次数。 history 不落盘,清理也不需要单独的定时 goroutine。每次 `Publish` 时顺手执行惰性清理,条数上限和时间上限取更严格的结果。服务重启后历史消失,浏览器重新连接;对于可降级的实时通知,这个代价小于引入持久化系统。 扇出时不会拿着连接表的锁做网络 I/O。代码先 `snapshot()` 当前 listener,再逐个写入;写失败的连接立即从活动表删除并关闭,避免集群面板的 `clients.active` 只增不减。 连接内部有两把不同的锁: ```go type Listener struct { mu sync.RWMutex // subscriptions writeMu sync.Mutex // WebSocket writer } ``` `mu` 保护订阅集合,`writeMu` 保证 gorilla 要求的单 writer。不能复用一把锁,否则一个慢网络写会同时阻塞订阅检查,最终拖住整个扇出路径。 每条连接只占一个专门读取的 goroutine,而不是传统的读写各一个。写入由处理 admin POST 的 goroutine 直接执行,`writeMu` 负责协调它与 pong、replay 等连接内回复。少一半 goroutine 的代价是,慢客户端仍可能短暂占住一次 admin 请求,因此快照、写锁和失败摘除必须同时存在。 还有一处看起来不够“干净”的历史行为:history 是全局的,没有按 instance 分区。Aphlict 原本也是这样,r6 没有在迁移时顺手改变。重放仍会经过订阅过滤,但没有 subscribers 的广播消息理论上可以跨 instance 出现。这被明确记录为兼容行为,而不是在重写过程中偷偷改变语义。 ## peer:用 fingerprint 阻止消息在集群里绕圈 多节点 notification 之间复用 admin 的 `POST /` 中继消息,没有额外设计第二套集群协议。 每个进程启动时生成一个 16 字符 fingerprint。消息经过某节点时,该节点把自己的 fingerprint 加入 `touched`: 1. 如果消息已经带着自己的 fingerprint,说明它绕了一圈回来;仍然返回 receipt,但不再 publish; 2. 广播给 peer 前,如果已经知道某个 peer 的 fingerprint 出现在 `touched`,则跳过这次请求。 peer 的 fingerprint 不是预先配置的,而是从对方第一次 POST 返回的 receipt 中学到。这样既减少配置,也避免两台服务器因为复制配置而意外使用同一个标识。 这里有两组不对称的时间预算: | 环节 | 超时 | | ---------------------- | --: | | PHP 等 admin 响应 | 2 秒 | | notification 中继单个 peer | 5 秒 | 如果同步等待 peer,一个不可达节点就足以让 PHP 先超时。更糟的是,PHP 会吞掉这个异常,外部表现只是偶发丢通知。因此本地 publish 同步完成后,peer 中继异步执行;失败只写 warning,不重试,也不影响已经成功的本地投递。 这是一种刻意接受的降级:实时通知偶尔漏一条,用户刷新页面仍能看到最终状态;为了它引入重试队列、持久化和幂等处理,复杂度反而超过了业务价值。 ## 优雅关闭对 WebSocket 并没有想象中有效 `httpx` 默认给普通 HTTP 请求 10 秒关闭时间。`RunAll()` 会并发 drain 两个 Echo server,所以 admin 端口的在途请求可以在这个窗口内完成。 但升级后的 WebSocket 已经是 hijacked connection。Go 的 `http.Server.Shutdown()` 不会接管或等待它们,因此 client 连接不会把关闭过程拖满十秒,也不会收到一套自动生成的优雅 close 协商;进程退出时连接直接断开,浏览器随后重连。 真要优雅地关闭 WebSocket,需要 hub 主动遍历 listener、发送 close frame,再等待确认。r6 没有做这件事,因为 notification 本身可降级,一次短暂重连的成本并不值得再维护一套关闭协议。 这也说明“框架支持优雅关闭”不能只看方法名。普通请求、流式响应和 hijacked 长连接,各自的实际语义可能完全不同。 ## Phorge 侧没有改 PHP,只改了部署接线 Phorge r6 相比 r4 只改了四个部署相关文件:`.env.example`、`DOCKER.md`、`docker-compose.gorge.yml` 和 `docker/entrypoint.sh`。没有修改通知业务代码,也没有替换浏览器里的 `JX.Aphlict`。完整的环境变量、端口与反向代理配置同时写进了 [Phorge 容器部署说明](https://github.com/soulteary/phorge/blob/2026.09.06-r6/DOCKER.md),避免运行约束只存在于 Compose 示例中。 这次接入依靠 Phorge 已有的 `notification.servers` 配置完成。它不是一个标量,而是同时包含 admin/client 两条记录的 JSON 数组,所以不能复用高亮配置的: ```bash bin/config set key value ``` 入口脚本改用官方支持的 stdin: ```bash php -r '/* 使用 json_encode 构造 server 列表 */' \ | bin/config set notification.servers --stdin ``` 这里没有用 shell `printf` 拼 JSON。端口必须是 JSON 整数,host 又可能含引号或反斜杠;交给 `json_encode` 才不会制造另一份转义协议。 在进入 PHP 前,脚本还先检查端口是否全部由数字组成。否则 `(int)"abc"` 会静默变成 0:生成的 JSON 类型正确,`bin/config` 也可能成功,最终只是服务永远连不上。 admin host 和 client host 必须同时给出。缺一项时不写“半份配置”,因为 Phorge 本身要求两类 server 都存在,而且 `notification.servers` 在 Config 页面中是只读的。写入成功后,入口脚本继续修正 `local.json` 的属主和权限,保证以 `www-data` 运行的 Apache 和 phd 能读到它。 与 `gorge.render.*` 一样,这段配置下发故意放在首次生成 `local.json` 的守卫之外。它描述的是部署拓扑,应当跟随 `.env` 和编排变化;更换域名或端口后重启容器即可生效,不必删除持久化配置卷。 发布工作流也因为第二个二进制改了一处不太显眼、但不能漏的地方。原来的 matrix 只保存 service 名称,现在每项同时携带健康检查端口: ```yaml matrix: include: - service: gorge-render port: 8140 - service: gorge-notification port: 22281 ``` 共享 Dockerfile 的 `PORT` 只用于生成镜像内置的 `HEALTHCHECK`。如果 notification 沿用 render 的默认 8140,容器本身虽然已经在 22280/22281 监听,镜像探针却会不断访问一个不存在的端口。Compose 文件显式覆盖了 healthcheck,本地联调反而看不出问题;真正有问题的是发布后的镜像被单独 `docker run` 时会一直 unhealthy。这里选择 admin 的 22281,因为它是普通 HTTP,探针不需要理解 WebSocket 和“501 表示健康”这层协议。 ## HTTPS 场景还要处理 WebSocket 路由 Phorge 如果通过 HTTPS 提供服务,client 地址仍写 `http`,浏览器会把 `ws://` 当作混合内容拦截。这时需要把: ```ini GORGE_NOTIFICATION_CLIENT_PROTOCOL=https GORGE_NOTIFICATION_CLIENT_HOST=code.example.com GORGE_NOTIFICATION_CLIENT_PORT=443 GORGE_NOTIFICATION_CLIENT_PATH=/ws/ ``` 并在 Traefik 上增加 `/ws/` 到 `gorge-notification:22280` 的路由。 这条路由不需要 StripPrefix,因为 client 端口原本就接受任意路径,`~{instance}/` 也正是通过路径传递。Forward Auth 则不能不经验证地照搬 Phorge 页面配置:WebSocket 握手是否携带认证后端需要的 Cookie,取决于域名与 Cookie 范围;中间件也必须正确支持 Upgrade。能够满足这些条件时,它可以收紧 client 入口,但不能替代 notification 自身缺少订阅授权这个事实。 走反向代理后,还要用 Compose 的 `!reset []` 移除 client 的宿主端口发布。否则把 client port 改为 443 后,通知容器会与 Traefik 的 HTTPS 入口争抢宿主 443。这个写法要求 Docker Compose 2.24.4 或更高版本。 ## 测试不是越多越好,关键是每条断言到底咬住了什么 r6 给 notification 建了四层验证: | 层 | 内容 | 独有价值 | | ---- | ----------------------------- | ---------------------- | | 单元测试 | admin、client、config、hub、peer | 真 WebSocket 会话、并发和过滤行为 | | 分层测试 | 禁止 platform 反向依赖 notification | 保持将来仍可拆分 | | 契约固件 | admin 7 份、client 4 份 | 固定两个端口各自的 HTTP 契约 | | E2E | 5 个场景 | 真正完成一次 101 Upgrade | `httptest.NewRecorder` 不实现 `http.Hijacker`,所以单靠 handler 测试无法完成 WebSocket 升级。E2E 里那次真实 101 握手不是重复覆盖,而是只有这一层能验证的行为。 更值得保留的是几条测试数据本身的“牙齿”。比如 `post-form-content-type.json` 里的 payload 刻意写了: ```text build 100% done ``` 百分号不是装饰。它会让错误的表单解析路径产生非法转义,从而把 `c.Bind()` 的退化从“静默揉碎”变成可观察失败。删掉 `%` 之后,这份固件仍可能拿到 200、fingerprint 和裸响应,看起来全绿,实际上已经失去保护作用。 更强的一条测试会使用 Phorge 真实发送的 form Content-Type,把同一条带 `%` 的消息 POST 到 admin,再从真实 WebSocket 逐字段读回。只有这样才能同时挡住两种失败:请求被错误拒绝,以及请求返回 200 但内容已被 binder 改坏。 最终三处守卫的能力边界可以写成一张表: | 守卫 | 能挡住的破坏路径 | 单独漏掉什么 | | ----- | ----------------------------- | -------------------------- | | 契约固件 | `%` 被当成非法表单转义,请求变成 400 | 普通 JSON 被揉碎后仍返回 200 | | 单元测试 | 读取 history 内容,发现字段被 binder 改坏 | 带 `%` 的请求在进入 handler 前已被拒绝 | | 端到端测试 | 用真实请求头投递,再从真实 WebSocket 逐字段读回 | 两条路径都覆盖 | 这里尤其不能把 `&`、`=` 或 `%20` 当作 `%` 的等价替代:前两者只会把垃圾键拆得更碎,合法的 `%20` 也不会让表单解析失败。契约固件里真正有约束力的就是那个非法 `% d` 序列。 这段约束的文档描述也前后错过两次:第一次把行为写成“binder 返回 415”,第二次又把单元测试描述成“只统计 history 条数”。两次都是文档先于测试演进,却没有回头对着实际失败原因复核。最后的表格不是根据测试名称推测出来的,而是临时把 handler 真改成 `c.Bind()`,逐条观察每个测试为什么失败后才写下来的。 这类案例说明,测试的价值不能只按数量或覆盖率判断。某个看似随意的字符、请求头或调用顺序,可能才是整条测试真正约束实现的部分。把它“清理得更漂亮”,测试会继续通过,守卫却已经消失。 ## 其他 ### 已知偏离和没有顺手处理的事情 和之前一样,这次仍然没有追求表面上的 100% 复刻。 * admin 端口的 `GET /` 现在是平台健康探针,返回 200;Aphlict 原来返回 405。PHP 只访问 `POST /` 和 `GET /status/`,真实调用观察不到差别。 * Aphlict 的 client server 对任意非升级 HTTP 请求都返回 501;新实现只给 GET 返回 501,`POST /` 会走 Echo 的 405。Phorge 和浏览器都不会发送这种请求。 * Aphlict 配置里的 `ssl.key`、`ssl.cert`、`ssl.chain`、`logs` 和 `pidfile` 不被解析。TLS 应在上游终止,日志和进程生命周期交给容器;但 `encoding/json` 会静默忽略这些键,所以“能直接读取 Aphlict 配置”目前只兑现了一部分,文档必须把这件事说清楚。 * cluster peers 目前只能从配置文件读取,没有对应的环境变量形式。 * history 仍然不按 instance 分区,这是继承原实现的行为,不在迁移中顺手修改。 这些差异的共同原则是:先确认 Phorge 和浏览器的真实调用能否观察到,再决定它是兼容缺陷、需要记录的偏离,还是一次应该独立完成的行为修正。 ### 这次迁移留下的经验 替换 Aphlict 之后,Gorge 从两个纯计算域走到了第一个有状态、长连接、独立安全边界的服务。它带来的经验也和前几篇不太一样。 第一,拆不拆进程应由状态、连接寿命和安全边界决定。notification 独立成 `gorge-notification`,但 admin/client 仍留在同一进程,因为它们共享同一个 hub。 第二,兼容的对象不只包括成功数据。端口数量、501 状态码、末尾换行、点号键、错误的 Content-Type,甚至“忽略未知命令”都可能是旧客户端真正依赖的协议。 第三,风险应按破坏后的可见性排序。配置直接报错并不可怕;返回 200、计数增长、页面却收不到通知,才最值得用契约测试守住。 第四,服务端全绿不等于链路可用。admin 探活、集群面板和容器健康都看不到浏览器是否能解析 client 地址。最终验收必须从真实浏览器走完一次 WebSocket 和通知投递。 第五,统一平台设施必须允许有理由的例外。`SkipRootProbe`、裸成功响应和两个 Echo 实例看起来破坏了一致性,却恰好让不同域的真实契约保持一致。平台层的价值不是让所有服务长得一样,而是把共同部分收好,同时允许差异被明确表达和测试。 从 Phorge 的角度看,这次改造只增加了编排和配置,PHP 源码改动是 **0 行**;从 Gorge 的角度看,它却把双端口启动、WebSocket 生命周期、内存 hub、集群防环和四层测试带进了共享平台。所谓“无侵入”,仍然不是少改几行,而是接住真实调用依赖的行为边界,同时把未复刻的差异和沿用的信任模型明确记录下来。 ## 最后 **替换组件时,把精力放在“它坏了谁会发现”上,而不只是“它能不能跑起来”。** 能不能跑起来,第一天就会知道;坏了却没人发现的那些约定,往往会在几个月后以“通知好像一直不太灵”的形式回来找你。 --EOF