【无标题】

REST API 设计规范

(整理一下我自己写接口踩过的坑)

前言

最近在整理自己项目里的后端代码,翻到几个月前写的接口,发现问题挺多的。比如接口路径长这样:

复制代码
GET /getUserList
GET /getUserById?id=1
POST /updateUser
POST /delUser

然后所有接口不管成功失败都返回 200,错误信息全塞在一个 code 字段里。结果就是前端调接口经常来问我"这个 code 是什么意思",我这边每加一个接口还得重新解释一遍,联调效率挺低的。

后来参考了一些资料,把接口重新设计了一下,前后端沟通顺畅了很多。这里把我整理出来的 REST API 设计规范记录一下,都是我自己在用的,可能不全面,但至少把我踩过的坑避开了。

一、REST 的核心思路

REST 这个名词听着挺唬人,其实核心就是一句话:

用 URL 定位资源,用 HTTP 方法表示动作,用状态码表达结果。

一个接口拆成这三部分,语义就清楚了,调用方看 URL 和状态码基本能猜到接口是干什么的。
#mermaid-svg-fr7VAE2Sui79bGeE{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-fr7VAE2Sui79bGeE .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-fr7VAE2Sui79bGeE .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-fr7VAE2Sui79bGeE .error-icon{fill:#552222;}#mermaid-svg-fr7VAE2Sui79bGeE .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-fr7VAE2Sui79bGeE .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-fr7VAE2Sui79bGeE .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-fr7VAE2Sui79bGeE .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-fr7VAE2Sui79bGeE .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-fr7VAE2Sui79bGeE .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-fr7VAE2Sui79bGeE .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-fr7VAE2Sui79bGeE .marker{fill:#333333;stroke:#333333;}#mermaid-svg-fr7VAE2Sui79bGeE .marker.cross{stroke:#333333;}#mermaid-svg-fr7VAE2Sui79bGeE svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-fr7VAE2Sui79bGeE p{margin:0;}#mermaid-svg-fr7VAE2Sui79bGeE .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-fr7VAE2Sui79bGeE .cluster-label text{fill:#333;}#mermaid-svg-fr7VAE2Sui79bGeE .cluster-label span{color:#333;}#mermaid-svg-fr7VAE2Sui79bGeE .cluster-label span p{background-color:transparent;}#mermaid-svg-fr7VAE2Sui79bGeE .label text,#mermaid-svg-fr7VAE2Sui79bGeE span{fill:#333;color:#333;}#mermaid-svg-fr7VAE2Sui79bGeE .node rect,#mermaid-svg-fr7VAE2Sui79bGeE .node circle,#mermaid-svg-fr7VAE2Sui79bGeE .node ellipse,#mermaid-svg-fr7VAE2Sui79bGeE .node polygon,#mermaid-svg-fr7VAE2Sui79bGeE .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-fr7VAE2Sui79bGeE .rough-node .label text,#mermaid-svg-fr7VAE2Sui79bGeE .node .label text,#mermaid-svg-fr7VAE2Sui79bGeE .image-shape .label,#mermaid-svg-fr7VAE2Sui79bGeE .icon-shape .label{text-anchor:middle;}#mermaid-svg-fr7VAE2Sui79bGeE .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-fr7VAE2Sui79bGeE .rough-node .label,#mermaid-svg-fr7VAE2Sui79bGeE .node .label,#mermaid-svg-fr7VAE2Sui79bGeE .image-shape .label,#mermaid-svg-fr7VAE2Sui79bGeE .icon-shape .label{text-align:center;}#mermaid-svg-fr7VAE2Sui79bGeE .node.clickable{cursor:pointer;}#mermaid-svg-fr7VAE2Sui79bGeE .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-fr7VAE2Sui79bGeE .arrowheadPath{fill:#333333;}#mermaid-svg-fr7VAE2Sui79bGeE .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-fr7VAE2Sui79bGeE .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-fr7VAE2Sui79bGeE .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-fr7VAE2Sui79bGeE .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-fr7VAE2Sui79bGeE .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-fr7VAE2Sui79bGeE .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-fr7VAE2Sui79bGeE .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-fr7VAE2Sui79bGeE .cluster text{fill:#333;}#mermaid-svg-fr7VAE2Sui79bGeE .cluster span{color:#333;}#mermaid-svg-fr7VAE2Sui79bGeE div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-fr7VAE2Sui79bGeE .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-fr7VAE2Sui79bGeE rect.text{fill:none;stroke-width:0;}#mermaid-svg-fr7VAE2Sui79bGeE .icon-shape,#mermaid-svg-fr7VAE2Sui79bGeE .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-fr7VAE2Sui79bGeE .icon-shape p,#mermaid-svg-fr7VAE2Sui79bGeE .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-fr7VAE2Sui79bGeE .icon-shape .label rect,#mermaid-svg-fr7VAE2Sui79bGeE .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-fr7VAE2Sui79bGeE .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-fr7VAE2Sui79bGeE .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-fr7VAE2Sui79bGeE :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 一个 HTTP 请求
URL 定位资源
HTTP 方法表达动作
状态码表达结果
/api/v1/todos ------ 我要操作待办
/api/v1/todos/123 ------ 具体某一条
GET 查询 / POST 新建 / PUT 整体更新 / PATCH 局部更新 / DELETE 删除
2xx 成功 / 4xx 客户端问题 / 5xx 服务端问题

