也能搞懂的RESTful API设计指南:7天写出规范后端接口(附避坑清单)
小白也能搞懂的RESTful API设计指南:7天写出规范后端接口(附避坑清单)
小白也能搞懂的RESTful API设计指南:7天写出规范后端接口
为啥你写的接口总被后端同事翻白眼?
说实话,我刚入行那会儿,写个接口能被后端老哥喷到怀疑人生。我记得特清楚,有次我写了个/getUserInfo的接口,对面直接甩过来一句话:“兄弟,你这URL咋还带上动词了?咱这是写代码还是写英语作文呢?”
当时我就懵了,心说这不挺清晰的吗,get用户info,多直白啊。结果人家给我发来一串链接,让我好好学学什么叫RESTful。我点开一看,好家伙,全是洋文,看得我头皮发麻。
后来混久了才明白,RESTful这玩意儿其实就是个约定俗成的江湖规矩。就像你去别人家做客,进门要换鞋、吃饭别吧唧嘴一样,不是硬性法律,但你不这么做就显得特没教养。接口设计也一样,你瞎写倒也能跑,但上下游对接的时候,别人一看你的文档,心里就会默默给你打上"野生程序员"的标签。
最尴尬的是啥?是你好不容易写了个功能,前端调你的接口,你返回个200,然后body里写个{"code": 500, "msg": "系统错误"}。前端小哥当场就炸了:“你HTTP状态码给200,意思是请求成功了,结果里面告诉我系统错误?你搁这儿玩套娃呢?”
所以啊,这篇文章就是我这些年被喷出来的血泪经验,全程大白话,代码直接复制粘贴就能用。看完不敢说让你变成架构师,但至少下次写接口的时候,后端同事不会再给你发那个"无语"的表情包了。
RESTful到底是个啥玩意儿,别被名字吓到
RESTful这个词听起来贼高级,全称是什么Representational State Transfer,翻译过来叫"表现层状态转化"。我第一次看到这翻译的时候,脑子里就一句话:说人话会死吗?
其实吧,你可以把它理解成一套"讲礼貌"的接口设计礼仪。它的核心思想就一句话:把网络上的所有东西都当成"资源",然后用HTTP协议自带的那些方法(GET、POST、PUT、DELETE)去操作这些资源。
举个接地气的例子。想象你开了一家中餐馆,RESTful就是那张菜单上的规矩:
- 顾客点餐(GET):“老板,给我看看你们家有啥菜”——这是获取资源
- 顾客下单(POST):“我要一份宫保鸡丁”——这是新建资源
- 顾客换菜(PUT/PATCH):“刚才那个不要辣,换成微辣”——这是修改资源
- 顾客退单(DELETE):“这菜太难吃了,撤了”——这是删除资源
看到没?全程没有出现"do"、“make”、"get"这种动词,但你一看就明白是在干嘛。这就是RESTful的精髓——用名词表示资源,用HTTP方法表示动作。
很多人误以为RESTful是一种技术或者框架,其实它就是一种设计风格。就像你写CSS可以用BEM命名规范一样,用不用随你,但用了代码看起来更专业。而且说实话,现在市面上稍微有点规模的公司,接口设计基本都是按这个套路来的,你不会这个,跳槽都不好意思跟HR聊。
核心原则掰开揉碎讲,这些规矩真不是后端装高冷
好了,既然知道RESTful是个啥了,那咱们就聊聊具体怎么落地。我总结了几条保命原则,你照着做,至少不会闹笑话。
资源必须用名词,动词滚出URL
这是最容易踩的坑。我见过太多新手写/getUsers、/createOrder、/deleteProduct这种URL了。说实话,功能上没毛病,但看起来就像把英语作业直接贴在了地址栏里。
正确的姿势是:
// 错误示范,满满的野生程序猿味道
GET /getUserInfo?id=123
POST /createNewOrder
DELETE /deleteProduct/456
// 正确姿势,看起来就很专业
GET /users/123 // 获取ID为123的用户
POST /orders // 创建新订单
DELETE /products/456 // 删除ID为456的商品
看到区别了吗?URL里只有名词(users、orders、products),动作全靠HTTP方法来表达。GET就是获取,POST就是创建,PUT就是全量更新,PATCH就是局部更新,DELETE就是删除。这是HTTP协议本来就有的语义,你非得在URL里再写一遍,就像你在微信里发消息:“我在吃饭(eating)”,括号里的英文纯属多余,还显得挺呆。
复数形式是政治正确
关于用单数还是复数,社区里其实争论了好多年。有人觉得/user/123比/users/123更直观,因为返回的是单个对象。但主流做法是用复数,为啥呢?因为资源集合本身就是复数概念。
// 获取所有用户
GET /users
// 获取单个用户
GET /users/123
// 获取某个用户的所有订单
GET /users/123/orders
// 获取某个用户的某个具体订单
GET /users/123/orders/456
你看,用复数的话,层级关系特别清晰。/users是用户池,/users/123是池子里的某一条鱼,/users/123/orders是这条鱼的所有订单。如果用单数/user/123/orders,读起来就有点别扭,好像这个user下面挂了个orders,语法上怪怪的。
当然,如果你就是喜欢用单数,也行,关键是整个项目要统一。别这边用/users,那边又用/order,那前后端不打起来才怪。
层级别嵌套太深,不然URL能绕地球三圈
RESTful推荐用层级关系表达关联资源,比如/users/123/orders/456,一眼就能看出这是用户123的456号订单。但千万别嵌套太深,我见过最离谱的接口长这样:
// 离谱他妈给离谱开门,离谱到家了
GET /shops/1/departments/2/categories/3/products/4/reviews/5/comments/6/authors/7
这谁受得了?URL长得跟绕口令似的。一般来说,超过三层嵌套就该考虑换个设计了。比如上面那个,完全可以拆成:
// 先拿到评论ID,再查作者
GET /reviews/5
// 返回里带上authorId,然后
GET /users/7
或者直接用查询参数:
GET /comments?reviewId=5&authorId=7
记住,URL是给人看的,不是给机器压缩的,别为了省那么一两次请求就把路径搞成俄罗斯套娃。
URL怎么起名才不被嘲笑
起名字这事儿,说简单也简单,说难也难。我见过有团队为了/userOrders还是/user-orders还是/user_orders吵了整整一个下午,最后CTO拍板用/user_orders,结果前端小哥默默吐槽:“这看起来像个Python变量名…”
大小写与分隔符的江湖恩怨
关于命名规范,业内主要有三派:
驼峰派:/userOrders、/orderItems
- 优点:Java程序员看着亲切
- 缺点:URL本身就是大小写不敏感的(虽然规范说敏感,但很多服务器配置会搞成不敏感),而且看起来有点挤
中划线派:/user-orders、/order-items
- 优点:可读性好,谷歌推荐,SEO友好
- 缺点:Shift键按得手指疼
下划线派:/user_orders、/order_items
- 优点:Python/PHP程序员看着亲切,不用按Shift
- 缺点:URL里下划线有时候会被识别成空格,而且看着像数据库表名
我的建议是用中划线(kebab-case),因为RFC 3986标准里明确说中划线是"unreserved character",不会引起歧义。而且你看GitHub的API,全是/repos/{owner}/{repo}/issues这种,清爽得很。
// 推荐这样写
GET /user-orders/123
GET /product-categories/electronics
// 别这样写
GET /userOrders/123 // 驼峰在URL里看着累
GET /user_orders/123 // 下划线像蛇精病
GET /userorders/123 // 这谁看得懂啊
版本号放哪儿这是个哲学问题
API肯定要迭代,今天v1明天v2的。版本号放哪儿呢?主要有三种流派:
Path派:/v1/users、/v2/users
// 优点:直观,一眼就知道是哪个版本
// 缺点:URL里多个前缀,有点丑
app.get('/v1/users', (req, res) => { ... });
app.get('/v2/users', (req, res) => { ... });
Header派:在Header里加API-Version: v1
// 优点:URL干净
// 缺点:测试的时候麻烦,curl命令要多写个header,浏览器直接访问也看不到版本
Content-Type派:Accept: application/vnd.api.v1+json
// 优点:最RESTful,最专业的写法
// 缺点:太专业了,一般团队hold不住,前端看了想打人
我的建议:小团队用Path派,简单粗暴;大团队用Header派,显得高级;Content-Type派除非你们公司有个架构师闲着没事干,否则别折腾。
而且版本号用v1、v2就行,别用什么1.0、1.1这种,看着像软件版本号,容易和API版本搞混。
过滤、排序、分页别往URL里塞动词
查询资源的时候,肯定要带条件。这时候别傻乎乎地写/getActiveUsers或者/searchUsers,用Query参数:
// 错误示范,动词又出现了
GET /getActiveUsers
GET /searchUsersByName?name=张三
// 正确姿势,Query参数走起来
GET /users?status=active
GET /users?name=张三&age=25
GET /users?sort=-createdAt&page=2&limit=20 // -表示降序
看到没?基础路径永远是/users,各种条件用问号挂后面。这样既符合RESTful风格,又灵活,加条件不用改路由。
分页参数建议用page和limit(或者size),offset那套虽然专业,但前端同事一般都习惯page:
// 推荐
GET /users?page=1&limit=20
// 或者
GET /users?page=1&size=20
// offset也不是不行,但得跟前端对齐
GET /users?offset=0&limit=20
HTTP方法别乱用,GET不是万能的
我敢说,80%的接口混乱都是因为HTTP方法用错了。很多人不管干啥都是POST,获取数据POST,提交数据POST,删除数据还是POST,问就是"POST稳一点"。
稳个屁啊!HTTP方法本身就是一种语义表达,你用错了,状态码就不知道该咋给,缓存策略也会出问题。
GET:只读,且无副作用
GET方法就是用来获取资源的,而且必须是幂等的(幂等就是调一万次结果都一样)和安全的(安全就是不改服务器数据)。
// 正确的GET用法
GET /users/123 // 获取用户123的信息
GET /users?page=2 // 获取第二页用户列表
GET /products?category=phone&sort=price // 按条件筛选并排序
// 错误的GET用法(虽然能跑,但被人看到会笑话)
GET /users/123/delete // 删除用户?用GET删除数据,胆子真大
GET /users/create?name=张三 // 创建用户?GET请求有长度限制,数据大了就GG
GET请求的几个忌讳:
- 别在GET里传敏感信息:因为URL会留在浏览器历史、服务器日志里,你传个密码试试?
- 别用GET做数据修改:有些浏览器或者爬虫会预加载GET请求,你写个
GET /transfer-money?to=黑客&amount=10000,人家爬虫一遍历,钱就没了。 - 注意URL长度限制:不同浏览器不一样,一般2KB到8KB,数据多了用POST。
POST:专职创建资源
POST就是用来创建新资源的,而且在RESTful里,POST的URL是复数资源路径,因为你是往这个集合里加新成员。
// 创建新用户
POST /users
Content-Type: application/json
{
"name": "张三",
"email": "zhangsan@example.com",
"password": "123456" // 别真这么干,要加密
}
// 返回201 Created,Location头指向新资源
HTTP/1.1 201 Created
Location: /users/123
{
"id": 123,
"name": "张三",
"email": "zhangsan@example.com",
"createdAt": "2024-01-15T10:30:00Z"
}
看到没?POST之后返回201状态码,表示"创建成功",并且在Header里给个Location,告诉前端"新资源在这儿呢"。这叫懂礼貌,前端拿到Location可以直接跳转到详情页,省得再组装URL。
POST不是垃圾桶,别啥都往里塞。有的同学更新数据也用POST,理由是"POST参数多",其实PUT/PATCH也能带body,而且语义更清晰。
PUT vs PATCH:全量更新和打补丁的区别
这俩是最容易搞混的。简单说:
- PUT:全量更新,你把整个资源的新版本发过去,没有的字段就置空
- PATCH:局部更新,你只发要改的字段,其他字段保持不变
// 用户原数据
{
"id": 123,
"name": "张三",
"email": "zhangsan@example.com",
"age": 25,
"avatar": "http://xxx.jpg"
}
// PUT请求(全量更新)
PUT /users/123
{
"name": "张三丰", // 改名了
"email": "zhangsan@example.com", // 没改也得发
"age": 25, // 没改也得发
"avatar": "http://xxx.jpg" // 没改也得发
}
// 如果PUT的时候漏了字段,比如只发了个name
PUT /users/123
{
"name": "张三丰"
}
// 结果:email、age、avatar全被清空或设为null,因为PUT是"替换"整个资源
// PATCH请求(局部更新)
PATCH /users/123
{
"name": "张三丰" // 只发要改的字段
}
// 结果:只有name变了,email、age、avatar保持原样
实际项目中,PATCH更实用,因为前端往往只改一两个字段,没必要把整个对象都发过去。但PUT也有用,比如"保存草稿"功能,用户写一半刷新页面,你PUT上去,下次GET下来还是完整的一份。
DELETE:删数据真得用,别怕
很多人不敢用DELETE方法,觉得不吉利,或者怕搜索引擎误删数据(其实搜索引擎不会用DELETE)。结果全用POST /users/123/delete,看着就别扭。
// 正确的删除
DELETE /users/123
// 返回204 No Content(删除成功,没东西可返回)
HTTP/1.1 204 No Content
// 或者返回200,带上被删的数据(防止误删后悔)
HTTP/1.1 200 OK
{
"message": "用户已删除",
"deletedUser": {
"id": 123,
"name": "张三"
}
}
注意:DELETE也是幂等的,删一次和删十次效果一样(都是没了)。但别写成像DELETE /users这种批量删除,除非你真的想删库跑路。要批量删,用Query参数:
// 批量删除指定ID的用户
DELETE /users?id=1,2,3,4,5
// 或者(更RESTful一点)
POST /users/batch-delete // 如果DELETE带body有争议的话
{
"ids": [1, 2, 3, 4, 5]
}
状态码别只会200和500,数字背后都是暗号
我见过最离谱的接口,不管成功失败全返回200,然后在body里自己定义一套code。前端调接口,HTTP状态是200,body里{code: -1, msg: "登录失败"},前端的路由拦截器一看200,以为成功了,结果页面跳转到登录后的首页,然后因为没数据又报错,用户体验稀碎。
2xx:成功系列,但不只是200
- 200 OK:通用成功,GET、PUT、PATCH、DELETE成功都可以用
- 201 Created:POST创建资源成功,必须配合Location头使用
- 204 No Content:删除成功,或者更新成功但没啥可返回的(省流量)
- 202 Accepted:已接受请求,但还没处理完(比如异步任务,“你的导出Excel请求已提交,稍后邮件发送”)
// 创建成功返回201
app.post('/orders', (req, res) => {
const newOrder = createOrder(req.body);
res.status(201)
.location(`/orders/${newOrder.id}`)
.json(newOrder);
});
// 删除成功返回204
app.delete('/users/:id', (req, res) => {
deleteUser(req.params.id);
res.status(204).send(); // 204不能带body,或者带空对象
});
4xx:客户端错了,别甩锅给服务器
4xx系列表示客户端请求有问题,不是服务器崩了,是前端传参传错了,或者用户没权限。
- 400 Bad Request:请求格式错了,比如JSON格式不对,或者必填字段没填
- 401 Unauthorized:未认证,用户没登录(虽然叫 unauthorized,但实际上是未认证)
- 403 Forbidden:已认证但没权限,比如普通用户想访问管理员接口(这个叫授权失败)
- 404 Not Found:资源不存在,比如
/users/999,但ID为999的用户不存在 - 409 Conflict:资源冲突,比如注册用户时邮箱已存在,或者并发修改冲突
- 422 Unprocessable Entity:语义错误,比如要求填手机号,前端传了个"abc"(虽然格式是JSON,但内容验证失败)
- 429 Too Many Requests:请求太频繁,被限流了,前端应该稍后再试
// 400示例:参数格式错误
app.post('/users', (req, res) => {
if (!req.body.email || !req.body.email.includes('@')) {
return res.status(400).json({
error: 'Bad Request',
message: '邮箱格式不正确',
field: 'email'
});
}
// ...
});
// 401 vs 403的区别,这个面试常考
app.get('/admin/dashboard', authenticate, (req, res) => {
if (!req.user) {
return res.status(401).json({ error: '请先登录' }); // 没登录
}
if (req.user.role !== 'admin') {
return res.status(403).json({ error: '权限不足' }); // 登录了但不是管理员
}
res.json({ data: '敏感数据' });
});
// 409:资源冲突
app.post('/register', (req, res) => {
if (userExists(req.body.email)) {
return res.status(409).json({
error: 'Conflict',
message: '该邮箱已被注册'
});
}
});
重要提示:4xx错误返回时,一定要在body里告诉前端具体哪错了。别只返回个"Bad Request",前端看了想打人。要告诉他是哪个字段错了,为什么错。
5xx:服务器凉了,赶紧找后端
5xx是服务器内部错误,前端看到5xx基本无能为力,只能给用户显示"系统繁忙,请稍后再试"。
- 500 Internal Server Error:通用服务器错误,代码抛异常了
- 502 Bad Gateway:网关挂了,一般是Nginx配置问题或后端服务没启动
- 503 Service Unavailable:服务暂时不可用,比如服务器维护中或过载
- 504 Gateway Timeout:网关超时,后端处理太慢了
// 全局错误处理,别让前端看到堆栈信息
app.use((err, req, res, next) => {
console.error(err.stack); // 自己记下来
// 生产环境别暴露错误细节,防止泄露敏感信息
res.status(500).json({
error: 'Internal Server Error',
message: process.env.NODE_ENV === 'production'
? '服务器内部错误'
: err.message,
requestId: req.id // 给前端一个请求ID,方便后端查日志
});
});
实际项目里怎么玩转RESTful,真实场景设计
光讲理论没意思,咱们来几个实战案例,看看用户系统、电商订单、社交评论这些常见功能怎么设计接口。
用户管理模块
这是最基础的,但坑也多。
// 用户注册
POST /auth/register
{
"email": "user@example.com",
"password": "securePassword123",
"nickname": "前端小王"
}
// 返回201,注意别返回密码,哪怕加了密
// 用户登录(这个其实不算RESTful资源,用POST也合理)
POST /auth/login
{
"email": "user@example.com",
"password": "securePassword123"
}
// 返回200 + JWT Token,或者返回204 + Set-Cookie头
// 获取当前用户信息
GET /users/me // 或者用 /auth/profile,看团队约定
Authorization: Bearer <token>
// 获取用户列表(带过滤和分页)
GET /users?page=1&limit=20&role=admin&sort=-createdAt
Authorization: Bearer <token> // 只有管理员能看列表
// 获取指定用户详情
GET /users/123
// 注意:用户只能看自己的详情,或者管理员能看所有人的,这里要做权限控制
// 更新用户信息
PATCH /users/123 // 用户自己改资料
{
"nickname": "前端老王",
"avatar": "http://new-avatar.jpg"
}
// 修改密码(单独接口,因为要验证旧密码)
PUT /users/123/password // 或者 PATCH /users/123
{
"oldPassword": "securePassword123",
"newPassword": "newSecurePassword456"
}
// 注销账号(软删除,别真删数据)
DELETE /users/123
// 或者 POST /users/123/deactivate 如果公司规定不让用DELETE
注意点:
- 密码相关操作要单独接口,别和更新用户信息混在一起,安全要求不一样
- 别在GET /users里返回密码字段,哪怕是加密的,这是大忌
- 软删除:很多时候别真DELETE,而是加个
isActive字段,删的时候改成false,防止误删后悔
电商订单系统
订单系统稍微复杂点,涉及状态流转和库存。
// 创建订单(从购物车结算)
POST /orders
{
"items": [
{"productId": "P001", "quantity": 2, "skuId": "SKU-RED-L"},
{"productId": "P002", "quantity": 1, "skuId": "SKU-BLUE-M"}
],
"addressId": "ADDR-123",
"couponCode": "SAVE20"
}
// 返回201,Location: /orders/ORDER-20240115-001
// 注意:创建订单时要扣库存,这是个事务操作
// 获取订单列表(用户只能看自己的)
GET /orders?status=paid&page=1&limit=10
// 返回当前登录用户的订单,管理员可以看 ?userId=xxx 查指定用户的
// 获取订单详情
GET /orders/ORDER-20240115-001
// 返回订单完整信息,包括商品快照、物流信息、支付状态等
// 取消订单(不是DELETE,因为订单数据要保留)
PATCH /orders/ORDER-20240115-001
{
"status": "cancelled",
"reason": "不想买了"
}
// 或者专门的动作接口
POST /orders/ORDER-20240115-001/cancel // 如果动作很复杂的话
{
"reason": "不想买了"
}
// 支付订单(这通常调用支付网关,但可以先创建支付单)
POST /orders/ORDER-20240115-001/payments
{
"paymentMethod": "alipay",
"returnUrl": "http://your-site.com/payment/callback"
}
// 返回支付页面URL或支付参数
// 申请退款
POST /orders/ORDER-20240115-001/refunds
{
"reason": "商品质量问题",
"items": [{"productId": "P001", "quantity": 1}], // 部分退款
"images": ["http://evidence1.jpg", "http://evidence2.jpg"]
}
设计思考:
- 订单状态变更用PATCH还是POST? 如果状态变更有复杂业务逻辑(比如取消订单要还原库存、发通知、退优惠券),建议用
POST /orders/{id}/cancel;如果只是简单改个字段,用PATCH就行。 - 订单ID怎么生成? 别用自增ID,容易暴露业务量。用
ORDER-年月日-随机数这种格式,或者雪花算法。
评论和点赞系统
社交功能,要考虑并发和防刷。
// 获取文章评论列表(树形结构)
GET /articles/123/comments?page=1&limit=20&sort=-createdAt
// 返回评论数组,每个评论包含:
// - 评论内容、作者信息
// - 点赞数、是否已点赞(根据当前登录用户)
// - 回复数、前几条回复预览
// 发表评论(一级评论)
POST /articles/123/comments
{
"content": "写得真好!",
"parentId": null // 一级评论parentId为null
}
// 回复评论(二级评论)
POST /articles/123/comments
{
"content": "谢谢支持!",
"parentId": "COMMENT-456" // 回复哪条评论
}
// 或者更清晰的层级
POST /comments/COMMENT-456/replies
{
"content": "谢谢支持!"
}
// 点赞评论(幂等操作,点两次是取消点赞)
PUT /comments/COMMENT-456/like
// 或者 POST /comments/COMMENT-456/likes,看怎么设计
// 返回204或更新后的点赞数
// 删除评论(软删除,显示"该评论已删除")
DELETE /comments/COMMENT-456
// 或者 PATCH /comments/COMMENT-456 { "isDeleted": true }
注意:
- 点赞这种操作要防重复,数据库里用唯一索引
(userId, targetId, targetType) - 评论列表要考虑性能,如果一篇文章有几万条评论,别一次性返回,要分页,而且深度嵌套的回复要考虑用"展开更多"的方式
踩过的坑比写过的代码还多
这些年我踩的坑,能填满一个太平洋。这里挑几个没人告诉你但一踩就炸的坑说说。
分页参数到底叫page还是offset?
后端喜欢offset,因为数据库查询用LIMIT 20 OFFSET 40这种SQL很直观。前端喜欢page,因为UI上显示"第2页"比"从第40条开始"更友好。
建议:支持两种!或者至少和前端对齐。如果只做一种,推荐page+limit,因为offset有个坑:如果在用户翻页的过程中,有新数据插入,offset会漂移,导致用户可能看到重复数据或跳过数据。
// 方案1:传统分页(可能有漂移问题)
GET /users?page=2&limit=20 // 后端转成 OFFSET 20 LIMIT 20
// 方案2:游标分页(适合实时性强的列表,比如微博朋友圈)
GET /users?cursor=eyJpZCI6MTIzfQ==&limit=20
// cursor是上一页最后一条数据的ID加密后的值,数据库用 WHERE id > cursor LIMIT 20
// 这样即使有新数据插入,也不会影响当前查询
过滤条件塞query还是body?
GET请求理论上可以带body,但很多服务器和库不支持(比如axios默认会把GET的body忽略掉)。所以过滤条件简单(几个字段)用Query,复杂(嵌套对象、数组)用POST。
// 简单过滤,Query搞定
GET /products?category=phone&priceMin=1000&priceMax=5000
// 复杂过滤,比如电商的"多选属性筛选"
// 这种如果硬塞Query会变成 ?attrs=color:red,blue;size:M,L,解析起来很痛苦
POST /products/search // 妥协一下,用POST做查询
{
"category": "phone",
"priceRange": [1000, 5000],
"attributes": {
"color": ["red", "blue"],
"size": ["M", "L"]
},
"sort": [{"field": "price", "order": "asc"}],
"page": 1,
"limit": 20
}
注意:虽然RESTful purist会说"查询用GET",但实用主义优先,复杂查询用POST是业内常见做法,别纠结。
文件上传算不算RESTful?
严格来说,文件上传(multipart/form-data)不太符合RESTful的"资源"概念,因为传的不是JSON。但生意还得做啊!
// 方案1:单独接口(推荐)
POST /uploads // 或者 /files
Content-Type: multipart/form-data
// 返回文件URL或文件ID
{
"fileId": "FILE-123",
"url": "http://cdn.example.com/files/123.jpg",
"size": 102456,
"mimeType": "image/jpeg"
}
// 然后业务接口只传fileId
POST /users/123/avatar
{
"fileId": "FILE-123"
}
// 方案2:Base64编码塞JSON(不推荐,文件大了性能差)
POST /users/123/avatar
{
"image": "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD..."
}
大文件上传要用分片,别直接传几百MB的文件,容易断。可以用OSS直传(前端直传阿里云/七牛云,后端只拿URL),或者用 tus 协议做断点续传。
批量操作怎么设计?
RESTful对批量操作支持不太好,因为URL和HTTP方法都是针对单个资源的。
// 方案1:逗号分隔ID(简单场景)
GET /users?id=1,2,3,4,5 // 获取多个用户
DELETE /users?id=1,2,3 // 批量删除(有些服务器不支持DELETE带Query,慎用)
// 方案2:批量接口(复杂场景)
POST /users/batch-delete
{
"ids": [1, 2, 3, 4, 5]
}
POST /users/batch-update
{
"ids": [1, 2, 3],
"data": {
"status": "active"
}
}
// 方案3:GraphQL(如果批量操作特别多,考虑上GraphQL)
关联资源是嵌套还是扁平?
获取订单详情时,要不要把用户信息嵌套进去?
// 方案1:扁平,只返回ID,前端再查(请求次数多,但缓存友好)
GET /orders/123
{
"id": "ORDER-123",
"userId": "USER-456",
"totalAmount": 199.9
}
// 前端再 GET /users/USER-456
// 方案2:嵌套,一次返回(请求次数少,但数据可能重复)
GET /orders/123
{
"id": "ORDER-123",
"user": {
"id": "USER-456",
"name": "张三",
"avatar": "http://xxx.jpg"
}
}
// 方案3:可选嵌入(用Query控制)
GET /orders/123?embed=user,items // 嵌入用户和订单项
GET /orders/123?fields=id,totalAmount,user.name // 只返回特定字段(GraphQL-lite)
建议:看场景。如果移动端(网络不稳定,请求次数越少越好),用嵌套;如果Web端(HTTP/2多路复用,请求开销小),用扁平,配合缓存更灵活。
调试和排查接口问题的野路子
接口写完了,得测啊。别跟我说你直接用浏览器地址栏测GET请求,那也太原始了。
Postman高级玩法
别只会点Send,这几个功能用起来:
// 1. 环境变量,切换开发和生产环境
// 在Postman右上角设置Environment,定义 {{baseUrl}}、{{token}}
// 2. 预请求脚本(自动登录)
// 在Collection的Pre-request Scripts里写:
pm.sendRequest({
url: "{{baseUrl}}/auth/login",
method: "POST",
body: {
mode: "raw",
raw: JSON.stringify({email: "test@test.com", password: "123456"})
}
}, function (err, res) {
const jsonData = res.json();
pm.environment.set("token", jsonData.token);
});
// 3. 测试脚本(自动校验响应)
// 在Tests标签页写:
pm.test("Status code is 200", function () {
pm.response.to.have.status(200);
});
pm.test("Response has user id", function () {
var jsonData = pm.response.json();
pm.expect(jsonData).to.have.property("id");
});
浏览器Network面板看门道
打开F12,别只看Console,Network才是宝藏:
- 看Timing:DNS查询、SSL握手、TTFB(首字节时间)各花了多久。如果TTFB太长,说明后端处理慢;如果SSL握手长,考虑开启TLS 1.3。
- 看Headers:重点看Request Headers里的
Accept、Content-Type,Response Headers里的Cache-Control、ETag。 - 看Preview和Response的区别:Preview是格式化后的,Response是原始文本。如果Preview显示乱码但Response正常,可能是Content-Type设置错了。
后端返回400但不说哪错了咋办?
这是最抓狂的情况。你得自己加日志:
// 前端拦截器里,遇到4xx错误把请求详情打出来
axios.interceptors.response.use(
response => response,
error => {
if (error.response && error.response.status >= 400 && error.response.status < 500) {
console.group(`🚨 API Error ${error.response.status}`);
console.log('Request URL:', error.config.url);
console.log('Request Method:', error.config.method);
console.log('Request Headers:', error.config.headers);
console.log('Request Data:', error.config.data);
console.log('Response Data:', error.response.data);
console.groupEnd();
// 还可以上报到Sentry等错误监控系统
}
return Promise.reject(error);
}
);
然后拿curl命令直接在后端环境跑,排除前端问题:
# 在Network面板里右键请求 -> Copy -> Copy as cURL
# 粘贴到终端,看是不是还报错。如果curl也报错,那就是后端问题
curl -X POST "http://api.example.com/users" \
-H "Content-Type: application/json" \
-d '{"name":"test"}'
线上问题排查,日志里加Request ID
微服务架构下,一个请求可能经过A->B->C三个服务,出问题的时候得串联日志。
// 前端生成唯一请求ID(或者后端网关生成)
const requestId = generateUUID();
// 每个请求都带这个ID
axios.defaults.headers.common['X-Request-ID'] = requestId;
// 后端把这个ID打印到所有日志里,排查的时候按ID搜索
// 后端日志:[2024-01-15 10:30:00] [REQ-abc-123] [UserService] 开始查询用户
// [2024-01-15 10:30:01] [REQ-abc-123] [OrderService] 开始查询订单
让接口更香的小技巧
基础功能实现了,还能怎么优化?这几个技巧用上,前端同事会感谢你。
统一响应格式
别这个接口返回{code: 0, data: {...}},那个接口返回{success: true, result: {...}},前端封装拦截器的时候想骂人。
// 统一结构
{
"status": "success", // 或 "error"
"data": { ... }, // 成功时的数据
"error": { // 失败时的详情
"code": "USER_NOT_FOUND",
"message": "用户不存在",
"details": { ... } // 可选,详细错误信息
},
"meta": { // 元数据(分页等)
"page": 1,
"limit": 20,
"total": 100,
"timestamp": "2024-01-15T10:30:00Z",
"requestId": "req-abc-123"
}
}
// 或者简单点,HTTP状态码已经表达成功失败了,body里只放数据
// 成功:
HTTP/1.1 200 OK
{
"id": 123,
"name": "张三",
"meta": {
"timestamp": "2024-01-15T10:30:00Z"
}
}
// 失败:
HTTP/1.1 404 Not Found
{
"error": {
"code": "RESOURCE_NOT_FOUND",
"message": "用户ID 123 不存在",
"resourceType": "User",
"resourceId": "123"
}
}
错误结构标准化
前端要根据错误码做不同处理,比如INVALID_TOKEN要跳登录页,INSUFFICIENT_BALANCE要提示充值。
// 定义错误码常量
const ErrorCodes = {
// 认证授权类 1xxx
INVALID_TOKEN: { code: 1001, status: 401, message: '登录已过期' },
INSUFFICIENT_PERMISSIONS: { code: 1002, status: 403, message: '权限不足' },
// 业务逻辑类 2xxx
USER_NOT_FOUND: { code: 2001, status: 404, message: '用户不存在' },
ORDER_EXPIRED: { code: 2002, status: 400, message: '订单已过期' },
INSUFFICIENT_BALANCE: { code: 2003, status: 400, message: '余额不足' },
// 系统错误类 5xxx
INTERNAL_ERROR: { code: 5000, status: 500, message: '系统繁忙' },
DATABASE_ERROR: { code: 5001, status: 500, message: '数据库错误' }
};
// 使用
if (user.balance < order.amount) {
throw new ApiError(ErrorCodes.INSUFFICIENT_BALANCE, {
currentBalance: user.balance,
requiredAmount: order.amount
});
}
HATEOAS做超媒体导航(装X必备)
HATEOAS(Hypermedia as the Engine of Application State)听起来高大上,其实就是在返回数据里带上相关操作的链接。
// 普通返回
GET /orders/123
{
"id": "123",
"status": "pending",
"total": 199.9
}
// HATEOAS风格
GET /orders/123
{
"id": "123",
"status": "pending",
"total": 199.9,
"_links": {
"self": { "href": "/orders/123" },
"user": { "href": "/users/456" },
"items": { "href": "/orders/123/items" },
"pay": { "href": "/orders/123/payments", "method": "POST" }, // 可以执行的操作
"cancel": { "href": "/orders/123", "method": "DELETE" }
}
}
优点:前端不需要硬编码URL,完全根据后端返回的_links决定展示哪些按钮。缺点:JSON体积变大,而且前端逻辑变复杂(得解析_links)。一般只有特别追求RESTful纯度的项目会用,比如Spring Data REST默认就带这个。
文档自动生成真香警告
别手写Word文档了,用工具自动生成:
- Swagger/OpenAPI:写代码的时候加注释,自动生成在线文档,还能直接在线测试
- Postman Collection:导出JSON文件,前端导入就能用
- API Blueprint:Markdown语法写API文档,适合技术写作
// Swagger示例(使用swagger-jsdoc)
/**
* @swagger
* /users:
* get:
* summary: 获取用户列表
* parameters:
* - in: query
* name: page
* schema:
* type: integer
* description: 页码
* responses:
* 200:
* description: 成功
* content:
* application/json:
* schema:
* type: array
* items:
* $ref: '#/components/schemas/User'
*/
app.get('/users', (req, res) => { ... });
别以为写完就完了,前后端联调的潜规则
接口设计好了,代码写完了,以为可以松口气了?Too young!前后端联调才是真正的战场。
前端怎么优雅处理加载态和错误提示
别每个请求都写try-catch,封装一个useApi钩子:
// React示例
function useApi(apiFunction) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const execute = async (...params) => {
setLoading(true);
setError(null);
try {
const result = await apiFunction(...params);
setData(result);
return result;
} catch (err) {
setError(err);
// 统一错误处理
if (err.response?.status === 401) {
message.error('登录已过期,请重新登录');
router.push('/login');
} else {
message.error(err.response?.data?.message || '请求失败');
}
throw err;
} finally {
setLoading(false);
}
};
return { data, loading, error, execute };
}
// 使用
function UserProfile({ userId }) {
const { data: user, loading, error, execute: fetchUser } = useApi(getUser);
useEffect(() => {
fetchUser(userId);
}, [userId]);
if (loading) return <Skeleton active />;
if (error) return <ErrorDisplay error={error} onRetry={() => fetchUser(userId)} />;
return <div>{user.name}</div>;
}
Mock数据怎么跟RESTful规则对齐
前端开发的时候后端接口可能还没ready,这时候Mock数据要遵循同样的规范,别到时候对接的时候一地鸡毛。
// 使用MSW (Mock Service Worker)
import { rest } from 'msw';
export const handlers = [
// Mock GET /users
rest.get('/api/users', (req, res, ctx) => {
const page = req.url.searchParams.get('page') || '1';
return res(
ctx.status(200),
ctx.json({
data: [
{ id: 1, name: 'Mock用户1' },
{ id: 2, name: 'Mock用户2' }
],
meta: {
page: parseInt(page),
total: 100
}
})
);
}),
// Mock POST /users,返回201和Location头
rest.post('/api/users', (req, res, ctx) => {
return res(
ctx.status(201),
ctx.set('Location', '/api/users/999'),
ctx.json({
id: 999,
...req.body,
createdAt: new Date().toISOString()
})
);
})
];
上线后改接口字段怎么不背锅
最怕的就是上线后改接口,前端没跟着改,直接报错。几个保命技巧:
- 版本控制:/v1/xxx 和 /v2/xxx并存,老客户端继续调v1,新功能用v2
- 字段兼容性:别删老字段,先标记deprecated,过几个版本再删
- 灰度发布:先给内部用户或1%流量用新接口,观察没问题再全量
- 契约测试:用Pact之类的工具,前后端约定好契约,谁违反谁Pipeline失败
// 字段废弃标记
{
"id": 123,
"name": "张三",
"nickname": "法外狂徒", // 新字段
"displayName": "法外狂徒", // 老字段,已废弃,但为了兼容还保留,value和nickname一样
"_deprecated": {
"displayName": "请使用nickname字段,displayName将在v2.0移除"
}
}
写到这儿差不多也该收尾了。说真的,RESTful这玩意儿理解起来不难,难的是坚持执行。我见过太多团队,刚开始信誓旦旦说我们要做RESTful API,写到后面就变成"能用就行",最后文档和代码对不上,前后端天天吵架。
我的建议是:不要追求100%纯RESTful,有些场景(比如批量操作、复杂查询)该妥协就妥协,但基础的URL命名、HTTP方法、状态码这些底线要守住。至少做到让前端看到你的接口,不用看文档就能猜出怎么调,这就成功了。
最后送大家一句话:好的API设计就像好的笑话——不需要解释。如果你写了个接口,得跟前端解释半天为啥URL长这样、为啥返回500但实际是前端参数错了,那这接口就该回炉重造了。
好了,工友们,去写接口吧,记得别再写/getUserInfo了,我看着害怕。

&spm=1001.2101.3001.5002&articleId=157552248&d=1&t=3&u=93fd94c0761c40afbfb310bf150b0ac3)
7487

被折叠的 条评论
为什么被折叠?



