我做后端开发这些年,前前后后评审过的接口设计请求多得数不清。几乎每个新项目启动时,团队都会说"这次我们一定要用 RESTful 风格来设计 API",可翻开代码一看,Controller 里十有八九长这样:/api/getUser、/api/updateUser、/api/sendVerifyCode……名字倒是挺规范,实际上跟 RESTful 没什么关系。说白了,多数人把"用 HTTP + JSON 写接口"当成了 RESTful,结果做出来的是披着 REST 外衣的 RPC 接口。
这篇文章我想把 RESTful API 设计这件事讲透:从资源建模、URL 命名、HTTP 方法语义,到错误处理、分页筛选、幂等并发,再到安全限流和文档契约。面向的读者是正在设计接口的后端开发、负责前后端协作的架构师,以及打算把现有接口规范化的团队。不管你是刚入行还是已经写了几年接口,只要能跟着把每一节的核心思路落到项目里,接口的可用性、可维护性和协作体验都会明显上一个台阶。
1. 先搞清楚 RESTful 到底在解决什么问题
1.1 为什么很多接口只是"形似 RESTful"
在聊最佳实践之前,得先花点时间说清楚 RESTful 的本质。很多团队上来的第一个问题就是"RESTful 的 URL 到底该用单数还是复数""用 PUT 还是 PATCH",这些当然要讨论,但都是战术层面的细节。真正的问题在于:你设计接口的时候,到底是用"资源"的视角在思考,还是用"动作"的视角在思考。
动作视角是最直觉的思考方式。业务上有个操作叫"下单",那就建一个/api/createOrder;有个操作叫"取消订单",那就建一个/api/cancelOrder。需求一多,接口列表会变成一长串动词清单。这种设计在 RPC 框架里完全没问题,但放在 HTTP 接口里就浪费了 HTTP 本身的能力。HTTP 协议天生是面向资源的,它定义了一套统一的方法(GET、POST、PUT、PATCH、DELETE、OPTIONS……)来表达"对资源做什么",而不是让你为每个动作发明新 URL。
REST 的全称是 Representational State Transfer,翻译过来是"表征状态转移"。这个名词很学术,拆开看其实不难:我们把业务数据抽象成"资源"(Resource),资源在服务器上有自己的状态;客户端通过 HTTP 方法让资源发生状态转移;服务器返回给客户端的不是"内部实现",而是资源的一种"表征"(Representation),通常是 JSON 或 XML。也就是说,客户端不关心服务器怎么存数据、怎么跑逻辑,它只跟"资源"打交道。
1.2 REST 的四个核心约束:少一个都不完整
很多人以为 RESTful 就是"URL 用名词、返回 JSON",其实那只是表象。正式一点的 REST 架构有四个核心约束,我结合自己的实践逐个说:
- 资源化(Resource):把业务对象建模为资源,例如用户、订单、商品。每个资源有唯一的标识,也就是 URI。这个约束是最容易理解的,但也是最容易被打破的。
- 无状态(Stateless):服务器不保存客户端上下文,每个请求都携带足够的信息,可以被服务器独立理解。登录状态、会话信息必须由客户端管理。好处是服务器容易横向扩展,负载均衡随便加机器。
- 统一接口(Uniform Interface):所有资源通过同一套 HTTP 方法访问,通过状态码、媒体类型等标准化的方式传递语义。这样客户端面对一个新资源时,不需要学习一套新的调用约定。
- 表征(Representation):服务器返回的是资源的表征,而不是内部对象本身。客户端通过表征来理解资源状态,并通过提交表征来请求修改资源。
我见过最典型的不满足 REST 约束的接口就是登录接口。很多项目里写POST /api/login,这没什么大问题;但紧接着会写POST /api/logout、POST /api/refreshToken、POST /api/changePassword,再加上GET /api/getUserInfo——接口的语义就完全碎掉了。资源化思考的第一步是识别出"登录"在系统里其实是对"Token 资源"的创建,POST /api/auth/tokens创建令牌,POST /api/auth/tokens/refresh刷新令牌,POST /api/auth/tokens/revoke吊销令牌。你看,动词就从 URL 里消失了,全被 HTTP 方法和资源名消化掉了。
1.3 别把 REST 当万能药:什么时候不该硬上
既然把 RESTful 说得这么重要,我也得泼一盆冷水。REST 适合的场景是资源清晰、CRUD 语义明确、客户端多样化(Web、App、小程序、第三方)的系统。但有几类接口,硬上 RESTful 反而别扭:
- 内部高并发 RPC 调用:服务与服务之间传输的都是明确的调用语义,比如"批量计算价格""推送消息给一百个人",用 gRPC 或内部 RPC 框架更合适,性能和表达能力都更好。
- 复杂报表聚合查询:一张大宽表十几个维度自由组合,你很难把它建模成一组干净的资源。这时候暴露一个
POST /api/reports/query反而诚实——它本质上就是一次命令式查询。 - 纯实时指令:比如重启设备、发送验证码、触发转码任务这类动作,强行资源化会得到
POST /devices/{id}/restart这种带动词的子资源。这其实无所谓,REST 社区普遍接受"动作子资源"作为一种实用妥协。
我个人的判断标准很简单:如果这个接口被客户端当作"函数"来调用,且没有明显的资源归属,那就别硬凹 RESTful 的造型。反过来,只要数据结构上有明显的"名词",比如用户、订单、文章、任务,那 REST 就是最省心的选择。
2. URL 设计:资源命名、层级与版本管理
2.1 命名规则:名词复数、小写、中划线
确认了资源的视角,URL 的命名就有章可循了。业界经过多年沉淀,基本收敛出了一套默认约定,我直接给结论:
| 约定项 | 推荐写法 | 不推荐写法 | 原因 |
|---|---|---|---|
| 资源名用名词 | /orders | /getOrders | URL 里不应出现动词 |
| 集合用复数 | /users | /user | 复数语义上表示集合,更一致 |
| 字母全小写 | /orders | /Orders | 避免大小写敏感导致的混乱 |
| 单词间用中划线 | /order-items | /order_items或/orderItems | 中划线在 URL 里最不易被误解 |
| 资源名具体化 | /customers/{id}/invoices | /data/{id} | 资源要能被业务理解 |
这套约定不是拍脑袋定的。URL 的第一个原则是"可预测性":客户端只要知道资源的集合名,就能推导出单个资源的 URL;知道了一个子资源,就能顺着层级探索到父资源。第二个原则是"可读性",日志和监控里出现/api/v1/order-items/1042的时候,任何人一看就知道在说什么。第三个原则是"安全",全小写加中划线能避免很多大小写转换引起的缓存失效和路由歧义。
一个常见的争议点是:单个资源该用/users/1还是/user/1?我推荐统一用复数。虽然单个资源用单数/user/1在逻辑上更精确,但团队里总会有人忘了这个约定,一会儿写/users/{id}一会儿写/user/{id},路由就成了隨缘匹配。统一用复数后,集合和单体的关系一目了然:/users是列表,/users/1是列表里的一个元素。
2.2 层级嵌套:两层够用,最多不要超过三层
URL 的层级能表达资源之间的从属关系。比如订单明细一定是从属于某个订单的,那么GET /orders/{orderId}/items就比GET /items?orderId=123更自然。嵌套的规则建议这样掌握:
- 有从属关系才嵌套,没有从属关系的资源一律平铺。比如用户和订单,逻辑上订单属于用户,可以嵌套
GET /users/{userId}/orders;但如果你需要按订单号直接查,也应该保留GET /orders/{orderId}这种扁平入口。 - 嵌套最多两层。
/organizations/{orgId}/projects/{projectId}/tasks/{taskId}这种三段嵌套虽然每个层级都真实存在,但会让 URL 变得冗长,而且客户端拼接容易出错。第三层开始,建议在查询参数里用关系字段表达:GET /tasks?projectId=xxx。 - 动作子资源要克制。
/orders/{id}/cancel这种带动词的 URL 不是禁用,但要先想想能不能用状态的转移来表达。比如订单从"待支付"变成"已取消",本质上是一次收集修改,那PATCH /orders/{id}加上{ "status": "cancelled" }就是资源化的做法。只有当修改附带大量副作用、或者状态机不允许客户端直接改字段时才用动作子资源。
我在实际项目里还遇到一个需求,前端经常问我"为什么不能用/api/orders_products这种中间表命名?"。原因很简单:URL 是给人看的,不是给数据库表设计的。如果你想让用户在所有订单中查询,同时筛选出包含某个商品品的订单,合理设计是GET /orders?productId=42而不是建一个/order-products资源。资源建模要贴近业务语言,而不是暴露物理表结构。
2.3 版本策略:路径版本为什么最省心
接口一定会变,这是铁律。版本管理的目标不是让人不破坏接口,而是让破坏控制在可接受的范围内。常见的版本方案有四种:
- 路径版本:
/api/v1/orders、/api/v2/orders。优点:直白、易缓存、日志清晰、网关容易做灰度路由;缺点:URL 看着重复。 - Header 版本:
Accept: application/vnd.example.v1+json。优点:URL 干净;缺点:排错时不容易看到,调试工具里要额外配置,对前后端联调不够友好。 - 参数版本:
/api/orders?version=1。优点:实现简单;缺点:容易被人忽略,缓存 key 天然隔离性差。 - 域名版本:
v1.api.example.com。优点:可以彻底隔离;缺点:运维成本高,同域认证和跨域策略要额外处理。
我自己的项目几乎无脑选路径版本,原因特别实际:线上排查接口问题时,负载均衡和网关日志里看到的都是完整 URL,版本号直接在路径里,不用再翻请求头;Nginx 灰度一个/v2/前缀远比解析 Header 简单。版本从v1开始,永远不要做v0,也不要带日期版本——20240101这种版本号在代码里没有任何语义优势。
版本策略还有一个容易踩的坑:什么时候升大版本?我的标准是"出现不兼容变更,且无法通过新增可选参数兼容"时才升版。能向后兼容的改动,比如新增字段、新增可选参数、新增枚举值,都不应该升版本。只有删字段、改类型、改必填约束这种操作才配得上升一个大版本。顺便提一句,删字段之前最好提前一个版本在文档里标记deprecated,给客户端留够迁移周期,这是生产级接口的基本素质。
3. HTTP 方法与状态码的语义化运用
3.1 方法分工:不要再把 POST 当万能用法
HTTP 协议定义了一组方法,每个方法都有明确语义。设计接口时,方法选对了,接口语义就完成了一半。我见过太多接口文档里的"接口类型:POST",下面列上一大堆不同的业务操作,这是典型的没有用 HTTP 方法表达语义。
具体分工是这样的:
- GET:安全且幂等,只读获取资源或资源列表,不产生任何副作用。
- POST:创建资源,或者触发一个不受约束的动作,不幂等。
- PUT:完整替换一个资源,幂等。客户端提交的是资源的完整新状态。
- PATCH:部分更新一个资源,不幂等。客户端只提交要修改的字段。
- DELETE:删除资源,幂等。第一次删除返回 204,第二次删除同一个 ID 返回 404,但资源状态不再变化,这被认为是幂等的。
- HEAD:与 GET 相同但只返回响应头,常用于探测资源是否存在。
- OPTIONS:返回资源支持的 HTTP 方法,用于 CORS 预检等场景。
这里很多团队纠结的是 PUT 和 PATCH 怎么选。我的建议很简单:绝大多数业务更新场景用 PATCH。因为前端的表单往往只提交几个字段,用 PUT 的话必须要求客户端把整个对象完整传回来,万一把没渲染出来的字段漏传,服务器一覆盖就出事。PUT 留给那些真正的"全量替换"场景,比如把充值的对象整个换掉。
另一个高频问题是"幂等"这个词。GET、PUT、DELETE 幂等,POST 不幂等,这个结论大家在表上都看到了,但生产环境要操心的往往是"客户端因为超时而重试请求"这类场景。比如前端创建一个订单,点击提交,网络超时,用户再点一次,结果出现了重复订单。这就引出第 5 章要重点讲的幂等键,这里先记着:HTTP 方法自带的幂等性只覆盖了同一条请求重复执行的情况,解决不了"两次不同的 POST 请求携带相同业务意图"的问题。
3.2 状态码:每一个数字都有它的位置
状态码用对了,调用方连响应体都不用看就能知道发生了什么。我整理了一份自己在项目里最常用的状态码清单,标注了真实使用场景:
| 状态码 | 含义 | 典型使用场景 |
|---|---|---|
| 200 OK | 成功 | GET 获取资源、PATCH 更新成功 |
| 201 Created | 创建成功 | POST 新建资源,响应头带 Location |
| 204 No Content | 成功但无响应体 | DELETE 成功、PUT 成功但无需返回内容 |
| 400 Bad Request | 请求参数错误 | 缺少必填参数、类型错误、JSON 格式错误 |
| 401 Unauthorized | 未认证 | 缺少 Token、Token 过期 |
| 403 Forbidden | 已认证但无权限 | 角色权限不足 |
| 404 Not Found | 资源不存在 | 资源 ID 错误、路径错误 |
| 409 Conflict | 状态冲突 | 重复创建、状态机不允许当前流转 |
| 422 Unprocessable Entity | 语义正确但业务校验失败 | 用户名重复、库存不足 |
| 429 Too Many Requests | 触发限流 | 超过速率限制 |
| 500 Internal Server Error | 服务器内部错误 | 未捕获异常 |
| 502 Bad Gateway | 上游代理错误 | 网关连不上后端服务 |
| 503 Service Unavailable | 服务不可用 | 过载熔断、停机维护 |
有几个容易用错的点值得单独说。401 和 403 的区别是"你是谁"和"你有没有权限",客户端拿到 401 会主动重新登录,拿到 403 会弹出"无权限"提示,用反了体验很糟。422 和 400 搞混也很常见,我的约定是:JSON 解析失败、类型错误这种"请求本身烂掉"用 400;参数格式正确但业务上过不去,比如邮箱已注册,用 422。409 是给并发冲突和状态冲突准备的,比如两个操作同时修改同一条数据、订单已经关闭却又要取消,409 比 400 更能描述"你的请求没问题,但当前状态不允许"。
顺带提醒一个容易忽略的:不要轻易返回 200 加业务错误码。很多国内团队把{"code": 5001, "msg": "库存不足"}和 HTTP 200 配套使用,理由是"反正业务错误也得走响应体"。
这个做法在对外 API 里是灾难:网关的限流统计、CDN 的缓存判断、监控系统的告警规则都依赖 HTTP 状态码,你全部返回 200,等于把这些基础设施全都蒙在鼓里。正确的做法是:HTTP 状态码表达"这次请求处理得怎么样",业务码表达"业务逻辑里具体是哪个环节出了问题",两者各司其职。响应体里可以同时保留业务码,但 HTTP 层面一定要如实反映结果。
3.3 响应体结构:让客户端少写几个 if
响应体的结构直接影响客户端代码的复杂度。我推荐的统一响应结构长这样:
{ "code": 0, "message": "success", "data": { "id": 10001, "status": "paid", "amount": 99.00 }, "traceId": "a1b2c3d4-5678-90ab-cdef-1234567890ab" }code是业务码,0表示成功;非 0 是业务错误标识。message是人类可读的描述,方便快速判断。data是核心业务数据,失败时可为null。traceId是链路追踪号,排查问题时让客户端直接甩给你这个号。
这个结构看起来简单,但有个原则性要求:成功和失败时响应结构必须一致。有些项目成功时返回{id: 1, name: "x"},失败时返回{error: "xxx"},客户端就得写两套解析逻辑。统一信封后,客户端只需要判断code === 0,然后取data,错误时读message。
关于"信封要不要带"其实也有争议。纯 REST 原教旨主义者会说:状态码已经足够表达结果,不该再包一层data。但现实是,Web 前端、App、小程序各自的请求封装不一样,有的需要用message直接给用户弹提示,有的需要拿traceId上报日志。统一信封降低了客户端的理解成本,我自己的对外公开 API 会保留信封结构,内部的微服务间调用反而会去掉信封直接用裸数据,因为服务间通信有更严格的结构约定。
4. 错误处理、分页过滤与字段筛选:别让客户端猜
4.1 错误信息要能直接定位问题
404 加一句{"message": "not found"}是最省事的写法,也是最让客户端头疼的写法。好的错误响应应该告诉调用方三件事:出了什么错、为什么错、怎么办。我参考了 RFC 7807 Problem Details 的思路,结合团队习惯,整理出了一个实用结构:
{ "code": 40002, "message": "订单金额不能为负数", "details": [ { "field": "amount", "reason": "must_be_positive", "message": "amount 必须大于 0" } ], "traceId": "a1b2c3d4", "path": "/api/v1/orders" }details数组是给前端表单报错用的。比如注册接口一次性提交用户名、邮箱、密码三个字段,其中两个不合格,你可以在details里带上每个字段的具体错误,前端直接映射到表单输入框上。字段错误码称之为reason,因为它是机器可读的稳定标识,前端可以根据reason展示预置的文案,而不是解析message字符串。
我踩过的坑是message里直接拼了内部异常信息,比如"SQLIntegrityConstraintViolationException: Duplicate entry",这既暴露了技术细节,又对客户端没有任何帮助。还有一次把整个 Java 堆栈放到响应体里,确实方便了排查,但生产环境风险太大,后来全部收敛为三个固定字段:可读的 hint(给用户看)、稳定的 reason(给代码判断)、traceId(给你去日志里查堆栈)。
4.2 分页、排序、过滤的通用约定
列表接口是后端最常用的接口种型,这类接口设计得好不好,直接影响数据库压力和前端表现。先说分页,业界两种主流风格:
- 页码分页:
?page=1&pageSize=20,响应里带total。适合管理后台、数据量可控的列表。 - 游标分页:
?cursor=eyJpZCI6MTAwMH0&limit=20,响应里带nextCursor。适合信息流、数据量无限增长、深分页性能敏感的场景。
我给了两种方案但更想强调的其实是"深分页陷阱"。客户端的页码一旦翻到一万页,OFFSET 10000 LIMIT 20这种 SQL 的扫描成本会越来越高,响应越来越慢。信息流场景必须用游标分页,游标里编码了上一次返回的最后一条记录的位置,每次查询都是"从那之后取 20 条",不管翻多深性能都一样。
排序和过滤的约定也要提前统一。我的习惯是这样:
排序: /orders?sort=created_at:desc,id:desc 过滤: /orders?status=paid&amount_min=100&amount_max=500 语义过滤: /orders?status=paid&customer_id=88 字段选择: /orders?fields=id,amount,status 模糊搜索: /orders?q=手机排序字段用冒号连接方向,多个排序条件用逗号分隔,这是后端最容易解析也最不容易产生歧义的格式。过滤条件也尽量不要用filter=key:value这种一次性编码的写法,字段名=值是最直白的。这里特别提醒:查询参数的字段命名用camelCase还是snake_case不重要,但一个项目只能选一种。推荐查询参数用snake_case(created_at),JSON 响应体用camelCase(createdAt),这符合 HTTP 查询串和 JSON 两个体系各自的社区习惯。
4.3 字段选择与敏感信息保护
一个复杂的业务对象的字段可能多达几十个,但大多数客户端列表场景只需要其中几个。我强烈建议对外 API 支持fields参数:
GET /api/v1/users/42?fields=id,name,avatar,email服务器只返回这几个字段。好处有三点:减少网络传输、降低客户端解析成本、避免把不该暴露的字段(比如password_hash、internal_note)输出出去。实现fields参数最省事的办法是 Jackson 的@JsonFilter,或者用专门的视图对象(DTO)来做字段映射——优先推荐 DTO,因为白名单写在代码里是可审计的,而fields参数一旦允许任意字段透传,你又得再加一层字段名白名单校验,否则等于开了一个泄露口子。
敏感信息保护是另一个容易漏的点。我见过一个真实案例:用户详情接口直接把phone明文返回给前端,前端又把它渲染到了页面上。抓包工具一抓,手机号全部泄露。正确做法是:响应用户对象时脱敏,138****1234;接口单独提供GET /users/{id}/balance等高敏感数据接口,并且要求二次认证或权限校验。
5. 幂等与并发:最容易翻车的两个设计点
5.1 幂等键:让客户端可以放心重试
现在很多项目都接了支付、下单、消息推送这类"绝对不能重复执行"的接口。拿下单举例,用户点击"提交订单",前端发出POST /orders,网络抖动导致响应超时,用户又点了一次,前端又发出一个一模一样的POST /orders。如果服务器没有幂等保护,就会出现两条重复订单、两笔重复扣款——这是线上事故级别的 bug,而且往往是设计阶段没考虑,等出了事故才补。
解决方案是幂等键。客户端在创建类请求的 Header 里携带一个全局唯一的 ID:
POST /api/v1/orders Idempotency-Key: 5d0a4a0e-6d4e-4f0e-b2d2-8b3c7a1f4e9a Content-Type: application/json { "product_id": 100, "quantity": 2 }服务器端做的处理逻辑是:
- 收到请求后,先查
idempotency_key对应的处理记录。 - 如果已存在且处于"处理中",直接返回之前的结果;如果已存在且处于"失败",则允许使用新的请求重试。
- 如果不存在,开始处理业务,同时把
idempotency_key与处理结果一起存下来。
实现上有几个细节值得注意。幂等键的存储最好与业务操作放在同一个数据库事务里,也就是"写业务记录"和"记录幂等键"要么同时成功要么同时失败,否则会出现业务没建上但幂等键已写入的脏状态。分布式环境下可以用 Redis 存幂等键,但要注意设置合理的过期时间,比如 24 小时到 48 小时,过期后再次出现相同 key 就不在保护范围内。我自己实践下来,凡是涉及资金、库存、积分变动的写接口,一律强制客户端传Idempotency-Key,不做兼容。
5.2 乐观锁与条件请求:防止并发覆盖
幂等键解决的是"重复请求",乐观锁解决的是"并发修改"。典型场景是这样的:两个管理后台的运营同时编辑同一个商品的标题和价格。A 先加载出商品信息,改了标题;B 在 A 保存之后也提交了自己的版本。由于后保存的 B 是基于旧数据改的,提交时直接用整条记录覆盖,A 的修改就丢了。这就是经典的"丢失更新"问题。
乐观锁的经典做法是在数据库表里加一个version字段:
UPDATE products SET title = ?, price = ?, version = version + 1 WHERE id = ? AND version = ?;如果更新后受影响行数为 0,说明 version 已经变了,返回 409 Conflict 给客户端。客户端重新拉取最新数据,再决定要不要合并。
HTTP 层面还有更规范的做法:用ETag加上If-Match头。服务器在GET /products/42的响应里返回ETag: "v1.2",客户端修改后提交PUT /products/42,带If-Match: "v1.2"头。服务器比较ETag,匹配则更新,不匹配则返回412 Precondition Failed。这个方案的好处是完全符合 HTTP 语义,坏处是服务端要自己生成ETag,稍微多写一点逻辑。业务压力不大时,用版本号字段就够了,只要记住在更新语句里带上WHERE version = ?,这个细节救过我好几次。
5.3 状态机与唯一约束:最后的兜底
幂等键和乐观锁之外,还有两个"兜底"手段,虽然不是 API 设计直接要求的部分,但生产级接口必须了解。
第一个是状态机校验。比如订单状态只能从"待支付"变成"已支付",再从"已支付"变成"已发货"。在一笔订单上做任何更新操作,都应该用状态流转校验来防止非法跳转。代码上可以做一层简单的状态机配置,而不是散落的if (order.status != XXX)判断。多个接口并发操作同一个订单时,状态机配合数据库行锁或乐观锁,能拦截掉大部分不该发生的操作。
第二个是数据库唯一约束。很多重复数据问题,靠代码判断if (exists(phone))是拦不住的,并发请求会同时通过这个判断。正确做法是在数据库字段上加唯一索引,比如users.phone设成 unique,插入时直接把异常抛出来,再用代码翻译成 409 返回。幂等键的存储表里,Idempotency-Key字段本身也应该建唯一索引,双保险。
6. 从能跑到能用:安全、限流、文档与契约
6.1 认证授权:别让每个接口自己造轮子
接口安全的首要原则是统一认证、统一授权,每个接口各写各的登录校验,最后一定有人漏掉。现在主流的 API 认证方式有三种,选择取决于你的系统类型:
- API Key:适合服务器之间的调用和开发者工具。在 Header 里传
X-API-Key: xxx,网关统一校验。实现简单,但 API Key 本质上是长期有效的明文凭证,必须有权限范围且定期轮换。 - Token(JWT / Opaque Token):适合用户态的 Web 和移动端。用户登录成功后拿到 Access Token,后续请求带
Authorization: Bearer <token>。JWT 自包含、无需查库,但过期、吊销都不好处理;Opaque Token 需要服务器存储或校验,安全性更好。 - OAuth2.0:适合有第三方授权需求的开放平台。完整流程复杂,但如果是标准的三方登录和开放 API 场景,这是最规范的选择。
我自己的建议是:如果团队没有专门的平台化需求,用户态接口用 OAuth2.0 的简化流程(比如 Authorization Code + PKCE)并配合短期 Access Token 和长期 Refresh Token 就足够了。Token 中不要塞入不必要的敏感数据,过期时间不要太长,一般 Access Token 控制在 15 分钟到 2 小时,Refresh Token 一周到一个月。所有接口都必须跑在 HTTPS 上,这是底线,没什么好讨论的。
6.2 限流与可观测性:高并发接口的必修课
一个设计良好的 API 不能只在正常负载下运转,还要在流量暴涨时保护自己。限流是 API 平台最基础的保护手段,常见做法是令牌桶算法,在网关层统一配置每个用户、每个 API Key 的调用速率。限流触发时,返回429 Too Many Requests,响应头里带上三个标准字段:
X-RateLimit-Limit: 1000 X-RateLimit-Remaining: 999 X-RateLimit-Reset: 1710000000这三个字段告诉客户端"一共多少额度、还剩多少、什么时候重置",客户端可以根据剩余额度决定是否降低调用频率,而不是傻傻地等到被限流了才开始重试。配合Retry-After: 120头,客户端可以知道下一次尝试的时间。
可观测性方面,最基础但也最容易被忽略的是traceId 透传。我前面在响应体结构里提过traceId,前端报错时把 traceId 甩给你,你就能在日志系统里查出整条调用链。服务端在接收请求时,traceId如果为空就生成一个,然后透传到下游的 Redis、数据库、MQ 等所有日志上下文。这样一次请求从网关到微服务再到数据库操作的完整链路都能串起来。
6.3 OpenAPI 契约先行:文档、Mock 与 Mock 测试一锅端
说到 RESTful API 的最佳实践,最后绕不开的就是文档。我见过太多团队维护一份 Markdown 格式接口文档,接口改了忘了更新,前端对着过期的文档联调,浪费大量时间。后来我转型为写 OpenAPI(Swagger)规范,直接用代码生成文档,把问题从根上解决了。
以 Spring Boot 为例,引入springdoc-openapi后,只需要在 Controller 上写注解:
@Operation(summary = "创建订单", description = "提交订单并扣减库存") @ApiResponses({ @ApiResponse(responseCode = "201", description = "创建成功"), @ApiResponse(responseCode = "422", description = "库存不足或金额非法") }) @PostMapping("/api/v1/orders") public Order createOrder(@Valid @RequestBody CreateOrderRequest request) { ... }文档自动生成,地址一般在/swagger-ui.html。更关键的是把 OpenAPI 文件当成契约来管理:接口改动前先改 YAML,通过评审后再动代码。前端的 Mock 服务可以直接基于 OpenAPI 文件生成,联调时不需要等后端代码部署完。
我个人的习惯是团队里维护一份openapi.yaml作为唯一的接口契约源,代码生成、Mock、自动化测试全部从这份文件拉取。改接口的时候,只要契约没改,测试就不会挂;契约一改,CI 里自动生成的兼容性检查就会报警。这套流程跑顺之后,接口设计和维护的效率提升非常明显,团队协作里那种"你改了接口不告诉我"的抱怨基本就消失了。
接口设计这件事,最适合作为起步的,不是把 URL 改漂亮,而是先把资源建模想清楚:这个业务对象是什么、它有哪些状态、客户端最常对它做什么操作。资源模型定了,URL、方法、状态码就顺理成章。其次是统一错误处理和分页这些通用约定,让调用方一次学会,处处复用。最后才是幂等、限流、文档这些生产级的东西——它们不一定立刻派上用场,但等出了问题再补,代价往往翻倍。
最后分享一个我一直在坚持的小技巧:在你团队的内网 Wiki 里维护一份"接口评审检查清单",包括"URL 是否使用了名词复数""创建接口是否支持 Idempotency-Key""错误响应是否包含 traceId""是否更新了 OpenAPI 契约"这些条目。每次提测前让开发同学自己过一遍,比你在 code review 时反复口头强调管用得多。规范只有沉淀成清单和工具,才能真正落地,而不是靠某一个人的记忆力。