Pull to refresh
176.2
JUG Ru Group
Конференции для Senior-разработчиков

Разрешение конфликтов в транзитивных зависимостях — Хороший, Плохой, Злой

Reading time 6 min
Views 44K

Вместо предисловия


В ближайшую субботу мы с EvgenyBorisov будем выступать в Питере на JUG.ru. Будет много веселого трэша и интересной инфы (иногда не разберешь, где проходит граница), и одно из моих выступлений будет посвящено WTF-нутости модульной разработки программ. Я расскажу несколько ужастиков, один из которых будет о том, как все пытаются быстро, гибко и корректно описать зависимости в проекте, и что из этого обычно получается. Интересно? Тогда добро пожаловать в ад!


Скорее, конечно, «Хороший, Удобный и WTF-ный».


Чуть-чуть теории...


Что Такое Менеджер Зависимостей и Зачем Он Нужен?

Любой современный инструмент сборки (особенно в мире JVM) включает в себя (либо имеет легко подключающийся) менеджер зависимостей (a.k.a. dependency manager). Самые известные, это, конечно, Apache Maven, Apache Ivy (менеджер зависимостей для Apache Ant), и Gradle.
Одна из главных функций инструментов сборки в мире JVM это создавать classpath-ы. Используются они во время процесса сборки много где — для компиляции кода, для запуска тестов, для сборки архивов (war-ов, ear-ов, или дистирбутивов и установщиков) и даже для настройки проектов в IDE.
Для облегчения процесса нахождения в сети, скачивания, хранения и конфигурации зависимостей и существуют менеджеры зависимостей. Вы декларируете, что вам нужен, например, commons-lang, и вуаля, он у вас есть.

Что такое транзитивные зависимости?

Транзитивная зависимость — это тот артефакт, от которого зависит прямая зависимость проекта. Представьте себе следующую ситуацию:

Наш проект A зависит от двух артефактов — E и B. На этом прямые зависимости заканчиваются и начинаются транзитивные (C, D). В итоге мы получаем цепочки зависимостей, артефакты в которых могут повторяться (D, в нашем примере)

Зачем нужны транзитивные зависимости?

Очевидно, не для компиляции. Но, для всего остального, пожалуй, нужны — эти артефакты должны находиться в сборках архивов, они должны находиться в classpath для запуска тестов, и пути к ним должны быть отданы через API для интеграции с IDE.

Как может образоваться конфликт?

Если мы посмотрим на диаграмму выше, то увидим тот самый конфликт. В classpath проекта A должны находиться и артефакт D версии 1 (от него зависит Е), и артефакт D версии 2 (от него зависит C)!

Почему это плохо?

JVM (и javac) определяет уникальность класса по его имени (и classloader-у, но в нашем простом примере все классы загружаются одним classloader-ом). В случае, когда в classpath встречаются два класса с одинаковым именем, загружен будет только первый. Если предположить, что в D1 и в D2 находятся классы с одинаковым именем (согласитесь, скорее всего, так и есть), то класс из jar-а, который будет прописан в сгенерированном classpath-е вторым просто не будет загружен. Какой из них будет первый? Это зависит от логики менеджера зависимостей и, в общем случае, неизвестно.
Как вы понимаете, это и есть конфликт:


Что делать?


Кто виноват понятно (Java, а не те, о ком вы подумали), а вот что можно сделать?
Есть несколько стратегий разрешения конфликтов в транзитивных зависимостях (некоторые из них логичные, другие — абсурдные), но, естественно, серебряной пули нет. Давайте посмотрим на некоторые из них:
  • Latest. Стратегия «Новейший» подразумевает обратную совместимость. Если D2 полностью совместим с D1, то оставив в classpath только более новый артефакт (D2) мы получим корректную работу C (ведь он написан под D2), но и корректную работу E (ведь если D2 обратно-совместим, то он работает точно так-же как D1, под который и написан E). Эта стратегия бывает двух подвидов — новейший по версии, и новейший по дате. Чаще всего они сработают одинаково (кроме случаев, в которых нет).
    В случае нашего примера, при использовании latest в classpath окажется D2.
  • Fail (a.k.a. Strict). При этой стратегии сборка упадет в тот момент, когда менеджер зависимостей обнаружит конфликт. Естественно, самая безопасная, но и самая трудоемкая стратегия.
    В случае нашего примера, при использовании fail сборка упадет.
  • All (a.k.a. No-conflict). «И то, и другое, и можно без хлеба» значит, что и D1 и D2 из нашего примера окажутся в classpath-е (в произвольном порядке). Ад? Ад! Но в случае использования технологий изолирования classpath-а (путем загрузки разных модулей разными classloader-ами), вполне может быть не только полезен, но и необходим.
    В случае нашего примера, при использовании all в classpath окажутся и D1, и D2.
  • Nearest. Стратегия «Ближайший» это целиком и полностью великолепный WTF, про который я с удовольствием расскажу ниже. Stay tuned.
  • Custom. В этом случае менеджер зависимостей спросит у вас, что изволит барин. Это, конечно, «ручное управление», но иногда может быть весьма полезно. Вот пример псевдокода на псевдогруви:
    coflictManager = {artifact, versionA, versionB ->
        //допустим, я полагаюсь на обратную совместимость только библиотек Apache, но не остальных
        if(artifact.org.startsWith ('org.apache')){
           [versionA, versionB].max()
        } else {
            fail()
        }
    }
    

    В случае нашего примера, при использовании этой имплементации custom, если предположить что org у D1 и D2 начинается с 'org.apache', то в classpath окажется D2, в противном случае, сборка упадет.

