【无标题】

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 设计经验,不一定全对,写出来记录一下。如果你有更好的做法或者哪里说得不对,欢迎指出来,正好我也学习一下。

相关推荐
一隅论数智1 天前
给AI一张“业务概念地图“:本体如何从哲学走向企业智能
大数据·人工智能·经验分享·笔记·学习·学习方法·政务
深圳老胡1 天前
STM32F407 控制 L6470 步进电机驱动 —— 控制过程简介
笔记·stm32·单片机·嵌入式硬件·代码规范
Because_of_Her11 天前
并查集-听课笔记
笔记·算法·并查集
彧azz1 天前
Linux 环境下 Redis 学习总结:数据类型、持久化、锁、事务、主从与缓存问题
linux·redis·笔记·学习·面试
陈卫军老师1 天前
陈卫军:把口味写在一张纸上,店才稳得住
经验分享·笔记·流量运营
`流年づ1 天前
人工智能学习笔记 - 补充
人工智能·笔记·深度学习·学习
qeen871 天前
【Linux】操作系统之进程介绍(二)
linux·笔记·学习·进程
从零开始的嵌入式之旅1 天前
day47
arm开发·经验分享·笔记·嵌入式硬件
陈年老古董1 天前
MediaPipe 姿态检测与脸部关键点检测
笔记·python·opencv·学习·dlib
彧azz1 天前
操作系统时间管理与系统核心板块学习总结
c语言·笔记·学习·系统架构