Die richtige Balance in der Gradle-Abhängigkeitsstrategie finden

Die richtige Balance in der Gradle-Abhängigkeitsstrategie finden

Veröffentlicht am 25.11.2023 | Zuletzt aktualisiert am 3.1.2026

Einführung

Im dynamischen Bereich des Software-Engineerings ist die Beherrschung der Abhängigkeitsverwaltung unerlässlich. Mit Gradle stehen Entwicklern zahlreiche Strategien zur Deklaration von Abhängigkeiten, Plugins und Versionen zur Verfügung. Wir werden diese Methoden untersuchen und die Gründe für die Wahl jedes Ansatzes analysieren, wobei wir ihre Vor- und Nachteile beleuchten.

🎉 Hurra! Dieser Artikel wurde im Gradle Newsletter für Februar 2024 vorgestellt.

Er wurde ursprünglich auf dev.to veröffentlicht — https://dev.to/y9vad9/finding-the-right-balance-in-gradle-dependency-strategy-4jdl

Warum?

Bevor wir uns mit den Methoden befassen, ist es entscheidend, die Bedeutung gut strukturierter Abhängigkeiten zu verstehen. Welche potenziellen Probleme können in Zukunft auftreten, und welche Probleme sollte eine ordnungsgemäße Strukturierung lösen? Lassen Sie uns das untersuchen.

Aktualisierung Ihrer Abhängigkeiten

In Multi-Modul-Projekten kann die Aktualisierung von Abhängigkeiten wie Kotlin-Versionen oder Bibliotheksversionen schnell zu einer entmutigenden Aufgabe werden. Stellen Sie sich vor, Sie müssten jede build.gradle.kts-Datei durchsuchen, den Plugins-Block finden und die Versionsfunktion für jede Abhängigkeit oder jedes Plugin ändern. Die eigentliche Herausforderung entsteht, wenn Sie es mit einer beträchtlichen Anzahl von Modulen zu tun haben. Es ist unglaublich einfach, eine bestimmte Abhängigkeit zu übersehen, was zu Versionskonflikten oder, wie im Fall von Jetpack Compose, zu Problemen mit instabilen APIs führen kann, die eng an bestimmte Kotlin-Compiler-Versionen gebunden sind (was Ihren Build mit unerwarteten Fehlern leicht zum Absturz bringen kann). Sie möchten doch nicht stundenlanges Debugging betreiben, nur wegen einfacher Fehler, oder?

Sicherheitslücken

Im Bereich der Softwaresicherheit können Schwachstellen in Ihrem Code erhebliche Risiken darstellen. Viele Schwachstellenscanner haben jedoch Probleme mit Abhängigkeiten, die teilweise in anderen Dateien als build.gradle.kts (z. B. Versionen in properties-Dateien) definiert sind. Es ist entscheidend, Praktiken anzuwenden, die mit Sicherheitssystemen übereinstimmen, um sicherzustellen, dass Ihr Code robust und sicher bleibt.

Mangelnde Zentralisierung

In kleineren Projekten mag die manuelle Verwaltung von Abhängigkeiten machbar erscheinen. Doch mit der Ausweitung des Projekts und des Teams wird dieser Ansatz schnell zu einer Herausforderung. Es ist weitaus effizienter, Probleme in bestimmten Dateien zu erkennen, anstatt zahlreiche Konfigurationsdateien zu durchforsten, um potenzielle Probleme zu identifizieren.

Eine Zentralisierung der Abhängigkeitsverwaltung verbessert die Dokumentation. Sie ermöglicht es, spezifische Entscheidungen, wie die Wahl der Versionen, klar zu dokumentieren. Dieser zentralisierte Ansatz vereinfacht das Hinterlassen von Notizen oder Aufgaben für Überprüfungen oder die Beeinflussung von Abhängigkeiten. Eine konsistente Formatierung minimiert Verwirrung und verbessert die Lesbarkeit, was ein gemeinsames Verständnis der Projektabhängigkeiten fördert. Dies schafft ein effizienteres kollaboratives Umfeld für das Team.

