Flywayによる起動時のMigrationを制御する

2020.02.07

こんにちは。こむろ@札幌です。

ある人たち(?)から毎度記事が生生しすぎると言われました。今回もそうかもしれません。 *1

概要

自分が開発に関わっているPrismatixというサービスはAPIサーバーをSpring Bootアプリケーションで実装している。その中でデータマイグレーションの手段として Flyway を利用している。

指定された Migrationファイル(SQLやJavaコード等)を必ず決まった順序で実行し、それらをどこまで適用したかを管理してくれるなど、とても役に立つ機能が豊富にあるため様々なところで紹介されている。

Developers.IOでもいくつか関連のエントリーが確認できた。とても参考になるのでおすすめ。

[Spring Boot] Flyway 3系で作ったテーブルを Flyway 5系にアップデートする方法

[Spring Boot 1] flywayのデータベースのマイグレーションだけ実行する

Flyway with Spring Boot でDBマイグレーションを自動化する

Flywayで簡単DBマイグレーション

しかし、アプリケーションの成長に伴い、ここ最近起動時に Migration を実行すると一向に処理が完了せず、アプリケーションの起動が長時間遅延する現象が確認されていた。任意のSQLが実行できてしまうため、特に何も考えずに実行すると、それはそれは長大な時間のかかる処理が簡単に書けてしまう。例えば数千万〜数億級のデータに対して Alter table の実行など。

そこで Flyway の Migration 処理を切り離す方法を検討し、七転八倒しながら調査と対応を行った。その記録をこちらに残す。

Spring Bootにおける Flyway の Configuration と Migration の実行タイミングについて

まず手始めに Spring Boot に組み込んだ Flyway はどのように動作しているのかを確認した。Flyway を Dependencies に追加してみるとわかるが、DBへの接続情報を記載するだけで、なんのコードの追加も必要なくスルッと Migration が実行されてしまう。とても便利だ。

ここでは Spring Boot の AutoConfiguration 等の深い動作の解説は一通り省く。興味がある方は以下のリンクをご覧いただくか、自分自身で AutoConfiguration 自作してみるとよろしいかと思う。

FlywayのAutoConfiguration

FlywayAutoConfiguration を確認。 *2

spring.flyway.enabled のパラメータによって適用されるかどうかを決定している。パラメータが存在しない場合は true なのでデフォルトでは「依存を取り込んだら適用する」という動作になっているようだ。

spring-projects - FlywayAutoConfiguration.java#L91-L99

@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(Flyway.class)
@Conditional(FlywayDataSourceCondition.class)
@ConditionalOnProperty(prefix = "spring.flyway", name = "enabled", matchIfMissing = true)
@AutoConfigureAfter({ DataSourceAutoConfiguration.class, JdbcTemplateAutoConfiguration.class,
        HibernateJpaAutoConfiguration.class })
@Import({ FlywayEntityManagerFactoryDependsOnPostProcessor.class, FlywayJdbcOperationsDependsOnPostProcessor.class,
        FlywayNamedParameterJdbcOperationsDependencyConfiguration.class })
public class FlywayAutoConfiguration {

このクラスで以下の箇所で FlywayMigrationInitlizer を Bean登録している箇所がある。

spring-projects - FlywayAutoConfiguration.java#L264-L269

@Bean
@ConditionalOnMissingBean
public FlywayMigrationInitializer flywayInitializer(Flyway flyway,
                                                    ObjectProvider<FlywayMigrationStrategy> migrationStrategy) {
  return new FlywayMigrationInitializer(flyway, migrationStrategy.getIfAvailable());
}

FlywayMigrationInializer を見ていく。

FlywayMigrationInitializer

Flyway の動作に必要なインタフェース等をパラメータにもつ。色々とパラメータをセットしており、必要なのは FlywayFlywayMigrationStrategy のみ。Flyway は Migration を実行するための実際の処理等が記述されているクラス。

flyway-core - Flyway.java

そして afterPropertiesSet() で以下が実行されている。

spring-projects - FlywayMigrationInitializer.java#L59-L67

@Override
public void afterPropertiesSet() throws Exception {
  if (this.migrationStrategy != null) {
    this.migrationStrategy.migrate(this.flyway);
  }
  else {
    this.flyway.migrate();
  }
}

つまり Migration 処理は Bean が登録されると即実行される。ただ、 FlywayMigrationStrategy というインタフェースがプロパティに設定されており、このインタフェースが Bean として登録されている場合、 Flyway ではなくインタフェース側の migrate が呼び出される。

@ConditionalOnPropertysprint.flyway.enabledfalse にするとそもそもこの Configuration 自体が実行されない。そのため、Migration 処理を呼び出す FlywayMigrationInitilizer が作成されず結果として Migration 処理が実行されない、という動きのようだ。

ひとまず Migration 処理が実行されるまでの簡単な流れは把握できた。本題にいこう。

分離の仕方について

分離の仕方については以下を検討した。

  1. Flyway の依存をなくす
  2. Flyway の依存を残しつつ、起動時に実行される Migration を停止する

本来は 1 が望ましいのだが、かなり古くから組み込まれている依存が故に引き剥がすのは容易ではないと思い、念の為 2 の手法も検討しておく。

Flyway の依存をなくす

元々やりたかったのは「アプリケーション起動時に Migration 処理を停止する」ことである。

そこで、ひとまず安直に Flyway の Configuration を実行させないことで、Spring Bootのアプリケーション起動時の Migration を停止させよう。この方法では Flyway の Configuration が実行されず不必要な Bean が作成されないため、比較的きれいに整理はされるものの、起動時の Migration 処理以外 Flyway が関与していないことが前提となる。

これを実現するには先程動作を確認したとおり、AutoConfiguration の有無を制御する設定値をいじればよいだけである。 application.properties に以下のパラメータを記述するのみ。これで Flyway の Configuration が Auto Configuration の対象外となり、 Flyway Migration が起動時に実行されない。

spring.flyway.enabled=false

しかし、ここで問題が。

Flyway Migration は指定のSQLを順序を守って実行してくれる。そのため、テストを実行する際の事前条件のデータ構築に非常に役に立つ。そして案の定修正対象のプロジェクトは、テストで以下を利用していることが分かった。

testCompile "org.flywaydb.flyway-test-extensions:flyway-dbunit-test:5.2.4"

テストに必要な事前条件となるデータをテスト実行時にDatabaseに設定するため、上記を利用している。名前の通り、 Flyway Core には強烈に依存している(当然だ・・・)

テストコードのイメージとしては以下のようなものである。

@RunWith(SpringJUnit4ClassRunner.class)
@SpringBootTest(classes = SampleApplication.class)
@Rollback
@FlywayTest
@TestExecutionListeners({DependencyInjectionTestExecutionListener.class,
        FlywayTestExecutionListener.class, SqlScriptsTestExecutionListener.class})
public class SampleTest {

    @FlywayTest(locationsForMigrate = "db.fixture_sample")
    @Test
    public void testSample() throws Exception {
        // test code....
    }
}

flyway-test-extensions - FlywayTestApplicationTest.java

こうなると少し話がややこしい。Flyway の Configuration を綺麗サッパリ掃除してしまうと、これらのテストコードが全滅してしまう。

以下は Flyway の Configuration を読まずにテストを実行した際のエラーログとなる。「Configuration が存在しない」というエラーメッセージが確認できる。

2020-02-05 16:45:29.833  WARN 36184 --- [    Test worker] o.s.test.context.TestContextManager      : Caught exception while invoking 'beforeTestClass' callback on TestExecutionListener [org.flywaydb.test.FlywayTestExecutionListener@5d6eb43c] for test class [class jp.classmethod.spring.SampleTest]

java.lang.IllegalArgumentException: Annotation class com.sun.proxy.$Proxy35 was set, but no Flyway configuration was given.
    at org.flywaydb.test.FlywayTestExecutionListener.dbResetWithAnnotation(FlywayTestExecutionListener.java:417) ~[flyway-spring-test-6.1.0.jar:6.1.0]
    at org.flywaydb.test.FlywayTestExecutionListener.handleFlywayTestAnnotationForClass(FlywayTestExecutionListener.java:181) ~[flyway-spring-test-6.1.0.jar:6.1.0]
    at org.flywaydb.test.FlywayTestExecutionListener.beforeTestClass(FlywayTestExecutionListener.java:160) ~[flyway-spring-test-6.1.0.jar:6.1.0]
    at org.springframework.test.context.TestContextManager.beforeTestClass(TestContextManager.java:213) ~[spring-test-5.2.3.RELEASE.jar:5.2.3.RELEASE]
    at org.springframework.test.context.junit.jupiter.SpringExtension.beforeAll(SpringExtension.java:77) [spring-test-5.2.3.RELEASE.jar:5.2.3.RELEASE]
    at 
    ....

なるほど。これは使えない。

実装コードでは Flyway の Configuraiton をなくしつつ、テストコードでは残すといった工夫が必要そうである。

Flyway の依存を残しつつ、起動時の Migration 処理を停止

Flywayには FlywayMigrationStrategy というインタフェースを定義しているのは先程確認したとおり。これを実装しどこか適当な Configuration で Bean として登録しておけば、このインタフェースの migrate が代わりに呼び出される。そこで依存をすべて消し去るのではなく起動時に起動する Migration 処理だけをスキップする方式を検討する。

インタフェースは以下の通り。

spring-boot docs - FlywayMigrationStrategy

FlywayMigrationStrategyvoid migrate(Flyway) のメソッドで何もしない処理を記述すれば、Migrate処理を空振りすることができる。

/**
 * Disable Startup FlywayMigration Strategy Configuration
 */
@Configuration
@Slf4j
public class EmptyMigrationStrategyConfiguration {

