[Перевод] Семантика средств разрешения зависимостей

?v=1

Средство разрешения зависимостей

Средство разрешения зависимостей (далее по тексту резолвер, прим. перев.) или менеджер пакетов — это программа, определяющая консистентный набор модулей с учётом ограничений, заданных пользователем.

Ограничения обычно задаются именами модулей и номерами версий. В экосистеме JVM для модулей Maven будет ещё указано наименование организации (group id). Кроме того, ограничения могут содержать диапазоны версий, исключаемые модули, переопределения версий и т.п.

Три основных категории пакетов представлены OS-пакетами (Homebrew, Debian-пакеты, и т.п.),
модулями для конкретных языков программирования (CPAN, RubyGem, Maven, etc) и hасширения, специфичные для приложения (Eclipse plugins, IntelliJ plugins, VS Code extensions).


Семантика резолвера

В первом приближении мы можем представить зависимости модулей, как DAG (directed acyclic graph, направленный ациклический граф).

Такое представление называют графом зависимостей. Рассмотрим зависимости двух модулей:


  • a:1.0 зависит от c:1.0
  • b:1.0 зависит от c:1.0 и d:1.0
+-----+  +-----+
|a:1.0|  |b:1.0|
+--+--+  +--+--+
   |        |
   +<-------+
   |        |
   v        v
+--+--+  +--+--+
|c:1.0|  |d:1.0|
+-----+  +-----+

Если модуль будет зависеть от a:1.0 и b:1.0, то полный перечень зависимостей будет представлен a:1.0, b:1.0, c:1.0 и d:1.0. И это лишь только обход по дереву.

Ситуация будет усложняться, если транзитивные зависимости будут заданы диапазоном версий:


  • a:1.0 зависит от c:1.0
  • b:1.0 зависит от c:[1.0,2) и d:1.0
+-----+  +-----+
|a:1.0|  |b:1.0|
+--+--+  +--+--+
   |        |
   |        +-----------+
   |        |           |
   v        v           v
+--+--+  +--+------+ +--+--+
|c:1.0|  |c:[1.0,2)| |d:1.0|
+-----+  +---------+ +-----+

Или же, если для транзитивных зависимостей будут указаны разные версии:


  • a:1.0 зависит от c:1.0
  • b:1.0 зависит от c:1.2 и d:1.2

Или, если для зависимости будут заданы исключения:


  • зависимость от a:1.0, которое зависит c:1.0, исключая c:*
  • b:1.0 зависит от c:1.2 и d:1.2

Разные резолверы по-разному интерпретируют ограничения, заданные пользователями. Я называю такие правила семантиками резолверов.

Вам может понадобиться знание некоторых подобных семантик, например:


  • семантика вашего собственного модуля (определяемая используемым вами средством сборки);
  • семантика используемых Вами библиотек (определяемая средством сборки, которое использовал автор);
  • семантика модулей, которые будут использовать Ваш модуль, в качестве зависимости (определяемая средством сборки конечного пользователя).


Средства разрешения зависимостей в экосистеме JVM

Поскольку я поддерживаю sbt, мне приходится работать преимущественно в экосистеме JVM.


Семантика Maven: nearest-wins

В графах, где имеет место конфликт зависимостей (в графе зависимостей a присутствует множество различных версий компонента d, например d:1.0 and d:2.0), Maven применяет стратегию nearest-wins для разрешения конфликта.


Урегулирование конфликтов зависимостей — процесс определяющий, какая версия артефакта будет выбрана, если среди зависимостей обнаружено несколько разных версий одного и того же артефакта. Maven выбирает ближайшее определение. Т.е. использует ту версию, которая в дереве зависимостей находится ближе всего к вашему проекту.
Вы всегда можете гарантированно использовать нужную версию, явно объявив её в POM проекта. Учтите, что если две версии зависимости будут иметь одинаковую глубину в дереве, то будет выбрана первая из них. Ближайшее определение означает, что будет использована самая близкая к проекту в дереве зависимостей версия. Например, если зависимости для A, B и C определены, как A -> B -> C -> D 2.0 and A -> E -> D 1.0, тогда, при сборке A, будет использована D 1.0, т.к. путь от A до D через E короче (чем через B и C, прим. перев.).

