handler 里遇到错误,是直接 c.JSON 返回,还是 c.Error 交给中间件?Gin 的 c.Errors 到底收集什么、Private 和 Public 有什么区别、框架自己会往里放什么?这篇把这些细节试出来,再写一个只暴露 Public 错误的统一处理中间件。
errors.go(Gin v1.12.0):
15 | // ErrorType is an unsigned 64-bit error code as defined in the gin spec.
16 | type ErrorType uint64
17 |
18 | const (
19 | // ErrorTypeBind is used when Context.Bind() fails.
20 | ErrorTypeBind ErrorType = 1 << 63
21 | // ErrorTypeRender is used when Context.Render() fails.
22 | ErrorTypeRender ErrorType = 1 << 62
23 | // ErrorTypePrivate indicates a private error.
24 | ErrorTypePrivate ErrorType = 1 << 0
25 | // ErrorTypePublic indicates a public error.
26 | ErrorTypePublic ErrorType = 1 << 1
27 | // ErrorTypeAny indicates any other error.
28 | ErrorTypeAny ErrorType = 1<<64 - 1
29 | )
context.go 的 Error(Gin v1.12.0):
252 | func (c *Context) Error(err error) *Error {
253 | if err == nil {
254 | panic("err is nil")
255 | }
256 |
257 | var parsedError *Error
258 | ok := errors.As(err, &parsedError)
259 | if !ok {
260 | parsedError = &Error{
261 | Err: err,
262 | Type: ErrorTypePrivate,
263 | }
264 | }
265 |
266 | c.Errors = append(c.Errors, parsedError)
267 | return parsedError
268 | }一个 *gin.Error 由底层 Err、类型 Type(位标志)和任意 Meta 组成。c.Error 先用 errors.As 找一个已有的 *gin.Error(所以 %w 包装过也认得),找不到才新建一个 Private 的。返回的指针可以继续 SetType / SetMeta。传 nil 会 panic。
c.Error(普通 error) → Type=1(Private=1) Meta=<nil>,同一个指针存进 Errors: true
c.Error(fmt.Errorf("%w", *gin.Error)) → 返回的指针就是原来的 *gin.Error: true;Type=2(Public=2)
len(c.Errors)=3(同一个 *Error 被存了 2 次)
Last().Error()="用户名已存在"
ByType(Public) 有 2 条,ByType(Private) 有 1 条,ByType(Any) 有 3 条
Errors() = ["数据库超时" "用户名已存在" "用户名已存在"]
String():
Error #01: 数据库超时
Error #02: 用户名已存在
Meta: map[code:1001]
Error #03: 用户名已存在
Meta: map[code:1001]
MarshalJSON() = [{"error":"数据库超时"},{"code":1001,"error":"用户名已存在"},{"code":1001,"error":"用户名已存在"}]
只有一个 Private 时 JSON() 是对象 {"error":"数据库超时"}
c.Error(nil) → panic: err is nilByType 按位标志过滤,Last 取最后一条,Errors() 得到字符串切片,String() 得到“Error #01 …”样式的多行文本,JSON() 一条时是对象、多条时是数组,Meta 里的字段会摊平进对象。注意同一个指针 c.Error 两次就会出现两次。
c.Bind 失败 Errors=1 [Type=9223372036854775808] IsAborted=true 状态=400 已写出=true
c.ShouldBind 失败 Errors=0 [] IsAborted=false 状态=200 已写出=false
c.JSON 序列化失败(channel) Errors=1 [Type=1] IsAborted=true 状态=200 已写出=false
ErrorTypeBind=1<<63=9223372036854775808 ErrorTypeRender=1<<62=4611686018427387904(c.Render 失败走的是 c.Error(err),类型是 Private;Render 常量没有被 Gin 自己用到)c.Bind 系列(MustBindWith)失败时用 AbortWithError(400, err).SetType(ErrorTypeBind),所以既写了 400 又终止了链;ShouldBind 系列不碰 c.Errors,由你决定怎么处理。渲染(如 c.JSON 序列化 channel)失败时用普通的 c.Error 并 Abort,状态还没写出。ErrorTypeRender 这个常量在源码里没有被使用。
GET /pub → 400 {"errors":{"error":"password too short","field":"password"}}
GET /priv → 500 {"error":"服务器内部错误"}
GET /ok → 200 fine
GET /early → 200 已经写了常见写法:handler 只负责 c.Error(...) 并 return,在最外层中间件的 Next 之后统一写响应。要点有三个:先判断 c.Writer.Written(),别在已经写出响应的情况下再写;只把 Public 类型展示给客户端;Private 错误留给日志,客户端只看固定文案,这样数据库 DSN 之类的内部信息不会泄漏。
"[GIN] 2026/10/11 - 18:48:30 | 200 | 5.083µs | 192.0.2.1 | GET \"/x\""
"Error #01: 私有错误 A"
"Error #02: 私有错误 C"Gin 的 Logger 在访问日志后面追加所有 Private 错误(c.Errors.ByType(ErrorTypePrivate).String()),Public 错误不会出现在日志里——所以想让某个错误既展示给客户端又进日志,需要自己在中间件里记录。