跳到主要内容

第 7 章 API 设计:业务动作如何变成接口

副题:资源、方法、状态码、版本——接口是业务的"对外承诺"

第 4 章我们翻译出了需求清单,第 6 章我们把业务模型翻译成了六张表。现在,业务线走到第四站,也是"从业务到系统"流水线的最后一张图纸:业务动作怎么变成接口?用户"发布内容"、卖家"上架商品"、买家"下单付款"——这些动作要变成前后端之间、系统之间的请求。第 6 章说"数据模型是接口的舞台,接口是数据模型的演员",本章就来看这些演员怎么上台。

本章要建立的认知

  1. 接口是业务的承诺:URL、方法、状态码、错误码、版本——每一层都是前后端之间(以及未来服务之间)的约定,约定不清楚,联调就是灾难;
  2. REST 不是"把 URL 写得好看",是一套三层约定:资源(名词)+ 方法(动作)+ 状态码(结果)——每层解决一类问题;
  3. 接口的代价在"改":一旦有人用,接口就是承诺,改动要版本化、要兼容——设计接口时多想五分钟,比上线后迁移省一整天。

7.1 没有约定的系统

六张表建完的第二天,你开始写接口。

需求清单里的业务动作很清楚:注册登录、发布内容、评论、上架商品、下单付款。你打开编辑器,按"动作"给每个功能起了一个 URL——这是大多数人写接口的第一反应:

POST /createUser 注册
POST /login 登录
POST /createContent 发布内容
POST /addComment 评论
POST /createOrder 下单
POST /doPay 付款
GET /getUserInfo 看用户信息
GET /getContentList 看内容列表

两周后,前端同事(就是你雇的第一个兼职)开始联调。混乱从第一行代码开始:

第一吵:方法。"为什么创建内容用 POST,获取内容列表也用 POST?"你答:"createContent 是 POST,getContentList 是 GET 啊。"前端:"你 getContentList 带了一堆筛选参数,URL 太长,我只好用 POST 传 body。"——同一个动作,两个人对"该用哪个方法"的理解不一样。

**第二吵:返回格式。**你写的接口返回格式五花八门:有的返回 {data: ...},有的直接返回数组,有的成功返回 {code: 0},有的成功返回 {success: true}。前端每接一个接口,先要读一遍你的代码猜返回结构。

**第三吵:错误。**登录失败,你返回 HTTP 200 + {code: 1001, msg: "密码错误"};下单失败,你返回 HTTP 500 + 一段 HTML 错误页。前端问"到底什么时候看 HTTP 状态码,什么时候看 body 里的 code?"你答不上来——因为你也没想清楚。

联调那几天,一半时间在吵这三件事。老周来验收时,你忍不住抱怨:"前端太笨了。"老周说:"不是前端笨,是你们没有约定。你想想,'发布内容'这件事,你们俩的约定是什么?"

你愣住了。发布内容——业务上是一句话,技术上应该是:前端发一个请求,后端存进 content 表,返回成功。但"请求长什么样、成功怎么表示、错了怎么办"——这三件事,你和前端谁都没定义过。接口的本质,就是这三件事的约定:请求怎么发、成功怎么表示、错了怎么办。没有约定的系统,联调就是灾难;而约定的质量,决定了这个系统十年里每个接它的人的心情。

把三场吵架再拆开看,它们不是三个问题,是一个问题的三个面:你和前端没有共享同一份"接口是什么"的定义。方法之争是"请求怎么发"没定义,返回格式之争是"成功怎么表示"没定义,错误之争是"错了怎么办"没定义。接口设计要做的事,就是把这三个问题各给一份明确的答案——本章的 7.2、7.3、7.4 各回答一个。

还有一个现实要提前说:接口的读者不只是前端。阶段 1 的接口只有前端在调;第 16 章性能优化时,你要在接口上加缓存语义;第 26 章服务拆分时,服务之间的调用走的就是这些接口;三年后维护这个系统的人,第一个读的也是接口——接口是系统里被读得最多的"代码",但它从来不是给人读着舒服而设计的,是给约定设计的。所以接口设计值得花时间,而且值得在动手写第一个接口之前想清楚。

为什么 1-3 年开发者特别容易随手写接口?两个原因:教程教的是"功能怎么实现"(注册接口就是 insert 一下),从不教"约定怎么设计";一个人开发时没有冲突——你自己写的接口自己调,格式对不上当场就改,"约定"根本没有存在感。等到第二个人(前端、同事、未来的服务)加入,冲突才爆发——而爆发时,接口已经写了几十个,改哪个都有人依赖。

接口设计可以总结成一句话的验收标准:调用方能不能不读代码就猜到约定。三个可猜性来源:一致(同一种操作永远同一种表达——列表都分页、错误都同构);简单(URL 和参数越少越不容易猜错);标准(用 HTTP 已有的语义,不用自创——方法、状态码、缓存头都是现成的约定,自创一套等于让所有人重新学)。

本章就是教这三层约定怎么做的:资源与方法(请求怎么发)、状态码(成功怎么表示)、错误码与版本(错了怎么办、改了怎么兼容)。

动手之前,先把阶段 1 的完整接口清单列出来——它是本章的"施工图",后面每节讲的约定都会落到这张表上(v1,与第 4 章需求清单逐条对应):

方法URL用途
POST/v1/users注册
POST/v1/login登录
GET/v1/users/{id}用户信息
GET/v1/contents?page=&size=内容列表(分页)
POST/v1/contents发布内容
GET/v1/contents/{id}内容详情
POST/v1/contents/{id}/comments评论
GET/v1/products?page=&size=商品列表
POST/v1/products上架商品
POST/v1/orders下单(幂等键 order_no)
POST/v1/orders/{id}/pay付款
GET/v1/orders/{id}订单详情

对照第 4 章需求清单:每个业务动作都有接口,每行验收标准都能通过接口验证——接口清单就是需求清单在协议层的投影。注意这张表里没有购物车、没有搜索——它们在第 4 章被砍了,接口层自然不会出现;也没有评论的"删除"——阶段 1 砍掉了(第 4 章的简化决策)。

这张清单还可以做一次验收对照:第 4 章的验收标准("重复点击只产生一个订单")落到接口层,就是"POST /v1/orders 带幂等键、重复请求返回同一订单"——接口清单是需求清单的投影,验收标准就是接口的测试用例(第 17 章)。

动手设计接口之前,先算一笔投入产出:一天把这张清单和约定想清楚,vs 联调几天在吵架里补约定。第 4 章算过需求分析的账(一天分析对两周返工),接口设计的账一模一样——约定是联调的地基,地基不画,楼就歪。

接口设计的时间点也值得说清:需求清单定稿后、后端开工前,花一天把接口清单和约定立起来。早于这个时间点(需求还没定就设计接口)会白做;晚于这个时间点(后端先写代码再补接口)就是 7.1 的联调灾难。接口设计不是开发的一个阶段,是需求分析和开发之间的一个工序——和建表(第 6 章)同一个位置:先画图,再施工。

7.2 第一层约定:资源与方法

REST 的第一层约定,是把"动作"翻译成"资源 + 方法"。

先看一个反例——7.1 的 URL 列表。它们有一个共同问题:URL 是动词(create、get、add、do)。动词式 URL 意味着每个动作都是"新发明",前端每接一个接口都要学一个新词:createUser、getUserInfo、updateUserAvatar……动作无穷无尽,URL 也就无穷无尽。

REST 换了一个思路:URL 只写名词(资源),动作交给 HTTP 方法。用户是一种资源,内容是一种资源,订单是一种资源——资源是有限的(和业务对象一样多),方法只有四个(GET/POST/PUT/DELETE)。把资源和方法组合起来,就能表达所有动作:

GET /v1/users/{id} 看用户
POST /v1/users 注册(创建用户)
GET /v1/contents 看内容列表
POST /v1/contents 发布内容
GET /v1/contents/{id} 看单条内容
POST /v1/contents/{id}/comments 评论内容
POST /v1/orders 下单
POST /v1/orders/{id}/pay 付款

对比 7.1 的动词式列表,资源式的变化是:URL 数量从"和动作一样多"变成"和资源一样多",动作的差异全部由方法表达。前端学一次"资源有哪些 + 方法是什么意思",就能猜出 80% 的接口——约定的价值,就是让"猜"变得可靠

资源式 URL 还有三个设计细节,直接影响可猜性。复数名词:资源名用复数(/contents、/orders),因为 URL 指向的是"这类资源"的集合,单条用 id 定位(/contents/{id})——复数是 REST 社区的事实标准,一致性比单复数之争本身重要。嵌套表示归属/contents/{id}/comments 读作"这条内容下的评论",嵌套表达"从属于"关系——但嵌套不要超过两层,超过就说明资源边界该重新划了(第 6 章的实体边界判断在这里同样适用)。路径参数和查询参数分工:路径参数定位资源(/contents/{id} 里的 id),查询参数筛选资源(/contents?author=3&tag=二手 里的筛选条件)——定位用路径,筛选用查询,两者分工明确,URL 才可读。

这里还有一个真实的设计选择:用户的订单列表,用嵌套还是查询参数?GET /users/{id}/orders(嵌套:从用户进)还是 GET /orders?buyer_id={id}(查询:从订单进)?两种都常见,选择的依据是"哪个资源是入口":用户详情页里的订单列表用嵌套(入口是用户),订单管理后台用查询(入口是订单)。同一个业务两种接口可以并存,但每个都要在契约里写清楚语义——不能同一个 URL 两种含义。

查询参数的筛选语义要单独说:/contents?author=3&tag=二手——按作者和标签筛内容。这个接口将来的命运和第 6 章的 JSONB 标签、第 14 章的索引直接相关:标签筛选走 product_tag 关联表查询(第 6 章建的那张),筛选条件多了要建复合索引(第 14 章),筛选结果慢了要加缓存(第 16 章)——一个 query 参数,牵动后面三章。

四个方法的语义逐个说清(它们不只是"增删改查"的缩写):

  • GET:读资源,幂等——读十次和读一次结果一样,可以安全重试、可以缓存、可以被浏览器直接打开。GET 永远不应该有副作用(不能"GET 一下就把订单取消了")。
  • POST:创建资源(或执行一个不被幂等语义覆盖的动作),不幂等——发两次会创建两条。POST /orders 发两次产生两个订单——这正是第 17 章幂等机制的接口侧起点:客户端必须带 order_no,服务端靠唯一约束挡住重复(第 6 章建表时埋的伏笔,在这里被接口暴露出来)。
  • PUT:整体更新资源,幂等——把资源的全部字段替换成请求里的值,做两次结果一样。
  • PATCH(一句带过):部分更新,只改请求里出现的字段——和 PUT 的边界(整体 vs 部分)容易混,团队里约定"更新用 PUT 还是 PATCH"二选一即可,混用会乱。
  • DELETE:删除资源,幂等——删不存在的资源返回 404 也算"结果一致"。

注意幂等出现了三次。幂等不是一个数据库概念,它是从接口就开始的语义约定:GET/PUT/DELETE 幂等,POST 不幂等——调用方(前端、重试机制、未来的消息队列)根据方法就知道能不能安全重试。第 17 章交易系统里的幂等判断(order_no 唯一键、状态前置检查),在设计上就是"把不幂等的 POST 变成幂等的业务操作"。

接口层的幂等约定还有一层:POST 的幂等键。POST /v1/orders 本身不幂等,但业务要求"同一笔下单只执行一次"——约定是:请求体里带 order_no(客户端生成,第 17 章会展开),服务端拿它当唯一键。接口文档里要写明"哪些 POST 支持幂等键、幂等键放哪"——这行字,就是第 17 章幂等机制的接口侧接口。

图7-1 图稿占位
REST 接口设计示意
业务动作→资源+方法+状态码

还有一个容易混的点要澄清:资源 ≠ 表。第 6 章的六张表是存储,REST 的资源是业务视图。POST /v1/contents 确实对应 content 表的一行插入,但 POST /v1/orders/{id}/pay 背后是订单状态流转(第 17 章的状态机),它不直接对应某张表的某一行。资源是"业务上的一件东西",表是"存储里的一个结构"——接口设计看业务,表设计看存储,两者经常长得不一样,这是正常的。把第 6 章和第 7 章合起来看,业务对象经历了两次翻译:表(存储)→ 资源(业务视图)→ 接口(协议)——六张表对应十来个资源、十二个接口,每次翻译都换一层语言,但业务语义(用户、内容、订单)始终没变;这就是"从业务到系统"流水线的完整形态:业务语言 → 需求清单(4)→ 技术方案(5)→ 表结构(6)→ 接口(7),每一层都是上一层的一次翻译,每一层都保留了业务的语义,只是换了表达的载体。

这也顺带回答了第 1 章开头留下的一个问题:**为什么接口返回前要过一层 DTO,直接把数据库查出来的结构返回不行吗?**直接返回会有什么问题?第一,表结构是存储的细节:content 表有 body、status、author_id……但列表页只需要 title、author_name、created_at——把整行返回,前端拿到一堆用不上的字段,还暴露了不该暴露的内部字段;第二,表结构会变:第 6 章说过表要改(加字段、拆表),如果接口直接映射表,表一变接口就变——DTO 是接口和表之间的缓冲:表怎么改,接口的承诺不变。DTO 的代价是多一层转换代码,收益是接口的稳定性——这正是"接口是承诺"在存储侧的实现。第 12 章服务端分层时,DTO 会在代码层正式登场。

列表接口还有一个每章都会遇到的形态:分页GET /v1/contents 返回什么?全部内容?内容会越来越多(第 16 章性能危机的种子),所以列表接口从第一天就约定分页:/contents?page=1&size=20,返回 {items: [...], total: 1234, page: 1, size: 20}。分页的约定细节(字段名、从 1 还是 0 开始、total 要不要)各团队有各团队的口味,但"列表必须分页"本身是底线——不分页的列表接口,是第 16 章性能章的第一批主角。

