本文使用「署名 4.0 国际 (CC BY 4.0)」许可协议,欢迎转载、或重新修改使用,但需要注明来源。 [署名 4.0 国际 (CC BY 4.0)](https://creativecommons.org/licenses/by/4.0/deed.zh) 本文作者: 苏洋 创建时间: 2026年09月08日 统计字数: 8771字 阅读时间: 18分钟阅读 本文链接: https://soulteary.com/2026/09/08/phorge-modernization-part-14-unified-conduit-api-gateway.html ----- # Phorge 现代化改造实战(十四):为 Go 服务建立统一的 Phorge API 入口,网关可以换框架,Conduit 协议不能变 本文是“Phorge 现代化改造实战”系列第十四篇。上一篇完成了 Phorge 与六项外部服务的整体联调;这一篇转向相反的调用方向,给 Go 服务增加一条统一、受控的 Phorge API 入口。 ## 系列导航 1. [从没有官方镜像到 Docker Compose 跑起来:建立最小可运行基线](https://soulteary.com/2026/09/06/phorge-modernization-part-1-from-no-official-image-to-docker-compose.html); 2. [改进容器化的七个细节:补齐权限、持久化、依赖和探活](https://soulteary.com/2026/09/06/phorge-modernization-part-2-seven-containerization-details.html); 3. [接入 Stargate:把 Forward Auth 的信任边界做完整](https://soulteary.com/2026/09/06/phorge-modernization-part-3-stargate-forward-auth-trust-boundary.html); 4. [拆分模块到 Gorge:无侵入改造不等于不碰文件](https://soulteary.com/2026/09/06/phorge-modernization-part-4-split-modules-to-gorge.html); 5. [替换 diff 子进程:兼容不等于逐字一致](https://soulteary.com/2026/09/07/phorge-modernization-part-5-replace-diff-subprocess.html); 6. [替换实时通知服务:为什么 HTTP 501 反而表示正常](https://soulteary.com/2026/09/07/phorge-modernization-part-6-replace-realtime-notification-service.html); 7. [迁移邮件服务:先分清哪些失败不该重试](https://soulteary.com/2026/09/07/phorge-modernization-part-7-migrate-mail-service.html); 8. [迁移搜索服务:写进索引不等于搜得到](https://soulteary.com/2026/09/07/phorge-modernization-part-8-migrate-search-service.html); 9. [迁移文件存储:写得进去也要读得回来](https://soulteary.com/2026/09/07/phorge-modernization-part-9-migrate-file-storage.html); 10. [迁移 Webhook 投递服务:先解决重复投递](https://soulteary.com/2026/09/07/phorge-modernization-part-10-migrate-webhook-delivery.html); 11. [升级 Gorge 的 HTTP 框架:接口没变,行为也不能变](https://soulteary.com/2026/09/08/phorge-modernization-part-11-upgrade-http-framework.html); 12. [兼容 Elasticsearch 5、6、7:版本配置决定整个索引结构](https://soulteary.com/2026/09/08/phorge-modernization-part-12-elasticsearch-version-compatibility.html); 13. [联调六项外部服务:容器在运行,不代表业务已经切换](https://soulteary.com/2026/09/08/phorge-modernization-part-13-integrate-six-external-services.html); 14. **为 Go 服务建立统一的 Phorge API 入口:网关可以换框架,Conduit 协议不能变;** 15. [用 Go 接管 Phorge 工作队列:如何避免新旧消费者互相抢任务](https://soulteary.com/2026/09/08/phorge-modernization-part-15-take-over-work-queue-with-go.html)。 ## 写在前面 前面几篇迁移的方向大致相同:Phorge 的 PHP 代码通过 HTTP 调用 Gorge,把高亮、搜索、发信、文件存储等能力交给 Go 服务。 Conduit 网关的调用方向相反。Conduit 是 Phorge 自带的 API 协议;worker 等 Go 服务先经过统一的认证和限流入口,再调用原有的 Conduit 方法。将来如果增加审计或访问策略,也只需要在这一处完成。 它接收其它 Go 服务发来的 `ANY /api/:method`,完成服务令牌认证和按 IP 限流,再把请求转发给 Phorge 自己的 Conduit API。也就是说,过去是“PHP 调 Go”,现在多出了一条“Go 经网关调 PHP”的回路。 ```mermaid flowchart LR W["gorge-worker / 其它 Go 服务"] -->|"Conduit 请求"| G["gorge-conduit :8150"] G -->|"/api/{method}"| P["Phorge PHP :80"] P -->|"Conduit 响应"| G G -->|"原样返回"| W ``` 这个服务的代码量不大,却同时涉及三组约束:融入 Fiber v3 单仓库时要保留 Conduit 响应格式;Phorge 侧接线完成后,仍要确认各调用方是否已经切流;发布时还要核对源码、构建任务和容器镜像是否属于同一个版本。 ### 先厘清发布版本:Phorge r2 接线,Gorge r3 才补上服务 [Phorge 的 `r1...r2` 对比](https://github.com/soulteary/phorge/compare/2026.09.08-r1...2026.09.08-r2)里已经包含 Conduit 接入:新增 PHP 客户端、配置项、Setup Check、Compose 服务与 entrypoint 下发逻辑。对应的核心提交是 [`feat(conduit): use gorge-conduit`](https://github.com/soulteary/phorge/commit/fe5fcd975c767be74600081c07709ef267abd8b6)。随后,发布的 Gorge [`2026.09.08-r3`](https://github.com/soulteary/gorge/releases/tag/2026.09.08-r3) 通过 [PR #13](https://github.com/soulteary/gorge/pull/13) 把实现、测试、契约、OpenAPI、Compose 和文档一起迁进 monorepo。Phorge 的 [`2026.09.08-r3`](https://github.com/soulteary/phorge/releases/tag/2026.09.08-r3) 则没有新增代码,[`r2...r3`](https://github.com/soulteary/phorge/compare/2026.09.08-r2...2026.09.08-r3) 完全相同。它的作用是与 Gorge `r3` 对齐版本名。 截至这两个 `r3`,项目状态情况: | 状态 | 公开版本中的事实 | 能得出的结论 | | ----------------- | -------------------- | --------------------------- | | Phorge 客户端与编排接线 | `r2` 已完成,`r3` 沿用 | Phorge 已能配置并调用兼容网关 | | Gorge monorepo 源码 | `r3` 已完成 | Fiber 版实现已经进入可复现 tag | | Gorge 容器镜像 | `r3` 构建矩阵仍漏掉 conduit | 不能仅凭 release 推断 Fiber 镜像已产出 | | 实际业务切流 | 仍需调用方配置 | 有服务和客户端,不等于 worker 已经过网关 | 下面的 Go 侧内容已经可以直接从 Gorge `r3` 复现。 ### 为什么需要一层 Conduit 网关 Conduit 是 Phorge 对外和对内都在使用的 API。`gorge-worker` 这类服务执行复杂任务时,仍会把一部分业务逻辑委托回 PHP;如果每个 Go 服务都直接连接 Phorge,那么每个调用方都要各自处理认证、限流、超时和将来的审计。 网关把这些横切能力收敛到一个入口: - 调用方只知道网关地址和 `X-Service-Token`; - 网关统一限制单个来源的请求速率; - Phorge 仍然负责 Conduit 方法的业务语义; - 将来增加审计、指标或访问策略时,不必修改所有消费者。 网关沿用现有的 Conduit API,没有再定义第二套 API。 **网关应尽量保持“无聊”:路径、状态码、响应头和响应体沿用 Conduit 原样,只补充边界能力。** ## 从独立 Echo 服务迁入 Fiber monorepo 几个月前,公开的独立仓库 [`soulteary/gorge-conduit`](https://github.com/soulteary/gorge-conduit) 使用 Echo v4,结构是 `cmd/server`、`internal/config`、`internal/gateway` 和 `internal/httpapi`。它已经具备反向代理、令牌认证、令牌桶限流和健康检查,但与现在的 Gorge monorepo 处在两套世界里。 迁入时有三种选择:保留 Echo 原样搬进来;目录对齐、反代仍完全沿用 `net/http`;或者把 HTTP 外壳重写为 Fiber v3,并复用 monorepo 的启动、配置和健康检查。 我最后选择第三种。 选择第三种方案,主要考虑 monorepo 的统一运行纪律:一个 `go.mod`、一套优雅关闭、同一种健康探针、同一次 `go test ./...`,再由分层测试禁止平台层反向依赖业务域。为了两百多行代码长期保留第二个 Web 框架,会持续增加依赖升级和排障成本。 最终的项目目录结构是: ```text go/ ├── cmd/gorge-conduit/main.go ├── internal/conduit/ │ ├── config.go │ ├── http.go │ ├── proxy.go │ └── ratelimit.go └── internal/contracts/conduit.go ``` 启动层保持尽量薄,只负责装配配置、代理和限流器,再交给 `platform/httpx` 启停。配置则优先读取带域前缀的新名字,同时用旧变量兜底: ```go func LoadFromEnv() *Config { return &Config{ Base: config.LoadBase(DefaultListenAddr), UpstreamURL: config.EnvStr( DefaultUpstreamURL, "GORGE_CONDUIT_UPSTREAM_URL", "UPSTREAM_URL", ), ProxyTimeoutSec: config.EnvInt( DefaultProxyTimeoutSec, "GORGE_CONDUIT_PROXY_TIMEOUT_SEC", "PROXY_TIMEOUT_SEC", ), RateLimitRPS: config.EnvInt( DefaultRateLimitRPS, "GORGE_CONDUIT_RATE_LIMIT_RPS", "RATE_LIMIT_RPS", ), } } ``` 这样旧部署不用一次性改完全部环境变量,新部署又不会继续把 `UPSTREAM_URL`、`RATE_LIMIT_RPS` 这类过于宽泛的名字带进共享进程环境。 端口继续沿用 `:8150`。服务自身的限流默认值是 `RPS=0`,即默认关闭;Phorge `r2` 的叠加编排主动设置为 `RPS=20`、`burst=40`。这两件事不矛盾:前者是二进制的保守默认值,后者是一个具体部署的策略。 ## 最重要的例外:不能使用 `{data,error}` Gorge 其它 JSON API 都使用统一信封: ```json { "data": {}, "error": null } ``` Conduit 自己已经有一套协议: ```json { "result": null, "error_code": "ERR-CONDUIT-PROXY", "error_info": "Upstream request failed." } ``` 如果代理成功后再包一层 `{data,error}`,所有现有 Conduit 客户端都会在顶层找不到 `result`、`error_code` 和 `error_info`。因此成功响应必须逐字节透传。 更容易忽略的是:网关自己产生的失败也必须使用 Conduit 信封。否则同一个 `/api/:method` 会根据失败发生的位置返回两种错误协议:上游业务错误说 Conduit,鉴权或限流错误却说 Gorge。客户端不仅要判断错误码,还得先猜这一包 JSON 属于谁。 因此网关定义了四个自己的错误码: | HTTP 状态 | Conduit 错误码 | 触发条件 | | ------- | ------------------- | ------------------- | | 400 | `ERR-CONDUIT-CORE` | 缺少方法名 | | 401 | `ERR-CONDUIT-AUTH` | Service Token 缺失或错误 | | 429 | `ERR-RATE-LIMIT` | 令牌桶耗尽 | | 502 | `ERR-CONDUIT-PROXY` | 上游无法连接或请求失败 | 这是一条有边界、有测试的协议例外,和 file-storage 返回文件裸字节是同一类问题:统一内部实现时,仍要保护外部协议。 ## Fiber v3 的两个路由陷阱 ### 限流不能挂在 group 级 限流器需要读取 `:method`,才能放行默认豁免的 `conduit.ping` 和 `conduit.getcapabilities`。问题是 Fiber 的 group 级 `Use` 会在子路由绑定参数之前执行,此时 `c.Params("method")` 还是空字符串。 所以鉴权可以挂在 `/api` group 上,限流必须成为具体路由的 handler: ```go func RegisterRoutes(app fiber.Router, deps *Deps) { g := app.Group("/api") g.Use(tokenAuth(deps.Token)) proxy := func(c fiber.Ctx) error { return deps.Proxy.Handle(c) } if deps.RateLimiter != nil { g.All("/:method", deps.RateLimiter.Middleware(), proxy) } else { g.All("/:method", proxy) } g.All("", proxy) g.All("/", proxy) } ``` 中间件顺序也有实际含义:先鉴权,再消耗限流桶。未经认证的洪水应该尽早被拒绝,不能先替它占掉合法客户端的配额。 ### `/api` 必须显式注册 如果只注册 `/:method`,`/api` 和 `/api/` 会落进平台的默认 404,于是又得到 `{data,error}`。这两个无方法名路径必须显式交给代理 handler,让 `method == ""` 产生 `ERR-CONDUIT-CORE`。 这类问题很适合写成契约测试,因为从 HTTP 状态看都是 4xx,只有响应体形状错了;人工冒烟很容易只看状态码就放过去。 ## 反向代理:Fiber 外壳,`net/http` 内核 对外 handler 改成 Fiber 后,内部仍可继续使用标准库。请求上游时沿用 `net/http.Client`,可以直接获得成熟的超时、上下文和重定向控制。这里有四个实现细节值得单独保留: 1. 上游 3xx 不自动追踪。重定向本身也是 Conduit 客户端应看到的响应,网关不能擅自把它变成另一次请求。 2. 请求与响应两侧都过滤 RFC 7230 定义的 hop-by-hop 头,避免把只属于某一跳的连接状态继续传下去。 3. 注入 `X-Forwarded-For`、`X-Forwarded-Proto` 和 `X-Conduit-Gateway: go-conduit`,让上游能识别真实来源与网关路径。 4. Conduit 的小型 JSON 响应先读入内存,再用 `Send` 写给 Fiber,避免把 `resp.Body` 直接交给 `SendStream`。 最后一点是 fasthttp 的生命周期陷阱:流式 body 可能在 handler 返回后才被读取,但上游响应体又必须及时关闭以复用连接。如果在 handler 退出时关闭 `resp.Body`,异步读取可能拿到截断内容。Conduit 响应通常只是很小的 JSON,缓冲的成本远低于偶发半包的排障成本。 ## 鉴权只能复用纪律,不能复用响应 平台已有 `auth.Token` 中间件,但它失败时调用 `httpx.Fail`,天然会写 Gorge 信封,所以这里不能直接拿来用。 认证规则继续沿用平台已有的安全纪律: - 优先读取 `X-Service-Token`,查询参数 `token` 只作兼容兜底; - 服务端未配置 token 时不启用认证; - 使用常量时间比较,避免普通字符串比较暴露时序差异; - 仅把失败响应换成 Conduit 的 `ERR-CONDUIT-AUTH`。 这里复用的是规则,响应代码需要单独适配。协议输出不同时,强行复用一个会写响应的中间件,比增加十几行薄适配器更危险。 限流器则整体保留原实现:按 IP 的惰性令牌桶、最多十万个 visitor、十分钟无访问清理、`Stop()` 由 `sync.Once` 保证幂等。`conduit.ping` 与 `conduit.getcapabilities` 默认豁免,因为 Phorge 和 Arcanist 会频繁调用它们进行探测和能力发现;把这两个方法限掉,会让一个健康网关在客户端眼里间歇性失联。 ## 其他 ### 测试协议关系,避免只覆盖处理器 其它 Gorge 域可以直接复用 `contracttest.Run`,Conduit 不行。共享 runner 假设只有一个 app,并按 `{data,error}` 的带点路径断言;Conduit 固件却需要逐例配置 app:透传场景要挂假上游,限流场景要建立容量很小的桶,响应字段还是扁平的。 固件格式继续沿用共享的 `contracttest.Fixture`,只增加一个 Conduit driver 解释所需字段。四个固件分别钉住: | 固件 | 守住的协议不变量 | | --------------------- | -------------------------------------------------------- | | `unauthorized.json` | 401、`ERR-CONDUIT-AUTH`、`result:null`,且不存在 `data`/`error` | | `missing-method.json` | `/api` 返回 Conduit 400,不掉进平台 404 | | `rate-limited.json` | 429 在访问上游之前发生 | | `proxy-pass.json` | 路径正确,上游状态、头和 Conduit body 原样返回 | 同时,monorepo 的 `layering_test.go` 还要把 `internal/conduit` 加进禁止反向依赖的前缀表。这个测试有个容易被误解的弱点:新增域如果没有手工登记,测试会静默漏检。因此“增加领域目录”和“补充分层禁令”必须被当成同一个提交动作。 ## worker 具备切流能力,但切流仍是部署动作 公开的 [`gorge-worker`](https://github.com/soulteary/gorge-worker) 已经把 Conduit 客户端写成“可直连 Phorge,也可连接网关”。实际读取的环境变量是: ```bash CONDUIT_URL=http://gorge-conduit:8150 CONDUIT_TOKEN=your-service-token ``` 应使用 `CONDUIT_URL`;`GO_CONDUIT_URL` 和 `GORGE_CONDUIT` 都不会生效。当 `CONDUIT_URL` 为空时,委托型 handler 不会注册;指向 Phorge 时表示直连,指向 `gorge-conduit:8150` 时才会经过网关。 这也是本系列一直在强调的状态分层: 1. 网关代码实现完成; 2. 网关进入可复现的发布 tag; 3. Phorge 或 worker 拥有客户端; 4. 部署配置把具体调用方指向网关; 5. 真实请求通过网关完成一次往返。 这些验证缺一不可,否则只能确认“可以使用”,还无法确认“已经接管”。 ## 最后 这个两百多行的网关仍要同时守住四条边界:运行时、协议、调用和发布。 --EOF