趣味で開発したとある 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)
}
}
実装しなければならないメソッドは 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 にデータを保持するようにするだけです。
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 に委ねられているため、保存するオブジェクトの構造を変更した場合などにマイグレーションする機能はまだないように思います。