响应体的结构也值得趁早统一(这是 7.1 联调第二吵的答案):要么统一包一层 {data: ..., meta: ...},要么直接返回数据本身。两种都行,选一种写进契约——前端不用每个接口猜一次。案例选的是:列表接口返回 {items, total, page, size},单资源接口直接返回资源对象——两种形态都在契约里写明,这就是"成功怎么表示"的完整约定。

7.3 第二层约定:状态码

请求怎么发定了,下一个问题:**成功怎么表示?**REST 的第二层约定是 HTTP 状态码——它不是"后端顺手返回的数字",是机器可读的"结果语义"。

状态码按百位分成五类,每类一个意思:

状态码含义什么时候用案例
2xx成功了200 读成功、201 创建成功、204 删除成功POST /orders → 201
4xx调用方错了400 参数不对、401 未登录、403 没权限、404 资源不存在、409 冲突库存不足 → 409
5xx服务端错了500 内部错误、503 服务不可用数据库挂了 → 500

五类状态码是"结果"的五种可能:成功、你错了、我错了。调用方(前端)拿到状态码,第一件事就是判断该走哪条路:2xx 走成功分支,4xx 提示用户检查输入,5xx 提示"稍后再试"(并触发重试逻辑——第 17 章的回调重试就是靠状态码区分"重试有用吗")。

状态码的完整使用,看一次真实的接口调用:用户下单,POST /v1/orders——参数不对返回 400(前端提示检查输入)、没登录返回 401(前端跳登录页)、库存不足返回 409 + OUT_OF_STOCK(前端提示缺货)、一切正常返回 201 + 订单对象(前端跳转订单页)。一次调用,四种结果,每种都有明确的下游动作——这就是状态码语义的全部价值。

状态码的两个使用纪律,是从联调吵架里长出来的:

**第一,4xx 和 5xx 不能混。**参数错误返回 500,前端会以为"服务器挂了"而重试;服务器出错返回 400,前端会提示"你输入错了"而让用户反复提交。状态码是"责任声明":4xx 是调用方的责任,5xx 是服务端的责任——责任分清楚,重试和提示才有依据。

第二,状态码管"结果",业务码管"细节"。POST /v1/orders 返回 409(冲突),调用方知道"下单没成功、不是我的网络问题",但不知道具体为什么——是库存不足?还是重复下单?这时候需要 body 里的业务错误码

HTTP/1.1 409 Conflict
{
"code": "OUT_OF_STOCK",
"message": "商品库存不足",
"request_id": "a1b2c3"
}

code 是给程序看的(稳定、机器可读、前后端都有对应表),message 是给人看的(可以展示给用户),request_id 是给排障用的(第 25 章可观测性会展开:出问题时报这个 id,日志一查一个准)。状态码回答"成没成功",业务码回答"为什么",消息回答"怎么办"——三层各司其职,前端才不会需要读后端代码猜错误。

2xx 内部的区分也值得做对:200 读成功、201 创建成功、204 删除成功——创建资源返回 200 而不用 201,前端就不知道"这次请求是不是真的创建了新东西";401 与 403 的区别:401 是"你没登录(或登录失效)",403 是"你登录了但没权限"——前者该跳登录页,后者该提示"无权访问",混用会让前端把"权限不足"当成"重新登录"。状态码的粒度,是"调用方能据此做对下一步"的粒度。404 还有两种用法:资源真的不存在,和"存在但你没权限看"(用 404 隐藏存在,防止探测)——大多数项目用前者,涉及隐私的资源用后者,选择也要写进契约。

请求校验的顺序也值得约定(它是 4xx 内部的判定顺序):先鉴权(401)→ 再参数(400)→ 再业务规则(409 等)。没登录就先去登录,参数错了先改参数,最后才是业务冲突——顺序乱了,前端会先收到业务错误、排查半天才发现是没登录。

还有一个常用的 4xx 变体要认识:429 Too Many Requests(限流)——调用方请求太频繁被暂时拒绝,响应头带 Retry-After 告诉它多久后再试。阶段 1 用不到,第 24 章高可用时它是限流的招牌状态码。

错误码本身的设计也有原则:稳定OUT_OF_STOCK 一旦发布不改名,前端对应表按名字写)、唯一(同一个错误全项目只有一个码,不允许两处各起一个)、文档化(错误码表进接口文档,和资源列表同等地位)。全项目一个编号体系(OUT_OF_STOCKORDER_REPEATEDUNAUTHORIZED),错误码表是一份会持续增长的资产——每加一个业务分支,就可能加一个码;不加码的代价是前端用"message 字符串匹配"判断错误,那是所有错误处理里最脆弱的写法。业务错误码还是第 17 章重试策略的输入:ORDER_REPEATEDOUT_OF_STOCK 的重试策略完全不同(前者不该重试,后者过会儿可以重试)。

