У меня есть маленькая библиотека 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 вырос всего на шесть строчек.



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




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

Комментарии (15)


  1. sshikov
    22.10.2019 20:46

    >А красивое решение (по крайней мере, в теории)
    После упоминания андроида эта фраза выглядит несколько странно. Не поясните? Это же фишка Java 9, как ее к андроиду-то прикручивать?

    >Понятно, что любую проблему в Maven можно решить с помощью Ant
    Любую проблему можно решить например при помощи gmaven — написав код на груви. Причем намного более быстро, гибко и удобно.


    1. lany Автор
      22.10.2019 22:16

      Не поясните? Это же фишка Java 9, как ее к андроиду-то прикручивать?

      Любая джава (и даже недоджава Андроида, я надеюсь), которая не в курсе этой фичи, будет просто игнорировать лишнюю строчку в манифесте и лишние файлы внутри META-INF. Если их не учитывать, всё остальное — вполне корректная Java-8-библиотека.


      Любую проблему можно решить например при помощи gmaven — написав код на груви

      На вкус и цвет все фломастеры разные. Я уж скорее перейду на Gradle+Kotlin script, чем куски груви буду втыкать.


      1. sshikov
        22.10.2019 22:20

        >Я уж скорее перейду на Gradle+Kotlin script, чем куски груви буду втыкать.
        Вообще-то вы предлагали Ant. Что намного хуже всего перечисленного :)


        1. lany Автор
          22.10.2019 22:24

          Почему хуже? XML внутри XML смотрится более органично, чем груви внутри XML. И это лучше, чем Gradle.kts, потому что менее кардинально и требует меньших усилий.


          1. sshikov
            22.10.2019 22:32

            Во-первых, груви не внутри xml, а где угодно, например в отдельном файле. Во-вторых, груви доступна нормальная модель POM, с которой можно делать практически что угодно. В третьих, груви мягко говоря, сильно лучше как язык (а ant ему при этом доступен через AntBuilder, если уж так захочется).

            Ну в общем, дело ваше конечно, но я этим пользуюсь примерно с момента появления (а это где-то 2006 год наверное, точнее не помню, но даже на github проекту уже не менее 7 лет), и успешно переписал таким образом не один десяток antrun. Это вообще не кардинально, а вполне естественно.


            1. lany Автор
              22.10.2019 22:46

              Дело моё, вы правы.


              1. sshikov
                23.10.2019 08:50

                Да, у вас же библиотека… а у меня по большей части приложения. Это обычно разные потребности, собрать библиотеку под разные окружения, или собрать и развернуть приложение (тоже в разных окружениях). Не могу сказать, что сложнее — но у меня обычно применение ant заканчивается не начавшись, так как он просто не умеет многие вещи, типа REST, которые нужны. Поэтому сразу груви, либо в maven, либо уже в jenkins.


  1. CyberSoft
    22.10.2019 21:56

    Будет ли IntelliJ IDEA поддерживать mr-jar? В частности maven-проекты.


    1. lany Автор
      22.10.2019 22:18

      Я не проверял, но говорят, что если это делать как многомодульный мавен-проект, то IDE взлетит из коробки. А здесь надо специально распознавать нестандартный плагин, который хотя и неплох, но — посмотрим правде в глаза — имеет всего 16 звёзд на гитхабе.


  1. reforms
    23.10.2019 10:19

    Как идея, что думаете над таким подходом взамен multi-release jar?

    package one.util.streamex;
    
    class VersionSpecificDetector {
    
        static VersionSpecific get() {
            boolean j9 = System.getProperty("java.version", "").compareTo("1.9") > 0;
            if (j9) {
                try {
                    Class<?> j9class = Class.forName("one.util.streamex.Java9Specific");
                    return (VersionSpecific) j9class.newInstance();
                } catch(ClassNotFoundException | InstantiationException | IllegalAccessException mixe) {
                    // Обработка ошибки должным образом
                    // use j8 impl
                }
            }
            // j8
            return new Java8Specific();
        }
    }
    
    interface VerSpec {
        VersionSpecific VER_SPEC = VersionSpecificDetector.get();
    }
    


    Где VersionSpecific — это интерфейс


    1. aleksandy
      23.10.2019 16:26

      Так идея была в том, чтобы не пользоваться рефлексией, нет?


    1. lany Автор
      23.10.2019 21:25

      Мне это не нравится. Придётся компилировать всё равно с разными таргетами, но сливать класс-файлы в одно место. Можно запутаться или сломать какие-нибудь тулзы.


  1. godin
    23.10.2019 22:49

    то второй прогон просто затрёт результаты первог


    У агента append=true по умолчанию и в твоем случае нет явного append=false, поэтому не затрет и можем упростить конфигурацию — github.com/amaembo/streamex/pull/206 ;)


  1. Maccimo
    25.10.2019 01:58

    Виртуальная машина Андроида — это не Java, она не реализует даже спецификацию Java 7. В частности, там нет методов с полиморфной сигнатурой вроде invokeExact, и само присутствие этого вызова в байткоде всё ломает.

    Вроде же MethodHandles в Android 8.0 (API level 26) добавили?


    1. lany Автор
      25.10.2019 06:40

      Я не слежу. Могли.