多版本JAR包——是好是坏?

目录

引言

Java 9带来了一项名为“多版本JAR包”的Java运行时新特性。对于我们Gradle团队来说,这可能是平台最具争议的添加之一。简而言之,我们认为它是一个真实问题的错误答案。本文将解释我们为什么这么认为,同时也会说明如果你确实需要,如何构建此类JAR包。

多版本JAR包,又称MRJARs,是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属性。因此,你最终会得到同一功能的两种不同实现。

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

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

多版本JAR包的常见替代方案 #

第二种解决方案,更易于维护和理解,是提供两个不同的JAR包,分别针对两个不同的运行时。基本上,你会在IDE中编写同一个类的两个实现,而构建工具的职责是正确地编译、测试并将它们打包成两个不同的artifact。例如,Guava 或 Spock 等一些工具多年来一直在使用这种方法。但这也是像Scala这样的一些语言所需要的。因为编译器和运行时有太多变体,所以二进制兼容性几乎不可能保持。

但还有更多理由倾向于使用独立的JAR包

  • 一个jar包仅仅是封装
    • 它是构建的一个构件,它恰好打包了类,但不仅仅如此:资源通常也会捆绑到jar包中。打包和处理资源都有成本。我们试图通过Gradle做的就是提高构建的性能,并减少开发者等待编译、测试和整个构建过程结果的时间。通过强制过早地构建jar包,您会创建一个多余的同步点。例如,要编译下游消费者,消费者唯一需要的是.class文件。它不需要jar包,也不需要jar包中的资源。同样,要执行测试,Gradle所需的就是类文件和资源。没有必要实际创建jar包来执行测试。jar包只在外部消费者需要时才需要(简而言之,发布)。但一旦你将artifact视为一个需求,那么你就会阻止某些任务并发运行,并且你会减慢整个构建。虽然对于小型项目来说这可能不是问题,但对于企业级构建来说,这是一个主要障碍。
  • 更重要的是,作为一个构件,JAR包不应该携带关于依赖项的信息。
    • 你的Java 9特定类的运行时依赖项与Java 8的相同,这完全没有道理。在我们非常简单的例子中它们是相同的,但对于大型项目来说,这是错误的建模:通常,用户会导入一个Java 9功能的backport库,并用它来实现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功能来发布额外的构件。然而,分类器在建模复杂情况方面非常糟糕。它们用于各种不同的方面,从发布源代码、文档、javadocs,到发布库的变体guava-jdk5guava-jdk7等)或不同的用途(api、fat jar等)。实际上,没有办法表明classifier的依赖树与项目本身的依赖树不同。换句话说,POM 是损坏的,因为它既表示组件的构建方式,又表示它产生的构件。假设您想生成两个不同的 JAR 包:一个经典 JAR 包和一个捆绑所有依赖项的fat jar。实际上,Maven 会认为这两个构件具有相同的依赖树,即使这显然是错误的!在这种情况下非常明显,但多版本 JAR 包的情况也完全相同!

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

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

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

最后但同样重要的是,我们说过我们可能也想测试我们类的两个版本。为此,你别无选择,只能使用分叉虚拟机,因为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 示例项目,立即尝试。

讨论