11 Lazy Configuration

Lazy Configuration revolutioniert die Art, wie Gradle Build-Skripte ausgewertet und Tasks konfiguriert werden. Das traditionelle Eager-Configuration-Modell evaluierte alle Tasks während der Konfigurationsphase, unabhängig davon, ob sie tatsächlich ausgeführt wurden. Bei einem Projekt mit hunderten Tasks bedeutete dies, dass selbst für einen simplen gradle help Aufruf alle Tasks konfiguriert wurden. Diese unnötige Arbeit verlängerte Build-Zeiten erheblich.

Das Lazy-Configuration-Paradigma verschiebt die Konfiguration auf den tatsächlichen Bedarfszeitpunkt. Tasks werden erst konfiguriert, wenn sie definitiv ausgeführt werden oder ihre Outputs von anderen Tasks benötigt werden. Diese Verzögerung reduziert die Konfigurationszeit drastisch, besonders in großen Multi-Modul-Projekten. Ein Build, der nur Tests ausführt, muss keine Deployment- oder Publishing-Tasks konfigurieren.

Die technische Basis bilden die Provider und Property APIs, eingeführt in Gradle 4.0 und kontinuierlich erweitert. Diese APIs ermöglichen die Deklaration von Werten, ohne sie sofort zu berechnen. Ein Provider repräsentiert einen zukünftigen Wert, der erst bei Bedarf materialisiert wird. Properties sind veränderbare Container, die Provider als Inputs akzeptieren und selbst als Provider fungieren.

11.1 Provider API im Detail

Provider ist das zentrale Interface für lazy Werte in Gradle. Ein Provider kapselt die Berechnung eines Wertes und führt diese erst bei Zugriff aus. Gradle erstellt Provider für verschiedene Kontexte: Task-Outputs, Projekt-Properties, File-Collections und berechnete Werte.

Die Erstellung und Verwendung von Providern:

val timestampProvider = providers.provider {
    println("Computing timestamp")
    System.currentTimeMillis()
}

tasks.register("printTimestamp") {
    doLast {
        println("Timestamp: ${timestampProvider.get()}")
    }
}

Die Berechnung erfolgt nur, wenn der Task tatsächlich ausgeführt wird. Bei gradle tasks wird der Provider nie evaluiert, die println-Ausgabe erscheint nicht.

Provider-Transformationen ermöglichen Verkettung ohne Evaluation:

val projectVersion = providers.provider { version.toString() }
val jarName = projectVersion.map { version -> "${project.name}-$version.jar" }
val jarPath = jarName.map { name -> layout.buildDirectory.file("libs/$name").get() }

tasks.register<Copy>("copyJar") {
    from(jarPath)
    into(layout.projectDirectory.dir("dist"))
}

Die gesamte Transformation-Chain bleibt lazy. Erst wenn der Copy-Task seine Inputs benötigt, werden alle Provider in der Kette evaluiert. Diese Verkettung ermöglicht komplexe Abhängigkeiten zwischen Werten ohne Performance-Penalty während der Konfiguration.

Provider bieten null-safe Operationen. Die orElse Methode definiert Fallback-Werte, orNull liefert null statt NoSuchElementException:

val customVersion = providers.systemProperty("build.version")
val effectiveVersion = customVersion.orElse(providers.provider { project.version.toString() })

11.2 Property API und Configuration Avoidance

Properties erweitern Provider um Mutabilität. Eine Property kann gesetzt, an Provider gebunden oder mit Conventions versehen werden. Task-Properties nutzen diese API für flexible Konfiguration:

abstract class CustomTask : DefaultTask() {
    @get:Input
    abstract val message: Property<String>
    
    @get:OutputFile
    abstract val outputFile: RegularFileProperty
    
    @TaskAction
    fun execute() {
        outputFile.get().asFile.writeText(message.get())
    }
}

tasks.register<CustomTask>("writeMessage") {
    message.set("Hello Gradle")
    outputFile.set(layout.buildDirectory.file("message.txt"))
}

Properties unterstützen Convention-Werte, die als Defaults fungieren aber überschrieben werden können:

message.convention("Default message")
// Später überschreibbar
message.set("Custom message")

