Strávte letnú sezónu zostavovaním projektov pomocou Kotlinu cez Gradle

2019/07/15

Prečo Gradle a prečo Kotlin?

Gradle je rokmi overený nástroj na zostavovanie projektov v Java ekosystéme. Samotné príkazy pre zostavenie boli od nepamätí písané v jazyku Gradle. Novým hitom je však Kotlin! Ukážme si, ako môžeme využiť tento jazyk na zostavovanie projektov.

Prvý gradlovský skript v Kotline

Predpokladajme, že máme k dispozícii posledný Gradle, napríklad 5.5.1. V nejakom adresári si založme kotlinovský build script:

touch build.gradle.kts

Vytvorme prvý task, teda príkaz, ktorý sa bude dať pomocou Gradle vykonať. Task je podobný targetu z nástroja make, či ant.

tasks {
    register("hello") {
        doLast {
            println("Hello world")
        }
    }
}

Task môžeme spustiť:

gradle hello

Uvidíme výsledok:

> Task :hello
Hello world

BUILD SUCCESSFUL in 546ms
1 actionable task: 1 executed                                     

Dlhý obkec sa dá skrátiť:

gradle -q hello

Následne uvidíme len samotnú správu.

Elegantný pomenovaný task

Task môžeme zaradiť do logickej skupiny (group) a môžeme mu priradiť popis (description):

tasks {
    register("hello") {
        group = "Greetings"
        description = "Say hello"
        doLast {
            println("Hello world")
        }
    }
}

Ak spustíme task, ktorý nám vypíše zoznam dostupných taskov, uvidíme elegantný popis:

gradle -q tasks

Výsledok bude obsahovať (niekde uprostred):

Greetings tasks
---------------
hello - Say hello

Tasky založené na existujúcich taskoch

Niekedy máme šťastie a vieme využiť tasky, ktoré sú k dispozícii buď automaticky, alebo z niektorých pluginov. Gradle ponúka viacero zabudovaných taskov, napr. task Exec na spúšťanie programov.

tasks {
    register<Exec>("workdir") {
        executable = "pwd"
    }
}

V tomto prípade sme zaregistrovali task s názvom workdir, ktorý využíva zabudovanú predlohu (task type) s názvom Exec. Vysvetlenie tohto zápisu je zatiaľ zložité, ale povedzme si len, že predloha task type sa udáva do lomených zátvoriek.

V rámci tasku sme nastavili vlastnosť (property) executable, ktorá berie reťazec s názvom systémového programu.

Kotlin, OOP a Gradle

Kotlin je objektovo orientovaný jazyk, presne tak ako Java. Môžeme si teda vytvárať vlastné tasky ako objekty (inštancie) danej triedy.

Deklarujme si task ako trieduHelloTask:

open class HelloTask : DefaultTask() {
    @TaskAction
    fun sayHello() {
        println("Hello world")
    }
}

Vidíme viacero vecí:

tasks {
    register("hello", HelloTask::class)
}

Funkciu zaregistrujeme pomocou názvu hello a odkazom na triedu tasku. Konštrukcia ::class reprezentuje mechanizmus známy z Javy, kde je uvádzaný cez dvojbodku.

Task môžeme spustiť obvyklým spôsobom cez gradle hello!

## Registrácia ustáleným spôsobom

Idiomatický spôsob používa kratší zápis, ale jeho vysvetlenie nie je v tejto chvíli jednoduché:

tasks {
    register<HelloTask>("hello")
}

Vlastnosti / properties a inicializačný blok pre konštruktor

Ak chceme nastaviť skupinu (group) a popis (description) v triede, môžeme využiť nasledovný zápis:

open class HelloTask : DefaultTask() {
    init {
        group = "Greetings"
        description = "Say hello"
    }

    @TaskAction
    fun sayHello() {
        println("Hello world")
    }
}

Sekcia init reprezentuje kód, ktorý sa zavolá v rámci konštruktora triedy.

Každá trieda v Kotline má totiž primárny konštruktor, ktorý je súčasťou hlavičky. V našom prípade ho vidíme v okrúhlych zátvorkách za DefaultTask(). Keďže zátvorky uvádzame za rodičovskou triedou, znamená to, že tento konštruktor sme zdedili.

Primárny konštruktor nesmie obsahovať kód, ale prípadné príkazy uvádzame do sekcie init.

V rámci sekcie init využívame dve properties (vlastnosti): pre skupinu a popis. Na rozdiel od Javy, kde sú properties reprezentované gettermi a settermi, je v Kotline prístup riešený bežným priradením do premennej (pre setter), resp. čítaním z premennej (pre getter).

Obe vlastnosti, group i description sme zdedili od rodiča, a pokojne by sme mohli použiť aj ekvivalentný zápis setGroup(“greetings”), resp. setDescription(“Say Hello”).

Task si môžeme spustiť obvyklým spôsobom:

gradle -q hello

Premenné, inferencia typov a kolekcie

Kotlin ponúka elegantnú syntax pre kolekcie (zoznamy/polia, mapy/slovníky/asociatívne polia, množiny). Vyrobme si najprv ďalší Gradle task, ktorý vypíše všetky súbory v aktuálnom projektovom adresári:

open class LsTask: DefaultTask() {
    @TaskAction
    fun listFiles() {
        println(project.projectDir)
    }
}

tasks {
    register<LsTask>("ls")
}

Ak spustíme task gradle ls, uvidíme celú cestu k adresáru, v ktorom sa nachádza build.gradle.kts.

Opäť využívame properties, pretože rodičovská trieda DefaultTask má metódu getProject(), ktorá sa v Kotline dá zavolať aj jednoduchšie. Trik sa ešte raz zopakuje, keď trieda Project ponúka metódu getProjectDir(), prístupnú cez property projectDir.

Premenné

Cestu k projektu si môžeme priradiť do premennej:

val dir = project.projectDir
println(dir)

Konštrukcia val dir reprezentuje deklaráciu immutable (nemeniteľnej) premennej.

Dátový typ nie je nutné uvádzať, pretože Kotlin si ho odvodí sám vďaka mechanizmu type inference, teda automatického odvodzovania dátových typov. Keďže projectDir, teda výsledok volania metódy getProjectDir() je typu java.io.File, Kotlin si domyslí, že premenná dir môže byť tiež iba File.

Kotlin je silne typovaný jazyk, kde každá premenná a každý výraz má konkrétny dátový typ, akurát ho v kóde nemusíme uvádzať, ak to nie je nutné.

V niektorých prípadoch je samozrejme možné typ uviesť explicitne, napríklad:

val dir: File = project.projectDir

Premenná dir je typu File, a keďže Gradle automaticky importuje balíček java.io, stačí uvádzať skrátený názov.

Polia a premenné s null-safety: ochrana proti výnimkám NullPointerException

Práca s null môže byť nepríjemná, pretože treba rozlišovať medzi dvoma svetmi: premenná s objektom, na ktorom možno volať metódy a premenná bez objektu, na ktorej metódy nemôžeme volať. Ak sa to popletie, nastávajú výnimky NullPointerException>

Kotlin sa rozhodol zrušiť null. To je samozrejme skvelé, ale keďže musíme interagovať so štandardnou knižnicou Javy, je treba nájsť kompromis.

Ukážme si to na príklade našej funkcie, kde chceme vypísať zoznam súborov/adresárov v projektovom adresári. Objekt File reprezentujúci projektový adresár, má metódu list(), ktorá vráti pole objektov File s potomkami alebo vráti null.

Potrebujeme vybaviť dve veci:

  1. polia v Kotline
  2. a premenné, ktoré nikdy nesmú byť null.

Polia (arrays) v Kotline — na rozdiel od Javy — sú reprezentované triedou Array. Dátový typ jednotlivých prvkov sa uvádza v lomených zátvorkách, teda pole reťazcov je Array<File> (podobne ako v Jave ide o generický typ).

Pre prípady, keď Kotlin potrebuje vybaviť interoperabilitu s Javou, kde objekt môže byť null, je dátový typ okrášlený otáznikom: Array<String>? znamená, že máme pole reťazcov, ktoré môže byť null, a treba to vybaviť špeciálnym spôsobom:

val children: Array<File>? = project.projectDir.listFiles()

S použitím typovej inferencie je zápis samozrejme kratši:

val children = project.projectDir.listFiles()

Poďme teraz vypisovať! Prípad, ak je premenná nenullová, vieme vyriešiť ifom:

val children = project.projectDir.listFiles()
if (children != null) {
    for (c in children) {
        println(c)
    }
}

V kóde vidíme ďalšiu elegantnú vec: smart cast, teda chytré pretypovanie. Kotlin vie, že vo vnútri if je premenná nenullová, a preto s ňou môžeme pracovať bezpečným spôsobom bez obáv, že nastane NullPointerException.

Zároveň vidíme ukážku cyklu for, kde prechádzame prvkami poľa. Dátový typ premennej c nemusíme uvádzať!

Lambda výrazy

Kotlin — podobne ako Java — podporuje lambda výrazy, teda zápisy pre funkcie, s ktorými môžeme zaobchádzať ako s objektami.

Napríklad nasledovná funkcia berie jeden parameter f typu File, vie ho vytlačiť na konzolu a samotnú funkciu priradíme do objektu doPrint.

val doPrint = { f: File -> println(f) }

S lambdami sa dajú robiť psie kusy. Ak máme funkciu, ktorá ako parameter berie inú funkciu, máme funkciu vyššieho rádu. Namiesto teórie si dajme príklad.

Pole má metódu (teda funkciu) forEach(), ktorá ako parameter berie funkciu, ktorá sa zavolá pre každý prvok. Môžeme teda spraviť toto:

val children = project.projectDir.listFiles()
val doPrint = { f: File -> println(f) }
children.forEach(doPrint)

Tento zápis je síce správny, ale takmer nikto ho v praxi nepoužije. Kotlin má totiž skvelú syntaktickú vlastnosť (prevzatú z Groovy): funkcia druhého rádu môže vynechať guľaté zátvorky. Kód funkcie v parametri sa dá uviesť medzi zložené zátvorky, čo pripomína klasický blok:

val children = project.projectDir.listFiles()
children.forEach { 
  f: File -> println(f) 
}

Vďaka skracovacej mánii môžeme pokračovať:

Výsledok je:

children.forEach { println(it) }

Zápis sa dá zjednodušiť ešte viac, ale to si nechajme na prílepok. Ešte sme stále nevyriešili jedno varovanie kompilátora, ktoré indikuje situáciu, kde children môže byť null.

Namiesto if , kde skontrolujeme nenullovosť, môžeme použiť špeciálny operátor safe call, teda bezpečného volania. Namiesto klasického volania metódy cez bodku použijeme ?., ktorá neurobí nič, ak je premenná children náhodou null.

Funkcia pre výpis súborov tak môže vyzerať nasledovne:

@TaskAction
fun listFiles() {
    project.projectDir.listFiles()?.forEach { 
        println(it)
    }
}

Inštančné premenné a vlastné konštruktory

Vylepšime náš task o možnosť prijať adresár z nejakého parametra, či premennej. Na toto môžeme využiť inštančnú premennú!

open class LsTask: DefaultTask() {
    val directory = File("/tmp")

		/* ... */
}

Do triedy LsTask sme dodali inštančnú premennú directory. Platia pre ňu viaceré vlastnosti:

Samozrejme, upravíme aj funkciu pre výpis:

open class LsTask: DefaultTask() {
    val directory = File("/tmp")

    @TaskAction
    fun listFiles() {
        directory.listFiles()?.forEach {
            println(it)
        }
    }
}

Často sa používa konvencia, kde inicializácia inštančných premenných zbehne v primárnom konštruktore. Zápis vyzerá nasledovne:

open class LsTask(val directory: File = File("/tmp")) : DefaultTask() {
    @TaskAction
    fun listFiles() {
        directory.listFiles()?.forEach {
            println(it)
        }
    }
}

Všimnime si, ako sme premennú deklarovali a inicializovali v hlavičke triedy.

V našom prípade to urobíme ale inak, keďže v Gradle môžeme parametrizovať tasky pomocou tzv. extras.

Parametrizovateľné tasky

Tasky možno parametrizovať, napríklad chceme volať:

 gradle ls --directory=/Users

V takom prípade stačí dodať nad príslušnú inštančnú premennú anotáciu @Option a uviesť názov parametra a popis.

open class LsTask: DefaultTask() {
    @Option(option = "directory", description = "A directory to list")
    var directory = project.buildDir.toString()

    @TaskAction
    fun listFiles() {
        File(directory).listFiles()?.forEach {
            println(it)
        }
    }
}

Premenná directory sa zmení na reťazec String, pretože automatický prevod parametrov z príkazového riadka na inštančnú premennú podporuje len reťazce, booleany, enumy a zoznamy reťazcov. Preto sme primerane upravili aj kód.

Úplne zadarmo dostaneme aj pomocníka, ktorý vypíše podporované parametre.

 gradle help --task ls

Prílepky

Hardcore: Member References — referencie na metódy a vlastnosti

Podobne ako v Jave existujú method references, ktoré využívajú fakt, kde každá metóda je vlastne lambda výraz, je v Kotline mechanizmus member reference.

V príklade považujeme funkciu println() za lambda výraz, ktorý berie jeden objekt a čosi s ním spraví.

children.forEach(this::println)

Keďže funkcia println() je automaticky k dispozícii, a objekt, na ktorom ju voláme, je this, môžeme i toto zjednodušiť a this vynechať:

children.forEach(::println)

Hardcore: syntax Kotlinu v script file

Syntax build scriptov využíva naplno vymoženosti Kotlinu. Napríklad nasledovný kód:

repositories {
    mavenCentral()
}

Build script je v skutočnosti nastavovanie vlastností na objekte typu KotlinBuildScript. Sekcia repositories prakticky volá metódu repositories(), ktorej parametrom je lambda. Keďže guľaté zátvorky okolo volania funkcie s lambdou možno vynechať, zrazu je zápis elegantný!

>> Home