Опыт перевода Maven-проекта на Multi-Release Jar: уже можно, но ещё сложно

в 15:45, , рубрики: java, java 8, java 9, maven, multi release jar, stream api

У меня есть маленькая библиотека StreamEx, которая расширяет возможности Java 8 Stream API. Библиотеку я традиционно собираю через Maven, и по большей части меня всё устраивает. Однако вот захотелось экспериментов.

Некоторые вещи в библиотеке должны работать по-разному в разных версиях Java. Самый яркий пример — новые методы Stream API вроде takeWhile, которые появились только в Java 9. Моя библиотека предоставляет реализацию этих методов и в Java 8, но когда расширяешь Stream API сам, попадаешь под некоторые ограничения, о которых я здесь умолчу. Хотелось бы, чтобы пользователи Java 9+ имели доступ к стандартной реализации.

Чтобы проект продолжал компилироваться с помощью Java 8, обычно это делается средствами reflection: мы выясняем, есть ли соответствующий метод в стандартной библиотеке и если есть, вызываем его, а если нет, то используем свою реализацию. Я впрочем решил использовать 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). Вся магия с method handles теперь только в классе Java9Specific. Такой подход, кстати, спас библиотеку для пользователей Android, которые до этого жаловались, что она не работает в принципе. Виртуальная машина Андроида — это не Java, она не реализует даже спецификацию Java 7. В частности, там нет методов с полиморфной сигнатурой вроде invokeExact, и само присутствие этого вызова в байткоде всё ломает. Теперь эти вызовы вынесены в класс, который никогда не инициализируется.

Однако всё это всё равно некрасиво. А красивое решение (по крайней мере, в теории) — использовать Multi Release Jar, который появился с Java 9 (JEP-238). Для этого часть классов должна компилироваться под Java 9 и скомпилированные класс-файлы помещаться в META-INF/versions/9 внутри Jar-файла. Кроме этого надо добавить в манифест строку Multi-Release: true. Тогда Java 8 будет успешно всё это игнорировать, а Java 9 и новее загрузит новые классы вместо классов с теми же именами, которые расположены в обычном месте.

Первый раз я пытался это сделать больше двух лет назад, незадолго до выхода Java 9. Это шло очень тяжело, и я бросил. Даже просто заставить проект компилироваться компилятором из Java 9 было трудно: многие Maven-плагины просто ломались из-за изменившихся внутренних API, изменившегося формата строки java.version или ещё чего-нибудь.

Новая попытка в этом году прошла более успешно. Плагины уже по большей части обновились и работают в новой Java вполне адекватно. Первым этапом я перевёл всю сборку на Java 11. Для этого помимо обновления версий плагинов пришлось сделать следующее:

  • Изменить в JavaDoc package-info.java ссылки вида <a name="..."> на <a id="...">. Иначе JavaDoc жалуется.
  • Указать в maven-javadoc-plugin additionalOptions = --no-module-directories. Без этого были странные баги с фичей поиска по JavaDoc: каталогов с модулями всё равно не создавалось, но при переходе на результат поиска в путь добавлялось /undefined/ (привет, JavaScript). Этой фичи в Java 8 не было вообще, так что моя деятельность уже принесла приятный результат: JavaDoc стал с поиском.
  • Починить плагин публикации результатов покрытия тестами в Coveralls (coveralls-maven-plugin). Он почему-то заброшен, что странно, учитывая, что Coveralls вполне себе живёт и предлагает коммерческие услуги. Из Java 11 исчезло jaxb-api, которое плагин использует. К счастью, исправить проблему несложно средствами 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. Теперь добавляем новый блок <execution> в конфигурацию maven-surefire-plugin с argLine = -Done.util.streamex.emulateJava8=true, и тесты проходят два раза.

