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

tlsmile 2020-06-08

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

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

原文链接(https://zhuanlan.zhihu.com/p/67082434)
本次教程所涉及到的源码已上传至Github,如果你不需要继续阅读下面的内容,你可以直接点击此链接获取源码内容。https://github.com/ramostear/una-saas-toturial

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,http://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配置文件中加入如下的一些配置:

<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().

相关推荐