Schließlich bietet ein zentralisierter Ansatz Konsistenz im Definitionsstil. Wenn sich alle an ein standardisiertes Format und eine standardisierte Struktur halten, minimiert dies Verwirrung und verbessert die Lesbarkeit. Es fördert ein kohärentes Verständnis der Projektabhängigkeiten, wodurch die Zusammenarbeit reibungsloser und effektiver wird.


Insgesamt macht die zentrale Deklaration Ihrer Methoden Ihre Build-Konfiguration übersichtlicher, verständlicher und für Neulinge wartbarer.

Lösungen

Wir haben bereits mögliche Probleme besprochen, also lassen Sie uns unsere Hauptziele definieren:

  • Einfach
  • Sicher
  • Wartbar

Nun werden wir die Lösungen mit ihren Problemen und Vorteilen besprechen. Beginnen wir mit der einfachsten.

Eigenschaften als Versionscontainer

In der sich ständig weiterentwickelnden Landschaft des Gradle-Build-Managements bleibt die Verwendung von .properties-Dateien als Versionscontainer ein weit verbreiteter Ansatz. Diese Methode zentralisiert die Abhängigkeitsversionen und gewährleistet eine projektweite Konsistenz.

Zum Beispiel können Sie Ihre Versionen zu gradle.properties hinzufügen:

kotlinVersion=1.9.20-RC
coroutinesVersion=1.7.3

Und darauf in Ihrer build.gradle.kts zugreifen:

// ... plugins, repositories

val coroutinesVersion by project // es wird die Eigenschaft automatisch nach Namen auflösen

dependencies {
	implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion")
}

Aber es hat offensichtliche Nachteile:

  • Fehlende Plugin-Unterstützung: Da plugins vor jedem anderen Code in der build.gradle.kts ausgewertet wird und Einschränkungen bei Operationen innerhalb hat, können Sie keine Eigenschaften verwenden, um Versionen für Ihren Build bereitzustellen.
  • Boilerplate: Es fügt viel Boilerplate-Code hinzu (wenn wir mehr als eine Abhängigkeit haben, was offensichtlich immer der Fall ist), der andere Logik unübersichtlich macht und sie weniger wartbar und verständlich.

Einer der Vorteile ist, dass Sie Eigenschaften in der Gradle Build-Phase überschreiben können, aber Sie werden dies wahrscheinlich nie benötigen.

In einigen Fällen können Sie einen solchen Ansatz mit einem anderen kombinieren, wenn Sie ihn wirklich benötigen, aber für die meisten Projekte ist es eine schlechte Idee.

Abhängigkeiten als Konstanten

Da Gradle Composite Builds und das buildSrc-Konventionsmodul (Hinweis: es wird ebenfalls als Composite Build behandelt, aber implizit) unterstützt, können Sie einfach ein Singleton mit Konstanten in Kotlin / Java / Groovy erstellen, das Abhängigkeiten und Versionsdefinitionen enthält und diese direkt in Ihren Builds auf folgende Weise verwenden:

object Deps {
	object Libs {
		object Kotlinx {
			const val Coroutines = "org.jetbrains.kotlinx:kotlinx-coroutines-core:${Versions.coroutines}"
		}
	}

	object Versions {
		const val kotlin = "1.9.20-RC"
		const val coroutines = "1.7.3"
	}

	object Plugins {
		object Kotlin {
			// nehmen wir das jvm-Plugin nur als Beispiel
			const val Id = "org.jetbrains.kotlin.jvm"
		}
	}
}

Im Fall von buildSrc ist es immer im Classpath (Kontext) Ihrer Gradle-Build-Konfigurationen und der gesamte Code aus src/main/kotlin (oder einem anderen, falls zutreffend) ist in jeder build.gradle.kts innerhalb eines Projekts zugänglich. Für Composite Builds sollten Sie ein Gradle-Plugin registrieren und es im gewünschten Modul definieren (wir werden dies in diesem speziellen Artikel nicht besprechen, es dient nur zu Ihrer Information).

