🤔

jOOQ のコード生成に Testcontainers を Gradle 上で使ってみる

2024/12/15に公開

Java の ORM である jOOQ を使う際、事前にデータベース上のテーブルスキーマを元にコード生成を行う必要があります。

https://www.jooq.org/doc/latest/manual/code-generation/

このコード生成を行う方法には、通常以下のような方法が考えられますが、それぞれに利点と欠点があります。

  • 開発環境のデータベースを元にコード生成を行う
    • スキーマを変更を伴う開発時に、開発環境へスキーマ定義の反映を事前にする必要がある
  • ローカルの開発用データベースを元にコード生成を行う
    • 各自のローカル開発環境用データベースの構成(バージョン等)が異なると、コード生成結果に差異が発生する場合がある
  • DDLDatabase を使う
    • H2 を内部で使っていて MySQL と完全に互換性があるわけではないので、実際のデータベースからダンプしたスキーマではエラーになることが多い
  • Docker で jOOQ 用のコンテナを立ち上げてコード生成を行う
    • 現状でベストな方法ではある、CI でも簡単にスキーマ定義からコード生成可能に
    • 開発用コンテナにはゴミが入っている可能性もあるので、使いたくない

Flyway を使っていたり、単一の .sql スキーマファイルから jOOQ のコード生成を行う場合には、コンテナを立ち上げてそこにスキーマを流し込み、jOOQ のコード生成を行うことが多いと思います。

しかし、現実的にビルド時にデータベースコンテナを立ち上げるのは少々苦労が必要です。

幸いなことに、現在では Testcontainers を使うと、コード生成用のデータベースコンテナを楽に立ち上げる事ができます。

https://testcontainers.com/

Testcontainers を使う方法は、jOOQ の公式ブログでも Using Testcontainers to Generate jOOQ Code として取り上げられており、Maven を使った例が紹介されています。

https://blog.jooq.org/using-testcontainers-to-generate-jooq-code/

Testcontainers のガイドでは、Maven で専用のプラグインを使った方法が紹介されています。

https://testcontainers.com/guides/working-with-jooq-flyway-using-testcontainers/

ただし、最近の Java/Kotlin プロジェクトでは、ビルドに Gradle を使っていることが多いと思います。

この記事では、Gradle において Testcontainers を使って jOOQ のコード生成を行う方法を紹介します。

Testcontainers の JDBC support を使う場合

Testcontainers には JDBC support とドライバが存在していて、JDBC URL に jdbc:tc:mysql:8.0.36:///databasename のような形で指定するだけで、コンテナを立ち上げることができます。IP やポート設定が不要なので、とても簡単です。

https://java.testcontainers.org/modules/databases/jdbc/

実際に、jOOQ 公式の Gradle plugin を使ってコード生成を行う場合は、以下のようなビルドスクリプトになります。

プロジェクトの構成としては、以下のような想定です。

  • Gradle multi-project 構成で、jooq モジュールをコード生成に利用
    • jOOQ を利用する各モジュールは、jooq モジュールを import する
  • スキーマ定義の SQL ファイルは jooq/src/main/resources/db/migration/tables.sql に配置
    • JDBC URL のパラメーター TC_INITSCRIPT を使って初期化スクリプトを実行
  • コード生成結果は、デフォルトの build/generated-sources/jooq に出力
jooq/build.gradle.kts
buildscript {
    dependencies {
        classpath("org.testcontainers:jdbc:1.20.4")
        classpath("org.testcontainers:mysql:1.20.4")
    }
}

plugins {
    `java-library`
    id("org.jooq.jooq-codegen-gradle") version "3.19.14"
}

dependencies {
    jooqCodegen("org.jooq:jooq-codegen:3.19.14")
    jooqCodegen("org.testcontainers:mysql:1.20.4")
    jooqCodegen("com.mysql:mysql-connector-j:9.1.0")
    api("org.jooq:jooq:3.19.14")
}

sourceSets {
    main {
        java {
            srcDir(layout.buildDirectory.dir("generated-sources/jooq"))
        }
    }
}

jooq {
    version = "3.19.14"
    delayedConfiguration {
        jdbc {
            driver = "org.testcontainers.jdbc.ContainerDatabaseDriver"
            url = "jdbc:tc:mysql:8.0:///jooqCodegen?TC_INITSCRIPT=file:${projectDir}/src/main/resources/db/migration/tables.sql"
            user = "root"
            password = "test"
        }
        generator {
            name = "org.jooq.codegen.JavaGenerator"
            database {
                name = "org.jooq.meta.mysql.MySQLDatabase"
                inputSchema = "jooqCodegen"
                isOutputSchemaToDefault = true
            }
            generate {
                ...
            }
            target {
                packageName = "${project.group}.jooq.generated"
            }
        }
    }
}

あとは、通常と同じように jooqCodegen タスクを以下のように叩くだけで、自動的にコンテナが立ち上がってコード生成が行われます。

./gradlew :jooq:jooqCodegen

ただし、スキーマの適用に失敗した場合でも、Testcontainers JDBC ドライバが立ち上げたコンテナは Gradle デーモンが生きている限り再利用されてるようで、場合によっては以下のコマンドを叩いて Gradle デーモンを終了させる必要があります。

./gradlew --stop
./gradlew :jooq:jooqCodegen

Flyway と Testcontainers を組み合わせて利用する場合

現実的には、データベースのマイグレーションには Flyway などを利用することが多いでしょう。

https://www.red-gate.com/products/flyway/community/

前述の例では コード生成に Testcontainers の JDBC ドライバを利用していましたが、Gradle の Flyway プラグインと組み合わせた場合にはうまく動作しません。

例えば以下のような感じで flyway プラグインの定義を追加しても、何も生成されません。flywayMigrate は正しく動作するのですが、jooqCodegen タスクでは何も出力されません。

flyway {
    // !! THIS EXAMPLE DOES NOT WORK !!
    driver = "org.testcontainers.jdbc.ContainerDatabaseDriver"
    url = "jdbc:tc:mysql:8.0:///jooqCodegen"
    user = "root"
    password = "test"
}

原因は、jooqCodegen タスクとは別なコンテナが flywayMigrate タスク実行時に起動してしまっているからです。空のデータベースを読んでいるだけになってしまうので、何も生成されないのですね。

最近の Testcontainers では、Reusable Containers のサポートもあるのですが、JDBC ドライバからの再利用はまだ対応してないようです。[1]

そこで、手動で Testcontainers のインスタンスを保持して、Flyway に渡す方法に変更します。ただし、随分とビルドスクリプトが複雑になってしまいます。

jooq/build.gradle.kts
import org.flywaydb.gradle.task.AbstractFlywayTask
import org.jooq.codegen.gradle.CodegenTask
import org.testcontainers.containers.MySQLContainer
import java.net.ServerSocket

buildscript {
    dependencies {
        classpath("org.flywaydb:flyway-mysql:10.20.1")
        classpath("org.testcontainers:mysql:1.20.4")
        classpath("com.mysql:mysql-connector-j:9.1.0")
    }
}

plugins {
    `java-library`
    id("org.jooq.jooq-codegen-gradle") version "3.19.14"
    id("org.flywaydb.flyway") version "10.20.1"
}

dependencies {
    jooqCodegen("org.jooq:jooq-codegen:3.19.14")
    api("org.jooq:jooq:3.19.14")
}

sourceSets {
    main {
        java {
            srcDir(layout.buildDirectory.dir("generated-sources/jooq"))
        }
    }
}

private lateinit var mysqlContainer: MySQLContainer<*>
private val mysqlPort = ServerSocket(0).use { it.localPort }

