Share
Embed
Exposed 応用編 〜内部実装 と 魔拡張〜
2022/09/02 に行われた、 Server-Side Kotlin Meetup vol.5 での発表資料です。
〜おしながき〜
Exposed 内部実装編
1. 知っておきたいExposed DAOの挙動と内部実装
Exposed 拡張編
1. もっと型安全なテーブルを。
2. RDBMSの独自データ型に対応する
3. RDBMSの独自関数を自在に呼び出す
4. トランザクションを、もっとおもしろく。
Hideyuki Takeuchi
September 02, 2022
Transcript
41 slides
-
Server-Side Kotlin Meetup vol.5 Exposed 応用編 内部実装 と 魔拡張 〜
細かすぎて伝わらない Exposed 〜 たけうち ひでゆき @chimerast & @yesodco_ceo -
Exposed つらいところ 公式ドキュメントが、貧弱!貧弱ゥ! ソースコード と GitHub Issues と Stack Overflow
を読むと 公式ドキュメントに書いていない機能がたくさんある @chimerast -
軽く自己紹介 たけうち ひでゆき @chimerast & @yesodco_ceo 株式会社イエソド フルスタックエンジニア ( ファンタズマ・クラス
幻 想 級) Kotlin 、Ktor 、Exposed を使い、 企業内の全てのアイデンティティ情報を 管理下に置くための 時系列マスタデータベースを作ってます @chimerast -
O/R マッパー実務使用経歴 ( うろおぼえ ) 自作 O/R マッパー 2004 年
〜 2007 年 iBATIS 2007 年(?) S2Dao 2006 年 〜 2016 年 jOOQ 2013 年 〜 2015 年 Spring Data JPA (Hibernate) 2013 年 〜 2017 年 Slim3 Datastore 2014 年(?) Doma2 2015 年 〜 2018 年 Exposed 20018 年 〜 現在 @chimerast -
おしながき 内部実装編 1. 知っておきたいExposed DAO の挙動と内部実装 拡張編 1. もっと型安全なテーブルを。 2.
RDBMS の独自データ型に対応する 3. RDBMS の独自関数を自在に呼び出す 4. トランザクションを、もっとおもしろく。 @chimerast -
知っておきたい DAO の挙動と内部実装 Exposed DAO は、一般的なO/R マッパーと同様に、 書き込みデータをキャッシュする val movie
= StarWarsFilm.new() movie.name = "The Last Jedi" movie.sequelId = 8 movie.director = "Rian Johnson" 上記のコードで、いつINSERT 文がDB に投げられるかわかりますか? @chimerast -
Entity の書き込みタイミング EntityLifecycleInterceptor クラスを読むと書いてある 1. トランザクションをコミットする直前 override fun beforeCommit(transaction: Transaction)
{ val created = transaction.flushCache() 2. 対象Entity のテーブルのクエリが走る直前などなど override fun beforeExecution(transaction: Transaction, context: StatementContext) { when (val statement = context.statement) { is Query -> transaction.flushEntities(statement) @chimerast -
キャッシュされた Entity の書き込み単位 RDBMS 側が対応していれば、BULK INSERT & BULK UPDATE EntityCache
クラスを読むと書いてある ( 下記はINSERT について) internal fun flushInserts(table: IdTable<*>) { ... val ids = executeAsPartOfEntityLifecycle { table.batchInsert(toFlush) { entry -> for ((c, v) in entry.writeValues) { this[c] = v } } } @chimerast -
合わせると感づくこと その1 読み出しと更新を1 レコード単位でやると物凄く遅くなる val ids = StarWarsFilms.slice(StarWarsFilms.id).selectAll() .map {
it[StarWarsFilms.id] } ids.forEach { id -> val movie = StarWarsFilm.find { StarWarsFilms.id eq id }.forUpdate().single() movie.watched = true } 勝手にBULK INSERT ・BULK UPDATE してくれるので、 パフォーマンスを上げたければ読み書きの順番を考える @chimerast -
合わせると感づくこと その2 大量のレコードのEntity を一気に作って書き込もうとすると OOM で死ぬ バルクで書き込まれるまで、キャッシュをためこむので、、、 TransactionManager.current().flushCache() を呼び出すと、 RDBMS
側に書き込んでキャッシュがリセットされる とはいえ、これもトランザクションがコミットされない限り、 RDBMS 側に責任を押しつけているだけではある @chimerast -
もっと型安全なテーブルを。 object StarWarsFilms : Table() { val id: Column<EntityID<Int>> =
integer("id").autoIncrement() val sequelId: Column<Int> = integer("sequel_id").uniqueIndex() val name: Column<String> = varchar("name", 50) val director: Column<String> = varchar("director", 50) } ID 系が全てInt 型 全部ValueObject で取り出したり、保存したりしたい @chimerast -
こうなってくれるとうれしい data class FilmId(val value: Int) data class SequelId(val value:
Int) data class Name(val value: String) data class Director(val value: String) object StarWarsFilms : Table() { val id: Column<EntityID<FilmId>> = filmId("id").autoIncrement() val sequelId: Column<SequelId> = sequelId("sequel_id").uniqueIndex() val name: Column<Name> = name("name", 50) val director: Column<Director> = director("director", 50) } @chimerast -
Table クラスで使う integer() とか varchar() ってなに val sequelId: Column<Int> =
integer("sequel_id").uniqueIndex() val name: Column<String> = varchar("name", 50) fun integer(name: String): Column<Int> = registerColumn(name, IntegerColumnType()) fun varchar(name: String, length: Int, collate: String? = null): Column<String> = registerColumn(name, VarCharColumnType(length, collate)) Table クラスを読むと、ColumnType クラスが重要っぽい ColumnType は、DB のデータ型とKotlin のオブジェクトの間の 変換も責務として持つ @chimerast -
ColumnType を自分で定義する class SequelIdColumnType : ColumnType() { override fun sqlType():
String = org.jetbrains.exposed.sql.vendors.currentDialect.dataTypeProvider.integerType() override fun valueFromDB(value: Any): SequelId = when (value) { is SequelId -> value // メソッド名に反してExposed内部でキャッシュされた値が通るため is Int -> SequelId(value) // DBから返却された値をSequelId型にする is Number -> SequelId(value.toInt()) // 同上 ただし、ほぼない is String -> SequelId(value.toInt()) // 同上 ただし、ほぼない else -> error("Unexpected value of type Int: $value of ${value::class.qualifiedName}") } override fun notNullValueToDB(value: Any): Any = when (value) { is SequelId -> value.id // DBに渡すときにINTEGERに変換する else -> error("Unexpected value of type FilmId: $value of ${value::class.qualifiedName}") } } @chimerast -
独自の ColumnType を利用する object StarWarsFilms : IntIdTable() { val sequelId:
Column<SequelId> = sequelId("sequel_id").uniqueIndex() private fun sequelId(name: String) = registerColumn<SequelId>(name, SequelIdColumnType()) } fun updateBySequelId(sequelId: SequelId) = StarWarsFilms.update({ StarWarsFilms.sequelId eq sequelId }) { it[StarWarsFilms.watched] = true } INT 型で格納されているが、DSL やDAO で読み書きするときには、 ValueObject として読み書きできるようになる @chimerast -
実際の現場での汎用的な ValueObject の定義 ValueObject 毎にColumnType をつくるのはメンテナンスが大変 Generics とReflection を使ってごにょごにょする (
以下は雰囲気) open class IntBasedValueObject(val value: Int) { /* data class っぽい挙動の実装 */ } class IntValueObjectColumnType<T : IntBasedValueObject>(private val type: KClass<T>) : ColumnType() { ... private fun valueObjectFromInt(value: Int): T { return requireNotNull(type.primaryConstructor).call(value) } ... companion object { inline operator fun <reified T : IntBasedValueObject> invoke() = IntValueObjectColumnType(T::class) } } inline fun <reified T : IntBasedValueObject> Table.integerValueObject(name: String) = registerColumn<T>(name, IntValueObjectColumnType<T>()) @chimerast -
前ページの汎用 ColumnType を利用する class SequelId(value: Int) : IntBasedValueObject(value) object StarWarsFilms
: Table() { ... val sequelId = integerValueObject<SequelId>("sequel_id") ... } できた ColumnType を自作できるようになると DAO のエンティティの表現の幅が一気にあがる @chimerast -
RDBMS の独自データ型に対応する PostgreSQL は、独自のデータ型や演算子が多い。例えば範囲型 -- 3を含み、7を含まない。その間の数はすべて含まれる SELECT '[3,7)'::int4range; -- 3も7も含まないが、その間の数はすべて含まれる
SELECT '(3,7)'::int4range; -- 1つの値、4だけを含む SELECT '[4,4]'::int4range; -- 含まれる点は何もない('empty'に正規化される) SELECT '[4,4)'::int4range; @chimerast -
PostgreSQL の範囲型の演算子 ( 一部抜粋 ) 演算子 意味 例 結果 &&
重複する( 共通点を持つ) int8range(3,7) && int8range(4,12) t << 厳密に左に位置する int8range(1,10) << int8range(100,110) t >> 厳密に右に位置する int8range(50,60) >> int8range(20,30) t &< 右側を越えない int8range(1,20) &< int8range(18,20) t &> 左側を越えない int8range(7,20) &> int8range(5,10) t @chimerast -
範囲型を Exposed から利用する 独自データ型の定義は、ColumnType を作るだけなので割愛 class IntervalColumnType : ColumnType() {
override fun sqlType(): String = "tstzrange" override fun valueFromDB(value: Any): Any = when (value) { is Interval -> value is PGobject -> // 独自データ型はJDBCドライバが // PGobject,PgArray等を返すので、よしなに変換する @chimerast -
範囲型の演算を追加 class ContainsOp(expr1: Expression<*>, expr2: Expression<*>) : ComparisonOp(expr1, expr2, "@>")
class ContainedByOp(expr1: Expression<*>, expr2: Expression<*>) : ComparisonOp(expr1, expr2, "<@") class OverlapsOp(expr1: Expression<*>, expr2: Expression<*>) : ComparisonOp(expr1, expr2, "&&") infix fun ExpressionWithColumnType<Interval>.contains(other: Expression<Interval>) = ContainsOp(this, other) infix fun ExpressionWithColumnType<Interval>.contains(other: DateTime) = ContainsOp(this, QueryParameter(other, TimestampColumnType())) Member.find { Members.valid contains DateTime.now() }.toList() SELECT * FROM members WHERE valid @> '2022-09-02T12:34:56.000Z' @chimerast -
RDBMS の独自関数を自在に呼び出す PostgreSQL では、例えば、立方根を求める関数 cbrt() がある Exposed では簡単に関数を定義できるようになっていて、 一般的な関数であればCustomFunction クラスを使うと
クエリの中で呼び出せる fun <T : Number?> cbrt(expression: ExpressionWithColumnType<T>) = CustomFunction<T>("cbrt", expression.columnType, expression) // 貨物の体積の立方根が3.0未満の物をリストにする Cargo.find { cbrt(Cargoes.volume) less 3.0 }.toList() @chimerast -
関数やリテラル単体のクエリを型安全に投げる SELECT 1; は、SQL としては正しいクエリだが、 Exposed DSL の exec() を使うと
生の文字列を投げる事になるし、型安全にならない Exposed には型安全に同等のクエリを投げる方法が、実はある val one: LiteralOp<Int> = intLiteral(1) val result: Int = Table.Dual.slice(one).selectAll().single()[one] println(result) // output: 1 @chimerast -
勧告的ロックを Exposed からいい感じに取得する fun <T> Expression<T>.execSingle() = Table.Dual.slice(this).selectAll().single()[this] fun pgAdvisoryXactLock(key:
Long) = CustomFunction<Unit>("pg_advisory_xact_lock", VoidColumnType(), longParam(key)) fun advisoryLock(key: Long) = pgAdvisoryXactLock(key).execSingle() Exposed のトランザクション内で、advisoryLock(1) を呼び出すと、 別のトランザクションがadvisoryLock(1) を呼び出したときに、 ロックを取得しているトランザクションが終わるまで待ち続ける @chimerast -
トランザクションの処理の差し込み interface StatementInterceptor { fun beforeExecution(transaction: Transaction, context: StatementContext) {}
fun afterExecution(transaction: Transaction, contexts: List<StatementContext>, executedStatement: PreparedStatementApi) {} fun beforeCommit(transaction: Transaction) {} fun afterCommit() {} fun beforeRollback(transaction: Transaction) {} fun afterRollback() {} fun keepUserDataInTransactionStoreOnCommit(userData: Map<Key<*>, Any?>): Map<Key<*>, Any?> = emptyMap() } TransactionManager.current().registerInterceptor(object : StatementInterceptor { override fun afterCommit() = wakeupRunner() }) @chimerast -
トランザクションの差し込み処理のユースケース ログや統計を取り出力する transaction.statementStats にトランザクション内の 実行済みクエリ内容やその回数の情報が保存されている コミットが成功した時などにアプリケーションキャッシュを再作成 Exposed DAO も StatementInterceptor
を利用して キャッシュの管理をしている ジョブが登録されたときに、ジョブの実行スレッドを起こす @chimerast -
トランザクションにデータを紐付ける トランザクション内で共有するべき、コンテキスト情報を管理できる object TransactionCounter { private val counterKey: org.jetbrains.exposed.sql.Key<AtomicInteger> =
Key() private val counter get() = TransactionManager.current().getOrCreate(counterKey) { AtomicInteger(0) } val currentCount: Int get() = counter.get() fun increment(): Int = counter.incrementAndGet() } transaction { TransactionCounter.increment() TransactionCounter.increment() println(TransactionCounter.currentCount) // -> 2 } @chimerast -
トランザクションの直接操作 JUnit で DB を使ったシナリオテストをうまくやる 各テスト毎にDB をロールバックして元の状態に戻したい Spring にはそういう機能(@Transactional) がある
方針 1. Nested transaction を有効にする ( 重要! ) 2. JUnit のInterceptor でテスト実行前にトランザクションを張る 3. JUnit のInterceptor でテスト実行後にトランザクションを ロールバックする @chimerast -
class DBResetInterceptor : BeforeAllCallback, AfterAllCallback, BeforeEachCallback, AfterEachCallback { private lateinit
var transactionAll: Transaction private lateinit var transactionEach: Transaction override fun beforeAll(context: ExtensionContext?) { transactionAll = TransactionManager.manager.newTransaction() } override fun beforeEach(context: ExtensionContext?) { transactionEach = TransactionManager.manager.newTransaction(outerTransaction = transactionAll) } override fun afterEach(context: ExtensionContext?) { transactionEach.rollback() transactionEach.close() } override fun afterAll(context: ExtensionContext?) { transactionAll.rollback() transactionAll.close() } } @chimerast -
@ExtendWith(DBResetInterceptor::class) abstract class DatabaseTestBase { companion object { init {
val dataSource = HikariDataSource(HikariConfig("/hikari.properties")) Database.connect(dataSource, DatabaseConfig { useNestedTransactions = true }) } } } @TestInstance(TestInstance.Lifecycle.PER_CLASS) // PER_CLASSはほぼ必須 class StarWarTableTest : DatabaseTestBase() { @Test fun StarWarsテーブルが更新できる() { transaction { /* データベースを更新するテスト */ } } } @chimerast