12 Task Bindings und Layout-API (Abschied von $buildDir)

12.1 Die Problematik hardcodierter Pfade

Traditionelle Gradle-Skripte verwendeten String-basierte Pfadangaben mit Variablen wie $buildDir oder ${project.buildDir}. Diese Ansätze führten zu mehreren Problemen: Pfade wurden während der Konfigurationsphase aufgelöst, Änderungen am Build-Verzeichnis nach der Konfiguration wurden ignoriert, und Task-Abhängigkeiten mussten manuell deklariert werden. Die String-Interpolation verhinderte zudem Configuration Caching, da die aufgelösten Pfade im Cache gespeichert wurden.

Ein typisches Anti-Pattern war die direkte Pfadkonstruktion:

// Problematisch - Eager Evaluation
tasks.register<Copy>("copyResources") {
    from("src/main/resources")
    into("${buildDir}/resources")  // String wird sofort aufgelöst
}

Diese Konfiguration evaluiert buildDir während der Konfigurationsphase. Wenn sich das Build-Verzeichnis später ändert, verwendet der Task weiterhin den ursprünglichen Pfad. Zusätzlich kann Gradle keine impliziten Abhängigkeiten zwischen Tasks erkennen, die über Dateipfade verbunden sind.

Die Layout-API löst diese Probleme durch typsichere, lazy Directory- und File-Provider. Diese Provider repräsentieren Speicherorte ohne sofortige Pfadauflösung. Task-Verbindungen über Input/Output-Properties etablieren automatisch korrekte Abhängigkeiten. Die Configuration Cache kann diese Strukturen effizient serialisieren, da sie keine aufgelösten Pfade enthält.

12.2 Layout-API Grundlagen

Die Layout-API bietet zwei zentrale Interfaces: ProjectLayout für projektbezogene Verzeichnisse und BuildLayout für build-weite Strukturen. Jedes Projekt erhält eine Layout-Instanz, die Zugriff auf wichtige Verzeichnisse ermöglicht:

val projectLayout = layout  // Injected property in Tasks

// Projekt-Verzeichnisse
val projectDir = layout.projectDirectory
val buildDir = layout.buildDirectory

// Dateien und Unterverzeichnisse
val configFile = layout.projectDirectory.file("config.properties")
val outputDir = layout.buildDirectory.dir("generated")

Die Layout-API arbeitet mit Provider-basierten Types. Ein DirectoryProperty oder RegularFileProperty repräsentiert einen konfigurierbaren Speicherort. Diese Properties können an Provider gebunden werden, was automatische Updates bei Änderungen ermöglicht:

abstract class ProcessTask : DefaultTask() {
    @get:InputDirectory
    abstract val sourceDir: DirectoryProperty
    
    @get:OutputDirectory
    abstract val targetDir: DirectoryProperty
    
    @TaskAction
    fun process() {
        val source = sourceDir.get().asFile
        val target = targetDir.get().asFile
        // Verarbeitung
    }
}

tasks.register<ProcessTask>("process") {
    sourceDir.set(layout.projectDirectory.dir("src/data"))
    targetDir.set(layout.buildDirectory.dir("processed"))
}

Die Layout-API unterstützt relative Pfadauflösung ohne String-Manipulation:

val baseDir = layout.buildDirectory.dir("base")
val subDir = baseDir.map { it.dir("sub/nested") }
val file = subDir.map { it.file("data.json") }

Diese Provider-Chains bleiben lazy und werden erst bei Bedarf aufgelöst.

12.3 Wire Protocol und implizite Task-Dependencies

Das Wire Protocol ist Gradles Mechanismus zur automatischen Erkennung von Task-Abhängigkeiten durch Input/Output-Verbindungen. Wenn ein Task-Output als Input eines anderen Tasks verwendet wird, etabliert Gradle automatisch die korrekte Ausführungsreihenfolge. Dies eliminiert fehleranfällige manuelle dependsOn Deklarationen.

Ein praktisches Beispiel demonstriert das Wire Protocol:

abstract class GenerateTask : DefaultTask() {
    @get:OutputFile
    abstract val outputFile: RegularFileProperty
    
    @TaskAction
    fun generate() {
        outputFile.get().asFile.writeText("Generated content")
    }
}

abstract class ProcessTask : DefaultTask() {
    @get:InputFile
    abstract val inputFile: RegularFileProperty
    
    @get:OutputFile
    abstract val outputFile: RegularFileProperty
    
    @TaskAction
    fun process() {
        val content = inputFile.get().asFile.readText()
        outputFile.get().asFile.writeText(content.uppercase())
    }
}

val generateTask = tasks.register<GenerateTask>("generate") {
    outputFile.set(layout.buildDirectory.file("temp/generated.txt"))
}

tasks.register<ProcessTask>("process") {
    inputFile.set(generateTask.flatMap { it.outputFile })
    outputFile.set(layout.buildDirectory.file("final/processed.txt"))
}

Gradle erkennt, dass process von generate abhängt, ohne explizite dependsOn Deklaration. Die flatMap Operation verbindet die Tasks über ihre Properties und behält die lazy Evaluation bei.

Task-Output-Collections werden durch ConfigurableFileCollection verbunden:

val sourceFiles = files()

tasks.register<JavaCompile>("compileExtra") {
    source = sourceFiles
    destinationDirectory.set(layout.buildDirectory.dir("classes/extra"))
}

tasks.register<Jar>("packageExtra") {
    from(tasks.named<JavaCompile>("compileExtra").flatMap { it.destinationDirectory })
    archiveFileName.set("extra.jar")
    destinationDirectory.set(layout.buildDirectory.dir("libs"))
}

12.4 Migration von Legacy-Patterns

Die Migration von String-basierten Pfaden zur Layout-API erfolgt systematisch. Direkte buildDir Referenzen werden durch Layout-API-Aufrufe ersetzt:

// Alt - String-basiert
tasks.register<Delete>("cleanTemp") {
    delete("${buildDir}/temp")
}

// Neu - Layout-API
tasks.register<Delete>("cleanTemp") {
    delete(layout.buildDirectory.dir("temp"))
}

File-Collections migrieren zu Provider-basierten Konstrukten:

// Alt - Eager file collection
val configs = fileTree("${buildDir}/configs") {
    include("*.xml")
}

// Neu - Lazy provider-basiert
val configs = layout.buildDirectory.dir("configs").map { configDir ->
    fileTree(configDir) {
        include("*.xml")
    }
}

Task-Inputs und -Outputs nutzen Property-Types statt Strings:

// Alt - String-basierte Inputs/Outputs
task.inputs.dir("${projectDir}/templates")
task.outputs.file("${buildDir}/report.html")

// Neu - Typsichere Properties
abstract class ReportTask : DefaultTask() {
    @get:InputDirectory
    abstract val templateDir: DirectoryProperty
    
    @get:OutputFile
    abstract val reportFile: RegularFileProperty
    
    init {
        templateDir.convention(project.layout.projectDirectory.dir("templates"))
        reportFile.convention(project.layout.buildDirectory.file("report.html"))
    }
}

Cross-Task-Wiring ersetzt manuelle Pfadweitergabe:

// Alt - Manuelle Pfadweitergabe
val generateTask = tasks.register("generate") {
    ext.set("outputPath", "${buildDir}/generated/data.json")
    doLast {
        file(ext.get("outputPath")).writeText("{}")
    }
}

tasks.register("consume") {
    dependsOn(generateTask)
    doLast {
        val path = generateTask.get().ext.get("outputPath") as String
        println(file(path).readText())
    }
}

// Neu - Wire Protocol
abstract class GenerateJsonTask : DefaultTask() {
    @get:OutputFile
    abstract val jsonFile: RegularFileProperty
}

