BLOG

ブログ

【Spring Boot】マルチテナント型データソースの動的切り替え

こちらは、Mavs Advent Calendar 2023の17日目の記事です!🎄✨

こんにちは、兼岡です。

最近、Spring Bootでデータソースを動的に切り替えるという要件があり、実現方法を調べながら進めることがあったので、備忘録としてまとめました。

application.ymlに設定してあるプライマリーとセカンダリーの切り替え方法については、調べたらすぐに見つかったのですが、データベースが多数存在する場合の切り替え方法の調査に苦労したので、参考になればと思います!

はじめに

SaaSやASPのようなマルチテナント型のサービスでは、下図のように、各テナント毎にデータベースを分ける場合があります。

この例では、システム共通データベース(system)が存在し、それぞれのテナントに対応するデータベース接続情報を持っています。

ユーザーはテナントIDとAPIキーなどで認証を行い、対応するテナントデータベース(tenant01、tenant02など)にアクセスすることができます。

ただし、もし他のテナントの情報を取得できてしまったら大変なので、各データベース毎の設定クラスを用意することで、接続先のデータベースを明示的に指定しなくても済むようにします。

動作環境

JavaAmazon Corretto 17
Spring Boot3.1.1
MySQL8.0

ディレクトリ構成

最終的なディレクトリ構成は以下のようになりました。

.
└── main
    ├── java
    │   └── com
    │       ├── domain
    │       │   ├── datasource
    │       │   │   └── DynamicRoutingDataSource.java  // データソースを動的に切り替えるためのクラス
    │       │   ├── entity
    │       │   │   ├── Connection.java                // データベース接続情報エンティティ
    │       │   │   └── User.java                      // ユーザー情報エンティティ
    │       │   ├── repository
    │       │   │   ├── config
    │       │   │   │   ├── SystemConfig.java          // システム共通データベース(system)用の設定クラス
    │       │   │   │   └── TenantConfig.java          // テナントデータベース(tenant)用の設定クラス
    │       │   │   ├── system
    │       │   │   │   └── ConnectionRepository.java  // データベース接続情報リポジトリ
    │       │   │   └── tenant
    │       │   │       └── UserRepository.java        // ユーザー情報リポジトリ
    │       │   └── service
    │       │       ├── ConnectionService.java         // データベース接続情報サービス
    │       │       └── UserService.java               // ユーザー情報サービス
    │       ├── mavs
    │       │   ├── common
    │       │   │   ├── config
    │       │   │   │   └── WebMvcConfig.java          // WebMVC設定用クラス
    │       │   │   └── interceptor
    │       │   │       └── DataSourceInterceptor.java // データソース用の共通処理クラス
    │       │   └── controller
    │       │       └── UserController.java            // ユーザー情報コントローラー
    │       └── Application.java
    └── resources
        └── application.yml

データソース動的切り替え処理の実装

プロパティファイル(application.yml)の設定

まず、データベースの接続情報を設定します。
テナントデータベースはテナントIDによって動的に切り替えるのですが、初期設定としてtenant01の接続情報を指定しました。

spring:
  datasource:
    # システム共通データベース
    system:
      url: jdbc:mysql://localhost:3306/system
      username: mysql_user
      password: mysql_password
    # テナントデータベース
    tenant:
      url: jdbc:mysql://localhost:3306/tenant01
      username: mysql_user
      password: mysql_password

Bean設定クラスの実装

システム共通データベース(system)用の設定クラスを作成します。

EnableJpaRepositoriesアノテーションでsystemパッケージ内のリポジトリ(com.domain.repository.system)をBeanに登録しています。

今回の例では、connectionsテーブルにアクセスする際にこのデータソースが使用されます。

/**
 * システム共通データベース(system)用の設定クラス
 */
@Configuration
@EnableJpaRepositories(basePackages = {"com.domain.repository.system"},
        entityManagerFactoryRef = "systemEntityManager",
        transactionManagerRef = "systemTransactionManager")
public class SystemConfig {
    /** JDBCドライバーURL */
    @Value("${spring.datasource.system.url}")
    private String url;

    /** ユーザー名 */
    @Value("${spring.datasource.system.username}")
    private String username;

    /** パスワード */
    @Value("${spring.datasource.system.password}")
    private String password;

    /** データソース */
    private HikariDataSource dataSource;

    /**
     * データソースを生成
     * 
     * @return HikariDataSource
     */
    @Primary
    @Bean(name = "system")
    public HikariDataSource createDataSource() {
        dataSource = new HikariDataSource();
        dataSource.setJdbcUrl(url);
        dataSource.setUsername(username);
        dataSource.setPassword(password);
        return dataSource;
    }

    /**
     * エンティティマネージャーファクトリを生成
     * 
     * @param dataSource データソース
     * @return EntityManagerFactory
     */
    @Primary
    @Bean(name = "systemEntityManager")
    public EntityManagerFactory mySqlEntityManagerFactory(
            @Qualifier("system") HikariDataSource dataSource) {

        LocalContainerEntityManagerFactoryBean factory =
                new LocalContainerEntityManagerFactoryBean();
        factory.setDataSource(dataSource);
        factory.setPackagesToScan("com.domain.entity");

        HibernateJpaVendorAdapter vendorAdapter = new HibernateJpaVendorAdapter();
        factory.setJpaVendorAdapter(vendorAdapter);
        factory.afterPropertiesSet();

        return factory.getObject();
    }

    /**
     * トランザクションマネージャーを生成
     * 
     * @param entityManagerFactory エンティティマネージャーファクトリ
     * @return JpaTransactionManager
     */
    @Primary
    @Bean(name = "systemTransactionManager")
    public PlatformTransactionManager transactionManager(
            @Qualifier("systemEntityManager") EntityManagerFactory entityManagerFactory) {
        return new JpaTransactionManager(entityManagerFactory);
    }

    /**
     * DIコンテナが破棄される前にデータソースをクローズ
     */
    @PreDestroy
    public void closeDataSource() {
        if (dataSource != null && !dataSource.isClosed()) {
            dataSource.close();
        }
    }
}

次に、テナントデータベース(tenant)用の設定クラスを作成します。

同様にEnableJpaRepositoriesアノテーションでtenantパッケージ内のリポジトリ(com.domain.repository.tenant)をBeanに登録します。

システム共通データベースと違い、データソースを生成する際にHikariDataSourceではなく、DynamicRoutingDataSourceを使用しているところがポイントです。
この、AbstractRoutingDataSourceを継承したクラスを利用することで、tenant01、tenant02と接続先を動的に切り替えることができます。

/**
 * テナントデータベース(tenant)用の設定クラス
 */
@Configuration
@EnableJpaRepositories(basePackages = {"com.domain.repository.tenant"},
        entityManagerFactoryRef = "tenantEntityManager",
        transactionManagerRef = "tenantTransactionManager")
public class TenantConfig {
    /** JDBCドライバーURL */
    @Value("${spring.datasource.tenant.url}")
    private String url;

    /** ユーザー名 */
    @Value("${spring.datasource.tenant.username}")
    private String username;

    /** パスワード */
    @Value("${spring.datasource.tenant.password}")
    private String password;

    /** デフォルトのデータソース */
    private HikariDataSource defaultTargetDataSource;

    /** データソース */
    private DynamicRoutingDataSource dataSource;

    /**
     * データソースを生成
     * 
     * @return HikariDataSource
     */
    @Bean(name = "tenant")
    public DynamicRoutingDataSource createDataSource() {
        defaultTargetDataSource = new HikariDataSource();
        defaultTargetDataSource.setJdbcUrl(url);
        defaultTargetDataSource.setUsername(username);
        defaultTargetDataSource.setPassword(password);

        Map<Object, Object> targetDataSources = new HashMap<>();
        targetDataSources.put("tenant", defaultTargetDataSource);
        dataSource = new DynamicRoutingDataSource();
        dataSource.setTargetDataSources(targetDataSources);
        dataSource.setDefaultTargetDataSource(defaultTargetDataSource);
        dataSource.afterPropertiesSet();

        DynamicRoutingDataSource.setCurrentLookupKey("tenant");
        return dataSource;
    }

    /**
     * エンティティマネージャーファクトリを生成
     * 
     * @param dataSource データソース
     * @return EntityManagerFactory
     */
    @Bean(name = "tenantEntityManager")
    public EntityManagerFactory mySqlEntityManagerFactory(
            @Qualifier("tenant") DynamicRoutingDataSource dataSource) {

        LocalContainerEntityManagerFactoryBean factory =
                new LocalContainerEntityManagerFactoryBean();
        factory.setDataSource(dataSource);
        factory.setPackagesToScan("com.domain.entity");

        HibernateJpaVendorAdapter vendorAdapter = new HibernateJpaVendorAdapter();
        factory.setJpaVendorAdapter(vendorAdapter);
        factory.afterPropertiesSet();

        return factory.getObject();
    }

    /**
     * トランザクションマネージャーを生成
     * 
     * @param entityManagerFactory エンティティマネージャーファクトリ
     * @return JpaTransactionManager
     */
    @Bean(name = "tenantTransactionManager")
    public PlatformTransactionManager transactionManager(
            @Qualifier("tenantEntityManager") EntityManagerFactory entityManagerFactory) {
        return new JpaTransactionManager(entityManagerFactory);
    }

    /**
     * DIコンテナが破棄される前にデータソースをクローズ
     */
    @PreDestroy
    public void closeDataSource() {
        if (defaultTargetDataSource != null && !defaultTargetDataSource.isClosed()) {
            defaultTargetDataSource.close();
            dataSource.clearDataSources();
        }
    }
}
/**
 * データソースを動的に切り替えるためのクラス
 */
public class DynamicRoutingDataSource extends AbstractRoutingDataSource {
    /** データソースのキー用のThreadLocal */
    private static final ThreadLocal<Object> currentLookupKey = new ThreadLocal<>();

    /**
     * データソース動的切り替えメソッド
     */
    @Override
    protected Object determineCurrentLookupKey() {
        // ThreadLocalから現在のキーを取得
        return currentLookupKey.get();
    }

    /**
     * 現在のスレッドに関連付けられたデータソースのキーを設定
     */
    public static void setCurrentLookupKey(Object lookupKey) {
        currentLookupKey.set(lookupKey);
    }

    /**
     * 現在のスレッドに関連付けられたデータソースのキーをクリア
     */
    public static void clearCurrentLookupKey() {
        currentLookupKey.remove();
        currentLookupKey.set(null);
    }

    /**
     * データソースをクリア
     */
    public void clearDataSources() {
        synchronized (this) {
            setTargetDataSources(new HashMap<>());
            super.afterPropertiesSet();
        }
    }
}

Interceptorクラスの実装

SpringにおけるInterceptorクラスは「コントローラーが呼ばれる前に共通の処理を行いたい」などの場合に使用されるクラスです。

今回は、HTTPリクエストヘッダー「X-Mavs-TenantId」にテナントIDを設定することで、該当のデータソースを設定し、コントローラー側で利用できるようにします。

/**
 * データソース用の共通処理クラス
 */
@Component
public class DataSourceInterceptor implements HandlerInterceptor {
    /** テナントデータベース用の動的データソース */
    @Autowired
    @Qualifier("tenant")
    DynamicRoutingDataSource dynamicRoutingDataSource;

    /** データベース接続情報サービス */
    @Autowired
    private ConnectionService connectionService;

    /** データソース */
    private HikariDataSource dataSource = null;

    /**
     * コントローラー前処理
     */
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
            Object handler) throws Exception {

        // HTTPヘッダーからテナントIDを取得
        String tenantId = request.getHeader("X-Mavs-TenantId");

        // テナントIDをキーにデータベース接続情報を取得
        Connection connection = connectionService.findByTenantId(tenantId);
        Map<Object, Object> targetDataSources = new HashMap<>();

        // 該当のデータソースを設定
        this.dataSource = new HikariDataSource();
        this.dataSource.setJdbcUrl(connection.getDatabase());
        this.dataSource.setUsername(connection.getUsername());
        this.dataSource.setPassword(connection.getPassword());

        targetDataSources.put("tenant", this.dataSource);
        dynamicRoutingDataSource.setTargetDataSources(targetDataSources);
        dynamicRoutingDataSource.setDefaultTargetDataSource(this.dataSource);
        dynamicRoutingDataSource.afterPropertiesSet();

        DynamicRoutingDataSource.setCurrentLookupKey(tenantId);
        return true;
    }

    /**
     * コントローラー後処理
     */
    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
            Object handler, @Nullable Exception ex) throws Exception {
        // 該当のデータソースをクローズ
        this.dataSource.close();
        dynamicRoutingDataSource.clearDataSources();
    }
}

次に、実装したInterceptorクラスを登録します。

/**
 * WebMVC設定用クラス
 * 
 * インターセプター(mavs.common.interceptor)を設定する
 */
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
    /** データソース用のインターセプター */
    @Autowired
    DataSourceInterceptor dataSourceInterceptor;

    /**
     * インターセプターの登録
     * 
     * @param InterceptorRegistry
     */
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(dataSourceInterceptor);
    }
}

テスト用APIの実装

これで、データソースの切り替え処理の準備ができました。
動作を確認するため、usersテーブルからデータを取得するだけの簡単なAPIを実装してみます。

Controllerクラスの実装

/**
 * ユーザー情報コントローラー
 */
@RestController
@RequestMapping(value = "/api/user")
public class UserController {
    /** ユーザー情報サービス */
    @Autowired
    UserService userService;

    @GetMapping("")
    public List<User> getUser() {
        return userService.findAll();
    }
}

Serviceクラスの実装

/**
 * ユーザー情報サービス
 */
@Service
public class UserService {
    /** ユーザー情報リポジトリ */
    @Autowired
    UserRepository userRepository;

    @Transactional(transactionManager = "tenantTransactionManager")
    public List<User> findAll() {
        return userRepository.findAll();
    }
}

Repositoryクラスの実装

/**
 * ユーザー情報リポジトリ
 */
public interface UserRepository extends JpaRepository<User, Integer> {
}

動作確認

APIの実行

作成したユーザー情報取得API(/api/user)を実行してみます。
まずは、リクエストヘッダー「X-Mavs-TenantId」に「tenant01」を指定して実行します。

tenant01データベースのusersテーブルのレコードを取得できました。

[
    {
        "id": 1,
        "name": "tenant01ユーザー1",
        "createdAt": "2023-12-17T12:00:00.000+00:00",
        "updatedAt": null
    },
    {
        "id": 2,
        "name": "tenant01ユーザー2",
        "createdAt": "2023-12-17T12:00:00.000+00:00",
        "updatedAt": null
    }
]

続いて、リクエストヘッダー「X-Mavs-TenantId」に「tenant02」を指定して実行してみます。

今度は、tenant02データベースのusersテーブルのレコードを取得することができました!

[
    {
        "id": 1,
        "name": "tenant02ユーザー",
        "createdAt": "2023-12-17T12:00:00.000+00:00",
        "updatedAt": null
    }
]

まとめ

以上、マルチテナント型データソースの動的切り替えについての紹介でした。

実装したコードは、以下のリポジトリにPushしてます。
https://github.com/mavs-kaneoka/spring-dynamic-datasource

今回は触れていませんが、データの登録や更新時には、該当するデータソースのトランザクションマネージャーを利用する必要があることや、明示的にデータソースをクローズする必要がある、など考慮すべき点が結構ありました。

また、実装中には、参考文献が少ないこともあり、Spring Bootではあまり一般的なパターンではないのかも、と個人的には思いました。
例えば、Laravelではもっと簡単に実現できるのですが、それぞれのフレームワークや言語において特有のメリット・デメリットが存在することを知っておくことも重要だと感じました。

この記事がどなたかの参考になれば幸いです!