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

云计算 SaaS
笔者从2014年开始接触SaaS(Software as a Service),即多租户(或多承租)软件应用平台;并一直从事相关领域的架构设计及研发工作。

[[326138]]

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.  
  7.         <dependency> 
  8.             <groupId>org.springframework.boot</groupId> 
  9.             <artifactId>spring-boot-devtools</artifactId> 
  10.             <scope>runtime</scope> 
  11.         </dependency> 
  12.         <dependency> 
  13.             <groupId>org.projectlombok</groupId> 
  14.             <artifactId>lombok</artifactId> 
  15.             <optional>true</optional> 
  16.         </dependency> 
  17.         <dependency> 
  18.             <groupId>org.springframework.boot</groupId> 
  19.             <artifactId>spring-boot-starter-test</artifactId> 
  20.             <scope>test</scope> 
  21.         </dependency> 
  22.         <dependency> 
  23.             <groupId>org.springframework.boot</groupId> 
  24.             <artifactId>spring-boot-starter-data-jpa</artifactId> 
  25.         </dependency> 
  26.         <dependency> 
  27.             <groupId>org.springframework.boot</groupId> 
  28.             <artifactId>spring-boot-starter-web</artifactId> 
  29.         </dependency> 
  30.         <dependency> 
  31.             <groupId>org.springframework.boot</groupId> 
  32.             <artifactId>spring-boot-configuration-processor</artifactId> 
  33.         </dependency> 
  34.         <dependency> 
  35.             <groupId>mysql</groupId> 
  36.             <artifactId>mysql-connector-java</artifactId> 
  37.             <version>5.1.47</version> 
  38.         </dependency> 
  39.         <dependency> 
  40.             <groupId>org.springframework.boot</groupId> 
  41.             <artifactId>spring-boot-starter-freemarker</artifactId> 
  42.         </dependency> 
  43.         <dependency> 
  44.             <groupId>org.apache.commons</groupId> 
  45.             <artifactId>commons-lang3</artifactId> 
  46.         </dependency> 
  47.     </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.       url:  jdbc:mysql://localhost:3306/master_tenant?useSSL=false 
  24.       username: root 
  25.       password: root 
  26.       driverClassName:  com.mysql.jdbc.Driver 
  27.       maxPoolSize:  10 
  28.       idleTimeout:  300000 
  29.       minIdle:  10 
  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.  
  4.     public static void main(String[] args) { 
  5.         SpringApplication.run(UnaSaasApplication.class, args); 
  6.     } 
  7.  

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

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

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

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

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

  1. package com.ramostear.una.saas.master.repository; 
  2.  
  3. import com.ramostear.una.saas.master.model.MasterTenant; 
  4. import org.springframework.data.jpa.repository.JpaRepository; 
  5. import org.springframework.data.jpa.repository.Query; 
  6. import org.springframework.data.repository.query.Param; 
  7. import org.springframework.stereotype.Repository; 
  8.  
  9. /** 
  10.  * @author : Created by Tan Chaohong (alias:ramostear) 
  11.  * @create-time 2019/5/25 0025-8:22 
  12.  * @modify by : 
  13.  * @since: 
  14.  */ 
  15. @Repository 
  16. public interface MasterTenantRepository extends JpaRepository<MasterTenant,String>{ 
  17.  
  18.     @Query("select p from MasterTenant p where p.tenant = :tenant"
  19.     MasterTenant findByTenant(@Param("tenant") String tenant); 

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

  1. package com.ramostear.una.saas.master.service; 
  2.  
  3. import com.ramostear.una.saas.master.model.MasterTenant; 
  4.  
  5. /** 
  6.  * @author : Created by Tan Chaohong (alias:ramostear) 
  7.  * @create-time 2019/5/25 0025-8:26 
  8.  * @modify by : 
  9.  * @since: 
  10.  */ 
  11.  
  12. public interface MasterTenantService { 
  13.     /** 
  14.      * Using custom tenant name query 
  15.      * @param tenant    tenant name 
  16.      * @return          masterTenant 
  17.      */ 
  18.     MasterTenant findByTenant(String tenant); 

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

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

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

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

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

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.  
  10.     @Id 
  11.     @Column(name = "ID"
  12.     private String id; 
  13.  
  14.     @Column(name = "USERNAME"
  15.     private String username; 
  16.  
  17.     @Column(name = "PASSWORD"
  18.     @Size(min = 6,max = 22,message = "User password must be provided and length between 6 and 22."
  19.     private String password
  20.  
  21.     @Column(name = "TENANT"
  22.     private String tenant; 

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

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

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

7. 配置拦截器

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

  1. /** 
  2.  * @author : Created by Tan Chaohong (alias:ramostear) 
  3.  * @create-time 2019/5/26 0026-23:17 
  4.  * @modify by : 
  5.  * @since: 
  6.  */ 
  7. @Slf4j 
  8. public class TenantInterceptor implements HandlerInterceptor{ 
  9.  
  10.     @Override 
  11.     public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { 
  12.         String tenant = request.getParameter("tenant"); 
  13.         if(StringUtils.isBlank(tenant)){ 
  14.             response.sendRedirect("/login.html"); 
  15.             return false
  16.         }else
  17.             TenantContextHolder.setTenant(tenant); 
  18.             return true
  19.         } 
  20.     } 
  21. @Configuration 
  22. public class InterceptorConfig extends WebMvcConfigurationSupport { 
  23.  
  24.     @Override 
  25.     protected void addInterceptors(InterceptorRegistry registry) { 
  26.         registry.addInterceptor(new TenantInterceptor()).addPathPatterns("/**").excludePathPatterns("/login.html"); 
  27.         super.addInterceptors(registry); 
  28.     } 

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

8. 维护租户标识信息

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

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

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

9. 动态数据源切换

要实现动态数据源切换,我们需要借助两个类来完成,CurrentTenantIdentifierResolver和AbstractDataSourceBasedMultiTenantConnectionProviderImpl。从它们的命名上就可以看出,一个负责解析租户标识,一个负责提供租户标识对应的租户数据源信息。 首先,我们需要实现CurrentTenantIdentifierResolver接口中的resolveCurrentTenantIdentifier()和validateExistingCurrentSessions()方法,完成租户标识的解析功能。实现类的源码如下:

  1. package com.ramostear.una.saas.tenant.config; 
  2.  
  3. import com.ramostear.una.saas.context.TenantContextHolder; 
  4. import org.apache.commons.lang3.StringUtils; 
  5. import org.hibernate.context.spi.CurrentTenantIdentifierResolver; 
  6.  
  7. /** 
  8.  * @author : Created by Tan Chaohong (alias:ramostear) 
  9.  * @create-time 2019/5/26 0026-22:38 
  10.  * @modify by : 
  11.  * @since: 
  12.  */ 
  13.  public class CurrentTenantIdentifierResolverImpl implements CurrentTenantIdentifierResolver { 
  14.  
  15.     /** 
  16.      * 默认的租户ID 
  17.      */ 
  18.     private static final String DEFAULT_TENANT = "tenant_1"
  19.  
  20.     /** 
  21.      * 解析当前租户的ID 
  22.      * @return 
  23.      */ 
  24.     @Override 
  25.     public String resolveCurrentTenantIdentifier() { 
  26.         //通过租户上下文获取租户ID,此ID是用户登录时在header中进行设置的 
  27.         String tenant = TenantContextHolder.getTenant(); 
  28.         //如果上下文中没有找到该租户ID,则使用默认的租户ID,或者直接报异常信息 
  29.         return StringUtils.isNotBlank(tenant)?tenant:DEFAULT_TENANT; 
  30.     } 
  31.  
  32.     @Override 
  33.     public boolean validateExistingCurrentSessions() { 
  34.         return true
  35.     } 

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

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

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

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

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

  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.  
  15.     @Bean("jpaVendorAdapter"
  16.     public JpaVendorAdapter jpaVendorAdapter(){ 
  17.         return new HibernateJpaVendorAdapter(); 
  18.     } 
  19.      @Bean(name = "tenantTransactionManager"
  20.     public JpaTransactionManager transactionManager(EntityManagerFactory entityManagerFactory){ 
  21.         JpaTransactionManager transactionManager = new JpaTransactionManager(); 
  22.         transactionManager.setEntityManagerFactory(entityManagerFactory); 
  23.         return transactionManager; 
  24.     } 
  25.  
  26.     @Bean(name = "datasourceBasedMultiTenantConnectionProvider"
  27.     @ConditionalOnBean(name = "masterEntityManagerFactory"
  28.     public MultiTenantConnectionProvider multiTenantConnectionProvider(){ 
  29.         return new DataSourceBasedMultiTenantConnectionProviderImpl(); 
  30.     } 
  31.      @Bean(name = "currentTenantIdentifierResolver"
  32.     public CurrentTenantIdentifierResolver currentTenantIdentifierResolver(){ 
  33.         return new CurrentTenantIdentifierResolverImpl(); 
  34.     } 
  35.  
  36.     @Bean(name = "tenantEntityManagerFactory"
  37.     @ConditionalOnBean(name = "datasourceBasedMultiTenantConnectionProvider"
  38.     public LocalContainerEntityManagerFactoryBean entityManagerFactory( 
  39.             @Qualifier("datasourceBasedMultiTenantConnectionProvider")MultiTenantConnectionProvider connectionProvider, 
  40.             @Qualifier("currentTenantIdentifierResolver")CurrentTenantIdentifierResolver tenantIdentifierResolver 
  41.     ){ 
  42.         LocalContainerEntityManagerFactoryBean localBean = new LocalContainerEntityManagerFactoryBean(); 
  43.         localBean.setPackagesToScan( 
  44.                 new String[]{ 
  45.                         User.class.getPackage().getName(), 
  46.                         UserRepository.class.getPackage().getName(), 
  47.                         UserService.class.getPackage().getName() 
  48.  
  49.                 } 
  50.         ); 
  51.         localBean.setJpaVendorAdapter(jpaVendorAdapter()); 
  52.         localBean.setPersistenceUnitName("tenant-database-persistence-unit"); 
  53.         Map<String,Object> properties = new HashMap<>(); 
  54.         properties.put(Environment.MULTI_TENANT, MultiTenancyStrategy.SCHEMA); 
  55.         properties.put(Environment.MULTI_TENANT_CONNECTION_PROVIDER,connectionProvider); 
  56.         properties.put(Environment.MULTI_TENANT_IDENTIFIER_RESOLVER,tenantIdentifierResolver); 
  57.         properties.put(Environment.DIALECT,"org.hibernate.dialect.MySQL5Dialect"); 
  58.         properties.put(Environment.SHOW_SQL,true); 
  59.         properties.put(Environment.FORMAT_SQL,true); 
  60.         properties.put(Environment.HBM2DDL_AUTO,"update"); 
  61.         localBean.setJpaPropertyMap(properties); 
  62.         return localBean; 
  63.     } 

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

10. 应用测试

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

  1. /** 
  2.  * @author : Created by Tan Chaohong (alias:ramostear) 
  3.  * @create-time 2019/5/27 0027-0:18 
  4.  * @modify by : 
  5.  * @since: 
  6.  */ 
  7. @Controller 
  8. public class LoginController { 
  9.  
  10.     @Autowired 
  11.     private UserService userService; 
  12.  
  13.     @GetMapping("/login.html"
  14.     public String login(){ 
  15.         return "/login"
  16.     } 
  17.  
  18.     @PostMapping("/login"
  19.     public String login(@RequestParam(name = "username") String username, @RequestParam(name = "password")String password, ModelMap model){ 
  20.         System.out.println("tenant:"+TenantContextHolder.getTenant()); 
  21.         User user = userService.findByUsername(username); 
  22.         if(user != null){ 
  23.             if(user.getPassword().equals(password)){ 
  24.                 model.put("user",user); 
  25.                 return "/index"
  26.             }else
  27.                 return "/login"
  28.             } 
  29.         }else
  30.             return "/login"
  31.         } 
  32.     } 

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

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

总结

在这里,我分享了使用Spring Boot+JPA快速实现多租户应用程序的方法,此方法只涉及了实现SaaS应用平台的最核心技术手段,并不是一个完整可用的项目代码,如用户的认证、授权等并未出现在本文中。

责任编辑:武晓燕 来源: 博客园
相关推荐

2023-06-07 13:50:00

SaaS多租户系统

2024-03-08 10:50:44

Spring技术应用程序

2023-11-06 08:26:11

Spring微服务架构

2023-12-14 12:26:16

SaaS数据库方案

2020-09-15 07:00:00

SaaS架构架构

2015-08-12 15:46:02

SaaS多租户数据存储

2015-04-02 11:04:27

云应用SaaSOFBIZ

2023-12-05 07:26:29

指标中台大数据

2009-06-22 17:32:25

J2EE平台

2022-05-07 14:31:46

物联网

2023-06-26 08:42:18

Spring类型Resource

2021-12-01 09:00:00

公共云云计算服务器

2011-07-11 09:06:22

SAAS平台独立Schema

2023-06-14 08:49:22

PodKubernetes

2009-06-26 16:01:39

EJB组织开发EJB容器EJB

2017-03-08 10:06:11

Java技术点注解

2016-11-15 14:33:05

Flink大数据

2022-05-09 08:21:29

Spring微服务Sentinel

2009-06-15 17:54:50

Java核心技术

2020-10-16 08:57:51

云平台之多租户的实践
点赞
收藏

51CTO技术栈公众号