本文是“Phorge 现代化改造实战”系列第十一篇。前十篇从 Phorge 的容器化开始,逐步处理入口信任、模块边界,以及 diff、实时通知、邮件、搜索、文件存储和 Webhook 等外部能力;这一篇暂停增加业务模块,集中更换所有 Gorge 服务共同依赖的 HTTP 底座。
系列导航
- 从没有官方镜像到 Docker Compose 跑起来:建立最小可运行基线;
- 改进容器化的七个细节:补齐权限、持久化、依赖和探活;
- 接入 Stargate:把 Forward Auth 的信任边界做完整;
- 拆分模块到 Gorge:无侵入改造不等于不碰文件;
- 替换 diff 子进程:兼容不等于逐字一致;
- 替换实时通知服务:为什么 HTTP 501 反而表示正常;
- 迁移邮件服务:先分清哪些失败不该重试;
- 迁移搜索服务:写进索引不等于搜得到;
- 迁移文件存储:写得进去也要读得回来;
- 迁移 Webhook 投递服务:先解决重复投递;
- 升级 Gorge 的 HTTP 框架:接口没变,行为也不能变;
- 兼容 Elasticsearch 5、6、7:版本配置决定整个索引结构;
- 联调六项外部服务:容器在运行,不代表业务已经切换;
- 为 Go 服务建立统一的 Phorge API 入口:网关可以换框架,Conduit 协议不能变;
- 用 Go 接管 Phorge 工作队列:如何避免新旧消费者互相抢任务。
写在前面
前几篇文章一直在给 Gorge 增加能力。到了 2026.09.07-r4,仓库里已经有七个业务域和六个二进制:语法高亮与 diff 共用 gorge-render,通知、邮件、搜索、文件存储和 Webhook 各自运行。
这些服务看起来各做各的,底下其实共享了同一层平台代码:
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 一次改动 45 个文件,增加 1926 行、删除 1240 行。统计以这个提交和迁移前的 2026.09.07-r4 为准:迁移前有 416 个测试函数,总覆盖率 74.9%;迁移提交完成后是 421 个测试函数,覆盖率仍为 74.9%。上面的数据不包括在迁移完毕之后,Go 1.27 升级和后端补测数据。
在迁移的时候,我定了一个简单的目标:测试只能升不能降,已有测试不能因为框架变了就删掉,总覆盖率也不能低于迁移前基线。
相比较“服务能够启动”,这个目标会更有用得多。
启动只能说明主路径还通。框架替换最容易破坏的,往往是此前由测试记录下来的那些“反直觉”行为。
换包名只是最表面的一步
原本使用的 Echo 建立在标准库 net/http 上,Fiber 则运行在 fasthttp 之上。两者都能注册路由、解析请求和返回 JSON,但接入模型并不相同。
最直接的变化是处理器签名:
func(c echo.Context) error
需要变成:
func(c fiber.Ctx) error
随之而来的是,测试入口也随之改变。
Echo 可以把应用当作一个标准 http.Handler,直接交给 httptest.NewRecorder:
e.ServeHTTP(recorder, request)
Fiber 的内存测试则由应用自己驱动:
response, err := app.Test(request, fiber.TestConfig{Timeout: 0})
前者的断言对象是 httptest.ResponseRecorder,后者拿到的是 *http.Response。这次共有 22 个测试文件受到影响,辅助函数、响应体读取、状态码和响应头断言都需要跟着调整。
错误类型也从 *echo.HTTPError 变成 *fiber.Error。WebSocket 的差异更大:原来的实现依赖 net/http 连接劫持,到了 fasthttp 不能继续原样使用,必须更换 upgrader 和底层连接类型。
因此,实际迁移要先换平台层,再让业务域逐个接回去:
依赖与平台层
↓
httpx / health / auth
↓
render / diff / mailer / search / filestorage / webhook
↓
notification HTTP 与 WebSocket
↓
契约测试、真实监听测试与全量验证
平台层先稳定下来,业务域的改动才主要是类型适配;如果反过来同时修改每个 handler 和公共错误语义,失败时很难判断问题来自框架、平台还是业务。
第一个兼容风险:没有 Committed,怎样避免写出两份响应
Echo 的 Response 带有 Committed 状态。Gorge 原来的全局错误处理器依赖它判断一件事:handler 是否已经回答过请求。
这个状态看起来只是框架内部细节,实际守着域级错误码。
例如 render 在高亮失败时会主动返回:
{
"error": {
"code": "ERR_HIGHLIGHT_FAILED",
"message": "..."
}
}
如果 handler 写完这份响应后又把错误向上返回,全局错误处理器不能再把它改成通用的 ERR_INTERNAL,更不能在响应体后面追加第二份 JSON。否则调用方会收到一段无法解析的内容,原有错误语义也随之丢失。
Fiber 没有可以直接替代的 Response().Committed。当前实现通过 c.Locals 补了一层很薄的状态:所有经过 httpx.OK 或 httpx.Fail 写出的响应都会先打上标记。
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}},
)
}
全局错误处理器先判断这个标记,已经回答过就立即结束:
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,负责把原有配置转换成整数。
app := fiber.New(fiber.Config{
BodyLimit: parseBodyLimit(cfg.BodyLimit),
ErrorHandler: errorHandler,
})
这里有两条不同的失败路径:
| 失败位置 | 典型原因 | 应答 |
|---|---|---|
| 进入 handler 之前 | 请求体超过传输层上限 | 413 ERR_TOO_LARGE |
| handler 绑定请求时 | JSON 语法错误或结构不合法 | 400 ERR_BAD_REQUEST |
全局错误处理器通过状态码映射保持这层区别:
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 默认会弱化这层差别,如果直接使用默认配置,原来不存在的路径可能也能命中。
所以平台层显式开启严格路由:
app := fiber.New(fiber.Config{
StrictRouting: true,
// ...
})
这类差异不会被 Go 类型系统发现,服务也能正常启动。把 404 作为契约的一部分写进测试,可以避免迁移时将“更宽松”误判为“更兼容”。
在普通网站里,自动兼容尾斜杠可能只是体验选择;在兼容一个已有客户端的服务里,路径是否存在本身就是协议。
第四个兼容风险:换掉 WebSocket 实现,保留 Aphlict 的特殊约定
通知服务是这次迁移中唯一不能只靠 app.Test 完成验证的业务域。它的客户端口有两条历史兼容约束。
第一条是普通 HTTP 请求。
Phorge 会对通知客户端口发起普通的 GET /,并把下面这个结果当作服务正常:
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 取出:
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} 信封:
return c.Status(status).JSON(response)
健康探针仍然返回扁平结构:
{"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。如果相信这个标签,绑定器会把消息当成表单执行百分号解码和字段拆分;内容中恰好出现 % 时会直接报错,没有 % 时则可能安静地解析成完全错误的结构。
所以迁移后仍然使用:
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,内部统一调用:
response, err := app.Test(
request,
fiber.TestConfig{Timeout: 0},
)
接口本身不变。请求方法、路径、头、body,以及对状态码、JSON 字段、HTML 类名和原始字节的断言都继续复用。
这正是共享契约接口的价值:测试驱动器可以从 Echo 换到 Fiber,但“Phorge 发什么、Gorge 必须答什么”没有跟着框架重写。
项目依赖发生的变化
迁移后的直接依赖中,Echo、Gommon 和 Gorilla WebSocket 被移除,依赖列表加入 Fiber v3 及其 WebSocket 实现:
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 有没有同时残留两套实现。
其他
如何验证一次“不增加功能”的功能迁移
这类改动最容易停在“编译通过、接口大致能访问”。
所以,我把验证分成四层。
第一层是静态检查:
gofmt -l .
go vet ./...
go build ./...
第二层是全量单元测试与覆盖率:
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