本文是“Phorge 现代化改造实战”系列第十四篇。上一篇完成了 Phorge 与六项外部服务的整体联调;这一篇转向相反的调用方向,给 Go 服务增加一条统一、受控的 Phorge API 入口。

系列导航

  1. 从没有官方镜像到 Docker Compose 跑起来:建立最小可运行基线
  2. 改进容器化的七个细节:补齐权限、持久化、依赖和探活
  3. 接入 Stargate:把 Forward Auth 的信任边界做完整
  4. 拆分模块到 Gorge:无侵入改造不等于不碰文件
  5. 替换 diff 子进程:兼容不等于逐字一致
  6. 替换实时通知服务:为什么 HTTP 501 反而表示正常
  7. 迁移邮件服务:先分清哪些失败不该重试
  8. 迁移搜索服务:写进索引不等于搜得到
  9. 迁移文件存储:写得进去也要读得回来
  10. 迁移 Webhook 投递服务:先解决重复投递
  11. 升级 Gorge 的 HTTP 框架:接口没变,行为也不能变
  12. 兼容 Elasticsearch 5、6、7:版本配置决定整个索引结构
  13. 联调六项外部服务:容器在运行,不代表业务已经切换
  14. 为 Go 服务建立统一的 Phorge API 入口:网关可以换框架,Conduit 协议不能变;
  15. 用 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/serverinternal/configinternal/gatewayinternal/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_URLRATE_LIMIT_RPS 这类过于宽泛的名字带进共享进程环境。

端口继续沿用 :8150。服务自身的限流默认值是 RPS=0,即默认关闭;Phorge r2 的叠加编排主动设置为 RPS=20burst=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 客户端都会在顶层找不到 resulterror_codeerror_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.pingconduit.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,可以直接获得成熟的超时、上下文和重定向控制。这里有四个实现细节值得单独保留:

  1. 上游 3xx 不自动追踪。重定向本身也是 Conduit 客户端应看到的响应,网关不能擅自把它变成另一次请求。
  2. 请求与响应两侧都过滤 RFC 7230 定义的 hop-by-hop 头,避免把只属于某一跳的连接状态继续传下去。
  3. 注入 X-Forwarded-ForX-Forwarded-ProtoX-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.pingconduit.getcapabilities 默认豁免,因为 Phorge 和 Arcanist 会频繁调用它们进行探测和能力发现;把这两个方法限掉,会让一个健康网关在客户端眼里间歇性失联。

其他

测试协议关系,避免只覆盖处理器

其它 Gorge 域可以直接复用 contracttest.Run,Conduit 不行。共享 runner 假设只有一个 app,并按 {data,error} 的带点路径断言;Conduit 固件却需要逐例配置 app:透传场景要挂假上游,限流场景要建立容量很小的桶,响应字段还是扁平的。

固件格式继续沿用共享的 contracttest.Fixture,只增加一个 Conduit driver 解释所需字段。四个固件分别钉住:

固件 守住的协议不变量
unauthorized.json 401、ERR-CONDUIT-AUTHresult: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_URLGO_CONDUIT_URLGORGE_CONDUIT 都不会生效。当 CONDUIT_URL 为空时,委托型 handler 不会注册;指向 Phorge 时表示直连,指向 gorge-conduit:8150 时才会经过网关。

这也是本系列一直在强调的状态分层:

  1. 网关代码实现完成;
  2. 网关进入可复现的发布 tag;
  3. Phorge 或 worker 拥有客户端;
  4. 部署配置把具体调用方指向网关;
  5. 真实请求通过网关完成一次往返。

这些验证缺一不可,否则只能确认“可以使用”,还无法确认“已经接管”。

最后

这个两百多行的网关仍要同时守住四条边界:运行时、协议、调用和发布。

—EOF