Zdarza się, że niezbędne jest połączenie się z dwoma bazami danych w ramach jednej aplikacji Spring Boota. Pytanie tylko jak to osiągnąć. Nie jest to tak trudne jakby mogło się wydawać. Postaram się przybliżyć Ci ten proces w dzisiejszym artykule. W celu uzyskania połączenia do odrębnych baz danych należy zdefiniować osobne DataSource. Dzięki application.properties jest to naprawdę łatwe do zrobienia. Oczywiście każde połączenie musi mieć dodatkowo własny EntityManagerFactory oraz PlatformTransactionManager. W ten sposób możemy poinstruować JPA jak chcemy osiągnąć nasz cel. Jeśli chodzi o detale to dobrze jest rozdzielić nasze klasy repozytoriów i encji na dwa osobne pakiety per baza danych. Zabierajmy się więc do działania.

Przygotowanie środowiska

Wykorzystując Dockera, a dokładniej Docker Compose, postawimy na start lokalnie dwie bazy danych MySQL. Poniżej zamieszczam przykładowy plik YAML.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
version: '3.8'

services:
  first-db:
  container_name: "first-db"
  image: mysql
  environment:
    MYSQL_ROOT_PASSWORD: root
    MYSQL_DATABASE: first_db
  ports:
    - "3307:3306"
  volumes:
    - "./scripts/init1.sql:/docker-entrypoint-initdb.d/1.sql"
    
  second-db:
  container_name: "second-db"
  image: mysql
  environment:
    MYSQL_ROOT_PASSWORD: root
    MYSQL_DATABASE: second_db
  ports:
    - "3308:3306"
  volumes:
    - "./scripts/init2.sql:/docker-entrypoint-initdb.d/1.sql"

Powyższą treść kopiujemy do pliku docker-compose.yaml i zapisujemy. Jak widzisz musimy jeszcze utworzyć inicjalne skrypty z instrukcjami SQL w katalogu scripts. Pierwszy z nich będzie odpowiedzialny za stworzenie tabeli posiadającej informacje o produkcie np. nazwę, cenę itp., a drugi za trzymanie informacji o ilości dostępnych egzemplarzy.

1
2
3
4
5
6
7
8
9
10
create table PRODUCT (
  id INT PRIMARY KEY AUTO_INCREMENT,
  name VARCHAR(100),
  price DECIMAL(7, 2),
  description VARCHAR(500)
);

INSERT INTO PRODUCT VALUES (1, 'Phone', 999.99, 'Super new mobile phone!');
INSERT INTO PRODUCT VALUES (2, 'Charger', 9.99, 'If you have phone, you definitely need this charger!');
INSERT INTO PRODUCT VALUES (3, 'TV', 2999.99, 'Extra thin TV fits to every home!');
1
2
3
4
5
6
7
8
9
create table PRODUCT_AMOUNT (
  id INT PRIMARY KEY AUTO_INCREMENT,
  product_id INT,
  amount INT
);

INSERT INTO PRODUCT_AMOUNT VALUES (1, 1, 10);
INSERT INTO PRODUCT_AMOUNT VALUES (2, 2, 100);
INSERT INTO PRODUCT_AMOUNT VALUES (3, 3, 3);

Następnie będąc w linii poleceń w folderze, gdzie umieściliśmy nasz plik, wpisujemy docker-compose up -d i czekamy aż nasze bazy danych powstaną. Dla sprawdzenia zajrzymy jeszcze do każdej z nich i upewnijmy się czy faktycznie mają one zdefiniowane wyżej tabele oraz rekordy.

Przygotowane kontenery z bazami danych w Dockerze
Przygotowane kontenery z bazami danych w Dockerze

Lista produktów w pierwszej bazie danych
Lista produktów w pierwszej bazie danych

Lista dostępnych egzemplarzy produktów w drugiej bazie danych
Lista dostępnych egzemplarzy produktów w drugiej bazie danych

Teraz przyszła pora na stworzenie aplikacji w Spring Boot. Wchodzimy na start.spring.io albo do IntelliJ Ultimate i wybieramy zależność do Web, JPA oraz MySQL. Generujemy i gotowe!

Przechodzimy do kodowania aplikacji

Pierwszym krokiem jest zdefiniowanie niezbędnych właściwości w application.properties, aby wskazywały na prawidłowe adresy baz danych.

spring.first-datasource.jdbc-url=jdbc:mysql://localhost:3307/first_db
spring.first-datasource.username=root
spring.first-datasource.password=root
spring.first-datasource.driver-class-name=com.mysql.cj.jdbc.Driver

