Как запустить Java-приложение с несколькими версиями одной библиотеки в 2017 году
Хочу поделиться решениями одной проблемы, с которой мне пришлось столкнуться, плюс исследование данного вопроса в контексте Java 9.
Писатель из меня ещё тот (пишу в первый раз), поэтому закидывание вкусными помидорами с указанием причин только приветствуется.
Сразу договоримся, что статья не годится в качестве руководства по:
- Java 9
- Elasticsearch
- Maven
Если по последним именам информации в сети полно, то по первому… со временем появится, по крайней мере здесь есть необходимая информация.
Представим себе простую ситуацию: разворачиваем кластер Elasticsearch и загружаем в него данные. Мы пишем приложение, которое занимается поиском в этом кластере. Поскольку постоянно выходят новые версии Elasticsearch, мы привносим в кластер новые проблемы фичи с помощью rolling upgrade. Но вот незадача — в какой-то момент у нас сменился формат хранимых данных (например, чтобы максимально эффективно использовать какую-то из новых фич) и делать reindex нецелесообразно. Нам подойдёт такой вариант: ставим новый кластер на этих же машинах — первый кластер со старой схемой данных остаётся на месте только для поиска, а поступающие данные загружаем во второй с новой схемой. Тогда нашему поисковому компоненту потребуется держать на связи уже 2 кластера.
Наше приложение использует Java API для общения с кластером, а это значит, что оно тянет в зависимостях сам Elasticsearch. Стоит отметить, что вместе с 5-ой версией вышел и Rest Client, избавляющий нас от таких проблем (а также от удобного API самого Elasticsearch), но мы переместимся во времени на момент релиза 2-ой версии.
Рассмотрим возможные решения на примере простого приложения: поиск документа в 2-х кластерах Elasticsearch 1.7 и 2.4. Код доступен на гитхабе, и повторяет структуру данной статьи (отсутствует только OSGi).
Перейдём к делу. Создадим Maven-проект следующей структуры:
+---pom.xml
+---core/
| +---pom.xml
| +---src/
| +---main/
| | +---java/
| | | +---elasticsearch/
| | | +---client/
| | | +---SearchClient.java
| | | +---Searcher.java
| | +---resources/
| +---test/
| +---java/
+---es-v1/
| +---pom.xml
| +---src/
| +---main/
| | +---java/
| | | +---elasticsearch/
| | | +---client/
| | | +---v1/
| | | +---SearchClientImpl.java
| | +---resources/
| +---test/
| +---java/
+---es-v2/
+---pom.xml
+---src/
+---main/
| +---java/
| | +---elasticsearch/
| | +---client/
| | +---v2/
| | +---SearchClientImpl.java
| +---resources/
+---test/
+---java/
Очевидно, что в одном модуле подключить несколько версий одной библиотеки не получится, поэтому проект должен быть многомодульным:
- core — здесь находится вся логика приложения;
- можно (и нужно) вынести интерфейсы для взаимодействия с Elasticsearch в отдельный модуль;
- es-v1 — реализация интерфейса для Elasticsearch 1.7.5;
- es-v2 — реализация интерфейса для Elasticsearch 2.4.5.
Модуль core содержит класс Searcher
, который является "испытателем" наших модулей es-v1 и es-v2:
public class Searcher {
public static void main(String[] args) throws Exception {
List<SearchClient> clients = Arrays.asList(
getClient("1"),
getClient("2")
);
for (SearchClient client : clients) {
System.out.printf("Client for version: %s%n", client.getVersion());
Map doc = client.search("test");
System.out.println("Found doc:");
System.out.println(doc);
System.out.println();
}
clients.forEach(SearchClient::close);
}
private static SearchClient getClient(String desiredVersion) throws Exception {
return null; // см. далее
}
}
Ничего сверхестественного: выводится версия Elasticsearch, используемая модулем, и проводится тестовый поиск через него — этого будет достаточно для демонстрации.
Взглянем на одну из реализаций, вторая почти идентична:
public class SearchClientImpl implements SearchClient {
private final Settings settings = ImmutableSettings.builder()
.put("cluster.name", "es1")
.put("node.name", "es1")
.build();
private final Client searchClient = new TransportClient(settings)
.addTransportAddress(getAddress());
private InetSocketTransportAddress getAddress() {
return new InetSocketTransportAddress("127.0.0.1", 9301);
}
@Override
public String getVersion() {
return Version.CURRENT.number();
}
@Override
public Map search(String term) {
SearchResponse response = searchClient.prepareSearch("*")
.setQuery(QueryBuilders.termQuery("field", term))
.execute()
.actionGet();
if (response.getHits().getTotalHits() > 0) {
return response.getHits().getAt(0).getSource();
} else {
return null;
}
}
@Override
public void close() {
searchClient.close();
}
}
Тоже всё просто: текущая версия, зашитая в Elasticsearch, и поиск по полю field
во всех индексах (*
), возвращающий первый найденный документ, если есть.
Проблема здесь кроется в том, как именно вызвать реализации интерфейса SearchClient
в методе Searcher#getClient
и получить желаемый результат.
Может быть, Class.forName?
Даже если вы не знаток Java, наверняка слышали, что там властвует ClassLoader. Он не позволит нам совершить задуманное, если оставить по умолчанию, поэтому такое решение влоб не сработает:
private static SearchClient getClient(String desiredVersion) throws Exception {
String className = String.format("elasticsearch.client.v%s.SearchClientImpl", desiredVersion);
return (SearchClient) Class.forName(className).newInstance();
}
Соберём, запустим и увидим результат… вполне неопределённый, например, такой:
Exception in thread "main" java.lang.IncompatibleClassChangeError: Implementing class
at java.lang.ClassLoader.defineClass1(Native Method)
at java.lang.ClassLoader.defineClass(ClassLoader.java:763)
at java.security.SecureClassLoader.defineClass(SecureClassLoader.java:142)
at java.net.URLClassLoader.defineClass(URLClassLoader.java:467)
at java.net.URLClassLoader.access$100(URLClassLoader.java:73)
at java.net.URLClassLoader$1.run(URLClassLoader.java:368)
at java.net.URLClassLoader$1.run(URLClassLoader.java:362)
at java.security.AccessController.doPrivileged(Native Method)
at java.net.URLClassLoader.findClass(URLClassLoader.java:361)
at java.lang.ClassLoader.loadClass(ClassLoader.java:424)
at sun.misc.Launcher$AppClassLoader.loadClass(Launcher.java:331)
at java.lang.ClassLoader.loadClass(ClassLoader.java:357)
at java.lang.Class.forName0(Native Method)
at java.lang.Class.forName(Class.java:348)
at elasticsearch.client.Searcher.getClient(Searcher.java:28)
at elasticsearch.client.Searcher.main(Searcher.java:10)
Хотя мог и ClassNotFoundException
бросить… или ещё чего...
Так как URLClassLoader
найдёт и загрузит первый попавшийся класс с заданным именем из заданного набора jar-файлов и директорий, это будет необязательно требуемый класс. В данном случае эта ошибка возникает из-за того, что в списке class-path библиотека elasticsearch-2.4.5.jar
идёт до elasticsearch-1.7.5.jar
, поэтому все классы (которые совпадают по имени) будут загружены для 2.4.5. Поскольку наш Searcher сначала пытается загрузить модуль для Elasticsearch 1.7.5 (getClient("1")
), URLClassLoader загрузит ему совсем не те классы...
Когда загрузчик классов имеет в своём распоряжении пересекающиеся по имени (а значит и по именам файлов) классы, такое его состояние называют jar hell (или class-path hell).
Свой ClassLoader
Становится очевидным, что модули и их зависимости нужно разнести по разным загрузчикам классов. Просто создаём URLClassLoader на каждый модуль es-v* и указываем каждому свою директорию с jar-файлами:
private static SearchClient getClient(String desiredVersion) throws Exception {
String className = String.format("elasticsearch.client.v%s.SearchClientImpl", desiredVersion);
Path moduleDependencies = Paths.get("modules", "es-v" + desiredVersion);
URL[] jars = Files.list(moduleDependencies)
.map(Path::toUri)
.map(Searcher::toURL)
.toArray(URL[]::new);
ClassLoader classLoader = new URLClassLoader(jars); // parent = app's class loader
return (SearchClient) classLoader.loadClass(className).newInstance();
}
Нам нужно собрать и скопировать все модули в соответствующие директории modules/es-v*/
, для этого используем плагин maven-dependency-plugin
в модулях es-v1 и es-v2.
Соберём проект:
mvn package
И запустим:
сент. 29, 2017 10:37:08 ДП org.elasticsearch.plugins.PluginsService <init>
INFO: [es1] loaded [], sites []
сент. 29, 2017 10:37:12 ДП org.elasticsearch.plugins.PluginsService <init>
INFO: [es2] modules [], plugins [], sites []
Client for version: 1.7.5
Found doc:
{field=test 1}
Client for version: 2.4.5
Found doc:
{field=test 2}
Бинго!
если не пропатчить JvmInfo, о чём упоминается ниже в пересборке Elasticsearch 1.7.
Совсем хардкорный случай предполагает, что модуль core тоже использует какие-нибудь утилитные методы из библиотеки Elasticsearch. Наше текущее решение уже не сработает из-за порядка загрузки классов:
- Invoke findLoadedClass(String) to check if the class has already been loaded.
- Invoke the loadClass method on the parent class loader. If the parent is null the class loader built-in to the virtual machine is used, instead.
- Invoke the findClass(String) method to find the class.
То есть в этом случае будут загружены классы Elasticsearch из core, а не es-v*. Присмотревшись к порядку загрузки, видим обходной вариант: написать свой загрузчик классов, который нарушает этот порядок, поменяв местами шаги 2 и 3. Такой загрузчик сможет загрузить не только отдельно свой модуль es-v*, но и увидит классы из core.
Напишем свой URLClassLoader, назовём его, например, ParentLastURLClassLoader:
public class ParentLastURLClassLoader extends URLClassLoader {
...
}
и переопределим loadClass(String,boolean)
, скопировав код из ClassLoader и убрав всё лишнее:
@Override
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
Class<?> c = findLoadedClass(name);
if (c == null) {
try {
if (getParent() != null) {
c = getParent().loadClass(name);
}
} catch (ClassNotFoundException e) {
}
if (c == null) {
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
Меняем местами вызовы getParent().loadClass(String)
и findClass(String)
и получаем:
@Override
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
Class<?> c = findLoadedClass(name);
if (c == null) {
try {
c = findClass(name);
} catch (ClassNotFoundException ignored) {
}
if (c == null) {
c = getParent().loadClass(name);
if(c == null) {
throw new ClassNotFoundException(name);
}
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
Поскольку наше приложение вручную будет загружать классы модуля, загрузчик должен бросить ClassNotFoundException
, если класса нигде не найдено.
Загрузчик написан, теперь используем его, заменив URLClassLoader
в методе getClient(String)
:
ClassLoader classLoader = new URLClassLoader(jars);
на ParentLastURLClassLoader
:
ClassLoader classLoader = new ParentLastClassLoader(jars);
и запустив приложение снова, видим тот же результат:
сент. 29, 2017 10:42:41 ДП org.elasticsearch.plugins.PluginsService <init>
INFO: [es1] loaded [], sites []
сент. 29, 2017 10:42:44 ДП org.elasticsearch.plugins.PluginsService <init>
INFO: [es2] modules [], plugins [], sites []
Client for version: 1.7.5
Found doc:
{field=test 1}
Client for version: 2.4.5
Found doc:
{field=test 2}
ServiceLoader API
В Java 6 добавили класс java.util.ServiceLoader, который предоставляет механизм загрузки реализаций по интерфейсу/абстрактному классу. Этот класс тоже решает нашу проблему:
private static SearchClient getClient(String desiredVersion) throws Exception {
Path moduleDependencies = Paths.get("modules", "es-v" + desiredVersion);
URL[] jars = Files.list(moduleDependencies)
.map(Path::toUri)
.map(Searcher::toURL)
.toArray(URL[]::new);
ServiceLoader<SearchClient> serviceLoader = ServiceLoader.load(SearchClient.class, new URLClassLoader(jars));
return serviceLoader.iterator().next();
}
Всё очень просто:
- ищем в директории модуля все jar-файлы,
- складываем их в загрузчик,
- пытаемся загрузить хоть одну реализацию нашего интерфейса, используя созданный загрузчик.
Чтобы ServiceLoader увидел реализации интерфейсов, нужно создать файлы с полным именем интерфейса в директории META-INF/services
:
+---es-v1/
| +---src/
| +---main/
| +---resources/
| +---META-INF/
| +---services/
| +---elasticsearch.client.spi.SearchClient
+---es-v2/
+---src/
+---main/
+---resources/
+---META-INF/
+---services/
+---elasticsearch.client.spi.SearchClient
и написать полные имена классов, реализующих этот интерфейс, на каждой строке: в нашем случае по одному SearchClientImpl
на каждый модуль.
Для es-v1 в файле будет строка:
elasticsearch.client.v1.SearchClientImpl
и для es-v2:
elasticsearch.client.v2.SearchClientImpl
Также используем плагин maven-dependency-plugin
для копирования модулей es-v* в modules/es-v*/
. Пересоберём проект:
mvn clean package
И запустим:
сент. 29, 2017 10:50:17 ДП org.elasticsearch.plugins.PluginsService <init>
INFO: [es1] loaded [], sites []
сент. 29, 2017 10:50:20 ДП org.elasticsearch.plugins.PluginsService <init>
INFO: [es2] modules [], plugins [], sites []
Client for version: 1.7.5
Found doc:
{field=test 1}
Client for version: 2.4.5
Found doc:
{field=test 2}
Отлично, требуемый результат снова получен.
Для упомянутого хардкорного случая придётся выносить интерфейс SearchClient
в отдельный модуль spi и использовать следующую цепочку загрузчиков:
core: bootstrap -> system
spi: bootstrap -> spi
es-v1: bootstrap -> spi -> es-v1
es-v2: bootstrap -> spi -> es-v2
т.е.
- создаём отдельный загрузчик для spi (в parent кидаем null — будет использован bootstrap-загрузчик),
- загружаем им spi (интерфейс
SearchClient
), - затем создаём по загрузчику на каждый модуль es-v*, у которых родительским будет загрузчик для spi,
- ...
- PROFIT!
Модули OSGi
Признаюсь сразу и честно — мне не доводилось сталкиваться с фреймворками OSGi (может оно и к лучшему?). Взглянув на маны для начинающих у Apache Felix и Eclipse Equinox, они больше походят на контейнеры, в которые (вручную?) загружают бандлы. Даже если есть реализации для встраивания, это слишком громоздко для нашего простого приложения. Если я не прав, выскажите обратную точку зрения в комментариях (да и вообще хочется увидеть, что его кто-то использует и как).
Я не стал углубляться в этом вопросе, т.к. в Java 9 модули теперь из коробки, которые мы сейчас и рассмотрим.
Нативные модули в Java?
На прошлой неделе релизнулась 9-ая версия платформы, в которой главным нововведением стали модуляризация рантайма и исходников самой платформы. Как раз то, что нам надо!
Hint: Для того, чтобы использовать модули, нужно сначала скачать и установить JDK 9, если вы ещё этого не делали.
Здесь дело осложняет только способность используемых библиотек запускаться под девяткой в качестве модулей (на самом деле, я просто не нашёл способа в IntelliJ IDEA указать class-path вместе с module-path, поэтому далее мы всё делаем в контексте module-path).
Как работает модульная система
Прежде чем переходить к модификации кода нашего приложения под модульную систему, сначала узнаем, как она работает (я могу ошибаться, поскольку сам только начал разбираться в этом вопросе).
Кроме упомянутых модулей, есть ещё слои, содержащие их. При старте приложения создаётся слой boot
, в который загружаются все указанные в --module-path
модули, из которых состоит приложение, и их зависимости (от модуля java.base
автоматически зависят все модули). Другие слои могут быть созданы программно.
Каждый слой имеет свой загрузчик классов (или иерархию). Как и у загрузчиков, модульные слои также могут быть построены иерархически. В такой иерархии модули одного слоя могут видеть другие модули, находящихся в родительском слое.
Сами модули изолированы друг от друга и по умолчанию их пакеты и классы в них не видны другим модулям. Дескриптор модуля (им является module-info.java
) позволяет указать, какие пакеты может открыть каждый модуль и от каких модулей и их пакетов зависят они сами. Дополнительно, модули могут объявлять о том, что они используют некоторые интерфейсы в своей работе, и могут объявлять о доступной реализации этих интерфейсов. Эта информация используется ServiceLoader API для загрузки реализаций интефрейсов из модулей.
Модули бывают явными и автоматическими (типов больше, но мы ограничимся этими):
- Явные модули описываются явно файлом-дескриптором
module-info.class
в корне jar-архиа (модуль или модуляризованный jar), - Автоматические модули — это библиотеки без дескриптора, помещённые в module-path; в качестве модулей такого типа предполагается использовать существующие немодуляризованные библиотеки, используемые в старом добром
class-path
.
Теперь этой информации будет достаточно, чтобы применить её на нашем проекте:
- В boot-слое у нас будет только модуль core. Если в нём будут находиться модули es-v*, то приложение не запустится из-за конфликтующих транзитивных модулей
elasticsearch.shaded
. - Класс
Searcher
будет вручную загружать модули es-v* в отдельные дочерние слои со своим загрузчиком классов, используя ServiceLoader API.
Всё так просто?...
Package Hell
Модулям не разрешено иметь пересекающиеся имена пакетов (по крайней мере в одном слое).
Например, есть некая библиотека, которая предоставляет какое-то API в публичном классе Functions
. В этой библиотеке есть класс Helpers
с пакетной областью видимости. Вот они:
com.foo.bar
public class Functions
class Helpers
На сцену выходит вторая библиотека, которая предоставляет которая дополняет функционал первой:
com.foo.baz
public class Additional
И ей требуется некоторый функционал из закрытого класса Helpers
. Выходим из положения, поместив какой-нибудь класс в этот же пакет:
com.foo.baz
public class Additional
com.foo.bar
public class AccessorToHelpers
Поздравим себя — мы только что создали себе проблему разделения пакетов (split package) с точки зрения модульной системы. Что можно сделать с такими библиотеками? Нам предлагают оставить такие библиотеки в class-path и дотянуться до них из модулей, используя автоматические модули в качестве моста. Но мы не ищем лёгких путей, поэтому используем другой вариант: докладём в библиотеку все его зависимости и получим один единственный jar-архив (известный под названиями fat jar и uber jar), его-то и можно использовать в module-path как автоматический модуль, минуя class-path. Проблемой может стать сборка такого all-in-one jar.
Elasticsearch активно использует доступ к package-private методам/полям для доступа к некоторому функционалу Lucene. Чтобы использовать его в виде автоматического модуля, сделаем из него uber jar и установим в локальный репозиторий под именем elasticsaerch-shaded
для дальнейшего использования в нашем проекте.
Собираем Elasticsearch 1.7
В первых версиях проект приложения представляет из себя единственный Maven-модуль, поэтому здесь проблем особо не возникнет: нужно поправить pom.xml
и некоторые классы, если собираем 8-кой.
Клонируем репозиторий в какую-нибудь директорию, чекаутим тег v1.7.5
и начинаем править:
- В проекте уже используется
maven-shade-plugin
, поэтоу для сборки uberjar потребуется закомментировать включение некоторых пакетов, чтобы включались все:
<!--
<includes>
<include>com.google.guava:guava</include>
<include>com.carrotsearch:hppc</include>
<include>com.fasterxml.jackson.core:jackson-core</include>
...
</includes>
-->
и желательно в оригинале, без перемещений:
<!--
<relocations>
<relocation>
<pattern>com.google.common</pattern>
<shadedPattern>org.elasticsearch.common</shadedPattern>
</relocation>
<relocation>
<pattern>com.carrotsearch.hppc</pattern>
<shadedPattern>org.elasticsearch.common.hppc</shadedPattern>
</relocation>
...
</relocations>
-->
- Придётся убрать Groovy (ломает загрузку из-за такой неоднозначности), а также логгеры (для них нет конфигов, JUL будет работать прекрасно по умолчанию), добавив
<excludes>
сразу за закоментированным узлом<includes>
:
<excludes>
<exclude>org.codehaus.groovy:groovy-all</exclude>
<exclude>org.slf4j:*</exclude>
<exclude>log4j:*</exclude>
</excludes>
- Выключим вырезание неиспользуемых классов — плагин не знает о ServiceLoader/Reflection API:
<!--<minimizeJar>true</minimizeJar>-->
- И добавим склеивание сервис-файлов с классами реализаций для ServiceLoader API в узел
<configuration>
плагина:
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer"/>
</transformers>
- С
pom.xml
закончили, осталось устранитьUnsupportedOperationException
, которое кидаетjava.lang.management.RuntimeMXBean#getBootClassPath
. Для этого найдём такую строку в классеJvmInfo
:
info.bootClassPath = runtimeMXBean.getBootClassPath();
и оборнём её в "правильные":
if (runtimeMXBean.isBootClassPathSupported()) {
info.bootClassPath = runtimeMXBean.getBootClassPath();
} else {
info.bootClassPath = "";
}
Эта информация используется всего лишь для статистики.
Готово, теперь можно собрать jar:
$ mvn package
и после компиляции и сборки получим требуемый elasticsearch-1.7.5.jar
в директории target
. Теперь его нужно установить в локальный репозиторий, например, под именем elasticsearch-shaded
:
$ mvn install:install-file > -Dfile=elasticsearch-1.7.5.jar > -DgroupId=org.elasticsearch > -DartifactId=elasticsearch-shaded > -Dversion=1.7.5 > -Dpackaging=jar > -DgeneratePom=true
Теперь этот артефакт можно использовать как автоматический модуль в нашем Maven-модуле es-v1:
<dependencies>
<dependency>
<groupId>org.elasticsearch</groupId>
<artifactId>elasticsearch-shaded</artifactId>
<version>1.7.5</version>
</dependency>
...
</dependencies>
Собираем Elasticsearch 2.4
Откатим локальные изменения и зачекаутим тег v2.4.5
. Начиная со 2-ой версии проект разбит на модули. Нужный нам модуль, выдающий elasticsearch-2.4.5.jar
— модуль core.
- Первым делом уберем снапшот, нам нужен релиз:
$ mvn versions:set -DnewVersion=2.4.5
- Теперь поищем, настроен ли где shade-плагин… и натыкаемся на такую доку:
Shading and package relocation removed
Elasticsearch used to shade its dependencies and to relocate packages. We no longer use shading or relocation.
You might need to change your imports to the original package names:
com.google.common
wasorg.elasticsearch.common
com.carrotsearch.hppc
wasorg.elasticsearch.common.hppc
jsr166e
wasorg.elasticsearch.common.util.concurrent.jsr166e
...
Нам придётся добавить shade-плагин заново в модуль core, добавив в настройках трансформер сервис-файлов и исключение логгеров:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
</execution>
</executions>
<configuration>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer"/>
</transformers>
<artifactSet>
<excludes>
<exclude>org.slf4j:*</exclude>
<exclude>log4j:*</exclude>
</excludes>
</artifactSet>
</configuration>
</plugin>
- Уберём зависимость
com.twitter:jsr166e
(там используетсяsun.misc.Unsafe
, которого в 9-ке "нет") у модуля core:
<!--
<dependency>
<groupId>com.twitter</groupId>
<artifactId>jsr166e</artifactId>
<version>1.1.0</version>
</dependency>
-->
и сменим импорты com.twitter.jsr166e
на java.util.concurrent.atomic
.
- Плагин
animal-sniffer-maven-plugin
задетектит изменение на предыдущем шаге (в 7-ке нет jsr166e), убираем:
<!--
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>animal-sniffer-maven-plugin</artifactId>
</plugin>
-->
Готово, теперь проделываем те же шаги по сборке и установке, что и для es-v1, с небольшими отличиями:
- поменять версию на 2.4.5,
- достаточно собрать только модуль core:
$ mvn clean package -pl org.elasticsearch:parent,org.elasticsearch:elasticsearch -DskipTests=true
Модули в нашем проекте
После того, как мы выяснили, что используемые нами библиотеки способны работать в модульной системе Java, сделаем из наших Maven-модулей явные Java-модули. Для этого нам потребуется в каждой директории с исходниками (src/main/java
) создать файлы module-info.java
и в них описать взаимоотношения между модулями.
Модуль core не зависит ни от каких других модулей, а только содержит интерфейс, который должны реализовать другие модули, поэтому описание будет выглядеть так:
// имя модуля - elasticsearch.client.core
module elasticsearch.client.core {
// очевидно, нужно открыть пакет с интерфейсом,
// чтобы он был доступен для модулей
exports elasticsearch.client.spi;
// и этим модулям скажем, что мы используем интерфейс SearchClient
// для динамической загрузки ServiceLoader'ом
uses elasticsearch.client.spi.SearchClient;
}
Для модулей es-v1 и es-v2 будет похожее описание:
- они используют модуль
elasticsearch.client.core
, т.к. в нём находится интерфейс, - каждый использует автоматический модуль Elasticsearch требуемой версии,
- и каждый модуль говорит, что он предоставляет реализацию для интерфейса
SearchClient
.
Итого имеем для es-v1:
// имя модуля - elasticsearch.client.v1
module elasticsearch.client.v1 {
// в core лежит SearchClient
requires elasticsearch.client.core;
// этот автоматический
requires elasticsearch.shaded;
// говорим модулю core, что у нас есть реализация его интерфейса
provides elasticsearch.client.spi.SearchClient with elasticsearch.client.v1.SearchClientImpl;
}
Для es-v2 почти всё тоже самое, только в именах должна фигурировать версия v2
.
Теперь как загрузить такие модули? Ответ на этот вопрос есть в описании класса ModuleLayer, который содержит небольшой пример загрузки модуля с ФС. Предположив, что модули es-v* находятся каждый всё в тех же директориях modules/es-v*/
, можно написать примерно такую реализацию:
private static SearchClient getClient(String desiredVersion) throws Exception {
Path modPath = Paths.get("modules", "es-v" + desiredVersion);
ModuleFinder moduleFinder = ModuleFinder.of(modPath);
ModuleLayer parent = ModuleLayer.boot();
Configuration config = parent.configuration().resolve(moduleFinder, ModuleFinder.of(), Set.of("elasticsearch.client.v" + desiredVersion));
ModuleLayer moduleLayer = parent.defineModulesWithOneLoader(config, Thread.currentThread().getContextClassLoader());
ServiceLoader<SearchClient> serviceLoader = ServiceLoader.load(moduleLayer, SearchClient.class);
Optional<SearchClient> searchClient = serviceLoader.findFirst();
if (searchClient.isPresent()) {
return searchClient.get();
}
throw new Exception("Module 'elasticsearch.client.v" + desiredVersion + "' not found on " + modPath);
}
ModuleLayer#defineModulesWithManyLoaders нам здесь не подойдёт, так как у нас получатся совсем изолированные модули и наши es-v* не смогут увидеть свои зависимости.
Итак, теперь нужно собрать все модули. Для компиляции потребуется плагин maven-compiler-plugin
поновее, на данный момент последняя версия — 3.7.0:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.7.0</version>
</plugin>
Укажем Java 9 для исходников:
<properties>
<maven.compiler.source>1.9</maven.compiler.source>
<maven.compiler.target>1.9</maven.compiler.target>
</properties>
И не забываем про maven-dependency-plugin
, поэтому пересоберём модули:
$ mvn clean package
Теперь можно запустить, и видим такой же вывод:
сент. 29, 2017 10:59:01 ДП org.elasticsearch.plugins.PluginsService <init>
INFO: [es1] loaded [], sites []
сент. 29, 2017 10:59:04 ДП org.elasticsearch.plugins.PluginsService <init>
INFO: [es2] modules [], plugins [], sites []
Client for version: 1.7.5
Found doc:
{field=test 1}
Client for version: 2.4.5
Found doc:
{field=test 2}
Может показаться, что решение с загрузчиком классов — проще, но решение из коробки просто глупо игнорировать, если только вы не собираетесь остаться на Java 8 или более ранней.
Конец
Загрузка библиотеки разных версий выливается в механизм, очень напоминающий систему плагинов/модулей. Для этого можно:
- построить свой велосипед с ClassLoader — может подойти там, где сильно
хочется проблемне хочется тащить библиотеки и разбивать на модули (можно извернуться и сделать всё в одном), - использовать ServiceLoader API — если не мешают META-INF/services,
- использовать готовые сторонние библиотеки, которые мы не рассматривали (например) — всё удобство готового и протестированного кода
и подросшего бандла - использовать мощную систему модулей Java 9 — если переход позволителен или вообще проект начинается с чистого листа.
P.S. Желаю всем джавистам удачного перехода на Java 9!
Hixon10
Хм, что-то было столько разговоров о том, что Модули из Java 9 не решают проблемы Jar Hell-а, а тут вроде бы рабочее решение.
CyberSoft Автор
На самом деле в этом вопросе мало чего поменялось, просто одно апи (ClassLoader) обернули в другое (апи модулей). Как раньше, так и сейчас эту ситуацию надо «обрабатывать» вручную.
Судя по всему, релиз откладывали в последний раз именно из-за этого.