☰
REST架构六大约束拆解:从CRUD思维到真RESTful设计
2026/9/26 4:54:49 网站建设 项目流程

前阵子帮朋友做技术评审,翻到一个内部系统的接口文档,整整四十多个端点,清一色的POST /saveXXX、POST /deleteXXX、POST /queryXXX。我说你这套接口更像是给数据库套了层 HTTP 皮,朋友还很认真:这不就是 REST 吗?POST 就是新增,DELETE 就是删除,我们团队有完整的 CRUD 映射规范。那一瞬间我突然理解了为什么 Roy Fielding 本人会在博客上发文吐槽——他把 REST 定义成一种架构风格,而太多人正在把 REST 用成一本数据库操作手册。

这篇文章不是来讲"REST 有哪些最佳实践"的,也不是给你列一份"RESTful API 规范模板"。我想从 Roy Fielding 2000 年博士论文里提出的六个架构约束出发,把 REST 原本想解决什么问题、每一句约束背后到底在保护什么价值讲透。你会发现,REST 的核心根本不是"用 HTTP 方法映射增删改查",而是可演化性、可伸缩性、可见性和组件解耦——这些才是 API 设计的"灵魂"。适合后端开发、架构师、以及对 API 设计有追求的每一位工程师阅读,无论你现在用的是 Spring Boot、Go 还是 Node.js,这套思路都通用。

1. REST 被滥用的根源:CRUD 思维怎么把架构风格变成了数据库接口

光骂"很多人不懂 REST"没有意义,得先搞清楚大家为什么会产生这样的误解。你随便翻开一个技术社区,搜"RESTful API 设计",十条有八条在讲"GET 查询、POST 新增、PUT 修改、DELETE 删除"。这套说法传播太广,以至于很多团队从第一天起就把 REST 理解成"HTTP 方法 + 数据库操作映射表",然后围绕这个映射表做接口规范、代码生成器、权限框架,最后产出一个又一个无比稳定的"伪 REST"系统。

1.1 CRUD 是数据库视角,REST 是网络架构视角

先说 CRUD。CRUD 是 Create、Read、Update、Delete 四个数据库基本操作的缩写,它描述的是数据在持久化存储里的生命周期。你往表里插一行,查一行,改一行,删一行,就这么简单。CRUD 关心的是数据本身的状态变化,和网络、组件、资源这些概念没有任何关系。

REST 完全不同。REST 的全称是 Representational State Transfer,直译过来是"表现层状态转移"。它描述的是分布式系统里,客户端和服务端之间如何通过资源的表征来驱动状态迁移。Fielding 提出 REST 的背景是研究 HTTP 协议的设计原理,他要解释的是:为什么 WWW 能支撑几十亿用户、能持续演化几十年、能让无数异构系统互操作?他的答案不是"因为 HTTP 方法设计得好",而是"因为 Web 的架构满足了一系列约束,这些约束组合起来产生了他想要的非功能属性"。

所以你看,CRUD 和目标数据库表,REST 和目标整个网络架构。把两者画等号,等于你开着一台挖掘机去研究城市规划——工具本身没错,但你找错了层级。

1.2 方法映射表带来的一连串连锁反应

当团队把 REST 简化成方法映射表之后,一些奇怪的设计就变得顺理成章了。最常见的就是动词路由泛滥:/getUser、/createOrder、/modifyPassword、/removeItem。动词路由的本质是 RPC(远程过程调用),调用方把接口当成一个函数去调用,完全忽略了"资源"这个核心抽象。

另一个常见连锁反应是所有接口统一 POST,参数全部塞进 body。一个系统里如果所有端点都叫/api/action,接口文档就退化成一份"函数清单"。客户端必须靠读文档才知道调哪个端点、传什么参数、响应怎么解析,服务端和客户端的耦合变成隐性的、脆弱的。日志里看到的只有/api/action,想根据 URL 做缓存、做限流、做权限策略,全都无从下手。

还可以常见的是把 HTTP 状态码用成自定义编码。业务错误返回 200 + 错误码,系统错误返回 500,甚至有人把"没有权限"也返回 200,理由是"HTTP 状态码不够精细,我们自己定义一套业务错误码更好"。这种做法短期内看着灵活,长期看等于把 HTTP 协议里最成熟的语义机制扔掉了,中间层(网关、负载均衡、监控系统)全部失效。

1.3 从"接口清单"到"资源模型":设计视角的转换

真正 REST 视角下的 API 设计,第一步不是列接口,而是识别资源。资源不是数据表,而是"可以被命名、可以被访问、可以发生状态变化的事物"。一个订单是一种资源,一个用户是一种资源,一个购物车也是一种资源。它们有名称(URI)、有表征(JSON/XML/HTML)、有状态(待支付、已支付、已取消)。

CRUD 思维下的设计会问:用户需要什么功能?然后翻译成"查询订单接口、修改订单接口、删除订单接口"。REST 思维下的设计会问:订单资源有哪些状态?什么操作能把订单从状态 A 变成状态 B?然后翻译成"对/orders/{id}发起某种方法,让订单的状态发生迁移"。

这两者的差异直接决定 API 的演化能力。CRUD 式接口每加一个业务动作就要加一个端点,而资源式接口只需要增加状态、增加表征、增加链接关系。客户端遵循超媒体链接去发现下一步动作,服务端可以在不影响已有客户端的情况下调整 URI、合并资源、拆分资源。这才是 REST 最大的价值:它让服务端和客户端在各自独立演化的同时,仍然保持协作。

2. Roy Fielding 六大原则逐条拆解:REST 的"灵魂"到底在哪里

Fielding 在论文第五章给出了 REST 的架构约束,一共六条。这六条不是拍脑袋列的,每一条都是在特定设计取舍之后活下来的。理解它们,你就理解了 REST 为什么长成今天这样。

2.1 客户端-服务器:关注点分离,不只前后端分离

第一条约束是客户端-服务器分离。这条看着最简单,很多人直接把它等同于"前后端分离",其实它的内涵不止于此。

Fielding 的初衷是解耦用户界面关注点与数据存储关注点。客户端负责渲染和交互,服务端负责数据管理和业务规则,两者独立演进。这样做最直观的收益是跨平台——同一个服务端可以服务 Web、移动端、桌面端、第三方集成——但这个收益只是表面。更深层的收益是,分离让两端的复杂度可以各自独立增长。如果所有逻辑都揉在一个应用里,任何一端的改动都可能拉着另一端一起重演;分离之后,服务端团队可以独立重构存储方案,客户端团队可以独立更新交互体验,彼此之间只需要守住资源和表征的契约。

实操层面的参考:这里说的分离粒度不是"前端一个项目、后端一个项目"这么简单,而是指渲染状态(UI 状态)与业务状态(资源状态)不能混在一起。最典型的反面案例是"接口返回一段拼好的 HTML 片段",或者"接口直接返回一个带格式的字符串让前端赋值"。这类接口把客户端的关注点塞进了服务端,一改样式可能都要动接口,属于违背了这条约束的设计。

2.2 无状态:让每一次请求都能被独立理解

第二条约束,无状态(Stateless)。Fielding 的原话是:客户端到服务端的请求必须包含理解该请求所需的全部信息,不能利用服务端存储的上下文。通俗点说,服务端不保存客户端状态,每一次请求都是完整的、自描述的。

注意,无状态不是说系统里不能有状态。订单还是订单,购物车数据还是要存,用户登录凭证还是要验证。它限制的是"客户端会话状态"不能存在服务端内存里。比如你不能在服务端开一个 Session 变量记录"当前用户正在选商品",下一次请求来了从内存里把状态捞出来接着算。这也就是为什么 JWT 这类令牌方案会流行——把用户身份状态编码到请求本身,服务端无状态地验证。

为什么 Fielding 要这么设计?因为他关心的是可伸缩性和可靠性。无状态让任意一台服务端节点都能处理任意一个请求,负载均衡不需要做会话粘滞(session affinity),水平扩容就是加机器。某一台机器挂了,其他机器照常接管请求,因为机器之间不需要共享内存态。除此之外,无状态还提升了可见性:监控系统抓到一条请求日志,就能完整还原这次交互,不用去翻上下文。

