Design DSL with Kotlin

Design DSL with Kotlin

這個概念最早了解是在學習如何用 Gradle 做打包的時候,而這個簡潔的寫法當時看了真是有點怕,一方面當時尚未熟悉 Groovy 這個語言,一方面 Gradle 也不大懂他的運作邏輯,不過當時看到這樣的概念卻有激起我想去實作看看的動力,可讀性好高啊。

Groovy 在 DSL 上的實現是利用其 Cloure 的 Delegate 特性

而這兩天有時間研究 DSL 了就把整個過程記錄下來分享。

Kotlin 支援的相關的語意用法

開始說明 Kotlin 如何自訂 DSL 前先統整一下,Kotlin 有提供了什麼語法特性,可以幫助簡化語意,以利建構 DSL

Regular syntaxClean syntaxFeature in use
StringUtil.capitalize(s)s.capitalize()Extension function
1.to(“one”)1 to “one”Infix call
set.add(2)set += 2Operator overloading
map.get(“key”)map[“key”]Convention for the get method
file.use({ f -> f.read() })file.use { it.read() }Lambda outside of parentheses
sb.append(“yes”)
sb.append(“no”)
with (sb) {
append(“yes”)
  append(“no”)
}
Lambda with a receiver

Lambda 是整個 DSL 的核心部分,這邊列幾個用法要點,會在 DSL 建構時用到這些概念:

Inferring Lamda Parameters

如果 Lambda 其傳入的參數只有一個,則Lambda 內可以使用 it 關鍵字去代替該參數。

val action: (String) -> Unit = { message ->
    println(message)
}
val action: (String) -> Unit = {
    println(it)
}

Lambdas Outside of Parentheses

當一個 Function 的參數只有一個 Lambda 的時候,可以把這個 Lambda 拉出來到小括號的外面去。

fun ask(
    name: String,
    action: () -> Unit
) {
    println("ask $name to do something.")
    action()
}

等價:

ask("John"){
    println("Work")
}
ask("John",{
    println("Work")
})

Extension Functions

擴充方法可以在不改動原本物件的情況下增加方法,對於第三方的 Library 特別有用,原理是利用 Java 中的 Static 方法來實現。

fun String.sayHello(){
    println("$this say Hello")
}
"John".sayHello()

Lambdas with receivers

這一塊就是整個 DSL 的重點了。

Kotlin 在其 Standard library 有提供了像是 apply、let、run 等等好用的 function。

val message = StringBuilder().run {
    append("A")
    append("B")
    toString()
}
assert(message == "AB")

用以上的例子來說,如果物件是採用建造者模式來做的話,這樣的寫法就會很清晰。

那如果我們也想造一個像這樣的 Function,一開始會怎麼做?? 如果尚未了解 Receiver Type 的話,可能會寫成像下面這樣:

/** 目標物件 */
data class Wallet(
    var money: Int = 0
) {
    fun addMoney(num: Int) {
        money += num
    }
}

/** 欲建立 DSL 用的 Function */
fun createWallet(
    action: (Wallet) -> Unit
): Wallet {
    val wallet = Wallet()
    action(wallet)
    return wallet
}
val wallet = createWallet {
    // it 必須要存在才可以 invoke method
    it.addMoney(10)
    it.addMoney(20)
}
println(wallet.money)

這樣的寫法最後會發現 it 必須要存在才可以調用方法,這是贅字而且我們也不想要。 那該怎麼去除 it 呢?? 那就是這邊要說的 Receivers Type。

把上方的例子改寫成:

/** 建立 DSL 用的 Function */
fun createWallet(
    /** 改成類似 Extension Function 的寫法 */
    action: Wallet.() -> Unit
): Wallet {
    val wallet = Wallet()
    wallet.action()
    return wallet
    // return wallet.apply(action)
}

action 的輸入參數變成了以 Wallet 為接收型別,不再傳入要操作特定物件。 就像擴充方法一樣,這樣子在 createWallet() 中就可以直接將 action 賦予在 wallet 物件上執行。

lambda 可以使用 kotlin 的擴充方法 apply() 來給予物件進行執行的動作,寫法上會更簡潔。

於是我們的建立物件寫法就可以省卻 it ,也就是改為使用 this 來使用,而 this 又可以在 lambda 中省略:

val wallet = createWallet {
    // this.addMoney(10),this 可以被省略
    addMoney(10)
    addMoney(20)
}

可以看到整個 Lambda 表達式就可以寫的比較乾淨。

以建構 JsonObject 為例

有了以上的知識,這裡就以建立 Gson 的 JsonObject 物件為例,創建一個屬於自己建立 JsonObject 的 DSL 寫法,先闡述我的目標:

  • 建立一個以小括號為Key、大括號為 Value 的 DSL 寫法

