Was ist Multi-Tenancy?
Multi-Tenancy beschreibt eine Architektur, bei der eine Anwendung serverseitig mehrere Tenants bedient. Übersetzt bedeutet Multi-Tenancy Mehrmandantenfähigkeit und Tenants Mandanten. Letztere können verschiedene Kunden, Benutzer oder Nutzergruppen sein. Die Besonderheit dieser Architektur besteht darin, den Tenants zwar eine gemeinsame Infrastruktur bereitzustellen, aber gleichzeitig eine separierte Datenhaltung zu gewährleisten, sodass verschiedene Tenants ihre Daten nicht gegenseitig einsehen können. Aus technischer Sicht wird für Datenseparierung in der Regel eine eigene Datenbank (im Folgenden auch DB) oder ein eigenes Datenbankschema pro Tenant aufgesetzt.
Arten von Multi-Tenancy
Multi-Tenancy bietet im Wesentlichen drei verschiedene Ansätze:
- Separate Datenbanken: Die Daten jedes einzelnen Tenants werden in separaten Datenbanken gehalten.
- Geteilte Datenbank und separate Schemata: Die Tenants teilen sich eine Datenbank, jedoch besitzt jeder Tenant sein eigenes Datenbankschema, in dem seine Daten persistiert werden.
- Geteilte Datenbank und geteiltes Schema: Hierbei verwenden alle Tenants dieselbe Datenbank und dasselbe Datenbankschema, wobei jede Datenbanktabelle eine Tenant-Identifier-Spalte besitzt, die den Tenant eindeutig zuordnet.
Im folgenden Codebeispiel beleuchten wir eine gesonderte Art der Multi-Tenancy: Es soll pro Tenant eine separate Datenbank und eine gemeinsam geteilte Datenbank verwendet werden. Dieses Szenario taucht in der Praxis häufig auf, wenn wir einerseits sensible Daten isoliert persistieren müssen, aber auf einer gemeinsamen Datenbasis aufbauen möchten.
Warum Multi-Tenancy?
- Gegenüber Single-Tenancy ist eine mehrmandantenfähige Architektur in der Lage, eine einzige Software-Instanz mehreren Tenants zur Nutzung bereitzustellen.
- Durch die mandantenübergreifende Skalierbarkeit von Hardwareressourcen ist eine schnelle und kostengünstige Neuinstallation von Mandanten möglich.
- Der Releasemanagementprozess ist effizienter: Bei Single-Tenant-Anwendungen müssen grundsätzlich alle softwarebasierten Änderungen pro Tenant individuell auf separaten Servern eingespielt werden. Bei Multi-Tenant-Lösungen ist dies deutlich effizienter, weil wir Änderungen nur auf einem gemeinsamen Server aktualisieren müssen.
- Die gemeinsame Nutzung der Ressourcen wirkt sich außerdem positiv auf die Faktoren Wartbarkeit, Erweiterbarkeit und Überwachung aus.
Beispiel: Implementierung eines länderübergreifenden Online-Shops
Die folgende Implementierung soll ein minimalistischer Online-Shop sein. Wir möchten Produkte anbieten, die Kunden aus unterschiedlichen Ländern bestellen können. Das folgende Diagramm veranschaulicht die Entitäten der Anwendung:
Das obige UML-Diagramm zeigt die Klassen unseres Beispielprojekts und in welchen Datenbanken sie als Entitäten gespeichert werden. Da wir Produkte global anlegen möchten, werden wir sie in einer geteilten Datenbank shared ablegen. Wir haben sie durch einen Namen und einen eindeutigen Code gekennzeichnet. Der Online-Shop soll länderübergreifend sein. Das bedeutet, dass es pro Land eine Tenant-Datenbank gibt. Ein Kunde wird durch einen eindeutigen Code (customerCode) identifiziert und hat darüber hinaus einen Vornamen, Nachnamen und eine Adresse. Kunden können Bestellungen tätigen, wobei eine Bestellung einen Bestellzeitpunkt, eine Kundenreferenz und das bestellte Produkt anhand des Produkt-Codes umfasst. Demnach halten wir Kunden und deren Bestellungen getrennt in den Tenant-Datenbanken fest. Das Produkt ordnen wir in einer Bestellung durch den Produktcode zu. Wir stellen es nicht durch eine Komposition dar, weil Entitätsbeziehungen zwischen unterschiedlichen relationalen Datenbanken nicht ohne Weiteres erstellt werden können.
In unserem Beispiel beschränken wir uns auf die zwei Tenant-Datenbanken germany und spain, sodass Kunden aus Deutschland und Spanien Bestellungen tätigen können.
Das Beispiel implementieren wir mit den folgenden Technologien:
- Java (v.17)
- Spring Boot (v.3.0.5)
- PostgreSQL (v.15)
Den gesamten Code könnt ihr in GitHub einsehen. Das Projekt kann ausgecheckt und lokal ausgeführt werden (genutzte Entwicklungsumgebung: IntelliJ).
Im Folgenden zeigen und erläutern wir wichtige Codestellen des Demoprojekts:
Konfiguration des Spring-Boot-Projekts
Da es sich bei der Demo-Applikation um ein Spring-Boot-Projekt handelt, werfen wir zunächst einen Blick auf die Konfigurationsdatei application.properties
.
spring.jpa.open-in-view=false
spring.jpa.properties.hibernate.multiTenancy=DATABASE
spring.jpa.properties.hibernate.multi_tenant_connection_provider=io.github.danielnaczo.multitenancydemo.database.multitenancy.MultiTenantConnectionProviderImpl
spring.jpa.properties.hibernate.multi_tenant_identifier_resolver=io.github.danielnaczo.multitenancydemo.database.multitenancy.TenantIdentifierResolver
spring.jpa.properties.hibernate.multi_tenant_routing=io.github.danielnaczo.multitenancydemo.database.multitenancy.MultiTenantRoutingDatasource
Code-Sprache: JavaScript (javascript)
Die erste Property spring.jpa.open-in-view
in Zeile 1 ist für das vorliegende Multi-Tenancy-Szenario essenziell. Mithilfe dieser Property legen wir fest, ob die Applikation das Pattern Open Session in View (OSIV) nutzen soll. Es dient dazu, am Anfang eines Requests eine Hibernate-Session zu öffnen. Jedes Mal, wenn im Codefluss des Requests eine Datenbank-Connection benötigt wird, greift die Anwendung auf diese Session zurück und liefert die entsprechende Connection. Der große Nachteil dabei ist, dass während des REST-Calls im Falle einer aufgespannten Session, kein DataSource-Wechsel mehr möglich ist, sodass wir nicht auf unterschiedliche Datenbanken zugreifen können. Indem wir dieses Property auf false
setzen, wird OSIV deaktiviert und datenbankübergreifende Zugriffe werden ermöglicht.
Die zweite Property in Zeile 2 kennzeichnet den gewählten Multi-Tenancy-Modus. Da wir pro Tenant eine eigene Datenbank aufsetzen, wählen wir hier DATABASE
.
Die Eigenschaften in den Zeilen 3–5 geben die Pfade zu den Klassen MultiTenantConnectionProviderImpl
, TenantIdentifierResolver
und MultiTenantRoutingDatasource
an, die zur Verwendung von Multi-Tenancy im Projekt implementiert werden müssen. Diese Klassen erläutern wir im Laufe dieses Beitrags näher.
Request-Scope-basierte Auflösung des Tenants
In diesem Beispielprojekt lösen wir den Tenant bei einem Request auf. Dafür implementieren wir eine Klasse TenantContext
, die den Tenant zwischenspeichert:
import org.springframework.stereotype.Component;
import org.springframework.web.context.annotation.RequestScope;
@Component
@RequestScope
public class TenantContext {
private String tenant;
public String getTenant() {
return tenant;
}
public void setTenant(String tenant) {
this.tenant = tenant;
}
}
Code-Sprache: JavaScript (javascript)
Die Klasse TenantContext
versehen wir mit zwei Spring-Boot-Annotationen. Die Annotation @Component
beschreibt eine eigene Spring-Boot-Komponente zur automatischen Bean-Erstellung. Damit ein Tenant pro Request aufgelöst wird, annotieren wir die Klasse anhand von @RequestScope
. Die Klasse enthält ein Attribut tenant
vom Typ String
und enthält die dazugehörigen get
und set
Funktionen. Bei jedem Request hält somit eine Request-basierte Instanz der Klasse TenantContext
den Tenant bereit, um auf die korrespondierende Datenbank zugreifen zu können. Der tenant
ist hierbei der Datenbankname (zum Beispiel germany oder spain).
Der Tenant muss bei einem Request gesetzt werden. Dafür implementieren wir die Klasse TenantFilter
, die den Tenant aus einem HTTP-Header-Feld extrahiert.
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
public class TenantFilter extends OncePerRequestFilter {
private static final String TENANT_HEADER_NAME = "X-Tenant-Id";
private final TenantContext tenantContext;
public TenantFilter(TenantContext tenantContext) {
this.tenantContext = tenantContext;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String tenant = request.getHeader(TENANT_HEADER_NAME);
tenantContext.setTenant(tenant);
filterChain.doFilter(request, response);
}
}
Code-Sprache: JavaScript (javascript)
Die Klasse TenantFilter
erbt von der Klasse OncePerRequestFilter
und überschreibt die Methode doFilterInternal
. Die überschriebene Methode extrahiert aus den HTTP-Headern das Feld X-Tenant-Id. Der extrahierte Wert wird dem Request-Scope-basierten TenantContext
übergeben.
In der Praxis werden zur Auflösung des Tenants tokenbasierte Lösungen implementiert. Da die Implementierung in der Regel aufwendiger ist, haben wir in diesem Beispiel auf Token verzichtet und stattdessen die vereinfachte filterbasierte Lösung eingesetzt.
Wichtig zu erwähnen ist, dass für die korrekte Funktion dieses Projektbeispiels in jedem Request das X-Tenant-Id Header-Feld vorhanden sein muss, damit der Tenant aufgelöst werden kann.
Implementierung der Multi-Tenancy-spezifischen Klassen
In diesem Abschnitt erläutern wir die wichtigen Klassen TenantIdentifierResolver
, MultiTenantRoutingDatasource
und MultiTenantConnectionProviderImpl
näher, da sie für das Multi-Tenancy-Projekt von zentraler Bedeutung sind.
Werfen wir jedoch zunächst einen Blick in die Klasse MultiTenancyKeys
. Dort werden einige Konstanten definiert, die für die Datenbankanbindung wichtig sind.
import static org.springframework.boot.jdbc.DatabaseDriver.POSTGRESQL;
public class MultiTenancyKeys {
public static final String DEFAULT_DB_NAME = "shared";
public static final String URL_PREFIX = "jdbc:postgresql://localhost:5432/";
public static final String USERNAME = "postgres";
public static final String PASSWORD = "";
public static final String DRIVER_CLASS_NAME = POSTGRESQL.getDriverClassName();
}
Code-Sprache: PHP (php)
Im Demoprojekt verwenden wir PostgreSQL (v.15) als Datenbank. Die oben definierten Konstanten sollen demnach die Postgres-Datenbankanbindung ermöglichen. Die Konstante DEFAULT_DB_NAME
ist der Name der Datenbank für die geteilten Entitäten. Die restlichen Konstanten sind PostgresSQL-spezifische Konstanten.
Falls ihr das Demoprojekt auschecken und lokal ausführen möchtet, aber einen anderen Datenbankprovider nutzen möchtet, könnt ihr dies relativ komfortabel durch die Modifizierung der Konstanten vornehmen.
Schauen wir uns nun die Klasse TenantIdentifierResolver
an.
import org.hibernate.cfg.AvailableSettings;
import org.hibernate.context.spi.CurrentTenantIdentifierResolver;
import org.springframework.boot.autoconfigure.orm.jpa.HibernatePropertiesCustomizer;
import org.springframework.stereotype.Component;
import java.util.Map;
import static io.github.danielnaczo.multitenancydemo.database.multitenancy.MultiTenancyKeys.DEFAULT_DB_NAME;
@Component
public class TenantIdentifierResolver implements CurrentTenantIdentifierResolver, HibernatePropertiesCustomizer {
private static final ThreadLocal<String> currentTenant = new ThreadLocal();
public void setTenant(String tenant) {
currentTenant.set(tenant);
}
public void setTenantToDefault() {
currentTenant.set(DEFAULT_DB_NAME);
}
@Override
public String resolveCurrentTenantIdentifier() {
if (currentTenant.get() != null) {
return currentTenant.get();
}
return DEFAULT_DB_NAME;
}
@Override
public boolean validateExistingCurrentSessions() {
return false;
}
@Override
public void customize(Map<String, Object> hibernateProperties) {
hibernateProperties.put(AvailableSettings.MULTI_TENANT_IDENTIFIER_RESOLVER, this);
}
}
Code-Sprache: JavaScript (javascript)
Die Klasse TenantIdentifierResolver
dient in erster Linie als Schnittstelle zu Hibernate und zur Auflösung des Tenants. Demzufolge implementiert die Klasse das Interface CurrentTenantIdentifierResolver
, wobei die zwei Methoden resolveCurrentTenantIdentifier
und validateExistingCurrentSessions
implementiert werden müssen. Um ein besseres Verständnis für die zu implementierenden Methoden zu bekommen, blicken wir zunächst auf die Konstante currentTenant
in Zeile 11. Es handelt sich um ein ThreadLocal
-Objekt, in dem Strings gespeichert werden. Ein ThreadLocal
-Objekt kennzeichnet sich dadurch, dass Daten threadgebunden zwischengespeichert und nur threadspezifisch ausgelesen werden. Da das Beispielprojekt ein Multi-Thread-Projekt ist, request-scoped und zur Laufzeit auf verschiedene Datenbanken zugreift, soll der Tenant pro Request dynamisch aufgelöst und threadbezogen verfügbar sein. Die Methode setTenant
bietet die Möglichkeit, den Tenant in der ThreadLocal-Konstante anhand des übergebenen Arguments zu setzen. Analog dazu setzt die Methode setTenantToDefault
den Tenant auf die geteilte Default-Datenbank. Die zu überschreibende Methode resolveCurrentTenantIdentifier
gibt den aktuell gesetzten Tenant zurück. Wenn dieser nicht gesetzt ist, wird der Default-Tenant DEFAULT_DB_NAME
zurückgegeben. Die Methode validateExistingCurrentSessions
gibt false
zurück, da in diesem Projekt keine Sessions um die Requests gespannt werden. Zuletzt implementieren wir das Interface HibernatePropertiesCustomize
und die zu überschreibende Methode customize
fügt den TenantIdentifierResolver
den HibernateProperties hinzu.
Der folgende Codeauszug zeigt die Klasse MultiTenantRoutingDatasource
:
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.jdbc.DataSourceBuilder;
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource;
import org.springframework.stereotype.Component;
import javax.sql.DataSource;
import java.util.HashMap;
import java.util.Map;
import static io.github.danielnaczo.multitenancydemo.database.multitenancy.MultiTenancyKeys.*;
@Component
public class MultiTenantRoutingDatasource extends AbstractRoutingDataSource {
private final TenantIdentifierResolver tenantIdentifierResolver;
@Autowired
public MultiTenantRoutingDatasource(TenantIdentifierResolver tenantIdentifierResolver) {
this.tenantIdentifierResolver = tenantIdentifierResolver;
createDefaultDatabase();
createTenantDatabases();
this.afterPropertiesSet();
}
private void createDefaultDatabase() {
String defaultDBUrl = URL_PREFIX + DEFAULT_DB_NAME;
setDefaultTargetDataSource(createDatasource(defaultDBUrl, USERNAME, PASSWORD));
}
private void createTenantDatabases() {
Map<Object, Object> targetDataSources = new HashMap();
createTenantDatabase("germany", targetDataSources);
createTenantDatabase("spain", targetDataSources);
this.setTargetDataSources(targetDataSources);
}
private void createTenantDatabase(String databaseName, Map<Object, Object> targetDataSources) {
DataSource dataSource = createDatasource(URL_PREFIX + databaseName, USERNAME, PASSWORD);
targetDataSources.put(databaseName, dataSource);
}
private DataSource createDatasource(String url, String username, String password) {
return DataSourceBuilder
.create()
.driverClassName(DRIVER_CLASS_NAME)
.url(url)
.username(username)
.password(password)
.build();
}
@Override
protected Object determineCurrentLookupKey() {
return this.tenantIdentifierResolver.resolveCurrentTenantIdentifier();
}
}
Code-Sprache: JavaScript (javascript)
Die Klasse MultiTenantRoutingDatasource
erbt von der Klasse AbstractRoutingDataSource
und wird als Spring-Boot-Komponente annotiert. In dieser Klasse werden alle Datasources initialisiert, damit die Applikation Zugriff auf die Datenbanken hat. Angesichts dessen werden im Konstruktor in den Zeilen 18 und 19 zwei Untermethoden aufgerufen, die einerseits die Datasource der Default-Datenbank shared und andererseits die Datasources der Tenant-DBs germany und spain kreiert. Der Code this.afterPropertiesSet()
in Zeile 20 stellt sicher, dass die Datasources nach der Initialisierung der Applikation zur Verfügung stehen. Die privaten Methoden zwischen den Zeilen 23 und 48 dienen der Initialisierung der Datasources. Wichtig hierbei ist, dass die Tenant-Datasources einer Map angehängt werden müssen, die in Zeile 32 dem Spring-Framework zur Verfügung gestellt wird. Dabei sind die Keys der Map die Tenants bzw. die Datenbanknamen und die Values die Datasources. Die zu überschreibende Methode determineCurrentLookupKey
ruft die Applikation immer dann auf, wenn eine Kommunikation mit einer Datenbank erfolgt. Da der Tenant im TenantIdentifierResolver
aufgelöst wird, reicht die Methode den Tenant mithilfe der Methode resolveCurrentTenantIdentifier()
des TenantIdentifierResolver
durch.
Nachfolgend zeigt der Auszug Teile des Codes zur Klasse MultiTenantConnectionProviderImpl
:
import org.hibernate.cfg.AvailableSettings;
import org.hibernate.engine.jdbc.connections.spi.MultiTenantConnectionProvider;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.orm.jpa.HibernatePropertiesCustomizer;
import org.springframework.stereotype.Component;
import java.sql.Connection;
import java.sql.SQLException;
import java.util.Map;
@Component
public class MultiTenantConnectionProviderImpl implements MultiTenantConnectionProvider, HibernatePropertiesCustomizer {
private final MultiTenantRoutingDatasource multiTenantRoutingDatasource;
@Autowired
public MultiTenantConnectionProviderImpl(MultiTenantRoutingDatasource multiTenantRoutingDatasource) {
this.multiTenantRoutingDatasource = multiTenantRoutingDatasource;
}
@Override
public Connection getAnyConnection() throws SQLException {
return multiTenantRoutingDatasource.getConnection();
}
@Override
public void releaseAnyConnection(Connection connection) throws SQLException {
connection.close();
}
@Override
public Connection getConnection(String connectionDetails) throws SQLException {
String tenantIdentifier = connectionDetails.split(":")[0];
Connection connection;
if (tenantIdentifier.equals(MultiTenancyKeys.DEFAULT_DB_NAME)) {
connection = this.multiTenantRoutingDatasource.getResolvedDefaultDataSource().getConnection();
} else {
connection = this.multiTenantRoutingDatasource.getResolvedDataSources().get(tenantIdentifier).getConnection();
}
return connection;
}
@Override
public void releaseConnection(String s, Connection connection) throws SQLException {
connection.close();
}
@Override
public void customize(Map<String, Object> hibernateProperties) {
hibernateProperties.put(AvailableSettings.MULTI_TENANT_CONNECTION_PROVIDER, this);
}
}
Code-Sprache: JavaScript (javascript)
Die Klasse MultiTenantConnectionProviderImpl
implementiert das Interface MultiTenantConnectionProvider
und besitzt ein Attribut der zuvor erläuterten Bean MultiTenantRoutingDatasource
. Die Methode getAnyConnection
nimmt sich aus der MultiTenantRoutingDatasource
eine beliebige Connection und releaseAnyConnection
schließt die übergebene Connection. Die Methode getConnection
in den Zeilen 30–40 extrahiert den Tenant aus den connectionDetails
, ermittelt daraus die entsprechende Datasource und gibt eine verfügbare Connection zurück. Anhand der Methode releaseConnection
wird die übergebene Connection geschlossen. Analog zur Klasse TenantIdentifierResolver
fügen wir den HibernateProperties in Zeile 49 die Klasse MultiTenantConnectionProviderImpl
hinzu.
Aspektorientiertes Setzen des Tenants
Die Besonderheit dieses Demoprojekts besteht darin, dass der Zugriff auf die Datenbank von Entitäten abhängig ist. Löst der Kunde eine Bestellung aus, ermittelt die Applikation das zu bestellende Produkt in der geteilten Datenbank shared. Die Applikation persistiert die Bestellung jedoch in der länderspezifischen Tenant-Datenbank. Demzufolge ist eine dynamische, sich zur Laufzeit ändernde Auflösung des Tenants erforderlich. Dies soll anhand der von Spring unterstützten aspektorientierten Programmierung erfolgen.
Die aspektorientierte Programmierung (AOP) ist ein Programmierparadigma, das darauf abzielt, die Modularität zu erhöhen. Dabei wird zusätzlicher Code zu bestehendem hinzugefügt, ohne den Code selbst zu verändern. Der neue Code und die dazugehörige neue Logik wird demnach separat deklariert. Das AOP-Framework von Spring unterstützt hierbei die Integrierung eigener Aspekte.
Für das dynamische Setzen des Tenants implementieren wir die zwei Aspekte ResolveTenantAspect
und ResolveDefaultTenantAspect
. Der folgende Code stellt die Klasse ResolveTenantAspect
dar:
import io.github.danielnaczo.multitenancydemo.database.multitenancy.TenantContext;
import io.github.danielnaczo.multitenancydemo.database.multitenancy.TenantIdentifierResolver;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Before;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
@Aspect
@Component
@Order(20)
public class ResolveTenantAspect {
private final TenantIdentifierResolver tenantIdentifierResolver;
private final TenantContext tenantContext;
public ResolveTenantAspect(TenantIdentifierResolver tenantIdentifierResolver, TenantContext tenantContext) {
this.tenantIdentifierResolver = tenantIdentifierResolver;
this.tenantContext = tenantContext;
}
@Before("execution(* io.github.danielnaczo.multitenancydemo.database.service.tenant.*.*(..))")
public void resolveTenant() {
setTenant();
}
@Before("@annotation(io.github.danielnaczo.multitenancydemo.database.multitenancy.aspect.annotation.SetTenantForTransaction)")
public void resolveTenantBeforeTransaction() {
setTenant();
}
private void setTenant() {
this.tenantIdentifierResolver.setTenant(tenantContext.getTenant());
}
}
Code-Sprache: JavaScript (javascript)
Damit die Applikation die Klasse ResolveTenantAspect
als Aspekt erkennt, müssen wir sie mit @Aspect
annotieren. Außerdem weist die Annotation @Order(20)
dem Aspekt eine eigene Präzedenz zu. Präzedenzen legen fest, in welcher Reihenfolge Aspekte ausgeführt werden. Wenn eine Methode mit mehr als einem Aspekt annotiert wird, dann führt die Applikation den Code der Aspekte anhand der Präzedenzwerte in aufsteigender Reihenfolge aus.
Die Klasse ResolveTenantAspect
besitzt zwei Beans der Klassen TenantIdentifierResolver
und TenantContext
. Im Projekt selbst gibt es Persistence-Klassen, die als Schnittstelle zwischen Services und Repositorys fungieren. In diesen Persistence-Klassen stellen wir Methoden zur Verfügung, die die entsprechenden Repository-Methoden aufrufen. Als Beispiel führen wir die Klasse CustomerPersistenceService
auf:
package io.github.danielnaczo.multitenancydemo.database.service.tenant;
import io.github.danielnaczo.multitenancydemo.database.repository.CustomerRepository;
import io.github.danielnaczo.multitenancydemo.model.tenant.Customer;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class CustomerPersistenceService {
private final CustomerRepository customerRepository;
@Autowired
public CustomerPersistenceService(CustomerRepository customerRepository) {
this.customerRepository = customerRepository;
}
public Customer saveCustomer(Customer customer) {
return this.customerRepository.save(customer);
}
public Customer findCustomerByCustomerCode(String customerCode) {
return this.customerRepository.findCustomerByCustomerCode(customerCode);
}
}
Code-Sprache: JavaScript (javascript)
In der Klasse CustomerPersistenceService
gibt es eine Bean CustomerRepository
, die das entsprechende Repository referenziert. Die zwei Methoden saveCustomer
und findCustomerByCustomerCode
rufen ihre passenden Repository-Methoden auf.
Damit vor einem Repository-Aufruf der richtige Tenant gesetzt wird, versehen wir die Methode resolveTenant
in der Klasse ResolveTenantAspect
mit einer Annotation @Before("execution(* io.github.danielnaczo.multitenancydemo.database.service.tenant.*.*(..))")
. Die Annotation sorgt dafür, dass alle Methoden in den Klassen des Packages io.github.danielnaczo.multitenancydemo.database.service.tenant
automatisch annotiert werden. Der Inhalt der Annotation wird somit vor jeder Methode des Packages ausgeführt. Beispielsweise befinden sich in diesem Package die Persistence-Klassen zu den Entitäten Customer und Order. Der Inhalt der Methode setTenant()
setzt den Tenant im TenantIdentifierResolver
, den wir aus dem Request-Scope-basierten TenantContext
entnehmen.
Ferner implementiert die Klasse ResolveTenantAspect
eine weitere Methode resolveTenantBeforeTransaction
, die auch dieselbe Implementierung besitzt. Hierbei wird mithilfe der Annotation @Before("@annotation(io.github.danielnaczo.multitenancydemo.database.multitenancy.aspect.annotation.SetTenantForTransaction)")
eine neue eigene Annotation referenziert. Die Annotation SetTenantForTransaction
definieren wir wie folgt:
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface SetTenantForTransaction {
}
Code-Sprache: CSS (css)
Mittels @Target(ElementType.METHOD)
stellen wir sicher, dass die eigene Annotation nur an Methoden annotiert werden darf. Außerdem greift sie nur zur Laufzeit (@Retention(RetentionPolicy.RUNTIME)
).
Anhand dieser neu eingeführten Annotation und dem zugehörigen Inhalt in resolveTenantBeforeTransaction
in der Klasse ResolveTenantAspect
können wir nun Methoden annotieren, die ein vorheriges Setzen des Tenants erfordern. Wo und wie wir die Annotation nutzen, erläutern wir im nachfolgenden Abschnitt.
Überdies implementieren wir einen weiteren Aspekt ResolveDefaultTenantAspect
, der dieselbe aspektorientierte Logik zum Setzen des Default-Tenants einbaut. Da der Code weitestgehend analog zur Implementierung der Klasse ResolveTenantAspect
ist, verweisen wir hier auf den Code in GitHub.
Anwendung von Multi-Tenancy im Code und Herausforderungen bei Transaktionen
In diesem Abschnitt geht es uns darum, ein Implementierungs-Beispiel der eingeführten Aspekte zu zeigen und dabei auf die Grenzen bezüglich Transaktionalitäten einzugehen.
Der folgende Codeauszug zeigt die Methode processOrder
in der Serviceklasse OrderService
:
public void processOrder(OrderRequestDto orderRequestDto) {
Product product = this.productService.findProductByCode(orderRequestDto.getProductCode());
if (product == null) {
throw new RuntimeException("Product with code '" + orderRequestDto.getProductCode() + "' not found in Database");
}
Order order = new Order();
order.setProductCode(orderRequestDto.getProductCode());
Customer customer = this.customerService.findCustomerByCustomerCode(orderRequestDto.getCustomerCode());
if (customer == null) {
throw new RuntimeException("Customer with customerCode '" + orderRequestDto.getCustomerCode() + "' not found in Database");
}
order.setCustomer(customer);
order.setOrderDateTime(orderRequestDto.getOrderDateTime());
this.orderTransactionService.saveOrder(order);
}
Code-Sprache: JavaScript (javascript)
Die Methode processOrder
nimmt das aus dem REST-Controller OrderController
kommendeorderRequestDto
entgegen. In Zeile 2 entnehmen wir das Produkt anhand der Produktcodes aus der Datenbank. Analog dazu suchen wir in Zeile 9 den Kunden in der Datenbank. Da sich alle Produkte in der geteilten Datenbank befinden, stellen wir mit der im letzten Abschnitt vorgestellten aspektorientierten Lösung sicher, dass das Produkt in der richtigen Datenbank gesucht wird – in diesem Fall in der Datenbank shared. Kunden finden wir in der länderspezifischen Tenant-Datenbank. Die zuvor erläuterten Klassen sorgen auch hier für den korrekten Datenbank-Zugriff.
In Zeile 15 führen wirthis.orderTransactionService.saveOrder(order)
aus, damit die Bestellung in der Tenant-Datenbank persistiert wird. Die Methode saveOrder
zeigen wir im Folgenden:
@SetTenantForTransaction
@Transactional
public void saveOrder(Order order) {
Order savedOrder = this.orderPersistenceService.saveOrder(order);
LOG.info("Saved order: {}", savedOrder);
}
Code-Sprache: JavaScript (javascript)
Wenngleich die Implementierung der saveOrder
-Methode nur eine Datenbank-Operation vorsieht, möchten wir diese transaktional ausführen. Dafür wird in der Regel die Annotation @Transactional
an die Methode geschrieben. Jedoch möchten wir den korrekten Tenant vor die Transaktion setzen. Dafür annotieren wir die Methode mit der im letzten Abschnitt eingeführten Annotation @SetTenantForTransaction
. Da die Präzedenz von @SetTenantForTransaction
größer, also vom Wert her kleiner als der von @Transactional
ist, wird zunächst der Inhalt der eigenen Annotation ausgeführt. Danach erst greift der Inhalt der Annotation @Transactional
.
Der Umgang mit Transaktionen in Verbindung mit Multi-Tenancy stellt eine Herausforderung dar, weil Spring Boot einen Wechsel der Datasource innerhalb einer transaktionalen Methode grundsätzlich nicht zulässt. Deshalb stellen wir mit den eigenen Annotationen @SetTenantForTransaction
und @SetDefaultForTransaction
sicher, dass vor einer transaktionalen Methode der richtige Tenant steht.
Fazit
In diesem Wissensbeitrag habt ihr erfahren, wie man Multi-Tenancy in einem Spring-Boot-Projekt integriert. Wir haben die dafür wichtigsten Codestellen aufgezeigt und näher erläutert. Zusätzlich haben wir anhand der aspektorientierten Programmierung eine Lösung demonstriert, die es ermöglicht, datenbankübergreifende Operationen durchzuführen. Die Problematik von Transaktionen haben wir unter diesem Aspekt ebenfalls beleuchtet.
Der gesamte Code der Demo ist auch auf GitHub zu finden.
In den Gründen für Multi-Tenancy haben wir schon auf effizienteres Releasemanagement, bessere Wartbarkeit und Überwachung hingewiesen. Damit kann auch eine höhere Softwarequalität sichergestellt werden. Wer zu diesem Bereich mehr von uns erfahren möchte, findet hier weitere Informationen: Check-Up: Software-Qualitätssicherung – Conciso GmbH