通常情况下,多租户有三种形式:
1、分区(Partitioned)数据:不同租户的数据都在一张表里,通过一个值(tenantId)来区分不同的租户。
2、分结构(Schema):不同的租户数据放置在相同数据库实例的不同结构(Schema)中。
3、分数据库(Database):不同租户的数据放置在不同的数据中。
在Spring Boot中,多租户的能力是由Hibernate提供的,我们在本文中结合Spring Data JPA一起对三种多租户的模式进行演示。本文不需要你具备任何Spring Data JPA和Hibernate基础,也可以通过本文领略下Spring Data JPA。
多租户最最简单也是最常用的多租户方式是[分区数据[],而这个功能是Hibernate 6.0才具有的功能,而Spring Boot 2.x只支持Hibernate 5.x,所以使用[分区数据]的方式进行多租户需要采用Spring Boot 3.x。幸运的是Spring Boot 3.0将于今年11月份发布,到时候你就可以在生产环境使用本功能了。
[分结构]和[分数据库]的实现方式Spring Boot 2.x是支持的,本文为了演示简单以及远期的前瞻性,将全部以Spring Boot 3.0来实现。
首先,让我们从最简单的例子开始。
一、[分区数据]多租户新建演示项目:spring-boot-multitenant-partition,依赖为Spring Web、Spring Data JPA、Lombok,Spring Boot版本注意选择3.x,Spring Boot 3.x的最小支持JDK版本为17。
演示实体:
@Entity // 1 @Data
public class Person { @Id // 2 @GeneratedValue ( strategy = GenerationType .IDENTITY ) // 3 private Long id ; @TenantId // 4 private String tenantId ; private String name ; private Integer age ; }
1、通过@Entity注解定义一个实体,对应数据库一张表;
2、通过@Id注解表名该属性对应数据库的主键;
3、通过@GeneratedValue(strategy= GenerationType.IDENTITY)配置使用MySQL的主键自增;
4、使用@TenantId注解的属性tenantId作为分区数据多租户的的区分标识。
演示数据访问
import org .springframework .data .jpa .repository .JpaRepository ; public interface PersonRepository extends JpaRepository < Person , Long > { List < Person > findByName ( String name ) ; }
这个是Spring Data JPA神奇的地方,通过一个定义一个接口PersonRepository继承框架提供的JpaRepository接口,框架将会给我们自动代理一个实现类,这个实现类除了基本的增删查改以外,还会通过方法名自动推算查询语句。如findByName相当于select * from person where name = ?
演示如何确定TenantId的来源
import org .hibernate .cfg .AvailableSettings ; import org .hibernate .context .spi .CurrentTenantIdentifierResolver ; import org .springframework .boot .autoconfigure .orm .jpa .HibernatePropertiesCustomizer ; @Component
public class WiselyTenantIdResolver implements CurrentTenantIdentifierResolver , // 1 HibernatePropertiesCustomizer { // 2 private static final ThreadLocal < String > CURRENT_TENANT = new ThreadLocal <> ( ) ; // 1.1 public void setCurrentTenant ( String currentTenant ) { // 1.2 CURRENT_TENANT .set ( currentTenant ) ; } @Override
public String resolveCurrentTenantIdentifier ( ) { // 1 return Optional .ofNullable ( CURRENT_TENANT .get ( ) ) .orElse ( "unknown" ) ; } @Override
public boolean validateExistingCurrentSessions ( ) { return false ; } @Override
public void customize ( Map < String , Object > hibernateProperties ) { // 2 hibernateProperties .put ( AvailableSettings .MULTI_TENANT_IDENTIFIER_RESOLVER , this ) ; } }
1.通过实现CurrentTenantIdentifierResolver接口来获取确定TenantId的来源。
1. 1使用线程本地变量CURRENT_TENANT来存储当前的TenantId;
1.2.通过setCurrentTenant方法接受外部设置当前访问者的TenantId,并存储在线程本地变量CURRENT_TENANT中;
1.3.通过重写接口的resolveCurrentTenantIdentifier方法,获得当前的TenantId;
2.通过重写HibernatePropertiesCustomizer接口的customize方法,可以将当前类注册到Hibernate的配置。
通过请求头设置TenantId
@Component
public class TenantIdInterceptor implements HandlerInterceptor { private final WiselyTenantIdResolver tenantIdResolver ; public TenantIdInterceptor ( WiselyTenantIdResolver tenantIdResolver ) { this .tenantIdResolver = tenantIdResolver ; } @Override
public boolean preHandle ( HttpServletRequest request , HttpServletResponse response , Object handler ) throws Exception { tenantIdResolver .setCurrentTenant ( request .getHeader ( "x-tenant-id" ) ) ; return HandlerInterceptor .super .preHandle ( request , response , handler ) ; } }
通过定义一个Spring MVC的拦截器,在每个request请求的头部设置key:x-tenant-id(如:companya),在拦截器中获取到TenantId后设置WiselyTenantIdResolver的TenantId,即当前的TenantId。
注册此拦截器
import org .springframework .context .annotation .Configuration ; import org .springframework .web .servlet .config .annotation .InterceptorRegistry ; import org .springframework .web .servlet .config .annotation .WebMvcConfigurer ; @Configuration
public class WebConfig implements WebMvcConfigurer { private final TenantIdInterceptor tenantIdInterceptor ; public WebConfig ( TenantIdInterceptor tenantIdInterceptor ) { this .tenantIdInterceptor = tenantIdInterceptor ; } @Override
public void addInterceptors ( InterceptorRegistry registry ) { registry .addInterceptor ( tenantIdInterceptor ) ; WebMvcConfigurer .super .addInterceptors ( registry ) ; } }
演示控制器
@RestController
@RequestMapping ( "/people" ) public class PersonController { private final PersonRepository personRepository ; public PersonController ( PersonRepository personRepository ) { this .personRepository = personRepository ; } @PostMapping
public Person save ( @RequestBody PersonDto personDto ) { // 1 return personRepository .save ( personDto .createPerson ( ) ) ; } @GetMapping
private List < Person > all ( ) { // 2 return personRepository .findAll ( ) ; } }
1.通过设置在头信息中设置不同的TenantId,数据库中的tenant_id字段将自动存储头中的租户;
2.通过设置在头信息中设置不同的TenantId,只能查询到该租户下的数据。
控制器所需要的DTO
import lombok .Value ; @Value
public class PersonDto { private String name ; private Integer age ; public Person createPerson ( ) { Person person = new Person ( ) ; person .setName ( this .name ) ; person .setAge ( this .age ) ; return person ; } }
配置
spring .datasource .url : jdbc : mysql : // localhost : 3306 / partitioned #1 spring .datasource .username : root spring .datasource .password : example spring .datasource .driver - class - name : com .mysql .cj .jdbc .Driver spring .jpa .hibernate .ddl - auto : update # 2 server .port : 80
1.在数据库中创建一个schema叫partitioned;
2.设置为update属性,hibernate会自动因实体类的变化自动创建和更新数据库的表;
启动程序 测试需保存的数据通过postman构造四条数据,分别为:
租户:companya,通过x-tenant-id头来设置:
{ "name" : "wang" , "age" : 22 } { "name" : "li" , "age" : 23 }
租户:companya,通过x-tenant-id头来设置:
{ "name" : "peng" , "age" : 24 } { "name" : "zhang" , "age" : 25 }
请求需保存的数据
在postman的样子是这样的:
我们一次对上述四条数据进行请求,查看数据库:
我们看见数据都添加了正确的tenant_id。
按租户查询数据查询租户companya的数据:
查询租户companyb的数据:
二、[分结构]多租户
第二个例子,我们在一个数据库中分别建立2个schema,分别是companya,用来放置租户companya的数据;companyb用来放置租户companyb的数据。再建一个schema:public作为默认的连接的schema。
演示实体
public class Person { @Id
@GeneratedValue ( strategy = GenerationType .IDENTITY ) private Long id ; private String name ; private Integer age ; }
无须标识租户的tenantId字段,因为租户已经通过schema来隔离开了。
如何获得当前租户id
@Component
public class WiselyTenantIdResolver implements CurrentTenantIdentifierResolver , HibernatePropertiesCustomizer { private static final ThreadLocal < String > CURRENT_TENANT = new ThreadLocal <> ( ) ; public void setCurrentTenant ( String currentTenant ) { CURRENT_TENANT .set ( currentTenant ) ; } @Override
public String resolveCurrentTenantIdentifier ( ) { return Optional .ofNullable ( CURRENT_TENANT .get ( ) ) .orElse ( "public" ) ; } @Override
public boolean validateExistingCurrentSessions ( ) { return false ; } @Override
public void customize ( Map < String , Object > hibernateProperties ) { hibernateProperties .put ( AvailableSettings .MULTI_TENANT_IDENTIFIER_RESOLVER , this ) ; } }
这里和上例没有什么区别,只是上例设置如果没有获取到TenantId,则将tenant_id字段设置为unknown,本例是将数据库的schema连到public。
通过获得tenantId,连接到对应的schema
import org .hibernate .cfg .AvailableSettings ; import org .hibernate .engine .jdbc .connections .spi .MultiTenantConnectionProvider ; import org .springframework .boot .autoconfigure .orm .jpa .HibernatePropertiesCustomizer ; import javax .sql .DataSource ; import java .sql .Connection ; import java .sql .SQLException ; @Component
public class WiselyMultiTenantConnectionProvider implements MultiTenantConnectionProvider , HibernatePropertiesCustomizer { private final DataSource dataSource ; public WiselyMultiTenantConnectionProvider ( DataSource dataSource ) { this .dataSource = dataSource ; } @Override
public Connection getAnyConnection ( ) throws SQLException { return dataSource .getConnection ( ) ; } @Override
public void releaseAnyConnection ( Connection connection ) throws SQLException { connection .close ( ) ; } @Override
public Connection getConnection ( String tenantIdentifier ) throws SQLException { final Connection connection = getAnyConnection ( ) ; connection .createStatement ( ) .execute ( String .format ( "use %s;" , tenantIdentifier ) ) ; return connection ; } @Override
public void releaseConnection ( String tenantIdentifier , Connection connection ) throws SQLException { connection .createStatement ( ) .execute ( "use public;" ) ; connection .close ( ) ; } // ...省略一些非关键方法
@Override
public void customize ( Map < String , Object > hibernateProperties ) { hibernateProperties .put ( AvailableSettings .MULTI_TENANT_CONNECTION_PROVIDER , this ) ; } }
因为我们是连接单个数据库的不同schema,所以我们只需要在系统中配置一个dataSource,通过这个dataSource我们获得数据库的连接。通过重写MultiTenantConnectionProvider接口的Connection getConnection(String tenantIdentifier)方法,我们根据在上面的WiselyTenantIdResolver获得的tenantId,即当前方法的tenantIdentifier参数来切换dataSource连接到不同的schema.
配置
spring .datasource .url : jdbc : mysql : // 127.0 .0 .1 : 3306 / public spring .datasource .username : root spring .datasource .password : example spring .datasource .driver - class - name : com .mysql .cj .jdbc .Driver spring .jpa .show - sql : true server .port : 80
其余代码和上例保持一致,省略
测试数据和postman和上例保持一致,查看数据库中的数据:
租户:companya
租户:companyb
查询租户:companya
查询租户:companyb
三、[分数据库]多租户
第三个例子是不同租户的数据分别在不同的数据库里,为了演示方便,本例还是用2个schema来模拟两个数据库,区别是相同数据库时我们使用一个dataSource来切换不同的schema;而本例中会有多个dataSource,使用tenantId来切换到不同的dataSource。
spring-jdbc包为我们提供一个类叫做AbstractRoutingDataSource,它可以设置多个数据源,并通过一个key来切换这个数据源,很显然,这个key是我们的tenantId。
动态切换数据源
import org .springframework .boot .jdbc .DataSourceBuilder ; import org .springframework .jdbc .datasource .lookup .AbstractRoutingDataSource ; import javax .sql .DataSource ; @Component
public class WiselyTenantRoutingDatasource extends AbstractRoutingDataSource { private final WiselyTenantIdResolver wiselyTenantIdResolver ; // 1 public WiselyTenantRoutingDatasource ( WiselyTenantIdResolver wiselyTenantIdResolver ) { this .wiselyTenantIdResolver = wiselyTenantIdResolver ; setDefaultTargetDataSource ( createDatabase ( "jdbc:mysql://127.0.0.1:3306/public" , "root" , "example" ) ) ; // 2 HashMap < Object , Object > targetDataSources = new HashMap <> ( ) ; // 3 targetDataSources .put ( "companya" , createDatabase ( "jdbc:mysql://127.0.0.1:3306/companya" , "root" , "example" ) ) ; // 4 targetDataSources .put ( "companyb" , createDatabase ( "jdbc:mysql://127.0.0.1:3306/companyb" , "root" , "example" ) ) ; setTargetDataSources ( targetDataSources ) ; // 5 } @Override
protected String determineCurrentLookupKey ( ) { // 6 return wiselyTenantIdResolver .resolveCurrentTenantIdentifier ( ) ; } private DataSource createDatabase ( String databaseUrl , String username , String password ) { DataSourceBuilder dataSourceBuilder = DataSourceBuilder .create ( ) ; dataSourceBuilder .driverClassName ( "com.mysql.cj.jdbc.Driver" ) ; dataSourceBuilder .url ( databaseUrl ) ; dataSourceBuilder .username ( username ) ; dataSourceBuilder .password ( password ) ; return dataSourceBuilder .build ( ) ; } }
1、注入WiselyTenantIdResolver的bean获得当前的tenantId;
2、添加默认数据源到动态路由数据源里;
3、定义一个Map,在里面存储不同租户的数据源;
4、通过代码编程的方式构建一个数据源;
5、将这些数据源都添加到动态路由数据源里;
6、通过从WiselyTenantIdResolver中拿到的TenantId,切换到不同的数据源。
在数据源被切换后,我们就可以轻松获得数据库的连接
@Component
public class WiselyMultiTenantConnectionProvider implements MultiTenantConnectionProvider , HibernatePropertiesCustomizer { private final DataSource dataSource ; public WiselyMultiTenantConnectionProvider ( DataSource dataSource ) { this .dataSource = dataSource ; } @Override
public Connection getAnyConnection ( ) throws SQLException { return dataSource .getConnection ( ) ; } @Override
public void releaseAnyConnection ( Connection connection ) throws SQLException { connection .close ( ) ; } @Override
public Connection getConnection ( String tenantIdentifier ) throws SQLException { return dataSource .getConnection ( ) ; } @Override
public void releaseConnection ( String tenantIdentifier , Connection connection ) throws SQLException { connection .close ( ) ; } // 省略不重要的方法
@Override
public void customize ( Map < String , Object > hibernateProperties ) { hibernateProperties .put ( AvailableSettings .MULTI_TENANT_CONNECTION_PROVIDER , this ) ; } }
这里直接注入前面切换数据源后的得到的dataSource,从dataSource中得到数据库的连接
其余代码与上例保持一致,数据源是编程获得,所以无须在配置中配置。
演示效果和上例一致,再此就不做演示了
四、源码地址分区数据多租户:https://github测试数据/wiselyman/spring-boot-multitenant-partition
分结构多租户:https://github测试数据/wiselyman/spring-boot-multitenant-schema
分数据库多租户:https://github测试数据/wiselyman/spring-boot-multitenant-database
五、参考资料https://spring.io/blog/2022/07/31/how-to-integrate-hibernates-multitenant-feature-with-spring-data-jpa-in-a-spring-boot-application
https://HdhCmsTestbaeldung测试数据/hibernate-5-multitenancy
https://HdhCmsTestbaeldung测试数据/multitenancy-with-spring-data-jpa
原文地址:https://HdhCmsTesttoutiao测试数据/article/7151386916141662733/
查看更多关于Spring Boot下如何实现数据库的多租户的详细内容...