计算机行业有句名言 —— 计算机科学领域的任何问题,都可以通过增加一个间接的中间层来解决。
当前的计算机领域,无论广度还是深度,已经没有一个人能完全掌握了。但是,通过各种中间层的组合使用,我们不需要了解其内部细节,也可以像搭积木一样,开发出各种有趣的服务和应用。
而各个中间层之所以能组合工作,正是因为大家都通过定义好的 API 交互和通信。每个模块在对外提供经过抽象 API 的同时,也需要使用其他模块的 API 作为自身运行的基础。
今天我们来聊聊融云在设计 API 过程保障稳定性的一些实践。
无处不在的 API
API(Application Programming Interface) 又称为应用编程接口。
而接口,本质可以理解为契约,一种约定。
计算机接口的概念起源于硬件。早期各家研发的各种元器件都不通用也没有标准,相互使用非常困难,于是大家约定了功能和规格,就产生了接口,后来蔓延到软件中。
接口蔓延到软件之后,又分为 ABI(Application Binary Interface) 和 API(Application Programming Interface) 。
前者主要约定了二进制的运行和访问的规则,后者则 专注于逻辑模块的交互。本文以下内容仅讨论开发者经常接触的 API。
很多人对 API 的印象只是包含一些函数的 Class 或 头文件。但 API 在我们生活中无处不在,只是我们有时并没有注意到。
比如,当我们在拨打电话时,手机和基站通信的整个系统是非常复杂的。
好在我们不需要了解内部的细节,仅需要把 11 位的电话号码传给“电话系统”的接口就可以,而隐藏的国家区号(如+86)可以理解为接口的默认参数。
这个高度抽象的 API 背后,隐藏了非常多的细节。借助上面的中间层理论,我们可以系统性地讨论设计一个 API 所需要考虑哪些内容。
模块对上层暴露的 API 如何被使用?
API 从使用的耦合方式上,可以分为两类:一种是通过协议调用,如调用 HTTP 接口;另一种是语言直接通过声明调用。
如设计 HTTP Restful API 时,并不需要关心使用者的操作系统、使用的编程语言、内存线程管理等,因此会比后者简单一些。
API 从使用者的规模和可控范围上,可以分为 LSUD(Larget Set of Unkown Developers) 和 SSKD(Small Set of Kown Developers) 两种。
前者一般都是公网开放的云服务,任何开发者都可以使用,无法提前预知以何种姿势被使用,版本也不可控制。融云提供的通信云就是这种 API。
后者用户群有限,一般都在同一家公司或团队内。比如前段时间比较火的组件化,即对内提供的模块化 API,使用范围和方式均可控,在更新时一般不用太纠结向后兼容。
API 的第一受众是人,然后才是机器,所以“可理解性”在设计时需要优先考虑。
而良好的 API 文档、简单扼要的 Demo、关键的 log,可以提升 API 使用者的体验。
API 所属模块对下层有什么依赖?
API 所属模块都运行在一定的地址空间中。而其中的环境变量、加载库、内存和线程模型、系统和语言特性都需要考虑。
API 所属模块的内部实现对其他层有什么影响?
一般而言,设计良好的 API 在使用时,并不需要理解其内部实现。但如果能了解其内部架构并辅助关键 log,有助于提升使用 API 的效率。
并且模块的内部实现,有时也会影响到 API 设计的风格。
如一个强依赖 IO 的接口,可能需要使用异步的方式。大量异步的方式,就衍生出了 RxJava 等框架。
向后兼容
因为 API 如此重要,涉及的范围又如此广泛,广大开发者对 API 的向后兼容可以说要求非常高。
毕竟谁也不想在开发过程中,频繁的更新接口和代码,想想《 swift 从入门到精通到再次入门到再再次入门》的惨案就心有余悸。
我们不仅问,为什么很多公司或者项目都无法向后兼容,仅仅是投入不够或不够重视,还是说 100% 的向后兼容实际就是不可能的?
假设设计是理想和经过论证的,正如一个完美的圆圈。
设计是要落实到编码中的,而编码的过程中总是不可避免的引入一些 bug,而带着 bug 的某个版本实现,其实正如一个 Amoeba 变形虫,形态是不固定的。而随着版本不断演进,不可避免会产生一定的差异。
第一个版本实现:
第二个版本实现:
所以说 100% 向后兼容本身就是不可能的。
因此,大家平时在谈论 API 稳定性时,其实默认是可以包含一定程度变更的。
但由于 API 涉及的范围太广泛,保障向后兼容都需要极大代价。
比如 Linux 就希望快速迭代,完全不保证 API 的稳定性。针对这个问题,Linux 还特意写了 stable-api-nonsense 文档。
有兴趣的可以点击阅读:stable-api-nonsense.rst
渐进式改进
所以说,保障 API 的稳定性会面临很多挑战,比如:
- 业务形态还不稳定,还在高速发展
- 业务和 API 历史包袱较重
- 多个平台和语言的特性不一致
- 用户群和使用方式不明确
我们回顾一下正常的开发流程,看看是否能通过一些指标和工具,改善 API 的稳定性,主要涉及:需求、设计、编码、Review、测试、发布、反馈等步骤。
※需求
普通的产品开发,在启动的时候,用户需求都比较明确,但对于 LSUD 的云服务而言,无法提前预知用户群都有哪些,以及用户在他的产品中如何使用 API。
这容易造成,没有明确的用户需求,API 就不好进行设计和迭代,没有设计就没有用户,需求更无从谈起。这是一个鸡生蛋、蛋生鸡的问题。
建议可以在 API 发布之前,内部先针对典型的使用场景,设计几个完整的 Demo,验证 API 的设计和使用是否合理。
需要注意的是,Demo 需要有完整应用场景,达到上架地步,如果能内部使用, Eating your own dog food 最好,过于简单的 Demo 无法提前暴露 API 的使用问题。
Demo 的开发人员最好与 API 的设计者有所区分,避免思维固化,更多内容大家可以参照 Rust 语言开发在自举过程中的一些实践。
※设计
在设计 API 的时候,有很多需要注意的点和普通开发不太一样。
普通开发,快速实现功能始终被放在第一位。比如大家会用一些敏捷开发的方式,优先实现功能再快速迭代等。
但 API 设计时,接口无法频繁变更,所以首先需要考虑的是“少”,少即是多。
l 每个 API 做的事情要少
一个接口只做一件事,把这个事情做好就足够了。
需要避免为了讨好某个场景,在一个 API 上进行复杂的组合逻辑,提供一个类似语法糖的接口。否则,场景的业务自身在演进时,很难保证 API 的行为不变。
如果需要支持多种业务,可以考虑将 API 分层,比如融云客户端的 API 会分为下面几层。
举个例子,融云考虑通用性,基于订阅分发的模型,抽象了 RTCLib,客户端能处理媒体的任意流,非常的灵活,但是对于用户而言开发代价可能高些,要思考和做的工作比较多。
考虑到大量的用户,其实需要的是音视频通话的业务,基于 RTCLib,融云分装了不带 UI 的 CallLib 以及集成了 UI 的 CallKit。
如果一个用户,需求和微信的音视频通话类似,可以集成带 UI 界面的 CallKit,开发效率会非常高;
如果用户对通话音视频通话 UI 的交互有大量需求,可以基于 CallLib 进行开发,对 UI 可以进行各种定制。
l 暴露的信息要少
成熟的 API 设计者都会尽可能的隐藏内部实现细节。
比如字段不应该直接暴露而是通过 Getter/Setter 提供,不需要的类、方法、字段都应该隐藏,都已经成为各个语言的基础要求,在此就不细述了。
但容易被忽略的一点需要提醒大家,应尽量隐藏技术栈的信息。
比如:API http://api.example.com/cgi-bi...,就明显混入了很多无用的信息,并且以后技术切换升级想维持 API 稳定非常麻烦。
l 行为扩散要少
在语言直接调用的 API 中,需要避免基础接口通过继承导致行为扩散。
在普通的编码过程中,抽象类和继承都是面向对象的强大武器。但是对于 API,更建议通过组合使用。
比如一个管理生命周期的类,如果被继承,子类有些行为就有可能被修改而导致出错。这时候建议使用 Interface + 工厂的方法提供实例。
由于 Java 8 之前 interface 没有 default 实现,为了避免增加功能需要频繁修改接口,可以使用 final class。
Objetive-C 则可以使用 __attribute__((objc_subclassing_restricted)) 和 __attribute__((objc_requires_super) 控制子类继承行为。
l 画风切换要少
API 命名要做到多个平台的业务命名统一,与每个平台的风格统一。
这点 HTTP 的接口要简单一些,只需要选定一种风格即可,Restful 或者 GraphQL 或者自己定义。
语言调用的 API 命名,建议首先遵循平台的风格,然后再是参考语言标准,最后才考虑团队的风格。
比如:iOS 平台的 API 开发,需要首先参照 iOS 的命名风格,did 和 will 之类的时态就非常有特色。
命名上细节较多,词汇、时态、单复数、介词、⼤小写、同步异步风格等都需要考量,需要长时间的积累。
l 理解成本要少
一般 API 每个接口都会有相应的注释说明,但是值得注意的是,大部分开发者并不看注释。
大部分开发者对接口的了解,都仅源于 IDE 的补全和提醒。一个接口看着像就直接用,不行再换一个试试,这其实是一种经验式编程的方式。
也就意味着接口命名需要提高可理解性。有一个办法可以验证,将接口的所有注释抹掉,使用者能否非常直接的看懂每个接口的含义。如果很困难,则需要改进。
API 设计还有一处和普通开发不太一致。普通开发设计好架构即可,每个模块的开发可能是同一个人,接口并不需要在设计时确定下来。
但是 API 的设计阶段,需要进行 Review 并直接确定接口的设计,以保证多端在开发时遵循完全一直的规则。
※编码
在 API 的编码过程中,有以下几点需要注意。
在 API 中,预定义好版本号。
这个主要是针对 HTTP API,如:http://api.example.com/v1/use...。 如果目前仅有一个版本,也可以暂时不加,第二版时再区分。
注意 API 版本检查。
当分层提供多种 API 时,每层 API 需要在启动时,先校验一下版本号,避免不匹配的情况。
比如在以下 Java 代码中,大家可能觉得判断版本号相等的代码非常奇怪,应该永远是 true 才对。
但是抽象类和实现类出现在不同的分层模块中,并且实现类先编译,抽象类版本更新后再编译,就会出现不一致的情况。有很多语言或平台能提供类似的方式来确定版本。
提供规范性的 log 输出。
普通开发的log,主要用于自己定位问题。但是 API 在编码时,最好针对性的添加一些 log,有利于 API 的使用者理解并简单排查问题。
但出于性能考虑,需要定义好 log 的级别并可以调整。
注意废弃与迁移。
当一个以前设计的 API 不再符合要求或者有重大问题时,我们可以对外标记成已废弃,并在注释中建议使用者迁移到另一个接口。
如果是类似的被废弃接口,内部编码时最好能使用新的接口来实现,以降低向后兼容的维护成本。
HTTP 的 API,需要预定义好迁移的错误码,比如在 HTTP 规范中,可以使用 410 Gone 说明已经不再支持某个接口。
※Review
API 的 Review 基于普通开发的 Code Review。
如果基础的 Code Review 都没有做好,肯定无法保障 API 的质量和稳定性。
可以通过一些工具,为 API 的 Review 提供一些参考报告。
比如可以使用 SonarLint 分析代码复杂度,如果接口层的代码复杂度较高,会是一个危险的信号。
还可以借助 Java 反射、Clang 语法分析,获取当前的 API 接口列表,生成接口变更报告,也有利于减少无用接口的暴露。
另外,自动化工具生成的接口文档也是 Review 重要的一环。
※测试
在测试环节,我们可以通过 unit test 来关注 API 的稳定性。
与敏捷开发经常修改 test case 不同,API 的 test case 基本代表了接口的稳定性。所以在修改旧 case 时需要特别明确,是 case 自身的 bug 还是接口行为发生了变更。
※发布
我们可以通过区分 dev 和 stable 版本,为不同阶段的开发者提供更好的体验。
dev 版本包含最新的功能,但是 API 接口有变更风险。stable 版本 API 稳定,但功能不一定是最新的。
如果开发者还在开发过程中,可以选用最新的 dev 版本,基于最新 API 开发。
如果应用已经上线,可以选择升级直接到最新的 stable 版本。
※反馈
由于前面提到的,云服务的 API 比较难确定用户群和用户的使用方式。
可以参考 APM(Application Performance Management) 的方式,记录热点 API 使用情况,为后续的优化提供数据。
总结
上面的改进,让保障 API 的稳定性变得更容易。
下面以融云 IMLib iOS SDK 2.0 版本演进为例,历尽 2015至 2019 四年时间,从 2.2.5 到 2.9.16 共 98 个版本。
API 接口数量翻了一番,考虑到接口更内聚,功能大约增加了 3 倍。
但是需要用户迁移的接口非常少,即使迁移时开发成本都非常低。
更多干货内容请点击注册查看!