Kто во что горазд


Теперь давайте посмотрим, кто из Дер Гроссе Тройки упомянутой выше, что умеет.

Apache Ivy

В плане менеджеров конфликтов Ivy прекрасен. Они подключаемы, оба варианта latest, а так же fail и all идут в коробке. С custom-ом тоже всё красиво. Можно полностью имплементировать свою логику, а можно воспользоваться полуфабрикатом и лишь придумать подходящий regex. По умолчанию работает latest (по версии).

Gradle

В первых версиях Gradle (до 0.6, если мне не изменяет память) использовался Ivy как менеджер зависимостей. Соответственно, всё сказанное выше было верно для Gradle тоже, но ребята из Gradleware написали свой менеджер зависимостей (в основном из за проблем с локальным хранилищем Ivy при параллельной сборкe, одного из главных преимуществ Gradle). В процессе выпуска своего менеджера такие «второстепенные» фичи как замена менеджера конфликтов были задвинуты далеко в roadmap, и довольно долгое время Gradle существовал только с latest. Не нравится latest — отключай транзитивные зависимости, и вперед, перечислять всё в ручную. Но, сегодня всё в порядке. Начиная с 1.0 есть fail, а с 1.4 и custom тоже.

Apache Maven

Ну, ради следующей картинки и был задуман весь пост.
Как вы считаете, какая из версий D попадет в classpath? D1? D2? обе? ни одной? сборка упадет?

Как вы уже, наверняка, догадались, в classpath попадет D1 (что с огромной вероятностью приведет к проблемам, потому что весь код в C, написанный под новую функциональность, которой не существует в D1, просто упадет). Это тот самый чудесный WTF, который я вам обещал. Maven работает с уникальной стратегией nearest, которая выбирает в classpath тот артефакт, который находится ближе к корню проекта (А) в дереве проектов.


Как же так? Что за ерунда?

Корень проблемы лежит в трудности исключения зависимости в Maven. Если, например, вы хотите использовать D2, а не D1, то, по хорошему, вы должны сказать Maven-у: Дорогой Maven, никогда не используй D1. Просто для примера, в Gradle мы бы написали вот так:
configurations {all*.exclude group: 'mygroup', module: 'D', version: '1'}

Проблема в том, что выразить это в Maven нельзя никак. (Опытный, и потому внимательный и вдумчивый пользователь Maven-а воскликнет здесь «А как же enforcer-plugin?!» И будет неправ). Можно сказать конкретно модулю E: «ты думал у тебя есть зависимость на D? Так вот, ее нет». Это хороший выход, конфликта больше нет, D2 в classpath, win. Но это решение совершенно не масштабируемо. Что если от D1 зависят десятки артефактов? На разных уровнях транзитивности?

Ну, и причем тут nearest?

Проблема отсутствия глобального exclude была решена в Maven-е очень «интересным» способом. Было решено, что если вы объявили в вашем проекте А зависимость с определенной версией, то только эта версия и попадет в classpath. То есть практически, это ультимативный nearest — ближе чем в A быть не может (это root), поэтому конфликт решён, не нужно искать все места откуда нужно исключать D. По дороге, правда, мы получили очень странное и трудно-предсказуемое поведение в тех случаях, когда A не объявляет D напрямую (см. наш пример), но что есть, то есть.

Достаточно интересно, что идея «то, что пользователь объявил сам — закон» используется в Gradle тоже, и это не мешает им использовать вменяемые стратегии типа latest и fail для всего остального.

Update: несколько человек в комментах напомнили, что у enforcer-plugin есть функциональность fail Это частично решает проблему. Остается: 1. дикий nearest по умолчанию. 2. варианты решения проблемы — прописывать все конфликтующие зависимости в своем проекте (сносно), либо бесконечные exclude-ы (адово).

А если одинаковая глубина?

Этот прекрасный вопрос (что делать, если бы в нашем примере B зависел от D2) не приходил ребятам из Maven-а в голову на протяжении двух с половиной лет (от релиза 2.0 в октябре 2005 и до версии 2.0.9 в апреле 2008) и какой артефакт будет в classpath было просто неопределенно. В Maven 2.0.9 было принято волевое решение — будет первый!

Как это нам помогает? Правильно, никак. Потому что мы в общем случае не знаем, какой из них будет первый, ведь транзитивные зависимости не проявляют себя пока не случается конфликт (либо пока мы не начинаем расследовать эту загадку). Спасибо, пацаны!

Вместо эпилога


WTF-нутость Maven-а, естественно, не ограничивается чудесным порождением альтернативного разума — стратегией nearest. Но на сегодня, я думаю, хватит. Холивары в комментах всячески приветствуются (если что, я притоплю за Gradle), а все питерцы приходят на JUG в субботу 31 числа в ПетроКонгресс на продолжение банкета.
Tags:
Hubs:
+40
Comments 83
Comments Comments 83

Articles

Information

Website
jugru.org
Registered
Founded
Employees
51–100 employees
Location
Россия
Representative
Алексей Федоров