Это значит, что множество Java-модулей, опубликованных при помощи Maven, были собраны с использованием семантики nearest-wins. Для иллюстрации сказанного, создадим простой pom.xml:


  4.0.0
  com.example
  foo
  1.0.0
  jar

   
     
       
         com.typesafe.play
         play-ws-standalone_2.12
         1.0.1
       
     
   

mvn dependency:build-classpath возвращает урегулированный classpath.
Примечательно то, что в полученном дереве используется com.typesafe:config:1.2.0 даже при том, что Akka 2.5.3 транзитивно зависит от com.typesafe:config:1.3.1.

mvn dependency:tree даёт тому визуальное подтверждение:

[INFO] --- maven-dependency-plugin:2.8:tree (default-cli) @ foo ---
[INFO] com.example:foo:jar:1.0.0
[INFO] \- com.typesafe.play:play-ws-standalone_2.12:jar:1.0.1:compile
[INFO]    +- org.scala-lang:scala-library:jar:2.12.2:compile
[INFO]    +- javax.inject:javax.inject:jar:1:compile
[INFO]    +- com.typesafe:ssl-config-core_2.12:jar:0.2.2:compile
[INFO]    |  +- com.typesafe:config:jar:1.2.0:compile
[INFO]    |  \- org.scala-lang.modules:scala-parser-combinators_2.12:jar:1.0.4:compile
[INFO]    \- com.typesafe.akka:akka-stream_2.12:jar:2.5.3:compile
[INFO]       +- com.typesafe.akka:akka-actor_2.12:jar:2.5.3:compile
[INFO]       |  \- org.scala-lang.modules:scala-java8-compat_2.12:jar:0.8.0:compile
[INFO]       \- org.reactivestreams:reactive-streams:jar:1.0.0:compile

Многие библиотеки обеспечивают обратную совместимость, но прямая совместимость не гарантируется за исключением некоторых исключений, что настораживает.


Семантика Apache Ivy: latest-wins

По умолчанию Apache Ivy для разрешения конфликта зависимостей использует стратегию latest-wins.


Если не присутствует этот контейнер, то для всех модулей используется менеджер конфликтов по умолчанию. Текущим менеджером конфликтов по умолчанию является «latest-revision».
Прим перев.: Контейнер conflicts — один из файлов Ivy.

Вплоть до версии SBT 1.3.x внутренним резолвером зависимостей является Apache Ivy. Использованный ранее pom.xml, описывается в SBT чуть более кратко:

ThisBuild / scalaVersion := "2.12.8"
ThisBuild / organization := "com.example"
ThisBuild / version      := "1.0.0-SNAPSHOT"

lazy val root = (project in file("."))
  .settings(
    name := "foo",
    libraryDependencies += "com.typesafe.play" %% "play-ws-standalone" % "1.0.1",
  )

В sbt shell введите show externalDependencyClasspath, чтобы получить урегулированный classpath. В нём должна быть указана версия com.typesafe:config:1.3.1. Кроме того, будет ещё выведено следующее предупреждение:

[warn] There may be incompatibilities among your library dependencies; run 'evicted' to see detailed eviction warnings.

Вызов команды evicted в sbt shell позволяет получить отчёт об урегулировании конфликтов:

sbt:foo> evicted
[info] Updating ...
[info] Done updating.
[info] Here are other dependency conflicts that were resolved:
[info]  * com.typesafe:config:1.3.1 is selected over 1.2.0
[info]      +- com.typesafe.akka:akka-actor_2.12:2.5.3            (depends on 1.3.1)
[info]      +- com.typesafe:ssl-config-core_2.12:0.2.2            (depends on 1.2.0)
[info]  * com.typesafe:ssl-config-core_2.12:0.2.2 is selected over 0.2.1
[info]      +- com.typesafe.play:play-ws-standalone_2.12:1.0.1    (depends on 0.2.2)
[info]      +- com.typesafe.akka:akka-stream_2.12:2.5.3           (depends on 0.2.1)

В семантике latest-wins указание config:1.2.0 на практике означает «предоставь мне версию 1.2.0 или выше».
Такое поведение чуть более предпочтительно, чем в стратегии nearest-wins, ведь версии транзитивных библиотек не понижаются. Тем не менее, вызовом evicted следует проверять, корректно ли были сделаны замещения.


Семантика Coursier: latest-wins

Перед тем, как мы подойдём к описанию семантики, отвечу на важный вопрос — как Coursier произносится. Согласно заметке Алекса Аршамбо, оно произносится кур-сье́.

Занимательно то, что в документации к Coursier есть страница о согласовании версий, на которой говорится о семантике разрешения зависимостей.


Рассмотрим пересечение заданных интервалов:
  • Если оно пустое (интервалы не пересекаются), то имеет место конфликт.
  • Если интервалы не заданы, допускается, что пересечение представлено (,) (интервалом, соответствующим всем версиям).
    Затем, рассмотрим конкретные версии:
    • Отбросим конкретные версии ниже границ интервала.
    • Если имеются конкретные версии выше границ интервала, то имеет место конфликт.
    • Если конкретные версии находятся внутри границ интервала, результатом следует взять самую позднюю из них.
    • Если внутри или выше границ интервала конкретных версий нет, результатом следует принять интервал.

Т.к. сказано взять самую позднюю, следовательно — это семантика latest-wins.
Можно в этом удостовериться, взяв sbt 1.3.0-RC3, которая использует Coursier.

ThisBuild / scalaVersion := "2.12.8"
ThisBuild / organization := "com.example"
ThisBuild / version      := "1.0.0-SNAPSHOT"

lazy val root = (project in file("."))
  .settings(
    name := "foo",
    libraryDependencies += "com.typesafe.play" %% "play-ws-standalone" % "1.0.1",
  )

Вызов show externalDependencyClasspath из консоли sbt 1.3.0-RC3 вернёт com.typesafe:config:1.3.1, как и ожидалось. Отчёт об урегулировании конфликтов сообщает о том же:

sbt:foo> evicted
[info] Here are other dependency conflicts that were resolved:
[info]  * com.typesafe:config:1.3.1 is selected over 1.2.0
[info]      +- com.typesafe.akka:akka-actor_2.12:2.5.3            (depends on 1.3.1)
[info]      +- com.typesafe:ssl-config-core_2.12:0.2.2            (depends on 1.2.0)
[info]  * com.typesafe:ssl-config-core_2.12:0.2.2 is selected over 0.2.1
[info]      +- com.typesafe.play:play-ws-standalone_2.12:1.0.1    (depends on 0.2.2)
[info]      +- com.typesafe.akka:akka-stream_2.12:2.5.3           (depends on 0.2.1)


Примечание: Apache Ivy эмулирует семантику nearest-wins?

При разрешении зависимостей модуля из Maven-репозитория, Ivy, конвертируя POM-файл, проставляет атрибут force="true" в ivy.xml в кеше.

Например, cat ~/.ivy2/cache/com.typesafe.akka/akka-actor_2.12/ivy-2.5.3.xml:

  
    
    
    
  

В документации Ivy сказано:


Эти два менеджера конфликтов, работающие по стратегии latest, принимают в расчёт атрибут зависимостей force.
Таким образом, прямые зависимости могут объявлять атрибут force (см. зависимость), указывающий на то, что из ревизий прямой зависимости и непрямой, предпочтение должно быть отдано ревизии прямой зависимости

Для меня эта формулировка означает, что force="true" задумывался для того, чтобы переопределять логику latest-wins и эмулировать семантику nearest-wins. Но, к счастью, этому не суждено было случиться, и у нас теперь есть latest-wins: как мы видим, sbt 1.2.8 подхватывает com.typesafe:config:1.3.1.

Однако, можно наблюдать эффект force="true" при использовании строгого менеджера конфликтов, который, похоже, сломан.

