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 - 不要
user、users混着用,容易让前端混乱
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)
}
七、小结
把我踩过的坑总结成几条:
- URL 里只放资源名词,动词交给 HTTP 方法,别写
/getUser这种 - 版本号建议从第一天就加上,
/api/v1/成本很低 - 状态码不要永远 200,让调用方看状态码就能定位问题
- 错误返回统一格式:业务码 + 用户提示 + 调试信息
- 幂等的操作用幂等的方法,POST 别用来做更新
- 分页响应带上 total 和 has_more,前端用起来方便
- 新建返回 201 + Location + 完整资源,省一次往返
以上是我自己整理的 REST API 设计经验,不一定全对,写出来记录一下。如果你有更好的做法或者哪里说得不对,欢迎指出来,正好我也学习一下。