Selbst bei dieser Lösung gibt es mehrere Varianten, wie wir einen solchen Ansatz letztendlich nutzen können, aber lassen Sie uns meiner Meinung nach die beste verwenden. In Ihrer Stamm-Gradle-Projektdatei build.gradle.kts können Sie Folgendes tun:

plugins {
	// wir sollten dies für jedes Plugin tun, das wir in Modulen verwenden
	// es wird den Classpath beeinflussen und uns von der Angabe der Version befreien
	// jedes Mal
	id(Deps.Plugins.Kotlin.Id) version (Deps.Versions.kotlin) apply false
}

Erklärung In Gradle gibt das Root-Modul den Ton für ein Multi-Modul-Projekt an. Konfigurationen, Plugins und Abhängigkeiten, die im Root-Modul definiert sind, werden automatisch auf alle Submodule erweitert. Dies gewährleistet eine einheitliche Build-Umgebung im gesamten Projekt. Die hierarchische Struktur vereinfacht die Verwaltung, indem sie allgemeine Einstellungen im Root-Verzeichnis zulässt, mit Flexibilität für Anpassungen in einzelnen Submodulen.

In unseren Modulen können wir dieses Plugin und unsere Abhängigkeit auf folgende Weise verwenden:

plugins {
	// jetzt müssen wir keine Version mehr angeben, sie ist bereits im Classpath
	id(Deps.Plugins.Kotlin.Id)
}

// ... repositories

dependencies {
	implementation(Deps.Libs.kotlinxCoroutines)
}

Was sind die Vorteile?

  1. Zugänglichkeit im Code: Im Falle von Composite Builds können wir es wie gewöhnlichen Code ohne Probleme von anderen Composite Builds (mein Beispiel) oder sogar in unserem Code verwenden. Bei Versionskatalogen, die wir als Nächstes besprechen werden, können Sie sie nicht als regulären Code in Composite Builds (wichtiger Hinweis: Sie können sie nicht in src/main/kotlin verwenden, aber in ihrer Konfiguration) ohne Hacks verwenden (auch dies ist nur im richtigen Kontext möglich).
  2. Bessere Navigation und Refactoring: Die Verwendung des gleichen Mechanismus im gesamten Code verbessert die IDE-Unterstützung und macht die Navigation und das Refactoring effizienter. Diese integrierte Konsistenz verbessert das gesamte Entwicklungserlebnis.

Nun, lassen Sie uns über die Nachteile dieser Methode sprechen:

  • Mangelnde Standardisierung: Da sie von Gradle nicht empfohlen wird und in der Community nicht so verbreitet ist, macht sie Ihre Build-Logik komplexer und weniger verständlich.
  • Sicherheitsscans: Fast alle Sicherheitsscanner (außer Scanner, die als Gradle-Plugins funktionieren) unterstützen diese Methode nicht (z. B. verwandtes Problem in Dependabot), wodurch die Effektivität der Sicherheitsanalyse im Projekt vollständig entfällt.
  • Automatisches Aktualisieren: Eine solche Methode wird von der IDE-Auto-Update- / Migrationsfunktion (und wie bereits erwähnt, auch von Dependabot) nicht unterstützt.

Bestimmte Probleme mit Abhängigkeitsverwaltungsmethoden können tatsächlich behoben (oder als für bestimmte Projekte unwichtig ignoriert) werden, wenn auch nicht ohne Kompromisse.

Zusammenfassend lässt sich sagen, dass diese Methode nicht die wartbarste oder sicherste ist und keine Integration mit leistungsstarken Tools wie Dependabot bietet. Sie sind relativ unkompliziert, haben aber solche Einschränkungen. In Zukunft könnte dies behoben werden, aber im Moment ist es ein großes Problem, wenn Sie Dependabot verwenden möchten.

📝 Hinweis Dies gilt auch für die anderen Varianten, z. B. dependencies.gradle-Dateien, die in Groovy geschrieben sind und Konstanten für Ihre Build-Skripte bereitstellen – sie werden von den meisten Scannern nicht überprüft.

