- PVSM.RU - https://www.pvsm.ru -
У меня есть маленькая библиотека StreamEx [1], которая расширяет возможности Java 8 Stream API. Библиотеку я традиционно собираю через Maven, и по большей части меня всё устраивает. Однако вот захотелось экспериментов.
Некоторые вещи в библиотеке должны работать по-разному в разных версиях Java. Самый яркий пример — новые методы Stream API вроде takeWhile
, которые появились только в Java 9. Моя библиотека предоставляет реализацию этих методов и в Java 8, но когда расширяешь Stream API сам, попадаешь под некоторые ограничения, о которых я здесь умолчу. Хотелось бы, чтобы пользователи Java 9+ имели доступ к стандартной реализации.
Чтобы проект продолжал компилироваться с помощью Java 8, обычно это делается средствами reflection: мы выясняем, есть ли соответствующий метод в стандартной библиотеке и если есть, вызываем его, а если нет, то используем свою реализацию. Я впрочем решил использовать MethodHandle API [2], потому что в нём декларируются меньшие накладные расходы на вызов. Можно заранее получить 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 [3]). Для этого часть классов должна компилироваться под 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. Для этого помимо обновления версий плагинов пришлось сделать следующее:
package-info.java
ссылки вида <a name="...">
на <a id="...">
. Иначе JavaDoc жалуется.additionalOptions = --no-module-directories
. Без этого были странные баги с фичей поиска по JavaDoc: каталогов с модулями всё равно не создавалось, но при переходе на результат поиска в путь добавлялось /undefined/
(привет, JavaScript). Этой фичи в Java 8 не было вообще, так что моя деятельность уже принесла приятный результат: JavaDoc стал с поиском.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. Я нашёл ряд рекомендаций, как это сделать. Первая [4] предлагала использовать много-модульный Maven-проект. Мне не хочется: это сильное усложнение структуры проекта: там пять pom.xml, например. Городить такое ради пары файлов, которые надо компилировать на Java 9, кажется перебор. Ещё одна [5] предлагала запускать компиляцию через maven-antrun-plugin
. Сюда я решил смотреть только в крайнем случае. Понятно, что любую проблему в Maven можно решить с помощью Ant, но это как-то совсем коряво. Наконец, я увидел рекомендацию использовать сторонний плагин multi-release-jar-maven-plugin [6]. Это уже прозвучало вкусно и правильно.
Плагин рекомендует размещать исходники специфичные для новых версий 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
.Для того, чтобы он работал, потребовалось довольно много шагов.
Поменять packaging с jar
на multi-release-jar
.
Добавить build-extension:
<build>
<extensions>
<extension>
<groupId>pw.krejci</groupId>
<artifactId>multi-release-jar-maven-plugin</artifactId>
<version>0.1.5</version>
</extension>
</extensions>
</build>
Скопировать конфигурацию из maven-compiler-plugin
. У меня там была только версия по умолчанию в духе <source>1.8</source>
и <arg>-Xlint:all</arg>
Я думал, что maven-compiler-plugin
теперь можно убрать, но оказалось, что новый плагин не подменяет компиляцию тестов, поэтому для неё версия Java сбросилась в дефолт (1.5!) и исчез аргумет -Xlint:all
. Так что пришлось оставить.
Чтобы не дублировать 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 библиотеки.
Сломался 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>
Тесты. У нас больше нет 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
, которые добавляются после.
Исходники. У меня собирается 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>
<sources>
<source>src/main/java-mr/9</source>
</sources>
</configuration>
</execution>
</executions>
</plugin>
Собрав артефакт, я подключил его к тестовому проекту и проверил в отладчике IntelliJ IDEA. Всё красиво работает: в зависимости от версии виртуальной машины, используемой для запуска тестового проекта, мы попадаем в разный исходник при отладке.
Было бы круто, чтобы это делалось само плагином multi-release-jar, поэтому я внёс [7] такое предложение.
JaCoCo. С ним оказалось сложнее всего, и я не обошёлся без посторонней помощи. Дело в том, что плагин совершенно нормально генерировал exec-файлы для Java-8 и Java-9, нормально склеивал их в один файл, однако при генерации отчётов в XML и HTML упорно игнорировал исходники из Java-9. Покопавшись в исходниках [8], я увидел, что он генерирует отчёт только для class-файлов, найденных в project.getBuild().getOutputDirectory()
. Этот каталог, конечно, можно подменить, но у меня по факту их два: classes
и classes-9
. Теоретически можно скопировать все классы в один каталог, поменять outputDirectory
и запустить JaCoCo, а потом поменять outputDirectory
назад, чтобы не сломать сборку JAR. Но это звучит совсем некрасиво. В общем, я решил пока отложить решение этой проблемы в своём проекте, но написал [9] ребятам из JaCoCo, что хорошо бы иметь возможность указать несколько каталогов с class-файлами.
К моему удивлению, буквально через несколько часов в мой проект пришёл один из разработчиков JaCoCo godin [10] и принёс pull-request [11], который решает проблему. Как решает? С помощью Ant, конечно! Оказалось, Ant-плагин для JaCoCo более продвинутый и умеет генерировать суммарный отчёт по нескольким каталогам исходников и класс-файлов. Стал даже не нужен отдельный шаг merge
, потому что ему можно сразу скормить несколько exec-файлов. В общем, избежать Ant не удалось, ну и пусть. Главное, что заработало, и pom.xml вырос всего на шесть строчек.
Я даже твитнул в сердцах:
Таким образом я получил вполне рабочий проект, который собирает красивый Multi-Release Jar. При этом даже вырос процент покрытия, потому что я убрал всякие catch (NoSuchMethodException | IllegalAccessException e)
, которые были недостижимы в Java 9. К сожалению, такая структура проекта не поддерживается IntelliJ IDEA, поэтому пришлось отказаться от импорта POM и настроить проект в IDE вручную. Надеюсь, в будущем появится всё-таки стандартное решение, которое будет автоматически поддерживаться всеми плагинами и инструментами.
Автор: Тагир Валеев
Источник [15]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/maven/334109
Ссылки в тексте:
[1] StreamEx: https://github.com/amaembo/streamex
[2] MethodHandle API: https://docs.oracle.com/javase/8/docs/api/java/lang/invoke/MethodHandle.html
[3] JEP-238: https://openjdk.java.net/jeps/238
[4] Первая: https://github.com/hboutemy/maven-jep238
[5] Ещё одна: https://in.relation.to/2017/02/13/building-multi-release-jars-with-maven/
[6] multi-release-jar-maven-plugin: https://github.com/metlos/multi-release-jar-maven-plugin
[7] внёс: https://github.com/metlos/multi-release-jar-maven-plugin/issues/10
[8] в исходниках: https://github.com/jacoco/jacoco/blob/7f5655075cadfd7c656c20d671ab9e183668280a/jacoco-maven-plugin/src/org/jacoco/maven/ReportSupport.java#L194
[9] написал: https://github.com/jacoco/jacoco/issues/965
[10] godin: https://habr.com/ru/users/godin/
[11] pull-request: https://github.com/amaembo/streamex/pull/203
[12] #Maven: https://twitter.com/hashtag/Maven?src=hash&ref_src=twsrc%5Etfw
[13] #Ant: https://twitter.com/hashtag/Ant?src=hash&ref_src=twsrc%5Etfw
[14] October 19, 2019: https://twitter.com/tagir_valeev/status/1185422598810800129?ref_src=twsrc%5Etfw
[15] Источник: https://habr.com/ru/post/472312/?utm_source=habrahabr&utm_medium=rss&utm_campaign=472312
Нажмите здесь для печати.