Kotlin DSL for HTML實例解析

Kotlin DSL, 指用Kotlin寫的Domain Specific Language. 本文經過解析官方的Kotlin DSL寫html的例子, 來講明Kotlin DSL是什麼.html

首先是一些基礎知識, 包括什麼是DSL, 實現DSL利用了那些Kotlin的語法, 經常使用的情形和流行的庫.java

對html實例的解析, 沒有一衝上來就展現正確答案, 而是按照分析需求, 設計, 和實現細化的步驟來逐步讓解決方案變得明朗清晰.android

理論基礎

DSL: 領域特定語言

DSL: Domain Specific Language. 專一於一個方面而特殊設計的語言.git

能夠看作是封裝了一套東西, 用於特定的功能, 優點是複用性和可讀性的加強. -> 意思是提取了一套庫嗎?github

不是.數據庫

DSL和簡單的方法提取不一樣, 有可能代碼的形式或者語法變了, 更接近天然語言, 更容易讓人看懂.安全

Kotlin語言基礎

作一個DSL, 改變語法, 在Kotlin中主要依靠:bash

  • 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:

相關文章
相關標籤/搜索