Learn from Kotlin: Kotlin DSL and Annotation

Written byKalanKalan
💡

If you have any questions or feedback, pleasefill out this form

This post is translated by ChatGPT and originally written in Mandarin, so there may be some inaccuracies or mistakes.

Recently, I developed a small tool for managing daily tasks using Kotlin. The main goal was to enable my Backend colleagues to contribute to its maintenance. Additionally, learning a new language always sparks fresh ideas and insights. Plus, there are many Java and JVM experts in the company who could provide valuable input.

The features of this daily task tool are quite simple:

  • It allows you to CRUD TODOs. Each Todo scheduled will send a notification to Slack if not completed within the set time.
  • It can create recurring schedules, such as daily checks for Pull Requests or the progress on Jira.

Writing in Kotlin is quite enjoyable. It retains the safety of static typing and the benefits brought by the compiler while reducing the verbosity commonly associated with Java. Furthermore, Kotlin offers many handy syntaxes that help simplify code. This article will introduce some concepts I learned while studying Kotlin.

Kotlin DSL

The following code is a valid Kotlin syntax.

Bot.sendMessage(channelId) {
   section {
       plainText("This is a plain text section block.")
   } 
   section {
       markdownText("This is a mrkdwn section block :ghost: *this is bold*, and ~this is crossed out~, and <https://google.com|this is a link>")
   }
   divider()
}  

After executing the code, it will be transformed into something like this:

{
	"blocks": [
		{
			"type": "section",
			"text": {
				"type": "plain_text",
				"text": "This is a plain text section block.",
				"emoji": true
			}
		},
		{
			"type": "section",
			"text": {
				"type": "mrkdwn",
				"text": "This is a mrkdwn section block :ghost: *this is bold*, and ~this is crossed out~, and <https://google.com|this is a link>"
			}
		}
	]
}

You can customize messages by defining the format specified in the Slack block.

Suppose you want to send a message to Slack. You can use Block Kit within Slack to construct messages. According to the official API, you can write it like this:

val slack = Slack.getInstance()
val token = System.getenv("token")
val response = slack.methods(token).chatPostMessage { req -> req
  .channel("C1234567")
  .blocks {
    section {
      // "text" fields can be constructed via `plainText()` and `markdownText()`
      markdownText("*Please select a restaurant:*")
    }
    divider()
    actions {
      button {
          text("Farmhouse", emoji = true)
          value("v1")
      }
      button {
          text("Kin Khao", emoji = true)
          value("v2")
      }
    }
  }
}

It looks clear and straightforward, almost like writing some kind of Component, which instantly made me fond of Kotlin's syntax.

Additionally, in the exposed library, you can write your SQL syntax like this:

val status = "DONE"
Todos.select {
    Todos.status eq (status)
}

This also gives the feeling that you are "writing some SQL" thanks to Kotlin DSL's features.

So how does Kotlin DSL achieve this? It mainly relies on a few concepts:

  • extension
  • infix notation
  • lambda expressions
  • function literal with receiver

Extension

In Kotlin, you can add a new method to a class without using inheritance or decorators, like this:

fun Int.add2() {
  this + 2
}

Now, you can call it directly on the Int type like this:

fun add() {
  val a = 100
  a.add2()
}

The implementation of the extension does not force the method into the class; instead, it allows calling the extension method when you invoke a member function with dot. So why not implement it directly in the class? Sometimes, due to third-party library implementations or when modifications are not easily feasible, extensions become a very useful method.

Infix notation

In Kotlin, if you declare a function with infix, it means you can call this function without the dot and parentheses. To achieve the effect of omitting the dot and parentheses, infix notation must meet several conditions:

  • It must be a member function or an extension function.
  • It must and can only have one parameter.
  • It cannot have default values.

The effect looks like this:

infix fun Int.addSome(num: Int) {
  this + num
}

fun add() {
  val a = 100
  a.addSome(10)
  a addSome 10
}

