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

浙公网安备 33010602011771号