Kotlin DSL for HTML實(shí)例解析

Kotlin DSL for HTML實(shí)例解析

Kotlin DSL, 指用Kotlin寫(xiě)的Domain Specific Language.
本文通過(guò)解析官方的Kotlin DSL寫(xiě)html的例子, 來(lái)說(shuō)明Kotlin DSL是什么.

首先是一些基礎(chǔ)知識(shí), 包括什么是DSL, 實(shí)現(xiàn)DSL利用了那些Kotlin的語(yǔ)法, 常用的情形和流行的庫(kù).

對(duì)html實(shí)例的解析, 沒(méi)有一沖上來(lái)就展示正確答案, 而是按照分析需求, 設(shè)計(jì), 和實(shí)現(xiàn)細(xì)化的步驟來(lái)逐步讓解決方案變得明朗清晰.

本文收錄于: https://github.com/mengdd/KotlinTutorials

理論基礎(chǔ)

DSL: 領(lǐng)域特定語(yǔ)言

DSL: Domain Specific Language.
專(zhuān)注于一個(gè)方面而特殊設(shè)計(jì)的語(yǔ)言.

可以看做是封裝了一套東西, 用于特定的功能, 優(yōu)勢(shì)是復(fù)用性和可讀性的增強(qiáng). -> 意思是提取了一套庫(kù)嗎?

不是.

DSL和簡(jiǎn)單的方法提取不同, 有可能代碼的形式或者語(yǔ)法變了, 更接近自然語(yǔ)言, 更容易讓人看懂.

Kotlin語(yǔ)言基礎(chǔ)

做一個(gè)DSL, 改變語(yǔ)法, 在Kotlin中主要依靠:

  • lambda表達(dá)式.
  • 擴(kuò)展方法.

三個(gè)lambda語(yǔ)法:

  • 如果只有一個(gè)參數(shù), 可以用it直接表示.
  • 如果lambda表達(dá)式是函數(shù)的最后一個(gè)參數(shù), 可以移到小括號(hào)()外面. 如果lambda是唯一的參數(shù), 可以省略小括號(hào)().
  • lambda可以帶receiver.

擴(kuò)展方法.

流行的DSL使用場(chǎng)景

Gradle的build文件就是用DSL寫(xiě)的.
之前是Groovy DSL, 現(xiàn)在也有Kotlin DSL了.

還有Anko.
這個(gè)庫(kù)包含了很多功能, UI組件, 網(wǎng)絡(luò), 后臺(tái)任務(wù), 數(shù)據(jù)庫(kù)等.

和服務(wù)器端用的: Ktor

應(yīng)用場(chǎng)景: Type-Safe Builders
type-safe builders指類(lèi)型安全, 靜態(tài)類(lèi)型的builders.

這種builders就比較適合創(chuàng)建Kotlin DSL, 用于構(gòu)建復(fù)雜的層級(jí)結(jié)構(gòu)數(shù)據(jù), 用半陳述式的方式.

官方文檔舉的是html的例子.
后面就對(duì)這個(gè)例子進(jìn)行一個(gè)梳理和解析.

html實(shí)例解析

1 需求分析

首先明確一下我們的目標(biāo).

做一個(gè)最簡(jiǎn)單的假設(shè), 我們期待的結(jié)果是在Kotlin代碼中類(lèi)似這樣寫(xiě):

html {
    head { }
    body { }
}

就能輸出這樣的文本:

<html>
  <head>
  </head>
  <body>
  </body>
</html>

發(fā)現(xiàn)1: 調(diào)用形式

仔細(xì)觀察第一段Kotlin代碼, html{}應(yīng)該是一個(gè)方法調(diào)用, 只不過(guò)這個(gè)方法只有一個(gè)lambda表達(dá)式作為參數(shù), 所以省略了().

里面的head{}body{}也是同理, 都是兩個(gè)以lambda作為唯一參數(shù)的方法.

發(fā)現(xiàn)2: 層級(jí)關(guān)系

因?yàn)闃?biāo)簽的層級(jí)關(guān)系, 可以理解為每個(gè)標(biāo)簽都負(fù)責(zé)自己包含的內(nèi)容, 父標(biāo)簽只負(fù)責(zé)按順序顯示子標(biāo)簽的內(nèi)容.

發(fā)現(xiàn)3: 調(diào)用限制

由于<head><body>等標(biāo)簽只在<html>標(biāo)簽中才有意義, 所以應(yīng)該限制外部只能調(diào)用html{}方法, head{}body{}方法只有在html{}的方法體中才能調(diào)用.

發(fā)現(xiàn)4: 應(yīng)該需要完成的

  • 如何加入和顯示文字.
  • 標(biāo)簽可能有自己的屬性.
  • 標(biāo)簽應(yīng)該有正確的縮進(jìn).

2 設(shè)計(jì)

標(biāo)簽基類(lèi)

因?yàn)闃?biāo)簽看起來(lái)都是類(lèi)似的, 為了代碼復(fù)用, 首先設(shè)計(jì)一個(gè)抽象的標(biāo)簽類(lèi)Tag, 包含:

  • 標(biāo)簽名稱(chēng).
  • 一個(gè)子標(biāo)簽的list.
  • 一個(gè)屬性列表.
  • 一個(gè)渲染方法, 負(fù)責(zé)輸出本標(biāo)簽內(nèi)容(包含標(biāo)簽名, 子標(biāo)簽和所有屬性).

怎么加文字

文字比較特殊, 它不帶標(biāo)簽符號(hào)<>, 就輸出自己.
所以它的渲染方法就是輸出文字本身.

可以提取出一個(gè)更加基類(lèi)的接口Element, 只包含渲染方法. 這個(gè)接口的子類(lèi)是TagTextElement.

有文字的標(biāo)簽, 如<title>, 它的輸出結(jié)果:

    <title>
      HTML encoding with Kotlin
    </title>

文字元素是作為標(biāo)簽的一個(gè)子標(biāo)簽的.
這里的實(shí)現(xiàn)不容易自己想到, 直接看后面的實(shí)現(xiàn)部分揭曉答案吧.

3 實(shí)現(xiàn)

有了前面的心路歷程, 再來(lái)看實(shí)現(xiàn)就能容易一些.

基類(lèi)實(shí)現(xiàn)

首先是最基本的接口, 只包含了渲染方法:

interface Element {
    fun render(builder: StringBuilder, indent: String)
}

它的直接子類(lèi)標(biāo)簽類(lèi):

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()
    }
}

完成了自身標(biāo)簽名和屬性的渲染, 接著遍歷子標(biāo)簽渲染其內(nèi)容. 注意這里為所有子標(biāo)簽加上了一層縮進(jìn).

initTag()這個(gè)方法是protected的, 供子類(lèi)調(diào)用, 為自己加上子標(biāo)簽.

帶文字的標(biāo)簽

帶文字的標(biāo)簽有個(gè)抽象的基類(lèi):

abstract class TagWithText(name: String) : Tag(name) {
    operator fun String.unaryPlus() {
        children.add(TextElement(this))
    }
}

這是一個(gè)對(duì)+運(yùn)算符的重載, 這個(gè)擴(kuò)展方法把字符串包裝成TextElement類(lèi)對(duì)象, 然后加到當(dāng)前標(biāo)簽的子標(biāo)簽中去.

TextElement做的事情就是渲染自己:

class TextElement(val text: String) : Element {
    override fun render(builder: StringBuilder, indent: String) {
        builder.append("$indent$text\n")
    }
}

所以, 當(dāng)我們調(diào)用:

html {
    head {
        title { +"HTML encoding with Kotlin" }
    }
}

得到結(jié)果:

<html>
  <head>
    <title>
      HTML encoding with Kotlin
    </title>
</html>

其中用到的Title類(lèi)定義:

class Title : TagWithText("title")

通過(guò)'+'運(yùn)算符的操作, 字符串: "HTML encoding with Kotlin"被包裝成了TextElement, 他是title標(biāo)簽的child.

程序入口

對(duì)外的公開(kāi)方法只有這一個(gè):

fun html(init: HTML.() -> Unit): HTML {
    val html = HTML()
    html.init()
    return html
}

init參數(shù)是一個(gè)函數(shù), 它的類(lèi)型是HTML.() -> Unit. 這是一個(gè)帶接收器的函數(shù)類(lèi)型, 也就是說(shuō), 需要一個(gè)HTML類(lèi)型的實(shí)例來(lái)調(diào)用這個(gè)函數(shù).

這個(gè)方法實(shí)例化了一個(gè)HTML類(lèi)對(duì)象, 在實(shí)例上調(diào)用傳入的lambda參數(shù), 然后返回該對(duì)象.

調(diào)用此lambda的實(shí)例會(huì)被作為this傳入函數(shù)體內(nèi)(this可以省略), 我們?cè)诤瘮?shù)體內(nèi)就可以調(diào)用HTML類(lèi)的成員方法了.

這樣保證了外部的訪(fǎng)問(wèn)入口, 只有:

html {
    
}

通過(guò)成員函數(shù)創(chuàng)建內(nèi)部標(biāo)簽.

HTML類(lèi)

HTML類(lèi)如下:

class HTML : TagWithText("html") {
    fun head(init: Head.() -> Unit) = initTag(Head(), init)

    fun body(init: Body.() -> Unit) = initTag(Body(), init)
}

可以看出html內(nèi)部可以通過(guò)調(diào)用headbody方法創(chuàng)建子標(biāo)簽, 也可以用+來(lái)添加字符串.

這兩個(gè)方法本來(lái)可以是這樣:

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
}

由于形式類(lèi)似, 所以做了泛型抽象, 被提取到了基類(lèi)Tag中, 作為更加通用的方法:

protected fun <T : Element> initTag(tag: T, init: T.() -> Unit): T {
    tag.init()
    children.add(tag)
    return tag
}

做的事情: 創(chuàng)建對(duì)象, 在其之上調(diào)用init lambda, 添加到子標(biāo)簽列表, 然后返回.

其他標(biāo)簽類(lèi)的實(shí)現(xiàn)與之類(lèi)似, 不作過(guò)多解釋.

4 修Bug: 隱式receiver穿透問(wèn)題

以上都寫(xiě)完了之后, 感覺(jué)大功告成, 但其實(shí)還有一個(gè)隱患.

我們居然可以這樣寫(xiě):

html {
    head {
        title { +"HTML encoding with Kotlin" }
        head { +"haha" }
    }
}

在head方法的lambda塊中, html塊的receiver仍然是可見(jiàn)的, 所以還可以調(diào)用head方法.
顯式地調(diào)用是這樣的:

this@html.head { +"haha" }

但是這里this@html.是可以省略的.

這段代碼輸出的是:

<html>
  <head>
    haha
  </head>
  <head>
    <title>
      HTML encoding with Kotlin
    </title>
  </head>
</html>

最內(nèi)層的haha反倒是最先被加到html對(duì)象的孩子列表里.

這種穿透性太混亂了, 容易導(dǎo)致錯(cuò)誤, 我們能不能限制每個(gè)大括號(hào)里只有當(dāng)前的對(duì)象成員是可訪(fǎng)問(wèn)的呢? -> 可以.

為了解決這種問(wèn)題, Kotlin 1.1推出了管理receiver scope的機(jī)制, 解決方法是使用@DslMarker.

html的例子, 定義注解類(lèi):

@DslMarker
annotation class HtmlTagMarker

這種被@DslMarker修飾的注解類(lèi)叫做DSL marker.

然后我們只需要在基類(lèi)上標(biāo)注:

@HtmlTagMarker
abstract class Tag(val name: String)

所有的子類(lèi)都會(huì)被認(rèn)為也標(biāo)記了這個(gè)marker.

加上注解之后隱式訪(fǎng)問(wèn)會(huì)編譯報(bào)錯(cuò):

html {
    head {
        head { } // error: a member of outer receiver
    }
    // ...
}

但是顯式還是可以的:

html {
    head {
        this@html.head { } // possible
    }
    // ...
}

只有最近的receiver對(duì)象可以隱式訪(fǎng)問(wèn).

總結(jié)

本文通過(guò)實(shí)例, 來(lái)逐步解析如何用Kotlin代碼, 用半陳述式的方式寫(xiě)html結(jié)構(gòu), 從而看起來(lái)更加直觀. 這種就叫做DSL.

Kotlin DSL通過(guò)精心的定義, 主要的目的是為了讓使用者更加方便, 代碼更加清晰直觀.

參考

More resources:

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • DSL(領(lǐng)域特定語(yǔ)言)是Kotlin所帶來(lái)的強(qiáng)大語(yǔ)法特性之一,也是Java中所不存在的功能,JetBrain也基于...
    _Preacher_閱讀 4,880評(píng)論 2 11
  • 1 DSL(domain-specific language) 1.1 DSL的定義 DSL被定義為處理特定領(lǐng)域問(wèn)...
    Rolrence閱讀 2,636評(píng)論 0 2
  • 我曾從別人故事里 看見(jiàn)過(guò)去的自己 也試圖因此窺探到 未知的結(jié)局 我曾從隱秘的對(duì)白中 暗自揣測(cè)將與怎樣的你 相遇別離...
    她在江湖閱讀 360評(píng)論 4 2
  • 剛剛結(jié)束和室友的交談,口干舌燥的我同時(shí)也感慨萬(wàn)分。失戀的姑娘??!是那么傷感,那么讓人心疼。 一年前,我室友和男友鬧...
    胖胖的啊鑫閱讀 321評(píng)論 0 0
  • 到今天為止,事情過(guò)去已經(jīng)整整兩年了。 如果當(dāng)初,我沒(méi)有按照世俗的眼光選擇過(guò)活。如果當(dāng)時(shí),我可以遵循自己本心的意愿,...
    曉熙069閱讀 374評(píng)論 0 6

友情鏈接更多精彩內(nèi)容