Multi-Tenancy in Spring-Boot-Projekten

Foto des Autors
Daniel Naczinski

In diesem Wissensbeitrag erläutern wir die grundlegenden Eigenschaften einer Multi-Tenancy-Architektur und demonstrieren anhand eines Spring Boot-Projekts eine beispielhafte Implementierung. Dabei zeigen wir die Vorzüge der aspektorientierten Programmierung, wie sie in Bezug auf Multi-Tenancy zum Einsatz kommt und was man bei Datenbanktransaktionen beachten muss.

Was ist Multi-Tenancy?

Titelbild zum Wissensbeitrag "Multi-Tenancy in Spring-Boot-Projekten"
Panoramablick auf mehrere Datenbanken mit Serverraum im Hintergrund, Quellenangabe: Tee11 – stock.adobe.com

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?

  1. Gegenüber Single-Tenancy ist eine mehrmandantenfähige Architektur in der Lage, eine einzige Software-Instanz mehreren Tenants zur Nutzung bereitzustellen.
  2. Durch die mandantenübergreifende Skalierbarkeit von Hardwareressourcen ist eine schnelle und kostengünstige Neuinstallation von Mandanten möglich.
  3. 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.
  4. 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:

UML-Diagramm des Beispielprojekts
UML-Diagramm des Beispielprojekts

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 MultiTenantRoutingDatasourcean, 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 resolveCurrentTenantIdentifiergibt 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 SetTenantForTransactiondefinieren 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

Das könnte Dich auch noch interessieren

CQRS und EventSourcing bei der DDD.Ruhr #5

CQRS und EventSourcing bei der DDD.Ruhr #5

Dankeschön Herzlichen Dank noch einmal an alle Teilnehmer meines Vortrags "Über Anatomie und Komplexität von CQRS und Event Sourcing" bei ...
SEACON digital 2020 Erfahrungsbericht

SEACON digital 2020 Erfahrungsbericht

Obwohl das vollständig remote Arbeiten für uns schon seit einer Weile selbstverständlich und eigentlich gar nichts besonderes mehr ist, war ...
Skalierbare Authentifizierung - Föderation statt Integration

Skalierbare Authentifizierung – Föderation statt Integration

Wie sinnvoll ist eine tiefe Integration von Authentifizierungslösungen im Zeitalter von verteilten, dezentralen Systemen und Microservices noch? Gar nicht - ...