SpringBoot Data JPA 集成多租户
背景:
iot-kit项目用的是SpringBoot JPA,不是Mybatis,项目中需要引入多租户。
文章中心思想: 通过Hibernate Filters 和AspectJ 切面编程,实现SpringBoot JPA多租户
什么是多租户
多租户我理解就是一个网站允许你多个公司去登录,每个公司都有他们独立的数据,互相之间的数据能做到独立、隔离。比如像阿里云,华为云这些网站,肯定有很多公司把部署在云服务器上面,每个公司就是一个租户。
多租户的三种形式
一个租户一个数据库
每个租户都有他自己的独立数据库,这种模式中,租户的数据是能做到物理隔离的,隔离性和安全性最好。一个租户一个数据库,能确保租户之间的数据做到彻底隔离,但这种方式的代价是每个数据库都得重复定义,维护起来也很低效,比如说,你要加一个字段,每个数据库都得加一编,非常麻烦。
相同Schema不同数据库
在共享数据库实例时,对每个租户使用单独的Schema。每个租户的数据通过数据库引擎提供的独立模式的语义进行逻辑隔离。这个模式由每个租户的单独数据库用户所拥有,数据库引擎的安全机制将进一步保证数据的隐私性和机密性(但是在这种情况下,数据库连接池不能被数据访问层重用)。
相同数据库,相同表,增加tenantId字段区分
这种模式,共享享通的数据,共享享通的表,通过字段tenantId区分不同租户,这中模式是绝大多数公司采用的。本文讲的也是这种模式。
最后一种模式主要通过Hibernate 过滤器和切面技术实现。
在Hibernate 的 5.4.x之前的版本,虽然有MultiTenancyStrategy.DISCRIMINATOR这种策略,但是他们一直都没实现,参考下面的JIRA:https://hibernate.atlassian.net/browse/HHH-6054
也就是说Hibernate 5.4.x的版本 官方并没有提供多租户的具体实现。
直到Hibernate 6出来了,官方才正式支持共享数据库共享实例。但是呢,这个Hibernate 6需要使用 Spring Boot 3,而这个 Spring Boot 3又需要把JDK升级到17,这样就没得玩了,因为目前大多数的公司,都是用jdk8 或者jdk11的。
https://spring.io/blog/2022/05/24/preparing-for-spring-boot-3-0
所以为了不升级spring boot版本以及JDK版本,我们就用另外一种形式实现多租户:Hibernate 过滤器 和 切面,说了那么多废话,下面进入正文
进入正文
Jpa Entity Listener
Hibernate的Entity Listener 允许我们在JPA的生命周期(新增、删除、更新)中,添加一个监听器。通过这个监听器,我们就能够对实体类做一些额外的操作,例如增加属性tenantId。假设我们自定义的监听器是TenantAware,那所有实现这个接口的实体类都可以实现如下所示的功能: 在更新、删除、插入之前,给entity增加一个租户id的字段。
public interface TenantAware {
void setTenantId(String tenantId);
}
public class TenantListener {
@PreUpdate
@PreRemove
@PrePersist
public void setTenant(TenantAware entity) {
final String tenantId = TenantContext.getTenantId();
entity.setTenantId(tenantId);
}
}
如果是超级管理员,也额外设置租户id吗?超级管理员肯定不用管啊,那我们在获取到租户id的时候,就判断一下,当前租户是不是超级管理员,如果是,那就直接忽略,不处理即可。
public class TenantListener {
@PreUpdate
@PreRemove
@PrePersist
public void setTenant(TenantAware entity) {
final String tenantId = TenantContext.getTenantId();
if(!"000000".equals(tenantId)){
entity.setTenantId(tenantId);
}
}
}
Hibernate Filter
上面的监听器,只能在监听新增、删除、更新这3个操作,对于查询,就无能为力了。好在有Hibernate Filter。
Hibernate Filter的过滤器有一个机制:只要实体类上有@Filter,那么这个实体的所有查询语句,都能被过滤器拦截,如下所示:
@FilterDef(name = "tenantFilter", parameters = {@ParamDef(name = "tenantId", type = "string")})
@Filter(name = "tenantFilter", condition = "tenant_id = :tenantId")
有了上面的监听器(Hibernate Filter)和过滤器(Jpa Entity Listener),我们就能把两者整合到一起,然后就可以对实体的增删改查进行拦截操作了。下面具体说说怎么整合。
@MappedSuperclass
@Getter
@Setter
@NoArgsConstructor
@FilterDef(name = "tenantFilter", parameters = {@ParamDef(name = "tenantId", type = "string")})
@Filter(name = "tenantFilter", condition = "tenant_id = :tenantId")
@EntityListeners(TenantListener.class)
public abstract class AbstractBaseEntity implements TenantAware, Serializable {
private static final long serialVersionUID = 1L;
@Size(max = 30)
@Column(name = "tenant_id")
private String tenantId;
public AbstractBaseEntity(String tenantId) {
this.tenantId = tenantId;
}
}
为了实现多租户功能,所有的租户都必须继承这抽象类:AbstractBaseEntity,如下所示:
@Entity
public class Product extends AbstractBaseEntity {
...
}
理论上,事情到这里就结束了,但是......
但是,一个定义在实体类上的@Filter注解,是不会自动生效的。当一个查询请求发送过来的时候,就会打开一个Hibernate的Session会话,这个Filter过滤器需要配置才能应用到这个session上。因为这个session是在运行时期动态创建的(一般是一个事务创建一次session),所以我们不能这样把这个Filter应用到Hibernate的会话上。
这时候,切面编程Aspect派上用场。
通过AspectJ 来定义execution points(执行点),就能在这些执行点上进行拦截操作,从而达到注入额外逻辑的目的。这,正是我们在那个Hibernate Filter那里需要用!!
通过AspectJ,我们可以在每一个Hibernate Session创建的时候进行拦截操作,然后把Filter过滤器应用到每一个session会话上。但是我们并不能用Spring内置的轻量级切面进行拦截,因为Spring只能管理它自己容器内的bean。我们这里的Hibernate Session对象,是不属于Spring bean的,自然不能像我们平时那样使用切面技术。
为了能够在运行期(runtime)随心所欲的拦截非SPring 容器下的对象(当前这里指Hibernate Session对象),AspectJ需要把定义好的切面织入到类中,织入的方法有两种:编译期(compile-time)、运行期(load-time)。编译器和运行期的说明,参考底部的名词解释。因为加载期的侵入性比较小,所以我们选择在加载期织入。
配置AspectJ 在运行期织入,只需要把下面的配置文件,放到类路径下即可:
<aspectj>
<weaver options="-Xreweavable -verbose -showWeaveInfo">
<include within="se.callista.blog.service.multi_tenancy.aspect.TenantFilterAspect"/>
<include within="org.hibernate.internal.SessionFactoryImpl.SessionBuilderImpl"/>
</weaver>
<aspects>
<aspect name="se.callista.blog.service.multi_tenancy.aspect.TenantFilterAspect"/>
</aspects>
</aspectj>
参数说明:
1.1 <weaver>
元素:指定了 AspectJ 织入器的配置选项
-Xreweavable
选项用于支持增量编译和重新织入,允许在运行时修改切面并动态重新织入到目标类中。
-verbose
选项用于在织入过程中输出详细信息,可以帮助调试和观察织入的效果。
-showWeaveInfo
选项用于在织入过程中显示织入的详细信息,包括被织入的类和方法等。
<include>
元素:用于指定需要织入的目标类或切点
在这个示例中,指定了 se.callista.blog.service.multi_tenancy.aspect.TenantFilterAspect
和 org.hibernate.internal.SessionFactoryImpl.SessionBuilderImpl
这两个类。
se.callista.blog.service.multi_tenancy.aspect.TenantFilterAspect
是自定义的切面类,其中定义了租户过滤器的逻辑。
org.hibernate.internal.SessionFactoryImpl.SessionBuilderImpl
是 Hibernate 框架的类,通过指定该类,可以将切面织入到 Hibernate SessionBuilderImpl 类中。
1.2 <aspects>
元素:用于指定要加载的 AspectJ 切面。在这个示例中,指定了 se.callista.blog.service.multi_tenancy.aspect.TenantFilterAspect
这个切面。
上面的配置文件,定义了一个切面:TenantFilterAspect,以及把这个切面织入的类:SessionBuilderImpl。此外,还需要在代码中,定义以下切面:
@Aspect
public class TenantFilterAspect {
@Pointcut("execution (* org.hibernate.internal.SessionFactoryImpl.SessionBuilderImpl.openSession(..))")
public void openSession() {
}
@AfterReturning(pointcut = "openSession()", returning = "session")
public void afterOpenSession(Object session) {
if (session != null && Session.class.isInstance(session)) {
final String tenantId = TenantContext.getTenantId();
if (tenantId != null) {
org.hibernate.Filter filter = ((Session) session).enableFilter("tenantFilter");
filter.setParameter("tenantId", tenantId);
}
}
}
}
当一个Hibernate 会话打开的时候,切入点(@Pointcut注解)可以在查询会话打开之前,执行一些额外的逻辑,我们这里是把Hibernate Filter注入进去。
为了让AspectJ在运行时(Load-time weaving(LTW))动态织入,我们需要在启动类中使用注解:@EnableLoadTimeWeaving,以及继承SpringBootServletInitializer ,如下所示:
@SpringBootApplication
@EnableLoadTimeWeaving(aspectjWeaving = EnableLoadTimeWeaving.AspectJWeaving.ENABLED)
public class MultiTenantServiceApplication extends SpringBootServletInitializer {
...
}
最后,我们需要把spring 的instrumentation agent 和AspectJ的aspectjweaver agent作为参数,传入到jvm参数中,如下所示:
java -javaagent:spring-instrument.jar -javaagent:aspectjweaver.jar -jar app.jar
同时spring-boot-maven-plugin插件增加配置及参数
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<agents>
<agent>${project.build.directory}/spring-instrument-${spring-framework.version}.jar</agent>
<agent>${project.build.directory}/aspectjweaver-${aspectj.version}.jar</agent>
</agents>
</configuration>
</plugin>
参数说明:
${project.build.directory}/spring-instrument-${spring-framework.version}.jar
上面代码是一个Maven构建项目中的路径表达式,用于指定Spring Instrumentation Agent JAR文件的位置。
${project.build.directory}
表示Maven构建时生成的目标目录,通常是target
目录。
${spring-framework.version}
是一个变量,表示Spring Framework的版本号。
因此,${project.build.directory}/spring-instrument-${spring-framework.version}.jar
表示在目标目录下,根据Spring Framework的版本号拼接出的Spring Instrumentation Agent JAR文件的路径。这个路径用于在构建过程中引用Spring Instrumentation Agent JAR文件,并将其用作Java代理加载到JVM中。
如果是在idea中启动,需要像下面一样增加JVM参数:-javaagent:D:\code\blog-multitenancy\multi-tenant-service\target\spring-instrument-5.3.18.jar 这个spring-instrument-xxxx.jar包编译后,在target目录下面可以找到,如果找不到,就去maven仓库,路径:
记住,
如果是部署到服务器上,打包成jar,那么运行时,需要增加参数:java -javaagent:spring-instrument.jar -jar app.jar。
名词解释说明:
Load-time weaving(LTW)是一种在运行时动态织入代码的技术,用于增强或修改应用程序的行为。它是一种AOP(面向切面编程)的实现方式之一。
在 Java 中,通常情况下,代码的编织发生在编译时或类加载时。而 Load-time weaving 允许在应用程序运行时动态地织入代码,即在类加载时进行字节码修改,实现对目标类的增强。
Load-time weaving 的实现依赖于 JVM 提供的 Instrumentation API。通过使用 Instrumentation API,可以在类加载时修改字节码,使得切面逻辑能够被织入目标类的方法中。这种方式相比于静态编织(AspectJ 编译器)或运行时编织(Spring AOP)更加灵活,因为可以在运行时动态决定何时、何地、以及如何织入代码。
Load-time weaving 的主要优点是可以在不修改源代码的情况下增加、修改或删除目标类的行为。它适用于那些无法通过静态编织或运行时编织实现的场景,例如对第三方库进行增强、在容器环境中动态织入切面等。
在 Spring 框架中,Load-time weaving 可以通过 Spring 的 Instrumentation agent 和相关配置来实现。Spring 的 Instrumentation agent 是一个 Java 代理,用于加载并启动一个字节码增强器,从而实现在类加载时进行字节码修改和切面织入的功能。
使用 Load-time weaving 可以在运行时动态织入切面逻辑,实现对目标类的增强,从而实现例如事务管理、日志记录、性能监控等功能。
参考文章:
【讲解多租户的实现与原理】 https://www.bilibili.com/video/BV1F84y1T7yf/?share_source=copy_web&vd_source=981718c4abc87423399e43793a5d3763
https://callistaenterprise.se/blogg/teknik/2020/10/17/multi-tenancy-with-spring-boot-part5/
https://www.baeldung.com/jpa-entity-lifecycle-events
https://nullbeans.com/how-to-use-prepersist-and-preupdate-in-jpa-hibernate/
https://stackoverflow.com/questions/14379365/jpa-entity-with-abstract-class-inheritance
https://blog.csdn.net/JavaSupeMan/article/details/125179429
https://plus-doc.dromara.org/#/ruoyi-vue-plus/quickstart/init
代码仓库地址:
https://gitee.com/gxnualbert/multi-tenant/commits/master