从 Gin 迁移到 Hertz:一次渐进式重构实战

TL;DR — 这次迁移不是“把 import 从 gin 改成 hertz”这么简单,而是一次分层完成的渐进式重构:先抽离 transport 层,再做 Gin 兼容层,最后把热点路由和关键中间件原生化到 Hertz。中间我们甚至短暂用 Fiber 验证过 fallback 方案。最重要的收获不是“换了框架”,而是沉淀出一套对存量 Go 服务可复制的迁移方法。


一、为什么我们决定从 Gin 迁到 Hertz

gin 是一个非常成熟的框架,ppanel-server 早期能快速迭代,离不开它带来的开发效率。

迁移的原因并不是因为 Gin “不够好”,而是随着项目变大,我们开始更在意几个问题:

  1. HTTP 传输层还有没有进一步压榨性能和分配开销的空间?
  2. 热点路由能不能更直接地贴近底层请求模型,而不是永远挂在一层通用适配之上?
  3. 如果以后要继续演进网络层,中间件和 handler 能不能不要再和具体框架强耦合?
  4. 能不能在不阻塞正常开发的情况下完成迁移?

在旧实现里,HTTP 启动逻辑是标准的 Gin 形态:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func New(svc *svc.ServiceContext) *gin.Engine {
initialize.StartInitSystemConfig(svc)

r := gin.Default()
r.RemoteIPHeaders = []string{"X-Original-Forwarded-For", "X-Forwarded-For", "X-Real-IP"}

sessionStore, err := redis.NewStore(...)
if err != nil {
panic(err)
}

r.Use(sessions.Sessions("ppanel", sessionStore))
r.Use(middleware.TraceMiddleware(svc), middleware.LoggerMiddleware(svc), middleware.CorsMiddleware, gin.Recovery())

handler.RegisterHandlers(r, svc)
handler.RegisterSubscribeHandlers(r, svc)
handler.RegisterTelegramHandlers(r, svc)
handler.RegisterNotifyHandlers(r, svc)
return r
}

这段代码本身没有问题,但它暴露了一个事实:

路由注册、中间件、Request/Response 语义、框架默认行为,全部绑在了 Gin 上。

这意味着一旦要迁移,就不只是“换个 router”那么简单,而是要同时面对:

  • Context 语义迁移
  • ShouldBind / ShouldBindQuery / ShouldBindUri 行为迁移
  • 中间件签名迁移
  • http.Request / http.ResponseWriter 兼容问题
  • ClientIPRecovery、Header 写回等细节差异

所以我们一开始就没有把这次工作定义成“框架替换”,而是定义成:

一次围绕 transport 层解耦的渐进式重构。


二、迁移前先定原则:不是重写,而是渐进替换

在真正动手前,我们先定了几条硬约束。

1)业务逻辑层尽量不动

logicsvcrepository 这些层不应该感知 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
2
3
4
type transportServer interface {
Start()
Shutdown(ctx context.Context) error
}

然后由 newTransportServer 决定底层到底实例化哪个实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func newTransportServer(svc *svc.ServiceContext, addr string) transportServer {
var tlsConfig *tls.Config
if svc.Config.TLS.Enable {
cert, err := tls.LoadX509KeyPair(svc.Config.TLS.CertFile, svc.Config.TLS.KeyFile)
if err != nil {
logger.Errorf("load tls certificate error: %s", err.Error())
return nil
}
tlsConfig = &tls.Config{
MinVersion: tls.VersionTLS12,
Certificates: []tls.Certificate{cert},
}
}
return httpserver.New(svc, addr, tlsConfig)
}

这个改动的意义非常大:

  • Service.Start() 不再关心 Gin / Fiber / Hertz
  • 启停、重启、TLS、trace agent 生命周期仍然保持一致
  • transport 层从“嵌在业务启动里”变成了“可替换实现”

很多迁移项目失败,是因为一上来就碰路由和业务。
我们这次先做这一步,本质上是在给后面所有迁移动作打地基。


五、第二步:不要急着重写 handler,先做一个兼容层

真正让迁移速度提起来的,不是 Hertz 本身,而是我们写的 pkg/hertzx

这个包的目标很明确:

让大部分原来依赖 Gin 语义的代码,在最小改动下先跑起来。

