Spring Boot實現SAAS平台的基本思路


 一、SAAS什么

 SaaS是Software-as-a-service(軟件即服務)它是一種通過Internet提供軟件的模式,廠商將應用軟件統一部署在自己的服務器

   上,客戶可以根據自己實際需求,通過互聯網向廠商定購所需的應用軟件服務,按定購的服務多少和時間長短向廠商支付費用,

 並通過互聯網獲得廠商提供的服務。用戶不用再購買軟件,而改用向提供商租用基於Web的軟件,來管理企業經營活動,且無需

 對軟件進行維護,服務提供商會全權管理和維護軟件。

 二、SAAS模式有哪些角色

 ①服務商:服務商主要是管理租戶信息,按照不同的平台需求可能還需要統合整個平台的數據,作為大數據的基礎。服務商在SAAS

 模式中是提供服務的廠商。

 ②租戶:租戶就是購買/租用服務商提供服務的用戶,租戶購買服務后可以享受相應的產品服務。現在很多SAAS化的產品都會划分

 系統版本,不同的版本開放不同的功能,還有基於功能收費之類的,不同的租戶購買不同版本的系統后享受的服務也不一樣。

   三、SAAS模式有哪些特點

 ①獨立性:每個租戶的系統相互獨立。

 ②平台性:所有租戶歸平台統一管理。

 ③隔離性:每個租戶的數據相互隔離。

   在以上三個特性里面,SAAS系統中最重要的一個標志就是數據隔離性,租戶間的數據完全獨立隔離。

   四、數據隔離有哪些方案

   ①獨立數據庫

   即一個租戶一個數據庫,這種方案的用戶數據隔離級別最高,安全性最好,但成本較高。

   優點:

   為不同的租戶提供獨立的數據庫,有助於簡化數據模型的擴展設計,滿足不同租戶的獨特需求,如果出現故障,恢復數據比較簡單。

   缺點:

   增多了數據庫的安裝數量,隨之帶來維護成本和購置成本的增加。 如果定價較低,產品走低價路線,這種方案一般對運營商來說是無法承受的。

   ②共享數據庫,隔離數據架構

   即多個或所有租戶共享數據庫,但是每個租戶一個Schema。

   優點:

   為安全性要求較高的租戶提供了一定程度的邏輯數據隔離,並不是完全隔離,每個數據庫可支持更多的租戶數量。

   缺點:

   如果出現故障,數據恢復比較困難,因為恢復數據庫將牽涉到其他租戶的數據 如果需要跨租戶統計數據,存在一定困難。

   ③共享數據庫,共享數據架構

   即租戶共享同一個數據庫、同一個Schema,但在表中增加TenantID多租戶的數據字段。這是共享程度最高、隔離級別最低的模式。 

   優點:

   三種方案比較,第三種方案的維護和購置成本最低,允許每個數據庫支持的租戶數量最多。

   缺點:

   隔離級別最低,安全性最低,需要在設計開發時加大對安全的開發量,數據備份和恢復最困難,需要逐表逐條備份和還原。

   如果希望以最少的服務器為最多的租戶提供服務,並且租戶接受犧牲隔離級別換取降低成本,這種方案最適合。

 五、基於spring boot 、spring-data-jpa實現共享數據庫,隔離數據架構的SAAS系統。

   在實現系統之前我們需要明白這套實現是共享數據庫,隔離數據架構的,在上面三個方案里面的第二種,為什么選擇第二種。

 第一種基本上只有對數據的隔離性要求非常高,並且有燒錢買服務器的覺悟才能搞。第三種對數據的隔離性太差,只要在程序實現

 上出現些問題就可能導致數據混亂的問題,並且數據備份還原的代價非常高。所以折中我們選擇第二種。

   首先在SAAS系統中,一般都是一套系統多個租戶,也就是說所有的租戶共享同一套系統,但是每個租戶看的數據又要不一樣。

 確定了數據隔離級別之后,我們就需要明確SAAS系統在實現上的難點:①動態創建數據庫;②動態切換數據庫;我們都知道傳統的

 系統中數據源的信息一般都是寫死在系統配置文件的,在啟動系統的時候加載配置信息創建數據源,這樣的系統是單數據源的。這明顯不適用

 SAAS系統,SAAS系統是有多少個租戶就需要多少個數據源的,並且會根據租戶的信息動態的切換數據源。

 技術准備:spring boot , spring-data-jpa , redis,消息隊列, mysql,maven等。

 工具准備:IDEA,PostMan

 項目結構:這里准備了兩套系統,平台管理端和租戶端,這兩套系統是獨立存在的可以單獨運行。

 

 在demo里面,管理端(saas-admin)創建的是一個獨立的spring boot項目,這里只是實現了租戶的注冊,及通過消息隊列通知租戶端創建數據庫。

 首先在saas-admin系統的POM.xml里面添加依賴

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

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

        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>druid-spring-boot-starter</artifactId>
            <version>1.1.10</version>
        </dependency>

        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
        </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-data-redis</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.session</groupId>
            <artifactId>spring-session-data-redis</artifactId>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter</artifactId>
            <exclusions>
                <exclusion>
                    <groupId>org.springframework.boot</groupId>
                    <artifactId>spring-boot-starter-logging</artifactId>
                </exclusion>
            </exclusions>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-log4j</artifactId>
            <version>1.3.8.RELEASE</version>
        </dependency>

        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>fastjson</artifactId>
            <version>1.2.31</version>
        </dependency>

        <dependency>
            <groupId>commons-lang</groupId>
            <artifactId>commons-lang</artifactId>
            <version>2.6</version>
        </dependency>

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-devtools</artifactId>
            <optional>true</optional>
        </dependency>

 配置spring boot的全局配置文件application.properties,需要注意spring.jpa.properties.hibernate.hbm2ddl.auto=update屬性,首次啟動需要先創建saas_admin數據庫,不需要建表。

server.port= 8080
spring.http.encoding.charset=UTF-8
spring.http.encoding.enabled=true
spring.jackson.date-format=yyyy-MM-dd HH:mm:ss
spring.jackson.time-zone=GMT+8

# Database
spring.datasource.url=jdbc:mysql://127.0.0.1:3306/saas_admin?useUnicode=true&characterEncoding=utf-8&autoReconnect=true&failOverReadOnly=false
spring.datasource.username=root
spring.datasource.password=root
spring.datasource.driver-class-name=com.mysql.jdbc.Driver
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.hbm2ddl.auto=update

# Session
spring.session.store-type=none

# Redis
spring.redis.database=0
spring.redis.host=127.0.0.1
spring.redis.port=6379
spring.redis.password=
spring.redis.pool.max-active=8
spring.redis.pool.max-wait=-1
spring.redis.pool.max-idle=8
spring.redis.pool.min-idle=0
spring.redis.timeout=1200

准備租戶實體類,這里使用了spring-data-jpa技術,類似hibernate的用法,使用過hibernate的應該很容易看懂。

@Entity
@Table(name = "tenant")
public class Tenant implements Serializable {

    @Id
    @Column(name = "id",length = 32)
    private String id;

    @Column(name = "account",length = 30)
    private String account;

    @Column(name = "token",length = 32)
    private String token;

    @Column(name = "url",length = 125)
    private String url;

    @Column(name = "data_base",length = 30)
    private String database;

    @Column(name = "username",length = 30)
    private String username;

    @Column(name = "password",length = 32)
    private String password;

    @Column(name = "domain_name",length = 64)
    private String domainName;

    public String getId() {
        return id;
    }

    public void setId(String id) {
        this.id = id;
    }

    public String getUrl() {
        return url;
    }

    public void setUrl(String url) {
        this.url = url;
    }

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public String getPassword() {
        return password;
    }

    public void setPassword(String password) {
        this.password = password;
    }

    public String getDatabase() {
        return database;
    }

    public void setDatabase(String database) {
        this.database = database;
    }

    public String getDomainName() {
        return domainName;
    }

    public void setDomainName(String domainName) {
        this.domainName = domainName;
    }

    public String getAccount() {
        return account;
    }

    public void setAccount(String account) {
        this.account = account;
    }

    public String getToken() {
        return token;
    }

    public void setToken(String token) {
        this.token = token;
    }

 接下來直接看注冊租戶的的實現,其實就是保存租戶信息,然后使用redis的消息隊列通知租戶端創建租戶的數據庫,redis消息隊列的實現代碼會放到github上面。

管理端就這樣了,在實際的系統的中租戶一般也是注冊信息到管理端,並且注冊信息的時候可以選擇使用版本,並且如果系統需要收費的話,也是在支付費用之后才會發送創建

數據庫的消息。

下面主要看下租戶端,動態創建數據庫和切換數據庫都是發生在租戶端的。

租戶端也是一個獨立的spring boot項目,可以獨立運行部署,使用的技術完全和管理端一樣,POM配置完全相同。

server.port= 9090
spring.http.encoding.charset=UTF-8
spring.http.encoding.enabled=true
spring.jackson.date-format=yyyy-MM-dd HH:mm:ss
spring.jackson.time-zone=GMT+8

# Database
spring.datasource.url=jdbc:mysql://127.0.0.1:3306/saas_tenant?useUnicode=true&characterEncoding=utf-8&autoReconnect=true&failOverReadOnly=false
spring.datasource.username=root
spring.datasource.password=root
spring.datasource.driver-class-name=com.mysql.jdbc.Driver

# Hibernate
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.hbm2ddl.auto=update
spring.jpa.properties.hibernate.multiTenancy=SCHEMA
spring.jpa.properties.hibernate.tenant_identifier_resolver=com.michael.saas.tenant.config.MultiTenantIdentifierResolver
spring.jpa.properties.hibernate.multi_tenant_connection_provider=com.michael.saas.tenant.config.MultiTenantConnectionProviderImpl

# Session
spring.session.store-type=none

# Redis
spring.redis.database=0
spring.redis.host=127.0.0.1
spring.redis.port=6379
spring.redis.password=finance123
spring.redis.pool.max-active=8
spring.redis.pool.max-wait=-1
spring.redis.pool.max-idle=8
spring.redis.pool.min-idle=0
spring.redis.timeout=1200

全局配置文件application.properties有幾個需要注意的地方。

①spring.jpa.properties.hibernate.multiTenancy=SCHEMA;

這個是hibernate的多租戶模式的支持,我們這里配置SCHEMA,表示獨立數據庫;

spring.jpa.properties.hibernate.tenant_identifier_resolver;

租戶ID解析器,簡單來說就是這個設置指定的類負責每次執行sql語句的時候獲取租戶ID;

spring.jpa.properties.hibernate.multi_tenant_connection_provider;

這個設置指定的類負責按照租戶ID來提供相應的數據源;

其中②和③是需要自己實現的;

租戶ID解析器:

public class MultiTenantConnectionProviderImpl extends AbstractDataSourceBasedMultiTenantConnectionProviderImpl {

    // 在沒有提供tenantId的情況下返回默認數據源
    @Override
    protected DataSource selectAnyDataSource() {
        return TenantDataSourceProvider.getTenantDataSource("Default");
    }

    // 提供了tenantId的話就根據ID來返回數據源
    @Override
    protected DataSource selectDataSource(String tenantIdentifier) {
        return TenantDataSourceProvider.getTenantDataSource(tenantIdentifier);
    }
}

切換數據源的操作是通過spring的aop機制實現的,可以看到切換數據源的操作發生在業務層。通過租戶的ID獲取存儲在本地線程中相應數據源完成業務操作。

@Aspect
@Order(-1)
@Component
public class TenantAspect {

    @Pointcut("execution(public * com.michael.saas.tenant.service..*.*(..))")
    public void switchTenant(){}

    @Before("switchTenant()")
    public void doBefore(JoinPoint joinPoint) throws Throwable {
        System.out.println(SpObserver.getTenantId());
        TenantDataSourceProvider.getTenantDataSource(SpObserver.getTenantId());
    }

    @AfterReturning(returning = "object", pointcut = "switchTenant()")
    public void doAfterReturning(Object object) throws Throwable {

    }

}

過濾器獲取存放session中的租戶ID存放本地線程

  public class BaseFilter implements Filter {

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {

    }

    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletRequest req = (HttpServletRequest) servletRequest;
        Object obj = req.getSession().getAttribute("TENANTID");
        if (null != obj){
            SpObserver.putTenantId(obj.toString());
        }
        filterChain.doFilter(servletRequest, servletResponse);
    }

    @Override
    public void destroy() {

    }
}

值得一提,租戶ID是登陸時存放到session中的,也代表着切換數據源的前提是先登陸。

  @PostMapping(value = "/login")
    public boolean login(@RequestParam("username")String username, @RequestParam("password")String password, HttpServletRequest request ){
        try {
            Tenant tenant = tenantService.findByAccountAndToken(username, password);
            if (null != tenant){
                request.getSession().setAttribute("TENANTID",tenant.getId());
            }else {
                return false;
            }
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
        return true;
    }

既然是獨立數據庫,就避免不了生成獨立數據庫的問題,我這邊是通過jdbc創建新的數據庫,不一定是好的實現,但能完成基本要求,創建新數據庫的操作在租戶注冊賬號時完成,這里使用了redis消息隊列去異步生成數據庫。

   @PostMapping(value = "/register")
    public boolean register(Tenant tenant){
        try {
            tenant.setUrl("jdbc:mysql://127.0.0.1:3306/");
            tenant.setDatabase("saas_tenant_" + tenant.getAccount());
            tenant.setUsername("root");
            tenant.setPassword("root");
            tenant = tenantService.save(tenant);
            queueService.send("register",tenant);
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
        return true;
    }
@Component
public class Receiver {
    private static final Logger logger = Logger.getLogger(Receiver.class);

    private CountDownLatch latch;

    @Autowired
    public Receiver(CountDownLatch latch) {
        this.latch = latch;
    }

    @Autowired
    private TenantService tenantService;

    /**
     * 消息處理
     * @param message
     */
    public void objectMessage(String message) {
        QueueTemplate queueTemplate = JSON.parseObject(message,QueueTemplate.class);
        if ("register".equals(queueTemplate.getMethod())){
            Tenant tenant = JSON.parseObject(JSON.toJSONString(queueTemplate.getObject()),Tenant.class);
            try {
                DBUtils.createDataBase(tenant.getUrl(),tenant.getDatabase(),tenant.getUsername(),tenant.getPassword());
                TenantDataSourceProvider.addDataSource(tenant);
                tenantService.save(tenant);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
        latch.countDown();
    }


}

SaaS模式基本實現就介紹到這里,github地址奉上https://github.com/gm-xiao/saas-demo.git

 

   

 

 

 

  


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM