本文是“Phorge 现代化改造实战”系列第十一篇。前十篇从 Phorge 的容器化开始,逐步处理入口信任、模块边界,以及 diff、实时通知、邮件、搜索、文件存储和 Webhook 等外部能力;这一篇暂停增加业务模块,集中更换所有 Gorge 服务共同依赖的 HTTP 底座。

系列导航

  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 工作队列:如何避免新旧消费者互相抢任务

写在前面

前几篇文章一直在给 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.OKhttpx.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 的两个应答入口补充明确语义:通过 OKFail 写出去的内容归 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 的配置一直使用 2M1K 这类便于阅读的写法,于是平台层增加了 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。两者的 ReadMessageWriteJSONClose 等核心接口接近,所以订阅、取消订阅、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 的 StartShutdown 换成 Fiber 的 ListenShutdownWithTimeout。Gorge 的 RunAllshutdownAll 没有改变原来的编排语义:一个二进制里如果有多个监听端口,其中一个失败,其他端口也应一起退出;关停时并发等待所有服务排空,最长耗时由最慢的那个超时决定,避免多个超时串行叠加。

这对 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
)

同时引入了 fasthttpgofiber/schemagofiber/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