ThisBuild / conflictManager := ConflictManager.strict

Проблема в том, что строгий менеджер конфликтов, похоже, не предотвращает замещения версий. show externalDependencyClasspath бодренько возвращает com.typesafe:config:1.3.1.
Связанная с этим проблема заключается в том, что добавление версии com.typesafe:config:1.3.1, которую строгий менеджер конфликтов проставил в граф, приводит к ошибке.

ThisBuild / scalaVersion    := "2.12.8"
ThisBuild / organization    := "com.example"
ThisBuild / version         := "1.0.0-SNAPSHOT"
ThisBuild / conflictManager := ConflictManager.strict

lazy val root = (project in file("."))
  .settings(
    name := "foo",
    libraryDependencies ++= List(
      "com.typesafe.play" %% "play-ws-standalone" % "1.0.1",
      "com.typesafe" % "config" % "1.3.1",
    )
  )

Выглядит это следующим образом:

sbt:foo> show externalDependencyClasspath
[info] Updating ...
[error] com.typesafe#config;1.2.0 (needed by [com.typesafe#ssl-config-core_2.12;0.2.2]) conflicts with com.typesafe#config;1.3.1 (needed by [com.example#foo_2.12;1.0.0-SNAPSHOT])
[error] org.apache.ivy.plugins.conflict.StrictConflictException: com.typesafe#config;1.2.0 (needed by [com.typesafe#ssl-config-core_2.12;0.2.2]) conflicts with com.typesafe#config;1.3.1 (needed by [com.example#foo_2.12;1.0.0-SNAPSHOT])


О порядке версионирования

Мы упоминали семантику latest-wins, предполагающую, что версии в строковом представлении могут встречаться в каком-то порядке.
Следовательно, порядок версионирования — часть семантики.


Порядок версионирования в Apache Ivy

В этом Javadoc-комментарии сказано, что при создании компаратора версий Ivy ориентировались на функцию cравнения версий из PHP:


Эта функция сначала заменяет _, — и + точкой . в строковых представлениях версий и ещё добавляет . до и после всего, что не является числом. Так, к примеру, '4.3.2RC1' становится '4.3.2.RC.1'. Затем она сравнивает полученные части слева направо.

