13 Benutzerdefinierte Tasks implementieren

13.1 Anatomie eines Custom Tasks

Custom Tasks erweitern Gradles Funktionalität für projekt-spezifische Build-Anforderungen. Ein gut implementierter Task kapselt eine spezifische Build-Operation, deklariert Inputs und Outputs explizit und integriert sich nahtlos in Gradles Execution-Model. Die Basis-Klasse DefaultTask bietet die notwendige Infrastruktur, während abstrakte Properties moderne Configuration-Patterns ermöglichen.

Die grundlegende Struktur eines Custom Tasks folgt etablierten Konventionen:

abstract class DocumentationTask : DefaultTask() {
    
    @get:InputDirectory
    @get:SkipWhenEmpty
    abstract val sourceDirectory: DirectoryProperty
    
    @get:Input
    abstract val documentTitle: Property<String>
    
    @get:OutputFile
    abstract val outputFile: RegularFileProperty
    
    init {
        description = "Generates documentation from source files"
        group = "documentation"
    }
    
    @TaskAction
    fun generate() {
        val sources = sourceDirectory.get().asFile
        val title = documentTitle.get()
        val output = outputFile.get().asFile
        
        // Task-Logik
        processDocumentation(sources, title, output)
    }
}

Die Verwendung abstrakter Properties ermöglicht Gradle die Injection von Property-Instanzen. Diese Injection erfolgt zur Task-Creation-Zeit und eliminiert Boilerplate-Code für Property-Initialisierung. Die Properties nutzen Gradles Provider-API für lazy Configuration und automatisches Task-Wiring.

Der Task-Constructor sollte keine Heavy-Lifting-Operations durchführen. Initialisierung von Properties, Logging oder Netzwerk-Zugriffe gehören nicht in den Constructor. Diese Operationen verzögern die Configuration-Phase unnötig und brechen Configuration Caching. Stattdessen erfolgt Initialisierung lazy in der Task-Action oder über Provider.

13.2 Input/Output-Annotations und ihre Semantik

Input/Output-Annotations sind fundamental für Gradles Incremental Build-System. Sie definieren, was ein Task konsumiert und produziert, ermöglichen Up-to-Date-Checks und etablieren implizite Task-Dependencies. Die korrekte Annotation ist entscheidend für Build-Performance und Korrektheit.

Die wichtigsten Input-Annotations und ihre Verwendung:

abstract class ComprehensiveTask : DefaultTask() {
    
    @get:Input
    abstract val version: Property<String>  // Einfacher Wert
    
    @get:InputFile
    @get:PathSensitive(PathSensitivity.RELATIVE)
    abstract val configFile: RegularFileProperty  // Einzelne Datei
    
    @get:InputDirectory
    @get:IgnoreEmptyDirectories
    abstract val sourceDir: DirectoryProperty  // Verzeichnis mit Inhalt
    
    @get:InputFiles
    @get:PathSensitive(PathSensitivity.NAME_ONLY)
    abstract val classpathFiles: ConfigurableFileCollection  // Datei-Collection
    
    @get:Classpath
    abstract val compileClasspath: ConfigurableFileCollection  // Classpath-Semantik
    
    @get:Optional
    @get:Input
    abstract val optionalParameter: Property<String>  // Optionaler Input
    
    @get:Internal
    abstract val workingDirectory: DirectoryProperty  // Nicht für Up-to-Date-Check
}

PathSensitivity kontrolliert, wie Pfadänderungen Up-to-Date-Checks beeinflussen. ABSOLUTE berücksichtigt den vollständigen Pfad, RELATIVE nur den relativen Pfad zum Projekt, NAME_ONLY ignoriert den Pfad komplett, NONE ignoriert sogar Dateinamen-Änderungen. Die richtige Wahl verhindert unnötige Task-Executions bei irrelevanten Änderungen.

Output-Annotations definieren Task-Produkte:

abstract class OutputTask : DefaultTask() {
    
    @get:OutputFile
    abstract val reportFile: RegularFileProperty
    
    @get:OutputDirectory
    abstract val generatedSources: DirectoryProperty
    
    @get:OutputFiles
    abstract val multipleOutputs: Map<String, File>
    
    @get:Destroys
    abstract val cleanedDirectory: DirectoryProperty  // Verzeichnis wird gelöscht
    
