От переводчика: мы активно работаем над переводом платформы на рельсы Java 11 и думаем над тем, как эффективно разрабатывать Java библиотеки (такие как YARG) с учётом особенностей Java 8 / 11 так, чтобы не пришлось делать отдельные ветки и релизы. Одно из возможных решений — multi-release JAR, но и тут не всё гладко.
Java 9 включает новую опцию Java-рантайма под названием multi-release JARs. Это, возможно, одно из самых противоречивых нововведений в платформе. TL;DR: мы считаем это кривым решением серьезной проблемы. В этом посте мы объясним, почему мы так думаем, а также расскажем, как вам собрать такой JAR, если вам сильно хочется.
Multi-release JARs, или MR JARs, — это новая функция платформы Java, появившаяся в JDK 9. Здесь мы подробно расскажем о значительных рисках, связанных с использованием этой технологии, и о том, как можно создавать multi-release JARs с помощью Gradle, если вы ещё хотите.
По сути, multi-release JAR — это Java-архив, включающий несколько вариантов одного класса для работы с разными версиями среды исполнения. Например, если вы работаете в JDK 8, среда Java будет использовать версию класса для JDK 8, а если в Java 9, используется версия для Java 9. Аналогично, если версия создана для будущего выпуска Java 10, рантайм использует эту версию вместо версии для Java 9 или версии по умолчанию (Java 8).
Под катом разбираемся в устройстве нового формата JAR и выясняем нужно ли это всё.
Когда использовать multi-release JARs
- Оптимизированный рантайм. Это решение проблемы, с которой сталкиваются многие разработчики: при разработке приложения неизвестно, в какой среде оно будет исполняться. Тем не менее, для некоторых версий среды исполнения можно встроить общие версии одного и того же класса. Допустим, нужно отобразить номер версии Java, в которой работает приложение. Для Java 9 можно использовать метод Runtime.getVersion. Однако, это новый метод, доступный только в Java 9+. Если вам нужны и другие рантаймы, скажем, Java 8, придется парсить свойство java.version. В итоге, у вас будет 2 разных реализации одной функции.
Конфликтующие API: разрешение конфликтов между API — тоже распространенная проблема. Например, вам нужно поддерживать два рантайма, но в одном из них API объявлен устаревшим. Есть 2 распространенных решения этой проблемы:
- Первый — рефлексия. Например, можно задать интерфейс VersionProvider, затем 2 конкретных класса Java8VersionProvider и Java9VersionProvider, и в рантайм загрузить соответствующий класс (забавно, что для того, чтобы выбирать между двумя классами, приходится парсить номер версии!). Один из вариантов этого решения — создание единого класса с различными методами, которые вызываются с помощью рефлексии.
- Более продвинутое решение — это использование Method Handles там, где возможно. Скорее всего, рефлексия покажется вам тормозной и неудобной, и, в общем-то, так оно и есть.
Известные альтернативы подходу multi-release JARs
Второй способ, более простой и понятный, — создать 2 разных архива для разных рантаймов. По идее, вы создаете две реализации одного класса в IDE, а их компиляция, тестирование и корректная упаковка в 2 разных артефакта — задача системы сборки. Это подход, который в Guava или Spock используется на протяжении многих лет. Но он также требуется и для языков, таких как Scala. А всё потому, что существует так много вариантов компилятора и рантайма, что бинарная совместимость становится практически недостижимой.
Но есть и многие другие причины использовать отдельные JAR-архивы:
- JAR — это только лишь способ упаковки.
это артефакт сборки, который включает классы, но это не все: ресурсы, как правило, тоже включены в архив. У упаковки, как и у обработки ресурсов, есть своя цена. Команда Gradle ставит перед собой задачу улучшить качество сборки и снизить для разработчика время ожидания результатов компиляции, тестов и процесса сборки вообще. Если архив появляется в процессе слишком рано, создается ненужная точка синхронизации. Например, для компиляции классов, зависящих от API, единственное, что необходимо, — это .class-файлы. Не нужны ни jar-архивы, ни ресурсы в jar. Аналогично, для запуска тестов Gradle нужны только файлы классов и ресурсы. Для тестирования нет нужды создавать jar. Он понадобится только внешнему пользователю (то есть при публикации). Но если создание артефакта становится обязательным, некоторые задачи не могут исполняться параллельно и весь процесс сборки тормозится. Если для небольших проектов это не критично, для масштабных корпоративных проектов это основной замедляющий фактор.
- гораздо важнее, что, будучи артефактом, jar-архив не может нести информацию о зависимостях.
Зависимости каждого класса в рантайме Java 9 и Java 8 вряд ли могут быть одинаковыми. Да, в нашем простом примере так и будет, но для более крупных проектов это неверно: обычно пользователь импортирует бэкпорт библиотеки для функционала Java 9 и использует ее для реализации версии класса Java 8. Однако, если упаковать обе версии в один архив, в одном артефакте окажутся элементы с разными деревьями зависимостей. Это значит, что если вы работаете с Java 9, у вас есть зависимости, которые никогда не понадобятся. Более того, это загрязняет classpath, создавая вероятные конфликты для пользователей библиотеки.
Ну и наконец, в одном проекте вы можете создавать JARs для разных целей:
- для API
- для Java 8
- для Java 9
- с нативным биндингом
- и т.д.
Некорректное использование classifier зависимостей ведет к конфликтам, связанным с совместным использованием одного и того же механизма. Обычно sources или javadocs устанавливаются как классификаторы, но на самом деле они не имеют зависимостей.
- Мы не хотим порождать несоответствия, процесс сборки не должен зависеть от того, как вы получаете классы. Другими словами, использование multi-release jars имеет побочный эффект: вызов из JAR-архива и вызов из директории класса — теперь совсем разные вещи. У них огромная разница в семантике!
- В зависимости от того, какой инструмент вы используете для создания JAR, у вас могут получиться несовместимые JAR-архивы! Единственный инструмент, гарантирующий, что при упаковке двух вариантов класса в один архив у них будет единый открытый API, — это сама утилита jar. Которую, не без причин, не обязательно задействуют средства сборки или даже пользователи. JAR — это, по сути, “конверт”, напоминающий ZIP-архив. Так что в зависимости от того, как вы собираете его, вы получите разное поведение, а может быть, вы соберете некорректный артефакт (а вы и не заметите).
Более эффективные способы управления отдельными JAR-архивами
Основная причина того, что разработчики не используют отдельные архивы, в том, что их неудобно собирать и использовать. Виноваты инструменты сборки, которые, до возникновения Gradle, совсем не справлялись с этим. В частности, те, кто использовал этот способ, в Maven могли полагаться только на слабенькую функцию classifier для публикации дополнительных артефактов. Однако, classifier не помогает в этой непростой ситуации. Они используются для разных целей, от публикации исходников, документации, javadocs, до реализации вариантов библиотеки (guava-jdk5, guava-jdk7, …) или различных вариантов использования (api, fat jar, …). На практике нет способа показать, что дерево зависимостей classifier отличается от дерева зависимостей основного проекта. Другими словами, формат POM фундаментально сломан, т.к. представляет собой и то, как компонент собирается, и артефакты, которые он поставляет. Предположим, вам нужно реализовать 2 разных jar-архива: классический и fat jar, включающий все зависимости. Maven решит, что у 2 артефактов идентичные деревья зависимостей, даже если это очевидно неверно! В данном случае это более чем очевидно, но ситуация такая же, как и с multi-release JARs!
Решение в том, чтобы правильно обрабатывать варианты. Это умеет делать Gradle, управляя зависимостями с учетом вариантов. На данный момент эта функция доступна для разработки на Android, но мы также работаем над ее версией для Java и нативных приложений!
Управление зависимостями с учетом вариантов основывается на том, что модули и артефакты — совсем разные вещи. Один и тот же код может отлично работать в разных рантаймах, с учётом разных требований. Для тех, кто работает с нативной компиляцией, это давно очевидно: мы компилируем для i386 и amd64 и никак не можем мешать зависимости библиотеки i386 с arm64! В контексте Java это значит, что для Java 8 нужно создать версию JAR-архива “java 8”, где формат классов будет соответствовать Java 8. Этот артефакт будет содержать метаданные с информацией о том, какие зависимости использовать. Для Java 8 или 9 будут выбраны зависимости, соответствующие версии. Вот так просто (на самом деле причина не в том, что рантайм — это только одно поле вариантов, можно сочетать несколько).
Конечно, раньше этого никто не делал из-за чрезмерной сложности: Maven, по всей видимости, никогда не позволит провернуть такую сложную операцию. Но с Gradle это возможно. Сейчас команда Gradle работает над новым форматом метаданных, подсказывающим пользователям, какой вариант использовать. Проще говоря, инструмент сборки должен справляться с компиляцией, тестированием, упаковкой, а также обработкой таких модулей. Например, проект должен работать в рантаймах Java 8 и Java 9. В идеале вам нужно реализовать 2 версии библиотеки. А значит, и 2 разных компилятора (чтобы избежать использования Java 9 API при работе в Java 8), 2 директории классов и, в конце концов, 2 разных JAR-архива. А еще, скорее всего, нужно будет тестировать 2 рантайма. Или вы реализуете 2 архива, но решите протестировать поведение версии Java 8 в рантайме Java 9 (потому что такое может произойти при запуске!).
Пока эта схема не реализована, но команда Gradle существенно продвинулась в этом направлении.
Как создать multi-release JAR с помощью Gradle
Но если эта функция еще не готова, что мне делать? Расслабьтесь, корректные артефакты создаются так же. До появления вышеописанной функции в экосистеме Java, есть два варианта:
- старый добрый способ с использованием рефлексии или разных JAR-архивов;
- использовать multi-release JARs (учтите, что это может быть плохим решением, даже при наличии удачных примеров использования).
Что бы вы ни выбрали, разные архивы или multi-release JARs, схема будет одинаковая. Multi-release JARs — это, по сути, неправильная упаковка: они должны быть опцией, но не целью. Технически, расположение исходников для отдельных и внешних JAR одинаковое. В этом репозитории описано, как создать multi-release JAR с помощью Gradle. Суть процесса вкратце описана ниже.
Прежде всего, нужно всегда помнить об одной вредной привычке разработчиков: они запускают Gradle (или Maven), используя ту же версию Java, на которой планируется запуск артефактов. Более того, иногда используется более поздняя версия для запуска Gradle, а компиляция происходит с более ранним уровнем API. Но делать так нет особых причин. В Gradle возможна кросс-компиляция. Она позволяет описывать положение JDK, а также запускать компиляцию отдельным процессом, чтобы компилировать компонент с помощью этого JDK. Оптимальный способ настройки различных JDK — это настройка пути к JDK через переменные среды, как сделано в этом файле. Затем нужно только настроить Gradle для использования нужного JDK, основываясь на совместимости с исходником/целевой платформой. Стоит отметить, что, начиная с JDK 9, предыдущие версии JDK для кросс-компиляции не нужны. Это делает новая функция, -release. Gradle использует эту функцию и настроит компилятор как надо.
Еще один ключевой момент — это обозначение source set. Source set — это набор исходных файлов, которые нужно скомпилировать вместе. JAR получается в результате компиляции одного или нескольких source set. Для каждого набора Gradle автоматически создает соответствующую настраиваемую задачу компиляции. Это означает, что при наличии исходников для Java 8 и Java 9, эти исходники будут в разных наборах. Именно так все и устроено в наборе исходников для Java 9, в котором будет и версия нашего класса. Это реально работает, и вам не нужно создавать отдельный проект, как в Maven. Но самое главное, что этот способ позволяет точно настраивать компиляцию набора.
Одна из сложностей наличия разных версий одного класса в том, что код класса редко бывает независим от остального кода (у него есть зависимости с классами, не входящими в основной набор). Например, его API может использовать классы, которым не нужны специальные исходники для поддержки Java 9. В то же время, не хотелось бы перекомпилировать все эти общие классы и паковать их версии для Java 9. Они представляют собой общие классы, поэтому должны существовать отдельно от классов для конкретной JDK. Настраиваем это здесь: добавляем зависимость между набором исходников для Java 9 и основным набором, чтобы при компиляции версии для Java 9 все общие классы остались в classpath компиляции.
Следующий этап прост: нужно объяснить Gradle, что основной набор исходников будет компилироваться с уровнем API Java 8, а набор для Java 9 — с уровнем Java 9.
Все вышеописанное поможет вам при использовании обоих ранее упомянутых подходов: реализации отдельных JAR-архивов или multi-release JAR. Раз уж пост именно на эту тему, посмотрим на пример того, как заставить Gradle собрать multi-release JAR:
jar {
into('META-INF/versions/9') {
from sourceSets.java9.output
}
manifest.attributes(
'Multi-Release': 'true'
)
}
В этом блоке описаны: упаковка классов для Java 9 в директорию META-INF/versions/9, которая используется для MR JAR, и установка метки multi-release в манифест.
И все, ваш первый MR JAR готов!
Но, к сожалению, работа на этом не окончена. Если вы работали с Gradle, вы знаете, что при применении плагина application вы можете запускать приложение напрямую через задачу run. Однако из-за того, что обычно Gradle старается свести объем работы к минимуму, задача run должна использовать и директории классов, и директории обрабатываемых ресурсов. Для multi-release JARs это проблема, потому что JAR нужен немедленно! Поэтому вместо использования плагина придется создать свою задачу, и это аргумент против использования multi-release JARs.
И последнее, но не менее важное: мы упоминали, что нам потребуется тестировать 2 версии класса. Для этого можно использовать только VM в отдельном процессе, потому что эквивалента маркера -release для Java рантайма нет. Идея в том, что написать нужно только один тест, но выполняться он будет дважды: в Java 8 и Java 9. Это единственный способ убедиться, что специфичные для рантайма классы работают корректно. По умолчанию Gradle создает одну задачу тестирования, и она точно так же использует директории классов вместо JAR. Поэтому мы сделаем две вещи: создадим задачу тестирования для Java 9 и настроим обе задачи так, что они будут использовать JAR и указанные Java рантаймы. Реализация будет выглядеть так:
test {
dependsOn jar
def jdkHome = System.getenv("JAVA_8")
classpath = files(jar.archivePath, classpath) - sourceSets.main.output
executable = file("$jdkHome/bin/java")
doFirst {
println "$name runs test using JDK 8"
}
}
task testJava9(type: Test) {
dependsOn jar
def jdkHome = System.getenv("JAVA_9")
classpath = files(jar.archivePath, classpath) - sourceSets.main.output
executable = file("$jdkHome/bin/java")
doFirst {
println classpath.asPath
println "$name runs test using JDK 9"
}
}
check.dependsOn(testJava9)
Теперь при запуске задачи check Gradle будет компилировать каждый набор исходников с помощью нужного JDK, создавать multi-release JAR, затем запускать тесты с помощью этого JAR’а на обоих JDK. Будущие версии Gradle помогут вам делать это более декларативно.
Заключение
Подведем итоги. Вы узнали, что multi-release JARs — это попытка решения реальной проблемы, с которой сталкиваются многие разработчики библиотек. Однако, такое решение проблемы выглядит неверным. Корректное управление зависимостями, связывание артефактов и вариантов, забота о производительности (способности исполнять как можно больше задач параллельно) — всё это делает MR JAR решением для бедных. Эту проблему можно решать правильно при помощи вариантов. И все-таки, пока управление зависимостями при помощи вариантов от Gradle находится в стадии разработки, multi-release JARs достаточно удобны в простых случаях. В этом случае этот пост поможет вам понять, как это сделать, и как философия Gradle отличается от Maven (source set vs project).
Наконец, мы не отрицаем, что есть случаи, в которых multi-release JARs имеют смысл: например, когда неизвестно, в какой среде будет исполняться приложение (не библиотека), но это скорее исключение. В этом посте мы описали основные проблемы, с которыми сталкиваются разработчиков библиотек, и как multi-release JARs пытаются их решить. Правильное моделирование зависимостей как вариантов повышает производительность (через мелкоструктурный параллелизм) и снижает затраты на обслуживание (избегая непредвиденной сложности) по сравнению с multi-release JARs. В вашей ситуации MR JARs тоже могут понадобиться, так что об этом Gradle уже позаботился. Посмотрите на этот образец проекта и попробуйте сами.