Spring Boot JPA MySQL 多租户系统 Part4 - 版本管理

目录

  • 前言
  • 多线程
    • 异步任务
    • 定时任务
  • 代码调整
    • 自动建库
    • 自动建表
      • 生成 Changelog
      • 生成 Tables
  • 版本管理
  • 总结

前言

在上篇文章中,我们使用 MasterDataSource 管理租户信息,使用 TenantDataSource 连接数据库处理具体业务逻辑。完成了前端租户管理的基础,无需手动配置租户信息,和重启应用程序。

上篇:Spring Boot JPA MySQL 多租户系统 Part3 - 管理租户

本篇是对之前系列文章的调整、补充和完善,讨论的内容有:

  1. 多线程中选择租户的方法。
  2. 数据库和数据表生成方法的调整。
  3. 数据库版本管理。

本文开发环境参考之前系列文章。

多线程

异步任务

我们知道,每个请求的处理对应一个新的线程。但是,请求处理(Controller、Service等)之外生成的对象也即应用程序主线程生成的对象,会在请求处理线程中共享。

比如:声明为 Object 的 TenantContext 会被请求线程共用。为了在一次请求中保持 currentTenant 变量值不变,或者说不被其他请求线程修改。我们使用了 ThreadLocal 来修饰它,这样每个线程都会生成一个 currentTenant 的副本,就不会相互影响了。

使用 ThreadLocal 修饰后,如果我们在请求线程中新开线程处理异步任务(@Async),也会有一个 currentTenant 副本,初始值为 null。这时如果在异步任务中有数据库读写操作,就会产生与期待不一致的结果。原因是,在异步任务中读取到的 currentTenant 值没有继承到请求处理线程的值。

一种方法是在配置异步线程池时,添加修饰器,让 currentTenant 值从请求线程中继承。

@Configuration
class AsyncConfig : AsyncConfigurerSupport() {
    override fun getAsyncExecutor(): Executor {
        val executor = ThreadPoolTaskExecutor()
        executor.corePoolSize = 7
        executor.maxPoolSize = 42
        executor.queueCapacity = 11
        executor.setThreadNamePrefix("TenantTaskExecutor-")
        executor.setTaskDecorator { runnable ->
            val tenant = TenantContext.getTenantId() // code 1
            Runnable {
                TenantContext.setTenantId(tenant) // code 2
                runnable.run()
            }
        }
        executor.initialize()
        return executor
    }
}

其中:“code 1” 会在请求线程中执行,"code 2"会在异步任务新开线程中执行。这样就继承了 currentTenant 值。

另一种方法是使用 InheritableThreadLocal 替换 ThreadLocal,在新开线程中使用 InheritableThreadLocal 定义的变量会继承原来的值,实现与上一种方法相同的效果。虽然 InheritableThreadLocal 可以在线程间继承,但是多个请求线程之间没有继承关系,它们之间 currentTenant 值依然相互独立。

定时任务

Spring Boot 定时任务(@Scheduled)也会新开线程来处理,默认也是没有配置租户信息的,也就是 currentTenant 值是 null。当我们需要在定时任务中操作数据库时,需要再次指定使用的租户数据库。

经常遇到的情况可能是对每个租户数据库都执行一遍定时任务。这里有如下简单示例。

在 TenantService 中添加下列方法:

    fun runOnAllTenants(run: () -> Unit) {
        TenantContext.setTenantId(tenantProperties.masterDb)
        tenants.findAll().forEach {
            TenantContext.setTenantId(it.dbName)
            run()
        }
    }

在测试位置调用:

    /**
     * 每 1min 执行一次
     */
    @Scheduled(cron = "30 * * * * ?")
    fun testSchedule() {
        Log.i(TAG, "start scheduling")
        tenantService.runOnAllTenants {
            persons.findAll()
        }
        Log.i(TAG, "end scheduling")
    }

代码调整

距离发布上篇文章也有多日,知识有所更新。之前的记录可以作为另一种实现方案。本篇引入 Liquibase 来管理数据库版本,同时也可以简化数据表的创建过程。

Liquibase 使用请参考:Spring Boot 集成和使用 Liquibase

自动建库

Mysql 自动创建数据库其实可以很简单地实现,只需要在 jdbc url 配置位置添加 createDatabaseIfNotExist=true。完整 url :

spring.datasource.url=jdbc:mysql://127.0.0.1:3306/mm?useUnicode=true&characterEncoding=utf8&serverTimezone=GMT&createDatabaseIfNotExist=true

自动建表

思路是这样的:

Entities
Changelog
Tenant1 Tables
Tenant2 Tables
Tenant... Tables

