随着Java 9的到来,Java运行时的一个新特性被称为多版本罐。对我们Gradle公司来说,这可能是该平台上最有争议的新增功能之一。TL/DR,我们认为这是对一个真实问题的错误回答。这篇文章将解释我们为什么这么想,但也会解释如果你真的想建立这样的jar,你可以怎么做。
多版本JAR,又称MRJAR,是Java平台的一个新特性,包含在Java 9 JDK中。在这篇文章中,我们将阐述采用这种技术的重大风险,并提供如果需要的话,如何用Gradle生产和消费多版本JARs。
简而言之,多版本JAR允许你对同一个类的多个版本进行打包,以便被不同的运行系统使用。例如,如果你在JDK 8上运行,Java运行时将使用该类的Java 8版本,但如果你在Java 9上运行,它将使用Java 9的具体实现。同样,如果一个版本是为即将发布的Java 10构建的,那么运行时将使用它而不是Java 9和默认(Java 8)版本。
多版本JARs的用例
-
优化的运行时。这回答了很多开发者在现实世界中面临的一个问题:当你开发一个应用程序时,你不知道它将在什么运行时中被执行。然而,你知道对于某些运行时间,你可以实现同一个类的优化版本。例如,想象一下,你想显示你的应用程序当前所执行的Java版本号。对于Java 9,你可以使用
Runtime.getVersion方法。然而,这是一个新的方法,只有当你在Java 9+上运行时才可用。如果你的目标是更多的运行时间,比如说,Java 8,那么你需要解析java.version属性。因此,你最终会得到同一功能的两种不同实现。 -
冲突的API。另一个常见的用例是处理冲突的API。例如,你需要支持2个不同的运行时,但其中一个有废弃的API。目前有2种广泛使用的解决方案来解决这个问题:
- 第一种是使用反射。例如,我们可以定义一个
VersionProvider接口,然后定义两个具体的类Java8VersionProvider和Java9VersionProvider,在运行时加载正确的类(注意,有趣的是,要在这两个类中做出选择,你可能需要解析版本号!)。这个解决方案的一个变种是有一个单一的类,但有不同的方法,通过反射访问和调用不同的方法。 - 一个更高级的解决方案是使用方法句柄,如果它在技术上是适用的。最有可能的是,你会认为反射的实现既痛苦又缓慢,而你很可能是对的。
- 第一种是使用反射。例如,我们可以定义一个
常用的多版本JARs的替代品
第二种解决方案,更容易维护和推理,是提供两个不同的jar,针对两个不同的运行时。基本上,你可以在你的IDE中为同一个类写两个实现,由构建工具负责将它们正确地编译、测试和打包成两个不同的工件。这是一些工具,例如Guava或Spock多年来一直使用的方法。但这也是Scala等一些语言所需要的。因为编译器和运行时的变体太多,二进制兼容性几乎不可能维持。
但还有更多的理由让我们更喜欢独立的jars:
- jar只是包装
- 这是构建时发生的对类进行打包的人工制品,但不仅仅是:资源通常也会被捆绑在jar中。包装,以及处理资源,都是有成本的。我们试图用Gradle做的是提高构建的性能,并减少开发者为看到编译、测试以及整个构建过程的结果所需的时间。通过强迫在过程中过早地构建一个jar,你创造了一个多余的同步点。例如,为了编译下游的消费者,消费者唯一需要的是.class文件。它不需要jar,也不需要jar中的资源。同样地,为了执行测试,Gradle所需要的是类文件,再加上资源。不需要实际创建jar来执行测试。只有在外部消费者需要它的时候才需要这个jar(简而言之,发布)。但是一旦你把工件看作是一个需求,那么你就会阻止一些任务的并发运行,你就会拖累整个构建。虽然对于小项目来说,这可能不是一个问题,但对于企业规模的构建来说,这是一个主要障碍。
- 更重要的是,作为一个工件,jar不应该携带关于依赖的信息。
- 绝对没有理由让你的Java 9特定类的运行时依赖性与Java 8的相同。在我们这个非常简单的例子中,他们会这样,但对于大型项目来说,这是错误的建模:通常,用户会导入一个Java 9功能的backport库,并使用它来实现该类的Java 8版本。然而,如果你将两个版本打包在同一个jar中,那么你就将那些没有相同依赖关系的东西混入了一个工件中。这通常意味着,如果你碰巧运行在Java 9上,你就会带来一个你永远不会使用的依赖关系。更糟糕的是,它可以(而且会)污染你的classpath,可能会给消费者带来冲突。
最终,对于一个项目,你可以生产不同的jar,针对不同的用途:
- 一个用于API
- 一个用于Java 8运行时
- 一个用于Java 9
- 一个有本地绑定的
- ...
滥用classifier 导致不一致的东西被使用相同的机制来引用。通常情况下,sources 或javadocs jars 被作为分类器发布,但实际上没有任何依赖性:
- 我们不希望根据你获取类的方式而产生不匹配。换句话说,使用多版本的jar有一个副作用,即从jar中消费和从类目录中消费不再是等同的。两者之间存在着语义上的差异,这很糟糕!
- 取决于要创建jar的工具,你可能会产生不一致的jar!到目前为止,唯一能保证如果你在一个jar中对同一个类打包两次,它们都有相同的公共API的工具,就是
jar。由于很多很好的原因,这个工具不一定被构建工具使用,甚至不被用户使用。在实践中,jar只是一个封套。它是一个伪装的zip。因此,根据你的构建方式,你会有不同的行为,或者你可能只是产生了错误的工件而从未注意到。
管理独立JARs的更好方法
开发者不使用单独的JAR的主要原因是,它们的生产和消费都不实际。错就错在构建工具上,在Gradle之前,这些工具在处理这个问题上是非常失败的。特别是,使用这种解决方案的开发者除了依靠Maven非常糟糕的classifier 功能来发布额外的工件外,别无选择。然而,分类器在模拟情况的复杂性方面非常糟糕。它们被用于各种不同的方面,从发布源代码、文档、javadocs,到发布一个库的变体(guava-jdk5,guava-jdk7, ...)或不同的使用方式(api, fat jar, ...)。而且在实践中,没有办法表明classifier 的依赖树不是项目本身的依赖树。换句话说,POM是坏的,因为它既代表了组件的构建方式,又代表了它产生的工件。假设你想生产两个不同的jar:一个是经典jar,另一个是捆绑了所有依赖项的fat jar 。在实践中,Maven会认为这两个工件的依赖树是相同的,即使这明显是错误的。在这种情况下,这一点非常明显,但对于多版本的jar来说,情况也完全一样。
解决办法是正确处理变体。这就是我们所说的变体感知依赖性管理,Gradle知道如何做到这一点。到目前为止,这个功能只在Android开发中启用,但我们目前也在为Java和本地开发。
变体感知的依赖性管理是指模块和工件是不同的野兽的想法。使用相同的源文件,你可以针对不同的运行时间,有不同的要求。对于本地世界来说,多年来这是显而易见的:我们为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个不同的编译器(以避免在针对Java 8时使用Java 9的API),2个不同的类目录,以及最终的2个不同的jars。但是,你也可能想测试这两个不同的运行时。或者,你可能想构建2个jar,但仍然想测试Java 8版本在Java 9运行时上执行时的行为(因为,这可能会在生产中发生!)。
我们在建模方面已经取得了重大进展,即使我们还没有准备好,这也解释了为什么我们不那么热衷于使用多版本的jar:虽然它们解决了一个问题,但它们解决的方式是错误的,而且Maven Central会因为没有正确声明其依赖关系的库而变得很臃肿。
如何用Gradle创建一个多版本的JAR
还没准备好,那么我应该怎么做?好消息是,生成正确工件的路径是一样的。在这个新功能为Java生态系统准备好之前,你有两个不同的选择。
- 用老方法,使用反射或不同的jars。
- 使用多版本的jars,(注意你可能会在这里做出错误的决定,即使有好的用例)。
无论你选择什么解决方案,独立的jars路线或多发布的jars,都使用相同的设置。多重发布的jars只是错误的(默认)包装:它们应该是一个选项,而不是一个目标。从技术上讲,单独的jars和外部jars的源码布局都是一样的。这个资源库解释了如何用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特定版本时,所有的通用类都在编译classpath上。
下一步其实很简单:我们需要向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 directory ,这在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确实有意义:比如说,运行时间事先不知道的应用,但这些都是例外情况,应该这样考虑。大多数问题是针对库设计者的:我们已经介绍了他们面临的常见问题,以及多版本JARs如何试图解决其中的一些问题。将依赖关系正确地建模为变体,可以提高性能(通过更细粒度的并行性),并比使用多版本JARs减少维护费用(避免意外的复杂性)。你的情况可能决定了要使用MRJARs;请放心,Gradle仍然支持它。请看这个mrjar-gradle示例项目,今天就来试试这个。