Saturday 8 October 2022

Kotlin/Gradle: abstracting versions into a config file, and wondering what delegated properties are

G'day:

Currently I have just been following the instructions fairly slavishly when it comes to my dependency management in my application. If some readme file says "put testImplementation("io.kotest:kotest-runner-junit5:5.5.0") in yer Gradle build file to include this dependency", then that's exactly what I do. Sometimes I monkey with the version if IntelliJ says there's a newer version. So I've ended up with this lot:

dependencies {
    testImplementation(kotlin("test"))
    testImplementation("io.kotest:kotest-runner-junit5:5.5.0")
    testImplementation("io.kotest:kotest-assertions-core:5.5.0")
    testImplementation("com.github.stefanbirkner:system-lambda:1.2.1")
    testImplementation("org.apache.commons:commons-lang3:3.12.0")
    testImplementation("org.junit.jupiter:junit-jupiter:5.9.0")
    testImplementation("org.junit.jupiter:junit-jupiter:5.9.0")
    testImplementation("io.kotest:kotest-framework-datatest:5.5.0")
    implementation("io.ktor:ktor-client-core:2.1.2")
    implementation("io.ktor:ktor-client-cio:2.1.2")
}

That's fine. However then I looked at the sample project that the Ktor Project Generator generates, and I see this in my its build file:

val ktor_version: String by project
val kotlin_version: String by project
val logback_version: String by project

// ...

dependencies {
    implementation("io.ktor:ktor-server-core-jvm:$ktor_version")
    implementation("io.ktor:ktor-server-netty-jvm:$ktor_version")
    implementation("ch.qos.logback:logback-classic:$logback_version")
    testImplementation("io.ktor:ktor-server-tests-jvm:$ktor_version")
    testImplementation("org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version")
}

What's all this by project carry-on? I looked for a string match for ktor_version, and found this lot in gradle.properties:

ktor_version=2.1.2
kotlin_version=1.7.20
logback_version=1.2.11
kotlin.code.style=official

(Mine currently only has that last line)

OK so that seems like a good idea, I'm gonna run with this approach for the dependencies in my scratch project too:

kotlin.code.style=official
kotest_version=5.5.0
kotlin_version=1.7.10
system_lambda_version=1.2.1
commons_lang_3_version=3.12.0
junit_jupiter_version=5.9.0
ktor_client_core_version=2.1.2
dependencies {
    testImplementation(kotlin("test"))
    testImplementation("io.kotest:kotest-runner-junit5:$kotest_version")
    testImplementation("io.kotest:kotest-assertions-core:$kotest_version")
    testImplementation("io.kotest:kotest-framework-datatest:$kotest_version")
    testImplementation("com.github.stefanbirkner:system-lambda:$system_lambda_version")
    testImplementation("org.apache.commons:commons-lang3:$commons_lang_3_version")
    testImplementation("org.junit.jupiter:junit-jupiter:$junit_jupiter_version")
    testImplementation("org.junit.jupiter:junit-jupiter:$junit_jupiter_version")
    implementation("io.ktor:ktor-client-core:$ktor_client_core_version")
    implementation("io.ktor:ktor-client-cio:$ktor_client_core_version")
}

When I rebuilt: it all went fine, and all my tests still pass.

I noted in the Ktor build file the Kotlin plug-in version was still a static inline string:

plugins {
    application
    kotlin("jvm") version "1.7.20"
    // …
}

I tried to make that dynamic as well:

plugins {
    kotlin("jvm") version kotlin_version
    application
}

But INtelliJ barfed at this saying:

'val kotlin_version: String' can't be called in this context by implicit receiver. Use the explicit one if necessary

I googled that, and landed on Why can’t I use val inside Plugins {}? which pointed me to this: Using Gradle Plugins › Limitations of the plugins DSL, and the important bit there is:

This requires that plugins be specified in a way that Gradle can easily and quickly extract, before executing the rest of the build script. It also requires that the definition of plugins to use be somewhat static.

(My emphasis).

Fair cop. I'll not try to do that then.

All this is lovely, but how does this work. What magic is "by project" doing? I guessed that by is some inline function, and project is its argument, similar to in a test where I have x shouldBe y. I can drill down on by, and it takes me to org.gradle.kotlin.dsl.ProjectExtensions:

operator fun Project.provideDelegate(any: Any?, property: KProperty<*>): PropertyDelegate =
    propertyDelegateFor(this, property)

I'm none-the-wiser looking at that, but it gave me something to google (googling on phrases where "by" is the important word was not a particularly edifying or fruitful endeavour). I found Delegated properties, the summary of which is:

With some common kinds of properties, even though you can implement them manually every time you need them, it is more helpful to implement them once, add them to a library, and reuse them later.

Kotlin supports delegated properties:

class Example {
    var p: String by Delegate()
}

OK, that answers that. There is also a bunch more interesting stuff in that article which I will need to look at later. Oh and what's the "project" part of "by project"? It's just an object that wraps up the values I set in gradle.properties. Slick.

I'm really glad to understand what's going on there, and also looking forward to looking at "delegated properties" some more. Later.


That was painless and short. I quite like that I have de-duped my versions there, and separated them from their usage. The changes made for this exercise are in GitHub @ kotlin_scratch, tag 1.12. Not that it's very intertesting code today, I s'pose ;-)

Righto.

--
Adam