Help us understand the problem. What is going on with this article?

Kotlinでネスト構造のDSLを実装する方法

ネスト構造のDSLとは

「ネスト構造のDSL」は正式名称ではなく、便宜的にここだけで名付けているものです。
ここでは、以下のような入れ子で構造化されたDSLコードを指すものとします。

httpPost {
    body("application/json") {
        TODO()
    }
}

上のHttpリクエストのライブラリの他、HTML構造の出力や(kotlinx.html)、WebフレームワークのRouting(KtorのRouting機能)などにも、このパターンは利用されています。

この記事ではこのパターンを実装するための方法について紹介します。

いきなり最終的なコード

現代人は忙しいので、結論のコードをまず先にお見せします。

仮に最終的に以下のようなDSLを利用したいとします。

//DSL code
httpPost {
    host = "localhost:8080"
    path = "/post"
    body {
        content = "hello, world"
    }
}

その場合に必要な定義はこちらです。

class HttpRequestContext(var host: String = "", var path: String = "") {
    fun body(init: HttpRequestBodyContext.() -> Unit) {
        val context = HttpRequestBodyContext()
        context.init()
    }
}

class HttpRequestBodyContext(var content: String = "")

//このメソッドがDSLとして最初に呼び出される
fun httpPost(init: HttpRequestContext.() -> Unit) {
    val context = HttpRequestContext()
    context.init()
}

ここで用いられている言語仕様として、普段馴染みが薄いのではないかと思われるものは以下の2つです。
1. trailing lambda
2. lambda with receiver
まず、これらについて解説します。
既にご存知の方は読み飛ばしてください。(現代人は忙しいの(ry)

Kotlin言語仕様説明

1. trailing lambda

公式ドキュメントの説明はこちらです。
以下のようにkotlinではメソッドの最終引数が関数の場合は、その部分を引数括弧の外のブロックとして記述することができます。(イケてますね)

//最終引数の型が関数(func1, func2の型)
fun test1(func1: () -> Unit) { TODO() }
fun test2(num: Int, func2: () -> Unit) { TODO() }

//以下のようにlambda関数部分はブロックとして呼び出せる
test1 {
    //do something...
}
test2(1) {
    //do something...
}

2. lambda with receiver

公式ドキュメントの説明はこちらです。
日本語でいうとレシーバ付きラムダと呼ばれるやつです。
わかりやすくいうと、拡張関数を引数として渡すパターンです。

Kotlinでは以下のように書くと、レシーバ付きラムダと呼ばれる拡張関数lambdaになります。

//`Int.() -> Unit` という書き方でInt型に対する拡張関数のlambdaを定義
val test1: Int.() -> Unit = {
    println(this * 2)
}

1.test1() //2

段階的に実装しながら理解しようの巻

Kotlinの文法の説明も終わったので実装について段階的に説明していきます。

まずは前述の1. trailing lambdaを使えば、ネスト構造自体は以下のように簡単に作れます。

fun httpPost(func: () -> Unit) { TODO() }
//この関数は本当は不要なので後で削除されます
fun body(path: String, func: () -> Unit) { TODO() }

//DSL code
httpPost {
    body("application/json") {
        TODO()
    }
}

ところがこれだけだと十分ではありません。
なぜならネストした構造内で状態を保持したり、メソッドを利用したりしたいためです。

httpPost {
    //httpPostのブロック内だけで状態を保持したい!!
    host = "localhost:8080"
    path = "/post"

    //httpPostのブロック内だけで使えるbodyメソッドが欲しい!!
    body {
        TODO()
    }
}

そのため、前述の2. lambda with receiverを使います。
ポイントは以下2点です。

  • mutableな変数を持つクラス(HttpRequestContext)を定義
  • そのクラスをDSL block内でのみ利用可能にするため、lambda with receiver機能を使う

具体的には以下のようなコードを書きます。

//DSLブロック内でのみ利用可能なContextクラス(変数がvarなのがミソ)
class HttpRequestContext(var host: String = "", var path: String = "")

//lambda with receiverを引数として渡す
fun httpPost(init: HttpRequestContext.() -> Unit) {
    val context = HttpRequestContext()
    context.init()
}

//DSL code
httpPost { // this: HttpRequestContext

    //this: HttpRequestContextとなっており、このブロック内でのみ変数にアクセス可能
    //HttpRequestContext.hostがmutableな変数(var)なので値を設定することができる
    host = "localhost:8080"
    path = "/post"

//  説明をシンプルにするため一時的にコメントアウト
//    body {
//        content = "hello, world"
//    }
}

コメントアウトしたbodyメソッドをHttpRequestContextに定義してやり、以上の要領でbodyブロック内のContextクラスも同じように作ってやれば、記事の冒頭で説明した最終的なコードができあがります。

class HttpRequestContext(var host: String = "", var path: String = "") {
    //bodyブロックを作るためのメソッド
    fun body(init: HttpRequestBodyContext.() -> Unit) {
        val context = HttpRequestBodyContext()
        context.init()
    }
}

//bodyブロック内で利用するためのContextクラス(変数はvar)
class HttpRequestBodyContext(var content: String = "")

fun httpPost(init: HttpRequestContext.() -> Unit) {
    val context = HttpRequestContext()
    context.init()
}

//DSL code
httpPost { //this: HttpRequestContext
    host = "localhost:8080"
    path = "/post"

    body { //this: HttpRequestBodyContext
        content = "hello, world"
    }
}

さらなる高みを目指して… @DslMarkerについて

ここまででDSLとして動作はするのですが、以下のようなコードで書けてしまいます。(コンパイルが通る)

//DSL code
httpPost {
    host = "localhost:8080"
    path = "/post"
    body {
        //CAUTION: bodyブロック内なのに、HttpRequestContextの変数にアクセスできてしまっている!!
        host = "jp.doyaaaaaken.org"
        content = "hello, world"
    }
}

HttpリクエストのDSLであれば大した問題にならないのですが、HTML構造を生成するためのDSLのように同じ変数名が出てくる場合などには不便があるかと思います。

div {
    id = "div-attr-id1"
    span {
        //ERROR: 変数idがspanのidなのかdivのidなのか分からない!!!
        id = "span-attr-id1"
    }
}

こういった問題は標準ライブラリに用意されている@DSLMarkerというアノテーションを使うと解決できます。
詳細は割愛しますが、以下のように@DslMarkerを付与したAnnotationを定義し、それをContextクラスに付与することで期待どおりの変数スコープになります。

@DslMarker
annotation class HttpRequestDslMarker

@HttpRequestDslMarker
class HttpRequestContext(//...

@HttpRequestDslMarker
class HttpRequestBodyContext(//.....

@DslMarkerアノテーションについてはこちらのQiita記事によくまとまっています

終わりに

いかがだったでしょうか?
DSLでオシャレ実装を書くの楽しいので、是非明日からプロダクションコードにコッソリ入れてみてくださいw
ちなみに私が作っているkotlin-csvというOSSもDSL使っているので、ご興味ある方はコードを参考にしてみてください。

その他参考URL

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした