人人都是 API 设计者:我对 RESTful API、GraphQL、RPC API 的思考

2021-02-20 00:13发布

点击上方 IT牧场 ,选择 置顶或者星标

技术干货每日送达!>>>技术讨论群<<<

梁桂钊 | 作者

有一段时间没怎么写文章了,今天提笔写一篇自己对 API 设计的思考。首先,为什么写这个话题呢?其一,我阅读了《阿里研究员谷朴:API 设计最佳实践的思考》一文后受益良多,前两天并转载了这篇文章也引发了广大读者的兴趣,我觉得我应该把我自己的思考整理成文与大家一起分享与碰撞。其二,我觉得我针对这个话题,可以半个小时之内搞定,争取在 1 点前关灯睡觉,哈哈。

现在,我们来一起探讨 API 的设计之道。我会抛出几个观点,欢迎探讨。

一、定义好的规范,已经成功了一大半

通常情况下,规范就是大家约定俗成的标准,如果大家都遵守这套标准,那么自然沟通成本大大降低。例如,大家都希望从阿里的规范上面学习,在自己的业务中也定义几个领域模型:VO、BO、DO、DTO。其中,DO(Data Object)与数据库表结构一一对应,通过 DAO 层向上传输数据源对象。而 DTO(Data Transfer Object)是远程调用对象,它是 RPC 服务提供的领域模型。对于 BO(Business Object),它是业务逻辑层封装业务逻辑的对象,一般情况下,它是聚合了多个数据源的复合对象。那么,VO(View Object) 通常是请求处理层传输的对象,它通过 Spring 框架的转换后,往往是一个 JSON 对象。


事实上,阿里这种复杂的业务中如果不划分清楚  DO、BO、DTO、VO 的领域模型,其内部代码很容易就混乱了,内部的 RPC 在 service 层的基础上又增加了 manager 层,从而实现内部的规范统一化。但是,如果只是单独的域又没有太多外部依赖,那么,完全不要设计这么复杂,除非预期到可能会变得庞大和复杂化。对此,设计过程中因地制宜就显得特别重要了。

另外一个规范的例子是 RESTful API。在 REST 架构风格中,每一个 URI 代表一种资源。因此,URI 是每一个资源的地址的唯一资源定位符。所谓资源,实际上就是一个信息实体,它可以是服务器上的一段文本、一个文件、一张图片、一首歌曲,或者是一种服务。RESTful API 规定了通过 GET、 POST、 PUT、 PATCH、 DELETE 等方式对服务端的资源进行操作。

     
     
     
  1. GET /users # 查询用户信息列表

  2. GET /users/1001 # 查看某个用户信息

  3. POST /users # 新建用户信息

  4. PUT /users/1001 # 更新用户信息(全部字段)

  5. PATCH /users/1001 # 更新用户信息(部分字段)

  6. DELETE /users/1001 # 删除用户信息

事实上,RESTful API 的实现分了四个层级。第一层次(Level 0)的 Web API 服务只是使用 HTTP 作为传输方式。第二层次(Level 1)的 Web API 服务引入了资源的概念。每个资源有对应的标识符和表达。第三层次(Level 2)的 Web API 服务使用不同的 HTTP 方法来进行不同的操作,并且使用 HTTP 状态码来表示不同的结果。第四层次(Level 3)的 Web API 服务使用 HATEOAS。在资源的表达中包含了链接信息。客户端可以根据链接来发现可以执行的动作。通常情况下,伪 RESTful API 都是基于第一层次与第二层次设计的。例如,我们的 Web API 中使用各种动词,例如 get_menu 和 save_menu ,而真正意义上的 RESTful API 需要满足第三层级以上。如果我们遵守了这套规范,我们就很可能就设计出通俗易懂的 API。

注意的是,定义好的规范,我们已经成功了一大半。如果这套规范是业内标准,那么我们可以大胆实践,不要担心别人不会用,只要把业界标准丢给他好好学习一下就可以啦。例如,Spring 已经在 Java 的生态中举足轻重,如果一个新人不懂 Spring 就有点说不过去了。但是,很多时候因为业务的限制和公司的技术,我们可能使用基于第一层次与第二层次设计的伪 RESTful API,但是它不一定就是落后的,不好的,只要团队内部形成规范,降低大家的学习成本即可。很多时候,我们试图改变团队的习惯去学习一个新的规范,所带来的收益(投入产出比)甚微,那就得不偿失了。

总结一下,定义好的规范的目的在于,降低学习成本,使得 API 尽可能通俗易懂。当然,设计的 API 通俗易懂还有其他方式,例如我们定义的 API 的名字易于理解,API 的实现尽可能通用等。

二、探讨 API 接口的兼容性

API 接口都是不断演进的。因此,我们需要在一定程度上适应变化。在 RESTful API 中,API 接口应该尽量兼容之前的版本。但是,在实际业务开发场景中,可能随着业务需求的不断迭代,现有的 API 接口无法支持旧版本的适配,此时如果强制升级服务端的 API 接口将导致客户端旧有功能出现故障。实际上,Web 端是部署在服务器,因此它可以很容易为了适配服务端的新的 API 接口进行版本升级,然而像 Android 端、IOS 端、PC 端等其他客户端是运行在用户的机器上,因此当前产品很难做到适配新的服务端的 API 接口,从而出现功能故障,这种情况下,用户必须升级产品到最新的版本才能正常使用。为了解决这个版本不兼容问题,在设计 RESTful API 的一种实用的做法是使用版本号。一般情况下,我们会在 url 中保留版本号,并同时兼容多个版本。

     
     
     
  1. GET /v1/users/{user_id} // 版本 v1 的查询用户列表的 API 接口

  2. GET /v2/users/{user_id} // 版本 v2 的查询用户列表的 API 接口

