ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

RESTful接口设计:资源、路径、方法的对应关系全拆解

RESTful接口设计:资源、路径、方法的对应关系全拆解

RESTful 接口这块,很多团队聊起来能聊一下午,但真到写代码的时候,最容易出问题的反而是最基础的三个词:资源、路径、方法。我见过不少前后端联调翻车的现场,根因几乎都不是框架不会用,而是这三者的对应关系没理清楚。这篇文章就专门拆解 RESTful 里“资源、路径与方法”的对应关系,从设计原理讲到落地细节,再给一套可以直接抄走的接口设计对照表,适合刚入门的后端开发者,也适合不想再看各种“漂亮但没卵用”文档的懒人工程师。

1. RESTful 的核心:先把“资源”这个概念讲透

1.1 资源是名词,不是动作

RESTful 世界里最重要的一个认知转变是:你在网络里传输和操作的是“资源”,而不是“功能”。资源是一个名词,比如订单、用户、商品;功能才是一个动词,比如创建、查询、删除。

很多同学一开始设计接口时,下意识会写/getUserList、/createOrder这样的路径。这就是典型的把动词塞进了路径。RESTful 的思路反过来:路径只负责描述“哪个资源”,至于“对这个资源做什么”,交给 HTTP 方法(GET、POST、PUT、PATCH、DELETE)来表达。

打个比方,你去银行办事。银行大厅里有很多柜台,每个柜台上面写的是“对公业务”“个人业务”“外汇业务”,而不是写“取钱”“存钱”“转账”。你走到“个人业务”窗口,然后柜员才问你是要取还是存。这里的“窗口”就是资源,取或存就是方法。路径就是柜台标识,方法才是操作指令。

资源还有一个特性:它可以有多种表示形式。同一个订单资源,客户端可能想要 JSON 格式,也可能想要 XML 格式;管理端可能想要包含全部字段,列表页可能只需要摘要字段。资源本身是抽象概念,URL 只是它的访问入口,响应的内容则是它的某一种表征。理解这一点,后面设计路由和响应体时会开放很多。

1.2 资源的粒度与层级:集合资源与单例资源

资源从粒度上可以分成两类:集合资源和单例资源。

集合资源代表一类东西的全体,URL 通常用名词复数形式,比如/orders表示所有订单,/users表示所有用户。单例资源代表集合中的某一个具体对象,URL 是在集合路径后面加上唯一标识,比如/orders/10086。

层级关系则表示资源的从属关系。比如订单里有订单项,那么订单项就天然是订单的子资源,路径可以设计成/orders/{orderId}/items/{itemId}。这种嵌套路径适合表达“脱离父资源就没有独立存在意义”的资源。比如items脱离了order单独存在就没有业务意义,所以嵌套在order下面。而用户user就不应该嵌套在订单下面,因为用户不是订单的一部分,它有自己的独立生命周期。

这里有个常见的争执:嵌套到底做多深?我的经验是尽量控制在两层以内。/orders/{orderId}/items/{itemId}/details这种三层甚至四层的嵌套,一方面让 URL 变得极长,另一方面也让服务端路由匹配变得脆弱。实践中如果一个资源要穿透三层以上才能定位到,十有八九是资源建模出了问题,应该把它拆成独立的顶层资源,或者直接用查询参数来表达筛选关系。

1.3 路径是资源定位,不是操作指令

路径的职责非常单一:定位资源。它不应该包含协议版本、不应该包含端口号、不应该包含动作词、原则上也不应该包含“Adapter”“Service”“Controller”这类实现层词汇。

有些团队喜欢在 URL 里带上.do、.action、.php这样的后缀,这是早期遗留习惯。RESTful 要求 URL 只表达资源位置,不表达资源用什么技术栈实现。/orders就是/orders,至于背后是 Java 还是 Node,那是实现细节,客户端不关心。