    @get:LocalState
    abstract val cacheDirectory: DirectoryProperty  // Lokaler Cache, nicht relocated
}

Die korrekte Output-Annotation ermöglicht Gradle das Caching und die Incremental Execution. Output-Directories werden automatisch erstellt, Output-Files erfordern manuelles Erstellen der Parent-Directories. Die @Destroys Annotation markiert Ressourcen, die der Task entfernt, was für korrekte Execution-Order wichtig ist.

13.3 Incremental Task Execution implementieren

Incremental Task Execution verarbeitet nur geänderte Inputs seit der letzten Execution. Dies reduziert Execution-Time dramatisch für Tasks, die viele Dateien verarbeiten. Die Implementierung erfordert spezielle Input-Annotations und eine angepasste Task-Action.

Ein incremental fähiger Task implementiert die entsprechende Logik:

abstract class IncrementalProcessingTask : DefaultTask() {
    
    @get:Incremental
    @get:InputDirectory
    abstract val inputDir: DirectoryProperty
    
    @get:OutputDirectory
    abstract val outputDir: DirectoryProperty
    
    @TaskAction
    fun execute(inputChanges: InputChanges) {
        val incremental = inputChanges.isIncremental
        println("Executing incrementally: $incremental")
        
        if (!incremental) {
            // Full rebuild - Output-Directory cleanen
            outputDir.get().asFile.deleteRecursively()
        }
        
        inputChanges.getFileChanges(inputDir).forEach { change ->
            val targetFile = computeOutputFile(change.file)
            
            when (change.changeType) {
                ChangeType.ADDED, ChangeType.MODIFIED -> {
                    processFile(change.file, targetFile)
                    println("Processed: ${change.file}")
                }
                ChangeType.REMOVED -> {
                    targetFile.delete()
                    println("Removed: $targetFile")
                }
            }
        }
    }
    
    private fun computeOutputFile(inputFile: File): File {
        val relativePath = inputFile.relativeTo(inputDir.get().asFile)
        return outputDir.get().asFile.resolve(relativePath.path.replace(".txt", ".processed"))
    }
}

Die InputChanges Parameter liefert Information über geänderte Dateien. Bei non-incremental Execution (erste Ausführung oder nach Clean) muss der Task alle Inputs verarbeiten. Die Change-Types ADDED, MODIFIED und REMOVED ermöglichen differenzierte Behandlung.

Incremental Tasks müssen idempotent sein. Mehrfache Ausführung mit identischen Inputs muss identische Outputs produzieren. Side-Effects wie Netzwerk-Calls oder Timestamps in Outputs brechen Idempotenz und führen zu ständigen Rebuilds.

13.4 Task-Configuration vs. Execution-Phase

Die strikte Trennung von Configuration und Execution ist fundamental für Gradle-Performance. Configuration erfolgt für alle Tasks im Build, Execution nur für tatsächlich ausgeführte Tasks. Expensive Operations in der Configuration-Phase degradieren Build-Performance signifikant.

Anti-Pattern in der Configuration-Phase:

// FALSCH - Expensive Operation während Configuration
tasks.register<DefaultTask>("badTask") {
    val files = fileTree("src").files  // Sofortige File-System-Traversierung
    val count = files.size
    
    doLast {
        println("Found $count files")
    }
}

// RICHTIG - Lazy Evaluation in Execution-Phase
tasks.register<DefaultTask>("goodTask") {
    val files = providers.provider {
        fileTree("src").files
    }
    
    doLast {
        println("Found ${files.get().size} files")
    }
}

Task-Configuration sollte nur Property-Bindings und Metadaten-Setup enthalten. File-System-Zugriffe, Netzwerk-Operationen oder komplexe Berechnungen gehören in die Task-Action oder werden über Provider verzögert.

Die @TaskAction annotierte Methode wird während der Execution-Phase aufgerufen:

abstract class WellBehavedTask : DefaultTask() {
    
    @get:Input
    abstract val configuration: Property<String>
    
    private lateinit var processedConfig: Config
    
    @TaskAction
    fun execute() {
        // Initialisierung in Execution-Phase
        processedConfig = parseConfiguration(configuration.get())
        
        // Actual Work
        performWork(processedConfig)
    }
    
    private fun parseConfiguration(config: String): Config {
        // Expensive Parsing nur bei tatsächlicher Execution
        return Config.parse(config)
    }
}

13.5 Fehlerbehandlung und Validierung

Robuste Tasks validieren Inputs und produzieren aussagekräftige Fehlermeldungen. Validierung erfolgt idealerweise über Gradle-Mechanismen statt Custom-Code. Die @Input Validation stellt sicher, dass Required Properties gesetzt sind. Custom-Validation erfolgt in der Task-Action.

Validierung mit aussagekräftigen Fehlern:

abstract class ValidatingTask : DefaultTask() {
    
    @get:InputFile
    abstract val configFile: RegularFileProperty
    
    @get:Input
    abstract val threshold: Property<Int>
    
    @TaskAction
    fun execute() {
        val file = configFile.get().asFile
        val limit = threshold.get()
        
        // Input-Validierung
        require(file.extension == "xml") {
            "Configuration file must be XML, got: ${file.extension}"
        }
        
        require(limit in 1..100) {
            "Threshold must be between 1 and 100, got: $limit"
        }
        
        try {
            processConfiguration(file, limit)
        } catch (e: ProcessingException) {
            throw TaskExecutionException(this, e).apply {
                initCause(e)
            }
        }
    }
}

StopExecutionException signalisiert erwartete Fehler ohne Stack-Trace:

if (!preconditionMet()) {
    throw StopExecutionException("Skipping task: precondition not met")
}

Diese Exception markiert den Task als FAILED, aber Gradle zeigt keinen Stack-Trace, was die Ausgabe übersichtlich hält.

13.6 Performance-Optimierung und Caching

Build Cache speichert Task-Outputs und ermöglicht Wiederverwendung über Build-Grenzen hinweg. Tasks müssen explizit Cache-fähig markiert werden und strikte Anforderungen erfüllen. Cacheable Tasks müssen deterministisch sein, keine absoluten Pfade in Outputs schreiben und reproduzierbare Outputs generieren.

Ein cache-fähiger Task:

@CacheableTask
abstract class CacheableProcessingTask : DefaultTask() {
    
    @get:InputFiles
    @get:PathSensitive(PathSensitivity.RELATIVE)
    abstract val sources: ConfigurableFileCollection
    
    @get:Input
    abstract val processingMode: Property<String>
    
    @get:OutputDirectory
    abstract val outputDir: DirectoryProperty
    
    @TaskAction
    fun process() {
        outputDir.get().asFile.mkdirs()
        
        sources.files.forEach { sourceFile ->
            val relativePath = sourceFile.relativeTo(sources.files.first().parentFile)
            val outputFile = outputDir.get().asFile.resolve(relativePath)
            
            outputFile.parentFile.mkdirs()
            
            // Deterministischer Output ohne Timestamps
            val processed = processContent(sourceFile.readText(), processingMode.get())
            outputFile.writeText(processed)
        }
    }
    
    private fun processContent(content: String, mode: String): String {
        // Keine Timestamps oder Random-Values
        return content.lines()
            .map { it.trim() }
            .filter { it.isNotEmpty() }
            .joinToString("\n")
    }
}

Normalization entfernt irrelevante Unterschiede aus Inputs:

@get:InputFiles
@get:PathSensitive(PathSensitivity.RELATIVE)
@get:Classpath  // Ignoriert Timestamps in JARs
abstract val libraries: ConfigurableFileCollection

@get:Input
@get:Normalizer(TrimWhitespaceNormalizer::class)
abstract val configuration: Property<String>

Performance-Profiling identifiziert Bottlenecks. Der --profile Parameter generiert HTML-Reports mit Task-Execution-Times. Build Scans visualisieren Task-Parallelität und Cache-Hits. Diese Metriken leiten Optimierungs-Entscheidungen.

Worker API parallelisiert CPU-intensive Tasks:

abstract class ParallelProcessingTask : DefaultTask() {
    @get:Inject
    abstract val workerExecutor: WorkerExecutor
    
    @TaskAction
    fun process() {
        val workQueue = workerExecutor.noIsolation()
        
        inputFiles.forEach { file ->
            workQueue.submit(ProcessingWork::class) {
                inputFile.set(file)
                outputDir.set(this@ParallelProcessingTask.outputDir)
            }
        }
    }
}

Diese Patterns und Practices resultieren in robusten, performanten Custom Tasks, die sich nahtlos in Gradles Build-Model integrieren. Die initiale Investition in korrekte Implementation zahlt sich durch Wartbarkeit und Build-Performance langfristig aus.

