博主記得在一個周五快下班的下午,產品找到我,跟我說有幾個業務列表查詢需要加上時間條件過濾數據,這個條件可能會變,不保證以后不修改,這個改動涉及到多個列表查詢,于是博主思考了一會想了幾種實現方案,
-
最簡單,直接將時間條件寫死,由 Service 層傳遞給 Dao 層進行條件拼接。實現上雖然簡單,但是代碼上感覺非常 low,如果這個參數需要在很多方法里進行傳遞,那么工作量就比較大。 -
復雜一點,通過 MyBatis 的攔截器機制,在 SQL 拼接的 prepare 階段修改 SQL 語句,實現動態 SQL。
考慮到攔截器機制不需要修改過多代碼,因此本文博主將帶領大家學習如何利用 MyBatis 攔截器機制來優雅的實現這個需求。
“本文示例代碼全部在 Spring Boot3.0、Mybatis Plus3.5.3.1 版本下運行。
”
簡介
MyBatis 是一個流行的 JAVA 持久層框架,它提供了靈活的 SQL 映射和執行功能。有時候我們可能需要在運行時動態地修改 SQL 語句,例如添加一些條件、排序、分頁等。MyBatis 提供了一個強大的機制來實現這個需求,那就是攔截器(Interceptor)。
“推薦博主開源的 H5 商城項目waynboot-mall,這是一套全部開源的微商城項目,包含三個項目:運營后臺、H5 商城前臺和服務端接口。實現了商城所需的首頁展示、商品分類、商品詳情、商品 sku、分詞搜索、購物車、結算下單、支付寶/微信支付、收單評論以及完善的后臺管理等一系列功能。技術上基于最新得 Springboot3.0、jdk17,整合了 MySQL、redis、RabbitMQ、ElasticSearch 等常用中間件。分模塊設計、簡潔易維護,歡迎大家點個 star、關注博主。
Github 地址:https://github.com/wayn111/waynboot-mall
”
攔截器介紹
攔截器是一種基于 AOP(面向切面編程)的技術,它可以在目標對象的方法執行前后插入自定義的邏輯。MyBatis 定義了四種類型的攔截器,分別是:
-
Executor:攔截執行器的方法,例如 update、query、commit、rollback 等。可以用來實現緩存、事務、分頁等功能。 -
ParameterHandler:攔截參數處理器的方法,例如 setParameters 等。可以用來轉換或加密參數等功能。 -
ResultSetHandler:攔截結果集處理器的方法,例如 handleResultSets、handleOutputParameters 等。可以用來轉換或過濾結果集等功能。 -
StatementHandler:攔截語句處理器的方法,例如 prepare、parameterize、batch、update、query 等。可以用來修改 SQL 語句、添加參數、記錄日志等功能。
實現攔截器
-
定義一個實現 org.Apache.ibatis.plugin.Interceptor 接口的攔截器類,并重寫其中的 intercept、plugin 和 setProperties 方法。 -
添加 @Intercepts 注解,寫上需要攔截的對象和方法,以及方法參數,例如 @Intercepts({@Signature(type = StatementHandler.class, method = “prepare”, args = {Connection.class, Integer.class})})
,表示在 SQL 執行之前進行攔截處理。
注冊攔截器
Spring Boot 項目中集成了 Mybatis Plus 后要讓攔截器生效很簡單,Mybatis Plus 的自動配置類會讀取項目中所有注冊到 Spring 容器的攔截器并進行自動注冊。如下圖,MybatisPlusAutoConfiguration
注冊攔截器
所以我們只需要定義一個 DynamicSqlInterceptor 攔截器并加上 @Component 注解就行,代碼如下,
@Component
@Slf4j
@Intercepts({
@Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})
})
public class DynamicSqlInterceptor implements Interceptor {
...
}
代碼示例
yml 配置
指定 xml 文件中需要替換的占位符標識:@dynamicSql
以及待替換日期條件。
# 動態sql配置
dynamicSql:
placeholder: "@dynamicSql"
date: "2023-07-10 20:10:30"
Dao 層代碼
在需要進行 SQL 占位符替換的方法上加 @DynamicSql 注解。
public interface DynamicSqlMApper {
@DynamicSql
Long count();
}
mapper 文件
將日期條件改成占位符 where create_time > @dynamicSql
。
<mapper namespace="ltd.newbee.mall.core.dao.DynamicSqlMapper">
<select id="count" resultType="java.lang.Long">
select count(1) from member
where create_time > @dynamicSql
</select>
</mapper>
攔截器核心代碼
@Component
@Slf4j
@Intercepts({
@Signature(type = StatementHandler.class,
method = "prepare", args = {Connection.class, Integer.class})
})
public class DynamicSqlInterceptor implements Interceptor {
@Value("${dynamicSql.placeholder}")
private String placeholder;
@Value("${dynamicSql.date}")
private String dynamicDate;
@Override
public Object intercept(Invocation invocation) throws Throwable {
// 1. 獲取 StatementHandler 對象也就是執行語句
StatementHandler statementHandler = (StatementHandler) invocation.getTarget();
// 2. MetaObject 是 MyBatis 提供的一個反射幫助類,可以優雅訪問對象的屬性,這里是對 statementHandler 對象進行反射處理,
MetaObject metaObject = MetaObject.forObject(statementHandler, SystemMetaObject.DEFAULT_OBJECT_FACTORY,
SystemMetaObject.DEFAULT_OBJECT_WRAPPER_FACTORY,
new DefaultReflectorFactory());
// 3. 通過 metaObject 反射獲取 statementHandler 對象的成員變量 mappedStatement
MappedStatement mappedStatement = (MappedStatement) metaObject.getValue("delegate.mappedStatement");
// mappedStatement 對象的 id 方法返回執行的 mapper 方法的全路徑名,如ltd.newbee.mall.core.dao.UserMapper.insertUser
String id = mappedStatement.getId();
// 4. 通過 id 獲取到 Dao 層類的全限定名稱,然后反射獲取 Class 對象
Class<?> classType = Class.forName(id.substring(0, id.lastIndexOf(".")));
// 5. 獲取包含原始 sql 語句的 BoundSql 對象
BoundSql boundSql = statementHandler.getBoundSql();
String sql = boundSql.getSql();
log.info("替換前---sql:{}", sql);
// 攔截方法
String mSql = null;
// 6. 遍歷 Dao 層類的方法
for (Method method : classType.getMethods()) {
// 7. 判斷方法上是否有 DynamicSql 注解,有的話,就認為需要進行 sql 替換
if (method.isAnnotationPresent(DynamicSql.class)) {
mSql = sql.replaceAll(placeholder, String.format("'%s'", dynamicDate));
break;
}
}
if (StringUtils.isNotBlank(mSql)) {
log.info("替換后---mSql:{}", mSql);
// 8. 對 BoundSql 對象通過反射修改 SQL 語句。
Field field = boundSql.getClass().getDeclaredField("sql");
field.setAccessible(true);
field.set(boundSql, mSql);
}
// 9. 執行修改后的 SQL 語句。
return invocation.proceed();
}
@Override
public Object plugin(Object target) {
// 使用 Plugin.wrap 方法生成代理對象
return Plugin.wrap(target, this);
}
@Override
public void setProperties(Properties properties) {
// 獲取配置文件中的屬性值
}
}
現在我們對攔截器核心代碼邏輯進行講解:
-
通過 invocation 參數獲取 statementHandler 對象,也就是包含拼接后 SQL 語句的對象。 -
獲取 metaObject 對象, MetaObject 是 MyBatis 提供的一個反射幫助類,可以優雅訪問對象的屬性,這里是訪問 statementHandler 對象進行反射處理。 -
通過 metaObject 反射獲取 statementHandler 對象的成員變量 mappedStatement。 -
通過 mappedStatement 對象的 id 方法獲取到 Dao 層類的全限定名稱,然后反射獲取 Dao 層類的 Class 對象。 -
獲取包含原始 SQL 語句的 BoundSql 對象。 -
遍歷 Dao 層類的方法。 -
判斷方法上是否有 DynamicSql 注解,有的話就進行時間條件替換。 -
對 BoundSql 對象通過反射修改 SQL 語句。 -
執行修改后的 SQL 語句。
代碼測試
// 測試類
@SpringBootTest
@RunWith(SpringRunner.class)
public class DynamicTest {
@Autowired
private DynamicSqlMapper dynamicSqlMapper;
@Test
public void test() {
Long count = dynamicSqlMapper.count();
Assert.notNull(count, "count不能為null");
}
}
執行結果:
2023-07-11 22:13:33.375 [mAIn] INFO l.n.m.config.DynamicSqlInterceptor - [intercept,52] - 替換前---sql:select count(1) from member
where create_time > @dynamicSql
2023-07-11 22:13:33.376 [main] INFO l.n.m.config.DynamicSqlInterceptor - [intercept,62] - 替換后---mSql:select count(1) from member
where create_time > '2023-07-10 20:10:30'
攔截器應用場景
-
SQL 語句執行監控:可以攔截執行的 SQL 方法,打印執行的 SQL 語句、參數等信息,并且還能夠記錄執行的總耗時,可供后期的 SQL 分析時使用。 -
SQL 分頁查詢:MyBatis 中使用的 RowBounds 使用的內存分頁,在分頁前會查詢所有符合條件的數據,在數據量大的情況下性能較差。通過攔截器,可以在查詢前修改 SQL 語句,提前加上需要的分頁參數。 -
公共字段的賦值:在數據庫中通常會有 createTime , updateTime 等公共字段,這類字段可以通過攔截統一對參數進行的賦值,從而省去手工通過 set 方法賦值的繁瑣過程。 -
數據權限過濾:在很多系統中,不同的用戶可能擁有不同的數據訪問權限,例如在多租戶的系統中,要做到租戶間的數據隔離,每個租戶只能訪問到自己的數據,通過攔截器改寫 SQL 語句及參數,能夠實現對數據的自動過濾。 -
SQL 語句替換:對 SQL 中條件或者特殊字符進行邏輯替換。(也是本文的應用場景)
總結
到此本文講解的 MyBatis 實現動態 SQL 內容就講解完畢了,希望大家喜歡。