我见过不少团队对这条约束的误解是"REST 要求无状态,所以 JWT 一定比 Session 好"。这属于本末倒置。Session 方案如果配合粘滞负载均衡或集中式缓存(比如 Redis),也可以满足分布式需求;JWT 如果滥用 payload 塞大量业务数据、不做过期校验,照样有状态问题。关键是理解无状态约束的目标——让服务器不保存客户端会话上下文——而不是死记"用 JWT 不用 Session"。

2.3 可缓存:让 HTTP 缓存体系成为 API 的一等公民

第三条约束,可缓存。REST 要求响应必须显式或隐式地声明自身可否缓存,客户端可以缓存响应内容,从而消除部分交互延迟,提升网络效率。

这条在 API 设计里被忽略的程度,和它的价值完全不成正比。很多团队天天调第三方接口,自己写 API 的时候却完全不设置Cache-Control、ETag、Last-Modified这些头。结果同一个订单详情接口每次被调用都要打到数据库,明明订单在 30 秒内根本没变过。

缓存约束另一个容易被忽略的点是:它要求响应本身是可缓存性自描述的。服务端不能说"这个接口结果可以被缓存"但是不告诉客户端缓存多久、怎么验证新鲜度。正确做法是通过 HTTP 头把规则声明出来。

  • Cache-Control: private, max-age=60——只有当前用户可缓存,最多缓存 60 秒
  • ETag: "686897696a7c876b7e"——配合条件请求,客户端发If-None-Match,命中返回 304 Not Modified
  • Last-Modified: Wed, 21 Oct 2023 07:28:00 GMT——配合If-Modified-Since

能缓存什么也是要设计的。搜索结果、商品列表、静态配置这类读多写少的资源适合缓存;用户余额、实时库存这类强一致性的资源不适合缓存,或者只能做短时缓存。你得在一致性和性能之间做取舍,REST 的缓存约束就是把这个取舍显式化、机制化,而不是等到性能出问题了再到处加缓存层。

2.4 统一接口:REST 最核心也最被低估的一条

第四条约束,统一接口。这是 REST 区分于其他架构风格的关键,也是被误解最深、实践中最难落地的一条。统一接口本身又由四个子约束组成:资源标识、资源表征、自描述消息、超媒体引擎(HATEOAS)。

资源标识是说,每个资源必须有一个可寻址的标识,即 URI。客户端通过对 URI 发起方法,表达"我想和这个资源交互"的意图。/orders/123唯一指向那个订单,/users/456唯一指向那个用户。这一步你已经很熟了,不多讲。

资源表征是说,客户端拿到的是资源的表征(representation),不一定是资源本身。你要查一个订单,服务端返回的是订单当前状态的一个 JSON 快照,而不是数据库里那行记录的指针。同一个订单,可以有 JSON 表征、XML 表征、PDF 表征,取决于客户端的 Accept 头。表征可能包含资源的当前属性,也可能包含链接、操作入口。

自描述消息是说,消息本身必须携带足够的元数据,让接收方知道如何处理。HTTP 方法表达语义(GET 是安全读取、POST 是提交、PUT 是全量替换、PATCH 是局部更新、DELETE 是移除),状态码表达结果(200、201、204、404、409),Content-Type表达表征格式,Link头表达关系。客户端解析一条响应时,不依赖外置文档就知道"这条消息在说什么、还能做什么"。

这里我要特别强调一点:很多团队觉得"REST 就是返回 JSON",其实 JSON 只是表征格式之一,而且 JSON 默认天生不携带"动作语义"。如果响应里只有数据字段,没有链接、没有动作描述,客户端依然不知道"支付这个订单"应该去向哪。这时候 REST 就退化成"返回 JSON 的数据接口"了,离 Fielding 说的"超媒体引擎"还差着十万八千里。

超媒体引擎(HATEOAS):全称是 Hypermedia As The Engine Of Application State,超媒体作为应用状态的引擎。这条要求:客户端不应该在代码里硬编码各种业务流程的 URL,而是通过服务端返回的超媒体链接来驱动下一步动作。

打个比方。你第一次坐地铁,进站后不需要背线路图,抬头看指示牌——"下一站是 X,换乘 2 号线往这边走"。指示牌会根据你的当前位置给你下一步指引,这就是超媒体。而 CRUD 式接口的做法等于让你先背熟整张地铁图,任何一个站台改动,你手里的小抄就废了。

落地到 API 里,意味着:

{ "orderId": "123456", "status": "待支付", "totalAmount": 299.00, "links": { "pay": { "href": "/orders/123456/payment", "method": "POST" }, "cancel": { "href": "/orders/123456/cancel", "method": "POST" }, "self": { "href": "/orders/123456", "method": "GET" } } }

客户端看到status是"待支付",就知道应该去调pay链接;订单一旦变成"已支付",服务端返回的links里就不再出现pay,而是换成"申请退款""查看物流"等下一步可能的动作。流程规则由服务端掌控,客户端不需要写"if status == '待支付' then POST /pay"这种硬编码分支。

2.5 分层系统:中间件、网关、代理为什么能透明介入

第五条约束,分层系统。REST 允许中间存在多层组件,每一层只看到与自己交互的相邻层,客户端不知道自己是直接连到最终服务端,还是经由一个或多个中间层。

为什么这很重要?因为分层是互联网规模化的基础。你请求一个资源,中间经过 CDN、网关、负载均衡、反向代理,每一层都可以对请求做缓存、鉴权、限流、路由,而客户端完全无感知。对服务端来说,分层让老系统可以被新系统逐步替换,只要 Layer 的接口契约不变,内部实现可以彻底重写。

分层系统的同时也有代价:数据经过的每一层都会引入延迟,并且分层可能让请求的实时性变差。Fielding 认可这个代价,因为它换来了巨大的可伸缩性和可演化性。现实中,如果一层既做缓存又做鉴权又做路由又做参数校验,这层就会变成新的单点瓶颈;如果中间层私自篡改请求或响应(比如把 404 改成 200),整个可见性就毁了。所以对中间层,我的建议是:能只转发就不要动业务内容,能靠标准头传递信息就不要改 body。

2.6 按需代码:唯一可选的一条约束,为什么今天很少有人用

第六条约束,按需代码。服务端可以临时把可执行代码发给客户端执行,扩展客户端能力。最常见的形式就是 Web 里的 JavaScript——浏览器从服务端加载脚本,在本地执行渲染逻辑。

不过按需代码是六条里唯一标记为可选的约束。Fielding 的原话是,它简化了客户端实现,但也会降低可见性,而且带来安全风险。想想看,如果服务端可以随时下发一段代码让客户端执行,那这个"代码"跟病毒有什么区别?你必须建立完整的沙箱、签名验证机制才能保证安全,这套体系不是每个 API 平台都愿意建立的。

所以你会发现,今天的 REST API 极少实现真正的按需代码,最多通过各种"可配置规则"间接实现类似效果。理解这条约束的意义更多在于:它提醒我们,REST 是"约束满足性"架构,不是"包治百病"框架。你选择哪几条、舍弃哪几条,都是在做工程权衡。Fielding 自己在论文里也承认,没有任何单一架构能满足所有场景,REST 只是针对特定需求的一组约束组合。

3. 从"伪 REST"到"真 REST":一次订单接口重构实录

讲了这么多理论,还是得落到代码上。我拿一个实际的订单接口来演示,第一版是典型的"CRUD 式 RPC",第二版按 REST 原则重构。你看完就能明白,REST 不是"把 URL 改成名词"那么简单,真正的差距在消息设计和交互模型上。

3.1 第一版:典型的"CRUD 式 RPC"接口

这是很多团队的实际写法的浓缩版:

POST /order/query { "orderId": "123456", "userId": "7890" } 响应: { "code": 0, "data": { "orderId": "123456", "status": 1, "statusDesc": "待支付", "totalAmount": 299.00, "createdAt": "2024-01-01T10:00:00Z" } }

问题一眼就能看出来。端点用了动词query,方法固定 POST;业务错误和成功全部用code字段区分,HTTP 状态码永远是 200;客户端拿到的data里没有下一步动作入口,客户端要自己拼"支付宝唤起链接""取消订单的 URL",这些 URL 靠接口文档手工同步;status: 1这个数字的含义,也只能靠文档说明。

这套接口的问题不是它"不能跑",而是每一次业务变化都在制造脆弱性。明天订单新增一个状态,statusDesc变长,客户端枚举要改;后天支付链接换域名,所有硬编码的拼 URL 逻辑要改;再后来权限策略想在中间层生效,但所有请求都是 POST/order/query,网关根本没法按资源和语义分流。

3.2 第二版:资源导向 + 状态转移的接口设计

重构后的版本,我按 REST 约束来设计:

GET /orders/123456 Accept: application/json 响应 200 OK: { "orderId": "123456", "status": "PENDING_PAYMENT", "totalAmount": 299.00, "items": [ { "productId": "SKU-001", "name": "机械键盘", "quantity": 1 } ], "links": { "pay": { "href": "/orders/123456/pay", "method": "POST" }, "cancel": { "href": "/orders/123456/cancel", "method": "POST" }, "self": { "href": "/orders/123456", "method": "GET" } } }

几个关键变化。第一,URI 是名词,订单是资源,查询用 GET,符合安全语义。第二,状态不是数字而是可读枚举,客户端不需要依赖文档。第三,响应里带links,其中pay和cancel是根据当前状态动态生成的——订单处于"待支付"状态下才能支付,才能取消;如果已经支付,links里会出现refund,而pay消失。

这里最值得品味的设计是:订单的可行操作被服务端显式告知。客户端不再需要维护一份"订单状态机"的代码,只需要跟着链接走。

3.3 支付流程的状态转移:超媒体驱动的真实含义

继续走流程。客户端调用支付链接后,服务端通常先把订单状态改成"待确认"(因为支付结果可能是异步回调确认的):

POST /orders/123456/pay Content-Type: application/json 响应 200 OK: { "orderId": "123456", "status": "PENDING_CONFIRMATION", "links": { "self": { "href": "/orders/123456", "method": "GET" }, "paymentStatus": { "href": "/orders/123456/payment/status", "method": "GET" } } }

这时客户端如果想展示"支付中"状态,下一跳是paymentStatus。等支付平台回调确认之后,再查订单:

GET /orders/123456 响应 200 OK: { "orderId": "123456", "status": "PAID", "paidAt": "2024-01-01T10:05:00Z", "links": { "self": { "href": "/orders/123456", "method": "GET" }, "refund": { "href": "/orders/123456/refund", "method": "POST" }, "shipments": { "href": "/orders/123456/shipments", "method": "GET" } } }

看到没有,客户端全程没有硬编码"订单支付后去查物流"的逻辑。它只是每次拿到links里的候选动作,根据产品需要决定要不要展示。服务端改流程时,只要保证links生成的规则同步变化,老客户端的兼容性就天然有了保障。这就是 HATEOAS 的作用——它是 API 的"可演化性保险"。

3.4 这样重构的代价与收益

当然,我不会无脑吹这套设计。真实团队落地时会发现两个现实问题。

第一个问题是响应体积变大。每个响应都要带links,数据量可能比之前多 30%。但实际走 HTTP 压缩之后(gzip/brotli),这点开销完全可接受,换来的却是客户端的逻辑大幅简化——这个账是划算的。

第二个问题是开发心智负担变重。后端不能只写一个 CRUD 接口了,得多想一步"当前状态下用户能做什么、下一步应该给什么链接"。这对团队的业务建模能力有要求,不能指望一个新手两天就上手。但换个角度想,这个思考过程本身就是把业务状态机的复杂度从客户端挪到了服务端,集中管理总比散落各处强。

从收益角度看,重构后的 API 有几个立竿见影的好处:网关可以根据 GET/POST + URI 做限流与缓存策略;日志里从 URL 就能判断请求意图;新增订单状态不需要新增端点;客户端对"流程变化"的敏感度大幅降低。这些都是架构层面的红利,不是"用起来顺手"能比的。

4. 常见误区与排查技巧:怎么判断你的 API 是不是真 RESTful

理论讲完,重构也演示完了,最后聊点排查和实弹。简单说下我常用的判断方法和踩过的坑。

4.1 五大常见误区速查表

这张表是我在做技术评审时最常用的检查项,几乎每次都能命中几条:

误区典型表现问题本质建议
动词路由/getUser、/createOrder把接口当函数调用,丢失资源抽象URI 只放名词,动作交给 HTTP 方法
一律 POST所有接口 POST,参数全塞 body丢弃 HTTP 语义,缓存/分流入手段全废按语义使用 GET/POST/PUT/PATCH/DELETE
全 200 返回业务错误也返回 HTTP 200 + 错误码中间层无法感知错误,掩盖系统问题按结果使用 4xx/5xx 状态码
业务码套娃JSON 里包一层 code/message/data自描述消息被自定义规则替代用标准状态码 + 响应体补充
无超媒体响应里只有数据,没有 links客户端硬编码流程,演化能力为零在表征中加入链接、动作入口

通常一个接口命中三条以上,基本可以断定它只是"用 HTTP 包装的 CRUD",跟 REST 关系不大。这里我不建议给人扣帽子,因为 CRUD 接口在很多内部场景确实够用,但当你的系统需要对外提供服务、需要跨团队协作、需要长期演进时,REST 的约束价值就会越来越明显。

4.2 Richardson 成熟度模型:判断 REST 落地深度的四层台阶

有一个不少人都知道的判断工具,Richardson Maturity Model,把 API 的 REST 化程度从低到高分成四层:

  • Level 0:HTTP 当传输管道,所有请求 POST 到同一个 URL,典型如 SOAP、老的 XML-RPC
  • Level 1:引入资源概念,每个资源有独立 URI,但方法仍然只有 POST
  • Level 2:使用 HTTP 方法表达语义,状态码表达结果,这一层是大多数人说的"RESTful API"
  • Level 3:加入超媒体(HATEOAS),响应包含下一步动作链接

多数团队止步 Level 2,给自己贴上 RESTful 标签。说实话 Level 2 已经比纯 CRUD 好很多,它拥有了标准的动词语义、状态码、资源 URI,中间层也可以正常工作了。但 Fielding 本人不止一次声明:如果响应里没有超媒体,他不认为那是 REST 架构,最多算是 HTTP API。这可能是整个 REST 讨论里争议最大的一处——到底要不要追求 Level 3?

我的看法是分场景。如果你做的是内部系统、B 端管理后台,用户固定、流程固定、团队协作紧密,强行上 HATEOAS 性价比很低,费半天劲写链接生成器,客户端也不一定跟着用。但如果你做的是开放式平台 API、第三方开发者要接入的公共服务,HATEOAS 带来的解耦和可演化性就值回票价。你自己要知道你站在哪一级,并且是有意识地选择了这一级,而不是因为不会做超媒体就假装它不存在。

4.3 判断一套 API 是否"真 REST"的自查清单

最后给出我在评审和 design review 时真正会过一遍的清单,你可以直接抄去用:

  • 端点是名词还是动词?有没有一个资源能对应到真实世界或业务领域的概念
  • GET 请求是否安全、无副作用?POST 是否用于创建或触发动作?
  • 响应状态码与动作结果是否一致?创建成功返回 201,无权限返回 403,资源不存在返回 404,而不是一律 200
  • Cache-Control、ETag、Last-Modified是否在合适的接口上使用过
  • 响应表征里,客户端能不能知道下一步可以做什么?如果去掉接口文档,客户端是否还能完成完整业务流程
  • 服务端是否保存了客户端会话状态?还是每个请求都自包含所需信息
  • 版本策略是改 URL(/v1/orders)还是改 Header?你在用什么机制保障客户端不因服务端演化而挂掉
  • 错误响应是否自描述?除了错误码,有没有 machine-readable 的错误类型字段和 human-readable 的说明

过完这份清单,你会发现很多你以为 RESTful 的接口其实并没有真正利用好这套架构风格。这不是在否定你的工作,而是在帮你把"API 设计"从"数据库接口生成"提升到"系统间契约设计"的高度。

我在实际项目里经历了几轮反复之后,最大的体会是:REST 不是银弹,它解决的是"网络系统如何长期演化"的问题,不是"如何把接口写得优雅"的表面问题。学到 Level 2 的团队已经能应付大多数场景了;如果你真想走到 Level 3,得从业务建模和团队协作模式下手,否则超媒体只会变成一套没人维护的摆设。

最后再分享一个小技巧。判断一个 API 设计得是否 RESTful,有个特别快的土办法:去掉所有文档,让一个新同事只看接口报文明文,能不能猜出完整业务流程。如果他盯着响应里的 JSON 完全不知道该调什么、动什么,说明你的 API 把"灵魂"丢了。补上 links,补上状态,补上自描述的信息,整个系统才真正活过来。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询