Spring Boot: 複数DB利用時に初期化が期待通りに行われない問題と解決法
概要
複数DB利用時、初期化スクリプト(schema.sql
やdata.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度しか呼ばれないため」となる。
初期化スクリプトが実行されるまでの処理を追っていくと以下のようになる。
DataSourceInitializationConfiguration
がDataSourceInitializerInvoker
とDataSourceInitializationConfiguration.Registrar
をImport。DataSourceInitializationConfiguration.Registrar#registerBeanDefinitions
が呼ばれ、DataSourceInitializerPostProcessor
がBean定義される。DataSourceInitializerPostProcessor#postProcessAfterInitialization
が呼ばれ、DataSourceInitializerInvoker
のインスタンスが生成される。DataSourceInitializerInvoker#afterPropertiesSet
が呼ばれる。DataSourceInitializer#createSchema
が呼ばれる。- 初期化スクリプト実行。
手順3のDataSourceInitializerPostProcessor#postProcessAfterInitialization
呼び出しの箇所はDataSourceごとに実行される。
しかし、DataSourceInitializerInvoker
がsingletonであるため、DataSourceInitializerInvoker#afterPropertiesSet
が最初の1回しか呼ばれず、後続処理も1回しか実行されない結果となる。
参考資料
Spring Bootにおける複数DB利用は公式ドキュメントに一応記載がある。が、ちょっと記載不足を感じる。
複数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の画面から「パスワード ハッシュ パラメータ」を選択すると、ダイアログでパラメータが表示される。
ハッシュ値の計算
取得したハッシュパラメータを利用して、パスワードをハッシュ化する。
# 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のロジックボードが壊れて修理に出したため、開発環境がすべて消え去ってしまった。
IntelliJでJavaを書くための環境を再セットアップしようとしたところ、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をかければ、無事エラーが消えたことが確認できる。