意见箱
恒创运营部门将仔细参阅您的意见和建议,必要时将通过预留邮箱与您保持联络。感谢您的支持!
意见/建议
提交建议

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

来源:恒创科技 编辑:恒创科技编辑部
2022-09-15 08:45:00
前言

上篇我们介绍了多租户系统的定义和分类,根据数据隔离的程度可以分为:

Database 类型 Schema 类型 Partition 类型

我们选择 Database 类型,使用 MySQL 数据库完成了基本功能实现。 具体可参考上篇:Spring Boot JPA MySQL 多租户系统 Part1 - 基础实现

基础实现可以做到无缝切换租户,根据租户信息,请求对应数据库数据。但这并不能让我们满意,比如我们需要手动为每个数据源创建数据库和数据表。都用上 SpringBoot 了还要手动建库,实在不能忍。接下来,我们将探索 SpringBoot 自动建表的原理,并实现所有数据源的自动建库与建表。


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

自动创建数据库 Hibernate 建表原理 相关概念

在开始分析原理之前,需要先明确一些重要概念,以保证文字描述上的语义一致性。

JPA (Java Persistence Api) 是 java 持久化 api ,它用于在Java对象和关系数据库之间保存数据(ORM)。 JPA充当面向对象的领域模型和关系数据库系统之间的桥梁。由于JPA只是一个规范,它本身不执行任何操作。 它需要一个实现,比如:Hibernate。

Hibernate 是JPA的一个实现,不仅实现ORM(Object Relational Mapping),还提供数据查询和检索工具,可以显著减少 SQL 和 JDBC 手动处理数据所花的时间。 ![在这里插入图片描述](/news/upload/ueditor/image/202209/pend5oxds1l.jpeg =240x)

SpringBoot 是一个容器,集成了各种应用开发场景所需的组件和技术,使用强大的IoC(Inversion of Control) 技术简化配置到极致,让组件之间配合默契地工作。

Spring Data JPA 是 Spring Data 的重要部分,集成了 Hibernate ORM 等功能,是对基于JPA的数据访问层的增强支持。

本篇使用到的技术主要源自 Hibernate ORM。

Hibernate ORM:https://docs.jboss.org/hibernate/orm/6.1/userguide/html_single/Hibernate_User_Guide.html

SpringBoot的实现

我们知道在 application.properties 中添加 datasource 配置之后,SpringBoot会自动根据 Entity 创建数据表。但是我们根据租户信息创建的 DataSource 连接的数据库并不会自动创建数据表。这是因为 SpringBoot 会在应用启动时,扫描一次配置信息,并完成数据表的创建,之后不会有这一步骤。

经过详细分析源码,我得到了一条 SpringBoot 自动创建数据表的调用路径,如下:

graph TB
A(A: LocalContainerEntityManagerFactoryBean.afterPropertiesSet)-->B(B: LocalContainerEntityManagerFactoryBean.createNativeEntityManagerFactory)
B-->C(C: SpringHibernateJpaPersistenceProvider.createContainerEntityManagerFactory)
C-->D(D: EntityManagerFactoryBuilderImpl.build)
D-->E(E: EntityManagerFactoryBuilderImpl.metadata)
E-->F(F: MultiTenantConnectionProvider.getAnyConnection)
F-->G(G: TenantConnectionProvider.selectAnyDataSource)
G-->H(H: SessionFactoryBuilderImpl.build)
H-->I(I: SchemaManagementToolCoordinator.process)
I-->J(J: Tables created)

LocalContainerEntityManagerFactoryBean 类非常重要,是 Spring Data JPA 配置入口,通过自定义 Bean 可以修改 Hibernate 的初始化过程。包括:DataSource 配置、HibernateJpaVendorAdapter 配置、SessionFactory配置、JpaTransactionManager 配置等。本篇内容不涉及该类的自定义,暂不展开。

从上述流程我们可以看到,我们自己实现的 TenantConnectionProvider.selectAnyDataSource 方法会在 EntityManagerFactory创建过程中将JDBC连接信息读取到 Metadata,然后根据JDBC连接信息以及Entity信息创建数据表。最终实现方法是:SchemaManagementToolCoordinator.process

接下就是尝试重现这一过程:

SchemaManagementToolCoordinator.process参数较多,每个参数都不容易配置,往上找 SessionFactoryBuilder、EntityManagerFactoryBuilder、JpaPersistenceProvider 都没有比较好从中修改参数达到创建数据表目的的入口。

有尝试修改 LocalContainerEntityManagerFactoryBean datasource 参数,然后执行 afterPropertiesSet,但是失败了。

如果有好的实现方案,希望看到文章的你能留言探讨。

虽然使用 SpringBoot JPA 没能达到建表的目的,但是过程分析让我对 Hibernate ORM 有更深入的认识。之后再深入学习,可能会实现得更优雅一些。

Hibernate 的实现

虽然我们找到了 SpringBoot JPA 创建数据表的流程,但是并没有找到很方便我们直接使用的接口。既然 SpringBoot JPA 是对 Hibernate ORM 的封装,那么 Hibernate 一定有最原始的实现方案。

最终找到一篇文章介绍了,使用 xml 配置文件来实现数据表的创建。

文章链接:https://o7planning.org/11223/generate-tables-from-entity-classes-in-hibernate

我们会参考这篇文章,实现数据表的自动创建。

详细步骤 开发环境

承接上篇示例开发环境: SpringBoot 2.7.3 语言:Kotlin JDK:jbr-11 数据库: MySQL8.0.27

自定义配置属性

对于多租户应用系统来说,租户信息应当在应用启动之前定义好。虽然可以做到在请求到达时,我们再根据租户信息动态创建对应数据库和数据表,但是这样不是很好的方案,不满足数据访问层的设计要求。

本示例在配置文件里定义租户信息,当需要添加租户时,修改配置文件,并重新启动项目(生产环境可以通过配置一个 MasterDataSource 来管理所有租户信息)。

配置租户ID:

@Configuration
@ConfigurationProperties("multi-tenant")
class MultiTenantProperties {
    var tenants: List<String> = listOf()
}

application.properties

#multitenancy
multi-tenant.tenants[0]=tenant1
multi-tenant.tenants[1]=tenant2
multi-tenant.tenants[2]=tenant3

这里使用了自定义配置属性的功能,请参考 SpringBoot自定义配置属性

DataSources 类中注入:

	@Autowired
	private lateinit var tenantProperties: MultiTenantProperties

在 DataSources 对象创建之后根据租户信息生成对应的 DataSource。

	@PostConstruct
    fun initDataSources() {
        tenantProperties.tenants.forEach {
            dataSources[it] = createDatasource(it)
        }
    }

createDataSource 方法内容可以参考上篇,也可以查看文章结尾的源码。

创建数据库

创建 DataSource 的同时,需要创建数据库,方便后续使用。这里我们使用 DriverManager 创建。

    private fun createDatabase(dbName: String) {
        Log.i(TAG, "createDatabase: $dbName")
        val dbUrl = sqlAddress(dataSourceProperties.url)
        DriverManager.registerDriver(
            com.mysql.cj.jdbc.Driver()
        )
        val connection = DriverManager.getConnection(
            dbUrl,
            dataSourceProperties.username,
            dataSourceProperties.password
        )
        val statement = connection.createStatement()
        statement.executeUpdate("create database if not exists `$dbName` charset utf8")
    }

这里需要注册对应的Driver,不然可能会报错。

创建数据表

创建 DataSource 的同时,也应当一并创建数据表。

    private fun generateTables(jdbcUrl: String) {
        Log.i(TAG, "generateTables: $jdbcUrl")
        val registryBuilder = StandardServiceRegistryBuilder()
            .configure("config/hibernate-mysql.cfg.xml")
            .applySetting("connection.url", jdbcUrl)
            .applySetting("hibernate.connection.url", jdbcUrl)
        val registry = registryBuilder.build()
        val metadata = MetadataSources(registry).metadataBuilder.build()
        val action = SchemaExport.Action.CREATE
        val targetTypes = EnumSet.of(TargetType.DATABASE, TargetType.STDOUT)
        SchemaExport().execute(targetTypes, action, metadata)
    }

这里由 xml 资源文件生成 RegistryService 再由 RegistryService 生成 Metadata。然后使用 SchemaExport 工具,生成数据表。其中,jdbcUrl 是修改过数据库名称的 mysql url 链接,xml 文件的配置信息与 application.properties 中的内容相似。

<hibernate-configuration xmlns="http://www.hibernate.org/xsd/orm/cfg">
    <session-factory>
        <!-- Database connection settings -->
        <property name="connection.driver_class">com.mysql.cj.jdbc.Driver</property>
        <property name="connection.url">jdbc:mysql://127.0.0.1:3306/tenant1?useUnicode=true&amp;characterEncoding=utf8&amp;serverTimezone=GMT</property>
        <property name="connection.username">root</property>
        <property name="connection.password">xiang</property>

        <!-- JDBC connection pool (use the built-in) -->
        <property name="connection.pool_size">1</property>

        <!-- SQL dialect -->
        <property name="dialect">org.hibernate.dialect.MySQL8Dialect</property>

        <!-- Enable Hibernate's automatic session context management -->
        <property name="current_session_context_class">thread</property>

        <!-- Disable the second-level cache  -->
        <property name="cache.provider_class">org.hibernate.cache.internal.NoCacheProvider</property>

        <!-- Echo all executed SQL to stdout -->
        <property name="show_sql">true</property>

        <mapping class="com.example.multitenant.model.Person"/>
    </session-factory>
</hibernate-configuration>

其中,dialect 根据使用的 MySQL 版本指定,不然会报错。mapping 标签下添加每个Entity的包路径。

到这里,已经完成了数据库的完整创建过程。测试过程这里省略。

需要注意的一点是:项目启动的过程中会报警告:

Using Hibernate built-in connection pool (not for production use!)

说,内建连接池不能在生产环境中使用。这里其实可以忽略,因为我们只是在应用启动时用这个连接池来生成数据表。之后每个租户请求都有自己的连接池,配置也可以自己设置。

由于本篇使用的数据源都是 MySQL,连接信息基本一致,使用一个 xml 来配置这些信息与 DataSourceProperties 中定义的信息有些重复。这里我们尝试复用这些信息 ,并使用代码完成 MetaData 的初始化。

    private fun generateTables(jdbcUrl: String) {
        val registry = StandardServiceRegistryBuilder()
            .applySettings(dataSourceProperties.toHibernateProperties(jdbcUrl))
            .build()
        val metadata = MetadataSources(registry)
            .addAnnotatedClass(Person::class.java)
            .metadataBuilder
            .build()
        val targetTypes = EnumSet.of(TargetType.DATABASE)
        SchemaExport().setDelimiter(";").createOnly(targetTypes, metadata)
    }

其中 dataSourceProperties.toHibernateProperties(jdbcUrl) 方法将 jpa 属性转换为 hibernate 属性。

private fun DataSourceProperties.toHibernateProperties(jdbcUrl: String): HashMap<String, Any?> {
        val map = HashMap<String, Any?>()
        map[AvailableSettings.URL] = jdbcUrl
        map[AvailableSettings.USER] = username
        map[AvailableSettings.PASS] = password
        map[AvailableSettings.DRIVER] = driverClassName
        map[AvailableSettings.DIALECT] = MySQL8Dialect::class.qualifiedName
        map[AvailableSettings.SHOW_SQL] = true
        return map
    }

需要自动建表的 Entity 类使用 MetadataSources.addAnnotatedClass 方法指定。

总结

本文介绍了 Spring Data JPA 集成 Hibernate 在应用启动时创建数据表的过程。使用 DriverManager 为每个租户创建数据库,SchemaExport 工具为每个租户创建数据表。这些工作均在应用启动时完成,不同租户的请求到来时,可以无缝切换数据源,实现租户的数据隔离。

后续仍然有比较多的工作需要做,比如:添加管理租户的 MasterDataSource,不需要每次添加租户后重新启动项目;添加 Entity 的注解扫描,不需要每次通过 addAnnotatedClass(Person::class.java) 指定 Entity 类,减少功能的耦合。这些内容后续会完善,请点赞评论收藏哦。

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

上一篇: 租用美国服务器:潜在的风险与应对策略。 下一篇: MongoDB 5.0 扩展开源文档数据库操作