二、URL 怎么设计

2.1 资源用名词,动词交给 HTTP 方法

我之前喜欢把动词写进 URL,比如 /getUser/updateUser。正确做法是资源用名词,动作由 HTTP 方法表示:

我原来写的 改成
GET /getUserList GET /api/v1/users
POST /updateUser PUT /api/v1/users/{id}
POST /delUser DELETE /api/v1/users/{id}

两个注意点:

  • 集合资源用复数 users,单个资源用 users/123
  • 不要 userusers 混着用,容易让前端混乱

2.2 用层级表达资源关系

有从属关系的资源可以嵌套:

复制代码
GET  /api/v1/users/123/orders        # 查某用户的所有订单
GET  /api/v1/users/123/orders/5      # 查某用户的某个订单

不过嵌套别超过三层,太深了说明资源拆得有问题,或者应该用查询参数。比如"查某用户已支付的订单":

复制代码
GET /api/v1/users/123/orders?status=paid

而不是 /api/v1/users/123/orders/paid,把状态当子资源不太合适。

2.3 建议加上版本号

接口大概率会改,一改前端就可能崩。比较省事的方式是在 URL 里带版本:

复制代码
/api/v1/users
/api/v2/users

就算第一版只有一个版本,写上 /v1 成本也很低,后面要升级直接加个 /v2 就行,不用动旧接口。

三、HTTP 方法的使用

我见过有人把增删改查全写成 POST,这样接口语义就乱了。用对方法会清晰很多:

方法 作用 幂等性 场景
GET 查询 幂等 查列表、查详情
POST 新增 不幂等 创建资源
PUT 整体更新 幂等 更新整个资源
PATCH 局部更新 不幂等 只改某个字段
DELETE 删除 幂等 删除资源

幂等的含义:同一个请求执行一次和执行一百次,结果一样。GET、PUT、DELETE 应该幂等,POST 不幂等(每次都会新建一条)。

我之前踩过的一个坑:用 POST 做"更新订单状态",前端网络重发了一次,状态被改了两次,数据就乱了。改成 PUT 之后重发也没问题。

四、状态码的选择

这个改完收益比较大。之前错误全靠 code 字段猜,现在看 HTTP 状态码就行:

状态码 含义 什么时候用
200 成功 查询、更新成功
201 已创建 POST 新建资源成功
204 无内容 删除成功,无返回体
400 参数错误 参数缺失 / 格式不对
401 未认证 没登录或 token 失效
403 无权限 登录了但没权限
404 资源不存在 URL 或资源找不着
409 冲突 比如用户名已存在
422 语义错误 参数合法但业务上不通过
500 服务器错误 后端异常

