15.3 统一错误响应与输入校验
15.2 结束时,service 会抛出 ErrNotFound、ErrEmptyTitle、ErrConflict 这些领域错误。可 HTTP 客户端看不懂 Go 的 error,它要的是一个状态码加一段 JSON。如果把「领域错误 → HTTP 状态码」的转换散落在每个 handler 里,很快就会出现「同一个 ErrNotFound 有人回 404、有人回 400」的不一致。本节把它收敛到一处。
本节把 TaskAPI 推进到:建立一套统一的错误响应格式与「领域错误 → HTTP 状态码」映射表,实现带字段名的输入校验错误,让 API 的错误输出既一致又对客户端友好。
15.3.1 问题的形状
先看散落式写法的问题:
// 反例:每个 handler 各写各的映射
func (h *Handler) get(w http.ResponseWriter, r *http.Request) {
t, err := h.svc.GetTask(r.Context(), id)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) // 这里回 400
return
}
// ...
}
func (h *Handler) update(w http.ResponseWriter, r *http.Request) {
t, err := h.svc.UpdateTask(r.Context(), in)
if err != nil {
http.Error(w, err.Error(), http.StatusNotFound) // 这里回 404
return
}
// ...
}
三个毛病:状态码不一致、响应体格式不一致(http.Error 输出纯文本,而别的接口输出 JSON)、内部错误直接外泄(err.Error() 可能带 SQL 片段)。正确做法是——handler 只管调用,错误统一交给一个函数处理。
15.3.2 定义领域错误集
先把 service 会抛出的错误集中声明(第 6 章的哨兵错误模式):
var (
ErrNotFound = errors.New("not found")
ErrConflict = errors.New("conflict")
ErrUnauthorized = errors.New("unauthorized")
)
这些错误在 service 里用 %w 包装上下文后往上抛:
func findTask(id int64) error {
return fmt.Errorf("task %d: %w", id, ErrNotFound)
}
包装后,err.Error() 是「task 7: not found」(给人看),而 errors.Is(err, ErrNotFound) 仍为真(给程序判别)。这正是第 6.2 节 %w 的价值:既能携带上下文,又不丢失根因。
15.3.3 统一的错误响应结构
定义一个固定的 JSON 形状,所有错误都用它:
type errorBody struct {
Code string `json:"code"`
Message string `json:"message"`
Fields map[string]string `json:"fields,omitempty"`
}
三个字段分工:code 是稳定的机器可读标识(客户端可以据此写逻辑),message 是给人看的说明,fields 只在字段级校验失败时出现。omitempty 让 fields 为空时不输出,保持响应干净。
响应长这样:
{"code": "not_found", "message": "resource not found"}
校验失败时:
{"code": "validation_failed", "message": "输入校验未通过", "fields": {"title": "不能为空"}}
15.3.4 映射函数:errors.Is / errors.As 分支
把「领域错误 → HTTP 状态码」的转换写成一个函数,用 errors.Is 判别根因:
func writeError(w http.ResponseWriter, r *http.Request, err error) {
code := http.StatusInternalServerError
body := errorBody{Code: "internal", Message: "internal error"}
var ve FieldErrors
switch {
case errors.Is(err, ErrNotFound):
code, body = http.StatusNotFound,
errorBody{Code: "not_found", Message: "resource not found"}
case errors.Is(err, ErrConflict):
code, body = http.StatusConflict,
errorBody{Code: "conflict", Message: err.Error()}
case errors.Is(err, ErrUnauthorized):
code, body = http.StatusUnauthorized,
errorBody{Code: "unauthorized", Message: "authentication required"}
case errors.As(err, &ve):
fields := map[string]string{}
for _, fe := range ve {
fields[fe.Field] = fe.Msg
}
code, body = http.StatusUnprocessableEntity,
errorBody{Code: "validation_failed", Message: "输入校验未通过", Fields: fields}
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(body)
}
这个函数体现了第 6 章两个工具的分工:errors.Is 判断根因(是哪个哨兵错误),errors.As 取回结构化错误(FieldErrors 里的字段与消息)。注意 errors.As 的分支放在最后,因为它是「取值」而非「判等」。
关键设计:默认分支回 500 且只暴露 "internal error"。任何没被显式映射的错误都当成内部错误,绝不把原始 err.Error() 透给客户端——这一点 15.3.8 会展开。
15.3.5 带字段的校验错误
输入校验要能回答「哪个字段、错在哪」,所以错误类型要带字段:
type ValidationError struct {
Field string
Msg string
}
func (e *ValidationError) Error() string { return e.Field + ": " + e.Msg }
type FieldErrors []ValidationError
func (f FieldErrors) Error() string {
parts := make([]string, len(f))
for i, e := range f {
parts[i] = e.Error()
}
return strings.Join(parts, "; ")
}
FieldErrors 是 ValidationError 的切片,这样一次校验可以收集多个字段的错误,一次性返回给客户端,而不是让用户改一个错、提交一次、再发现下一个错。
15.3.6 校验函数
校验逻辑写成输入类型的方法,返回 FieldErrors:
type CreateInput struct {
Title string `json:"title"`
}
func (in CreateInput) Validate() error {
var errs FieldErrors
title := strings.TrimSpace(in.Title)
switch {
case title == "":
errs = append(errs, ValidationError{"title", "不能为空"})
case len([]rune(title)) > 100:
errs = append(errs, ValidationError{"title", "不能超过 100 个字符"})
}
if len(errs) > 0 {
return errs
}
return nil
}
两个细节值得注意。第一,用 len([]rune(title)) 而不是 len(title) 算长度——len 数的是字节,一个中文字符占 3 字节,用 len 会把「100 个字符」错算成「33 个汉字」。第二,返回类型是 error(接口),但实际装的是 FieldErrors,这样 writeError 里的 errors.As(err, &ve) 才能把它取回来。
handler 里的用法就一行:
if err := in.Validate(); err != nil {
writeError(w, r, err)
return
}
15.3.7 实测结果
把映射函数套在几个输入上实测:
POST /tasks/missing -> 404 {"code":"not_found","message":"resource not found"}
POST /tasks -> 422 {"code":"validation_failed","message":"输入校验未通过","fields":{"title":"不能为空"}}
POST /tasks -> 422 {"code":"validation_failed","message":"输入校验未通过","fields":{"title":"不能超过 100 个字符"}}
POST /tasks -> 201 {"title":"写 Go 书"}
逐条对照:查询不存在的任务回 404;空标题回 422 并指出是 title 字段;超长标题回 422 并给出具体原因;合法输入回 201。同一个 writeError 处理了所有情况,handler 里没有任何状态码判断。
再验证判别工具:
As FieldErrors: true count: 1
wrapped Is ErrConflict: true
ctx err is Canceled: true
errors.As 能从包装链里取回 FieldErrors;被 %w 包装的 ErrConflict 仍能被 errors.Is 认出;context.Canceled 也能被判别——后者在服务里很实用:请求被客户端取消时,映射成 499(客户端关闭连接)或直接不写响应。
15.3.8 不要把内部错误暴露给客户端
这是本节最重要的安全准则。看反面:
// 危险:直接把数据库错误回给客户端
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
// 响应体可能包含:pq: duplicate key value violates unique constraint "tasks_pkey"
// 甚至:dial tcp 10.0.3.12:5432: connect: connection refused
}
这类信息泄露了数据库类型、表名、约束名、内网 IP,是攻击者做信息收集的绝佳材料。正确做法是内外分离:
| 面向 | 内容 | 去处 |
|---|---|---|
| 客户端 | 稳定的 code + 通用 message | HTTP 响应体 |
| 运维/开发 | 完整错误 + 堆栈 + 请求 ID | 服务端日志 |
所以 writeError 的默认分支只回 "internal error",而真实错误应该记进日志(第 16.1 节的 slog),并带上请求 ID(第 13.3 节)以便对账。日志里写全,响应里写少——这条原则能挡掉一大类信息泄露。
15.3.9 小结
- 领域错误用哨兵错误声明,service 用
%w包装上下文后上抛。 - 「领域错误 → HTTP 状态码」的映射收敛到一个
writeError函数。 errors.Is判根因、errors.As取结构化错误,后者放最后。- 统一错误响应含
code(机器可读)、message(人读)、fields(字段级校验)。 - 校验错误用
FieldErrors收集多个字段,一次返回,减少往返。 - 算字符长度用
len([]rune(s)),别用len(s)。 - 默认分支回 500 且只暴露通用文案;内部细节只进日志,不进响应。
第 15 章到此收尾,TaskAPI 有了配置、分层装配与统一的错误契约。但它还有个黑盒问题:线上出故障时,你怎么知道哪个请求慢、哪次查询拖了后腿?下一章引入可观测性——用 slog 打结构化日志、用 pprof 剖析性能、暴露 /healthz 与 /metrics。
阅读导航:上一节:15.2 依赖注入与项目分层 · 下一节:16.1 log/slog 结构化日志 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。