接口文档写不完是常态,真正麻烦的是它一定会和代码漂移。这篇以我自己的自托管博客后端为例,拆解 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"`
	}
}

几个点值得说明:

  1. Body 是 Huma 的约定字段,表示请求体 / 响应体。路径参数用 path:"articleId",查询参数用 query:"pageNum",header 用 header:"...",都能放进同一个结构体。
  2. 校验标签是结构体的一部分:required、maxLength、enum、minimum / maximum。它们不是注释,会真正参与校验,也会原样出现在 OpenAPI 里。
  3. 指针类型表达可选字段,也表达「三态」:字段缺省(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、测试

一次定义如果只能省下写文档的时间,价值有限。真正的收益在后面:

  1. 交互式文档:/api/docs 是 Swagger UI,/api/api-doc.json 可以直接喂给 Postman、Apifox 或代码生成器。
  2. TypeScript SDK:make sdk-gen 从 OpenAPI 生成 sdk/typescript,前端管理后台直接用生成类型调用接口,字段改名会在编译期报错。
  3. 契约测试:项目里有一批以契约为目标的测试——路由快照、分页参数约定、错误码登记(AllErrors 必须覆盖所有声明的错误变量)。这类测试不测业务逻辑,只锁「对外承诺」。
make sdk-gen     # 生成 SDK
make sdk-check   # CI 校验 SDK 是否过期

如果团队愿意再进一步,可以把 OpenAPI 的 diff 也放进 CI,检测破坏性变更。

8. 踩坑清单

把这篇里散落的坑集中列一下:

  1. Status 和 Code 混用 → WriteHeader panic。结构体拆两个字段,GetStatus 只返回 HTTP 状态码。
  2. 指针语义要统一:更新接口里字段缺省表示不修改,显式 null 表示清空。用 *T 表达三态,不要在结构体上随手加 required。
  3. 包级错误变量不要就地改:WithMessage 返回副本,并实现 Is 让 errors.Is 按业务码匹配。
  4. 枚举校验与缺省值:enum:"markdown,html" 配合 omitempty 时,空字符串是否合法取决于服务端的默认值处理,别指望框架替你决定。
  5. 文件流不要走统一信封:下载、图片、SSE 都放在业务 API 之外或单独处理,否则信封会把字节流裹坏。
  6. 加密开关不是万能的:多实例部署时 key 必须一致,否则请求打到另一台就解不开。

9. 小结

Huma + Wire 这套组合解决的是同一个问题:让接口的对外承诺只有一处来源。结构体负责契约,Wire 负责装配,中间件顺序负责安全边界。

它的代价是「先写结构体再写 handler」的开发习惯,以及改 Provider 后要重新生成代码。但只要项目里接口数量超过十几个,这份约束换来的确定性和可维护性,远超那点仪式感。

下一篇会写这个项目在 SQLite、MySQL、PostgreSQL 之间的多库适配,以及全文检索的方言差异——那也是「一套代码、三种数据库」里最容易被低估的部分。