本文是“Phorge 现代化改造实战”系列第五篇。前四篇从容器基线、运行质量和入口信任一路写到 Gorge 的模块边界;这一篇开始把第二个纯计算模块迁入 Gorge,并讨论替换旧实现之前最难说清楚的问题:兼容究竟要精确到什么程度。

系列导航

  1. 从没有官方镜像到 Docker Compose 跑起来:建立最小可运行基线
  2. 改进容器化的七个细节:补齐权限、持久化、依赖和探活
  3. 接入 Stargate:把 Forward Auth 的信任边界做完整
  4. 拆分模块到 Gorge:无侵入改造不等于不碰文件
  5. 替换 diff 子进程:兼容不等于逐字一致
  6. 替换实时通知服务:为什么 HTTP 501 反而表示正常
  7. 迁移邮件服务:先分清哪些失败不该重试
  8. 迁移搜索服务:写进索引不等于搜得到
  9. 迁移文件存储:写得进去也要读得回来
  10. 迁移 Webhook 投递服务:先解决重复投递

写在前面

在上一篇《Phorge 现代化改造实战(四):拆分模块到 Gorge,无侵入改造不等于不碰文件》里,我把 Phorge 的语法高亮从 PHP 应用中拆了出来,迁进新的 Gorge 仓库。

那次解决的是 Pygments 子进程:PHP 不再为每次高亮启动 Python,改为请求一个常驻的 Go 服务。本篇处理第二个计算模块 diff,代码保存在 Gorge 2026.09.06-r5,完整改动可以从 r4…r5 的代码差异 中查看。

需要先划清版本边界:r5 完成的是 Gorge 服务端的 diff 实现与兼容验证,不是 Phorge 调用链的切换。完整集成测试还依赖外部 CVS 功能,因此本文讨论的是“替代实现怎样达到可接入状态”;要完成彻底替换,后续仍需补齐 PHP 客户端、接入点、配置、失败回退和端到端验收。

这次处理了两条路径的事情:

从表面看,这仍然是一次“把 PHP 或子进程换成 Go”的工程改造。但真正麻烦的地方却不在语言层面,而在兼容的定义:一份输出究竟只要语义正确,还是必须逐字节一样?如果存在多个同样正确的答案,又该拿什么判断迁移是否成功?

diff 和 Phorge 的功能是什么关系

如果只看实现,diff 很像一个藏在基础设施目录里的小工具;放回 Phorge 的产品功能里,它却位于代码评审理解“改了什么”的关键路径上。

其中,unified diff 路径接收旧文件和新文件的内容,生成带文件头、hunk 头、上下文行、删除行和新增行的文本。下游解析器再把它拆成 changeset 与 hunk,供 Differential 按文件、行号和上下文展示变更。审阅者最后看到的代码块、行号和可讨论的位置,都建立在这份结构化差异能够被正确解释的前提上。

旧文件与新文件
  → unified diff
  → changeset / hunk 解析
  → Differential 变更展示
  → 按行审阅与讨论

但,并不是每一份 Differential 变更都由这个函数即时生成:例如外部工具可以直接提交已经生成的 diff。但只要流程进入“根据两份文件内容生成 raw diff”这条路径,输出就不再是单纯给人阅读的文本,而是下游解析器的输入协议。文件头、hunk 行号或尾换行标记只要出现偏差,前端页面仍可能正常打开,展示的上下文和评论位置却可能已经错了。

prose diff 承担的是另一层工作。它不会生成 Differential 使用的 unified diff,而是把两段文本递归拆成相同、删除和新增片段,让页面能够把段落、句子、词语乃至字符级的变化标出来。两条路径都在回答“旧内容和新内容有什么不同”,但消费者和兼容要求并不相同:

路径 在 Phorge 中承担的工作 最敏感的兼容边界
unified diff 为文件级变更生成可继续解析的差异文本 文件头、hunk 范围、行记录和尾换行标记
prose diff 为文本变化生成可直接渲染的片段序列 片段类型、内容、顺序以及能否无损还原原文

这也解释了为什么要拆出 diff 功能,但又不能把它写成“把 Differential 拆成 Go 服务”。仓库、修订、changeset、评论、权限和页面渲染这些功能仍然留在 Phorge;Gorge 接走的只是输入明确、无状态、计算密集并且能够独立验证的差异计算。这个边界足够清晰和薄,不需要让 Go 服务理解用户、项目或代码评审对象,也不会把 Phorge 的业务模型复制一份到外部服务。

选择 diff 作为高亮之后的第二个模块,还有四个实际原因:

  1. 旧路径要么启动系统命令,要么在 PHP worker 内执行递归计算,运行成本和依赖都容易识别;
  2. 输入只有 old/new 等少量字段,输出可以通过 HTTP 契约完整描述,不需要数据库事务;
  3. 系统 diff 和仍然存在的 PHP 引擎都可以作为参照实现,迁移期间能够做差分验证;
  4. 它同时包含“必须逐字节兼容”和“允许多个正确答案”两类问题,适合检验 Gorge 的契约、测试和模块边界是否真的可复用。

因此,这次拆分的目标不是为了多做一个微服务,也不是为了重写 Phorge 的代码评审功能,而是把代码评审链路中边界清楚的计算环节抽出来。Gorge 的 diff 模块说明 描述服务接口,与 Phorge 的兼容约束 则记录哪些历史行为必须保留。

宿主模块对接完成后,Phorge 仍负责业务,Gorge 这边只负责计算。

从“替换子进程”开始,但不要把收益混在一起

Phorge 里几种适合外移的计算,原先付出的成本并不相同。

原实现 完成宿主模块对接后的主要收益
每次 fork Python 执行 Pygments 避免反复启动解释器,让词法分析常驻,并移除 PHP 容器里的 Python/Pygments 依赖
PHP 内联执行 prose diff 把 CPU 密集的字符串切分、矩阵计算和递归从 PHP worker 中移走
fork GNU diff 性能收益未必明显,重点是为消除宿主命令依赖准备可验证的替代实现,并统一部署和契约

表格最后一行“fork GNU diff”值得再展开聊聊。

GNU diff 是成熟的 C 程序,启动也很快;把一次本地 fork + exec 换成一次 localhost HTTP 请求,并不能天然保证延迟更低。因此,完成整条替换的理由不一定是“Go 更快”,而是:

  1. 高亮路径已经不再需要 Python/Pygments;diff 路径完成模块对接后,才可以进一步移除 GNU diff 依赖;
  2. 完成模块对接后,diff 计算不再与 PHP-FPM worker 绑定;
  3. 高亮、diff 等纯计算能力可以统一观察、限制资源,并按需要独立扩展;
  4. 替代实现已经具备明确的输入、输出和错误响应契约,不再把宿主命令的行为当作隐式前提。

了解清楚收益和目标动机,就能够避免用一组漂亮但并不适用于所有路径的性能数字替代真实收益,也能够决策出我们愿意为“兼容性”付出多大成本。

第二个计算模块拆分后,Gorge 仍然只有一个进程

diff 最初规划为独立的 gorge-diff 功能,监听 :8130。真正执行模块迁入时,我没有再增加一个容器,而是把它并入现有的 gorge-render,继续监听在 :8140