13.7 Übung – Eigener Custom Task mit Show-Effekt

13.7.1 Ziel

Einen Custom Task implementieren, der aus Textdateien ASCII-Art-Banner erzeugt, dabei inkrementell arbeitet und sauberes Input/Output-Wiring nutzt.


13.7.2 Schritt 1: Projekt anlegen

mkdir custom-task-demo && cd custom-task-demo
gradle init --type basic --dsl kotlin
mkdir -p src/messages

Beispieldateien anlegen:

echo "Gradle Rocks" >src/messages/msg1.txt
echo "Custom Tasks sind mächtig" >src/messages/msg2.txt

13.7.3 Schritt 2: Buildskript erstellen

Datei build.gradle.kts:

import org.gradle.api.DefaultTask
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.tasks.*
import org.gradle.work.InputChanges
import org.gradle.work.ChangeType
import java.io.File

plugins {
    java
}

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

// Custom Task: ASCII-Banner-Generator
@CacheableTask
abstract class BannerTask : DefaultTask() {

    @get:Incremental
    @get:InputDirectory
    @get:PathSensitive(PathSensitivity.RELATIVE)
    abstract val inputDir: DirectoryProperty

    @get:OutputDirectory
    abstract val outputDir: DirectoryProperty

    init {
        description = "Erzeugt ASCII-Banner aus Textdateien"
        group = "demo"
    }

    @TaskAction
    fun generate(inputChanges: InputChanges) {
        val outDir = outputDir.get().asFile
        if (!inputChanges.isIncremental) {
            outDir.deleteRecursively()
        }
        outDir.mkdirs()

        inputChanges.getFileChanges(inputDir).forEach { change ->
            val rel = change.file.relativeTo(inputDir.get().asFile)
            val target = outDir.resolve(rel.nameWithoutExtension + ".banner")

            when (change.changeType) {
                ChangeType.ADDED, ChangeType.MODIFIED -> {
                    target.parentFile.mkdirs()
                    val content = change.file.readText().trim()
                    target.writeText(renderBanner(content))
                    println("✨ Banner erzeugt: ${target.name}")
                }
                ChangeType.REMOVED -> {
                    if (target.exists()) {
                        target.delete()
                        println("🗑️  Banner gelöscht: ${target.name}")
                    }
                }
            }
        }
    }

    private fun renderBanner(text: String): String {
        val border = "#".repeat(text.length + 6)
        return buildString {
            appendLine(border)
            appendLine("#  $text  #")
            appendLine(border)
        }
    }
}

// Task registrieren
tasks.register<BannerTask>("generateBanners") {
    inputDir.set(layout.projectDirectory.dir("src/messages"))
    outputDir.set(layout.buildDirectory.dir("banners"))
}

13.7.4 Schritt 3: Ausprobieren

  1. Task ausführen:

    ./gradlew generateBanners

    Ausgabe:

    ✨ Banner erzeugt: msg1.banner
    ✨ Banner erzeugt: msg2.banner

    Inhalt von build/banners/msg1.banner:

    ##################
    #  Gradle Rocks!  #
    ##################
  2. Datei ändern:

    echo "Nur Gradle!" > src/messages/msg1.txt
    ./gradlew generateBanners

    Ausgabe:

    ✨ Banner erzeugt: msg1.banner

    → Nur die geänderte Datei wurde verarbeitet (inkrementell).

  3. Datei löschen:

    rm src/messages/msg2.txt
    ./gradlew generateBanners

    Ausgabe:

    🗑️  Banner gelöscht: msg2.banner

13.7.5 Erkenntnisse

13.7.6 Aufgabe: Eigener Validierungs-Task

  1. Implementieren Sie einen Custom Task ValidateConfigTask, der eine Konfigurationsdatei (config.xml) prüft.

  2. Fügen Sie eine zweite Property threshold vom Typ Property hinzu.

  3. Werfen Sie bei ungültigen Eingaben eine aussagekräftige Exception mit Hinweisen, was korrigiert werden muss.

  4. Registrieren Sie den Task im build.gradle.kts und konfigurieren Sie:

  5. Legen Sie eine Testdatei config.xml im Projektverzeichnis an, führen Sie den Task mit ./gradlew validateConfig aus und überprüfen Sie die Konsolenausgabe.