Chào mừng các bạn đến với Cafedev! Trong bài viết này, chúng ta sẽ khám phá thế giới của Kotlin với Type-safe builders. Đây không chỉ là một cách để viết mã một cách thuận tiện, mà còn là cách để xây dựng ngôn ngữ cụ thể cho các cấu trúc dữ liệu phức tạp. Hãy cùng Cafedev đàm phán về sức mạnh của Type-safe builders và cách chúng có thể giúp tạo ra mã Kotlin đẹp và dễ đọc. Đồng hành cùng chúng tôi trong hành trình này!

Sử dụng các hàm có tên rõ ràng như builders kết hợp với hàm với receiver cho phép tạo ra các builders kiểu an toàn, kiểu tĩnh trong Kotlin.
Các builders kiểu an toàn cho phép tạo ra các ngôn ngữ đặc biệt cho miền (DSLs) dựa trên Kotlin, thích hợp để xây dựng cấu trúc dữ liệu phức tạp theo cách bán khai báo. Các trường hợp sử dụng mẫu cho builders bao gồm:

  • Tạo markup với mã Kotlin, chẳng hạn như HTML hoặc XML
  • Cấu hình các route cho máy chủ web: Ktor


Xem đoạn mã sau:

import com.example.html.* // see declarations below

fun result() =
    html {
        head {
            title {+"XML encoding with Kotlin"}
        }
        body {
            h1 {+"XML encoding with Kotlin"}
            p  {+"this format can be used as an alternative markup to XML"}

            // an element with attributes and text content
            a(href = "https://kotlinlang.org") {+"Kotlin"}

            // mixed content
            p {
                +"This is some"
                b {+"mixed"}
                +"text. For more see the"
                a(href = "https://kotlinlang.org") {+"Kotlin"}
                +"project"
            }
            p {+"some text"}

            // content generated by
            p {
                for (arg in args)
                    +arg
            }
        }
    }

Đây là mã Kotlin hoàn toàn hợp lệ.

1. Cách nó hoạt động

Giả sử bạn cần triển khai một builder kiểu an toàn trong Kotlin. Trước hết, định nghĩa mô hình bạn muốn xây dựng. Trong trường hợp này, bạn cần mô hình hóa các thẻ HTML. Điều này được thực hiện dễ dàng với một loạt các lớp. Ví dụ, HTML là một lớp mô tả thẻ xác định các phần con như . (Xem phần dưới đây để biết cách khai báo.)
Bây giờ, hãy nhớ tại sao bạn có thể viết một cái gì đó như thế này trong mã:

html {
// …
}

html thực sự là một cuộc gọi hàm nhận một biểu thức lambda làm đối số. Hàm này được định nghĩa như sau:

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

Hàm này nhận một tham số có tên là init, thực chất là một hàm. Kiểu của hàm là HTML.() -> Unit, đó là một kiểu hàm với receiver. Điều này có nghĩa là bạn cần truyền một thể hiện của kiểu HTML (một receiver) cho hàm và bạn có thể gọi các thành viên của thể hiện đó trong hàm.
Receiver có thể được truy cập thông qua từ khóa this:

html {
    this.head { ... }
    this.body { ... }
}

(head và body là các hàm thành viên của HTML.)
Bây giờ, this có thể được bỏ qua, như thường lệ, và bạn có một cái gì đó giống như một builder đã.

html {
    head { ... }
    body { ... }
}

Vậy, cuộc gọi này làm gì? Hãy xem xét phần thân của hàm html được định nghĩa ở trên. Nó tạo một thể hiện mới của HTML, sau đó khởi tạo nó bằng cách gọi hàm được truyền làm đối số (trong ví dụ này, nói chung là gọi headbody trên thể hiện HTML), và sau đó trả về thể hiện này. Điều này chính xác là điều mà một builder nên làm.
Hàm headbody trong lớp HTML được định nghĩa tương tự như html. Sự khác biệt duy nhất là chúng thêm các thể hiện đã được xây dựng vào bộ sưu tập children của thể hiện HTML bao quanh:

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
}

Trên thực tế, hai hàm này làm đúng một điều, vì vậy bạn có thể có một phiên bản chung, initTag:

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

Vì vậy, bây giờ hàm của bạn rất đơn giản:

fun head(init: Head.() -> Unit) = initTag(Head(), init)

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

Và bạn có thể sử dụng chúng để xây dựng các thẻ .
Một điều khác cần thảo luận ở đây là cách bạn thêm văn bản vào nội dung thẻ. Trong ví dụ trên, bạn nói một cái gì đó như:

html {
    head {
        title {+"XML encoding with Kotlin"}
    }
    // ...
}

Vì vậy, đơn giản là bạn đặt một chuỗi vào bên trong thân thẻ, nhưng có chút + phía trước, vì vậy đó là một cuộc gọi hàm triệu hồi một phép toán tiền tố unaryPlus(). Phép toán đó thực sự được định nghĩa bởi một hàm mở rộng unaryPlus() là thành viên của lớp trừu tượng TagWithText (một lớp cha của Title):

