本文使用「署名 4.0 国际 (CC BY 4.0)」许可协议,欢迎转载、或重新修改使用,但需要注明来源。 [署名 4.0 国际 (CC BY 4.0)](https://creativecommons.org/licenses/by/4.0/deed.zh) 本文作者: 苏洋 创建时间: 2026年09月06日 统计字数: 11634字 阅读时间: 24分钟阅读 本文链接: https://soulteary.com/2026/09/06/phorge-modernization-part-4-split-modules-to-gorge.html ----- # Phorge 现代化改造实战(四):拆分模块到 Gorge,无侵入改造不等于不碰文件 本文是“Phorge 现代化改造实战”系列第四篇。前三篇建立了容器基线、运行质量和入口信任;这一篇进入应用扩展边界,例如把语法高亮拆成 Go 服务时,如何控制 fork 的冲突面并保留宿主的隐含语义。 ## 系列导航 1. 从没有官方镜像到 Docker Compose 跑起来:建立最小可运行基线; 2. 改进容器化的七个细节:补齐权限、持久化、依赖和探活; 3. 接入 Stargate:把 Forward Auth 的信任边界做完整; 4. **拆分 Gorge:无侵入不等于不碰文件**。 ## 写在前面 前三篇文章分别完成了容器化、运行时加固和 Forward Auth 接入。接下来开始处理另一个长期问题:如何在持续跟进活跃上游的同时,把适合独立演进的能力逐步拆出去。 这一次拆的是语法高亮。 Phorge 自带的高亮器只覆盖少数语言,传统方案是安装 Python 与 Pygments。当前容器镜像没有这套运行时,我也不想为了代码着色继续扩大 PHP 镜像,于是把通用语法高亮交给一个常驻的 Go 服务:[`gorge-render`](https://github.com/soulteary/gorge)。 服务本身并不复杂: ```text POST /api/highlight/render 输入:源码 + 语言名 输出:带 Pygments CSS 类名的 HTML ``` 真正需要想清楚的是另一件事:怎么把它接进 Phorge,才不会让这个 fork 每次跟进上游都变成一场手工合并。 这次接入已经分别进入两个同名的发布版本,不再需要以主分支作为基线: | 项目 | 发布版本 | 这次固定下来的内容 | | ------ | --------------------------------------------------------------------------------- | ------------------------------------------------------- | | Phorge | [`2026.09.06-r4`](https://github.com/soulteary/phorge/releases/tag/2026.09.06-r4) | Gorge 客户端、高亮引擎、Future 适配、Setup Check、配置项和可选 Compose 叠加层 | | Gorge | [`2026.09.06-r4`](https://github.com/soulteary/gorge/releases/tag/2026.09.06-r4) | `gorge-render`、HTTP 契约、兼容固件、四层测试,以及架构与交付文档 | Phorge r4 指向提交 [`dbd631f`](https://github.com/soulteary/phorge/commit/dbd631f9ae5de10b14a8c454f2bdeebc329dc758)。相对上一篇使用的 [`2026.09.06-r3`](https://github.com/soulteary/phorge/releases/tag/2026.09.06-r3),它增加 2 个提交,改动 12 个文件,增加 914 行、删除 7 行,完整差异见 [r3...r4](https://github.com/soulteary/phorge/compare/2026.09.06-r3...2026.09.06-r4)。 Gorge r4 指向提交 [`d9d5524`](https://github.com/soulteary/gorge/commit/d9d552406b7ea4b9a8c345e7869b0c7278b1f792)。核心代码仍以 [`da522f`](https://github.com/soulteary/gorge/commit/da522f999dafe475111aa246be5e2b82c86e2e7c) 为基线,r4 在它之后增加了 1 个纯文档提交:新增 7 份技术文档,共 832 行,把代码里已经存在的架构边界、测试策略、交付方式和已知偏差正式记录下来。 两个 r4 的关系也因此很清楚:Phorge r4 固定“宿主怎样接入”,Gorge r4 固定“服务怎样实现和继续演进”。 这次改动得到的最有价值的经验是:fork 的债务不按改动行数计价,而是应该按冲突面计价;无侵入的也不只是不修改上游文件,更重要的是不破坏上游没有写在接口签名里的语义。 ## 第一层:先减少文件层面的冲突 新增一个独立文件,上游通常不会和你发生内容冲突;直接修改一个活跃的核心文件,只要上游以后碰到附近代码,就可能在每次同步时收一次手工合并费用。 所以,在 fork 里增加功能时,第一个问题不应该是“从哪里改最快”,而应该是:**宿主有没有已经留好的扩展点,让这个功能主要通过新增文件完成?** ### 高亮引擎本来就是配置实例化的 我手上有一份更早的项目改动实现,直接修改 `PhutilDefaultSyntaxHighlighterEngine`,把调用 Pygments 的分支换成调用 Go 服务。功能可以工作,但会产生三个问题: - 高频变化的核心文件变成永久冲突点; - 默认行为与自定义行为混在一起,不容易对照; - 原来的本地高亮和降级路径被挖掉,回滚只能靠重新改代码。 继续往调用方找,可以看到 Phorge 并没有把默认引擎写死: ```php $engine = PhabricatorEnv::newObjectFromConfig( 'syntax-highlighter.engine'); ``` 对应的 `syntax-highlighter.engine` 是一个 `class` 类型配置,并声明了: ```php ->setBaseClass('PhutilSyntaxHighlighterEngine') ``` 这意味着,只要新增一个继承 `PhutilSyntaxHighlighterEngine` 的类,就可以通过配置切换,不需要挖掉默认引擎里的任何一段代码: ```bash ./bin/config set syntax-highlighter.engine PhabricatorGorgeSyntaxHighlighterEngine ``` 回滚同样只是一条命令: ```bash ./bin/config set syntax-highlighter.engine PhutilDefaultSyntaxHighlighterEngine ``` 这种“配置驱动的类实例化”在老牌 PHP 系统中很常见。动核心文件之前,值得先搜索配置类型、基类和类映射,看看宿主是否已经为替换实现留了入口。 ### 遇到 `final`,选择组合而不是删掉它 原计划是继承默认引擎,只覆写需要转发给 Gorge 的分支。真正写时发现: ```php final class PhutilDefaultSyntaxHighlighterEngine extends PhutilSyntaxHighlighterEngine { ``` 去掉 `final` 只需要改一行,但这一行会让上游文件永久进入冲突清单。`final` 也是上游明确表达的设计意图:它不承诺内部实现可以被子类依赖。 当前实现改用组合: ```php final class PhabricatorGorgeSyntaxHighlighterEngine extends PhutilSyntaxHighlighterEngine { private function newDefaultEngine() { return new PhutilDefaultSyntaxHighlighterEngine(); } } ``` 新引擎继承稳定的抽象基类,内部创建默认引擎;需要 Gorge 的语言发到远端,其余请求原样委托回去。 组合并不是没有成本。父类原来替你维护的判断,现在要显式保留一份本地语言列表。上游以后给默认引擎增加新的特殊分支,需要回头判断它是否也应该留在本地。 但这种耦合至少是可见的:它集中在新类里,可以通过注释和测试维护,不会变成散落在上游核心文件中的补丁。 ### 不要抢走宿主已经做得更好的事 当前引擎不会把所有代码都交给 Gorge。`console`、`diviner`、`remarkup`、`text` 等类型继续使用本地实现;如果 XHPAST 可用,PHP 也留在本地: ```php if ($language == 'php' && PhutilXHPASTBinary::isAvailable()) { return false; } ``` XHPAST 是真正的 PHP 解析器,不只是通用词法器。它更理解 PHP 结构,也不需要一次网络请求往返。 无侵入还有一层意思:外部服务只接管自己明显更擅长的能力,不要为了架构整齐,把宿主已经做得更好的事情也搬出去。 ### 到底修改动了多少上游文件 对比提交,当前差异包含 6 个新增文件: | 新增文件 | 用途 | | --------------------------------------------- | --------------- | | `PhabricatorGorgeRenderClient.php` | HTTP 客户端与响应信封解析 | | `PhabricatorGorgeSyntaxHighlighterEngine.php` | 引擎选择和本地降级 | | `PhabricatorGorgeSyntaxHighlighter.php` | 单次高亮请求 | | `PhabricatorGorgeHighlightFuture.php` | 异步结果适配 | | `PhabricatorGorgeSetupCheck.php` | 可达性与启用状态检查 | | `docker-compose.gorge.yml` | 可选服务编排 | 同时,也修改了 6 个现有文件,分为两类: | 类型 | 文件 | 原因 | | -------- | ------------------------------------------------ | -------------------------------------------- | | 上游注册点 | `PhabricatorSyntaxHighlightingConfigOptions.php` | 声明 `gorge.render.uri` 与 `gorge.render.token` | | 上游生成文件 | `src/__phutil_library_map__.php` | 注册新增的五个 PHP 类 | | 上游体验逻辑 | `PhabricatorPygmentSetupCheck.php` | Gorge 已配置时不再提示安装 Pygments | | fork 部署层 | `docker/entrypoint.sh` | 把服务地址和 Token 写入本地配置 | | fork 文档层 | `.env.example` | 声明 Gorge 环境变量 | | fork 文档层 | `DOCKER.md` | 记录部署、回滚与排障方法 | 所以,并不是“只修改两个上游文件”就能实现拆分原始项目能力到微服务中。 **功能主路径必须修改的上游位置包含了配置声明和类映射,以及额外修改了一个 SetupCheck,用来消除已经失效的 Pygments 建议。** 虽然后者不是必需修改的内容,但如果不改,Config 页面会长期保留一条“请安装 Pygments”的烦人提示。以及,如果我们不在 `PhabricatorApplicationConfigOptions` 中声明,`PhabricatorExtraConfigSetupCheck` 会把 Gorge 的键判断为 `Unknown Configuration Option`;不重新生成 `__phutil_library_map__.php`,新增类则会直接变成 `Class not found`。 当我们判断维护 fork 版本软件的成本时,最好能够同时列出“必须修改”和“为了体验修改”,将来上游产生冲突时才知道哪些补丁可以优先放弃。 ## 第二层:文件没冲突,不代表语义没被破坏 到这里为止,做的还是相对机械的事情:找到扩展点、绕开 `final`、把主要实现放进新增文件、统计现有文件的改动面。 真正花时间的是另一件事:你没有修改上游文件,不代表你没有破坏上游机制。 成熟系统里有大量没有写在接口签名上的约定。你返回了正确的类型,单元测试通过,页面正常渲染,日志里没有错误,但某个关键机制已经被悄悄架空。 这次一共撞上了三类。 ## 约定一:返回 Future 不只是为了满足类型 Phorge 的高亮接口返回 Future,而不是直接返回高亮结果。 原因要到调用方里才能看出来。Differential 渲染 diff 时,需要同时处理左右两侧: ```php foreach (new FutureIterator($futures) as $key => $future) { try { $highlighted = $future->resolve(); } catch (PhutilSyntaxHighlighterException $ex) { // ... } } ``` `FutureIterator` 会一起推进多个请求,底层使用 curl multi。左右两边的高亮可以真正并发,而不是先等旧文件,再等新文件。 早先的实现是在客户端内部调用 `resolve()`,拿到结果后再用 `ImmediateFuture` 包一层返回。类型完全正确,接口也没有报错,但并发语义已经消失,每个 diff 都会多付一个远端 RTT。 当前客户端只负责构造请求,不负责等待: ```php public function newHighlightFuture($source, $language) { $future = $this->newRequestFuture($this->getHighlightURI()) ->setMethod('POST') ->addHeader('Content-Type', 'application/json'); $future->setData(phutil_json_encode( array( 'source' => $source, 'language' => $language, ))); return $future; } ``` 响应解析则放进 `FutureProxy::didReceiveResult()`。这样,调用方拿到的仍然是尚未 resolve 的 Future,Phorge 原有调度机制可以继续工作。 这类退化很难靠静态检查发现,只能测量。使用两个 522KB 的 Rust 文件测试时,串行实现的两个请求首尾相接,相差约 706ms;保留 Future 之后,两个请求的服务端起始时间只差约 17ms。 接口的真正契约不只是“返回 Future 类型”,而是“返回一个尚未执行完、可以与其他 Future 一起调度的对象”。 ## 约定二:异常类型也是协议的一部分 早期实现遇到 Gorge 不可用时,自己捕获 `Exception`、写日志,再返回默认高亮结果。听起来很稳健,但 Phorge 已经定义了自己的降级协议: ```php final public function highlightSource($language, $source) { try { return $this->getHighlightFuture($language, $source)->resolve(); } catch (PhutilSyntaxHighlighterException $ex) { return id(new PhutilDefaultSyntaxHighlighter()) ->getHighlightFuture($source) ->resolve(); } } ``` `DifferentialChangesetParser` 捕获同一个异常时,还会设置 `highlightErrors`,在页面上显示高亮失败提示。 如果适配层自己把异常吞掉,就等于把宿主的“可见降级”改成“静默降级”:页面依然打开,代码只是突然没有颜色,运维和用户都不知道发生了什么。 当前的 Future 适配器会把 Gorge 客户端异常重新抛成宿主认识的类型: ```php try { $data = PhabricatorGorgeRenderClient::parseResponseEnvelope( $this->uri, $result); } catch (Exception $ex) { throw new PhutilSyntaxHighlighterException($ex->getMessage()); } ``` 只要遵守这个异常契约,就可以免费复用 Phorge 已经写好的默认高亮、Differential 告警和页面容错逻辑。 这也是为什么“捕获所有异常,保证页面不报错”的策略通常不是更稳妥的实现。异常类型本身可能就是宿主用来选择降级路径的控制信号。 ## 约定三:跨进程复制的数据表带着历史语义 Gorge 中有一张语言别名表,来源是 Phorge 的 Pygments 映射。它负责把数据库和文件名里的历史标识,例如 `adb`、`cxx`,翻译成 Chroma 能识别的 lexer。 因为两张表存在不一致,同一个文件会在两个后端中选择不同的语言,生成不同 HTML 结果。即便,请求仍然是 200,页面也不会报错。 ### 大小写不能提前抹掉 Phorge 从文件名推导语言时没有统一转成小写,所以 `foo.R` 真的会以 `R` 到达高亮器。在 PHP 项目的语言表里存在两组同字母、不同语义的映射: ```php 'R' => 'splus', 'r' => 'rebol', 'S' => 'splus', 's' => 'gas', ``` 早期 Go 实现一开始就调用了 `strings.ToLower()`,于是: - `.R` 被当作 `r`,落到 REBOL; - `.S` 被当作 `s`,落到 GAS 汇编。 当前实现先按原始字符串查表,未命中时才尝试小写形式。契约测试也成对覆盖 `R/r`、`S/s`;如果只测大写一侧,而没有测试小写的内容,这个 bug 就容易蒙混过关。 ### “能够找到 lexer”不是正确判据 对齐映射表时,最初采用的判据是:PHP 写的目标名,只要 Chroma 的 `lexers.Get()` 返回非空,就可以照抄。 `.sv` 会让这个判据失效: ```go "sv": "verilog", // PHP: "v", which is V/vlang in Chroma, not Verilog ``` `.sv` 指的是 SystemVerilog。 Pygments 使用 `v` 表示 Verilog,但 Chroma 中的 `v` 是 V/vlang,一门完全不同的语言。`lexers.Get("v")` 不仅非空,还能正常输出结果,只是结果语义完全错误。 因此测试不能只判断返回值是不是 nil,而要比较最终 lexer 的身份,也就是比较 `Config().Name`。而 `antlr-ruby`、`ragel-em` 这类 Chroma 根本不认识的名字,反而更容易在测试时暴露。 真正危险的是“存在、能运行、但含义错误”的对象。 ### 兼容不是两张表逐字相等 Gorge 的兼容文档最初要求 PHP 与 Go 的别名表双向同步,后来也做了修正。 PHP 的逻辑是: ```php idx($map, $language, $language) ``` 未命中时,它会把语言名原样传给 Pygments。因此真正需要守住的是单向约束: - PHP 表中存在的非恒等映射,Go 侧必须提供等价行为; - Go 侧可以额外支持 TypeScript、Rust、Kotlin 等现代语言别名; - 新增 Go 独有别名前,要确认 Pygments 原样接收时会落到同一个 lexer; - 对齐的是最终语言语义,不是两张表的行数。 当前 PHP 表有 166 条非恒等映射,Go 表是它的超集。兼容测试应该保证 PHP → Go 这一方向不丢失,而不必为了表面相等删掉 Go 侧安全的扩展。 ## Gorge 如何避免变成十几个更难维护的小仓库 把高亮拆成 HTTP 服务之后,还有另一个风险:原本 PHP 里的复杂度,被换成一组仓库、CI、依赖和发布流程的复杂度。 Gorge 因此采用“开发与发布时的模块化单体,运行时按服务部署”的结构: ```text go/ ├── cmd/gorge-render/ ├── internal/contracts/ ├── internal/platform/ └── internal/render/ api/openapi/render.yaml compat/phorge/README.md deploy/compose/ tests/contract/render/ ``` 目前项目只有一个二进制 `gorge-render`。 语法高亮属于 render 域,后续 diff 等服务也会进入同一个进程,而不是继续维护3月时创建的 `gorge-highlight`、`gorge-diff` 等一批只有少量代码的小仓库。 我将项目的 Go 侧分成三层: - `internal/platform`:HTTP、鉴权、健康检查和配置; - `internal/contracts`:线上数据结构; - `internal/render`:高亮与后续 diff 业务。 `platform` 不允许反向依赖业务域,并由分层测试守住。所有二进制共享一个 Go module 和一份 Dockerfile,通过 `SERVICE` 构建参数选择入口。新增服务时,不需要再复制一套日志、错误处理、健康检查和发布工作流。这个结构保留了运行边界,同时收回了开发与发布的碎片化成本。 服务是否独立,不等于仓库必须一一对应。 Gorge r4 的文档里,还给出了一个很有意思的代码比例:生产代码 962 行,测试代码 1565 行,此外还有 12 份与语言无关的契约固件和一份 e2e 冒烟脚本。 测试代码比生产代码多,并不是因为高亮算法本身复杂,而是这次迁移最昂贵的部分恰好不在算法,而在兼容边界。这些内容可以被拆成四层验证: | 层次 | 主要守住什么 | | ------ | ------------------------------------------ | | 单元测试 | 配置、鉴权、HTTP 错误处理、高亮和别名解析 | | 分层测试 | `platform` 不得反向依赖业务域和契约层 | | 契约固件 | Go 服务、未来的 PHP runner 与 OpenAPI 对同一线上行为达成一致 | | e2e 冒烟 | 真实二进制、监听端口、容器探针和鉴权链路 | 其中最高价值的是分层测试。 使用 `go/parser` 检查 `internal/platform` 的 import,把“平台层不能依赖业务域”从 README 里的一句话变成 `go test ./...` 会失败的约束检查。 架构文档负责解释设计方向,测试负责阻止一次随手 import 把架构毁掉。 ## HTTP 契约也要复用宿主的表现层 Gorge 使用 Chroma 做高亮,但没有让它自由决定输出格式。formatter 固定了三项行为: ```go formatter = html.New( html.WithClasses(true), html.PreventSurroundingPre(true), ) defaultStyle = styles.Get("pygments") ``` 原因分别是: | 设置 | 原因 | | ----------------------------- | ------------------------------------ | | `styles.Get("pygments")` | 输出 Phorge 现有样式表认识的类名 | | `WithClasses(true)` | 使用 `class="k"`,避免内联颜色绕过主题 | | `PreventSurroundingPre(true)` | 不生成外层 `
`,由 Phorge 负责行号和 diff 结构 |

服务端路由也被当作兼容契约固定下来:

```text
POST /api/highlight/render
GET  /api/highlight/languages
GET  /healthz
GET  /readyz
```

API 请求通过 `X-Service-Token` 鉴权,响应使用 `{data,error}` 信封:

```json
{
  "data": {
    "html": "func",
    "language": "go"
  },
  "error": null
}
```

失败时,客户端先读取 `error.code`,再把 HTTP 状态作为兜底。`ERR_NOT_FOUND`、`ERR_UNAUTHORIZED`、`ERR_TOO_LARGE` 比一个孤立的 404、401 或 413 更能说明问题。

健康探针刻意不套信封,直接返回:

```json
{"status":"ok"}
```

因为它们面向容器和负载均衡器,不需要业务 API 的解析逻辑。

这一层同样属于无侵入:虽然高亮已经跨进程,返回的 CSS 类、HTML 边界、错误类型和健康语义仍然与 Phorge 的调用方式对齐。

## 部署配置也有自己的所有权

第二篇里,我们把 `local.json` 改成只在不存在时生成,避免每次重启覆盖用户配置。

Gorge 的两个配置却采用相反策略:

```text
gorge.render.uri
gorge.render.token
```

它们描述的是部署拓扑,而不是用户偏好。服务地址变化或 Token 轮换后,配置应该跟随编排立即变化,不能继续使用持久卷里第一次启动时留下的值。

所以 entrypoint 把 Gorge 配置写在守卫式初始化之外,每次启动幂等更新。环境变量为空时则跳过,不写空值,也不删除已有手工配置。

这条规则比“启动脚本不能覆盖配置”更准确:**用户配置应该持久化;部署拓扑应该跟随编排。**

两类配置即使最终写进同一个 `local.json`,也不应该采用相同生命周期。

## 启动服务和切换流量是两件事

Phorge 新增的 `docker-compose.gorge.yml` 只负责启动 `gorge-render`、下发地址和 Token,并等待服务健康:

```bash
docker compose -f docker-compose.yml -f docker-compose.gorge.yml up -d
```

`gorge-render` 默认不发布宿主机端口,只通过 Compose 网络接受 Phorge 调用。服务起来之后,还要手工切换引擎:

```bash
docker compose exec phorge /opt/phorge/phorge/bin/config set syntax-highlighter.engine PhabricatorGorgeSyntaxHighlighterEngine

docker compose exec phorge /opt/phorge/phorge/bin/cache purge --all
```

“部署服务”和“切换流量”被刻意拆成两步。这样可以先验证 `/healthz`、Token 和语言列表,再让真实页面开始调用;回滚时也只需要把引擎切回默认类。

缓存清理不能省。Phorge 缓存的是高亮后的 HTML,不是源码。只切引擎不清缓存,已经看过的 Paste 和 Differential 会继续返回旧 HTML,看起来像配置没有生效。

`PhabricatorGorgeSetupCheck` 也按这个顺序工作:

1. 配置了 URI 但服务不可达,只报告不可达;
2. 服务健康但引擎尚未切换,再提示“已部署但未使用”;
3. 一次只显示最需要处理的一项,而不是为同一个未完成配置挂两条告警。

## 最后

回过头看,文件层面的无侵入相对容易机械实现:先找扩展点,使用组合绕开 `final`,把主要逻辑放进新增类,只在注册点留下最小修改。

但是,语义层面的无侵入没有捷径。

你必须先理解宿主为什么返回 Future 而不是结果,为什么专门定义一种高亮异常,为什么语言表保留大小写,为什么高亮之后的 HTML 会进入缓存。答案通常不在接口签名里,而在调用方、错误处理和历史数据中。

Gorge 的模块化也遵循同一个原则:不是为了“微服务”三个字把代码切得越碎越好,而是在保持运行边界的同时,把开发、契约、测试和发布重新收拢。PHP 继续负责页面与业务语义,Go 服务承接边界清楚、适合独立演进的计算,两边通过很薄的 HTTP 契约连接。

所以最终留下的注释,有相当一部分不是解释代码做了什么,而是在解释某个看起来更聪明的写法为什么会坏。那些才是真正容易被后人“顺手优化”掉、也最值得保存的知识。

至此,这个四篇系列完成了一条从“可运行”到“可维护”的路径:第一篇建立容器基线,第二篇修正运行细节,第三篇收紧入口信任,第四篇用 Phorge r4 与 Gorge r4 固定服务拆分的契约,并控制继续跟进上游的长期成本。

下一篇文章,我们继续来设计和拆分应用,让 Phroge 更加现代化。

--EOF