路径的大小写和尾部斜杠也需要约定。严格来说,URL 是区分大小写的,/Orders和/orders是两个不同的资源标识。团队必须定一个统一规范,我的习惯是:路径全部使用小写字母加连字符,比如/order-items而不是/orderItems。尾部斜杠也容易出问题,/orders/和/orders在很多框架里被视作不同路由,最好统一约定不带尾部斜杠,同时让网关层做一次 301 跳转纠偏。

2. HTTP 方法与资源操作的对应关系

2.1 五大核心方法的 CRUD 映射表

HTTP 协议里方法很多,做 RESTful 接口设计时真正需要精通的是五个:GET、POST、PUT、PATCH、DELETE。它们的语义和数据库操作可以建立一张清晰的对照表:

HTTP 方法语义典型场景幂等性对应 SQL 类比
GET查询资源获取列表、获取详情幂等SELECT
POST在集合下创建新资源,或触发一个非幂等操作创建订单、创建用户不保证幂等INSERT
PUT全量替换一个已知资源全量更新用户资料幂等UPDATE(覆盖)
PATCH部分更新一个已知资源只修改用户的手机号幂等UPDATE(部分字段)
DELETE删除一个资源删除订单、删除用户幂等DELETE

这张表我建议贴在工位上。实际评审接口设计时,先问这个接口要对资源做什么,再决定用哪个方法。很多人 POST 一把梭,把所有操作都塞给 POST,短期内能跑,但后续缓存、重试、日志排查、网关策略全都难以精准处理。

GET 是最容易理解的方法,但它有两个隐蔽要求:第一,GET 请求不应当产生副作用,你不能在 GET 的响应里偷偷修改数据;第二,GET 请求是可以被浏览器、CDN、代理服务器缓存的,响应体里最好有明确的Cache-Control头,否则不同节点缓存策略不一致,会出一堆诡异问题。

2.2 PUT 与 PATCH:完整替换和局部更新的关键区别

PUT 和 PATCH 是很多团队最容易搞混的两个方法。两者都是更新,但语义有本质区别。

PUT 是“全量替换”,客户端提交的请求体应该包含这个资源的完整表示。服务器收到 PUT 请求后,原则上会拿请求体里的内容整体覆盖现有资源,请求体里没出现的字段,要么设成默认值,要么被清空。示例:用户资源/users/42,当前字段是{ "name": "张三", "phone": "138..." , "email": "zhang@example.com" },如果客户端发送PUT /users/42,请求体{ "name": "李四" },那么更新后这个资源应该是{ "name": "李四" },另外两个字段应当被清空或重置。

PATCH 是“部分更新”,客户端只发送需要修改的字段,服务器只修改这些字段,其他字段保持原样。同样的例子,如果发送PATCH /users/42,请求体{ "phone": "139..." },那么更新后资源只剩手机号变了,姓名和邮箱都原封不动。

理解这个区别后,接口设计的沟通成本会下降很多。前端同学说“我调了 PUT 但字段丢了我没传的字段没了”,大概率就是语义用错了。后端同学看到这个现象也不用慌张,先确认前端用的是 PUT 还是 PATCH。另外,PATCH 请求体建议用 JSON Merge Patch 的形式,也就是普通 JSON 对象,哪个字段出现就更新哪个字段,不要引入$patch这类复杂操作指令,增加理解和排障成本。

2.3 POST 的“模糊地带”:集合创建与自定义动作

POST 在 RESTful 里是最灵活但也最容易滥用的方法。它的核心语义有两个:

第一个语义是在集合资源下创建新资源。比如POST /orders表示在订单集合下创建一个新订单,成功时返回201,响应头带Location指向新资源的路径,通常也就是GET /orders/{id}。

第二个语义是触发一个无法被其他方法清晰表达的操作。虽说 RESTful 强调资源建模,但真实业务里总有兜不住的地方。比如“取消订单”,你可以把它建模成PATCH /orders/{id},请求体里传{ "status": "cancelled" };但如果取消订单是一整套包含复杂校验、库存回补、退款触发的业务动作,把它强行拆成 PATCH 会导致请求体里塞进一坨专用字段,反而不如直接设计成POST /orders/{id}/cancel来得清楚。

