从 Gin 迁移到 Hertz:一次渐进式重构实战
TL;DR — 这次迁移不是“把 import 从 gin 改成 hertz”这么简单,而是一次分层完成的渐进式重构:先抽离 transport 层,再做 Gin 兼容层,最后把热点路由和关键中间件原生化到 Hertz。中间我们甚至短暂用 Fiber 验证过 fallback 方案。最重要的收获不是“换了框架”,而是沉淀出一套对存量 Go 服务可复制的迁移方法。
一、为什么我们决定从 Gin 迁到 Hertz
gin 是一个非常成熟的框架,ppanel-server 早期能快速迭代,离不开它带来的开发效率。
迁移的原因并不是因为 Gin “不够好”,而是随着项目变大,我们开始更在意几个问题:
- HTTP 传输层还有没有进一步压榨性能和分配开销的空间?
- 热点路由能不能更直接地贴近底层请求模型,而不是永远挂在一层通用适配之上?
- 如果以后要继续演进网络层,中间件和 handler 能不能不要再和具体框架强耦合?
- 能不能在不阻塞正常开发的情况下完成迁移?
在旧实现里,HTTP 启动逻辑是标准的 Gin 形态:
1 | func New(svc *svc.ServiceContext) *gin.Engine { |
这段代码本身没有问题,但它暴露了一个事实:
路由注册、中间件、Request/Response 语义、框架默认行为,全部绑在了 Gin 上。
这意味着一旦要迁移,就不只是“换个 router”那么简单,而是要同时面对:
Context语义迁移ShouldBind/ShouldBindQuery/ShouldBindUri行为迁移- 中间件签名迁移
http.Request/http.ResponseWriter兼容问题ClientIP、Recovery、Header 写回等细节差异
所以我们一开始就没有把这次工作定义成“框架替换”,而是定义成:
一次围绕 transport 层解耦的渐进式重构。
二、迁移前先定原则:不是重写,而是渐进替换
在真正动手前,我们先定了几条硬约束。
1)业务逻辑层尽量不动
logic、svc、repository 这些层不应该感知 Gin 还是 Hertz。
变化应该尽量收敛在 transport 层和最外面的 handler 层。
2)允许新旧路由共存
我们不接受“一天之内把几百个 handler 全改成 Hertz 原生”的方案。
迁移必须允许:
- 一部分路由继续走旧语义
- 一部分路由已经切到原生 Hertz
- 两者可以长期共存一段时间
3)中间件迁移要和 handler 迁移解耦
在存量项目里,中间件通常比 handler 更麻烦。
像这些逻辑都深度依赖框架上下文:
- 鉴权
- 设备加解密
- Trace
- Logger
- CORS
- Notify 校验
如果把中间件和 handler 绑成一个迁移批次,风险会非常高。
所以我们选择:
- 先让老中间件继续跑
- 再逐步把高价值中间件原生化到 Hertz
4)迁移过程必须可回退
只要迁移路径不可回退,它就一定会拖慢团队节奏。
我们需要的不是“最优雅的理论方案”,而是对线上系统最安全的工程方案。
三、迁移不是一跳完成的:我们甚至短暂经过了 Fiber
这次迁移的 commit 时间线,真实地反映了我们的思考过程:
| 阶段 | 提交 | 目标 |
|---|---|---|
| 1 | 9e61fc8 |
重构 Gin server 初始化 |
| 2 | 666f561 |
增加 Fiber transport 骨架 |
| 3 | 6fc3274 |
给 Fiber 增加 Gin fallback |
| 4 | 662d269 |
用 Hertz 替换 Fiber transport |
| 5 | adda20b |
批量把 Gin handlers 迁到 Hertz 兼容层 |
| 6 | 1c2f889 |
把热点路由切到原生 Hertz |
| 7 | 5e60172 |
把关键中间件改成原生 Hertz 中间件 |
很多人看到这里会疑惑:
不是写 Gin 到 Hertz 的迁移吗,为什么中间会出现 Fiber?
答案很简单:
我们先验证的是“迁移策略”,不是“框架喜好”。
在早期阶段,我们更关心的是:
- 能不能把 transport 层独立出来?
- 能不能让新 transport 承接一部分路由?
- 能不能让其余路由继续 fallback 到旧栈?
Fiber 在这里扮演的角色,其实更像是一个“迁移策略验证器”。
等这条路径走通后,我们再把底层 transport 实现换成真正要落地的 Hertz。
这一步的价值非常大,因为它证明了一件事:
迁移的关键,不是先选新框架,而是先证明系统允许你渐进替换。
四、第一步:先把 transport 层从业务里抽出来
我们先做的不是改 handler,而是改启动方式。
在 internal/server.go 里,我们把 HTTP 启动抽象成了一个很薄的 transportServer 接口:
1 | type transportServer interface { |
然后由 newTransportServer 决定底层到底实例化哪个实现:
1 | func newTransportServer(svc *svc.ServiceContext, addr string) transportServer { |
这个改动的意义非常大:
Service.Start()不再关心 Gin / Fiber / Hertz- 启停、重启、TLS、trace agent 生命周期仍然保持一致
- transport 层从“嵌在业务启动里”变成了“可替换实现”
很多迁移项目失败,是因为一上来就碰路由和业务。
我们这次先做这一步,本质上是在给后面所有迁移动作打地基。
五、第二步:不要急着重写 handler,先做一个兼容层
真正让迁移速度提起来的,不是 Hertz 本身,而是我们写的 pkg/hertzx。
这个包的目标很明确:
让大部分原来依赖 Gin 语义的代码,在最小改动下先跑起来。
它没有试图完整“复刻一个 Gin”,而是只兼容项目里真正用到的那一小部分能力:
ContextHandlerFuncEngine/RouterGroupWrap()ShouldBind()/ShouldBindJSON()/ShouldBindQuery()/ShouldBindUri()JSON()/String()/Redirect()/Header()Abort()/Next()http.Request/http.ResponseWriter兼容桥接
核心入口大概长这样:
1 | type HandlerFunc func(*Context) |
这里最关键的是 NewContext()。它做了两件事:
- 把 Hertz 的
RequestContext包成一个我们熟悉的*Context - 构造一个兼容逻辑层的
*http.Request
1 | func NewContext(base context.Context, ctx *app.RequestContext) *Context { |
这层兼容的直接收益是:
- 大量 handler 可以继续保留原来的调用形态
handler.RegisterHandlers(engine, svc)这样的 generated route 注册代码几乎不用推倒重来- 老中间件还能继续以
func(c *hertzx.Context)的方式运行
这让我们避免了最危险的一件事:
在路由切换的同时,重写整个 handler 语义。
六、第三步:把批量迁移的 handler 先挂到兼容层上
有了 hertzx.Engine 和 hertzx.RouterGroup 之后,大部分原来面向 Gin 的 handler 注册代码可以整体平移。
比如现在的注册方式仍然保持原来的组织结构:
1 | handler.RegisterHandlers(engine, svc) |
这里的 engine 不是 Gin 的 *gin.Engine,而是我们封装过的 *hertzx.Engine。
它对外暴露的 Group、GET、POST、Use 接口,都尽量保持了原有使用习惯。
这一步的价值不是“优雅”,而是“低风险”:
- route 文件不需要全改
- handler 文件不需要当天全改
- 中间件的演进顺序可以独立安排
也正因为这样,我们才能把迁移拆成多个 commit,持续推进,而不是开一个长寿命分支大爆炸合并。
七、第四步:热点路由优先原生化到 Hertz
兼容层只是桥,不是终点。
真正对性能和协议细节最敏感的路径,最终还是要回到 Hertz 原生接口上。
所以接下来我们专门引入了 RegisterNativeHandlers():
1 | func RegisterNativeHandlers(router *server.Hertz, serverCtx *svc.ServiceContext) { |
我们优先迁到原生 Hertz 的,是两类接口:
1)订阅类接口
订阅路径通常访问频率高、对 header / query / body / user-agent 很敏感,而且很多时候返回的不是标准 JSON,而是配置文本。
SubscribeHandler 迁到原生 Hertz 后,可以直接从 RequestContext 构造请求对象:
1 | func SubscribeHandler(svcCtx *svc.ServiceContext) app.HandlerFunc { |
这类代码用原生 Hertz 写起来更直接,也更容易看清楚请求模型到底是什么。
2)节点 / Server 上报接口
像这些路径:
/v1/server/config/v1/server/user/v1/server/online/v1/server/push/v1/server/status/v2/server/:server_id
本身就更偏“协议接口”,请求模型清晰,错误码和 header 行为也很明确。
我们把公共解析逻辑也顺手抽了出来:
1 | func ServerMiddleware(svcCtx *svc.ServiceContext) app.HandlerFunc { |
这一步有两个非常直接的收益:
- 减少 compat 层的额外跳转开销
- 让协议细节变得明确,不再埋在一层通用适配里
八、第五步:中间件原生化,兼容层只留给真正需要的地方
最后一层优化,是中间件。
在最终形态里,HTTP server 的核心初始化已经直接挂载原生 Hertz 中间件:
1 | func newServer(svc *svc.ServiceContext, opts []config.Option) *Server { |
这里有一个非常重要的策略选择:
不是所有中间件都要在同一天原生化。
我们优先原生化的是:
TraceMiddlewareLoggerMiddlewareCorsMiddleware
原因很简单:
- 它们是全局中间件
- 会作用到每一个请求
- 原生化收益最大
- 行为最值得统一到 Hertz 模型里
例如 TraceMiddleware 直接使用 app.RequestContext 采集 OpenTelemetry 所需字段:
1 | func TraceMiddleware(_ *svc.ServiceContext) app.HandlerFunc { |
LoggerMiddleware 也同样直接从 Hertz 的 request/response 结构里取信息,并对节点 telemetry 路径做了专门裁剪,避免把大 body 全量打进日志。
而像 AuthMiddleware、DeviceMiddleware 这类更依赖历史 Context 语义的中间件,则继续先挂在 compat 层上。这样迁移风险最低。
这也是我们这次迁移中反复验证的一条经验:
迁移顺序应该由“风险”和“收益”共同决定,而不是按文件顺序决定。
九、迁移中最值得记录的几个坑
如果只写“迁移完成、效果很好”,这篇文章就没价值了。
真正有价值的部分,恰恰是那些在存量系统里一定会踩到的坑。
坑 1:兼容的不是 API,而是语义
ShouldBind() 看起来只是一个函数名兼容,但真正麻烦的是:
- query 从哪里取
- body 什么时候被读掉
- form / json / uri 的优先级是什么
- 下游看到的是 Hertz request,还是兼容出来的
*http.Request
在 hertzx.Context 里,我们最终做的是一层“项目足够用”的绑定适配:
1 | func (c *Context) ShouldBind(obj interface{}) error { |
注意这里不是“1:1 复刻 Gin”,而是针对项目当前真实用法做兼容。
这点非常关键。
坑 2:请求被改写后,要同步回 Hertz 本体
设备加解密中间件是这次迁移里最典型的案例。
它会在请求进入业务前解密 query/body,然后把解密后的内容重新塞回请求对象。
如果你只改了兼容出来的 *http.Request,但没有同步回 Hertz 原生 request,那么后面的 binder 和 native handler 看到的仍然是旧数据。
所以我们专门补了两个同步函数:
1 | hertzx.SyncRequestURI(c) |
这类问题在迁移前很难凭空想到,只有在“兼容层 + 原生层并存”的阶段才会暴露出来。
坑 3:Client IP 语义不是默认一致的
旧 Gin 初始化里我们显式配置过:
1 | r.RemoteIPHeaders = []string{"X-Original-Forwarded-For", "X-Forwarded-For", "X-Real-IP"} |
而 Hertz 默认关注的是 X-Forwarded-For 和 X-Real-IP。
如果你的反向代理链路里用到了 X-Original-Forwarded-For,迁移后一定要重新检查 ClientIP() 的语义,否则日志、风控、审计链路都可能出现偏差。
这个问题很隐蔽,因为本地调试通常看不出来,只有挂到真实代理链路上才会暴露。
坑 4:最容易出问题的是 Header 写回
兼容层里最危险的一类 bug,通常不是业务逻辑 bug,而是协议层 bug。
比如:
- Header 重复追加
Location重复ETag行为异常- 自定义 header 被覆盖或多写
原因通常是:
- 一部分 header 写到了兼容
ResponseWriter - 一部分 header 又直接写到了 Hertz 原生 response
- 最后 flush 时如果再统一回写一次,而且是
Add而不是Set,就可能把同一个 header 重复追加
这类问题在普通“返回 200 就算通过”的测试里很难暴露,
但在 redirect、subscription-userinfo、ETag 这类协议头上会非常明显。
这也是我对兼容层最大的一个体会:
兼容层的复杂度不在于“能不能跑”,而在于“协议语义会不会悄悄漂移”。
十、迁移完成后,我们怎么验证它是安全的
很多迁移项目的问题是:
- 编译过了
- 几个页面点通了
- 就默认迁移成功
但 HTTP 框架迁移真正容易出问题的,是那些“肉眼不一定看得出来”的协议细节。
所以这次我们专门补了 transport 层测试。
例如 internal/transport/httpserver/server_test.go 里就覆盖了这些场景:
1 | func TestServerSecretMiddlewareBlocksMigratedPost(t *testing.T) { |
以及:
server_id非法时返回400- secret key 错误时返回
401/403 - CORS 预检请求要能绕过 server secret 校验
1 | func TestCorsPreflightBypassesServerSecretMiddleware(t *testing.T) { |
除了测试,我们还加了 benchmark 和 CI workflow:
scripts/perf/bench.sh.github/workflows/performance.yml
benchmark 直接跑 ./internal/transport/httpserver:
1 | go test ./internal/transport/httpserver \ |
这样迁移就不再只是“感觉上更快”,而是有持续可比较的数据基线。
十一、这次迁移里最有价值的,不是换成了 Hertz
如果只从结果看,这次迁移确实是“Gin -> Hertz”。
但如果从工程角度看,真正有价值的东西其实是下面这几条:
1)先抽 transport,再换引擎
没有这一层抽象,后面的所有迁移都会互相缠绕。
2)兼容层不是终点,是桥梁
hertzx 的意义不是永远存在,而是让我们能安全跨过去。
3)热点路径优先原生化
真正值得原生化的,不是“最容易改的路由”,而是:
- 请求量高
- 协议敏感
- compat 成本高
- 性能收益明显
4)中间件可以独立演进
没必要把 handler 和 middleware 绑在一个迁移批次里。
5)协议级测试比页面回归更重要
HTTP 框架迁移最可怕的往往不是业务错误,而是 header、status、body、redirect 语义漂移。
十二、如果让我再重来一次,我会更早做这三件事
1)更早补协议一致性测试
尤其是这些:
- redirect
- custom header
- CORS preflight
- ETag / 304
- Client IP
这些测试写得越早,后面的兼容层越不容易悄悄长歪。
2)更早明确 compat 层边界
哪些 API 兼容,哪些不兼容,最好一开始就说清楚。
不然 compat 层会不断“长功能”,最后变成另一个小框架。
3)更早把高频路径切到原生 Hertz
compat 层非常适合兜底,但它不适合永久承载热点流量。
结语
这次迁移让我最大的一个感受是:
存量系统的框架迁移,核心问题从来不是“新框架香不香”,而是“我们有没有能力把变化控制在正确的边界里”。
从 Gin 迁到 Hertz,本质上不是一次“技术栈翻新”,而是一次面向长期演进能力的重构。
它让我们把 transport、handler、中间件、协议语义重新梳理了一遍;
也让我们以后再做网络层优化时,不需要再从一团耦合代码里硬拆。
如果你面对的也是一个已经在线上跑了很久的 Go 服务,我会非常建议你记住一句话:
不要试图一口气迁完所有东西。先让系统支持渐进迁移,然后再一点点替换。
这是这次 Gin -> Hertz 迁移里,我们验证过最有效的路线。