---
title: "从 Gin 迁移到 Hertz：一次渐进式重构实战"
source: https://www.embermoth.blog/2026/05/21/ppanel-gin-to-hertz-migration/
date: 2026-05-21
updated: 2026-05-21
tags: [ppanel, golang, gin, hertz, backend, performance]
categories: [开发笔记]
---

# 从 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 形态：

```go
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` 兼容问题
-   `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` 接口：

```go
type transportServer interface {
    Start()
    Shutdown(ctx context.Context) error
}
```

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

```go
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` 兼容桥接

核心入口大概长这样：

```go
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`

```go
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.Engine` 和 `hertzx.RouterGroup` 之后，大部分原来面向 Gin 的 handler 注册代码可以整体平移。

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

```go
handler.RegisterHandlers(engine, svc)
handler.RegisterTelegramHandlers(engine, svc)
handler.RegisterNotifyHandlers(engine, svc)
```

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

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

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

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

* * *

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

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

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

```go
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` 构造请求对象：

```go
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 行为也很明确。

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

```go
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 中间件：

```go
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 所需字段：

```go
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 全量打进日志。

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

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

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

* * *

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

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

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

### 坑 1：兼容的不是 API，而是语义

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

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

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

```go
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 看到的仍然是旧数据。

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

```go
hertzx.SyncRequestURI(c)
hertzx.SyncRequestBody(c)
```

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

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

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

```go
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` 里就覆盖了这些场景：

```go
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 校验

```go
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`：

```bash
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 迁移里，我们验证过最有效的路线。
