译者注:
有时为了缓解数据库压力,我们会对数据进行分库处理,即不同用户的数据放在不同的数据库中。我们基本都习惯了面向单库的开发,实际上,Spring还内置了一个分库的抽象类——AbstractRoutingDataSource,以辅助我们对多数据源进行条件化路由。另外,在编写后台管理类项目时,偶尔也需要管理多个数据源的数据,此时利用上述类来切换数据源也是极为方便的。
本文要求对Spring Boot有一定的了解。
多租户应用允许不同的客户在不会看到他人数据的情况下使用同一个应用程序。这意味着我们必须为每个租户建立一个单独的数据存储。这似乎不算困难,但如果我们想对数据库进行更改,我们就必须对每个租户都这样做。
本文介绍了如何实现一个每个租户都拥有自己的数据源的Spring Boot应用,同时也介绍了如何使用Flyway一次更新所有租户的数据库。
代码示例
本文的可运行代码已放在Github上,见 https://github.com/arkuksin/flyway-multitenancy。
一般方法
为了在应用中处理多个租户,我们需要考虑:
如何将请求和租户绑定起来
如何确定当前租户的数据源
如何为所有租户同时运行sql脚本(主要为了同步数据库结构)
将请求和租户进行绑定
当应用程序被许多不同的租户使用时,每个租户都有自己的数据。这意味着 发送到应用程序的每个请求 所执行的业务逻辑 处理的必须是发送请求的租户的数据(这一句需要多品品
)。
这也是我们需要把请求绑定到已有租户的原因。
有不同的方法可以将传入的请求绑定到特定的租户:
将 tenantId 作为请求URI的一部分。
将 tenantId 放在 JWT token当中。
将 tenantId 放在请求头中。
以及其它方式。
为了保持简单,让我们考虑最后一个选项。我们将在HTTP请求头中包含一个 tenantId 字段。
在 Spring Boot 中,为了从请求中读取header信息,需要实现 WebRequestInterceptor 接口。此接口允许我们在 web controller 接收到请求之前拦截它:
@Componentpublic class HeaderTenantInterceptorimplements WebRequestInterceptor {public static final String TENANT_HEADER = "X-tenant";@Overridepublic void preHandle(WebRequest request) throws Exception {ThreadTenantStorage.setId(request.getHeader(TENANT_HEADER));}// 忽略其它方法}
在 preHandle() 方法中,我们读取每个请求的 tenantId 头信息 并将其转存到 ThreadTenantStorage 中。
ThreadTenantStorage 是一个包含了 ThreadLocal 变量的存储类。通过将 tenantId 存储在 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知道我们的拦截器:
@Configurationpublic class WebConfiguration implements WebMvcConfigurer {private final HeaderTenantInterceptor headerTenantInterceptor;public WebConfiguration(HeaderTenantInterceptor headerTenantInterceptor) {this.headerTenantInterceptor = headerTenantInterceptor;}@Overridepublic void addInterceptors(InterceptorRegistry registry) {registry.addWebRequestInterceptor(headerTenantInterceptor);}}
不要使用(自增)序列号作为租户ID!
序列号容易被猜到。客户可以通过修改http请求头中的 tenantId 来访问其它租户的数据。
最好使用UUID,因为这几乎是不可能猜测的,而且人们不会意外地混淆一个租户ID和另一个租户ID。更好的方法是,在每个请求中验证登录用户是否实际属于指定的租户。
为每个租户配置自己的 Datasource
为不同的租户分离数据存在不同的方式。我们可以:
不同的租户使用不同的schema
不同的租户使用完全不同的数据库
从应用程序的角度看,schema 和 database 都是抽象为了 DataSource,因此,在代码层面,我们可以以相同的方式处理这两种方法。
在 Spring Boot 应用程序中,我们通常使用前缀为 spring.datasource 的属性在 application.yaml 中配置数据源(DataSource)。但是这么做只能定义一个数据源。要定义多个数据源,我们需要在 application.yaml 中自定义一些属性:
tenants:datasources:vw:jdbcUrl: jdbc:h2:mem:vwdriverClassName: org.h2.Driverusername: sapassword: passwordbmw:jdbcUrl: jdbc:h2:mem:bmwdriverClassName: org.h2.Driverusername: sapassword: password
在本例中,我们为两个租户配置了数据源:用户 vw 和 bmw。
要在代码中访问这些数据源,可以使用 @ConfigurationProperties 将属性绑定到一个 Spring bean 中:
@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 中,我们构建一个Map,其中数据源名称作为键,数据源对象作为值。现在我们可以向 application.yaml 添加一个新的租户,当应用程序启动时,这个新租户的数据源将被自动加载。
Spring Boot 的默认配置只有一个数据源。然而,在我们的例子中,我们需要一种方法,它能根据HTTP请求的 tenantId,为租户定位到正确的数据源。我们可以通过使用 AbstractRoutingDataSource 来实现这一点。
AbstractRoutingDataSource 可以管理多个数据源以及它们之间的路由。我们可以扩展AbstractRoutingDataSource 以在租户的数据源之间进行切换:
public class TenantRoutingDataSourceextends AbstractRoutingDataSource {@Overrideprotected Object determineCurrentLookupKey() {return ThreadTenantStorage.getTenantId();}}
每当客户端请求数据库连接时,AbstractRoutingDataSource 将调用 determineCurrentLookupKey() 。当前租户可从 ThreadTenantStorage 获得,因此 determineCurrentLookupKey() 方法返回的是当前租户。这样,TenantRoutingDataSource 将找到此租户的数据源并自动返回该数据源的连接(connection)。
现在,我们必须用 TenantRoutingDataSource 替换 Spring Boot 的默认 DataSource:
@Configurationpublic class DataSourceConfiguration {private final DataSourceProperties dataSourceProperties;public DataSourceConfiguration(DataSourceProperties dataSourceProperties) {this.dataSourceProperties = dataSourceProperties;}@Beanpublic DataSource dataSource() {TenantRoutingDataSource customDataSource = new TenantRoutingDataSource();customDataSource.setTargetDataSources(dataSourceProperties.getDatasources());return customDataSource;}}
为了让 TenantRoutingDataSource 知道要使用哪些数据源,我们将map数据源从 DataSourceProperties 传递给了 setTargetDataSources() 。
就这样。根据HTTP请求头中的 tenantId,每个HTTP请求现在都有自己的数据源了。
同时迁移多个schema
如果我们想用 Flyway 对数据库状态进行版本控制,并对其进行更改,比如添加列、添加表或删除约束,我们必须编写SQL脚本。有了 Spring Boot 对 Flyway 的支持,我们只需要部署应用程序,它会自动执行新写的脚本,将数据库迁移到新状态。
要为所有租户的数据源启用 Flyway,首先我们必须在 application.yaml 中禁用自动 Flyway迁移预定义好的配置:
spring:flyway:enabled: false
如果不这样做,Flyway将在启动应用程序时尝试将脚本迁移到当前数据源。但是在启动期间,我们没有当前的租户,因此 ThreadTenantStorage.getTenantId() 将返回 null,应用程序也会随之崩溃。
接下来,我们要将Flyway管理的SQL脚本应用于在应用程序中定义的所有数据源。我们可以在 @PostConstruct 方法中遍历我们的数据源:
@Configurationpublic class DataSourceConfiguration {private final DataSourceProperties dataSourceProperties;public DataSourceConfiguration(DataSourceProperties dataSourceProperties) {this.dataSourceProperties = dataSourceProperties;}@PostConstructpublic void migrate() {for (Object dataSource : dataSourceProperties.getDatasources().values()) {DataSource source = (DataSource) dataSource;Flyway flyway = Flyway.configure().dataSource(source).load();flyway.migrate();}}}
每当应用程序启动时,SQL脚本就会运行在每个租户的数据源下。
如果要添加新租户,只需在 application.yaml 中添加新配置,然后重新启动应用程序以触发SQL迁移。新租户的数据库将自动更新为当前状态。
如果我们不想为添加或删除租户而重新编译应用程序,我们可以将租户的配置外部化(即不将application.yaml硬打包到JAR或WAR文件中)。之后,触发Flyway迁移所需的只是简单的重启。(对于配置外部化,作者也写了一篇介绍 https://reflectoring.io/externalize-configuration/)
总结
Spring Boot 为实现多租户应用提供了良好的方法。使用拦截器,可以将请求绑定到租户。Spring Boot 支持处理多数据源,并且通过Flyway,我们可以跨所有这些数据源执行SQL同步脚本。
本文所有代码可以在Github上找到:https://github.com/arkuksin/flyway-multitenancy
文章译自: https://reflectoring.io/flyway-spring-boot-multitenancy/
如果觉得有用,请点下「在看」,谢谢支持!
关注作者

一起学习
还请多多转发分享




