多租戶是一種單個軟體範例可以為多個不同使用者組提供服務的軟體架構。在雲端計算中,多租戶也可以指共用主機,其伺服器資源將在不同客戶之間進行分配。與多租戶相對應的是單租戶,單租戶是指軟體範例或計算機系統中有 1 個終端使用者或使用者組。
目前saas多租戶系統的資料隔離有三種解決方案,即為每個租戶提供獨立的資料庫、獨立的表空間、按欄位區分租戶,每種方案都有其各自的適用情況。
最終選用以欄位隔離為主,表隔離為輔的方案,其好處就是成本低,改造小。
使用 MyBatis-Plus
中的 TenantLineInnerInterceptor
(多租戶)外掛和 DynamicTableNameInnerInterceptor
(動態表名)外掛 實現租戶間資料隔離
<!-- 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版本升級後解決 -->
(前提:前端發起請求時或者在閘道器轉發時向header
中新增租戶id)
首先從過濾器中拿到 request
header
中的 tenantId
放到執行緒上下文中,後續獲取
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();
}
}
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";
}
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<>();
}
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 每個操作的 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是最簡單的,根據索引隔離,新增租戶時新增相關索引,然後CRUD時查詢時在索引上拼接租戶id
檔案也較為簡單,將組態檔裡檔案目錄設定新增變數,在 getPath
的時候將租戶id拼上去,根據租戶id進行檔案隔離
feign
呼叫也比較簡單,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());
}
}
將定時任務改為區分多租戶和不區分多租戶的兩種,不區分的不需要管,區分多租戶的定時任務將 執行引數jobparam
設定成租戶id ,然後在定時任務裡獲取後塞入上下文 TenantContextHolder
,後續mysql
,redis
,es
,檔案操作都能拿到tenanId
。
由於定時任務獲取租戶id,塞入租戶id,改的太多,我這邊是將 xxl-job-core
執行器的 init
和 destory
改造後實現 ,具體如下:
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();
}
}
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();
}
}
如圖所示,由於執行器是通過反射獲取 init 和 destroy 方法,子類必須重寫父類別方法,否則獲取不到對應方法
#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;
}
}
}
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
初始化比較簡單,將初始化 sql 寫在檔案中,再用流讀出來,降替換符替換成對應的租戶號再去執行sql;
es使用 elasticsearch-rest-high-level-client
包裡自帶的api去實現即可。
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執行省略
}
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() : "";
}
}
為了提高同步速度,在方法上新增 @Async("threadPool")
註解(需要在啟動類或設定類加上@EnableAsync
,才可生效),實現非同步呼叫,threadPool
為自定義執行緒池,可以用自己環境裡公用的,也可以使用預設的 SimpleAsyncTaskExecutor
執行緒池
mybatis-plus
新多租戶外掛設定,一緩和二緩遵循mybatis
的規則,需要設定 MybatisConfiguration
#useDeprecatedExecutor = false
避免快取萬一出現問題;mybatis-plus3.4.1
之前的版本,可以通過自定義一個TenantSqlParser解析器並重寫processInsert方法,網上很多,可自行百度;pagehelper
需要注意版本應與mybatis-plus
對應,否則有可能啟動報錯 (我使用的版本:pagehelper 5.3.1
,mybatis-plus 3.5.2
)因為是對舊專案改造,使用最小代價實現多租戶功能,方案或許不是最優,但每一步可行,大概方向對了,可以少走很多彎路,少踩一些坑。