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 类的功能已经完成。
多数据源 数据源的配合添加管理数据源之后数据源种类达到了三个,这里有必要阐述下各数据源的生成,功能以及相互配合。
管理数据源(MasterDataSource):DataSources 注入时由我们自己生成,对应数据库表也是。数据库中仅存储租户信息,其他业务表不需要生成。它的作用是管理租户信息,应用启动时和请求头中 X-TENANT-ID 为 master 时使用该数据源。租户数据源生成时,需要读取该数据库里的租户信息。租户信息发生改变时,也需要即时更新租户数据源。 默认数据源(DefaultDataSource):在应用启动后由我们自己生成,同时生成数据库表。对应默认的租户信息和默认的数据库。管理数据源中没有默认租户时,需要在应用启动时插入。默认数据源是应用默认的业务数据源,当请求头中的 X-TENANT-ID 不被识别时使用,可以处理一些未注册租户的请求,只是这些租户的数据放在一起,没有隔离。 租户数据源(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
版权声明
本文仅代表作者观点,不代表博信信息网立场。