如何使用Spring Boot和Flyway建立不同資料庫的多租戶應用? - reflectoring.io

banq發表於2020-02-15

多租戶應用能讓不同客戶端通過同一應用程式訪問不同的隔離的資料庫,客戶端之間無法檢視彼此的資料。這意味著我們必須為每個租戶建立一個單獨的資料儲存。但是如果我們想對資料庫進行一些統一的更改,這是針對為每個租戶資料庫都需要進行的統一修改。

本文展示了一種方法,該方法如何使用每個租戶的資料來源來實現Spring Boot應用程式,以及如何使用Flyway一次對所有租戶資料庫進行更新。

本文隨附GitHub上的工作示例程式碼。

通用做法

要實現一個應用程式中的多個租戶一起使用,需要:

  1. 如何將傳入請求繫結到租戶,
  2. 如何為當前租戶提供資料來源,以及
  3. 如何一次為所有租戶執行SQL指令碼。

將請求繫結到租戶

當許多不同的租戶使用該應用程式時,每個租戶都有自己的資料。這意味著對傳送到應用程式的每個請求執行的業務邏輯必須與傳送請求的租戶的資料一起使用。

這就是我們需要將每個請求分配給現有租戶的原因。

有多種將傳入請求繫結到特定租戶的方法:

  • 傳送tenantId帶有請求的URI,
  • tenantId在JWT令牌中新增,
  • tenantId在HTTP請求的標頭中包含一個欄位,
  • 還有很多…。

為了簡單起見,讓我們考慮最後一個選項。我們將tenantId在HTTP請求的標頭中包含一個欄位。

在Spring Boot中,為了從請求中讀取標頭,我們實現了WebRequestInterceptor介面。該介面使我們能夠在Web控制器中接收到請求之前對其進行攔截:

@Component
public class HeaderTenantInterceptor implements WebRequestInterceptor {

  public static final String TENANT_HEADER = "X-tenant";

  @Override
  public void preHandle(WebRequest request) throws Exception {
    ThreadTenantStorage.setId(request.getHeader(TENANT_HEADER));
  }
  
  // other methods omitted

}

在該方法中preHandle(),我們tenantId從標頭讀取每個請求,並將其轉發給ThreadTenantStorage。

ThreadTenantStorage是包含ThreadLocal變數的儲存。通過將tenantIdin 儲存在中,ThreadLocal我們可以確保每個執行緒都有該變數的自己的副本,並且當前執行緒無法訪問另一個執行緒tenantId:

public class ThreadTenantStorage {

  private static ThreadLocal<String> currentTenant = new ThreadLocal<>();

  public static void setTenantId(String tenantId) {
    currentTenant.set(tenantId);
  }

  public static String getTenantId() {
    return currentTenant.get();
  }

  public static void clear(){
    currentTenant.remove();
  }
}

配置承租人繫結的最後一步是讓Spring知道我們的攔截器:

@Configuration
public class WebConfiguration implements WebMvcConfigurer {

  private final HeaderTenantInterceptor headerTenantInterceptor;

  public WebConfiguration(HeaderTenantInterceptor headerTenantInterceptor) {
    this.headerTenantInterceptor = headerTenantInterceptor;
  }

  @Override
  public void addInterceptors(InterceptorRegistry registry) {
    registry.addWebRequestInterceptor(headerTenantInterceptor);
  }
}

不要使用順序號作為租戶ID!

順序數很容易猜到。作為客戶端,您要做的就是在自己的客戶端中新增或減去tenantId,修改HTTP標頭,然後訪問其他租戶的資料。

最好使用UUID,因為幾乎無法猜測,而且人們不會將一個租戶ID與另一個租戶ID混淆。更好的是,驗證每個請求中登入使用者是否實際上屬於指定的租戶。

DataSource為每個租戶配置

分離不同租戶的資料有不同的可能性。我們可以

  • 為每個租戶使用不同的架構,或者
  • 為每個租戶使用完全不同的資料庫。

從應用程式的角度來看,模式和資料庫由來抽象DataSource,因此,在程式碼中,我們可以以相同的方式處理這兩種方法。

在Spring Boot應用程式中,我們通常使用字首配置DataSourcein application.yaml使用屬性spring.datasource。但是我們只能DataSource用這些屬性定義一個。要定義多個,DataSource我們需要在中使用自定義屬性application.yaml:

tenants:
  datasources:
    vw:
      jdbcUrl: jdbc:h2:mem:vw
      driverClassName: org.h2.Driver
      username: sa
      password: password
    bmw:
      jdbcUrl: jdbc:h2:mem:bmw
      driverClassName: org.h2.Driver
      username: sa
      password: password

在這種情況下,我們為兩個租戶配置了資料來源:vw和bmw。

要 讓DataSource在我們的程式碼中訪問這些,我們可以使用以下方法將屬性繫結到Spring bean @ConfigurationProperties

@Component
@ConfigurationProperties(prefix = "tenants")
public class DataSourceProperties {

