前两年团队做接口联调时,我发现一个特别普遍的现象:不少后端同学把“RESTful”理解成了“URL长得好看”。能精准区分GET和POST,却搞不清PUT和PATCH到底该用哪个;返回码一律200,报错也靠code字段硬扛;更别谈什么无状态、可缓存这些约束。标题这句话“什么是RESTful风格的API设计”看起来基础,但真要系统讲透,反而比一些框架源码更值得聊。这篇文章我就从概念起源、六大约束、资源设计、方法语义、实操流程到踩坑排查,把RESTful设计完整梳理一遍。适合需要写接口的开发者、刚接触前后端协作的新人,以及想优化存量接口设计的人。
1. 内容整体设计与思路拆解
1.1 REST到底解决的是什么问题
回到2000年,Fielding博士在论文里提出REST(Representational State Transfer,表现层状态转移)时,针对的其实是传统RPC风格接口的痛点。早期很多接口是这么设计的:/getUserInfo、/createOrder、/deleteGoods,动词塞进URL,一个资源一套接口,随着业务膨胀,接口数量爆炸,协作成本极高。REST的核心思路是反过来的——资源是名词,HTTP方法才是动词,URL只负责定位“资源”,GET/POST/PUT/DELETE负责表达“对这个资源做什么操作”。
这个转变带来一个直接收益:接口从“业务的动词清单”变成了“资源的统一操作面”。比如用户这个资源,不管你是查询、新增、改昵称还是注销,URL都围绕/users展开,方法区分动作。后端只需要维护一套路由体系,前端也能靠“名词+方法”的直觉猜出大部分接口的含义,联调效率自然不一样。
1.2 为什么很多团队学完还是一堆坑
我观察下来,不少团队不是不知道REST概念,而是把“风格”当成了“规范全集”,忽略了REST核心的三个词:资源、表述、状态转移。资源好理解,表述是指客户端和服务器传递的其实是资源在某时刻的“快照”(比如JSON数据),状态转移是指客户端通过HTTP方法让服务器端的资源状态发生变化。这三个词串起来才是完整的REST视角。
同时REST强调“超媒体即应用状态引擎”(HATEOAS),意思是服务器返回的资源表述里,应该带上下一步可执行的动作链接,让客户端跟着链接走而不是硬编码URL。实践中大多数业务系统都没做到HATEOAS,因为代价高、收益不明显。所以在落地时我们要分清“理想REST”和“务实REST”:完全照搬论文会让团队痛苦,完全抛开又会退回RPC老路。权衡之后,多数团队的可行做法是:严格用名词化URL和HTTP方法语义,状态码用到位,无状态和可缓存尽量贴近,HATEOAS按需裁掉。这是全文讨论的基线。
2. 核心概念前置:资源、表述、无状态
2.1 资源与表述的关系
先聊资源和表述。一个资源不是数据库里那张表,而是“可以被命名并暴露给客户端的一个业务实体”。以订单为例,订单是资源,/orders/10086定位到它,但返回给客户端的是它在某一时刻的JSON表述。同一个资源可以有多种表述:内部用XML、外部用JSON、管理端用CSV下载,资源没变,变的是表述形式。客户端通过Accept请求头声明想要哪种表述,服务器通过Content-Type告诉客户端返回的是什么格式。
这一点新手最容易忽略。不少团队把“返回结构”当成一件约定俗成的事,接口文档里直接写死“返回字段如下”。但REST的思路是:资源模型稳定,表述层灵活。哪天对接方要求多一个字段、少一个层级,正确做法是调整表述适配层,而不是把资源模型改了。我在一些项目里见过接口文档里出现“下单传参order_status_code”,这就是资源模型和表述混为一谈的典型问题。
2.2 无状态约束的真正含义
无状态(Stateless)是REST里最容易被误解的词。它不是说服务端不能存任何状态,而是说请求的上下文必须由客户端完整携带,服务端不靠会话上下文来理解请求。用户点击“查看购物车”,请求里要带着用户身份的令牌;连续操作的中间状态,也保存在客户端或独立的缓存服务里,不能依赖服务端Session。
这个约束的直接价值是水平扩展容易。服务端没有会话黏性,前面挂N个实例,负载均衡随便轮询,哪个实例处理请求都不影响正确性。对运维来说这是实打实的红利。落地时注意:用户登录态怎么办?主流做法是Token机制,JWT这类自包含令牌尤其适合,服务端验签即可,不需要存会话。
2.3 为什么统一接口是REST的命根子
REST的四个约束:资源标识、资源表述、自描述消息、超媒体驱动,合起来叫“统一接口”。翻译成大白话就是:所有资源都用同一套路数来操作,客户端对A资源和B资源的理解方式完全一致。自描述消息也值得单独说——请求里应该带够足够的元信息(方法、Content-Type、Accept、认证头),让中间层和服务端不需要额外“查文档”就能处理消息。
现实里最常见的破坏统一接口的做法,就是“动词+URL”。比如/users/login这种接口,一眼看过去就知道团队没有理解REST的意图。登录其实可以建模成“会话资源”的创建,POST/sessions。但老实说,很多团队觉得这样绕,直接用/auth/login也无可厚非。我的建议是:纯业务资源严格REST,认证这类基础设施接口保持简洁约定。这里没有绝对对错,关键是团队内部统一标准。
3. 资源设计与URL命名规范
3.1 URL只放名词,且用复数
资源定位是REST设计中最先落地也最能体现功力的部分。一条核心原则:URL用名词不用动词,用复数不用单数。
对比一下:
/getUserInfo→/users/123/createOrder→ POST/orders/deleteGoods?id=5→ DELETE/goods/5
复数形式的好处是集合和单数的关系清晰。/users是用户集合,/users/123是集合里的单个用户。新增用户就是向集合POST一个表述,删用户就是对单个资源发DELETE。整个资源模型很自然。名词的选择也有讲究,业界基本默认用英文名词,不出现拼音和缩写混用。
3.2 子资源的层级表达
现实业务必然有嵌套资源。一个用户有多张银行卡,表示层级关系时用/users/123/cards,语义是“属于用户123的银行卡集合”。这里有个值得思考的问题:层级要不要无限制嵌套?
我见过的经验是两层就好,最多三层。因为层级越深,接口越难维护,前端拼URL也容易出错。而且层级的本质是“归属关系”,但很多业务关系并不是严格的父子关系。比如“某个订单里的商品”,商品本身是独立资源,如果纠结于从订单找到商品,可以用查询参数代替层级:/orders/10086/items可以,但如果商品要被多个订单引用,更合理的做法是/order-items?order_id=10086。所以设计子资源前先问一句:这个子资源离开父资源还有独立生命周期吗?如果有,就不要嵌套,用查询参数关联。
3.3 查询参数做过滤、排序、分页
集合型的GET接口,几乎都要支持过滤、排序、分页,标准做法是全部放查询参数。
- 过滤:
GET /users?role=admin - 排序:
GET /users?sort=-created_at负号代表倒序 - 分页:
GET /users?page=2&size=20或GET /users?offset=20&limit=20
两种分页风格各有拥趸。基于页码的page/size适合后台管理类系统,人肉翻页方便;基于游标的cursor适合信息流,因为新数据插入不会导致翻页错乱。要注意的是分页响应不要只返回数据数组,最好把总数、页码、每页数量一并返回,或者返回下一页的游标。前端渲染分页组件时需要知道总数,否则只能再额外调一次COUNT接口,浪费性能。
3.4 一个完整资源URL设计示例
以电商系统的商品和订单为例,设计一组合理的URL:
| 功能 | 设计示例 | 说明 |
|---|---|---|
| 商品列表 | GET/products?category=phone&sort=-price&page=1&size=20 | 过滤+排序+分页组合 |
| 商品详情 | GET/products/52 | 定位单个资源 |
| 创建商品 | POST/products | 请求体是商品JSON表述 |
| 更新商品 | PUT/products/52 | 全量更新 |
| 部分更新 | PATCH/products/52 | 只传需要改的字段 |
| 删除商品 | DELETE/products/52 | 标记删除或物理删除均可 |
| 订单列表 | GET/orders?status=paid&cursor=xxx | 游标分页 |
| 订单详情 | GET/orders/10086 | 单个资源 |
| 订单条目 | GET/orders/10086/items | 子资源集合 |
4. HTTP方法与状态码语义
4.1 五种核心方法的分工与语义
HTTP方法在REST里就是“操作原语”,每种方法的语义都是公共约定的,理解透了你设计接口会顺很多。
- GET:读取资源表述,必须是安全的(不改变服务端状态)和幂等的(重复调用结果一致)。实际开发中见过有人用GET接口带JSON body做复杂查询,虽然HTTP协议没禁止,但绝大多数代理、缓存、日志组件不会处理GET body,强烈不建议。
- POST:在集合上创建新资源,或触发一个“难以用PUT/PATCH表达的操作”。语义上是非幂等的——提交两次订单就是两张订单。
- PUT:全量更新指定资源,用请求体里的完整表述替换服务端资源。幂等。
- PATCH:部分更新指定资源,只需要传要改的字段。PATCH的幂等性需要看实现,严格语义不保证幂等,但通常业务上按幂等设计更安全。
- DELETE:删除指定资源,幂等。
有个实操中经常被问到的问题:POST和PUT到底啥区别?最简单的判断标准:客户端知道目标资源的URL吗?知道就PUT(对已有资源整体替换),不知道就POST(交给服务端生成新资源)。比如创建订单时URL是/orders,服务端生成订单号,此时POST;订单详情更新时URL是/orders/10086,客户端明确知道要动哪个资源,此时PUT或PATCH。
4.2 状态码别只用200和500
状态码是REST设计里信息量最密集的部分,但实际联调时我看到最多的就是:成功200、失败500,其他一概不用。这就把HTTP状态码最重要的语义空间浪费了。
常用状态码速查表:
| 状态码 | 含义 | 典型场景 |
|---|---|---|
| 200 OK | 请求成功 | GET/PUT/PATCH成功 |
| 201 Created | 资源创建成功 | POST创建新资源 |
| 204 No Content | 成功但无返回体 | DELETE成功、PUT全量更新成功 |
| 400 Bad Request | 请求体格式错误或参数缺失 | 就客户端说“你传错了” |
| 401 Unauthorized | 未认证 | 没带Token或Token失效 |
| 403 Forbidden | 已认证但无权限 | 普通用户访问管理员接口 |
| 404 Not Found | 资源不存在 | URL拼错或数据被删 |
| 409 Conflict | 资源状态冲突 | 重复创建、版本冲突 |
| 422 Unprocessable Entity | 请求语义错误 | 校验失败,比如手机号格式错 |
| 429 Too Many Requests | 触发限流 | 刷接口太频繁 |
| 500 Internal Server Error | 服务端内部错误 | 代码异常、数据库超时 |
特别注意:400和422的区别。400偏重“HTTP层面没法处理”,比如json解析失败;422偏重“业务校验没过”,比如用户名已被占用。两者都返回时,响应体里应带错误码、错误信息、可读的提示文案。200和201的区别也常被忽略,创建资源时返回201并带上新资源URL(Location响应头),这是REST的推荐做法,但很多团队一律返回200,前端也习惯了。建议从新建接口开始改习惯,老接口逐步迁移。
4.3 幂等性:REST安全性的基石
幂等性这个概念值得展开讲。一个操作幂等,意思是执行一次和执行N次的效果完全一样。GET天然幂等,PUT天然幂等,DELETE按理说幂等(删不存在的资源可以返回404,状态没变)。为什么看重幂等?分布式环境下,客户端可能因为超时重试、消息队列重复投递而重复调用接口。如果下单接口不幂等,一次重试就多一个订单;有了幂等设计,重复请求只返回第一次的结果。
落地幂等有几个常见做法:幂等键——客户端在请求头里带一个唯一标识(比如Idempotency-Key),服务端用这个键做去重,第一次处理完存下结果,同一个键再来直接返回缓存结果。唯一约束——数据库给业务唯一键(订单号、交易流水号)加唯一索引,重复插入直接被数据库拦下来。状态机校验——比如订单状态必须是“待支付”才能执行“支付”动作,状态流转后重复请求会因状态不匹配而失败。
5. 从零设计一套RESTful API的实操流程
5.1 第一步:梳理业务资源清单
动手写路由前,先别急着建Controller。把业务拆成“名词清单”,每个名词就是一个候选资源。比如一个博客系统,名词有:用户、文章、评论、标签、分类、点赞记录。把这些名词列出来后,再按生命周期判断哪些是独立资源、哪些是子资源。文章的评论可以建模成子资源/articles/1/comments,因为评论离开文章没有独立意义;标签就得独立成/tags,因为标签可以被多篇文章共用。
这一步想清楚,后面所有URL设计都顺了。我见过团队跳过建模直接写路由,最后接口URL五花八门,前端文档翻到吐。
5.2 第二步:为每个资源确定方法与状态码
资源清单出来后,给每个资源配一张“方法-行为-状态码”表。以订单为例:
| 方法 | 行为 | 成功状态码 | 失败状态码 |
|---|---|---|---|
| GET /orders | 查询订单列表 | 200 | 400参数错误 |
| POST /orders | 创建订单 | 201 | 422库存不足 |
| GET /orders/10086 | 查单详情 | 200 | 404订单不存在 |
| PATCH /orders/10086 | 修改收货地址 | 200 | 422地址不规范 |
| PUT /orders/10086 | 全量更新(用于对账修正) | 200 | 409订单状态锁定 |
| DELETE /orders/10086 | 删除/作废订单 | 204 | 409订单已支付不可删 |
配表的好处是接口的行为边界一眼看清,也方便后期评审时逐项讨论。
5.3 第三步:过滤、分页、排序和字段选择
集合接口几乎是高频流量入口,参数设计必须提前约定。通用的建议是:
- 过滤参数名和资源字段保持一致,避免同一个字段叫法不一
- 排序用
sort=field1,-field2格式,支持多个排序条件 - 分页默认值要定:page从1开始,size建议默认20、最大100,防止拉全量
- 字段裁剪用
fields=id,name,avatar,减少大对象响应体,对移动端尤其友好
这步容易被当成“小事”忽略,但实际上前后端联调中大多数争议都出在这。早定规范,省掉大量口水。
5.4 第四步:设计统一响应结构
REST响应到底要不要包一层data/code/message?我见过两种极端。一种是完全裸返回,资源数组直接给,HTTP状态码就是唯一语义。另一种是包一层大壳,code=0代表成功,HTTP状态码永远200。前者更贴近REST原教旨,但异常信息不好统一表达;后者方便前端统一处理,但把HTTP状态码废了。
我的折中方案:成功响应裸返回资源表述,失败响应统一错误结构。比如:
{ "error": { "code": "ORDER_STATUS_CONFLICT", "message": "订单已支付,无法删除", "details": {} } }这样成功路径简洁,失败路径有统一结构,日志和前端报错提示都容易做。是否要包data壳,可以看团队习惯,但我建议至少别把HTTP状态码全部废弃,否则REST语义优势就没了。
5.5 第五步:定义版本策略
接口一定会演进,版本策略要在第一版就定好。三种主流方式:
| 方式 | 示例 | 优缺点 |
|---|---|---|
| URL路径版本 | /v1/users、/v2/users | 最直观,适合外部开放API,但版本多了URL丑 |
| 请求头版本 | Accept: application/vnd.xxx.v2+json | 干净但排查问题不直观 |
| 查询参数版本 | /users?version=2 | 实现最简单,但容易被人忽略 |
对外部不可控的客户端,我建议URL路径版本,因为最不容易配错也最方便做兼容;内部系统用查询参数或请求头都可以,团队约定统一就行。
6. 工具链与周边配套:文档和调试
6.1 用OpenAPI描述你的RESTful API
RESTful API设计得再好,没有一份可靠文档,协作体验也会崩塌。业界事实标准是OpenAPI Specification(原Swagger)。它用一份YAML或JSON描述所有路径、方法、参数、请求响应结构,然后生成文档站点、客户端SDK和Mock服务。定义OpenAPI文件的过程,本身也是检查资源设计的好机会:写paths时你会发现URL规不规范、参数全不全、响应结构清不清晰。
实践中建议用“代码注释生成+自动校验”的方案:Spring Boot配合springdoc、FastAPI配合自动生成,Go的gin-swagger差不多一个套路。关键是CI里加一步OpenAPI规范校验,格式错了直接挂流水线。
6.2 调试工具怎么选
接口调试日常主要用这几类:
- curl:命令行快速验接口,适合脚本和排查问题
- Postman:功能全,集合管理、环境变量、自动化测试都方便,适合联调和团队分享
- Apifox:Postman和Swagger的本地化合体,中文团队用得不少
- 浏览器开发者工具:看请求头、Cookie、状态码最直接
个人习惯是日常联调用Apifox,遇到诡异问题开浏览器DevTools看完整请求链路,排查信息更全。日志侧建议接口层统一打点,记录method、path、status、耗时、error_code,这些数据对排查线上问题价值极高。
7. 常见问题与排查技巧实录
7.1 到底要不要把动词放进URL?
遇到过最经典的灵魂拷问:“导出报表不是动词吗?它也得有接口啊。”确实有一部分操作没法单纯靠“增删改查”表达,比如“导出”、“发送短信”、“触发邮件”。我的处理思路是先问能不能映射到资源状态变化上。导出报表→GET/reports/xxx/export把export当动作参数;发送短信→POST/sms_messages创建一条发信任务,本身就是资源。如果实在绕不开通用动作,比如“批量审核”,用自定义方法也不是十恶不赦,但要规范命名:POST/orders/batch-review,并明确这是团队的便利约定,不是REST标准。核心是:默认贴近REST,必要时保留例外,但例外要有明确清单。
7.2 嵌套资源到底该展平还是该嵌套?
有人习惯把所有关联都做成嵌套,/users/123/orders/456/items/789,诚然层级表达了归属,但这种URL又长又脆,任何一个中间层级字段变了URL就失效。排查时会很痛苦——到底是用户不存在还是订单不存在还是商品不存在?我的建议是:非要表达归属时按“业务聚合根”来定,一个聚合根下最多两层子资源;跨聚合的关联一律展平,用查询参数连接。比如查用户下的订单,用/orders?user_id=123而不是/users/123/orders,这样订单就是独立资源,可以单独被其他系统引用。
7.3 部分更新到底用PUT还是PATCH?
每次评审接口必争问题。我的建议分三种:整个对象替换用PUT,且要求客户端传完整资源;只改一两个字段用PATCH;不确定且团队无历史包袱,直接用PATCH。但有一个细节容易被坑:PATCH在部分框架里默认是全量更新的别名,比如某个知名框架的@PatchMapping实际执行的就是把空字段置空,操作前一定要确认框架行为。
7.4 文件上传算不算RESTful?
上传文件的接口经常被问。本质上文件是资源,所以POST /files没什么不对,响应返回文件ID和访问URL。大文件分片上传则是把“上传任务”变成资源:POST /upload_tasks创建任务,PUT /upload_tasks/xxx/parts上传分片,POST /upload_tasks/xxx/complete完成合并。看起来比RPC风格的/uploadBigFile复杂,但可恢复、可查询进度,其实是更健壮的建模方式。
7.5 错误信息怎么写才能帮到前端?
错误响应除了给机器读的code,一定要给人类读的message,最好再带一个hint或details字段告诉调用方怎么修。比如“用户名至少6个字符”比“参数错误”有用十倍。同时注意别把服务端内部异常堆栈直接暴露给客户端,那既不安全也不利于排查。服务端要做的,是把标准化错误码、日志traceId、用户提示文案三层拆开。
7.6 查询参数与请求体的边界
一个常见边界问题:GET能不能带body?HTTP规范没禁止,但实践上通信链路里的代理、缓存组件普遍忽略GET body,服务端框架也可能不解析。所以复杂查询一律推荐GET+查询参数;再复杂就把查询条件建模成“查询任务”资源,POST后台异步执行,通过任务ID轮询结果,这种方案在海量数据导出场景非常好用。
7.7 状态机与资源状态设计
RESTful接口的易用性很多时候取决于资源状态设计。拿订单举例,如果状态字段是一堆无序字符串,那前端拿到数据根本不知道下一步能不能取消。正确做法是做显式状态机:定义状态集合和允许的转移。设计时可以把状态转移做成资源行为的一部分——比如“支付”在业务上就是让订单从pending_payment变成paid。两种建模思路都行:一种是把状态转移作为字段更新(PATCH修改status),另一种是单独的动作接口(POST/orders/10086/pay)。前者更REST,后者更显式,团队按自己的理解水平选择就好,关键是文档要写清楚状态流转图。
8. 我的实际体会与收尾建议
踩过这么多坑之后,我个人对RESTful设计的感受是:它不是什么高深理论,而是一套“约束换取一致性”的工程方案。你牺牲掉随手命名接口的自由,换来的是路由清晰、定位直观、前后端沟通成本下降。设计一套接口之前,多花半天做资源建模,比写三百行Controller然后返工要划算得多。
有几个小习惯强烈建议养成:第一个,新建接口前先对着资源清单问一遍“这个方法对吗、状态码对吗、要不要幂等”;第二个,把响应结构和错误结构做成团队模板,先统一再发挥;第三个,OpenAPI文件不是文档而是契约,前端可以基于它自动生成请求代码,后端可以用它做接口测试,别让它躺着吃灰。
这个内容其实还可以继续往深处聊,比如REST与GraphQL、gRPC的对比选型,比如超媒体驱动在实际业务里怎么低成本落地。但先把基础这一层扎实了,你的接口设计水平已经能超过大多数团队平均水平了。