この記事は筆者のソロ Advent Calendar 2022 7日目の記事です。
前回まではSpockについての記事を書いてきましたが今回はORMであるJOOQをKotlinで使用する方法について紹介していきます。また、マイグレーションにFlyway, テストにTestcontainersを使用します。
本記事で作成したサンプルコードはこちら
JOOQとは
JVM言語においてORMの選択肢は多くあり、JOOQもそのうちの一つである。JOOQを使用するメリットとして以下のような特徴がある。
- SQLライクに処理がかける。
- 複雑なSQLが書きやすい。
- 型安全。
- DBからコードを自動生成できる。
Spring
Spring Initializerでプロジェクトを作成する。JOOQは依存関係が用意されているためJOOQとMySQLドライバーの依存関係を選択しておく。今回はTestcontainersも使用するため依存関係に追加しておく。build.gradle.ktsは以下のようになる。
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
id("org.springframework.boot") version "2.7.5"
id("io.spring.dependency-management") version "1.0.15.RELEASE"
kotlin("jvm") version "1.6.21"
kotlin("plugin.spring") version "1.6.21"
}
group = "com.example"
version = "0.0.1-SNAPSHOT"
java.sourceCompatibility = JavaVersion.VERSION_17
repositories {
mavenCentral()
}
extra["testcontainersVersion"] = "1.17.4"
dependencies {
implementation("org.springframework.boot:spring-boot-starter-jooq")
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
runtimeOnly("com.mysql:mysql-connector-j")
testImplementation("org.springframework.boot:spring-boot-starter-test")
// testcontainers
testImplementation("org.testcontainers:junit-jupiter")
testImplementation("org.testcontainers:mysql")
}
dependencyManagement {
imports {
mavenBom("org.testcontainers:testcontainers-bom:${property("testcontainersVersion")}")
}
}
tasks.withType<KotlinCompile> {
kotlinOptions {
freeCompilerArgs = listOf("-Xjsr305=strict")
jvmTarget = "17"
}
}
tasks.withType<Test> {
useJUnitPlatform()
}
FlywayとJOOQを組み合わせて使う場合、FlywayのマイグレーションとJOOQのコード生成タスクがうまく噛み合わないのでFlywayは手動でマイグレーションを実行する。また、JOOQのコード生成用のタスク定義も行う。テストにKotestを追加して、最終的なbuild.gradle.ktsは以下のようになる。
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
plugins {
id("org.springframework.boot") version "2.7.5"
id("io.spring.dependency-management") version "1.0.15.RELEASE"
+ id("nu.studer.jooq") version "8.0" // これを追加
+ id("org.flywaydb.flyway") version "8.0.1" //これも追加
kotlin("jvm") version "1.6.21"
kotlin("plugin.spring") version "1.6.21"
}
group = "com.example"
version = "0.0.1-SNAPSHOT"
java.sourceCompatibility = JavaVersion.VERSION_17
repositories {
mavenCentral()
}
extra["testcontainersVersion"] = "1.17.4"
dependencies {
implementation("org.springframework.boot:spring-boot-starter-jooq")
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
runtimeOnly("com.mysql:mysql-connector-j")
testImplementation("org.springframework.boot:spring-boot-starter-test")
// testcontainers
testImplementation("org.testcontainers:junit-jupiter")
testImplementation("org.testcontainers:mysql")
// flyway
+ implementation("org.flywaydb:flyway-mysql")
// kotest
+ val kotest_version = "5.5.4"
+ testImplementation("io.kotest:kotest-runner-junit5-jvm:$kotest_version")
+ testImplementation("io.kotest.extensions:kotest-extensions-spring:1.1.2")
// jooq
+ jooqGenerator("com.mysql:mysql-connector-j")
+ jooqGenerator("jakarta.xml.bind:jakarta.xml.bind-api:4.0.0")
}
dependencyManagement {
imports {
mavenBom("org.testcontainers:testcontainers-bom:${property("testcontainersVersion")}")
}
}
tasks.withType<KotlinCompile> {
kotlinOptions {
freeCompilerArgs = listOf("-Xjsr305=strict")
jvmTarget = "17"
}
}
tasks.withType<Test> {
useJUnitPlatform()
}
+ jooq {
+ configurations {
+ create("main") {
+ jooqConfiguration.apply {
+ jdbc.apply {
+ url = "jdbc:mysql://localhost:3306/library?enabledTLSProtocols=TLSv1.2"
+ user = "root"
+ password = "root"
+ }
+ generator.apply {
+ name = "org.jooq.codegen.KotlinGenerator"
+ database.apply {
+ name = "org.jooq.meta.mysql.MySQLDatabase"
+ inputSchema = "library"
+ excludes = "flyway_schema_history"
+ }
+ generate.apply {
+ isDeprecated = false
+ isTables = true
+ }
+ target.apply {
+ packageName = "com.example.ktknowledgeTodo.infra.jooq"
+ directory = "$buildDir/generated/source/jooq/main"
+ }
+ }
+ }
+ }
+ }
+ }
+ flyway {
+ url = "jdbc:mysql://localhost:3306/library?enabledTLSProtocols=TLSv1.2"
+ user = "root"
+ password = "root"
+ }
手抜きで DB情報を直書きしていますが、実際には環境変数などに置き換えてください。JOOQタスクの設定は以下の記事をほぼそのまま使用させていただいてます🙇♂️
DB
DBはdockerで準備します。
version: '3'
services:
# MySQL
db:
image: mysql:latest
container_name: mysql_container
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: library
TZ: 'Asia/Tokyo'
command: mysqld --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
volumes:
- ./docker/db/data:/var/lib/mysql
- ./docker/db/my.cnf:/etc/mysql/conf.d/my.cnf
- ./docker/db/sql:/docker-entrypoint-initdb.d
ports:
- "3306:3306"
マイグレーション用のSQLファイルは以下。
CREATE DATABASE IF NOT EXISTS `library`;
USE `library`;
CREATE TABLE IF NOT EXISTS `author` (
`id` int NOT NULL AUTO_INCREMENT,
`first_name` varchar(255) DEFAULT NULL,
`last_name` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`)
);
dokcerを起動
docker-comose up -d
マイグレーション
./gradlew flywayMigrate
JOOQコード生成
./gradlew generateJooq
build配下にコードが生成されていれば完了です。
実装
以下のようなAutherテーブルを扱うインターフェースなどを実装していく。
インターフェイスの定義
package com.example.jooqdemo.domain.repository
import com.example.jooqdemo.domain.model.Author
interface AuthorRepository {
fun findById(id: Int): Author?
fun findAll(): List<Author>
fun save(firstName: String, lastName: String): Author
fun deleteAll()
}
インターフェイスの実装クラス。特筆することも特にないがDSLContextをDIしてSQLみたいに処理を組み立てる。本当にSQLのように書ける。最終的な戻り値がRecordクラスになるのでModelクラスを用意
して変換している。
package com.example.jooqdemo.infrastructure.repository
import com.example.jooqdemo.domain.model.Author
import com.example.jooqdemo.domain.repository.AuthorRepository
import com.example.ktknowledgeTodo.infra.jooq.tables.Author.Companion.AUTHOR
import org.jooq.DSLContext
import org.jooq.Record
import org.springframework.stereotype.Repository
@Repository
class AuthorRepositoryImpl(
private val dslContext: DSLContext
) : AuthorRepository {
override fun findById(id: Int): Author? {
return this.dslContext.select()
.from(AUTHOR)
.where(AUTHOR.ID.eq(id))
.fetchOne()?.let { toModel(it) }
}
override fun findAll(): List<Author> {
return this.dslContext.select()
.from(AUTHOR)
.fetch().map { toModel(it) }
}
override fun save(firstName: String, lastName: String): Author {
val record = this.dslContext.newRecord(AUTHOR).also {
it.firstName = firstName
it.lastName = lastName
it.store()
}
return Author(record.id!!, record.firstName!!, record.lastName!!)
}
override fun deleteAll() {
this.dslContext.deleteFrom(AUTHOR).execute()
}
private fun toModel(record: Record) = Author(
record.getValue(AUTHOR.ID)!!,
record.getValue(AUTHOR.FIRST_NAME)!!,
record.getValue(AUTHOR.LAST_NAME)!!
)
}
package com.example.jooqdemo.domain.model
data class Author(val id: Int, val firstName: String, val lastName: String)
テスト
テストにはKotestとTestcontainersを使用します。まず、準備としてKotestで@SpringBootTestを使用するのにExtentionを登録する必要があるので以下のようなConfigクラスを用意する。
package com.example.jooqdemo
import io.kotest.core.config.AbstractProjectConfig
import io.kotest.extensions.spring.SpringExtension
class ProjectConfig : AbstractProjectConfig() {
override fun extensions() = listOf(SpringExtension)
}
Testcontainersを使用する場合、大量のログが出るのが嫌なので以下のlogbackファイルを配置して止めておく。
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger - %msg%n</pattern>
</encoder>
</appender>
<root level="info">
<appender-ref ref="STDOUT"/>
</root>
<logger name="org.testcontainers" level="INFO"/>
<logger name="com.github.dockerjava" level="WARN"/>
<logger name="com.github.dockerjava.zerodep.shaded.org.apache.hc.client5.http.wire" level="OFF"/>
</configuration>
実際のテストはこんな感じ
package com.example.jooqdemo.domain
import com.example.jooqdemo.domain.repository.AuthorRepository
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import org.flywaydb.core.Flyway
import org.springframework.boot.test.context.SpringBootTest
import org.testcontainers.containers.MySQLContainer
@SpringBootTest
internal class AuthorRepositoryTest(
private val authorRepository: AuthorRepository
) : FunSpec({
beforeSpec {
//起動したMYSQLコンテナでマイグレーションを実行する
Flyway.configure()
.dataSource(container.jdbcUrl, container.username, container.password)
.load()
.migrate()
}
//テストが終わるたびに後処理
afterTest { authorRepository.deleteAll() }
test("save and find") {
val saved = authorRepository.save("first", "last")
val find = authorRepository.findById(saved.id)
saved shouldBe find
}
test("find count one") {
authorRepository.save("first", "last")
authorRepository.findAll().size shouldBe 1
}
}) {
companion object {
//MySQLコンテナを起動
val container = MySQLContainer("mysql:latest").apply {
withDatabaseName("library")
withUsername("root")
withPassword("root")
start()
}
}
}
これでテスト前にMySQLコンテナの起動とマイグレーション実行をした上で好きにテストが書けるようになります。注意点として起動したコンテナはテスト間で共有して使用されるため作成したデータの後処理をしないと他のテストに影響が出てしまいます。afterTestで作成したデータを削除していますが、この処理がないと2つ目のテストは失敗することになります。
まとめ
今回は以下の内容を紹介しました。
- Kotlin + SpringプロジェクトにJOOQを導入する手順
- JOOQと合わせてFlywayを導入する手順
- Kotest, Testcontainersを使用したDB処理のテストについて
触ってみた感想としてはかなり書き心地はよかったです。筆者はHibernateの経験が多かったですが本当にSQLを書く感じでコードが書けたので書き方で悩むこともあまりなかったです。コードを自動生成できるのも実装負担が減りいいと思います。
導入にあたってFlywayとの兼ね合いというかコードの自動生成のタイミングとマイグレーション実行のタイミングが全て自動実行になっているとうまく動かないなどの障壁があるかもしれませんが一回動くところまでできれば問題ないと思います。とりあえず書いていて楽しいので個人的にはこれからはJOOQを使用していこうと思いました!
ぜひみなさんもお試しください!