  private Map<Object, Object> datasources = new LinkedHashMap<>();

  public Map<Object, Object> getDatasources() {
    return datasources;
  }

  public void setDatasources(Map<String, Map<String, String>> datasources) {
    datasources
        .forEach((key, value) -> this.datasources.put(key, convert(value)));
  }

  public DataSource convert(Map<String, String> source) {
    return DataSourceBuilder.create()
        .url(source.get("jdbcUrl"))
        .driverClassName(source.get("driverClassName"))
        .username(source.get("username"))
        .password(source.get("password"))
        .build();
  }
}

DataSourceProperties中,使用資料來源名稱作為鍵和DataSource物件作為值來構建一個Map。現在,我們可以向其中新增新的租戶,我們可以向其中新增新的租戶,application.yaml並且DataSource在應用程式啟動時將自動為該新租戶載入。

Spring Boot的預設配置只有一個DataSource。但是,在我們的情況下,我們需要一種方法來根據tenantIdHTTP請求中的來為租戶載入正確的資料來源。我們可以使用AbstractRoutingDataSource來實現。

AbstractRoutingDataSource可以管理多個DataSources和它們之間的路由。我們可以擴充套件AbstractRoutingDataSource 到租戶之間的路線Datasource:

public class TenantRoutingDataSource extends AbstractRoutingDataSource {

  @Override
  protected Object determineCurrentLookupKey() {
    return ThreadTenantStorage.getTenantId();
  }

每當客戶端請求連線時,AbstractRoutingDataSource都會呼叫determineCurrentLookupKey()。當前租戶可從ThreadTenantStorage獲得,方法determineCurrentLookupKey() 將返回此當前租戶。這樣,TenantRoutingDataSource將找到DataSource該租戶,並自動返回到該資料來源的連線。

現在,我們必須將Spring Boot的預設值替換DataSource為TenantRoutingDataSource:

@Configuration
public class DataSourceConfiguration {

  private final DataSourceProperties dataSourceProperties;

  public DataSourceConfiguration(DataSourceProperties dataSourceProperties) {
    this.dataSourceProperties = dataSourceProperties;
  }

  @Bean
  public DataSource dataSource() {
    TenantRoutingDataSource customDataSource = new TenantRoutingDataSource();
    customDataSource.setTargetDataSources(
        dataSourceProperties.getDatasources());
    return customDataSource;
  }
}

為了讓TenantRoutingDataSource知道要使用哪個DataSource,我們將DataSourceProperties的DataSource通過setTargetDataSources()傳入。

現在,每個HTTP請求都有自己的請求,DataSource具體取決於HTTP標頭中的tenantId了。

一次遷移多個SQL模式

如果要使用Flyway對資料庫狀態進行版本控制並對其進行更改(例如新增列,新增表或刪除約束),則必須編寫SQL指令碼。有了Spring Boot的Flyway支援,我們只需要部署應用程式,新指令碼就會自動執行以將資料庫遷移到新狀態。

為了為所有租戶的資料來源啟用Flyway,首先,我們在application.yaml以下位置禁用了Flyway用於自動遷移的預配置屬性:

spring:
  flyway:
    enabled: false

如果我們不這樣做,Flyway將在啟動應用程式時嘗試將指令碼遷移到當前DataSource指令碼。但是在啟動過程中,我們還沒有當前租戶,因此ThreadTenantStorage.getTenantId()將返回null並導致應用程式崩潰。

接下來,我們要將Flyway託管的SQL指令碼應用於在應用程式中定義的所有DataSource。我們可以在

@PostConstruct方法對DataSource進行迭代:
@Configuration
public class DataSourceConfiguration {

  private final DataSourceProperties dataSourceProperties;

  public DataSourceConfiguration(DataSourceProperties dataSourceProperties) {
    this.dataSourceProperties = dataSourceProperties;
  }

  @PostConstruct
  public void migrate() {
    for (Object dataSource : dataSourceProperties
          .getDatasources()
          .values()) {
      DataSource source = (DataSource) dataSource;
      Flyway flyway = Flyway.configure().dataSource(source).load();
      flyway.migrate();
    }
  }

}

無論何時啟動應用程式,現在都會為每個租戶的DataSource執行SQL指令碼。

如果要新增新的租戶,則只需放入新配置到application.yaml,然後重新啟動應用程式以觸發SQL遷移。新租戶的資料庫將自動更新為當前狀態。

如果我們不想重新編譯用於新增或刪除租戶的應用程式,則可以將租戶的配置外部化(即不要烘焙application.yaml到JAR或WAR檔案中)。然後,觸發Flyway遷移所需的一切只是重新啟動。

結論

Spring Boot提供了實現多租戶應用程式的好方法。使用攔截器,可以將請求繫結到租戶。Spring Boot支援使用許多資料來源,而使用Flyway,我們可以跨所有這些資料來源執行SQL指令碼。

您可以在GitHub上找到程式碼示例。

 

相關文章