必须使用 r.Group("/v1") 分组管理版本路由,避免硬编码路径、确保中间件隔离与反向代理兼容;DTO 必须按版本独立定义并显式转换,禁止复用结构体或依赖 Accept 头做主版本控制。

必须用 r.Group("/v1") 分组,别拼字符串路径
硬编码 r.GET("/v1/users", handler) 看似简单,但会导致路由表膨胀、中间件无法按版本隔离、反向代理配置重复。Gin/Echo/Chi 都提供 Group() 方法,它是唯一能天然绑定前缀、作用域和中间件的机制。
常见错误包括:r.Group("/users/v1")(把版本塞进资源后)或 r.Group("/api/v1")(加冗余前缀),前者破坏资源语义,后者除非你同时暴露 /health 等非 API 路由,否则纯属多此一举。
-
v1 := r.Group("/v1")和v2 := r.Group("/v2")必须是两个独立变量,不能复用同一个 group 实例 - 嵌套资源走子 Group:比如
orders := v1.Group("/users/:user_id/orders"),而不是拼/v1/users/:user_id/orders - 所有路径必须显式注册:哪怕逻辑一样,
v1.GET("/users/:id", getV1User)和v2.GET("/users/:id", getV2User)都得写全,缺一个就 404
V1User 和 V2User 必须是两个 struct,哪怕字段名一模一样
共用 User struct 是最隐蔽的兼容性雷——v2 加个 Nickname string 字段,v1 handler 一不小心用了 User{} 返回,v1 客户端解析 JSON 就 panic。Go 没有运行时 schema 校验,JSON 序列化只认字段标签和类型。
DTO 必须按版本声明,哪怕只是复制粘贴:
立即学习“go语言免费学习笔记(深入)”;
type V1User struct {
ID int `json:"id"`
Name string `json:"name"`
}
type V2User struct {
ID int `json:"id"`
Name string `json:"name"`
Nickname string `json:"nickname"`
}
- 禁止用指针模拟可选字段来“凑合”老版本:v1 的
Name string是必填契约,就不能在 v1 DTO 里改成Name *string - handler 内必须显式转换:
c.JSON(200, toV1User(dbUser)),绝不裸传 GORM 模型或 domain struct - 数据库模型(如
GORMUser)保持稳定,只负责存;API 层响应永远走转换函数
别用 version=v2 查询参数做主路由
/users?version=v2 看起来 URL 干净,但实际会让 CDN 缓存失效、Prometheus 指标混杂、Swagger 文档错乱、Postman 调试反复失败。CDN 只看路径,不解析 query;http_request_duration_seconds{path="/users"} 会把 v1/v2 全塞进同一个 metric。
Accept 头方案(如 Accept: application/vnd.myapp.v2+json)仅适合内部灰度场景,调试成本高,且前端库默认不发该 header,容易漏测。
- 路径前缀是唯一能被反向代理、CDN、日志系统、监控平台无歧义识别的方案
- 如果真要用 Accept 做灰度,必须在中间件里统一拦截校验,非法版本(如
v999)立刻返回400 Bad Request - 绝对不要同时启用路径 + Accept 双版本路由,优先级没定义清楚,上线就逻辑串包
数据库变更必须兜底,字段语义变更比结构变更更危险
v2 新增 NOT NULL 字段,v1 接口不能因此崩出 500 Internal Server Error。service 层要主动降级或补默认值,比如查不到 nickname 就设空字符串,而不是让 ORM 报错穿透到 API 层。
更隐蔽的是字段语义变化:v1 的 status 是 "active"/"inactive",v2 改成 "published"/"draft"。v1 客户端 if 判断直接失效,必须在 DTO 转换层做映射:
func toV1User(u *GORMUser) V1User {
status := "inactive"
switch u.Status {
case "published":
status = "active"
case "draft":
status = "inactive"
}
return V1User{Status: status}
}
- 这种映射逻辑必须写死在 v1 的转换函数里,不能靠配置或运行时判断
- 任何字段含义变动,都要同步检查所有旧版本的 DTO 转换代码
- 别指望客户端自己适配——契约一旦发布,就得长期维护


















