Slide 1

Slide 1 text

Server-Side Kotlin Meetup vol.5 Exposed 応用編 内部実装 と 魔拡張 〜 細かすぎて伝わらない Exposed 〜 たけうち ひでゆき @chimerast & @yesodco_ceo

Slide 2

Slide 2 text

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

Slide 3

Slide 3 text

Exposed つらいところ 公式ドキュメントが、貧弱!貧弱ゥ! ソースコード と GitHub Issues と Stack Overflow を読むと 公式ドキュメントに書いていない機能がたくさんある @chimerast

Slide 4

Slide 4 text

今日伝えたいこと Exposed には、実はこんな機能もあるよ!!! @chimerast

Slide 5

Slide 5 text

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

Slide 6

Slide 6 text

軽く自己紹介 たけうち ひでゆき @chimerast & @yesodco_ceo 株式会社イエソド フルスタックエンジニア ( ファンタズマ・クラス 幻 想 級) Kotlin 、Ktor 、Exposed を使い、 企業内の全てのアイデンティティ情報を 管理下に置くための 時系列マスタデータベースを作ってます @chimerast

Slide 7

Slide 7 text

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

Slide 8

Slide 8 text

おしながき 内部実装編 1. 知っておきたいExposed DAO の挙動と内部実装 拡張編 1. もっと型安全なテーブルを。 2. RDBMS の独自データ型に対応する 3. RDBMS の独自関数を自在に呼び出す 4. トランザクションを、もっとおもしろく。 @chimerast

Slide 9

Slide 9 text

Exposed 内部実装編 @chimerast

Slide 10

Slide 10 text

知っておきたい DAO の挙動と内部実装 Exposed DAO は、一般的なO/R マッパーと同様に、 書き込みデータをキャッシュする val movie = StarWarsFilm.new() movie.name = "The Last Jedi" movie.sequelId = 8 movie.director = "Rian Johnson" 上記のコードで、いつINSERT 文がDB に投げられるかわかりますか? @chimerast

Slide 11

Slide 11 text

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

Slide 12

Slide 12 text

キャッシュされた 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

Slide 13

Slide 13 text

合わせると感づくこと その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

Slide 14

Slide 14 text

合わせると感づくこと その2 大量のレコードのEntity を一気に作って書き込もうとすると OOM で死ぬ バルクで書き込まれるまで、キャッシュをためこむので、、、 TransactionManager.current().flushCache() を呼び出すと、 RDBMS 側に書き込んでキャッシュがリセットされる とはいえ、これもトランザクションがコミットされない限り、 RDBMS 側に責任を押しつけているだけではある @chimerast

Slide 15

Slide 15 text

内部実装編 まとめ 挙動がわからなかったら ソースコードを読もう 公式Wiki だけがドキュメントではない @chimerast

Slide 16

Slide 16 text

Exposed 拡張編 @chimerast

Slide 17

Slide 17 text

もっと型安全なテーブルを。 object StarWarsFilms : Table() { val id: Column> = integer("id").autoIncrement() val sequelId: Column = integer("sequel_id").uniqueIndex() val name: Column = varchar("name", 50) val director: Column = varchar("director", 50) } ID 系が全てInt 型 全部ValueObject で取り出したり、保存したりしたい @chimerast

Slide 18

Slide 18 text

こうなってくれるとうれしい 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> = filmId("id").autoIncrement() val sequelId: Column = sequelId("sequel_id").uniqueIndex() val name: Column = name("name", 50) val director: Column = director("director", 50) } @chimerast

Slide 19

Slide 19 text

Table クラスで使う integer() とか varchar() ってなに val sequelId: Column = integer("sequel_id").uniqueIndex() val name: Column = varchar("name", 50) fun integer(name: String): Column = registerColumn(name, IntegerColumnType()) fun varchar(name: String, length: Int, collate: String? = null): Column = registerColumn(name, VarCharColumnType(length, collate)) Table クラスを読むと、ColumnType クラスが重要っぽい ColumnType は、DB のデータ型とKotlin のオブジェクトの間の 変換も責務として持つ @chimerast

Slide 20

Slide 20 text

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

Slide 21

Slide 21 text

独自の ColumnType を利用する object StarWarsFilms : IntIdTable() { val sequelId: Column = sequelId("sequel_id").uniqueIndex() private fun sequelId(name: String) = registerColumn(name, SequelIdColumnType()) } fun updateBySequelId(sequelId: SequelId) = StarWarsFilms.update({ StarWarsFilms.sequelId eq sequelId }) { it[StarWarsFilms.watched] = true } INT 型で格納されているが、DSL やDAO で読み書きするときには、 ValueObject として読み書きできるようになる @chimerast

Slide 22

Slide 22 text

実際の現場での汎用的な ValueObject の定義 ValueObject 毎にColumnType をつくるのはメンテナンスが大変 Generics とReflection を使ってごにょごにょする ( 以下は雰囲気) open class IntBasedValueObject(val value: Int) { /* data class っぽい挙動の実装 */ } class IntValueObjectColumnType(private val type: KClass) : ColumnType() { ... private fun valueObjectFromInt(value: Int): T { return requireNotNull(type.primaryConstructor).call(value) } ... companion object { inline operator fun invoke() = IntValueObjectColumnType(T::class) } } inline fun Table.integerValueObject(name: String) = registerColumn(name, IntValueObjectColumnType()) @chimerast

