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