title: Restful API 设计学习
date: 2021/03/30 10:15
一、协议
API与用户的通信协议,总是使用HTTPs协议。
如果能全站 HTTPS 当然是最好的,不能的话也请尽量将登录、注册等涉及密码的接口使用 HTTPS。
二、域名
应该尽量将API部署在专用域名之下:
https://api.example.com
如果确定API很简单,不会有进一步扩展,可以考虑放在主域名下。
https://example.org/api/
三、版本(Versioning)
应该将API的版本号放入URL。
https://api.example.com/v1/
另一种做法是,将版本号放在HTTP头信息中,但不如放入URL方便和直观。Github采用这种做法。
四、路径(Endpoint)
路径又称"终点"(endpoint),表示API的具体网址。
在RESTful架构中,每个网址代表一种资源(resource),所以网址中不能有动词,只能有名词,而且所用的名词往往与数据库的表格名对应。一般来说,数据库中的表都是同种记录的"集合"(collection),所以API中的名词也应该使用复数。
举例来说,有一个API提供动物园(zoo)的信息,还包括各种动物和雇员的信息,则它的路径应该设计成下面这样。
- https://api.example.com/v1/zoos
- https://api.example.com/v1/animals
- https://api.example.com/v1/employees
五、HTTP动词
对于资源的具体操作类型,由HTTP动词表示。
常用的HTTP动词有下面五个(括号里是对应的SQL命令)。
- GET(SELECT):从服务器取出资源(一项或多项)。
- POST(CREATE):在服务器新建一个资源。
- PUT(UPDATE):在服务器更新资源(客户端提供改变后的完整资源)。
- PATCH(UPDATE):在服务器更新资源(客户端提供改变的属性)。
- 正确使用 Patch 请求方式
- DELETE(DELETE):从服务器删除资源。
还有两个不常用的HTTP动词。
- HEAD:获取资源的元数据。
- OPTIONS:获取信息,关于资源的哪些属性是客户端可以改变的。
下面是一些例子。
- GET /zoos:列出所有动物园
- POST /zoos:新建一个动物园
- GET /zoos/{ID}:获取某个指定动物园的信息
- PUT /zoos/{ID}:更新某个指定动物园的信息(提供该动物园的全部信息)
- PATCH /zoos/{ID}:更新某个指定动物园的信息(提供该动物园的部分信息)
- DELETE /zoos/{ID}:删除某个动物园
- GET /zoos/{ID}/animals:列出某个指定动物园的所有动物
- DELETE /zoos/{ID}/animals/{ID}:删除某个指定动物园的指定动物
六、过滤信息(Filtering)
如果记录数量很多,服务器不可能都将它们返回给用户。API应该提供参数,过滤返回结果。
下面是一些常见的参数。
- ?limit=10:指定返回记录的数量
- ?offset=10:指定返回记录的开始位置。
- ?page=2&per_page=100:指定第几页,以及每页的记录数。
- ?sortby=name&order=asc:指定返回结果按照哪个属性排序,以及排序顺序。
- ?animal_type_id=1:指定筛选条件
参数的设计允许存在冗余,即允许API路径和URL参数偶尔有重复。比如,GET /zoo/ID/animals 与 GET /animals?zoo_id=ID 的含义是相同的。
七、状态码(Status Codes)
服务器向用户返回的状态码和提示信息,常见的有以下一些(方括号中是该状态码对应的HTTP动词)。
- 200 OK - [GET]:服务器成功返回用户请求的数据,该操作是幂等的(Idempotent)。
- 201 CREATED - [POST]:成功请求并创建了新的资源,该请求已经被实现,而且有一个新的资源已经依据请求的需要而建立,且其 URI 已经随Location 头信息返回。假如需要的资源无法及时建立的话,应当返回 '202 Accepted'。
- 202 Accepted - [*]:表示一个请求已经进入后台排队(异步任务)
- 204 NO CONTENT - [GET, DELETE]:服务器成功处理了请求,但没返回任何内容。
- 204 VS 没有响应体的HTTP/200响应
- 如果导航到的URL返回了一个没有响应体的HTTP/200响应,则页面将会显示一个空白文档(就是一片白色).页面的URL地址也会变成新指定的URL.
- 如果服务器返回的是一个HTTP/204响应,当前页面不会有任何变化,就好像根本没有进行导航操作一样.页面的URL地址也保持不变.
- 400 INVALID REQUEST - [POST/PUT/PATCH]:用户发出的请求有错误(例如,格式错误的请求语法,太大的大小,无效的请求消息或欺骗性路由请求),服务器没有进行新建或修改数据的操作,该操作是幂等的。
- 401 Unauthorized - [*]:表示用户没有权限(令牌、用户名、密码错误)。
- 403 Forbidden - [*] 表示用户得到授权(与401错误相对),但是访问是被禁止的。
- 404 NOT FOUND - [*]:用户发出的请求针对的是不存在的记录,服务器没有进行操作,该操作是幂等的。
- 406 Not Acceptable - [GET]:用户请求的格式不可得(比如用户请求JSON格式,但是只有XML格式)。
- 409 Conflict -[PUT]:表示请求与服务器端目标资源的当前状态相冲突。 冲突最有可能发生在对 PUT 请求的响应中。例如,当上传文件的版本比服务器上已存在的要旧,从而导致版本冲突的时候,那么就有可能收到状态码为409 的响应。
- 410 Gone -[GET]:用户请求的资源被永久删除,且不会再得到的。
- 422 Unprocesable entity - [POST/PUT/PATCH] 当创建一个对象时,发生一个验证错误。
- 500 INTERNAL SERVER ERROR - [*]:服务器发生错误,用户将无法判断发出的请求是否成功。
422 VS 400:
422(不可处理实体)状态码表示服务器理解请求实体的内容类型,并且请求实体的语法正确(因此400(错误请求)状态码不合适),但无法处理其中的说明。例如,如果XML请求主体包含格式正确(即,语法正确)但语义上错误的XML指令,则可能发生此错误情况。
所以我觉得验证异常应该使用 422 状态码。
状态码的完全列表参见这里。
- 但是有些时候仅仅使用 HTTP 状态码没有办法明确的表达错误信息,所以我倾向于在里面再包一层自定义的返回码,例如:
成功时:
{
"code": 100,
"msg": "成功",
"data": {}
}
失败时:
{
"code": -1000,
"msg": "用户名或密码错误"
}
data
是真正需要返回的数据,并且只会在请求成功时才存在,msg
只用在开发环境,并且只为了开发人员识别。客户端逻辑只允许识别code
,并且不允许直接将msg
的内容展示给用户。如果这个错误很复杂,无法使用一段话描述清楚,也可以在添加一个doc
字段,包含指向该错误的文档的链接。
八、错误处理(Error handling)
如果状态码是4xx,就应该向用户返回出错信息。一般来说,返回的信息中将error作为键名,出错信息作为键值即可。
{
error: "Invalid API key"
}
九、返回结果
针对不同操作,服务器向用户返回的结果应该符合以下规范。
- GET /collection:返回资源对象的列表(数组)
- GET /collection/resource:返回单个资源对象
- POST /collection:返回新生成的资源对象
- PUT /collection/resource:返回完整的资源对象
- PATCH /collection/resource:返回完整的资源对象
- DELETE /collection/resource:返回一个空文档
创建和修改操作成功后,需要返回该资源的全部信息。
返回数据不要和客户端界面强耦合,不要在设计 API 时就考虑少查询一张关联表或是少查询 / 返回几个字段能带来多大的性能提升。
最好将返回数据进行加密和压缩,尤其是压缩在移动应用中还是比较重要的。
十、Hypermedia API
注:文档相关,我们一般用 swagger,所以不太重要
RESTful API最好做到Hypermedia,即返回结果中提供链接,连向其他API方法,使得用户不查文档,也知道下一步应该做什么。
比如,当用户向api.example.com的根目录发出请求,会得到这样一个文档。
{"link": { "rel": "collection https://www.example.com/zoos", "href": "https://api.example.com/zoos", "title": "List of zoos", "type": "application/vnd.yourformat+json" }}
上面代码表示,文档中有一个link属性,用户读取这个属性就知道下一步该调用什么API了。rel表示这个API与当前网址的关系(collection关系,并给出该collection的网址),href表示API的路径,title表示API的标题,type表示返回类型。
Hypermedia API的设计被称为HATEOAS。Github的API就是这种设计,访问api.github.com会得到一个所有可用API的网址列表。
{ "current_user_url": "https://api.github.com/user", "authorizations_url": "https://api.github.com/authorizations", // ... }
从上面可以看到,如果想获取当前用户的信息,应该去访问api.github.com/user,然后就得到了下面结果。
{ "message": "Requires authentication", "documentation_url": "https://developer.github.com/v3" }
上面代码表示,服务器给出了提示信息,以及文档的网址。
十一、其他
(1)API的身份认证应该使用OAuth 2.0框架。
API 需要设计成无状态,所以客户端在每次请求时都需要提供有效的 Token 和 Sign,在我看来它们的用途分别是:
- Token 用于证明请求所属的用户,一般都是服务端在登录后随机生成一段字符串(UUID)和登录用户进行绑定,再将其返回给客户端。Token 的状态保持一般有两种方式实现:一种是在用户每次操作都会延长或重置 TOKEN 的生存时间(类似于缓存的机制),另一种是 Token 的生存时间固定不变,但是同时返回一个刷新用的 Token,当 Token 过期时可以将其刷新而不是重新登录。
- Sign 用于证明该次请求合理,所以一般客户端会把请求参数拼接后并加密作为 Sign 传给服务端,这样即使被抓包了,对方只修改参数而无法生成对应的 Sign 也会被服务端识破。当然也可以将时间戳、请求地址和 Token 也混入 Sign,这样 Sign 也拥有了所属人、时效性和目的地。
(2)服务器返回的数据格式,应该尽量使用JSON,避免使用XML。
十二、接口幂等性
RESTful 中使用 GET、POST、PUT 和 DELETE 来表示资源的查询、创建、更改、删除,并且除了 POST 其他三种请求都具备幂等性(多次请求的效果相同,我觉得这个效果指的是对资源操作后的效果)。
操作 | 释义 |
---|---|
查询 | 查询对于结果是不会有改变的,查询一次和查询多次,在数据不变的情况下,查询结果是一样的。select是天然的幂等操作 |
删除 | 删除一次和多次删除都是把数据删除。 |
更新 | 修改在大多场景下都具有幂等性的,但是如果是增量修改就需要我们来手动的保证幂等性,如下例子: |
1. 将表中的 age 字段的值设置为 1,这种操作不管执行多少次都是幂等的 | |
2. 将表中的 age 字段的值增加 1,这种操作就不是幂等的,就需要我们来对这个接口进行防重设计。 | |
新增 | 增加在重复提交的场景下会出现幂等性问题 |
像这种非幂等性的接口,我们一般都需要对其进行防重设计。
注:
防重设计
和幂等设计
,其实是有区别的。防重设计主要为了避免产生重复数据,对接口返回没有太多要求。而幂等设计除了避免产生重复数据之外,还要求每次请求都返回一样的结果。
这里只介绍两种常见的防重设计:
- 通过代码逻辑实现接口的幂等性,但只能针对一些满足判断的逻辑实现,具有一定的局限性。
- 使用 token 的机制来实现接口的幂等性,通用性比较强
代码逻辑实现防重
假设一个用户支付的场景,涉及到了订单系统
与支付系统
- 订单系统负责记录用户的购买记录已经订单的流转状态(orderStatus)
- 支付系统用于付款,提供如下接口,订单系统与支付系统通过分布式网络交互。
支付系统提供给订单系统的接口如下:
boolean pay(int accountid, BigDecimal amount); // 用于付款,扣除用户的
此时订单系统执行的 sql:
update userAmount set amount = amount - 'value' where account= 'account';
如果用户进行了支付操作,订单系统调用这个接口来进行扣款,但是由于网络原因,没有获取到确切的结果,因此订单系统需要重试。由于支付系统并没有做防重设计,所以会导致重复扣款,不符合幂等性原则(同一个订单,无论是调用了多少次,用户都只会扣款一次)。如果需要支持幂等性,付款接口需要修改为以下接口:
boolean pay(int orderId,int accountId,BigDecimal amount)
此时订单系统执行的 sql:
update userAmount set amount = amount - 'value' ,paystatus = 'paid' where orderId= 'orderid' and paystatus = 'unpay';
token 机制实现防重
该方案需要两次请求才能完成一次业务操作。
- 第一次请求获取
token
- 第二次请求带着这个
token
,完成业务操作。
具体流程图如下:
第一步,先获取token。
第二步,做具体业务操作。
token 的特点:
- 需要申请
- 一次有效
- 可以用来限流(多次相同请求不执行)
注意:redis要用删除操作来判断token,删除成功代表token校验通过,如果用 select+delete 来校验token,存在并发问题,不建议使用。
其他方案实现防重
1、insert前先select
该方案不适用于并发场景,在并发场景中,要配合其他方案一起使用,否则同样会产生重复数据。
2、加唯一索引
绝大数情况下,为了防止重复数据的产生,我们都会在表中加唯一索引,这是一个非常简单,并且有效的方案。
alter table `order` add UNIQUE KEY `un_code` (`code`);
加了唯一索引之后,第一次请求数据可以插入成功。但后面的相同请求,插入数据时会报Duplicate entry '002' for key 'order.un_code
异常,表示唯一索引有冲突。
虽说抛异常对数据来说没有影响,不会造成错误数据。但是为了保证接口幂等性,我们需要对该异常进行捕获,然后返回成功。
如果是java
程序需要捕获:DuplicateKeyException
异常,如果使用了spring
框架还需要捕获:MySQLIntegrityConstraintViolationException
异常。
3、建防重表
有时候表中并非所有的场景都不允许产生重复的数据,只有某些特定场景才不允许。这时候,直接在表中加唯一索引,显然是不太合适的。
针对这种情况,我们可以通过建防重表
来解决问题。
该表可以只包含两个字段:id
和 唯一索引
,唯一索引可以是多个字段比如:name、code等组合起来的唯一标识,例如:susan_0001。
4、加分布式锁
其实前面介绍过的加唯一索引
或者加防重表
,本质是使用了数据库
的分布式锁
,也属于分布式锁的一种。但由于数据库分布式锁
的性能不太好,我们可以改用:redis
或zookeeper
。
鉴于现在很多公司分布式配置中心改用apollo
或nacos
,已经很少用zookeeper
了,我们以redis
为例介绍分布式锁。
目前主要有三种方式实现redis的分布式锁:
- setNx命令
- set命令
- Redission框架
本部分参考
十三、代码示例
/**
* 注:这里面的实体全部用的是 User 对象,正式使用的时候需要有相应的 pojo
*
* http://websystique.com/springmvc/spring-mvc-4-restful-web-services-crud-example-resttemplate/
*
* http://www.ruanyifeng.com/blog/2014/05/restful_api.html
*
* @author yujx
* @date 2021/03/29 10:18
*/
@RestController
public class UserController {
private static final Logger log = LoggerFactory.getLogger(UserController.class);
private UserService userService;
// 一般来说,数据库中的表都是同种记录的"集合"(collection),所以**API中的名词也应该使用复数**。
@GetMapping("/v1/users")
public ResponseEntity<List<User>> getUserList() {
List<User> userList = userService.listUser();
if (userList.isEmpty()) {
// 204 No Content:服务器成功处理了请求,但没返回任何内容。
return new ResponseEntity<>(HttpStatus.NO_CONTENT);
}
return new ResponseEntity<>(userList, HttpStatus.OK);
}
@GetMapping("/v1/users/{id}")
public ResponseEntity<User> getUserById(@PathVariable Long id) {
User user = userService.getUserById(id);
if (user == null) {
log.warn("User with id 「{}」 not found", id);
// 404 Not Found:因为客户端输入了一个错误的 id,无法定位资源
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
}
return new ResponseEntity<>(user, HttpStatus.OK);
}
@PostMapping("/v1/users")
public ResponseEntity<User> createUser(@Valid @RequestBody User user,
BindingResult bindingResult,
UriComponentsBuilder ucBuilder) throws MethodArgumentNotValidException {
// 其实我觉得这种错误应该使用 409
// 409 Conflict 表示请求与服务器端**目标资源**的当前状态相冲突。 冲突最有可能发生在对 PUT 请求的响应中。
// 例如,当上传文件的版本比服务器上已存在的要旧,从而导致版本冲突的时候,那么就有可能收到状态码为409 的响应。
// * 因为我觉得当前场景和"目标资源"的这个定义有冲突,所以还是用 422 吧
User theUser = userService.getUserByName(user.getName());
if (theUser != null) {
bindingResult.rejectValue("name", "already.exists", "A User with name " + user.getName() + " already exist");
}
// 如果验证有异常,则对外抛出 MethodArgumentNotValidException,会由 ResponseEntityExceptionHandler catch 住,
// 然后包装成 400 状态码,INVALID REQUEST - [POST/PUT/PATCH]:用户发出的请求有错误,服务器没有进行新建或修改数据的操作,该操作是幂等的。
// 但其实我觉得应该用 422 状态码,Unprocessable Entity 请求格式正确,但是由于含有语义错误,无法响应。-[POST/PUT/PATCH] 当创建一个对象时,发生一个验证错误。
if (bindingResult.hasErrors()) {
throw new MethodArgumentNotValidException(
// https://stackoverflow.com/questions/442747/getting-the-name-of-the-currently-executing-method
new MethodParameter(new Object() {
}.getClass().getEnclosingMethod(), 0), bindingResult);
}
// 新增之后要返回资源的访问路径,添加到 location 中,并响应 201 状态码
// 201状态码英文名称是Created,该状态码表示已创建。成功请求并创建了新的资源,该请求已经被实现,而且有一个新的资源已经依据请求的需要而建立,
// 且其 URI 已经随Location 头信息返回。假如需要的资源无法及时建立的话,应当返回 '202 Accepted' - [*]:表示一个请求已经进入后台排队(异步任务)
user = userService.saveUser(user);
HttpHeaders headers = new HttpHeaders();
headers.setLocation(ucBuilder.path("/v1/user/{id}").buildAndExpand(user.getId()).toUri());
// 理论上不应该返回这个 user 对象的,但还是返回吧
return new ResponseEntity<>(user, headers, HttpStatus.CREATED);
}
@PutMapping("/v1/users/{id}")
public ResponseEntity<User> updateUser(@PathVariable Long id, @RequestBody User user) {
// 如果两个 id 不同,则响应 400 状态码
if (!id.equals(user.getId())) {
return new ResponseEntity<>(HttpStatus.BAD_REQUEST);
}
User theUser = userService.getUserById(id);
if (theUser == null) {
// 404 Not Found:因为客户端输入了一个错误的 id,无法定位资源
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
}
// 因为 put 请求是全量更新,所以直接调用 save 方法,如果要部分更新则使用 patch 请求
// 正确使用 Patch 请求方式:https://williamdurand.fr/2014/02/14/please-do-not-patch-like-an-idiot/
user = userService.saveUser(user);
return new ResponseEntity<>(user, HttpStatus.OK);
}
@DeleteMapping("/v1/users/{id}")
public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
User theUser = userService.getUserById(id);
if (theUser == null) {
// 404 Not Found:因为客户端输入了一个错误的 id,无法定位资源
return new ResponseEntity<>(HttpStatus.NOT_FOUND);
}
userService.deleteUserById(id);
// 204 NO CONTENT - [DELETE]:用户删除数据成功。
return new ResponseEntity<>(HttpStatus.NO_CONTENT);
}
@DeleteMapping("/v1/users")
public ResponseEntity<Void> deleteAllUsers() {
userService.deleteAllUsers();
// 204 NO CONTENT - [DELETE]:用户删除数据成功。
return new ResponseEntity<>(HttpStatus.NO_CONTENT);
}
/*
获取指定用户的家庭成员 GET /v1/users/{uid}/familyMembers
获取指定用户的指定家庭成员 GET /v1/users/{uid}/familyMembers/{fid}
获取某个审查任务的审查要点信息 /v1/reviewTasks/{rid}/reviewPoints/{pid}
*/
}
参考文章
**Spring MVC 4 RESTFul Web Services CRUD Example+RestTemplate
**RESTful API 设计指南
HTTP状态码:204 No Content
*我所认为的RESTful API最佳实践
基于游标的分页接口实现
*分页加载实现方案
Please. Don't Patch Like That.
Redis SCAN 命令
*HTTP Status Codes For Invalid Data: 400 vs. 422
*高并发下如何保证接口的幂等性?
**接口的幂等性原则