Rhythm & Biology

Engineering, Science, et al.

Spring Boot: 複数DB利用時に初期化が期待通りに行われない問題と解決法

概要

複数DB利用時、初期化スクリプトschema.sqldata.sql)が適切に実行されないという問題が発生。 具体的には、@Primary指定したDataSourceでは実行されるが、それ以外では実行されずテーブル作成などの初期化処理が行われないというもの。

調査の結果、仕組み上このような動作になるのは仕方がないことがわかった(仕様なのかはどうかは不明)。
この問題が発生する理由と解決法をまとめる。

検証環境

  • Spring Boot 2.1.6.RELEASE
  • Spring Data JPA
  • H2

解決法

まず先に解決法を整理。
ざっくり結論を書くと「DataSourceごとにDataSourceInitializerをBean定義する」ことで解決する。

今回は "user" と "book" という2つのDB(どちらもH2)に接続するという前提にする。

application.yaml上のDataSourceに関する設定は以下のようにする。
initialization-mode: neverを忘れずにつけること。 *1

app:
  datasource:
    userdb:
      url: jdbc:h2:mem:userdb
      driver-class-name: org.h2.Driver
      username: sa
      password: password
      schema: classpath:schema-userdb.sql
      data: classpath:data-userdb.sql
      initialization-mode: never
    bookdb:
      url: jdbc:h2:mem:bookdb
      driver-class-name: org.h2.Driver
      username: sa
      password: password
      schema: classpath:schema-bookdb.sql
      data: classpath:data-bookdb.sql
      initialization-mode: never

まず "user" DB側のConfiguration。こちらをPrimaryにする。
今回重要なのはDataSourceInitializerのBean定義の箇所。

@Configuration
@EnableJpaRepositories(
        basePackages = "demo.multidb.repository.userdb",
        entityManagerFactoryRef = "userEntityManagerFactory",
        transactionManagerRef = "userTransactionManager"
)
public class UserDbConfig {

    @Primary
    @Bean(name = "userDataSourceProperties")
    @ConfigurationProperties(prefix = "app.datasource.userdb")
    public DataSourceProperties dataSourceProperties() {
        return new DataSourceProperties();
    }

    @Primary
    @Bean(name = "userDataSource")
    public DataSource dataSource(
            @Qualifier("userDataSourceProperties") DataSourceProperties dataSourceProperties) {
        return dataSourceProperties.initializeDataSourceBuilder().build();
    }

    @Primary
    @Bean(name = "userEntityManagerFactory")
    public LocalContainerEntityManagerFactoryBean entityManagerFactory(
            EntityManagerFactoryBuilder builder,
            @Qualifier("userDataSource") DataSource dataSource) {
        return builder
                .dataSource(dataSource)
                .packages("demo.multidb.entity.userdb")
                .persistenceUnit("userPU")
                .build();
    }

    @Primary
    @Bean(name = "userTransactionManager")
    public PlatformTransactionManager transactionManager(
            @Qualifier("userEntityManagerFactory") EntityManagerFactory entityManagerFactory) {
        return new JpaTransactionManager(entityManagerFactory);
    }

    @Primary
    @Bean(name = "userDataSourceInitializer")
    public DataSourceInitializer dataSourceInitializer(
            @Qualifier("userDataSourceProperties") DataSourceProperties dataSourceProperties,
            @Qualifier("userDataSource") DataSource dataSource) {
        ResourceDatabasePopulator populator = new ResourceDatabasePopulator();
        ResourceLoader resourceLoader = new DefaultResourceLoader();
        Stream.concat(dataSourceProperties.getSchema().stream(), dataSourceProperties.getData().stream())
                .map(resourceLoader::getResource)
                .forEach(populator::addScript);

        DataSourceInitializer dataSourceInitializer = new DataSourceInitializer();
        dataSourceInitializer.setDataSource(dataSource);
        dataSourceInitializer.setDatabasePopulator(populator);
        return dataSourceInitializer;
    }

}

次に "book" DB側のConfiguration。

@Configuration
@EnableJpaRepositories(
        basePackages = "demo.multidb.repository.bookdb",
        entityManagerFactoryRef = "bookEntityManagerFactory",
        transactionManagerRef = "bookTransactionManager"
)
public class BookDbConfig {

    @Bean(name = "bookDataSourceProperties")
    @ConfigurationProperties(prefix = "app.datasource.bookdb")
    public DataSourceProperties dataSourceProperties() {
        return new DataSourceProperties();
    }

    @Bean(name = "bookDataSource")
    public DataSource dataSource(
            @Qualifier("bookDataSourceProperties") DataSourceProperties dataSourceProperties) {
        return dataSourceProperties.initializeDataSourceBuilder().build();
    }

    @Bean(name = "bookEntityManagerFactory")
    public LocalContainerEntityManagerFactoryBean entityManagerFactory(
            EntityManagerFactoryBuilder builder,
            @Qualifier("bookDataSource") DataSource dataSource) {
        return builder
                .dataSource(dataSource)
                .packages("demo.multidb.entity.bookdb")
                .persistenceUnit("bookPU")
                .build();
    }

    @Bean(name = "bookTransactionManager")
    public PlatformTransactionManager transactionManager(
            @Qualifier("bookEntityManagerFactory") EntityManagerFactory entityManagerFactory) {
        return new JpaTransactionManager(entityManagerFactory);
    }

    @Bean(name = "bookDataSourceInitializer")
    public DataSourceInitializer dataSourceInitializer(
            @Qualifier("bookDataSourceProperties") DataSourceProperties dataSourceProperties,
            @Qualifier("bookDataSource") DataSource dataSource) {
        ResourceDatabasePopulator populator = new ResourceDatabasePopulator();
        ResourceLoader resourceLoader = new DefaultResourceLoader();
        Stream.concat(dataSourceProperties.getSchema().stream(), dataSourceProperties.getData().stream())
                .map(resourceLoader::getResource)
                .forEach(populator::addScript);

        DataSourceInitializer dataSourceInitializer = new DataSourceInitializer();
        dataSourceInitializer.setDataSource(dataSource);
        dataSourceInitializer.setDatabasePopulator(populator);
        return dataSourceInitializer;
    }

}

あとは以下のファイルを用意すれば完成。

  • src/main/resources/schema-userdb.sql
  • src/main/resources/data-userdb.sql
  • src/main/resources/schema-bookdb.sql
  • src/main/resources/data-bookdb.sql

原因詳細

ざっと原因を書くと「DataSourceInitializerInvokerがsingletonで、DataSourceInitializerInvoker#afterPropertiesSetが1度しか呼ばれないため」となる。

初期化スクリプトが実行されるまでの処理を追っていくと以下のようになる。

  1. DataSourceInitializationConfigurationDataSourceInitializerInvokerDataSourceInitializationConfiguration.RegistrarをImport。
  2. DataSourceInitializationConfiguration.Registrar#registerBeanDefinitionsが呼ばれ、DataSourceInitializerPostProcessorがBean定義される。
  3. DataSourceInitializerPostProcessor#postProcessAfterInitializationが呼ばれ、DataSourceInitializerInvokerインスタンスが生成される。
  4. DataSourceInitializerInvoker#afterPropertiesSetが呼ばれる。
  5. DataSourceInitializer#createSchemaが呼ばれる。
  6. 初期化スクリプト実行。

手順3のDataSourceInitializerPostProcessor#postProcessAfterInitialization呼び出しの箇所はDataSourceごとに実行される。
しかし、DataSourceInitializerInvokerがsingletonであるため、DataSourceInitializerInvoker#afterPropertiesSetが最初の1回しか呼ばれず、後続処理も1回しか実行されない結果となる。

spring-boot/DataSourceInitializerPostProcessor.java at 605599138ec56d2230d92f423fdbcfab53311682 · spring-projects/spring-boot · GitHub

参考資料

Spring Bootにおける複数DB利用は公式ドキュメントに一応記載がある。が、ちょっと記載不足を感じる。

https://docs.spring.io/spring-boot/docs/current/reference/html/howto-data-access.html#howto-two-datasources

複数DB利用の記事はたくさんあるが、この記事が一番簡潔でわかりやすいかもしれない。

Using multiple datasources with Spring Boot and Spring Data 💈 ⇄🌱 ⇄ 💈

この問題に関するStack Overflowでのスレッド。回答者は "expected behavior" と書いているが本当にそうなのか?期待動作の定義は特にされておらず、たまたまそうなっただけにも感じる。

java - Spring Boot 2 Multiple Datasources initialize schema - Stack Overflow

*1:"initialization-mode: never"を忘れると、Primary側のDataSourceはデフォルトの初期化処理が動いてしまい、今回自前で用意したDataSourceInitializerの処理と重複してしまう。