$30 off During Our Annual Pro Sale. View Details »

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 好きなところ 型安全に色んな所が拡張できる O/R マッパーの中では、挙動を比較的推測しやすい @chimerast

  3. Exposed つらいところ 公式ドキュメントが、貧弱!貧弱ゥ! ソースコード と GitHub Issues と Stack Overflow

    を読むと 公式ドキュメントに書いていない機能がたくさんある @chimerast
  4. 今日伝えたいこと Exposed には、実はこんな機能もあるよ!!! @chimerast

  5. 今日の発表 のつらみ ネタを仕込む時間が なかった!!! 結構淡々とした 発表になるかも どこにも書いてない 情報は多いと思う @chimerast

  6. 軽く自己紹介 たけうち ひでゆき @chimerast & @yesodco_ceo 株式会社イエソド フルスタックエンジニア ( ファンタズマ・クラス

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

    RDBMS の独自データ型に対応する 3. RDBMS の独自関数を自在に呼び出す 4. トランザクションを、もっとおもしろく。 @chimerast
  9. Exposed 内部実装編 @chimerast

  10. 知っておきたい DAO の挙動と内部実装 Exposed DAO は、一般的なO/R マッパーと同様に、 書き込みデータをキャッシュする val movie

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

    側に書き込んでキャッシュがリセットされる とはいえ、これもトランザクションがコミットされない限り、 RDBMS 側に責任を押しつけているだけではある @chimerast
  15. 内部実装編 まとめ 挙動がわからなかったら ソースコードを読もう 公式Wiki だけがドキュメントではない @chimerast

  16. Exposed 拡張編 @chimerast

  17. もっと型安全なテーブルを。 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
  18. こうなってくれるとうれしい 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
  19. 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
  20. 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
  21. 独自の 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
  22. 実際の現場での汎用的な 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
  23. 前ページの汎用 ColumnType を利用する class SequelId(value: Int) : IntBasedValueObject(value) object StarWarsFilms

    : Table() { ... val sequelId = integerValueObject<SequelId>("sequel_id") ... } できた ColumnType を自作できるようになると DAO のエンティティの表現の幅が一気にあがる @chimerast
  24. 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
  25. 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
  26. 範囲型を 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
  27. 範囲型の演算を追加 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
  28. 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
  29. 関数やリテラル単体のクエリを型安全に投げる 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
  30. 勧告的ロック PostgreSQL では、明らかにロックを取るべき状況に、 明示的にロックを取得する勧告的ロックという機能がある トランザクションから抜けると同時に開放されるロックとして、 pg_advisory_xact_lock() 、pg_try_advisory_xact_lock() がある それぞれ、待ち続けるか、取得に失敗したときにすぐに返るかが違い @chimerast

  31. 勧告的ロックを 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
  32. トランザクションを、もっとおもしろく。 Exposed のトランザクション関連でできること トランザクションおよびSQL の実行前後に処理を差し込める トランザクションに紐付くデータを持たせることができる トランザクションを直接操作できる @chimerast

  33. トランザクションの処理の差し込み 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
  34. トランザクションの差し込み処理のユースケース ログや統計を取り出力する transaction.statementStats にトランザクション内の 実行済みクエリ内容やその回数の情報が保存されている コミットが成功した時などにアプリケーションキャッシュを再作成 Exposed DAO も StatementInterceptor

    を利用して キャッシュの管理をしている ジョブが登録されたときに、ジョブの実行スレッドを起こす @chimerast
  35. トランザクションにデータを紐付ける トランザクション内で共有するべき、コンテキスト情報を管理できる 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
  36. トランザクションの直接操作 JUnit で DB を使ったシナリオテストをうまくやる 各テスト毎にDB をロールバックして元の状態に戻したい Spring にはそういう機能(@Transactional) がある

    方針 1. Nested transaction を有効にする ( 重要! ) 2. JUnit のInterceptor でテスト実行前にトランザクションを張る 3. JUnit のInterceptor でテスト実行後にトランザクションを ロールバックする @chimerast
  37. 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
  38. @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
  39. 拡張編 まとめ GitHub Issues を漁ろう そしてソースコードを読もう 公式Wiki だけがドキュメントではない @chimerast

  40. まとめ 答えはすべてソースコードに書いてある (どの OSS もだけど) Exposed の利用人口増やしたい @chimerast

  41. ご清聴ありがとうございました サーバサイド Kotlin エンジニア積極採用してます by 株式会社イエソド @chimerast