1. Java 学习社区首页
  2. Java
  3. Spring Boot

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

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. 维护、识别和路由租户数据源

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

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

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

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

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

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

我们都知道,在启动Spring
Boot应用程序之前,就需要为其提供有关数据源的配置信息(有使用到数据库的情况下),按照一开始的需求,有N个客户需要使用我们的应用程序,我们就需要提前配置好N个数据源(多数据源),如果N<50,我认为我还能忍受,如果更多,这样显然是无法接受的。

为了解决这一问题,我们需要借助Hibernate 5 提供的动态数据源特性,让我们的应用程序具备动态配置客户端数据源的能力。

简单来说,当用户请求系统资源时,我们将用户提供的租户信息(tenantId)存放在ThreadLoacal中,紧接着获取TheadLocal中的租户信息,并根据此信息查询单独的租户库,获取当前租户的数据配置信息,然后借助Hibernate动态配置数据源的能力,为当前请求设置数据源,最后之前用户的请求。

这样我们就只需要在应用程序中维护一份数据源配置信息(租户数据库配置库),其余的数据源动态查询配置。接下来,我们将快速的演示这一功能。

4. 项目构建

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

    <dependencies>  
            <dependency>  
                <groupId>org.springframework.boot</groupId>  
                <artifactId>spring-boot-starter</artifactId>  
            </dependency>  

            <dependency>  
                <groupId>org.springframework.boot</groupId>  
                <artifactId>spring-boot-devtools</artifactId>  
                <scope>runtime</scope>  
            </dependency>  
            <dependency>  
                <groupId>org.projectlombok</groupId>  
                <artifactId>lombok</artifactId>  
                <optional>true</optional>  
            </dependency>  
            <dependency>  
                <groupId>org.springframework.boot</groupId>  
                <artifactId>spring-boot-starter-test</artifactId>  
                <scope>test</scope>  
            </dependency>  
            <dependency>  
                <groupId>org.springframework.boot</groupId>  
                <artifactId>spring-boot-starter-data-jpa</artifactId>  
            </dependency>  
            <dependency>  
                <groupId>org.springframework.boot</groupId>  
                <artifactId>spring-boot-starter-web</artifactId>  
            </dependency>  
            <dependency>  
                <groupId>org.springframework.boot</groupId>  
                <artifactId>spring-boot-configuration-processor</artifactId>  
            </dependency>  
            <dependency>  
                <groupId>mysql</groupId>  
                <artifactId>mysql-connector-java</artifactId>  
                <version>5.1.47</version>  
            </dependency>  
            <dependency>  
                <groupId>org.springframework.boot</groupId>  
                <artifactId>spring-boot-starter-freemarker</artifactId>  
            </dependency>  
            <dependency>  
                <groupId>org.apache.commons</groupId>  
                <artifactId>commons-lang3</artifactId>  
            </dependency>  
        </dependencies>  

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


    spring:  
      freemarker:  
        cache: false  
        template-loader-path:  
        - classpath:/templates/  
        prefix:  
        suffix: .html  
      resources:  
        static-locations:  
        - classpath:/static/  
      devtools:  
        restart:  
          enabled: true  
      jpa:  
        database: mysql  
        show-sql: true  
        generate-ddl: false  
        hibernate:  
          ddl-auto: none  
    una:  
      master:  
        datasource:  
          url:  jdbc:mysql://localhost:3306/master_tenant?useSSL=false  
          username: root  
          password: root  
          driverClassName:  com.mysql.jdbc.Driver  
          maxPoolSize:  10  
          idleTimeout:  300000  
          minIdle:  10  
          poolName: master-database-connection-pool  
    logging:  
      level:  
        root: warn  
        org:  
          springframework:  
            web:  debug  
          hibernate: debug  

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

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

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


    @SpringBootApplication(exclude = {DataSourceAutoConfiguration.class})  
    public class UnaSaasApplication {  

        public static void main(String[] args) {  
            SpringApplication.run(UnaSaasApplication.class, args);  
        }  

    }  

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

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