request_id 和错误码是一对排障搭档:用户报错时带上 request_id,后端从日志里一查就能定位到那次请求的完整链路(第 25 章可观测性会展开 trace 机制)——错误码回答"什么错了",request_id 回答"哪次请求错了",两个字段在接口层就位,排障效率差一个量级。

错误响应的结构也必须全项目一致——这是 7.1 联调第二吵(返回格式五花八门)在错误场景的延续:所有 4xx/5xx 响应都返回同一个结构 {code, message, request_id},前端写一套错误处理代码就能接所有接口。不一致的错误结构,等于每个接口一套错误处理——前端最恨这个。

重试策略和状态码的关系,这里先立一条简单规则,第 17 章会用到:5xx 可以重试,4xx 不要重试。5xx 是服务端的问题,重试可能刚好赶上恢复;4xx 是调用方的问题,重试一万次结果一样(参数还是错的)。这一条写进接口文档,前端和重试机制就都有了行为依据。

7.4 接口是承诺:契约与版本

前两节讲了"请求怎么发、成功怎么表示",剩最后一个、也是最重的一个问题:接口定了之后,还能改吗?

答案取决于一件事:有没有人在用。没有人用的接口随便改;有人用的接口——哪怕是只有前端在用——接口就是承诺:你承诺了 POST /v1/orders 长这样,前端就按这个写了代码,改接口 = 改前端的代码。这不是"前端娇气",是接口的本质:接口是系统之间的边界,边界两边的实现都依赖这个约定

"接口是承诺"带来的第一个推论:接口文档是契约,不是说明书。说明书描述"现在长什么样",契约声明"长什么样算数"。所以接口文档要机器可读(OpenAPI 规范),因为契约要能被校验:前端可以拿契约生成客户端代码,测试可以拿契约生成测试用例,后端可以拿契约做接口自检——契约的价值是"两边都对着同一份约定写代码,而不是对着各自的理解"。文档漂移(代码改了文档没改)是契约失效的头号原因,机器可读的契约 + 自动化校验是它的解药。

契约的三方用法值得展开:前端拿它生成客户端(类型定义、请求封装,少写一半样板代码);测试拿它生成用例骨架(每个接口的参数校验、状态码断言);后端拿它做接口自检(响应格式和契约不一致,CI 直接报错——第 22 章的 CI 流水线里会有这一步)。一份契约,三方复用,这正是"文档即契约"和"文档即说明书"的差距:说明书只有人读,契约机器也读。

契约的维护有一条铁律:接口变更时,先改契约,再改代码。流程是:改契约 → 评审(第 20 章)→ 后端按新契约实现 → 前端按新契约生成客户端 → CI 里契约校验(第 22 章)——契约是唯一真相,代码跟着契约走,而不是反过来。顺序反了(先改代码再改文档),文档漂移就开始了;文档漂移三个月,契约就名存实亡,回到"读代码猜约定"。

契约是接口的"宪法"——它约束的不只是接口长什么样,还有接口怎么变(版本)、谁在用它(权限)、它要多快(预算)。把这几栏写进契约的那一天,接口就从"代码"变成了"承诺";而承诺越早立,联调就越省心——7.1 联调那几天里的吵架,就是没立承诺的代价。

契约还有一个第 15 章的伏笔:契约测试。前后端按同一份契约独立开发,联调从"对接"(两边对着各自的理解凑)变成"验证"(两边都对着契约,对不上就是有一方违反了契约)——第 15 章联调与全链路走通会看到,契约测试是联调从地狱变例行公事的关键。

接口文档的一个条目长什么样(以 GET /v1/contents 为例):

  • 方法/URL/用途:GET /v1/contents?page=&size=——内容列表;
  • 参数:page(页码,从 1 起)、size(每页条数,默认 20,上限 100)、author(可选,按作者筛选)、tag(可选,按标签筛选);
  • 响应:200 {items, total, page, size};400 参数不合法;
  • 错误码:无(列表接口一般不产生业务错误码)。

每个接口条目都包含四部分:怎么调、传什么、返回什么、错了怎么办——正好是本章四层约定的映射。文档条目写全的接口,前端不需要问后端;写不全的接口,前端每接一个都要来问一次。

条目还可以加两栏,都是给未来章节铺路:权限标注(第 13 章认证的接口侧接口)——哪些接口要登录(POST /v1/orders 要、GET /v1/contents 不要)、哪些只有本人/管理员能调(DELETE /v1/contents/{id} 只有作者),第 13 章实现认证中间件时照着这张表做就行;性能预算(第 4 章"首页要快"的验收标准落到接口层)——每个接口标注 P95 目标(GET /v1/contents < 200ms、POST /v1/orders < 500ms),第 16 章性能优化时它是"哪个接口需要优化"的判决书,没有预算的接口优化时只能"感觉慢"。