Slide 23

Slide 23 text

前ページの汎用 ColumnType を利用する class SequelId(value: Int) : IntBasedValueObject(value) object StarWarsFilms : Table() { ... val sequelId = integerValueObject("sequel_id") ... } できた ColumnType を自作できるようになると DAO のエンティティの表現の幅が一気にあがる @chimerast

Slide 24

Slide 24 text

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

Slide 25

Slide 25 text

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

Slide 26

Slide 26 text

範囲型を 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

Slide 27

Slide 27 text

範囲型の演算を追加 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.contains(other: Expression) = ContainsOp(this, other) infix fun ExpressionWithColumnType.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

Slide 28

Slide 28 text

RDBMS の独自関数を自在に呼び出す PostgreSQL では、例えば、立方根を求める関数 cbrt() がある Exposed では簡単に関数を定義できるようになっていて、 一般的な関数であればCustomFunction クラスを使うと クエリの中で呼び出せる fun cbrt(expression: ExpressionWithColumnType) = CustomFunction("cbrt", expression.columnType, expression) // 貨物の体積の立方根が3.0未満の物をリストにする Cargo.find { cbrt(Cargoes.volume) less 3.0 }.toList() @chimerast

Slide 29

Slide 29 text

関数やリテラル単体のクエリを型安全に投げる SELECT 1; は、SQL としては正しいクエリだが、 Exposed DSL の exec() を使うと 生の文字列を投げる事になるし、型安全にならない Exposed には型安全に同等のクエリを投げる方法が、実はある val one: LiteralOp = intLiteral(1) val result: Int = Table.Dual.slice(one).selectAll().single()[one] println(result) // output: 1 @chimerast

Slide 30

Slide 30 text

勧告的ロック PostgreSQL では、明らかにロックを取るべき状況に、 明示的にロックを取得する勧告的ロックという機能がある トランザクションから抜けると同時に開放されるロックとして、 pg_advisory_xact_lock() 、pg_try_advisory_xact_lock() がある それぞれ、待ち続けるか、取得に失敗したときにすぐに返るかが違い @chimerast

Slide 31

Slide 31 text

勧告的ロックを Exposed からいい感じに取得する fun Expression.execSingle() = Table.Dual.slice(this).selectAll().single()[this] fun pgAdvisoryXactLock(key: Long) = CustomFunction("pg_advisory_xact_lock", VoidColumnType(), longParam(key)) fun advisoryLock(key: Long) = pgAdvisoryXactLock(key).execSingle() Exposed のトランザクション内で、advisoryLock(1) を呼び出すと、 別のトランザクションがadvisoryLock(1) を呼び出したときに、 ロックを取得しているトランザクションが終わるまで待ち続ける @chimerast

Slide 32

Slide 32 text

トランザクションを、もっとおもしろく。 Exposed のトランザクション関連でできること トランザクションおよびSQL の実行前後に処理を差し込める トランザクションに紐付くデータを持たせることができる トランザクションを直接操作できる @chimerast

Slide 33

Slide 33 text

トランザクションの処理の差し込み interface StatementInterceptor { fun beforeExecution(transaction: Transaction, context: StatementContext) {} fun afterExecution(transaction: Transaction, contexts: List, executedStatement: PreparedStatementApi) {} fun beforeCommit(transaction: Transaction) {} fun afterCommit() {} fun beforeRollback(transaction: Transaction) {} fun afterRollback() {} fun keepUserDataInTransactionStoreOnCommit(userData: Map, Any?>): Map, Any?> = emptyMap() } TransactionManager.current().registerInterceptor(object : StatementInterceptor { override fun afterCommit() = wakeupRunner() }) @chimerast

Slide 34

Slide 34 text

トランザクションの差し込み処理のユースケース ログや統計を取り出力する transaction.statementStats にトランザクション内の 実行済みクエリ内容やその回数の情報が保存されている コミットが成功した時などにアプリケーションキャッシュを再作成 Exposed DAO も StatementInterceptor を利用して キャッシュの管理をしている ジョブが登録されたときに、ジョブの実行スレッドを起こす @chimerast

Slide 35

Slide 35 text

トランザクションにデータを紐付ける トランザクション内で共有するべき、コンテキスト情報を管理できる object TransactionCounter { private val counterKey: org.jetbrains.exposed.sql.Key = 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

Slide 36

Slide 36 text

トランザクションの直接操作 JUnit で DB を使ったシナリオテストをうまくやる 各テスト毎にDB をロールバックして元の状態に戻したい Spring にはそういう機能(@Transactional) がある 方針 1. Nested transaction を有効にする ( 重要! ) 2. JUnit のInterceptor でテスト実行前にトランザクションを張る 3. JUnit のInterceptor でテスト実行後にトランザクションを ロールバックする @chimerast

Slide 37

Slide 37 text

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

Slide 38

Slide 38 text

@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

Slide 39

Slide 39 text

拡張編 まとめ GitHub Issues を漁ろう そしてソースコードを読もう 公式Wiki だけがドキュメントではない @chimerast

Slide 40

Slide 40 text

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

Slide 41

Slide 41 text

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