operator fun String.unaryPlus() {
    children.add(TextElement(this))
}

Vì vậy, cái mà tiền tố + làm ở đây là bọc một chuỗi vào một thể hiện của TextElement và thêm nó vào bộ sưu tập children, để nó trở thành một phần đúng của cây thẻ.
Tất cả điều này được định nghĩa trong một gói com.example.html được nhập vào đầu ví dụ builder ở trên. Trong phần cuối cùng, bạn có thể đọc toàn bộ định nghĩa của gói này.

2. Kiểm soát phạm vi: @DslMarker

Trong việc sử dụng DSL, có thể bạn đã gặp vấn đề rằng quá nhiều hàm có thể được gọi trong ngữ cảnh. Bạn có thể gọi các phương thức của mọi người nhận được ngầm định trong một hàm lambda và do đó có kết quả không đồng nhất, như thẻ head bên trong một head khác:

html {
    head {
        head {} // should be forbidden
    }
    // ...
}

Trong ví dụ này, chỉ có thành viên của người nhận được ngầm định gần nhất this@head phải được sử dụng; head() là thành viên của người nhận được bên ngoài this@html, vì vậy nó phải là bất hợp lệ khi gọi nó.
Để giải quyết vấn đề này, có một cơ chế đặc biệt để kiểm soát phạm vi của người nhận.
Để làm cho trình biên dịch bắt đầu kiểm soát các phạm vi, bạn chỉ cần đánh dấu các kiểu của tất cả người nhận được sử dụng trong DSL với cùng một chú thích đánh dấu. Ví dụ, đối với HTML Builders, bạn khai báo một chú thích @HTMLTagMarker:

@DslMarker
annotation class HtmlTagMarker

Một lớp chú thích được gọi là một DSL marker nếu nó được đánh dấu bằng chú thích @DslMarker.
Trong DSL của chúng tôi, tất cả các lớp thẻ đều mở rộng từ superclass Tag. Điều này đủ để đánh dấu chỉ superclass với @HtmlTagMarker và sau đó trình biên dịch Kotlin sẽ xem xét tất cả các lớp được kế thừa như được đánh dấu:

@HtmlTagMarker
abstract class Tag(val name: String) { ... }

Bạn không cần phải đánh dấu các lớp HTML hoặc Head với @HtmlTagMarker vì superclass của chúng đã được đánh dấu:
class HTML() : Tag("html") { ... }class Head() : Tag("head") { ... }
Sau khi bạn đã thêm chú thích này, trình biên dịch Kotlin biết được người nhận ngầm định nào thuộc cùng DSL và chỉ cho phép gọi các thành viên của người nhận gần nhất:

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

Lưu ý rằng vẫn có thể gọi các thành viên của người nhận bên ngoài, nhưng để làm điều đó, bạn phải chỉ định rõ người nhận này:

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

3. Định nghĩa đầy đủ của gói com.example.html

Đây là cách gói com.example.html được định nghĩa (chỉ có các phần được sử dụng trong ví dụ ở trên). Nó xây dựng một cây HTML. Nó sử dụng nhiều các hàm mở rộnghàm lambda với người nhận.

package com.example.html

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

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

@DslMarker
annotation class HtmlTagMarker

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

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

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

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

class Head : TagWithText("head") {
    fun title(init: Title.() -> Unit) = initTag(Title(), init)
}

class Title : TagWithText("title")

abstract class BodyTag(name: String) : TagWithText(name) {
    fun b(init: B.() -> Unit) = initTag(B(), init)
    fun p(init: P.() -> Unit) = initTag(P(), init)
    fun h1(init: H1.() -> Unit) = initTag(H1(), init)
    fun a(href: String, init: A.() -> Unit) {
        val a = initTag(A(), init)
        a.href = href
    }
}

class Body : BodyTag("body")
class B : BodyTag("b")
class P : BodyTag("p")
class H1 : BodyTag("h1")

class A : BodyTag("a") {
    var href: String
        get() = attributes["href"]!!
        set(value) {
            attributes["href"] = value
        }
}

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

Cảm ơn bạn đã đồng hành cùng chúng tôi trên Cafedev trong hành trình khám phá Kotlin với Type-safe builders. Hy vọng rằng thông qua bài viết này, bạn đã có cái nhìn sâu sắc về cách sử dụng builders để tạo ra mã nguồn an toàn, linh hoạt và dễ đọc. Đừng quên theo dõi Cafedev để cập nhật thêm nhiều thông tin thú vị về lập trình và công nghệ. Chúc bạn mọi điều tốt lành và hẹn gặp lại tại Cafedev!

Các nguồn kiến thức MIỄN PHÍ VÔ GIÁ từ cafedev tại đây

Nếu bạn thấy hay và hữu ích, bạn có thể tham gia các kênh sau của Cafedev để nhận được nhiều hơn nữa:

Chào thân ái và quyết thắng!

Đăng ký kênh youtube để ủng hộ Cafedev nha các bạn, Thanks you!