所以我的态度是:动词路径不是绝对不能出现,但它应该是“最后手段”。能用资源加方法表达的,优先用资源加方法;确实表达不了的,再允许自定义动作点,约定只使用 POST。一个接口里如果出现三四个/xxx/cancel、/xxx/refund、/xxx/submit这种路径,就要回头审视资源建模是不是有问题了。

POST 还有一个特性必须记住:它不保证幂等。也就是说同一个 POST 请求连续发两次,会创建出两个订单。这个特性在分布式系统和网络重试场景下特别危险。后面我会单独展开幂等性的工程处理。

3. 路径设计规则:静态标识与查询参数如何分工

3.1 路径表达位置,查询参数表达条件

路径和查询参数的分工,我用一句话概括:路径说明你在大楼的哪一层哪个房间,查询参数说明你要在房间里筛选什么样的文件。

路径里应当放资源的“位置标识”,也就是那条资源的唯一 ID 或天然层级关系。例如/users/42里的42是用户的唯一标识;/orders/10086/items/7里的10086和7是层级定位。这些标识删掉任何一个,你都无法确定客户端想要的是哪条资源。

查询参数应当放“条件”,也就是对资源集合的筛选、排序、分页、字段投影。比如GET /orders?status=paid&sort=-createdAt&page=2&pageSize=20,表达的是:从订单集合里筛选出已支付状态的订单,按创建时间倒序排列,取第二页,每页 20 条。

很多团队犯的错是路径和查询参数打架。比如GET /orders/10086和GET /orders?id=10086表达了同一个意思。前者是路径定位,后者是条件筛选,虽然结果一样,但语义层级完全不同。我的建议是:主键定位一律走路径;按业务字段筛选一律走查询参数。这样路由表一目了然,日志里也能快速区分“你在看某个资源详情”和“你在跑一个列表搜索”。

3.2 复杂筛选、排序、分页到底放哪边

列表接口做筛选时,查询参数是最自然的位置。但查询参数写多了也会失控,一个/orders后面跟七八个参数,既不美观也难排查。我的经验是分层:基础筛选条件走查询参数,复杂条件需要本地缓存或聚合时不强行塞 URL。

比如按状态筛,?status=paid没问题;按时间范围筛,?startTime=...&endTime=...没问题;但如果你要支持一套完整的弹性查询语句,比如条件组嵌套、多个条件之间 AND/OR 混合,就不要硬塞到 URL 里。这时候可以设计一个专门的高级搜索资源,比如POST /orders/search,把查询条件放在请求体重,返回列表结果。虽然严格说这并不完全符合 RESTful 教条,但工程上它是可维护、可演进的设计。

排序参数建议写法是sort=field1,-field2,-表示倒序。分页参数建议统一是page和pageSize,从 1 开始计页。不要一个接口用page,另一个接口用offset,前后端联调时最烦这种不统一。

另外,查询参数的渲染有个易错点:数组参数怎么传。常见做法是?tag=a&tag=b或?tag=a,b。我建议在团队规范里定死其中一种,且在服务端用逗号分隔方案时要注意 URL 编码问题。

3.3 路径设计的经典反例对照

直接看一组反例,这些路径在真实项目里都很常见:

反例路径问题所在标准做法
/orders/getAllOrders路径里带动词“get”,操作语义被写死GET /orders
/orders/list“list”不是资源,只是表达列表功能GET /orders
/createOrder创建动作被写进路径,方法却没被使用POST /orders
/user/addNewUser重复表达 + 动词POST /users
/api/orders/detail?id=1详情这种修饰词没必要出现,主键应该放路径GET /orders/1
/orders/update.php技术栈后缀泄漏PATCH /orders/{id}

这些反例背后都有一个共同心态:希望把接口“写得更清楚”。但真相是,动词堆砌不是清楚,是噪音。资源的层级结构本身就是最清晰的表达。你看到DELETE /orders/1不需要任何注释就知道它在干嘛;但你看到/order/deleteOrderById还得花一秒确认这个“删除”是不是真的要删。

4. 状态码与响应语义:怎么验证方法用对了

4.1 HTTP 状态码与业务语义的分层

一个接口光有路径和方法还不够,响应状态码是方法语义的延伸。很多团队偷懒,无论成功失败全部返回200,然后在响应体里塞一个code,这是 RESTful 设计与实际工程落地之间的常见妥协。

我的观点是:底线是不能全部 200。HTTP 状态码负责“传输层的语义”:请求有没有被理解、有没有被授权、资源存不存在。业务码负责“业务层的语义”:这个动作在业务上是否成功、失败的原因是什么。两层应该配合使用,而不是互相替代。

一个正常的分层方式:

  • 请求成功、资源存在:200 OK
  • 请求成功、新资源被创建:201 Created,响应头带Location
  • 请求成功、资源被删除且不需要返回内容:204 No Content
  • 请求语义或参数有误:400 Bad Request
  • 身份未认证:401 Unauthorized
  • 无权限访问资源:403 Forbidden
  • 资源不存在:404 Not Found
  • 方法不被资源支持:405 Method Not Allowed
  • 请求与资源当前状态冲突:409 Conflict
  • 请求体格式正确但语义校验失败:422 Unprocessable Entity
  • 服务端内部异常:500 Internal Server Error

这里要注意区分404与403。很多人用403表示“你找的资源不存在”,这是混淆概念。403核心语义是不允许访问,404是资源根本不存在。某些安全场景下,为了不暴露资源存在性,故意把不该暴露的404返回成403,那是另一回事,需要团队明确约定。

4.2 成功响应的标准形态

成功的响应需要遵循一致的结构,不然前端拿到响应都不知道从哪取数据。我用过比较顺手的成功响应结构有两种。

单资源场景:直接返回资源本体,不需要额外包装。比如GET /users/42成功时返回:

{ "id": 42, "name": "张三", "phone": "138...", "email": "zhang@example.com", "createdAt": "2024-06-01T10:00:00Z" }

列表场景:需要返回分页信息,没有包装就不行了。推荐结构是:

{ "items": [ { "id": 42, "name": "张三" }, { "id": 43, "name": "李四" } ], "total": 100, "page": 1, "pageSize": 20 }

POST /orders成功创建后,返回201 Created,响应头加上Location: /orders/10086,响应体可以返回创建后的完整订单资源,方便前端直接使用。

DELETE /orders/10086成功后,返回204 No Content,响应体为空。不要画蛇添足再返回一个“删除成功”的 JSON,前端看见状态码就够了。

4.3 失败响应的统一格式

失败响应比成功响应更需要统一格式,因为失败场景多,前端每个场景都要区分处理。推荐结构:

{ "error": { "code": "ORDER_STATUS_CONFLICT", "message": "订单当前状态不允许取消", "detail": "只有待支付的订单可以取消" } }

code是给前端程序判断用的,message是给用户看的,detail是给开发者排查用的。这个结构一定要稳定,不然前端每年要写两种错误解析。

失败的响应体不应该携带堆栈信息、SQL 片段、内网 IP 这类敏感内容。我的习惯是:日志里记录完整的堆栈和请求链路 ID,响应体里只放code、message、detail和traceId。这样既方便排障,也安全。

5. 从零设计订单 API:一张表理清路径与方法

理论讲了这么多,来看一个完整的例子。假设我们要为一个电商订单模块设计核心 API,需求包括:订单列表查询、订单详情、创建订单、修改收货地址、取消订单、删除无用的草稿订单、查询订单下的商品条目详情。

5.1 需求拆解与资源建模

先做资源模型拆分。订单和订单项是典型的父子和子资源关系。订单是顶层集合资源,订单项在业务上不能脱离订单存在,所以嵌套在订单下面。收货地址是订单的一个属性字段,不是一个独立资源,所以不单独建路径。取消订单本来是状态流转,可以直接归入订单资源的 PATCH 操作;但取消订单动作里有复杂的业务规则和副作用,为了表达意图,我倾向于使用POST /orders/{id}/cancel作为自定义动作,同时在文档里明确这是一个例外约定。

