すきま風

勉強したことのメモとか

Spring Boot2 × MyBatis × HikariCPで複数データソースにアクセスするコードを実装する 静的バージョン

前置き

MyBatisを利用した複数データソースの実装には データソースごとのmapperをpackageレベルで分割する静的な手法と、SpringのAbstractRoutingDataSource を使って都度利用するデータソースを決定する動的な手法があります。今回は静的な手法のコードを記述します。 動的 (透過的) バージョンについてはこちらの記事を参照ください。

ソフトウェアバージョン

software version
OS MacOS Mojave
Spring Boot 2.1.5
Java 1.8.0_192-b12
Kotlin 1.3.31
mybatis-spring-boot-starter 2.0.1
Gradle 5.2.1

実装

複数データソースの場合、Spring BootのAuto ConfigureでBeanを登録することができないので各データソースごとに手作業で登録していきます。登録が必要なものは以下3つ

  • DataSource (HikariDataSource)
  • TransactionManager (PlatFormTransactionManager)
  • SqlSessionFactory (mybatis)

Mapperの配置ディレクト

  • com.example.demo.infra.mapper.oracle.master (oracleデータソースを利用)
  • com.example.demo.infra.mapper.mysql.master (mysqlデータソースを利用)

application.yml

datasource:
  oracle:
    master:
      hikari:
        jdbc-url: jdbc:oracle:thin:@0.0.0.0:1111/test
        username: user
        password: ~
        driver-class-name: oracle.jdbc.OracleDriver
        connection-timeout: 20000
        auto-commit: false
        connection-test-query: SELECT 1 from dual
        minimum-idle: 0
        maximum-pool-size: 1
        # jmxで見たい時にtrue
        register-mbeans: true
        pool-name: HikariPoolOracle

  mysql:
    master:
      hikari:
        jdbc-url: jdbc:mysql://0.0.0.0:3306/test
        username: sa
        password: ~
        driver-class-name: com.mysql.cj.jdbc.Driver
        connection-timeout: 20000
        auto-commit: false
        connection-test-query: SELECT 1
        minimum-idle: 0
        maximum-pool-size: 1
        connection-init-sql: "SET SESSION sql_mode='TRADITIONAL,NO_AUTO_VALUE_ON_ZERO,ONLY_FULL_GROUP_BY'"
        register-mbeans: true
        pool-name: HikariPoolMysql

spring:
  transaction:
    rollback-on-commit-failure: true
    default-timeout: 60s

