Spring Boot Multiple Datasources
สวัสดีครับผมไผ่แพนด้า สายจาวาทราบดีอยู่แล้วว่าเราใช้ JPA และ ORM จัดการ domain และ table กับฐานข้อมูลผ่านสิ่งที่เรียกว่า datasource บทความนี้จะนำเสนอการเชื่อมต่อที่มากกว่า 1 datasource ซึ่งเรียกว่า multiple datasrouces ครับ
เนื้อหาทั้งหมด
- สร้างโปรเจกต์ที่ประกอบด้วย 2 datasources คือ MS SQL Server กับ PostgreSQL
- Connect to MS SQL Server 2017 ประกอบด้วยการ config ผ่าน application.yml ปกติเทียบกับ Java Annotation
- Connect to PostgreSQL 13 เพิ่มการเชื่อมต่ออีก datasource ผ่าน application.yml ควบคู่กับ Java Annotation
- Bean Names
- Handle 2 Domains And ORM
- Transactions
สร้างโปรเจกต์
ตรงไปยัง Spring Initializr ที่นี่ แล้วกำหนดความต้องการของโปรเจกต์
Connect to MS SQL Server 2017
เรามาเริ่มต้นเรื่องนี้แบบเร็วๆด้วยไฟล์ที่เราคุ้นเคย application.properties โปรเจกต์ตัวอย่างนี้เชื่อมต่อไปยังฐานข้อมูล MS SQL Server 2017 แบบ 1 datasource ก่อน
ติดตั้ง MS SQL Server 2017
ผมใช้ Docker กับ MS SQL Server 2017 ที่นี่
docker run -e 'ACCEPT_EULA=Y' -e 'SA_PASSWORD=P@ssw0rd' -p 1433:1433 -d mcr.microsoft.com/mssql/server:2017-latest
user คือ SA
password คือ P@ssw0rd
port เชื่อมต่อจากภายนอกคือ 1433
สร้างฐานข้อมูลชื่อ db1
CREATE DATABASE db1
COLLATE THAI_CI_AS;
ข้างต้นนั้นรองรับภาษาไทย (พวกเราสามารถดูตาราง Character sets เวอร์ชัน 2017 ที่นี่)
ผลลัพธ์
Configuration
ไฟล์ application.properties กำหนดค่าต่อไปนี้
spring.datasource.driver-class-name=com.microsoft.sqlserver.jdbc.SQLServerDriver
spring.datasource.url=jdbc:sqlserver://localhost:1433;databaseName=db1
spring.datasource.username=SA
spring.datasource.password=P@ssw0rd
หรือจะเปลี่ยนสกุลไฟล์จาก .properties
เป็น .yml
ก็ได้
ไฟล์ application.yml
spring:
datasource:
driver-class-name: com.microsoft.sqlserver.jdbc.SQLServerDriver
url: jdbc:sqlserver://localhost:1433;databaseName=db1
username: SA
password: P@ssw0rd
ผลลัพธ์การทดสอบ
จากตัวอย่าง ถ้าเราตั้งคำถามว่า Spring Boot ทราบได้อย่างไรว่า datasource อยู่ที่ไหน? คำตอบคือ มันจะมองหา key ชื่อ spring.datasource
ซึ่งจะต้องถูกเขียนไว้ตรงไหนสักแห่งในไฟล์ configuration
Annotation Configuration
มือเก๋าจาวารู้อยู่แล้วว่าเดิมพวก config มักเขียนไว้ใน XML แล้วก็ย้ายมาไว้ในจาวาผ่าน annotation (มือใหม่ไม่รู้จักกดดูตัวอย่าง annotation ที่นี่) จากนั้นก็มีวิธีการเขียนเป็นไฟล์ configuation .properties ก็มา .yaml หรือ .yml ก็มา ผมกำลังบอกว่าหากเราไม่เขียนไว้ในไฟล์ configuration ก็เอามาเขียนไว้ในจาวาได้
ดังนั้นตัวอย่าง datasource ข้างต้นจะขอย้ายมาเขียนไว้ในจาวา
ลบทุกอย่างในไฟล์ application.properties หรือ application.yml ทิ้งไปครับ
ผมจะสร้าง package ชื่อ configuration ภายในบรรจุคลาสชื่อ SqlServerDataSourceConfiguration
เนื้อหาดังนี้
SqlServerDataSourceConfiguration
@Configuration
@EnableJpaRepositories(
entityManagerFactoryRef = "entityManagerFactory"
)
@EnableTransactionManagement
public class SqlServerDataSourceConfiguration { }
คุยกับฐานข้อมูลหัวใจต้องการ 2 อย่าง
- datasource อยู่ที่ไหน
- transaction ที่เกิด เพื่อจัดการ commit หรือ reject
จากโค้ดข้างต้น @EnableJpaRepositories
กำหนดให้ค่าโดย default ของ entityManagerFactoryRef คือสตริง entityManagerFactory
หากไม่ใช่ค่าโดย default พวกเราสามารถตั้งได้ตามใจชอบซึ่งก็ขอให้เขียนบอกแก่ Spring Boot ด้วย (ใช้ @Bean
name คู่กับ @Qualifier
)
คลาส SqlServerDataSourceConfiguration เบื้องต้นนี้จะบัญญัติแค่ datasrouce เพียงอย่างเดียว ที่เหลือละไว้เป็นค่า default
ทีนี้ถ้าเรามี 2 datasources ขึ้นไปล่ะ? แล้วก็ไม่อยาก hard code สตริงเอามาเขียนไว้ในจาวาเพราะ configuration แยกไว้ในไฟล์ application.yml นั่นดูดีแล้ว (จัดการแก้ไขได้ง่าย) พอจะมีวิธีการไหม? แด่คำตอบของความต้องการนี้ ผมขอเพิ่มอีกหนึ่ง datasource เป็นฐานข้อมูล PostgreSQL แล้วกันนะ
+ Connect to PostgreSQL 13
เช่นเคยผมใช้ Docker
ติดตั้ง PostgreSQL 13
ผมใช้ Docker กับ Postgres SQL 13 ที่นี่
docker run -e POSTGRES_PASSWORD=P@ssw0rd -p 5432:5432 -d postgres:13
user คือ postgres
password คือ P@ssw0rd
port เชื่อมต่อจากภายนอกคือ 5432
สร้างฐานข้อมูลชื่อ db2
หมายเหตุ หากใช้ Docker Dashboard ปุ่ม exec ดูเหมือนจะ config มาไม่ดี (ขาดอักษรตัว d) อย่างไรก็ตามเราสามารถใช้ command แบบดั่งเดิมต่อไปนี้เข้าไปได้
docker exec -it <container-id> /bin/sh
กดดู locate ผลลัพธ์
มันไม่มี th_TH มาให้ อื่ม…ไม่เป็นไรใช้ที่มีไปก่อน (พวกเราสามารถดูตาราง Character sets เวอร์ชัน 13 ทั้งหมด ที่นี่)
สั่งสร้างฐานข้อมูล
CREATE DATABASE db2
WITH OWNER postgres
ENCODING 'UTF8' LC_COLLATE='en_US.utf8' LC_CTYPE='en_US.utf8'
TEMPLATE=template0;
ผลลัพธ์
Configuration
พอมี 2 datasources แล้วคราวนี้เรามาออกแบบ key ของพวกมันในไฟล์ application.yml กัน เอาที่พอใจ
db:
sqlserver:
driver-class-name: com.microsoft.sqlserver.jdbc.SQLServerDriver
url: jdbc:sqlserver://localhost:1433;databaseName=db1
username: SA
password: P@ssw0rd
postgresql:
driver-class-name: org.postgresql.Driver
url: jdbc:postgresql://localhost:5432/db2
username: postgres
password: P@ssw0rd
จากตัวอย่างนี้พวกเราจะสังเกตเห็นว่าทั้ง datasource แรกและ datasource หลังถูกกำหนด key เป็น db.sqlserver
และ db.postgresql
ตามลำดับ ทำแบบนี้แล้ว Sprint Boot ก็ไปไม่เป็นแล้วครับ รันแล้วก็พัง ต้องเป็นเราเองบอกกับมันว่าจะหา datasources ทั้งสองนี้ให้เจอได้ยังไง
เดิมเรามี package ชื่อ configuration เดี๋ยวเราจะเพิ่มคลาสใหม่ชื่อว่า PostgreSqlDataSourceConfiguration นี้เข้าไป
เท่ากับว่าตอนนี้เราให้ SqlServerDataSourceConfiguration
รับผิดชอบ db.sqlserver
ส่วน PostgreSqlDataSourceConfiguration
รับผิดชอบ db.postgresql
SqlServerDataSourceConfiguration
เริ่มที่ของเก่าก่อน SqlServerDataSourceConfiguration แก้ไขให้มันไปอ่านค่าจากไฟล์ application.yml
ทดสอบแล้วสามารถเชื่อมต่อฐานข้อมูลได้เป็นปกติ
สิ่งที่เพิ่มเข้ามาใหม่คือ
@Primary
เพื่อใช้บอกกับ Spring Boot ว่า dataSourceProperties ไหนที่ต้องการให้ไปอ่าน ระหว่าง dataSourceProperties ที่อยู่ในคลาส SqlServerDataSourceConfiguration ของเราหรือที่อยู่ใน spring.datasource-org.springframework.boot.autoconfigure.jdbc.DataSourceProperties แน่นอนว่าต้องเป็นที่คลาสเรา@ConfigurationProperties
กำหนดว่าให้ไปอ่าน key ใดในไฟล์ application.yml
ทำให้ดูเพียงเท่านี้พวกเราก็พอจะคาดเดาได้แล้วใช่ไหมว่า PostgreSQL จะลงเอยท่าไหน ฮ่า ก็เหมือนๆกันน่ะสิ
PostgreSqlDataSourceConfiguration
สร้างคลาสใหม่แล้วเติมรายละเอียดลงไปครับ โปรดสังเกตว่าจะไม่มี @Primary
เพราะนี่เป็น secondary datasource ไม่ใช่ primary datasrouce เหมือนอย่าง SqlServerDataSourceConfiguration (มี primary ได้เพียงแหล่งเดียว)
แต่พอรันเท่านั้น เราก็จะพบกับ
The bean ‘dataSourceProperties’, defined in class path resource [com/pros/multidatasource/configuration/SqlServerDataSourceConfiguration.class], could not be registered. A bean with that name has already been defined in class path resource [com/pros/multidatasource/configuration/PostgreSqlDataSourceConfiguration.class] and overriding is disabled
เขาบอกว่า dataSourceProperties พบใน SqlServerDataSourceConfiguration และ PostgreSqlDataSourceConfiguration เหตุก็เพราะมันเป็นออบเจ็กต์เดียวกัน สามารถแยกได้ด้วยการตั้งชื่อ bean ให้ต่างกัน
Bean Names
คลาส SqlServerDataSourceConfiguration เพิ่มชื่อแก่ dataSourceProperties bean
@Primary
@Bean("sqlserverProperties")
@ConfigurationProperties("db.sqlserver")
public DataSourceProperties dataSourceProperties() {
return new DataSourceProperties();
}
ตอนเรียกใช้งานที่ datasource bean ก็ต้องแก้ไขด้วย ทำอย่างนี้แล้วก็ต้องใส่ @Primary
@Primary
@Bean("sqlserverDatasource")
public DataSource dataSource(
@Qualifier("sqlserverProperties") DataSourceProperties properties
) {
return properties
.initializeDataSourceBuilder()
.build();
}
เป็นผลให้ตอนเรียกใช้งานที่ entityManagerFactoryBean ก็ต้องแก้ไขด้วย ทำอย่างนี้แล้วก็ต้องใส่ @Primary จะได้เป็นแนวทางเดียวกัน
@Primary
@Bean
public LocalContainerEntityManagerFactoryBean entityManagerFactoryBean(
EntityManagerFactoryBuilder builder,
@Qualifier("sqlserverDatasource") DataSource dataSource
) {
return builder
.dataSource(dataSource)
.packages("com.pros.multidatasource")
.build();
}
อย่าลืมทำแบบเดียวกันนี้กับ PostgreSqlDataSourceConfiguration โดยการตั้งชื่อ bean ในทำนองเดียวกันแต่ไม่ต้องใส่ @Primary นะ
ทดลองรัน ผลลัพธ์
The bean ‘entityManagerFactoryBean’, defined in class path resource [com/pros/multidatasource/configuration/SqlServerDataSourceConfiguration.class], could not be registered. A bean with that name has already been defined in class path resource [com/pros/multidatasource/configuration/PostgreSqlDataSourceConfiguration.class] and overriding is disabled.
แหม entityManagerFactoryBean ไม่มีชื่อที่แตกต่างกันคงเป็นไปไม่ได้ งั้นตั้งชื่อให้ bean เหล่านี้ต่อเลย
@Configuration
@EnableJpaRepositories(
entityManagerFactoryRef = "sqlserverEntityManagerFactory"
)
@EnableTransactionManagement
public class SqlServerDataSourceConfiguration {...@Primary
@Bean("sqlserverEntityManagerFactory")
public LocalContainerEntityManagerFactoryBean entityManagerFactoryBean(
EntityManagerFactoryBuilder builder,
@Qualifier("sqlserverDatasource") DataSource dataSource
) {
return builder
.dataSource(dataSource)
.packages("com.pros.multidatasource")
.build();
}
และอย่าลืมทำแบบเดียวกันนี้กับ PostgreSqlDataSourceConfiguration
ถึงตรงนี้ทดสอบรันแล้วต้องเป็นปกติ
ถัดมาคือ packages ที่กำกับ domain ที่จะต้องไปทำงานด้วย แต่ละ datasource หรือฐานข้อมูลก็จะมี domain เป็นของตนเองอยู่แล้ว ดูหัวข้อต่อไปเลยดีกว่า
Handle 2 Domains And ORM
ใจความคือ LocalContainerEntityManagerFactoryBean
กำหนดให้ไปดูแล package แหล่งเดียวกัน ตามตัวอย่างคือ com.pros.multidatasource
เป็นปกติที่เรามี 2 datasources ก็เพราะตารางในฐานข้อมูลเหล่านั้นไม่เหมือนกัน แสดงว่า LocalContainerEntityManagerFactoryBean
ต้องชี้ไปยัง package ที่แตกต่างกันครับ
สมมติให้ SqlServerDataSourceConfiguration เป็นเรื่องสวัสดีการประกันสังคม ส่วน PostgreSqlDataSourceConfiguration เป็นเรื่องของคนไข้ทำฟัน
สร้าง package ชื่อ com.pros.multidatasource.domain.sso
คลาสที่เป็นหัวใจคือ Member หรือสมาชิกของผู้ประกันตน
package com.pros.multidatasource.domain.sso;import lombok.Data;import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;@Data
@Entity
public class Member {
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Id
private Long id;
private String firstName;
private String lastName;
}
และสร้าง package ชื่อ com.pros.multidatasource.domain.dc
คลาสที่เป็นหัวใจคือ Patient หรือคนไข้ทำฟันที่สามารถเบิกค่าทำฟันกับประกันสังคมได้ปีละครั้ง
package com.pros.multidatasource.domain.dc;import lombok.Data;import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;@Data
@Entity
public class Patient {
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Id
private Long id;
private String firstName;
private String lastName;
}
ดังนั้นแก้ไข
- SqlServerDataSourceConfiguration ให้ package ชี้ไปยัง
com.pros.multidatasource.domain.sso
return builder
.dataSource(dataSource)
.packages("com.pros.multidatasource.domain.sso")
.build();
- PostgreSqlDataSourceConfiguration ให้ package ชี้ไปยัง
com.pros.multidatasource.domain.dc
return builder
.dataSource(dataSource)
.packages("com.pros.multidatasource.domain.dc")
.build();
ถึงตรงนี้ทดสอบรันแล้วต้องเป็นปกติ
ORM
Object Relational Mapping (ORM) นั้นว่าด้วยเรื่องของการสร้างความสัมพันธ์ระหว่างตารางโดยอัตโนมัตผ่านออบเจ็กต์จาวา โดยปกติไฟล์ application.yml ก็สามารถกำหนดได้แบบนี้
spring:
jpa:
hibernate:
ddl-auto: update
แต่เพราะเราให้ LocalContainerEntityManagerFactoryBean
ดูแลเรื่องนี้แทนแล้วการไป config ดังข้างต้นจึงไม่เกิดผลใดๆ (ถ้ามีก็ลบออกไปเสียเถิด)
กำหนดเพิ่มไปว่าเราต้องการแบบเดียวกันนี้แหละ บอกแก่ LocalContainerEntityManagerFactoryBean ไป
@Primary
@Bean("sqlserverEntityManagerFactory")
public LocalContainerEntityManagerFactoryBean entityManagerFactoryBean(
EntityManagerFactoryBuilder builder,
@Qualifier("sqlserverDatasource") DataSource dataSource
) {
Map<String, String> properties = new HashMap<>();
properties.put("hibernate.hbm2ddl.auto", "update");return builder
.dataSource(dataSource)
.packages("com.pros.multidatasource.domain.sso")
.properties(properties)
.build();
}
รันและดูผลลัพธ์
หมายเหตุ สำหรับฐานข้อมูล PostgreSQL คำสั่ง @GeneratedValue(strategy = GenerationType.IDENTITY) หากเกิดปัญหาไม่สามารถเข้าใจ auto increment ได้ก็ให้ใช้วิธีการสร้างตารางขึ้นมาเองก่อน เช่น
CREATE TABLE patient(ID SERIAL PRIMARY KEY NOT NULL);
ผลลัพธ์
Transactions
สุดท้ายคือเรื่อง transaction ระหว่าง datasource ใดๆ Spring Boot ให้ช่องทางนี้โดยกำหนดผ่าน transactionManagerRef ของ @EnableJpaRepositories แบบนี้
@Configuration
@EnableJpaRepositories(
entityManagerFactoryRef = "sqlserverEntityManagerFactory",
transactionManagerRef = "sqlserverTransactionManager"
)
@EnableTransactionManagement
public class SqlServerDataSourceConfiguration {...@Bean(name = "sqlserverTransactionManager")
public PlatformTransactionManager platformTransactionManager(
@Qualifier("sqlserverEntityManagerFactory") LocalContainerEntityManagerFactoryBean entityManagerFactory
) {
return new JpaTransactionManager(entityManagerFactory.getObject());
}
ระหว่าง datasources ทั้งหลายจะใช้ transaction ร่วมกันหรือแยกคิดว่าสามารถกำหนดชื่อเหมือนหรือต่างได้เลยครับ
โค้ดทั้งหมด
SqlServerDataSourceConfiguration
PostgreSqlDataSourceConfiguration
ผมเป็นคนหนึ่งที่พยายามเสมอที่จะศึกษาโปรเจกต์ที่อยู่ในมือเพื่อให้เกิดความเข้าใจ จากนั้นจะพยายามอธิบายว่าโค้ดพวกนั้นมันทำงานอย่างไรมากกว่าพึงพอใจแค่มันทำงานได้ คุณเป็นเหมือนผมไหม? ใจเขาใจเราถ้าไม่มีคนเสียสละความรู้ที่เขามีแบ่งปันกันแก่โลก บทความนี้ก็คงไม่เกิดขึ้นเป็นแน่
ไว้พบกันใหม่ สวัสดีครับ
อ้างอิง
https://www.baeldung.com/spring-data-jpa-multiple-databases
https://springframework.guru/how-to-configure-multiple-data-sources-in-a-spring-boot-application/
https://fullstackdeveloper.guru/2020/05/28/how-to-auto-generate-and-auto-increment-identifier-values-for-postgresql-tables-through-hibernate/