5
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

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

Last updated at Posted at 2020-06-14

はじめに

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で雛形をサクッと作っちゃいます。
mariadbの依存モジュールも入れたかったのですが、spring initializrでは無かったので、自分でgradle.build.ktsに書きます。
スクリーンショット 2020-06-14 14.54.41(2).png

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

全件取得するだけの簡単なものですが笑

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

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

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

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

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

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クラスです。
ソースコードと一緒に説明した方がやりやすいので本文をソースコード内に埋め込んでいます。

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の指定が違うくらいでしょうか。

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のプレフィックスに合わせて書きます。

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を書きます。

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の初期化用

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の初期化用

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を起動しておいた状態で、アプリケーションを起動します。

$ 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用

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用

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

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アプリケーションのテストが正常に行われるかと思います。

$ ./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が見えないところでいろんなデフォルト設定をやってくれているので、いざ自分で設定を書いて動かそうと思うとなかなか動かなくて苦しい思いをしています。。。

5
3
0

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
  3. You can use dark theme
What you can do with signing up
5
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?