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):

  1. first-level a transitive
  2. 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 zamiast enforcedPlatform
  • 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.