Kotlin DSL for HTML例項解析
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通過精心的定義, 主要的目的是為了讓使用者更加方便, 程式碼更加清晰直觀.
參考
- 官方文件: Type-Safe Builders
- Domain-Specific Languages In Kotlin
More resources:
- Kotlin之美——DSL篇
- From Java Builders to Kotlin DSLs
- Oversimplified network call using Retrofit, LiveData, Kotlin Coroutines and DSL