LoginSignup
62

More than 5 years have passed since last update.

posted at

[Spring Boot] 動的データソース変更

SpringBootで動的にデータソースの切り替えをする必要ができたのでやり方を調べました。
ちなみに、lombok、JPA、MySQLを使ってます。

1.まずはシングルデータソースの場合

Configクラスを作成して(別にAppクラスに書いてもいいですが)DatasourceのBeanを作成します。
設定内容はとりあえずオンコーディングで。

DatasourceConfig
@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を作って動かすとこんな感じですね。

findallの結果
[{"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を複数定義します。

DatasourceConfig
@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にします。

DatasourceConfig
@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がそれに当たります。
もちろん継承さえしてれば名前はなんでもいいのですが。

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が呼ばれてどのデータソースを使うかを都度決定します。
ここで返すのはキー文字列だけなので、先ほどDatasourceConfigsetTargetDataSourcesに渡したHashMapのキーと対応させる必要があります。

さらにここで登場しているSchemaContextHolderについては次。

2.3.SchemaContextHolder

これはPOJOなので必ずしもこの形でなくてもいいのですが、ThreadLocalを使っているのが重要です。
WebServerなので誰かがデータソース変えたらみんなそっちに接続にいく、なんてことがあったら困りますので。

というわけで中身はこんな感じです。

SchemaContextHolder
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の中身。

SchemaType
public enum SchemaType {
    DATA1,
    DATA2
}

後は使うときにスキーマを切り替えてあげればOK。

controller
    @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の結果はこんな感じ。

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の結果はこんな感じ。

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で管理するようにしましょう。
共通の設定は何度も書かなくていいようにしましょう。

application.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つ作ります。

DatasourceBaseProperties(共通設定)
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;
}
DataSourceProperties01
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;
}
DataSourceProperties02
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"が大文字だったり小文字だったり統一感ないな・・・)

DatasourceConfig
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ももっとすっきりするんじゃね?

というわけでこんな感じです。

AbstractDatasourceProperties
@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;
    }
}
DatasourceProperties01
@Component
@ConfigurationProperties("datasource.data1")
public class DatasourceProperties01 extends AbstractDatasourceProperties {
    // has no methods...
}

(02も同じなので省略)

DatasourceConfig
@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.インターセプタ作成

DatasourceInterceptor
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に追記しましょう。

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.データソース切り替え用コントローラ

せっかくなのでついでに。こんな感じです。

DatasourceSettingController
@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";
    }
}

もちろん参照するの方の切り替え処理は消しておきます。

controller
    @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/

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
What you can do with signing up
62