Работаете с Gradle? Dependency Analysis Gradle Plugin помогает не только находить лишние зависимости, но и автоматически исправлять проблемы с ними.

Механизм fixDependencies переписывает скрипты сборки, чтобы они соответствовали реальной структуре проекта. Последние обновления сделали процесс анализа ещё точнее, а работу с Kotlin DSL — проще и надёжнее. Читайте про фикс зависимостей в один клик в новом переводе от команды SpringАйО.


Если вы поддерживаете проект на JVM (1) или Android, вероятно, вы слышали о плагине Dependency Analysis Gradle Plugin (DAGP). С более чем 1800 звёздами на GitHub, он используется в некоторых из крупнейших проектов на Gradle в мире, включая сам Gradle. Этот плагин играет значительную роль в экосистеме Gradle: без него, насколько мне известно, нет других способов устранить неиспользуемые зависимости и корректно декларировать все реально используемые. Иными словами, с этим плагином ваши декларации зависимостей будут ровно такими, какими должны быть для сборки проекта: ни больше, ни меньше.

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

Проблема в том, что если ваш инструмент только указывает на все существующие проблемы, но не помогает их исправить, у вас может появиться огромная (и крайне раздражающая) задача. Я уже упоминал об этом как об одном из важных факторов в своей недавней критике форматтеров кода. Именно поэтому с версии 1.11.0 DAGP включает задачу fixDependencies, которая берёт отчёт о проблемах и переписывает скрипты сборки прямо на месте. Ещё до этого, в версии 0.46.0, плагин получил полноценную поддержку регистрации "post-processing task", позволяя продвинутым пользователям использовать отчёт о “build health” (здоровье сборки) любым удобным способом. Например, Foundry (ранее известный как The Slack Gradle Plugin) имеет функцию "dependency rake", которая предшествовала и вдохновила создание fixDependencies.

Однако fixDependencies не всегда работала идеально. Например, мог быть баг в анализе, из-за которого при "исправлении" всех проблем ваша сборка могла сломаться. (DAGP активно развивается, поэтому если это когда-нибудь произойдёт с вами, пожалуйста, создайте issue!) В таких случаях иногда требуется эксперт, чтобы понять, что именно сломалось и как это исправить, или же приходится возвращаться к ручным изменениям и итерациям.

Кроме того, переписчик скриптов сборки использовал упрощённую грамматику для парсинга и переписывания скриптов Gradle на Groovy и Kotlin DSL. Поэтому этот механизм может давать сбои, если ваши скрипты слишком сложны (2). Однако вскоре эта проблема будет решена с введением парсера Gradle Kotlin DSL, основанного на грамматике KotlinEditor, который полностью поддерживает язык Kotlin. (Скрипты Gradle на Groovy DSL пока продолжат использовать старую упрощённую грамматику.)

Кроме того, недавно было внесено множество исправлений, направленных на (1) повышение точности анализа и (2) усиление устойчивости процесса переписывания при работе с различными распространёнными идиомами. Например, DAGP теперь значительно лучше поддерживает использование аксессоров версионных каталогов (экспериментальные проектные аксессоры пока не поддерживаются).

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

  1. Более 500 репозиториев.

  2. Каждый со своим версионным каталогом.

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

  4. Более 2000 модулей Gradle.

  5. Почти 15 миллионов строк кода на Kotlin и Java, распределённых по более чем 100 тысячам файлов, а также свыше 150 тысяч строк кода Gradle в более чем 3000 скриптов сборки (3). Последний пункт не так важен, как первые четыре, но он помогает проиллюстрировать, что я имею в виду под "промышленным масштабом".

Кроме того, код сборки, который мы хотим написать для управления всем этим, должен соответствовать лучшим практикам Gradle: он должен быть кэшируемым по возможности, работать с кэшем конфигурации и, как дополнительный бонус, не нарушать контракт изолированных проектов (что также способствует максимальной производительности).

Главная цель заключается в том, чтобы разработчики и сопровождающие сборку могли выполнить одну Gradle task’у, которая:

  1. Исправляет все декларации зависимостей, включая добавление новых в скрипты сборки, если это необходимо.

  2. Обеспечивает, чтобы все декларации в скриптах сборки имели записи в версионном каталоге, где это возможно.

  3. Гарантирует, что все записи в версионных каталогах используют одно глобальное пространство имён, так чтобы весь набор из более чем 500 репозиториев был полностью согласован друг с другом.

Этот последний пункт особенно важен, так как мы, допустим, мигрируем эти репозитории в единый моно-/мега-репозиторий по другим причинам.

Вот task’а, которую они теперь могут выполнить (для справки):

gradle :fixAllDependencies

(Примечание: мы используем gradle, а не ./gradlew, поскольку управляем Gradle отдельно для каждого репозитория с помощью hermit.)

Итак, как это реализовать?

Предварительная обработка

Первым шагом было создание глобального пространства имён для каталога версий. Мы не пытались создать единый опубликованный глобальный каталог версий, так как до завершения миграции в мега-репозиторий важно, чтобы каждый репозиторий сохранял собственные зависимости (и их версии). Вместо этого мы собрали полную карту соответствий имен в каталогах версий и идентификаторов зависимостей (координат зависимостей без строки версии).

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

Концептуальная основа

Фреймворк Gradle в целом считает Project основным пунктом отсчёта (4). Экземпляр Project лежит в основе всех ваших скриптов build.gradle[.kts], и большинство плагинов реализуют интерфейс Plugin<Project>. Безопасный, производительный и качественный код сборки соблюдает эту концептуальную границу и рассматривает каждый проект (также называемый "модулем") как атомарную единицу.

Если задачи (Tasks) имеют чётко определённые входные и выходные данные (что буквально аннотируется как @Input<X> и @Output<X>), полезно рассматривать проекты как аналогично имеющие входы и выходы. В общем случае входные данные проекта — это его исходный код (по соглашению, он находится в каталоге src/ в корне проекта) и его зависимости. Выходные данные проекта — это артефакты, которые он производит. Для Java-проектов основными артефактами являются JAR-файлы (для внешнего использования) или файлы классов (для использования другими проектами в мультипроектной сборке) (5).

С учётом этого, мы можем определить, что если два проекта должны взаимодействовать, они должны делать это через свои чётко определённые входные и выходные данные. Связи между проектами определяются через зависимости: например, A -> B означает, что проект A зависит от проекта B, а значит, B является входом для A.

Мы можем уточнить эту связь, указав Gradle, какие выходные данные B интересуют A. По умолчанию это основной артефакт (обычно файлы классов для использования в classpath), но это может быть и что-то другое (любое, что можно записать на диск). Например, это могут быть метаданные о проекте B. Более того, это могут быть оба типа данных одновременно!

Вы можете объявить несколько зависимостей между одними и теми же двумя проектами, причём каждая связь может иметь свой "вид" (flavor), то есть представлять другую вариацию. Станет гораздо понятнее, когда мы перейдём к конкретному примеру.

Реализация: :fixAllDependencies

Дальнейшая часть этого текста сосредоточена на реализации, но на достаточно высоком уровне детализации. Некоторые примеры кода будут фактически псевдокодом. Моя цель — показать общий процесс на концептуальном уровне, чтобы мотивированный читатель мог реализовать что-то подобное в своей собственной работе или, что более вероятно, просто узнать, как сделать что-то крутое с Gradle.

Вот схема упрощённого графа задач, созданная с помощью Excalidraw:

Обратите внимание, что каждый проект независим от других. Чётко определённые сборки Gradle максимизируют параллелизм, соблюдая границы проектов.

Шаг 1: Глобальное пространство имён

Как упоминалось в разделе предварительной обработки, нам нужно глобальное пространство имён. Мы хотим, чтобы все декларации зависимостей ссылались на записи в версионном каталоге, например, libs.amazingMagic, а не на строки вида "com.amazing:magic:1.0".

Поскольку DAGP уже поддерживает ссылки на записи каталога версий в своём анализе, это будет работать автоматически, если ваш каталог версий уже содержит запись вида amazingMagic = "com.amazing:magic:1.0". Однако, если такой записи нет, DAGP по умолчанию использует декларацию в виде "raw string".

Если необходимо, мы можем явно указать DAGP другие соответствия, которые он не может обнаружить самостоятельно:

// корневой скрипт сборки
dependencyAnalysis {
  structure {
    map.putAll(
      "com.amazing:magic" to "libs.amazingMagic",
      // more entries
    )
  }
}

Эта конфигурация позволяет DAGP правильно сопоставлять зависимости с записями в вашем глобальном пространстве имён, упрощая их управление.

dependencyAnalysis.structure.map — это MapProperty<String, String>, которое вы можете изменять непосредственно в своих скриптах сборки или через плагин.

Обратите внимание, что "raw string" в декларации не содержит информации о версии. Это важно, так как версия, которую вы указываете, может не совпадать с версией, которую Gradle в итоге разрешит.

Шаг 2: Обновление версионного каталога, часть 1

После выполнения Шага 1, DAGP перепишет скрипты сборки с помощью встроенной задачи fixDependencies, чтобы они соответствовали вашему желаемому шаблону. Однако следующая сборка завершится неудачно, так как некоторые зависимости будут ссылаться на, например, libs.amazingMagic, которых пока нет в вашем каталоге версий. Теперь нам нужно обновить каталог, чтобы включить все эти новые записи. Это будет многошаговый процесс.

Шаг 2.1: Вычисление недостающих записей

Сначала необходимо определить потенциально отсутствующие записи. Для этого мы создаём новую задачу ComputeNewVersionCatalogEntriesTask, которая наследует AbstractPostProcessingTask из самого DAGP.

Этот класс предоставляет функцию projectAdvice(), которая даёт доступ к "project advice", генерируемым DAGP в консоли, но в форме, удобной для программной обработки.

Мы используем этот вывод, фильтруем его, чтобы оставить только "add advice", а затем записываем эти значения на диск через выходной файл задачи. Нас интересуют только "add advice", так как это единственный тип, который может указывать на зависимость, отсутствующую в каталоге версий.

Пример кода:

// В пользовательской задаче
val newEntries = projectAdvice()
    .dependencyAdvice
    .filter { it.isAnyAdd() } // Фильтруем только "add advice"
    .filter { it.coordinates is ModuleCoordinates } // Убедимся, что это модули
    .map { it.coordinates.gav() } // Получаем координаты зависимости
    .toSortedSet() // Сортируем для стабильности
outputFile.writeText(newEntries.joinToString(separator = "\n"))

Примечание:

При работе с выходными данными Gradle важно всегда сортировать их для обеспечения стабильности и возможности использования удалённого кэша сборок.

Далее мы подключим эту задачу к DAGP, чтобы она могла получать данные из projectAdvice().

// subproject's build script
computeNewVersionCatalogEntries = tasks.register(...)

dependencyAnalysis {
  registerPostProcessingTask(computeNewVersionCatalogEntries)
}

И, наконец, мы также должны зарегистрировать выходные данные нашей новой задачи как артефакт этого проекта!

val publisher = interProjectPublisher(
  project,
  MyArtifacts.Kind.VERSION_CATALOG_ENTRIES
)
publisher.publish(
  computeNewVersionCatalogEntries.flatMap { 
    it.newVersionCatalogEntries 
  }
)

где interProjectPublisher и связанный с ним код базируются на пакете artifacts из DAGP, поскольку я сам его написал. Вкратце, это механизм, который обучает Gradle работать с вторичными артефактами проекта. Жаль, что Gradle не имеет полноценного API для этого.

Шаг 3: Обновление версионного каталога, часть 2

Вернувшись к корневому проекту, нам нужно объявить зависимости для каждого подпроекта, уточнив, что нас интересует артефакт VERSION_CATALOG_ENTRIES:

// корневой проект
val resolver = interProjectResolver(
  project,
  MyArtifacts.Kind.VERSION_CATALOG_ENTRIES
)

// Да, это МОЖЕТ БЫТЬ ОК, но вы должны получать доступ 
// ТОЛЬКО к НЕИЗМЕНЯЕМЫМ (IMMUTABLE) СВОЙСТВАМ каждого проекта p.
// Это настраивает зависимости от корневого проекта 
// ко всем "реальным" подпроектам, где "реальные" 
// исключают промежуточные директории без кода.
allprojects.forEach { p ->

  // implementation left to reader
  if (isRealProject(p)) {
    dependencies.add(
      resolver.declarable.name, 
      // p.path is an immutable property, so we're
      // good
      dependencies.project(mapOf("path" to p.path))
    )
  }
}

val fixVersionCatalog = tasks.register(
  "fixVersionCatalog", 
  UpdateVersionCatalogTask::class.java
) { t ->
    t.newEntries.setFrom(resolver.internal)
    t.globalNamespace.putAll(...)
    t.versionCatalog.set(layout.projectDirectory.file("gradle/libs.versions.toml"))
  }

Корневой проект — это правильное место для регистрации этой задачи, так как каталог версий обычно находится в корневом каталоге по пути gradle/libs.versions.toml.

С такой настройкой пользователь может выполнить команду gradle :fixVersionCatalog, которая, по сути, запустит <every module>*:projectHealth, затем <every module>*:computeNewVersionCatalogEntries и, наконец, :fixVersionCatalog, так как эти шаги были заявлены и связаны.

Это обновляет каталог версий, добавляя все необходимые ссылки для разрешения всех потенциальных деклараций зависимостей вида libs.<foo> в рамках сборки.

Шаг 4: Исправление всех деклараций зависимостей

Этот шаг использует задачу fixDependencies из DAGP и заключается в завершении всего процесса в аккуратный и удобный пакет.

Нам нужна одна задача, зарегистрированная в корневом проекте. Назовём её :fixAllDependencies. Это будет задача жизненного цикла, выполнение которой запустит как :fixVersionCatalog, так и все задачи <every module>*:fixDependencies.

// корневой проект
val fixDependencies = mutableListOf<String>()

allprojects.forEach { p ->
  if (isRealProject(p)) {
    // ...как и раньше…

// не используйте что-то вроде `p.tasks.findByName()`, 
// это нарушает контракт изолированных проектов и 
// ленивую конфигурацию задач.
    fixDependencies.add("${p.path}:fixDependencies")    
  }

}

tasks.register("fixAllDependencies") { t ->
  t.dependsOn(fixVersionCatalog)
  t.dependsOn(fixDependencies)
}

На этом всё (6). 

(Необязательно) Шаг 5: Сортировка блоков зависимостей

Если вы выполнили все предыдущие шаги, у вас должна получиться успешная сборка с минимальным графом зависимостей. Однако блоки dependency могут оказаться в хаотичном порядке, что затрудняет их визуальное восприятие. DAGP не стремится упорядочивать декларации, так как это отдельная задача, и у разных команд могут быть разные предпочтения по порядку.

Именно поэтому я создал и опубликовал CLI и плагин Gradle Dependencies Sorter, который применяет, на мой взгляд, разумный порядок по умолчанию. Если вы примените его к своим сборкам (как это делаем мы через наш плагин соглашений), вы можете дополнить выполнение :fixAllDependencies командой:

gradle sortDependencies

И это обычно просто работает. Этот плагин уже использует улучшенную грамматику Kotlin из KotlinEditor, так что скрипты сборки на Gradle Kotlin DSL не должны вызывать проблем.

Теперь точно всё. 

Примечания

  1. В настоящее время поддерживаемые языки: Groovy, Java, Kotlin и Scala.

  2. Это одна из причин, почему я считаю важным сохранять скрипты простыми и декларативными.

  3. Замеры выполнены с помощью инструмента cloc.

  4. На мой взгляд, одна из главных проблем Gradle заключается в том, что API не обеспечивает соблюдение этой концептуальной границы.

  5. Этот абзац является упрощением для целей обсуждения.

  6. Ну, за исключением автоматического тестирования и написания поста в блоге.

Присоединяйтесь к русскоязычному сообществу разработчиков на Spring Boot в телеграм — Spring АйО, чтобы быть в курсе последних новостей из мира разработки на Spring Boot и всего, что с ним связано.

Комментарии (1)


  1. ChSergeyG
    21.12.2024 06:18

    А с механизмом создания и поддержания локов

    dependencyLocking{ lockAllConfigurations() }

    , устанавливающим отношение зависимость-[конфигурации], не будет конфликтовать автоупорядочение и автоподтягивание?