ktorでオフィシャルサポートがないテンプレートエンジンを使う


Ubieのルーカスです。この記事はなんとQiita初投稿!日本に長くいるドイツ人ですが、技術は全世界共通。最近はKotlinをよく仕事と趣味で使います。
では、早速本題に入りましょう。

ktorのテンプレートエンジン

「ktorでウェブサーバーを作りたい!HTMLを返したい!」というのは一般的な使い方ですね。それでHTMLをどうやって書きますか?
html-dsl (.respondHtml)を使うのは一般的ですが、テンプレートエンジンに慣れていて、使いたい場合があると思います。外部テンプレートエンジンだとどういう選択肢があるかを調べる

  • Freemarker
  • Mustache
  • Thymeleaf
  • Velocity

が正式サポートされています。

「ちょっと待って!自分が大好きな○○○テンプレートエンジンがない!」と思った時はどうすればいいですか?
自分で対応する方法はここで紹介したいと思います。

ktorのオブジェクトTransform

最終的にktorのこの機能に一番頼ります。

ktorではRouteの中のcall.responseでなんでも(Any)を返すことができます。そのオブジェクトがktorの内部的なパイプラインに乗ります。自分で定義したオブジェクトをパイプラインにも乗せますが、特別対応しないと当然500エラーが出ます。

パイプラインのTransform(変換)ステップを追加し、自分で定義したオブジェクトをktorがわかるようなオブジェクトに変換してあげる必要があります。その変換ロジックはテンプレート + ビューモデルHTMLであれば、Routeからテンプレート+ビューモデルを返すことだけでHTMLに変わります。実質的にテンプレートエンジンを使えるようになります。

ktorのFeatureのインターフェースでその変換ステップを定義できます:

// テンプレートを表すオブジェクト、ビューモデルもついてます。ビューモデルがないとあまり意味がありません。
class CustomObject(
    val template: String,
    val viewModel: Any
)

// ktorの「feature」を定義するクラス、key + installが必要
class TransformingFeature {
    companion object Feature : ApplicationFeature<ApplicationCallPipeline, Any, TransformingFeature> {
        // 識別できるキー(ktor内部用)
        override val key = AttributeKey<TransformingFeature>("custom")
        override fun install(pipeline: ApplicationCallPipeline, configure: Any.() -> Unit): TransformingFeature {
            // 変換ステップを定義
            pipeline.sendPipeline.intercept(ApplicationSendPipeline.Transform) { value ->
                // 他のレンダリング方法もあるので、カスタムオブジェクトのみを反応する
                if (value is CustomObject) {
                    val output = "${value}をここで変換します!"
                    // TextContentはコンテントヘッダつき文字列である。それならktorがわかるフォーマット
                    proceedWith(TextContent(output, ContentType.Text.Html))
                }
            }
            return TransformingFeature()
        }
    }
}

応用編

それではktorが対応してないテンプレートエンジンでやってみましょう。
私が作ったテンプレートエンジンKoreanderなら、絶対にktorサポートがないはずです。

テンプレートエンジン自体はpug, jade4j, slim, haml のような記述でHTMLを生み出します。

まずはテンプレートを表すオブジェクトを定義します:

class KoreanderContent(
    val resource: String,
    val model: Any,
    val type: KType
)

テンプレートはjavaのresourceから取得する想定でresourceの場所をStringとして定義します。
ビューモデルがないとあまり意味ないのでAnyでmodelを定義します。(Genericsはktorのfeature背景で使いづらい)
Koreanderは型が必要なテンプレートエンジンなので、Typeも提供します。

次は変換ステップの調整:

    // 省略
    if (value is KoreanderContent) {
        // resourceを取得
        val template = TransformingFeature::class.java.getResource(value.resource)
        // 型とテンプレートで中間オブジェクトを作る(型チェックが行われるなど)
        val compiled = koreander.compile(template, value.type)
        // 渡されているビューモデルでレンダリング
        val output = koreander.unsafeRender(compiled, value.model)
        // HTMLテキストとして処理を続く
        proceedWith(TextContent(output, ContentType.Text.Html))
    }
    // 省略

Genericsは使わなかったので少し複雑になりました。Koreanderの使い方なのでこの記事で細かく説明しません。KoreanderのReadmeで通常の使い方が書いてあります。

最後にアプリケーションに変換ステップ(というかktorのfeature)を登録する必要があります。

fun main() {
    val server = embeddedServer(Netty, port = 8080) {
        install(TransformingFeature) // ← これ
        routing {
            get("/") {
                // ここでcall.response(KoreanderContent(...))すれば良い
            }
        }
    }
    server.start(wait = true)
}

上記だとKoreanderContentオブジェクトをresponseに渡せば、テンプレートがレンダリングされますが、ktorっぽいDSLで書きたいですね。拡張関数の定義でktorのdslを拡張しましょう:

suspend inline fun <reified T: Any> ApplicationCall.respondKor(
    template: String,
    model: T
) = respond(KoreanderContent(template, model, Koreander.typeOf(model)))

これでresponseKorは型まで調べてくれて、代行でresponseをコールするロジックになります。ktorっぽくcall.respondKor(...)で書けるようになります。

アプリケーションを修正して、挨拶するアプリケーションを作ってみました。挨拶の相手はGETパラメータwhoで渡します。

data class ViewModel(val hello: String)

// 省略
        routing {
            get("/") {
                val who = call.request.queryParameters["who"]
                val viewModel = ViewModel(who ?: "World")
                call.respondKor("/index.kor", viewModel)
            }
        }
// 省略

resourcesにindex.korを置く必要もあります。下記のものを使いましょう。

!!! 5
%html
  %head
    %link href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css" rel="stylesheet"
  %body
    %h1 Hello ${hello}!

curlで確認:

$ curl -i "localhost:8080/?who=Ubie"
HTTP/1.1 200 OK
Content-Length: 198
Content-Type: text/html

<!DOCTYPE html>
<html>
  <head>
    <link href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css" rel="stylesheet">
  </head>
  <body>
    <h1>Hello Ubie!</h1>
  </body>
</html>

Browserで確認:

まとめ

ktorのパイプラインでは自分が定義したオブジェクトを変換することによって、テンプレートエンジンを対応できました。
ここでは紹介していないけど、そのロジックをライブラリー化して、mavenパッケージとかでも提供できます。
例:Thymeleaf

ktorのパイプラインの様々な可能性も実感できました。テンプレートエンジンの対応以外にも便利そうです。