半熟前端

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

本部落格使用 Gatsby 製作

本部落格有使用 Google Analytic 及 Cookie

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 語法。

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

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

json
{
"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 當中可以這樣子寫:

kotlin
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 語法:

kotlin
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 的方式,例如下面的語法:

kotlin
fun Int.add2() {
this + 2
}

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

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

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

Infix notation

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

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

效果會像這樣:

kotlin
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:

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

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

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

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

Lambda 表達式

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

kotlin
fun test(a: Int, block: () -> Unit) {
}
test(1, {
println("hello")
})

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

kotlin
fun test(a: Int, block: () -> Unit) {
}
test(1) {
println("hello")
}

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

kotlin
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

意思會等同於:

kotlin
fun test(a: Int, block: (i: Int) -> Unit) {
}
test(1) { i ->
println("hello", i)
}

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

Function Literal with receiver

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

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

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

背後的實作是這樣:

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

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

kotlin
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 學來的:

kotlin
@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,大概像這樣:

kotlin
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 型別上。

如果覺得這篇文章對你有幫助的話,可以考慮到下面的連結請我喝一杯 ☕️
可以讓我平凡的一天變得閃閃發光 ✨

Buy me a coffee