先建立一個 DSL 用的 Class,把需要用的擴充方法放進這裡:

  • 直接寫在 Class 外是因為擴充方法要寫在 Class 級別上才能提供其他類別使用,寫在 Class 內的話 Scope 只有在該 Class 內而已,會無法調用

JsonObjectDSL.kotlin

package com.enix.dsl

import com.enix.util.LoggerUtil
import com.google.gson.JsonArray
import com.google.gson.JsonNull
import com.google.gson.JsonObject

/**
 * JsonObject/JsonArray 建造用 DSL,
 * import com.enix.dsl.* 後即可服用
 */
class JsonObjectDSL
/** Log 可以拿掉 */
private val log = LoggerUtil.getLogger<JsonObjectDSL>()

/** Extension Function :建立物件*/
fun JsonObject.create(
    buildAction: JsonObject.() -> Unit
): JsonObject {
    return this.apply(buildAction)
}

/** 物件中建立物件 */
fun JsonObject.obj(
    key: String,
    buildAction: JsonObject.() -> Unit
) {
    this.add(key, JsonObject().apply(buildAction))
}

/**物件中建立 key/value */
fun JsonObject.value(
    key: String,
    value: () -> Any
) {
    when (val result = value()) {
        is Char -> this.addProperty(key, result)
        is Number -> this.addProperty(key, result)
        is String -> this.addProperty(key, result)
        is Boolean -> this.addProperty(key, result)
        is Unit -> this.add(key, JsonNull.INSTANCE)
        else -> log.error { "Json element is not compatible:${result::class}" }
    }
}

/** 物件中建立陣列 */
fun JsonObject.array(
    key: String,
    buildAction: JsonArray.() -> Unit
) {
    this.add(key, JsonArray().apply(buildAction))
}

/** Extension Function :建立陣列 */
fun JsonArray.create(
    buildAction: JsonArray.() -> Unit
): JsonArray {
    return this.apply(buildAction)
}

/** 陣列中建立物件 */
fun JsonArray.obj(
    buildAction: JsonObject.() -> Unit
) {
    this.add(JsonObject().apply(buildAction))
}

/** 陣列中建立數值 */
fun JsonArray.value(
    value: () -> Any
) {
    when (val result = value()) {
        is Char -> this.add(result)
        is Number -> this.add(result)
        is String -> this.add(result)
        is Boolean -> this.add(result)
        is Unit -> this.add(JsonNull.INSTANCE)
        else -> log.error { "Json array value is not compatible:${result::class}" }
    }
}

再來嘗試利用上方擴充方法進行 JsonObject 物件建立:

val json = JsonObject().create {
    value("userName") { "John" }
    obj("userData") {
        value("phone") { "0987654321" }
        value("address") { "New Taipei City" }
        value("email") { "abccar@gmail.com" }
    }
    array("skill") {
        obj {
            value("name") { "coding" }
            obj("level") {
                value("java") { "expert" }
                value("c") { "novice" }
                value("python") {}
            }
            value("note") {}
        }
        obj {
            value("name") { "driving" }
            obj("level") {
                value("knowledge") { "intermediate" }
            }
            value("note") {}
        }
    }
    array("score") {
        value { 90 }
        value { 100 }
        value { 95 }
    }
}
println(json)

如果進行 toString() 輸出就可以看到以下結果:

{
    "userName": "John",
    "userData":
    {
        "phone": "0987654321",
        "address": "New Taipei City",
        "email": "abccar@gmail.com"
    },
    "skill": [
    {
        "name": "coding",
        "level":
        {
            "java": "expert",
            "c": "novice",
            "python": null
        },
        "note": null
    },
    {
        "name": "driving",
        "level":
        {
            "knowledge": "intermediate"
        },
        "note": null
    }],
    "score": [90, 100, 95]
}

到這裡就可以看到整個 DSL 的效果了,以這個例子來說可能閱讀性上還沒那麼好,不過應該是能提供一個建立 DSL 的想法與思緒。

  • 其實這個例子還有一個問題,就是內部的 Lambda 區塊事實上可以調用外層 Lambda 的方法,這樣會造成物件的建立位置是錯的,寫在裡面卻建在外面,這部分的作用域該如何解決尚須研究。

結語

DSL 能用上的地方挺多的,最容易想到的建造者模式了,尤其是有多層嵌套之下使用 DSL 的設計會比較好理解。當然一般情況下直接使用 kotlin stanard library 提供的方法也能夠有類似效果,但…有點冗就是了。 而當 Functional Programing 越來越盛行後,這樣的 DSL 寫法其實也蠻常看到的,像是 myBatis 的動態 SQL、 Spring webflux 的 router 設定等等,都有很大程度閱讀性上的加分,熟悉一下其背後的原理也是不錯的。

有任何指教歡迎留言。

發佈留言