Administrator
发布于 2018-07-17 / 4235 阅读
113

RESTful API 设计与那些年写错的接口

前端同事贴了我的三个接口,问:这仨怎么都是 POST?

刚入职那阵子我给订单模块写了几个接口,写完自我感觉良好。结果前端同事在群里贴了这么一串:

POST /order/getOrderList
POST /order/createOrder
POST /order/updateOrderStatus
POST /order/deleteOrderById

底下跟了一句:"哥,这四个能不能有点区别?"我当时还嘴硬,说 POST 最稳,不用管参数长度。后来师傅让我把接口文档重画一遍,我才承认那批接口确实不叫 REST,只是"用 HTTP 传输的 RPC"。

我踩的第一个坑:URL 里写动词

REST 的核心是把一切看成资源,URL 里只出现名词,动作交给 HTTP 方法。对照一下我那批接口和改完之后的样子:

原来的改成说明
POST /order/getOrderListGET /orders复数名词,列表
POST /order/createOrderPOST /orders创建资源
POST /order/updateOrderStatusPUT /orders/{id}/status子资源单独更新
POST /order/deleteOrderByIdDELETE /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));
    }
}

版本管理:三种做法我选了第二种

接口总要改,老客户端还在跑,不能一刀切。常见的三种:

  1. URL 带版本/api/v1/orders。最直白,我们最后选了这个。缺点是 URL 变了,严格说不算同一个资源。
  2. 请求头带版本Accept: application/vnd.myapp.v1+json。URL 干净,但前端调试麻烦,curl 得手写 header。
  3. 参数带版本/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 了。

参考