#

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

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


    @Data  
    @Entity  
    @Table(name = "MASTER_TENANT")  
    @NoArgsConstructor  
    @AllArgsConstructor  
    @Builder  
    public class MasterTenant implements Serializable{  

        @Id  
        @Column(name="ID")  
        private String id;  

        @Column(name = "TENANT")  
        @NotEmpty(message = "Tenant identifier must be provided")  
        private String tenant;  

        @Column(name = "URL")  
        @Size(max = 256)  
        @NotEmpty(message = "Tenant jdbc url must be provided")  
        private String url;  

        @Column(name = "USERNAME")  
        @Size(min = 4,max = 30,message = "db username length must between 4 and 30")  
        @NotEmpty(message = "Tenant db username must be provided")  
        private String username;  

        @Column(name = "PASSWORD")  
        @Size(min = 4,max = 30)  
        @NotEmpty(message = "Tenant db password must be provided")  
        private String password;  

        @Version  
        private int version = 0;  
    }  

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


    package com.ramostear.una.saas.master.repository;  

    import com.ramostear.una.saas.master.model.MasterTenant;  
    import org.springframework.data.jpa.repository.JpaRepository;  
    import org.springframework.data.jpa.repository.Query;  
    import org.springframework.data.repository.query.Param;  
    import org.springframework.stereotype.Repository;  

    /**  
     * @author : Created by Tan Chaohong (alias:ramostear)  
     * @create-time 2019/5/25 0025-8:22  
     * @modify by :  
     * @since:  
     */  
    @Repository  
    public interface MasterTenantRepository extends JpaRepository<MasterTenant,String>{  

        @Query("select p from MasterTenant p where p.tenant = :tenant")  
        MasterTenant findByTenant(@Param("tenant") String tenant);  
    }  

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


    package com.ramostear.una.saas.master.service;  

    import com.ramostear.una.saas.master.model.MasterTenant;  

    /**  
     * @author : Created by Tan Chaohong (alias:ramostear)  
     * @create-time 2019/5/25 0025-8:26  
     * @modify by :  
     * @since:  
     */  

    public interface MasterTenantService {  
        /**  
         * Using custom tenant name query  
         * @param tenant    tenant name  
         * @return          masterTenant  
         */  
        MasterTenant findByTenant(String tenant);  
    }  

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


    @Getter  
    @Setter  
    @Configuration  
    @ConfigurationProperties("una.master.datasource")  
    public class MasterDatabaseProperties {  

        private String url;  

        private String password;  

        private String username;  

        private String driverClassName;  

        private long connectionTimeout;  

        private int maxPoolSize;  

        private long idleTimeout;  

        private int minIdle;  

        private String poolName;  

        @Override  
        public String toString(){  
            StringBuilder builder = new StringBuilder();  
            builder.append("MasterDatabaseProperties [ url=")  
                    .append(url)  
                    .append(", username=")  
                    .append(username)  
                    .append(", password=")  
                    .append(password)  
                    .append(", driverClassName=")  
                    .append(driverClassName)  
                    .append(", connectionTimeout=")  
                    .append(connectionTimeout)  
                    .append(", maxPoolSize=")  
                    .append(maxPoolSize)  
                    .append(", idleTimeout=")  
                    .append(idleTimeout)  
                    .append(", minIdle=")  
                    .append(minIdle)  
                    .append(", poolName=")  
                    .append(poolName)  
                    .append("]");  
            return builder.toString();  
        }  
    }  

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


    package com.ramostear.una.saas.master.config;  

    import com.ramostear.una.saas.master.config.properties.MasterDatabaseProperties;  
    import com.ramostear.una.saas.master.model.MasterTenant;  
    import com.ramostear.una.saas.master.repository.MasterTenantRepository;  
    import com.zaxxer.hikari.HikariDataSource;  
    import lombok.extern.slf4j.Slf4j;  
    import org.hibernate.cfg.Environment;  
    import org.springframework.beans.factory.annotation.Autowired;  
    import org.springframework.beans.factory.annotation.Qualifier;  
    import org.springframework.context.annotation.Bean;  
    import org.springframework.context.annotation.Configuration;  
    import org.springframework.context.annotation.Primary;  
    import org.springframework.dao.annotation.PersistenceExceptionTranslationPostProcessor;  
    import org.springframework.data.jpa.repository.config.EnableJpaRepositories;  
    import org.springframework.orm.jpa.JpaTransactionManager;  
    import org.springframework.orm.jpa.JpaVendorAdapter;  
    import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;  
    import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter;  
    import org.springframework.transaction.annotation.EnableTransactionManagement;  

    import javax.persistence.EntityManagerFactory;  
    import javax.sql.DataSource;  
    import java.util.Properties;  

    /**  
     * @author : Created by Tan Chaohong (alias:ramostear)  
     * @create-time 2019/5/25 0025-8:31  
     * @modify by :  
     * @since:  
     */  
    @Configuration  
    @EnableTransactionManagement  
    @EnableJpaRepositories(basePackages = {"com.ramostear.una.saas.master.model","com.ramostear.una.saas.master.repository"},  
                           entityManagerFactoryRef = "masterEntityManagerFactory",  
                           transactionManagerRef = "masterTransactionManager")  
    @Slf4j  
    public class MasterDatabaseConfig {  

        @Autowired  
        private MasterDatabaseProperties masterDatabaseProperties;  

        @Bean(name = "masterDatasource")  
        public DataSource masterDatasource(){  
            log.info("Setting up masterDatasource with :{}",masterDatabaseProperties.toString());  
            HikariDataSource datasource = new HikariDataSource();  
            datasource.setUsername(masterDatabaseProperties.getUsername());  
            datasource.setPassword(masterDatabaseProperties.getPassword());  
            datasource.setJdbcUrl(masterDatabaseProperties.getUrl());  
            datasource.setDriverClassName(masterDatabaseProperties.getDriverClassName());  
            datasource.setPoolName(masterDatabaseProperties.getPoolName());  
            datasource.setMaximumPoolSize(masterDatabaseProperties.getMaxPoolSize());  
            datasource.setMinimumIdle(masterDatabaseProperties.getMinIdle());  
            datasource.setConnectionTimeout(masterDatabaseProperties.getConnectionTimeout());  
            datasource.setIdleTimeout(masterDatabaseProperties.getIdleTimeout());  
            log.info("Setup of masterDatasource successfully.");  
            return datasource;  
        }  
        @Primary  
        @Bean(name = "masterEntityManagerFactory")  
        public LocalContainerEntityManagerFactoryBean masterEntityManagerFactory(){  
            LocalContainerEntityManagerFactoryBean lb = new LocalContainerEntityManagerFactoryBean();  
            lb.setDataSource(masterDatasource());  
            lb.setPackagesToScan(  
               new String[]{MasterTenant.class.getPackage().getName(), MasterTenantRepository.class.getPackage().getName()}  
            );  

            //Setting a name for the persistence unit as Spring sets it as 'default' if not defined.  
            lb.setPersistenceUnitName("master-database-persistence-unit");  

            //Setting Hibernate as the JPA provider.  
            JpaVendorAdapter vendorAdapter = new HibernateJpaVendorAdapter();  
            lb.setJpaVendorAdapter(vendorAdapter);  

            //Setting the hibernate properties  
            lb.setJpaProperties(hibernateProperties());  

            log.info("Setup of masterEntityManagerFactory successfully.");  
            return lb;  
        }  
        @Bean(name = "masterTransactionManager")  
        public JpaTransactionManager masterTransactionManager(@Qualifier("masterEntityManagerFactory")EntityManagerFactory emf){  
            JpaTransactionManager transactionManager = new JpaTransactionManager();  
            transactionManager.setEntityManagerFactory(emf);  
            log.info("Setup of masterTransactionManager successfully.");  
            return transactionManager;  
        }  

        @Bean  
        public PersistenceExceptionTranslationPostProcessor exceptionTranslationPostProcessor(){  
            return new PersistenceExceptionTranslationPostProcessor();  
        }  
        private Properties hibernateProperties(){  
            Properties properties = new Properties();  
            properties.put(Environment.DIALECT,"org.hibernate.dialect.MySQL5Dialect");  
            properties.put(Environment.SHOW_SQL,true);  
            properties.put(Environment.FORMAT_SQL,true);  
            properties.put(Environment.HBM2DDL_AUTO,"update");  
            return properties;  
        }  
    }  

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

6. 实现租户业务模块

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

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


    @Entity  
    @Table(name = "USER")  
    @Data  
    @NoArgsConstructor  
    @AllArgsConstructor  
    @Builder  
    public class User implements Serializable {  
        private static final long serialVersionUID = -156890917814957041L;  

        @Id  
        @Column(name = "ID")  
        private String id;  

        @Column(name = "USERNAME")  
        private String username;  

        @Column(name = "PASSWORD")  
        @Size(min = 6,max = 22,message = "User password must be provided and length between 6 and 22.")  
        private String password;  

        @Column(name = "TENANT")  
        private String tenant;  
    }  

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


    @Repository  
    public interface UserRepository extends JpaRepository<User,String>,JpaSpecificationExecutor<User>{  

        User findByUsername(String username);  
    }  
    @Service("userService")  
    public class UserServiceImpl implements UserService{  

        @Autowired  
        private UserRepository userRepository;  

        private static TwitterIdentifier identifier = new TwitterIdentifier();  

        @Override  
        public void save(User user) {  
            user.setId(identifier.generalIdentifier());  
            user.setTenant(TenantContextHolder.getTenant());  
            userRepository.save(user);  
        }  

        @Override  
        public User findById(String userId) {  
            Optional<User> optional = userRepository.findById(userId);  
            if(optional.isPresent()){  
                return optional.get();  
            }else{  
                return null;  
            }  
        }  

        @Override  
        public User findByUsername(String username) {  
            System.out.println(TenantContextHolder.getTenant());  
            return userRepository.findByUsername(username);  
        }  

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

#

7. 配置拦截器

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


    /**  
     * @author : Created by Tan Chaohong (alias:ramostear)  
     * @create-time 2019/5/26 0026-23:17  
     * @modify by :  
     * @since:  
     */  
    @Slf4j  
    public class TenantInterceptor implements HandlerInterceptor{  

        @Override  
        public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {  
            String tenant = request.getParameter("tenant");  
            if(StringUtils.isBlank(tenant)){  
                response.sendRedirect("/login.html");  
                return false;  
            }else{  
                TenantContextHolder.setTenant(tenant);  
                return true;  
            }  
        }  
    }  
    @Configuration  
    public class InterceptorConfig extends WebMvcConfigurationSupport {  

        @Override  
        protected void addInterceptors(InterceptorRegistry registry) {  
            registry.addInterceptor(new TenantInterceptor()).addPathPatterns("/**").excludePathPatterns("/login.html");  
            super.addInterceptors(registry);  
        }  
    }  

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

#

8. 维护租户标识信息

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


    public class TenantContextHolder {  

        private static final ThreadLocal<String> CONTEXT = new ThreadLocal<>();  

        public static void setTenant(String tenant){  
            CONTEXT.set(tenant);  
        }  

        public static String getTenant(){  
            return CONTEXT.get();  
        }  

        public static void clear(){  
            CONTEXT.remove();  
        }  
    }  

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

#

9. 动态数据源切换

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

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

实现类的源码如下:


    package com.ramostear.una.saas.tenant.config;  

    import com.ramostear.una.saas.context.TenantContextHolder;  
    import org.apache.commons.lang3.StringUtils;  
    import org.hibernate.context.spi.CurrentTenantIdentifierResolver;  

    /**  
     * @author : Created by Tan Chaohong (alias:ramostear)  
     * @create-time 2019/5/26 0026-22:38  
     * @modify by :  
     * @since:  
     */  
     public class CurrentTenantIdentifierResolverImpl implements CurrentTenantIdentifierResolver {  

        /**  
         * 默认的租户ID  
         */  
        private static final String DEFAULT_TENANT = "tenant_1";  

        /**  
         * 解析当前租户的ID  
         * @return  
         */  
        @Override  
        public String resolveCurrentTenantIdentifier() {  
            //通过租户上下文获取租户ID,此ID是用户登录时在header中进行设置的  
            String tenant = TenantContextHolder.getTenant();  
            //如果上下文中没有找到该租户ID,则使用默认的租户ID,或者直接报异常信息  
            return StringUtils.isNotBlank(tenant)?tenant:DEFAULT_TENANT;  
        }  

        @Override  
        public boolean validateExistingCurrentSessions() {  
            return true;  
        }  
    }  

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

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


    @Slf4j  
    @Configuration  
    public class DataSourceBasedMultiTenantConnectionProviderImpl extends AbstractDataSourceBasedMultiTenantConnectionProviderImpl{  

        private static final long serialVersionUID = -7522287771874314380L;  
        @Autowired  
        private MasterTenantRepository masterTenantRepository;  

        private Map<String,DataSource> dataSources = new TreeMap<>();  

        @Override  
        protected DataSource selectAnyDataSource() {  
            if(dataSources.isEmpty()){  
                List<MasterTenant> tenants = masterTenantRepository.findAll();  
                tenants.forEach(masterTenant->{  
                    dataSources.put(masterTenant.getTenant(), DataSourceUtils.wrapperDataSource(masterTenant));  
                });  
            }  
            return dataSources.values().iterator().next();  
        }  
    @Override  
        protected DataSource selectDataSource(String tenant) {  
            if(!dataSources.containsKey(tenant)){  
                List<MasterTenant> tenants = masterTenantRepository.findAll();  
                tenants.forEach(masterTenant->{  
                    dataSources.put(masterTenant.getTenant(),DataSourceUtils.wrapperDataSource(masterTenant));  
                });  
            }  
            return dataSources.get(tenant);  
        }  
    }  

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

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


    @Slf4j  
    @Configuration  
    @EnableTransactionManagement  
    @ComponentScan(basePackages = {  
            "com.ramostear.una.saas.tenant.model",  
            "com.ramostear.una.saas.tenant.repository"  
    })  
    @EnableJpaRepositories(basePackages = {  
            "com.ramostear.una.saas.tenant.repository",  
            "com.ramostear.una.saas.tenant.service"  
    },entityManagerFactoryRef = "tenantEntityManagerFactory"  
    ,transactionManagerRef = "tenantTransactionManager")  
    public class TenantDataSourceConfig {  

        @Bean("jpaVendorAdapter")  
        public JpaVendorAdapter jpaVendorAdapter(){  
            return new HibernateJpaVendorAdapter();  
        }  
         @Bean(name = "tenantTransactionManager")  
        public JpaTransactionManager transactionManager(EntityManagerFactory entityManagerFactory){  
            JpaTransactionManager transactionManager = new JpaTransactionManager();  
            transactionManager.setEntityManagerFactory(entityManagerFactory);  
            return transactionManager;  
        }  

        @Bean(name = "datasourceBasedMultiTenantConnectionProvider")  
        @ConditionalOnBean(name = "masterEntityManagerFactory")  
        public MultiTenantConnectionProvider multiTenantConnectionProvider(){  
            return new DataSourceBasedMultiTenantConnectionProviderImpl();  
        }  
         @Bean(name = "currentTenantIdentifierResolver")  
        public CurrentTenantIdentifierResolver currentTenantIdentifierResolver(){  
            return new CurrentTenantIdentifierResolverImpl();  
        }  

        @Bean(name = "tenantEntityManagerFactory")  
        @ConditionalOnBean(name = "datasourceBasedMultiTenantConnectionProvider")  
        public LocalContainerEntityManagerFactoryBean entityManagerFactory(  
                @Qualifier("datasourceBasedMultiTenantConnectionProvider")MultiTenantConnectionProvider connectionProvider,  
                @Qualifier("currentTenantIdentifierResolver")CurrentTenantIdentifierResolver tenantIdentifierResolver  
        ){  
            LocalContainerEntityManagerFactoryBean localBean = new LocalContainerEntityManagerFactoryBean();  
            localBean.setPackagesToScan(  
                    new String[]{  
                            User.class.getPackage().getName(),  
                            UserRepository.class.getPackage().getName(),  
                            UserService.class.getPackage().getName()  

                    }  
            );  
            localBean.setJpaVendorAdapter(jpaVendorAdapter());  
            localBean.setPersistenceUnitName("tenant-database-persistence-unit");  
            Map<String,Object> properties = new HashMap<>();  
            properties.put(Environment.MULTI_TENANT, MultiTenancyStrategy.SCHEMA);  
            properties.put(Environment.MULTI_TENANT_CONNECTION_PROVIDER,connectionProvider);  
            properties.put(Environment.MULTI_TENANT_IDENTIFIER_RESOLVER,tenantIdentifierResolver);  
            properties.put(Environment.DIALECT,"org.hibernate.dialect.MySQL5Dialect");  
            properties.put(Environment.SHOW_SQL,true);  
            properties.put(Environment.FORMAT_SQL,true);  
            properties.put(Environment.HBM2DDL_AUTO,"update");  
            localBean.setJpaPropertyMap(properties);  
            return localBean;  
        }  
    }  

在改配置文件中,大部分内容与主数据源的配置相同,唯一的区别是租户标识解析器与租户数据源补给源的设置,它将告诉 Hibernate

在执行数据库操作命令前,应该设置什么样的数据库连接信息,以及用户名和密码等信息。

#

10. 应用测试

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


    /**  
     * @author : Created by Tan Chaohong (alias:ramostear)  
     * @create-time 2019/5/27 0027-0:18  
     * @modify by :  
     * @since:  
     */  
    @Controller  
    public class LoginController {  

        @Autowired  
        private UserService userService;  

        @GetMapping("/login.html")  
        public String login(){  
            return "/login";  
        }  

        @PostMapping("/login")  
        public String login(@RequestParam(name = "username") String username, @RequestParam(name = "password")String password, ModelMap model){  
            System.out.println("tenant:"+TenantContextHolder.getTenant());  
            User user = userService.findByUsername(username);  
            if(user != null){  
                if(user.getPassword().equals(password)){  
                    model.put("user",user);  
                    return "/index";  
                }else{  
                    return "/login";  
                }  
            }else{  
                return "/login";  
            }  
        }  
    }  

在启动项目之前,我们需要为主数据源创建对应的数据库和数据表,用于存放租户数据源信息,同时还需要提供一个租户业务模块数据库和数据表,用来存放租户业务数据 。

一切准备就绪后,启动项目,在浏览器中输入: > http://localhost:8080/login.html

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

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

原文出处:java那些事

原文链接:http://mp.weixin.qq.com/s?__biz=MjM5MTM0NjQ2MQ==&mid=2650143142&idx=3&sn=20daadbf634b8854bb10f67b21a9565c&scene=0#wechat_redirect

版权申明:内容来源网络,版权归原创者所有。除非无法确认,我们都会标明作者及出处,如有侵权烦请告知,我们会立即删除并表示歉意。谢谢。

发表评论

电子邮件地址不会被公开。 必填项已用*标注