[Перевод] Семантика средств разрешения зависимостей
Средство разрешения зависимостей
Средство разрешения зависимостей (далее по тексту резолвер, прим. перев.) или менеджер пакетов — это программа, определяющая консистентный набор модулей с учётом ограничений, заданных пользователем.
Ограничения обычно задаются именами модулей и номерами версий. В экосистеме 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
andA -> 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 докладов полных технического хардкора будут представлены на конференции.