@kawmra 氏の記事、 Ktor の SessionStorage として Redis を使用する を参考に、Exposedを使ったSessionStorageを作ってみました。
Session Sorageに関しては、上記の記事をご覧ください。
Exposedは、Jetbrains公式の、KotlinによるNoSQLフレームワーク(ライブラリ)です。
内部的には、JDBCを使って一般的なRDBMSに接続しています。
ここで作るコードは一部SQLite向けなコードがありますが、他の環境でも動かす上では問題ありません。
ExposedのDSLで保存用テーブルを定義する
通常、Exposedにおいて、DBのテーブルを定義するときはObject Declarationを用います。
例:
object UserTable : IntIdTable("User") {
var uuid = blob("uuid").uniqueIndex()
var name = text("name").uniqueIndex()
var email = text("email").uniqueIndex()
var password = blob("password")
}
ですが、コレを使うと1つのTableに決まってしまいます。
SessionStorageの場合、複数種類のSessionを保持することも当然あるわけで、そのためにはこのように具体的な1つのテーブルを定義してしまうとあまりよろしくないです。(DBにSessionとともにSessionの種類を保存してしまう、ということも出来なくはないです。)
そこで、こんな感じの継承可能なクラスを作りました。
open class SessionTable(name: String) : Table(name) {
var sessionId = text("sessionId").uniqueIndex().primaryKey()
var value = text("value")
var expireTime = long("expire")
}
そして、SessionStorage用のテーブルの定義はこちら
object LoginSessionTable : SessionTable("LoginSession")
そのままコンストラクタにテーブルの名前を突っ込んで、シングルトン化するだけですね
書いてて思いましたが、Object Declaration使わなくても普通にval定義でも良かったかも・・・?
そこら辺どうなんでしょうね
SessionStorageを実装する
とりあえずコードそのまま
import io.ktor.cio.*
import io.ktor.sessions.SessionStorage
import org.jetbrains.exposed.dao.EntityID
import org.jetbrains.exposed.sql.deleteWhere
import org.jetbrains.exposed.sql.insert
import org.jetbrains.exposed.sql.select
import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.update
import toliner.ratya.backend.model.SessionTable
import java.sql.Connection
import java.time.Instant
const val EXPIRE_DURATION = 60 * 60 * 24 * 7 * 1000L
val expire: Long
get() = Instant.now().toEpochMilli() + EXPIRE_DURATION
class NoSuchSessionException(id: String, e: Exception) : Exception("SessionId: $id is not found.", e)
class SessionStorageExposed(private val table: SessionTable) : SessionStorage {
suspend override fun write(id: String, provider: suspend (WriteChannel) -> Unit) {
ByteBufferWriteChannel().also { channel: ByteBufferWriteChannel ->
provider(channel)
transaction(Connection.TRANSACTION_SERIALIZABLE, 3) {
table.insert {
it[sessionId] = id
it[value] = channel.toString(Charsets.UTF_8)
it[expireTime] = expire
}
}
}
}
suspend override fun <R> read(id: String, consumer: suspend (ReadChannel) -> R): R {
return consumer(transaction(Connection.TRANSACTION_SERIALIZABLE, 3) {
try {
table.select {
table.sessionId eq id
}.mapNotNull {
if (Instant.now().toEpochMilli() > it[table.expireTime]) {
table.deleteWhere { table.sessionId eq it[table.sessionId] }
null
} else {
table.update(
{ table.sessionId eq id }, null,
{ it[table.expireTime] = expire}
)
it
}
}.first()[table.value].toByteArray(Charsets.UTF_8).toReadChannel()
} catch (e: NoSuchElementException) {
throw NoSuchSessionException(id, e)
}
})
}
suspend override fun invalidate(id: String) {
transaction(Connection.TRANSACTION_SERIALIZABLE, 3) {
table.deleteWhere { table.sessionId eq id }
}
}
}
こだわったところは、「極力変数を宣言しない」です。
Javaにはできない、The Kotlinな感じがして良いですよね!(なお可読性)
write()は、provider()でByteBufferWriteChannelに書き込み、DBに対してセッションID&値&有効期限を保存。
read()は、セッションIDが同じものを選び、その中から期限が既に終了しているものを削除、期限内であったものの期限を延長、それを読み込んで返す。
セッションIDはuniqueなので重複はありえないのですが、Exposedのselect{}で返ってくるのがIterationなので、このような形になっています。
ちなみにわざわざNoSuchSessionExceptionなるラッパークラスを作っているのは、Ktorにおいてのエラーハンドリングが基本的に例外のクラス単位だからです。
そのままNoSuchElementの様な汎用的な例外を投げられてしまうと、他の部分との差別化が面倒なのでこのような感じになっています。
KtorのStatusPages Featureで、exception< NoSuchSessionException>{}を使うことでセッションが存在しない時という状況にだけ対応することができます。
まとめ
Full-Kotlin最高。Kotlinマジで神。
KtorもExposedもPre-Release段階で、まだまだ未熟なところも多いですが、十分使えるしこれからの発展に期待です。