选状态码可以按这个流程判断:
#mermaid-svg-FUKyEiyrqHo2D70L{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-FUKyEiyrqHo2D70L .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-FUKyEiyrqHo2D70L .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-FUKyEiyrqHo2D70L .error-icon{fill:#552222;}#mermaid-svg-FUKyEiyrqHo2D70L .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-FUKyEiyrqHo2D70L .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-FUKyEiyrqHo2D70L .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-FUKyEiyrqHo2D70L .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-FUKyEiyrqHo2D70L .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-FUKyEiyrqHo2D70L .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-FUKyEiyrqHo2D70L .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-FUKyEiyrqHo2D70L .marker{fill:#333333;stroke:#333333;}#mermaid-svg-FUKyEiyrqHo2D70L .marker.cross{stroke:#333333;}#mermaid-svg-FUKyEiyrqHo2D70L svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-FUKyEiyrqHo2D70L p{margin:0;}#mermaid-svg-FUKyEiyrqHo2D70L .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-FUKyEiyrqHo2D70L .cluster-label text{fill:#333;}#mermaid-svg-FUKyEiyrqHo2D70L .cluster-label span{color:#333;}#mermaid-svg-FUKyEiyrqHo2D70L .cluster-label span p{background-color:transparent;}#mermaid-svg-FUKyEiyrqHo2D70L .label text,#mermaid-svg-FUKyEiyrqHo2D70L span{fill:#333;color:#333;}#mermaid-svg-FUKyEiyrqHo2D70L .node rect,#mermaid-svg-FUKyEiyrqHo2D70L .node circle,#mermaid-svg-FUKyEiyrqHo2D70L .node ellipse,#mermaid-svg-FUKyEiyrqHo2D70L .node polygon,#mermaid-svg-FUKyEiyrqHo2D70L .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-FUKyEiyrqHo2D70L .rough-node .label text,#mermaid-svg-FUKyEiyrqHo2D70L .node .label text,#mermaid-svg-FUKyEiyrqHo2D70L .image-shape .label,#mermaid-svg-FUKyEiyrqHo2D70L .icon-shape .label{text-anchor:middle;}#mermaid-svg-FUKyEiyrqHo2D70L .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-FUKyEiyrqHo2D70L .rough-node .label,#mermaid-svg-FUKyEiyrqHo2D70L .node .label,#mermaid-svg-FUKyEiyrqHo2D70L .image-shape .label,#mermaid-svg-FUKyEiyrqHo2D70L .icon-shape .label{text-align:center;}#mermaid-svg-FUKyEiyrqHo2D70L .node.clickable{cursor:pointer;}#mermaid-svg-FUKyEiyrqHo2D70L .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-FUKyEiyrqHo2D70L .arrowheadPath{fill:#333333;}#mermaid-svg-FUKyEiyrqHo2D70L .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-FUKyEiyrqHo2D70L .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-FUKyEiyrqHo2D70L .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-FUKyEiyrqHo2D70L .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-FUKyEiyrqHo2D70L .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-FUKyEiyrqHo2D70L .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-FUKyEiyrqHo2D70L .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-FUKyEiyrqHo2D70L .cluster text{fill:#333;}#mermaid-svg-FUKyEiyrqHo2D70L .cluster span{color:#333;}#mermaid-svg-FUKyEiyrqHo2D70L div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-FUKyEiyrqHo2D70L .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-FUKyEiyrqHo2D70L rect.text{fill:none;stroke-width:0;}#mermaid-svg-FUKyEiyrqHo2D70L .icon-shape,#mermaid-svg-FUKyEiyrqHo2D70L .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-FUKyEiyrqHo2D70L .icon-shape p,#mermaid-svg-FUKyEiyrqHo2D70L .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-FUKyEiyrqHo2D70L .icon-shape .label rect,#mermaid-svg-FUKyEiyrqHo2D70L .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-FUKyEiyrqHo2D70L .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-FUKyEiyrqHo2D70L .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-FUKyEiyrqHo2D70L :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 不能
参数缺失/格式错
没登录/token失效
登录了但没权限
资源不存在
业务规则不通过
后端异常

