多租戶實現

2022-09-27 18:00:22

多租戶改造


一、多租戶概念

1.多租戶是什麼意思?怎麼理解?

​ 多租戶是一種單個軟體範例可以為多個不同使用者組提供服務的軟體架構。在雲端計算中,多租戶也可以指共用主機,其伺服器資源將在不同客戶之間進行分配。與多租戶相對應的是單租戶,單租戶是指軟體範例或計算機系統中有 1 個終端使用者或使用者組。

2.多租戶架構的優勢

  • 多租戶可以節省成本。計算規模越大,成本就越低,並且多租戶還允許對資源進行有效地整合和分配,最終節省運營成本。 對於個人使用者而言,存取雲服務或 SAAS 應用所需的費用通常要比執行單租戶硬體和軟體更具成本效益。
  • 多租戶可以提高靈活性。如果您選擇自行購置硬體和軟體,那麼在需求旺盛時可能會難以滿足需求,而在需求疲軟時則可能會閒置不用。另一方面,多租戶雲卻可以根據使用者的需要來靈活地擴充套件和縮減資源池。作為公共雲提供商的客戶,您可以在需要時獲得額外容量,而在不需要時則無需付費。
  • 多租戶可以提高效率。多租戶消除了單個使用者管理基礎架構及處理更新和維護的必要。每個租戶都可以依靠中央雲提供商(而不用自己組建團隊)來處理這些日常瑣事。

2.多租戶技術特點

  • 多個租戶共用平臺。
  • 租戶之間資料隔離。
  • 租戶之間釋出更新互不影響。

4.多租戶資料隔離實現

目前saas多租戶系統的資料隔離有三種解決方案,即為每個租戶提供獨立的資料庫、獨立的表空間、按欄位區分租戶,每種方案都有其各自的適用情況。

最終選用以欄位隔離為主,表隔離為輔的方案,其好處就是成本低,改造小。


一、資料庫隔離

1.方案

使用 MyBatis-Plus 中的 TenantLineInnerInterceptor(多租戶)外掛和 DynamicTableNameInnerInterceptor(動態表名)外掛 實現租戶間資料隔離

2.相關依賴

 <!-- mybatis-plus 外掛 -->
 <dependency>
     <groupId>com.baomidou</groupId>
     <artifactId>mybatis-plus-boot-starter</artifactId>
     <version>3.5.2</version>
 </dependency>

<!--阿里thread-local 用來儲存租戶id -->
 <dependency>
     <groupId>com.alibaba</groupId>
     <artifactId>transmittable-thread-local</artifactId>
     <version>2.12.2</version>
</dependency>

 <!-- 注意:如果啟動或者功能報JSqlParser相關錯誤,需關注pagehelper版本,pagehelper版本升級後解決 -->

3.實現

(1)思路

(前提:前端發起請求時或者在閘道器轉發時向header中新增租戶id)

首先從過濾器中拿到 request header 中的 tenantId放到執行緒上下文中,後續獲取

(2)執行緒上下文(用於存放租戶id )
import com.alibaba.ttl.TransmittableThreadLocal;
import lombok.experimental.UtilityClass;

/**
 * 多租戶上下文
 *
 * @author cherf
 * @since 2022-9-1
 */
@UtilityClass
public class TenantContextHolder {

	/**
	 * 支援父子執行緒資料傳遞
	 */
	private final ThreadLocal<String> THREAD_LOCAL_TENANT = new TransmittableThreadLocal<>();

	/**
	 * 設定租戶ID
	 *
	 * @param tenantId 租戶ID
	 */
	public void setTenantId(String tenantId) {
		THREAD_LOCAL_TENANT.set(tenantId);
	}

	/**
	 * 獲取租戶ID
	 *
	 * @return String
	 */
	public String getTenantId() {
		return THREAD_LOCAL_TENANT.get();
	}

	/**
	 * 清除tenantId
	 */
	public void clear() {
		THREAD_LOCAL_TENANT.remove();
	}
}

(3)過濾器(使用者向上下文中新增租戶id)
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.GenericFilterBean;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

/**
 * 租戶上下文過濾器
 *
 * @author cherf
 * @date 2022-08-30 13:28:00
 */
@Slf4j
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class TenantContextHolderFilter extends GenericFilterBean {
    @Override
    @SneakyThrows
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletRequest request = (HttpServletRequest) servletRequest;
        HttpServletResponse response = (HttpServletResponse) servletResponse;
        try {
            String tenantId = request.getHeader(Constant.TENANT_ID);
            if (StringUtil.isBlank(tenantId)) {
                tenantId = Constant.TENANT_ID_DEFAULT;
            }
            log.info("獲取到的租戶ID為:{}", tenantId);
            if (StringUtil.isNotBlank(tenantId)) {
                TenantContextHolder.setTenantId(tenantId);
            } else {
                if (StringUtil.isBlank(TenantContextHolder.getTenantId())) {
                    TenantContextHolder.setTenantId(Constant.TENANT_ID_DEFAULT);
                }
            }
            filterChain.doFilter(request, response);
        } finally {
            TenantContextHolder.clear();
        }
    }
}

 /**
     * 租戶相關靜態常數
     */
class Constant {
     
    public static final String TENANT = "tenant";

    /**
     * header 中租戶ID
     */
    public static final String TENANT_ID = "tenantId";

    /**
     * 預設租戶ID
     */
    public static final String TENANT_ID_DEFAULT = "tenant_id_default";
}
(4)需要設定多租戶表名設定
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.cloud.context.config.annotation.RefreshScope;

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

/**
 * @author cherf
 * @description: 租戶設定屬性(可以寫在yml組態檔裡,也可以在這兒寫死)
 * @date 2022/08/30 13:57
 **/
@Getter
@Setter
@RefreshScope
@ConfigurationProperties(prefix = "tenant")
public class TenantProperties {
    /**
     * 是否開啟租戶模式
     */
    private Boolean enable = true;

    /**
     * 需要排除的多租戶的表(根據自己需要修改)
     */
    private List<String> ignoreTables = Arrays.asList("t_menu", "t_oauth_client","t_tenant_info","t_server_info");
    /**
     * 動態表名的表(根據自己需要修改)
     */
    private List<String> dynamicTables = Arrays.asList("t_opt_log", "t_flow");

    /**
     * 多租戶欄位名稱(根據實際專案修改)
     */
    private String column = "tenant_id";

    /**
     * 排除不進行租戶隔離的sql
     * 樣例全路徑:com.cherf.system.sauth.mapper.AppAccessMapper.findList
     */
    private List<String> ignoreSqls = new ArrayList<>();

}

(5)mybatis外掛設定
import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.handler.TenantLineHandler;
import com.baomidou.mybatisplus.extension.plugins.inner.DynamicTableNameInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor;
import com.cherf.system.common.constant.StringPool;
import com.cherf.system.common.context.TenantContextHolder;
import com.cherf.system.common.util.StringUtil;
import lombok.AllArgsConstructor;
import net.sf.jsqlparser.expression.Expression;
import net.sf.jsqlparser.expression.NullValue;
import net.sf.jsqlparser.expression.StringValue;
import org.springframework.boot.autoconfigure.AutoConfigureBefore;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * Mybatis-plus 外掛,分頁外掛,多租戶外掛,動態表名外掛
 *
 * @author cherf
 * @date 2022年9月1日
 */
@Configuration
@AllArgsConstructor
@AutoConfigureBefore(MybatisPlusConfig.class)
@EnableConfigurationProperties(TenantProperties.class)
public class MybatisPlusConfig {

    private final TenantProperties tenantProperties;

    @Bean
    public MybatisPlusInterceptor paginationInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();


		//動態表名外掛
		DynamicTableNameInnerInterceptor dynamicTableNameInnerInterceptor = new DynamicTableNameInnerInterceptor();
		dynamicTableNameInnerInterceptor.setTableNameHandler((sql, tableName) -> {
			String tenantId = TenantContextHolder.getTenantId();
			//符合的表名拼接租戶號
			if (tenantProperties.getDynamicTables().stream().anyMatch(
					(t) -> t.equalsIgnoreCase(tableName))) {
				return tableName + StringPool.UNDER_LINE + tenantId;
			}
			return tableName;
		});
		interceptor.addInnerInterceptor(dynamicTableNameInnerInterceptor);
		
        
     	// 新多租戶外掛設定,一緩和二緩遵循mybatis的規則,需要設定 MybatisConfiguration#useDeprecatedExecutor = false 避免快取萬一出現問題
		//租戶攔截器
		interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new TenantLineHandler() {

			/**
			 * 獲取租戶ID
			 * @return
			 */
			@Override
			public Expression getTenantId() {
				String tenantId = TenantContextHolder.getTenantId();
				if (StringUtil.isNotEmpty(tenantId)) {
					return new StringValue(tenantId);
				}
				return new NullValue();
			}

			/**
			 * 獲取多租戶的欄位名
			 * @return String
			 */
			@Override
			public String getTenantIdColumn() {
				return tenantProperties.getColumn();
			}

			/**
			 * 過濾不需要根據租戶隔離的表
			 * 這是 default 方法,預設返回 false 表示所有表都需要拼多租戶條件
			 * @param tableName 表名
			 */
			@Override
			public boolean ignoreTable(String tableName) {
				return tenantProperties.getIgnoreTables().stream().anyMatch(
						(t) -> tableName.startsWith(t) || tableName.equalsIgnoreCase(t)
				);
			}
		}));
		// 如果用了分頁外掛注意先 add TenantLineInnerInterceptor 再 add PaginationInnerInterceptor
        interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
        return interceptor;
    }
}

資料庫隔離到此完成!

然後測試sql後自動拼接 tenant_id 條件

注意:如果不想讓 sql 拼接 tenant_id 可以在mapper 上新增 @InterceptorIgnore(tenantLine = "true") 註解 ;

如圖:


二、redis隔離

1.方案

原本思路是在 redis 每個操作的 key 前拼租戶號,但是涉及的 太多 key ,挨個加太多,最終繼承 RedisSerializer 類 重寫方法,拼裝租戶id 實際實現如下:

package com.cherf.system.common.redis;

import com.cherf.system.common.constant.StringPool;
import com.cherf.system.common.context.TenantContextHolder;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.redis.serializer.RedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import org.springframework.lang.Nullable;
import org.springframework.util.Assert;
import org.springframework.util.CollectionUtils;

import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.util.List;

/**
 * @author cherf
 * @Description 自定義序列化,用於儲存租戶號,隔離資料
 * @Date 2022/9/5
 */
public class TenantStringRedisSerializer implements RedisSerializer<String> {

    private static final Logger logger = LoggerFactory.getLogger(TenantStringRedisSerializer.class);

    private final Charset charset;
    public static final StringRedisSerializer US_ASCII;
    public static final StringRedisSerializer ISO_8859_1;
    public static final StringRedisSerializer UTF_8;

    private static final String TENANT_PREFIX = "tid";

    private static final Boolean IS_LOG = false;

    private RedisProperties redisProperties = new RedisProperties();

    public TenantStringRedisSerializer() {
        this(StandardCharsets.UTF_8);
    }

    public TenantStringRedisSerializer(Charset charset) {
        Assert.notNull(charset, "Charset must not be null!");
        this.charset = charset;
    }

    public String deserialize(@Nullable byte[] bytes) {

        return (bytes == null ? null : new String(bytes, charset).replaceFirst(TenantContextHolder.getTenantId()+":", ""));
    }

    public byte[] serialize(@Nullable String string) {

        if (StringUtils.isBlank(string)){
            echoLog(string);
            return null;
        }

        String tenantId = TenantContextHolder.getTenantId();
        if (StringUtils.isBlank(tenantId)){
            echoLog(string);
            return string.getBytes(charset);
        }

        // 本身帶有多租戶ID的不拼接
        if (string.indexOf(StringPool.COLON) > 0 && string.startsWith(TENANT_PREFIX)){
            echoLog(string);
            return string.getBytes(charset);
        }

        // true:拼接多租戶ID
        Boolean flag = true;
        List<String> ignoreEqualsKeys = redisProperties.getIgnoreEqualsKeys();
        if (!CollectionUtils.isEmpty(ignoreEqualsKeys)){
            for (String key: ignoreEqualsKeys
            ) {
                if (key.equals(string)){
                    flag = false;
                    break;
                }
            }
        }

        if (flag){
            List<String> ignoreContainsKeys = redisProperties.getIgnoreContainsKeys();
            if (!CollectionUtils.isEmpty(ignoreContainsKeys)){
                for (String key: ignoreContainsKeys
                ) {
                    if (string.startsWith(key)){
                        flag = false;
                        break;
                    }
                }
            }
        }

        if (flag){
            echoLog(tenantId + StringPool.COLON + string);
            return (tenantId + StringPool.COLON + string).getBytes(charset);
        }

        echoLog(string);
        return string.getBytes(charset);
    }

    private void echoLog(String key){

        if (IS_LOG){
            logger.info("redis的key:"+key);
        }

    }

    public Class<?> getTargetType() {
        return String.class;
    }

    static {
        US_ASCII = new StringRedisSerializer(StandardCharsets.US_ASCII);
        ISO_8859_1 = new StringRedisSerializer(StandardCharsets.ISO_8859_1);
        UTF_8 = new StringRedisSerializer(StandardCharsets.UTF_8);
    }
}

注:正常請求中是能從 header 獲取到租戶id ,但是一些非請求的程式碼無法實現,目前採用的方法比較笨,就是撈出所有的租戶資訊,然後去迴圈,每個迴圈裡塞入到上下文,後續程式碼邏輯可以獲取到,邏輯結束再 clear 掉(包括一些執行緒和啟動邏輯程式碼)!


三、ES,logstash

1.方案

ES是最簡單的,根據索引隔離,新增租戶時新增相關索引,然後CRUD時查詢時在索引上拼接租戶id


四、檔案相關

1.方案

檔案也較為簡單,將組態檔裡檔案目錄設定新增變數,在 getPath 的時候將租戶id拼上去,根據租戶id進行檔案隔離

五、Feign

1.方案

feign 呼叫也比較簡單,feign 可以統一設定請求頭 具體如下

2.實現

(1)feign 統一設定
package com.cherf.system.common.config;

import com.cherf.system.common.constant.Constant;
import com.cherf.system.common.context.TenantContextHolder;
import feign.RequestInterceptor;
import feign.RequestTemplate;

/**
 * @author cherf
 * @description: Feign呼叫時新增租戶ID
 * @date 2022/09/03 14:46
 **/
public class FeignConfig implements RequestInterceptor {
    @Override
    public void apply(RequestTemplate requestTemplate) {
        //TENANT_ID
        requestTemplate.header(Constant.TENANT_ID, TenantContextHolder.getTenantId());
    }
}

(2)呼叫時新增設定,如圖


六、定時任務(使用xxl-job,不一定適用其他)

1.方案

將定時任務改為區分多租戶和不區分多租戶的兩種,不區分的不需要管,區分多租戶的定時任務將 執行引數jobparam 設定成租戶id ,然後在定時任務裡獲取後塞入上下文 TenantContextHolder ,後續mysql,redis,es,檔案操作都能拿到tenanId

由於定時任務獲取租戶id,塞入租戶id,改的太多,我這邊是將 xxl-job-core 執行器的 initdestory 改造後實現 ,具體如下:

2.實現

(1)執行器改造如圖

(2)新增統一父類別
package com.cherf.common.base;

import com.alibaba.fastjson.JSONObject;
import com.cherf.common.constant.TenantConstant;
import com.cherf.common.context.TenantContextHolder;
import com.cherf.common.util.StringUtil;
import com.xxl.job.core.context.XxlJobHelper;
import org.springframework.stereotype.Component;

/**
 * @author cherf
 * @description: 定時任務父類別, 用來處理租戶號(子類需重寫init和destroy方法)
 * @description: (不進行租戶隔離的job不用繼承)
 * @date 2022/09/01 09:57
 **/
@Component
public class BaseSchedule {

 /**
     * jobParam 格式 {"tenantId":"tidXXXXXXX"}
     * 獲取 jobParam 中的 tenantId 塞入上下文
     */
    public void init() {
        String jobParam = XxlJobHelper.getJobParam();
        if (StringUtil.isNotEmpty(jobParam)) {
            JSONObject jsonObject = JSONObject.parseObject(jobParam);
            if (null != jsonObject && jsonObject.containsKey(TenantConstant.TENANT_ID)) {
                TenantContextHolder.setTenantId(jsonObject.getString(TenantConstant.TENANT_ID));
            }
        }
    }

    /**
     * 清除上下文中的 tenantId
     */
    public void destroy() {
        TenantContextHolder.clear();
    }

}

(3)範例
package com.cherf.system.sys;

import com.cherf.system.common.base.BaseSchedule;
import com.cherf.system.common.context.TenantContextHolder;
import com.xxl.job.core.context.XxlJobHelper;
import com.xxl.job.core.handler.annotation.XxlJob;
import org.springframework.stereotype.Component;

/**
 * @author cherf
 * @description: test
 * @date 2022/09/01 10:25
 **/
@Component
public class TaskTest extends BaseSchedule {
    @XxlJob(value = "TaskTest", init = "init", destroy = "destroy")
    public void execute() {
        XxlJobHelper.log("start============================================");
        XxlJobHelper.log(TenantContextHolder.getTenantId());
        XxlJobHelper.log("end============================================");
        XxlJobHelper.handleSuccess("yunxingchenggong");
    }
    
	
    @Override
    public void init() {
        super.init();
    }

    @Override
    public void destroy() {
        super.destroy();
    }
}

3.注意

如圖所示,由於執行器是通過反射獲取 init 和 destroy 方法,子類必須重寫父類別方法,否則獲取不到對應方法


七、Nginx ,Gateway 動態 url 範例

1.Nginx範例

#user  root;
worker_processes  1;

#error_log CHERF_BASE_HOME/cherf/logs/nginx/nginx_error.log crit;
#pid CHERF_BASE_HOME/cherf/conf/nginx/nginx.pid;

#error_log  logs/error.log;
#error_log  logs/error.log  notice;
#error_log  logs/error.log  info;

#pid        logs/nginx.pid;

worker_rlimit_nofile 65535;
events {
    use epoll;
    worker_connections  65535;
}


http {
    include       mime.types;
    default_type  application/octet-stream;
    sendfile        on;
    keepalive_timeout  65;
    client_max_body_size 500M;
    server_tokens off;
    autoindex off;

    access_log CHERF_BASE_HOME/cherf/logs/nginx/nginx_access.log;
    #sendfile        on;
    #tcp_nopush     on;

    #keepalive_timeout  0;
    #keepalive_timeout  65;

    #gzip  on;

   ##微服務閘道器負載均衡
   upstream gatewayServer{
        server 127.0.0.1:9527 weight=1;
   }

    server {
            listen       9080;
            server_name  localhost;

            #charset koi8-r;

            #access_log  logs/host.access.log  main;

            location /ngx_status  {
                    stub_status on;
                    access_log off;
                    #allow 127.0.0.1;允許哪個ip可以存取
            }
        }

    server {
        add_header Strict-Transport-Security "max-age=63072000; includeSubdomains; preload";
        add_header Content-Security-Policy "default-src 'self' * 'unsafe-inline' 'unsafe-eval' blob: data: ; upgrade-insecure-requests;";
        add_header X-Frame-Options SAMEORIGIN;
        add_header X-XSS-Protection "1; mode=block";
        add_header X-Content-Type-Options nosniff;

        listen                  443 ssl;
        #listen                 10080;
        server_name             _;
        #ssl                    on;
        ssl_certificate         CHERF_BASE_HOME/cherf/conf/nginx/ssl/server.crt; # 改成你的證書的名字
        ssl_certificate_key     CHERF_BASE_HOME/cherf/conf/nginx/ssl/server.key; # 你的證書的名字
        ssl_verify_depth        1;

        ssl_session_timeout 5m;
        ssl_protocols TLSv1.2;
        ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE:ECDH:AES:HIGH:!NULL:!aNULL:!MD5:!ADH:!RC4:!DH:!DHE;
        ssl_prefer_server_ciphers on;

        #charset                koi8-r;

        #access_log  logs/host.access.log  main;
        error_page 404 403 500 502 503 504 /404.html;

        location = /404.html {
            root    CHERF_BASE_HOME/cherf/conf/nginx;
        }

        ## IC模組【vue】
        location /ic {
           proxy_redirect off;
           proxy_set_header Host $host;
           proxy_set_header X-Real-IP $remote_addr;
           proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
           proxy_pass http://127.0.0.1:6770/;
        }
        
     ## IC 介面)【java】
        location ~ ^/cherfApi/(.*)/ic/(.*) {
          proxy_redirect off;
          proxy_set_header Host $host;
          proxy_set_header X-Real-IP $remote_addr;
          proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
          proxy_pass http://gatewayServer/cherfApi/$1/ic/$2;
        }
        #---------------------logstash--------------------------
        location = /lgs {
                        proxy_pass http://gatewayServer/lgs;
        }

        location ~ ^/cherfApi/(.*)/lgs {
                proxy_pass http://gatewayServer/cherfApi/$1/lgs;
        }

        # /ic/outLogin
        location ^~ /console/ {
                return 301  https://CHERF_CURRENT_HOST/ic/outLogin?$query_string;
        }

       
        error_page   500 502 503 504  /50x.html;
        location = /50x.html {
            root   html;
        }

    }

  
    ##vue IC
    server {
            listen       6770;
            server_name  _;
            root   CHERF_BASE_HOME/cherf/fronts/ic;
            index  index.html index.htm;
            #charset koi8-r;
            #access_log  logs/host.access.log  main;
            location / {
                root  CHERF_BASE_HOME/cherf/fronts/ic;
                try_files $uri $uri/ /index.html last;
                index  index.html index.htm;
            }
            error_page   500 502 503 504  /50x.html;
            location = /50x.html {
                root   html;
            }
        }
   
}

2.Gateway 路由設定範例

jasypt:
  encryptor:
    password: 8e0a

server:
  port: 9527
spring:
  cloud:
    nacos:
      discovery:
        # 不使用nacos的設定
        enabled: false
        server-addr: 127.0.0.1:8848
    gateway:
      discovery:
        locator:
          enabled: true #開啟從註冊中心動態建立路由的功能,利用微服務名進行路由
      routes:

        # ************************************* IC 模組**********************************
        - id: i-cherf-tenant-ic-api #payment_route    #路由的ID,沒有固定規則但要求唯一,建議配合服務名
          uri: lb://i-cherf-ic-api #匹配後提供服務的路由地址,lb為負載均衡的其中一種模式
          predicates:
            # 斷言,路徑相匹配的進行路由
          - Path= /cherf/t_{tenantName}/ic/**
          filters:
          # 過濾器去掉⼀個路徑(租戶引數)
          - StripPrefix=2

        - id: i-cherf-ic-api
          uri: lb://i-cherf-ic-api
          predicates:
          - Path= /ic/**

  #  解決某些錯誤,啟動覆蓋
  main:
    allow-bean-definition-overriding: true

  redis:
    database: 0
    port: 6379
    host: 127.0.0.1
    password: ENC(ygfK2l63KfPfFTfsswuXq3Sr+QPmyZYXpFCDnBLaJ2F8Hhi/Mbx3wE4ynHp8nmu/)

# 簽名開關
signature:
  base: true

# 介面存取許可權
interface:
  auth: false


oauth2:
  cloud:
    sys:
      parameter:
        ignoreUrls:

        # 原來校驗TOKEN的白名單
        - /oauth/**


        # 加上多租戶的校驗TOKEN白名單
        - /isapi/t_{tenantName}/oauth/**

八、新增租戶ddl,dml,es索引初始化範例

1.方案

ddl,dml初始化比較簡單,將初始化 sql 寫在檔案中,再用流讀出來,降替換符替換成對應的租戶號再去執行sql;

es使用 elasticsearch-rest-high-level-client 包裡自帶的api去實現即可。

2.實現

(1) msysql 包括 ddl,dml
import cn.hutool.core.io.IoUtil;
import cn.hutool.core.io.resource.ClassPathResource;
import cn.hutool.core.io.resource.Resource;
@Service
public class AsyncExecutorIniTenant { 
/**
     * 獲取ini檔案sql
     *
     * @param fileName
     * @return
     */
    private String getIniResourec(String fileName) {
        String sql = "";
        Resource resource = new ClassPathResource(fileName);
        InputStream is = resource.getStream();
        sql = IoUtil.readUtf8(is);
        return sql;
    }
    //動態替換租戶號和sql執行省略
}
(2) es索引
package com.cherf.sys.tanent.service.impl;

import cn.hutool.core.io.IoUtil;
import cn.hutool.core.io.resource.ClassPathResource;
import cn.hutool.core.io.resource.Resource;
import cn.hutool.log.Log;
import cn.hutool.log.LogFactory;
import com.cherf.common.enums.ElasticAliasEnum;
import com.cherf.common.enums.ElasticTypeFileEnum;
import com.cherf.common.exception.CherfException;
import org.elasticsearch.action.admin.indices.alias.Alias;
import org.elasticsearch.client.RequestOptions;
import org.elasticsearch.client.RestHighLevelClient;
import org.elasticsearch.client.indices.CreateIndexRequest;
import org.elasticsearch.client.indices.CreateIndexResponse;
import org.elasticsearch.client.indices.GetIndexRequest;
import org.elasticsearch.common.xcontent.XContentType;
import org.springframework.stereotype.Service;

import java.io.IOException;

@Service
public class AsyncExecutorIniTenant {

    protected Log log = LogFactory.get(getClass());

	/**
     * 初始化elastic索引
     *
     * @param tenantId
     */
    @Async("simpleTaskExecutor")
    public Future<Boolean> initElastic(String tenantId) {

        try {
            long start = System.currentTimeMillis();
            RestHighLevelClient client = ElasticRestHignLevelClient.buildClient();
            createIndex(ElasticTypeFileEnum.DATA_ACCESS_INDEX.getCode(), tenantId, client);
            long end = System.currentTimeMillis();
            log.info("總共耗時" + (end - start) / 1000 + "秒");
        } catch (Exception e) {
            log.error("租戶管理:elastic索引初始化失敗:" + e);
            return new AsyncResult<>(false);
        }
        return new AsyncResult<>(true);
    }


    /**
     * 建立es索引
     */
    private void createIndex(String indexName, String tenantId, RestHighLevelClient client) throws IOException {

        // 索引拼上租戶號
        String tenantIndexName = tenantId + "_" + indexName;
        // 索引別名
        String tenantIndexAlias = tenantId + "_" + ElasticAliasEnum.getInfoByCode(indexName);
        // 讀取分片組態檔
        String settings = ElasticTypeFileEnum.ELASTIC_SET.getInfo();
        // 讀取索引json組態檔
        String indexMappingPath = ElasticTypeFileEnum.getInfoByCode(indexName);

        // 判斷當前索引是否存在
        boolean exists = true;
        try {
            exists = client.indices().exists(new GetIndexRequest(tenantIndexName), RequestOptions.DEFAULT);
        } catch (IOException e) {
            log.error("ES查詢索引失敗");
            throw new CherfException("ES查詢索引失敗");
        }

        if (!exists) {
            // 索引名稱
            CreateIndexRequest request = new CreateIndexRequest(tenantIndexName);
            // 索引別名
            request.alias(new Alias(tenantIndexAlias));

            //索引設定
            Resource resource = new ClassPathResource(settings);
            String settingJson = IoUtil.readUtf8(resource.getStream());
            request.settings(settingJson, XContentType.JSON);

            // 新增索引mapping
            //Map<String, Object> mapping = new HashMap<>();
            //mapping.put("t-job-index", properties);
            Resource indexMappingResource = new ClassPathResource(indexMappingPath);
            String mapping = IoUtil.readUtf8(indexMappingResource.getStream());
            request.mapping(mapping, XContentType.JSON);

            CreateIndexResponse response = client.indices().create(request, RequestOptions.DEFAULT);
        }
    }

}

ElasticTypeFileEnum範例:

package com.cherf.common.enums;

import java.io.File;
import java.util.Arrays;

public enum ElasticTypeFileEnum {

    ELASTIC_INI("elastic","elastic" + File.separator),
    ELASTIC_SET("setting","elastic/settings.json" + "settings.json"),

    DATA_ACCESS_INDEX("data-access-index-v3", ELASTIC_INI.getInfo() + "data-access-index.json");

    private final String code;
    private final String info;

    ElasticTypeFileEnum(String code, String info) {
        this.code = code;
        this.info = info;
    }

    public String getCode() {
        return code;
    }

    public String getInfo() {
        return info;
    }

    /**
     * getByCode
     *
     * @param code key
     * @return AlertStatusEnum
     */
    public static ElasticTypeFileEnum getByCode(String code) {
        return Arrays.stream(values()).filter(d -> d.getCode().equals(code)).findFirst().orElse(null);
    }

    /**
     * getInfoByCode
     *
     * @param code key
     * @return String
     */
    public static String getInfoByCode(String code) {
        ElasticTypeFileEnum e = getByCode(code);
        return e != null ? e.getInfo() : "";
    }
}

3.注意

​ 為了提高同步速度,在方法上新增 @Async("threadPool") 註解(需要在啟動類或設定類加上@EnableAsync,才可生效),實現非同步呼叫,threadPool為自定義執行緒池,可以用自己環境裡公用的,也可以使用預設的 SimpleAsyncTaskExecutor 執行緒池


九、總結

  1. 注意 mybatis-plus 新多租戶外掛設定,一緩和二緩遵循mybatis的規則,需要設定 MybatisConfiguration #useDeprecatedExecutor = false 避免快取萬一出現問題;
  2. 如果是使用mybatis-plus3.4.1之前的版本,可以通過自定義一個TenantSqlParser解析器並重寫processInsert方法,網上很多,可自行百度;
  3. 如果專案中使用了pagehelper需要注意版本應與mybatis-plus 對應,否則有可能啟動報錯 (我使用的版本:pagehelper 5.3.1mybatis-plus 3.5.2

因為是對舊專案改造,使用最小代價實現多租戶功能,方案或許不是最優,但每一步可行,大概方向對了,可以少走很多彎路,少踩一些坑。