tasks.register("mysqlContainerUp") {
    doFirst {
        mysqlContainer = MySQLContainer("mysql:8.0").apply {
            withDatabaseName("jooqCodegen")
            withEnv("TZ", "UTC")
            portBindings = listOf("$mysqlPort:3306")
            start()
        }
    }
}

tasks.withType<AbstractFlywayTask>().configureEach {
    dependsOn("mysqlContainerUp")
}
tasks.withType<CodegenTask>().configureEach {
    dependsOn("flywayMigrate")
}

jooq {
    version = "3.19.14"
    delayedConfiguration {
        jdbc {
            driver = "com.mysql.cj.jdbc.Driver"
            url = mysqlContainer.jdbcUrl
            user = mysqlContainer.username
            password = mysqlContainer.password
        }
        generator {
            name = "org.jooq.codegen.JavaGenerator"
            database {
                name = "org.jooq.meta.mysql.MySQLDatabase"
                inputSchema = "jooqCodegen"
                isOutputSchemaToDefault = true
                excludes = "flyway_schema_history"
            }
            generate {
                ...
            }
            target {
                packageName = "${project.group}.jooq.generated"
            }
        }
    }
}

flyway {
    driver = "com.mysql.cj.jdbc.Driver"
    url = "jdbc:mysql://localhost:${mysqlPort}/jooqCodegen"
    user = "root"
    password = "test"
}

いかがでしょうか?あとは通常と同様に、jooqCodegen タスクを実行するだけです。

./gradlew :jooq:jooqCodegen

Testcontainers が利用したコンテナがずっと立ち上がっているように見えるかもしれませんが、Ryuk が処理を行なったプロセスが終了すると同時に自動でシャットダウンします。通常の Gradle 環境では、Gradle daemon が利用されているため、daemon が一定期間アイドルになって終了した時や、明示的に --stop した時にコンテナは勝手に破棄されます。

ポイント: コンテナのポート設定

事前にポート番号を知っておく必要があるため、mysqlPort ランダムな空きポート番号を設定しています。これで、同一マシンや CI で並列に流してもポートが被ったりするようなことは防げるはずです。

flyway のタスク定義でも mysqlContainer.jdbcUrl を利用すれば良いのでは?と思うかもしれませんが、Flyway プラグインの設定の評価がビルドスクリプトの読み込み時に行われるため、この時点では mysqlContainer がセットされておらずエラーになってしまいます。

jOOQ プラグインに関しては delayedConfiguration が存在するので、設定が実際の実行時に評価されるために、この問題が発生しません。

動かねーよ

プロジェクトによっては buildSrc を使っていると、以下のようなエラーが出てしまうことがあります。

* What went wrong:
Execution failed for task ':jooq:flywayMigrate'.
> Error occurred while executing flywayMigrate
  No database found to handle jdbc:mysql://localhost:65265/jooqCodegen

この場合、buildSrc/build.gradle.ktsbuildscript { dependencies { ... } } の中身を書いてみてください。この場合、classpath ではなくて、implementation で依存関係を指定します。

buildSrc/build.gradle.kts
dependencies {
    implementation("org.flywaydb:flyway-mysql:10.20.1")
    implementation("org.testcontainers:mysql:1.20.4")
    implementation("com.mysql:mysql-connector-j:9.1.0")
}

まとめ

Gradle でも Testcontainers を利用することで、特別な設定やコンテナの明示的な起動なしに、jOOQ のコード生成をどんな環境でも行うことができるようになります。

これで、公式プラグインのデフォルト設定のように、jOOQ の生成コードをリポジトリに含めず、CI や開発環境で毎回安定的に生成することが可能になるかと思います。

Flyway を利用する場合にビルドスクリプトが若干複雑になってしまうのは難点ですが、将来的な JDBC ドライバの Reusing Containers サポートに期待しましょう。

脚注
  1. Issue はあります: Container reuse across r2dbc:tc and jdbc:tc URLs #4473 ↩︎

Discussion