无论是架构设计还是编码实现,现在都越来越离不开接口设计,接口可以说是新时代的“集装箱”,是得到了几乎所有人一致共识的通用标准。
GoF 在很多年前便建议大家应该针对接口编程,原因其实就是为了降低编程变化而导致风险出现的概率。
不过现实中,你可能遇见的更多是这样的情况:
-
定义好的 API 接口,却接收了额外的参数,导致程序异常退出;
-
查询返回的数据,没有回传预期的格式,数据处理出错;
-
一个 API 服务进行分布式部署,遗漏了某个节点,导致处理数据不一致。
现代软件开发虽然一直在按照约定进行编程,但是程序员群体却是喜欢违反契约的群体之一,因为契约代表了约束,而编程本身是自由灵活的事情。那如何才能平衡这种“约束”与“自由”之间的关系?如何才能更好地避免再发生上述情况?以及如何才能真正做到 API 设计呢?下面,我们就一起来看看做好API设计的方法和技巧。
一、契约式设计原则:API 设计的指导书
契约式设计原则(Design by Contract,缩写为 DbC,为表述统一,下文我们就简称为“契约原则”),是一种软件设计方法。其原理是:在软件设计时应该为软件组件定义一种精确和可验证的接口规范,这种规范要包括使用的预置条件、后置条件和不变条件,用来扩展普通抽象数据类型的定义。比如,在 Web 请求中,预置条件主要用于处理输入的参数校验,不变条件主要指一个请求中的共享数据状态,后置条件则是对返回的响应数据的检查与确认。如下示意图:
契约原则的核心思想是对软件系统中元素之间的相互合作以及“责任”与“义务”的比喻,这种比喻是从商业活动中“客户”与“供应商”达成“契约”而得来。
在我看来,理解三者之间的关系非常重要,因为这是帮助我们合理利用契约原则进行 API 设计的基础。
现在 API 设计已经变得越来越重要,但是不管怎么变,API 的设计实现依然需要回答三个关键问题:
-
API 期望的是什么?
-
API 要保证的是什么?
-
API 要保持不变的是什么?
能否设计好一个 API,其实只需要回答好上面这三个问题就行了,而这三个问题本质上就是契约原则的一种最佳实践。
整理了一份好像面试笔记包括了:Java面试、Spring、JVM、MyBatis、Redis、MySQL、并发编程、微服务、Linux、Springboot、SpringCloud、MQ、Kafka 面试专题
需要全套面试笔记【点击此处即可】免费获取
首先,API 必须要保证输入是接收者期望的输入条件。这里的重点就在于“期望”两个字,换句话说,就是:使用者使用 API 的前提条件是什么?只有满足了这个前提条件,API 才能提供正常的功能。比如,API 需要 A、B、C 三个参数,那么使用者就需要提前准备好这三个参数。好的 API 接口都会提前告知使用者,它需要什么、不需要什么,当条件不满足时,它会拒绝;当条件满足时,才会开始处理。
其次,API 必须要保证输出结果的正确性。API 在处理过程中可能会遇见各种异常或错误的情况,这时 API 就不应该把错误或异常抛给其他系统去处理,而是在内部就得做好异常处理,并最终输出正确结果给使用者。
最后,API 必须要保持处理过程中的一致性。比如,同一个 API 被部署在 10 个服务器上,那么只要外部输入了正确条件后,每一个 API 内部的处理过程就应该是相同的、不变的,也就是说,当修改源代码后,你应该要同时部署这 10 个服务,才能让 API 整体的服务看起来是一个整体。不变条件通常有会话信息、共享的上下文数据状态等。所谓不变,你可以理解为多个相同的副本对同一个代码含义的统一解释。
为什么一定使用契约原则来指导 API 的编程和设计呢?其实主要有以下三个原因。
提前告知 API 有哪些约束会让你在编码过程中节省很多不必要的沟通成本。比如,你的 API 决定使用 RESTful 风格,那么当对方在使用你的 API 时,就会知道返回的数据格式需要采用 JSON。
契约会强迫你去思考 API 是否满足更好的独立性。 API 更多时候是提供给不同类型客户端去使用的,而客户端是不需要关注你的 API 的内部实现,所以好的 API 一定是具备更好的独立性才能对外提供服务。如果 API 不使用标准的协议和消息格式,那么就意味着客户端在使用时需要适配你的 API,这样就会造成系统和 API 之间的强耦合。
契约式设计会提醒你应该保证 API 的高可用性。契约就像是一种承诺,当你在提供 API 时,不仅要保证输入满足要求,还要保证经过处理后返回结果的正确性。同时,服务还应该是稳定可用的,而不能因为网络故障、流量过大等就无法使用服务。
二、如何做好API接口设计?
1、 让接口职责分离
当你在设计 API 的时候,应该尽量让每一个 API 只做一个职责的事情。这样做的好处在于能保证 API 功能的简单易用性和稳定性,一旦接口职责混在一起,就会造成接口间功能相互影响。
假设一个 API 既用于修改商品的价格,又用于修改订单优惠价格,还用于修改库存数量等,那么当调用修改商品价格的接口流量增大时,势必会影响同一个接口中修改订单或修改库存的功能。
在使用接口隔离原则时要注意尽量找到变化相似的职责来进行封装,不要在一个接口中加很多方法,方法越多越有可能变成多个职责,那么就无法实现真正的接口隔离。
2、 API 命名很重要
好的 API 设计应该给使用者提供一种简单、直观、一致的体验。其中,最关键的就是对 API 的命名,常用的命名技巧有以下三种。
-
使用英文的小写。例如,使用美式英语的 license、color,并且尽量使用小写,因为大写稍不注意就会看错。
-
使用被大家都接受的通用缩写。比如,API 就比 Application Programming Interface 好。
-
通过命名含义描述接口。换句话说,API 的名字应该能自解释,比如 list-userinfo,结合其英文字面含义就知道是查询用户信息的列表。
当然,现在也有很多自动生成接口说明的工具,比如 Swagger,但是对于开发者来说,依然不如直接阅读命名来得方便,并且像 Swagger 这类的工具一旦暴露到公网还会引发安全问题。
3、 尽量少创造自定义错误码
在 API 设计中,很多人会经常犯一个错误,那就是:想方设法地创建自己的 error code,用以表达自己对不同错误的个性化理解。
但实际上,在 API 设计中,这是一种错误应用惯例原则的实践。比如,当使用方在使用 HTTP API 时,获得了一个 404 错误码(在 HTTP 中是未找到资源的错误),而你恰好也定义了 404 错误码(假设代表接口数据未找到),结果必然出现冲突。
你可能会说自定义的 error code 有助于传递信息,但这有一个前提条件:必须有系统且能对自定义的 error code 进行处理,这样才有意义。如果只是传递信息的话,error message 里面的字段也可以达到同样的效果。
4、 同一接口要做到幂等
什么是幂等?简单来说,就是当一个操作多次执行所产生的影响均与一次执行的影响相同,则它是幂等的。
幂等是一种通用策略。实际上,HTTP 规范中就明确指出 GET、PUT 和 DELETE 方法必须是幂等的。但 POST 方法不一定保证是幂等的,如果 POST 方法创建新资源,通常不能保证此操作是幂等的。
为什么要实现幂等?这是因为现代服务越来越多地往分布式服务方向发展,而分布式服务的特性就在于服务的分散性,分散性的服务会带来一个问题:多个服务在同一时间可能会并行修改同一份数据。比如,你在楼下超市买完一瓶水后进行微信支付,第一次支付时网络延迟,于是你又刷了一次,那么钱应该只减一份,如果扣两次那就是没有实现幂等。
那么如何在 API 设计中做到接口幂等呢?通常有以下五种方法。
-
使用天然幂等的操作。比如,数据库中的 select 查询,只要数据没发生改变,那么查询一次和多次的结果始终是一样的;还有 delete 删除操作,删除一次和多次删除都是把数据删除了(不存在了),影响是一样的,典型的天然幂等操作。
-
使用唯一键值。比如,在数据库中加唯一索引,或是使用 UUID 做唯一 ID,都可以防止在新增数据时出现不必要的脏数据,因为不同的服务节点操作数据时都收到了唯一 ID 的约束。
-
使用加锁策略。比如,悲观锁、乐观锁、分布式锁等。加锁的目的就是让不同的服务在同一个数据变更时不被其他服务所影响,比如,订单服务 API 部署了 500 个相同实例,那么即便通过 Nginx 网关做了负载均衡的流量分配,在修改订单的时候如果不加锁,就会导致数据被重复多次的修改,也就是无法保证幂等。
-
使用 Source+Token 验证机制。这和使用唯一键值有一点类似,不过这种方法更多用在对外提供的 API 中去保证幂等性。这两个字段实际上既做了联合的唯一索引,又做了使用来源的日志记录,这样既能保证接口的幂等性,也能记录不同客户端使用 API 的调用情况。
-
使用有限状态机。在一些状态变更比较频繁的业务中,会经常使用到状态机,比如,订单、支付、秒杀等业务。当状态机已经处于下一个状态,这时候又来了一个上一个状态的变更,那么这时就不允许再进行状态变更了。正是通过这种状态扭转的约束,保证了接口服务的幂等性。
5、安全策略
在做 API 设计时,对于内部系统和外部系统的 API 所考虑的安全策略会有所不同。
对于内部系统来说,更多的是考虑输入与输出数据的准确性,通常都采用更为基础的安全策略。比如,对接收的数据要有足够的验证,出现错误要能及时抛出异常并进行异常处理。当然,对于一些核心系统,比如,财务、订单、用户数据等,依然还是需要进行安全加固,避免外部被攻破后内部数据的泄漏。
对于外部系统来说,API 面临的主要挑战是来自外部的一些针对安全上的攻击、错误调用、接口滥用等。那么在设计的时候,针对黑客攻击,一般是购买业界一些专门做安全的防护公司的产品;针对错误的调用方式,API 在预置条件判断时就不应该让调用进入处理,一定要及时给出错误信息;对于接口滥用的情况,就需要做一些限流的措施。
6、版本管理
从本质上来讲,API 是服务提供者与服务使用者之间的一种合同协议。一旦服务提供者对 API 进行更改,则有破坏服务使用者使用 API 的风险。因此,你在做 API 设计时一定要考虑更改次数所带来的风险。另外,API 还可能因为不断升级优化而出现旧功能与新功能兼容的问题。
实际上,解决这两个问题的办法就是对 API 进行版本控制和管理。
当需要进行重大的 API 更改时,要提供一个完整的新版本,同时还要继续支持以前的版本。这里有两种简单的办法:一种是在同一服务 API 中公开两个版本,比如在接口中使用 V1 和 V2;另一种是并行运行两个版本的服务,可以通过增加 API 网关来根据路由规则将请求发送到对应版本中。
不过,要注意的是:同时维护两个或多个版本会提升维护成本。因此,在版本管理时,最好提前计划一个旧版本的下线时间。对于内部 API 来说,要及时通知使用方进行升级和迁移。对于外部(公共)API,则只能不断告知或减缓升级的周期来让使用方及时跟上升级的节奏。