前端同事贴了我的三个接口,问:这仨怎么都是 POST?
刚入职那阵子我给订单模块写了几个接口,写完自我感觉良好。结果前端同事在群里贴了这么一串:
POST /order/getOrderList
POST /order/createOrder
POST /order/updateOrderStatus
POST /order/deleteOrderById
底下跟了一句:"哥,这四个能不能有点区别?"我当时还嘴硬,说 POST 最稳,不用管参数长度。后来师傅让我把接口文档重画一遍,我才承认那批接口确实不叫 REST,只是"用 HTTP 传输的 RPC"。
我踩的第一个坑:URL 里写动词
REST 的核心是把一切看成资源,URL 里只出现名词,动作交给 HTTP 方法。对照一下我那批接口和改完之后的样子:
| 原来的 | 改成 | 说明 |
|---|---|---|
| POST /order/getOrderList | GET /orders | 复数名词,列表 |
| POST /order/createOrder | POST /orders | 创建资源 |
| POST /order/updateOrderStatus | PUT /orders/{id}/status | 子资源单独更新 |
| POST /order/deleteOrderById | DELETE /orders/{id} | 删除资源 |
几个当时没想通、后来才消化的点:
- 用复数
/orders而不是/order。约定俗成,表示资源集合,单个资源是/orders/1024。 - 层级别太深。我一开始写过
/users/{uid}/orders/{oid}/items/{iid},看着很规范,实际前端用起来痛苦。超过两层就考虑用查询参数平铺,比如/order-items?orderId=1024。 - 实在找不到合适名词的动作(比如"取消订单"),可以用动词后缀,但要统一:
POST /orders/{id}/cancel。别一会儿 cancel 一会儿 cancelOrder。
四个方法的语义,别乱用
这块我一开始的理解是"增删改查对应 POST/DELETE/PUT/GET",漏了两个关键性质:安全性和幂等性。
| 方法 | 语义 | 幂等 | 典型错误 |
|---|---|---|---|
| GET | 查询,不改服务端状态 | 是 | 用 GET 做删除,被爬虫或预加载打穿 |
| POST | 创建子资源 / 执行动作 | 否 | 拿 POST 做查询,缓存全部失效 |
| PUT | 整体替换,客户端给全量字段 | 是 | 只传了部分字段,其余被置空 |
| DELETE | 删除 | 是 | 删除不存在的资源返回 500 |
PUT 和 POST 的区别我搞混过一次。PUT 是"我把这个资源的完整状态给你,覆盖掉",POST 是"在这个集合下新建一个"。部分更新用 PATCH,不过我们项目 2018 年那会儿为了省事,部分更新统一走了 PUT /orders/{id}/status 这种子资源形式,语义上是替换子资源,也说得通。
幂等这件事在支付接口上真的救过我。我们下单接口原来是 POST,网络超时重试会产生两笔订单。后来加了幂等键:客户端生成 Idempotency-Key 请求头,服务端在 Redis 里 setnx 一个 24 小时过期的记录,重复请求直接返回第一次的结果。
状态码:我以前只会返回 200 和 500
我最早写的所有接口,成功返回 200,出错也返回 200,靠 body 里一个 code 字段区分:
{
"code": 500,
"msg": "库存不足",
"data": null
}
这个写法本身没问题(国内很多公司在用),但 HTTP 状态码还是得用对,不然网关、监控、前端的通用错误处理全都失效。我们监控平台是按状态码统计错误率的,全是 200 的话,线上炸了监控图上一点反应都没有。我整理的常用几个:
- 200 OK:查询成功。
- 201 Created:创建成功,响应头带
Location: /orders/1024。 - 204 No Content:删除成功,body 为空。我一开始删除还返回个
{"success":true},多此一举。 - 400 Bad Request:参数校验不过,客户端的锅。
- 401 Unauthorized:没带 token 或 token 失效。
- 403 Forbidden:认出来了但没权限,比如普通用户访问管理员接口。
- 404 Not Found:资源不存在。
- 409 Conflict:状态冲突,比如订单已发货不能再取消。这个我以前一律用 400,后来发现 409 能让前端区分"要不要提示用户刷新"。
- 500 / 503:服务端的锅,503 用于依赖的下游挂了。
在 Spring Boot 2.0 里,我用 @ResponseStatus 和全局异常处理配合:
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BizException.class)
public ResponseEntity<ErrorBody> handleBiz(BizException e) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(new ErrorBody(e.getCode(), e.getMessage()));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorBody> handleValid(MethodArgumentNotValidException e) {
String msg = e.getBindingResult().getFieldErrors().stream()
.map(f -> f.getField() + " " + f.getDefaultMessage())
.collect(Collectors.joining(", "));
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(new ErrorBody(400, msg));
}
}
版本管理:三种做法我选了第二种
接口总要改,老客户端还在跑,不能一刀切。常见的三种:
- URL 带版本:
/api/v1/orders。最直白,我们最后选了这个。缺点是 URL 变了,严格说不算同一个资源。 - 请求头带版本:
Accept: application/vnd.myapp.v1+json。URL 干净,但前端调试麻烦,curl 得手写 header。 - 参数带版本:
/orders?version=1。我最早用的这种,被师傅否了,说这属于把版本号当业务参数用,语义不对。
在 Spring Boot 里实现 v1/v2 共存,我用的是两个 Controller 类加不同的 path 前缀,而不是在方法里 if-else 判断版本。if-else 那套我写过,三个月后就没人敢动那个方法了。
几个我现在会下意识遵守的小习惯
- 查询列表一定支持分页,
GET /orders?page=1&size=20。我第一版没做分页,测试环境 200 条数据好好的,上线后 3 万条数据直接把接口拖到 8 秒。 - 返回的时间字段统一用 ISO-8601 格式字符串,别返回时间戳。前端同事为这个找我确认过好几次。
- 不要在 URL 里塞敏感信息,
/users/13800138000这种会进 Nginx 访问日志。 - 错误信息要能直接给用户看,"参数错误"这种等于没说,要写"手机号格式不正确"。
改完这批接口后,前端同事在群里回了个"舒服了"。虽然现在回头看还是有不少可以改进的地方,但至少不再是清一色的 POST 了。