如何保障 API 设计的稳定性

  1. 云栖社区>
  2. 博客>
  3. 正文

如何保障 API 设计的稳定性

游客4rrl65islkdqk 2019-08-06 10:52:21 浏览188
展开阅读全文

计算机行业有句名言 —— 计算机科学领域的任何问题,都可以通过增加一个间接的中间层来解决。

当前的计算机领域,无论广度还是深度,已经没有一个人能完全掌握了。但是,通过各种中间层的组合使用,我们不需要了解其内部细节,也可以像搭积木一样,开发出各种有趣的服务和应用。

而各个中间层之所以能组合工作,正是因为大家都通过定义好的 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 所需要考虑哪些内容。

  1. 模块对上层暴露的 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 使用者的体验。

  1. API 所属模块对下层有什么依赖?

API 所属模块都运行在一定的地址空间中。而其中的环境变量、加载库、内存和线程模型、系统和语言特性都需要考虑。

  1. API 所属模块的内部实现对其他层有什么影响?
    一般而言,设计良好的 API 在使用时,并不需要理解其内部实现。但如果能了解其内部架构并辅助关键 log,有助于提升使用 API 的效率。

并且模块的内部实现,有时也会影响到 API 设计的风格。

如一个强依赖 IO 的接口,可能需要使用异步的方式。大量异步的方式,就衍生出了 RxJava 等框架。

| 向后兼容

因为 API 如此重要,涉及的范围又如此广泛,广大开发者对 API 的向后兼容可以说要求非常高。

毕竟谁也不想在开发过程中,频繁的更新接口和代码,想想《 swift 从入门到精通到再次入门到再再次入门》的惨案就心有余悸。

我们不仅问,为什么很多公司或者项目都无法向后兼容,仅仅是投入不够或不够重视,还是说 100% 的向后兼容实际就是不可能的?

假设设计是理想和经过论证的,正如一个完美的圆圈。

设计是要落实到编码中的,而编码的过程中总是不可避免的引入一些 bug,而带着 bug 的某个版本实现,其实正如一个 Amoeba 变形虫,形态是不固定的。而随着版本不断演进,不可避免会产生一定的差异。

第一个版本实现:

2

第二个版本实现:

3

所以说 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 会分为下面几层。

4

举个例子,融云考虑通用性,基于订阅分发的模型,抽象了 RTCLib,客户端能处理媒体的任意流,非常的灵活,但是对于用户而言开发代价可能高些,要思考和做的工作比较多。

考虑到大量的用户,其实需要的是音视频通话的业务,基于 RTCLib,融云分装了不带 UI 的 CallLib 以及集成了 UI 的 CallKit。

如果一个用户,需求和微信的音视频通话类似,可以集成带 UI 界面的 CallKit,开发效率会非常高;

如果用户对通话音视频通话 UI 的交互有大量需求,可以基于 CallLib 进行开发,对 UI 可以进行各种定制。

l 暴露的信息要少

成熟的 API 设计者都会尽可能的隐藏内部实现细节。

比如字段不应该直接暴露而是通过 Getter/Setter 提供,不需要的类、方法、字段都应该隐藏,都已经成为各个语言的基础要求,在此就不细述了。

但容易被忽略的一点需要提醒大家,应尽量隐藏技术栈的信息。

比如:API http://api.example.com/cgi-bin/

get_user.php?user=100,就明显混入了很多无用的信息,并且以后技术切换升级想维持 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 的编码过程中,有以下几点需要注意。

1.在 API 中,预定义好版本号。

这个主要是针对 HTTP API,如:

http://api.example.com/v1/users/12345?fields=name,age

如果目前仅有一个版本,也可以暂时不加,第二版时再区分。

2.注意 API 版本检查。

当分层提供多种 API 时,每层 API 需要在启动时,先校验一下版本号,避免不匹配的情况。

比如在以下 Java 代码中,大家可能觉得判断版本号相等的代码非常奇怪,应该永远是 true 才对。

但是抽象类和实现类出现在不同的分层模块中,并且实现类先编译,抽象类版本更新后再编译,就会出现不一致的情况。有很多语言或平台能提供类似的方式来确定版本。

3.提供规范性的 log 输出。

普通开发的log,主要用于自己定位问题。但是 API 在编码时,最好针对性的添加一些 log,有利于 API 的使用者理解并简单排查问题。但出于性能考虑,需要定义好 log 的级别并可以调整。

4.注意废弃与迁移。

当一个以前设计的 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 倍。

但是需要用户迁移的接口非常少,即使迁移时开发成本都非常低。

_1

网友评论

登录后评论
0/500
评论
游客4rrl65islkdqk
+ 关注