FURYU Tech Blog - フリュー株式会社

フリュー株式会社の開発者が技術情報を発信するブログです。

Kotlin製 ORM Exposedをカスタマイズする

はじめに

ゲーム・アニメ事業部でスマートフォンゲームのサーバサイド開発をしている山根です。
現在の担当しているタイトルでは、Kotlinを使ってサーバサイドを開発しており、 フレームワークはKtor、ORMはExposedを使っています。
今回は、ORMであるExposedを使う上で工夫した点をお話しさせていただきます。

Exposedについて

Exposedは、プログラミング言語であるKotlinを開発したJetBrainsが開発した純Kotlin製のORMです。
また、今回話には出ませんが、KtorもJetBrainsが開発した純Kotlin製のフレームワークになります。 github.com

github.com

工夫した点

DBの接続先の切り替え

処理中にDBの接続先を切り替える必要があり、実装を検討してみました。
まず、Exposedではtransactionメソッドを使うことでDBへ接続してSQLを実行することができます。

transaction {
    // DSL/DAO operations go here
}

ですが、そのままだとデフォルトのDatabaseへ接続してしまいます。
Exposedのドキュメントによると、transaction引数に接続先を表すDatabaseのインスタンスを指定することができるので、 その切り替えを実現することが出来ると記載されています。

val newDatabase: Database = Database.connect("url", "driver", "user", "password")
transaction(db = newDatabase) { 
    // DSL/DAO operations go here
}

github.com 上記のコードを元に、複数の接続先の切り替えができるようにしてみます。

// 接続先をenvKeyを使って切り替える
fun transactionByEnvKey(envKey: String, block: () -> Unit): {
    val targetDB = when (envKey) {
        "Env1" -> Database.connect("env1-url", "env1-driver", "env1-user", "env1-password"),
        "Env2" -> Database.connect("env2-url", "env2-driver", "env2-user", "env2-password"),
            ....
        else -> Database.connect("else-url", "else-driver", "else-user", "else-password")
    }
    transaction(db = targetDB) {
        block()
    }
}
transactionByEnvKey("Env1") {
    // DSL/DAO operations go here
}

transactionByEnvKeyは、指定のenvKeyによって接続先を切り替えることができる関数になります。
このままでも、接続先の切り替えはできますが、さらに工夫していきます。
まず、envKeyDatabaseを管理するクラスDatabaseConnectionを用意し、 さらに、DatabaseConnectionを複数管理するためにDatabaseConnectionsを用意します。

data class DatabaseConnections(private val databaseConnections: List<DatabaseConnection>) {
    fun getConnection(envKey: String) =
        databaseConnections.first { it.envKey == envKey }
}

class DatabaseConnection(val envKey: String, override val database: Database): DatabaseConnectionManager

interface DatabaseConnectionManager {
    val database: Database

    fun <T> withConnection(block: () -> T): T  {
        return transaction(db = database){
            block()
        }
    }
}

DatabaseConnectionsgetConnectionは、DatabaseConnectionの配列からenvKeyを指定することで、 指定のDatabaseConnectionを取得することができます。
これを使い、接続先の切り替えを行ってみます。

val env1DatabaseConnection = DatabaseConnection("Env1", Database.connect("env1-url", "env1-driver", "env1-user", "env1-password"))
val env2DatabaseConnection = DatabaseConnection("Env2", Database.connect("env2-url", "env2-driver", "env2-user", "env2-password"))
    ....
val elseDatabaseConnection = DatabaseConnection("else", Database.connect("else-url", "else-driver", "else-user", "else-password"))

val allConnections: DatabaseConnections = DatabaseConnections(listOf(env1DatabaseConnection, env2DatabaseConnection, ...., elseDatabaseConnection))

allConnections.getConnection("Env1").withConnection {
    // DSL/DAO operations go here
}

上記のようにすることで、DatabaseConnectionsを使った接続先の切り替えができました。
また、このように複数のDatabaseConnectionDatabaseConnectionsのようなクラスに置き、依存注入することで、 DatabaseConnectionのインスタンスの生成頻度を抑えつつ、envKeyを指定するだけで切り替えることができます。

created_at, updated_atをinsert, update時に共通で入れる方法

一般的にテーブル定義には、created_at, updated_atカラムを用意していることが多いと思いますが、 このカラムをinsert, updateの処理で、毎回記述するのはちょっとなと思い、 created_at, updated_atを意識しないinsert, updateの処理を用意してみました。

コード例
import org.jetbrains.exposed.dao.id.IntIdTable
import org.jetbrains.exposed.sql.Column
import org.jetbrains.exposed.sql.Table
import org.jetbrains.exposed.sql.javatime.datetime
import org.jetbrains.exposed.sql.statements.InsertStatement
import org.jetbrains.exposed.sql.transactions.TransactionManager
import java.time.LocalDateTime

interface BaseModel {
    val createdAt: LocalDateTime
    val updatedAt: LocalDateTime
}

open class BaseEntity<M: BaseModel>: IntIdTable() {
    val createdAt: Column<LocalDateTime> by lazy {
        datetime("created_at")
    }

    val updatedAt: Column<LocalDateTime> by lazy {
        datetime("updated_at")
    }
}

interface BaseRepository<M : BaseModel, Entity : BaseEntity<M>> {
    val entity: Entity

    fun <T : Table> T.insert(body: T.(InsertStatement<Number>) -> Unit) {
        val now = LocalDateTime.now()
        InsertStatement<Number>(this).apply {
            this[entity.createdAt] = now
            this[entity.updatedAt] = now
            body(this)
            execute(TransactionManager.current())
        }
    }
    
    fun <T : Table> T.update(
        where: Op<Boolean>? = null,
        limit: Int? = null,
        body: T.(UpdateStatement) -> Unit,
    ) {
        val now = LocalDateTime.now()
        UpdateStatement(this, limit, where).apply {
            this[entity.updatedAt] = now
            body(this)
            execute(TransactionManager.current())
        }
    }
}

上記のように用意すれば、insert, updateの処理の共通処理を実装することができ、以下のような感じで実装できます。

data class User(
    override val id: EntityID<Int>,
    val name: String,
    val age: Int,
    override val createdAt: LocalDateTime,
    override val updatedAt: LocalDateTime
): BaseModel

object Users : BaseEntity<User>() {
    val name = varchar("name", 64)
    val age = integer("age")
}

class UsersRepository: BaseRepository<User, Users> {
    override val entity = Users

    fun insert(name: String, age: Int) {
        entity.insert {
            it[this.name] = name
            it[this.age] = age
        }
    }

    fun update(age: Int) {
        entity.update(entity.name.eq("test")) {
            it[this.age] = age
        }
    }
}

おまけ

Exposedでは、SQLのBulkInsertに変換するためのbatchInsertという関数が用意されています。 先ほどのコード例に寄せたサンプルコードが以下になります。

fun <T : Table, E> T.batchInsert(
    data: Iterable<E>,
    shouldReturnGeneratedValues: Boolean,
    body: BatchInsertStatement.(E) -> Unit,
) {
    val now = LocalDateTime.now()
    val customBody: BatchInsertStatement.(E) -> Unit = {
        this.apply {
            this[entity.createdAt] = now
            this[entity.updatedAt] = now
            body(it)
        }
    }

    batchInsert(
        data,
        ignore = false,
        shouldReturnGeneratedValues = shouldReturnGeneratedValues,
        customBody
    )
}

これにより、BulkInsertでもcreated_at, updated_atを意識しない記述をすることができます。