通过 JPA Buddy 将 Model (Enities) 转换成 DDL 日志文件 (Changelog),再通过 Liquibase Java API 读取日志文件生成数据表 (Tables)。Liquibase ChangeLog 文件是关键中间件,一次生成,到处使用。

我们需要分别为 MasterDataSource 和 TenantDataSource 添加日志文件。这里为MultiTenantProperties 扩展两个属性 tenantChangelog 、masterChangelog 分别指定这个两个日志文件的路径:

@Configuration
@ConfigurationProperties("multi-tenant")
class MultiTenantProperties(
    var scanPackage: String = "",
    var defaultDb: String = "",
    var defaultGroup: String = "",
    var tenantChangelog: String = "",
    var masterDb: String = "",
    var masterChangelog: String = ""
)

application.properties 文件相关配置示例:

#multitenancy
multi-tenant.scan-package=com.example.multitenant.model
multi-tenant.default-db=tenant1
multi-tenant.default-group=tenant1.tenant1.tenant1
multi-tenant.master-db=tenant_master
multi-tenant.master-changelog=classpath:db/master-changelog.sql
multi-tenant.tenant-changelog=classpath:db/tenant-changelog.sql
#liquibase
spring.liquibase.enabled=false

spring.liquibase.enabled 指定为 false 是为了关闭 SpringBoot 自动读取 changelog 文件建表的功能。这一功能只能关联配置好的 DataSource 更新数据库,在这里不再适用,我们需要手动使用 Liquibase Java API 更新数据库。

生成 Changelog

Liquibase 参考文章已经描述使用 JPA Buddy 生成 changelog 文件的方法,这里不再赘述。只是,这里将生成两个 changelog 文件。在生成时需要将不需要的 changeset 删除掉。
Spring Boot JPA MySQL 多租户系统 Part4 - 版本管理_第1张图片比如在生成 tenant-changelog 时需要删除 tenant 相关 changeset,这很好理解,因为业务数据库中不包含租户信息数据库,反过来亦然。

生成 Tables

有了 tenant-changelog 和 master-changelog,我们还需要读取它们,来生成对应的数据表。在 DataSources 中添加以下方法:

    private fun runLiquibase(dataSource: DataSource, changelog: String) {
        Log.i(TAG, "runLiquibase")
        val connection = JdbcConnection(dataSource.connection)
        val database = DatabaseFactory.getInstance().findCorrectDatabaseImplementation(connection)
        val liquibase = liquibase.Liquibase(
            changelog,
            ClassLoaderResourceAccessor(),
            database
        )
        liquibase.update(Contexts())
    }

该方法先根据 DataSource 连接信息生成 Liquibase,然后在配置 changelog 文件路径后,将变更应用到数据库实现更新。

生成 MasterDataSource 和 TenantDataSource 的方法相应修改为:

    @PostConstruct
    private fun initMasterDataSource() {
        val dbName = tenantProperties.masterDb
        val jdbcUrl = replaceJdbcDb(dataSourceProperties.url, dbName)
        val masterDataSource = createDatasource(jdbcUrl)
        dataSources[tenantProperties.masterDb] = masterDataSource
        runLiquibase(masterDataSource, tenantProperties.masterChangelog)
    }
    
    fun initTenantDataSources() {
        tenants.findAll().forEach {
            if (!dataSources.containsKey(it.dbName)) {
                val jdbcUrl = replaceJdbcDb(dataSourceProperties.url, it.dbName)
                val datasource = createDatasource(jdbcUrl)
                dataSources[it.dbName] = datasource
                runLiquibase(datasource, tenantProperties.tenantChangelog)
            }
        }
    }

使用 Liquibase 生成的 Changelog ,帮我们省去了扫描 Entity 和生成数据表的几个方法,DataSources 代码简洁了许多。

版本管理

有了 Liquibase,数据库版本管理就相对简单了。当数据库结构变化时,生成 Diff Changelog 文件,重新配置下 changelog 文件路径即可更新原来的数据库。具体方法已经在 Liquibase 参考文章中描述。

总结

本文补充了上篇关于多线程的处理细节说明,然后在之前系列文章基础上,引入 Liquibase 简化数据库和数据表的生成代码,同时让多租户模块拥有了数据库版本管理的能力。

后续我们会探索将已经渐渐成形的多租户模块,从项目中解耦,变成独立的依赖。请点赞评论关注哦。

本文源码:https://gitee.com/yoshii_x/multi-tenant.git
系列文章使用同一个 Git 库,可以根据标签获取不同部分对应的源码。

你可能感兴趣的:(SpringBoot学习,spring,boot,mysql,kotlin,gradle)