将Maven项目转移到Multi-Release Jar的经验:已经可以,但仍然很困难

我有一个小的StreamEx库,它扩展了Java 8 Stream API。 我传统上是通过Maven收集图书馆的,在大多数情况下,我对一切都很满意。 但是,我想尝试一下。


库中的某些内容在不同版本的Java中应以不同的方式工作。 最引人注目的示例是仅在Java 9中出现的新Stream API方法,例如takeWhile 。我的库也提供了Java 8中这些方法的实现,但是当您自己扩展Stream API时,会遇到一些限制,在这里我将不赘述。 我希望Java 9+用户可以访问标准实现。


为了使项目继续使用Java 8进行编译,通常使用反射工具来完成:我们在标准库中查找是否存在相应的方法,如果存在,则调用它,如果没有,则使用实现。 但是,我决定使用MethodHandle API ,因为它声明了更少的调用开销。 您可以提前获取MethodHandle并将其保存在静态字段中:


 MethodHandles.Lookup lookup = MethodHandles.publicLookup(); MethodType type = MethodType.methodType(Stream.class, Predicate.class); MethodHandle method = null; try { method = lookup.findVirtual(Stream.class, "takeWhile", type); } catch (NoSuchMethodException | IllegalAccessException e) { // ignore } 

然后使用它:


 if (method != null) { return (Stream<T>)method.invokeExact(stream, predicate); } else { // Java 8 polyfill } 

一切都很好,但是看起来很丑。 最重要的是,在可能实现的每一个点上,您都必须编写这样的条件。 一种稍微替代的方法是将Java 8和Java 9的策略分离为同一接口的实现。 或者,为了节省库的大小,只需在单独的非最终类中实现Java 8的所有功能,然后用Java 9的继承者代替。 这样做是这样的:


 //    Internals static final VersionSpecific VER_SPEC = System.getProperty("java.version", "").compareTo("1.9") > 0 ? new Java9Specific() : new VersionSpecific(); 

然后,在使用时,您可以简单地编写return Internals.VER_SPEC.takeWhile(stream, predicate) 。 所有带有方法句柄的魔术现在仅在Java9Specific类中。 顺便说一下,这种方法为以前曾抱怨它在原则上不起作用的Android用户保存了该库。 Android虚拟机不是Java,它甚至没有实现Java 7规范,特别是,没有像invokeExact这样的具有多态签名的方法,而字节码中此调用的存在就破坏了一切。 现在,这些调用被放置在从未初始化的类中。


但是,这一切仍然很丑陋。 一个不错的解决方案(至少在理论上来说)是使用Java 9( JEP-238 )附带的Multi Release Jar。 为此,必须在Java 9下编译部分类,并将已编译的类文件放在Jar文件中的META-INF/versions/9 。 另外,您需要在清单中添加“ Multi-Release: true ”行。 这样,Java 8将成功忽略所有这些,而Java 9和更高版本将加载新类,而不是通常位置的具有相同名称的类。


我是两年多前第一次尝试这样做,就在Java 9发行不久之前。这非常困难,我退出了。 甚至仅使项目使用Java 9编译器进行编译都是困难的:许多Maven插件只是由于内部API更改, java.version字符串java.version更改或其他原因而java.version


今年的新尝试更加成功。 大多数情况下,插件已更新,并且可以在新Java中正常运行。 第一步,我将整个程序集转换为Java11。为此,除了更新插件版本外,我还必须执行以下操作:


  • 将形式为<a name="..."> JavaDoc package-info.java链接更改为<a id="..."> 。 否则,JavaDoc会抱怨。
  • 在maven-javadoc-plugin中指定additionalOptions = --no-module-directories 。 否则,JavaDoc搜索功能会出现奇怪的错误:仍未创建带有模块的目录,但是当切换到搜索结果时, /undefined/添加到路径(hello,JavaScript)。 Java 8根本没有此功能,因此我的活动已经带来了不错的结果:JavaDoc已成为搜索对象。
  • 修复用于在Coveralls中发布测试覆盖率结果的插件( coveralls-maven-plugin )。 出于某种原因,它被放弃了,这很奇怪,因为Coveralls自己生活并提供商业服务。 该插件使用的jaxb-api来自Java 11。 幸运的是,使用Maven工具解决该问题并不困难:只需在插件上显式注册依赖项即可:
     <plugin> <groupId>org.eluder.coveralls</groupId> <artifactId>coveralls-maven-plugin</artifactId> <version>4.3.0</version> <dependencies> <dependency> <groupId>javax.xml.bind</groupId> <artifactId>jaxb-api</artifactId> <version>2.2.3</version> </dependency> </dependencies> </plugin> 

下一步是测试的改编。 由于库行为在Java 8和Java 9中明显不同,因此对两个版本运行测试都是合乎逻辑的。 现在,我们在Java 11下运行所有​​内容,因此未测试Java 8特定的代码。 这是一个相当大且不平凡的代码。 为了解决这个问题,我做了一个人造笔:


 static final VersionSpecific VER_SPEC = System.getProperty("java.version", "").compareTo("1.9") > 0 && !Boolean.getBoolean("one.util.streamex.emulateJava8") ? new Java9Specific() : new VersionSpecific(); 

现在,在运行测试时只需传递-Done.util.streamex.emulateJava8=true
以测试通常在Java 8中正常运行的代码。现在,使用argLine = -Done.util.streamex.emulateJava8=true将新的<execution>块添加到maven-surefire-plugin argLine = -Done.util.streamex.emulateJava8=true maven-surefire-plugin配置中,测试将通过两次。


但是,我想考虑一下总覆盖率测试。 我使用的是JaCoCo,如果您什么都没告诉他,那么第二轮将简单地覆盖第一轮的结果。 JaCoCo如何工作? 它首先运行prepare-agent目标,该目标将设置argLine Maven属性,并在其中签名-javaagent:blah-blah/.m2/org/jacoco/org.jacoco.agent/0.8.4/org.jacoco.agent-0.8.4-runtime.jar=destfile=blah-blah/myproject/target/jacoco.exec 。 我希望形成两个不同的exec文件。 您可以通过这种方式进行破解。 将destFile=${project.build.directory}添加destFile=${project.build.directory} prepare-agent配置中。 粗糙但有效。 现在argLine将以blah-blah/myproject/target结尾。 是的,这根本不是文件,而是目录。 但是我们可以在测试开始时替换文件名。 我们返回到maven-surefire-plugin argLine = @{argLine}/jacoco_java8.exec -Done.util.streamex.emulateJava8=true maven-surefire-plugin并为Java 8运行设置argLine = @{argLine}/jacoco_java8.exec -Done.util.streamex.emulateJava8=true ,为Java 11运行设置argLine = @{argLine}/jacoco_java11.exec 。 然后,使用JaCoCo插件还提供的merge目标即可轻松地将这两个文件merge在一起,从而获得了总体覆盖范围。


更新: Godin在评论中说这是不必要的,您可以写入一个文件,它会自动将结果与前一个文件粘合在一起。 现在,我不确定为什么这种情况最初对我不起作用。


好吧,我们已经准备好切换到多版本Jar。 我发现了一些有关如何执行此操作的建议。 第一个建议使用多模块Maven项目。 我不想:这是项目结构的一个很大的复杂性:例如,有五个pom.xml。 为了几个需要在Java 9中编译的文件而四处逛逛似乎是过大的选择。 另一个建议通过maven-antrun-plugin开始编译。 在这里,我决定仅将其作为最后的选择。 显然,使用Ant可以解决Maven中的任何问题,但是这有点笨拙。 最终,我看到了使用第三方multi-release-jar-maven-plugin插件的建议 。 听起来已经不错了。


该插件建议将特定于Java新版本的源代码放在src/main/java-mr/9这样的目录中。 我仍然决定最大程度地避免类名冲突,因此Java 8和Java 9中都存在唯一的类(甚至是接口),我有以下几点:


 // Java 8 package one.util.streamex; /* package */ interface VerSpec { VersionSpecific VER_SPEC = new VersionSpecific(); } // Java 9 package one.util.streamex; /* package */ interface VerSpec { VersionSpecific VER_SPEC = new Java9Specific(); } 

原来的常数移到了新的地方,但没有其他改变。 直到现在, Java9Specific类变得更加简单:所有使用MethodHandle的蹲坐都已成功地被直接方法调用所取代。


该插件承诺会执行以下操作:


  • 替换标准的maven-compiler-plugin并使用不同的目标版本分两步进行编译。
  • 替换标准的maven-jar-plugin并使用正确的路径maven-jar-plugin编译结果。
  • 将行Multi-Release: trueMANIFEST.MF

为了使其工作,它采取了许多步骤。


  1. 将包装从jar更改为multi-release-jar


  2. 添加构建扩展名:


     <build> <extensions> <extension> <groupId>pw.krejci</groupId> <artifactId>multi-release-jar-maven-plugin</artifactId> <version>0.1.5</version> </extension> </extensions> </build> 

  3. maven-compiler-plugin复制配置。 本着<source>1.8</source><arg>-Xlint:all</arg>的精神,我只有默认版本。


  4. 我以为现在可以删除maven-compiler-plugin ,但是事实证明,新插件不能代替测试的编译,因此将其Java版本重置为默认值(1.5!),并且-Xlint:all参数都消失了。 所以我不得不离开。


  5. 为了不重复这两个插件的源和目标,我发现它们都尊重maven.compiler.sourcemaven.compiler.target的属性。 我安装了它们,并从插件设置中删除了版本。 但是,突然发现maven-javadoc-plugin使用maven-compiler-plugin设置中的源来查找标准JavaDoc的URL,在引用标准方法时必须将其链接。 现在他不尊重maven.compiler.source 。 因此,我必须将<source>${maven.compiler.source}</source>返回到maven-compiler-plugin设置。 幸运的是,不需要其他更改即可生成JavaDoc。 它可以很好地从Java 8来源生成,因为带有版本的整个轮播不会影响库API。


  6. maven-bundle-plugin ,这使我的库变成了OSGi工件。 他只是拒绝使用packaging = multi-release-jar 。 原则上,我从不喜欢他。 他在清单中添加了一组额外的行,同时破坏了排序顺序并添加了更多垃圾。 幸运的是,事实证明,通过手工编写所需的所有内容来摆脱它并不难。 当然,不是在maven-jar-plugin ,而是在新的中。 最终, multi-release-jar插件的整个配置变成了这样(我自己定义了一些属性,例如project.package ):


     <plugin> <groupId>pw.krejci</groupId> <artifactId>multi-release-jar-maven-plugin</artifactId> <version>0.1.5</version> <configuration> <compilerArgs><arg>-Xlint:all</arg></compilerArgs> <archive> <manifestEntries> <Automatic-Module-Name>${project.package}</Automatic-Module-Name> <Bundle-Name>${project.name}</Bundle-Name> <Bundle-Description>${project.description}</Bundle-Description> <Bundle-License>${license.url}</Bundle-License> <Bundle-ManifestVersion>2</Bundle-ManifestVersion> <Bundle-SymbolicName>${project.package}</Bundle-SymbolicName> <Bundle-Version>${project.version}</Bundle-Version> <Export-Package>${project.package};version="${project.version}"</Export-Package> </manifestEntries> </archive> </configuration> </plugin> 

  7. 测试。 我们不再拥有one.util.streamex.emulateJava8 ,但是您可以通过修改类路径测试来达到相同的效果。 现在情况正好相反:默认情况下,该库以Java 8模式运行,对于Java 9,您需要编写:


     <classesDirectory>${basedir}/target/classes-9</classesDirectory> <additionalClasspathElements>${project.build.outputDirectory}</additionalClasspathElements> <argLine>@{argLine}/jacoco_java9.exec</argLine> 

    重要的一点: classes-9应该在普通的类文件之前,因此我们必须将普通的文件转移到AdditionalClasspathElements,然后在其后添加。


  8. 资料来源。 我将使用source-jar,将Java 9源打包到其中是一个很好的选择,例如,IDE中的调试器可以正确显示它们。 我不太担心重复的VerSpec ,因为只有一行,仅在初始化期间执行。 我可以只保留Java 8中的选项。但是, Java9Specific.java会很好。 可以通过手动添加其他源目录来完成:


     <plugin> <groupId>org.codehaus.mojo</groupId> <artifactId>build-helper-maven-plugin</artifactId> <version>3.0.0</version> <executions> <execution> <phase>test</phase> <goals><goal>add-source</goal></goals> <configuration> <sou​rces> <sou​rce>src/main/java-mr/9</sou​rce> </sou​rces> </configuration> </execution> </executions> </plugin> 

    收集了工件之后,我将其连接到测试项目并在IntelliJ IDEA调试器中对其进行了检查。 一切工作正常:根据用于运行测试项目的虚拟机的版本,调试时最终会获得不同的资源。


    可以通过multi-release-jar插件本身来完成此操作,所以我提出了这样的建议。


  9. JaCoCo。 事实证明,这对他来说是最困难的,没有外界的帮助我是无法做到的。 事实是,为Java-8和Java-9插件完美生成的exec文件通常将它们粘合到一个文件中,但是,当以XML和HTML生成报告时,他们却顽固地忽略了Java-9的来源。 在源代码中回想一下,我看到它只为project.getBuild().getOutputDirectory()找到的类文件生成报告。 当然,可以替换此目录,但是实际上我有两个目录: classesclasses-9 。 从理论上讲,您可以将所有类复制到一个目录中,更改outputDirectory并启动JaCoCo,然后再改回outputDirectory以免破坏JAR程序集。 但这听起来很丑。 通常,我决定在项目中推迟解决此问题的方法,但是我写信给 JaCoCo的人说,能够使用类文件指定多个目录会很好。


    令我惊讶的是,几个小时后,JaCoCo godin的一名开发人员来到我的项目并提出了pull-request ,它解决了问题。 它如何决定? 当然,使用Ant! 事实证明,JaCoCo的Ant插件更高级,可以为多个源目录和类文件生成摘要报告。 他甚至不需要单独的merge步骤,因为他可以一次输入多个exec文件。 总的来说,无法避免使用Ant。 最有效的方法是,pom.xml仅增长了六行。



    我什至在推特上写道:




因此,我得到了一个非常有效的项目,该项目构建了一个漂亮的Multi-Release Jar。 同时,覆盖范围的百分比甚至增加了,因为我删除了Java 9中无法实现的所有catch (NoSuchMethodException | IllegalAccessException e) 。不幸的是,IntelliJ IDEA不支持此项目结构,因此我不得不放弃POM导入并在IDE中手动配置项目。 我希望将来还会有一个标准解决方案,所有插件和工具都将自动支持该解决方案。

Source: https://habr.com/ru/post/zh-CN472312/


All Articles