你接手过一个号称“RESTful”的老接口吗?点开代码,清一色的POST /api/getXXX、POST /api/updateXXX,问就是“REST 不就是增删改查嘛,用 HTTP 方法对应 CRUD 不就完事了”。这个误读太普遍了,导致很多人把 REST 当成一种“风格模板”,而完全忽略了 Roy Fielding 在博士论文里定义的 REST 本质是一套架构约束。我做了十几年 API 设计,踩过无数因为“只学了 CRUD 没学原则”而埋下的坑,今天想认真聊聊 REST 的灵魂——Roy Fielding 提出的六大架构约束,以及它们对实际 API 设计到底意味着什么。
这篇文章会从原则本身出发,逐一拆解每条约束背后的动机、适用边界和落地姿势,再结合真实工程场景讲透 REST 设计与 CRUD 思维的本质区别。适合正在设计新接口、或者想重构老接口的开发者,也适合那些“知道 REST 但说不出所以然”的读者。读完你会明白一件事:REST 的威力不在方法名,而在架构层面的长期可维护性。
1. 先破题:REST 和 CRUD 到底差在哪一层
1.1 CRUD 思维的本质是“面向操作”,REST 思维是“面向资源”
很多人把 REST 理解成“用 GET 代替查、用 POST 代替增、用 PUT 代替改、用 DELETE 代替删”,这种理解不能说完全错,但只看到了 HTTP 方法这层皮。CRUD 的本质是面向操作——你关心的是“我要做什么动作”,设计出来的接口自然就是一堆动词:createOrder、getOrderById、updateOrderStatus。这种接口风格在早期内部系统里很常见,因为直接、好懂,而且实现成本低。但问题在于,动词一旦多起来,接口就变成了函数的堆砌,调用方必须一个个记住,耦合度极高。
REST 的核心恰恰相反,它面向资源。你关心的是“系统里有哪些东西”,操作是附着在资源之上的。POST /orders表示在订单集合上追加一个资源,GET /orders/123表示读取指定的订单资源。 URL 全是名词,动词统一收敛到 HTTP 方法上。这一层转变看起来简单,实则是整个 API 可演化性的根基。
1.2 REST 不是一套“规则”,而是 Roy Fielding 对 Web 架构的总结
Roy Fielding 是 HTTP 协议的主要作者之一,也是 Apache 基金会的联合创始人,他在 2000 年的博士论文《Architectural Styles and the Design of Network-based Software Architectures》里提出 REST(Representational State Transfer)这个词,本意不是发明新东西,而是总结当时 Web 能够大规模成功背后的架构特征。他观察到 WWW 之所以能撑住几十亿用户,不是因为某个具体的软件写得好,而是因为一套约束相互作用,形成了“网络应用架构风格”。
这套约束就是大家常说的“六大原则”:客户端-服务器(Client-Server)、无状态(Stateless)、缓存(Cache)、统一接口(Uniform Interface)、分层系统(Layered System)、按需代码(Code on Demand)。注意,Fielding 用的词是constraints(约束),不是 best practices(最佳实践)。约束意味着“去掉什么才能得到什么”——比如去掉服务端会话状态,换来的好处是更好的扩展性;去掉客户端对服务端实现细节的依赖,换来的是独立的演化能力。理解 REST,必须先理解这种“通过限制来获得规模收益”的逆直觉逻辑。
1.3 为什么说“REST 不仅仅是 CRUD”
如果说 CRUD 是“饭店菜单上的菜名”,REST 就是“后厨的流水线布局”。菜名告诉你有什么可以点,但流水线布局决定了饭店能不能同时服务一千桌客人。CRUD 是接口的外在表现形式,REST 是决定这种形式能否大规模演化的内在架构。
举个我实际遇到的例子:老系统里有个GET /api/order/list接口,后来需求要加筛选条件,就把参数加到 Query 上:?status=paid&startTime=...。三个月后又要按用户维度筛,再加参数。半年后接口参数十几个,响应体从原来 10 个字段膨胀到 40 个,不同调用方各取所需,谁都不敢删字段。这个接口表面上看是 RESTful(用的 GET),但实际上已经退化成一个“RPC 风格的信息倾销口”。真正的 REST 设计会让订单资源自己带状态流转能力,让调用方通过标准化的方式筛选、分页,而不是任由接口无限膨胀。这就是 CRUD 思维和 REST 思维最根本的分野:前者在叠接口,后者在设计可演化的资源模型。
2. Roy Fielding 六大约束逐一拆解,每一条约束都对应一个架构收益
2.1 客户端-服务器(Client-Server):分离关注点是第一步
这一条看似最“没营养”,不就是前后端分离吗?但它其实是整个 REST 的基石。客户端负责用户界面和交互状态,服务器负责数据存储和业务逻辑,两者独立演化。没有这条约束,后面所有原则都无从谈起——因为你一旦让客户端直接操作数据库表或者依赖服务端内部类结构,那什么缓存、无状态、统一接口全都成了空话。
实际落地时,这条约束的难点不在技术,而在边界意识。我在很多项目里见过“看似分离实则耦合”的接口:响应体直接映射数据库实体,字段名就是数据库列名,甚至连created_at这种明显是内部实现细节的字段都原样返回。这样做换来的短期便利是少写几行转换代码,但代价是数据库表结构一变,接口就得跟着变,客户端也跟着遭殃。真正的客户端-服务器约束要求你有一个“表示层(Representation)”的概念:服务端输出的是资源的表示(可以理解为一张“视图”),而不是内部存储结构本身。
实操上,我给自己定的规矩是:响应体结构和数据库表结构脱钩,哪怕现在只有一个调用方。因为后端的表是物理模型,会有主外键、会有冗余字段、会为了查询性能做反范式设计,这些都不该暴露给客户端。客户端只需要在“业务语言”层面理解资源。
2.2 无状态(Stateless):服务端不保存客户端上下文,扩展性从这里来
无状态约束要求:每一个请求都必须携带理解该请求所需的全部信息,服务端不能依赖任何保存在会话(Session)里的上下文。换句话说,服务端不记得“你之前是谁”“你之前干了什么”,每次请求都是独立的、完整的。
这条约束带来的直接好处是水平扩展变得极其简单。只要请求不依赖服务端本地内存里的会话数据,任何一台服务器都能处理任意请求,负载均衡器可以把请求轮流分发到不同机器上,不会出现“粘滞会话(Sticky Session)”这种诡异的运维要求。我接过一个历史系统,登录后用 Session 存用户信息,一到促销高峰就得给负载均衡配会话保持,否则用户请求一漂移就掉登录态,非常痛苦。后来改成无状态 Token(JWT),烦恼直接消失。
要注意的是,“无状态”指的是**应用状态(Application State)**不存服务端,不是“没有状态”或者“不存数据”。订单、用户、商品这些资源状态当然要存数据库,这是资源状态(Resource State),两者不是一个概念。用大白话说:服务端可以记住“订单 123 已经付款了”(资源状态),但不需要记住“你正在看订单 123 这个页面”(应用状态)。这条约束的难点在于区分这两者。
落地建议:分页信息、筛选条件、当前步骤(比如多步表单进行到第几步)这类“客户端进度”,如果不适合塞进 URL 和请求体,就别强行做无状态。我在实际项目里见过钻牛角尖把“校验短信验证码”做成无状态的,结果就是每次校验都要重新发验证码——这是把无状态原则和用户体验对立起来了,属于误用。
2.3 缓存(Cache):把热点数据的重复计算省掉,是 Web 高并发的底气
缓存约束要求:响应必须显式或隐式地声明自己是“可缓存的”还是“不可缓存的”,让客户端和中间层能复用这些响应,避免重复请求打穿服务端。
这是最容易被忽略的一条,因为在单体应用、内网调用场景下,“反正服务器跑得动,多查一次也没啥”的心态很普遍。但一旦系统上了规模,缓存就是保命符。我见过一个接口,页面加载要并发调用 5 次同样的配置接口,每次都打到数据库。后来在响应头加了Cache-Control: max-age=300,那个接口的数据库压力直接降了 80%。
这一条之所以难做好,是因为很多人不知道 HTTP 缓存机制是个“组合拳”,需要理解Cache-Control、ETag、Last-Modified、Expires这几个头的配合。我见过最多的错误是:接口返回了数据,但完全没有缓存头,然后客户端自己加了个“本地 Map 缓存 10 秒”,时间长了数据不一致,排查起来根本不知道是哪一层的缓存。正确的做法是遵循 HTTP 规范——服务端明确告诉中间层和客户端“这个响应可以缓存多久”,各方按标准协议行事。
实操要点:
- 对于不常变的数据(比如配置项、字典表),设置
Cache-Control: public, max-age=3600,让公共缓存和浏览器都能复用。 - 对于个性化数据,至少设置
Cache-Control: private,避免公共 CDN 缓存用户专属信息。 - 对于实时性要求高的数据,可以设置
ETag,配合If-None-Match做条件请求,数据没变的时候只返回 304,省流量省计算。
2.4 统一接口(Uniform Interface):REST 和其他 RPC 风格最大的分水岭
统一接口是 REST 最核心的约束,也是让 REST 成为 REST 的东西。它又拆成四个子约束:资源标识(Identification of Resources)、通过表示操作资源(Manipulation of Resources Through Representations)、自描述消息(Self-descriptive Messages)、超媒体作为应用状态引擎(HATEOAS)。
这四个子约束定义的是一种“资源共享语言”:所有资源都通过 URI 来标识;客户端拿到的不是资源本身,而是资源的“表示”(可以是 JSON、XML 等格式);消息里必须带够足够的信息让客户端知道怎么处理(比如 Content-Type 指明格式);最关键的是,客户端应该能通过超媒体链接“发现”下一步能做什么,而不是把 URL 硬编码在代码里。
HATEOAS 是很多人觉得“最难落地”的一条。举个简单例子:一个订单资源,当状态是“待付款”时,响应体里应该带一个pay链接;当状态是“已付款”时,带入的是refund链接而不是pay链接。客户端不做硬编码判断,只看当前资源“给”了什么链接。这样做的收益是:服务端改流程、加步骤时,客户端不需要跟着改代码。
我在实践中的体会是:HATEOAS 在“面向人类用户的开放式 API”里极度重要,比如公共开放平台、电商订单状态机流转这类场景;但在内部后端服务之间,如果两边都是自家团队维护,硬套 HATEOAS 反而增加复杂度。原因很简单,超媒体的核心价值是“解耦客户端与服务端的硬编码知识”,内部服务之间本来就共享大量业务知识,强行隐藏 URL 结构没有实际收益。所以,我的建议是:统一接口的前三个子约束尽量都遵守,HATEOAS 看场景决定深度,至少让响应体带self链接和关键状态迁移链接,为以后演进留条路。
2.5 分层系统(Layered System):通过中间层获得架构上的自由
分层系统约束要求:客户端不需要知道它连接的是最终服务器还是中间件。代理、网关、负载均衡、API 网关都作为透明的一层插入在客户端和服务器之间,每一层只和相邻层通信。
这条约束的工程价值太好用了。我在实际项目里经常利用它做三件事:第一,加一层 API 网关统一处理鉴权、限流、日志,业务服务完全不需要关心这些横切关注点;第二,做灰度发布和协议转换,老客户端走老接口、新客户端走新接口,中间层做转发;第三,为了安全隔离,内部服务不直接暴露公网,所有请求都经过网关层先“消毒”。
分层最需要注意的是“别把一层做成了上帝层”。我见过架构图上画了好几个“中间层”,结果所有业务逻辑都堆在网关里,网关变成了大泥球。真正的分层是“层与层之间有清晰职责边界”,网关只做路由和横切关注点,业务规则必须留在业务服务里。另外,分层会增加延迟(每多一跳网络开销就多一次),所以不是层越多越好,而是要为具体的架构目标(安全、扩展性、复用性)服务。
2.6 按需代码(Code on Demand):可选的约束,REST 里唯一的“非必选”项
按需代码是六大约束里唯一标注为“可选(Optional)”的。它允许服务端把可执行代码(比如 JavaScript 脚本)发送给客户端,由客户端在本地执行,从而扩展客户端功能。网页里的<script>标签就是这样——浏览器从服务器获取 JS,在本地跑起来,这就是按需代码在 Web 里的典型应用。
为什么这个约束在现代 API 设计里很少有人提?因为它和“前后端分离”“安全沙箱”趋势是冲突的。下发可执行代码意味着客户端要信任服务端的内容,这在移动端 App、第三方开放平台这类场景下风险很高——你不可能让第三方 App 执行你下发的一段未知代码。所以实际工程里,绝大多数 JSON API 都不采用按需代码,把它留作可选是务实的。但对 REST 的完整理解来说,知道它存在很重要,否则面试一被问到“REST 六大原则有哪些”,你会漏掉这个“可选”项。
3. 从原则到实践:用六大约束审查你的 API 设计
3.1 第一步:重新审视你的 URL 和 HTTP 方法语义
对照六大约束,我最先建议你做的是“词汇表”层面的审查。看一遍现有接口,把所有带有动词的 URL 列出来,逐个问:这个动作能不能改写成“对某个资源的某种方法”?
比如POST /api/getUserInfo应该改为GET /api/users/{id};POST /api/deleteOrder应该改为DELETE /api/orders/{id};POST /api/updateProduct应该改为PUT /api/products/{id}(全量更新)或PATCH /api/products/{id}(部分更新)。这些改动不只是“好看”,它们直接让 HTTP 方法本身携带语义,让网关、缓存、监控都能基于标准语义做处理。
还有一个高频问题:什么时候用 POST,什么时候用 PUT?我见过大量把 PUT 当“更新方法”用的设计,但 PUT 在 HTTP 语义里是“把资源替换为请求里的表示”——如果资源不存在,PUT 甚至可以用来创建资源(幂等性要求:同一个 PUT 请求执行多少次效果都一样)。而 POST 是“把请求交给服务器处理,结果由服务器决定”,常用于创建资源(服务器生成 ID)、执行复杂动作、或者局部语义不明的操作。我把这个写出速查表:
| 方法 | 语义 | 是否幂等 | 典型场景 |
|---|---|---|---|
| GET | 读取资源 | 是 | 查询列表、查询详情 |
| POST | 提交处理 | 否 | 创建资源、触发动作 |
| PUT | 整体替换资源 | 是 | 覆盖更新、按客户端指定 ID 创建 |
| PATCH | 局部更新资源 | 否 | 修改部分字段 |
| DELETE | 删除资源 | 是 | 删除资源 |
3.2 第二步:设计资源模型时,先画“状态机”而不是先列表字段
应付 CRUD 思维的人设计接口,通常第一反应就是“要做一个订单模块,先把订单表的字段列出来,然后生成 5 个接口”。但按 REST 思维来的话,第一步应该是画出订单资源的生命周期状态机:创建(Pending)→ 支付(Paid)→ 发货(Shipped)→ 完成(Completed),中间还有取消(Cancelled)、退款(Refunded)等分支。
状态机一旦画出来,接口设计的顺序就变了。你会意识到:
- “取消订单”不是一个
POST /api/cancelOrder的动词接口,而是一个把订单资源状态从Pending迁移到Cancelled的操作,可以用POST /api/orders/{id}/cancel(这是 action 子资源)或者PATCH /api/orders/{id}加status: cancelled来表示。 - 状态迁移有非法路径(比如已发货的订单不能直接取消),服务端要负责校验,不能只提供“改状态”的通路。
- 每个状态的资源表示可能不同:待付款订单需要响应
paymentUrl,已发货订单需要响应trackingNumber。响应体结构跟着状态走,而不是“一刀切”返回所有可能的字段。
我在做设计评审时,最常问的一句话就是:“这个接口改了什么状态?状态迁移图里有没有这条边?”如果对方答不上来,说明设计还没想清楚。资源状态机的设计,是 REST 设计里最值得多花时间的部分,它直接决定了接口语义是否清晰、能否抵御后续需求变更。
3.3 第三步:让“自描述消息”落地,而不是停留在学术概念
自描述消息这个子约束,说人话就是:让消息自己说明“我是谁、该被怎么处理、下一步能做什么”。我见过太多接口只返回一个哑数据数组,连类型都不知道是什么意思:
{ "code": 0, "data": [ { "id": 1, "name": "苹果", "price": 5.5 }, { "id": 2, "name": "香蕉", "price": 3.2 } ] }这个响应当没毛病,但“自描述”程度很低。更好的做法是带上分页信息、资源类型、关联链接:
{ "items": [ { "id": 1, "name": "苹果", "price": 5.5, "_links": { "self": "/api/products/1" } }, { "id": 2, "name": "香蕉", "price": 3.2, "_links": { "self": "/api/products/2" } } ], "page": 1, "pageSize": 20, "total": 2, "_links": { "self": "/api/products?page=1", "next": "/api/products?page=2" } }别小看这些看似冗余的“包装”,它至少带来三个好处:客户端不需要从代码里猜总分页字段叫什么;翻页链接由服务端给,客户端不需要拼接 URL 参数;每个资源自带self链接,日志排查时直接把 ID 跳转到具体资源。
关于自描述消息,还有一个容易被忽略的点:Content-Type 必须准确。我见过大量接口无论返回什么内容一律application/json; charset=utf-8,这是基础,但还不够。如果某个接口返回的是二进制图片、CSV 文件或者事件流(SSE),Content-Type 必须对应变化,否则客户端解析必然出问题。这条看似是小事,但在后端服务互相调用时,Content-Type 错误会导致框架解析失败或者乱码,排查起来极其隐蔽。
3.4 第四步:给“错误”一个规范的结构,让异常也可被编程处理
CRUD 思维的接口错误处理通常是:返回一个和正常响应结构完全不同的 body,比如{"error": "数据库连接失败"},HTTP 状态码永远是 200。这种做法让调用方非常痛苦——我得先解析 body 里的 error 字段是否存在,才能判断请求成功没有。
按 REST 的自描述消息原则,错误响应应该有自己的规范结构,而且 HTTP 状态码要能直接反映错误类型。我推荐用 RFC 7807(Problem Details for HTTP APIs)的结构,或者在团队内约定一个固定格式:
{ "type": "https://api.example.com/errors/order-already-paid", "title": "订单已支付,无法取消", "status": 409, "detail": "订单 #1234 已于 2024-06-01 10:00 完成支付,不允许取消操作", "instance": "/api/orders/1234/cancel" }这样做的好处是:客户端可以根据状态码快速判断是否需要重试(4xx 不重试,5xx 可以重试);错误的业务说明集中在 body 里,便于统一做日志监控和告警聚合;type字段指向错误文档的 URL,方便开发者查阅详细错误原因。
我再分享一个经验和“错误即文档”的心得:很多团队把错误信息当成“运维日志”来写,结果返回给客户端的是“NullPointerException at OrderServiceImpl.java:182”这种内部堆栈。这既泄露了内部实现细节,也给客户端毫无可操作性信息。正确的做法是:错误信息要面向调用方写清楚“发生了什么、为什么、下一步该怎么办”,内部堆栈留在服务端日志里,配合 traceId 做关联排查。
4. 那些年我踩过的坑:REST 落地中的经典误区和实战排查
4.1 “POST 一切”的接口设计为什么注定走不远
我见过很多项目从“REST 很麻烦”出发,干脆只用 POST 一个方法。要查数据?POST。要删除?POST。要更新状态?POST。这样做短时间内很舒服,因为 POST 是“最自由”的方法,服务端想干什么都行。但长远的代价非常明显:
- 无法利用 HTTP 缓存。POST 的响应默认不可缓存(除非显式声明),而你根本没法区分哪些 POST 是幂等的、哪些不是。
- 监控和日志丧失了语义。看到
POST 500和中看到GET 200带来的判断是完全不同的,前者可能是错误写入(幂等性未知),后者通常安全可重试。 - 调用方无法安全重试。如果支付接口因为网络超时被调用方重试,POST 不能保证幂等,可能出现重复扣款。
我给的解决路径是:先把 GET、PUT、DELETE 用起来,这三个方法的幂等语义已经很清晰;POST 只留给真正的“创建”和“不确定操作”。如果你担心客户端重复提交,可以在 POST 创建场景里引入Idempotency-Key头(很多支付 API 和 Stripe 都在用这个方案),服务端拿这个 Key 做去重。
4.2 PUT 和 PATCH 的边界,以及“部分更新”的隐藏坑
PUT 和 PATCH 的混淆是一个经久不衰的面试题,但实际工程里更难的是“PUT 到底是全量替换还是部分更新”这个分歧。按照 HTTP 规范,PUT 是“把 URI 指向的资源替换为请求体里的表示”,也就是说,缺了的字段会被重置为默认值,这是很多团队不敢用 PUT 的原因——一不留神就“更新丢了字段”。
我的建议是:如果你不能保证客户端每次都提交完整对象,就用 PATCH,别用 PUT。同时要细化 PATCH 的实现语义,因为 PATCH 的请求体可以有多种格式(JSON Merge Patch 或者 JSON Patch),不同格式含义差异很大。我推荐使用 JSON Merge Patch 的语义(RFC 7386):请求体里只有出现的字段才被更新,没出现的字段保持不变;如果要清空一个字段,就显式设为null。这个语义对客户端来说最符合直觉。
实际开发中,我在更新接口上遇到过非常经典的坑:客户端拉起详情页面时拿到 30 个字段的完整对象,用户只改了其中的备注字段,前端就把整个 30 个字段原封不动地 PUT 回来。某一天后端新增了一个字段,老版本客户端没这个字段,一更新就把新字段重置成空值了。后来我强制要求所有更新操作走 PATCH,只传要改的字段,问题才彻底解决。
4.3 无状态约束成了“JWT 万能解”,这是另一个被滥用的地方
无状态约束在工程里最常见的落地手段是 JWT(JSON Web Token),因为 JWT 本身可以携带用户信息,服务端不需要查会话表。但 JWT 也带来一个麻烦:你没法让一个已经签发的 Token 失效(除非引入黑名单,而黑名单又是有状态的)。这导致“强制用户下线”“修改密码后踢掉其他设备”这类需求实现起来极其别扭。
我的建议是:无状态是指“不依赖服务端内存中的会话状态”,不是“不能用数据库做状态管理”。如果业务确实需要可靠的控制权(比如运营后台需要能立刻封禁某个用户的访问),那不妨用“数据库/Redis 里有状态的会话”,请求过来时先查一下会话是否有效。这种设计虽然多一次查询,但换来的是可控性。结合缓存约束分散压力,这个查询的代价是完全可以接受的。所谓架构设计不是拿着锤子找钉子,而是根据需求选择最合适的约束组合。
4.4 缓存“一刀切”导致的数据不一致,以及我的分级策略
缓存约束虽然带来了性能收益,但落地时最容易踩的坑是“所有接口都缓存”或者“所有接口都不缓存”。我的分级策略是:
- 一级缓存:静态不变的数据(图片、JS、CSS),用 CDN + 长 max-age。
- 二级缓存:低频变化的业务数据(配置、商品详情、类目树),用服务端缓存(Redis)+ 短 max-age + ETag。
- 三级缓存:个性化高频数据(用户订单列表),用 ETag/Last-Modified 做条件请求,客户端协商验证,数据没变只返回 304。
- 不缓存:强实时数据(库存、余额),设置
Cache-Control: no-store。
最高频的翻车现场是这样的:接口加了Cache-Control: max-age=60之后,用户改了头像,24 小时不生效,然后产品经理来找架构师“你这缓存设计有问题”。问题不出在缓存本身,出在缓存粒度——头像应该用“用户 ID + 版本号”做缓存键,资源更新时版本号递增,而不是一股脑对所有接口套固定时长。所以我对缓存有一条铁律:缓存的是“资源表示”,不是“接口结果”,要有明确的缓存键设计。
4.5 用状态码表达语义,但别把状态码用成玄学
REST 设计里 HTTP 状态码的选择经常被两种极端拉锯:一种是什么都返回 200 然后 body 里塞 code 字段,另一种是试图用状态码表达所有的业务细节(甚至把“余额不足”映射成 402)。我推荐的策略是:
- 2xx:请求成功。用 200 表示通用的成功;用 201 表示创建成功,并带上
Location头指向新资源;用 204 表示删除成功、无响应体。 - 4xx:客户端问题。400 语法错误;401 未认证;403 已认证但无权限;404 资源不存在;409 状态冲突(比如发货后再取消);422 字段校验失败;429 频率超限。
- 5xx:服务端问题。500 未知错误;502/503/504 网关相关。
这里有个细节:许多团队一边声称“用 REST”,一边把业务错误码全部塞在 body 的code字段里,HTTP 状态码统一 200。这样做的直接后果是调用方必须为“响应内容”而不是“传输状态”做分支判断,监控系统也无法通过 HTTP 状态码统计错误率。我的建议是:HTTP 状态码用来表达“这个请求是否成功了”的传输语义,body 里的 code 用来表达更具体的业务码(比如“订单状态不允许取消”对应 409 + 业务码 10024),两者各司其职。
5. API 设计不是一次性的,而是持续演化的过程
在项目里落实 REST 的六大约束,我建议不要追求“一步到位”,因为大团队里的老接口迁移是个长期工程。更务实的做法是:新接口从第一天就按原则设计,老接口结合“债”的复杂度逐步偿还。我在团队里做了一个“接口健康度检查”清单,每次设计评审都过一遍:
- URL 里有没有动词?
- 是否用对了 HTTP 方法和状态码?
- 响应体是否自描述(带类型、链接、分页信息)?
- 错误结构是否统一、是否可编程处理?
- 是否明确了可缓存性和缓存键设计?
- 资源状态迁移是否清晰,有没有非法路径?
我个人在实际操作中最深的体会是:REST 设计得好不好,短期内看不到差别,长期才见真章。一个严格按照六大约束设计的接口,在半年后、一年后接受新需求时,演化成本会远低于那些“先凑合再说”的接口。你不需要把 HATEOAS 做到教科书级别,也不需要为了炫技引入复杂的分层,但“客户端-服务器”“无状态”“缓存”“统一接口”这四条核心约束,值得你在每一个新接口设计时认真对待。最后再分享一个小技巧:当你犹豫某个接口设计是否合理时,试着把接口当成一本“说明书”讲给新人听——如果讲得清楚、自然,说明它符合 REST 原则;如果讲起来要补充一堆“特殊情况”“这个参数是给某某端用的”,那大概率它在设计层就出了偏差。