目录标题
- ❓为什么需要转义 `%` 和 `_`
- 🧪 使用案例:防止传入 `%` 导致全表查询
- 🎯 支持哪些场景?
- ✅ 拦截器实现思路
- 🧩 核心拦截器代码实现
- 🔐 可选忽略某些 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);