好得很程序员自学网

<tfoot draggable='sEl'></tfoot>

Spring Boot下如何实现数据库的多租户

通常情况下,多租户有三种形式:

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下如何实现数据库的多租户的详细内容...

  阅读:21次