本文是“Phorge 现代化改造实战”系列第六篇。上一篇把 diff 的替代实现迁入 Gorge,并区分了“服务端实现就绪”和“宿主已经切换”;这一篇转向一条真正完成部署接线的实时链路,检查有状态长连接服务怎样兼容 Aphlict。
系列导航
- 从没有官方镜像到 Docker Compose 跑起来:建立最小可运行基线;
- 改进容器化的七个细节:补齐权限、持久化、依赖和探活;
- 接入 Stargate:把 Forward Auth 的信任边界做完整;
- 拆分模块到 Gorge:无侵入改造不等于不碰文件;
- 替换 diff 子进程:兼容不等于逐字一致;
- 替换实时通知服务:为什么 HTTP 501 反而表示正常;
- 迁移邮件服务:先分清哪些失败不该重试;
- 迁移搜索服务:写进索引不等于搜得到;
- 迁移文件存储:写得进去也要读得回来;
- 迁移 Webhook 投递服务:先解决重复投递。
写在前面
与前一篇的纯计算模块不同,notification 有自己的状态、持续数小时的长连接,也不能与 gorge-render 共用安全边界;它同时满足了独立进程的三个判据。
在 Gorge 2026.09.06-r6 版本中,新增了 gorge-notification 模块来替换 Phorge 自带的 Node.js 常驻服务 Aphlict,具体实现可以从 Gorge r5…r6 的代码差异 中查看;Phorge 2026.09.06-r6 中,则补齐容器编排和配置下发,对应的宿主侧调整集中在 Phorge r4…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 把消息推给对应页面。
Phorge 业务事件
→ PHP 生成消息与订阅者列表
→ admin HTTP 投递
→ Aphlict / gorge-notification 路由与扇出
→ client WebSocket
→ 浏览器里的 JX.Aphlict
这条链路最终服务的是 Phorge 页面上的实时体验:某项状态在服务端发生变化后,浏览器不必等待下一次刷新,就能收到与当前用户或对象相关的消息。消息代表什么、哪些业务对象发生了变化、谁应当成为接收者,仍然由 Phorge 决定;Aphlict 不保存代码评审、任务或用户数据,也不理解这些对象的业务含义。
因此,替换 Aphlict 并不是重写 Phorge 的通知业务,更不是把权限判断迁到 Go。gorge-notification 接走的只有一段边界清楚的基础设施职责:接收 PHP 已经生成的消息,维护短期连接与订阅状态,再把消息实时送到浏览器。服务重启会断开连接并丢失短期重放历史,却不会损坏 Phorge 的持久业务数据;浏览器重新连接或刷新页面后,最终状态仍由 Phorge 提供。
选择替换它,主要有四个原因:
- Aphlict 是 Phorge 部署中唯一需要单独维护的 Node.js/npm 常驻运行时,与现有容器基线和发布方式不同;
- 它的上下游已经固定为 PHP 客户端与
JX.Aphlict,协议边界明确,适合在不修改业务代码的前提下替换实现; - 它只保存可丢弃的连接、订阅和短期历史状态,不需要复制 Phorge 的数据库模型;
- 它第一次把双端口、WebSocket、内存状态和多节点转发带进 Gorge,能够检验共享平台是否真的支持纯计算之外的服务。
也正因为它位于实时体验而不是持久数据的主链路上,兼容错误往往不会让页面彻底报错。更常见的现象是数据已经写入、HTTP 也返回成功,用户却没有及时看到通知。Gorge 的 notification 模块技术文档 描述新服务的结构,notification 与 Phorge 的兼容约束 则专门记录这些容易静默失效的协议细节。
这次替换掉的是一整条 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 版本,新增了独立二进制:
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() 一起启动:
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 宿主本机打开浏览器。需要从局域网或公网访问时,必须同时修改两个值:
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 会返回:
HTTP/501 Use Websockets
末尾还带一个换行。Phorge 的 testClient() 对这件事写得非常直接:
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:
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/** 习惯返回:
{"data": {...}, "error": null}
notification 的两个 admin 成功响应必须例外:
{"fingerprint":"..."}
以及:
{
"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 写法看起来是:
var msg hub.Message
if err := c.Bind(&msg); err != nil {
// 400
}
这在普通测试中也很容易通过,因为测试通常会正确设置:
Content-Type: application/json
但 Phorge 的真实请求不是这样。HTTPSFuture 把 phutil_json_encode() 的结果作为裸 body 交给 curl,却没有显式设置 JSON Content-Type,curl 最终把它标成:
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 类似这样:
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 解码:
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 错误信封。
所以升级成功后有两行看似只是补状态的代码:
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 只增不减。
连接内部有两把不同的锁:
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:
- 如果消息已经带着自己的 fingerprint,说明它绕了一圈回来;仍然返回 receipt,但不再 publish;
- 广播给 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 容器部署说明,避免运行约束只存在于 Compose 示例中。
这次接入依靠 Phorge 已有的 notification.servers 配置完成。它不是一个标量,而是同时包含 admin/client 两条记录的 JSON 数组,所以不能复用高亮配置的:
bin/config set key value
入口脚本改用官方支持的 stdin:
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 名称,现在每项同时携带健康检查端口:
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:// 当作混合内容拦截。这时需要把:
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 刻意写了:
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