エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

Spring Boot + Flyway で複数の DB に接続したい!!!

【Unit4 ブログリレー5日目】

こんにちは。Unit4 Eng の西川です。JavaScript が好きですが、今回は JavaScript ではなく、最近やった Spring Boot + Flyway を利用しているシステムから別の DB への接続を増やした時の話をしたいと思います。

非常に可愛いうちの猫です

動作環境

今回のプロダクトで利用している Spring Boot と Flyway のバージョンは以下のとおりです。 DB フレームワークは MyBatis、テストは JUnit を利用しています。

Java 17
Kotlin 1.7.20
Spring Boot 2.7.4
Flyway 7.11.3
PostgreSQL 42.4.1

背景

そもそもどうして複数の DB に接続する必要が出たかというお話なのですが、Unit4 ではかなり多くのサービスを扱っているため、どうしても保守が間に合わないサービスが出てきてしまいます。そこで、とあるレガシーなサービスを比較的新しいサービスに持ってくる際に、先に API や画面を持ってきて、最後に DB を持ってこようとしたわけです。

API や画面を持ってきている間は、旧サービスの方の DB を見に行く必要があります。だから、複数の DB に接続する必要があったんですね。

なお、移行元のサービスには Flyway が入っておらず、移行先のサービスにのみ入っている状態です。

Spring Boot で複数の DB に接続する

とりあえず、Spring Boot で複数の DB に接続する方法を調べました。

概要

Spring Boot(+ MyBatis)で必要な Bean は以下のとおりです。
- DataSource
- TransactionManager
- SqlSessionFactory(MyBatis 用)

Spring Boot は Auto-Configuration*1 で、application.yaml (orapplication.properties) に記載されている情報を元に、自動で Bean を作成してくれます。

以下のように application.yamlspring.datasource として DB の接続情報を記載すると、その情報を元に上記の Bean を作ってくれる感じです。

spring:
  datasource:
    driver-class-name: org.postgresql.Driver
    url: ${POSTGRES_URL}
    username: ${POSTGRES_USER}
    password: ${POSTGRES_PASSWORD}

上述した Auto-Configuration で作成してくれる各種 Bean はひとつずつであるため、複数利用したい場合には手動で設定する必要があります。今回は 2 台の DB に接続したいので、2 台分の DB 接続情報を設定する形になります。*2

具体的に言うと、以下のステップを踏む必要があります。順番に見ていきます。

  1. application.yaml の記載
  2. Configuration ファイルの作成
application.yaml の記載

最初に application.yamlspring.datasource に、 2 台分の DB 接続情報を記載します。

spring:
  datasource:
    datasourceA:
      driver-class-name: org.postgresql.Driver
      url: ${A_POSTGRES_URL}
      username: ${A_POSTGRES_USER}
      password: ${A_POSTGRES_PASSWORD}
    datasourceB:
      driver-class-name: org.postgresql.Driver
      url: ${B_POSTGRES_URL}
      username: ${B_POSTGRES_USER}
      password: ${B_POSTGRES_PASSWORD}
Configuration ファイルの作成

次に、Bean の定義を記載する Configuration ファイルを作成します。今回はそれぞれ以下の場所に置きました。

└── adapter
    └── gateway
        └─ db
           ├── datasourceA
           │   ├─ datasourceAConfiguration.kt
           │   └─ datasourceAMapper.kt
           └── datasourceB
               ├─ datasourceBConfiguration.kt
               └─ datasourceBMapper.kt

Configuration ファイルの中身は以下のようになります。

@Configuration
@MapperScan(sqlSessionFactoryRef = "datasourceASqlSessionFactory")
class DatasourceADbConfiguration {
    @Bean
    @ConfigurationProperties(prefix = "spring.datasource.datasourceA")
    fun datasourceAProperties(): DataSourceProperties {
        return DataSourceProperties()
    }

    @Bean(name = ["datasourceA"])
    fun datasourceA(
        @Qualifier("datasourceAProperties") properties: DataSourceProperties
    ): DataSource {
        return properties.initializeDataSourceBuilder().build()
    }

    @Bean(name = ["datasourceATxManager"])
    fun datasourceATxManager(datasourceADataSource: DataSource): PlatformTransactionManager {
        return DataSourceTransactionManager(datasourceADataSource)
    }

    @Bean(name = ["datasourceASqlSessionFactory"])
    @Throws(Exception::class)
    fun sqlSessionFactory(@Qualifier("datasourceA") datasourceADataSource: DataSource?): SqlSessionFactory? {
        val sqlSessionFactory = SqlSessionFactoryBean()
        sqlSessionFactory.setDataSource(datasourceADataSource)
        return sqlSessionFactory.getObject()
    }
}

@MapperScan@Bean(name = ["datasourceASqlSessionFactory"]) は MyBatis 用の設定で、それ以外が Spring Boot の設定になります。

上述の application.yaml の設定値を @ConfigurationProperties(prefix = "spring.datasource.datasourceA") で読み取った DataSourceProperties から、 DataSource の Bean を作成し、作成された DataSource を元に TransactionManager の Bean を作成しているのがわかるかと思います。

これでそれぞれの DB に接続していい感じに動いてくれるようになりました。

Flyway を複数の DB に適用する

さて、これで問題なし……と思いきや、移行元のサービスでは Flyway を入れておらず、移行先のサービスでは入れているため、そのまま起動しようとするとエラーになってしまいます。

Caused by: org.flywaydb.core.api.FlywayException: Found non-empty schema(s) "public" but no schema history table. Use baseline() or set baselineOnMigrate to true to initialize the schema history table.

Flyway は起動時に flyway_schema_history というテーブルから現在のマイグレーションの状態を確認して、同じ変更を適用しないようにしてくれるのですが、DB に既にテーブルがあるのに flyway_schema_history テーブルがない場合はエラーになってしまうんですね。

これを回避するためには flyway_schema_history を手動で作成するか、Flyway の baseline コマンドでベースライン(Flyway の適用が開始された状態)を設定するか、baselineOnMigrate でベースラインを設定するかのどれかをする必要があります。今回は一番最後の、baselineOnMigrate でベースラインを設定する方式にしました。

baselineOnMigrate を適用する

baselineOnMigrate はだいたいの場合は以下のように設定すれば、先述した Spring Boot の Auto-Configuration で Flyway の Bean を作って適用してくれます。

設定値は以下をご参照ください。
- Baseline On Migrate - Flyway - Product Documentation
- Baseline Version - Flyway - Product Documentation

spring:
  flyway:
    enabled: true
    clean-on-validation-error: false
    baseline-on-migrate: true
    baseline-version: 1
Configuration ファイルの作成

しかし、このやり方だと複数の DB がある時に適用できません。Flyway の Bean も、DataSource と同じように手動で作成する必要があります。今回は Configuration ファイルを以下の場所に置きました。

src/main
├── kotlin/com/m3/service
│   ├── adapter
│   │   └── gateway
│   │       └─ db
│   │          ├── datasourceA
│   │          └── datasourceB
│   └── config
│       └── FlywayConfiguration.kt
│
└── resource/db/migration
    ├── datasourceA
    │   └── V1__init_datasourceA.sql
    └── datasourceB
        └── V1__init_datasrouceB.sql

Configuration ファイルの中身は以下のようになります。

@Configuration
class FlywayConfiguration {
    // すでに存在する DB に適用するため、baselineOnMigrate を true にする
    // init ファイルを適用したくないので、baselineVersion を 1 にする
    @Bean(name = ["datasourceAFluentConfiguration"])
    fun datasourceADbFluentConfiguration(): FluentConfiguration {
        return FluentConfiguration().baselineOnMigrate(true).baselineVersion("1")
    }

    @Bean(name = ["datasourceBFluentConfiguration"])
    fun datasourceBFluentConfiguration(): FluentConfiguration {
        return FluentConfiguration()
    }

    @Bean(name = ["datasourceAFlyway"], initMethod = "migrate")
    fun createDatasourceAFlyway(
        @Qualifier("datasourceAFluentConfiguration") fluentConfiguration: FluentConfiguration,
        @Qualifier("datasourceA") dataSource: DataSource?
    ): Flyway {
        return Flyway(
            fluentConfiguration.locations("db/migration/datasourceA").dataSource(dataSource)
        )
    }

    @Bean(name = ["datasourceBFlyway"], initMethod = "migrate")
    fun createDatasourceBFlyway(
        @Qualifier("datasourceBFluentConfiguration") fluentConfiguration: FluentConfiguration,
        @Qualifier("datasourceB") dataSource: DataSource?
    ): Flyway {
        return Flyway(
            fluentConfiguration.locations("db/migration/datasourceB").dataSource(dataSource)
        )
    }
}

FluentConfiguration は、Flyway の Bean で利用する設定を入れておく Bean です。本当は @ConfigurationProperties を利用して、上記の application.yaml で設定した内容を読み取らせるのですが、なぜかうまくいかなかったので、直指定しています。datasourceB の方は既存 DB で最初から Flyway が入っていたので、特に設定を追加していません。datasourceA の方は接続を追加する DB で、Flyway 自体を追加する必要があったため、init ファイルのバージョン(1)を指定することで、初期テーブルの作成はされているという状態で Flyway を開始するようにしました。

その下の createDatasourceAFlyway メソッドで、実際の Flyway の Bean を作成しています。引数で指定する FluentConfiguration は上記で作成した Bean を*3DataSource は、Spring Boot を複数 DB に接続するための Configuration に記載した Bean を指定します。それぞれにつけた名前を使って @Qualifier で呼び出します。

これで起動した時に、datasourceA は createDatasourceAFlyway で作成された Bean を、datasourceB は createDatasourceBFlyway で作成された Bean を使って Flyway を起動してくれるわけです。

テスト用の Configuration ファイルを作成

これで全ての工程が完了かと思えば、テストがいい感じに動きません。テストの時は init から migrate して欲しいのですが、そこの指定をしないといけません。そこで、テストの Mapper を置くディレクトリに、テスト用の Configuration ファイルを置きました。

src
├── main
│    ├── kotlin/com/m3/service
│    │   ├── adapter
│    │   │   └── gateway
│    │   │       └─ db
│    │   │          ├── datasourceA
│    │   │          └── datasourceB
│    │   └── config
│    │       └── FlywayConfiguration.kt
│    │
│    └── resource/db/migration
│        ├── datasourceA
│        └── datasourceB
│
└── test
     └── kotlin/com/m3/service
         └── adapter
             └── gateway
                 └─ db
                    ├── datasourceA
                    │   └── DatasourceAConfigurationTest.kt
                    └── datasourceB

中身は以下のようになります。

@Bean(name = ["datasourceAFlyway"], initMethod = "migrate")
fun createDatasourceAFlyway(@Qualifier("datasourceA") dataSource: DataSource?): Flyway {
    return Flyway(
        FluentConfiguration().locations("db/migration/datasourceA").dataSource(dataSource)
    )
}

FluentConfiguration を設定せず、初期状態で migrate するようにした、というだけですね。これでテストでは init から、通常起動では init は実行されずに Flyway 管理されるようになりました!
これで DB をいじった履歴が残って、誰がやらかしたかわかりますね!(怖い!)

終わりに

いかがでしたか?

今回のようにレガシーなシステムに追加で Flyway を入れるというのはままあることだと思いますが、別々で利用するというのはなかなかないのかもなと思いました。誰かの助けになれば幸いです。

We're hiring!

エムスリーでは一緒に働くエンジニアを募集しています。Spring Boot 以外にもさまざまなフレームワーク/言語のサービスを取り扱っているので、興味がある方は以下からお問い合わせください!

jobs.m3.com

*1:https://docs.spring.io/spring-boot/docs/2.0.x/reference/html/using-boot-auto-configuration.html

*2:手動で Bean が定義されていない場合のみ、Auto-Configuration で Bean が作成されるので、2 台以上の場合は全て手動で記載する必要があります

*3:datasourceB の方は追加で設定をしていないので、FluentConfiguration を明示的に指定する必要はないのですが、わかりやすくするために設定しています