val generateTask = tasks.register<GenerateJsonTask>("generate") {
    jsonFile.set(layout.buildDirectory.file("generated/data.json"))
}

tasks.register<DefaultTask>("consume") {
    val jsonInput = generateTask.flatMap { it.jsonFile }
    inputs.file(jsonInput)
    
    doLast {
        println(jsonInput.get().asFile.readText())
    }
}

12.5 FileOperations mit Layout-API

Die Layout-API integriert nahtlos mit Gradles FileOperations. Copy-Tasks nutzen Directory- und File-Provider als Sources und Destinations:

tasks.register<Copy>("copyDocs") {
    from(layout.projectDirectory.dir("docs"))
    into(layout.buildDirectory.dir("documentation"))
    
    // Dynamische Filterung basierend auf Properties
    val includePattern = providers.systemProperty("docs.include").orElse("**/*.md")
    include(includePattern)
}

Sync-Tasks verwenden Layout-Provider für konsistente Verzeichnis-Synchronisation:

tasks.register<Sync>("syncResources") {
    from(layout.projectDirectory.dir("src/main/resources"))
    
    // Destination kann von anderen Tasks abhängen
    val processedDir = tasks.named<ProcessResources>("processResources")
        .flatMap { it.destinationDir }
    
    into(layout.buildDirectory.dir("synchronized").map { syncDir ->
        if (processedDir.isPresent) syncDir else layout.buildDirectory.dir("fallback").get()
    })
}

Archive-Tasks nutzen Provider für dynamische Namensgebung:

tasks.register<Zip>("packageSources") {
    from(layout.projectDirectory.dir("src"))
    
    // Archivname basierend auf Version
    archiveBaseName.set(project.name)
    archiveVersion.set(providers.provider { version.toString() })
    archiveExtension.set("zip")
    
    destinationDirectory.set(layout.buildDirectory.dir("distributions"))
}

12.6 Best Practices und Performance-Optimierungen

Die konsequente Nutzung der Layout-API verbessert Build-Performance und Wartbarkeit. Provider sollten so lange wie möglich lazy bleiben. Die get() Methode sollte nur in Task-Actions aufgerufen werden, niemals während der Konfiguration. Dies maximiert Configuration Avoidance und reduziert Konfigurationszeit.

Convention-basierte Defaults reduzieren Boilerplate:

abstract class StandardTask : DefaultTask() {
    @get:OutputDirectory
    abstract val outputDir: DirectoryProperty
    
    init {
        outputDir.convention(
            project.layout.buildDirectory.dir("output/${this.name}")
        )
    }
}

Task-Konfiguration nutzt Provider-Mapping für dynamische Werte:

tasks.withType<JavaCompile>().configureEach {
    options.generatedSourceOutputDirectory.set(
        layout.buildDirectory.dir("generated/sources/${name}")
    )
}

Fehlerbehandlung berücksichtigt nicht-existente Verzeichnisse:

val sourceDir = layout.projectDirectory.dir("optional-sources")
val safeSource = providers.provider {
    sourceDir.asFile.takeIf { it.exists() }
}.map { dir ->
    fileTree(dir)
}.orElse(files())

Performance-Monitoring validiert Wire-Protocol-Nutzung. Build Scans zeigen Task-Dependencies und deren Ursprung. Implizite Dependencies durch Wire Protocol werden als “WIRED” markiert. Dies hilft bei der Identifikation fehlender Verbindungen oder unnötiger expliziter Dependencies.

Die Migration zur Layout-API ist eine Investition in Build-Qualität. Configuration Caching funktioniert zuverlässiger, Task-Dependencies sind korrekt, und Builds werden reproduzierbarer. Die initiale Umstellung erfordert Aufwand, zahlt sich aber durch reduzierte Wartung und bessere Performance langfristig aus.

