多租戶(Multi-Tenant)是SaaS中的一個(gè)重要概念,它是一種軟件架構(gòu)技術(shù),在多個(gè)租戶的環(huán)境下,共享同一套系統(tǒng)實(shí)例,并且租戶之間的數(shù)據(jù)具有隔離性,也就是說(shuō)一個(gè)租戶不能去訪問(wèn)其他租戶的數(shù)據(jù)?;诓煌母綦x級(jí)別,通常具有下面三種實(shí)現(xiàn)方案:
每個(gè)租戶使用獨(dú)立DataBase,隔離級(jí)別高,性能好,但成本大
租戶之間共享DataBase,使用獨(dú)立的Schema
租戶之間共享Schema,在表上添加租戶字段,共享數(shù)據(jù)程度最高,隔離級(jí)別最低。
數(shù)據(jù)庫(kù)設(shè)計(jì)
Mybatis-plus在第3層隔離級(jí)別上,提供了基于分頁(yè)插件的多租戶的解決方案,我們對(duì)此來(lái)進(jìn)行介紹。在正式開(kāi)始前,首先做好準(zhǔn)備工作創(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`) )
引入依賴
在項(xiàng)目中導(dǎo)入需要的依賴:
???? com.baomidou ????mybatis-plus-boot-starter ????3.3.2 com.github.jsqlparser jsqlparser 3.1
實(shí)現(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; ????} }
這里主要實(shí)現(xiàn)的功能:
創(chuàng)建SQL解析器集合
創(chuàng)建租戶SQL解析器
設(shè)置租戶處理器,具體處理租戶邏輯
這里暫時(shí)把租戶的id固定寫(xiě)成3,來(lái)進(jìn)行測(cè)試。測(cè)試執(zhí)行全表語(yǔ)句:
public?List?getUserList()?{ ????????return?userMapper.selectList(new?LambdaQueryWrapper ().isNotNull(User::getId)); ????????}
使用插件解析執(zhí)行的SQL語(yǔ)句,可以看到自動(dòng)在查詢條件后加上了租戶過(guò)濾條件:
那么在實(shí)際的項(xiàng)目中,怎么將租戶信息傳給租戶處理器呢,根據(jù)情況我們可以從緩存或者請(qǐng)求頭中獲取,以從Request請(qǐng)求頭獲取為例:
@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請(qǐng)求時(shí),在Header中加入tenantId字段,后端在處理器中獲取后,設(shè)置為當(dāng)前這次請(qǐng)求的租戶過(guò)濾條件。
如果是基于請(qǐng)求頭攜帶租戶信息的情況,那么在使用中可能會(huì)遇到一個(gè)坑,如果當(dāng)使用多線程的時(shí)候,新開(kāi)啟的異步線程并不會(huì)自動(dòng)攜帶當(dāng)前線程的Request請(qǐng)求。
@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í)行上面的方法,可以看出是獲取不到當(dāng)前的Request請(qǐng)求的,因此無(wú)法獲得租戶id,會(huì)導(dǎo)致后續(xù)報(bào)錯(cuò)空指針異常:
修改的話也非常簡(jiǎn)單,開(kāi)啟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; ????????}
這樣修改后,在異步線程中也能正常的獲取租戶信息了。
那么,有的小伙伴可能要問(wèn)了,在業(yè)務(wù)中并不是所有的查詢都需要過(guò)濾租戶條件啊,針對(duì)這種情況,有兩種方式來(lái)進(jìn)行處理。
1、如果整張表的所有SQL操作都不需要針對(duì)租戶進(jìn)行操作,那么就對(duì)表進(jìn)行過(guò)濾,修改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表的所有查詢都不進(jìn)行過(guò)濾:
2、如果有一些特定的SQL語(yǔ)句不想被執(zhí)行租戶過(guò)濾,可以通過(guò)@SqlParser注解的形式開(kāi)啟,注意注解只能加在Mapper接口的方法上:
@SqlParser(filter?=?true) @Select("select?*?from?user?where?name?=#{name}") User?selectUserByName(@Param(value="name")?String?name);
或在分頁(yè)攔截器中指定需要過(guò)濾的方法:
@Bean public?PaginationInterceptor?paginationInterceptor()?{ ????????PaginationInterceptor?paginationInterceptor?=?new?PaginationInterceptor(); ????????paginationInterceptor.setSqlParserFilter(metaObject->{ ????????MappedStatement?ms?=?SqlParserHelper.getMappedStatement(metaObject); ????????//?對(duì)應(yīng)Mapper、dao中的方法 ????????if("com.cn.tenant.dao.UserMapper.selectUserByPhone".equals(ms.getId())){ ????????return?true; ????????} ????????return?false; ????????}); ????????... ????????}
?
上面這兩種方式實(shí)現(xiàn)的功能相同,但是如果需要過(guò)濾的SQL語(yǔ)句很多,那么第二種方式配置起來(lái)會(huì)比較麻煩,因此建議通過(guò)注解的方式進(jìn)行過(guò)濾。
除此之外,還有一個(gè)比較容易踩的坑就是在復(fù)制Bean時(shí),不要復(fù)制租戶id字段,否則會(huì)導(dǎo)致SQL語(yǔ)句報(bào)錯(cuò):
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); ????????}
查看報(bào)錯(cuò)可以看出,本身Bean的租戶字段不為空的情況下,SQL又自動(dòng)添加一次租戶查詢條件,因此導(dǎo)致了報(bào)錯(cuò):
我們可以修改復(fù)制Bean語(yǔ)句,手動(dòng)忽略租戶id字段,這里使用的是hutool的BeanUtil工具類,可以添加忽略字段。
BeanUtil.copyProperties(user,userSnapshot,"tenantId");
在忽略了租戶id的拷貝后,查詢可以正常執(zhí)行。
最后,再來(lái)看一下對(duì)聯(lián)表查詢的支持,首先看一下包含子查詢的SQL:
@Select("select?*?from?user?where?id?in?(select?id?from?user_snapshot)") List?selectSnapshot();
查看執(zhí)行結(jié)果,可以看見(jiàn),在子查詢的內(nèi)部也自動(dòng)添加的租戶查詢條件:
再來(lái)看一下使用Join進(jìn)行聯(lián)表查詢:
@Select("select?u.*?from?user?u?left?join?user_snapshot?us?on?u.id=us.id") List?selectSnapshot();
同樣,會(huì)在左右兩張表上都添加租戶的過(guò)濾條件:
再看一下不使用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é)果,可以看見(jiàn)在這種情況下,只在FROM關(guān)鍵字后面的第一張表上添加了租戶的過(guò)濾條件,因此如果使用這種查詢方式,需要額外注意,用戶需要手動(dòng)在SQL語(yǔ)句中添加租戶過(guò)濾。
編輯:黃飛
?
評(píng)論
查看更多