💡 Bonus Bezüglich buildSrc birgt es auch Herausforderungen. Die Verwendung kann die Wiederverwendbarkeit behindern, die Komplexität erhöhen und schrittweise Projektmigrationen erschweren. Es kann sogar zu Classpath-Problemen und Leistungsproblemen in komplexen Nutzungsszenarien führen. Folglich vermeide ich die Verwendung von buildSrc in meinen Projekten aufgrund dieser potenziellen Komplikationen. Bei Composite Builds ist dies nicht immer der Fall, aber es macht Ihre Build-Konfiguration ohnehin weniger verständlich.

Versionskataloge

Nun wollen wir eine relativ neue Innovation in Gradle besprechen – Versionskataloge. Was sind Versionskataloge?

❓ Definition Versionskatalog – dies ist eine zentralisierte Datei (im TOML-Format) in einem Gradle-Projekt, die strukturierte Versionsinformationen für Bibliotheken und Plugins enthält (normalerweise in gradle/libs.versions.toml).

Hier ist ein Beispiel einer solchen Definition:

[versions]
kotlin = "1.9.20-RC"
coroutines = "1.7.3"

[libraries]
kotlinx-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }

[plugins]
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }

Innerhalb von build.gradle.kts:

plugins {
	// es gibt eine spezielle Funktion für die Versionskatalogdefinition
	alias(libs.plugins.kotlin.jvm)
}

dependencies {
	implementation(libs.kotlinx.coroutines)
}

📝 Hinweis Sie können mehrere TOML-Definitionsdateien erstellen, Details hierzu finden Sie im offiziellen Handbuch.

Warum Versionskataloge?

  1. Standardisierung und Vertrautheit: Versionskataloge werden von Gradle empfohlen, was sie für Entwickler weithin verständlich macht. Ihr strukturierter Ansatz vereinfacht die Abhängigkeitsverwaltung und macht sie einem breiten Publikum zugänglich.
  2. Dependabot-Kompatibilität: Versionskataloge lassen sich nahtlos in Tools wie Dependabot integrieren und stellen sicher, dass Ihr Projekt mit den neuesten Bibliotheksversionen auf dem neuesten Stand bleibt. Diese Kompatibilität optimiert den Prozess der Verwaltung von Abhängigkeiten und der Behebung von Sicherheitslücken.
  3. IDE-Unterstützung: IDEs wie IntelliJ IDEA bieten eine integrierte Unterstützung für Versionskataloge. Entwickler können bequem direkt in der IDE nach Updates suchen, was den Entwicklungsworkflow verbessert und eine effiziente Abhängigkeitsverwaltung fördert.

📝 Hinweis Es gibt mehrere Möglichkeiten, Versionskataloge zu definieren, aber zum Beispiel unterstützt Dependabot nur das Lesen aus einer Datei (es ist erwähnenswert, dass dies die meisten Fälle abdecken wird). Es könnte jedoch in Zukunft gelöst werden.

Nachteile:

  1. Einschränkung bei Composite Builds: Versionskataloge haben Schwierigkeiten bei der Integration von generiertem Code in Composite Builds. Diese Einschränkung beeinträchtigt die Flexibilität der Verwendung von generiertem Code als reguläre Komponenten und erfordert zusätzlichen Aufwand und Workarounds in Composite Build (und nicht nur) Szenarien.
  2. Refactoring: Wenn Sie Namen oder Pfade Ihrer Abhängigkeiten ändern möchten, müssen Sie dies in Ihren Build-Konfigurationen manuell tun, da es keine integrierte Unterstützung dafür in der IDE gibt (zumindest zum Zeitpunkt der Erstellung dieses Artikels).

Insgesamt wähle ich diese Methode für alle meine neuen Projekte, da sie bereits ein Standard ist.

Classpath

Es gibt verschiedene Möglichkeiten, wie Sie Versionen Ihrer Plugins bereitstellen können, indem Sie sie über den Classpath weitergeben.

BuildScript

In älteren Gradle-Versionen war die Angabe von Plugin-Versionen direkt im buildscript-Block eine gängige Praxis. Sie ermöglichte es Entwicklern, die Version eines Plugins explizit zu definieren. So wurde es typischerweise gemacht:

buildscript {
	repositories {
		mavenCentral()
	}

	dependencies {
		classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.20-RC")
	}
}

In diesem Ansatz wird die Version des Kotlin Gradle-Plugins (1.9.20-RC in diesem Beispiel) im buildscript-Block deklariert. Diese Versionsbeschränkung stellt sicher, dass das Projekt die angegebene Version des Plugins verwendet (kann aber von Submodulen mit speziellem Aufwand überschrieben werden).

Sobald die Plugin-Version im buildscript-Block angegeben ist, wird das Anwenden des Plugins vereinfacht. Entwickler können das Plugin anwenden, ohne seine Version explizit anzugeben:

plugins {
	kotlin("jvm")
}

In diesem Schnipsel wird das kotlin("jvm")-Plugin angewendet, ohne die Version zu erwähnen. Gradle verweist automatisch auf die im buildscript (genauer gesagt, wird buildscript vor jedem anderen Block in Ihrem Skript ausgewertet und fügt angegebene Abhängigkeiten in den Classpath ein, der immer zur Auflösung von Plugins und Abhängigkeiten ohne angegebene Version verwendet wird; außerdem erzwingt es die Verwendung einer bestimmten Version, da Sie nicht dasselbe Plugin mit verschiedenen Versionen haben können) Block angegebene Version.

Vorteile:

  • Explizite Versionierung: Die Versionsbeschränkung ist im Build-Skript klar definiert, wodurch sichergestellt wird, dass das Projekt eine bestimmte Version des Plugins verwendet.
  • Konsistente Versionen: Alle Module im Projekt verwenden automatisch die angegebene Version, was die Konsistenz fördert.
  • Automatisches Aktualisieren: IntelliJ IDEA (und Android Studio) unterstützt die automatische Migration bei solchen Deklarationen.

Nachteile:

  • Wartungsaufwand: Das manuelle Aktualisieren der Version im buildscript-Block für jedes Plugin kann mühsam sein, insbesondere in großen Projekten mit zahlreichen Plugins.
  • Weniger prägnante Build-Skripte: Der buildscript-Block erhöht die Ausführlichkeit des Build-Skripts, was das Lesen und Warten erschweren kann, insbesondere wenn die Anzahl der Plugins zunimmt.
  • Kompatibilität mit Sicherheitsscannern: Dependabot unterstützt keine Schwachstellenprüfungen bei solchen Deklarationen.

Obwohl diese Methode in der Vergangenheit weit verbreitet war, wird sie aufgrund des Wartungsaufwands und der damit verbundenen Ausführlichkeit heute weniger empfohlen.

❓ Erklärung Moderne Gradle-Praktiken raten aus praktischen Gründen davon ab, Plugin-Versionen direkt im buildscript-Block anzugeben. Stattdessen wird empfohlen, die Versionsverwaltung mithilfe von Versionskatalogen oder pluginManagement (wir werden dies weiter unten besprechen) zu zentralisieren. Der Plugin-Management-Ansatz ermöglicht beispielsweise die dynamische Versionsauflösung, was Build-Skripte vereinfacht und unnötige Unordnung verhindert.

pluginManagement

Wie bereits erwähnt, ist dies eine moderne Methode zur Deklaration Ihrer Plugin-Version, Repositories und Auflösungsregeln. Beispiel:

pluginManagement {
    repositories {
        gradlePluginPortal()
        mavenCentral()
        google()
    }
    plugins {
        id("org.jetbrains.kotlin.jvm") version "1.9.20-RC"
    }
}

Zusätzlich können Sie bestimmte Versionen mithilfe von resolutionStrategy erzwingen:

pluginManagement {
    repositories {
        mavenCentral()
    }
    resolutionStrategy {
        eachPlugin {
            if (requested.id.namespace == "com.example") {
                useVersion("1.2.3") // Geben Sie hier die gewünschte Version an
            }
        }
    }
}
Vorteile von pluginManagement:
  1. Fehlende Syntaxbeschränkung: Sie können Versionen aus Eigenschaften oder jeder anderen unterstützten Quelle verwenden. Dasselbe konnten Sie mit Plugins nicht tun, da Plugins-Block immer separat ausgewertet wird von anderen Teilen des Build-Skripts.
  2. Dynamische Versionsauflösung: Sie können Versionen oder ganze Plugin-Quellen mithilfe von resolutionStrategy ersetzen.
Nachteile von pluginManagement:
  1. Potenzielle Komplexität:
    • Nachteil: Übermäßiger Gebrauch (oder Gebrauch ohne wirkliche Notwendigkeit) kann zu komplexen und schwerer wartbaren Skripten führen.
  2. Kompatibilität mit Sicherheitsscannern: Dependabot unterstützt keine Schwachstellenprüfungen bei solchen Deklarationen.

Insgesamt sollte diese Funktion nur verwendet werden, wenn Sie einen speziellen Bedarf haben. Ziehen Sie einfachere Varianten in Betracht, wenn Sie nur die Versionskontrolle benötigen.

BOM

Ein BOM (Bill of Materials) in Gradle ist ein zentrales Versionsverwaltungstool für mehrere Abhängigkeiten. Es ist eine Datei, die kompatible Versionen von Bibliotheken und ihren Abhängigkeiten auflistet. Das Importieren einer BOM gewährleistet konsistente Versionen und minimiert Konflikte in komplexen Projekten. BOMs vereinfachen die Versionskontrolle und bieten Stabilität und Kohärenz im gesamten Projekt.

Hier ist ein Beispiel einer BOM-Deklaration:

javaPlatform {
    allowDependencies()
}

dependencies {
    constraints {
        api("org.slf4j:slf4j-api:2.0.9")
        api("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3")
        // ...
    }
}

Und wie man es verwendet:

dependencies {
	implementation(enforcedPlatform(project(":some-bom")))
}

Wie genau hilft es bei der Projektorganisation?

  • Verhindert Konflikte: Stellt sicher, dass Module kompatible Versionen verwenden, wodurch Konflikte vermieden werden.
  • Veröffentlichbar: Sie können Ihre BOM problemlos auf Maven veröffentlichen, zum Beispiel für Ihre Bibliothekskonsumenten; das Gleiche können Sie mit Versionskatalogen (mit der gleichen Einfachheit), Eigenschaften usw. nicht tun.
  • Kompatibilität mit Scannern: Das .pom-Format, das für veröffentlichte BOMs verwendet wird, wird von den meisten Scannern leicht erkannt (wichtiger Hinweis: die Maven scannen, Dependabot unterstützt es nicht direkt; aber Sie können es immer zusammen mit Versionskatalogen verwenden, zum Beispiel).

Es ist besonders nützlich in Fällen, in denen Ihre Bibliotheken eng an bestimmte Versionen anderer Bibliotheken, Compiler-APIs usw. gebunden sind.

Der Hauptnachteil ist die Möglichkeit, dass Sie dieses Maß an Abhängigkeitsdeklaration möglicherweise nicht benötigen und einfachere Optionen wie Versionskataloge bevorzugen könnten.

Fazit

Wir haben verschiedene Methoden zur Definition von Abhängigkeiten und zur Verwaltung von Plugins und deren Versionen untersucht. Es ist entscheidend zu betonen, dass Ihre Wahl den spezifischen Anforderungen Ihres Projekts entsprechen sollte. Zum Beispiel kann der pluginManagement-Block Plugin-Versionen verwalten, aber er könnte Ihre Build-Konfiguration unnötig verkomplizieren, ohne wesentliche Vorteile zu bieten. Ebenso sollte die Entscheidung für Konstanten in Composite Builds oder buildSrc auf tatsächlicher Notwendigkeit beruhen, wobei sowohl Vorteile als auch potenzielle Nachteile berücksichtigt werden sollten. Bewerten Sie immer die tatsächlichen Bedürfnisse Ihres Projekts, bevor Sie diese Entscheidungen treffen.