目錄標題
- ?為什么需要轉義 `%` 和 `_`
- 🧪 使用案例:防止傳入 `%` 導致全表查詢
- 🎯 支持哪些場景?
- ? 攔截器實現思路
- 🧩 核心攔截器代碼實現
- 🔐 可選忽略某些 SQL 的轉義
?為什么需要轉義 %
和 _
在使用 MyBatis-Plus 進行模糊查詢時,如果用戶傳入的 %
符號不加以限制或轉義,可能導致嚴重的全表掃描問題,甚至帶來數據泄露風險或性能災難。
在 SQL 中:
%
表示匹配任意個字符_
表示匹配單個字符
例如以下 SQL:
SELECT * FROM user WHERE name LIKE '%';
這條語句會查出全表所有數據。如果直接將用戶輸入的 %
、_
用于 LIKE
查詢,容易引發模糊查詢誤傷甚至查詢全表。因此,必須對其進行轉義處理。
🧪 使用案例:防止傳入 %
導致全表查詢
假設前端傳入了:
{"username": "%"
}
如果你未做轉義,執行的 SQL 就會變為:
SELECT * FROM user WHERE username LIKE '%';
而添加了本攔截器之后,系統會自動將 %
轉義為:
SELECT * FROM user WHERE username LIKE '\%';
從而避免了無意的全表模糊匹配。
🎯 支持哪些場景?
該攔截器兼容以下查詢方式:
查詢方式 | 是否支持 |
---|---|
XML SQL 顯式 LIKE 查詢 | ? |
Wrapper 條件構造器 | ? |
實體類作為參數查詢 | ? |
參數為 Map / 多參數 | ? |
嵌套實體對象字段 | ? |
@Param 注解參數 | ? |
? 攔截器實現思路
我們通過實現 Mybatis-Plus
的 InnerInterceptor
接口,實現對 SQL 中 LIKE
查詢參數的攔截和轉義,主要思路如下:
- 檢測 SQL 是否包含 LIKE 語句
- 獲取 SQL 中參數綁定的值
- 判斷參數是否為字符串且包含特殊字符
- 自動將
%
和_
添加轉義符\
🧩 核心攔截器代碼實現
- 首先聲明一個攔截器:EscapeLikeSqlInterceptor類
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.util.StrUtil;
import com.baomidou.mybatisplus.core.conditions.AbstractWrapper;
import com.baomidou.mybatisplus.extension.plugins.inner.InnerInterceptor;
import org.apache.ibatis.executor.Executor;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.session.ResultHandler;
import org.apache.ibatis.session.RowBounds;
import org.springframework.lang.Nullable;import java.sql.SQLException;
import java.util.*;/*** MyBatis-Plus LIKE 查詢特殊字符轉義攔截器**/
public class EscapeLikeSqlInterceptor implements InnerInterceptor {public static final String DOT = ".";public static final String PLACEHOLDER_REGEX = "\\?";public static final String DOT_REGEX = "\\.";public static final char LIKE_WILDCARD_CHARACTER = '%';public static final String PLACEHOLDER = "?";public static final String WRAPPER_PARAMETER_PROPERTY = "ew.paramNameValuePairs.";private final String LIKE_SQL = " like ";private static final String SQL_SPECIAL_CHARACTER = "_%";private final String IGNORE = "EscapeLikeSqlIgnore";private static final String PARAM_PREFIX = "__frch_";@Overridepublic void beforeQuery(Executor executor, MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) throws SQLException {// 在查詢前檢查參數是否包含忽略標志或需要轉義的LIKE查詢,不建議開啟不轉義字符if (parameter instanceof Map) {Map<?, ?> parameterMap = (Map<?, ?>) parameter;if (parameterMap.containsKey(IGNORE)) {return;}}
// // 處理參數為實體對象時的情況,不建議開啟不轉義字符
// if (!(parameter instanceof Map)) {
// try {
// Map<String, Object> paramMap = BeanUtil.beanToMap(parameter);
// if (paramMap.containsKey(IGNORE)) {
// Object ignoreValue = paramMap.get(IGNORE);
// if (ignoreValue instanceof Boolean && (Boolean) ignoreValue) {
// return;
// }
// }
// } catch (Exception e) {
// // 忽略轉換失敗,正常繼續
// }
// }if (needEscape(boundSql.getSql())) {return;}escapeSql(boundSql, true);}@Overridepublic void beforeUpdate(Executor executor, MappedStatement ms, Object parameter) throws SQLException {// 在更新前檢查參數是否包含忽略標志或需要轉義的LIKE查詢,不建議開啟不轉義字符if (parameter instanceof Map) {Map<?, ?> parameterMap = (Map<?, ?>) parameter;if (parameterMap.containsKey(IGNORE)) {return;}}
// // 處理參數為實體對象時的情況,不建議開啟不轉義字符
// if (!(parameter instanceof Map)) {
// try {
// Map<String, Object> paramMap = BeanUtil.beanToMap(parameter);
// if (paramMap.containsKey(IGNORE)) {
// Object ignoreValue = paramMap.get(IGNORE);
// if (ignoreValue instanceof Boolean && (Boolean) ignoreValue) {
// return;
// }
// }
// } catch (Exception e) {
// // 忽略轉換失敗,正常繼續
// }
// }BoundSql boundSql = ms.getBoundSql(parameter);if (needEscape(boundSql.getSql())) {return;}escapeSql(boundSql, false);}private boolean needEscape(String sql) {// 判斷SQL是否需要轉義,即是否包含LIKE關鍵字且包含占位符return !containLike(sql) || !containPlaceholder(sql);}private boolean containLike(String sql) {// 判斷SQL中是否包含LIKE關鍵字return StrUtil.containsIgnoreCase(sql, LIKE_SQL);}private boolean containPlaceholder(String sql) {// 判斷SQL中是否包含占位符return StrUtil.containsIgnoreCase(sql, PLACEHOLDER);}private boolean containWrapper(String property) {// 判斷屬性是否包含Wrapper參數return StrUtil.contains(property, WRAPPER_PARAMETER_PROPERTY);}private boolean cascadeParameter(String property) {// 判斷屬性是否為級聯參數(即包含點號)return StrUtil.contains(property, DOT);}@SuppressWarnings("unchecked")private void escapeSql(BoundSql boundSql, boolean flag) {// 對SQL中的LIKE查詢參數進行轉義處理String[] split = boundSql.getSql().split(PLACEHOLDER_REGEX);Object parameter = boundSql.getParameterObject();Set<String> processedProperty = new HashSet<>();for (int i = 0; i < split.length; i++) {if (StrUtil.lastIndexOfIgnoreCase(split[i], LIKE_SQL) > -1) {if (parameter instanceof Map) {String property = boundSql.getParameterMappings().get(i).getProperty();if (processedProperty.contains(property)) {continue;}Map<Object, Object> parameterMap = (Map<Object, Object>) parameter;if (containWrapper(property)) {handlerWrapperEscape(property, parameterMap);} else {handlerOriginalSqlEscape(boundSql, property, parameterMap);}processedProperty.add(property);} else if (parameter instanceof String) {BeanUtil.setFieldValue(boundSql.getParameterObject(), "value", addSplashes(((String) parameter)).toCharArray());} else if (parameter instanceof Object) {// 如果參數是實體對象,處理其字段handleEntityFields(parameter, boundSql);}}}}private void handleEntityFields(Object parameter, BoundSql boundSql) {// 遍歷實體類的所有字段,對LIKE查詢參數進行轉義if (parameter != null) {Map<String, Object> fieldValues = BeanUtil.beanToMap(parameter);for (Map.Entry<String, Object> entry : fieldValues.entrySet()) {String property = entry.getKey();Object value = entry.getValue();if (value instanceof String) {// 僅對String類型的LIKE查詢參數進行轉義BeanUtil.setProperty(parameter, property, addSplashes((String) value));} else if (value instanceof Map) {// 處理嵌套實體(Map)handleEntityFields(value, boundSql);}}}}private void handlerWrapperEscape(String property, Map<?, ?> parameterObject) {// 處理Wrapper中的LIKE查詢參數轉義String[] keys = property.split(DOT_REGEX);Object ew = parameterObject.get(keys[0]);if (ew instanceof AbstractWrapper) {Map<String, Object> paramNameValuePairs = ((AbstractWrapper<?, ?, ?>) ew).getParamNameValuePairs();Object paramValue = paramNameValuePairs.get(keys[2]);if (paramValue instanceof String && ((String) paramValue).startsWith("%") && ((String) paramValue).endsWith("%")) {paramNameValuePairs.put(keys[2], String.format("%%%s%%", addSplashes((String) paramValue, LIKE_WILDCARD_CHARACTER)));}}}private void handlerOriginalSqlEscape(BoundSql boundSql, String property, Map<Object, Object> parameterObject) {// 處理原始SQL中的LIKE查詢參數轉義if (cascadeParameter(property)) {String[] keys = property.split(DOT_REGEX, 2);Object parameterBean = parameterObject.get(keys[0]);Object parameterValue = BeanUtil.getProperty(parameterBean, keys[1]);if (parameterValue instanceof String) {BeanUtil.setProperty(parameterBean, keys[1], addSplashes((CharSequence) parameterValue));}} else if (property.startsWith(PARAM_PREFIX)) {Object additionalParameter = boundSql.getAdditionalParameter(property);if (additionalParameter instanceof String) {boundSql.setAdditionalParameter(property, addSplashes((CharSequence) additionalParameter));} else if (additionalParameter instanceof Collection) {boundSql.setAdditionalParameter(property, lists(additionalParameter));}} else {parameterObject.computeIfPresent(property, (key, value) -> {if (value instanceof String) {return addSplashes((CharSequence) value);}return value;});}}private List<?> lists(Object value) {// 處理集合類型參數中的LIKE查詢參數轉義List<?> list = (List<?>) value;List<Object> objects = new ArrayList<>();for (Object o : list) {if (o instanceof Collection) {Object lists = lists(o);objects.add(lists);} else if (o instanceof String) {String s = addSplashes(o.toString());objects.add(s);} else {objects.add(o);}}return objects;}private static String addSplashes(CharSequence content) {// 對內容進行轉義處理return getString(content);}@Nullableprivate static String getString(CharSequence content) {// 對內容進行轉義,如果內容為空則直接返回if (StrUtil.isEmpty(content)) {return StrUtil.str(content);}StringBuilder sb = new StringBuilder();for (int i = 0; i < content.length(); i++) {char c = content.charAt(i);if (StrUtil.contains(SQL_SPECIAL_CHARACTER, c)) {sb.append('\\');}sb.append(c);}return sb.toString();}private static String addSplashes(String content) {// 對字符串內容進行轉義處理return getString(content);}private static String addSplashes(CharSequence content, char trimFix) {// 對內容進行轉義處理,并去除首尾的特定字符if (content.charAt(0) == trimFix) {content = content.subSequence(1, content.length());}if (content.charAt(content.length() - 1) == trimFix) {content = content.subSequence(0, content.length() - 1);}return addSplashes(content);}
}
攔截器中調用的 escapeSql
方法會:
- 自動識別是否為
LIKE
查詢 - 根據參數是 Map、實體類、Wrapper 構造器等自動處理字段值
- 替換
%
、_
等特殊字符為\%
、\_
- 然后再集成攔截器到 Mybatis-Plus
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();// 添加特殊字符轉義攔截 注:mybatis-plus特殊字符轉義要在分頁攔截之interceptor.addInnerInterceptor(new EscapeLikeSqlInterceptor());// 添加分頁插件interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));return interceptor;
}
🔐 可選忽略某些 SQL 的轉義
若某些 SQL 不希望執行轉義,可以在參數中加上:
Map<String, Object> paramMap = new HashMap<>();
paramMap.put("EscapeLikeSqlIgnore", true);