RESTful API 规范
定义
REST,Representational State Transfer,表现层状态转换,是一种 Web 应用架构风格。 REST 是一种风格,而非一种标准,但却已是 Web 交互应用的事实标准(de facto standard)。 遵循 REST 的 Web 服务被成为 RESTful(形容词)。
REST 的提出者是 HTTP 协议指定者之一的 Roy Thomas Fielding,用以在应用交互过程中充分利用 HTTP 协议的特性。
要点
- 通过 URI 定位资源,URI 应能够精确描述资源的定位
- 通过 HTTP 请求方法定义对资源的操作(见下文的说明)
- 资源可以有多种表现形式,如
JSON
、XML
、HTML
等(通过Content-Type
请求/响应头说明) - 通过操作资源的表现形式来操作资源
- 无状态,服务器不存储与客户端之间的会话,每个请求都是独立的,传输的数据应包含所有必要信息
建议与规范
- URI 由斜线(
/
)、小写字母、数字及横线(-
)组成 - 资源层级通过斜线分隔
- 单词通过横线分隔
- 正确地使用单词的单数或复数形式
- 将 API 版本号设置在路径中(利于日志分析)
- 用于定位资源的参数放置在 URI 中(即路径参数)
- 用于过滤资源的参数放置在查询参数中(即 Query String 中)
- 响应的 HTTP 状态码能够准确表达执行结果
- 响应的数据包含会话所需的必要数据,能够自解释
窍门
我们可以将整个系统比作一个文件系统,把资源层级比作文件夹,把资源比作文件,设计 REST API 即规划文件系统的目录结构。
例如,一个电商系统有由用户、店铺、商品、订单等实体,那么其目录结构可以是
/
├─ users
│ ├─ U001
│ │ └─ orders
│ │ └─ O001 → /stores/S001/orders/O001
│ └─ U002
└─ stores
└─ S001
├─ members
│ └─ U002 → /users/U002
├─ items
│ ├─ I001
│ └─ I002
└─ orders
└─ O001
于是,创建订单即
POST /users/U001/orders
提交订单即
POST /users/U001/orders/O001
修改订单即
PATCH /users/U001/orders/O001
取消订单即
DELETE /users/U001/orders/O001
修改订单总额即
PUT /stores/S001/orders/O001/total-amount
我们可以分别为文件夹设置访问权限,从而使得一个用户必须拥有特定的权限才可以对一个资源执行特定的操作。例如,只有订单的所有者可以修改、取消、提交自己的订单,只有店铺的管理员可以修改订单的总金额等。
精确定位资源
以电商应用场景为例。
设客户可通过如下请求创建订单
POST /api/v1/users/U001/orders
{
"storeId": "S001",
"itemId": "I001",
"itemCount": 2
}
且会生成如下订单数据
{
"id": "O001",
"userId": "U001",
"storeId": "S001",
"itemId": "I001",
"itemPrice": 20.0,
"itemCount": 2,
"totalAmount": 40.0,
"createdAt": "2021-03-02T08:00:00Z",
"status": "PENDING"
}
则客户可以通过以下请求提交订单
POST /api/v1/users/U001/orders/O001/submit
店铺管理员可以通过以下请求修改订单总金额
PUT /api/v1/stores/S001/customer-orders/O001/total-amount
{"totalAmount": 35.0}
在以上示例中,订单 O001
作为资源可以有多种定位(视图),即客户视角下自己创建的订单(/users/{userId}/orders
),以及店铺视角下客户创建的订单(/stores/{storeId}/customer-orders
)。
同一订单实体数据体现为不同的资源,因此可以应用不同的操作权限。例如,客户无法更新自己所创建的订单的总金额,店铺管理员也不能代替客户提交订单。
准确表达 HTTP 方法的语义
请求方法 | 语义 | 要求幂等 | 要求安全 |
---|---|---|---|
GET | 获取资源 | 是 | 是 |
PUT | 替换资源 | 是 | |
PATCH | 部分更新资源 | ||
DELETE | 删除资源 | 是 | |
POST | 创建资源,或其他操作 |
所谓幂等,是说对同一资源的相同操作的请求,发送多次与发送一次,其对该资源的影响应是相同的。
所谓安全,是说除了获取资源外不应当再做其他影响资源的操作(因此也是幂等的)。
上述例子中,店铺管理员也可以通过以下请求修改订单总金额……
PATCH /api/v1/stores/S001/customer-orders/O001
{"totalAmount": 35.0}
但这样做存在以下问题:
- 无法从(如 NginX 的)请求日志中体现正在执行设么样的操作
- 操作的颗粒度过粗,不利于设置访问权限,将不得不在业务逻辑中加入当前用户可以更新哪些属性的判断
返回正确的 HTTP 状态码
参考资料:HTTP response status codes 。
状态码 | 代码 | 类型 | 说明 |
---|---|---|---|
200 | OK | 成功 | 请求被成功处理 |
201 | Created | 成功 | 请求被成功处理且创建了新的资源 |
400 | Bad Request | 客户端错误 | 客户端提交的数据无法被服务器正确处理 |
401 | Unauthorized | 客户端错误 | 客户端未提供必要的认证信息 |
403 | Forbidden | 客户端错误 | 登录用户无权操作指定的资源 |
404 | Not Found | 客户端错误 | 请求的资源不存在 |
409 | Conflict | 客户端错误 | 操作冲突(如乐观锁错误) |
500 | Internal Server Error | 服务器错误 | 服务器内部错误 |
501 | Not Implemented | 服务器错误 | 接口尚未实现 |
返回会话所需的必要数据
以查询订单为例
GET /users/U001/orders?storeId=S001&pageSize=10&pageNumber=2
假设将返回以下结果
{
"success": true,
"meta": {
"totalCount": 12,
"pageSize": 10,
"pageNumber": 2,
"pageCount": 2,
"isFirstPage": false,
"isLastPage": true,
"hasPreviousPage": true,
"previousPageLink": "/users/U001/orders?storeId=S001&pageSize=10&pageNumber=1",
"hasNextPage": false,
"nextPageLink": null
},
"data": [
{
"id": "O011",
"userId": {"$ref": "U001"},
"storeId": {"$ref": "S001"},
"itemId": {"$ref": "I011"},
"itemPrice": 15.0,
"itemCount": 1,
"totalAmount": 15.0,
"createdAt": "2021-03-02T09:00:00Z",
"status": "PENDING"
},
{
"id": "O012",
"userId": {"$ref": "U001"},
"storeId": {"$ref": "S001"},
"itemId": {"$ref": "I012"},
"itemPrice": 20.0,
"itemCount": 2,
"totalAmount": 40.0,
"discountAmount": 5.0,
"createdAt": "2021-03-02T10:00:00Z",
"lastModifiedAt": "2021-03-02T10:10:00Z",
"lastModifiedBy": {"$ref": "U002"},
"status": "SUBMITTED"
}
],
"included": {
"U001": {
"name": "用户#1"
},
"U002": {
"name": "店铺管理员"
},
"S001": {
"name": "店铺#1"
},
"I011": {
"name": "商品#11",
"price": 15.0
},
"I012": {
"name": "商品#12",
"price": 20.0
}
}
}
该结果是可自解释,且会话充分的。在需要分页显示的场景下,该结果通过 meta
描述了分页相关信息,客户端可根据这部分信息控制分页组件的显示,设置翻页按钮的请求。客户端还可以通过 included
部分的信息组装数据,无需额外请求相关接口。
对比:RPC 风格 API
仍以前面所述的客户创建订单及店铺管理员更新订单总额为例。
客户创建订单时,虽然服务器可以通过令牌获取客户信息,但从日志中无法分析出订单的所有者:
POST /v1/rpc/createOrder
{
"storeId": "S001",
"itemId": "I001",
"itemCount": 2
}
店铺管理员修改订单总额时也无法从日志中获取订单及订单所属店铺信息,同时未能充分利用 HTTP 请求方法的语义:
POST /v1/rpc/updateOrderTotalAmount
{
"orderId": "O001",
"totalAmount": 35.0
}
使用 REST API 时由于资源定位信息存在与 URI 中,因此可以通过控制器方法的注解实现权限控制,而使用 RPC API 时则不得不先解析请求数据。
对比:基于 Session 的分页查询
一个依赖 Session 的分页查询的【下一页】按钮的接口可能会被设计为如下形式
GET /v1/orderList/nextPage
这种设计的问题在于
- 服务器需要记录会话(Session),以确定当前页
- 使用 Session 在大用户量的场景下会加重服务器的负担
- 使用 Session 不利于服务器水平扩展,需要引入如 Memcached 一类的 Session 共享机制
- 必须保证 Session 中的值在页面跳转时被正确重置
- 无法通过访问日志反映访问的数据位于哪一页