多版本 JAR 文件 - 好主意还是坏主意?

随着 Java 9 的发布,Java 运行时出现了一项名为多版本 JAR 文件的新功能。对于我们 Gradle 来说,这可能是平台上最具争议的添加之一。简而言之,我们认为这是一个对真实问题错误的答案。这篇文章将解释我们为什么这样认为,但也解释了如果你真的想构建这样的 JAR 文件,该如何操作。

多版本 JAR 文件,也称为 MRJAR,是 Java 平台的一项新功能,包含在 Java 9 JDK 中。在这篇文章中,我们将详细阐述采用这项技术的重大风险,并提供如何在需要时使用 Gradle 生成和使用多版本 JAR 文件。

简而言之,多版本 JAR 文件允许你打包同一类的多个版本,供不同的运行时使用。例如,如果你在 JDK 8 上运行,Java 运行时将使用该类的 Java 8 版本,但如果你在 Java 9 上运行,它将使用 Java 9 特定的实现。类似地,如果为即将发布的 Java 10 版本构建了一个版本,那么运行时将使用它,而不是 Java 9 和默认(Java 8)版本。

多版本 JAR 文件的用例

  • 优化的运行时。这解决了许多开发人员在现实世界中遇到的问题:当你开发应用程序时,你不知道它将在什么运行时执行。但是,你知道对于某些运行时,你可以实现同一类的优化版本。例如,假设你想显示应用程序当前执行的 Java 版本号。对于 Java 9,你可以使用 Runtime.getVersion 方法。但是,这是一种仅在 Java 9+ 上运行时才可用的新方法。如果你要针对更多运行时,比如 Java 8,那么你需要解析 java.version 属性。因此,你最终会得到同一个功能的 2 种不同实现。

  • 冲突的 API:另一个常见的用例是处理冲突的 API。例如,您需要支持 2 个不同的运行时,但其中一个具有已弃用的 API。目前有 2 种广泛使用的解决方案来解决这个问题。

    • 第一个是使用反射。例如,可以定义一个 VersionProvider 接口,然后是 2 个具体类 Java8VersionProviderJava9VersionProvider,在运行时加载正确的类(请注意,有趣的是,为了能够在两者之间进行选择,您可能需要解析版本号!)。这种解决方案的一个变体只是拥有一个单一类,但不同的方法,通过反射访问和调用不同的方法。
    • 更高级的解决方案是使用方法句柄,如果技术上可行的话。最有可能的是,您会发现反射既难以实现又很慢,而且您很可能是对的。

多版本 JAR 的知名替代方案

第二个解决方案更容易维护和推理,就是提供 2 个不同的 jar,针对 2 个不同的运行时。基本上,您将在 IDE 中为同一个类编写 2 个实现,并且构建工具负责将它们正确地编译、测试和打包到 2 个不同的工件中。这就是像 Guava 或 Spock 这样的工具多年来一直在使用的方法。但这也是像 Scala 这样的语言所需要的。因为编译器和运行时有太多变体,以至于几乎不可能保持二进制兼容性。

但还有更多理由更喜欢单独的 jar

  • jar 只是打包
    • 它是构建的工件,恰好打包了类,但不仅仅是类:资源通常也会打包到 jar 中。打包以及处理资源都需要成本。我们试图用 Gradle 做的是提高构建的性能,并减少开发人员必须等待看到编译、测试以及整个构建过程结果的时间。通过强制过早地在流程中构建 jar,您创建了一个多余的同步点。例如,要编译下游消费者,消费者唯一需要的是 .class 文件。它不需要 jar,也不需要 jar 中的资源。类似地,要执行测试,Gradle 只需要 class 文件加上资源。无需实际创建 jar 来执行测试。只有当外部消费者需要它时(简而言之,发布)才需要 jar。但是,一旦您将工件视为一项要求,那么您就会阻止某些任务并发运行,并且会减慢整个构建速度。虽然对于小型项目来说这可能不是问题,但对于企业级构建来说,这是一个主要障碍。
  • 更重要的是,作为一个工件,jar 不应该包含有关依赖项的信息。
    • Java 9 特定类的运行时依赖项与 Java 8 的运行时依赖项完全没有理由相同。在我们非常简单的示例中,它们是相同的,但对于更大的项目来说,这种建模是错误的:通常,用户会导入 Java 9 特性的回溯库,并使用它来实现 Java 8 版本的类。但是,如果您将两个版本打包在同一个 jar 中,那么您将把依赖树不同的东西混合到一个工件中。这意味着,通常情况下,如果您碰巧在 Java 9 上运行,您将引入一个您永远不会使用的依赖项。更糟糕的是,它会污染您的类路径,可能会为使用者造成冲突。

