DE EN

Multitenancy: Datenbanken zur Laufzeit wechseln mit Spring

von

Spring Mandantenfähigkeit (Multitenancy): Datenquellen zur Laufzeit wechseln

In der Regel befindet sich eine Datenbankebene unterhalb Ihrer Webanwendung. Es kommt jedoch nicht so oft vor, dass mehrere Klone dieser Datenbank vorhanden sind und diese beispielsweise nach einem im Header übermittelten Wert eines Web-Requests ausgewählt werden.

Dennoch existieren diese Anwendungsfälle. Wenn Sie nach “switch databases in Spring” (oder Hibernate) suchen, dann werden Sie nicht viele Ergebnisse finden. Stattdessen wird man unter dem Suchbegriff “Multitenancy” fündig, das sowohl von Hibernate als auch von Spring standardmäßig unterstützt wird.

Konfigurieren der Datenbanken

Das erste, was zu definieren ist: Welche Art von Datenbanken haben wir?

Eine YAML-Datei hilft hier weiter (Es können auch andere Alternativen, wie Properties, verwendet werden):

db:
  configurations:
    de:
      url: jdbc:mysql://localhost/db1
      username: db1
      driver: com.mysql.cj.jdbc.Driver
      password: db1
    ch:
      url: jdbc:mysql://localhost/db2
      username: db2
      driver: com.mysql.cj.jdbc.Driver
      password: db2
    at:
      url: jdbc:mysql://localhost/db3
      username: db3
      driver: com.mysql.cj.jdbc.Driver
      password: db3

Die Idee dahinter ist, dass jedem Ländercode eine Datenbank zugeordnet wird.

Natürlich wäre es schön, wenn diese Konfiguration als Java-Objekt zur Verfügung stehen würde, da die Eigenschaftszuordnungen in Spring ganz vernünftig sind. Ich muss dazu nur zwei Klassen erstellen.

Hier ist eine Java-Klasse, die eine Datenbankkonfiguration darstellt:

public class DatabaseConfiguration {
    private String url;
    private String username;
    private String driver;
    private String password;
    // get/set ommitted

    public DataSource createDataSource() {
        DriverManagerDataSource dataSource = new DriverManagerDataSource();
        dataSource.setDriverClassName(driver);
        dataSource.setUrl(url);
        dataSource.setUsername(username);
        dataSource.setPassword(password);
        return dataSource;
    }
}

Diese Entität kann mit “createDataSource()” in eine Datenquelle “transformiert” werden.

Der folgende Container enthält alle Konfigurationen:

@ConfigurationProperties(prefix = "db")
public class DatabaseConfigurations {
    private Map<String, DatabaseConfiguration> configurations = new HashMap<>();

    // get/set ommitted

    public Map<Object, Object> createTargetDataSources() {
        Map<Object, Object> result = new HashMap<>();
        configurations.forEach((key, value) ->  result.put(key, value.createDataSource()));
        return result;
    }
}

Es gibt zwei Dinge, die hier vielleicht auffallen.

Zum Ersten, die Verwendung von @ConfigurationProperties. Dies teilt Spring mit, dass alle Konfigurationsdaten unter dem Schlüssel “db” einem Objekt der Klasse zugeordnet werden sollen. Es werden also verschachtelte Objekte erstellt. Der Name der Membervariable “configurations” stimmt dabei mit dem in der YAML-Datei überein.

Zweitens verwende ich eine kleine Methode, um Datenquellen zu erstellen und diese einer Map hinzuzufügen. Die Datenquellen werden mit der oben dargestellten Factory-Methode erstellt.

Somit kann ich eine Map erstellen, die die Länderschlüssel mit ihren zugehörigen Datenquellen beinhaltet.

Erstellen einer AbstractRoutingDataSource

Jetzt wird es interessant. Normalerweise erstellen wir eine DataSource entweder durch Konfiguration oder durch das Hinzufügen einer @Bean-Factory-Methode zur Anwendungskonfiguration.

Aber dieses Mal werden wir etwas anderes machen. Anstelle der üblichen DataSource, die wir von AbstractRoutingDataSource erben lassen, das ist wie der Name schon andeutet - eine Datenquelle, die wir so flexibel steuern können wie wir das brauchen.

public class CountryRoutingDataSource extends AbstractRoutingDataSource {
    @Override
    protected Object determineCurrentLookupKey() {
        ServletRequestAttributes attr =
            (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        String pathInfo = attr.getRequest().getRequestURI();
        return pathInfo.substring(1, 3);
    }
}

In dieser Klasse ist nur determineCurrentLookupKey() implementiert. Das ist die Stelle, an dem wir den Schlüssel aus der zuvor erstellten Map herausfinden möchten. In dem obigen Fall versuche ich, diesen aus der Servlet-Anfrage abzuleiten. Das heißt, ich erwarte folgendes URL-Format:

http://localhost:8080/de/cars

In diesem Fall würde “de” ausgewählt und zurückgegeben, was (hoffentlich) dazu führt, dass die deutsche Datenbank ausgewählt wird.

Diese Methode wird nach jedem Aufruf von “getConnection()” ausgeführt.

Das ist die Minimalkonfiguration. Leider ist es damit noch nicht getan. Es wird auf jeden Fall eine Servlet-Anfrage benötigt. Dies ist aber möglicherweise nicht immer der Fall. Denken Sie an JUnit-Tests oder daran, wenn der Spring Container startet. Es könnten mehrere Verbindungen angefordert werden, wenn Beans erstellt werden.

Es müssen also passende Standardeinstellungen oder alternative Strategien verwendet werden um NullPointers zu verhindern.

Beispielsweise könnte man der obigen Auswahlstrategie so etwas hinzufügen:

if (attr == null) {
    // when the bean was created, attr is null (startup time)
    return "de";
}

// when running without servlet, this is empty (like in CLI)
if ("".equals(attr.getRequest().getRequestURI())) {
    return "de";
}

Vielleicht findest Du auch noch einen besseren Ansatz :)

