Kotlin DSL, 指用Kotlin寫的Domain Specific Language. 本文通過解析官方的Kotlin DSL寫html的例子, 來說明Kotlin DSL是什麼. 首先是一些基礎知識, 包括什麼是DSL, 實現DSL利用了那些Kotlin的語法, 常用的情形和流行的庫. ... ...
Kotlin DSL for HTML實例解析
Kotlin DSL, 指用Kotlin寫的Domain Specific Language.
本文通過解析官方的Kotlin DSL寫html的例子, 來說明Kotlin DSL是什麼.
首先是一些基礎知識, 包括什麼是DSL, 實現DSL利用了那些Kotlin的語法, 常用的情形和流行的庫.
對html實例的解析, 沒有一衝上來就展示正確答案, 而是按照分析需求, 設計, 和實現細化的步驟來逐步讓解決方案變得明朗清晰.
理論基礎
DSL: 領域特定語言
DSL: Domain Specific Language.
專註於一個方面而特殊設計的語言.
可以看做是封裝了一套東西, 用於特定的功能, 優勢是復用性和可讀性的增強. -> 意思是提取了一套庫嗎?
不是.
DSL和簡單的方法提取不同, 有可能代碼的形式或者語法變了, 更接近自然語言, 更容易讓人看懂.
Kotlin語言基礎
做一個DSL, 改變語法, 在Kotlin中主要依靠:
- lambda表達式.
- 擴展方法.
三個lambda語法:
- 如果只有一個參數, 可以用
it
直接表示. - 如果lambda表達式是函數的最後一個參數, 可以移到小括弧
()
外面. 如果lambda是唯一的參數, 可以省略小括弧()
. - lambda可以帶receiver.
擴展方法.
流行的DSL使用場景
Gradle的build文件就是用DSL寫的.
之前是Groovy DSL, 現在也有Kotlin DSL了.
還有Anko.
這個庫包含了很多功能, UI組件, 網路, 後臺任務, 資料庫等.
和伺服器端用的: Ktor
應用場景: Type-Safe Builders
type-safe builders指類型安全, 靜態類型的builders.
這種builders就比較適合創建Kotlin DSL, 用於構建複雜的層級結構數據, 用半陳述式的方式.
官方文檔舉的是html的例子.
後面就對這個例子進行一個梳理和解析.
html實例解析
1 需求分析
首先明確一下我們的目標.
做一個最簡單的假設, 我們期待的結果是在Kotlin代碼中類似這樣寫:
html {
head { }
body { }
}
就能輸出這樣的文本:
<html>
<head>
</head>
<body>
</body>
</html>
發現1: 調用形式
仔細觀察第一段Kotlin代碼, html{}
應該是一個方法調用, 只不過這個方法只有一個lambda表達式作為參數, 所以省略了()
.
裡面的head{}
和body{}
也是同理, 都是兩個以lambda作為唯一參數的方法.
發現2: 層級關係
因為標簽的層級關係, 可以理解為每個標簽都負責自己包含的內容, 父標簽只負責按順序顯示子標簽的內容.
發現3: 調用限制
由於<head>
和<body>
等標簽只在<html>
標簽中才有意義, 所以應該限制外部只能調用html{}
方法, head{}
和body{}
方法只有在html{}
的方法體中才能調用.
發現4: 應該需要完成的
- 如何加入和顯示文字.
- 標簽可能有自己的屬性.
- 標簽應該有正確的縮進.
2 設計
標簽基類
因為標簽看起來都是類似的, 為了代碼復用, 首先設計一個抽象的標簽類Tag
, 包含:
- 標簽名稱.
- 一個子標簽的list.
- 一個屬性列表.
- 一個渲染方法, 負責輸出本標簽內容(包含標簽名, 子標簽和所有屬性).
怎麼加文字
文字比較特殊, 它不帶標簽符號<>
, 就輸出自己.
所以它的渲染方法就是輸出文字本身.
可以提取出一個更加基類的介面Element
, 只包含渲染方法. 這個介面的子類是Tag
和TextElement
.
有文字的標簽, 如<title>
, 它的輸出結果:
<title>
HTML encoding with Kotlin
</title>
文字元素是作為標簽的一個子標簽的.
這裡的實現不容易自己想到, 直接看後面的實現部分揭曉答案吧.
3 實現
有了前面的心路歷程, 再來看實現就能容易一些.
基類實現
首先是最基本的介面, 只包含了渲染方法:
interface Element {
fun render(builder: StringBuilder, indent: String)
}
它的直接子類標簽類:
abstract class Tag(val name: String) : Element {
val children = arrayListOf<Element>()
val attributes = hashMapOf<String, String>()
protected fun <T : Element> initTag(tag: T, init: T.() -> Unit): T {
tag.init()
children.add(tag)
return tag
}
override fun render(builder: StringBuilder, indent: String) {
builder.append("$indent<$name${renderAttributes()}>\n")
for (c in children) {
c.render(builder, indent + " ")
}
builder.append("$indent</$name>\n")
}
private fun renderAttributes(): String {
val builder = StringBuilder()
for ((attr, value) in attributes) {
builder.append(" $attr=\"$value\"")
}
return builder.toString()
}
override fun toString(): String {
val builder = StringBuilder()
render(builder, "")
return builder.toString()
}
}
完成了自身標簽名和屬性的渲染, 接著遍歷子標簽渲染其內容. 註意這裡為所有子標簽加上了一層縮進.
initTag()
這個方法是protected
的, 供子類調用, 為自己加上子標簽.
帶文字的標簽
帶文字的標簽有個抽象的基類:
abstract class TagWithText(name: String) : Tag(name) {
operator fun String.unaryPlus() {
children.add(TextElement(this))
}
}
這是一個對+
運算符的重載, 這個擴展方法把字元串包裝成TextElement
類對象, 然後加到當前標簽的子標簽中去.
TextElement
做的事情就是渲染自己:
class TextElement(val text: String) : Element {
override fun render(builder: StringBuilder, indent: String) {
builder.append("$indent$text\n")
}
}
所以, 當我們調用:
html {
head {
title { +"HTML encoding with Kotlin" }
}
}
得到結果:
<html>
<head>
<title>
HTML encoding with Kotlin
</title>
</html>
其中用到的Title
類定義:
class Title : TagWithText("title")
通過'+'運算符的操作, 字元串: "HTML encoding with Kotlin"被包裝成了TextElement
, 他是title標簽的child.
程式入口
對外的公開方法只有這一個:
fun html(init: HTML.() -> Unit): HTML {
val html = HTML()
html.init()
return html
}
init
參數是一個函數, 它的類型是HTML.() -> Unit
. 這是一個帶接收器的函數類型, 也就是說, 需要一個HTML
類型的實例來調用這個函數.
這個方法實例化了一個HTML
類對象, 在實例上調用傳入的lambda參數, 然後返回該對象.
調用此lambda的實例會被作為this
傳入函數體內(this
可以省略), 我們在函數體內就可以調用HTML
類的成員方法了.
這樣保證了外部的訪問入口, 只有:
html {
}
通過成員函數創建內部標簽.
HTML類
HTML類如下:
class HTML : TagWithText("html") {
fun head(init: Head.() -> Unit) = initTag(Head(), init)
fun body(init: Body.() -> Unit) = initTag(Body(), init)
}
可以看出html
內部可以通過調用head
和body
方法創建子標簽, 也可以用+
來添加字元串.
這兩個方法本來可以是這樣:
fun head(init: Head.() -> Unit) : Head {
val head = Head()
head.init()
children.add(head)
return head
}
fun body(init: Body.() -> Unit) : Body {
val body = Body()
body.init()
children.add(body)
return body
}
由於形式類似, 所以做了泛型抽象, 被提取到了基類Tag
中, 作為更加通用的方法:
protected fun <T : Element> initTag(tag: T, init: T.() -> Unit): T {
tag.init()
children.add(tag)
return tag
}
做的事情: 創建對象, 在其之上調用init lambda, 添加到子標簽列表, 然後返回.
其他標簽類的實現與之類似, 不作過多解釋.
4 修Bug: 隱式receiver穿透問題
以上都寫完了之後, 感覺大功告成, 但其實還有一個隱患.
我們居然可以這樣寫:
html {
head {
title { +"HTML encoding with Kotlin" }
head { +"haha" }
}
}
在head方法的lambda塊中, html塊的receiver仍然是可見的, 所以還可以調用head
方法.
顯式地調用是這樣的:
[email protected] { +"haha" }
但是這裡this@html.
是可以省略的.
這段代碼輸出的是:
<html>
<head>
haha
</head>
<head>
<title>
HTML encoding with Kotlin
</title>
</head>
</html>
最內層的haha反倒是最先被加到html對象的孩子列表裡.
這種穿透性太混亂了, 容易導致錯誤, 我們能不能限制每個大括弧里只有當前的對象成員是可訪問的呢? -> 可以.
為瞭解決這種問題, Kotlin 1.1推出了管理receiver scope的機制, 解決方法是使用@DslMarker
.
html的例子, 定義註解類:
@DslMarker
annotation class HtmlTagMarker
這種被@DslMarker
修飾的註解類叫做DSL marker
.
然後我們只需要在基類上標註:
@HtmlTagMarker
abstract class Tag(val name: String)
所有的子類都會被認為也標記了這個marker.
加上註解之後隱式訪問會編譯報錯:
html {
head {
head { } // error: a member of outer receiver
}
// ...
}
但是顯式還是可以的:
html {
head {
[email protected] { } // possible
}
// ...
}
只有最近的receiver對象可以隱式訪問.
總結
本文通過實例, 來逐步解析如何用Kotlin代碼, 用半陳述式的方式寫html結構, 從而看起來更加直觀. 這種就叫做DSL.
Kotlin DSL通過精心的定義, 主要的目的是為了讓使用者更加方便, 代碼更加清晰直觀.
參考
More resources:
- Kotlin之美——DSL篇
- From Java Builders to Kotlin DSLs
- Oversimplified network call using Retrofit, LiveData, Kotlin Coroutines and DSL