JavaMyBatis

拦截MyBatis执行的完整SQL

Memory

2178字约7分钟

JavaMyBatisSQL

2024-04-19

说明

日常开发中,有些时候会有要获取到MyBatis执行时的完整SQL的需求。在此记录一下我自己的实现方式。

添加切面

提示

其实也可以用MyBatis拦截器来实现,但是我的拦截器用于实现结果集解密和通用Mapper了,所以使用切面来实现。

首先定义切入点表达式,并选择 Around

@Around("execution(* com.test.mapper.*.*(..))")
public Object doAround(ProceedingJoinPoint pjp) throws Throwable {
    // .......
}

具体实现

提示

由于我把MyBatis各种信息封装到一个类中,这个类构造器执行完毕后就持有了所有信息,现在单独拆分代码解释。


获取拦截的方法信息

import cn.hutool.core.util.ReflectUtil;

// ------------------------------------------------ 
Signature signature = pjp.getSignature();
// 接口
// 因为拦的是Mapper,所以一定是MapperStatement
Class<?> mapperInterface = signature.getDeclaringType();
// 方法名
String methodName = signature.getName();

// 获取当前执行的方法,我记得我之前用的切面是可以直接获取方法对象的
// 但是现在用的版本获取不到,所以才用这个方式获取方法对象
// 因为是Mapper方法,所以通常在一个接口中只会存在一个名字的方法,标签内不允许定义重复名称
Method method = ReflectUtil.getMethodByName(mapperInterface, methodName);


获取MyBatis持有的信息

import org.apache.ibatis.mapping.MappedStatement;

// ------------------------------------------------ 
// MappedStatement (接口方法的SQL定义,包含映射、SQL等元素)
// 这里其实就是 org.apache.ibatis.session.Configuration#getMappedStatement()方法
MappedStatement mappedStatement = MyBatisUtil.getMappedStatement(mapperInterface, methodName);

// 获取参数对象,需要用这个对象获取SQL。
Object paramObj = MyBatisUtil.getNamedParams(method, pjp.getArgs());








 

提示

>  上面代码中获取的 paramObj 对象,其实就是MyBatis对我们在Mapper方法形参加的 @Param 注解的处理结果。

>  如果我们只有一个参数:String city,并且加上了 @Param("city") 注解的情况下,
>  那么 paramObj 对象会是一个 MapperMethod.ParamMap 类型的对象,
>  里面大概存储着两个键值对:city -> 参数值 param_1 = 参数值

>  如果我们只有一个参数,但是是对象:UserEntity entity,那么这时的 paramObj 就是这个 UserEntity 对象。

注意:

我们在下面代码会用 MappedStatement#getBoundSql() 方法获取SQL脚本,这样可能会导致一个问题出现。

如果你有定义过 SqlProvider,也就是类似这样的定义:
@SelectProvider(type = BaseProvider.class, method = "getUser")
它会调用 BaseProvider 类的 getUser 方法,用于获取SQL脚本,默认情况下这样是没有问题的。

但是如果你在 BaseProvider 类中有对 参数对象(例如上面提到的 UserEntity 对象) 做出赋值的操作,
这样就会导致一个问题:重复赋值


原因:

这其实是因为 MappedStatement#getBoundSql() 方法的原因,再加上方法参数在MyBatis中一直流转,
由于MyBatis本身会调用一次这个方法,在你实现了 SqlProvider 的前提下,这个调用其实是调用了 SqlProvider 的方法。

如果你在 SqlProvider 的方法(例如上面提到的 BaseProvider.getUser)中有赋值动作,
比方说 entity.setName("test" + entity.getName),那么我们的程序中还会调用一次,
那么 entity.setName() 就被执行了两次,这样参数就乱了。


解决方案

最简单的方法是用 paramObj 参数对象深拷贝一份对象出来,这样互不干扰。我们只需要一份拷贝对象即可实现逻辑。

深拷贝参数对象
// 因为参数对象类型不同,拷贝逻辑也需灵活变动。

// 使用不同的拷贝方式
Object paramObject;
// 多属性或单独非对象属性
if (paramObj instanceof MapperMethod.ParamMap) {
    MapperMethod.ParamMap<Object> paramMap = (MapperMethod.ParamMap<Object>) paramObj;
    // 新Map
    MapperMethod.ParamMap<Object> newParamMap = new MapperMethod.ParamMap<>();

    // Json根据层级深拷贝
    for (Map.Entry<String, Object> entry : paramMap.entrySet()) {
        Object value = entry.getValue();
        // 过滤空
        if (Objects.isNull(value)) {
            newParamMap.put(entry.getKey(), null);
            continue;
        }

        // 数组(由于Json序列化数组再反序列化回来时是一个JsonArray对象,所以不使用Json做深拷贝)
        if (ArrayUtil.isArray(value)) {
            Object[] valueArray = ((Object[]) value).clone();
            newParamMap.put(entry.getKey(), valueArray);
        } else {
            // 非数组
            newParamMap.put(entry.getKey(), JsonUtil.clone(value));
        }
    }

    paramObject = newParamMap;
} else {
    // 可能是对象,直接拷贝
    paramObject = (Objects.isNull(paramObj)) ? null : JsonUtil.clone(paramObj);
}
 


















 














这样拷贝出的对象我们就可以直接使用,不会出现这种类似的问题。



获取SQL脚本

BoundSql boundSql = MyBatisUtil.getBoundSql(mappedStatement, paramObject);

这样获取出的SQL并不完整,因为默认使用预编译,所以其中可能还充斥着 ? 占位符
所以我们还需要对 boundSql 对象做一道处理:把 ? 占位符顺序替换为我们正常的参数

替换正常参数
String fullSql = MyBatisUtil.getFullSql(boundSql, paramObject);

使用到的工具类

MyBatisUtil
import cn.hutool.core.stream.StreamUtil;
import com.test.ParameterValue;
import org.apache.ibatis.builder.SqlSourceBuilder;
import org.apache.ibatis.mapping.*;
import org.apache.ibatis.reflection.ArrayUtil;
import org.apache.ibatis.reflection.MetaObject;
import org.apache.ibatis.reflection.ParamNameResolver;
import org.apache.ibatis.session.Configuration;
import org.apache.ibatis.session.SqlSessionFactory;
import org.apache.ibatis.type.JdbcType;
import org.springframework.util.CollectionUtils;

import java.lang.reflect.Method;
import java.sql.Array;
import java.sql.SQLException;
import java.sql.Time;
import java.sql.Timestamp;
import java.util.*;

/**
 * @author wcp
 * @since 2023/5/24
 */
public class MyBatisUtil {

    private static Configuration configuration;

    /**
     * 获取{@link MappedStatement}
     *
     * @param mapperInterface 接口Class
     * @param methodName      方法名
     * @return {@link MappedStatement}
     */
    public static MappedStatement getMappedStatement(Class<?> mapperInterface, String methodName) {
        return getConfiguration().getMappedStatement(mapperInterface.getName() + "." + methodName);
    }


    /**
     * 获取 MyBatis定义的参数
     * <pre>
     *     就是@Param("city") String city,把@Param的值当作key
     * </pre>
     *
     * @param method 方法对象
     * @param args   方法参数
     * @return 参数,可用来获取SQL
     */
    public static Object getNamedParams(Method method, Object[] args) {
        ParamNameResolver paramNameResolver = new ParamNameResolver(getConfiguration(), method);
        return paramNameResolver.getNamedParams(args);
    }


    /**
     * 获取SQL封装对象(需要携带参数对象,因为{@link BoundSql}对象是需要通过参数获取完整SQL)
     *
     * @param mappedStatement 可以理解为mapper方法
     * @param namedParams     被包装过的参数
     * @return SQL脚本
     */
    public static BoundSql getBoundSql(MappedStatement mappedStatement, Object namedParams) {
        // 获取SQL及参数包装对象
        return mappedStatement.getBoundSql(namedParams);
    }


    /**
     * 获取完整Sql(按照 MyBatis底层源码操作)
     *
     * @param boundSql        Sql包装对象
     * @param parameterObject Mapper方法入参
     * @return 完整SQL脚本
     */
    public static String getFullSql(BoundSql boundSql, Object parameterObject) {
        // 获取带有占位符的Sql
        String sql = boundSql.getSql();
        // 去除换行等空白
        sql = SqlSourceBuilder.removeExtraWhitespaces(sql);
        // 获取参数
        Map<Integer, ParameterValue> parameterMap = getParameterMap(boundSql, parameterObject);
        // 替换?为 {}(简单方案)
        sql = sql.replaceAll("\\?", "{}");

        // 按顺序排列
        int size = parameterMap.size();
        List<String> valueList = new ArrayList<>(size);
        for (int i = 1; i <= size; i++) {
            ParameterValue parameterValue = parameterMap.get(i);
            valueList.add(objectValueString(parameterValue.getValue(), parameterValue.getJdbcType()));
        }


        return StrUtil.format(sql, valueList.toArray());
    }


    /**
     * 获取参数 Map(key: 参数序号;value: 参数值)
     *
     * @param boundSql        Sql包装对象
     * @param parameterObject Sql完整参数
     * @return 参数 Map
     */
    public static Map<Integer, ParameterValue> getParameterMap(BoundSql boundSql, Object parameterObject) {
        // 参数 Map(key -> 序号,value值)
        Map<Integer, ParameterValue> parameterMap = new HashMap<>();
        List<ParameterMapping> parameterMappings = boundSql.getParameterMappings();
        if (parameterMappings != null) {
            for (int i = 0; i < parameterMappings.size(); i++) {
                ParameterMapping parameterMapping = parameterMappings.get(i);
                JdbcType jdbcType = parameterMapping.getJdbcType();
                if (parameterMapping.getMode() != ParameterMode.OUT) {
                    Object value;
                    String propertyName = parameterMapping.getProperty();

                    if (boundSql.hasAdditionalParameter(propertyName)) {
                        value = boundSql.getAdditionalParameter(propertyName);
                    } else if (parameterObject == null) {
                        value = null;
                    } else if (configuration.getTypeHandlerRegistry().hasTypeHandler(parameterObject.getClass())) {
                        value = parameterObject;
                    } else {
                        MetaObject metaObject = configuration.newMetaObject(parameterObject);
                        value = metaObject.getValue(propertyName);
                    }

                    parameterMap.put(i + 1, new ParameterValue(propertyName, value, jdbcType));
                }
            }
        }

        return parameterMap;
    }
    

    /**
     * 获取对象的toString结果
     *
     * @param value 对象
     * @return String
     */
    public static String objectValueString(Object value, JdbcType jdbcType) {
        if (Objects.isNull(value)) {
            return "null";
        }

        if (value instanceof Array) {
            try {
                return ArrayUtil.toString(((Array) value).getArray());
            } catch (SQLException e) {
                return value.toString();
            }
        }

        // 通过获取JdbcType做特殊处理,并通过其类型判断是否加单引号展示
        return typeToString(transformJdbcType(value, jdbcType));
    }


    /**
     * 通过JdbcType做特殊处理(需要根据JdbcType转换成不同类型的值)
     *
     * @param value    参数值
     * @param jdbcType 数据库类型
     * @return 转换后的值
     */
    public static Object transformJdbcType(Object value, JdbcType jdbcType) {
        if (Objects.isNull(value)) {
            return "null";
        }

        Object result = value;

        if (Objects.isNull(jdbcType)) {
            if (value instanceof Date) {
                Date date = (Date) value;
                result = new Timestamp(date.getTime());
            }

        } else {
            switch (jdbcType) {
                case DATE:
                    Date date = (Date) value;
                    result = new java.sql.Date(date.getTime());
                    break;
                case TIME:
                    Date time = (Date) value;
                    result = new Time(time.getTime());
                    break;
                // todo 还有的自己补充

            }
        }

        return result;
    }


    /**
     * 根据值的不同类型判断是否加单引号
     *
     * @param value
     * @return 转换后的值
     */
    private static String typeToString(Object value) {
        if ((value instanceof String) || (value instanceof Date)) {
            return "'" + value + "'";
        } else {
            return value.toString();
        }
    }


    /**
     * 获取 MyBatis 全局配置对象(多数据源情况可能)
     *
     * @return 全局配置
     */
    public static synchronized Configuration getConfiguration() {
        if (Objects.isNull(configuration)) {
            // 暂时是单数据源可以这样做
            SqlSessionFactory factory = SpringUtil.getBean(SqlSessionFactory.class);
            configuration = factory.getConfiguration();
        }

        return configuration;
    }
}
JsonUtil
import com.alibaba.fastjson2.JSON;
import com.fasterxml.jackson.databind.ObjectMapper;

@UtilityClass
public class JsonUtil {

    public static final ObjectMapper objectMapper = new ObjectMapper();

    /**
     * 深拷贝
     *
     * @param obj 对象
     * @param <T> 参数化类型
     * @return 完全不一致的对象
     */
    @SneakyThrows
    @SuppressWarnings("unchecked")
    public static <T> T clone(T obj) {
        if (Objects.isNull(obj)) {
            return null;
        }

        return (T) toJavaBean(toJson(obj), obj.getClass());
    }

    /**
     * Json反序列化为Java对象
     *
     * @param json Json
     * @param clz  对象类型
     * @param <T>  对象的参数化类型
     * @return Java对象
     */
    public static <T> T toJavaBean(String json, Class<T> clz) {
        return toJavaBean(json, clz, false);
    }

    /**
     * Json反序列化为Java对象
     *
     * @param json       Json
     * @param clz        对象类型
     * @param ignoreCase 反序列化时是否忽略Json Key的大小写
     * @param <T>        对象的参数化类型
     * @return Java对象
     */
    @SneakyThrows
    public static <T> T toJavaBean(String json, Class<T> clz, boolean ignoreCase) {
        // fastJson可以忽略大小写差异进行反序列化,而Jackson比较严格
        return (ignoreCase) ? JSON.parseObject(json).toJavaObject(clz) : objectMapper.readValue(json, clz);
    }

}
ParameterValue
import lombok.Getter;
import org.apache.ibatis.type.JdbcType;
import java.util.Objects;

@Getter
public class ParameterValue {

    private final String propertyName;
    private final Object value;
    private final String type;
    private final JdbcType jdbcType;

    public ParameterValue(String propertyName, Object value, JdbcType jdbcType) {
        this.propertyName = propertyName;
        this.value = value;
        this.type = (Objects.isNull(value)) ? "" : value.getClass().getSimpleName();
        this.jdbcType = jdbcType;
    }
}