接手一个老项目的时候,我看了下接口文档,心里凉了半截。文档里齐刷刷地写着/api/getUserList、/api/doUpdateUser、/api/delOrder,动作写满了路径,一眼望去像是给服务号写开放接口,而不是在做一个前后端分离的产品。我叹了口气,把项目组成员叫到会议室,第一句话就是:“我们先聊聊什么是真正的REST风格。”
这大概是很多团队的真实写照:大家嘴上都在说RESTful,简历上都写着常年使用REST风格设计接口,但真正拿出来的东西,十个里有八个只是“长得像REST”。URL换成了名词、把POST换成PUT就算完成了,却完全没有理解REST作为一种架构风格背后的原则。这篇文章我不打算再给你复述一遍教科书定义,我想从一名一线开发者的角度,把REST风格的来龙去脉、设计取舍、实际落地经验,以及那些最容易踩的坑都拆开讲清楚。不管你是刚接触接口设计的新人,还是正在为团队推行API规范的老手,应该都能在里面找到点用得上的东西。
1. 很多人理解的REST,其实只是“长得像REST”
1.1 动词型URL与REST之间的鸿沟
先说说“长得像REST”是什么感觉。一个团队说自己会REST,实际做的却是把/api/getUser改成/api/user,把/api/deleteUser改成DELETE /api/user?id=1。路径里确实没有动词了,HTTP动词也换成了对应的语义,看起来挺像那么回事。
但真正的REST不是URL风格,而是一套约束下的架构风格。REST这个缩写来自“表征状态转移”(Representational State Transfer),它讨论的是在分布式系统中,客户端和服务端之间如何通过资源的概念互相交互。URL只是其中一个很表面、很小的组成部分。如果把REST比作一套交通规则,URL命名充其量只是路牌上的字体,真正管用的是红绿灯设置、车道划分和行人优先这些底层的制度安排。
实际开发里,动词型URL带来的问题远不只是“不优雅”。它破坏了资源的可预测性。一个接口叫/api/getUserList,另一个叫/api/userList,还有一个叫/api/queryUsers,前端对接时要反复翻文档才能弄明白每个接口到底是干嘛的。而当所有接口都统一成“名词+HTTP方法”的格式后,调用方天然就能猜出接口的行为,这种可预测性在前后端并行开发时价值极高。
1.2 所谓“REST风格”里常见的三处遗漏
很多团队在推行REST风格时,只把精力放在了路径命名上,却忽略了另外三件更重要的事情。
第一是HTTP方法语义的完整性。不少项目无论什么操作都用POST,创建用POST、更新用POST、删除也用POST。这导致接口的语义信息完全丢失。REST风格要求尽量利用HTTP本身提供的动词与语义:GET负责查询、POST负责创建、PUT负责整体替换、PATCH负责局部修改、DELETE负责删除。这样做的价值不仅在于“规范”,更在于让网关、缓存、监控这些基础组件也能理解接口的行为。比如网关可以天然识别出GET请求是可以缓存、可以重试的,而对POST请求要做特殊处理。
第二是状态码的语义化。很多后端工程师无论成功失败都返回HTTP 200,然后把业务码放在响应体的code字段里。从REST的角度看,状态码本身就是响应的一部分,它应该用来表达本次请求的处理结果。错误有错误的码,权限有权限的码,资源不存在有不存在的码。如果所有情况下都返回200,意味着协议层面的信息完全被浪费了,调用方只能层层剥开body才知道发生了什么。
第三是超媒体与自描述性。REST的完整定义里有一个常被忽略的约束:HATEOAS,也就是超媒体作为应用状态的引擎。简单说,服务端返回用户数据时,可以顺带返回相关的链接,告诉客户端接下来可以去哪里。比如订单资源返回后,附带cancel、pay这些操作的链接。这个理念在市面上绝大多数业务系统里都没有落实,因为确实会增加不少工作量。但至少设计接口时应该知道这件事的存在,而不是完全没听过。
1.3 为什么“长得像REST”反而更危险
如果完全不懂REST,倒是无所谓,接口写错了也会有人指出。最怕的是那种“半桶水”式的REST:路径从动词改成了名词,HTTP方法也用对了,看起来已经“达标”了,但内部设计仍然是RPC思维。这种项目在外人面前有模有样,实际维护的时候同样会陷入混乱。
举个例子。一个订单系统,为了兼容“查询待支付订单”和“查询已发货订单”两个场景,接口设计成/api/order/pending和/api/order/shipped。路径是名词了,却没有意识到这两个接口其实是一个资源(订单集合)在不同过滤条件下的两种视图。正确做法应该是一个/api/orders?status=pending把参数交给查询条件处理。前者导致每增加一种订单状态就要新增一个接口,接口数量会随着业务状态膨胀;后者只需要在参数层面做扩展,后端增加一个枚举判断即可。
这就是我想说的核心问题:REST风格的重要价值之一是收敛接口数量,用统一的方式表达一类操作。如果只学了皮毛而没有理解背后的资源抽象,就会在表面的合规之下保留着RPC的放荡不羁,最后接口越写越多、越写越乱,大家还都觉得自己的设计挺REST。
2. 回到源头:一篇博士论文和六个约束
2.1 从论文到互联网主流API风格
REST这个词最早出现在Roy Fielding在2000年发表的博士论文《架构风格与基于网络的软件架构设计》里。那时候可没有什么前后端分离,更没有移动端App要对接后端API的概念。Fielding是HTTP协议的主要作者之一,也是后来Apache基金会的重要人物。他研究的是:互联网这种大规模分布式系统,到底应该遵循什么样的架构风格,才能保证它能够持续演进、承受住Web规模的并发压力。
这篇论文里,Fielding基于对早期Web架构的分析,抽象出了一组约束,称其为REST。这组约束并不是拍脑袋想出来的,而是从互联网的实践中反推出来的架构原则。有意思的是,这篇论文诞生后很长一段时间里,真正理解它的人并不多。REST开始大规模流行,是在2007年前后,Rails框架把RESTful Routes作为默认路由方式写进了框架之后。后来各种框架纷纷跟进,前后端分离成为主流,REST风格的接口设计才真正变成工程师的必修课。
这里可以打个比方。REST就像城市规划里的分区制度,它规定了住宅区、商业区、工业区应该分开,道路应该按等级划分。至于每个小区里种什么树、刷什么墙,是细节问题。很多人只学到了“道路刷成淡黄色”这种表面特征,却没有理解分区制度是为了解决城市蔓延、交通拥堵这些问题。
2.2 六大约束和它们各自解决什么问题
REST架构风格由六个约束组成,我来逐个说一下它们的内涵。
第一个是客户端-服务器分离。客户端只管展示与交互,服务端只管数据存储与业务处理,两者独立演化。这是分布式系统最基本的解耦方式,也是Web能发展出这么多前端框架、移动端技术栈的前提。很多团队当初推行前后端分离,本质上就是在落实这个约束。
第二个是无状态。服务器不能在请求之间保存任何上下文信息。每一次HTTP请求都应该携带足够的信息,让服务器能够独立处理。有人会觉得这很别扭,因为有状态的设计写起来更自然,比如登录状态存在服务端session里,多方便。但无状态约束保证了服务器的可伸缩性。如果一个服务器挂掉了,另一台服务器可以直接接管请求,不会因为“这台机器上有用户登录状态”而服务不了。它在牺牲一点点开发便利性的同时,把水平扩展这个能力拿到了手。现代系统通常用Token、JWT这些方式配合无状态约束,让认证信息随请求头携带,而不是存在服务端的session里。
第三个是缓存。服务器返回的数据可以标记为可缓存,客户端拿到响应后,可以在一段时间内不用再发请求,直接用缓存的副本。这个约束对Web性能优化意义重大。在实际API设计中,GET请求默认就是可以被缓存的,而POST、PUT这些非安全方法则会穿透缓存系统。理解了这点,就不会在业务里滥用POST来替代所有请求了。
第四个是统一接口。这是REST最核心的一个约束,也是被理解得最浅的一个。统一接口可以拆成四个子约束:资源识别、通过表示对资源进行操作、自描述消息、超媒体作为应用状态的引擎。翻译成大白话:客户端不直接操作数据库,而是操作资源的表示(通常就是JSON);请求与响应里携带着足够描述自己意图的信息;客户端基于资源状态和超媒体链接来推进应用流程。这四个子约束是REST与普通RPC接口最大的区别。
第五个是分层系统。允许系统由多个层级组成,客户端不需要关心它访问的到底是直接提供数据的服务,还是中间层的代理、网关或负载均衡器。这个约束给了系统极大的灵活性,比如加一层CDN缓存静态资源,加一层API网关做鉴权和限流,客户端完全不知情。
第六个是按需代码。服务器可以通过返回可执行代码(比如JavaScript、Flash插件)扩展客户端的功能。这是REST里唯一一个可选约束,在实际业务API中几乎用不上。
2.3 约束不是束缚,是取舍的依据
很多人谈到REST的约束就头疼,觉得“这也限制那也限制”,不如直接想怎么写就怎么写。但约束恰恰提供了决策依据。举个现实中的例子。很多公司内部服务之间直接使用HTTP+JSON,接口路径写得很随意,根本没有统一接口的约束。这种做法的代价是什么?一旦服务数量变多,每一个接口的调用规则都需要额外文档记录,前端的对接成本高,后端改动也容易被上游调用方偷偷破坏。
REST约束就是在逼你把“资源的表述方式”稳定下来,让接口变成一种可以被理解、被信任的契约。它当然有局限:如果一个操作本质上就不是资源操作,硬套REST就会很别扭。此时应该选择更合适的RPC框架,而不是非要在一棵树上吊死。这一点我在后面专门用一章来说。
3. 资源思维:先分清“名词”和“动词”再谈设计
3.1 资源为什么要用名词而不是动词
把接口设计成REST风格的第一课,是学会用名词定义一切。订单是资源,用户是资源,商品是资源。而支付、取消、发货这些,在REST风格里不是资源,而是“对资源施加的操作”。
但为什么资源和名词绑定,而不是和操作绑定?因为资源是稳定的分类,操作是易变的行为。一个业务系统里,核心资源往往就那么十几个(用户、订单、商品、优惠券……),而围绕资源展开的操作可能有几十上百个(注册、登录、下单、付款、退款、发货、退货……)。如果接口按操作来组织,接口数量就会失去控制。按资源组织,操作再多也可以收敛到几种标准的HTTP行为上。
打个比方。如果你把图书馆的管理规则设计成“每本书附带一本说明书,说明怎么借、怎么还、怎么预约”,那每本书的说明书都不一样,读者每借一本新书都要重新学习一道流程。而如果图书馆规定“所有书都遵循同一个借阅流程”,读者只需要学一次,换任何书都会操作。资源就是那本“被统一操作的书”,HTTP方法就是那套“所有人都遵守的流程”,两者结合,接口自然收敛。
3.2 路径设计的核心是层级与从属关系
既然用名词表示资源,路径自然就是一个个层层嵌套的名词组合。这里有两条设计原则需要掌握。
第一,路径的全部职责是定位资源,不是传递行为。查询、创建、修改等行为交给HTTP方法,过滤、排序、分页等条件交给查询参数,不要把行为和动作塞进路径。这是最基础的一步。把/api/order/pay改为/api/orders/{id}/pay并没有解决本质问题,“pay”仍然是动词。正确的做法要么是提供一个资源化的“支付单”来建模:POST /api/payments带订单ID;要么在刚引入REST风格时先用一个自定义方法过渡,但明确这只是演进过程中的妥协,代码里要做好隔离。总之,路径设计要能体现出“这是一组资源,操作方式由方法决定”的气息。
第二,路径有层级关系,但层级关系应该表达“从属”而不是“路径”本身。最常见的子资源场景是“某个用户下的订单”:/api/users/123/orders。这个设计告诉你“订单从属于用户”。但如果订单本身已是独立重要资源,直接写/api/orders?userId=123更合理。什么时候用嵌套、什么时候用平铺?看资源和父资源之间的耦合度。订单即使离开用户也存在自己的生命周期,通常用平铺;评论如果不属于某篇文章就没有存在意义,通常用嵌套。这是我设计接口时的一条实用判断标准。
3.3 集合与单体:一条URI对应一种资源粒度
资源还可以进一步分为“集合资源”和“单体资源”。/api/users是用户集合,/api/users/123是单个用户。这两个URI虽然是同一类资源的不同粒度,但对应的HTTP操作组合完全不同。集合资源通常支持GET(列出)、POST(创建);单体资源通常支持GET(获取)、PUT(整体替换)、PATCH(局部更新)、DELETE(删除)。
理解了这个粒度区分,就不会出现“用POST /api/users/update”这种四不像的写法了。集合资源承载“批量”与“新增”语义,单体资源承载“操作单个对象”语义,权限控制、缓存策略、参数校验规则都可以依据粒度来设置。比如单体资源接口一般可以做细粒度的缓存,集合资源接口的缓存会更复杂,需要结合过滤条件考虑缓存失效的问题。
这条规则还可以扩展到“容器类资源”的设计。例如公司有多个项目,每个项目有多个成员:/api/projects?orgId=1或/api/orgs/1/projects,再往下/api/orgs/1/projects/22这种路径虽然长,但语义清晰。设计路径时,只要始终问自己“这个资源是属于哪个父资源底下的”,层级就会自然推出来。
4. HTTP方法不是动词库,是语义契约
4.1 五个核心方法的语义映射
REST风格中最容易出问题的地方,之一是HTTP方法的使用。很多团队对方法的理解就停留在“GET用于查询,POST用于新增,DELETE用于删除”,这是对的,但远远不够。我给团队成员做培训时,会把五个核心方法用一张表格列出来,让大家每次写接口前先过一遍这张表。
| 方法 | 语义 | 是否安全 | 是否幂等 | 典型场景 |
|---|---|---|---|---|
| GET | 获取资源的表示 | 是 | 是 | 查询用户信息、获取订单详情 |
| POST | 创建资源或触发特定操作 | 否 | 否 | 创建订单、注册用户 |
| PUT | 整体替换资源 | 否 | 是 | 更新用户全部字段 |
| PATCH | 局部更新资源 | 否 | 否 | 修改用户昵称 |
| DELETE | 删除资源 | 否 | 是 | 删除订单、下架商品 |
“安全”意味着这个请求不会修改服务器上的任何状态,也因此可以被缓存、被预取。“幂等”意味着同一个请求执行一次和执行十次,最终结果是一样的。这两个概念很多人分不清,但它们在实际系统中影响很大。比如网络超时后自动重试,如果请求方法是幂等的,重试就是安全的;如果不是幂等的,重试可能产生重复订单,那就必须靠业务层的唯一键去兜底。
4.2 PUT与PATCH的差别,比你想象的更重要
更新接口是REST风格里争议最多的地方。有人习惯把所有更新都设计成PUT,也有人喜欢全部用PATCH。我从实践中得出的结论是:PUT代表“整体替换”,PATCH代表“局部更新”,两者适用的场景完全不同。
什么时候用PUT?当客户端有能力提供资源的完整表示时。比如修改一个用户资料,前端把表单里的所有字段都提交上来,服务端拿这份完整数据整体覆盖旧数据。这种情况下用PUT,客户端和服务端的语义非常明确:客户端说了算,缺省字段就是置空,服务端不用猜。
什么时候用PATCH?当客户端只需要提交变化的部分时。比如用户只修改了手机号,提交的数据里只有phone字段,其他字段服务端保持原样。这就是PATCH的用法。它的好处是数据量小,服务端逻辑也简单(只更新传入字段)。但PATCH的代价是非幂等的,因为服务端的当前状态会影响最终结果。比如同一份PATCH请求,第一次执行时把数量从3改成5,执行两次就变成7了。
实际开发中,PUT和PATCH的误用很常见。一个订单更新接口,客户端想改一下收货地址,却用PUT把整个订单都提交上来,服务端要是没有做“空字段不更新”的保护,这个设计就会吞掉订单里的其他字段数据。我的建议是,除非接口明确要求客户端提交完整资源,否则更新操作一律优先考虑PATCH。这样既省流量,也避免覆盖风险。如果把PUT用于局部更新,必须把所有可能缺失的字段都考虑成“不更新”,这本身就是反直觉的。
4.3 POST不止是“创建”,还有“动作资源化”的艺术
POST在REST风格里除了创建资源,还有一个更微妙的用法:表达“非CRUD操作”。类似“发货”“取消订单”“支付”“确认收货”这种操作,它们本质是业务动作,不是资源状态本身。强行用PUT或PATCH去表达,要么语义不对,要么参数很尴尬。最典型的是“取消订单”:PATCH /api/orders/123传{"status": "cancelled"},技术上说得通,却不直观。业务上真正发生的是一系列动作(取消库存、触发退款、发通知),不是一个简单的状态变更。
这种情况下,业界有两种常见思路。一种是“动作资源化”,把“取消”这个动作建模成一个子资源:POST /api/orders/123/cancellation。这看起来很REST,也确实是最贴近REST理念的做法,因为动作被转化成了“资源状态的转移过程”。另一种思路是保留RPC式动作接口:POST /api/orders/123/cancel,路径里带了动词,但因为是POST调用,逻辑上排除在“资源定位”规则之外,实践上也能接受。
从我带团队的经验来看,内部系统用第二种更省事,对外API想追求风格的纯度可以用第一种。但无论选哪种,都要守住一条底线:只有POST可以承载语义不那么标准的动作接口,GET、PUT、DELETE这些方法都应该严格对应它们的标准语义。
5. 状态码选不对,接口再准确也是半成品
5.1 状态码是按协议说话,不是业务码的备胎
很多后端工程师是从零设计接口的,没有系统学过HTTP语义。他们习惯把业务码放在JSON里:{"code": 10001, "message": "用户不存在"},然后让前端根据body里的code来写逻辑。这种做法从REST风格的角度看,是把协议层的表达能力白白浪费了。
HTTP状态码本身就是这次请求的结果摘要。它有三类信息量:结果大类(2xx成功、3xx重定向、4xx客户端错误、5xx服务端错误)、具体语义(200成功、201创建成功、204无内容、400参数错误、401未认证、403无权限、404不存在、409冲突、422无法处理、500服务器内部错误),以及可缓存性(2xx里有一些可缓存,4xx一般不可缓存)。接口设计者不利用这些信息,前端就只能猜、只能把所有请求都当成200来处理,再根据code做一次二次分发。这等于把网关、日志、监控系统本来就该具备的“按状态码告警”能力全部绕过了。
5.2 按场景选状态码:一张常用清单
我整理了一张在业务API里最常用的状态码清单,适合大多数RESTful接口场景。你不用背全部状态码,记住这张清单基本上就够用了。
| 场景 | 状态码 | 说明 |
|---|---|---|
| 查询成功 | 200 | 返回资源列表或详情 |
| 创建成功 | 201 | 必须在响应头里带Location指向新资源 |
| 更新成功 | 200 或 204 | 返回完整新数据用200,纯成功无内容用204 |
| 删除成功 | 204 | 无响应体,表示删除已完成 |
| 参数错误 | 400 或 422 | 参数格式不对用400,语义不对用422 |
| 未认证 | 401 | 没带Token或Token失效 |
| 禁止访问 | 403 | 认证了但没有权限 |
| 资源不存在 | 404 | 路径错了或资源被删除 |
| 状态冲突 | 409 | 例如订单已支付,不能再次提交支付 |
| 服务端错误 | 500 | 未捕获异常,伴随错误日志 |
| 服务不可用 | 503 | 依赖的下游服务挂了,或服务在重启 |
这里容易出问题的是401和403的区分。401表示“我根本不知道你是谁”,403表示“我知道你是谁,但你没有权限”。前者是认证问题,后者是授权问题。很多前端工程师会把这两个混为一谈,看到401就跳登录页,导致403场景里用户被反复弹登录。接口设计时应该在错误响应体里写清楚原因,便于排查。
另一个容易忽略的是400和422的区别。400类状态码里最常用的是“参数格式错误”,比如id传的不是数字。422则更适合表达“我的参数格式没问题,但按照业务规则无法处理”,比如仓库里库存不足,下单失败。这两个状态码的区分,能直接提升接口错误信息的可读性。
5.3 错误响应体:用统一结构封装问题细节
光有状态码还不够,错误响应体也需要统一设计。我不建议把错误信息只放在一个message字符串里,因为前端既要展示给用户看,也要根据错误类型做不同处理,一个纯文本消息的能力非常弱。
推荐的结构是这样的:
{ "error": { "code": "ORDER_STATUS_CONFLICT", "message": "订单已支付,无法取消", "fieldErrors": [ { "field": "status", "message": "当前订单状态为PAID,期望状态为PENDING" } ], "requestId": "a1b2c3d4" } }code是机器可读的错误码,前端可以用它做分支处理;message是给用户看的友好提示;fieldErrors是字段级别的错误详情,适用于参数校验;requestId用于关联日志,排查问题。这套结构与HTTP状态码配合起来,一层是协议层面的粗粒度,一层是业务层面的细粒度,既不浪费状态码,又保留了灵活性。
这里要强调一点:HTTP状态码不要和业务错误码做成一一映射。比如所有业务错误都返回400,然后在body里用code区分,这等于又回到“一切皆200”的糟糕体验。状态码给大类,业务码给细节,两者各司其职,配合最舒服。
6. 查询参数:分页、过滤、排序和搜索的成熟姿势
6.1 分页设计:offset/limit与cursor的本质区别
列表接口最核心的设计就是分页。REST风格在分页这件事上并没有一套唯一的标准,我见过三种主要方案,每一种都有合适的应用场景。
?page=1&pageSize=20是最常见的,适合数据量不大、跳页需求强的场景,比如管理后台的表格。它的缺点有两个:一是深翻页时性能差,MySQL里LIMIT 100000, 20意味着要扫描前10万行再丢掉,效率很低;二是并发写入下数据会漂移,翻页过程中可能出现重复或遗漏。
?offset=20&limit=20在语义上更贴近数据库操作,适合内部工具类接口,但问题跟page/pageSize基本一致。
?cursor=eyJzdGF0dXMiOi...是基于游标的分页方式。服务端返回一页数据的同时,返回一个不透明的游标,客户端拿这个游标请求下一页。它的核心思路是通过“排序列上的位置”来定位下一批数据,天然支持数据的实时变化,而且深翻页性能非常稳定。缺点是跳页困难,只能一页一页往后翻。内容流、动态列表、IM消息列表这些页面最适合cursor分页。
我倾向于在对外API里默认用cursor分页,只有在管理后台场景才提供page/pageSize。游标字符串建议Base64编码带上排序键值和时间戳,服务端解析后进行条件查询。响应体里可以把下一页游标放在nextCursor字段中,最后一页返回null。
6.2 过滤、排序、搜索的参数规范
列表接口的资源量一大,必然需要过滤、排序和搜索。这方面的设计乱象不亚于方法误用,最常见的写法是把过滤条件揉进路径:/api/orders/pending。我这里给出一个更统一的规范。
过滤条件放在查询参数里,字段名直接映射:/api/orders?status=paid&channel=app。如果某个过滤字段有多值需求,可以用逗号分隔:?status=paid,shipped,服务端按集合处理。范围过滤可以用操作符前缀:?price_gte=100&price_lte=500,尤其是需要做区间查询的时候,前缀法比JSON嵌套参数更直观。
排序可以用sort参数表达,格式是字段名+方向,多个排序条件用逗号分隔:?sort=-created_at,id。负号表示倒序,正号表示正序(通常省略)。服务端要对sort字段做白名单校验,不然用户传一个?sort=password;drop table进来,拼接SQL时就是个灾难。
搜索关键词则统一放在keyword或q参数里。接口文档要明确说明搜索的作用范围,比如只搜索订单号、商品名称,避免前端以为所有字段都会被模糊匹配,结果搜了手机号却什么都没有,来回对需求。
6.3 版本控制:URI版本与Header版本之争
REST服务上线后,接口的结构不可能一成不变。但修改总是会破坏已有的调用方,所以版本控制是每个对外API都要想的。常见方案有两类。
URI版本是最直观的做法:/api/v1/orders、/api/v2/orders。优点是调用方明确,接口文档也好归类,缺点是会让接口的URL变长,也不利于对旧版本资源的复用和整理。很多大厂的开放平台倾向于用这种方案,因为它语义清晰。
Header版本则是通过Accept头(比如Accept: application/vnd.example.v2+json)或自定义头(比如X-API-Version: 2)来指定版本。优点是URI保持干净,缺点是版本信息透明性差,调用方很容易忽略。如果团队没有强制的API文档管理工具,不建议用这种方式。
我的建议是:对外部公开API用URI版本,内部微服务之间用Header版本。内部服务调用方是自家团队,愿意配合升级;外部调用方则要给足够显眼的版本标识,减少升级踩坑的概率。另外无论哪种方式,版本升级时都要在老版本上保留足够长的过渡期,而不是一刀切下线。
7. 什么时候别用REST:和RPC的边界
7.1 RPC是动作导向,REST是资源导向
REST风格不是什么万能银弹。不少场景里,它的资源抽象并不适合。典型就是RPC(远程过程调用)风格的接口。RPC的本质是“调用一个远端函数”,路径写的是动作:/api/userService/getUserById、/api/orderService/createOrder。这种风格面向“方法调用”,非常直接:我要创建一个订单,就调用创建订单的方法。它省去了资源建模的过程,也更符合程序员的直觉。
REST风格则要求把一切抽象成资源:POST /api/orders创建订单,GET /api/orders/{id}获取订单。如果业务场景里“动作”占据主导,资源建模就很费劲。比如一个推荐系统,客户端核心操作就是“喂一条行为数据”和“拿一批推荐结果”,前者是提交行为,后者是获取结果,虽然可以用“行为”和“推荐结果”建模成资源,但经常感觉多此一举,直接定义两个RPC方法反而更简洁。
7.2 内部服务用RPC,外部API用REST
我参与过的不少项目,都是这样一种分工:外部开放接口、前后端交互接口用REST风格;服务与服务之间用RPC框架(gRPC、Thrift、Dubbo)直接调用。
为什么会有这种分工?因为内部服务之间有更严格的技术栈约束和性能要求。gRPC基于HTTP/2,支持双向流式传输,序列化用Protobuf,数据量小、性能高。服务治理上还内置了负载均衡、重试、熔断等能力。这些好处集中在内部服务间体现得最充分,因为双方是同一边的,契约由代码生成,维护成本低。
对外API则不同,调用方多种多样,可能是网页端、App、第三方合作伙伴,还有可能不是我们自己的程序员。REST依赖HTTP协议本身的可理解性,一个只懂基础Web知识的人也能根据“动词+名词”猜出接口的大致行为,这种低门槛优势是Protobuf这类契约机制给不了的。
所以我的判断清单很简单:同一个公司内部,优先用成熟RPC框架;对外公开接口,优先用REST风格的HTTP接口。不要拿REST风格去硬套内部RPC场景,那只会平白增加建模成本,也不会带来太多收益。
7.3 一个判断工具:对资源模型问五个问题
如果不确定某个业务场景到底适不适合REST设计,可以问自己五个问题。第一,业务里有稳定的核心资源吗?一个外卖系统,“商家、订单、骑手、用户”是稳定的资源;一个计算引擎,“把任务提交上去然后轮询拿结果”则没有稳定的资源。第二,操作是不是可以归约为CRUD?能归约,REST的收益就大;操作之间跳跃性强、经常需要串联执行多个步骤,RPC的表达更直接。第三,接口调用方是外部还是内部?外部用REST便于理解,内部用RPC效率更高。第四,是否需要利用HTTP的缓存、网关、监控生态?REST可以直接吃这些红利。第五,团队对REST的掌握程度如何?一个没有资源设计经验的团队,硬推行REST风格很可能只是把路径改成名词而已,不如先上RPC,等有了建模能力再切REST。
这个工具不是什么官方标准,是我自己带团队时整理的,用来在答辩和技术评审时快速对齐大家的预期。它能帮你避免那种最尴尬的局面:花大力气设计了一个“理论完善”的RESTful接口,开发三个月后发现客户端根本不需要那么丰富的资源语义,反而被资源抽象限制住了手脚。
8. 落地实操:从混乱接口到一套可执行的REST API章程
8.1 一个真实项目的接口改造:动作与思想同步替换
前面讲了不少理论,这章分享一个我实际推动的接口改造。流程是:先梳理出当前系统里所有对外接口,按“路径里是否带动词、HTTP方法是否对应语义、是否返回了合理状态码”三个维度打了一遍分。结果触目惊心,120个接口里超过三分之二的路径自带动词,HTTP方法基本都是POST,所有响应HTTP码都是200。
改造分三步走。第一步,把路径里的动词去掉。系统里四大核心资源(user、order、product、review)先建立好,然后把所有动词搬出去。比如/api/getUserInfo改成GET /api/users/{id},/api/updateUserAddress改成PATCH /api/users/{id}。这一步不涉及任何业务逻辑变化,纯路径迁移,风险可控。
第二步,再把方法语义矫正过来。此前“创建操作”都被POST统一取代,这一步要把创建改成POST、更新改成PATCH或PUT、删除改成DELETE。注意这里有个兼容问题:老接口的调用方还在用POST。处理方法是给老接口留一条兼容路由,路由内部转发到新接口,但返回Deprecated头,提示调用方尽快升级。
第三步才是真正复杂的:把业务逻辑和资源状态解耦。比如取消订单,以前有一个/api/order/cancel接口直接操作订单状态。改造后,核心动作仍然是修改订单状态,但对外暴露的形态变成了POST /api/orders/{id}/cancel或POST /api/orders/{id}/cancellation,后端逻辑负责校验状态、触发库存回补、创建退款单。这一步不改变底层代码组织,但会逼着团队思考“操作一个资源”和“修改一个资源字段”之间的边界。
8.2 一套REST API章程的骨架:可以直接抄
改造完接口后,我又把这些规则整理成了一份团队内部章程,大家可以照着这个骨架搭自己的版本。
第一,路径命名:一律复数名词,一律小写,用连字符而非下划线。/api/orders/{id}为一等奖设计,/api/order/{id}只会带来混乱。子资源只有在从属关系强烈时才嵌套,否则平铺。
第二,方法选择:新建用POST,全部字段更新用PUT,单字段更新用PATCH,删除用DELETE,纯查询用GET。任何情况下都不允许用GET去触发状态变更,这是底线中的底线。
第三,响应结构:统一封一层data字段,避免客户端直接读裸数组。列表接口额外提供pagination信息。错误响应按前面说的code/message/requestId结构。成功响应不要随便包装一层“resultCode=0”式的业务包裹层,用HTTP状态码就已经能表达结果大类。
第四,查询参数:分页默认page_size=20、cursor或page两种模式按场景切换;过滤字段直接使用资源字段名;排序字段白名单校验;搜索统一叫keyword。
第五,安全性:非公开接口全部要求Bearer Token认证;敏感操作(删除、变更金额)要求二次校验(比如短信验证码或操作人身份确认);所有写操作记录审计日志,带上发起者ID、来源IP、请求体和变更前后快照。
第六,文档化:接口文档至少包含请求路径、方法、参数、响应示例、错误码列表、变更历史。文档更新与代码合并绑定,不允许只写代码不写文档。
这套章程不是一夜之间形成的,而是踩了无数坑之后总结出来的。它的作用不是束缚创造力,而是把所有人在接口设计上的脑洞收敛到同一套语言体系里,减少协作成本。
8.3 用契约测试守住REST风格的底线
章程定下来只是开始,真正难的是让每个开发都严格遵守。契约测试是一个很好用的工具。在接口的代码仓库里维护一套“契约测试用例”,每次接口变更都必须跑这些用例,测试内容包括路径语义、方法语义、响应状态码、必填参数、错误格式。测试不过,代码不允许合并。
这种做法相当于给REST风格套上了一道自动化的护栏。曾经有个开发把删除用户接口写成了POST /api/users/1/delete,测试用例里明确写着“路径不得包含动词”和“删除操作必须使用DELETE方法”,CI跑挂之后他别无选择,只能改成DELETE /api/users/1。这就是自动化规则的价值,它可以把纸面上的规范变成代码里的硬约束,而不是靠review时的口舌之争。
移动端和前端也可以通过契约测试来联调。只要后端接口的契约测试通过了,前端就可以依据契约文档做Mock,不需要等后端联调环境完全就绪。这在大型团队里的效率提升非常明显。
8.4 从老接口到微服务,REST风格如何演进来收尾
REST风格不是一套死板的规则,它在演进。微服务时代,服务拆分变细了,接口数量和调用链路都变得复杂。此时REST风格依然适用,但需要配合网关、服务发现和统一的流量治理机制使用。例如网关可以对REST接口做统一的认证、限流和缓存,让服务本身只关注业务逻辑。这正是REST“分层系统”约束带来的扩展性红利。
云原生时代,Kubernetes的Ingress、Service Mesh也都天然理解HTTP语义,GET/POST/PUT/DELETE这些方法被基础设施吸收为标准流量特征。这意味着,维护一套REST风格的接口,整个技术栈的通用组件都能直接为你服务。相反,如果接口设计成非标准形态,这些基础设施的便利就会打折扣。
对团队来说,推行REST风格最难的不是学会规则,而是改变思维方式。从“我写一个接口帮你做一件事”到“我暴露一批资源,你用标准动作去操作它们”,这中间隔着一道很深的坎。跨过去了,后面的维护和演进会顺滑很多。我的体会是,刚开始推行的时候可以允许一部分接口“长得不完全像REST”,但必须明确哪些位置允许妥协、为什么妥协、后续怎么演进。保持方向一致,比一步到位更重要。