mybatis:
  mapperLocations: classpath*:mybatis/sql/**/**/*.xml
  configuration:
    cacheEnabled: true
    # prepared statementの再利用をする
    defaultExecutorType: REUSE
    # oracle用の設定
    jdbcTypeForNull: 'NULL'
    defaultFetchSize: 100
    defaultStatementTimeout: 30
    mapUnderScoreToCamelCase: true

設定可能なconfigはHikariCP 参照
また、Mysqlの方はsql_mode を traditional にして、カラムに不正な値を挿入したときにエラーを返すように設定します。参考元

OracleDataSourceConfig.kt

@EnableMBeanExport(registration = RegistrationPolicy.IGNORE_EXISTING)
@Configuration
// mapperScanでoracle用のmapperを置く場所を指定
@MapperScan(basePackages = ["com.example.demo.infra.mapper.oracle.master"],
    sqlSessionFactoryRef = "oracleMasterSqlSessionFactory")
class OracleMasterDatasourceConfig {

    @Bean(name = [DATA_SOURCE])
    @Primary
    fun oracleMasterDataSource(
        @Qualifier(HIKARI_CONFIG) hikariConfig: HikariConfig
    ): DataSource {
        return HikariDataSource(hikariConfig)
    }

    @Bean(name = [HIKARI_CONFIG])
    @ConfigurationProperties(prefix = HIKARI_CONFIGURATION_PROPERTIES)
    fun oracleHikariConfig(): HikariConfig {
        return HikariConfig()
    }

    @Bean(name = [TX_MANAGER])
    @Primary
    fun oracleMasterTxManager(
        @Qualifier(DATA_SOURCE) dataSource: DataSource,
        transactionProperties: TransactionProperties
    ): PlatformTransactionManager {
        val tx = DataSourceTransactionManager(dataSource)

        // txのrollbackCommitOnFailureとdefaultTimeOutが設定される
        // FIXME 設定しなくてもrollbackされたのでやらなくても良い?
        transactionProperties.customize(tx)

        return tx
    }

    @Bean(name = [SQL_SESSION_FACTORY])
    @Primary
    fun oracleMasterSqlSessionFactory(
        @Qualifier(DATA_SOURCE) dataSource: DataSource,
        mybatisProperties: MybatisProperties
    ): SqlSessionFactory {
        // val resolver = ResourcePatternUtils.getResourcePatternResolver(DefaultResourceLoader())
        val sqlSessionFactoryBean = SqlSessionFactoryBean()
        sqlSessionFactoryBean.setDataSource(dataSource)

        sqlSessionFactoryBean.vfs = SpringBootVFS::class.java

        sqlSessionFactoryBean.setMapperLocations(mybatisProperties.resolveMapperLocations())

        val configuration = org.apache.ibatis.session.Configuration()
        mybatisProperties.configuration?.let {
            // oracleに必要
            configuration.jdbcTypeForNull = it.jdbcTypeForNull

            configuration.isCacheEnabled = it.isCacheEnabled
            configuration.defaultExecutorType = it.defaultExecutorType
            configuration.defaultFetchSize = it.defaultFetchSize
            configuration.defaultStatementTimeout = it.defaultStatementTimeout
            configuration.isMapUnderscoreToCamelCase = true
        }
        sqlSessionFactoryBean.setConfiguration(configuration)

        return sqlSessionFactoryBean.`object` ?: throw NullPointerException()
    }

    companion object {
        private const val HIKARI_CONFIGURATION_PROPERTIES = "datasource.oracle.master.hikari"
        private const val DATA_SOURCE = "oracleMaster"
        private const val HIKARI_CONFIG = "oracleMasterHikari"
        private const val TX_MANAGER = "oracleMasterTxManager"
        private const val SQL_SESSION_FACTORY = "oracleMasterSqlSessionFactory"
    }
}

必要なBeanを登録します。Mysql側でも同じ型のBeanを登録するため@Primaryを付けています。またメソッド引数に autowiredする際には@Qualifierで明示的にBean Idを指定しています。
DataSourceはHikariDataSourceを明示的に返すようにしています。HikariConfigのurlはjdbc-urlという名前なので注意。Spring BootのAutoConfigureではConnection Poolライブラリの実装の差異をaliasを設定することで吸収しているようです。DataSourceBuilder.java

MysqlMasterDataSourceConfig

Oracleの設定から@Primary を取り除いてconfiguration.jdbcTypeForNullをデフォルト値にしているだけなのでここでは省略します。

利用

mapper
// https://stackoverflow.com/questions/25379348/idea-inspects-batis-mapper-bean-wrong/34584526
// Repositoryを入れないとIntelliJがautowiredを誤認識する
@Repository
@Mapper
interface ArticleMapper {
    fun find(): List<ArticleDto>

    fun update()
}
repository
@Repository
class ArticleRepositoryImpl(
    private val articleMapper: ArticleMapper
) : CompanyRepository {
    override fun update() {
        articleMapper.update()
    }

    override fun find(): List<Article> =
        articleMapper.find().map {
            Article(
                it.title ?: ""
            )
        }
}
application (usecase)
@Component
class ArticleFacade(
    private val articleRepository: ArticleRepository
) : Facade {
    fun find(): Output =
        Output(articleRepository.find())

    // oracleは@PrimaryなのでtransactionManagerは明示しなくてもOK
    @Transactional(rollbackFor = [Exception::class], transactionManager = "oracleMasterTxManager")
    fun update() {
        articleRepository.update()
    }

    data class Output(
        val articles: List<Article>
    )
}

packageレベルで利用するデータソースが決めてしまっているため特に仕掛けもなく動作します。Exception時のrollbackも正常に機能していました。
動的な手法については設定するclassの数が多いことと複数のSpringのclassの拡張が必要になり、ThreadLocalの考慮も必要と静的な手法に比べて複雑になるため、できるだけ静的な手法を使って実装するほうが良さそうです。

最後に

本番環境で試したソースではないです ;-)

参考サイト

java-handbook/spring at master · tokuhirom/java-handbook · GitHub

MyBatis – MyBatis 3 | 設定

How to use HikariCP in Spring Boot with two datasources in conjunction with Flyway - Stack Overflow

6.2. データベースアクセス(MyBatis3編) — TERASOLUNA Server Framework for Java (5.x) Development Guideline 5.4.1.RELEASE documentation

mybatis-spring-boot-autoconfigure – MyBatis Sring-BootStarter | Reference Documentation

SpringのDataSourceTransactionManagerを使うとエラー時にCommitされる可能性あり!? - Qiita

GitHub - brettwooldridge/HikariCP: 光 HikariCP・A solid, high-performance, JDBC connection pool at last.