没有被了解的 API?一个老码农眼中的 API 世界

即便做了 20 多年的软件开发,仍然发现自己经常会低估完成一个特定的编程任务所需要的时间。有时,错误的时间表是由于自己的能力不足造成的: 当深入研究一个问题时,会发现它比最初想象的要难得多,因此解决这个问题需要更长的时间ーー这就是程序员的生活。

即使自己清楚地知道想要实现什么以及如何实现它,还会经常比预期的要花费更多的时间,这种情况往往因为与 API 的纠缠。

目录

1 无所不在,API 的空间视角
2 良好与糟糕,API 的真面目
3 API 设计的经验性原则
3.1 功能的完整性
3.2 调用的简单性
3.3 设计的场景化
3.4 有无策略性的设置
3.5 面向用户的设计
3.6 推卸责任源于无知
3.7 清晰的文档化
3.8 API 的人体工程学
4 性能约定,API 的时间视角
4.1 API 的性能分类
4.2 按性能规划 API
4.3 API 的性能变化
4.4 API 调用失败时的性能
5 确保 API 性能的经验性方法
5.1 谨慎地选择 API 和程序结构
5.2 在新版本发布时提供一致的性能约定
5.3 防御性编程可以提供帮助
5.4 API 公开的参数调优
5.5 测量性能以验证假设
5.6 使用日志检测和记录异常
6 面对 API,开发者的苦恼
6.1 没有 API
6.2 繁琐的注册
6.3 多收费的 API
6.4 隐藏 API 文档
6.5 糟糕的私有协议
6.6 单一的 API 密钥
6.7 手动维护文档
6.8 忽略运维环境
6.9 非幂等性
7 API 设计中的文化认知
7.1 API 意识的训练
7.2 API 设计人才的流失
7.3 开放与控制
个人认为,现在所普遍使用的 API 与二十年前 C 语言编写的 API 并没有本质的不同。关于 API 的设计,似乎有些难以捉摸的东西。

API(Application Programming Interface,应用编程接口) 是一些预先定义的函数,或软件系统不同组成部分之间的衔接约定。API 提供了基于软件或硬件得以访问一组例程的能力,而无需使用源代码,也无需理解其内部的工作机制。

1 无所不在,API 的空间视角

一看到 API,很多人首先想到的是 Restful API,基于 HTTP 协议的网络编程接口。实际上, API 的概念外延很大,从微观到宏观,会发现 API 在计算机的世界里无处不在。

拿起显微镜,如果 Rest API 面向的是网络通信,可以想把空间限制在单机上。一台主机上的 IPC 同样由各种各样的 API 组成,而一切代码的执行都会归结到系统调用上来,操作系统提供的系统调用同样是 API。走进操作系统,走进函数指针的 API,调整显微镜的镜头,API 可能体现在 Jump 指令上,在深入就会进入电路与系统的领域了。

抬起望远镜,感谢通信与网络技术的发展,一切软件都几乎演变成了分布式系统。API 成为了分布式系统中的血管和关节,Restful API 只是 RPC 的一种。节点内子系统之间的 API 通信往往形成了东西流量,节点间子系统之间的 API 通信形成了南北流量。调整显微镜的镜头,这个系统通过开放平台提供了 API,逐渐形成了生态系统。生态系统间的异构 API 正在随着网络世界的延伸而形成新的世界。

从空间的视角来看,计算机的世界几乎就是通过 API 连接的世界。

2 良好与糟糕,API 的面目

好的 API 给我们带来乐趣,几乎可以忽略他们的存在,它们能给对一个特定任务在合理的时间内完成,可以很容易地被发现和记忆,有良好的文档记录,有一个直观的界面使用,并能够正确处理边界条件。

然而,对于每一种正确设计 API 的方法,通常都有几十种不正确的设计方法。简单地说,创建一个糟糕的 API 非常容易,而创建一个好的 API 则比较困难。即使是很小很简单的设计缺陷也有被夸大的倾向,因为 API 会被多次调用。设计缺陷会导致代码笨拙或效率低下,那么在调用 API 的每个点都会出现由此产生的问题。此外,一个设计缺陷在孤立的情况下是微小的,但通过相互作用的方式具有惊人的破坏性,并迅速导致大量的间接伤害。

对于糟糕的 API 设计,后果众多且严重,难于编程,通常需要编写额外的代码。如果没有别的原因,这些额外的代码会使程序变大,效率更低,因为每一行不必要的代码都会增加工作集合的大小,并可能减少 CPU 缓存的命中率。此外,糟糕的 API 还可能会强制进行不必要的数据复制,或者对预期的结果抛出异常,从本质上产生效率低下的代码。糟糕的 API 更难理解,也更难使用。面对糟糕的 API,程序员编写代码的时间会更长,直接导致了开发成本的增加。

举个例子,如果一个设计不当的 API 会导致 Microsoft PowerPoint 常常崩溃的话,那可能会有海量用户受到影响。类似地,笔者见过的大量安全漏洞都是由标准 c 库 (如 strcpy) 中不安全的 IO 操作或者字符串操作造成的。这些设计缺陷的 API,造成的累积成本很容易达到数十亿资金。

从开发的视角看 API 的话,大多数软件开发都是关于不同层次的抽象,而 API 是这些抽象的可见接口。抽象减少了复杂性, 应用程序调用较高级别的库,通常再调用较低级别的库提供的服务来实现,而较低级别的库又调用操作系统的系统调用接口提供的服务。这种抽象层次结构非常强大,没有它,咱们所知道的软件就可能不存在,程序员会被复杂性完全压垮。

有悖常理的是,抽象层常常被用来淡化糟糕 API 的影响: “这不重要,我们可以编写一个 API 来隐藏问题。” 这种说法大错特错,首先,就内存和执行速度而言,即使是效率最高的 API 封装也会增加一些成本,其次,本来就是设计良好的 API 的份内工作, 通常情况下,API 封装会产生一系列的问题。因此,尽管 API 的封装可以是糟糕的 API 可用,这并不意味着这个糟糕的 API 无关紧要,这里没有“负负得正”,不必要的 API 封装只会导致软件更加臃肿。

3 API 设计的经验性原则

有些时候,自己新的认知可能只是别人的常识。在设计 API 的时候,有一些经验性原则可以使用。很遗憾。仍然无法提炼到方法论的高度。

3.1 功能的完整性

API 要提供完整的功能,这似乎是显而易见的,提供不足功能的 API 是不完整的。在设计过程中仔细检查一个功能清单,不断地问自己: “是否有遗漏呢?”

3.2 调用的简单性

API 使用的类型、函数和参数越少,学习、记忆和正确使用就越容易。许多 API 最终成为了助手函数的组合器,C+ + 标准字符串类及其超过 100 个的成员函数就是一个例子。在使用 C + + 编程多年之后,自己仍然无法在不查阅使用手册的情况下使用标准字符串来处理任何重要的事情。

为了很好地设计 API,设计人员必须了解使用 API 的环境,并对该环境进行设计。是否提供一个非基本的便利函数取决于设计者预期这个 API 被使用的频率。如果频繁使用,就值得添加。对 API 向下兼容的担忧随着时间的推移而侵蚀 API , API 累积起来最终造成的损害比它保持向后兼容所带来的好处还要大。

3.3 设计的场景化

考虑一个类,它提供了对一组字符串键值对的访问,比如环境变量:

class KVPairs {public string lookup(string name); // … }
lookup 方法提供了对命名变量值的访问。显然,如果设置了具有给定名称的变量,函数将返回其值。但是,如果没有设置变量,函数应该如何运行? 可能有几种选择:

抛出 VariableNotSet 异常
返回 null
返回空字符串
如果预期查找一个不存在的变量不是一个常见的情况,并且可能视为一个错误,那么抛出异常是适当的。异常会迫使调用方处理错误。另一种情况,调用者可能查找一个变量,如果没有则替换一个默认值。如果是这样的话,抛出异常完全是错误的,因为处理异常会破坏正常的控制流,而且比测试 null 或空返回值更困难。

假设如果没有设置变量时不抛出异常,有两个方式表明查找失败: 返回 null 或空字符串。哪一个是更合适呢? 同样,答案取决于预期的场景用例。返回 null 允许调用者区分没有设置的变量和设置为空字符串的变量,而返回未设置的变量的空字符串使得不可能区分从未设置的变量和显式设置为空字符串的变量。如果认为能够进行这种区分很重要,那么返回 null 是必要的; 但是,如果这种区分不重要,那么最好返回空字符串并且永远不返回 null。

3.4 有无策略性的设置

API 设置策略的程度对其可用性有着深远的影响 ,只有当调用者对 API 的使用与设计者预期的场景相一致时,API 才能最优地执行。如果对将要使用的 API 场景知之甚少,那么只能保持所有选项的开放性,并允许 API 尽可能广泛地应用。

实际上,什么应该是错误和什么不应该是错误之间的界限非常细微,而且错误地快速放置这个界限会导致更大的痛苦。对 API 的背景了解得越多,它可以制定的策略就越多。这样做对调用方有利,因为它捕获了错误,否则就无法检测到这些错误。通过仔细设计类型和参数,通常可以在编译时捕获错误,而不是延迟到运行时。在编译时捕获的每个错误都减少了一个错误,这个错误可能会在测试期间或生产环境中产生额外的成本。

通常情况下,人们对上下文知之甚少,因为 API 是低级的,或者就其本质而言,必须在许多不同的上下文中工作。在这种情况下,策略模式往往可以用来取得良好的效果。它允许调用者提供策略,从而保持设计的开放性。根据编程语言的不同,调用方提供的策略可以使用回调、虚函数、代理或模板等来实现。如果 API 提供了合理的缺省值,那么这种外部化的策略可以在不损害可用性和清晰性的情况下带来更大的灵活性。

3.5 面向用户的设计

程序员很容易进入解决问题的模式: 需要什么数据结构和算法来完成这项工作,需要什么输入和输出来完成这项工作? 实现者专注于解决问题,而调用者的关注点很快被忘记。

获得可用 API 的一个好方法是让调用者编写函数名,并将该 API 签名交给程序员来实现。仅这一步就至少消除了一半糟糕的 API,如果 API 的实现者从不使用他们自己的 API,这对可用性会造成灾难性的后果。此外,API 与编程、数据结构或算法无关,API 与 GUI 一样,也是一个用户界面。使用 API 的用户是一个程序员,也就是一个人。尽管倾向于认为 API 是机器接口,但它们不是: 它们是人机接口。

驱动 api 设计的不是实现者的需求,这意味着好的 api 是根据调用者的需求设计的,即使这会使实现者的工作变得更加复杂。

3.6 不推卸责任

“推卸责任”的一种方式是害怕设置策略: “好吧,调用者可能想要这样或那样做,但我不能确定是哪个,所以我会设置它。” 这种方法的典型结果是采用五个或十个参数的函数。因为设计者没有设置策略,也不清楚 API 应该做什么和不应该做什么,所以 API 最终的复杂性远远超过了必要的程度。一个好的 API 很清楚它想要实现什么和不想要实现什么,并且不害怕预先了解它。由此产生的简单性通常可以弥补功能的轻微损失,特别是如果 API 具有良好的基本操作,可以很容易地组合成更复杂的操作。

另一种推卸责任的方式是牺牲可用性来提高效率。性能增益是一种错觉,因为它使调用者“干脏活” ,而不是由 API 执行。换句话说,可以以零运行时开销提供更安全的 API。通过仅对 API 内部完成的工作进行基准测试,而不是由调用方和 API 共同完成的任务 ,设计人员可以声称已经创建了性能更好的 API,是缺乏价值的。

即便是内核也不是没有瑕疵,并且偶尔会推卸责任。某些系统调用会被中断,迫使程序员明确处理并手动重启被中断的系统调用,而不是让内核透明地处理。

推卸责任可以采取许多不同的形式,各种 API 的细节差别很大。对于设计者来说,关键的问题是: 有没有什么可以合理地为调用者做的事情是我没有做的? 如果有,是否有正当理由不这样做? 明确地提出这些问题使得设计成为一个有意识的过程,而不是“偶然的设计”。

3.7 清晰的文档化

API 文档的一个大问题是,它通常是在 API 实现之后编写的,而且通常是由实现者编写的。然而,实现者被实现所污染,并且倾向于简单地写下所做的事情。这通常会导致文档不完整,因为实现人员对 API 太熟悉了,并且假设有些事情是显而易见的,而实际上并非如此。更糟糕的是,它经常导致 API 完全忽略重要的用例。另一方面,如果调用者编写文档,调用者可以从用户的角度处理问题,不受当前实现的影响。这使得 API 更有可能满足调用者的需求,并防止许多设计缺陷的出现。

最不适合编写文档的人是 API 的实现者,最不适合编写文档的时间是在实现之后。这样做会增加接口、实现和文档都出现问题的可能性。

确保文档是完整的,特别是关于错误处理的文档。当事情出错时,API 的行为是其中的一部分,也是事情进展顺利时的一部分。文档是否说明 API 是否维护异常? 是否详细说明了在出错情况下输出和输入输出参数的状态? 是否详细说明了在错误发生后可能存在的任何副作用? 是否为调用者提供了足够的信息来理解错误? 程序员确实需要知道当出现错误时 API 的行为,并且确实需要获得详细的错误信息,以便通过编程方式进行处理。

单元测试和系统测试对 API 也有影响,因为它们可以发现以前没有人想到的东西。测试结果可以帮助改进文档,从而改进 API,文档是 API 的一部分。

3.8 API 的人体工程学

人体工程学本身就是一个研究领域,也可能是 API 设计中最难确定的部分之一。关于这个主题,已经有了很多内容,例如定义命名约定、代码布局、文档样式等。除了单纯的时尚问题,符合人体工程学的实现良好是困难的,因为它提出了复杂的认知和心理问题。程序员是人,所以一个程序员认为很好的 API 可能被另一个程序员认为是一般的。

特别是对于大型和复杂的 api,人机工程学涉及到一致性的问题。例如,如果一个 API 总是以相同的顺序放置特定类型的参数,那么它就更容易使用。类似地,如果 API 建立命名规则,将相关函数与特定的命名风格组合在一起,那么就更容易使用。同时, API 为相关任务建立简单统一的约定并使用统一的错误处理。

一致性不仅使事物更容易使用和记忆,而且还可以转移学习。转移学习不仅在 API 内部很重要,而且在 API 之间也很重要。API 之间可以采用的概念越多,就越容易掌握所有的概念。实际上,即便是 Unix 中的标准 IO 库也在许多地方违背了这一思想。例如,read ()和 write () 的系统调用将文件描述符放在第一个参数位置,但是标准库中如 fgets ()和 fputs () ,将流指针放在最后,而 fscanf ()和 fprintf () 又将流指针放在第一个位置。这时候,往往要感谢 IDE 的代码补全功能了。

4 性能约定,API 的时间视角

在 API 中调用函数时,当然期望它们能够正确工作,这种期望可以被称为调用方和实现之间的约定。同时,调用者对这些功能也有性能期望,软件系统的成功通常也取决于满足这些期望的 API。因此,除了正确性约定之外,还有性能约定。履行合同通常是隐含的,常常是模糊的,有时是被违反的 (由调用者或执行者)。如何改进这方面的 API 设计和文档?

当今任何重要的软件系统都依赖于其他人的工作,通过 API 调用操作系统和各种软件包中的函数,从而减少了必须编写的代码量。在某些情况下,要把工作外包给远程服务器,这些服务器通过网络与你连接。我们既依赖于这些函数和服务来实现正确的操作,也依赖于它们的执行性能以保证整个系统的性能。在涉及分页、网络延迟、共享资源 (如磁盘) 等的复杂系统中,性能必然会有变化。然而,即使是在简单的设置中,比如在内存中包含所有程序和数据的独立计算机中,当一个 API 或操作系统达不到性能预期时,也是令人沮丧的。

人们习惯于谈论应用程序和 API 实现之间的功能约定。虽然如今的 API 规范并没有以一种导致正确性证明的方式明确规定正确性的标准,但是 API 函数的类型声明和文本文档力求对其逻辑行为毫不含糊。然而,API 函数的意义远不止正确性。它消耗什么资源,速度有多快? 人们常常根据自己对某个函数的实现应该是什么的判断做出假设。遗憾的是,API 文档没有提示哪些函数有性能保证,哪些函数实际上代价高昂。

更复杂的是,当应用程序调整到 API 的性能特征之后,一个新版本的 API 实现或者一个新的远程存储服务却削减了软件系统的整体性能。简而言之,从时间的视角来看,API 的性能契约值得更多关注。

4.1 API 的性能分类

为了实用有效,从计算复杂度来看,可以对 API 的性能做一个简单的分类。

恒定的性能

例如 toupper, isdigit, java.util.HashMap.get 等。前两个函数总是计算廉价的,通常是内联表查找。正确大小的哈希表查找应该也很快,但是哈希冲突可能会偶尔减慢访问的速度。

通常的性能

例如 fgetc, java.util.HashMap.put 等。许多函数被设计成大多数时候都很快,但是偶尔需要调用更复杂的代码; fgetc 必须偶尔读取一个新的字符缓冲区。在哈希表中存储一条新数据可能会使该表变满,以至于会重对表中所有条目进行哈希计算。

java.util.HashMap 在性能约定方面有一个很好的描述: “这个实现为基本操作 (get 和 put) 提供了常量时间性能,假设散列函数将元素正确地分散在存储桶中。对集合视图的迭代,需要与 HashMap ‘容量‘成比例的时间…… “。fgetc 的性能取决于底层流的属性。如果是磁盘文件,那么该函数通常将从用户内存缓冲区读取,而不需要系统调用,但它必须偶尔调用操作系统来读取新的缓冲区。如果是从键盘读取输入,那么实现可能会调用操作系统来读取每个字符。

程序员建立性能模型是基于经验,而不是规范,并非所有函数都有明显的性能属性。

可预期的性能

例如 qsort, regexec 等。这些函数的性能随其参数的属性 (例如,要排序数组的大小或要搜索的字符串的长度) 而变化。这些函数通常是数据结构或公共算法实用程序,使用众所周知的算法,不需要系统调用。

我们通常可以根据对底层算法的期望来判断性能 (例如,排序将花费 nlogn 的时间)。当使用复杂的数据结构(例如 B 树) 或通用集合 (在这些地方可能很难确定底层的具体实现) 时,可能更难估计性能。重要的是,可预测性只是可能的; regexec 基于它的输入通常是可预测的,但是有一些病态的表达会导致复杂计算的爆发。

未知的性能

例如 fopen, fseek, pthread_create, 很多初始化的函数以及所有网络调用。这些函数的性能常常有很大的差异。它们从池 (线程、内存、磁盘、操作系统对象) 分配资源,通常需要对共享操作系统或 IO 资源的独占访问。通常需要大量的初始化工作, 通过网络进行调用相对于本地访问总是慢的,这使得合理性能模型的形成变得更加困难。

线程库是性能问题的简单标志。Posix 标准花了很多年才稳定下来,然而如今仍然被性能问题所困扰。线程应用程序的可移植性仍然存在风险,原因是线程需要与操作系统紧密集成,几乎所有操作系统 (包括 Unix 和 Linux) 最初设计时都没有考虑到线程; 线程与其他库的交互,为了使线程安全而导致的性能问题等等。

4.2 按性能划分 API

有些库提供了执行一个函数的多种方法,通常是因为这些方法的性能差别很大。

大多数程序员被告知使用库函数来获取每个字符并不是最快的方法,更注重性能的人会读取一个大型的字符数组,并使用编程语言中的数组或指针来操作提取每个字符。在极端情况下,应用程序可以将文件页映射到内存页,以避免将数据复制到数组中。作为提高性能的副作用是,这些函数给调用方带来了更大的负担。例如,获得缓冲区算法的正确性,调用 fseek 需要调整缓冲区指针和可能的内容。

程序员总是被建议避免在程序中过早地进行优化,从而推迟修订,直到更简单的做法被证明是不满足要求的。确定性能的唯一方法是测量。程序员通常在编写完整个程序之后,才会面对性能期望与所交付的实现之间的不匹配。

4.3 API 的性能变化

可预测函数的性能可以根据其参数的属性进行估计,未知函数的性能功能也可能因要求它们做什么而有很大的不同。在存储设备上打开流所需的时间当然取决于底层设备的访问时间,或许还取决于数据传输的速率。通过网络协议访问的存储可能特别昂贵,而且它是变化的。

由于各种原因,一般的函数随着时间的推移变得越来越强大。I/O 流就是一个很好的例子,根据打开的流类型 (本地磁盘文件、网络服务文件、管道、网络流、内存中的字符串等) ,调用打开流在库和操作系统中调用不一样的代码。随着 IO 设备和文件类型范围的扩展,性能的差异只会增加。大多数 API 的共同生命周期是随着时间的推移逐步增加功能,从而不可避免地增加了性能变化。

一个很大的变化来源是不同平台的库接口之间的差异。当然,平台的底层速度 (硬件和操作系统) 会有所不同,但是库接口可能会导致 API 内函数的性能或 API 间的性能变化。有些库 (例如那些用于处理线程的库) 的移植性能差异非常大。线程异常可能以极端行为的形式出现ーー极其缓慢的应用程序甚至是死锁。

这些差异是难以建立精确的 API 性能约定的原因之一。我们往往不需要非常精确地了解性能,但是预期行为的极端变化可能会导致问题。例如,使用 malloc ()函数的动态内存分配可以被描述为“通常的性能” ,这将是错误的,因为内存分配 (尤其是 malloc) 是程序员开始寻找性能问题时的首要嫌疑之一。作为性能直觉的一部分,如果调用 malloc 数以万计次,特别是为了分配小的固定大小的块,最好使用 malloc 分配一个更大的内存块,将其分割成固定大小的块,并管理自己的空闲块列表。Malloc 的实现多年来一直在努力让它变得高效,提供虚拟内存、线程和非常大的内存的系统都对 malloc 和 free 构成了挑战,必须权衡某些使用模式 (如内存碎片) 的效率和弊端。

一些软件系统,如 Java,使用自动内存分配和垃圾收集来管理空闲存储。虽然这是一个很大的便利,但是一个关心性能的程序员必须意识到成本。例如,一个 Java 程序员应该尽早被告知 String 对象和 StringBuffer 对象之间的区别,String 对象只能通过在新内存中创建一个新的副本来修改,而 StringBuffer 对象包含容纳字符串可以延长的空间。随着垃圾收集系统的改进,它们使得不可预知的垃圾收集暂停变得不那么常见; 这可能会让程序员自满,相信自动回收内存永远不会成为性能问题,而实际上这就是一个性能问题。

4.4 API 调用失败时的性能

API 的规范包括了调用失败时的行为。返回错误代码和抛出异常是告诉调用方函数未成功的常用方法。但是,与正常行为的规范一样,没有指定故障的性能。主要有以下是三种形式:

快速失败。一个 API 调用很快就失败了,和它的正常行为一样快或者更快。例如,调用 sqrt (- 1) 会很快失败。即使当一个 malloc 调用因为没有更多的内存可用而失败时,这个调用也应该像任何 malloc 调用一样快速地返回,因为后者必须从操作系统请求更多的内存。为了读取一个不存在的磁盘文件而打开一个流的调用很可能与成功调用返回的速度一样快。

慢慢失败。有时,一个 API 调用失败的速度非常慢,以至于应用程序可能希望以其他方式进行。例如,打开到另一台计算机的网络连接请求只有在几次长时间超时后才会失败。

永远失败。有时候一个 API 调用只是暂停,根本不允许应用程序继续运行。例如,其实现等待从未释放的同步锁的调用可能永远不会返回。
对于失败性能的直觉很少像对于正常性能的直觉那样好。原因很简单,编写、调试和调优程序时处理故障事件的经验远远少于处理普通事件。另一个原因是,API 调用可能在许多方面出现故障,其中一些是致命的,而且并非所有的故障都在 API 规范中有所描述。即使是旨在更精确地描述错误处理的异常机制,也不能使所有可能的异常都可见。此外,随着库函数的增加,失败的机会也在增加。例如,包装网络服务的 API(ODBC、 JDBC、 UPnP 等等) 从本质上订阅了大量的网络故障机制。
一个勤奋的程序员会尽可能处理不可能的失败。一种常见的技术是用 try… catch 块包围程序的大部分,这些块可以重试失败的整个部分。交互式程序可以尝试保存用户的工作,捕获周围的整个程序,其效果是减轻失败的主程序造成的损失,例如保存在一个磁盘文件,关键日志或数据结构等等。
处理暂停或死锁的唯一方法可能是设置一个 watchdog 线程,该线程期望定期检查一个正常运行的应用程序,如果健康检查异常,watchdog 就会采取行动,例如,保存状态、中止主线程和重新启动整个应用程序等。如果一个交互式程序通过调用可能缓慢失败的函数来响应用户的命令,它可能会使用 watchdog 终止整个命令,并返回到允许用户继续执行其他命令的已知状态,这就产生了一种防御性的编程风格。

5 确保 API 性能的经验性方法

程序员根据对 API 性能的期望选择 API、数据结构和整个程序结构。如果预期或性能严重错误,程序员不能仅仅通过调优 API 调用来恢复,而必须重写程序 (可能是主要部分)。前面提到的交互式程序的防御结构是另一个例子。

当然,有许多程序的结构和性能很少受到库性能的影响 (科学计算和大型模拟通常属于这一类)。然而,今天的许多“常规 IT” ,特别是遍及基于 web 的服务的软件,广泛使用了对整体性能至关重要的库。

即使性能上的微小变化也会导致用户对程序的感知发生重大变化,在处理各种媒体的节目中尤其如此。偶尔放弃视频流的帧可能是可接受的 ,但是用户可以感知到音频中哪怕是轻微的中断,因此音频媒体性能的微小变化可能会对整个节目的可接受性产生重大影响。这种担忧引起了人们对服务质量的极大兴趣,在许多方面,服务质量是为了确保高水平的业绩。

尽管违反性能契约的情况很少,而且很少是灾难性的,但在使用软件库时注意性能可以帮助构建更健壮的软件。以下是一些经验性原则:

5.1 谨慎地选择 API 和程序结构

如果有幸从头开始编写一个程序,那么在开始编写程序时,要考虑一下性能约定的含义。如果这个程序一开始只是一个原型,然后在服务中保持一段时间,那么毫无疑问它至少会被重写一次; 重写是一个重新思考 API 和结构选择的机会。

5.2 在新版本发布时提供一致的性能约定

一个新的实验性 API 会吸引那些开始衍生 API 性能模型的用户。此后,更改性能约定肯定会激怒开发人员,并可能导致他们重写自己的程序。一旦 API 成熟,性能约定不变就很重要了。事实上,大多数通用 API (例如 libc) 之所以变得如此,部分原因在于它们的性能约定在 API 发展过程中是稳定的。同样的道理也适用于 api 端口

人们可能希望 API 提供者能够定期测试新版本,以验证它们没有引入性能怪癖。不幸的是,这样的测试很少进行。但是,这并不意味着不能对依赖的 API 部分进行自己的测试。使用分析器,通常可以发现程序依赖于少量的 API。编写一个性能测试工具,将一个 API 的新版本与早期版本的记录性能进行比较,这样可以给程序员提供一个早期预警警,即随着 API 新版本的发布,他们自己代码的性能将发生变化。

许多程序员希望计算机及其软件能够一致地随着时间的推移而变得更快。这实际上对于供应商来说是很难保证的,但是它们会让客户相信是这样的。许多程序员希望图形库、驱动程序和硬件的新版本能够提高所有图形应用程序的性能,并热衷于多种功能的改进,这通常会降低旧功能的性能,哪怕只是轻微的降低。

我们还可以希望 API 规范将性能约定明确化,这样使用、修改或移植代码的人就可以遵守约定。注意,函数对动态内存分配的使用,无论是隐式的还是自动的,都应该是 API 文档的一部分。

5.3 防御性编程可以提供帮助

在调用性能未知或高度可变的 API 时,程序员可以使用特殊的方式,对于考虑故障性能的情况尤其如此。我们可以将初始化移到性能关键区域之外,并尝试预加载 API 可能使用的任何缓存数据 (例如字体)。表现出大量性能差异或拥有大量内部缓存数据的 API ,可以通过提供帮助函数将关于如何分配或初始化这些结构的提示从应用程序传递给 API。某个程序偶尔会向服务器发出 ping 信号,这可以建立一个可能不可用的服务器列表,从而避免一些长时间的故障暂停。

5.4 API 公开的参数调优

有些库提供了影响性能的明确方法 (例如,控制分配给文件的缓冲区的大小、表的初始大小或缓存的大小),操作系统还提供了调优选项。调整这些参数可以在性能约定的范围内提高性能,调优不能解决总体问题,但可以减少嵌入在库中的固定选项,这些选项会严重影响性能。

有些库提供了具有相同语义函数的替代实现,通常是通用 API 的具体实现形式。通过选择最好的具体实现进行调优通常非常容易,Java 集合就是这种结构的一个很好的例子。

越来越多的 API 被设计成动态地适应应用,使程序员无需选择最佳的参数设置。如果一个散列表太满,它会自动扩展和重新哈希 (这是一种优点,但偶尔会降低性能)。如果文件是按顺序读取的,那么可以分配更多的缓冲区,以便在更大的块中读取。

5.5 测量性能以验证假设

常见方式是检测关键数据结构,以确定每个结构是否正确使用。例如,可以测量哈希表的完整程度或发生哈希冲突的频率。或者,可以验证一个以写性能为代价的快速读取结构实际上被读取的次数多于被写入的次数。

添加足够的工具来准确地度量许多 API 调用的性能是困难的,这需要大量的工作,而且可能投入产出比较低。然而,在那些对应用程序的性能至关重要的 API 调用上添加工具 (假设能够识别它们,并且正确的识别) ,就可以在出现问题时节省大量时间。注意,这些代码中的大部分可以在新版本的性能监视器中重用。

所有这些都不是为了阻止完美主义者开发自动化仪表盘和测量的工具,或者开发详细说明性能约定的方法,以便性能测量能够建立对性能约定的遵守。这些目标并不容易实现,回报可能也不会很大。

通常可以在不事先检测软件的情况下进行性能度量,优点是在出现需要跟踪的问题之前不需要任何工作还可以帮助诊断当修改代码或库影响性能时出现的问题。定期进行概要分析,从可信赖的基础上衡量性能偏差。

5.6 使用日志检测和记录异常

当分布式服务组成一个复杂的系统时,会出现越来越多的违反性能约定的行为。注意,通过网络接口提供的服务有时具有指定可接受性能的 SLA。在许多配置中,度量过程偶尔会发出服务请求,以检查 SLA 是否满足 。由于这些服务使用类似于 API 调用的方法 (例如,远程过程调用或其变体,如 XML-RPC、 SOAP 或 REST),因此可能是有性能约定的期望。应用程序会检测这些服务的失败,并且通常会应对得当。

然而,响应缓慢,特别是当有许多这样的服务互相依赖时,可能会非常快地破坏系统性能。如果这些服务的客户能够记录他们所期望的性能,并生成有助于诊断问题的日志 (这就是 syslog 的用途之一) ,那将会很有帮助。当文件备份看起来不合理的慢,那是不是比昨天慢呢? 比最新的操作系统软件更新之前还要慢? 考虑到多台计算机可能共享的备份设备,它是否比预期的要慢? 或者是否有一些合理的解释 (例如,备份系统发现一个损坏的数据结构并开始一个长的过程来重新构建它) ?

在没有源代码,也没有构成组合的模块和 API 的细节的情况下,诊断不透明软件组合中的性能问题可以在报告性能和发现问题方面发挥作用。虽然不能在软件内部解决性能问题 ,但是可以对操作系统和网络进行调整或修复。如果备份设备由于磁盘几乎已满而速度较慢,那么肯定可以添加更多的磁盘空间。好的日志和相关的工具会有所帮助; 遗憾的是,日志在计算机系统演进中可能是一个被低估和忽视的领域。

诚然,性能约定没有功能正确性约定那么重要,但是软件系统的重要用户体验几乎都取决于它。

6 面对 API,开发者的苦恼

对于向外部提供的 API,有一些因素成为了开发者的苦恼。

6.1 没有 API

API 允许客户实现你没有想到的功能,允许客户更多的使用产品。如果存在一个 API,开发者可以自动使用 API 的产品,这将产生更多的应用。他们可以自动化整个公司的配置,可以基于你的 API 构建全新的应用程序。只要想想他们能够通过 API 使用多少产品就可以了。

6.2 繁琐的注册

只有复杂的注册过程才能保证 API 的安全么? 实际上只是自寻烦恼。要么使整个过程完全自助服务,要么根本不需要任何类型的注册过程,这样才是良好的 API 使用开端

6.3 多收费的 API

对服务收费是正常的,或者只在“企业版”中包含 API 访问 。让 API 访问变得如此昂贵,以至于销售部门认为 API 代表额外的利润激励。事实上,API 不应该成为一种收入来源,而是一种鼓励人们使用产品的方式。

6.4 隐藏 API 文档

没有什么比在搜索引擎中看不到 API 文档更糟糕的事了。那些将 API 文档放在登录之后的体验屏幕后面,可以认为是设计人员的大脑短路。通过某种注册或登录来阻止竞争对手查看 API 并从中学习,这是一种幼稚的想法。

6.5 糟糕的私有协议

一个私有协议可能很难理解,也不可能调试。SOAP 可能会变得臃肿和过于冗长,从而导致带宽消耗和速度减慢。它也是基于 XML 的,尤其是在移动或嵌入式客户端上,解析和操作起来非常昂贵。许多 API 使用 JSON API 或 JSON-rpc ,它们是轻量级的,易于使用,易于调试。

6.6 单一的 API 密钥

如果只允许使用一个 API 密钥,相当于创建了一个“第 22 条军规”的情况。开发者无法更改服务器上的 API 密钥,因为客户端也会在更新之前失去了访问权限。他们也不能首先更改客户端,因为服务器还不知道新的 API 密钥。如果有多个客户端,那基本上就是一场灾难。

API 密钥基本上就是用于识别和验证客户的密码。也许是密钥泄露,也许某个员工离开了公司带走了钥匙,或许每年轮换密钥是安全策略的一部分,最终,开发者都将需要更改他们的 API 密钥。

6.7 手动维护文档

随着 API 的发展,API 和文档有可能脱离同步。一个错误的 API 说明会导致一个无法工作的系统,会令人极其的沮丧。一个与文档不同步的 API,会让人束手无策。

6.8 忽略运维环境

将基础设施视为代码的能力正在成为运维团队的要事。它不仅使操作更容易、更可测试和更可靠,而且还为诸如支付行业所要求的安全性最佳实践铺平了道路。如果忽略了 Ansible, Chef, Puppet 等类似的系统,可能会导致一系列令人困惑的不兼容选项,使得生产环境的 API 调用难以为继。

6.9 非幂等性

假设有一个创建虚拟机的 API 调用。如果这个 API 调用是幂等的,那么我们第一次调用它的时候就创建了 VM。第二次调用它时,系统检测到 VM 已经存在,并简单地返回,没有错误。如果这个 API 调用是非幂等的,那么调用 10 次就会创建 10 个 vm。

为什么有人要多次调用同一个 API? 在处理 rpc 时,响应可能是成功、失败或根本没有应答。如果没有收到服务器的回复,则必须重试请求。使用幂等协议,可以简单地重发请求。对于非幂等协议,每个操作后都必须跟随发现当前状态并进行正确恢复的代码,将所有恢复逻辑放在客户机中是一种丑陋的设计。将此逻辑放在客户机库中可以确保客户端需要更频繁的更新,要求用户实现恢复逻辑是令人难受的。

当 API 是幂等的时候,这些问题就会减少或消除。

如果网络是不可靠的,那么网络 API 本质上也是不可靠的。请求可能在发送到服务器的途中丢失,而且永远不会执行。执行可能已经完成,但是回复的信息已经丢失了。服务器可能在操作期间重新启动。客户端可能在发送请求时重新启动,在等待请求时重新启动,或者在接收请求时重新启动,在本地状态存储到数据库之前重新启动。在分布式计算中,一切都有可能失败。

程序员们以做好工作和完善的系统给用户留下深刻印象而自豪,令开发者苦恼的 API 往往出于无知、缺乏资源或者不可能的最后期限。

7 API 设计中的文化认知

如果让 API 的设计可以做得更好的话,除了一些细节性的技术问题外,还可能需要解决一些文化问题。我们不仅需要技术智慧,还需要改变我们认识和实践软件工程的方式。

7.1 API 的有意识训练

自己念书的时候,程序员的培训主要侧重于数据结构和算法。这意味着一个称职的程序员必须知道如何编写各种数据结构并有效地操作它们。随着开源运动的发展,这一切都发生了巨大的变化。如今,几乎所有可以想象到的可重用功能都可以使用开放源码。因此,创建软件的过程发生了很大的变化,今天的软件工程不是创建功能,而是集成现有的功能或者以某种方式重新封它。换句话说,现在的 API 设计比 20 年前更加重要,不仅拥有了更多的 API,而且这些 API 提供了比以前更加丰富且复杂的功能。

从来没有人费心去解释如何决定某个值应该是返回值还是输出参数,如何在引发异常和返回错误代码之间做出选择,或者如何决定一个函数修改它的参数是否合适。所以,期望程序员擅长一些他们从未学过的东西是不合理的。

然而,好的 API 设计,即使很复杂,也是可以训练的,关键是认识到重要性并有意识的训练。

7.2 API 设计人才的流失

一个老码农环顾四周,才发现周围是多么的不寻常: 所有的编程同事都比我年轻,当自己以前的同事或者同学,大多数人不再写代码; 他们转到了不同的岗位比如各种经理、总监、CXO ,或者完全离开了这个行业。这种趋势在软件行业随处可见: 年长的程序员很少,通常是因为看不到职业生涯。如果不进入管理层,未来的加薪几乎是不可能的。

一种观点认为,年长程序员的职业优势在不断丧失。这种想法可能是错误的: 年长的程序员可能不会像年轻人那样熬夜,但这并不是因为他们年纪大,而是因为很多时候他们不用熬夜就能完成工作。

老程序员的流失是不幸的,特别是在 API 设计方面。虽然好的 API 设计是可以学习的,但是经验是无法替代的。需要时间和不断的挖坑填坑才会做得更好。不幸的是,这个行业的趋势恰恰是把最有经验的人从编程中提拔出来。

另一个趋势是公司将最好的程序员提升为设计师或系统架构师。通常情况下,这些程序员作为顾问外包给各种各样的项目,目的是确保项目在正确的轨道上起步,避免在没有顾问智慧的情况下犯错误。这种做法的意图值得称赞,但其结果通常是发人深省的: 顾问从来没有经历过自己的设计决策的后果,这是对设计的一种嘲讽。让设计师保持敏锐和务实的方法是让他们吃自己的狗粮, 剥夺设计师反馈的过程最终可能注定失败。

7.3 开放与控制

随着计算的重要性不断增长,有一些 API 的正确功能几乎是无法描述的。例如, Unix 系统调用接口、 C 标准库、 Win32 或 OpenSSL。这些 API 的接口或语义的任何改变都会带来巨大的经济成本,并可能引发漏洞。允许单个公司在没有外部控制的情况下对如此关键的 API 进行更改是不负责任的。严格的立法控制和更开放的同行审查相结合,在两者之间找到恰当的平衡对于计算机的未来和网络经济至关重要。

API 设计确实很重要,因为整个计算机世界都是由 API 连接的。然而,证明实现良好 API 所需的投入产出比可能是困难的,特别是当一个 API 没有被客户使用的时候。“当几乎没有人使用我们的 API 时,收益是多少? ” 产品经理或者老板们经常可能会问这样的问题。呵呵,也许你没有做过这些事情,所以使用率很低。