程序员社区

Restful API 设计学习


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这种操作就不是幂等的,就需要我们来对这个接口进行防重设计。
新增 增加在重复提交的场景下会出现幂等性问题

像这种非幂等性的接口,我们一般都需要对其进行防重设计

注:防重设计幂等设计,其实是有区别的。防重设计主要为了避免产生重复数据,对接口返回没有太多要求。而幂等设计除了避免产生重复数据之外,还要求每次请求都返回一样的结果。

这里只介绍两种常见的防重设计:

  1. 通过代码逻辑实现接口的幂等性,但只能针对一些满足判断的逻辑实现,具有一定的局限性。
  2. 使用 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 机制实现防重

该方案需要两次请求才能完成一次业务操作。

  1. 第一次请求获取token
  2. 第二次请求带着这个token,完成业务操作。

具体流程图如下:

第一步,先获取token。

Restful API 设计学习插图
获取token

第二步,做具体业务操作。

Restful API 设计学习插图1
具体业务操作

token 的特点:

  1. 需要申请
  2. 一次有效
  3. 可以用来限流(多次相同请求不执行)

注意:redis要用删除操作来判断token,删除成功代表token校验通过,如果用 select+delete 来校验token,存在并发问题,不建议使用。

其他方案实现防重

1、insert前先select

Restful API 设计学习插图2

该方案不适用于并发场景,在并发场景中,要配合其他方案一起使用,否则同样会产生重复数据。

2、加唯一索引

绝大数情况下,为了防止重复数据的产生,我们都会在表中加唯一索引,这是一个非常简单,并且有效的方案。

alter table `order` add UNIQUE KEY `un_code` (`code`);

加了唯一索引之后,第一次请求数据可以插入成功。但后面的相同请求,插入数据时会报Duplicate entry '002' for key 'order.un_code异常,表示唯一索引有冲突。

虽说抛异常对数据来说没有影响,不会造成错误数据。但是为了保证接口幂等性,我们需要对该异常进行捕获,然后返回成功。

如果是java程序需要捕获:DuplicateKeyException异常,如果使用了spring框架还需要捕获:MySQLIntegrityConstraintViolationException异常。

Restful API 设计学习插图3

3、建防重表

有时候表中并非所有的场景都不允许产生重复的数据,只有某些特定场景才不允许。这时候,直接在表中加唯一索引,显然是不太合适的。

针对这种情况,我们可以通过建防重表来解决问题。

该表可以只包含两个字段:id唯一索引,唯一索引可以是多个字段比如:name、code等组合起来的唯一标识,例如:susan_0001。

Restful API 设计学习插图4

4、加分布式锁

其实前面介绍过的加唯一索引或者加防重表,本质是使用了数据库分布式锁,也属于分布式锁的一种。但由于数据库分布式锁的性能不太好,我们可以改用:rediszookeeper

鉴于现在很多公司分布式配置中心改用apollonacos,已经很少用zookeeper了,我们以redis为例介绍分布式锁。

目前主要有三种方式实现redis的分布式锁:

  1. setNx命令
  2. set命令
  3. Redission框架
Restful API 设计学习插图5

本部分参考

十三、代码示例

/**
 * 注:这里面的实体全部用的是 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

*高并发下如何保证接口的幂等性?

**接口的幂等性原则

赞(0) 打赏
未经允许不得转载:IDEA激活码 » Restful API 设计学习

一个分享Java & Python知识的社区