場景
業務場景中,需要對某些表中的數據做水平的數據隔離,比如某些表中如果含有某個字段,比如store_id(門店id)這個字段,
則對某些有對應門店權限的用户角色開放數據,如果請求的用户沒有對該門店的權限,則自動對sql進行攔截添加where條件。
當然如果同一張表,又必須要查詢全量數據,又可以通過添加自定義註解的方式,跳過數據隔離,返回全量數據。
並且如果用户沒有任何門店的權限,或其他類似權限限制,則直接不執行查詢,返回數據為空。
實現
新建SpringBoot項目,並引入相關依賴
如下依賴特別關注:
<!--MybatisPlus依賴-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.1</version>
</dependency>
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjrt</artifactId>
<version>1.9.7</version>
</dependency>
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
<version>1.9.7</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-aspects</artifactId>
</dependency>
注意:
mybatis-plus-boot-starter 3.5.1 已包含 JSqlParser 依賴
所以此處不需要額外引入如下依賴:
<dependency>
<groupId>com.github.jsqlparser</groupId>
<artifactId>jsqlparser</artifactId>
<version>4.3</version> <!-- MyBatis-Plus 3.5.1 使用的版本 -->
</dependency>
另外還需引入其它非關鍵依賴,按需選擇:
<!-- spring-boot -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- spring-boot-test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
<exclusions>
<exclusion>
<groupId>org.junit.vintage</groupId>
<artifactId>junit-vintage-engine</artifactId>
</exclusion>
</exclusions>
</dependency>
<!-- lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.26</version>
<scope>provided</scope>
</dependency>
<!-- 數據庫連接 -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
添加mybatisplus的配置類,在配置類中實現初始化表緩存、定期刷新表緩存、註冊數據隔離攔截器操作
代碼實現如下:
@Configuration
@MapperScan("com.badao.demo.mapper")
public class MybatisPlusConfig {
// 關鍵功能:
// 1. 初始化時掃描數據庫表結構(initTableCache)
// 2. 定時刷新表結構緩存(scheduleCacheRefresh)
// 3. 註冊MyBatis-Plus攔截器鏈
public MybatisPlusConfig(DataSource dataSource) {
this.dataSource = dataSource;
initTableCache();
//每5分鐘刷新緩存(應對錶結構變更)
scheduleCacheRefresh();
}
// 數據源
private final DataSource dataSource;
// 模式名稱
public static final String DATABASE_B_GAS_STATION = "test";
// 含有門店id字段的數據表
// 使用ConcurrentHashMap保證線程安全
private final Set<String> tablesWithStoreId = Collections.newSetFromMap(new ConcurrentHashMap<>());
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 註冊數據水平隔離攔截器
interceptor.addInnerInterceptor(new StoreDataInterceptor(tablesWithStoreId));
// 註冊分頁攔截器
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
/**
* 初始化表緩存
*/
private void initTableCache() {
try (Connection conn = dataSource.getConnection()) {
DatabaseMetaData metaData = conn.getMetaData();
Map<String, Set<String>> tableColumnsMap = new HashMap<>();
try (ResultSet columns = metaData.getColumns(DATABASE_B_GAS_STATION, null, "%", "%")) {
while (columns.next()) {
String tableName = columns.getString("TABLE_NAME").toLowerCase();
String columnName = columns.getString("COLUMN_NAME").toLowerCase();
tableColumnsMap.computeIfAbsent(tableName, k -> new HashSet<>()).add(columnName);
}
}
// 獲取所有表
try (ResultSet tables = metaData.getTables(DATABASE_B_GAS_STATION, null, "%", new String[]{"TABLE"})) {
while (tables.next()) {
String tableName = tables.getString("TABLE_NAME").toLowerCase();
Set<String> columns = tableColumnsMap.getOrDefault(tableName, Collections.emptySet());
if (columns.contains(StoreDataInterceptor.STORE_ID)) {
tablesWithStoreId.add(tableName);
}
}
}
} catch (Exception e) {
throw new RuntimeException("SellerIso: Failed to init table cache", e);
}
}
/**
* 定時刷新表結構緩存
*/
private void scheduleCacheRefresh() {
//刷新表結構緩存
ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
scheduler.scheduleAtFixedRate(this::initTableCache, 5, 5, TimeUnit.MINUTES);
}
}
數據隔離攔截器實現代碼
public class StoreDataInterceptor implements InnerInterceptor {
private final Set<String> tablesWithStoreId;
//隔離字段column
public static final String STORE_ID = "store_id";
public StoreDataInterceptor(Set<String> tablesWithStoreId) {
this.tablesWithStoreId = tablesWithStoreId;
}
/**
* 優先級高於SQL改寫
* 若返回false,則不會觸發後續的beforeQuery(SQL重寫邏輯)
*/
@Override
public boolean willDoQuery(Executor executor, MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) {
// 指定跳過數據隔離
if (SkipDataIsolation.getMethodSkipDataIsolation()) {
return true;
}
// 其它業務邏輯則不予查詢,比如獲取請求頭中的數據做權限校驗,完全禁止無權限的查詢(如未登錄用户)
// if(!CollectionUtils.isEmpty(UserContextHolder.getStoreIds())
// {
// return false;
// }
return true;
}
@Override
public void beforeQuery(Executor executor, MappedStatement ms, Object parameter, RowBounds rowBounds,
ResultHandler resultHandler, BoundSql boundSql) {
// 如果用户是超管用户則跳過攔截器-自己添加邏輯判斷
if (false) {
return;
}
// 指定跳過數據隔離
if (SkipDataIsolation.getMethodSkipDataIsolation()) {
return;
}
String sql = boundSql.getSql();
try {
//解析SQL並重寫
Select select = (Select) CCJSqlParserUtil.parse(sql);
SelectBody selectBody = select.getSelectBody();
// 遞歸處理所有SELECT部分
processSelectBody(selectBody);
PluginUtils.mpBoundSql(boundSql).sql(select.toString());
} catch (Exception e) {
System.out.println(e.getMessage());
}
}
/**
* 處理PlainSelect
*/
private void processSelectBody(SelectBody selectBody) {
if (selectBody instanceof PlainSelect) {
processPlainSelect((PlainSelect) selectBody);
} else if (selectBody instanceof SetOperationList) {
// 處理UNION/INTERSECT等
for (SelectBody body : ((SetOperationList) selectBody).getSelects()) {
processSelectBody(body);
}
}
// 其他類型如WithItem暫不處理
}
/**
* 處理FROM項
*/
private void processPlainSelect(PlainSelect plainSelect) {
// 1. 處理FROM項
Map<String, String> aliasTableMap = new HashMap<>();
processFromItem(plainSelect.getFromItem(), aliasTableMap);
// 2. 處理JOIN表
if (plainSelect.getJoins() != null) {
for (Join join : plainSelect.getJoins()) {
processFromItem(join.getRightItem(), aliasTableMap);
}
}
// 3. 添加條件到當前SELECT
addConditionsToSelect(plainSelect, aliasTableMap);
// 4. 遞歸處理子查詢
processSubQueries(plainSelect);
}
/**
* 處理查詢
*/
private void processFromItem(FromItem fromItem, Map<String, String> aliasTableMap) {
if (fromItem instanceof Table) {
Table table = (Table) fromItem;
String tableName = table.getName().toLowerCase();
String alias = table.getAlias() != null ?
table.getAlias().getName().toLowerCase() : tableName;
// 緩存別名映射
aliasTableMap.put(alias, tableName);
} else if (fromItem instanceof SubSelect) {
// 處理子查詢
processSelectBody(((SubSelect) fromItem).getSelectBody());
}
}
/**
* 處理子查詢
*/
private void processSubQueries(PlainSelect plainSelect) {
// 1. 處理WHERE子句中的子查詢
if (plainSelect.getWhere() != null) {
plainSelect.getWhere().accept(new SafeExpressionVisitor());
}
// 2. 處理SELECT列表中的子查詢
for (SelectItem item : plainSelect.getSelectItems()) {
item.accept(new SafeSelectItemVisitor());
}
}
/**
* 添加查詢條件到SELECT
*/
private void addConditionsToSelect(PlainSelect plainSelect, Map<String, String> aliasTableMap) {
// 檢查哪些表需要添加條件
for (Map.Entry<String, String> entry : aliasTableMap.entrySet()) {
String alias = entry.getKey();
String tableName = entry.getValue();
//List<String> storeIds = UserContextHolder.getStoreIds();
//此處用模擬數據示例
List<String> storeIds = new ArrayList(){{
this.add("1");
this.add("2");
}};
//對含store_id的表自動添加條件:
if (tablesWithStoreId.contains(tableName)) {
handleSelectSql(alias, plainSelect, storeIds);
}
}
}
/**
* 創建查詢表達式
*/
private static void handleSelectSql(String alias, PlainSelect plainSelect,
List<String> companyChannelIds) {
// 創建條件表達式
Column channelColumn = new Column(alias + "." + StoreDataInterceptor.STORE_ID);
// 創建表達式列表
ExpressionList expressionList = new ExpressionList();
// 手動初始化expressions列表
expressionList.setExpressions(new ArrayList<>());
for (String id : companyChannelIds) {
expressionList.getExpressions().add(new StringValue(id));
}
// 構建條件表達式:WHERE (store_id IN (1,2) OR store_id IS NULL)
// 創建IN表達式
InExpression inExpression = new InExpression(channelColumn, expressionList);
// 添加or 數據隔離字段is null 條件避免聯表查詢時未能關聯數據導致全部數據被過濾
IsNullExpression isNullExpression = new IsNullExpression();
isNullExpression.setLeftExpression(channelColumn);
OrExpression orExpression = new OrExpression(isNullExpression, inExpression);
// 調整or條件優先級 加()
Parenthesis parenthesis = new Parenthesis(orExpression);
// 獲取現有WHERE條件
Expression where = plainSelect.getWhere();
plainSelect.setWhere(where == null ? parenthesis : new AndExpression(where, parenthesis));
}
/**
* 避免查詢無限遞歸
*/
private class SafeExpressionVisitor extends ExpressionVisitorAdapter {
private final Set<Object> visitedObjects = Collections.newSetFromMap(new IdentityHashMap<>());
@Override
public void visit(SubSelect subSelect) {
// 防止SubSelect無限遞歸
if (visitedObjects.add(subSelect)) {
try {
// 限制遞歸深度
if (visitedObjects.size() < 50) {
processSelectBody(subSelect.getSelectBody());
}
} catch (Exception e) {
System.out.println(e.getMessage());
} finally {
visitedObjects.remove(subSelect);
}
}
}
@Override
public void visit(AllColumns allColumns) {
// 關鍵:避免處理AllColumns時的無限遞歸
// 在JSqlParser 4.3中,這裏不能調用super.visit(allColumns)
}
}
/**
* 避免查詢無限遞歸
*/
private class SafeSelectItemVisitor extends SelectItemVisitorAdapter {
@Override
public void visit(SelectExpressionItem item) {
try {
item.getExpression().accept(new SafeExpressionVisitor());
} catch (Exception e) {
System.out.println(e.getMessage());
}
}
}
}
代碼如下:
注意:
1、willDoQuery中
核心作用
攔截器開關控制
決定是否允許當前SQL查詢繼續執行(true放行,false攔截)
與跳過機制集成
通過檢查SkipDataIsolation的線程狀態,實現動態攔截控制
方法調用時機
sequenceDiagram
MyBatis->>StoreDataInterceptor: 執行查詢前
StoreDataInterceptor->>willDoQuery: 檢查攔截條件
alt 返回true
MyBatis->>DB: 正常執行查詢
else 返回false
MyBatis->>調用方: 直接返回空結果
end
優先級高於SQL改寫
若返回false,則不會觸發後續的beforeQuery(SQL重寫邏輯)
典型使用場景
完全禁止無權限的查詢(如未登錄用户)
快速跳過無需處理的查詢類型(如特定Mapper方法)
2、beforeQuery中
如果用户是超管用户則跳過攔截器-自己添加邏輯判斷
addConditionsToSelect添加查詢條件中SELECT中,獲取當前用户的門店id權限使用模擬數據演示效果。
正常應該是從權限控制相關業務中獲取,此處注意使用時修改。
自定義跳過數據隔離註解實現
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface SkipDataIsolationAnnotation {
}
跳過數據隔離切面實現
/**
* 跳過數據隔離切面
*/
@Aspect
@Component
public class SkipDataIsolationAspect {
//Around增強:在方法執行前後插入邏輯
@Around("@annotation(skipDataIsolationAnnotation)")
public Object handleSkipDataIsolation(ProceedingJoinPoint joinPoint,
SkipDataIsolationAnnotation skipDataIsolationAnnotation) throws Throwable {
try {
//進入方法時設置ThreadLocal標誌為true
SkipDataIsolation.setMethodSkipDataIsolation(true);// 設置線程標誌
return joinPoint.proceed();
} finally {
//通過try-finally確保異常時也能清理狀態
SkipDataIsolation.methodClear(); // 清理線程狀態
}
}
}
上下文控制器實現
/**
* 上下文控制器
*/
public class SkipDataIsolation {
// 單次sql語句級別跳過數據隔離: 使用ThreadLocal存儲跳過數據隔離的標誌,默認不跳過value=false
private static final ThreadLocal<Boolean> SKIP_DATA_ISOLATION = ThreadLocal.withInitial(() -> false);
// 方法級別跳過數據隔離: 使用ThreadLocal存儲跳過數據隔離的標誌, 默認不跳過value=false
private static final ThreadLocal<Boolean> SKIP_DATA_ISOLATION_METHOD = ThreadLocal.withInitial(() -> false);
/**
* 單次sql級別:設置跳過數據隔離標誌
*/
public static void setSkipDataIsolation(Boolean skip) {
SKIP_DATA_ISOLATION.set(skip);
}
/**
* 單次sql級別:獲取跳過數據隔離標誌
*/
public static Boolean getSkipDataIsolation() {
return SKIP_DATA_ISOLATION.get();
}
/**
* 單次sql級別:清理ThreadLocal,防止內存泄漏
*/
public static void clear() {
SKIP_DATA_ISOLATION.remove();
}
/**
* 方法級別:設置跳過數據隔離標誌
*/
public static void setMethodSkipDataIsolation(Boolean skip) {
SKIP_DATA_ISOLATION_METHOD.set(skip);
}
/**
* 方法級別:獲取跳過數據隔離標誌
*/
public static Boolean getMethodSkipDataIsolation() {
return SKIP_DATA_ISOLATION_METHOD.get();
}
/**
* 方法級別:清理ThreadLocal,防止內存泄漏
*/
public static void methodClear() {
SKIP_DATA_ISOLATION_METHOD.remove();
}
}
測試效果
新建一個包含store_id字段的表,並生成5條數據,其中有兩條數據store_id為1和2。
新建兩個controller並且一個添加跳過數據隔離註解,一個不添加,執行同樣的mp的條件查詢。
不進行數據隔離的查詢效果
帶數據隔離的效果