Kotlin DSL for HTML實例解析
Kotlin DSL, 指用Kotlin寫的Domain Specific Language.
本文通過解析官方的Kotlin DSL寫html的例子, 來說明Kotlin DSL是什么.
首先是一些基礎知識, 包括什么是DSL, 實現DSL利用了那些Kotlin的語法, 常用的情形和流行的庫.
對html實例的解析, 沒有一沖上來就展示正確答案, 而是按照分析需求, 設計, 和實現細化的步驟來逐步讓解決方案變得明朗清晰.
本文被收錄在: https://github.com/mengdd/KotlinTutorials
理論基礎
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
方法.
顯式地調用是這樣的:
this@html.head { +"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 {
this@html.head { } // possible
}
// ...
}
只有最近的receiver對象可以隱式訪問.
總結
本文通過實例, 來逐步解析如何用Kotlin代碼, 用半陳述式的方式寫html結構, 從而看起來更加直觀. 這種就叫做DSL.
Kotlin DSL通過精心的定義, 主要的目的是為了讓使用者更加方便, 代碼更加清晰直觀.
參考
More resources: