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

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

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

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

- Freemarker
- Mustache
- Thymeleaf
- Velocity

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

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

## ktorのオブジェクトTransform

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

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

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

ktorの[Feature](https://ktor.io/servers/features.html)のインターフェースでその変換ステップを定義できます:

```kotlin
// テンプレートを表すオブジェクト、ビューモデルもついてます。ビューモデルがないとあまり意味がありません。
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](https://github.com/lukasjapan/koreander)なら、絶対にktorサポートがないはずです。

テンプレートエンジン自体は[pug](https://github.com/pugjs/pug), [jade4j](https://github.com/neuland/jade4j), [slim](http://slim-lang.com/), [haml](http://haml.info/) のような記述でHTMLを生み出します。

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

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

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

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


```kotlin
    // 省略
    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](https://github.com/lukasjapan/koreander/blob/master/README.md)で通常の使い方が書いてあります。


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

```kotlin
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を拡張しましょう：

```kotlin
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`で渡します。

```kotlin
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`を置く必要もあります。下記のものを使いましょう。

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


curlで確認：

```bash
$ 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で確認：

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/536104/d6893ce2-a560-ac83-1740-ffd1d868c70c.png)

## まとめ

ktorのパイプラインでは自分が定義したオブジェクトを変換することによって、テンプレートエンジンを対応できました。
ここでは紹介していないけど、そのロジックをライブラリー化して、mavenパッケージとかでも提供できます。
例：[Thymeleaf](https://github.com/ktorio/ktor/tree/master/ktor-features/ktor-thymeleaf)

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