RESTful API设计规范实战:从资源建模到状态码的接口落地指南
最近在团队里做接口评审,看了将近一个月的新老接口实现,越来越觉得"RESTful API 设计规范"这句话不能只停留在概念层面。我见过太多接口,名字叫 getUserInfo、getNewsList,一堆 POST 请求干着 GET 的活儿,状态码永远是 200,返回体外面再套一层 {code: 200, message: "success", data: {...}}。前后端联调的时候,光"这次返回结构又变了吗"就能扯半天。
这篇东西不是教科书式的理论复述,是我自己写接口、评接口、被接口坑过之后攒出来的实操规范。不追求"纯正 REST"的原教旨主义,只求一条:让接口可读、可维护、可演进,合作的人不需要翻源码就能猜个八九不离十。适合刚接触接口设计的新人照着落地,也适合团队内部做代码评审的时候当一把"尺子"用。
1. 先给接口诊断:那些让我血压升高的"野路子"写法
要谈规范,先得知道不规范长什么样。接口这东西,写的时候谁都觉得自己的命名挺清楚,但过三个月再看,或者换个人接手,问题全冒出来了。下面几种是我在评审时遇到频率最高的。
1.1 命名混乱:URL 里长出了动词和大小写
最常见的病根,是把后端接口当成"函数调用"来设计。比如:
一眼看过去,每个 URL 都像一个方法名。问题在于:URL 里塞了动词,资源的含义被稀释了,前端调用的时候完全依赖后端给的"接口清单",没有规律可循。今天心情好叫 getUserOrderList,明天重构一下改成 fetchUserOrders,前端就得跟着改一遍。
还有一类是大小写和分隔符混用:
同一个团队里三种风格并存,看着就头疼。URL 本质上是给人读的标识符,它应该稳定、统一、有规律。混用连字符和下划线看起来是小事,但对使用方来说是实打实的认知负担。
1.2 全 POST + 状态码永远 200 的"万能接口"
有的项目里,不管查列表、查详情、删数据、改状态,全部 POST。原因也听过,说"POST 什么都能传,最方便"。方便是方便了,后果是:
- 缓存系统完全失效,相同的查询请求每次都打满后端;
- 浏览器、网关、日志系统的语义判断全部失灵;
- 谁也不知道这个接口到底安不安全、幂不幂等,排查问题全靠猜。
配合着来的是状态码全 200。业务失败就返回 {"code": 500, "message": "服务器开小差"},HTTP 状态码永远是 200。前端拿到响应要先看 code 是不是 200,不是就弹个错。这套路不能说完全错,问题在于把 HTTP 层的语义全丢光了。网关监控看所有接口都健康,实际上内部错误一大堆,报警体系形同虚设。
1.3 响应结构随意变,前端拿命猜字段
接口字段命名不一致也很致命。有的接口返回 createTime,有的返回 created_at,有的返回 ct,同一个字段三种叫法。前端的 TypeScript 类型定义得跟着改三遍,改错了线上直接白屏。
更离谱的是同一接口不同情况返回不同的结构:正常时 data 是数组,空数据时 data 是 {list: []},异常时 data 直接是 string。前端每次取值之前都得"健壮性判断",业务代码里全是 Array.isArray、typeof 的防御逻辑。这种接口,写的人爽了,用的人天天想骂人。
这些问题的根源,不是技术不行,是缺一套默认大家都遵守的设计约定。规范的意义不是限制发挥,是让接口设计变成一件"有套路、可预期"的事。
2. 把资源想清楚:URL 设计才是 REST 的根基
RESTful API 设计规范里我最看重的,是"资源"这个思维模型。简单说,你的系统里有哪些"东西",就为每个"东西"设计一组端点。用户是资源,订单是资源,文章是资源;"删除用户"这种动作不是资源,它是对用户资源的某种操作。
2.1 资源用名词,单数还是复数?选一个并坚持
资源命名第一原则:URL 里用名词,不用动词。查询一个订单信息,应该是 GET /orders/123,而不是 GET /getOrder?id=123。"获取"这个动作已经包含在 HTTP 方法里了,没必要在 URL 里再说一遍。
单数和复数的问题,业界没有绝对标准。我个人建议统一用复数:/users、/orders。理由有两个:
- 集合语义清晰,
GET /users表示"用户集合",GET /users/1表示"集合中的一个",层级关系一目了然; - 很多 REST 框架和生成文档的工具默认按复数资源生成,团队协作时惯例比偏好更重要。
我见过某团队用单数 /user,代码里有些地方拼成了 /users,前端"差一个 s"找半天 bug。规范的目的就是消灭这种无意义的差异,选哪个不重要,统一才重要。
2.2 嵌套层级别贪深,子资源关联用查询参数
常见需求:查询某个用户的所有订单。两种写法:
这两种都不算错。嵌套写法的优势是层级清晰;查询参数写法的优势是资源扁平、更灵活,比如同时按用户和状态过滤时直接拼参数。在实际项目中,我建议嵌套控制在两层以内,超过两层就要警惕。
举例,/users/123/orders/456/items/789 这种三层嵌套,既难读又难维护。真需要"某个订单项"的详情时,不如直接 GET /orders/456,或者 GET /order-items/789,让每个资源尽量独立。嵌套本质上是表达"归属关系",层级多了,这种归属关系就变成负担了。
关联资源还有一种常见场景:用户和用户的收货地址。可以 /users/123/addresses,也可以 /addresses?user_id=123。我的习惯是:以主导资源为核心的归属查询用嵌套,跨资源的过滤查询用查询参数。新闻列表按分类过滤就是 GET /articles?category=tech,不要把分类做成 /categories/tech/articles,除非你确实需要一个独立的"分类"资源端点。
2.3 非资源语义的操作:用子资源或动作端点
有些操作确实不是增删改查能表达的,比如"点赞""发布""下单""取消订单"。第一反应别急着发明 POST /doLike 这种动词 URL,先想想能不能用资源操作表达。
"用户对文章点赞",本质是创建一个"点赞关系"资源:
以 likers(点赞者集合)为资源,用户点赞就是"把自己放进集合",取消就是"把自己移出集合",语义干净且天然幂等。类似的还有关注、收藏、加购物车。
有些动作确实找不到资源模型,比如"批量审核通过"。这种我建议单独建一个动作端点,顺便把它当子资源处理:
允许出现少量动作端点,但要控制数量。如果系统里全是动词 URL,说明资源建模这一步没做好,先回头重新理数据关系,别急着写代码。
3. 状态码不是摆设:用对 2xx/4xx/5xx 能省一多半扯皮
这是我最想吐槽的点。HTTP 状态码设计出来就是干这个的,结果很多团队只认 200、500 两兄弟。状态码用得越准,前后端沟通成本越低,监控报警越灵敏。
3.1 别再搞"200 万能响应包"了,HTTP 状态码和业务码要分家
我理解"200 万能包"的初衷:有些老系统的网关、前端框架只认 200,你返回 400 可能被拦截。但这不是接口层该背的锅,是框架配置的问题。
正确做法是:HTTP 状态码表达"这次请求在 HTTP 层面成功还是失败",业务码放在响应体里表达"业务上是什么结果"。这俩互不替代,也不该混着用。
想象一个场景:用户下单时余额不足。HTTP 返回 200、业务码 40001,还是 HTTP 返回 402、业务码 40001?我在线上见过两种极端:
- 全部 HTTP 200,网关监控永远健康,实际业务错误率 30%;
- 全部按 HTTP 语义返回,前端每个接口都要写全局错误拦截,业务码反而没人在乎。
实际落地方案是:HTTP 状态码负责粗粒度(请求成功、参数错、未授权、服务器内部错误),响应体里的业务码负责精确到具体错误。比如"余额不足"本质是"请求被拒绝",HTTP 用 200 和 400 都说得通,但我倾向于:参数校验类的用 4xx(400、422),业务逻辑拒绝类的 HTTP 用 200 配合业务码,因为这类错误对 HTTP 层来说是"请求已经正确处理完毕",只是业务上没有通过——具体怎么选,团队内部必须达成一致,不要今天这个明天那个。
关键不是"必须用哪种",而是定下来哪种,并且让前端知道分类规则。我现在的项目采用的就是"HTTP 语义 + 业务码补充"的双层结构,前端全局拦截只处理 HTTP 4xx/5xx 和特定业务码,其他业务码走各自的业务分支,非常清爽。
3.2 常见场景状态码对照表
实践下来,真正高频用到的状态码并不多,我整理成一张对照表,直接贴在规范文档里:
| 场景 | HTTP 状态码 | 说明 |
|---|---|---|
| 查询成功 | 200 OK | 列表、详情 |
| 创建成功 | 201 Created | 返回 Location 头或 body 里的 id |
| 创建或更新成功,但服务端不返回 body | 204 No Content | 删除、改状态常用 |
| 请求参数缺失或格式错误 | 400 Bad Request | 必填没传、类型不对 |
| 未认证 | 401 Unauthorized | 没带 token 或 token 过期 |
| 无权限 | 403 Forbidden | 认证过了但没权限 |
| 资源不存在 | 404 Not Found | 包括"id 不存在"和"URL 本身不存在",统一 404 防止信息泄露 |
| 请求方法不对 | 405 Method Not Allowed | 比如只允许 GET 的端点收到了 DELETE |
| 参数语义错误 | 422 Unprocessable Entity | 参数格式对但业务上不合法,如未来日期 |
| 服务器内部异常 | 500 Internal Server Error | 未知异常 |
| 服务不可用 | 503 Service Unavailable | 限流、依赖组件故障 |
有个容易忽略的细节:资源不存在和权限不足,都建议返回 404。直接告诉别人"这个订单不存在"比告诉他"这个订单存在但你无权访问"安全得多,防止接口被用来探测资源。
3.3 你以为没用、其实很有用的 304 和 206
除了上面这些,还有两个状态码值得用起来:
- 304 Not Modified:配合
If-None-Match或If-Modified-Since做缓存验证。列表接口、配置接口加上它,可以让客户端在响应没变化时直接复用本地缓存,大幅减少带宽。做法也不复杂:后端在生成响应时算一个 ETag,前端请求时带上If-None-Match,后端判断没变化直接返回 304 空 body。我在配置中心接口上加了这层,一个月带宽省了一半。 - 206 Partial Content:配合
Range头做断点续传、大文件分片下载。做文件下载、音视频播放的接口一定会碰到。
这两个状态码属于"平时用不上,用上就是高光时刻"的类型,但设计接口时提前考虑缓存和范围请求,能让接口的适用面宽很多。
4. 请求侧的设计细节:方法选择、幂等、分页过滤排序
URL 确定之后,请求怎么发、参数怎么传,同样有一堆讲究。这部分做得好,接口用起来舒服;做得不好,前端天天跟后端吵架。
4.1 POST/PUT/PATCH 的选择题:幂等是核心判据
HTTP 方法不只是"动词选择",它携带了三个关键语义:是否幂等、请求体是否有意义、是否可缓存。
- GET:查询,无请求体(特殊场景除外),幂等,可缓存。
- POST:新建"不可寻址"的资源,不幂等,通常不可缓存。注意:POST 不要求幂等,所以同一个请求发两次会产生两条数据,下单接口要有防重机制,这是另一个话题。
- PUT:整体替换资源,幂等,完整资源。比如更新用户资料,
PUT /users/123,body 里是完整的用户对象。 - PATCH:部分更新资源,不要求幂等,body 里只是要改的字段。
- DELETE:删除资源,幂等。删除不存在的资源,返回 404 还是 204?我建议返回 204 或 404 都行,但要在规范里明确;如果"删除一个不存在的 id"希望客户端能感知到,用 404 更明确。
实操里最常见的坑是:更新一个字段用了 PUT,却只传了要改的字段。PUT /users/123 传 {name: "新名字"},结果用户的其他字段(头像、简介)全部被置空或丢失,因为 PUT 的语义是"用 body 整体替换"。遇到这种场景,要么用 PATCH 传部分字段,要么 PUT 时让前端传完整对象。两者选一,不要混搭,这是数据安全层面的问题。
4.2 分页、过滤、排序参数:约定俗成比自定义更好用
列表接口基本离不开分页。参数命名建议直接沿用业界通行惯例,减少使用方学习成本:
| 参数 | 约定 | 说明 |
|---|---|---|
| 页码 | page(从 1 开始) |
简单直观,适合中小数据量 |
| 每页条数 | page_size 或 limit |
建议设上限,如最大 100 |
| 偏移量 | offset |
适合大数据的游标场景,但和 page 别混用 |
| 游标 | cursor |
适合高频写入、数据不断变化的列表 |
| 排序字段 | sort=created_at |
字段白名单校验,防止注入 |
| 排序方向 | order=desc |
默认 desc 或 asc,按业务习惯 |
分页有两种主流:offset/limit 和 cursor。数据量小、变动少,用 page/page_size 就行;数据量大、实时插入多,用 cursor 游标分页,避免"翻到第 50 页时前面删了一条,导致重复或漏数据"。游标设计时注意是模糊的透传值,不要让客户端自己拼。
过滤参数我习惯用查询参数表达:GET /orders?status=paid&created_at_from=2024-01-01。注意:from/to 这种复合参数比 created_at_start_time 短一半,但还是建议团队内统一用 _from/_to 后缀,并且做参数白名单校验,别把数据库字段名直接暴露在 URL 里。
排序参数同理,sort 的值必须和服务端白名单比对,避免前端把任意字段拼进排序条件,既防注入又防性能问题。排序加在 updated_at 上可能没问题,但加在 text 这种大字段上就是灾难。
4.3 字段选择、批量查询、搜索:列表接口的三大进阶需求
- 字段选择(sparse fieldsets):
GET /articles?fields=id,title,published_at。当某个资源字段特别多(比如文章正文全文、JSON 配置等),允许客户端指定只要哪些字段,能大幅降低响应体积和前端解析成本。实现上就是后台把返回对象白名单化。 - 批量查询:
GET /articles?id=1,2,3或POST /articles/batch_get。REST 本身推荐用查询参数逗号分隔,但 id 一多 URL 就超长。我自己的经验:少量 id 用 GET 逗号分隔,大量 id 或带复杂条件用 POST 批量查询端点。这类端点数量要控制在个位数,不能每个资源都搞一个。 - 搜索:简单搜索直接
?q=keyword走列表接口;复杂搜索(多条件、相关性排序、高亮)建议独立建/search端点。不要把搜索塞进列表接口强行判断q参数存在与否,逻辑里全是分支,后面维护的人想骂人。
5. 响应体长什么样:统一包装到底是省事还是挖坑
响应结构是接口设计里最容易吵起来的部分。核心争议集中在:要不要统一包一层 {code, message, data}?我的态度是:统一包装可以有,但必须约定清楚"什么时候包、包什么",别把所有语义都塞进自定义 code。
5.1 统一响应格式的取舍:规范三要素
我见过两种极端方案:
- 方案 A:纯 REST 语义,响应体就是资源本身,无包装。API 处女座的最爱,但业务错误、分页信息都得塞 HTTP 头或额外字段,前端用起来费劲。
- 方案 B:无论成功失败,全包
{code, message, data}。前端确实好判断,但 HTTP 状态码变成装饰品,监控全废。
现实世界的折中是"分场景定义":
- 查询、创建、更新成功的响应:
data直接是资源或资源列表,不加壳。 - 需要加分页信息:包一层
{items, total, page, page_size},这是唯一需要"壳"的场景。 - 业务错误:统一格式
{code, message, details},HTTP 状态码按错误类型返回。
拿订单列表举例,成功时:
失败时:
统一的是"错误格式",不是"所有响应的壳",这样既保留 HTTP 语义,前端又不会因为结构多变而抓狂。
5.2 错误码和错误信息设计:机器可读,人类可懂
错误码别用纯数字,纯数字没法从日志里一眼看出含义,前端 switch 起来也麻烦。我建议用大写蛇形字符串,比如 INVALID_PASSWORD、ORDER_NOT_FOUND、RATE_LIMITED。这样日志检索、前端映射、后续兼容都比数字友好。
错误信息 message 要写给"调用方的用户"看,而不是贴异常堆栈。后端拿 try-catch 捕获异常后,不要直接返回 exception.getMessage(),那种"NullPointerException at xxx line 123"的消息用户根本看不懂。message 应该是能直接展示给终端用户的一句话,details 里再放机器可读的结构化数据,比如校验失败的字段列表。
常见错误码示例:
| 错误码 | HTTP 状态码 | 场景 |
|---|---|---|
INVALID_PARAMETER |
400/422 | 参数错误,details 可带字段明细 |
UNAUTHORIZED |
401 | 未认证或 token 过期 |
FORBIDDEN |
403 | 无权限执行该操作 |
NOT_FOUND |
404 | 资源不存在 |
CONFLICT |
409 | 状态冲突,如重复操作 |
RATE_LIMITED |
429 | 请求过于频繁 |
INTERNAL_ERROR |
500 | 未知异常,message 给通用提示 |
错误信息里不要暴露敏感信息:SQL 语句、文件路径、堆栈信息都不该出现在响应体中,只进服务端日志。线上踩过的坑:某接口报错直接把 MySQL 的表名字段名全返回给前端了,等于给攻击者递了一份结构图。
5.3 时间格式、ID 类型、属性命名:细节决定体验
- 时间格式统一用 ISO 8601:
2024-05-20T10:30:00Z。不要有的接口返回时间戳、有的返回YYYY-MM-DD HH:mm:ss。时间戳数字对不同时区开发者都不友好,ISO 8601 带时区信息,前端直接用,解析无歧义。 - ID 类型统一:如果主键是数字,全用数字;如果用了 UUID/雪花 ID,全用字符串。最怕同一个系统里
orderId一会儿是12345,一会儿是"a1b2c3...",前端类型定义直接崩。 - 属性命名统一 camelCase:JSON 序列化时保持代码风格一致,前端也少做一层转换。注意:如果用了某些数据库下划线命名,序列化层要自己映射,别让下划线字段直接露给客户端。
- null 和空数组的意义要区分:列表接口空数据返回
[],不要返回null;可选字段无值返回null,不要返回""或省略字段。三者混用会让前端的判空逻辑变成灾难。规范里必须写明。
6. API 会老,但接口不能乱改:版本管理与兼容策略
接口一旦上线,就有人依赖它。改接口是所有后端最头疼的事,尤其当你不知道谁在调、用的什么参数时。版本管理是给接口留一条退路,让大家敢迭代。
6.1 版本放 URI 还是 Header?我的取舍
两种主流方案:
- URL 版本:
/v1/users,/v2/users - Header 版本:
Accept: application/vnd.myapp.v1+json
URL 版本的好处是直观,任何人打开网络面板或文档都能看到当前调的是哪个版本,调试、日志、排查成本低。缺点是 URL 被版本号污染,但我觉得这对绝大多数业务系统都不是问题。
Header 版本的好处是 URL 干净、同一套资源可以同时支持多版本,但调试极度不方便,网关层要解析 Accept 头,前端还得保证每个请求都带对头。我自己的观点:面向外部开放的平台 API 考虑 Header 版本;内部服务、微服务间调用直接 URL 版本。大部分团队选 URL 版本就行,别把事情搞复杂。
版本号从 v1 开始,不要从 v0 开始,也不要直接不写版本让所有人都用默认版本。没有版本号的 API 一旦被大量调用,后续升级就是一场灾难。
6.2 哪些改动算破坏性变更?
破坏性变更的判断标准很简单:不升级客户端,客户端会不会出错? 只要会出错,就是破坏性变更,必须走新版本或者和调用方充分沟通。
具体来说,这些改动大概率是破坏性的:
- 删除或重命名已有字段
- 改变字段类型或含义(比如
total从 int 变成 string) - 改变 URL 或请求方法(
POST /orders改成PUT /orders) - 改变分页参数语义(
page从 0 开始改成从 1 开始) - 改变错误码的含义或响应结构
这些改动通常是"增强型"的,可以原地升级:
- 新增可选字段(客户端不确定时忽略)
- 新增接口端点(不与原有重名)
- 扩充枚举值(除非客户端有白名单校验)
- 放宽校验规则(把必填改成选填,要谨慎,可能影响下游数据处理)
6.3 兼容性保护的实操手段
我在团队里强推三条规则:
- 字段只增不改不删。新字段可以加,老字段哪怕写错了也别删,最多标注 deprecated,然后提供新字段替换。字段删除要至少等一个完整的大版本周期,不能这个月加下个月删。
- 用
_deprecated或文档标注替换关系。接口文档里把废弃字段标出来,写清"请使用xxx字段代替",给前端足够的过渡期。在代码层可以对废弃字段打日志,统计还有多少调用方在用,数据说话,别拍脑袋下架。 - 保留旧版本至少 N 个版本周期。我的项目里约定:新版本发布后旧版本至少保留 6 个月或 3 个小版本,到期后通知调用方迁移,再择机下线。不要为了"整洁"立刻删旧版本,生产环境稳定性优先。
7. 我现在的接口套模板:可直接抄走的配套规范
前面聊了原则,最后给一套可以实际落地的模板。这部分是"拿来即用"的内容,也是我每次开新项目时直接复制的基底。
7.1 一个完整的接口设计示例
以电商系统的订单模块为例,看一个规范化的接口从 URL 到响应长什么样:
这个示例里体现了几个要点:资源复数命名、版本号在 URL、分页参数统一、状态码语义化、动作端点控制数量。照着这个模板写接口,基本不会跑偏。
7.2 接口上线前的评审检查表
每次新接口上线前,评审时过一遍这个清单,能拦住大部分问题:
| 检查项 | 说明 |
|---|---|
| URL 是否只用了名词? | 动词是否过多?如果是,考虑 resource 化 |
| 是否统一复数命名? | 团队选定后,别今天单数明天复数 |
| 方法选择是否匹配语义? | GET 绝不改数据;更新选 PUT 还是 PATCH 明确了吗 |
| 状态码用得准吗? | 200/201/204/400/401/403/404/409/422 是否区分清楚 |
| 错误码是否机器可读? | 不要纯数字,尽量用 NOT_FOUND 这种语义化字符串 |
| 分页参数是否统一? | page/page_size 还是 cursor?文档同步了吗 |
| 时间格式是否统一? | 是否统一 ISO 8601,带不带时区 |
| 响应字段类型是否稳定? | null 和空数组是否语义清晰 |
| 是否做了字段白名单? | sort、fields 这种参数必须服务端校验 |
| 是否考虑幂等? | 创建类接口要不要加 Idempotency-Key |
| 是否标注了 deprecated? | 有废弃字段时是否提前通知 |
这张检查表我打印出来贴在工位旁边,评审时一个一个对。写接口的人自己先自查一遍,评审效率会高很多。
7.3 从代码层面落地的两个小建议
最后分享两个代码层面的实践心得。第一,所有接口的分页、时间格式化、错误码映射都封装成公共函数或中间件,不要把 total 和 page_size 的计算逻辑在每个接口里复制一遍,后期改分页策略时你会庆幸当初统一封装了。第二,错误处理的兜底不能省:全局异常处理器一定要有,否则任何没被你 try-catch 住的异常都会变成裸的 500,然后前端看到一堆堆栈信息,这种体验一次就够让人记住了。
接口规范本质上是团队契约,最怕的不是规范不够完美,而是每个人各写一套。把这些约定沉淀成文档、变成检查表、放进代码模板里,新同事入职一两天就能上手,而不是靠老同事口口相传"我们那边有点特殊"。这也是我在团队推这套规范的初衷:少一点惊喜,多一点可预期。