充值记录、支付单这些也属于订单上下文内的资源,可以独立建模为顶层资源/payments,通过查询参数与订单 ID 关联。这么拆的好处是支付模块后续要单独接回调、单独扩展时,不会动到订单资源的路由结构。

5.2 路径、方法、状态码、请求响应体对照表

功能点路径方法请求体成功状态典型失败状态
订单列表/ordersGET无,查询参数:status、page、pageSize、sort200400
订单详情/orders/{id}GET无200404
创建订单/ordersPOST订单商品明细、收货地址201,Location 指向新资源422、409
修改收货地址/orders/{id}PATCH{ "shippingAddress": { ... } }200404、422
取消订单/orders/{id}/cancelPOST无,或取消原因200409、403
删除草稿订单/orders/{id}DELETE无204404、409
订单项详情/orders/{id}/items/{itemId}GET无200404

这张表可以直接拿去做接口评审提纲。现在逐行分析几个容易出状况的设计决策。

创建订单返回201而不是200,这要求前端必须有能力读Location头,如果你的前端同学比较习惯 response body,就约定响应体里也同时返回完整订单 JSON。取消订单返回200而不是204,因为取消操作通常需要回传订单的最新状态和退款信息。

5.3 请求响应体设计要点

创建订单的请求体示例:

{ "items": [ { "productId": 101, "quantity": 2 }, { "productId": 222, "quantity": 1 } ], "shippingAddress": { "province": "广东省", "city": "深圳市", "detail": "某街道某号" } }

服务端从令牌中确定当前用户,不需要前端传userId。这个细节很重要:路径和请求体里原则上不应该出现能够被上下文推导出的冗余字段,否则前端传错userId就会产生越权风险。

修改收货地址的请求体:

{ "shippingAddress": { "province": "广东省", "city": "东莞市", "detail": "新地址" } }

这里用 PATCH 而不是 PUT,因为只要改一个字段,不能让前端传整个订单的全量数据。同时要提醒前端:PATCH 传null表示要把这个字段置空,如果前端不想改这个字段就不应该把它放进请求体,否则后端会不知道你是在“表达意图为清空”还是“忘了传这个字段”。

取消订单响应体示例:

{ "orderId": 10086, "status": "CANCELLED", "refundAmount": 399.00 }

5.4 实操中的几个隐蔽细节

第一,并发创建订单要防重复。POST /orders 天然不幂等,用户手滑点了两次按钮就建出两单,所以服务端一定要支持客户端传一个Idempotency-Key头(UUID 字符串)。服务端根据这个 key 做一次性校验,相同的 key 在有效期内直接返回首次的结果。网关层也可以加这个能力,但业务侧至少要做到对重复请求返回409 Conflict或幂等处理结果。

第二,列表接口的缓存处理。订单列表是强实时数据,不宜在 CDN 或浏览器缓存太久,建议Cache-Control: private, max-age=0。但不代表列表接口不能缓存,可以用 ETag 做条件请求。第一次GET /orders返回ETag头,第二次请求时前端带If-None-Match,服务端比对数据版本一致就返回304 Not Modified,省流量也省计算。

第三,DELETE 草稿订单和 DELETE 已支付订单的语义不同。草稿订单是未生效的数据,删掉没有副作用;已支付订单不能删,只能取消。所以 DELETE 接口遇到已支付订单应该返回409 Conflict,响应体说明“该订单已支付,不能删除”。

6. 常见设计误区与排查实录

6.1 动词路径、裸 URL 与万能 POST

我评审接口时最常见的神奇路径是/orders/listAll、/orders/detail、/order/operate。把一个资源的所有操作放一个路径上,用operate或类似词做万能入口,前端文档看着是简单,后端逻辑却全糊在这里。

做接口规范的推广时,团队里总会有人跳出来说“这样写也能跑,你管我”。我的应对方式不是强压,而是从三个成本角度解释:第一,日志检索成本——你/order/operate一个接口承载了几种操作,出问题排查时根本不知道哪个参数组合触发了 BUG;第二,缓存成本——GET 语义被阉割,想给列表接口上缓存没法精准命中,因为所有操作都挤在 POST 上;第三,前端协作成本——前端看到/order/operate根本不知道该传什么,文档会越写越长。

把接口按资源和方法的矩阵拆开,每一条路径的职责会变得非常单一。单一职责的路由,测试也好写,文档也好写,出了问题也好定位。

6.2 API 版本管理放在哪:路径与头之争

版本管理是一场常年争论。常见方案有/v1/orders、Accept: application/vnd.orders.v1+json、/orders?version=1三类。

我的经验是:实践中最容易被团队接受的是/v1/orders路径前缀方案。Accept 头方案更“标准”,但浏览器调试、日志分类、网关转发都要做额外适配,对很多团队来讲测试成本太高。查询参数版本方案最省事,但版本信息容易丢,也会污染业务查询条件,不建议。

路径版本一旦定了v1,就要约定升级策略:v1接口保多月供,新功能走v2,老客户端没有能力迁移时v1不能随便删。版本号不要用时间戳,也不要带小版本号v1.2.3。API 版本是契约级别的概念,不是软件发布版本。

6.3 幂等性:为什么 PUT 和 DELETE 可以放心重试,POST 不行

接口幂等性是个容易被忽略的工程问题。幂等的定义是:同一个请求执行一次和执行 N 次,最终结果都一样。PUT 和 DELETE 天然符合这个要求,因为它们的操作对象是“已知资源的完整替换或删除”。你PUT /users/42发两次,资源最终都是请求体里的样子;你DELETE /orders/10086发两次,第一次删掉了,第二次再删,资源仍然不存在,结果感知是一样的,只是第二次的响应可能是404或204,这需要后端做兼容处理。

POST 创建资源不满足幂等性,因为每发一次都会新建一个资源,产生新的 ID。这种接口如果遇到客户端超时重发,就会产生重复数据。排查问题时我经常会看到这样的记录:同一时间点、同一请求内容、两三条相同的数据,时间间隔只有几百毫秒,基本可以断定是重试导致。

解决手段有两个:一是客户端超时后不要盲目重试,先查询是否已存在;二是服务端接收Idempotency-Key头,按照 key 去重。第二个方案更通用。特别提醒:幂等键要放在请求头而不是请求体,因为请求体在网关层做日志脱敏时可能有不同配置。

6.4 排查实录:一次“改个字段改出灾难”的经历

最后分享一个真实踩坑现场。某个项目里有一个用户资料接口,最初设计是POST /updateUserInfo,请求体是一个全量字段都要传的巨大 JSON。某次前端版本升级时漏传了一个字段avatarUrl,后端代码按“缺省即清零”的逻辑处理,直接把线上大量用户的头像清空了。

问题的根源不是前端的失误,而是接口方法用错了。如果当初使用的是PATCH /users/{id},请求体里只放需要修改的字段,漏传的字段天然不会被改动,这种事故根本不会发生。后来我把该项目所有全量更新接口从 POST 改成了 PUT,把局部修改接口统一成 PATCH,约定前端“能不用 POST 就不用 POST”,这类因漏传字段导致的数据覆盖问题从此基本绝迹。

这个案例也说明,RESTful 方法选型不只是“写代码的风格问题”,而是直接与数据的正确性和安全性挂钩。你选择 POST、PUT 还是 PATCH,其实是在选择一种对数据变更范围的承诺。


接口设计走到最后,拼的不是谁背的标准多,而是谁能在具体场景里把“资源、路径、方法”这套三角关系用得自洽。我自己有一个习惯:拿到一个模块,先不急着写代码,而是用表格把资源、路径、方法、状态码列一页纸出来,然后对着这张纸问三个问题——这个路径真的指向一个资源吗?这个方法的语义对这个资源成立吗?错误状态码用户能自己看懂吗?三个问题的答案都清晰,接口设计基本就不会跑偏。这个方法也分享给你,做完规划先给团队成员过一眼,真的能省掉后面无数的联调口舌。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进