思考,输出,沉淀。用通俗的语言陈述技术,让自己和他人都有所收获。
作者:毅航😜
当我们使用Mybatis
结合Mybatis-plus
进行开发时,为了查看执行sql
的信息通常我们可以通过属性配置的方式打印出执行的sql
语句,但这样的打印出了sql
语句常带有占位符
信息,不利于排错。
为了解决这一痛点问题,我们可以通过Mybatis
提供的拦截器,来获取到真正执行的sql
信息,从而避免我们手动替换占位符
的额外操作。
前言
在日常使用Mybatis-plus
开发时,为了能获取到执行的sql
语句,通常可以在配置文件
进入如下的配置:
yaml
mybatis-plus:
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
mapper-locations: classpath*:mappers/*.xml
通过配置MyBatis-plus
中将log-impl
的日志打印的实现为org.apache.ibatis.logging.stdout.StdOutImpl
,以实现sql
语句在控制台的打印。
此时,当我们执行如下sql
信息时:
xml
<select id="selectByUserName" resultType="com.example.pojo.User">
select user_name userName , age from t_user where user_name = #{name}
</select>
可以看到在控制台会打印出如下内容:
不难发现,我们打印出的sql
信息其实是带有占位符
的。如果我们想在sql
工具中对sql
进行执行,则需要我们手动对占位符
进行替换,对于上述这样的sql
来说这并不是一件难事。但当sql
相关查询参数比较多的时,通过手动对sql
的占位符
进行替换显然不是一件明智的举措了。
为了解决这一问题,我们其实可以借助Mybatis
提供的拦截器
来获取真正执行的sql
信息,从而避免手动对占位符
的替换!
Mybatis
的拦截器
Interceptor
是MyBatis
一个非常强大的特性,它允许你拦截执行的sql
语句,并在 sql
执行前后进行自定义处理。从而实现诸如日志记录、参数修改、结果处理、分页等功能。
通常MyBatis
内部允许对sql
执行过程中Executor、ParameterHandler、ResultSetHandler 和 StatementHandler
四个关键节进行拦截。众所周知,Executor
是sql
执行过程的核心组件。Executor
会调用 StatementHandler
和 ParameterHandler
来完成sql
的准备和执行。
因此,对于Executor
拦截可以获取执行sql
,并且对于sql
执行前后添加自定义逻辑,如缓存逻辑,在查询语句执行前后检查和添加缓存。
进一步来看,对于Execuotr
而言,其还允许在数据库操作的不同阶段进行精确的干预和拦截。例如,如果对Executor
中的update
方法进行拦截,则其可以获取sql
执行中 insert、update、delete
三种类型的sql
语句。而对Executor# query
方法拦截器,其则可以获取 select
类型的 sql
语句。
知晓了MyBatis
中Interceptor
对于Mybatis
核心组件Executor
的拦截逻辑后。接下来,我们将主要介绍如何在Mybatis
中自定义一个自己的Interceptor
。
事实上,如果要在MyBatis
中编写一个拦截器,则首先需要实现 Interceptor
接口,该接口主要包含如下方法:
intercept
方法
intercept
方法接收一个 Invocation
对象,代表被拦截的方法调用。这个方法可以在方法调用前后执行自定义逻辑,并决定是否继续执行原方法。
java
@Override
public Object intercept(Invocation invocation) throws Throwable {
// 在这里编写拦截逻辑
return invocation.proceed(); // 继续执行原方法
}
plugin
方法
plugin
方法用于生成目标对象的代理。如果目标对象是需要拦截的类型,返回代理对象;否则直接返回目标对象。
java
@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this);
}
setProperties
方法
setProperties
方法用于接收在配置文件中定义的属性,这些属性可以用来配置拦截器的行为。
java
@Override
public void setProperties(Properties properties) {
// 读取配置属性
}
如下是Interceptor
的一个统计sql
执行时长的示例代码:
java
package com.example.interceptor;
import org.apache.ibatis.executor.statement.StatementHandler;
import org.apache.ibatis.plugin.*;
import java.sql.Connection;
import java.util.Properties;
@Intercepts({
@Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})
})
public class SqlPrintInterceptor implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
StatementHandler statementHandler = (StatementHandler) invocation.getTarget();
String sql = statementHandler.getBoundSql().getSql();
long startTime = System.currentTimeMillis();
try {
return invocation.proceed();
} finally {
long endTime = System.currentTimeMillis();
System.out.println("SQL: " + sql);
System.out.println("Execution Time: " + (endTime - startTime) + "ms");
}
}
@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this);
}
}
sql
打印拦截器
经过上述分析,相信大家对Mybatis
中的Interceptor
已经有了比较整体的认识。接下来,我们便来分析该如何构建一个打印完整sql
的拦截器。
在开始写代码时,首先来对我们的需求进行再次明确。我们的目标是期待通过Mybatis
的Interceptor
来实现完整sql
的打印。 而如果要实现这一目标,对Executor
进行拦截无疑来说是恰当的选择。因为Executor
是Mybatis
执行sql
的一个媒介,其调用 StatementHandler
和 ParameterHandler
来完成对sql
的准备和执行。明确了拦截器的切入点后,我们再来看我们要对Executor
中的那些方法进行拦截。
正如之前介绍的那样," 如果拦截 Executor
中的 update
方法,可以捕获执行 insert
、update
和 delete
三种类型的 SQL 语句。相反,拦截 Executor
的 query
方法将允许对 select
类型的 SQL 语句进行捕获。"
因此,在构建拦截器时我们的@Signature
内容如下:
java
@Intercepts({
@Signature(type = Executor.class, method = "query",
args = {MappedStatement.class, Object.class,
RowBounds.class, ResultHandler.class}),
@Signature(type = Executor.class, method = "query",
args = {MappedStatement.class, Object.class,
RowBounds.class, ResultHandler.class, CacheKey.class, BoundSql.class}),
@Signature(type = Executor.class, method = "update",
args = {MappedStatement.class, Object.class})
})
我们对Executor
中的query
和update
方法进行拦截,其中args
表示的是方法入参信息。由于Executor
中的query
方法存在方法的重载,所以出现两次!
在此基础上,我们构建出的拦截器如下:
java
@Intercepts({
@Signature(type = Executor.class, method = "query",
args = {MappedStatement.class, Object.class,
RowBounds.class, ResultHandler.class}),
@Signature(type = Executor.class, method = "query",
args = {MappedStatement.class, Object.class,
RowBounds.class, ResultHandler.class, CacheKey.class, BoundSql.class}),
@Signature(type = Executor.class, method = "update",
args = {MappedStatement.class, Object.class})
})
@Slf4j
public class SqlInterceptor implements Interceptor {
/**
* 默认替换字符
*/
public static final String UNKNOWN = "UNKNOWN";
/**
* 替换sql中的?占位符
*/
public static final String SQL_PLACEHOLDER = "#{%s}";
@Override
public Object intercept(Invocation invocation) throws Throwable {
String completeSql = "";
try {
completeSql = getCompleteSqlInfo(invocation);
}catch (RuntimeException e) {
log.error("获取sql信息出错,异常信息 ",e);
}finally {
log.info("sql执行信息:[{}] ",completeSql);
}
return invocation.proceed();
}
@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this);
}
/**
* 获取完整的sql信息
* @param invocation
* @return
*/
private String getCompleteSqlInfo(Invocation invocation) {
// invocation中的Args数组中第一个参数即为MappedStatement对象
MappedStatement mappedStatement = (MappedStatement) invocation.getArgs()[0];
// invocation中的Args数组中第二个参数为sql语句所需要的参数
Object parameter = null;
if (invocation.getArgs().length > 1) {
parameter = invocation.getArgs()[1];
}
return generateCompleteSql(mappedStatement, parameter);
}
private String generateCompleteSql(MappedStatement mappedStatement, Object parameter) {
// 获取sql语句
String mappedStatementId = mappedStatement.getId();
// BoundSql就是封装myBatis最终产生的sql类
BoundSql boundSql = mappedStatement.getBoundSql(parameter);
// 格式化sql信息
String sql = SqlFormatter.format(boundSql.getSql());
// 获取参数列表
List<ParameterMapping> parameterMappings = boundSql.getParameterMappings();
Object parameterObject = boundSql.getParameterObject();
Configuration configuration = mappedStatement.getConfiguration();
if (!CollUtil.isEmpty(parameterMappings) && parameterObject != null) {
// 遍历参数完成对占位符的替换处理
for (int i = 0 ; i < parameterMappings.size() ; i++) {
String replacePlaceHolder = String.format(SQL_PLACEHOLDER,i);
sql = sql.replaceFirst("\?",replacePlaceHolder);
}
// MetaObject主要是封装了originalObject对象,提供了get和set的方法用于获取和设置originalObject的属性值
MetaObject metaObject = configuration.newMetaObject(parameterObject);
for (int i = 0 ; i < parameterMappings.size() ; i ++) {
ParameterMapping parameterMapping = parameterMappings.get(i);
String replacePlaceHolder = String.format(SQL_PLACEHOLDER,i);
String propertyName = parameterMapping.getProperty();
if (metaObject.hasGetter(propertyName)) {
Object obj = metaObject.getValue(propertyName);
sql = sql.replaceFirst(Pattern.quote(replacePlaceHolder),
Matcher.quoteReplacement(getParameterValue(obj)));
} else if (boundSql.hasAdditionalParameter(propertyName)) {
// 处理动态sql标签信息
Object obj = boundSql.getAdditionalParameter(propertyName);
sql = sql.replaceFirst(Pattern.quote(replacePlaceHolder),
Matcher.quoteReplacement(getParameterValue(obj)));
} else {
// 未知参数,替换?为特定字符
sql = sql.replaceFirst(Pattern.quote(replacePlaceHolder), UNKNOWN);
}
}
}
StringBuilder formatSql = new StringBuilder()
.append(" mappedStatementId - ID:").append(mappedStatementId)
.append(StringPool.NEWLINE).append("Execute SQL:").append(sql);
return formatSql.toString();
}
/**
*
* @author 毅航
* @date 2024/7/7 9:14
*/
private static String getParameterValue(Object obj) {
// 直接返回空字符串将避免在 SQL 查询中加入不必要的单引号,从而保持查询的正确性。
if (obj == null) {
return "";
}
String stringValue = obj.toString();
// 对于非空字符串,我们添加单引号以满足以满足参数优化的需求。
return "'" + stringValue + "'";
}
为了读者能快速理解上述拦截器的原理,笔者
在此上述代码中的generateCompleteSql
的处理逻辑进行简单的分析。
首先,generateCompleteSql
方法的主要目的是生成一个完整的、可读性高的Sql
语句,其它接收两个参数:MappedStatement
对象和parameter
(参数对象)。其内部逻辑如下:
-
获取SQL语句和基本信息:
mappedStatementId
存储了MappedStatement
的ID,这通常与MyBatis中的映射语句相关联。- 通过
mappedStatement.getBoundSql(parameter)
获取BoundSql
对象,其中包含了未解析的SQL语句和参数映射信息。
-
格式化SQL语句:
- 调用
SqlFormatter.format()
方法来格式化SQL语句,增加可读性。
- 调用
-
准备参数信息:
- 从
BoundSql
中提取参数映射列表parameterMappings
和参数对象parameterObject
。 - 检查参数映射列表是否非空且参数对象非空,这是进行参数替换的前提。
- 从
-
参数替换:
- 遍历参数映射列表,使用正则表达式和字符串操作,将SQL语句中的
?
占位符替换为特定的占位符(如#{param0}
)。 - 利用
configuration.newMetaObject(parameterObject)
创建MetaObject
,用于访问参数对象的属性。 - 对于每个参数映射,尝试通过
MetaObject
获取属性值或通过BoundSql
的附加参数信息获取值,然后将这些值转换为字符串形式,再替换到SQL语句中。 - 如果属性值无法通过上述方式获取,则将占位符替换为预定义的未知标识符
UNKNOWN
。
- 遍历参数映射列表,使用正则表达式和字符串操作,将SQL语句中的
-
构建并返回完整SQL语句:
- 最后,构造一个字符串,包含
mappedStatementId
和最终的SQL语句,便于日志记录或调试。 - 返回这个字符串作为函数的结果。
- 最后,构造一个字符串,包含
将上述拦截器
注入Spring
容器,
java
@Configuration
public class MybatisConfigBean {
@Bean
public SqlInterceptor addMybatisInterceptor() {
return new SqlInterceptor();
}
}
启动SpringBoot
应用,然后执行相关sql
时,可以看到控制台有如下输出:
shell
2024-07-07 10:11:46.407 INFO 19076 --- [nio-8080-exec-9] com.example.Interceptor.SqlInterceptor
: sql执行信息:[ mappedStatementId - ID:com.example.dao.UserMapper.selectByUserName
Execute SQL:select
user_name userName ,
age
from
t_user
where
user_name = 'zhangSan?'
and remark = 'test1']
至此,我们就利用MyBatis
对外暴露出的Interceptor
接口,手动实现一个能优雅地打印完整sql
日志的拦截器!
总结
本文首先对Mybatis
内置sql
打印机制进行了分析,深入阐述了其所面临痛点,然后对Mybatis
的拦截器机制进行了深入介绍,并借助拦截器截止,实现了一款可以完整打印sql
的拦截器!