新建资源
更新/查询
删除
收到一个请求
能正常处理吗?
是哪类问题?
400 参数错误
401 未认证
403 无权限
404 资源不存在
422 语义错误
500 服务器错误
做了什么操作?
201 已创建 + Location
200 成功
204 无内容

错误信息统一格式

状态码能说明是哪类问题,但具体哪里错了还得靠返回体。我统一成这种格式:

json 复制代码
{
  "code": 40001,
  "message": "手机号格式不正确",
  "details": "phone 字段不合法,应为 11 位数字"
}
  • code:业务错误码,前后端约定好
  • message:给用户看的提示
  • details:给前端调试用的细节

前端捕获到非 2xx 响应,直接解析 body 里的 message 弹给用户,基本不用再问后端。

五、列表接口的参数

列表接口一般都有分页、过滤、排序的需求,参数规约好会省事很多:

参数 说明 示例
page / page_size 分页 ?page=1&page_size=20
过滤字段 查询参数表达 ?status=paid&category=phone
sort / order 排序 ?sort=created_at&order=desc
fields 字段裁剪 ?fields=id,name,price

分页响应我一般这样返回:

json 复制代码
{
  "items": [ ],
  "total": 137,
  "page": 1,
  "page_size": 20,
  "has_more": true
}

has_more 让前端判断是否还有下一页,比前端自己算"当前页 × 页大小 < total"省心一些,也少踩边界 bug。

六、一个完整的例子

以 Todo 接口为例:

需求 接口
获取待办列表 GET /api/v1/todos?page=1&page_size=20
获取单个待办 GET /api/v1/todos/123
新建待办 POST /api/v1/todos
更新标题 PATCH /api/v1/todos/123
整体替换 PUT /api/v1/todos/123
删除待办 DELETE /api/v1/todos/123

一次新建待办的完整调用流程:
数据库 后端服务 网关/鉴权 前端 数据库 后端服务 网关/鉴权 前端 #mermaid-svg-AUf38y46lKWJYqm4{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-AUf38y46lKWJYqm4 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-AUf38y46lKWJYqm4 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-AUf38y46lKWJYqm4 .error-icon{fill:#552222;}#mermaid-svg-AUf38y46lKWJYqm4 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-AUf38y46lKWJYqm4 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-AUf38y46lKWJYqm4 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-AUf38y46lKWJYqm4 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-AUf38y46lKWJYqm4 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-AUf38y46lKWJYqm4 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-AUf38y46lKWJYqm4 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-AUf38y46lKWJYqm4 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-AUf38y46lKWJYqm4 .marker.cross{stroke:#333333;}#mermaid-svg-AUf38y46lKWJYqm4 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-AUf38y46lKWJYqm4 p{margin:0;}#mermaid-svg-AUf38y46lKWJYqm4 .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-AUf38y46lKWJYqm4 text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-AUf38y46lKWJYqm4 .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-AUf38y46lKWJYqm4 .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-AUf38y46lKWJYqm4 .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-AUf38y46lKWJYqm4 .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-AUf38y46lKWJYqm4 #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-AUf38y46lKWJYqm4 .sequenceNumber{fill:white;}#mermaid-svg-AUf38y46lKWJYqm4 #sequencenumber{fill:#333;}#mermaid-svg-AUf38y46lKWJYqm4 #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-AUf38y46lKWJYqm4 .messageText{fill:#333;stroke:none;}#mermaid-svg-AUf38y46lKWJYqm4 .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-AUf38y46lKWJYqm4 .labelText,#mermaid-svg-AUf38y46lKWJYqm4 .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-AUf38y46lKWJYqm4 .loopText,#mermaid-svg-AUf38y46lKWJYqm4 .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-AUf38y46lKWJYqm4 .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-AUf38y46lKWJYqm4 .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-AUf38y46lKWJYqm4 .noteText,#mermaid-svg-AUf38y46lKWJYqm4 .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-AUf38y46lKWJYqm4 .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-AUf38y46lKWJYqm4 .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-AUf38y46lKWJYqm4 .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-AUf38y46lKWJYqm4 .actorPopupMenu{position:absolute;}#mermaid-svg-AUf38y46lKWJYqm4 .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-AUf38y46lKWJYqm4 .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-AUf38y46lKWJYqm4 .actor-man circle,#mermaid-svg-AUf38y46lKWJYqm4 line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-AUf38y46lKWJYqm4 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} POST /api/v1/todos(带 Token)校验 Token转发请求参数校验INSERT 一条待办返回新记录201 Created + 完整资源 + Location