接口的测试也在这里埋下伏笔:契约是测试用例的骨架(每个接口的状态码断言、参数校验),接口实现的单元/集成测试是第 18 章测试体系的第一批素材——接口层是全书唯一"契约、实现、测试三方对齐"的地方,这正是它值得多花一天设计的原因。

OpenAPI 就是把这四条写成机器可读的规范(YAML 片段,示意):

/contents:
get:
summary: 内容列表
parameters:
- { name: page, in: query, schema: { type: integer } }
- { name: size, in: query, schema: { type: integer, default: 20 } }
responses:
'200':
description: 成功
content:
application/json:
schema: { type: object, properties: { items: { type: array } } }
'400': { description: 参数不合法 }

这份 YAML 是契约的实体形态:前端生成客户端、测试生成用例、后端做自检,都从它来。

第二个推论:改接口要版本化。契约不能随便改,但业务会变——资源要加字段、接口语义要调整。业界约定俗成的解法是 URL 版本号:/v1/orders/v2/orders。版本化的规则只有一条:v1 永远不破坏,新东西进 v2。v1 的字段只能加不能删(删字段会破坏已经按旧格式解析的前端),v2 是全新的承诺,等所有调用方迁到 v2,v1 才能下线。

图7-2 图稿占位
接口版本演进
接口是承诺,版本是兼容策略

版本化的代价是并存:v1 和 v2 同时跑,两套逻辑、两套测试。所以版本化的纪律是别轻易开新版本——能用"加字段 + 兼容"解决的改动,不要开 v2;每开一个版本,都是把承诺的维护成本翻一倍。什么时候该开 v2?接口的语义变了(比如"下单"从一单一件变成一单多件,返回结构完全重写),而不是"加了一个可选字段"。

版本号放哪也有讲究:放 URL(/v1/orders)是主流——简单、直观、任何人都看得见;放 Header(Accept: application/vnd.api+json; version=1)更"干净"(URL 不变),但看不见、调试麻烦。1-3 年团队选 URL 版本号就对了:可见性比优雅更重要,"看得到版本"本身就是一种约定。

用案例推演一次完整的接口变更:阶段 3 业务要求"购物车"——下单从一单一件变成一单多件。接口怎么改?POST /v1/orders 的请求体从 {product_id, quantity} 变成 {items: [...]}——破坏性变更(前端旧代码解析不了新结构),不能加字段兼容,必须开 v2。于是 /v2/orders 接受新结构,v1 继续服务旧调用方,等迁移完成 v1 下线。这次推演说明版本化的真实流程:评估变更性质(加字段 or 破坏)→ 决定 v1 兼容 or v2 → 迁移 → 下线——每一步都有成本。所以第 4 章砍需求时把购物车砍掉,省下的不只是后端开发,还有这次接口迁移——砍掉一个功能,接口层也会少一次 v2

向后兼容有一条具体的规则表,值得记下:加字段安全(前端不认识的字段会忽略);改字段类型危险(前端按 string 解析,你改成 number,全线崩);删字段危险(前端还在用);改语义最危险(字段没变,但含义变了——"金额"从含税变不含税,前端代码一行不用改,业务全错)。所以 v1 的演化原则是:只加不改不删;要改要删,进 v2。这条规则表,第 26 章服务拆分时接口改造还要用一遍。

回到案例:阶段 1 的接口全部是 v1,只有内部前端在用。但第 7 章必须把版本化的规矩立好——因为第 16 章性能优化、第 26 章服务拆分时,接口会变,而那时"承诺"的约束会显现。**接口设计里最便宜的一步,是在第一个接口上线前想清楚版本策略;最贵的一步,是上线后发现没想过。**接口评审也一样:第 19 章会看到,接口是 Code Review 里最值得先审的部分——因为改代码容易,改接口等于改所有人的代码。

下线一个接口(v1 退休)也有流程,第 26 章会用到:先通知所有调用方(谁在用这个接口,要有清单)→ 观察流量(没人调了才敢动)→ 灰名单(只拒绝特定调用方)→ 下线。每一步都是为了回答同一个问题:有没有人还在依赖这个承诺?——接口的"承诺"属性,从设计到下线,贯穿始终。

7.5 REST 的边界:什么时候它不够

REST 是 Web 接口的默认语言,但它不是唯一语言,也不是所有场景的最优解。第 5 章的决策模型在这里用得上——REST vs RPC 是一次真实的选型,只是大多数项目在"默认用 REST"时没意识到自己做了选择。

走一遍九步(轻量版):业务目标:前后端(以及未来的服务间)高效约定动作;业务约束:阶段 1 只有内部前端、接口数量几十个、团队 1-2 人;技术问题:接口用什么风格约定?候选方案:REST(资源式)、RPC(远程过程调用,如 gRPC:接口像函数调用,getUser(id) 而不是 GET /users/{id});评价维度:可读性、生态、性能、跨语言、工具链;方案选择:REST——内部前端 + 浏览器生态,REST 的可读性和 HTTP 生态最省心;收益:约定简单、工具成熟、前后端都好调试;代价:语义表达力有限(复杂查询、多资源聚合要绕);演进条件:出现强类型跨语言服务间调用(第 26 章拆分后服务间通信)或复杂查询场景时,重新评估 RPC/GraphQL。

压缩成决策记录(第 5 章的习惯):REST vs RPC——选 REST。目标:前后端约定动作;约束:内部前端、1-2 人;候选:REST/RPC;维度:可读性+生态优先;收益:约定简单工具成熟;代价:复杂查询要绕;演进条件:第 26 章服务间通信再评估。一行一行的记录,比九段话好查——这就是决策记录的意义。决策记录写进接口文档的附录,第 26 章服务拆分重审接口时,第一份要看的就是它。

REST 的两个已知边界先认识一下(都是"场景到了再说"):复杂查询——REST 的资源式语义表达"按作者+标签+时间筛选内容"要堆 query 参数,GraphQL 用声明式查询解决,但引入查询语言本身的复杂度;服务间高频强类型调用——RPC(gRPC)性能更好、类型更严,但那是第 26 章服务拆分后的话题。阶段 1 的结论很干净:REST 够用,不引入新东西——这正是第 4 章砍需求("先不做搜索,用标签筛选顶住")在接口层的同款决策:能少引入就少引入,复杂度留给被逼出来的那一天

REST 还有两个"隐藏福利",和后面章节直接相关。缓存语义:GET 是幂等的读,天然可以缓存——第 11 章 CDN 缓存静态资源、第 16 章性能优化给接口加缓存,靠的都是"GET 语义 + 缓存头(Cache-Control)";如果当初接口设计成"用 POST 查列表"(7.1 前端干过的事),缓存就没法做。生态:HTTP 状态码、URL、JSON 是所有语言和工具的公共语言——浏览器调试工具、压测工具、网关、日志系统全都认识它们。这两个福利不是 REST 的附加功能,是"资源+方法+状态码"三层约定带来的副产品:约定越标准,生态越能帮你。

登录接口单独说一句:POST /v1/login 是一个"动作"而不是"资源"——它没有对应的表,也不符合"名词+方法"的形态。REST 社区对这类接口的共识是:动作可以存在,但要少(登录、支付这类"动词接口"每个系统只有几个),且要明确它是例外。登录返回什么?一个会话凭证(token)——它怎么生成、怎么校验、怎么过期,是第 13 章认证章的主角。第 7 章只需要把接口立起来:POST /v1/login 接受账号密码,返回凭证,401 表示凭证无效。

顺带破除一个常见误解:REST 不是"URL 好看",更不是"JSON 接口"。一个返回 JSON 的接口可以是设计得很烂的 REST(动词式 URL、状态码乱用、错误码没有);一个设计良好的接口,即使 URL 不"美观",只要遵守"资源+方法+状态码"的语义,就是合格的 REST。判断一个接口设计好不好,标准是"调用方能不能不读代码就猜到约定",不是 URL 好不好看。

还有一句 HTTP 版本的事(第 10 章会展开):本章的接口语义(资源/方法/状态码)建立在 HTTP 之上,HTTP/1.1 和 HTTP/2 只是传输层的变化——接口的约定不变,变的只是请求怎么在网络上跑。接口设计可以放心依赖 HTTP 语义,不用等"HTTP 版本稳定"。

最后把 REST 放回它的位置:REST 是 Web 世界的接口语言,它之所以成为默认,不是因为"优雅",是因为 HTTP 是 Web 上唯一所有人都认得的协议——接口约定建立在 HTTP 上,等于建立在所有人的共识上。这一章的每一层约定(资源/方法/状态码/版本),都是"把共识变成可执行的细节"。第 10 章会从协议层面看 HTTP 本身怎么工作——那是接口约定之下的地基。

7.6 本章对应表

