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

前言

多租户系统可以帮助我们方便地实现为多个租户服务的服务器应用。可以做到各租户间数据彼此隔离,其他资源共享。

上篇我们在项目启动时分别为每个租户创建了数据库和数据表,减少了部分手动配置的工作。

上篇:Spring Boot JPA MySQL 多租户系统 Part2 - 自动建表

本篇我们来继续完善多租户系统的功能,尝试让其成为独立的模块,最终成为开发的基础设施。

管理租户

上篇我们使用 application.properties 文件配置多租户的信息,应用每次启动时读取配置文件并为每个租户生成对应的 DataSource。后续添加租户,需要修改配置文件并重启应用以更新租户信息。

我们期望能通过前端管理租户信息,更新租户后能立即使用,不影响其他租户的访问。

下文实现环境延续上篇。

扫描实体

在添加管理数据源之前,我们需要修改下自定义的 MultiTenantProperties, 实现自动扫描 Entity 文件的功能。

上篇我们使用 MetadataSources.addAnnotatedClass 方法添加 Entity 用于生成对应的数据表。这种方法耦合性比较高,我们这里配置一个包路径,然后扫描包路径获取到想要的 Entity,并配置给 MetaData。

首先添加配置属性:

@Configuration
@ConfigurationProperties("multi-tenant")
class MultiTenantProperties {
    var scanPackage: String = ""
}

之前的 tenants 属性去掉了,因为后续会使用数据库管理租户信息。

scanPackage 属性设置示例:

multi-tenant.scan-package=com.example.multitenant.model

根据包路径,我们可以获取其中包含的 Entity 类名。获取 Entity 类名的关键在于如何扫描包路径。 我们来看看 LocalContainerEntityManagerFactoryBean 是如何扫描的。

LocalContainerEntityManagerFactoryBean.setPackagesToScan 方法将工作交给了 DefaultPersistenceUnitManager.scanPackage 方法来完成,我们参照该方法可以写一个一样的扫描功能:

    /**
     * 扫描包路径,得到需要的 Entity 类名
     */
    private fun getClassNames(): HashSet<String> {
        val set = HashSet<String>()
        val pattern = ResourcePatternResolver.CLASSPATH_ALL_URL_PREFIX +
                ClassUtils.convertClassNameToResourcePath(tenantProperties.scanPackage) +
                CLASS_RESOURCE_PATTERN
        val resourcePatternResolver = PathMatchingResourcePatternResolver()
        val resources: Array<Resource> = resourcePatternResolver.getResources(pattern)
        val readerFactory: MetadataReaderFactory = CachingMetadataReaderFactory(resourcePatternResolver)
        resources.forEach {
            val reader: MetadataReader = readerFactory.getMetadataReader(it)
            val className = reader.classMetadata.className
            if (matchesFilter(reader, readerFactory)) {
                set.add(className)
            }
        }
        return set
    }

其中 entityTypeFilters 定义的是需要读取的注解类型:

    private val entityTypeFilters = listOf(
        AnnotationTypeFilter(Entity::class.java, false),
        AnnotationTypeFilter(Embeddable::class.java, false),
        AnnotationTypeFilter(MappedSuperclass::class.java, false),
        AnnotationTypeFilter(Converter::class.java, false)
    )

matchesFilter 鉴别读取的类是否包含以上注解类型:

    private fun matchesFilter(reader: MetadataReader, readerFactory: MetadataReaderFactory): Boolean {
        for (filter in entityTypeFilters) {
            if (filter.match(reader, readerFactory)) {
                return true
            }
        }
        return false
    }

将 generateTables 方法中的 MetadataSources 初始化修改为:

        val metadata = MetadataSources(registry)
            .apply {
                classNames.forEach {
                    addAnnotatedClassName(it)
                }
            }
            .metadataBuilder
            .build()

至此,扫描 Entity 类的功能已经完成。

多数据源

数据源的配合

添加管理数据源之后数据源种类达到了三个,这里有必要阐述下各数据源的生成,功能以及相互配合。

  1. 管理数据源(MasterDataSource):DataSources 注入时由我们自己生成,对应数据库表也是。数据库中仅存储租户信息,其他业务表不需要生成。它的作用是管理租户信息,应用启动时和请求头中 X-TENANT-ID 为 master 时使用该数据源。租户数据源生成时,需要读取该数据库里的租户信息。租户信息发生改变时,也需要即时更新租户数据源。
  2. 默认数据源(DefaultDataSource):在应用启动后由我们自己生成,同时生成数据库表。对应默认的租户信息和默认的数据库。管理数据源中没有默认租户时,需要在应用启动时插入。默认数据源是应用默认的业务数据源,当请求头中的 X-TENANT-ID 不被识别时使用,可以处理一些未注册租户的请求,只是这些租户的数据放在一起,没有隔离。
  3. 租户数据源(TenantDataSource):在前端管理员添加租户信息后生成,同时生成数据库表。如果应用重启时,管理数据源里有租户信息,应当先生成,保持应用重启前后的一致性。

以上数据源统一在 DataSources 类中管理。不再使用 SpringBoot 根据配置文件生成的数据源,也就是配置文件中的 datasource 部分信息仅提供连接信息,不连接真正的数据库。

有了以上准备工作,来看看每步是如何实现的。

配置文件

在 MultiTenantProperties 中添加数据源配置:

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

application.properties 示例:

# datasource
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://127.0.0.1:3306/mm?useUnicode=true&characterEncoding=utf8&serverTimezone=GMT
spring.datasource.username=root
spring.datasource.password=xiang
#jpa
spring.jpa.hibernate.ddl-auto=none
spring.jpa.show-sql=true
spring.jpa.open-in-view=false
#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

其中 spring.datasource.url 中指定的数据库名“mm”没有意义,后面会替换掉。spring.jpa.hibernate.ddl-auto 指定为 none,不再自动生成数据表。

管理数据源

MasterDataSource 仅管理租户信息,先定义租户信息 Entity 和 Dao:

@Entity
class Tenant(
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long = 0L,
    @Column(unique = true, name = "group_id")
    var groupId: String = "",
    @Column(unique = true, name = "db_name")
    var dbName: String = ""
)

interface Tenants : JpaRepository<Tenant, Long>

然后在 DataSources 注入时,生成:

    @PostConstruct
    private fun initMasterDataSource() {
        val dbName = tenantProperties.masterDb
        val jdbcUrl = replaceJdbcDb(dataSourceProperties.url, dbName)
        val masterDataSource = createDatasource(jdbcUrl)
        dataSources[TenantContext.MASTER_TENANT] = masterDataSource
        val dbUrl = sqlAddress(dataSourceProperties.url)
        createDatabase(dbUrl, dbName)
        generateTables(jdbcUrl, hashSetOf(TENANT_PACKAGE))
    }

其中生成数据源,数据库和数据表的方法有所调整,可以参考文末源码,这里不再详论。注意,这里将生成的 masterDataSource 添加到了 dataSources HashMap中。是因为对于 MultiTenantConnectionProvider 来说,三种数据源都一样,只是在请求到来时切换下数据源而已。

租户数据源

与上篇不同,这里将租户数据源的生成放在了应用启动后执行。因为 DataSources 生成时 Tenants 这个 JpaRepository 还未生成,使用时会产生循环调用的错误。

添加一个组件,run方法在应用启动后运行:

@Component
@Order(value = 1)
class AfterApplicationRunner : ApplicationRunner {

    @Autowired
    private lateinit var dataSources: DataSources

    override fun run(args: ApplicationArguments?) {
        TenantContext.setTenantId(TenantContext.MASTER_TENANT)
        dataSources.insertDefaultTenant()
        dataSources.initTenantDataSources()
    }

}

insertDefaultTenant 方法:

    fun insertDefaultTenant() {
        val exist = tenants.findAll().find {
            it.dbName == TenantContext.DEFAULT_TENANT
        } != null
        if (!exist) {
            tenants.save(
                Tenant(
                    groupId = tenantProperties.defaultGroup,
                    dbName = tenantProperties.defaultDb
                )
            )
        }
    }

initTenantDataSources 方法:

    fun initTenantDataSources() {
        tenants.findAll().forEach {
            if (!dataSources.containsKey(it.dbName)) {
                val jdbcUrl = replaceJdbcDb(dataSourceProperties.url, it.dbName)
                dataSources[it.dbName] = createDatasource(jdbcUrl)
                val dbUrl = sqlAddress(dataSourceProperties.url)
                createDatabase(dbUrl, it.dbName)
                generateTables(jdbcUrl, getClassNames())
            }
        }
    }

如此应用会在启动时生成默认数据源和所有已经添加的数据源。

前端添加数据源后需要调用一次 initTenantDataSources 来更新数据源。这有一个简单的示例:

@RestController
class TenantController {

    @Autowired
    private lateinit var tenants: Tenants

    @Autowired
    private lateinit var dataSources: DataSources

    @PostMapping("/tenant")
    fun addTenant(groupId: String, dbName: String): String {
        tenants.save(Tenant(groupId = groupId, dbName = dbName))
        dataSources.initTenantDataSources()
        return "success"
    }

    @GetMapping("/tenant")
    fun getTenants(): List<Tenant> {
        return tenants.findAll()
    }

}

总结

为了方便前端管理租户信息,我们在上篇的基础上添加了管理数据源。使用配置文件配置基础数据源信息和实体扫描路径,以期实现多租户功能的模块化。

后续会讨论,多租户系统下租户信息在多线程中传递的问题。请点赞评论收藏哦。

本文源码:https://gitee.com/yoshii_x/multi-tenant.git

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

PeterGamp

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值