前后端联调的时候,最让人头疼的往往不是功能逻辑,而是接口返回的东西五花八门——有的接口返回{success: true},有的返回{code: 200, msg: "ok"},还有的直接把 HTTP 状态码当作业务状态用,200 也可能是登录过期。一旦出问题,前端要写一堆兼容判断,后端要反复解释某个字段的含义,测试同学也要在文档和代码之间来回比对。这套规范就是用来终结这种混乱的。
我结合自己在多个项目里的实践,把通用 API 数据结构、错误码分级分段、消息文案规范、兼容性策略这几块内容完整梳理了一遍。不管是刚接触接口设计的新人,还是正在推动团队统一规范的技术负责人,这篇文章都能提供一套可以直接抄作业的模板,以及我在实际落地过程中踩过的坑和总结的教训。
1. 先搞清楚:API数据结构到底在解决什么问题
1.1 没有统一结构时的乱象
我先描述一个场景,经历过的朋友一定深有体会。某天你打开一个内部系统的前端控制台,发现同一个项目里,几个后端同事写的接口返回格式是这样的:
// 风格A:只返回数据本体 { "id": 1024, "name": "订单A", "status": 1 } // 风格B:套一层data { "status": 200, "message": "success", "data": { "id": 1024, "name": "订单A", "status": 1 } } // 风格C:把错误信息放在data里 { "code": 0, "msg": "", "result": { "id": 1024, "name": "订单A", "status": 1 } }三种风格,字段名不一样,语义不一样,连“成功”的判断标准都不一样。风格A直接返回数据本体,前端怎么知道有没有出错?风格B看着规范,但 status 是从 HTTP 层抄过来的,和业务错误混在一起;风格C的 code=0 是成功,但其它接口又可能是 code=200 成功。这种混乱会造成几个直接后果:
- 前端每个接口都要单独写返回处理逻辑,没法抽公共拦截器。
- 后端同事之间互相调接口,也要先问一句“你那个 code=xxx 是什么意思”。
- 错误提示只能靠后端随手写的字符串,前端想根据错误类型做特殊处理(比如跳转登录页)根本无从下手。
- 排查问题全靠日志关键词搜索,同一个错误可能有七八种不同的表达。
这套规范的核心目标,就是把“接口长什么样”这件事,从每个人的个人习惯,变成团队层面的共识。有了共识,前端才能把“取数据”“报错误”“跳登录”这些动作统一封装成一套代码;后端才能把“参数校验失败”“权限不足”“资源不存在”这些场景用同一种语言表达出来。
1.2 统一之后带来的实际收益
在我自己维护的项目里,推动这套规范之后,最明显的变化是联调效率的提升。之前后端改一个字段名,前端就要全局搜索替换;现在字段位置固定、命名统一,前端可以把response.data和response.code的处理逻辑沉淀成一个公共模块。新增一个列表页,前端关注点就从“解析返回结构”变成了“处理业务数据”。
再往深一层说,统一结构也是可观测性和自动化测试的基础。如果所有接口都返回同样的结构,监控报警就可以基于错误码维度做统计:哪些错误码出现频率最高、集中在哪个接口、是参数问题还是服务端异常。API 网关也能根据约定好的结构做统一熔断、限流和日志采集。
2. 数据结构设计:从“够用”到“好用”
2.1 最基础的响应包裹格式
我推荐的统一响应结构很简单,就是三到四个字段的固定 JSON 包裹。
{ "code": 0, "message": "success", "data": {}, "traceId": "a1b2c3d4-e5f6-4a5b-8c9d-1e2f3a4b5c6d" }这里每个字段都有明确的职责:
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| code | int | 是 | 业务错误码,0 表示成功,非 0 表示各类业务异常或系统异常 |
| message | string | 是 | 人类可读的结果描述,成功时固定为 success,异常时给用户看的提示 |
| data | object | 否 | 业务数据实体,成功时返回,失败时可为 null |
| traceId | string | 是 | 一次请求的链路追踪 ID,用于全链路日志串联 |
为什么 code 用整型而不是字符串?因为整型方便程序判断,也方便做区间分段。为什么成功码固定为 0 而不是 200?因为 HTTP 状态码是传输层语义,业务码是业务层语义,两者应该解耦。HTTP 200 的时候业务可能已经失败了(比如密码错误),HTTP 500 的时候业务可能只是参数没传全,这两者不能混为一谈。
2.2 traceId:一笔请求的完整链路凭证
traceId 这个字段,很多初版规范里会被忽略,但等到线上出问题、需要从几千条日志里捞出某一笔完整请求的时候,就会知道它有多重要。我在对接一个外部门接口时,对方报错只说“你看看你传的参数”,我这边日志分散在网关、服务、数据库三个地方,如果没有 traceId,根本没法把三者串联起来。
简单做法是:网关或入口拦截器为每个请求生成一个 UUID 放入 traceId,通过日志框架的 MDC(Mapped Diagnostic Context)机制把它注入到所有日志输出中。后端服务在收到上游请求时透传这个 traceId,在调用下游时也透传。这样从前端发起请求到后端最终响应,全程只需要一个 traceId,就能把链路中每个环节的日志、耗时、参数、异常全部拉出来。
2.3 分页、列表、下拉选项等常见结构的约定
除了最基础的包裹格式,业务中最常碰到的还有分页数据和列表数据。如果不做约定,前端每个分页接口都要问“你到底返回 count 还是 total?pageNum 还是 pageNo?”这种低级问题,消耗的是两边的时间。
我建议的分页结构如下:
{ "code": 0, "message": "success", "data": { "list": [], "total": 235, "pageNum": 3, "pageSize": 20, "totalPages": 12 }, "traceId": "a1b2c3d4..." }list是当前页的数据数组,total是总条数,pageNum和pageSize是当前请求的页码和每页大小,totalPages是总页数。这样前端拿到数据后不需要自己再算一遍总页数,直接渲染分页器即可。对于列表、枚举下拉、字典数据这类接口,我习惯在 data 里直接返回数组,而不是再包一层对象。
{ "code": 0, "message": "success", "data": [ { "label": "待支付", "value": 1 }, { "label": "已支付", "value": 2 } ], "traceId": "a1b2c3d4..." }对于空列表,返回[]而不是null,前端做array.length判断时才不会报错。这是一个很小的约定,但能减少不少防御性代码。
3. 错误码设计:最容易翻车也最值得花心思的部分
3.1 错误码设计的第一原则:机器可读、人可读、可定位
错误码表面上是一个数字,实际上承担着三重职责:机器判断用的类型标识、人眼定位问题用的线索、以及用户看到错误提示时的依据。设计错误码时,这三重职责都要照顾到。
机器可读意味着错误码必须有稳定语义,前端可以根据 code 精确判断“该弹窗”“该跳登录”还是“该静默处理”;人可读意味着看到20001时,后端能够第一时间想到“这是用户模块的参数错误”,而不是翻查文档半天;可定位则要求错误码能够关联到具体模块和具体异常类型。
很多团队会犯的毛病是:把错误码和 HTTP 状态码混为一谈。比如直接用 500 表示“服务器内部错误”,用 400 表示“参数错误”。短期来看确实省事,但长期看有一个致命缺陷——HTTP 状态码是给 HTTP 协议用的,只有那么几个,根本装不下业务世界里丰富的错误语义。比如“订单已支付”和“订单不存在”都是业务错误,用 400 还是 200 区分都不合适,最好的办法是使用业务错误码,并让 HTTP 状态码始终保持 200 或只反映传输层状态。
3.2 错误码分段规则
我推荐的做法是:把错误码看成一个多段组合的编号,用固定的区间段来表示错误所属的模块和类型。一个常见的分段方式是用 5 位数字:
| 错误码区间 | 所属模块 | 说明 |
|---|---|---|
| 0 | 全局 | 成功 |
| 10000 - 19999 | 通用错误 | 参数校验失败、签名错误、频率限制、系统繁忙等 |
| 20000 - 29999 | 用户模块 | 登录过期、账号不存在、密码错误、权限不足等 |
| 30000 - 39999 | 订单模块 | 订单不存在、订单状态异常、库存不足等 |
| 40000 - 49999 | 支付模块 | 支付超时、余额不足、重复支付等 |
| 50000 - 59999 | 商品模块 | 商品下架、价格变动等 |
| 90000 - 99999 | 系统级错误 | 数据库异常、缓存异常、第三方服务异常等 |
采用这种分段规则,好处一目了然:看到错误码的第一位或前两位,就知道问题出在哪个模块;再往下看具体数字,就知道是哪种异常。对于前端而言,可以根据错误码区间提前注册全局拦截逻辑——比如10000 - 19999统一弹 toast,20001统一跳登录页。
不过我必须提醒一句:错误码不能无限细化,每增加一个错误码都有维护成本。错误码的粒度应该和业务可操作粒度对齐——前端需要针对某个问题做差异化处理,或者运维需要针对某个异常做监控报警,这个错误码才值得单独存在。如果一个错误码对应的提示信息和处理方式与另一个完全一样,那就合并掉。
3.3 message 写给人看还是写给机器看
message 字段是给人看的,但到底是给开发人员看还是给最终用户看,业界有些分歧。我的实践经验是:message 在面向 C 端用户的接口中,写用户能看懂的话;在面向内部管理系统的接口中,写对排查问题有帮助的技术性描述。更稳妥的方案是增加一个detail字段,专门给开发人员看技术细节。
{ "code": 30001, "message": "订单不存在或已被删除", "detail": "orderId=1024 not found in db", "data": null, "traceId": "a1b2c3d4..." }message 给用户看,要简洁、明确、不带技术术语;detail 给开发看,可以包含具体的参数值、查询条件、堆栈关键信息。这样既不会把内部实现的复杂性暴露给用户,又不会让排查问题的人陷入“只知道错了,不知道哪里错了”的尴尬。
4. 从设计到落地:代码示例与团队协作
4.1 后端统一返回结构的封装
有了规范,最终要落到代码里。我以 Java 后端为例,展示一个最精简的封装方式,其它语言思路完全相同。
public class ApiResponse<T> { private int code; private String message; private T data; private String traceId; public static <T> ApiResponse<T> success(T data) { ApiResponse<T> response = new ApiResponse<>(); response.setCode(0); response.setMessage("success"); response.setData(data); response.setTraceId(TraceContext.get()); return response; } public static <T> ApiResponse<T> error(int code, String message) { ApiResponse<T> response = new ApiResponse<>(); response.setCode(code); response.setMessage(message); response.setTraceId(TraceContext.get()); return response; } }注意这里我把traceId的生成和透传放到了TraceContext这个类里,由拦截器统一设置,业务方法不需要关心。这样做的好处是,任何业务代码只需返回ApiResponse.success(...)或ApiResponse.error(...),响应结构不会乱。
4.2 错误码枚举定义的最佳实践
错误码如果散落在代码各处,迟早会失控。我建议定义一个统一枚举类,把模块分段、错误描述、用户提示统一管理起来:
public enum ErrorCode { // 通用 SUCCESS(0, "success"), PARAM_ERROR(10001, "参数错误"), UNAUTHORIZED(10002, "未登录或登录过期"), FORBIDDEN(10003, "没有操作权限"), NOT_FOUND(10004, "资源不存在"), RATE_LIMITED(10005, "操作太频繁,请稍后再试"), SYSTEM_ERROR(99999, "系统繁忙,请稍后再试"), // 用户模块 USER_NOT_EXIST(20001, "用户不存在"), USER_PASSWORD_ERROR(20002, "密码错误"), USER_DISABLED(20003, "账号已被禁用"), // 订单模块 ORDER_NOT_FOUND(30001, "订单不存在"), ORDER_STATUS_INVALID(30002, "订单状态异常"), ORDER_STOCK_NOT_ENOUGH(30003, "库存不足"); private final int code; private final String message; ErrorCode(int code, String message) { this.code = code; this.message = message; } public int getCode() { return code; } public String getMessage() { return message; } }这里有一个容易被忽略的点:枚举的名字一旦发布出去,就尽量不要再改动。因为前端或下游系统很可能已经把错误码和对应的处理逻辑写死在某段代码里了。如果误改了 name 或者 code 的指向,线上就可能出现前端明明收到了错误码却走错分支的情况。
4.3 前端如何配合消费这套规范
后端把规范定好了,前端也得有对应的消费姿势。以 Axios 为例,我习惯在响应拦截器里做统一处理。
service.interceptors.response.use( (response) => { const res = response.data; if (res.code !== 0) { if (res.code === 10002) { // 登录过期,跳登录页 router.push('/login'); } else { // 其它业务错误,统一弹提示 ElMessage.error(res.message); } return Promise.reject(new Error(res.message)); } return res; }, (error) => { ElMessage.error('网络异常,请稍后再试'); return Promise.reject(error); } );两个关键细节:第一,HTTP 层只要收到 200,就先走业务码判断逻辑;只有网络层错误才走第二个回调。第二,业务码为 10002 时,不弹任何提示,直接跳登录页,因为这时候弹“未登录或登录过期”用户体验很差,不如直接把人带到登录页。
5. 实践中踩过的坑与排查技巧
5.1 错误码膨胀带来的维护灾难
规范跑了一年之后,最容易出现的问题是错误码不受控地膨胀。A 同学加一个“订单备注超长”,B 同学加一个“备注不能为空”,C 同学又加一个“备注格式错误”。三个错误码,本质上都是备注参数的问题,却要维护三条文案、三个分支。
我的解决办法是:每个模块只保留有限数量的通用错误码,配合 message 或 detail 做具体化。比如参数类错误,全局只留一个10001 PARAM_ERROR,具体是哪个参数错、错在哪里,全部放到 message 里。除非前端真的需要针对“备注超长”和“备注为空”做不同的交互,否则不要拆新的错误码。
5.2 文档与实现不一致
规范写得很漂亮,但代码里难免有人绕过去。我的建议是两条腿走路:一是把规范文档放在仓库里随代码走,改代码时能顺手发现文档更新;二是在 CI 里加一个简单的校验脚本,扫描所有 Controller 的返回值类型,确保统一返回ApiResponse。我见过最离谱的情况是有人在拦截器里把返回值整个改了,导致所有接口的 data 结构全都变样,前端排查了两个小时才发现是拦截器的锅。
5.3 下游系统怎么识别错误
如果你在维护一个对外开放的 API 平台,下游系统有几十上百个,错误码的兼容性就特别重要。我建议把错误码定义拆成两层:对外暴露的稳定版本,和对内使用的扩展版本。对外文档只承诺一套最小可保证的错误码集合,比如登录失败、参数错误、无权限、系统繁忙、频率限制这几个。其余所有内部错误,全部归入“系统繁忙”大类,详情报错放在detail里通过专门的接口查询,避免把内部错误码的变更暴露给外部调用方。
5.4 兼容性处理:加字段和改错误码的正确姿势
最后聊一个关于版本演进的细节。在数据结构上,新增字段永远是向后兼容的,前提是前端没有对返回对象做深拷贝或全量覆盖。我在一个项目里就吃过亏:前端拿到 data 后直接Object.assign(this.form, res.data)覆盖整个表单,后端在 data 里加了一个新字段,前端老版本代码虽然没有显式引用它,但表单校验逻辑却意外被影响了。
错误码的改动是高风险操作。已经发布到网上的错误码,不要修改它的含义,也不要复用它表示另一个错误。如果确实需要调整,要么新增错误码,要么废弃旧错误码并保留兼容映射。具体的做法是:旧错误码仍然返回,但 message 里注明“已废弃,请升级到 xxx 版本”,同时新错误码正常返回;等确认所有调用方都升级之后,再把旧错误码从代码里移除。
6. 最后分享两个个人体会
第一点,API 数据结构规范这件事,最难的从来不是设计规范本身,而是让所有人长期遵守。我的经验是,规范越简单越容易被遵守,所以不要把规范设计得太复杂。能定三五个字段解决的,绝不定七八个;能定七八个错误码解决的,绝不定二十个。规范在落地初期肯定会遇到各种诉求,要把“克制”作为核心原则,任何新增错误码和新增字段都需要经过评审。
第二点,也是一次印象比较深的教训。我们曾经为了“规范”而规范,给所有接口都强制要求返回data字段,哪怕这个接口确实没有任何业务数据,也要返回一个空的 JSON 对象{}。结果前端每次都要判断data是否为空对象,后期想改成返回null都不好改。所以我现在设计的规范里,data字段在无数据时返回null,而不强行要求空对象。规范是为人服务的,不是为了让人迁就规范的。
上面这套设计,我在好几个不同类型的项目里都落地过,包括面向 C 端的交易系统、面向内部的管理平台、以及对外开放的查询接口。标准的响应结构、分段的错误码、配套的代码封装,不管项目大小,都能用同一种姿势去理解和维护。如果你正准备在团队里推行类似规范,我的建议是从最小集开始,先在两三个接口上跑通,再逐步扩大覆盖范围。不要一上来就设计一个面面俱到的巨大规范,那样的规范通常活不过第一个迭代。