半熟前端

軟體工程師 / 台灣人 / 在前端的路上一邊探索其他領域的可能性

Kotlin

從 Kotlin 當中學到的事:Kotlin DSL 與 Annotation

從 Kotlin 當中學到的事:Kotlin DSL 與 Annotation

最近用 Kotlin 開發了一個管理日常任務的小工具,主要是希望 Backend 的同事們也能一起維護,另外則是學習新語言總是可以得到一些新的激盪與想法,還有公司裡頭很多 Java 跟 JVM 大神趁機挖寶。

這個日常任務小工具的功能很簡單:

  • 可以 CRUD TODO,每個 Todo 建立之後都會作排程,如果沒有在時間內完成會跳通知到 Slack
  • 可以建立重複排程,例如每天檢查 Pull Request、Jira 的進度是否完成等。

Kotlin 寫起來很舒服,一方面保有了靜態型別的安全性與編譯器帶來的好處,一方面減少了 Java 寫起來的冗長感。另外 Kotlin 裡頭也有非常多好用的語法來幫助簡化程式碼。這篇文章會介紹一些我在 Kotlin 學習到的概念。

Kotlin DSL

以下的程式碼是一個合法的 Kotlin 語法。

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()
}  

經過程式碼執行之後,會轉換成像下面這樣:

{
	"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>"
			}
		}
	]
}

你可以透過定義 slack block 裡頭規範的格式來自行組合訊息。

假設今天要將訊息傳送到 slack 上,在 slack 當中可以使用 Block Kit 來建立訊息。在官方提供的 API 當中可以這樣子寫:

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")
      }
    }
  }
}

看起來清楚明瞭,感覺就像在寫某種 Component 一樣,讓我瞬間對 Kotlin 的語法產生好感。

另外在 exposed 這個函式庫當中,你可以這樣子寫你的 SQL 語法:

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

也是透過 Kotlin DSL 的特性達到「好像在寫某種 SQL」的感覺。

那麼 Kotlin DSL 背後是怎麼作到的呢?主要是搭配幾個概念:

  • extension
  • infix notation
  • lambda 表達式
  • function literal with receiver

Extension

在 Kotlin 當中,你可以幫一個 class 加入一個新方法而不需要使用繼承或是使用 decorator 的方式,例如下面的語法:

fun Int.add2() {
  this + 2
}

這樣子一來你就可以直接在 Int 型別裡頭這樣子呼叫:

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

extension 的實作並不是幫你把方法硬塞到 class 裡頭,而是當你呼叫 dot 成員函數時可以另外呼叫 extension 實作的方法。那麼為什麼不直接實作在 class 裡面就好了?有時候可能因為第三方 library 的實作或是沒辦法輕易更動時,extension 就是一個很好用的方法。

Infix notation

在 Kotlin 當中,如果你將 function 加入 infix,代表這個函數可以省略 dot 與括號來直接呼叫。為了達到 dot 與括號省略的效果,infix notation 需要符合幾個條件:

  • 必須要是成員函數或是 extension 函數。
  • 必須且只能有一個參數
  • 不可以有預設值

效果會像這樣:

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

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

在這邊,a.addSome(10)a addSome 10 兩者是完全相同的。10 addSome a 當然也是一個合法的表達式。

在 Kotlin 當中最常見應該就是 to 這個 infix function 了。在 kotlin 你可以這樣宣告一個 map:

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

to 實際上就是一個 infix 函數,是一個方便產生 Pair 的語法糖。他的實作是這樣子的:

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

透過這樣子的定義,我們可以將寫程式碼的視覺雜訊儘可能減低,閱讀性也變得更高。

Lambda 表達式

Kotlin 當中的 lambda 表達式彈性相當高,你可以這樣寫:

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

}

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

當 lambda 表達式是最後一個參數的時候可以將這個 lambda 函數移到外頭,像這樣:

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

}

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

兩者是完全相同的。另外 Kotlin 會幫你的 lambda 函數作型別推斷,所以如果你這樣寫的話:

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

}

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

在 IDE 當中會顯示 it 當作 value parameter,這個 it 就是你在 block 裡頭傳入的 i

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

意思會等同於:

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

}

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

這部份可以參考 Kotlin 當中的 Scope Functions,來理解他們是怎麼運作的。

Function Literal with receiver

在這裡我們直接從官方文件上的範例來作介紹,在 Kotlin 當中的函數當中可以這樣寫:

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

我們先來看看 init 這個參數,他被宣告為一個函數,同時這個函數以 HTML.() -> Unit 表示,這個就叫做 function type with receiver。代表我們需要在某個時間點將 html 當作 context 傳給 init 函數,有點像是 JavaScript 當中的 apply(context) 一樣,不過好處是更容易達成型別安全。T.() -> K 代表執行型別 T 的成員函數,返回 K

接下來我們看看其他例子,在 kotlinx.serialization 當中,我們可以這樣宣告一個 Json

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

背後的實作是這樣:

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)
}

也就是說在剛剛的程式碼裡,實際執行的狀況會像這個樣子:

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

透過上述幾個 Kotlin 的語法,再運用一些想像力,就可以做出一個簡單且強大的 DSL 語法進而簡化程式碼,而且一切都是 type-safe 的!

Annotation

我在寫 Cronjob 的實作時,為了方便閱讀實作了一個 cronjob 的 annotation,是從 Spring Boot 的 @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) {}

然後在 runtime 時會去對所有 CronWorkEntry 找有標記 CronSchedule 的 class 並且排程 cronjob,大概像這樣:

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)
    }
}

不過這裡 it.createInstance() as Scheduler 感覺怪怪的,因為在 compile time 沒有強制規定 class 一定要是 Scheduler,所以一旦沒有繼承的話就會直接噴 Exception,不知道有沒有方法要求 annotation 只作用在某些 class 型別上。