這個概念最早了解是在學習如何用 Gradle 做打包的時候,而這個簡潔的寫法當時看了真是有點怕,一方面當時尚未熟悉 Groovy 這個語言,一方面 Gradle 也不大懂他的運作邏輯,不過當時看到這樣的概念卻有激起我想去實作看看的動力,可讀性好高啊。
Groovy 在 DSL 上的實現是利用其 Cloure 的 Delegate 特性
而這兩天有時間研究 DSL 了就把整個過程記錄下來分享。
Kotlin 支援的相關的語意用法
開始說明 Kotlin 如何自訂 DSL 前先統整一下,Kotlin 有提供了什麼語法特性,可以幫助簡化語意,以利建構 DSL
Regular syntax | Clean syntax | Feature in use |
StringUtil.capitalize(s) | s.capitalize() | Extension function |
1.to(“one”) | 1 to “one” | Infix call |
set.add(2) | set += 2 | Operator 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 設定等等,都有很大程度閱讀性上的加分,熟悉一下其背後的原理也是不錯的。
有任何指教歡迎留言。