1. 程式人生 > 實用技巧 >MyBatis-Plus之多租戶架構(Multi-tenancy)——SAAS

MyBatis-Plus之多租戶架構(Multi-tenancy)——SAAS

作者:梅西愛騎車
連結:https://www.jianshu.com/p/742f40eb9937
來源:簡書
著作權歸作者所有。商業轉載請聯絡作者獲得授權,非商業轉載請註明出處。

一、什麼是多租戶

多租戶技術或稱多重租賃技術,簡稱多租戶。是一種軟體架構技術,是實現如何在多使用者環境下(此處的多使用者一般是面向企業使用者)共用相同的系統或程式元件,並且可確保各使用者間資料的隔離性。

簡單講:在一臺伺服器上執行單個應用例項,它為多個租戶(客戶)提供服務。從定義中我們可以理解:多租戶是一種架構,目的是為了讓多使用者環境下使用同一套程式,且保證使用者間資料隔離。那麼重點就很淺顯易懂了,多租戶的重點就是同一套程式下實現多使用者資料的隔離。SaaS

應用基於此實現。

二、資料隔離有三種方案

  1. 獨立資料庫:簡單來說就是一個租戶使用一個數據庫,這種資料隔離級別最高,安全性最好,但是提高成本。
  2. 共享資料庫、隔離資料架構:多租戶使用同一個資料庫,但是每個租戶對應一個Schema(資料庫user)。
  3. 共享資料庫、共享資料架構:使用同一個資料庫,同一個Schema,但是在表中增加了租戶ID的欄位,這種共享資料程度最高,隔離級別最低。

這裡採用方案三,即共享資料庫,共享資料架構,因為這種方案伺服器成本最低,但是提高了開發成本。


三、Mybatis-plus實現多租戶方案

為什麼選擇MyBatisPlus?
除了一些系統共用的表以外,其他租戶相關的表,我們都需要在sql不厭其煩的加上AND t.tenant_id = ?

查詢條件,稍不注意就會導致資料越界,資料安全問題讓人擔憂。好在有了MybatisPlus這個神器,可以極為方便的實現多租戶SQL解析器。

Mybatis-plus就提供了一種多租戶的解決方案,實現方式是基於分頁外掛(攔截器)進行實現的。


3.1 第一步:

在應用新增維護一張sys_tenant(租戶管理表),在需要進行隔離的資料表上新增租戶id;

3.2 第二步:

建立表:

CREATE TABLE `orders_1`.`tenant`  (
  `id` int(0) NOT NULL AUTO_INCREMENT COMMENT '自增主鍵',
  `expire_date` 
datetime(0) COMMENT '協議到期時間', `amount` decimal(8, 2) COMMENT '金額', `tenant_id` int(0) COMMENT '租戶ID', PRIMARY KEY (`id`) );

自定義系統的上下文,儲存從cookie等方式獲取的租戶ID,在後續的getTenantId()使用。

package com.erbadagang.mybatis.plus.tenant.config;

import org.springframework.stereotype.Component;

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
 * @description 系統的上下文幫助類。ConcurrentHashMap設定租戶ID,供後續的MP的getTenantId()取出
 * @ClassName: ApiContext
 * @author: 郭秀志 [email protected]
 * @date: 2020/7/12 21:50
 * @Copyright:
 */
@Component
public class ApiContext {
    private static final String KEY_CURRENT_TENANT_ID = "KEY_CURRENT_TENANT_ID";
    private static final Map<String, Object> mContext = new ConcurrentHashMap<>();

    public void setCurrentTenantId(Long providerId) {
        mContext.put(KEY_CURRENT_TENANT_ID, providerId);
    }

    public Long getCurrentTenantId() {
        return (Long) mContext.get(KEY_CURRENT_TENANT_ID);
    }
}

核心類——MyBatisPlusConfig通過分頁外掛配置MP多租戶。

package com.erbadagang.mybatis.plus.tenant.config;

import com.baomidou.mybatisplus.core.parser.ISqlParser;
import com.baomidou.mybatisplus.extension.plugins.PaginationInterceptor;
import com.baomidou.mybatisplus.extension.plugins.tenant.TenantHandler;
import com.baomidou.mybatisplus.extension.plugins.tenant.TenantSqlParser;
import net.sf.jsqlparser.expression.Expression;
import net.sf.jsqlparser.expression.LongValue;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.ArrayList;
import java.util.List;

/**
 * @description MyBatisPlus配置類,分頁外掛,多租戶也是使用的分頁外掛進行的配置。
 * @ClassName: MyBatisPlusConfig
 * @author: 郭秀志 [email protected]
 * @date: 2020/7/12 21:34
 * @Copyright:
 */
@Configuration
@MapperScan("com.erbadagang.mybatis.plus.tenant.mapper")//配置掃描的mapper包
public class MyBatisPlusConfig {

    @Autowired
    private ApiContext apiContext;

    /**
     * 分頁外掛
     *
     * @return
     */
    @Bean
    public PaginationInterceptor paginationInterceptor() {
        PaginationInterceptor paginationInterceptor = new PaginationInterceptor();

        // 建立SQL解析器集合
        List<ISqlParser> sqlParserList = new ArrayList<>();

        // 建立租戶SQL解析器
        TenantSqlParser tenantSqlParser = new TenantSqlParser();

        // 設定租戶處理器
        tenantSqlParser.setTenantHandler(new TenantHandler() {

            // 設定當前租戶ID,實際情況你可以從cookie、或者快取中拿都行
            @Override
            public Expression getTenantId(boolean select) {
                // 從當前系統上下文中取出當前請求的服務商ID,通過解析器注入到SQL中。
                Long currentProviderId = apiContext.getCurrentTenantId();
                if (null == currentProviderId) {
                    throw new RuntimeException("Get CurrentProviderId error.");
                }
                return new LongValue(currentProviderId);
            }

            @Override
            public String getTenantIdColumn() {
                // 對應資料庫中租戶ID的列名
                return "tenant_id";
            }

            @Override
            public boolean doTableFilter(String tableName) {
                // 是否需要需要過濾某一張表
              /*  List<String> tableNameList = Arrays.asList("sys_user");
                if (tableNameList.contains(tableName)){
                    return true;
                }*/
                return false;
            }
        });

        sqlParserList.add(tenantSqlParser);
        paginationInterceptor.setSqlParserList(sqlParserList);

        return paginationInterceptor;
    }

}

四、測試

配置好之後,不管是查詢、新增、修改刪除方法,MP都會自動加上租戶ID的標識,測試如下:

package com.erbadagang.mybatis.plus.tenant;

import com.erbadagang.mybatis.plus.tenant.config.ApiContext;
import com.erbadagang.mybatis.plus.tenant.entity.Tenant;
import com.erbadagang.mybatis.plus.tenant.mapper.TenantMapper;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import java.util.List;

/**
 * @description 多租戶測試用例
 * @ClassName: MultiTanentApplicationTests
 * @author: 郭秀志 [email protected]
 * @date: 2020/7/12 22:06
 * @Copyright:
 */
@SpringBootTest
class MultiTanentApplicationTests {

    @Autowired
    private ApiContext apiContext;

    @Autowired
    private TenantMapper tenantMapper;

    @Test
    public void before() {
        // 在上下文中設定當前服務商的ID
        apiContext.setCurrentTenantId(1L);
    }

    @Test
    public void select() {
        List<Tenant> tenants = tenantMapper.selectList(null);
        tenants.forEach(System.out::println);
    }
}

輸出的SQL自動包括WHERE tenant_id = 1

==>  Preparing: SELECT id, expire_date, amount, tenant_id FROM t_tenant WHERE tenant_id = 1 
==> Parameters: 
<==      Total: 0

五、特定SQL過濾

如果在程式中,有部分SQL不需要加上租戶ID的表示,需要過濾特定的sql,可以通過如下兩種方式:

5.1 方式一:

在配置分頁外掛中加上配置ISqlParserFilter解析器,如果配置SQL很多,比較麻煩,不建議。

        //有部分SQL不需要加上租戶ID的表示,需要過濾特定的sql。如果比較多不建議這裡配置。
        /*paginationInterceptor.setSqlParserFilter(new ISqlParserFilter() {
            @Override
            public boolean doFilter(MetaObject metaObject) {
                MappedStatement ms = SqlParserHelper.getMappedStatement(metaObject);
                // 對應Mapper或者dao中的方法
                if("com.erbadagang.mybatis.plus.tenant.mapper.UserMapper.selectList".equals(ms.getId())){
                    return true;
                }
                return false;
            }
        });*/

5.2 方式二:

通過租戶註解的形式,目前只能作用於Mapper的方法上。特定sql過濾 過濾特定的方法 也可以在userMapper需要排除的方法上加入註解SqlParser(filter=true) 排除 SQL 解析。

package com.erbadagang.mybatis.plus.tenant.mapper;

import com.baomidou.mybatisplus.annotation.SqlParser;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.erbadagang.mybatis.plus.tenant.entity.Tenant;
import org.apache.ibatis.annotations.Select;

/**
 * <p>
 * Mapper 介面
 * </p>
 *
 * @author 郭秀志 [email protected]
 * @since 2020-07-12
 */
public interface TenantMapper extends BaseMapper<Tenant> {
    /**
     * 自定Wrapper, @SqlParser(filter = true)註解代表不進行SQL解析也就沒有租戶的附加條件。
     *
     * @return
     */
    @SqlParser(filter = true)
    @Select("SELECT count(5) FROM t_tenant ")
    public Integer myCount();
}

測試

    @Test
    public void myCount() {
        Integer count = tenantMapper.myCount();
        System.out.println(count);
    }

SQL輸出

==>  Preparing: SELECT count(5) FROM t_tenant 
==> Parameters: 
<==    Columns: count(5)
<==        Row: 0
<==      Total: 1

開啟 SQL 解析快取註解生效,如果你的MP版本在3.1.1及以上則不需要配置

# 開啟 SQL 解析快取註解生效,如果你的MP版本在3.1.1及以上則不需要配置
mybatis-plus:
  global-config:
    sql-parser-cache: true

本文原始碼使用 Apache License 2.0開源許可協議,可從Gitee程式碼地址通過git clone命令下載到本地或者通過瀏覽器方式檢視原始碼。