spring.second-datasource.jdbc-url=jdbc:mysql://localhost:3308/second_db
spring.second-datasource.username=root
spring.second-datasource.password=root
spring.second-datasource.driver-class-name=com.mysql.cj.jdbc.Driver

spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL5Dialect
spring.jpa.show-sql=true

Standardowo podajemy adres URL, nazwę i hasło użytkownika oraz sterownik do obsługi każdej z baz danych. Nie korzystamy ze standardowych właściwości jak spring.datasource.jdbc-url tylko definiujemy własne dla pierwszego i drugiego połączenia. Na koniec możemy jeszcze ustawić dialekt, z którego ma korzystać Hibernate oraz czy zapytania SQL mają się pojawiać w logach.

Zdefiniowanie niezbędnych klocków

Jak wspomniałem wcześniej musimy zdefiniować dwa odrębne pakiety. W jednym z nich będziemy umieszczać encje i repozytoria odpowiedzialne za obsługę pierwszej bazy danych, a w kolejnym dla drugiej bazy danych. Muszę zwrócić uwagę, że kod, który zobaczysz poniżej nie jest żadną rekomendacją jeśli chodzi o prawidłowość biznesową. Skupiam się w nim tylko i wyłącznie na celu jakim jest pokazanie możliwości posiadania połączeń do dwóch oddzielnych baz danych.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
package pl.devcezz.twodbs.first;

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;
import java.math.BigDecimal;

@Entity
@Table(name = "PRODUCT")
public class Product {

  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;

  private String name;

  private BigDecimal price;

  private String description;

  protected Product() {
  }

  public Product(String name, BigDecimal price, String description) {
    this.name = name;
    this.price = price;
    this.description = description;
  }

  // getters & setters
}
1
2
3
4
5
6
package pl.devcezz.twodbs.first;

import org.springframework.data.jpa.repository.JpaRepository;

public interface ProductRepository extends JpaRepository<Product, Long> {
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
package pl.devcezz.twodbs.second;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;

@Entity
@Table(name = "PRODUCT_AMOUNT")
public class ProductAmount {

  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;

  @Column(name = "product_id")
  private Long productId;

  private Long amount;

  protected ProductAmount() {
  }

  public ProductAmount(Long productId, Long amount) {
    this.productId = productId;
    this.amount = amount;
  }

  public void increaseByOne() {
    amount++;
  }

  // getters & setters
}
1
2
3
4
5
6
7
8
9
10
package pl.devcezz.twodbs.second;

import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface ProductAmountRepository extends JpaRepository<ProductAmount, Long> {

  Optional<ProductAmount> findByProductId(Long productId);
}

Teraz nie pozostaje nam nic innego jak zagłębić się w czeluście konfiguracji.

Przygotowanie beanów konfiguracyjnych

Podział na pakiety nie był tutaj przypadkowy. Dzięki nim możemy nauczyć naszą aplikację, które repozytoria mają się łączyć z konkretną bazą danych. W ten sposób pakiet pl.devcezz.twodbs.first będzie miał połączenie z first-db, natomiast pl.devcezz.twodbs.second będzie obsługiwał second-db.

Dzięki adnotacji EnableTransactionManagement otrzymamy wsparcie przy tworzenia transakcji poprzez wykorzystanie @Transactional. Natomiast adnotacja EnableJpaRepositories pomoże nam w zarządzaniu danymi repozytoriami Spring Data, które znajdą się we wskazanym pakiecie.

Niezbędnymi klockami dla każdej konfiguracji połączenia z bazą danych będą DataSource, LocalContainerEntityManagerFactoryBean i PlatformTransactionManager. Idąc po kolei, DataSource odpowiada stricte za ustanowienie połączenie z konkretną bazą danych, a LocalContainerEntityManagerFactoryBean obsługuje cykl życia encji. Natomiast PlatformTransactionManager jest wsparciem do obsługi transakcji. Przy okazji konfiguracji DataSource warto zwrócić uwagę na @ConfigurationProperties. To właśnie dzięki tej adnotacji wskazujemy jakie właściwości z application.properties mają być wykorzystane przy ustanowieniu danego połączenia.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
package pl.devcezz.twodbs.first;

import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.jdbc.DataSourceBuilder;
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 org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.EnableTransactionManagement;

import javax.persistence.EntityManagerFactory;
import javax.sql.DataSource;

@Configuration
@EnableTransactionManagement
@EnableJpaRepositories(
    entityManagerFactoryRef = "firstEntityManagerFactory",
    transactionManagerRef = "firstTransactionManager",
    basePackages = {"pl.devcezz.twodbs.first"}
)
public class FirstDbConfig {

  @Bean
  @Primary
  @ConfigurationProperties(prefix = "spring.first-datasource")
  public DataSource firstDataSource() {
    return DataSourceBuilder.create().build();
  }

  @Bean
  @Primary
  public LocalContainerEntityManagerFactoryBean firstEntityManagerFactory(EntityManagerFactoryBuilder entityManagerFactoryBuilder,
                                      @Qualifier("firstDataSource") DataSource firstDataSource) {
    return entityManagerFactoryBuilder.dataSource(firstDataSource)
        .packages("pl.devcezz.twodbs.first")
        .build();
  }

  @Bean
  @Primary
  public PlatformTransactionManager firstTransactionManager(
      @Qualifier("firstEntityManagerFactory") EntityManagerFactory firstEntityManagerFactory) {
    return new JpaTransactionManager(firstEntityManagerFactory);
  }

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
package pl.devcezz.twodbs.second;

import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.jdbc.DataSourceBuilder;
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 org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.EnableTransactionManagement;

import javax.persistence.EntityManagerFactory;
import javax.sql.DataSource;

@Configuration
@EnableTransactionManagement
@EnableJpaRepositories(
    entityManagerFactoryRef = "secondEntityManagerFactory",
    transactionManagerRef = "secondTransactionManager",
    basePackages = {"pl.devcezz.twodbs.second"}
)
public class SecondDbConfig {

  @Bean
  @ConfigurationProperties(prefix = "spring.second-datasource")
  public DataSource secondDataSource() {
    return DataSourceBuilder.create().build();
  }

  @Bean
  public LocalContainerEntityManagerFactoryBean secondEntityManagerFactory(EntityManagerFactoryBuilder entityManagerFactoryBuilder,
                                       @Qualifier("secondDataSource") DataSource secondDataSource) {
    return entityManagerFactoryBuilder.dataSource(secondDataSource)
        .packages("pl.devcezz.twodbs.second")
        .build();
  }

  @Bean
  public PlatformTransactionManager secondTransactionManager(
      @Qualifier("secondEntityManagerFactory") EntityManagerFactory secondEntityManagerFactory) {
    return new JpaTransactionManager(secondEntityManagerFactory);
  }

}

Wszędzie zostały wykorzystane kwalifikatory mające w sobie nazwę danej metody tworzącej beana (domyślne nazewnictwo). W klasie konfiguracyjnej FirstDbConfig wszystkie beany oznaczyliśmy jako @Primary, aby były domyślnym rozwiązaniem, gdyby Spring nie będzie widział jaką zależność wstrzyknąć. Jak się zaraz przekonamy powoduje to pewną niedogodność.

API naszej aplikacji

Idąc dalej musimy jeszcze przygotować API, dzięki któremu sprawdzimy działanie naszej aplikacji. Wystawimy dwa endpointy /shop/product i /shop/product/increase-amount typu POST. Niezbędne funkcjonalności znajdują się w serwisie ShopService. To tam będziemy dodawać nowe produkty oraz zwiększać ich ilość o jeden.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
package pl.devcezz.twodbs;

import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.math.BigDecimal;

@RestController
@RequestMapping("/shop")
class ShopController {

  private final ShopService shopService;

  ShopController(ShopService shopService) {
    this.shopService = shopService;
  }

  @PostMapping("/product")
  void addProduct(@RequestBody AddProductRequest request) {
    shopService.addProduct(request.name(), request.price(), request.description());
  }

  @PostMapping("/product/increase-amount")
  void increaseAmountByOne(@RequestBody IncreaseProductAmountRequest request) {
    shopService.increaseAmountByOne(request.productId());
  }
}

record AddProductRequest(String name, BigDecimal price, String description) {
}

record IncreaseProductAmountRequest(Long productId) {
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
package pl.devcezz.twodbs;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import pl.devcezz.twodbs.first.Product;
import pl.devcezz.twodbs.first.ProductRepository;
import pl.devcezz.twodbs.second.ProductAmount;
import pl.devcezz.twodbs.second.ProductAmountRepository;

import java.math.BigDecimal;

@Service
public class ShopService {

  private final ProductRepository productRepository;
  private final ProductAmountRepository productAmountRepository;

  public ShopService(ProductRepository productRepository, ProductAmountRepository productAmountRepository) {
    this.productRepository = productRepository;
    this.productAmountRepository = productAmountRepository;
  }

  @Transactional
  public void addProduct(String name, BigDecimal price, String description) {
    Product savedProduct = productRepository.save(
        new Product(name, price, description));
    productAmountRepository.save(
        new ProductAmount(savedProduct.getId(), 1L));
  }

  @Transactional
  public void increaseAmountByOne(Long productId) {
    productAmountRepository.findByProductId(productId)
        .ifPresent(ProductAmount::increaseByOne);
  }
}

Czujny czytelnik zauważy, że w increaseAmountByOne nie ma użycia metody save oraz fakt, że wykorzystano adnotację @Transactional z pakietu org.springframework.transaction.annotation, a nie javax.transaction. Pierwsze pominięcie spowodowane jest tym, że powinien zadziałać mechanizm dirty-checkingu. To Spring będzie odpowiedzialny za zapis wartości encji, ale czy na pewno? Zaraz się o tym przekonamy jak przejdziemy do weryfikowania działania aplikacji.

Weryfikacja stworzonej aplikacji

Uruchamiamy aplikację, przechodzimy do Postmana i uderzamy na endpoint, aby utworzyć nowy produkt. Otrzymaliśmy status 200, więc wchodzimy do bazy danych. Tam wykonujemy proste zapytanie o wszystkie produkty i znajdujemy wśród nich dodane słuchawki.

Wykonanie zapytania utworzenia produktu w Postman
Wykonanie zapytania utworzenia produktu w Postman

Dodany produkt pojawił się w pierwszej bazie danych
Dodany produkt pojawił się w pierwszej bazie danych

Następnie spróbujemy zwiększyć liczbę dostępnych egzemplarzy dla produktu o id 1. Tworzymy nowe zapytanie w Postman, wykonujemy je i otrzymujemy 200. Wchodzimy na docker z drugą bazą danych, a tam… brak jakiejkolwiek zmiany! No dobra, jest zmiana, bo doszedł nowy rekord, ale liczba egzemplarzy produktu o id 1 została na poziomie dziesięciu. Gdy wejdziemy w logi aplikacji zobaczymy, że wykonał tam się tylko SELECT bez UPDATE. Dlaczego tak się stało?

Wykonanie zapytania zwiększenia liczby egzemplarzy produktu w Postman
Wykonanie zapytania zwiększenia liczby egzemplarzy produktu w Postman

Brak jakiejkolwiek zmiany w drugiej bazie danych (poza dodaniem wpisu z poprzedniego zapytania)
Brak jakiejkolwiek zmiany w drugiej bazie danych (poza dodaniem wpisu z poprzedniego zapytania)

W logach dostajemy informację o wykonaniu się tylko zapytania SELECT
W logach dostajemy informację o wykonaniu się tylko zapytania SELECT

Po prostu nie zadziałał mechanizm dirty-checkingu. Okazuje się, że domyślnie dla adnotacji @Transactional nad metodą increaseAmountByOne w klasie ShopService został wykorzystany menadżer transakcji dla pierwszej bazy danych zamiast dla drugiej. Zadziało się to właśnie z racji adnotacji @Primary. Rozwiązaniem jest wykorzystanie właściwości adnotacji @Transational z pakietu org.springframework.transaction.annotation o nazwie transactionManager. Jeśli zastosujemy nad problematyczną metodą @Transactional("secondTransactionManager") to kłopot powinien zniknąć. Wykonujemy jeszcze raz zapytanie i faktycznie, wszystko działa jak należy. To rozwiązanie zostało zaczerpnięte z dyskusji na StackOverflow.

Po poprawce w logach znajdziemy już prawidłowe zapytania do drugiej bazy danych
Po poprawce w logach znajdziemy już prawidłowe zapytania do drugiej bazy danych

Licznik egzemplarzy dla produktu o id 1 został prawidłowo zwiększony o jeden
Licznik egzemplarzy dla produktu o id 1 został prawidłowo zwiększony o jeden

Dla ciekawych informacja, że w logach znajdziemy wpis o tym, że Spring Boot nawet w tej sytuacji wykorzystał HikariCP. Więcej o tym narzędziu przeczytasz w poprzednim wpisie.

Podsumowanie

Przy obsłudze dwóch baz danych trzeba się trochę napracować. Nie dostajemy wszystkie ad hoc jak ma się to w przypadku standardowej konfiguracji, ale jednak wszystko jest do ogarnięcia. Naprawdę warto sprawdzić jak to wygląda w praktyce. Można oczywiście pójść o krok dalej i spróbować wykorzystać dwie różne bazy danych, w tym jedną NoSQL. Oczywiście takie rozwiązanie daje nam możliwość rozdzielenia aplikacji na kilka niezależnych kontekstów w ramach jednego projektu.

Link do GitHub: https://github.com/cezarysanecki/code-from-blog