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の処理と重複してしまう。

Firebaseの認証情報を外部に移行する

Firebaseでプロダクトを作ってたけど、色々キツくなって、やはりAWSに乗せ替えたいという話をよく聞く。
EC2の上に普通にアプリケーション組んでRDSにデータを移行すれば済む話だが、Firebase Authenticationのデータ移行ができない問題にぶち当たる。
どういうことかと言うと、パスワードがハッシュ化されてるから、移行後に全員にパスワードリセットさせないといけなくなるということだ。

しかし、これはよくある勘違いで正しくない。
Firebase Authenticationのハッシュアルゴリズムやパラメータは知ることができるので、同一アルゴリズム・パラメータでハッシュ計算をしてあげれば、パスワードリセットをさせることなくパスワード認証をすることができる。
パスワード認証を一度通した後に新しいハッシュアルゴリズム・パラメータでハッシュ計算し、以降はそのハッシュ値を認証に用いれば、徐々にFirebase Authenticationの遺産を捨てていくことができる(いわゆるrehash)。

それでは、検証用プロジェクトを作成して試していく。

認証情報の取得

まず、Firebaseに保存されている認証情報を取得する。
参考資料: https://firebase.google.com/docs/cli/auth?hl=ja

$ firebase auth:export auth.json

取得された認証情報の中身はこのようになっている。ハッシュ化されたパスワードとsaltが書かれている。

{"users": [
{
  "localId": "Qfh40RddndYxtiapxaiTRVIWwvq1",
  "email": "user@example.com",
  "emailVerified": false,
  "passwordHash": "WUBKepsUdgEv8aY8xPXvwJTajSP2ZpEylHpzrh8LyrMwjXPNJ5+ZRytsNDTY8oIwF/2QrdPMwwp0sQriJvQ4Pw==",
  "salt": "tgNRhCeGNElNzA==",
  "lastSignedInAt": "1531668171000",
  "createdAt": "1531667480000",
  "providerUserInfo": []
}]}

ハッシュ値計算

上記で取得されたハッシュ値を利用してパスワード認証する。同じハッシュ値が生成できるようになれば良い。

必要な情報は全てここに書かれている。
参考資料: https://github.com/firebase/scrypt

scryptのビルド

Firebase Authenticationにおけるscryptは独自に改変したものと書かれている。

Firebase Authentication uses an internally modified version of scrypt to hash account passwords.

ビルド方法のドキュメントに従い、独自改変されたscryptをビルドする。
参考資料: https://github.com/firebase/scrypt/blob/master/BUILDING

今回はmacで試すので、新しいopensslをhomebrewで入れておく(macに標準で入っているopensslでは動かない)。

$ brew install openssl

ビルドする。すると、scryptという名前のバイナリが生成される。

$ autoreconf -i
$ ./configure CPPFLAGS="-I/usr/local/opt/openssl/include" LDFLAGS="-L/usr/local/opt/openssl/lib"
$ make

ハッシュパラメータの取得

FirebaseのWebコンソール上で見ることができる。

Authenticationの画面から「パスワード ハッシュ パラメータ」を選択すると、ダイアログでパラメータが表示される。 f:id:aka_mythosil:20180716103034p:plain f:id:aka_mythosil:20180716103041p:plain

ハッシュ値の計算

取得したハッシュパラメータを利用して、パスワードをハッシュ化する。

# Params from the project's password hash parameters
base64_signer_key="QaqkkQw2B4WKY2d5WFngPnySkb75gWBoYorxnygbX9ThJDG9+5V3ZP5Qx5+Nj2tVSzcRirSxbM8L3SrnnrDhBg=="
base64_salt_separator="Bw=="
rounds=8
memcost=14

# Params from the exported account
base64_salt="tgNRhCeGNElNzA=="

# The users raw text password
password="password"

# Generate the hash
echo `./scrypt "$base64_signer_key" "$base64_salt" "$base64_salt_separator" "$rounds" "$memcost" -P <<< "$password"`

上記のシェルを実行するとハッシュ値が出力される。auth:exportで得たハッシュ値と比較すると、値が一致していることが分かる。

$ sh hash.sh
WUBKepsUdgEv8aY8xPXvwJTajSP2ZpEylHpzrh8LyrMwjXPNJ5+ZRytsNDTY8oIwF/2QrdPMwwp0sQriJvQ4Pw==

注意点

Firebase Authenticationにはauth:importという機能もある。外部の認証情報(パスワードハッシュ値アルゴリズム、パラメータ等)を取り込む機能である。
取り込んだ後に、パスワード認証が行われたタイミングでrehashされる。逆に言うと、パスワード認証が行われていない間はrehashされない。

rehashされていない状態での問題点は、auth:exportをした際に、そのユーザのパスワードハッシュ値が空になることである。
なので、外部の認証情報を過去にFirebaseに取り込んだことがあり、それを再度Firebase外に移行させる、といった場合には注意が必要になる。

IntelliJ: Could not determine Java version using executable ...

macbookのロジックボードが壊れて修理に出したため、開発環境がすべて消え去ってしまった。
IntelliJJavaを書くための環境を再セットアップしようとしたところ、gradleのバージョン問題でちょっとつまづいたのでメモ。

環境

問題

Gradleプロジェクトを作り、その際、gradle wrapperを使うように指定しておく。
すると、以下のようなエラーが出て、ビルドが失敗してしまう。

Could not determine Java version using executable
/Library/Java/JavaVirtualMachines/jdk-10.0.1.jdk/Contents/Home/bin/java.

ざっと調べてみると、Javaのバージョニングの変更の影響で、java -versionの出力をgradleがうまく解釈できないことが原因であるらしい。

ではgradleでうまく解釈させることができるか、という問いに対しては「新しいgradleを入れる」ことが解となる。
stackoverflowなどで解決策として示されているのはローカルインストールした新しいgradleを利用するよう設定を変える、というものが多い。
確かにそれで解決するだろうけども、複数人開発してる場合にはgradle wrapperを使いたい。

解決

新しいgradle wrapperを入れる。
gradleをローカルインストールしておく必要はあるが、1回だけやってwrapperをリポジトリに含めてしまえば済む。

$ ls -a
./               ../              .idea/           build.gradle     settings.gradle

$ gradle wrapper --gradle-version=4.7 --distribution-type=bin
BUILD SUCCESSFUL in 1s
1 actionable task: 1 executed

$ ls -la
./               .gradle/         build.gradle     gradlew*         settings.gradle
../              .idea/           gradle/          gradlew.bat

この状態で、IntelliJでプロジェクトのSyncをかければ、無事エラーが消えたことが確認できる。