[Kotlin + Spring Boot]複数DB使用する方法とテスト時にDBを切り分ける方法

#はじめに
SpringBootでそもそも複数のDBを使用する方法や、`複数DB使用時にテスト用のDBと本番用のDBとを切り分ける方法`でハマったので記事にしておこうと思いました。
本記事では複数DBを使用する際の設定なども含めて解説していきたいと思います。言語はKotlinを使います。
#環境
今回は以下の想定で進めていきたいと思います。
`PrimaryDB` : `MySQL`
`SecondaryDB` : `MariaDB`
`PrimaryDB用テストDB` : `H2database`
`SecondaryDB用テストDB` : `H2database`
なお、MySQLとMariaDBはDockerコンテナで起動します。
##バージョンなど
`SpringBoot`: `2.3.1`
`Kotlin`: `1.3.72`
`Java` : `11`
`jvm` : `1.8`
[spring initializr](https://start.spring.io/)で雛形をサクッと作っちゃいます。
mariadbの依存モジュールも入れたかったのですが、spring initializrでは無かったので、自分で`gradle.build.kts`に書きます。
![スクリーンショット 2020-06-14 14.54.41（2）.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/564587/241a9f70-3ce6-2677-0a09-35569e5dcd74.png)

```kotlin:build.gradle.kts
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

plugins {
	id("org.springframework.boot") version "2.3.1.RELEASE"
	id("io.spring.dependency-management") version "1.0.9.RELEASE"
	kotlin("jvm") version "1.3.72"
	kotlin("plugin.spring") version "1.3.72"
	kotlin("plugin.jpa") version "1.3.72"
}

group = "com.demo"
version = "0.0.1-SNAPSHOT"
java.sourceCompatibility = JavaVersion.VERSION_11

repositories {
	mavenCentral()
}

dependencies {
	implementation("org.springframework.boot:spring-boot-starter-data-jpa")
	implementation("org.springframework.boot:spring-boot-starter-web")
	implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
	implementation("org.jetbrains.kotlin:kotlin-reflect")
	implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
	runtimeOnly("com.h2database:h2")
	runtimeOnly("mysql:mysql-connector-java")
	runtimeOnly("org.mariadb.jdbc:mariadb-java-client")//← これ
	testImplementation("org.springframework.boot:spring-boot-starter-test") {
		exclude(group = "org.junit.vintage", module = "junit-vintage-engine")
	}
}

tasks.withType<Test> {
	useJUnitPlatform()
}

tasks.withType<KotlinCompile> {
	kotlinOptions {
		freeCompilerArgs = listOf("-Xjsr305=strict")
		jvmTarget = "1.8"
	}
}
```

#Controller, Repository, Entityを作る
あんまり本質的ではないので`Service`を使ったりはしないでおきます。
##MySQLで使うもの
###Controller
全件取得するだけの簡単なものですが笑

```kotlin
package com.demo.multidb.controller

import com.demo.multidb.repository.mysql.StoreRepository
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.ResponseBody
import org.springframework.web.bind.annotation.RestController

@RestController
@RequestMapping("/store")
class MysqlStoreController (
        val storeRepository: StoreRepository
){
    @GetMapping("/")
    @ResponseBody
    fun getAllStore(): ResponseEntity<Any>{
        return ResponseEntity.ok(storeRepository.findAll())
    }
}
```
###Entity
```kotlin
package com.demo.multidb.entity.mysql

import javax.persistence.*

@Entity
@Table(name = "store")
data class Store(
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        @Column(name = "id")
        var id: Int?,

        @Column(name = "store_name")
        var store_name: String?,

        @Column(name = "store_code")
        var store_code: Int?
)
```

###Repository
```kotlin
package com.demo.multidb.repository.mysql

import com.demo.multidb.entity.mysql.Store
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository

@Repository
interface StoreRepository : JpaRepository<Store, Int>
```

##MariaDBで使うもの
###Controller
```kotlin
package com.demo.multidb.controller

import com.demo.multidb.repository.mariadb.UserRepository
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.ResponseBody
import org.springframework.web.bind.annotation.RestController

@RestController
@RequestMapping("/user")
class MariaDBUserController (
        val userRepository: UserRepository
){
    @GetMapping("/")
    @ResponseBody
    fun getAllUser(): ResponseEntity<Any>{
        return ResponseEntity.ok(userRepository.findAll())
    }
}
```
###Entity
```kotlin
package com.demo.multidb.entity.mariadb

import javax.persistence.*

@Entity
@Table(name = "user")
data class User(
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        @Column(name = "id")
        var id: Int?,

        @Column(name = "name")
        var name: String?
)
```
###Repository

```kotlin
package com.demo.multidb.repository.mariadb

import com.demo.multidb.entity.mariadb.User
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository

@Repository
interface UserRepository : JpaRepository<User, Int>

```
#DB用のConfigクラス
MySQLで使用するdatasourceやEntity、Repositoryはどれか、同様にMariaDBで使用するdatasourceやEntityとRepositoryはどれかを定義する必要があります。
そのためのConfigクラスを記述します。
以下はMySQL用のConfigクラスです。
ソースコードと一緒に説明した方がやりやすいので本文をソースコード内に埋め込んでいます。

```kotlin
package com.demo.multidb.config

import org.springframework.beans.factory.annotation.Autowired
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties
import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.boot.orm.jpa.EntityManagerFactoryBuilder
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.context.annotation.Primary
import org.springframework.data.jpa.repository.config.EnableJpaRepositories
import org.springframework.orm.jpa.JpaTransactionManager
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean
import javax.persistence.EntityManagerFactory
import javax.sql.DataSource


@Configuration
@EnableJpaRepositories(
        //ここで使用するRepositoryのパッケージ名を指定しています
        basePackages = ["com.demo.multidb.repository.mysql"],
        entityManagerFactoryRef = "primaryDBEntityManager",
        transactionManagerRef = "primaryDBTransactionManager"
)
class PrimaryDBConfig {
    @Bean
    //複数のDBを扱う場合には1つはデフォルトの設定であることを@Primaryアノテーションで表します。
    //これがないとエラーになります。
    @Primary
    //ここで使用するdatasourceのpropertyのプレフィックスを指定しています。
    //後ほどapplication.propertiesを書く時にこのプレフィックスに合わせて記述します。
    @ConfigurationProperties(prefix = "spring.datasource.primary")
    fun primaryDBProperties(): DataSourceProperties {
        return DataSourceProperties()
    }

    @Bean
    @Primary
    @Autowired
    fun primaryDBDataSource(
            @Qualifier("primaryDBProperties") properties: DataSourceProperties
    ): DataSource {
        return properties.initializeDataSourceBuilder().build()
    }

    @Bean
    @Primary
    @Autowired
    fun primaryDBEntityManager(
            builder: EntityManagerFactoryBuilder,
            @Qualifier("primaryDBDataSource") dataSource: DataSource
    ): LocalContainerEntityManagerFactoryBean {
        return builder.dataSource(dataSource)
                //ここでEntityを指定しています。
                .packages("com.demo.multidb.entity.mysql")
                //テスト時などで使用したいddl-autoのpropertyはapplication.propertiesに書いてもうまく動かなかったので、
                //EntityManager内で指定します。本番環境のデータを壊さないようにupdateで設定しておきます。
                .properties(mapOf("hibernate.hbm2ddl.auto" to "update"))
                .persistenceUnit("primary")
                .build()
    }

    @Bean
    @Primary
    @Autowired
    fun primaryDBTransactionManager(
            @Qualifier("primaryDBEntityManager") primaryDBEntityManager: EntityManagerFactory
    ): JpaTransactionManager {
        return JpaTransactionManager(primaryDBEntityManager)
    }
}
```

MariaDBの方もほとんど同じです。`@Primary`がないのとEntity,Repositoryの指定が違うくらいでしょうか。

```kotlin
package com.demo.multidb.config

import org.springframework.beans.factory.annotation.Autowired
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties
import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.boot.orm.jpa.EntityManagerFactoryBuilder
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.data.jpa.repository.config.EnableJpaRepositories
import org.springframework.orm.jpa.JpaTransactionManager
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean
import javax.persistence.EntityManagerFactory
import javax.sql.DataSource


@Configuration
@EnableJpaRepositories(
        basePackages = ["com.demo.multidb.repository.mariadb"],
        entityManagerFactoryRef = "secondaryDBEntityManager",
        transactionManagerRef = "secondaryDBTransactionManager"
)
class SecondaryDBConfig {
    @Bean
    @ConfigurationProperties(prefix = "spring.datasource.secondary")
    fun secondaryDBProperties(): DataSourceProperties {
        return DataSourceProperties()
    }

    @Bean
    @Autowired
    fun secondaryDBDataSource(
            @Qualifier("secondaryDBProperties") properties: DataSourceProperties
    ): DataSource {
        return properties.initializeDataSourceBuilder().build()
    }

    @Bean
    @Autowired
    fun secondaryDBEntityManager(
            builder: EntityManagerFactoryBuilder,
            @Qualifier("secondaryDBDataSource") dataSource: DataSource
    ): LocalContainerEntityManagerFactoryBean {
        return builder.dataSource(dataSource)
                .packages("com.demo.multidb.entity.mariadb")
                .properties(mapOf("hibernate.hbm2ddl.auto" to "update"))
                .persistenceUnit("primary")
                .build()
    }

    @Bean
    @Autowired
    fun secondaryDBTransactionManager(
            @Qualifier("secondaryDBEntityManager") primaryDBEntityManager: EntityManagerFactory
    ): JpaTransactionManager {
        return JpaTransactionManager(primaryDBEntityManager)
    }
}

```
mainアプリケーション側の`application.properties`はConfigクラスで指定したdatasoureのpropertyのプレフィックスに合わせて書きます。

```properties
spring.datasource.primary.url=jdbc:mysql://localhost:3306/demo
spring.datasource.primary.username=root
spring.datasource.primary.password=root
spring.datasource.primary.driver-class-name=com.mysql.cj.jdbc.Driver

spring.datasource.secondary.url=jdbc:mariadb://localhost:3307/demo
spring.datasource.secondary.username=root
spring.datasource.secondary.password=root
spring.datasource.secondary.driver-class-name=org.mariadb.jdbc.Driver
```
DB名はそれぞれ`demo`,portはMysqlは`3306`, MariaDBは`3307`にしておきます。
これに合わせて`docker-compose.yml`を書きます。

```yaml:docker-compose.yml
version: '3'
services:
  demo_mysql:
    container_name: demo_mysql
    image: mysql
    ports:
      - 3306:3306
    command:
      --port 3306
    environment:
      - MYSQL_ROOT_PASSWORD=root

  demo_mariadb:
    container_name: demo_mariadb
    image: mariadb
    ports:
      - 3307:3307
    command:
      --port 3307
    environment:
      - MYSQL_ROOT_PASSWORD=root
```
#DBの初期化
動作確認用にデータを入れておきます。

MySQLの初期化用

```sql
create database if not exists demo default character set utf8mb4 collate utf8mb4_unicode_ci;
use demo;
create table store
(
    id         int not null auto_increment,
    store_name varchar(20),
    store_code int,
    primary key (id)
);

insert into store (store_name, store_code) values ('test1', '1234');
insert into store (store_name, store_code) values ('test2', '5678');
```
MariaDBの初期化用

```sql
create database if not exists demo default character set utf8mb4 collate utf8mb4_unicode_ci;
use demo;
create table user
(
    id         int not null auto_increment,
    name varchar(20),
    primary key (id)
);

insert into user (name) values ('hoge');
insert into user (name) values ('fuga');
```
`docker-entrypoint-initdb.d`に初期化用スクリプトを配置してもいいのですが、あくまでdemoなので、コンテナに入り直接上記SQLを叩いてもいいのかなと。

#ここまでで動作確認
dockerでmysqlとmariadbを起動しておいた状態で、アプリケーションを起動します。

```sh
$ curl localhost:8080/store/
#[{"id":1,"store_name":"test1","store_code":1234},{"id":2,"store_name":"test2","store_code":5678}]%

$ curl localhost:8080/user/
#[{"id":1,"name":"hoge"},{"id":2,"name":"fuga"}]%
```
MySQLとMariaDBに入れたものがきちんと取り出されていたらとりあえずは複数DBの住み分けはできていると思います。

#テストコード
MySQLとMariaDBのそれぞれのControllerのテストコードを書いていきます。
##MySQLを使うController用

```kotlin
package com.demo.multidb

import com.demo.multidb.controller.MysqlStoreController
import com.demo.multidb.entity.mysql.Store
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.http.MediaType
import org.springframework.test.context.TestPropertySource
import org.springframework.test.context.jdbc.Sql
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.*
import org.springframework.test.web.servlet.setup.MockMvcBuilders
import org.springframework.transaction.annotation.Transactional

@SpringBootTest
@TestPropertySource(locations = ["/application-test.properties"])
@Transactional
class MultidbApplicationMysqlStoreTests {
    lateinit var mysqlStoreMockMvc: MockMvc

    @Autowired
    lateinit var mysqlStoreController: MysqlStoreController


    @BeforeEach
    fun setup() {
        mysqlStoreMockMvc = MockMvcBuilders
                .standaloneSetup(mysqlStoreController)
                .build()
    }

    @Test
    @Sql(statements = ["insert into store (id, store_name, store_code) values (1, 'hoge', 1234)"])
    fun mysql_getAllStoreTest() {
        val expectedValue = listOf(Store(1, "hoge", 1234))
        mysqlStoreMockMvc.perform(
                MockMvcRequestBuilders.get("/store/")
        )
                .andExpect(status().isOk)
                .andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE))
                .andExpect(content().json(jacksonObjectMapper().writeValueAsString(expectedValue)))
    }
}

```
後でtest用のpropertyである`application-test.properties`を書きますが、テスト時に
`@TestPropertySource(locations = ["/application-test.properties"])`
で使用するpropertyを宣言しないと、自動的にmainのapplication.propertiesを読みに行って、本番環境用のDBでテストしてしまいます。
単一のDBを使用している時はそこの切り替えも自動的に行ってくれたのですが、なぜか複数DBだと自動で行ってくれないみたいです。

##MariaDBで使うController用

```kotlin
package com.demo.multidb

import com.demo.multidb.controller.MariaDBUserController
import com.demo.multidb.entity.mariadb.User
import com.demo.multidb.entity.mariadb.UserResponse
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.http.MediaType
import org.springframework.test.context.TestPropertySource
import org.springframework.test.context.jdbc.Sql
import org.springframework.test.context.jdbc.SqlConfig
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.*
import org.springframework.test.web.servlet.setup.MockMvcBuilders
import org.springframework.transaction.annotation.Transactional

@SpringBootTest
@TestPropertySource(locations = ["/application-test.properties"])
@Transactional
class MultidbApplicationMariaDBUserTests {
    lateinit var mariadbUserMockMvc: MockMvc

    @Autowired
    lateinit var mariaDBUserController: MariaDBUserController


    @BeforeEach
    fun setup() {
        mariadbUserMockMvc = MockMvcBuilders
                .standaloneSetup(mariaDBUserController)
                .build()
    }

    @Test
    @Sql(statements = ["insert into user (name) values ('fuga')"],
            config = SqlConfig(
                    dataSource = "secondaryDBDataSource",
                    transactionManager = "secondaryDBTransactionManager")
    )
    fun mariadb_getAllUserTest() {
        val expectedValue = listOf(User(1,"fuga"))
        mariadbUserMockMvc.perform(
                MockMvcRequestBuilders.get("/user/")
        )
                .andExpect(status().isOk)
                .andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE))
                .andExpect(content().json(jacksonObjectMapper().writeValueAsString(expectedValue)))
    }
}

```

`＠Sql`アノテーションで指定したクエリはデフォルトではPrimaryの方に適用されてしまうので、DataSourceとTransactionManagerの指定を行っておきます。
##application-test.properties
```properties
spring.datasource.primary.driver-class-name=org.h2.Driver
spring.datasource.primary.url=jdbc:h2:mem:test1
spring.datasource.primary.username=root
spring.datasource.primary.password=root

spring.datasource.secondary.driver-class-name=org.h2.Driver
spring.datasource.secondary.url=jdbc:h2:mem:test2
spring.datasource.secondary.username=root
spring.datasource.secondary.password=root
```
H2DBのインメモリDBを使用することで、テストが終わるとデータが全て揮発する特性を利用します。
propertyに`ddl-auto=create`の設定がprimaryの方にしか効かなかったので、Configクラスで各EntityManagerに`update`を設定し、
テスト終了後全て揮発することで、擬似的に`create`になるように動きます。多分よくないと思うのでこの辺りちゃんと動かせるようになりたいです。。。

#テスト実行
ひとまずはこれで複数DBを含むSpringBootアプリケーションのテストが正常に行われるかと思います。

```sh
$ ./gradlew clean test

> Task :test
2020-06-14 19:10:55.662  INFO 70927 --- [extShutdownHook] o.s.s.concurrent.ThreadPoolTaskExecutor  : Shutting down ExecutorService 'applicationTaskExecutor'
2020-06-14 19:10:55.663  INFO 70927 --- [extShutdownHook] j.LocalContainerEntityManagerFactoryBean : Closing JPA EntityManagerFactory for persistence unit 'primary'
2020-06-14 19:10:55.663  INFO 70927 --- [extShutdownHook] com.zaxxer.hikari.HikariDataSource       : HikariPool-2 - Shutdown initiated...
2020-06-14 19:10:55.665  INFO 70927 --- [extShutdownHook] com.zaxxer.hikari.HikariDataSource       : HikariPool-2 - Shutdown completed.
2020-06-14 19:10:55.665  INFO 70927 --- [extShutdownHook] j.LocalContainerEntityManagerFactoryBean : Closing JPA EntityManagerFactory for persistence unit 'primary'
2020-06-14 19:10:55.666  INFO 70927 --- [extShutdownHook] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Shutdown initiated...
2020-06-14 19:10:55.666  INFO 70927 --- [extShutdownHook] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Shutdown completed.

BUILD SUCCESSFUL in 7s
6 actionable tasks: 6 executed

```

#最後に
SpringBootが見えないところでいろんなデフォルト設定をやってくれているので、いざ自分で設定を書いて動かそうと思うとなかなか動かなくて苦しい思いをしています。。。