最终,对于单个项目,您可以生成不同的 jar,针对不同的用途

  • 一个用于 API
  • 一个用于 Java 8 运行时
  • 一个用于 Java 9
  • 一个带有原生绑定

滥用 classifier 会导致使用相同机制引用不一致的东西。通常,sourcesjavadocs jar 作为分类器发布,但实际上没有任何依赖关系。

  • 我们不想根据您获取类的方式来创建不匹配。换句话说,使用多版本 jar 会产生副作用,即从 jar 中消费和从类目录中消费不再等效。两者之间存在语义差异,这太糟糕了!
  • 根据将要创建 jar 的工具,您可能会生成不一致的 jar!到目前为止,唯一能够保证如果在 jar 中两次打包同一个类,它们都具有相同的公共 API 的工具是 jar 工具本身。出于很多充分的理由,它不一定被构建工具甚至用户使用。实际上,jar 只是一个信封。它是一个伪装的 zip。因此,根据您的构建方式,您将有不同的行为,或者您可能会生成错误的工件而没有注意到。

管理独立 JAR 的更好方法

开发人员不使用独立的 jar 文件的主要原因是它们在生产和使用方面都不切实际。问题出在构建工具上,直到 Gradle 出现之前,它们在处理这个问题上都失败得很惨。特别是,使用这种解决方案的开发人员别无选择,只能依赖 Maven 的非常糟糕的 classifier 功能来发布额外的工件。然而,分类器在模拟复杂情况方面非常糟糕。它们用于各种不同的方面,从发布源代码、文档、Javadoc 到发布库的变体guava-jdk5guava-jdk7,…)或不同的用途(api、fat jar,…)。在实践中,无法表明 classifier 的依赖关系树不是项目本身的依赖关系树。换句话说,POM 已经损坏,因为它既代表了组件的构建方式,也代表了它产生的工件。假设您想生成 2 个不同的 jar 文件:一个经典的 jar 文件,一个 fat jar 文件,它捆绑了所有依赖项。在实践中,Maven 会认为这两个工件具有相同的依赖关系树,即使这完全是错误的!在这种情况下,这非常明显,但对于多版本 jar 文件来说,情况完全相同!

解决方案是正确地处理变体。这就是我们所说的变体感知依赖管理,Gradle 知道如何做到这一点。到目前为止,此功能仅在 Android 开发中启用,但我们目前正在为 Java 和原生开发它!

变体感知依赖管理的理念是模块和工件是不同的东西。使用相同的源文件,您可以针对不同的运行时,并具有不同的需求。对于原生世界来说,这已经很明显很多年了:我们为 i386 和 amd64 编译,您无法将 i386 库的依赖项与 arm64 的依赖项混合!移植到 Java 世界,这意味着如果您针对 Java 8,则应该生成您的 jar 文件的 Java 8 版本,其中包含针对 Java 8 类格式的类。此工件将附加元数据,以便 Java 8 消费者知道使用哪些依赖项。如果您针对 Java 9,则将选择 Java 9 依赖项。就这么简单(好吧,在实践中并非如此,因为运行时只是变体的维度之一,您可以组合多个)。