它没有试图完整“复刻一个 Gin”,而是只兼容项目里真正用到的那一小部分能力:

  • Context
  • HandlerFunc
  • Engine / RouterGroup
  • Wrap()
  • ShouldBind() / ShouldBindJSON() / ShouldBindQuery() / ShouldBindUri()
  • JSON() / String() / Redirect() / Header()
  • Abort() / Next()
  • http.Request / http.ResponseWriter 兼容桥接

核心入口大概长这样:

1
2
3
4
5
6
7
8
9
type HandlerFunc func(*Context)

func Wrap(handler HandlerFunc) app.HandlerFunc {
return func(base context.Context, ctx *app.RequestContext) {
c := NewContext(base, ctx)
handler(c)
c.flush()
}
}

这里最关键的是 NewContext()。它做了两件事:

  1. 把 Hertz 的 RequestContext 包成一个我们熟悉的 *Context
  2. 构造一个兼容逻辑层的 *http.Request
1
2
3
4
5
6
7
8
9
10
11
func NewContext(base context.Context, ctx *app.RequestContext) *Context {
c := &Context{
base: base,
ctx: ctx,
Request: compatRequest(base, ctx),
Writer: newResponseWriter(ctx),
keys: make(map[string]interface{}),
}
ctx.Set(contextKey, c)
return c
}

这层兼容的直接收益是:

  • 大量 handler 可以继续保留原来的调用形态
  • handler.RegisterHandlers(engine, svc) 这样的 generated route 注册代码几乎不用推倒重来
  • 老中间件还能继续以 func(c *hertzx.Context) 的方式运行

这让我们避免了最危险的一件事:

在路由切换的同时,重写整个 handler 语义。


六、第三步:把批量迁移的 handler 先挂到兼容层上

有了 hertzx.Enginehertzx.RouterGroup 之后,大部分原来面向 Gin 的 handler 注册代码可以整体平移。

比如现在的注册方式仍然保持原来的组织结构:

1
2
3
handler.RegisterHandlers(engine, svc)
handler.RegisterTelegramHandlers(engine, svc)
handler.RegisterNotifyHandlers(engine, svc)

这里的 engine 不是 Gin 的 *gin.Engine,而是我们封装过的 *hertzx.Engine
它对外暴露的 GroupGETPOSTUse 接口,都尽量保持了原有使用习惯。

这一步的价值不是“优雅”,而是“低风险”:

  • route 文件不需要全改
  • handler 文件不需要当天全改
  • 中间件的演进顺序可以独立安排

也正因为这样,我们才能把迁移拆成多个 commit,持续推进,而不是开一个长寿命分支大爆炸合并。


七、第四步:热点路由优先原生化到 Hertz

兼容层只是桥,不是终点。

真正对性能和协议细节最敏感的路径,最终还是要回到 Hertz 原生接口上。
所以接下来我们专门引入了 RegisterNativeHandlers()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func RegisterNativeHandlers(router *server.Hertz, serverCtx *svc.ServiceContext) {
subscribePath := serverCtx.Config.Subscribe.SubscribePath
if subscribePath == "" {
subscribePath = "/v1/subscribe/config"
}
router.GET(subscribePath, SubscribeHandler(serverCtx))
if serverCtx.Config.Subscribe.PanDomain {
router.GET("/", PanDomainSubscribeHandler(serverCtx))
}

serverGroup := router.Group("/v1/server", serverHandler.ServerMiddleware(serverCtx))
serverGroup.GET("/config", serverHandler.GetServerConfigHandler(serverCtx))
serverGroup.POST("/online", serverHandler.PushOnlineUsersHandler(serverCtx))
serverGroup.POST("/push", serverHandler.ServerPushUserTrafficHandler(serverCtx))
serverGroup.POST("/status", serverHandler.ServerPushStatusHandler(serverCtx))
serverGroup.GET("/user", serverHandler.GetServerUserListHandler(serverCtx))

router.GET("/v2/server/:server_id", serverHandler.QueryServerProtocolConfigHandler(serverCtx))
}

我们优先迁到原生 Hertz 的,是两类接口:

1)订阅类接口

订阅路径通常访问频率高、对 header / query / body / user-agent 很敏感,而且很多时候返回的不是标准 JSON,而是配置文本。