Here, a.addSome(10) and a addSome 10 are entirely equivalent. 10 addSome a is, of course, also a valid expression.

One of the most common infix functions in Kotlin is to. In Kotlin, you can declare a map like this:

val map = mapOf("name" to "kalan", "age" to 25)

to is actually an infix function, providing a convenient syntax to generate Pairs. Its implementation looks like this:

public infix fun <A, B> A.to(that: B): Pair<A, B> = Pair(this, that)

Through this definition, we can minimize the visual noise in our code, making it more readable.

Lambda Expressions

Lambda expressions in Kotlin are highly flexible. You can write it like this:

fun test(a: Int, block: () -> Unit) {

}

test(1, {
  println("hello")
})

When the lambda expression is the last parameter, you can move it outside, like this:

fun test(a: Int, block: () -> Unit) {

}

test(1) {
  println("hello")
}

Both are entirely the same. Moreover, Kotlin will perform type inference for your lambda function. So, if you write it like this:

fun test(a: Int, block: (i: Int) -> Unit) {

}

test(1) {
  println("hello")
}

In the IDE, it will show it as the value parameter, which corresponds to i that you pass in the block.

Screenshot from 2020-10-25 19-29-58

This means it is equivalent to:

fun test(a: Int, block: (i: Int) -> Unit) {

}

test(1) { i ->
  println("hello", i)
}

You can refer to the Scope Functions in Kotlin to understand how they work.

Function Literal with Receiver

Here, we will introduce an example from the official documentation. In Kotlin, you can write functions like this:

fun html(init: HTML.() -> Unit): HTML {
    val html = HTML()
    html.init()
    return html
}

Let's first look at the init parameter, which is declared as a function with the type HTML.() -> Unit. This is called a function type with a receiver. It means we need to pass html as a context to the init function at some point, somewhat similar to apply(context) in JavaScript, but with the advantage of achieving type safety more easily. T.() -> K denotes executing a member function of type T, returning K.

Next, let's examine another example. In kotlinx.serialization, we can declare a Json like this:

val serializer = Json {
  ignoreUnknownKeys = true
  encodeDefaults = true
  coerceInputValues = true
}

The underlying implementation looks like this:

public fun Json(from: Json = Json.Default, builderAction: JsonBuilder.() -> Unit): Json {
    val builder = JsonBuilder(from.configuration)
    builder.builderAction()
    val conf = builder.build()
    return JsonImpl(conf)
}

This means that in the previous code, the actual execution would look like this:

val builder = JsonBuilder()
builder.ignoreUnknownKeys = true
builder.encodeDefaults = true
builder.coerceInputValues = true
builder.build()

By utilizing the above Kotlin syntaxes and some creativity, you can create simple yet powerful DSLs to simplify your code, ensuring everything is type-safe!

Annotation

While implementing a Cronjob, I created a cronjob annotation for better readability, inspired by Spring Boot's @Scheduled:

@CronSchedule("00 18 * * 1-5")
class NotifyMeCheck: Scheduler(), Workable {
    override fun work(args: Arg) {
		// job implementation
    }
}

@Target(AnnotationTarget.CLASS)
@MustBeDocumented
annotation class CronSchedule(val cronExpression: String) {}

At runtime, it scans all CronWorkEntry classes for those marked with CronSchedule and schedules the cronjobs, like this:

CronWorkEntry::class.nestedClasses.forEach {
    val annotation = it.findAnnotation<CronSchedule>()
    if (annotation != null) {
      val worker = it.createInstance() as Scheduler
      worker.performCron(annotation.cronExpression,"${CronWorkEntry::class.qualifiedName!!}$${it.simpleName!!}", "", true)
    }
}

However, it.createInstance() as Scheduler seems a bit odd, as there is no compile-time enforcement that the class must be a Scheduler. If it doesn't inherit from that class, it will throw an Exception. I'm wondering if there is a way to restrict the annotation to certain class types only.

If you found this article helpful, please consider buying me a coffee ☕ It'll make my ordinary day shine ✨

Buy me a coffee