Configuration Avoidance nutzt lazy APIs, um unnötige Task-Konfiguration zu vermeiden. Die register Methode erstellt Tasks lazy, während create sofort konfiguriert:

// Lazy - Task wird nur bei Bedarf konfiguriert
tasks.register<JavaCompile>("compileExtra") {
    source = fileTree("src/extra")
}

// Eager - Task wird sofort konfiguriert (vermeiden!)
tasks.create<JavaCompile>("compileEager") {
    source = fileTree("src/extra")
}

Die named Methode liefert einen TaskProvider für existierende Tasks:

val testTask = tasks.named<Test>("test")
testTask.configure {
    maxHeapSize = "2g"
}

Diese Configuration erfolgt nur, wenn der test-Task Teil des Build-Graphen wird.

11.3 Migration von Eager zu Lazy Patterns

Die Migration bestehender Build-Skripte zu Lazy Configuration erfolgt durch systematisches Ersetzen eager Patterns. Direct Task Access wird durch Provider-basierte Referenzen ersetzt:

// Eager - Alte Schreibweise
task myTask {
    dependsOn tasks.getByName("compile")
}

// Lazy - Moderne Schreibweise
tasks.register("myTask") {
    dependsOn(tasks.named("compile"))
}

Project-Properties migrieren von direktem Zugriff zu Provider-basiertem Access:

// Eager
val buildNumber = project.findProperty("buildNumber") ?: "0"
tasks.register("printBuild") {
    doLast {
        println(buildNumber)
    }
}

// Lazy
val buildNumber = providers.systemProperty("buildNumber").orElse("0")
tasks.register("printBuild") {
    doLast {
        println(buildNumber.get())
    }
}

File-Operations nutzen lazy FileCollection und ConfigurableFileTree:

// Eager - Files werden sofort gesucht
val sourceFiles = fileTree("src").matching {
    include("**/*.java")
}

// Lazy - Files werden erst bei Bedarf gesucht
val sourceFiles = providers.provider {
    fileTree("src").matching {
        include("**/*.java")
    }
}

Task-Inputs und -Outputs verwenden Property-Types statt direkte Werte:

// Eager
task.inputs.file("config.xml")
task.outputs.file("${buildDir}/output.txt")

// Lazy
task.inputs.file(layout.projectDirectory.file("config.xml"))
task.outputs.file(layout.buildDirectory.file("output.txt"))

11.4 Performance-Auswirkungen und Messungen

Lazy Configuration reduziert Konfigurationszeit signifikant. Messungen in realen Projekten zeigen Verbesserungen von 30-70% für die Configuration-Phase. Ein Gradle-Enterprise-Projekt mit 500 Submodulen reduzierte die Konfigurationszeit von 45 auf 12 Sekunden durch konsequente Lazy-Configuration-Nutzung.

Die Performance-Gewinne skalieren mit Projektgröße. Kleine Projekte mit wenigen Tasks profitieren minimal. Multi-Modul-Projekte mit hunderten Tasks und komplexen Inter-Task-Dependencies zeigen dramatische Verbesserungen. Der Effekt verstärkt sich bei häufig ausgeführten, fokussierten Builds wie gradle test oder gradle assemble.

Build Scans visualisieren Configuration Avoidance Erfolge. Die Timeline zeigt vermiedene Task-Konfigurationen als graue Einträge. Die Configuration Performance Sektion quantifiziert eingesparte Zeit. Diese Metriken helfen bei der Identifikation weiterer Optimierungspotentiale.

Profiling mit --profile generiert detaillierte Reports:

Configuration on demand is an incubating feature.
:buildSrc:compileKotlin UP-TO-DATE
:buildSrc:jar UP-TO-DATE

Configuration time: 2.451 secs (avoided 85%)
Task execution time: 15.234 secs

Total time: 17.685 secs

Der “avoided” Prozentsatz zeigt den Erfolg der Lazy Configuration. Werte über 80% sind in gut strukturierten Projekten erreichbar.

11.5 Best Practices und Patterns

Die konsequente Verwendung der Provider API maximiert Performance-Gewinne. Alle Task-Konfigurationen sollten in configure Blocks erfolgen, nicht während der Registration. Provider-Chains sollten so lange wie möglich lazy bleiben. Die get() Methode sollte nur in Task Actions aufgerufen werden, nie während der Konfiguration.