12.7 Übung – Task Bindings mit der Layout-API

12.7.1 Ziel

Verstehen, wie man mit der Layout-API typsicher und lazy arbeitet, anstatt $buildDir-Strings zu verwenden. Außerdem erleben, wie Gradle über das Wire Protocol automatisch Task-Abhängigkeiten erkennt, wenn Outputs und Inputs verbunden sind.


12.7.2 Schritt 1: Projekt vorbereiten

mkdir layout-demo && cd layout-demo
gradle init --type basic --dsl kotlin

12.7.3 Schritt 2: Buildskript erstellen

Datei build.gradle.kts mit folgendem Inhalt:

import org.gradle.api.DefaultTask
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.tasks.InputFile
import org.gradle.api.tasks.OutputFile
import org.gradle.api.tasks.TaskAction
import org.gradle.kotlin.dsl.register
import org.gradle.api.tasks.bundling.Zip

plugins {
    java
}

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

repositories {
    mavenCentral()
}

// Task 1: Datei erzeugen
abstract class GenerateTask : DefaultTask() {
    @get:OutputFile
    abstract val outputFile: RegularFileProperty

    @TaskAction
    fun generate() {
        outputFile.get().asFile.writeText("Hello Layout API!")
        println("Datei erzeugt: ${outputFile.get().asFile}")
    }
}

val generateTask = tasks.register<GenerateTask>("generateMessage") {
    outputFile.set(layout.buildDirectory.file("generated/message.txt"))
}

// Task 2: Datei verarbeiten
abstract class ProcessTask : DefaultTask() {
    @get:InputFile
    abstract val inputFile: RegularFileProperty

    @get:OutputFile
    abstract val outputFile: RegularFileProperty

    @TaskAction
    fun process() {
        val text = inputFile.get().asFile.readText()
        outputFile.get().asFile.writeText(text.uppercase())
        println("Datei verarbeitet: ${outputFile.get().asFile}")
    }
}

val processTask = tasks.register<ProcessTask>("processMessage") {
    inputFile.set(generateTask.flatMap { it.outputFile })
    outputFile.set(layout.buildDirectory.file("processed/result.txt"))
}

// Task 3: Archiv erstellen
tasks.register<Zip>("archiveMessage") {
    from(processTask.flatMap { it.outputFile })
    archiveFileName.set("message.zip")
    destinationDirectory.set(layout.buildDirectory.dir("archives"))
}

12.7.4 Schritt 3: Ausführen und prüfen

  1. Nur den letzten Task starten:

    ./gradlew archiveMessage

    → Gradle erkennt automatisch die Abhängigkeiten: generateMessageprocessMessagearchiveMessage.

  2. Ergebnis prüfen:

    unzip -l build/archives/message.zip
    cat build/processed/result.txt

    Inhalt der Datei:

    HELLO LAYOUT API!

12.7.5 Erkenntnisse

Ja, hier ergibt eine weitere Aufgabe Sinn – und zwar als Vertiefung.

Die bestehende Übung zeigt bereits den klassischen Flow Generate → Process → Archive mit Layout-API und Wire Protocol. Eine zusätzliche Aufgabe könnte die Teilnehmer dazu bringen, die Layout-API flexibler einzusetzen:


12.7.6 Zusatzaufgabe: Dynamische Verzeichnisse mit der Layout-API

  1. Erweitern Sie das Projekt um einen Task copyDocs, der alle .md-Dateien aus einem Verzeichnis docs/ ins Build-Verzeichnis kopiert.

  2. Ergänzen Sie einen zweiten Task zipDocs, der das Ergebnis von copyDocs automatisch als Input nimmt und daraus ein ZIP-Archiv erstellt.

  3. Legen Sie ein paar Testdateien in docs/ an, führen Sie ./gradlew zipDocs aus und überprüfen Sie, dass die Dateien im Archiv landen.


Damit wird deutlich: