文档提取与人工智能的完整指南
Java API 最佳实践
引言
作为工程师,每天都在编写代码,这些代码不可能完全孤立于其他所有曾经编写的软件之外。如今,软件工程中的“站在巨人的肩膀上”的比喻尤为贴切,GitHub、Stack Overflow、Maven Central 以及其他代码、支持和软件库的目录都随时触手可及。
软件是由应用程序编程接口(APIs)构建的——我们每天都使用 JDK 以及通过 Maven 或 Gradle 等工具引入的众多依赖。如果询问一个软件工程师团队是否是 API 开发者,他们通常会回答是否。然而,这种说法是不正确的!任何曾经创建过公共类或公共方法的人都应该视自己为 API 开发者。这里特意使用了“创建”一词。软件工程往往被形式化的工程过程所包围,但 API 设计同样,甚至更是一门艺术,需要创造力和多年积累的直觉,而非一种精确的科学。
API 特性
API 可以通过多种标准进行描述,以下介绍了其中的六个特性,这些特性将成为我们在本参考卡中深入讨论的重点。
1. 可理解性
作为工程师,多少次下载了 Maven 依赖库后,曾经困惑于如何开始使用 API?如果开发者不能直观地理解如何使用 API,那么这个 API 不应被认为是成功的。
开发者应考虑 API 的入口点。完整的文档有助于开发者理解整体情况,但理想的做法是确保开发者能够以最小的摩擦开始使用,因此应在文档的最上方提供简便的起步步骤。从这里开始,好的 API 尝试通过这个入口点暴露其功能,指导开发者使用 API 支持的主要用例。更高级的功能可以通过外部文档在必要时进行发现。
2. 文档完善
由于期望其他人使用我们的 API,因此应投入精力进行文档编写。本参考卡重点关注高质量、详细的 JavaDoc 内容。
3. 一致性
一个好的 API 不应让用户感到意外,而不一致是导致这种情况的一种方式。谈到一致性时,我们指的是在 API 中重复相同的概念,而不是以随意的方式引入不同的概念。例如:
重点是建立一个团队范围内的术语表和“备忘单”,以便在整个 SDK 中应用一致性的表象。
4. 适用性
在开发 API 时,必须确保其目标定位于适合预期用户的正确层级。这可以从两个方面考虑:
一个很好的例子是 JDK 中包含的 Collections APIs。用户应能够非常容易地从集合中存储和检索项目,而不必首先定义重新分配阈值、配置工厂、指定增长策略、哈希冲突策略、比较策略、负载因子、缓存策略等。开发者不应被期望了解集合框架的内部工作原理即可利用其功能。
5. 克制
创建新的 API 可能会发生得过于迅速,但应记住,对于每个新 API,我们实际上可能承诺了终身的支持。
我们 API 决策的实际成本很大程度上取决于项目及其社区——一些项目乐于不断进行破坏性更改,而另一些(如 JDK 本身)则希望尽量减少破坏。大多数项目则处于中间地带,采用语义化版本控制的方法,在主要版本中谨慎地弃用 API,然后才删除。
一些项目甚至会包含各种标记,以指示实验性、测试版或预览功能,使其在发布前接受反馈,然后再确定最终 API。常见的方法是通过注解引入这些新实验性功能,并在 API 准备好后移除注解。@Deprecated
6. 可演进性
每一个 API 决策都可能让我们陷入新的困境。在做出 API 决策时,我们必须花时间将其放在 SDK 未来版本的更广泛背景下进行考虑。
API 作为契约
API 必须被视为一种契约——当我们将 API 提供给其他开发者时,我们承诺了特定的功能。工程师常常难以不修改自己的代码,因为他们不断设想新的、更好的方法(即使在不工作时也是如此)。对 API 进行修订以试图改进,应该在经过充分考虑后进行,因为这可能会引入破坏性更改和下游开发者的错误。
在 API 的 1.0.0 版本发布前,我们应该感到有实验的自由。在一些项目中,达到 1.0.0 版本是 API 锁定的时点(至少在 2.0.0 版本发布之前)。然而,在其他项目中,可能会有更多的灵活性,可以持续实验和改进 API。实际上,通过明智的弃用过程,可以在较长时间内以向后兼容的方式演进 API,而不会限制引入更好方法的能力。
理由的重要性
维护最简单的 API 就是没有 API,因此,对于构成我们 API 的每个方法和类,要求有充分的理由是极其重要的。在设计 API 的过程中,我们应经常问自己一个问题:“这真的需要吗?”我们应确保 API 能够自我支撑,为其持续存在带来必要的功能。
自我检验
作为 API 开发者,我们必须小心确保所创建的 API 确实对其声明的目的有用。我们需要具备开发者的同理心,从开发者的角度来审视问题,而不是仅从自身的角度。确保这一点的最佳方式是“自我检验”。换句话说,重要的是确保在 API 开发过程中,不仅自己使用它,更重要的是由可信赖的“真实用户”使用它。
引入“真实用户”的价值在于帮助我们避免失去自我克制,仅根据对 API 的深入理解来添加功能。“真实用户”有助于平衡这一点,确保我们只修复那些真正被认为是破坏性的地方。
在使用我们的 API 编写应用程序时,应利用这个机会查找那些工作不流畅(或意图不明确)的代码、重复或冗余的代码,以及那些迫使开发者在过于低层次或过于高层次的抽象级别上工作的地方。
API 文档
在开发 SDK 时,有两种文档对开发者至关重要:JavaDoc 和更深入的文章(如 Microsoft 发布的 Java on Azure 教程)。这两者对开发者都同样重要,但它们服务的目的不同。在这份 Refcard 中,我们将重点讨论 JavaDoc,因为它与 API 开发者的兴趣更为相关。
JavaDoc 应该充当 API 的规范。负责编写 API 的工程师应将确保 JavaDoc 完整作为其工作的一部分,包括类级别和方法级别的概述,指定预期的输入、输出、异常情况及其他细节。虽然这种文档充当了规范,但重要的是它不应成为对程序员的过度详细指南,也不讨论实现如何工作。
在理想情况下,创建高质量的 JavaDoc 还应进一步包含用户可以复制粘贴到自己软件中的代码片段,以启动自己的开发。这些代码片段无需长篇大论,最好限制在五到十行以内。随着用户开始提出对 API 的问题,这些代码片段可以逐步添加到相关类或方法的 JavaDoc 中。
JavaDoc 的价值不仅仅在于为其他开发者提供文档,它也可以帮助我们。因为 JavaDoc 提供了一个经过筛选的 SDK 视图,只显示公开使用的 API。如果我们建立定期生成 JavaDoc 的惯例,可以检查 API 中的问题,如缺失的 JavaDoc、泄露的实现类或外部依赖项,以及其他与预期不符的内容。
由于大多数 Java 项目都基于 Maven 或 Gradle,为项目生成 JavaDoc 通常很简单,只需分别运行 mvn javadoc:javadoc
或 gradle javadoc
。定期生成这些文档(特别是配置为在任何错误或警告时失败)可以确保我们始终能够早期发现 API 问题,并提醒自己哪些 API 需要更多的 JavaDoc 内容。
JavaDoc 的行为契约
JavaDoc 的一个未充分利用的方面是用于指定行为契约。例如,Arrays.stableSort()
方法保证其“稳定性”(即相等的元素不会被重新排序)。这种保证没有简单的方法可以作为 API 的一部分指定(除非使我们的 API 变得笨重,例如 Arrays.sort()
),但 JavaDoc 是一个理想的地点来进行说明。
然而,如果我们将行为契约作为 API 的一部分添加进来,那么它就成为了我们 API 的一部分。我们不能以相同的考虑级别来更改这些行为契约,因为这可能会对用户产生下游问题。
JavaDoc 标签
JavaDoc 带有一些标签,如 @link
、@param
和 @return
,这些标签为 JavaDoc 工具提供了更多上下文,并在生成 HTML 输出时实现了更丰富的体验。在编写 JavaDoc 内容时,牢记这些标签的使用非常有用,以确保在相关情况下都能使用它们。有关每个标签的使用时机,请参考 Java 平台标准版工具参考文档中的“标签注释”部分。
一致性
如今,软件开发很少由一个人完成,即使是这样,人类的条件也是如此变化无常,他们今天认为好的东西,可能明天就被认为是错误的。幸运的是,在我们设计 API 时,我们有一个明确的记录,即我们的公共 API,这使得识别偏离这一形成约定的内容变得相对容易。
拥有一致的 API 的短期好处是可以减少用户的挫败感,而长期好处是,一致的 API 确保当终端用户到达 API 的新部分时,他们能够更容易地直观地理解如何使用它。
一些关于一致性的更重要的考虑因素包括:
返回类型
理想情况下,所有必须返回集合的 API 应保持一致,使用少数几个类,而不是所有可能的类。例如,一个好的集合返回子集可能包括 List
、Set
、Iterator
和 Collection
(在这种情况下,永远不要使用 Iterable
、Stream
和其他类型——但请注意,这些都是有效的返回类型——只是为了示例,它们没有被包含在这个子集中)。相关地,如果 API 努力避免在大多数情况下返回 null
(对于某种返回类型),最好是永远不要为该返回类型返回 null
。
方法命名模式
开发者依赖 IDE 自动完成输入,因此考虑 API 命名的重要性,以确保相关概念在用户的自动完成弹出列表中相邻出现。一个 API 应有一套 well-established 的术语,并在类型名、方法名、参数名、常量等中始终如一地使用这些术语。像 Type.of(...)
、Type.valueOf()
、Type.toXYZ()
、Type.from(...)
等方法名应一致使用,而不应混用。我们的目标是建立一个团队范围的词汇表,在整个 API 中保持一致。
参数顺序
重载方法以接受不同数量或类型的参数的 API 应始终确保参数的顺序一致且符合逻辑。在某些情况下,我们传递给方法的参数形成某种逻辑分组,这时引入一个中间参数类型来包装这些参数可能是有意义的。这可以减少我们在后续版本中需要重载方法以接受更多参数的风险。这也有助于实现我们 API 的可进化性。
最小化 API
API 开发者的自然本能是编写尽可能多的 API,以提供更多的便利,但这会带来两个问题:
所有 API 开发者应该首先理解其 API 所需的关键用例,并将其 API 设计成支持这些用例。他们应该抵制添加更多便利功能的冲动(认为通过添加一个新方法可以节省开发者编写更多代码的时间)。
尽管如此,需要明确的是,便利 API 在任何优秀的 API 中都扮演着至关重要的角色,特别是在实现我们使 API 易于理解的目标时。挑战在于确定哪些功能值得被接受,哪些功能因价值不足而应被拒绝。例如,JDK 中的 List.add(int, Object)
方法是一个良好的便利 API,避免了开发者总是需要调用 List.add(Object)
。
在与 Oracle JDK 团队的工程师 Stuart Marks 讨论此话题时,他提供了以下见解:
防止泄漏
确保实现类和外部依赖类不会以公共 API 的返回类型或参数类型泄漏是至关重要的。应采取适当措施确保这些类被隐藏。
隐藏实现类的主要方法有两个:
当我们通过 API 泄漏外部依赖时,我们无意中增加了 API 的表面区域,包含了所有被泄漏的依赖类所暴露的 API,同时也使我们的 API 受制于超出我们控制范围的 API。如果发现我们正在暴露外部依赖项,应该考虑这是否是一个期望的结果,或者是否需要采取措施进行反制。可选的措施包括:移除泄漏外部依赖的 API,或编写一个包装类来封装被泄漏的类,从而避免暴露实际的类。
理解Protected
在 Java 中,protected
关键字常常被误解和过度使用。简而言之,protected
成员用于与子类通信,而 public
成员用于与调用者通信。
确实,在 API 开发者的工具包中,protected
可以是一个非常有用的工具,但除非从一开始就将其设计到类中,否则通常会被不正确地使用,导致类看似可扩展,但实际上却无法扩展。实际上,有时 protected
关键字可能会像一种病毒一样感染类,因为 API 用户会要求将更多方法设置为 protected
或 public
以满足他们的需求。
此外,API 开发者需要理解,方法的 protected
访问修饰符和 public
修饰符一样,都构成了 API 的公开接口。这一点常常被初学者误解,从而对其最终造成不利影响。
有意的继承
作为 API 开发者,我们必须在提供开发人员所需的功能和灵活性与我们自身逐步演进 API 的能力之间取得平衡。确保我们保持一定程度的控制的一种方式是使用 final
关键字。通过将我们的类或方法标记为 final
,我们向开发人员发出信号,表示在当前时刻,他们不能考虑扩展或重写这些特定的类和方法。
final
对于 API 开发者的价值在于,有时我们的 API 并不完美,许多开发人员倾向于绕过这些缺陷,尝试在他们的版本中修补,从而继续解决下一个问题。这种做法往往会为他们自己以及最终为我们作为 API 开发者创造新的问题。理想情况下,当 API 用户遇到类或方法时,他们会联系我讨论他们的需求,这可能会导致更好的 API。毕竟,final
关键字可以在后续版本中移除,但在已发布之后,最好不要将某些东西标记为 final
。
向后兼容性
在 API 演进中,添加新的 API 通常是可以接受的,但移除或更改现有的 API 是不推荐的。原因在于,添加新 API 通常是向后兼容的,而移除或更改现有 API 则可能导致向后不兼容,即用户在升级到新版本时,原有依赖的 API 可能不再以他们的代码期望的方式存在,从而导致破坏。
有时我们确实需要进行向后不兼容的更改,例如当 API 设计出现错误或遗漏了需求的某个方面时。这种情况下,重要的是尽可能清晰地与用户沟通这些更改。使用 @Deprecated
注解和相关的 JavaDoc 标签是一个良好的开始,但这仅在我们有清晰的政策来规定何时允许破坏性更改时才有效。常见的做法是采用语义版本控制策略,仅在主要版本(MAJOR)中进行不兼容的 API 更改。在这种策略中,任何计划更改或移除的内容都会被标记为弃用,但直到下一个主要版本才实际移除或修改。如果采用这种策略,重要的是向外界清楚地传达这一计划。
另一方面,意外的 API 破坏是常见的问题,通常由于开发人员对他们的更改可能产生的影响缺乏了解。这种情况往往难以察觉,但可以使用工具如 Revapi 来监控 API 的变化并通知你引入的向后不兼容问题。
兼容性问题不仅涉及名称和方法签名,更重要的是行为(即实现)的更改也可能导致破坏性更改。甚至有观点认为每个更改都是不兼容的,因为即使是修复 bug 也可能破坏依赖该 bug 的用户!
我们为何要关注向后兼容性?因为破坏兼容性会给用户带来极大的痛苦。有些项目因为过于随意地处理向后兼容性问题而遭遇了严重后果。
不要返回 null
托尼·霍尔(Sir Tony Hoare)曾称返回 null
的发明是他“价值十亿美元的错误”。在 Java 中,我们习惯于通过返回 null
来处理一些错误条件,导致在编写代码时几乎成了自然而然的行为,然而在许多情况下,存在更好的选择,而不是返回 null
。
保证 API 始终返回非 null 值可以使用户不必在代码中处理繁琐的 null 检查。然而,如果采用这种方法,就必须确保在整个 API 中始终如一地应用这一模式。如果 API 在一致性上出现问题,会很容易削弱用户对 API 的信任,导致意外的空指针异常。
理解何时使用Optional
Java 8 引入了 Optional
作为减少空指针异常的手段。当一个方法返回 Optional
类型时,它保证返回的值不会为 null
。消费者需要判断 Optional
是否包含元素或为空。换句话说,Optional
可以看作是一个最多包含一个元素的容器。
Optional的最佳使用场景:
- 可能无法返回结果的情况: 如果一个结果可能无法被返回,并且 API 消费者需要在这种情况下执行不同的操作,
Optional
是合适的返回类型。 - 避免空值检查:
Optional
提供了许多便利方法,如orElse
、orElseGet
、orElseThrow
和ifPresent
,以帮助处理可能为空的情况,避免直接使用null
值。
示例代码:
public Optional<Car> getFastest(List<Car> cars) {
// 假设 getFastest 方法返回 Optional<Car>,如果 cars 列表为空,则返回 Optional.empty()
}
// 返回值为 Optional<Car>,如果为空,则可以映射到无效值
Car fastestCar = getFastest(cars).orElse(Car.INVALID);
// 如果替代值的计算比较昂贵,可以使用 Supplier 仅在 Optional 为空时生成替代值
Car fastestCar = getFastest(cars).orElseGet(() -> searchTheWeb());
// 也可以选择抛出异常
Car fastestCar = getFastest(cars).orElseThrow(MissingCarsException::new);
// 对于非空的 Optional,可以提供 lambda 表达式进行操作
getFastest(cars).ifPresent(this::raceCar);
不建议使用Optional的场景:
- **
Optional<Collection<T>>
**: 永远不要从方法中返回Optional<Collection<T>>
,因为这可以通过返回一个空集合更简洁地表示,如前面提到的建议。返回空集合比返回Optional<Collection<T>>
更简明。 Optional
的不当使用: 不要将Optional
用作方法参数类型,或在 API 中不适当地使用。Optional
主要用于方法返回类型,不应该用于方法参数或用于处理没有元素的情况。
这些规则和最佳实践可以帮助 API 设计者决定何时以及如何使用 Optional
,以提高代码的可读性和可靠性。
结论
本文涵盖了所有工程师在编写公共 API 时应牢记的一系列考虑因素。从高层次来看,读者应该认识到精心设计 API 的重要性。如果之前没有意识到这一点,读者应当开始体会到 API 设计的艺术性,提升这方面技能需要通过实践和反馈——无论是来自导师还是用户。与许多艺术形式一样,API 设计的成功不在于能够加入多少内容,而在于能够去除多少不必要的东西。因此,挑战在于简约、一致性、意图明确,最重要的是考虑——不仅是 API 本身,更是 API 的最终用户。我们必须不断练习开发者的同理心,以确保我们始终保持对最终用户需求的敏锐关注。
无论我们是在为自己、组织内的其他人,还是更广泛的开源项目或商业库编写 API,考虑本 Refcard 中概述的价值观都将有助于我们实现更高质量和更专业的成果。这不应被视为“更多工作”,而是一个挑战,让我们专注于将 API 打造成一个令人愉悦、功能齐全且高效的工具。