现在,我们可以不改变版本 v1 的查询用户列表的 API 接口的情况下,新增版本 v2 的查询用户列表的 API 接口以满足新的业务需求,此时,客户端的产品的新功能将请求新的服务端的 API 接口地址。虽然服务端会同时兼容多个版本,但是同时维护太多版本对于服务端而言是个不小的负担,因为服务端要维护多套代码。这种情况下,常见的做法不是维护所有的兼容版本,而是只维护最新的几个兼容版本,例如维护最新的三个兼容版本。在一段时间后,当绝大多数用户升级到较新的版本后,废弃一些使用量较少的服务端的老版本API 接口版本,并要求使用产品的非常旧的版本的用户强制升级。注意的是,“不改变版本 v1 的查询用户列表的 API 接口”主要指的是对于客户端的调用者而言它看起来是没有改变。而实际上,如果业务变化太大,服务端的开发人员需要对旧版本的 API 接口使用适配器模式将请求适配到新的API 接口上。

有趣的是,GraphQL 提供不同的思路。GraphQL 为了解决服务 API 接口爆炸的问题,以及将多个 HTTP 请求聚合成了一个请求,提出只暴露单个服务 API 接口,并且在单个请求中可以进行多个查询。GraphQL 定义了 API 接口,我们可以在前端更加灵活调用,例如,我们可以根据不同的业务选择并加载需要渲染的字段。因此,服务端提供的全量字段,前端可以按需获取。GraphQL 可以通过增加新类型和基于这些类型的新字段添加新功能,而不会造成兼容性问题。

此外,在使用 RPC API 过程中,我们特别需要注意兼容性问题,二方库不能依赖 parent,此外,本地开发可以使用 SNAPSHOT,而线上环境禁止使用,避免发生变更,导致版本不兼容问题。我们需要为每个接口都应定义版本号,保证后续不兼容的情况下可以升级版本。例如,Dubbo 建议第三位版本号通常表示兼容升级,只有不兼容时才需要变更服务版本。

关于规范的案例,我们可以看看 k8s 和 github,其中 k8s 采用了 RESTful API,而 github 部分采用了 GraphQL。

  • https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.10/

  • https://developer.github.com/v4/

三、提供清晰的思维模型

所谓思维模型,我的理解是针对问题域抽象模型,对域模型的功能有统一认知,构建某个问题的现实映射,并划分好模型的边界,而域模型的价值之一就是统一思想,明确边界。假设,大家没有清晰的思维模型,那么也不存在对 API 的统一认知,那么就很可能出现下面图片中的现实问题。

四、以抽象的方式屏蔽业务实现

我认为好的 API 接口具有抽象性,因此需要尽可能的屏蔽业务实现。那么,问题来了,我们怎么理解抽象性?对此,我们可以思考 java.sql.Driver 的设计。这里,java.sql.Driver 是一个规范接口,而 com.mysql.jdbc.Driver
则是 mysql-connector-java-xxx.jar 对这个规范的实现接口。那么,切换成 Oracle 的成本就非常低了。

一般情况下,我们会通过 API 对外提供服务。这里,API 提供服务的接口的逻辑是固定的,换句话说,它具有通用性。但是,但我们遇到具有类似的业务逻辑的场景时,即核心的主干逻辑相同,而细节的实现略有不同,那我们该何去何从?很多时候,我们会选择提供多个 API 接口给不同的业务方使用。事实上,我们可以通过 SPI 扩展点来实现的更加优雅。什么是 SPI?SPI 的英文全称是 Serivce Provider Interface,即服务提供者接口,它是一种动态发现机制,可以在程序执行的过程中去动态的发现某个扩展点的实现类。因此,当 API 被调用时会动态加载并调用 SPI 的特定实现方法。

此时,你是不是联想到了模版方法模式。模板方法模式的核心思想是定义骨架,转移实现,换句话说,它通过定义一个流程的框架,而将一些步骤的具体实现延迟到子类中。事实上,在微服务的落地过程中,这种思想也给我们提供了非常好的理论基础。

现在,我们来看一个案例:电商业务场景中的未发货仅退款。这种情况在电商业务中非常场景,用户下单付款后由于各种原因可能就申请退款了。此时,因为不涉及退货,所以只需要用户申请退款并填写退款原因,然后让卖家审核退款。那么,由于不同平台的退款原因可能不同,我们可以考虑通过 SPI 扩展点来实现。

此外,我们还经常使用工厂方法+策略模式来屏蔽内部的复杂性。例如,我们对外暴露一个 API 接口 getTask(int operation),那么我们就可以通过工厂方法来创建实例,通过策略方法来定义不同的实现。其中,operation 就是具体的指令。

     
     
     
  1. @Component

  2. public class TaskManager {


  3. private static final Logger logger = LoggerFactory.getLogger(TaskManager.class);


  4. private static TaskManager instance;


  5. public Map<Integer, ITask> taskMap

    标签: