1. 程式人生 > >Kotlin DSL for HTML例項解析

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, 只包含渲染方法. 這個介面的子類是TagTextElement.

有文字的標籤, 如<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內部可以通過呼叫headbody方法建立子標籤, 也可以用+來新增字串.

這兩個方法本來可以是這樣:

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