身为软件开发工程师,我们每天都编写代码。然而不可思议的是,这些代码会一直“存在于真空中”,与所有其他开发的软件隔离开来。在软件工程领域,“站在巨人的肩膀上”这个比喻从来没有像今天这样合适。GitHub、StackOverflow、MavenCentral以及所有其他代码仓库、支持库和软件库都唾手可得。
软件是由应用程序编程接口(API)构建的——我们每天都在使用Maven或Gradle等工具引入的JDKAPI和数量众多的依赖API。如果你走到一个待满了软件工程师房间里,问他们是不是API开发者,他们的回答通常是:“不,我们不是”。这是不正确的!任何曾经亲手设计过publicclass或定义过publicmethod的人都应该认为自己是API开发者。这里故意使用了“手工设计(craft)”这个词。软件工程往往会被工程的形式所掩盖,但某种程度上API设计更像是一门艺术,而非一门精确的科学,需要依赖打磨多年的创造力和直觉。
2.API的特点
2.1容易理解
从Maven下载了函数库,接下来该从哪个class下手?如果不能凭直觉找到入口,可能就算不上一个成功函数库。
API开发者应该充分考虑API的入口。完整的文档对于帮助使用API使用者了解全局非常有用,但理想情况下我们希望确保开发者使用API时遇到的阻碍降到最低。因此,应该在文档的开始部分为开发人员提供最少的步骤。好的API会从入口公开其最重要的功能,帮助开发者掌握API的主要功能。然后,开发人员可以根据需要通过外部文档了解更高级的功能。
2.2文档完备
既然是提供给他人使用,那么完备的文档很重要。接下来会介绍如何编写高质量、详尽的JavaDoc文档。
2.3一致性
一个好的API不应该让用户在使用过程中感到意外,比如前后概念不一致。在讨论一致性时,我们的意思是确保在API中重复相同的概念,而不是引入不同的临时概念。比如下面的例子:
重点是建立一套团队中公用的词汇表和“备忘清单”,并在整个SDK中通过这种方式保证一致性。
2.4适用性
在开发API的过程中,必须确保API为目标用户提供合适的抽象级别。可以从两方面考虑:
JDK中的CollectionAPI就是最佳范例。使用者不用关心存储空间的阈值、扩容策略、hash冲突策略、装载系数、缓存策略等。只要调用、存储就可以了。开发人员不必理解内部工作机制就可以使用集合框架实现自己想要的功能。
2.5约束
开发新API的过程可能非常快。但我们应该在心里提醒自己,每个新的API都有可能承诺终身支持。
我们对API决策的实际成本在很大程度上取决于我们的项目和社区——一些项目乐于不断地进行突破性的改进,而其他项目(如JDK本身)则希望尽可能少地出现突破性改进。而大多数项目则处于中间地带,采用一种语义版本控制方法,在主版本删除API之前小心地弃用它们。
有些项目甚至提供了各种标记区分experimental、beta和preview功能,以便在最终锁定API之前寻求反馈。一种通常的做法是,对新引入的实验性API加上@Deprecated,当它们已经就绪的时候再把注解拿掉。
2.6可扩展性
每次API的决定,都让自己的余地变得更小。所以尽可能从SDK的长远发展来考虑。
3.API即约定
API更像是一种约定,为其他开发者承诺了某种功能。我们需要不断改进API实现,每次改进都深思熟虑。每次冒险增加新功,都可能给下游API的使用者带来bug风险。
4.必要性
最容易维护的API就是不使用API,因此证明API中每个方法和类存在的必要性是一件十分重要的事情。在设计API的过程中,我们要时刻问这样的问题:“它真的是必须的吗?”只有不断地提问和证明,才能确保留下的函数都是必须的,而且是值得长久保留的。
5.吃自己的狗粮
作为API开发者,如何保证自己设计的API能够满足现实需求?我们需要用API使用的视角而非自己的角度来看待这个问题。要做到这点,最好的方法就是“吃自己的狗粮”,在整个开发过程中不但自己要使用自己开发的API,更重要的是还要确保有“真实世界”中可信赖的用户使用你的API。
引入真实用户的价值,在于能够避免自己失去限制,仅凭自己对API的理解加入高级功能。“真实世界”的用户可以平衡这点,让我们能够确保只修复那些真正的问题。
6.API文档
JavaDoc是API的说明书。开发API的工程师需要确保JavaDoc的完整性,包括类功能说明、函数功能说明、期望的输入格式、输出结果、异常等等。虽然起到了说明书的作用,但是很重要的一点,它既不是详细的开发指南也不讨论实现细节。
JavaDoc的价值不仅为其他开发人员提供价值,还能为我们提供帮助。JavaDoc对API进行了过滤,只展示标记了public的方法。如果我们定期生成JavaDoc,就能审查API中JavaDoc缺失、遗漏实现类、缺少外部依赖及其他没有想到的问题。
Java项目大多基于Maven或Gradle构建,生成JavaDoc非常方便,可以分别运行mvnjavadoc:javadoc或者gradlejavadoc。养成定期生成JavaDoc的好习惯(可以设置在错误或报警时生成失败),能够确保及早发现API中的问题,提醒自己在哪些地方还需要更详细的JavaDoc。
6.1JavaDoc中的行为约定
JavaDoc一个未被充分利用的方面是通过它来定义行为约定。关于行为约定的一个例子是Arrays.sort()方法,该方法保证是“稳定的”(即不对相等的元素重新排序)。想要通过API本身信息没有办法很容易地做到这一点(除非使API变得难以使用,比如Arrays.stableSort()),但JavaDoc提供了最理想的实现场所。
然而,如果我们添加行为约定作为API的一部分,那么它就会成为API的一部分,就像API本身一样。我们不能在API层次改变行为约定,这么做可能会给API下游的使用者带来问题。
6.2JavaDoc标签
JavaDoc附带了许多标签,例如@link、@param和@return。它们为JavaDoc工具提供了更多的上下文,并在生成HTML时提供更丰富的体验。在编写JavaDoc时,将这些内容牢记在心是非常有用的,可以确保它们在需要的时候用到。要了解何时使用这些标记,请参阅“J2SE参考文档”中的“标记注释”部分。
7.一致性
现在很少有软件由一个人开发。即便是,人类也会反复无常,今天认为伟大的东西第二天可能会被认为是大错特错。幸运的是,在设计API的时候,我们清楚地记录了以publicAPI的形式所做的决策,并且很容易发现什么东西背离了这种约定。
API具备一致性,短期的好处是可以减小让用户感到沮丧的风险。长期来看,可以让用户在使用API功能的时候凭直觉就知道该如何使用。
关于一致性需要考虑:
7.1返回值
理想情况下,所有返回集合的API都应该保持一致,只使用几个集合类而不是所有可能的类。返回集合的一个很好的子集可以是List、Set和Iterator(这种情况下,绝对不要用Collection、Iterable和Stream。但是请注意,这些都是有效的返回类型——仅在本例中,它们不在考虑的子集范围中)。对应的,如果(针对某种类型)API在大多数情况下都不返回null,那么最好不要为该返回类型返回null。
7.2方法命名模式
7.3参数顺序
重载API以接受不同数量或类型的参数时,应该始终确保参数顺序的一致性和逻辑性。在某些情况下,我们会按照某种逻辑分组的形式将参数传递给方法。这时,引入封装这些参数的中间类型可能是有意义的。这样能够减少API后续版本中需要重载方法以接受更多参数的风险。这也有助于我们达成API可扩展这个目标。
8.最小化API
开发更强大的API是API开发者的本能——提供更多而不是更少的便利。但是这会导致两个问题:
所有API开发者都应该从了解他们负责的API所需的关键用例开始,设计API并支持这些用例。应该抵制增加更多便利的冲动(自认为通过增加新API使开发人员少写几行代码)。
话虽如此,需要澄清一点,便捷的API在任何好的API中都扮演着至关重要的角色,尤其是让API变得易于理解方面非常有用。挑战在于,决定什么应该被作为有价值的东西被接纳,什么东西不能“证明自己的价值”应该拒绝。JDKAPI中一个很好的例子是List.add(Object),可以避免开发人员必须总是调用List.add(int,Object)。
在与OracleJDK团队的工程师StuartMarks讨论这个主题时,他发表了以下见解:
另一方面,我也见过一些API因为“便捷性”而陷入困境。这里有一个假设的例子。假设有一个提供了bar()和foo()操作的API。它们可以单独使用,但经常会放在一起使用。这时,可能有一个bf()操作,它能同时做这两件事。目前为止没有问题。
现在,假设你增加了一个mumble()操作,需要分别调用bf()和mumble()因此需要更方便的API,比如bfm()。好吧,如果你不需要foo()怎么办?再提供一个bm()怎么样?另外,还可以再增加一个fm()。现在你有了7个方法,其中一半以上是三个基本操作的组合。也许这是好事,也许不是;当然,这么干可能会使API膨胀。在某种程度上,有足够多的便捷API,它们往往会比基本操作更方便。现在,主要是风格问题。可以只执行基本操作,交给用户来组合,这是JDK的风格。或者你可以提供所有的组合,这样一旦用户了解他们的系统,所需的任何组合都已经有了。后者的例子可以参考EclipseCollections。9.防止泄露
防止“泄露”很重要。要确保实现类、属于外部依赖项的类不在publicAPI中以返回类型或参数类型的形式暴露出来。应该采取适当的措施来确保这些类被隐藏。
隐藏实现类主要有两种方法:
10.理解protected
Java中的protected关键字经常被误解,甚至被滥用。简而言之,protected成员用于与子类通信,而public成员用于与调用者通信。
在某些情况下,protected在API开发人员工具箱中可能是非常有用的工具,但是除非从一开始就将它设计成一个类,否则它经常被错误地使用。导致看起来类是可扩展的,但实际上并不是。实际上,有时候protected关键字看做感染class的一种病毒,用户越来越多地要求API中private方法变成protected(或public)时,为class加上protected满足他们的需求。
此外,API开发者需要理解,protected方法和publicAPI一样,也是其中的一部分。这一点常常被API开发的新手误解,最终造成伤害。
11.有意的继承
作为一名API开发者,我们必须保持一种平衡,既能为开发人员提供功能和灵活性以完成他们的工作,又让自己的API具有长期可扩展性。确保我们能够保持一定程度的控制,一种方法是使用final关键字。通过将我们的类或方法设置为final,我们向开发人员发出的信号是,此时他们不能扩展或覆盖这些特定的类和方法。
final对API开发者的价值是基于这样的事实,我们的API不是完美的。相比联系API开发者修复问题,更多的开发人员会绕过我们的问题来改进自己的代码,这样他们就可以继续解决下一个问题。通过这种方法,他们只会给自己提出新问题,最终会给我们这些API开发者提新需求。理想情况下,当API使用者遇到一个final类或方法时,会联系我们讨论他们的需求,这将引出一个更好的API。明智的做法,不要在发布版本后再标记final,毕竟final关键字总是可以在以后的版本中删除。
12.向后兼容
另一种是由开发人员不知道所做更改带来的影响造成的API意外中断。这种情况比理想种的情况更常见,而且往往很难注意到。有一些工具可以监视API变化,并通知已经引入的向后不兼容情况。Revapi就是这样一个工具,我在微软参与的几个项目中它都起到了很好的效果。
为什么要关心向后的兼容性呢?就是因为破坏兼容性对我们的用户来说真的很痛苦。有一些项目由于过于快速和随意地应对向后兼容性而遭受了相当严重的后果。
13.不要返回null
TonyHoare称null引用的发明(他创造的东西)是他的“十亿美元错误”。在Java中,我们已经非常习惯于通过返回null来处理一些错误条件。所以,对所有内容进行null检查成为了第二天性。但在许多情况下,有比直接返回null更好的方法。一些常见的用法可参阅下表:
通过保证向API调用者返回非null值,用户在他们的代码中不必到处写检查null的代码。然而重要的是,如果采用这种方式,必须确保在整个API中保持一致。如果API不能始终如一地应用模式,就很容易损害使用者对它的信任(如果不这样做,会导致用户遇到意外的空指针异常)。
译注:TonyHoare爵士,计算机领域专家,图灵奖得主,发明了快速排序算法。
14.理解何时使用Optional
Java8引入Optional是为了减少可能出现的空指针异常,因为当一个方法返回Optional时,能保证返回值非null。然后由API的调用者决定返回的Optional包含元素还是为空。换句话说,Optional
Optional返回类型最适用于以下情况:
假设有publicOptional
上面展示了正确调用API返回Optional的示例。如果大多数API使用者像下面这样处理Optional返回值,则有可能认为这是一个检查空引用的面向对象版本,并且可能不如返回null更好(或者更直观)。
API返回Optional有两条终极规则:
15.总结
无论为自己使用编写API,还是为组织中的其他人编写API,或者更广泛地将其作为开源项目或商业开发库的一部分,思考本文中列举的内容将有助于指导读者产出更高质量和更专业的结果。这不应该被简单地看作是“更多的工作”,更应该看成是对自己的一种挑战,即专注于为我们的用户提供一个愉快的、功能丰富的、高效的API。