多租戶(Multi-Tenant)是SaaS中的一個重要概念,它是一種軟件架構(gòu)技術(shù),在多個租戶的環(huán)境下,共享同一套系統(tǒng)實例,并且租戶之間的數(shù)據(jù)具有隔離性,也就是說一個租戶不能去訪問其他租戶的數(shù)據(jù)。基于不同的隔離級別,通常具有下面三種實現(xiàn)方案:
每個租戶使用獨立DataBase,隔離級別高,性能好,但成本大
租戶之間共享DataBase,使用獨立的Schema
租戶之間共享Schema,在表上添加租戶字段,共享數(shù)據(jù)程度最高,隔離級別最低。
數(shù)據(jù)庫設(shè)計
Mybatis-plus在第3層隔離級別上,提供了基于分頁插件的多租戶的解決方案,我們對此來進行介紹。在正式開始前,首先做好準備工作創(chuàng)建兩張表,在基礎(chǔ)字段后都添加租戶字段tenant_id:
CREATE?TABLE?`user`?( ??`id`?bigint(20)?NOT?NULL, ??`name`?varchar(20)?DEFAULT?NULL, ??`phone`?varchar(11)?DEFAULT?NULL, ??`address`?varchar(64)?DEFAULT?NULL, ??`tenant_id`?bigint(20)?DEFAULT?NULL, ??PRIMARY?KEY?(`id`) ) CREATE?TABLE?`dept`?( ??`id`?bigint(20)?NOT?NULL, ??`dept_name`?varchar(64)?DEFAULT?NULL, ??`comment`?varchar(128)?DEFAULT?NULL, ??`tenant_id`?bigint(20)?DEFAULT?NULL, ??PRIMARY?KEY?(`id`) )
引入依賴
在項目中導(dǎo)入需要的依賴:
???? com.baomidou ????mybatis-plus-boot-starter ????3.3.2 com.github.jsqlparser jsqlparser 3.1
實現(xiàn)
Mybatis-plus 配置類:
@EnableTransactionManagement(proxyTargetClass?=?true) @Configuration public?class?MybatisPlusConfig?{ ????@Bean ????public?PaginationInterceptor?paginationInterceptor()?{ ????????PaginationInterceptor?paginationInterceptor?=?new?PaginationInterceptor(); ????????List?sqlParserList=new?ArrayList<>(); ????????TenantSqlParser?tenantSqlParser=new?TenantSqlParser(); ????????tenantSqlParser.setTenantHandler(new?TenantHandler()?{ ????????????@Override ????????????public?Expression?getTenantId(boolean?select)?{ ????????????????String?tenantId?=?"3"; ????????????????return?new?StringValue(tenantId); ????????????} ????????????@Override ????????????public?String?getTenantIdColumn()?{ ????????????????return?"tenant_id"; ????????????} ????????????@Override ????????????public?boolean?doTableFilter(String?tableName)?{ ????????????????return?false; ????????????} ????????}); ????????sqlParserList.add(tenantSqlParser); ????????paginationInterceptor.setSqlParserList(sqlParserList); ????????return?paginationInterceptor; ????} }
這里主要實現(xiàn)的功能:
創(chuàng)建SQL解析器集合
創(chuàng)建租戶SQL解析器
設(shè)置租戶處理器,具體處理租戶邏輯
這里暫時把租戶的id固定寫成3,來進行測試。測試執(zhí)行全表語句:
public?List?getUserList()?{ ????????return?userMapper.selectList(new?LambdaQueryWrapper ().isNotNull(User::getId)); ????????}
使用插件解析執(zhí)行的SQL語句,可以看到自動在查詢條件后加上了租戶過濾條件:
那么在實際的項目中,怎么將租戶信息傳給租戶處理器呢,根據(jù)情況我們可以從緩存或者請求頭中獲取,以從Request請求頭獲取為例:
@Override public?Expression?getTenantId(boolean?select)?{ ????????ServletRequestAttributes?attributes=(ServletRequestAttributes)?RequestContextHolder.getRequestAttributes(); ????????HttpServletRequest?request?=?attributes.getRequest(); ????????String?tenantId?=?request.getHeader("tenantId"); ????????return?new?StringValue(tenantId); ????????}
前端在發(fā)起http請求時,在Header中加入tenantId字段,后端在處理器中獲取后,設(shè)置為當前這次請求的租戶過濾條件。
如果是基于請求頭攜帶租戶信息的情況,那么在使用中可能會遇到一個坑,如果當使用多線程的時候,新開啟的異步線程并不會自動攜帶當前線程的Request請求。
@Override public?List?getUserListByFuture()?{ ????????Callable?getUser=()->?userMapper.selectList(new?LambdaQueryWrapper ().isNotNull(User::getId)); ????????FutureTask >?future=new?FutureTask<>(getUser); ????????new?Thread(future).start(); ????????try?{ ????????return?future.get(); ????????}?catch?(Exception?e)?{ ????????e.printStackTrace(); ????????} ????????return?null; ????????}
執(zhí)行上面的方法,可以看出是獲取不到當前的Request請求的,因此無法獲得租戶id,會導(dǎo)致后續(xù)報錯空指針異常:
修改的話也非常簡單,開啟RequestAttributes的子線程共享,修改上面的代碼:
@Override public?List?getUserListByFuture()?{ ????????ServletRequestAttributes?sra?=?(ServletRequestAttributes)?RequestContextHolder.getRequestAttributes(); ????????Callable?getUser=()->?{ ????????RequestContextHolder.setRequestAttributes(sra,?true); ????????return?userMapper.selectList(new?LambdaQueryWrapper ().isNotNull(User::getId)); ????????}; ????????FutureTask >?future=new?FutureTask<>(getUser); ????????new?Thread(future).start(); ????????try?{ ????????return?future.get(); ????????}?catch?(Exception?e)?{ ????????e.printStackTrace(); ????????} ????????return?null; ????????}
這樣修改后,在異步線程中也能正常的獲取租戶信息了。
那么,有的小伙伴可能要問了,在業(yè)務(wù)中并不是所有的查詢都需要過濾租戶條件啊,針對這種情況,有兩種方式來進行處理。
1、如果整張表的所有SQL操作都不需要針對租戶進行操作,那么就對表進行過濾,修改doTableFilter方法,添加表的名稱:
@Override public?boolean?doTableFilter(String?tableName)?{ ????????List?IGNORE_TENANT_TABLES=?Arrays.asList("dept"); ????????return?IGNORE_TENANT_TABLES.stream().anyMatch(e->e.equalsIgnoreCase(tableName)); ????????}
這樣,在dept表的所有查詢都不進行過濾:
2、如果有一些特定的SQL語句不想被執(zhí)行租戶過濾,可以通過@SqlParser注解的形式開啟,注意注解只能加在Mapper接口的方法上:
@SqlParser(filter?=?true) @Select("select?*?from?user?where?name?=#{name}") User?selectUserByName(@Param(value="name")?String?name);
或在分頁攔截器中指定需要過濾的方法:
@Bean public?PaginationInterceptor?paginationInterceptor()?{ ????????PaginationInterceptor?paginationInterceptor?=?new?PaginationInterceptor(); ????????paginationInterceptor.setSqlParserFilter(metaObject->{ ????????MappedStatement?ms?=?SqlParserHelper.getMappedStatement(metaObject); ????????//?對應(yīng)Mapper、dao中的方法 ????????if("com.cn.tenant.dao.UserMapper.selectUserByPhone".equals(ms.getId())){ ????????return?true; ????????} ????????return?false; ????????}); ????????... ????????}
?
上面這兩種方式實現(xiàn)的功能相同,但是如果需要過濾的SQL語句很多,那么第二種方式配置起來會比較麻煩,因此建議通過注解的方式進行過濾。
除此之外,還有一個比較容易踩的坑就是在復(fù)制Bean時,不要復(fù)制租戶id字段,否則會導(dǎo)致SQL語句報錯:
public?void?createSnapshot(Long?userId){ ????????User?user?=?userMapper.selectOne(new?LambdaQueryWrapper().eq(User::getId,?userId)); ????????UserSnapshot?userSnapshot=new?UserSnapshot(); ????????BeanUtil.copyProperties(user,userSnapshot); ????????userSnapshotMapper.insert(userSnapshot); ????????}
查看報錯可以看出,本身Bean的租戶字段不為空的情況下,SQL又自動添加一次租戶查詢條件,因此導(dǎo)致了報錯:
我們可以修改復(fù)制Bean語句,手動忽略租戶id字段,這里使用的是hutool的BeanUtil工具類,可以添加忽略字段。
BeanUtil.copyProperties(user,userSnapshot,"tenantId");
在忽略了租戶id的拷貝后,查詢可以正常執(zhí)行。
最后,再來看一下對聯(lián)表查詢的支持,首先看一下包含子查詢的SQL:
@Select("select?*?from?user?where?id?in?(select?id?from?user_snapshot)") List?selectSnapshot();
查看執(zhí)行結(jié)果,可以看見,在子查詢的內(nèi)部也自動添加的租戶查詢條件:
再來看一下使用Join進行聯(lián)表查詢:
@Select("select?u.*?from?user?u?left?join?user_snapshot?us?on?u.id=us.id") List?selectSnapshot();
同樣,會在左右兩張表上都添加租戶的過濾條件:
再看一下不使用Join的普通聯(lián)表查詢:
@Select("select?u.*?from?user?u?,user_snapshot?us,dept?d?where?u.id=us.id?and?d.id?is?not?null") List?selectSnapshot();
?
?
查看執(zhí)行結(jié)果,可以看見在這種情況下,只在FROM關(guān)鍵字后面的第一張表上添加了租戶的過濾條件,因此如果使用這種查詢方式,需要額外注意,用戶需要手動在SQL語句中添加租戶過濾。
編輯:黃飛
?
評論