はじめに
ゲーム・アニメ事業部でスマートフォンゲームのサーバサイド開発をしている山根です。
現在の担当しているタイトルでは、Kotlinを使ってサーバサイドを開発しており、
フレームワークはKtor、ORMはExposedを使っています。
今回は、ORMであるExposedを使う上で工夫した点をお話しさせていただきます。
Exposedについて
Exposedは、プログラミング言語であるKotlinを開発したJetBrainsが開発した純Kotlin製のORMです。
また、今回話には出ませんが、KtorもJetBrainsが開発した純Kotlin製のフレームワークになります。
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によって接続先を切り替えることができる関数になります。
このままでも、接続先の切り替えはできますが、さらに工夫していきます。
まず、envKeyとDatabaseを管理するクラス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()
}
}
}
DatabaseConnectionsのgetConnectionは、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を使った接続先の切り替えができました。
また、このように複数のDatabaseConnectionをDatabaseConnectionsのようなクラスに置き、依存注入することで、
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を意識しない記述をすることができます。