Prawdopodobnie słyszałeś termin dependency hell. Nowoczesne narzędzia do budowania, takie jak Gradle, bardzo dobrze radzą sobie z tym problemem, zwłaszcza poprzez automatyczne rozwiązywanie zależności transitive (przechodnich). Gdy wymaganych jest wiele różnych wersji tej samej biblioteki, Gradle domyślnie wybiera najwyższą wersję. Jednak nadal ma to pewne wady i skupimy się na jednej z nich, zaobserwowanej w projektach wielomodułowych.
Na czym właściwie polega problem?
Przede wszystkim powinniśmy wyjaśnić terminologię. Zależności można podzielić na dwie kategorie: zadeklarowane jawnie lub niejawnie. Pierwsza jest nazywana zależnością „first-level”, druga zależnością „transitive”.
Powiedzmy, że w projekcie mamy dwa podprojekty: project-a
i project-b
. Nasz project-a
ma zależność first-level do
biblioteki lib-x
w wersji 1.2
, podczas gdy project-b
ma zależność do biblioteki lib-y
, która również ma
zależność na lib-x
ale tutaj jest w niższej wersji - powiedzmy 1.1
. Co więcej project-a
zależy od project-b
.
Można to wyrazić jako:
project-a
- project-b
- lib-x 1.2
project-b
- lib-y
- lib-x 1.1
Uruchomienie project-a
rozwiąże zależność lib-x
w najwyższej wersji 1.2
zgodnie z oczekiwaniami. Jednak osobne
uruchomienie project-b
(np. testów) rozwiąże lib-x
w wersji 1.1
, zatem wystąpi niespójność między zależnymi
projektami. Prostym rozwiązaniem dla project-b
byłoby podwyższenie wersji biblioteki lib-x
, ponieważ skutkowałoby to
tym, że obie rozwiązane wersje byłyby takie same dla obu podprojektów. Ale nie zawsze jest to takie łatwe.
Raport zależności dla project-a
z niższą wersją transitive lib-x
w project-b
wygląda następująco:
+--- project-b
| +--- lib-y
| | +--- lib-x 1.1 -> 1.2
+--- lib-x 1.2
Natomiast raport dla project-b
osobno rozwiąże lib-x
do innej wersji:
+--- lib-y
| +--- lib-x 1.1
Jasne, ale tak naprawdę to ma sens, prawda?
Racja, project-b
nic nie wie o zależnościach project-a
. Rzeczywiście, tak
działa resolution strategy
- Gradle wybiera najwyższą wersję ze wszystkich żądanych modułów w danym kontekście.
Zatem jaki jest problem? W większości projektów podprojekty traktujemy jako całość. Dlatego jeśli rozwiązanie zależności nie jest spójne we wszystkich podprojektach, może to spowodować nieoczekiwane zachowanie w runtime!
Czy to naprawdę może się wydarzyć? Istnieje kilka przykładów niezgodności binarnych , ale jest jeszcze gorzej, jeśli chodzi o różnice w runtime, ponieważ trudniej jest to wykryć. Poza tym, zgodnie z prawem Murphy’ego: „Jeśli coś może pójść nie tak, to pójdzie”;)
Teraz przejdźmy dalej i rozważmy scenariusz, w którym niespójność występuje między zależnościami transitive.
Zmodyfikujemy nasz przykład, aby lib-x
w wersji 1.2
była teraz wymagana przez zależność first-level lib-z
zadeklarowaną w project-a
.
project-a
- project-b
- lib-z
- lib-x 1.2
project-b
- lib-y
- lib-x 1.1
Wygląda to jeszcze mniej intuicyjnie, ponieważ narzędzie do kompilacji powinno wykonać całą brudną robotę za nas , prawda? ;)
Podsumowując, możemy rozważyć dwa istotne przypadki konfliktów wersji tej samej biblioteki między podprojektami (pod warunkiem, że jeden zależy od drugiego):
- first-level a transitive
- transitive a transitive
Jak wykryć konflikty wersji?
Aby to zrobić, potrzebujemy narzędzia, które rozwiązuje wszystkie zależności z każdego projektu osobno, a następnie
wyświetla je razem w celu znalezienia duplikatów. W moim przypadku naturalnym wyborem jest Intellij IDEA. Zależności
możemy sprawdzić w zakładce Libraries
w oknie Project structure
. Gromadzi on wszystkie rozwiązane zależności ze *
wszystkich konfiguracji*. Jeśli <group>:<name>
jest wymieniony więcej niż raz, może to oznaczać, że masz kłopoty :)
Wadą jest to, że musisz samodzielnie znaleźć te konflikty, przeglądając zależności linia po linii. Inną rzeczą jest to,
że zależności z buildSrc
są również uwzględnione, co może być mylące.
Jeśli nie jesteś użytkownikiem Intellij, możesz wypróbować task dependencies
z Gradle. Jednakże generuje on raporty
tylko dla jednego projektu. Aby użyć go do wszystkich podprojektów, powinniśmy
zrobić tę sztuczkę
i zdefiniować własny task. Możemy również chcieć uruchomić go tylko dla konfiguracji testRuntimeClasspath
:
allprojects {
afterEvaluate { project ->
if (project.configurations.findByName("testRuntimeClasspath")) {
task allDeps(type: DependencyReportTask) {
configuration = "testRuntimeClasspath"
}
}
}
}
Następnie możemy spróbować przeanalizować dane wyjściowe i znaleźć sprzeczne zależności:
$ ./gradlew allDeps | sed -E 's/:[0-9][^ ]* -> /:/g' | grep -Po "[^ ]+:.+:\d[^ ]*" \
| sort | uniq | cut -d':' -f-2 | uniq -c | grep -v 1
2 commons-beanutils:commons-beanutils
2 commons-collections:commons-collections
...
Wyjaśnijmy ten kod. Po pierwsze przez sed -E 's/:[0-9][^ ]* -> /:/g'
, chcemy się upewnić, że pod uwagę brane są tylko
rozwiązane wersje (a nie zadeklarowane). W ten sposób wycieliśmy zadeklarowaną wersję dla zależności transitive
oznaczonych strzałką (->
). Następnie poprzez grep -Po "[^ ]+:.+:\d[^ ]*"
wyodrębniany jest tylko
GAV (<group>:<name>:<version>
). Kolejno przygotowujemy unikalną listę wersji z sort | uniq
. Następnie chcemy
pogrupować rezultat według <group>:<name>
, aby zidentyfikować zduplikowane wersje, więc odcinamy wersję za
pomocą cut -d':' -f-2
. Wreszcie możemy zliczyć każde wystąpienie uniq -c
i wydrukować tylko te z licznością większa
niż jeden: grep -v 1
.
Okej, ale czy naprawdę nie ma sposobu, aby powiedzieć Gradle’owi, żeby mnie ostrzec?
Właściwie jest. Gradle ma wbudowane sprawdzanie konfliktów zależności transitive. Moglibyśmy użyć
flagę failOnVersionConflict
. Następnie za pomocą taska dependencyInsight
możemy zawęzić konflikt do jednej
zależności. Niestety w naszym przypadku nie jest to zbyt pomocne, ponieważ pojedyncze biblioteki lub duże BOMy mogą same
w sobie mieć wiele konfliktów (np. org.springframework.boot:spring-boot-dependencies:2.2.7.RELEASE
).
Niemniej jednak są dobre wieści :) W nowych wydaniach (Gradle 7.x) istnieją plany wprowadzenia nowej flagi dla resolution strategy, która poinformuje w przypadku niespójnego rozwiązywania zależności w podprojektach. W Gradle 6.x i wcześniejszych możemy wymusić tylko te same wersje dla wszystkich konfiguracji w jednym projekcie:
java {
consistentResolution {
useCompileClasspathVersions()
}
}
Czy jest jakieś rozwiązanie?
Na szczęście oficjalna dokumentacja dotycząca zależności transitive obejmuje szeroki wachlarz opcji. Znalazłem kilka
podejść, w
tym niestandardowe pluginy
. Obecnie zalecanym rozwiązaniem do udostępniania wersji zależności między projektami jest platform. Innym
rozwiązaniem, wprowadzonym jako feature preview w Gradle 7, jest version catalogs
jednak nie daje on
aż tylu możliwości. Nasz cel jest
jasny: chcemy mieć najwyższą wspólną wersję we wszystkich projektach. Czy platform to potrafi? Dowiedzmy Się!
Cała idea platform pochodzi od Maven-owego BOM (Bill of
Materials), który jest scentralizowanym zestawem wersji zależności, które „dobrze ze sobą współpracują”. W ten sposób
możemy zdefiniować nasze zależności first-level za pomocą tylko <group>:<name>
, o ile są zdefiniowane w platform .
dependencies {
implementation platform(project(":platform"))
implementation "<group>:<name>"
}
Oznacza to, że możemy używać tej samej wersji w każdym miejscu. Brzmi dobrze! Jednak są pewne pułapki… Główną jest to, że tak naprawdę nie rozwiązuje to naszego problemu.
Najpierw przyjrzyjmy się przykładowi zależności first-level kontra transitive. Gdy niższa wersja jest zdefiniowana w
projekcie bazowym project-b
, to pomimo zdefiniowania lib-x
jako zależności first-level w platform
, różne wersje są
nadal rozwiązywane.
Platform
- lib-x 1.1 (first-level)
- lib-y
- lib-x 1.2 (transitive)
project-a
- Platform
- project-b
- lib-y
- lib-x (version 1.2 resolved)
project-b
- Platform
- lib-x (version 1.1 resolved)
Zatem platform
nie zapewnia magicznie spójności sama w sobie - te same zasady rozwiązywania zależności nadal
obowiązują. Aby to naprawić i zawsze używać wersji zależności zadeklarowanej w first-level, musimy
użyć enforcedPlatform
:
dependencies {
implementation enforcedPlatform(project(":platform"))
}
Teraz mamy tę samą wersję we wszystkich podprojektach, ale w rzeczywistości jest ona niższa. Oznacza to, że możesz przypadkowo obniżyć wersję zależności ;) Jednak nie chcemy obniżać poziomu zależności, tylko jedynie chcemy je uspójnić.
W związku z tym moglibyśmy rozważyć tutaj dwie inspekcje:
- lista miejsc, w których
platform
jest używany zamiastenforcedPlatform
- zależności, które są rozwiązywane w wersji niższej niż zadeklarowana (najwyższa wersja powinna być OK, ale zawsze lepiej się upewnić i zaktualizować też biblioteki posiadające zależność transitive)
Innym problemem jest przypadek transitive kontra transitive. Nawet z pomocą enforcedPlatform
musimy jawnie zadeklarować zależność transitive
, aby pozbyć się konfliktu. I znowu możemy przypadkowo obniżyć wersję obu zależności, więc musimy być ostrożni.
Dlatego wymuszanie platform
nie różni się zbytnio od używania force
w resolutionStrategy
. Oba przypadki są nadal
trudne do utrzymania, ponieważ potrzebujemy dodatkowych raportów o nieużywanych deklaracjach lub niezamierzonych
obniżeniach wersji.
Wnioski
Rozwiązanie platform
wydaje się być idealne, ale takie nie jest, zwłaszcza dla programistów utrzymujących zależności.
Nadal mogą wystąpić nieoczekiwane konflikty zależności, szczególnie w przypadku wielu projektów. Dlatego każda
aktualizacja zależności musi być wykonywana ostrożnie i mądrze. Nie ma łatwego sposobu. Możemy jednak opracować
narzędzia, które mogą nas wesprzeć, np. raporty. Miejmy nadzieję, że przyszłe wydania Gradle będą ostrzegały nas o tego
typu konfliktach.