当然,以前没有人这样做过,因为处理起来很复杂:Maven 肯定不会让您做如此复杂的事情。但 Gradle 使其成为可能。好消息是,我们还在开发一种新的元数据格式,它将让消费者知道他们应该使用哪个变体。简而言之,构建工具需要处理编译、测试、打包以及使用此类模块的复杂性。例如,假设您想支持 Java 8 和 Java 9 作为运行时。那么,理想情况下,您需要编译库的 2 个版本。这意味着 2 个不同的编译器(为了避免在针对 Java 8 时使用 Java 9 API)、2 个不同的类目录,以及最终的 2 个不同的 jar 文件。但是,您可能还想测试 2 个不同的运行时。或者,您可能想构建 2 个 jar 文件,但仍然想测试 Java 8 版本在 Java 9 运行时执行时的行为(因为这可能会在生产中发生!)。

我们已经取得了重大进展,朝着这个方向建模,即使我们还没有准备好,这也解释了为什么我们不太热衷于使用多版本 jar:虽然它们解决了问题,但它们以错误的方式解决了问题,Maven Central 将充斥着没有正确声明其依赖项的库!

如何使用 Gradle 创建多版本 JAR

还没有准备好,我该怎么办?好消息是生成正确工件的路径是相同的。在该新功能准备好用于 Java 生态系统之前,您有两个不同的选择

  • 使用旧方法,使用反射或不同的 jar。
  • 使用多版本 jar(即使有很好的用例,也要注意您可能会做出错误的决定)。

无论您选择哪种解决方案,无论是单独的 jar 路由还是多版本 jar,两者都使用相同的设置。多版本 jar 只是错误的(默认)打包方式:它们应该是一个选项,而不是一个目标。从技术上讲,无论是单独的 jar 还是外部 jar,源代码布局都是相同的。这个仓库解释了如何使用 Gradle 创建多版本 jar,但这里简要介绍了它的工作原理。

首先,您必须了解,作为开发人员,我们经常有一个非常糟糕的习惯:我们倾向于使用与要生成的工件相同的 Java 版本来运行 Gradle(或 Maven)。有时甚至更糟,当我们使用更新的版本运行 Gradle,并使用旧的 API 级别进行编译时。但没有充分的理由这样做。Gradle 支持交叉编译。它允许您解释在哪里找到 JDK,并分叉编译以使用此特定 JDK 编译组件。设置不同 JDK 的合理方法是通过环境变量配置 JDK 的路径,这就是我们在这个文件中所做的事情。然后,我们只需要配置 Gradle 使用适当的 JDK根据源/目标兼容性。值得注意的是,从 JDK 9 开始,不再需要提供旧的 JDK 来执行交叉编译。一个新的选项,-release,正是这样做的。Gradle 将识别此选项并相应地配置编译器

第二个关键概念是 **源集** 的概念。源集代表着一组将一起编译的源代码。一个 jar 包是由一个或多个源集的编译结果构建的。对于每个源集,Gradle 会自动创建一个相应的编译任务,你可以对其进行配置。这意味着,如果我们有 Java 8 的源代码和 Java 9 的源代码,那么它们应该放在不同的源集中。这就是我们通过创建一个 **针对 Java 9 的特定源集** 来实现的,该源集将包含我们类的专用版本。这符合实际情况,并且不会强迫你像 Maven 那样创建单独的项目。但更重要的是,它允许我们精确地配置 **这个源集将如何编译**。

同一个类的多个版本带来的挑战之一是,这样的类很少完全独立于其他代码(它依赖于主源集中找到的类)。例如,它的 API 会使用不需要 Java 9 特定源代码的类。但是,你不想重新编译所有这些通用类,也不想打包所有这些类的 Java 9 版本。它们实际上是共享的,应该保持独立。这就是 **这行代码** 的意义所在:它将配置 Java 9 源集和主源集之间的依赖关系,确保当我们编译 Java 9 特定版本时,所有通用类都在编译类路径上。

**下一步** 非常简单:我们需要告诉 Gradle 主源集将针对 Java 8 语言级别,而 Java 9 源集将针对 Java 9 语言级别。

到目前为止,我们描述的所有步骤都允许你使用之前描述的两种方法:发布单独的 jar 包,或者发布多版本 jar 包。由于这是本文的主题,让我们看看我们现在如何告诉 Gradle 我们将只生成一个多版本 jar 包。


jar {
  into('META-INF/versions/9') {
     from sourceSets.java9.output
  }

  manifest.attributes(
     'Multi-Release': 'true'
  )
}

这个配置块做了两件事:将 Java 9 特定类捆绑到 **`META-INF/versions/9` 目录** 中,这是 MRJar 中所期望的;将多版本标志添加到清单文件中。

就是这样,您已经构建了第一个 MRJar!但是不幸的是,我们还没有完成。如果您熟悉 Gradle,您会知道,如果您应用了 application 插件,您也可以使用 run 任务直接运行应用程序。但是,由于 Gradle 通常会尝试执行最少的工作量来完成您的需求,因此 run 任务被连接到使用类目录以及处理后的资源目录。对于多版本 jar,这是一个问题,因为您现在需要 jar!因此,我们别无选择,只能 创建我们自己的任务,这也是不使用多版本 jar 的另一个原因。

最后但并非最不重要的一点是,我们说我们可能还想测试我们类的两个版本。为此,您别无选择,只能使用分叉的 VM,因为 Java 运行时没有等效于 -release 标志。这里的想法是,您编写一个单元测试,但它将执行两次:一次使用 Java 8,另一次使用 Java 9 运行时。这是确保替换的类正常工作的唯一方法。默认情况下,Gradle 只创建一个测试任务,它也会使用类目录而不是 jar。因此,我们需要做两件事:创建一个特定于 Java 9 的测试任务,配置两个测试任务,以便它们使用 jar 和特定的 Java 运行时。

这可以通过以下操作轻松实现


test {
   dependsOn jar
   def jdkHome = System.getenv("JAVA_8")
   classpath = files(jar.archivePath, classpath) - sourceSets.main.output
   executable = file("$jdkHome/bin/java")
   doFirst {
       println "$name runs test using JDK 8"
   }
}

task testJava9(type: Test) {
   dependsOn jar
   def jdkHome = System.getenv("JAVA_9")
   classpath = files(jar.archivePath, classpath) - sourceSets.main.output
   executable = file("$jdkHome/bin/java")
   doFirst {
       println classpath.asPath
       println "$name runs test using JDK 9"
   }

}

check.dependsOn(testJava9)

现在,如果您运行 check 任务,Gradle 将使用正确的 JDK 编译每个源集,构建一个多版本 jar,然后使用此 jar 在两个 JDK 上运行单元测试。Gradle 的未来版本将帮助您以更具声明性的方式执行此操作。

结论

总之,我们已经看到多版本 jar 解决了许多库设计人员面临的实际问题。但是,我们认为这是问题的错误解决方案。依赖项的正确建模,以及工件和变体的耦合,以及不要忘记性能(能够并发执行更多任务)使它们成为我们正在以正确的方式解决问题的穷人解决方案,使用变体感知依赖项管理。但是,我们认为,对于简单的用例,知道 Java 的变体感知依赖项管理尚未完成,生成这样的 jar 可能很方便。在这种情况下,仅在这种情况下,这篇文章帮助您了解如何做到这一点,以及 Gradle 的理念在这种情况下与 Maven 的不同之处(源集与项目)。

最后,我们不否认在某些情况下多版本 jar 确实有意义:例如,运行时事先未知的应用程序,但这些情况是例外的,应该被视为例外。大多数问题都是针对库设计人员的:我们已经涵盖了他们面临的常见问题,以及多版本 JAR 如何尝试解决其中一些问题。将依赖项正确建模为变体可以提高性能(通过更细粒度的并行性)并降低维护开销(避免意外复杂性),而不是使用多版本 JAR。您的情况可能要求使用 MRJAR;请放心,Gradle 仍然支持它。请参阅 此 mrjar-gradle 示例项目 以立即尝试。

讨论