MyBatis在原则上无法直接记录完整的SQL查询。为了记录查询,所有参数都需要被序列化并表示为字符串。对于简单的数据类型(如String或Integer),这并不成问题,但对于更复杂的数据类型(如Timestamp或Blob),其表示方式可能依赖于具体的数据库。 当查询执行时,并不需要将参数转换为字符串,因为JDBC驱动程序会以更高效(且数据库依赖的)格式将它们传递给数据库。然而,出于日志记录的目的,MyBatis只能获取到Java对象,而MyBatis并不知道如何将它们表示为数据库特定的字符串字面量。 因此,你能实现的最佳方案(这也是MyBatis所支持的)是记录带有占位符的查询,并单独记录使用的参数,这也是我们常见到的。
vbnet
==> Preparing: SELECT * FROM event WHERE 1=1 and `enabled` = ?
==> Parameters: true(Boolean)
<== Total: 8
但是,很多情况下,我们的SQL查询当中并不会使用到很复杂的数据类型,如果能将拼接好的SQL直接输出到控制台的话,这将极大的提高我们日常开发/调试时的效率。
接下来就是我实现这一想法的关键步骤:
- 首先,我增强了Mybatis的MappedStatement类,通过在项目启动时自动挂载上一个agent实现的,它的作用是拦截到SQL和参数,然后将他们拼接成完整可执行的SQL,然后通过日志系统输出到控制台。
ini
package com.tiktok.core.transformer;
import net.bytebuddy.agent.builder.AgentBuilder;
import net.bytebuddy.asm.Advice;
import net.bytebuddy.matcher.ElementMatchers;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.mapping.ParameterMapping;
import org.apache.ibatis.reflection.MetaObject;
import org.apache.ibatis.session.Configuration;
import org.apache.ibatis.type.TypeHandlerRegistry;
import java.text.DateFormat;
import java.tiktok.spy.SpyAPI;
import java.util.Date;
import java.util.List;
import java.util.Locale;
import java.util.regex.Matcher;
public class MybatisGetBoundSqlTransformer implements AgentBuilderVisitor {
private final static String clazz = "org.apache.ibatis.mapping.MappedStatement";
@Override
public String getClazz() {
return clazz;
}
@Override
public AgentBuilder build(AgentBuilder agentBuilder) {
return agentBuilder
// 作用于类,指定拦截
.type(ElementMatchers.named(getClazz()))
// 作用于方法,匹配并修改
.transform((builder, typeDescription, classLoader, javaModule, protectionDomain) -> builder
.visit(
Advice.to(MybatisGetBoundSqlTransformer.MybatisGetBoundSqlInterceptor.class)
.on(ElementMatchers.named("getBoundSql")
.and(ElementMatchers.takesArguments(Object.class))
.and(ElementMatchers.returns(BoundSql.class))
)
)
);
}
public static class MybatisGetBoundSqlInterceptor {
@Advice.OnMethodExit
public static void exit(@Advice.This MappedStatement mappedStatement, @Advice.Return BoundSql returnObject) {
try {
if (SpyAPI.checkEntrypoint()) {
Object parameterObject = returnObject.getParameterObject();
List<ParameterMapping> parameterMappings = returnObject.getParameterMappings();
final String sql = returnObject.getSql().replaceAll("\s+", " ");
final String mapperId = mappedStatement.getId();
if (parameterObject == null || parameterMappings == null || parameterMappings.isEmpty()) {
SpyAPI.atExit("mybatis", mapperId, sql);
return;
}
Configuration configuration = mappedStatement.getConfiguration();
TypeHandlerRegistry typeHandlerRegistry = configuration.getTypeHandlerRegistry();
String newSql = String.copyValueOf(sql.toCharArray());
if (typeHandlerRegistry.hasTypeHandler(parameterObject.getClass())) {
String value;
if (parameterObject instanceof String) {
value = "'" + parameterObject + "'";
} else if (parameterObject instanceof Date) {
DateFormat formatter = DateFormat.getDateTimeInstance(DateFormat.DEFAULT, DateFormat.DEFAULT, Locale.getDefault());
value = "'" + formatter.format(parameterObject) + "'";
} else {
value = parameterObject.toString();
}
newSql = newSql.replaceFirst("\?", Matcher.quoteReplacement(value));
} else {
MetaObject metaObject = configuration.newMetaObject(parameterObject);
for (ParameterMapping parameterMapping : parameterMappings) {
String propertyName = parameterMapping.getProperty();
Object parameter = null;
if (metaObject.hasGetter(propertyName)) {
parameter = metaObject.getValue(propertyName);
} else if (returnObject.hasAdditionalParameter(propertyName)) {
parameter = returnObject.getAdditionalParameter(propertyName);
}
String value;
if (parameter instanceof String) {
value = "'" + parameter + "'";
} else if (parameter instanceof Date) {
DateFormat formatter = DateFormat.getDateTimeInstance(DateFormat.DEFAULT, DateFormat.DEFAULT, Locale.getDefault());
value = "'" + formatter.format(parameter) + "'";
} else {
if (null != parameter) {
value = parameter.toString();
} else {
value = "缺失";
}
}
newSql = newSql.replaceFirst("\?", Matcher.quoteReplacement(value));
}
}
SpyAPI.atExit("mybatis", mapperId, newSql);
}
} catch (Throwable throwable) {
throwable.printStackTrace();
}
}
}
}
SpyAPI.atExit("mybatis", mapperId, newSql)是借鉴了arthas中间谍类的设计,将用户应用中获取到的SQL转交给agent进行处理。
- 然后,通过获取了被
@RestContronller
注解的类,拦截了所有的接口请求,这样做的目的是希望只有当接口被请求时,才会在控制台中打印请求接口内执行的SQL,不必被持续不断滚动的SQL所干扰。
typescript
package com.tiktok.core.transformer;
import net.bytebuddy.agent.builder.AgentBuilder;
import net.bytebuddy.asm.Advice;
import net.bytebuddy.matcher.ElementMatchers;
import org.springframework.context.support.AbstractApplicationContext;
import org.springframework.web.bind.annotation.RestController;
import java.tiktok.spy.SpyAPI;
import java.util.Map;
public class AbstractApplicationContextTransformer implements AgentBuilderVisitor {
private final static String clazz = "org.springframework.context.support.AbstractApplicationContext";
@Override
public String getClazz() {
return clazz;
}
@Override
public AgentBuilder build(AgentBuilder agentBuilder) {
return agentBuilder
// 作用于类,指定拦截
.type(ElementMatchers.named(getClazz()))
// 作用于方法,匹配并修改
.transform((builder, typeDescription, classLoader, javaModule, protectionDomain) -> builder
.visit(
Advice.to(AbstractApplicationContextTransformer.AbstractApplicationContextInterceptor.class)
.on(ElementMatchers.named("finishRefresh")
.and(ElementMatchers.isProtected())
.and(ElementMatchers.takesNoArguments())
)
)
);
}
public static class AbstractApplicationContextInterceptor {
@Advice.OnMethodExit
public static void exit(@Advice.This AbstractApplicationContext abstractApplicationContext) {
try {
Map<String, Object> restControllerMap = abstractApplicationContext.getBeansWithAnnotation(RestController.class);
SpyAPI.setRestControllerMap(restControllerMap);
} catch (Throwable throwable) {
// ignore
}
}
}
}
最后,效果时这样的:
- 请求哪个接口,就只会打印哪个接口内执行的SQL语句
- SQL已经被拼接完成,可以直接被执行
- 为了方便复制,右键选择"Copy Mybatis Log"即可自动复制、格式化当前行的SQL语句
- 还有一个功能是,普通方法也可以右键选择"Printf Mybatis Log"进行动态增强