Kotlin DSL for HTML實例解析


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, 只包含渲染方法. 這個接口的子類是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方法.
顯式地調用是這樣的:

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:


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM