飞道的博客

Spring Boot 构建多租户SaaS平台核心技术指南

359人阅读  评论(0)

1. 概述

笔者从2014年开始接触SaaS(Software as a Service),即多租户(或多承租)软件应用平台;并一直从事相关领域的架构设计及研发工作。机缘巧合,在笔者本科毕业设计时完成了一个基于SaaS的高效财务管理平台的课题研究,从中收获颇多。最早接触SaaS时,国内相关资源匮乏,唯一有的参照资料是《互联网时代的软件革命:SaaS架构设计》(叶伟等著)一书。最后课题的实现是基于OSGI(Open Service Gateway Initiative)Java动态模块化系统规范来实现的。

时至今日,五年的时间过去了,软件开发的技术发生了巨大的改变,笔者所实现SaaS平台的技术栈也更新了好几波,真是印证了那就话:“山重水尽疑无路,柳暗花明又一村”。基于之前走过的许多弯路和踩过的坑,以及近段时间有许多网友问我如何使用Spring Boot实现多租户系统,决定写一篇文章聊一聊关于SaaS的硬核技术。

说起SaaS,它只是一种软件架构,并没有多少神秘的东西,也不是什么很难的系统,我个人的感觉,SaaS平台的难度在于商业上的运营,而非技术上的实现。就技术上来说,SaaS是这样一种架构模式:它让多个不同环境的用户使用同一套应用程序,且保证用户之间的数据相互隔离。现在想想看,这也有点共享经济的味道在里面。

笔者在这里就不再深入聊SaaS软件成熟度模型和数据隔离方案对比的事情了。今天要聊的是使用Spring Boot快速构建独立数据库/共享数据库独立Schema的多租户系统。我将提供一个SaaS系统最核心的技术实现,而其他的部分有兴趣的朋友可以在此基础上自行扩展。

2. 尝试了解多租户的应用场景

假设我们需要开发一个应用程序,并且希望将同一个应用程序销售给N家客户使用。在常规情况下,我们需要为此创建N个Web服务器(Tomcat),N个数据库(DB),并为N个客户部署相同的应用程序N次。现在,如果我们的应用程序进行了升级或者做了其他任何的改动,那么我们就需要更新N个应用程序同时还需要维护N台服务器。接下来,如果业务开始增长,客户由原来的N个变成了现在的N+M个,我们将面临N个应用程序和M个应用程序版本维护,设备维护以及成本控制的问题。运维几乎要哭死在机房了...

为了解决上述的问题,我们可以开发多租户应用程序,我们可以根据当前用户是谁,从而选择对应的数据库。例如,当请求来自A公司的用户时,应用程序就连接A公司的数据库,当请求来自B公司的用户时,自动将数据库切换到B公司数据库,以此类推。从理论上将没有什么问题,但我们如果考虑将现有的应用程序改造成SaaS模式,我们将遇到第一个问题:如果识别请求来自哪一个租户?如何自动切换数据源?

3. 维护、识别和路由租户数据源

我们可以提供一个独立的库来存放租户信息,如数据库名称、链接地址、用户名、密码等,这可以统一的解决租户信息维护的问题。租户的识别和路由有很多种方法可以解决,下面列举几个常用的方式:

  • 1.可以通过域名的方式来识别租户:我们可以为每一个租户提供一个唯一的二级域名,通过二级域名就可以达到识别租户的能力,如tenantone.example.com,tenant.example.com;tenantone和tenant就是我们识别租户的关键信息。

  • 2.可以将租户信息作为请求参数传递给服务端,为服务端识别租户提供支持,如saas.example.com?tenantId=tenant1,saas.example.com?tenantId=tenant2。其中的参数tenantId就是应用程序识别租户的关键信息。

  • 3.可以在请求头(Header)中设置租户信息,例如JWT等技术,服务端通过解析Header中相关参数以获得租户信息。

  • 4.在用户成功登录系统后,将租户信息保存在Session中,在需要的时候从Session取出租户信息。

解决了上述问题后,我们再来看看如何获取客户端传入的租户信息,以及在我们的业务代码中如何使用租户信息(最关键的是DataSources的问题)。

我们都知道,在启动Spring Boot应用程序之前,就需要为其提供有关数据源的配置信息(有使用到数据库的情况下),按照一开始的需求,有N个客户需要使用我们的应用程序,我们就需要提前配置好N个数据源(多数据源),如果N<50,我认为我还能忍受,如果更多,这样显然是无法接受的。为了解决这一问题,我们需要借助Hibernate 5提供的动态数据源特性,让我们的应用程序具备动态配置客户端数据源的能力。简单来说,当用户请求系统资源时,我们将用户提供的租户信息(tenantId)存放在ThreadLoacal中,紧接着获取TheadLocal中的租户信息,并根据此信息查询单独的租户库,获取当前租户的数据配置信息,然后借助Hibernate动态配置数据源的能力,为当前请求设置数据源,最后之前用户的请求。这样我们就只需要在应用程序中维护一份数据源配置信息(租户数据库配置库),其余的数据源动态查询配置。接下来,我们将快速的演示这一功能。

4. 项目构建

我们将使用Spring Boot 2.1.5版本来实现这一演示项目,首先你需要在Maven配置文件中加入如下的一些配置:

 

  
  1. <dependencies>
  2.          <dependency>
  3.              <groupId>org.springframework.boot </groupId>
  4.              <artifactId>spring-boot-starter </artifactId>
  5.          </dependency>
  6.          <dependency>
  7.              <groupId>org.springframework.boot </groupId>
  8.              <artifactId>spring-boot-devtools </artifactId>
  9.              <scope>runtime </scope>
  10.          </dependency>
  11.          <dependency>
  12.              <groupId>org.projectlombok </groupId>
  13.              <artifactId>lombok </artifactId>
  14.              <optional>true </optional>
  15.          </dependency>
  16.          <dependency>
  17.              <groupId>org.springframework.boot </groupId>
  18.              <artifactId>spring-boot-starter-test </artifactId>
  19.              <scope>test </scope>
  20.          </dependency>
  21.          <dependency>
  22.              <groupId>org.springframework.boot </groupId>
  23.              <artifactId>spring-boot-starter-data-jpa </artifactId>
  24.          </dependency>
  25.          <dependency>
  26.              <groupId>org.springframework.boot </groupId>
  27.              <artifactId>spring-boot-starter-web </artifactId>
  28.          </dependency>
  29.          <dependency>
  30.              <groupId>org.springframework.boot </groupId>
  31.              <artifactId>spring-boot-configuration-processor </artifactId>
  32.          </dependency>
  33.          <dependency>
  34.              <groupId>mysql </groupId>
  35.              <artifactId>mysql-connector-java </artifactId>
  36.              <version>5.1.47 </version>
  37.          </dependency>
  38.          <dependency>
  39.              <groupId>org.springframework.boot </groupId>
  40.              <artifactId>spring-boot-starter-freemarker </artifactId>
  41.          </dependency>
  42.          <dependency>
  43.              <groupId>org.apache.commons </groupId>
  44.              <artifactId>commons-lang3 </artifactId>
  45.          </dependency>
  46.      </dependencies>

然后提供一个可用的配置文件,并加入如下的内容:

 

  
  1. spring:
  2.   freemarker:
  3.     cache: false
  4.     template-loader-path:
  5.     - classpath:/templates/
  6.     prefix:
  7.     suffix: .html
  8.   resources:
  9.     static-locations:
  10.     - classpath:/static/
  11.   devtools:
  12.     restart:
  13.       enabled: true
  14.   jpa:
  15.     database: mysql
  16.     show-sql: true
  17.     generate-ddl: false
  18.     hibernate:
  19.       ddl-auto: none
  20. una:
  21.    master:
  22.      datasource:
  23.        urljdbc: mysql: //localhost:3306/master_tenant?useSSL=false
  24.        username: root
  25.        password: root
  26.        driverClassName: com.mysql.jdbc.Driver
  27.        maxPoolSize10
  28.        idleTimeout300000
  29.        minIdle10
  30.        poolName: master-database-connection-pool
  31. logging:
  32.    level:
  33.      root: warn
  34.      org:
  35.        springframework:
  36.          web: debug
  37.        hibernate: debug

由于采用Freemarker作为视图渲染引擎,所以需要提供Freemarker的相关技术

una:master:datasource配置项就是上面说的统一存放租户信息的数据源配置信息,你可以理解为主库。

接下来,我们需要关闭Spring Boot自动配置数据源的功能,在项目主类上添加如下的设置:


  
  1. @SpringBootApplication(exclude = { DataSourceAutoConfiguration. class})
  2. public  class  UnaSaasApplication {
  3.      public  static  void  main( String[] args) {
  4.          SpringApplication. run( UnaSaasApplication. class, args);
  5.     }
  6. }

最后,让我们看看整个项目的结构:

5. 实现租户数据源查询模块

我们将定义一个实体类存放租户数据源信息,它包含了租户名,数据库连接地址,用户名和密码等信息,其代码如下:

 

  
  1. @Data
  2. @Entity
  3. @Table(name =  "MASTER_TENANT")
  4. @NoArgsConstructor
  5. @AllArgsConstructor
  6. @Builder
  7. public class MasterTenant implements Serializable{
  8.      @Id
  9.      @Column(name= "ID")
  10.     private String id;
  11.      @Column(name =  "TENANT")
  12.      @NotEmpty(message =  "Tenant identifier must be provided")
  13.     private String tenant;
  14.      @Column(name =  "URL")
  15.      @Size(max =  256)
  16.      @NotEmpty(message =  "Tenant jdbc url must be provided")
  17.     private String url;
  18.      @Column(name =  "USERNAME")
  19.      @Size(min =  4,max =  30,message =  "db username length must between 4 and 30")
  20.      @NotEmpty(message =  "Tenant db username must be provided")
  21.     private String username;
  22.      @Column(name =  "PASSWORD")
  23.      @Size(min =  4,max =  30)
  24.      @NotEmpty(message =  "Tenant db password must be provided")
  25.     private String password;
  26.      @Version
  27.     private int version =  0;
  28. }

持久层我们将继承JpaRepository接口,快速实现对数据源的CURD操作,同时提供了一个通过租户名查找租户数据源的接口,其代码如下:


  
  1. package com.ramostear.una.saas.master.repository;
  2. import com.ramostear.una.saas.master.model.MasterTenant;
  3. import org.springframework. data.jpa.repository.JpaRepository;
  4. import org.springframework. data.jpa.repository.Query;
  5. import org.springframework. data.repository.query.Param;
  6. import org.springframework.stereotype.Repository;
  7. @Repository
  8. public  interface  MasterTenantRepository  extends  JpaRepository< MasterTenant,String>{
  9.      @Query("select p from MasterTenant p where p.tenant = :tenant")
  10.     MasterTenant findByTenant( @Param("tenant") String tenant);
  11. }

业务层提供通过租户名获取租户数据源信息的服务(其余的服务各位可自行添加):

 

  
  1. package com.ramostear.una.saas.master.service;
  2. import com.ramostear.una.saas.master.model.MasterTenant;
  3. public  interface  MasterTenantService {
  4.     
  5.     MasterTenant findByTenant(String tenant);
  6. }

最后,我们需要关注的重点是配置主数据源(Spring Boot需要为其提供一个默认的数据源)。在配置之前,我们需要获取配置项,可以通过@ConfigurationProperties("una.master.datasource")获取配置文件中的相关配置信息:

 

  
  1. @Getter
  2. @Setter
  3. @Configuration
  4. @ConfigurationProperties( "una.master.datasource")
  5. public  class  MasterDatabaseProperties {
  6.      private  String url;
  7.      private  String password;
  8.      private  String username;
  9.      private  String driverClassName;
  10.      private long connectionTimeout;
  11.      private int maxPoolSize;
  12.      private long idleTimeout;
  13.      private int minIdle;
  14.      private  String poolName;
  15.      @Override
  16.      public  String  toString( ){
  17.          StringBuilder builder =  new  StringBuilder();
  18.         builder. append( "MasterDatabaseProperties [ url=")
  19.                 . append(url)
  20.                 . append( ", username=")
  21.                 . append(username)
  22.                 . append( ", password=")
  23.                 . append(password)
  24.                 . append( ", driverClassName=")
  25.                 . append(driverClassName)
  26.                 . append( ", connectionTimeout=")
  27.                 . append(connectionTimeout)
  28.                 . append( ", maxPoolSize=")
  29.                 . append(maxPoolSize)
  30.                 . append( ", idleTimeout=")
  31.                 . append(idleTimeout)
  32.                 . append( ", minIdle=")
  33.                 . append(minIdle)
  34.                 . append( ", poolName=")
  35.                 . append(poolName)
  36.                 . append( "]");
  37.          return builder. toString();
  38.     }
  39. }

接下来是配置自定义的数据源,其源码如下:

 

  
  1. package com.ramostear.una.saas.master.config;
  2. import com.ramostear.una.saas.master.config.properties.MasterDatabaseProperties;
  3. import com.ramostear.una.saas.master.model.MasterTenant;
  4. import com.ramostear.una.saas.master.repository.MasterTenantRepository;
  5. import com.zaxxer.hikari.HikariDataSource;
  6. import lombok.extern.slf4j.Slf4j;
  7. import org.hibernate.cfg.Environment;
  8. import org.springframework.beans.factory. annotation.Autowired;
  9. import org.springframework.beans.factory. annotation.Qualifier;
  10. import org.springframework.context. annotation.Bean;
  11. import org.springframework.context. annotation.Configuration;
  12. import org.springframework.context. annotation.Primary;
  13. import org.springframework.dao. annotation.PersistenceExceptionTranslationPostProcessor;
  14. import org.springframework. data.jpa.repository.config.EnableJpaRepositories;
  15. import org.springframework.orm.jpa.JpaTransactionManager;
  16. import org.springframework.orm.jpa.JpaVendorAdapter;
  17. import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
  18. import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter;
  19. import org.springframework.transaction. annotation.EnableTransactionManagement;
  20. import javax.persistence.EntityManagerFactory;
  21. import javax.sql.DataSource;
  22. import java.util.Properties;
  23. @Configuration
  24. @EnableTransactionManagement
  25. @EnableJpaRepositories(basePackages = {"com.ramostear.una.saas.master.model","com.ramostear.una.saas.master.repository"},
  26.                        entityManagerFactoryRef = "masterEntityManagerFactory",
  27.                        transactionManagerRef = "masterTransactionManager")
  28. @Slf4j
  29. public  class  MasterDatabaseConfig {
  30.      @Autowired
  31.      private MasterDatabaseProperties masterDatabaseProperties;
  32.      @Bean(name = "masterDatasource")
  33.      public DataSource masterDatasource(){
  34.         log.info( "Setting up masterDatasource with :{}",masterDatabaseProperties.toString());
  35.         HikariDataSource datasource = new HikariDataSource();
  36.         datasource.setUsername(masterDatabaseProperties.getUsername());
  37.         datasource.setPassword(masterDatabaseProperties.getPassword());
  38.         datasource.setJdbcUrl(masterDatabaseProperties.getUrl());
  39.         datasource.setDriverClassName(masterDatabaseProperties.getDriverClassName());
  40.         datasource.setPoolName(masterDatabaseProperties.getPoolName());
  41.         datasource.setMaximumPoolSize(masterDatabaseProperties.getMaxPoolSize());
  42.         datasource.setMinimumIdle(masterDatabaseProperties.getMinIdle());
  43.         datasource.setConnectionTimeout(masterDatabaseProperties.getConnectionTimeout());
  44.         datasource.setIdleTimeout(masterDatabaseProperties.getIdleTimeout());
  45.         log.info( "Setup of masterDatasource successfully.");
  46.          return datasource;
  47.     }
  48.      @Primary
  49.      @Bean(name = "masterEntityManagerFactory")
  50.      public LocalContainerEntityManagerFactoryBean masterEntityManagerFactory(){
  51.         LocalContainerEntityManagerFactoryBean lb = new LocalContainerEntityManagerFactoryBean();
  52.         lb.setDataSource(masterDatasource());
  53.         lb.setPackagesToScan(
  54.            new String[]{MasterTenant. class.getPackage().getName(), MasterTenantRepository. class.getPackage().getName()}
  55.         );
  56.         
  57.         lb.setPersistenceUnitName( "master-database-persistence-unit");
  58.         
  59.         JpaVendorAdapter vendorAdapter = new HibernateJpaVendorAdapter();
  60.         lb.setJpaVendorAdapter(vendorAdapter);
  61.         
  62.         lb.setJpaProperties(hibernateProperties());
  63.         log.info( "Setup of masterEntityManagerFactory successfully.");
  64.          return lb;
  65.     }
  66.      @Bean(name = "masterTransactionManager")
  67.      public JpaTransactionManager masterTransactionManager( @Qualifier("masterEntityManagerFactory")EntityManagerFactory emf){
  68.         JpaTransactionManager transactionManager = new JpaTransactionManager();
  69.         transactionManager.setEntityManagerFactory(emf);
  70.         log.info( "Setup of masterTransactionManager successfully.");
  71.          return transactionManager;
  72.     }
  73.      @Bean
  74.      public PersistenceExceptionTranslationPostProcessor exceptionTranslationPostProcessor(){
  75.          return new PersistenceExceptionTranslationPostProcessor();
  76.     }
  77.      private Properties hibernateProperties(){
  78.         Properties properties = new Properties();
  79.         properties.put(Environment.DIALECT, "org.hibernate.dialect.MySQL5Dialect");
  80.         properties.put(Environment.SHOW_SQL, true);
  81.         properties.put(Environment.FORMAT_SQL, true);
  82.         properties.put(Environment.HBM2DDL_AUTO, "update");
  83.          return properties;
  84.     }
  85. }

在改配置类中,我们主要提供包扫描路径,实体管理工程,事务管理器和数据源配置参数的配置。

6. 实现租户业务模块

在此小节中,租户业务模块我们仅提供一个用户登录的场景来演示SaaS的功能。其实体层、业务层和持久化层根普通的Spring Boot Web项目没有什么区别,你甚至感觉不到它是一个SaaS应用程序的代码。

首先,创建一个用户实体User,其源码如下:

 

  
  1. @Entity
  2. @Table(name = "USER")
  3. @Data
  4. @NoArgsConstructor
  5. @AllArgsConstructor
  6. @Builder
  7. public  class  User  implements  Serializable {
  8.      private static  final long serialVersionUID = - 156890917814957041L;
  9.      @Id
  10.      @Column(name = "ID")
  11.      private String id;
  12.      @Column(name = "USERNAME")
  13.      private String username;
  14.      @Column(name = "PASSWORD")
  15.      @Size(min = 6,max = 22,message = "User password must be provided and length between 6 and 22.")
  16.      private String password;
  17.      @Column(name = "TENANT")
  18.      private String tenant;
  19. }

业务层提供了一个根据用户名检索用户信息的服务,它将调用持久层的方法根据用户名对租户的用户表进行检索,如果找到满足条件的用户记录,则返回用户信息,如果没有找到,则返回null;持久层和业务层的源码分别如下:

 

  
  1. @Repository
  2. public  interface  UserRepository  extends  JpaRepository< User, String>, JpaSpecificationExecutor< User>{
  3.      User  findByUsername( String username);
  4. }
  5. @Service( "userService")
  6. public  class  UserServiceImpl  implements  UserService{
  7.      @Autowired
  8.      private  UserRepository userRepository;
  9.      private  static  TwitterIdentifier identifier =  new  TwitterIdentifier();
  10.      @Override
  11.      public  void  save( User user) {
  12.         user. setId(identifier. generalIdentifier());
  13.         user. setTenant( TenantContextHolder. getTenant());
  14.         userRepository. save(user);
  15.     }
  16.      @Override
  17.      public  User  findById( String userId) {
  18.          Optional< User> optional = userRepository. findById(userId);
  19.          if(optional. isPresent()){
  20.              return optional. get();
  21.         } else{
  22.              return  null;
  23.         }
  24.     }
  25.      @Override
  26.      public  User  findByUsername( String username) {
  27.          System. out. println( TenantContextHolder. getTenant());
  28.          return userRepository. findByUsername(username);
  29.     }

在这里,我们采用了Twitter的雪花算法来实现了一个ID生成器。

7. 配置拦截器

我们需要提供一个租户信息的拦截器,用以获取租户标识符,其源代码和配置拦截器的源代码如下:

 

  
  1. @Slf4j
  2. public  class  TenantInterceptor  implements  HandlerInterceptor{
  3.      @Override
  4.      public  boolean  preHandle (HttpServletRequest request, HttpServletResponse response, Object handler)  throws Exception {
  5.          String tenant = request.getParameter( "tenant");
  6.          if(StringUtils.isBlank(tenant)){
  7.             response.sendRedirect( "/login.html");
  8.              return  false;
  9.         } else{
  10.             TenantContextHolder.setTenant(tenant);
  11.              return  true;
  12.         }
  13.     }
  14. }
  15. @Configuration
  16. public  class  InterceptorConfig  extends  WebMvcConfigurationSupport {
  17.      @Override
  18.      protected  void  addInterceptors (InterceptorRegistry registry) {
  19.         registry.addInterceptor( new  TenantInterceptor()).addPathPatterns( "/**").excludePathPatterns( "/login.html");
  20.          super.addInterceptors(registry);
  21.     }
  22. }

/login.html是系统的登录路径,我们需要将其排除在拦截器拦截的范围之外,否则我们永远无法进行登录

8. 维护租户标识信息

在这里,我们使用ThreadLocal来存放租户标识信息,为动态设置数据源提供数据支持,该类提供了设置租户标识、获取租户标识以及清除租户标识三个静态方法。其源码如下:

 

  
  1. public  class  TenantContextHolder {
  2.      private  static final ThreadLocal< String> CONTEXTnew  ThreadLocal<>();
  3.      public  static  void  setTenant( String tenant){
  4.          CONTEXT. set(tenant);
  5.     }
  6.      public  static  String  getTenant( ){
  7.          return  CONTEXT. get();
  8.     }
  9.      public  static  void  clear( ){
  10.          CONTEXT. remove();
  11.     }
  12. }

此类时实现动态数据源设置的关键

9. 动态数据源切换

要实现动态数据源切换,我们需要借助两个类来完成,CurrentTenantIdentifierResolver和AbstractDataSourceBasedMultiTenantConnectionProviderImpl。从它们的命名上就可以看出,一个负责解析租户标识,一个负责提供租户标识对应的租户数据源信息。

首先,我们需要实现CurrentTenantIdentifierResolver接口中的resolveCurrentTenantIdentifier()和validateExistingCurrentSessions()方法,完成租户标识的解析功能。实现类的源码如下:

 

  
  1. package com. ramostear. una. saas. tenant. config;
  2. import com. ramostear. una. saas. context. TenantContextHolder;
  3. import org. apache. commons. lang3. StringUtils;
  4. import org. hibernate. context. spi. CurrentTenantIdentifierResolver;
  5. public  class  CurrentTenantIdentifierResolverImpl  implements  CurrentTenantIdentifierResolver {
  6.     
  7.      private  static final  String DEFAULT_TENANT"tenant_1";
  8.     
  9.      @Override
  10.      public  String  resolveCurrentTenantIdentifier( ) {
  11.         
  12.          String tenant = TenantContextHolder. getTenant();
  13.         
  14.          return  StringUtils. isNotBlank(tenant)? tenant: DEFAULT_TENANT;
  15.     }
  16.      @Override
  17.      public  boolean  validateExistingCurrentSessions( ) {
  18.          return  true;
  19.     }
  20. }

此类的逻辑非常简单,就是从ThreadLocal中获取当前设置的租户标识符

有了租户标识符解析类之后,我们需要扩展租户数据源提供类,实现从数据库动态查询租户数据源信息,其源码如下:

 

  
  1. @Slf4j
  2. @Configuration
  3. public  class  DataSourceBasedMultiTenantConnectionProviderImpl  extends  AbstractDataSourceBasedMultiTenantConnectionProviderImpl{
  4.      private  static final long serialVersionUID = -7522287771874314380L;
  5.      @Autowired
  6.      private  MasterTenantRepository masterTenantRepository;
  7.      private  Map< String, DataSource> dataSources =  new  TreeMap<>();
  8.      @Override
  9.      protected  DataSource  selectAnyDataSource( ) {
  10.          if(dataSources. isEmpty()){
  11.              List< MasterTenant> tenants = masterTenantRepository. findAll();
  12.             tenants. forEach(masterTenant->{
  13.                 dataSources. put(masterTenant. getTenant(), DataSourceUtils. wrapperDataSource(masterTenant));
  14.             });
  15.         }
  16.          return dataSources. values(). iterator(). next();
  17.     }
  18.      @Override
  19.      protected  DataSource  selectDataSource( String tenant) {
  20.          if(!dataSources. containsKey(tenant)){
  21.              List< MasterTenant> tenants = masterTenantRepository. findAll();
  22.             tenants. forEach(masterTenant->{
  23.                 dataSources. put(masterTenant. getTenant(), DataSourceUtils. wrapperDataSource(masterTenant));
  24.             });
  25.         }
  26.          return dataSources. get(tenant);
  27.     }
  28. }

在该类中,通过查询租户数据源库,动态获得租户数据源信息,为租户业务模块的数据源配置提供数据数据支持。

最后,我们还需要提供租户业务模块数据源配置,这是整个项目核心的地方,其代码如下:

 

  
  1. @Slf4j
  2. @Configuration
  3. @EnableTransactionManagement
  4. @ComponentScan(basePackages = {
  5.          "com.ramostear.una.saas.tenant.model",
  6.          "com.ramostear.una.saas.tenant.repository"
  7. })
  8. @EnableJpaRepositories(basePackages = {
  9.          "com.ramostear.una.saas.tenant.repository",
  10.          "com.ramostear.una.saas.tenant.service"
  11. },entityManagerFactoryRef =  "tenantEntityManagerFactory"
  12. ,transactionManagerRef =  "tenantTransactionManager")
  13. public  class  TenantDataSourceConfig {
  14.      @Bean( "jpaVendorAdapter")
  15.      public  JpaVendorAdapter  jpaVendorAdapter( ){
  16.          return  new  HibernateJpaVendorAdapter();
  17.     }
  18.      @Bean(name =  "tenantTransactionManager")
  19.      public  JpaTransactionManager  transactionManager( EntityManagerFactory entityManagerFactory){
  20.          JpaTransactionManager transactionManager =  new  JpaTransactionManager();
  21.         transactionManager. setEntityManagerFactory(entityManagerFactory);
  22.          return transactionManager;
  23.     }
  24.      @Bean(name =  "datasourceBasedMultiTenantConnectionProvider")
  25.      @ConditionalOnBean(name =  "masterEntityManagerFactory")
  26.      public  MultiTenantConnectionProvider  multiTenantConnectionProvider( ){
  27.          return  new  DataSourceBasedMultiTenantConnectionProviderImpl();
  28.     }
  29.      @Bean(name =  "currentTenantIdentifierResolver")
  30.      public  CurrentTenantIdentifierResolver  currentTenantIdentifierResolver( ){
  31.          return  new  CurrentTenantIdentifierResolverImpl();
  32.     }
  33.      @Bean(name =  "tenantEntityManagerFactory")
  34.      @ConditionalOnBean(name =  "datasourceBasedMultiTenantConnectionProvider")
  35.      public  LocalContainerEntityManagerFactoryBean  entityManagerFactory(
  36.              @Qualifier( "datasourceBasedMultiTenantConnectionProvider")MultiTenantConnectionProvider connectionProvider,
  37.              @Qualifier( "currentTenantIdentifierResolver")CurrentTenantIdentifierResolver tenantIdentifierResolver
  38.     ){
  39.          LocalContainerEntityManagerFactoryBean localBean =  new  LocalContainerEntityManagerFactoryBean();
  40.         localBean. setPackagesToScan(
  41.                  new  String[]{
  42.                          User. class. getPackage(). getName(),
  43.                          UserRepository. class. getPackage(). getName(),
  44.                          UserService. class. getPackage(). getName()
  45.                 }
  46.         );
  47.         localBean. setJpaVendorAdapter( jpaVendorAdapter());
  48.         localBean. setPersistenceUnitName( "tenant-database-persistence-unit");
  49.          Map< String, Object> properties =  new  HashMap<>();
  50.         properties. put( Environment. MULTI_TENANT, MultiTenancyStrategy. SCHEMA);
  51.         properties. put( Environment. MULTI_TENANT_CONNECTION_PROVIDER,connectionProvider);
  52.         properties. put( Environment. MULTI_TENANT_IDENTIFIER_RESOLVER,tenantIdentifierResolver);
  53.         properties. put( Environment. DIALECT, "org.hibernate.dialect.MySQL5Dialect");
  54.         properties. put( Environment. SHOW_SQL, true);
  55.         properties. put( Environment. FORMAT_SQL, true);
  56.         properties. put( Environment. HBM2DDL_AUTO, "update");
  57.         localBean. setJpaPropertyMap(properties);
  58.          return localBean;
  59.     }
  60. }

在改配置文件中,大部分内容与主数据源的配置相同,唯一的区别是租户标识解析器与租户数据源补给源的设置,它将告诉Hibernate在执行数据库操作命令前,应该设置什么样的数据库连接信息,以及用户名和密码等信息。

10. 应用测试

最后,我们通过一个简单的登录案例来测试本次课程中的SaaS应用程序,为此,需要提供一个Controller用于处理用户登录逻辑。在本案例中,没有严格的对用户密码进行加密,而是使用明文进行比对,也没有提供任何的权限认证框架,知识单纯的验证SaaS的基本特性是否具备。登录控制器代码如下:

 

  
  1. @Controller
  2. public  class  LoginController {
  3.      @Autowired
  4.      private  UserService userService;
  5.      @GetMapping( "/login.html")
  6.      public  String  login( ){
  7.          return  "/login";
  8.     }
  9.      @PostMapping( "/login")
  10.      public  String  login( @RequestParam(name = "username"String username, @RequestParam(name = "password")String password, ModelMap model){
  11.          System. out. println( "tenant:"+ TenantContextHolder. getTenant());
  12.          User user = userService. findByUsername(username);
  13.          if(user !=  null){
  14.              if(user. getPassword(). equals(password)){
  15.                 model. put( "user",user);
  16.                  return  "/index";
  17.             } else{
  18.                  return  "/login";
  19.             }
  20.         } else{
  21.              return  "/login";
  22.         }
  23.     }
  24. }

在启动项目之前,我们需要为主数据源创建对应的数据库和数据表,用于存放租户数据源信息,同时还需要提供一个租户业务模块数据库和数据表,用来存放租户业务数据。一切准备就绪后,启动项目,在浏览器中输入:http://localhost:8080/login.html

在登录窗口中输入对应的租户名,用户名和密码,测试是否能够正常到达主页。可以多增加几个租户和用户,测试用户是否正常切换到对应的租户下。

总结

在这里,我分享了使用Spring Boot+JPA快速实现多租户应用程序的方法,此方法只涉及了实现SaaS应用平台的最核心技术手段,并不是一个完整可用的项目代码,如用户的认证、授权等并未出现在本文中。额外的业务模块感兴趣的朋友可以在此设计基础上自行扩展.


转载:https://blog.csdn.net/z_ssyy/article/details/128484045
查看评论
* 以上用户言论只代表其个人观点,不代表本网站的观点或立场