gorge-render (:8140)
├── /api/highlight/*
└── /api/diff/*
    ├── POST /api/diff/generate
    └── POST /api/diff/prose

理由并不是“微服务太多不好”,而是 render 与 diff 此刻具备相同的运行特征:

  • 都是无状态的纯计算;
  • 都没有数据库、缓存和下游服务;
  • 健康检查语义相同,进程起来就已经 ready;
  • 暂时没有证据表明两者的伸缩曲线或资源配额需要隔离。

在这些条件下,拆成两个容器只会多出一套构建、部署、探针和运维对象,却换不来真正的故障隔离。

反过来,何时应该拆,也有了比较明确的判据:当某个域开始依赖外部系统、流量或资源曲线明显分叉,或者它的 OOM 不应影响其他域时,再把它提成独立进程。将来的 conduit、search 等模块如果需要访问下游服务,就更可能满足这些条件。

这次还兑现了一个早期设计:路由一直按业务域命名为 /api/highlight/*,而不是按二进制命名为 /api/render/*。因此 diff 加入同一进程时,只需要增加 /api/diff/*,不用再创建新的服务地址、Compose service 或镜像。未来 Phorge 接入这两条路由时,可以复用 gorge-render 的 URI 和 service token;这个 token 认证的是调用方对整个进程的身份,而不是对某个路由分组的身份。

同一个仓库,兼容策略却不能统一

render 与 diff 共用 Go module、平台层、契约固件 runner 和发布流程,但它们对输出的测试方式正好相反。

高亮域 unified diff 域
输出消费者 浏览器渲染 ArcanistDiffParserDifferentialHunkParser 解析
输出能否在没有回归时变化 能,底层高亮库升级会改变 HTML 细节 原则上不能,格式本身就是输入协议
应当固定的内容 CSS 类名等页面依赖的不变量 文件头、hunk 头、正文与标记的完整字符串

高亮模块,输出的 HTML 结果如果做 golden test,很容易在 Chroma 升级后因为 token 切分或 <span> 边界变化而失败。这类变化未必影响页面结果,但长期维护下来,“收益”估计只是训练维护者看到失败就重新录制快照。所以,高亮域只固定 Phorge 样式表真正依赖的 Pygments CSS 类名体系。

unified diff 不同。它不是一段给人看的文本,而是一份要继续被程序解释的协议。比如 GNU 写:

@@ -1 +1,2 @@

如果实现写成:

@@ -1,1 +1,2 @@

它依然是合法的 unified diff,但已经不再是 Phorge 原路径一直收到的字节序列。更糟的是,解析器不一定报错;它可能接受这个 hunk,再把后面的行放到错误位置。最终症状出现在代码评审页面,离真正的格式偏差已经很远。

所以这里不能追求“一套测试规范管全仓库”。而应该是:这个输出会不会在没有回归的情况下变化?会,就断言不变量;不会,就比较完整结果。

复现 diff -U65535,最容易写错的三个地方

行不只是字符串

一开始很容易把文本按 \n 切成 []string,再丢掉末尾的空元素。但这会丢失一条真实信息:一行是否以换行符结束。

在 GNU diff 看来,“内容相同但没有尾换行”与“内容相同且有尾换行”不是同一行。因此实现中的行模型必须同时保存文本和终止状态:

type line struct {
	text       string
	hasNewline bool
}

两个字段都参与相等判断。否则,给文件补一个尾换行这样的真实变更会被错误地吞掉。

hunk 头有三种计数形式

-U65535 会把整个文件放进同一个 hunk,起始位置通常固定,计数却不能统一拼成 1,count

该侧行数 GNU 的写法
0 0,0
1 1,省略 ,1
大于等于 2 1,count

整个格式化函数只有三个分支,却是最容易造成静默错位的地方。

No newline 标记跟着记录走

规则只有一句:\ No newline at end of file 紧跟在承载无尾换行内容的那条 diff 记录之后。

由此自然得到三种情况:

  • 两侧共享的末行都没有尾换行:上下文行之后出现一次;
  • 一对 -/+ 记录的两侧都没有尾换行:分别出现一次;
  • 只有一侧没有尾换行:只跟在那一侧之后。

需要区分的是:缺少尾换行的是被描述的源文件内容,不是 diff 文本本身。diff 的内容行和标记行仍然都以 \n 结束。

有一处反而不能照抄 GNU diff

两个输入完全相同时,GNU diff 退出码为 0,并且不输出任何内容。但 Phorge 原来的 PHP 代码并没有把空字符串直接交给下游,而是自己合成一份“没有发生变化的 diff”,让调用方仍然能够渲染完整文件。

这段历史行为带着几个不太漂亮的细节:

  • explode("\n", $old) 会保留末尾空元素,所以 "a\nb\n" 被算作三行,最后一行渲染为一个空格;
  • hunk 计数始终写成 -1,{len} +1,{len},单行也保留 GNU 会省略的 ,1
  • 空字符串仍会拆出一个元素,因此会得到一行 hunk,而不是只有文件头。

也就是说,两侧都为空时,兼容结果仍包含:

@@ -1,1 +1,1 @@

这不是 GNU diff 的行为,但是却是 Phorge 项目下游长期接收到的行为。

而我们做功能迁移时,真正要保留的是后者。这也提醒我们,所谓“兼容原实现”不能只看它调用了谁。

正常差异路径以 GNU diff 为参照,相同输入路径却必须以 PHP 合成逻辑为参照。

我们把这处怪癖注释成“不要修正为 GNU 行为”,比把它包装成更优雅的算法更有价值。

11 组精心设计的用例都通过了,然后发现 8.6% 不一致

完成上述规则后,我准备了 11 组格式用例。期望值不是凭记忆手写,而是从真实的 diff 二进制抓取:

printf '<old>' > a
printf '<new>' > b
diff -U65535 -L 'a 9999-99-99' -L 'b 9999-99-99' a b

所有用例都通过了。按一般的开发节奏,这里似乎已经可以结束。

但手写用例只能覆盖“我想到的输入形状”。既然外部参照实现就在系统里,我又加了一组交叉验证:批量生成 old/new,分别交给 Go 引擎和真实 diff,再逐字节比较输出。

结果是:约 2900 组输入中,91.4% 完全相同,8.6% 存在差异。

先别急着改算法。把差异继续拆开后,结果变成了:

检查项 结果
出现逐字节差异 8.6%
差异是否都包含重复行
hunk 头不同 0 次
编辑数量不同 0 次

例如:

old: "  indented\n  indented\n"
new: "  indented\nalpha\ngamma\n"

Go 实现可能保留第二个 indented,GNU 则保留第一个。两份结果都是一次删除、两次插入,hunk 头也一致。差别只发生在重复行存在多个同样最小的对齐时:GNU 的 Myers 算法与边界平移启发式选择了一个答案,当前 LCS 回溯选择了另一个。

这时,“没有逐字节复现”与“实现错误”就需要分开看:

  • 如果 hunk 头不同,下游可能静默错配行号,必须修;
  • 如果编辑数量更多,说明新引擎生成了更差的 diff,也应该修;
  • 如果只是在多个同样最小的解中选择了不同对齐,解析正确性和最小编辑规模都没有受损。

因此,r5 最终把兼容承诺写成了三层:

内容 保证
文件头、hunk 头计数、No newline 标记位置 与 GNU 逐字节等同
编辑脚本规模 保证最小
存在歧义时具体保留哪一个重复行 不保证与 GNU 相同

兼容承诺变窄了,但也更准确了。

让测试直接去问参照实现

这次发现 8.6% 差异的系统 diff 交叉验证测试被保留在仓库里,不再依赖谁记得手工执行命令。

测试首先查找系统里的 diff。如果运行环境没有这个二进制,就明确 skip;存在时,则把两份输入写进临时文件,调用:

exec.Command(bin, "-U65535",
	"-L", "a 9999-99-99",
	"-L", "b 9999-99-99",
	a, b).Output()

这里有一个很容易踩的细节:diff 在文件不同时返回退出码 1,而这恰好是测试希望看到的正常情况。只有退出码 2 或命令未能执行,才算真正失败。

常驻测试分成两组,而且故意使用不同的断言强度。

第一组穷举行数与尾换行状态的组合。这些是实现自己负责的确定性格式规则,因此要求与系统 diff 逐字节相同;相同输入则显式排除,因为那一支刻意复现 PHP 行为。

第二组刻意制造重复行、移动和共享上下文。这里承认多个最小对齐都可能正确,只要求 hunk 头和编辑数量相同。字节一致率仍会记录在日志里,但不设阈值——输入本来就是为了制造歧义而构造的,这个比例更多反映生成器的偏斜,而不是引擎质量。

探索阶段我会用随机输入尽量扩大搜索面;把问题固化为回归测试时,则改成确定性枚举。这样每次运行覆盖的都是同一批明确形状,失败可以稳定复现,也更容易在代码审查中理解。

一个很实用的判断是:如果某个断言衡量的主要是测试数据的分布,而不是被测代码的性质,它通常更适合成为日志,而不是 CI 门槛。

prose diff:没有外部标准,就和旧实现做差分验证

/api/diff/prose 功能迁移的是另一类实现。它把文本按段落、句子、词和字符逐级细化,只让真正变化的部分进入下一层:

段落 → 句子 → 词 → 字符

它的结果是供页面渲染的片段序列,并没有 GNU diff 这样的外部参照物。这里最硬的不变量是无损:拼接 =- 片段必须还原旧文本,拼接 =+ 片段必须还原新文本。

但“不丢字”仍不足以证明迁移忠实。趁旧实现还在,我把迁移前的引擎临时放到测试环境中,用三类输入同时调用新旧版本:

  • 18 组手工挑选的边界情况;
  • 2500 组小字母表随机字符串;
  • 800 组基于固定长文本生成的局部编辑。

最后一组最有价值:大部分内容保持相同,递归会真正深入到句子、词和字符层,而不是把整段都判断为替换。

3318 组结果逐片段比较,TypeText 全部一致。验证完成后,旧实现副本和一次性测试被删除,因为它们的产物是一条“本次迁移保持行为一致”的证据,而不是值得永久维护的第二套引擎。

两种差分验证因此有不同归宿:

常驻交叉验证 一次性迁移验证
参照物 环境中的外部二进制 临时保留的旧实现
目的 防止未来格式漂移 证明这次移植没有改变行为
最终处理 留在仓库 得出结论后删除

第二个计算模块还暴露了两个比算法更实际的问题

共享 helper 改变了控制流

两个 diff 端点都要解码 body、检查组合输入大小,于是公共逻辑被抽成 helper。这一步引入了一个不明显的问题:httpx.Fail 写完错误响应后返回 nil,符合 Echo 对“handler 已经自行答复”的约定;但 helper 的调用方无法仅凭这个 nil 区分“检查通过,继续执行”和“错误已经答复”。

结果可能是先写入一个 400 JSON,再继续执行并向同一响应追加第二个 JSON。只检查状态码的单元测试仍然会通过,因为第一次写入的 400 没有改变。

修复方式是显式返回 answered

if answered, err := bind(c, &req); answered {
	return err
}
if answered, err := checkSize(c, deps, len(req.Old)+len(req.New)); answered {
	return err
}

这里必须按 answered 分支,而不是按 err 分支。发现它的是契约固件,因为固件会把完整响应体解码为唯一的 {data,error} 信封;两个 JSON 拼在一起时会直接失败。之后,单元测试也增加了只允许一个响应信封的检查。

这里不是“不要抽公共函数”,而是:抽取逻辑时要重新检查原代码依赖的控制流。原先的 return httpx.Fail(...) 同时表达了“写响应”和“终止 handler”,抽成 helper 后,第二层含义消失了。

进程配置还住在 render 域里

监听地址、service token 等进程级配置目前包含在 render.Config 中。diff 只有 MaxBytes 这样的域级配置,因此入口程序需要先 render.Load(),再把其中的 token 传给 diff。

两个域时这还可以工作,第三个域进来时就会显得别扭:一个与 render 无关的模块,也要从 render.Load() 借进程配置。

正确方向是把 config.Base 的加载提升到 cmd/gorge-render 这一进程层,各域只保留自己的字段。这次没有顺手处理,是因为 render 仍承担旧环境变量兜底逻辑,把配置重构混进 diff 迁移会扩大一次改动的风险。它已经作为明确的后续项记录下来。

其他

还没有解决:LCS 的两个症状

当前 unified 引擎使用完整的 (n+1) × (m+1) LCS 动态规划表,时间和空间复杂度都是 O(n*m)。为了避免单个请求分配过多内存,代码设置了 400 万单元的护栏;在 64 位环境下,DP 表里的整数本身大约需要 32 MiB。

这道护栏保护了服务,也意味着 2001 行对 2001 行这种并不夸张的比较会被拒绝。与此同时,LCS 在重复行的歧义对齐上也不会复现 GNU 的选择。

两个现象来自同一处技术债:算法没有使用 Myers。与其分别给内存上限和对齐选择打补丁,更合理的后续工作是一起替换算法:既降低对完整矩阵的依赖,也收窄与 GNU 在歧义场景中的差异。

这次没有为了一个更漂亮的“100% 一致”数字马上重写算法。现有实现的关键格式不变量已经由外部参照测试守住,剩余偏差也被量化并写进承诺;Myers 后续可以作为一次边界清楚、收益明确的独立改动来完成。

这次迁移留下的六条经验

这次迁移模块的经验,比“用 Go 重写 diff”更通用。

第一,先分清替换的收益。高亮、PHP prose diff 和 GNU diff 虽然都能迁出 PHP,请求成本和主要收益并不相同。

第二,测试精度取决于输出的消费者。给浏览器看的 HTML 应固定不变量;给解析器吃的格式应尽量整值比较。

第三,兼容对象可能不止一个。正常差异路径以 GNU diff 为准,相同输入路径却必须保留 PHP 的历史行为。

第四,手写用例只覆盖已经想到的问题。当参照实现可以执行时,让测试直接去问它;探索时随机,固化时枚举。

第五,发现不一致后先量化影响。8.6% 的字节差异看起来严重,但拆成 hunk 头、编辑数量和歧义对齐后,真正的风险边界就清楚了。

第六,把规则写成测试并不是终点。架构分层守卫目前仍靠人工维护禁止列表;新增域时忘记更新,它也会静默退化。下一步应让它自动发现 internal/ 下的平台层、契约层与业务域,而不是继续依赖记忆。

最后

Gorge r5 版本,没有带来一个新的容器服务,但是让 diff 的替代实现进入了与高亮相同的代码边界、HTTP 契约和验证体系。

对这种老系统的现代化改造来说,明确区分实现就绪、宿主模块对接与生产切换,比“又拆出了一个微服务”更重要:每一个阶段都应说明自己保留了什么、改变了什么,以及还缺哪一步。

–EOF