本文是“Phorge 现代化改造实战”系列第四篇。前三篇建立了容器基线、运行质量和入口信任;这一篇进入应用扩展边界,例如把语法高亮拆成 Go 服务时,如何控制 fork 的冲突面并保留宿主的隐含语义。

系列导航

  1. 从没有官方镜像到 Docker Compose 跑起来:建立最小可运行基线;
  2. 改进容器化的七个细节:补齐权限、持久化、依赖和探活;
  3. 接入 Stargate:把 Forward Auth 的信任边界做完整;
  4. 拆分 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。consoledivinerremarkuptext 等类型继续使用本地实现;如果 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.urigorge.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 映射。它负责把数据库和文件名里的历史标识,例如 adbcxx,翻译成 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/rS/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-rubyragel-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-highlightgorge-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_FOUNDERR_UNAUTHORIZEDERR_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 也按这个顺序工作:

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

最后

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

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

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

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

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

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

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

–EOF