Kotlin
Ktor

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

More than 1 year has passed since last update.

趣味で開発したとある Web サービスに Ktor を使用してみました。

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


Sessions feature

Ktor でセッション機能を実装するには、 Sessions Feature をインストールします。

application.install(Sessions) {

cookie<MySession>("SESSION")
}

これで MySession オブジェクトを異なるリクエストでも使用できるようになります。

この場合は SESSION というキー名で Cookie に保存されます。

セッションの保持を Header で行うようにもできます。詳細は公式の Handle Conversations with Sessions をご参照ください。

しかし、これだけでは MySession オブジェクトをシリアライズしたものがそのまま Cookie に保存されてしまいます。

セッションを使用する動機として、クライアントに見られたくない情報をリクエストを跨いで使用したいというのがあると思います。これを実現するために SessionStorage インタフェースがあります。


SessionStorage

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

そこで、 どのようにサーバサイドにセッションの情報を保存するか を定義(実装)するのが SessionStorage の役割です。

SessionStorage は先程の cookieメソッド(またはheaderメソッド)の第二引数に指定します。

application.install(Sessions) {

cookie<MySession>("SESSION", SessionStorageMemory())
}

SessionStorageMemory クラスはセッション情報をメモリに保存する SessionStorage の実装クラスです。

io.ktor.sessions パッケージに定義されています。

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)
}
}

実装しなければならないメソッドは writereadinvalidate の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 にデータを保持するようにするだけです。

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 を使用し、コネクションプールとして 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 さんに聞きました