业务诉求技术选择为什么代价/取舍
前后端各说各话REST 约定(资源+方法+状态码)统一的"动作语言",可读、可缓存、生态成熟语义要设计,动词式 URL 的习惯要改
接口不知道成没成功HTTP 状态码(2xx/4xx/5xx)机器可读的结果语义,责任声明(4xx 你错/5xx 我错)语义要统一,混用会误导调用方
业务错误说不清业务错误码(code+message+request_id)状态码管结果、业务码管原因、消息管怎么办错误码表要维护,前后端要对齐
接口不能随便改版本化(/v1/、/v2/)接口是承诺,改要兼容;v1 不破坏、新东西进 v2多版本并存的双倍维护成本
文档没人信文档即契约(OpenAPI)机器可读契约:生成客户端/测试/自检文档要维护,漂移要防
动作复杂、REST 不够(预告)RPC/GraphQL(边界)REST 是默认不是唯一;九步决策在 7.5场景没到不上(第 26 章服务拆分再评估)

每一行都在本章正文里有完整的论证:REST 三层约定在 7.2/7.3,状态码在 7.3,错误码在 7.3,版本化在 7.4,契约文档在 7.4,REST 边界在 7.5。和前面章节的对应表一样,先看"业务诉求"列——每一行都是从联调吵架里长出来的。

把接口放回全书地图:接口是系统的门面——第 13 章认证("谁在调用这个接口"——门禁)、第 19 章安全("这个接口会不会被攻击"——攻击面)、第 16 章性能("这个接口慢不慢"——缓存与优化)都从接口出发。第 7 章立下的约定(资源/方法/状态码/版本)是这三章共同的地基:认证加在接口上(中间件)、安全审查从接口清单开始、性能优化从最慢的接口下手——接口约定越清晰,这三件事越有抓手。这也是为什么接口设计要写在业务线里而不是"开发中":它是后面所有章节的公共地基。

到这里,第二部分(第 4-7 章)的流水线走完了:需求清单(要什么)→ 技术选型(怎么做)→ 数据模型(怎么存)→ 接口(怎么调)——四张图纸,把"一个社区交易产品"从一句话变成了可以开工的完整设计。从第 8 章开始,书要从"画图纸"切换到"看运行时":图纸上的一切(表、接口、选型),将在一台真实运行的机器上被一次真实的请求串起来。

本章小结

API 设计速查(后续章节回指本章时翻回这里):

  1. 三层约定:资源(名词)+ 方法(动作)+ 状态码(结果)——URL 只写名词,动作交给方法;
  2. 方法语义:GET/PUT/DELETE 幂等可重试,POST 不幂等要幂等键(order_no,第 17 章);
  3. 状态码:2xx 成功 / 4xx 你错 / 5xx 我错;4xx 不重试,5xx 可重试;
  4. 错误码:状态码管结果、业务码管原因、message 管怎么办、request_id 管排障;
  5. 接口是承诺:文档即契约(OpenAPI),v1 只加不改不删,要改要删进 v2;
  6. 列表必分页;资源 ≠ 表;嵌套不超过两层。

(六条速查对应四层约定:1-2 是请求怎么发,3-4 是成功与错误怎么表示,5 是改了怎么兼容,6 是细节底线——四层约定记牢,接口设计就有章法。)


  • 接口是业务的承诺:请求怎么发(资源+方法)、成功怎么表示(状态码)、错了怎么办(错误码)、改了怎么兼容(版本)——四层约定,每一层都在减少"读代码猜约定";
  • REST 的核心不是"URL 好看",是资源是名词、动作是方法、结果是状态码——约定让"猜"变得可靠;GET/PUT/DELETE 幂等、POST 不幂等,是第 17 章幂等机制的接口侧起点;
  • 接口的代价在"改":契约要机器可读(OpenAPI)、改动要版本化(v1 不破坏、新东西进 v2)——最便宜的一步是上线前想好版本策略;
  • REST 是默认不是唯一:复杂查询(GraphQL)、服务间强类型调用(RPC)是已知边界,阶段 1 不引入——和砍需求同一个决策逻辑;
  • 本章画完"从业务到系统"的最后一张图纸:需求清单(4)→ 技术选型(5)→ 数据模型(6)→ 接口(7)——业务线的前半段走完,接下来视角切换:一次请求真的从浏览器出发,看它在运行时里怎么走(第 8 章起)。

下一章

接口定好了:POST /v1/contentsGET /v1/contents……前后端对着契约写代码。但契约只是约定,请求真正发出去的那一刻,会发生什么?用户在浏览器输入网址、按下回车——URL 怎么变成 DNS 查询、HTTP 请求、服务器响应、页面渲染?

下一章开始,视角切换:从"业务怎么变成系统"切换到"一次请求在系统里怎么走"——第 8 章,请求的第一站:浏览器与前端基础。

(第二部分四张图纸全部画完:需求清单 → 技术选型 → 数据模型 → 接口。接下来,让图纸上的系统真正跑起来——从一次请求的视角,从头看它怎么走。图纸是静态的,请求是动态的。)