在MYSQL中,使用LIKE关键字进行模糊匹配时候,特殊字符是需要转义的,如下: \
转义成 \\
, _
转义成 \_
, %
转义成 \%
。
在mybatis和mybatis-plus中,没有提供相关的方法来自动转义。其实这是符合逻辑的,转义这种事情该交给开发者自己去处理,官方框架不去入侵这种逻辑代码也是为了更好的灵活性。
在业务开发的环境中,往往会有测试提出这样的bug:“输入特殊字符进行筛选,没有过滤掉对应的数据”。像这种问题其实本质上是不应该存在的,在根源上就应该切除掉,就是说禁止输入特殊字符进行筛选。但往往测试和产品他们根本不懂技术,你也别废口舌去和他们争论了。那么我们就来看看如何处理关于LIKE特殊字符转义问题。
方案一:实现mybatis的插件接口
我们知道mybatis提供了插件接口方便开发者进行二次开发。mybatis-plus对mybatis进行了增强,在mybatis-plus中需要实现InnerInterceptor接口进行拦截。如果只是引入的mybatis,则实现Interceptor接口进行拦截。注意一下:
该方案对 like xxx%
和 like %xxx
的查询方式会有一定的局限性。如:
用户输入 = %a 时,业务按照 like xxx%
的语义查询,是查询xxx开头的数据。正常该转义成 like \%a%
。
用户输入 = a% 时,业务按照 like %xxx
的语义查询,是查询xxx结尾的数据。正常该转义成 like %a\%
。
但程序在转换时,依然会按照 like %xxx%
的语义去转义。这是不可避免的,因为这确实是歧义的地方。 程序解析 like %a%
时,无法完全地确定哪一部分是用户输入的,所以只有按照标准的 %xxx%
来进行解析转义。
不过在一般的业务查询中,左LIKE 和 右LIKE 很少用到,可以不用考虑此情况。
下面是实现了mybatis-plus的拦截接口,代码如下:
import com.baomidou.mybatisplus.core.toolkit.StringUtils;
import com.baomidou.mybatisplus.extension.plugins.inner.InnerInterceptor;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.executor.Executor;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.reflection.MetaObject;
import org.apache.ibatis.reflection.SystemMetaObject;
import org.apache.ibatis.session.ResultHandler;
import org.apache.ibatis.session.RowBounds;
import java.sql.SQLException;
import java.util.HashSet;
import java.util.Set;
/**
* @author shaohuailin
* @date 2023/10/8
*/
@Slf4j
public class LikeEscapeInnerInterceptor implements InnerInterceptor {
@Override
public void beforeQuery(Executor executor, MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) throws SQLException {
escapeLike(boundSql);
}
/**
* like条件中若有关键字,则进行转义
*
* @param boundSql
*/
public void escapeLike(BoundSql boundSql) {
String sql = boundSql.getSql().toLowerCase();
if (!sql.contains(" like ") || !sql.contains("?")) {
return;
}
MetaObject parameterObjectMetaObject = SystemMetaObject.forObject(boundSql.getParameterObject());
String[] strList = sql.split("\\?");
Set<String> keyNames = new HashSet<>();
for (int i = 0; i < strList.length; i++) {
if (strList[i].contains(" like ")) {
String keyName = boundSql.getParameterMappings().get(i).getProperty();
keyNames.add(keyName);
}
}
for (String keyName : keyNames) {
if (parameterObjectMetaObject.hasGetter(keyName)) {
Object value = parameterObjectMetaObject.getValue(keyName);
if (value instanceof String) {
String v = (String) value;
parameterObjectMetaObject.setValue(keyName, convert(v));
}
} else if (boundSql.getParameterObject() instanceof String) {
String v = (String) boundSql.getParameterObject();
boundSql.setAdditionalParameter(keyName, convert(v));
}
}
}
private String convert(String before) {
char[] chars = before.toCharArray();
int len = chars.length;
int offset = 0;
char[] result = new char[len*2];
for (int i = 0; i < len; i++) {
char c = chars[i];
// 不是首尾的字符,或者首尾不是“%”
if ((i > 0 && i < len -1) || c != '%') {
// 特殊字符增加转义
if (c == '\\' || c == '_' || c == '%') {
result[offset++] = '\\';
}
}
result[offset++] = c;
}
return new String(result, 0, offset);
}
}
然后配置MybatisPlusInterceptor
即可,需要注意的是,PaginationInnerInterceptor
插件需要在最末尾。
/**
* @author shaohuailin
* @date 2023/10/12
*/
@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new LikeEscapeInnerInterceptor());
interceptor.addInnerInterceptor(new PaginationInnerInterceptor());
return interceptor;
}
}
方案二:使用切面结合注解的方式
相对于方案一,该方案的灵活性要高些,可以针对部分接口加特殊字符处理。同时此方案,不会存在方案一中的歧义问题。
首先定义特殊字符处理注解如下:
import java.lang.annotation.*;
/**
* 转义like特殊字符
*
* @author shaohuailin
* @date 2023/10/12
*/
@Target({ElementType.PARAMETER, ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface EscapeLike {
}
配置一个特殊字符处理的切面,如下:
/**
* 转义like查询特殊字符的切面
* <p> 在需要转义的属性字段上加{@link EscapeLike} 注解
*
* @author shaohuailin
* @date 2023/10/12
*/
@Slf4j
@Aspect
@Component
public class EscapeLikeAspect {
@Pointcut("@within(org.springframework.web.bind.annotation.RestController)")
public void pointcut() {}
@Around("pointcut()")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
Object[] args = joinPoint.getArgs();
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Annotation[][] parameterAnnotations = signature.getMethod().getParameterAnnotations();
for (int i = 0; i < args.length; i++) {
Object arg = args[i];
if (arg instanceof String) {
// 处理字符串参数
boolean match = Arrays.stream(parameterAnnotations[i]).anyMatch(n -> n instanceof EscapeLike);
if (match) {
// 开始处理
args[i] = convert(((String) arg));
}
} else {
// 处理对象参数
Field[] fields = ClassUtil.getFields(arg);
for (Field field : fields) {
EscapeLike annotation = field.getAnnotation(EscapeLike.class);
if (annotation == null) {
continue;
}
String fieldName = field.getName();
Object value = ReflectUtil.getValue(arg, fieldName);
if (value instanceof String) {
ReflectUtil.setValue(arg, field.getName(), convert(((String) value)));
}
}
}
}
return joinPoint.proceed(args);
}
/**
* 特殊字符转换
*
* @param before
* @return
*/
private String convert(String before) {
char[] chars = before.toCharArray();
int len = chars.length;
int offset = 0;
char[] result = new char[len*2];
for (int i = 0; i < len; i++) {
char c = chars[i];
// 特殊字符增加转义
if (c == '\\' || c == '_' || c == '%') {
result[offset++] = '\\';
}
result[offset++] = c;
}
return new String(result, 0, offset);
}
}
代码中所用到的工具类,替换成自己常用的就可以了。注意一下,该方案的 convert
和方案一的 convert
是有区别的哦!这样,在需要增加转义的字段属性加上注解 @EscapeLike
就可以了。
总结
方案一和方案二都解决了like特殊字符的处理,各有利弊吧,得结合实际情况来决定。