Ktor の SessionStorage として Redis を使用する

趣味で開発したとある Web サービスに [Ktor](http://ktor.io/) を使用してみました。

その際にセッションの保存先として [Redis](https://redis.io/) を使用したかったのですが、 Ktor でセッションの保存先として Redis を利用するための out-of-the-box な方法がなかった[^1]ため、備忘録としてまとめておきます。

# Sessions feature
Ktor でセッション機能を実装するには、 [Sessions Feature](http://ktor.io/features/sessions.html) をインストールします。

```kotlin
application.install(Sessions) {
    cookie<MySession>("SESSION")
}
```

これで `MySession` オブジェクトを異なるリクエストでも使用できるようになります。
この場合は `SESSION` というキー名で Cookie に保存されます。
セッションの保持を Header で行うようにもできます。詳細は公式の [Handle Conversations with Sessions](http://ktor.io/features/sessions.html) をご参照ください。

しかし、これだけでは `MySession` オブジェクトをシリアライズしたものがそのまま Cookie に保存されてしまいます。
セッションを使用する動機として、クライアントに見られたくない情報をリクエストを跨いで使用したいというのがあると思います。これを実現するために `SessionStorage` インタフェースがあります。

# SessionStorage

サーバサイドセッションを実現するためには、サーバサイドにセッションの情報を保存し、クライアントにはそれを参照するためのセッションIDのみを渡さなければなりません。

そこで、 *どのようにサーバサイドにセッションの情報を保存するか* を定義（実装）するのが SessionStorage の役割です。
SessionStorage は先程の `cookie`メソッド（または`header`メソッド）の第二引数に指定します。

```kotlin
application.install(Sessions) {
    cookie<MySession>("SESSION", SessionStorageMemory())
}
```

`SessionStorageMemory` クラスはセッション情報をメモリに保存する SessionStorage の実装クラスです。
`io.ktor.sessions` パッケージに定義されています。

```kotlin
package io.ktor.sessions

import io.ktor.cio.*
import java.util.concurrent.*

class SessionStorageMemory : SessionStorage {
    private val sessions = ConcurrentHashMap<String, ByteArray>()

    override suspend fun write(id: String, provider: suspend (WriteChannel) -> Unit) {
        val writeChannel = ByteBufferWriteChannel()
        provider(writeChannel)
        sessions[id] = writeChannel.toByteArray()
    }

    override suspend fun <R> read(id: String, consumer: suspend (ReadChannel) -> R): R {
        return sessions[id]?.let { bytes -> consumer(bytes.toReadChannel()) } ?: throw NoSuchElementException("Session $id not found")
    }

    override suspend fun invalidate(id: String) {
        sessions.remove(id)
    }
}
```

実装しなければならないメソッドは `write`、 `read`、 `invalidate` の3つだけです。
それぞれそのまま「書き込み」「読み込み」「削除」に該当します。

`write` メソッドにはセッションIDとして `id` と、引数に渡された WriteChannel にセッションオブジェクト（この場合 MySession）をシリアライズした文字列を書き込む suspend な関数オブジェクトとして `provider` が渡されます。
少しややこしいですが、 provider を WriteChannel を引数に指定して実行し、その後で WriteChannel に書き込まれた値を取り出すことで、セッションとして保存すべき値が取得できます。
`SessionStorageMemory` ではそうして取得した値を `id` をキーとして `ConcurrentHashMap` に保存しています。

`read` メソッドにはセッションIDとして `id` と、引数に渡された ReadChannel からシリアライズされたセッションオブジェクトを読み出す suspend な関数オブジェクトとして `consumer` が渡されます。 
こちらもややこしいですが、保存されているセッションオブジェクトのデータを読み出せる ReadChannel を consumer の引数に渡して consumer を実行することによって、セッション情報　を読み出させることができます。

`invalidate` メソッドはシンプルで、引数に渡されたセッションID (`id`) に対応する保存済みの値を削除しています。

# Redis を読み書きする SessionStorage

セッションの保存先を Redis に変更する場合も、やることは `SessionStorageMemory` クラスとほとんど変わりません。 `ConcurrentHashMap` の代わりに Redis にデータを保持するようにするだけです。

```kotlin
const val KEY_SUFFIX = "ss:"
const val EXPIRES_DURATION = 60 * 60 * 24 * 30 // 30 days

class SessionStorageRedis(private val jedisPool: JedisPool) : SessionStorage {

    suspend override fun write(id: String, provider: suspend (WriteChannel) -> Unit) {
        val key = KEY_SUFFIX + id
        val writeChannel = ByteBufferWriteChannel()
        provider(writeChannel)
        jedisPool.resource.use { jedis ->
            jedis[key] = writeChannel.toString(Charsets.UTF_8)
            jedis.expire(key, EXPIRES_DURATION)
        }
    }
    
    suspend override fun <R> read(id: String, consumer: suspend (ReadChannel) -> R): R {
        val key = KEY_SUFFIX + id
        jedisPool.resource.use { jedis ->
            return jedis[key]?.let { str ->
                jedis.expire(key, EXPIRES_DURATION)
                consumer(str.toByteArray(Charsets.UTF_8).toReadChannel())
            } ?: throw NoSuchElementException("Session $id not found")
        }
    }

    suspend override fun invalidate(id: String) {
        val key = KEY_SUFFIX + id
        jedisPool.resource.use { jedis ->
            jedis.del(key)
        }
    }
}
```

今回は Redis クライアントとして [Jedis](https://github.com/xetorthio/jedis) を使用し、コネクションプールとして Jedis に含まれる JedisPool を使用しています。

`write` メソッドでは `SessionStorageMemory` クラスと同様に WriteChannel からシリアライズされたセッションオブジェクトを取得し、それを Redis に保存しています。
セッションという特性上、削除しなければ永遠に増加してしまうため、適当な有効期限を Redis に指定しておきます。

`read` メソッドでは Redis から `id` に対応する値を取得し、 ReadChannel に変換してから `consumer` に渡して実行しています。 `id` に対応する値が見つからなかった場合は `NoSuchElementException` 例外を投げるようにします。
セッションの有効期限を読み込み時にも延長するため、ここでも Redis に対して有効期限を設定し直します。

`invalidate` メソッドは単に Redis から `id` に該当する値を削除するだけです。

# まとめ
Ktor は 100% Kotlin で記述された軽量 Web フレームワークですが、まだまだ out-of-the-box で使える機能は限られています。
サーバサイドセッションを実装するには、大抵の場合独自に SessionStorage インタフェースを実装したクラスを作成する必要がありそうです。

オブジェクトのシリアライズとデシリアライズは Ktor の Sessions feature に委ねられているため、保存するオブジェクトの構造を変更した場合などにマイグレーションする機能はまだないように思います。

[^1]: Kotlin Slack で JB の Ilya Ryzhenkov さんに[聞きました](https://kotlinlang.slack.com/archives/C0A974TJ9/p1513881046000058)