最近用 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
。
意思會等同於:
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 型別上。