如何使用Spring Boot和Flyway建立不同数据库的多租户应用? - reflectoring.io


多租户应用能让不同客户端通过同一应用程序访问不同的隔离的数据库,客户端之间无法查看彼此的数据。这意味着我们必须为每个租户建立一个单独的数据存储。但是如果我们想对数据库进行一些统一的更改,这是针对为每个租户数据库都需要进行的统一修改。
本文展示了一种方法,该方法如何使用每个租户的数据源来实现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上找到代码示例。