SubscribeHandler 迁到原生 Hertz 后,可以直接从 RequestContext 构造请求对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func SubscribeHandler(svcCtx *svc.ServiceContext) app.HandlerFunc {
return func(c context.Context, ctx *app.RequestContext) {
req := types.SubscribeRequest{
Token: string(ctx.GetHeader("token")),
UA: string(ctx.UserAgent()),
Flag: ctx.Query("flag"),
Type: ctx.Query("type"),
Params: getQueryMap(ctx),
}
if req.Token == "" {
req.Token = ctx.Query("token")
}
writeSubscribeResponse(c, ctx, svcCtx, req)
}
}

这类代码用原生 Hertz 写起来更直接,也更容易看清楚请求模型到底是什么。

2)节点 / Server 上报接口

像这些路径:

  • /v1/server/config
  • /v1/server/user
  • /v1/server/online
  • /v1/server/push
  • /v1/server/status
  • /v2/server/:server_id

本身就更偏“协议接口”,请求模型清晰,错误码和 header 行为也很明确。

我们把公共解析逻辑也顺手抽了出来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
func ServerMiddleware(svcCtx *svc.ServiceContext) app.HandlerFunc {
return func(c context.Context, ctx *app.RequestContext) {
key, ok := ctx.GetQuery("secret_key")
if ok && key == svcCtx.Config.Node.NodeSecret {
ctx.Next(c)
return
}
ctx.String(consts.StatusForbidden, "Forbidden")
ctx.Abort()
}
}

func serverCommonRequest(ctx *app.RequestContext) (types.ServerCommon, error) {
var serverID int64
if rawServerID := ctx.Query("server_id"); rawServerID != "" {
id, err := strconv.ParseInt(rawServerID, 10, 64)
if err != nil {
return types.ServerCommon{}, err
}
serverID = id
}
return types.ServerCommon{
Protocol: ctx.Query("protocol"),
ServerId: serverID,
SecretKey: ctx.Query("secret_key"),
}, nil
}

这一步有两个非常直接的收益:

  • 减少 compat 层的额外跳转开销
  • 让协议细节变得明确,不再埋在一层通用适配里

八、第五步:中间件原生化,兼容层只留给真正需要的地方

最后一层优化,是中间件。

在最终形态里,HTTP server 的核心初始化已经直接挂载原生 Hertz 中间件:

1
2
3
4
5
6
7
8
9
10
func newServer(svc *svc.ServiceContext, opts []config.Option) *Server {
engine := hertzx.Default(opts...)
engine.Hertz().Use(middleware.TraceMiddleware(svc), middleware.LoggerMiddleware(svc), middleware.CorsMiddleware)

handler.RegisterNativeHandlers(engine.Hertz(), svc)
handler.RegisterHandlers(engine, svc)
handler.RegisterTelegramHandlers(engine, svc)
handler.RegisterNotifyHandlers(engine, svc)
return &Server{h: engine.Hertz()}
}

这里有一个非常重要的策略选择:

不是所有中间件都要在同一天原生化。

我们优先原生化的是:

  • TraceMiddleware
  • LoggerMiddleware
  • CorsMiddleware

原因很简单:

  • 它们是全局中间件
  • 会作用到每一个请求
  • 原生化收益最大
  • 行为最值得统一到 Hertz 模型里

例如 TraceMiddleware 直接使用 app.RequestContext 采集 OpenTelemetry 所需字段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func TraceMiddleware(_ *svc.ServiceContext) app.HandlerFunc {
return func(c context.Context, ctx *app.RequestContext) {
tracer := trace.TracerFromContext(c)
spanName := ctx.FullPath()
method := string(ctx.Method())

c, span := tracer.Start(
c,
fmt.Sprintf("%s %s", method, spanName),
oteltrace.WithSpanKind(oteltrace.SpanKindServer),
)
defer span.End()

requestId := trace.TraceIDFromContext(c)
ctx.Header(trace.RequestIdKey, requestId)
ctx.Next(c)
}
}

LoggerMiddleware 也同样直接从 Hertz 的 request/response 结构里取信息,并对节点 telemetry 路径做了专门裁剪,避免把大 body 全量打进日志。

而像 AuthMiddlewareDeviceMiddleware 这类更依赖历史 Context 语义的中间件,则继续先挂在 compat 层上。这样迁移风险最低。

这也是我们这次迁移中反复验证的一条经验:

迁移顺序应该由“风险”和“收益”共同决定,而不是按文件顺序决定。


九、迁移中最值得记录的几个坑

如果只写“迁移完成、效果很好”,这篇文章就没价值了。