请求:

http 复制代码
POST /api/v1/todos
Content-Type: application/json

{
  "title": "给接口写文档",
  "done": false
}

响应:

http 复制代码
HTTP/1.1 201 Created
Location: /api/v1/todos/123

{
  "id": 123,
  "title": "给接口写文档",
  "done": false,
  "created_at": "2026-09-02T10:00:00+08:00"
}

几个小细节:

  • 新建成功返回 201 + Location,告诉前端新资源的地址
  • 直接返回完整的资源对象,前端不用再发一次 GET 去拿
  • 时间用 ISO 8601 带时区,前端解析比较友好

Go 后端实现(gin 框架):

go 复制代码
type Todo struct {
    ID        int64     `json:"id"`
    Title     string    `json:"title"`
    Done      bool      `json:"done"`
    CreatedAt time.Time `json:"created_at"`
}

// 新建待办
func CreateTodo(c *gin.Context) {
    var req struct {
        Title string `json:"title"`
        Done  bool   `json:"done"`
    }
    // 参数解析失败:400
    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(400, gin.H{"code": 40000, "message": "参数错误", "details": err.Error()})
        return
    }
    // 业务规则不通过:422
    if req.Title == "" {
        c.JSON(422, gin.H{"code": 40001, "message": "标题不能为空"})
        return
    }
    // ... 落库,成功返回 201 + 完整资源
    c.JSON(201, todo)
}

七、小结

把我踩过的坑总结成几条:

  1. URL 里只放资源名词,动词交给 HTTP 方法,别写 /getUser 这种
  2. 版本号建议从第一天就加上,/api/v1/ 成本很低
  3. 状态码不要永远 200,让调用方看状态码就能定位问题
  4. 错误返回统一格式:业务码 + 用户提示 + 调试信息
  5. 幂等的操作用幂等的方法,POST 别用来做更新
  6. 分页响应带上 total 和 has_more,前端用起来方便
  7. 新建返回 201 + Location + 完整资源,省一次往返

以上是我自己整理的 REST API 设计经验,不一定全对,写出来记录一下。如果你有更好的做法或者哪里说得不对,欢迎指出来,正好我也学习一下。

相关推荐
Capricorn198825 分钟前
科研智能体出现召回率低与数据覆盖冲突怎么排查?知芽 Notebook Skill 机制拆解
大数据·论文阅读·人工智能·笔记
tju新生代魔迷33 分钟前
Verilog HDL学习笔记(三)| 第3章 基本概念(上)——词法约定与数据类型
笔记·学习
Hotchip_MEMS1 小时前
MS2202AB-M15E输出驱动能力:直连MCU GPIO无需三极管
人工智能·笔记·物联网·电脑·制造
Privasa-隐私实验室1 小时前
跨端技术选型|Flutter搭建隐私加密App,安卓与iOS双端差异适配实战
android·笔记·安全·flutter·ios·隐私安全·aes-256
tju新生代魔迷2 小时前
Verilog HDL 学习笔记(九)| 第 9 章 实用建模技术
笔记·学习
凯尔萨厮2 小时前
Java学习笔记十三(Stream与Lambda)
笔记·学习
M78佐菲10 小时前
Linux学习笔记:TCP协议
linux·笔记·学习·tcp/ip·算法
噜~噜~噜~10 小时前
操作系统笔记-2.3.5.1.1生产者-消费者问题
笔记·操作系统
Huathy-雨落江南,浮生若梦14 小时前
Caddy 学习笔记 - 反向代理与 IP 封禁
笔记·学习·tcp/ip