本文是“Phorge 现代化改造实战”系列第十四篇。上一篇完成了 Phorge 与六项外部服务的整体联调;这一篇转向相反的调用方向,给 Go 服务增加一条统一、受控的 Phorge API 入口。
系列导航
- 从没有官方镜像到 Docker Compose 跑起来:建立最小可运行基线;
- 改进容器化的七个细节:补齐权限、持久化、依赖和探活;
- 接入 Stargate:把 Forward Auth 的信任边界做完整;
- 拆分模块到 Gorge:无侵入改造不等于不碰文件;
- 替换 diff 子进程:兼容不等于逐字一致;
- 替换实时通知服务:为什么 HTTP 501 反而表示正常;
- 迁移邮件服务:先分清哪些失败不该重试;
- 迁移搜索服务:写进索引不等于搜得到;
- 迁移文件存储:写得进去也要读得回来;
- 迁移 Webhook 投递服务:先解决重复投递;
- 升级 Gorge 的 HTTP 框架:接口没变,行为也不能变;
- 兼容 Elasticsearch 5、6、7:版本配置决定整个索引结构;
- 联调六项外部服务:容器在运行,不代表业务已经切换;
- 为 Go 服务建立统一的 Phorge API 入口:网关可以换框架,Conduit 协议不能变;
- 用 Go 接管 Phorge 工作队列:如何避免新旧消费者互相抢任务。
写在前面
前面几篇迁移的方向大致相同:Phorge 的 PHP 代码通过 HTTP 调用 Gorge,把高亮、搜索、发信、文件存储等能力交给 Go 服务。
Conduit 网关的调用方向相反。Conduit 是 Phorge 自带的 API 协议;worker 等 Go 服务先经过统一的认证和限流入口,再调用原有的 Conduit 方法。将来如果增加审计或访问策略,也只需要在这一处完成。
它接收其它 Go 服务发来的 ANY /api/:method,完成服务令牌认证和按 IP 限流,再把请求转发给 Phorge 自己的 Conduit API。也就是说,过去是“PHP 调 Go”,现在多出了一条“Go 经网关调 PHP”的回路。
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 对比里已经包含 Conduit 接入:新增 PHP 客户端、配置项、Setup Check、Compose 服务与 entrypoint 下发逻辑。对应的核心提交是 feat(conduit): use gorge-conduit。随后,发布的 Gorge 2026.09.08-r3 通过 PR #13 把实现、测试、契约、OpenAPI、Compose 和文档一起迁进 monorepo。Phorge 的 2026.09.08-r3 则没有新增代码,r2...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 使用 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 框架,会持续增加依赖升级和排障成本。
最终的项目目录结构是:
go/
├── cmd/gorge-conduit/main.go
├── internal/conduit/
│ ├── config.go
│ ├── http.go
│ ├── proxy.go
│ └── ratelimit.go
└── internal/contracts/conduit.go
启动层保持尽量薄,只负责装配配置、代理和限流器,再交给 platform/httpx 启停。配置则优先读取带域前缀的新名字,同时用旧变量兜底:
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 都使用统一信封:
{
"data": {},
"error": null
}
Conduit 自己已经有一套协议:
{
"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:
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,可以直接获得成熟的超时、上下文和重定向控制。这里有四个实现细节值得单独保留:
- 上游 3xx 不自动追踪。重定向本身也是 Conduit 客户端应看到的响应,网关不能擅自把它变成另一次请求。
- 请求与响应两侧都过滤 RFC 7230 定义的 hop-by-hop 头,避免把只属于某一跳的连接状态继续传下去。
- 注入
X-Forwarded-For、X-Forwarded-Proto和X-Conduit-Gateway: go-conduit,让上游能识别真实来源与网关路径。 - 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 已经把 Conduit 客户端写成“可直连 Phorge,也可连接网关”。实际读取的环境变量是:
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 时才会经过网关。
这也是本系列一直在强调的状态分层:
- 网关代码实现完成;
- 网关进入可复现的发布 tag;
- Phorge 或 worker 拥有客户端;
- 部署配置把具体调用方指向网关;
- 真实请求通过网关完成一次往返。
这些验证缺一不可,否则只能确认“可以使用”,还无法确认“已经接管”。
最后
这个两百多行的网关仍要同时守住四条边界:运行时、协议、调用和发布。
—EOF