SpringBootで動的にデータソースの切り替えをする必要ができたのでやり方を調べました。
ちなみに、lombok、JPA、MySQLを使ってます。
#1.まずはシングルデータソースの場合
Configクラスを作成して(別にAppクラスに書いてもいいですが)DatasourceのBeanを作成します。
設定内容はとりあえずオンコーディングで。
@Component
@Configuration
public class DatasourceConfig {
@Bean
public DataSource datasource(){
DataSource ds = new org.apache.tomcat.jdbc.pool.DataSource();
ds.setDriverClassName("com.mysql.jdbc.Driver");
ds.setUrl("jdbc:mysql://localhost:3306/sample01");
ds.setUsername("user01");
ds.setPassword("pass");
return ds;
}
}
確認用にテーブルの中身を単純に吐き出すRestControllerを作って動かすとこんな感じですね。
[{"name":"キャラ","age":21},
{"name":"丸山","age":33},
{"name":"亀岡","age":19},
{"name":"倉員","age":28},
{"name":"本並","age":41},
{"name":"林","age":26},
{"name":"齋藤","age":15}]
※データは適当です(特に年齢)
#2.データソースを複数にして動的に変更できるようにする
##2.1.DatasourceConfigの変更
DataSouceを複数定義します。
@Component
@Configuration
public class DatasourceConfig {
@Bean
public DataSource datasource1(){
DataSource ds = new org.apache.tomcat.jdbc.pool.DataSource();
ds.setDriverClassName("com.mysql.jdbc.Driver");
ds.setUrl("jdbc:mysql://localhost:3306/sample01");
ds.setUsername("user01");
ds.setPassword("pass");
return ds;
}
@Bean
public DataSource datasource2(){
DataSource ds = new org.apache.tomcat.jdbc.pool.DataSource();
ds.setDriverClassName("com.mysql.jdbc.Driver");
ds.setUrl("jdbc:mysql://localhost:3306/sample02");
ds.setUsername("user02");
ds.setPassword("pass");
return ds;
}
}
これで実行するとどちらのデータソースを使うかわからないから@Primaryつけろよ!とエラーが出てきます。
2016-10-29 16:11:43.069 WARN 8160 --- [ main] ationConfigEmbeddedWebApplicationContext : Exception encountered during context initialization - cancelling refresh attempt: org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration': Unsatisfied dependency expressed through constructor parameter 0; nested exception is org.springframework.beans.factory.NoUniqueBeanDefinitionException: No qualifying bean of type [javax.sql.DataSource] is defined: expected single matching bean but found 2: datasource1,datasource2
(中略)
Consider marking one of the beans as @Primary, updating the consumer to accept multiple beans, or using @Qualifier to identify the bean that should be consumed
もちろんどっちかにPrimaryつければエラーは無くなりますが、それでは動的変更になりませんので、動的に変更してくれる設定を作り、そちらをPrimaryにします。
@Component
@Configuration
public class DatasourceConfig {
// @Bean
public DataSource datasource1(){
DataSource ds = new org.apache.tomcat.jdbc.pool.DataSource();
ds.setDriverClassName("com.mysql.jdbc.Driver");
ds.setUrl("jdbc:mysql://localhost:3306/sample01");
ds.setUsername("user01");
ds.setPassword("pass");
return ds;
}
// @Bean
public DataSource datasource2(){
DataSource ds = new org.apache.tomcat.jdbc.pool.DataSource();
ds.setDriverClassName("com.mysql.jdbc.Driver");
ds.setUrl("jdbc:mysql://localhost:3306/sample02");
ds.setUsername("user02");
ds.setPassword("pass");
return ds;
}
@Bean
// @Primary
public DynamicRoutingDataSourceResolver dataSource() {
DynamicRoutingDataSourceResolver resolver = new DynamicRoutingDataSourceResolver();
Map<Object, Object> dataSources = new HashMap<Object,Object>();
dataSources.put("datasource1", datasource1());
dataSources.put("datasource2", datasource2());
resolver.setTargetDataSources(dataSources);
// default datasource
resolver.setDefaultTargetDataSource(datasource1());
return resolver;
}
}
と思ったら今1.4.1でやると(?)@Beanを1つにしないとうまく動かなかったですね。うーむ。
あと、私の環境では(?)resolver.setDefaultTargetDataSource
を指定してあげないとうまく動いてくれませんでした。
突然出てきたDynamicRoutingDataSourceResolver
さんは次で詳しく。
##2.2.DynamicRoutingDataSourceResolver
データソースを使用する前に動的にどのデータソースを使用するか解決してあげるSpringの機能を使います。
AbstractRoutingDataSource
を継承して作るDynamicRoutingDataSourceResolver
がそれに当たります。
もちろん継承さえしてれば名前はなんでもいいのですが。
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
public class DynamicRoutingDataSourceResolver extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
if (SchemaContextHolder.getSchemaType() == null) {
// デフォルト
return "datasource1";
} else if(SchemaContextHolder.getSchemaType() == SchemaType.DATA1) {
return "datasource1";
} else if(SchemaContextHolder.getSchemaType() == SchemaType.DATA2) {
return "datasource2";
}else{
// デフォルト
return "datasource1";
}
}
}
セッションを使用する前にこのdetermineCurrentLookupKey
が呼ばれてどのデータソースを使うかを都度決定します。
ここで返すのはキー文字列だけなので、先ほどDatasourceConfig
でsetTargetDataSources
に渡したHashMapのキーと対応させる必要があります。
さらにここで登場しているSchemaContextHolderについては次。
##2.3.SchemaContextHolder
これはPOJOなので必ずしもこの形でなくてもいいのですが、ThreadLocal
を使っているのが重要です。
WebServerなので誰かがデータソース変えたらみんなそっちに接続にいく、なんてことがあったら困りますので。
というわけで中身はこんな感じです。
public class SchemaContextHolder {
private static ThreadLocal<SchemaType> contextHolder = new ThreadLocal<SchemaType>();
public static void setSchemaType(SchemaType datasourcename) {
Assert.notNull(datasourcename, "Schema type cannot be null.");
contextHolder.set(datasourcename);
}
public static SchemaType getSchemaType() {
return contextHolder.get();
}
public static void clear() {
contextHolder.remove();
}
}
ここでは(というかどのサンプルを見ても)SchemaTypeというEnumを作っていますが、別にStringでもちゃんと動きます。
一応SchemaTypeの中身。
public enum SchemaType {
DATA1,
DATA2
}
後は使うときにスキーマを切り替えてあげればOK。
@GetMapping("findall")
public List<User> findall(@RequestParam("ds") String ds){
if(ds.equals("ds1")){
SchemaContextHolder.setSchemaType(SchemaType.DATA1);
}else{
SchemaContextHolder.setSchemaType(SchemaType.DATA2);
}
return userService.findAll();
}
これで実行すると
http://localhost:8080/user/findall?ds=ds1
の結果はこんな感じ。
[{"name":"キャラ","age":21},
{"name":"丸山","age":33},
{"name":"亀岡","age":19},
{"name":"倉員","age":28},
{"name":"本並","age":41},
{"name":"林","age":26},
{"name":"齋藤","age":15}]
※データは適当です。(特に年齢!!!)
http://localhost:8080/user/findall?ds=ds2
の結果はこんな感じ。
[{"name":"サラ","age":26},
{"name":"上野","age":15},
{"name":"壺井","age":22},
{"name":"巴月","age":28},
{"name":"成宮","age":18},
{"name":"秋葉","age":19},
{"name":"虎尾","age":20}]
※データは適当です。(特に年齢!!!!!!)
さてまあ、ここまででもおしまいなのですが、もうちょっと手を入れていきます。
#3.接続先をapplication.ymlで管理
接続先がオンコーディングなんておしゃれじゃないのでappliation.ymlで管理するようにしましょう。
共通の設定は何度も書かなくていいようにしましょう。
# Datasource BaseSetting
my.datasource.abstract:
driverClassName: com.mysql.jdbc.Driver
sqlScriptEncoding: UTF-8
# pooling
maxActive: 15
maxIdle: 10
minIdle: 5
initialSize: 2
# Re-connect
validationQuery: SELECT 1 FROM DUAL
testOnBorrow: true
testWhileIdle: true
timeBetweenEvictionRunsMillis: 600000
minEvictableIdleTimeMillis: 600000
# datasource1
datasource.data1:
url: jdbc:mysql://localhost:3306/sample01
username: user01
password: pass
# datasource2
datasource.data2:
url: jdbc:mysql://localhost:3306/sample02
username: user02
password: pass
で、こいつを読み取るためのクラスを3つ作ります。
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import lombok.Data;
@Data
@Component
@ConfigurationProperties("my.datasource.abstract")
public class DatasourceBaseProperties {
private String driverClassName;
private String SqlScriptEncoding;
private Integer maxActive;
private Integer maxIdle;
private Integer minIdle;
private Integer initialSize;
private String validationQuery;
private Boolean testOnBorrow;
private Boolean testWhileIdle;
private Integer timeBetweenEvictionRunsMillis;
private Integer minEvictableIdleTimeMillis;
}
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import lombok.Data;
@Data
@Component
@ConfigurationProperties("datasource.data1")
public class DatasourceProperties01 {
private String url;
private String username;
private String password;
}
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import lombok.Data;
@Data
@Component
@ConfigurationProperties("datasource.data2")
public class DatasourceProperties02 {
private String url;
private String username;
private String password;
}
@ComponentをつけるとSpringBootの起動時に設定スキャンの対象になります。
@ConfigurationPropertiesをつけるとその値+変数名と同名のapplication.ymlの設定が反映されます。
つまりDatasourceConfigはこう変わるわけです。
(それにしてもsourceの"s"が大文字だったり小文字だったり統一感ないな・・・)
public class DatasourceConfig {
@Autowired
DatasourceBaseProperties datasourceBase;
@Autowired
DatasourceProperties01 datasourceP01;
@Autowired
DatasourceProperties02 datasourceP02;
// @Bean
public DataSource datasource1(){
DataSource ds = new org.apache.tomcat.jdbc.pool.DataSource();
ds.setDriverClassName(this.datasourceBase.getDriverClassName());
ds.setUrl(this.datasourceP01.getUrl());
ds.setUsername(this.datasourceP01.getUsername());
ds.setPassword(this.datasourceP01.getPassword());
return ds;
}
// @Bean
public DataSource datasource2(){
DataSource ds = new org.apache.tomcat.jdbc.pool.DataSource();
ds.setDriverClassName(this.datasourceBase.getDriverClassName());
ds.setUrl(this.datasourceP02.getUrl());
ds.setUsername(this.datasourceP02.getUsername());
ds.setPassword(this.datasourceP02.getPassword());
return ds;
}
(以下略)
結果は変わらないので省略!
もちろん「OracleでDBが1個でスキーマが違うだけだからurlも一緒でいいんじゃよ」という場合はurlを共通にまわせばOK。
めでたしめでたし。
#4.クラスをすっきりさせよう
同じ記述が何回も出てくるのって気持ち悪いですよね!
というわけですっきりさせます。
・DatasourceProperties01とDatasourceProperties02の共通項目を抜き出すために基底クラスを作成する
・application.ymlの設定を取得するクラスとDatasourceProperties01が別なのがもやっとするので一緒にしてしまう。
・あれ?じゃあDatasourceConfigももっとすっきりするんじゃね?
というわけでこんな感じです。
@Data
public class AbstractDatasourceProperties {
@Autowired
DatasourceBaseProperties dataSourceProperties;
protected String url;
protected String username;
protected String password;
@Bean
public DataSource createDataSourceBean(){
DataSource ds = new org.apache.tomcat.jdbc.pool.DataSource();
ds.setDriverClassName(this.dataSourceProperties.getDriverClassName());
ds.setMaxActive(this.dataSourceProperties.getMaxActive());
ds.setMaxIdle(this.dataSourceProperties.getMaxIdle());
ds.setMinIdle(this.dataSourceProperties.getMinIdle());
ds.setInitialSize(this.dataSourceProperties.getInitialSize());
ds.setValidationQuery(this.dataSourceProperties.getValidationQuery());
ds.setTestOnBorrow(this.dataSourceProperties.getTestOnBorrow());
ds.setTestWhileIdle(this.dataSourceProperties.getTestWhileIdle());
ds.setTimeBetweenEvictionRunsMillis(this.dataSourceProperties.getTimeBetweenEvictionRunsMillis());
ds.setMinEvictableIdleTimeMillis(this.dataSourceProperties.getMinEvictableIdleTimeMillis());
ds.setUrl(this.getUrl());
ds.setUsername(this.getUsername());
ds.setPassword(this.getPassword());
return ds;
}
}
@Component
@ConfigurationProperties("datasource.data1")
public class DatasourceProperties01 extends AbstractDatasourceProperties {
// has no methods...
}
(02も同じなので省略)
@Component
@Configuration
public class DatasourceConfig {
@Autowired
DatasourceBaseProperties datasourceBase;
@Autowired
DatasourceProperties01 datasourceP01;
@Autowired
DatasourceProperties02 datasourceP02;
@Bean
@Primary
public DynamicRoutingDataSourceResolver dataSource() {
DynamicRoutingDataSourceResolver resolver = new DynamicRoutingDataSourceResolver();
Map<Object, Object> dataSources = new HashMap<Object,Object>();
dataSources.put("datasource1", datasourceP01.createDataSourceBean());
dataSources.put("datasource2", datasourceP02.createDataSourceBean());
resolver.setTargetDataSources(dataSources);
// default datasource
resolver.setDefaultTargetDataSource(datasourceP01.createDataSourceBean());
return resolver;
}
}
(DatasourceBasePropertiesは変更なし)
結果も使い方も変わらずですね。
めでたしめでたし。
#5.インターセプタでデータソースを切り替えるようにする。
人や権限や設定によって使用するスキーマ(データベース)を切り替える。どのテーブルも一緒。
っていうことはつまり、処理によって見るデータソースが変わるなんてことがあるとまずい。
もし「あれ?ここの処理だけいつもuser01のDB見てね?」なんてバグが見つかったら不毛な横展開祭りの始まりです。
よくわかってない人が機能追加や修正したら壊れること間違いなし。
共通処理作ったってその処理呼び出し忘れてるなんてことは当然考えられます。
危険なことこの上ないのでこんなものインターセプタでやるようにしましょう。個々の処理で切り替えるなんてやめやめ!
##5.1.インターセプタ作成
public class DatasourceInterceptor extends HandlerInterceptorAdapter {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
if(!StringUtils.isEmpty(request.getSession().getAttribute("datasource"))){
SchemaContextHolder.setSchemaType((SchemaType)request.getSession().getAttribute("datasource"));
}
return true;
}
}
インターセプタを作るにはHandlerInterceptorAdapter
を継承すればOK。
データソースの設定はセッション情報に持つようにしています。
どうでもいいけどインターセプタに関する英語サイトを自動翻訳するとことごとく「迎撃戦闘機」に翻訳してくれるの面白すぎですよね。合ってるけど。
##5.2.インターセプタ登録
作成したインターセプタを使うようにConfigクラスに追記します。ここでは先ほど作ったDatasourceConfigに追記しましょう。
@Bean
public HandlerInterceptor datasourceInterceptor(){
return new com.example.interceptor.DatasourceInterceptor();
}
@Bean
public MappedInterceptor interceptorMapping(){
return new MappedInterceptor(new String[]{"/**"}, datasourceInterceptor());
}
MappedInterceptorではインターセプタを適用するURL、適用除外するURLが指定できます。
今回は全部対象にしてしまいます。
##5.3.データソース切り替え用コントローラ
せっかくなのでついでに。こんな感じです。
@RestController
@RequestMapping("datasource/")
public class DatasourceSettingController {
@RequestMapping("set")
public String setDatasource(HttpServletRequest request, @RequestParam("ds") String ds){
SchemaType type;
switch(ds){
case "ds1":
type = SchemaType.DATA1;
break;
case "ds2":
type = SchemaType.DATA2;
break;
default:
type = SchemaType.DATA1;
}
request.getSession().setAttribute("datasource", type);
return "OK";
}
}
もちろん参照するの方の切り替え処理は消しておきます。
@GetMapping("findall")
public List<User> findall(){
return userService.findAll();
}
これでhttp://localhost:8080/datasource/set?ds=ds2
を叩いてからfindall
すると結果が変わるようになりました。
めでたしめでたし!!!!
#今回のソース
GitHubに置きました。
https://github.com/syukai/SpringbootMultiDatasourceSample
1~5のそれぞれでcommit切ってるので参考までに。
#参考サイト
●Spring Blog - Dynamic DataSource Routing
http://spring.io/blog/2007/01/23/dynamic-datasource-routing/
⇒これがすべてといえばすべてなのですが、SpringBootなんでBeanの書き方が違うとか、application.ymlとのつながりとか色々と書きたかったので。
●m-namikiの日記 データソースの動的切り替え
http://m-namiki.hatenablog.jp/entries/2011/11/24
⇒大変参考になりました。たぶんググって最初にいきつくのはここですよね。
●Alexander Fedulov - Dynamic DataSource Routing with Spring @Transactional
http://fedulov.website/2015/10/14/dynamic-datasource-routing-with-spring/