《Go 语言编程入门》15.3 统一错误响应与输入校验

service 抛出的领域错误怎么变成正确的 HTTP 状态码?本节把错误映射收敛到一处:定义领域错误集、用 errors.Is/As 分支把 ErrNotFound 映射成 404、把校验错误映射成 422,并给出统一的 JSON 错误响应结构。同时实现带字段的输入校验,让客户端知道到底是哪个字段不合法,最后强调不要把内部错误细节泄露给外部。

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 + 通用 messageHTTP 响应体
运维/开发完整错误 + 堆栈 + 请求 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 结构化日志 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练