Для частей, содержащих специальные элементы (dev, alpha или a, beta или b, RC или rc, #, pl или p)*, происходит сравнение элементов в следующем порядке:

любая строка, не являющаяся специальным элементом < dev < alpha = a < beta = b < RC = rc < # < pl = p.

Таким образом могут сравниваться не только разные уровни (например, '4.1' и '4.1.2'), но версии, специфичные для PHP, содержащие сведения о состоянии разработки.
*прим. перев.

Мы можем проверить, как упорядочиваются версии, написав небольшую функцию.

scala> :paste
// Entering paste mode (ctrl-D to finish)

val strategy = new org.apache.ivy.plugins.latest.LatestRevisionStrategy
case class MockArtifactInfo(version: String) extends
    org.apache.ivy.plugins.latest.ArtifactInfo {
  def getRevision: String = version
  def getLastModified: Long = -1
}
def sortVersionsIvy(versions: String*): List[String] = {
  import scala.collection.JavaConverters._
  strategy.sort(versions.toArray map MockArtifactInfo)
    .asScala.toList map { case MockArtifactInfo(v) => v }
}

// Exiting paste mode, now interpreting.

scala> sortVersionsIvy("1.0", "2.0", "1.0-alpha", "1.0+alpha", "1.0-X1", "1.0a", "2.0.2")
res7: List[String] = List(1.0-X1, 1.0a, 1.0-alpha, 1.0+alpha, 1.0, 2.0, 2.0.2)


Порядок версионирования в Coursier

На GitHub-странице о семантике разрешения зависимостей есть раздел о порядке версионирования.


Coursier использует адаптированный порядок версионирования Maven. Перед сравнением, строковые представления версий разбиваются на отдельные элементы…
Для получения таких элементов, версии разделяются по символам ., -, и _ (а сами разделители отбрасываются), и по заменам буква-в-цифру или цифра-в-букву.

Для написания теста, создадим подпроект с зависимостями libraryDependencies += "io.get-coursier" %% "coursier-core" % "2.0.0-RC2-6" и запустим console:

sbt:foo> helper/console
[info] Starting scala interpreter...
Welcome to Scala 2.12.8 (OpenJDK 64-Bit Server VM, Java 1.8.0_212).
Type in expressions for evaluation. Or try :help.

scala> import coursier.core.Version
import coursier.core.Version

scala> def sortVersionsCoursier(versions: String*): List[String] =
     |   versions.toList.map(Version.apply).sorted.map(_.repr)
sortVersionsCoursier: (versions: String*)List[String]

scala> sortVersionsCoursier("1.0", "2.0", "1.0-alpha", "1.0+alpha", "1.0-X1", "1.0a", "2.0.2")
res0: List[String] = List(1.0-alpha, 1.0, 1.0-X1, 1.0+alpha, 1.0a, 2.0, 2.0.2)

Как выясняется, Coursier упорядочивает номера версий совсем в ином порядке, чем Ivy.
Если вы пользовались разрешительными буквенными тэгами, то такое упорядочивание может вызвать некоторую путаницу.


О диапазонах версий

Обычно, я избегаю использования диапазонов версий, хотя они широко используются в webjars и npm-модулях переопубликованных на Maven Central. В модуле может быть написано нечто вроде "is-number": "^4.0.0" что будет соответствовать [4.0.0,5).


Обработка диапазонов версий в Apache Ivy

В этой сборке angular-boostrap:0.14.2 зависит от angular:[1.3.0,).

ThisBuild / scalaVersion  := "2.12.8"
ThisBuild / organization  := "com.example"
ThisBuild / version       := "1.0.0-SNAPSHOT"

lazy val root = (project in file("."))
  .settings(
    name := "foo",
    libraryDependencies ++= List(
      "org.webjars.bower" % "angular" % "1.4.7",
      "org.webjars.bower" % "angular-bootstrap" % "0.14.2",
    )
  )

Вызов show externalDependencyClasspath в sbt 1.2.8 вернёт angular-bootstrap:0.14.2 и angular:1.7.8. А куда же подевался 1.7.8? Когда Ivy встречает диапазон версий, он, по сути, идёт в интернет и находит то, что удаётся найти, иногда даже применяя screenscraping.

Такая обработка диапазонов версий делает сборки неповторяющимися (запустив одну и ту же сборку раз в несколько месяцев, Вы получаете разный результат).


Обработка диапазонов версий в Coursier

Раздел о разрешении зависимостей в Coursier на GitHub-странице
гласит:


Конкретные версии в интервалах более предпочтительны
Если у Вашего модуля есть зависимость от [1.0,2.0) и 1.4, согласование версий будет выполнено в пользу 1.4.
Если есть зависимость на 1.4, то этой версии будет отдано предпочтение в диапазоне [1.0,2.0).

Выглядит многообещающе.

sbt:foo> show externalDependencyClasspath
[warn] There may be incompatibilities among your library dependencies; run 'evicted' to see detailed eviction warnings.
[info] * Attributed(/Users/eed3si9n/.sbt/boot/scala-2.12.8/lib/scala-library.jar)
[info] * Attributed(/Users/eed3si9n/.coursier/cache/v1/https/repo1.maven.org/maven2/org/webjars/bower/angular/1.4.7/angular-1.4.7.jar)
[info] * Attributed(/Users/eed3si9n/.coursier/cache/v1/https/repo1.maven.org/maven2/org/webjars/bower/angular-bootstrap/0.14.2/angular-bootstrap-0.14.2.jar)

show externalDependencyClasspath на той же сборке с angular-bootstrap:0.14.2 возвращает angular-bootstrap:0.14.2 и angular:1.4.7, как ожидалось. Это улучшение по сравнению с Ivy.

Рассмотрим более сложный случай, когда используется множество непересекающихся диапазонов версий. Например:

ThisBuild / scalaVersion  := "2.12.8"
ThisBuild / organization  := "com.example"
ThisBuild / version       := "1.0.0-SNAPSHOT"

lazy val root = (project in file("."))
  .settings(
    name := "foo",
    libraryDependencies ++= List(
      "org.webjars.npm" % "randomatic" % "1.1.7",
      "org.webjars.npm" % "is-odd" % "2.0.0",
    )
  )

Вызов show externalDependencyClasspath в sbt 1.3.0-RC3 возвращает такую ошибку:

sbt:foo> show externalDependencyClasspath
[info] Updating
https://repo1.maven.org/maven2/org/webjars/npm/kind-of/maven-metadata.xml
  No new update since 2018-03-10 06:32:27
https://repo1.maven.org/maven2/org/webjars/npm/is-number/maven-metadata.xml
  No new update since 2018-03-09 15:25:26
https://repo1.maven.org/maven2/org/webjars/npm/is-buffer/maven-metadata.xml
  No new update since 2018-08-17 14:21:46
[info] Resolved  dependencies
[error] lmcoursier.internal.shaded.coursier.error.ResolutionError$ConflictingDependencies: Conflicting dependencies:
[error] org.webjars.npm:is-number:[3.0.0,4):default(compile)
[error] org.webjars.npm:is-number:[4.0.0,5):default(compile)
[error]   at lmcoursier.internal.shaded.coursier.Resolve$.validate(Resolve.scala:394)
[error]   at lmcoursier.internal.shaded.coursier.Resolve.validate0$1(Resolve.scala:140)
[error]   at lmcoursier.internal.shaded.coursier.Resolve.$anonfun$ioWithConflicts0$4(Resolve.scala:184)
[error]   at lmcoursier.internal.shaded.coursier.util.Task$.$anonfun$flatMap$2(Task.scala:14)
[error]   at scala.concurrent.Future.$anonfun$flatMap$1(Future.scala:307)
[error]   at scala.concurrent.impl.Promise.$anonfun$transformWith$1(Promise.scala:41)
[error]   at scala.concurrent.impl.CallbackRunnable.run(Promise.scala:64)
[error]   at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
[error]   at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
[error]   at java.lang.Thread.run(Thread.java:748)
[error] (update) lmcoursier.internal.shaded.coursier.error.ResolutionError$ConflictingDependencies: Conflicting dependencies:
[error] org.webjars.npm:is-number:[3.0.0,4):default(compile)
[error] org.webjars.npm:is-number:[4.0.0,5):default(compile)

Технически, всё верно, т.к. эти диапазоны не пересекаются. В то время, как sbt 1.2.8 разрешает это в is-number:4.0.0.

Ввиду того, что диапазоны версий встречаются достаточно часто для того, чтобы это раздражало, я отправляю Pull Request в Coursier, чтобы реализовать дополнительные правила семантики latest-wins, которые позволят выбирать более поздние версии из нижних границ диапазонов.
См. coursier/coursier#1284.


Заключение

Семантика резолвера определяет конкретный classpath, основываясь на ограничениях, заданных пользователем.

Обычно, различия в деталях проявляются в разном способе разрешения конфликтов версий.


  • Maven использует стратегию nearest-wins, которая может понизить версии транзитивных зависимостей.
  • Ivy применяет стратегию latest-wins.
  • Coursier преимущественно использует стратегию latest-wins, при этом пытаясь задать версии более строго.
  • Обработчик диапазонов версий в Ivy идёт в интернет, что делает одну и ту же сборку неповторяющейся.
  • Coursier и Ivy очень по-разному упорядочивают строковые представления версий.


Еще и не такие тонкости экосистемы Scala будут обсуждаться на ScalaConf 26 ноября в Москве. Артем Селезнев познакомит с практикой работы с базой данных в функциональном программировании без JDBC. Wojtek Pitula поговорит об интеграции и расскажет, как создал приложение, в которое поместил все рабочие библиотеки. И еще 16 докладов полных технического хардкора будут представлены на конференции.

© Habrahabr.ru