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