真正有价值的部分,恰恰是那些在存量系统里一定会踩到的坑。

坑 1:兼容的不是 API,而是语义

ShouldBind() 看起来只是一个函数名兼容,但真正麻烦的是:

  • query 从哪里取
  • body 什么时候被读掉
  • form / json / uri 的优先级是什么
  • 下游看到的是 Hertz request,还是兼容出来的 *http.Request

hertzx.Context 里,我们最终做的是一层“项目足够用”的绑定适配:

1
2
3
4
5
6
7
8
9
10
11
12
func (c *Context) ShouldBind(obj interface{}) error {
if isJSONRequest(c.Request) {
return c.ShouldBindJSON(obj)
}
if err := bindValues(obj, c.Request.URL.Query()); err != nil {
return err
}
if len(c.ctx.Request.Body()) == 0 {
return nil
}
return c.ctx.Bind(obj)
}

注意这里不是“1:1 复刻 Gin”,而是针对项目当前真实用法做兼容
这点非常关键。

坑 2:请求被改写后,要同步回 Hertz 本体

设备加解密中间件是这次迁移里最典型的案例。

它会在请求进入业务前解密 query/body,然后把解密后的内容重新塞回请求对象。
如果你只改了兼容出来的 *http.Request,但没有同步回 Hertz 原生 request,那么后面的 binder 和 native handler 看到的仍然是旧数据。

所以我们专门补了两个同步函数:

1
2
hertzx.SyncRequestURI(c)
hertzx.SyncRequestBody(c)

这类问题在迁移前很难凭空想到,只有在“兼容层 + 原生层并存”的阶段才会暴露出来。

坑 3:Client IP 语义不是默认一致的

旧 Gin 初始化里我们显式配置过:

1
r.RemoteIPHeaders = []string{"X-Original-Forwarded-For", "X-Forwarded-For", "X-Real-IP"}

而 Hertz 默认关注的是 X-Forwarded-ForX-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 就算通过”的测试里很难暴露,
但在 redirectsubscription-userinfoETag 这类协议头上会非常明显。

这也是我对兼容层最大的一个体会:

兼容层的复杂度不在于“能不能跑”,而在于“协议语义会不会悄悄漂移”。


十、迁移完成后,我们怎么验证它是安全的

很多迁移项目的问题是:

  • 编译过了
  • 几个页面点通了
  • 就默认迁移成功

但 HTTP 框架迁移真正容易出问题的,是那些“肉眼不一定看得出来”的协议细节。

所以这次我们专门补了 transport 层测试。

例如 internal/transport/httpserver/server_test.go 里就覆盖了这些场景:

1
2
3
4
5
6
7
8
9
10
func TestServerSecretMiddlewareBlocksMigratedPost(t *testing.T) {
app := newTestServer("secret")
status, body := performNativeRequest(app, http.MethodPost, "/v1/server/online?secret_key=wrong")
if status != http.StatusForbidden {
t.Fatalf("expected status %d, got %d", http.StatusForbidden, status)
}
if body != "Forbidden" {
t.Fatalf("expected forbidden body, got %q", body)
}
}

以及:

  • server_id 非法时返回 400
  • secret key 错误时返回 401/403
  • CORS 预检请求要能绕过 server secret 校验
1
2
3
4
5
6
7
8
9
10
11
12
13
func TestCorsPreflightBypassesServerSecretMiddleware(t *testing.T) {
app := newTestServer("secret")

ctx := app.Engine().NewContext()
ctx.Request.SetRequestURI("/v1/server/online")
ctx.Request.Header.SetMethod(http.MethodOptions)
ctx.Request.Header.Set("Origin", "https://example.com")
app.Engine().ServeHTTP(context.Background(), ctx)

if status := ctx.Response.StatusCode(); status != http.StatusNoContent {
t.Fatalf("expected status %d, got %d", http.StatusNoContent, status)
}
}

除了测试,我们还加了 benchmark 和 CI workflow:

  • scripts/perf/bench.sh
  • .github/workflows/performance.yml

benchmark 直接跑 ./internal/transport/httpserver

1
2
3
4
go test ./internal/transport/httpserver \
-run '^$' \
-bench '^BenchmarkHTTPServer' \
-benchmem

这样迁移就不再只是“感觉上更快”,而是有持续可比较的数据基线。


十一、这次迁移里最有价值的,不是换成了 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 迁移里,我们验证过最有效的路线。