Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Exposed 応用編 〜内部実装 と 魔拡張〜

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
Tweet

More Decks by Hideyuki Takeuchi

Other Decks in Technology

Transcript

  1. Server-Side Kotlin Meetup vol.5 Exposed 応用編 内部実装 と 魔拡張 〜

    細かすぎて伝わらない Exposed 〜 たけうち ひでゆき @chimerast & @yesodco_ceo
  2. Exposed つらいところ 公式ドキュメントが、貧弱!貧弱ゥ! ソースコード と GitHub Issues と Stack Overflow

    を読むと 公式ドキュメントに書いていない機能がたくさんある @chimerast
  3. 軽く自己紹介 たけうち ひでゆき @chimerast & @yesodco_ceo 株式会社イエソド フルスタックエンジニア ( ファンタズマ・クラス

    幻 想 級) Kotlin 、Ktor 、Exposed を使い、 企業内の全てのアイデンティティ情報を 管理下に置くための 時系列マスタデータベースを作ってます @chimerast
  4. 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
  5. おしながき 内部実装編 1. 知っておきたいExposed DAO の挙動と内部実装 拡張編 1. もっと型安全なテーブルを。 2.

    RDBMS の独自データ型に対応する 3. RDBMS の独自関数を自在に呼び出す 4. トランザクションを、もっとおもしろく。 @chimerast
  6. 知っておきたい DAO の挙動と内部実装 Exposed DAO は、一般的なO/R マッパーと同様に、 書き込みデータをキャッシュする val movie

    = StarWarsFilm.new() movie.name = "The Last Jedi" movie.sequelId = 8 movie.director = "Rian Johnson" 上記のコードで、いつINSERT 文がDB に投げられるかわかりますか? @chimerast
  7. 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
  8. キャッシュされた 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
  9. 合わせると感づくこと その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
  10. 合わせると感づくこと その2 大量のレコードのEntity を一気に作って書き込もうとすると OOM で死ぬ バルクで書き込まれるまで、キャッシュをためこむので、、、 TransactionManager.current().flushCache() を呼び出すと、 RDBMS

    側に書き込んでキャッシュがリセットされる とはいえ、これもトランザクションがコミットされない限り、 RDBMS 側に責任を押しつけているだけではある @chimerast
  11. もっと型安全なテーブルを。 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
  12. こうなってくれるとうれしい 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
  13. 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
  14. 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
  15. 独自の 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
  16. 実際の現場での汎用的な 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
  17. 前ページの汎用 ColumnType を利用する class SequelId(value: Int) : IntBasedValueObject(value) object StarWarsFilms

    : Table() { ... val sequelId = integerValueObject<SequelId>("sequel_id") ... } できた ColumnType を自作できるようになると DAO のエンティティの表現の幅が一気にあがる @chimerast
  18. 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
  19. 範囲型を 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
  20. 範囲型の演算を追加 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
  21. 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
  22. 関数やリテラル単体のクエリを型安全に投げる 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
  23. 勧告的ロックを 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
  24. トランザクションの処理の差し込み 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
  25. トランザクションにデータを紐付ける トランザクション内で共有するべき、コンテキスト情報を管理できる 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
  26. トランザクションの直接操作 JUnit で DB を使ったシナリオテストをうまくやる 各テスト毎にDB をロールバックして元の状態に戻したい Spring にはそういう機能(@Transactional) がある

    方針 1. Nested transaction を有効にする ( 重要! ) 2. JUnit のInterceptor でテスト実行前にトランザクションを張る 3. JUnit のInterceptor でテスト実行後にトランザクションを ロールバックする @chimerast
  27. 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
  28. @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