5.4 KiB
5.4 KiB
id, title, type, domain, stack, tags, level, prerequisites, est_hours, related, week
| id | title | type | domain | stack | tags | level | prerequisites | est_hours | related | week | |||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| backend.gin.scenario.unified-api-response | 企业后台接口规范:统一响应、错误码、请求 ID 和参数校验 | scenario | backend |
|
|
intermediate |
|
2 |
|
|
企业后台接口规范:统一响应、错误码、请求 ID 和参数校验
业务背景与问题
一个内部管理系统由 4 个后端同事开发,半年后接口风格五花八门:有的返回 {"error": "..."},有的返回 {"msg": "...", "status": -1};参数错误有时 400 有时 200;数据库报错原文直接返回给前端(暴露了表名);用户反馈“提交失败”时,开发只能根据时间点大海捞针地翻日志。前端为每个接口单独写错误处理,维护成本高。
约束
- 不能一次性重写所有接口,要能逐步迁移。
- 前端需要稳定的结构:成功、业务失败、系统错误能用同一套代码处理。
- 安全要求:内部错误信息(SQL、堆栈、连接串)不能返回给用户。
- 运维要求:任何一次失败都能通过一个 ID 在日志里找到完整链路。
方案设计
响应格式(HTTP 状态码表达大类,code 表达具体业务原因):
{"code": 0, "message": "ok", "data": {"id": 1}, "request_id": "a1b2c3d4e5f60718"}
{"code": 40000, "message": "title 不能为空", "data": null, "request_id": "..."}
错误码规则:5 位数字,前 3 位对应 HTTP 状态码,后 2 位是业务细分。例如 40000 参数错误、40100 未登录、40101 登录过期、40300 无权限、40400 资源不存在、40900 状态冲突、50000 系统错误。错误码在一个文件里集中定义,并生成文档给前端。
sequenceDiagram
participant FE as 前端
participant RID as RequestID 中间件
participant ERR as Errors 中间件
participant H as 处理函数
FE->>RID: 请求(可带 X-Request-ID)
RID->>ERR: 设置 request_id,写回响应头
ERR->>H: c.Next()
H-->>ERR: c.Error(AppError) 或 panic 或 成功响应
ERR-->>FE: 统一格式;内部错误只返回“服务器开小差”,详细信息写日志(带 request_id)
职责划分:
RequestID中间件:沿用网关或前端传入的 ID(便于跨服务追踪),没有就生成。- 处理函数:只做“绑定参数 → 调用 service → 成功响应 /
c.Error(err)”,不自己拼错误 JSON。 Errors中间件:把AppError转成对应响应;未知错误和 panic 统一转成 50000,详细信息只写日志。- 校验错误翻译成中文、用 JSON 字段名,前端可以直接展示。
- 逐步迁移:新接口直接用新规范;旧接口在网关层或前端适配层兼容,按模块逐步改造。
关键代码
完整实现和 8 个测试用例见 examples/unified-api。核心是错误中间件:
func Errors(log *slog.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
defer func() {
if r := recover(); r != nil {
log.Error("panic", "request_id", c.GetString("requestID"), "panic", r)
writeErr(c, ErrInternal)
}
}()
c.Next()
if len(c.Errors) == 0 || c.Writer.Written() {
return
}
err := c.Errors.Last().Err
var ae *AppError
if errors.As(err, &ae) {
writeErr(c, ae) // 业务错误:提示语是写给用户看的
return
}
log.Error("internal error", "request_id", c.GetString("requestID"), "err", err)
writeErr(c, ErrInternal) // 未知错误:不暴露细节
}
}
取舍
- HTTP 状态码 + 业务码 vs 全部返回 200:全部 200 的做法前端处理简单,但网关监控、浏览器缓存、重试策略都依赖 HTTP 状态码。本方案两者都给:状态码表达大类,业务码表达细节。
- 错误码集中定义:增加一点流程成本(新增错误码要登记),换来前后端一致和可生成文档。
- 请求 ID 自己生成 vs 接入链路追踪(OpenTelemetry):先用请求 ID 解决 80% 的排查问题,后续接入追踪时把 trace ID 作为请求 ID 即可,接口格式不变。
易错点
- 处理函数里既
c.JSON又c.Error,中间件里要判断c.Writer.Written(),否则会写两次响应。 panic恢复后要记录堆栈(debug.Stack()),否则很难定位。- 请求 ID 来自客户端时要限制长度和字符,防止日志注入。
- 错误提示里拼接用户输入,要注意不要把敏感数据回显。
- 不同服务的错误码冲突:多服务时按服务分段(例如前两位是服务编号)。
面试怎么讲
“我们后台接口曾经格式不统一、错误信息直接暴露 SQL、排查问题要翻很久日志。我定了统一响应格式和 5 位业务错误码,用两个中间件实现:一个生成或透传请求 ID,一个统一把业务错误、未知错误和 panic 转成标准响应,内部错误只给用户友好提示、细节带着请求 ID 写日志。校验错误翻译成中文直接给前端展示。新接口直接用,旧接口逐步迁移。之后用户报错只要提供请求 ID,就能在日志里一次定位。”
可能的追问:错误码怎么管理和生成文档?多个微服务之间请求 ID 怎么传递(HTTP 头透传、gRPC metadata)?为什么不全部返回 200?