Multi-release JARs - 好主意还是坏主意?

目录

引言

随着 Java 9 的发布,Java 运行时引入了一个名为 multi-release jars 的新功能。对于 Gradle 的我们来说,这可能是该平台最具争议的补充之一。简而言之,我们认为这是对实际问题的错误答案。这篇文章将解释我们为什么这么认为,但也解释如果您真的想构建这样的 jar,该如何构建。

Multi-release JARs,又名 MRJARs,是 Java 平台的一项新功能,包含在 Java 9 JDK 中。在本文中,我们将详细阐述采用这项技术的重大风险,并提供如果需要,如何使用 Gradle 生成和使用 multi-release JARs。

简而言之,multi-release jars 允许您打包同一类的多个版本,供不同的运行时使用。例如,如果您在 JDK 8 上运行,Java 运行时将使用该类的 Java 8 版本,但如果您在 Java 9 上运行,它将使用 Java 9 特定的实现。同样,如果为即将发布的 Java 10 版本构建了某个版本,则运行时将使用它来代替 Java 9 和默认(Java 8)版本。

multi-release JARs 的用例 #

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

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

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

multi-release JARs 的知名替代方案 #

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

但有更多理由偏爱单独的 jar

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

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

  • 一个用于 API
  • 一个用于 Java 8 运行时
  • 一个用于 Java 9
  • 一个带有本地绑定的
  • ...

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

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

管理单独 JARs 的更好方法 #

开发人员不使用单独 jar 的主要原因是它们在生产和消费方面都不切实际。这归咎于构建工具,直到 Gradle 出现,构建工具在处理这个问题上都彻底失败了。特别是,使用此解决方案的开发人员别无选择,只能依赖 Maven 非常糟糕的 classifier 功能来发布额外的工件。但是,分类器在建模情况的复杂性方面非常糟糕。它们用于各种不同的方面,从发布源代码、文档、javadocs,到发布库的变体guava-jdk5guava-jdk7、...)或不同的用途(api、fat jar、...)。实际上,没有办法表明 classifier 的依赖树不是项目本身的依赖树。换句话说,POM 已损坏,因为它既表示组件的构建方式,又表示它生成什么工件。假设您要生成 2 个不同的 jar:一个经典的 jar 和一个捆绑所有依赖项的 fat jar。实际上,Maven 会认为 2 个工件具有相同的依赖树,即使这完全错误!在这种情况下,这非常明显,但 multi-release jars 的情况完全相同!

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

变体感知依赖项管理是指模块和工件是不同的概念。使用相同的源文件,您可以针对不同的运行时,具有不同的需求。对于 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 作为运行时。那么,理想情况下,您需要编译 2 个版本的库。这意味着 2 个不同的编译器(以避免在使用 Java 8 API 时使用 Java 9 API),2 个不同的类目录,以及最终的 2 个不同的 jar。而且,您可能还想测试 2 个不同的运行时。或者,您可能想要构建 2 个 jar,但仍然想测试 Java 8 版本在 Java 9 运行时上执行时的行为(因为,这可能在生产中发生!)。

我们已经在建模方面取得了 重大进展,即使我们尚未准备就绪,这也解释了为什么我们对使用 multi-release jars 不太热衷:虽然它们解决了问题,但它们解决问题的方式是错误的,Maven Central 将会充斥着未正确声明其依赖项的库!

如何使用 Gradle 创建 multi-release JAR #

它尚未准备就绪,那么我应该怎么做?好消息是,生成正确工件的路径是相同的。在此新功能为 Java 生态系统准备就绪之前,您有 2 种不同的选择

  • 以旧的方式进行,使用反射或不同的 jar。
  • 使用 multi-release jars,(意识到您可能在这里做出错误的决定,即使有好的用例)

无论您选择哪种解决方案,单独的 jar 路线还是 multi-release jars,两者都使用相同的设置。Multi-release jars 只是错误的(默认)打包:它们应该是一个选项,而不是目标。从技术上讲,单独的 jar 和外部 jar 的源布局是相同的。这个 仓库 解释了如何使用 Gradle 创建 multi-release jar,但以下是它的工作原理的简述。

首先,您必须理解,作为开发人员,我们经常有一个非常坏的习惯:我们倾向于使用与您要生成的工件相同的 Java 版本来运行 Gradle(或 Maven)。有时甚至更糟,当我们使用更新的版本来运行 Gradle,并使用较旧的 API 级别进行编译时。但是没有充分的理由这样做。Gradle 支持 交叉编译。它允许您解释在哪里找到 JDK,并 fork 编译以使用此特定 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 或发布 multi-release jar。由于这是这篇博文的主题,让我们看看我们现在如何告诉 Gradle 我们只会生成 multi-release jar


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

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

此配置块执行 2 个不同的操作:将 Java 9 特定类捆绑到 META-INF/versions/9 directory 中,这是 MRJar 中期望的;将 multi-release 标志添加到清单中。

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

最后但并非最不重要的一点是,我们说过我们可能还想测试我们类的 2 个版本。为此,您别无选择,只能使用 fork 的 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 编译每个源集,构建 multi-release jar,然后在两个 JDK 上使用此 jar 运行单元测试。未来版本的 Gradle 将帮助您以更声明式的方式执行此操作。

结论 #

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

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

讨论