本文是“Phorge 现代化改造实战”系列第四篇。前三篇建立了容器基线、运行质量和入口信任;这一篇进入应用扩展边界,例如把语法高亮拆成 Go 服务时,如何控制 fork 的冲突面并保留宿主的隐含语义。
系列导航
- 从没有官方镜像到 Docker Compose 跑起来:建立最小可运行基线;
- 改进容器化的七个细节:补齐权限、持久化、依赖和探活;
- 接入 Stargate:把 Forward Auth 的信任边界做完整;
- 拆分 Gorge:无侵入不等于不碰文件。
写在前面
前三篇文章分别完成了容器化、运行时加固和 Forward Auth 接入。接下来开始处理另一个长期问题:如何在持续跟进活跃上游的同时,把适合独立演进的能力逐步拆出去。
这一次拆的是语法高亮。
Phorge 自带的高亮器只覆盖少数语言,传统方案是安装 Python 与 Pygments。当前容器镜像没有这套运行时,我也不想为了代码着色继续扩大 PHP 镜像,于是把通用语法高亮交给一个常驻的 Go 服务:gorge-render。
服务本身并不复杂:
POST /api/highlight/render
输入:源码 + 语言名
输出:带 Pygments CSS 类名的 HTML
真正需要想清楚的是另一件事:怎么把它接进 Phorge,才不会让这个 fork 每次跟进上游都变成一场手工合并。
这次接入已经分别进入两个同名的发布版本,不再需要以主分支作为基线:
| 项目 | 发布版本 | 这次固定下来的内容 |
|---|---|---|
| Phorge | 2026.09.06-r4 |
Gorge 客户端、高亮引擎、Future 适配、Setup Check、配置项和可选 Compose 叠加层 |
| Gorge | 2026.09.06-r4 |
gorge-render、HTTP 契约、兼容固件、四层测试,以及架构与交付文档 |
Phorge r4 指向提交 dbd631f。相对上一篇使用的 2026.09.06-r3,它增加 2 个提交,改动 12 个文件,增加 914 行、删除 7 行,完整差异见 r3…r4。
Gorge r4 指向提交 d9d5524。核心代码仍以 da522f 为基线,r4 在它之后增加了 1 个纯文档提交:新增 7 份技术文档,共 832 行,把代码里已经存在的架构边界、测试策略、交付方式和已知偏差正式记录下来。
两个 r4 的关系也因此很清楚:Phorge r4 固定“宿主怎样接入”,Gorge r4 固定“服务怎样实现和继续演进”。
这次改动得到的最有价值的经验是:fork 的债务不按改动行数计价,而是应该按冲突面计价;无侵入的也不只是不修改上游文件,更重要的是不破坏上游没有写在接口签名里的语义。
第一层:先减少文件层面的冲突
新增一个独立文件,上游通常不会和你发生内容冲突;直接修改一个活跃的核心文件,只要上游以后碰到附近代码,就可能在每次同步时收一次手工合并费用。
所以,在 fork 里增加功能时,第一个问题不应该是“从哪里改最快”,而应该是:宿主有没有已经留好的扩展点,让这个功能主要通过新增文件完成?
高亮引擎本来就是配置实例化的
我手上有一份更早的项目改动实现,直接修改 PhutilDefaultSyntaxHighlighterEngine,把调用 Pygments 的分支换成调用 Go 服务。功能可以工作,但会产生三个问题:
- 高频变化的核心文件变成永久冲突点;
- 默认行为与自定义行为混在一起,不容易对照;
- 原来的本地高亮和降级路径被挖掉,回滚只能靠重新改代码。
继续往调用方找,可以看到 Phorge 并没有把默认引擎写死:
$engine = PhabricatorEnv::newObjectFromConfig(
'syntax-highlighter.engine');
对应的 syntax-highlighter.engine 是一个 class 类型配置,并声明了:
->setBaseClass('PhutilSyntaxHighlighterEngine')
这意味着,只要新增一个继承 PhutilSyntaxHighlighterEngine 的类,就可以通过配置切换,不需要挖掉默认引擎里的任何一段代码:
./bin/config set syntax-highlighter.engine PhabricatorGorgeSyntaxHighlighterEngine
回滚同样只是一条命令:
./bin/config set syntax-highlighter.engine PhutilDefaultSyntaxHighlighterEngine
这种“配置驱动的类实例化”在老牌 PHP 系统中很常见。动核心文件之前,值得先搜索配置类型、基类和类映射,看看宿主是否已经为替换实现留了入口。
遇到 final,选择组合而不是删掉它
原计划是继承默认引擎,只覆写需要转发给 Gorge 的分支。真正写时发现:
final class PhutilDefaultSyntaxHighlighterEngine
extends PhutilSyntaxHighlighterEngine {
去掉 final 只需要改一行,但这一行会让上游文件永久进入冲突清单。final 也是上游明确表达的设计意图:它不承诺内部实现可以被子类依赖。
当前实现改用组合:
final class PhabricatorGorgeSyntaxHighlighterEngine
extends PhutilSyntaxHighlighterEngine {
private function newDefaultEngine() {
return new PhutilDefaultSyntaxHighlighterEngine();
}
}
新引擎继承稳定的抽象基类,内部创建默认引擎;需要 Gorge 的语言发到远端,其余请求原样委托回去。
组合并不是没有成本。父类原来替你维护的判断,现在要显式保留一份本地语言列表。上游以后给默认引擎增加新的特殊分支,需要回头判断它是否也应该留在本地。
但这种耦合至少是可见的:它集中在新类里,可以通过注释和测试维护,不会变成散落在上游核心文件中的补丁。
不要抢走宿主已经做得更好的事
当前引擎不会把所有代码都交给 Gorge。console、diviner、remarkup、text 等类型继续使用本地实现;如果 XHPAST 可用,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 时,需要同时处理左右两侧:
foreach (new FutureIterator($futures) as $key => $future) {
try {
$highlighted = $future->resolve();
} catch (PhutilSyntaxHighlighterException $ex) {
// ...
}
}
FutureIterator 会一起推进多个请求,底层使用 curl multi。左右两边的高亮可以真正并发,而不是先等旧文件,再等新文件。
早先的实现是在客户端内部调用 resolve(),拿到结果后再用 ImmediateFuture 包一层返回。类型完全正确,接口也没有报错,但并发语义已经消失,每个 diff 都会多付一个远端 RTT。
当前客户端只负责构造请求,不负责等待:
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 已经定义了自己的降级协议:
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 客户端异常重新抛成宿主认识的类型:
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 项目的语言表里存在两组同字母、不同语义的映射:
'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 会让这个判据失效:
"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 的逻辑是:
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 因此采用“开发与发布时的模块化单体,运行时按服务部署”的结构:
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 固定了三项行为:
formatter = html.New(
html.WithClasses(true),
html.PreventSurroundingPre(true),
)
defaultStyle = styles.Get("pygments")
原因分别是:
| 设置 | 原因 |
|---|---|
styles.Get("pygments") |
输出 Phorge 现有样式表认识的类名 |
WithClasses(true) |
使用 class="k",避免内联颜色绕过主题 |
PreventSurroundingPre(true) |
不生成外层 <pre>,由 Phorge 负责行号和 diff 结构 |
服务端路由也被当作兼容契约固定下来:
POST /api/highlight/render
GET /api/highlight/languages
GET /healthz
GET /readyz
API 请求通过 X-Service-Token 鉴权,响应使用 {data,error} 信封:
{
"data": {
"html": "<span class=\"k\">func</span>",
"language": "go"
},
"error": null
}
失败时,客户端先读取 error.code,再把 HTTP 状态作为兜底。ERR_NOT_FOUND、ERR_UNAUTHORIZED、ERR_TOO_LARGE 比一个孤立的 404、401 或 413 更能说明问题。
健康探针刻意不套信封,直接返回:
{"status":"ok"}
因为它们面向容器和负载均衡器,不需要业务 API 的解析逻辑。
这一层同样属于无侵入:虽然高亮已经跨进程,返回的 CSS 类、HTML 边界、错误类型和健康语义仍然与 Phorge 的调用方式对齐。
部署配置也有自己的所有权
第二篇里,我们把 local.json 改成只在不存在时生成,避免每次重启覆盖用户配置。
Gorge 的两个配置却采用相反策略:
gorge.render.uri
gorge.render.token
它们描述的是部署拓扑,而不是用户偏好。服务地址变化或 Token 轮换后,配置应该跟随编排立即变化,不能继续使用持久卷里第一次启动时留下的值。
所以 entrypoint 把 Gorge 配置写在守卫式初始化之外,每次启动幂等更新。环境变量为空时则跳过,不写空值,也不删除已有手工配置。
这条规则比“启动脚本不能覆盖配置”更准确:用户配置应该持久化;部署拓扑应该跟随编排。
两类配置即使最终写进同一个 local.json,也不应该采用相同生命周期。
启动服务和切换流量是两件事
Phorge 新增的 docker-compose.gorge.yml 只负责启动 gorge-render、下发地址和 Token,并等待服务健康:
docker compose -f docker-compose.yml -f docker-compose.gorge.yml up -d
gorge-render 默认不发布宿主机端口,只通过 Compose 网络接受 Phorge 调用。服务起来之后,还要手工切换引擎:
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 也按这个顺序工作:
- 配置了 URI 但服务不可达,只报告不可达;
- 服务健康但引擎尚未切换,再提示“已部署但未使用”;
- 一次只显示最需要处理的一项,而不是为同一个未完成配置挂两条告警。
最后
回过头看,文件层面的无侵入相对容易机械实现:先找扩展点,使用组合绕开 final,把主要逻辑放进新增类,只在注册点留下最小修改。
但是,语义层面的无侵入没有捷径。
你必须先理解宿主为什么返回 Future 而不是结果,为什么专门定义一种高亮异常,为什么语言表保留大小写,为什么高亮之后的 HTML 会进入缓存。答案通常不在接口签名里,而在调用方、错误处理和历史数据中。
Gorge 的模块化也遵循同一个原则:不是为了“微服务”三个字把代码切得越碎越好,而是在保持运行边界的同时,把开发、契约、测试和发布重新收拢。PHP 继续负责页面与业务语义,Go 服务承接边界清楚、适合独立演进的计算,两边通过很薄的 HTTP 契约连接。
所以最终留下的注释,有相当一部分不是解释代码做了什么,而是在解释某个看起来更聪明的写法为什么会坏。那些才是真正容易被后人“顺手优化”掉、也最值得保存的知识。
至此,这个四篇系列完成了一条从“可运行”到“可维护”的路径:第一篇建立容器基线,第二篇修正运行细节,第三篇收紧入口信任,第四篇用 Phorge r4 与 Gorge r4 固定服务拆分的契约,并控制继续跟进上游的长期成本。
下一篇文章,我们继续来设计和拆分应用,让 Phroge 更加现代化。
–EOF