Anwendungsklasse

Es ist an der Zeit, all dies mit unserer Anwendungsklasse zu verbinden.


@SpringBootApplication
@EnableConfigurationProperties(DatabaseConfigurations.class)
public class ApiApplication implements WebMvcConfigurer {

    @Autowired
    DatabaseConfigurations databaseConfigurations;

    @Bean
    public DataSource dataSource() {
        CustomRoutingDataSource dataSource = new CustomRoutingDataSource();
        dataSource.setTargetDataSources(databaseConfigurations.createTargetDataSources());
        return dataSource;
    }

    public static void main(String[] args) {
        SpringApplication.run(ApiApplication.class, args);
    }
}

Das ist keine Zauberei. Es handelt sich lediglich um unsere benutzerdefinierte Factory-Methode, die eine DataSource zurückgibt. Wie oben dargestellt, gibt sie jetzt eine CustomRoutingDataSource zurück. Außerdem darf natürliche nicht vergessen werden die Zieldatenquellen, die die Schlüssel-/Wertzuordnung enthält, hinzuzufügen.

Wie kann man die Migration mit Flyway realisieren?

Wenn Du Flyway verwendest, um deine Datenbankschemata zu aktualisieren, müssen wir einige weitere Codezeilen hinzufügen.

Der erste Schritt wäre, die Autokonfiguration von Flyway zu deaktivieren, da wir in 99% der Fälle den “üblichen Pfad” verlassen.

Dem entsprechend müsste die Anwendungsklasse wie folgt geändert werden:

@SpringBootApplication(
    exclude = {
        FlywayAutoConfiguration.class
    }
)
@EnableConfigurationProperties(DatabaseConfigurations.class)
public class ApiApplication implements WebMvcConfigurer {
   ...
}

Nun müssen wir uns überlegen, wie wir die Migrationen in einer Art Schleife zum Startzeitpunkt des Containers hinzufügen können. Eine Möglichkeit, dies zu tun, besteht darin, einen Listener für das ApplicationEvent zu erstellen, der ausgelöst wird, wenn das ContextRefreshedEvent eintritt, wenn also der Kontext erstellt wurde. Bis dahin sollten alle Beans initialisiert sein.

@Component
public class DatabaseMigration implements ApplicationListener<ContextRefreshedEvent> {
    // use constructor wiring
    @Autowired
    private DatabaseConfigurations databaseConfigurations;

    @Override
    public void onApplicationEvent(ContextRefreshedEvent contextRefreshedEvent) {
        databaseConfigurations.getConfigurations().forEach((key, value) -> {
            Flyway flyway = new Flyway();
            flyway.setDataSource(value.createDataSource());
            flyway.migrate();
        });
    }
}

Wann immer der Kontext gelesen wird, wird die Methode onApplicationEvent aufgerufen. Wir durchlaufen einfach alle Datenbankkonfigurationen und geben die jeweiligen Datenquellen zurück. Mit einem neuen Flyway-Objekt für jede Datenbank können wir einfach die Datenquelle festlegen und dann die Methode migrate() aufrufen.

Bitte beachte, dass das Flyway-Objekt nicht wiederverwendet werden kann, in es zu einer Spring Bean gemacht wird. Das kann zu Problemen führen, weil das vorheriges Verhalten zwischengespeichert wird.

Außerdem muss bedacht werden, dass dieser Ansatz zu Problemen führen kann, wenn mehrere Server gleichzeitig bereitgestellt werden, da sie möglicherweise alle gleichzeitig die Aktualisierungsskripts ausführen.

Bei Amazon AWS kann man ein fortlaufendes Update anfordern (jeweils ein Server). Die andere Alternative besteht darin, zusätzlichen Code bereitzustellen, um die Konflikte der parallelen Aktualisierungen zu vermeiden. Im Allgemeinen verwendet man, sobald man mehrere Server ausführt, normalerweise einen anderen Ansatz, um die Datenbankänderungen zu implementieren.

Fazit

Das Anrufen von URLs mit Ländercodes sollte zur Auswahl der korrekte Datenbank führen. Es sollte auch generell mit MockMVC- und Junit-Tests funktionieren.

Was wir noch nicht implementiert haben, ist ein Datenbankverbindungspool. Abhängig von deinen Anforderungen kannst Du entweder einen Pool für jede Verbindung erstellen oder den Code so erweitern, dass nur ein Pool für die wichtigsten Datenbanken erstellt wird und ansonsten die traditionelle Idee des Öffnens bzw. Schließens beibehalten wird.

Image Credits

Tags: #Java #Spring #Database #JPA #Login

Newsletter

ABMELDEN