Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
У меня есть маленькая библиотека 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
.
Для того, чтобы он работал, потребовалось довольно много шагов.
Поменять 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, поэтому я внёс такое предложение.
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 вырос всего на шесть строчек.
Я даже твитнул в сердцах:
Таким образом я получил вполне рабочий проект, который собирает красивый Multi-Release Jar. При этом даже вырос процент покрытия, потому что я убрал всякие catch (NoSuchMethodException | IllegalAccessException e)
, которые были недостижимы в Java 9. К сожалению, такая структура проекта не поддерживается IntelliJ IDEA, поэтому пришлось отказаться от импорта POM и настроить проект в IDE вручную. Надеюсь, в будущем появится всё-таки стандартное решение, которое будет автоматически поддерживаться всеми плагинами и инструментами.