Help us understand the problem. What is going on with this article?

KtorのSessionStorageとしてExposedを使う

More than 1 year has passed since last update.

@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段階で、まだまだ未熟なところも多いですが、十分使えるしこれからの発展に期待です。

Why do not you register as a user and use Qiita more conveniently?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away