接口文档写不完是常态,真正麻烦的是它一定会和代码漂移。这篇以我自己的自托管博客后端为例,拆解 Huma v2 如何让 Go 类型成为唯一契约,以及统一响应、业务错误码、Wire 装配和中间件顺序在真实项目里的落地方式。
1. 文档漂移不是态度问题
一个接口至少有三份定义:路由、参数校验、文档。手写时它们天然不同步:
- 新增字段忘改文档;
- 文档写了
required,代码没校验; - 前端照着文档传
categoryId,后端早已改名category_id; - 文档说错误码 400,实际返回 422。
靠 review 和自觉解决不了,因为「三份定义」这个前提就错了。可行的方向只有一条:让接口定义只写一次,再从同一份定义派生出路由注册、参数校验、OpenAPI 文档和 SDK。
Go 生态里能做到这一点的框架不多,Huma 是其中一个。它的做法很朴素:用 Go 结构体描述请求和响应,框架负责生成 JSON Schema、OpenAPI 3.1、参数绑定和校验。
2. 类型即契约:一次定义,四处生效
先看注册一个接口长什么样(文章创建接口的简化版):
huma.Register(humaApi, huma.Operation{
OperationID: "create-article",
Method: http.MethodPost,
Path: "/api/admin/articles",
Summary: "Create article",
Tags: []string{"Admin - Articles"},
Metadata: map[string]any{"permission": "article:create"},
}, func(ctx context.Context, input *vo.CreateArticleRequest) (*api.Response, error) {
article, err := articleService.CreateArticle(ctx, input)
if err != nil {
return nil, api.MapError(ctx, err, nil, zap.String("title", input.Body.Title))
}
return api.Success(toArticleDetail(article, nil)), nil
})
Operation 里的元数据除了生成文档,还承担两个运行时职责:
Metadata里的permission会被授权中间件读取。handler 只声明自己需要哪个权限,不写判断逻辑;Security为空切片表示显式公开。Huma 据此生成 OpenAPI 的安全声明,而不是靠路径前缀猜。
handler 的输入输出类型就是契约本体。vo.CreateArticleRequest 同时是请求体、校验规则和 JSON Schema:
type CreateArticleRequest struct {
Body struct {
Title string `json:"title" required:"true" maxLength:"255" example:"我的第一篇文章"`
Slug string `json:"slug,omitempty" example:"my-first-article"`
Content string `json:"content" required:"true"`
ContentType string `json:"contentType,omitempty" enum:"markdown,html" example:"markdown"`
CategoryId uint `json:"categoryId,omitempty"`
TagIds []uint `json:"tagIds,omitempty"`
PublishedAt *time.Time `json:"publishedAt,omitempty"`
}
}
几个点值得说明:
Body是 Huma 的约定字段,表示请求体 / 响应体。路径参数用path:"articleId",查询参数用query:"pageNum",header 用header:"...",都能放进同一个结构体。- 校验标签是结构体的一部分:
required、maxLength、enum、minimum/maximum。它们不是注释,会真正参与校验,也会原样出现在 OpenAPI 里。 - 指针类型表达可选字段,也表达「三态」:字段缺省(nil)表示不修改,显式
null表示清空。这个语义在更新接口里非常关键。
结果是:新增一个字段只改结构体,文档、校验、SDK 一起变。文档和代码不一致在 Huma 里不是承诺,而是结构决定的。生成的文档在 /api/docs,机器可读的规范在 /api/api-doc.json 和 /api/api-doc.yaml。
3. 统一响应:Body 字段就是响应体
客户端代码通常先看 code,再看 data,所以需要一个统一信封:
type Response struct {
Body struct {
Code int `json:"code"`
Message string `json:"message"`
Data any `json:"data"`
}
}
func Success(data any) *Response {
resp := &Response{}
resp.Body.Code = 200
resp.Body.Message = "ok"
resp.Body.Data = data
return resp
}
Huma 会把输出结构体里的 Body 字段直接作为 HTTP 响应体,所以客户端看到的是扁平的 {"code":200,"message":"ok","data":{...}},没有多余嵌套。Success(nil) 会把 data 置为 {},避免前端对 null 做额外判空。
这里有一个容易忽略的细节:统一信封不能只统一成功响应。错误响应如果换一种形状,前端就要写两套解析逻辑,网关的监控规则也要写两套。
4. 错误码:HTTP 状态码和业务码是两个维度
这是 Huma 项目里最容易踩坑、也最值得写清楚的地方。
先看线上真实的两个响应。登录失败:
HTTP/2 401
{"code":2001,"message":"用户名或密码错误"}
请求不存在的文章:
HTTP/2 404
{"code":4001,"message":"文章不存在"}
HTTP 状态码给协议层看:网关、监控、重试策略都依赖它。业务码给前端做分支:4001 表示文章不存在,2001 表示账号密码错误。两者语义不同,取值区间也不同:业务码从 1001 开始按域分段登记(1000 通用、2000 认证、3000 用户权限、4000 内容……),HTTP 状态码只能是 100–599。
如果用一个 int 字段同时承载两个概念,一定会出事。这个项目最初就踩过:
// CustomError 同时承载 HTTP 状态码与业务错误码
type CustomError struct {
Status int `json:"-"`
Code int `json:"code"`
Message string `json:"message"`
Data []string `json:"data,omitempty"`
}
坑在于:Huma 会用 GetStatus() 的返回值写 HTTP 状态码。如果把业务码(比如 1001)填进 Status,net/http 的 WriteHeader 会直接 panic,因为它只接受合法的 HTTP 状态码。所以 GetStatus 必须永远只返回 Status:
func (e *CustomError) GetStatus() int { return e.Status }
还有一个配套要求:包级错误变量是单例,任何「就地修改 Message」的写法都会污染其他请求。正确做法是返回副本:
func (e *CustomError) WithMessage(msg string) *CustomError {
derived := *e
derived.Message = msg
return &derived
}
// Is 让副本仍能按业务码匹配,而不是按指针身份
func (e *CustomError) Is(target error) bool {
var other *CustomError
if errors.As(target, &other) {
return e.Code == other.Code
}
return false
}
handler 层不直接判断错误类型,而是统一交给 MapError:
func MapError(ctx context.Context, err error, notFound *CustomError, fields ...zap.Field) error {
switch {
case err == nil:
return nil
case errors.Is(err, gorm.ErrRecordNotFound):
if notFound != nil {
return notFound
}
return ErrNotFound
case errors.As(err, new(*CustomError)):
return err
default:
logger.From(ctx).Error("request failed", append([]zap.Field{zap.Error(err)}, fields...)...)
return ErrInternalServerError
}
}
约定很清楚:
- 数据不存在 → 404 + 内容域业务码(4001 文章、4005 分类……),调用方可以据此跳转;
- 参数校验失败 → 422 + 1001(业务校验)或 422 + 422(框架校验),区别在于谁先发现;
- 未知错误 → 记完整日志,只对外暴露 500,绝不把内部错误透传出去。
顺带一提,框架校验错误也走同一信封,是因为启动时替换了 Huma 的错误构造器:
func bindHumaError() {
huma.NewError = api.HumaError
}
这样「required 没填」和「文章不存在」对客户端来说形状一致,只是 code 不同。
5. Wire:把装配从运行时挪到编译期
接口定义清楚之后,剩下的问题是依赖怎么装。Huma 负责路由,但 handler 依赖的 service、dao、DB、配置、日志由谁组装?
这个项目用 Wire 做编译期依赖注入。控制器只依赖 service:
func AddAdminArticleRouter(humaApi huma.API, articleService *service.ArticleService) {
// 只注册路由,不关心 ArticleService 怎么来的
}
service 依赖 dao,dao 依赖 *gorm.DB,这些关系集中在 ProviderSet 里声明:
var ProviderSet = wire.NewSet(
server.NewHTTPServer,
system.ProviderSet,
business.ProviderSet,
ProvideModuleRouters,
// business 通过接口读取站点设置,避免直接依赖 system/service
wire.Bind(new(businessservice.ContentSettings), new(*systemservice.ConfigService)),
)
wire.Bind 那一行是重点:business 模块只定义自己需要的接口,具体实现由 system 提供。依赖方向因此是 system ← business,反过来不行;改 system 的内部实现时,business 不需要任何改动。
模块路由的装配则刻意保持显式:
func ProvideModuleRouters(
systemRouter *system.Router,
businessRouter *business.Router,
) []server.ModuleRouter {
return []server.ModuleRouter{systemRouter, businessRouter}
}
新增模块必须在 ProviderSet 和这个函数里各加一处,漏了直接编译失败。这个「不便利」是有意为之:路由注册是安全边界,宁可显式列出,也不要靠扫描或反射自动发现。
代价也要说清楚:改了 Provider 之后必须重新生成。
make wire # 重新生成 wire_gen.go
make wire-check # 检查有没有未使用的 Provider
生成物 wire_gen.go 不要手改。换来的是启动期零反射、依赖图可读、缺依赖在编译期就报错。
6. 中间件顺序:谁包住谁,是安全属性
中间件顺序往往被当成风格问题,其实它是安全属性。项目的顺序是:
RequestID → RealIP → ClientInfo → CaptureRequestBody → Logger
→ Recoverer → Timeout → CORS → SecurityHeaders → Encrypt → Auth
几个必须成立的约束:
- RealIP 必须在 ClientInfo 之前。登录失败计数按 IP 维度限流,拿不到真实 IP 就等于没限流。
- SecurityHeaders 必须在 Encrypt 之前。Encrypt 会把整个响应体缓冲起来再加密,一旦它先执行,后面再写 header 就晚了。
- Encrypt 和 Auth 只包住 Huma 业务路由。静态资源和图片回源不能被打包成加密信封,否则
<img>拿不到字节。 - 加密白名单和 JWT 白名单不完全相同。
/api/auth/login、/api/auth/refresh跳过鉴权,但不能跳过加密——账号密码和 refresh token 恰恰最需要保护;而/api/public/*两边都跳过,因为公开站和浏览器要直接读。 - 上传接口原样透传。
multipart/form-data装不进{"payload": "..."}信封,而且io.ReadAll会把整个文件读进内存,大文件直接打爆进程。
响应加密还有一个容易误解的设计:服务端开了 encryptResponse 只是允许加密,真正加密还要请求自己带 X-Request-Encrypted: true,两个条件同时成立才返回加密响应。否则普通客户端会收到它无法处理的加密体。这是把「配置开关」和「单次请求意图」分开的典型取舍。
7. 契约的复利:文档、SDK、测试
一次定义如果只能省下写文档的时间,价值有限。真正的收益在后面:
- 交互式文档:
/api/docs是 Swagger UI,/api/api-doc.json可以直接喂给 Postman、Apifox 或代码生成器。 - TypeScript SDK:
make sdk-gen从 OpenAPI 生成sdk/typescript,前端管理后台直接用生成类型调用接口,字段改名会在编译期报错。 - 契约测试:项目里有一批以契约为目标的测试——路由快照、分页参数约定、错误码登记(
AllErrors必须覆盖所有声明的错误变量)。这类测试不测业务逻辑,只锁「对外承诺」。
make sdk-gen # 生成 SDK
make sdk-check # CI 校验 SDK 是否过期
如果团队愿意再进一步,可以把 OpenAPI 的 diff 也放进 CI,检测破坏性变更。
8. 踩坑清单
把这篇里散落的坑集中列一下:
- Status 和 Code 混用 →
WriteHeaderpanic。结构体拆两个字段,GetStatus只返回 HTTP 状态码。 - 指针语义要统一:更新接口里字段缺省表示不修改,显式
null表示清空。用*T表达三态,不要在结构体上随手加required。 - 包级错误变量不要就地改:
WithMessage返回副本,并实现Is让errors.Is按业务码匹配。 - 枚举校验与缺省值:
enum:"markdown,html"配合omitempty时,空字符串是否合法取决于服务端的默认值处理,别指望框架替你决定。 - 文件流不要走统一信封:下载、图片、SSE 都放在业务 API 之外或单独处理,否则信封会把字节流裹坏。
- 加密开关不是万能的:多实例部署时 key 必须一致,否则请求打到另一台就解不开。
9. 小结
Huma + Wire 这套组合解决的是同一个问题:让接口的对外承诺只有一处来源。结构体负责契约,Wire 负责装配,中间件顺序负责安全边界。
它的代价是「先写结构体再写 handler」的开发习惯,以及改 Provider 后要重新生成代码。但只要项目里接口数量超过十几个,这份约束换来的确定性和可维护性,远超那点仪式感。
下一篇会写这个项目在 SQLite、MySQL、PostgreSQL 之间的多库适配,以及全文检索的方言差异——那也是「一套代码、三种数据库」里最容易被低估的部分。




评论
加载中…