    @Bean
    public FlywayMigrationStrategy flywayMigrationStrategy() {
        return flyway -> {
            log.info("Skip Startup flywayMigration");
        };
    }
}

本当に空の実装にしてしまうと、実際のDBの中身を確認しないとわからない。念の為「意図的に Migration を空振りしたよ」というログを残しておく。

Database Migrations with Flyway

しかし、これもまた問題が。当然ながらテストコードでもこのインタフェースを利用してしまう。そのため、 Migration がスキップされてしまい想定した動作にならない。うーむ、これもまた困った。

テストコード実行時のみConfigurationを有効にする

application.properties の設定値による制御

簡単なのは application.properties による制御。

先程みたように spring.flyway.enabled はデフォルトでは true となり、 Configuration が実行されてしまう。

そこで、実装コードの方では spring.flyway.enabled=false と明示的に指定し、テストコードの application.properties の方に spring.flyway.enabled=true を指定する。こうすることでテスト実行時のみ Flyway の Configuration が取り込まれる。

├── build.gradle
├── gradle.properties
└── src
    ├── main
    │   ├── java
    │   │   └── jp
    │   │       ...
    │   └── resources
    │       ├── application.properties <-- ここと
    │       └── db
    │           └── migration
    │               └── V1__Create_person.sql
    └── test
        ├── java
        │   └── jp
        │      ...
        └── resources
            ├── application.properties <-- ここと
            └── db
                └── fixture_sample
                    └── V99__InsertPerson_5.sql

一応これで Configuration の有無を制御できるがどうなんだろうか・・・。

実装コードの入った設定に明示的に何らかの設定値を記述しなければならないという点で、不要な情報が入り込んでいる気がするのであまりすんなり受け入れられない。

build.gradleのDependencyで制御

プロジェクトの設定を build.gradle 等で管理している場合、テスト時のみ依存を有効とする testCompileOnly を使えば良い。

元々実装でもテストでも flyway-core の依存は取り込んでいたので、修正前の build.gradle は以下の通りである。

dependencies {
    compileOnly 'org.springframework.boot:spring-boot-starter-web'
    compileOnly "org.springframework.boot:spring-boot-starter-jdbc"
    compileOnly "org.flywaydb:flyway-core:$flywayVersion"
    compileOnly 'org.projectlombok:lombok'
    compileOnly "mysql:mysql-connector-java:$mysqlVersion"
    annotationProcessor 'org.projectlombok:lombok'

    // test
    testImplementation('org.springframework.boot:spring-boot-starter-test') {
        exclude group: 'org.junit.vintage', module: 'junit-vintage-engine'
    }
    testCompile "org.flywaydb:flyway-core:$flywayVersion"
    testCompile "mysql:mysql-connector-java:$mysqlVersion"
    testCompile "org.flywaydb.flyway-test-extensions:flyway-dbunit-test:6.1.0"
}

見てわかるように compileOnlytestCompile の双方で flyway-core の依存を取り込んでいる。実装側の Flyway の依存を切ってテストコードのみに残したいのであれば compileOnly のみ削除すれば良い。つまり以下

dependencies {
    compileOnly 'org.springframework.boot:spring-boot-starter-web'
    compileOnly "org.springframework.boot:spring-boot-starter-jdbc"
//  compileOnly "org.flywaydb:flyway-core:$flywayVersion"
    compileOnly 'org.projectlombok:lombok'
    compileOnly "mysql:mysql-connector-java:$mysqlVersion"
    annotationProcessor 'org.projectlombok:lombok'

    // test
    testImplementation('org.springframework.boot:spring-boot-starter-test') {
        exclude group: 'org.junit.vintage', module: 'junit-vintage-engine'
    }
    testCompile "org.flywaydb:flyway-core:$flywayVersion"
    testCompile "mysql:mysql-connector-java:$mysqlVersion"
    testCompile "org.flywaydb.flyway-test-extensions:flyway-dbunit-test:6.1.0"
}

これで実装コードの実行時は Flyway の Migration が実行されず、テスト実行時には Flyway の依存が取り込まれ期待通りの動作が実現できる。この方法が比較的分かりやすく良いのでは、という感想。

まとめ

プロジェクトに古くからある依存を引き剥がす際には、それに依存する部分をきちんと考慮しないと大出血するということを改めて実感した。また、依存のなくし方にしても、単純に引き剥がすだけでも設定によって作らせないのか、そもそも依存をなくすのか、中身の処理をスキップさせるのかなど様々なアプローチがある。ケースバイケースと状況次第で使い分ければよいかと思う。

特に AutoConfiguration はとても便利で良いのだが、歴史が長いプロジェクトになると、存在して当然の依存となり、意識せずあちらこちらで強い依存が生まれてしまうようだ。今回は概ねテストプロジェクトのみの依存で助かったが、実装コードで強い依存を持っていた場合は上記では解決できない。

おまけ

実は、これら調査を行ったものの最終的にはうまくいっていない。なぜならば、実装が強く依存してしまっている箇所が見つかったため。以下の Configuration は Flyway の依存に強く結びついてしまっている。

@Configuration
@RequiredArgsConstructor
@DependsOn({
    "flyway",
    "flywayInitializer"
})
public class HogeConfiguration implements InitializingBean {}

そのため、今まで書いた対応だと Migration の処理をスキップさせる FlywayMigrationStrategy インタフェースを実装する他ない。しかし、それを行うとテストでの Migration もスキップしてしまうのであちらを立てればこちらが立たずである。

さらに @ConditionalOnProperty がついていたらもう最悪である。設定によっては Configuration が実行されないため Flyway の依存がなくても実行できてしまう。そして何かのタイミングで設定が変更され、この Configuration が実行されたときに初めて発覚する。もはやこの依存は実装者にしかわからない。

参照

脚注

  1. すみません。
  2. 今回の動作に関わるところのみピックアップしているので完全解説ではありません。あしからず。