Хочется однако считать суммарное покрытие тестами. Я использую JaCoCo, и если ему ничего не сказать, то второй прогон просто затрёт результаты первого. Как работает JaCoCo? У него вначале выполняется цель prepare-agent, которая устанавливает Maven-свойство argLine, подписывая туда что-то вроде -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-файла. Можно это хакнуть таким образом. В конфигурацию prepare-agent дописываем destFile=${project.build.directory}. Грубо, но эффективно. Теперь argLine закончится на blah-blah/myproject/target. Да, это вовсе не файл, а каталог. Но мы можем подставить имя файла уже при запуске тестов. Возвращаемся в maven-surefire-plugin и устанавливаем argLine = @{argLine}/jacoco_java8.exec -Done.util.streamex.emulateJava8=true для Java 8 прогона и argLine = @{argLine}/jacoco_java11.exec для Java 11 прогона. Затем эти два файла несложно объединить с помощью цели merge, которую тоже предоставляет плагин JaCoCo, и мы получаем общее покрытие.

Ну вот, мы неплохо подготовились, чтобы всё-таки перейти на Multi-Release Jar. Я нашёл ряд рекомендаций, как это сделать. Первая предлагала использовать много-модульный Maven-проект. Мне не хочется: это сильное усложнение структуры проекта: там пять pom.xml, например. Городить такое ради пары файлов, которые надо компилировать на Java 9, кажется перебор. Ещё одна предлагала запускать компиляцию через maven-antrun-plugin. Сюда я решил смотреть только в крайнем случае. Понятно, что любую проблему в Maven можно решить с помощью Ant, но это как-то совсем коряво. Наконец, я увидел рекомендацию использовать сторонний плагин 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 и запаковать результат компиляции с правильными путями.
  • Добавить в MANIFEST.MF строчку Multi-Release: true.

Для того, чтобы он работал, потребовалось довольно много шагов.

  1. Поменять packaging с jar на multi-release-jar.

  2. Добавить build-extension:

    <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. Чтобы не дублировать source и target для двух плагинов, я выяснил, что они оба уважают свойства maven.compiler.source и maven.compiler.target. Я их установил и удалил версии из настроек плагинов. Однако внезапно оказалось, что maven-javadoc-plugin использует source из настроек maven-compiler-plugin'а, чтобы выяснить URL стандартного JavaDoc, который надо линковать при ссылках на стандартные методы. И вот он не уважает 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, зато можно добиться того же эффекта, модифицируя class-path тестов. Теперь всё наоборот: по дефолту библиотека работает в режиме 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. С ним оказалось сложнее всего, и я не обошёлся без посторонней помощи. Дело в том, что плагин совершенно нормально генерировал exec-файлы для Java-8 и Java-9, нормально склеивал их в один файл, однако при генерации отчётов в XML и HTML упорно игнорировал исходники из Java-9. Покопавшись в исходниках, я увидел, что он генерирует отчёт только для class-файлов, найденных в project.getBuild().getOutputDirectory(). Этот каталог, конечно, можно подменить, но у меня по факту их два: classes и classes-9. Теоретически можно скопировать все классы в один каталог, поменять outputDirectory и запустить JaCoCo, а потом поменять outputDirectory назад, чтобы не сломать сборку JAR. Но это звучит совсем некрасиво. В общем, я решил пока отложить решение этой проблемы в своём проекте, но написал ребятам из JaCoCo, что хорошо бы иметь возможность указать несколько каталогов с class-файлами.

    К моему удивлению, буквально через несколько часов в мой проект пришёл один из разработчиков JaCoCo godin и принёс pull-request, который решает проблему. Как решает? С помощью Ant, конечно! Оказалось, Ant-плагин для JaCoCo более продвинутый и умеет генерировать суммарный отчёт по нескольким каталогам исходников и класс-файлов. Стал даже не нужен отдельный шаг merge, потому что ему можно сразу скормить несколько exec-файлов. В общем, избежать Ant не удалось, ну и пусть. Главное, что заработало, и pom.xml вырос всего на шесть строчек.

    Опыт перевода Maven-проекта на Multi-Release Jar: уже можно, но ещё сложно - 1

    Я даже твитнул в сердцах:

Таким образом я получил вполне рабочий проект, который собирает красивый Multi-Release Jar. При этом даже вырос процент покрытия, потому что я убрал всякие catch (NoSuchMethodException | IllegalAccessException e), которые были недостижимы в Java 9. К сожалению, такая структура проекта не поддерживается IntelliJ IDEA, поэтому пришлось отказаться от импорта POM и настроить проект в IDE вручную. Надеюсь, в будущем появится всё-таки стандартное решение, которое будет автоматически поддерживаться всеми плагинами и инструментами.

Автор: Тагир Валеев

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js