Task-Dependencies nutzen Provider-basierte Methoden:

tasks.register("aggregate") {
    val testTasks = tasks.withType<Test>()
    dependsOn(testTasks)
    
    inputs.files(testTasks.map { it.outputs.files })
}

Dieser Pattern vermeidet die Iteration über alle Test-Tasks während der Konfiguration.

Cross-Project-Dependencies bleiben lazy durch Project-Provider:

val apiProject = project.provider { project(":api") }
dependencies {
    implementation(apiProject)
}

Convention Plugins nutzen lazy APIs für flexible Defaults:

interface MyExtension {
    val serverUrl: Property<String>
}

val extension = extensions.create<MyExtension>("myPlugin")
extension.serverUrl.convention(providers.environmentVariable("SERVER_URL")
    .orElse("https://default.server.com"))

Error Handling in lazy Kontexten erfordert Sorgfalt. Provider-Evaluation kann Exceptions werfen, die erst zur Execution-Zeit auftreten. Defensive Programmierung mit mapNotNull und orElse verhindert Runtime-Fehler:

val safePath = providers.systemProperty("custom.path")
    .mapNotNull { path ->
        try {
            file(path).takeIf { it.exists() }?.absolutePath
        } catch (e: Exception) {
            null
        }
    }
    .orElse(layout.projectDirectory.dir("default").asFile.absolutePath)

Die Dokumentation lazy Patterns in Team-Guidelines standardisiert die Verwendung. Code Reviews prüfen auf eager Anti-Patterns. Automated Tests validieren Configuration Avoidance durch Assertions auf Task-Registrations. Diese Praktiken etablieren Lazy Configuration als Standard und verhindern Performance-Regressionen.

11.6 Übung

11.6.1 Lazy Configuration in Gradle

Gradle unterscheidet zwischen eager und lazy Configuration.

Wichtige Mechanismen:


11.6.1.1 Beispielprojekt

11.6.1.1.1 Projekt initialisieren
mkdir lazy-demo && cd lazy-demo
gradle init --type basic --dsl kotlin
mkdir -p src/main/java
11.6.1.1.2 build.gradle.kts
import java.time.LocalDateTime

plugins {
    java
}

repositories {
    mavenCentral()
}

java {
    toolchain {
        languageVersion.set(JavaLanguageVersion.of(17))
    }
}

dependencies {
    testImplementation("junit:junit:4.13.2")
}

// Provider für verzögerte Berechnung
val zeitstempel = providers.provider {
    println(">>> BERECHNE Zeitstempel")
    LocalDateTime.now().toString()
}

// Custom Task mit Properties
abstract class MeinTask : DefaultTask() {
    @get:Input
    abstract val eingabe: Property<String>
    @get:OutputFile
    abstract val ausgabe: RegularFileProperty

    @TaskAction
    fun aktion() {
        ausgabe.get().asFile.writeText(eingabe.get())
        println("Datei geschrieben: ${eingabe.get()}")
    }
}

// Lazy Task Registration
tasks.register<MeinTask>("meinTask") {
    eingabe.set(zeitstempel)
    ausgabe.set(layout.buildDirectory.file("output.txt"))
}

// Zusätzlicher Demo-Task
tasks.register("demo") {
    doLast {
        println("Demo-Task läuft")
    }
}
11.6.1.1.3 src/main/java/Demo.java
public class Demo {
    public static void main(String[] args) {
        System.out.println("Lazy Demo");
    }
}

11.6.1.2 Verhalten


11.6.1.3 Kernprinzipien

11.6.2 Aufgabe: Eager vs. Lazy Configuration ausprobieren

  1. Ergänze dein Projekt um zwei Tasks:

  2. Führe ./gradlew tasks aus.

  3. Führe ./gradlew lazyTask aus.

  4. Erstelle zusätzlich einen Provider, der einen Zufallswert erzeugt:

    val randomProvider = providers.provider {
        println("Berechne Zufallswert")
        (1..1000).random()
    }

Verwende diesen Provider in beiden Tasks. Vergleiche, wann die Berechnung ausgelöst wird.