本文使用「署名 4.0 国际 (CC BY 4.0)」许可协议,欢迎转载、或重新修改使用,但需要注明来源。 [署名 4.0 国际 (CC BY 4.0)](https://creativecommons.org/licenses/by/4.0/deed.zh) 本文作者: 苏洋 创建时间: 2026年09月08日 统计字数: 11006字 阅读时间: 22分钟阅读 本文链接: https://soulteary.com/2026/09/08/phorge-modernization-part-11-upgrade-http-framework.html ----- # Phorge 现代化改造实战(十一):升级 Gorge 的 HTTP 框架,接口没变,行为也不能变 本文是“Phorge 现代化改造实战”系列第十一篇。前十篇从 Phorge 的容器化开始,逐步处理入口信任、模块边界,以及 diff、实时通知、邮件、搜索、文件存储和 Webhook 等外部能力;这一篇暂停增加业务模块,集中更换所有 Gorge 服务共同依赖的 HTTP 底座。 ## 系列导航 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 框架:接口没变,行为也不能变;** 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 协议不能变](https://soulteary.com/2026/09/08/phorge-modernization-part-14-unified-conduit-api-gateway.html); 15. [用 Go 接管 Phorge 工作队列:如何避免新旧消费者互相抢任务](https://soulteary.com/2026/09/08/phorge-modernization-part-15-take-over-work-queue-with-go.html)。 ## 写在前面 前几篇文章一直在给 Gorge 增加能力。到了 `2026.09.07-r4`,仓库里已经有七个业务域和六个二进制:语法高亮与 diff 共用 `gorge-render`,通知、邮件、搜索、文件存储和 Webhook 各自运行。 这些服务看起来各做各的,底下其实共享了同一层平台代码: ```text internal/platform/httpx ├── HTTP Server 与中间件 ├── {data, error} 响应信封 ├── 全局错误处理 ├── 请求 ID 与访问日志 ├── 健康探针 └── 优雅关闭 ``` 原来的实现建立在 Echo v4 上。这次我把 HTTP 层整体迁到 Fiber v3,将 WebSocket 也从 `gorilla/websocket` 换成了 `gofiber/contrib/v3/websocket`。现有服务没有遇到性能瓶颈,这次调整主要为了统一技术栈,并验证现有契约能否跨框架保持稳定。 更实际的考虑,是让这批 Go 服务继续使用同一套熟悉的 HTTP 技术栈,减少不同项目之间的维护差异。Gorge 目前已经把 Web 服务外壳都收敛进了 `internal/platform`。 这次没有修改 Phorge 调用的任何接口。因此,访问路径、状态码、错误码、响应体、探针和 WebSocket 协议都应该保持原样。本文只迁移底层框架,外部契约保持不动。 [迁移提交记录在 `33faf0e`](https://github.com/soulteary/gorge/commit/33faf0efd5b5b768db8d0b97230de789b58c9659) 一次改动 45 个文件,增加 1926 行、删除 1240 行。统计以这个提交和迁移前的 `2026.09.07-r4` 为准:迁移前有 416 个测试函数,总覆盖率 74.9%;迁移提交完成后是 421 个测试函数,覆盖率仍为 74.9%。上面的数据不包括在迁移完毕之后,Go 1.27 升级和后端补测数据。 在迁移的时候,我定了一个简单的目标:测试只能升不能降,已有测试不能因为框架变了就删掉,总覆盖率也不能低于迁移前基线。 相比较“服务能够启动”,这个目标会更有用得多。 启动只能说明主路径还通。框架替换最容易破坏的,往往是此前由测试记录下来的那些“反直觉”行为。 ## 换包名只是最表面的一步 原本使用的 Echo 建立在标准库 `net/http` 上,Fiber 则运行在 `fasthttp` 之上。两者都能注册路由、解析请求和返回 JSON,但接入模型并不相同。 最直接的变化是处理器签名: ```go func(c echo.Context) error ``` 需要变成: ```go func(c fiber.Ctx) error ``` 随之而来的是,测试入口也随之改变。 Echo 可以把应用当作一个标准 `http.Handler`,直接交给 `httptest.NewRecorder`: ```go e.ServeHTTP(recorder, request) ``` Fiber 的内存测试则由应用自己驱动: ```go response, err := app.Test(request, fiber.TestConfig{Timeout: 0}) ``` 前者的断言对象是 `httptest.ResponseRecorder`,后者拿到的是 `*http.Response`。这次共有 22 个测试文件受到影响,辅助函数、响应体读取、状态码和响应头断言都需要跟着调整。 错误类型也从 `*echo.HTTPError` 变成 `*fiber.Error`。WebSocket 的差异更大:原来的实现依赖 `net/http` 连接劫持,到了 `fasthttp` 不能继续原样使用,必须更换 upgrader 和底层连接类型。 因此,实际迁移要先换平台层,再让业务域逐个接回去: ```text 依赖与平台层 ↓ httpx / health / auth ↓ render / diff / mailer / search / filestorage / webhook ↓ notification HTTP 与 WebSocket ↓ 契约测试、真实监听测试与全量验证 ``` 平台层先稳定下来,业务域的改动才主要是类型适配;如果反过来同时修改每个 handler 和公共错误语义,失败时很难判断问题来自框架、平台还是业务。 ### 第一个兼容风险:没有 `Committed`,怎样避免写出两份响应 Echo 的 Response 带有 `Committed` 状态。Gorge 原来的全局错误处理器依赖它判断一件事:handler 是否已经回答过请求。 这个状态看起来只是框架内部细节,实际守着域级错误码。 例如 render 在高亮失败时会主动返回: ```json { "error": { "code": "ERR_HIGHLIGHT_FAILED", "message": "..." } } ``` 如果 handler 写完这份响应后又把错误向上返回,全局错误处理器不能再把它改成通用的 `ERR_INTERNAL`,更不能在响应体后面追加第二份 JSON。否则调用方会收到一段无法解析的内容,原有错误语义也随之丢失。 Fiber 没有可以直接替代的 `Response().Committed`。当前实现通过 `c.Locals` 补了一层很薄的状态:所有经过 `httpx.OK` 或 `httpx.Fail` 写出的响应都会先打上标记。 ```go const committedLocal = "httpx_committed" func markCommitted(c fiber.Ctx) { c.Locals(committedLocal, true) } func OK(c fiber.Ctx, data any) error { markCommitted(c) return c.Status(http.StatusOK).JSON(&Response{Data: data}) } func Fail(c fiber.Ctx, status int, code, message string) error { markCommitted(c) return c.Status(status).JSON( &Response{Error: &Error{Code: code, Message: message}}, ) } ``` 全局错误处理器先判断这个标记,已经回答过就立即结束: ```go if isCommitted(c) { return nil } if c.Method() == http.MethodHead { return c.SendStatus(status) } return c.Status(status).JSON( &Response{Error: &Error{Code: code, Message: message}}, ) ``` 这层状态只为 Gorge 的两个应答入口补充明确语义:通过 `OK` 或 `Fail` 写出去的内容归 handler 所有,平台层不能再覆盖。 对应测试让 handler 先用 `Fail` 写出域级错误,再主动返回另一个 500,最后确认响应中仍然只有第一份错误信封。这样才能验证响应既不会被覆盖,也不会写两遍。 ### 第二个兼容风险:413 和 400 不能混在一起 Gorge 需要区分多类请求失败。Phorge 客户端会根据错误码决定怎样处理,所以请求体过大必须稳定返回 `413 + ERR_TOO_LARGE`,JSON 格式错误则应该是 `400 + ERR_BAD_REQUEST`。 Echo 原来通过 Body Limit 中间件限制请求体。Fiber 把这项配置放进 `fiber.Config.BodyLimit`,而且接收的是字节数。Gorge 的配置一直使用 `2M`、`1K` 这类便于阅读的写法,于是平台层增加了 `parseBodyLimit`,负责把原有配置转换成整数。 ```go app := fiber.New(fiber.Config{ BodyLimit: parseBodyLimit(cfg.BodyLimit), ErrorHandler: errorHandler, }) ``` 这里有两条不同的失败路径: | 失败位置 | 典型原因 | 应答 | | ------------- | --------------- | --------------------- | | 进入 handler 之前 | 请求体超过传输层上限 | `413 ERR_TOO_LARGE` | | handler 绑定请求时 | JSON 语法错误或结构不合法 | `400 ERR_BAD_REQUEST` | 全局错误处理器通过状态码映射保持这层区别: ```go var statusCodes = map[int]string{ http.StatusBadRequest: CodeBadRequest, http.StatusUnauthorized: CodeUnauthorized, http.StatusNotFound: CodeNotFound, http.StatusMethodNotAllowed: CodeMethodNotAllowed, http.StatusRequestEntityTooLarge: CodeTooLarge, } ``` 业务 handler 中的 `c.Bind().Body()` 还要多做一步:如果拿到的是非 400 的 `*fiber.Error`,就把它原样交还给平台错误处理器,不能一概改写成“JSON 不合法”。否则 fasthttp 已经识别出的 413,会在业务层被降成 400。 这部分还有一个测试陷阱。`app.Test` 很适合大多数路由测试,但不能完整复现请求到达真实 fasthttp 监听器之前的体积检查。要验证前置 413,就必须让服务监听 `127.0.0.1:0`,再通过真实 TCP 请求触发限制。 内存测试和真实监听测试各自覆盖不同边界。前者快,适合覆盖大量 handler 分支;后者用来验证只有网络栈运行时才会出现的行为。 ### 第三个兼容风险:Fiber 默认会改变尾斜杠语义 路由迁移里还有一个很小、但会直接破坏契约的差异。 notification 的管理端口提供 `/status/`。在原有 Echo 路由下,`/status/` 与 `/status` 是两条不同的路径;其中前者返回状态,后者应该是 404。Fiber 默认会弱化这层差别,如果直接使用默认配置,原来不存在的路径可能也能命中。 所以平台层显式开启严格路由: ```go app := fiber.New(fiber.Config{ StrictRouting: true, // ... }) ``` 这类差异不会被 Go 类型系统发现,服务也能正常启动。把 404 作为契约的一部分写进测试,可以避免迁移时将“更宽松”误判为“更兼容”。 在普通网站里,自动兼容尾斜杠可能只是体验选择;在兼容一个已有客户端的服务里,路径是否存在本身就是协议。 ### 第四个兼容风险:换掉 WebSocket 实现,保留 Aphlict 的特殊约定 通知服务是这次迁移中唯一不能只靠 `app.Test` 完成验证的业务域。它的客户端口有两条历史兼容约束。 第一条是普通 HTTP 请求。 Phorge 会对通知客户端口发起普通的 `GET /`,并把下面这个结果当作服务正常: ```text HTTP 501 HTTP/501 Use Websockets\n ``` 状态码和 body 都必须保持一致。返回 200 会被 Phorge 视为通知服务异常。第六篇文章已经专门解释过这个看似反直觉的约定。 因此,notification 的客户端口继续启用 `SkipRootProbe`,不注册平台默认的 `GET /` 健康探针,让业务路由自己回答 501;`/healthz` 和 `/readyz` 仍由平台层提供,并依靠静态路由优先级避开 `/*` 通配路由。 第二条是 WebSocket 连接本身。 迁移前,hub 的 Listener 包装的是 `*gorilla/websocket.Conn`。换成 `gofiber/contrib/v3/websocket` 后,底层连接变成 `*fasthttp/websocket.Conn`。两者的 `ReadMessage`、`WriteJSON`、`Close` 等核心接口接近,所以订阅、取消订阅、replay 和消息分发逻辑基本不需要变化,主要改动集中在连接升级这一层。 还有一个不容易从接口上看出来的问题:Phorge 的 instance 写在 WebSocket 路径里,例如 `/~prod/`。连接被 hijack 以后,再读取 Fiber 的通配参数并不可靠。当前实现先在外层 handler 中通过 `c.Path()` 解析 instance,放进 `c.Locals`,再由 WebSocket handler 取出: ```go if !websocket.IsWebSocketUpgrade(c) { return c.Status(http.StatusNotImplemented). SendString(useWebsocketsBody) } c.Locals(localInstance, parseInstance(c.Path())) return upgrade(c) ``` 原来的 `TestUpgradedResponseIsMarkedCommitted` 直接检查 Echo 的 `Committed` 标记,到了 Fiber 已经没有同一个内部状态可以断言。这个测试保留下来,断言目标改为线上结果:完成升级以后,连接中只能出现 WebSocket frame,不能被全局错误处理器塞进一份 HTTP JSON 信封。 测试先发送一帧会被 read loop 忽略的畸形消息,再发 `ping`,确认仍能收到正常的 `pong`。断言从框架内部字段转向客户端实际依赖的行为。 测试迁移时,应保留原来的故障假设,同时允许断言方式随框架变化。框架内部状态可以消失,线上不变量不能跟着消失。 ## 看似机械适配工作,需要逐项确认 解决上面几个兼容风险以后,其余代码大多属于 API 适配,仍需逐项检查,不能直接批量替换。 ### 响应信封与健康探针 业务接口继续使用 `{data,error}` 信封: ```go return c.Status(status).JSON(response) ``` 健康探针仍然返回扁平结构: ```json {"status":"ok"} ``` 探针是给容器和负载均衡器消费的,现有编排已经依赖这个形状。把它顺手统一进业务信封并不会让接口更整齐,只会让运行环境与应用产生新的版本耦合。 ### 全局错误与 HEAD 请求 Fiber 默认错误处理器会返回纯文本,Gorge 则要求没有命中路由、方法错误、请求过大和 panic 等失败也进入统一信封,所以 `fiber.Config.ErrorHandler` 必须由平台层接管。 `HEAD` 是例外。它不能带响应体,错误处理器只能返回状态码,不能为了“统一”再写 JSON。 ### 请求 ID、日志与 panic 恢复 原来由 Echo 中间件完成的请求 ID、访问日志和 panic 恢复,迁到 Fiber 后仍按相同顺序挂在平台层。请求 ID 优先沿用调用方传来的 `X-Request-Id`,没有时再生成;访问日志在 handler 返回后记录,才能得到最终状态码;panic 的具体内容和堆栈只进入 `slog`,客户端始终看到通用的 `ERR_INTERNAL`。 这些功能单独看都不复杂,顺序却不能随便换。日志如果在 handler 之前写,记录不到最终状态;panic 详情如果进入响应,又会把内部实现暴露给调用方。 ### 优雅关闭 服务启动从 Echo 的 `Start`、`Shutdown` 换成 Fiber 的 `Listen` 与 `ShutdownWithTimeout`。Gorge 的 `RunAll` 和 `shutdownAll` 没有改变原来的编排语义:一个二进制里如果有多个监听端口,其中一个失败,其他端口也应一起退出;关停时并发等待所有服务排空,最长耗时由最慢的那个超时决定,避免多个超时串行叠加。 这对 notification 尤其重要。它同时运行 client 与 admin 两个端口,任何一个端口单独存活都无法构成完整服务。 ### notification admin 仍然直接解析原始 body notification 的管理端口不能改用 Fiber 的 Content-Type 绑定器。 Phorge 发来的是一段原始 JSON,但请求头可能仍是 curl 默认的 `application/x-www-form-urlencoded`。如果相信这个标签,绑定器会把消息当成表单执行百分号解码和字段拆分;内容中恰好出现 `%` 时会直接报错,没有 `%` 时则可能安静地解析成完全错误的结构。 所以迁移后仍然使用: ```go var msg hub.Message if err := json.Unmarshal(c.Body(), &msg); err != nil { return httpx.Fail( c, http.StatusBadRequest, httpx.CodeBadRequest, err.Error(), ) } ``` 这里不能因为 Fiber 提供了更方便的绑定 API 就改写。协议中的真实内容比一个不准确的 Content-Type 更可信。 ### 文件读取继续返回原始字节 file-storage 的读取成功响应是全仓少数不套信封的接口:成功时返回 `application/octet-stream`,失败时才返回 JSON 错误。 迁移后使用 `SendStream` 输出文件,并在长度已知时设置 `Content-Length`。零字节文件也必须明确返回成功,不能因为 body 为空被当成读失败。框架改变以后,这组“成功是字节、失败是 JSON”的分支仍由原来的契约测试守住。 ### 共享契约测试也要换驱动方式 `contracttest.Run` 原来接收标准 `http.Handler`,迁移后改为接收 `*fiber.App`,内部统一调用: ```go response, err := app.Test( request, fiber.TestConfig{Timeout: 0}, ) ``` 接口本身不变。请求方法、路径、头、body,以及对状态码、JSON 字段、HTML 类名和原始字节的断言都继续复用。 这正是共享契约接口的价值:测试驱动器可以从 Echo 换到 Fiber,但“Phorge 发什么、Gorge 必须答什么”没有跟着框架重写。 ## 项目依赖发生的变化 迁移后的直接依赖中,Echo、Gommon 和 Gorilla WebSocket 被移除,依赖列表加入 Fiber v3 及其 WebSocket 实现: ```go require ( github.com/alecthomas/chroma/v2 v2.27.0 github.com/aws/aws-sdk-go-v2 v1.46.0 github.com/aws/aws-sdk-go-v2/credentials v1.20.3 github.com/aws/aws-sdk-go-v2/service/s3 v1.111.0 github.com/fasthttp/websocket v1.5.12 github.com/go-sql-driver/mysql v1.10.1 github.com/gofiber/contrib/v3/websocket v1.2.5 github.com/gofiber/fiber/v3 v3.5.0 ) ``` 同时引入了 `fasthttp`、`gofiber/schema`、`gofiber/utils` 等间接依赖。 这次迁移不以依赖数量衡量成败。 但我们需要核对清楚的是:旧框架是否已经从 `go.mod` 和生产代码中消失,新的间接依赖是否都能由直接依赖解释,以及 WebSocket 有没有同时残留两套实现。 ## 其他 ### 如何验证一次“不增加功能”的功能迁移 这类改动最容易停在“编译通过、接口大致能访问”。 所以,我把验证分成四层。 第一层是静态检查: ```bash gofmt -l . go vet ./... go build ./... ``` 第二层是全量单元测试与覆盖率: ```bash go test ./... go test -coverprofile=coverage.out ./... go tool cover -func=coverage.out | tail -n 1 ``` 第三层是契约接口。它们验证请求与响应的外部形状,尤其是状态码、错误码、裸响应、固定字节和路径差异。 第四层是真实监听测试。前置 413 和 WebSocket 握手都依赖真实网络栈,不能只看 `app.Test` 的结果。 迁移提交完成时的对比结果如下: | 指标 | `2026.09.07-r4` 基线 | Fiber 迁移提交 | | ------------------------------------------- | -----------------: | ---------: | | 测试函数数 | 416 | 421 | | 总覆盖率 | 74.9% | 74.9% | | `gofmt` / `go vet` / `go build` / `go test` | 通过 | 通过 | `httpx` 的覆盖率从 97.1% 提高到 99.1%,主要来自响应信封、Body Limit 解析和错误路径的补测。业务包没有因为迁移而降低覆盖率,`webhook` 仍保持迁移前的 70.4%。 当然,测试数量本身当然不能证明迁移正确。这里记录下来它,是为了确认没有通过删除难改的测试来制造“形式上的全绿”;覆盖率不下降,用来防止一批旧路径在改写测试辅助函数时悄悄失去执行机会。 ## 最后 把 Echo 换成 Fiber,表面上是一次框架迁移;对 Gorge 来说,更像是检查平台层到底有没有把业务和框架隔开。 大部分业务逻辑确实没有变化。语法高亮、diff、邮件适配、搜索调度、文件后端和 Webhook 投递都不需要重新设计。迁移时间主要花在几处藏于框架行为中的约定上:写过响应以后谁还有权继续处理错误,请求体超限发生在哪一层,尾斜杠是否命中同一条路由,WebSocket hijack 以后能否继续写 HTTP 响应,以及通知服务为何用 HTTP 501 表示正常。 更换基础框架时,迁移重点应放在外部行为;旧框架的代码形状只供参考。 路径和 JSON 保持原样只是第一步,还要确认失败路径中的约定同样稳定,包括“返回 501 表示正常”这样的特殊行为。两方面都经得起验证,才能证明新底座已经站稳。 --EOF