diff --git a/README.md b/README.md index a62239d14..f31c02b89 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ Topiam IAM/IDaaS身份管理平台 - https://www.topiam.cn/
| Redis客户端 | 采用 Redisson Redis官方推荐 基于Netty的客户端工具
支持Redis 90%以上的命令 底层优化规避很多不正确的用法 例如: keys被转换为scan
支持单机、哨兵、单主集群、多主集群等模式 | Lettuce + RedisTemplate 支持模式少 工具使用繁琐
连接池采用 common-pool Bug多经常性出问题 | | 缓存注解 | 采用 Spring-Cache 注解 对其扩展了实现支持了更多功能
例如 过期时间 最大空闲时间 组最大长度等 只需一个注解即可完成数据自动缓存 | 需手动编写Redis代码逻辑 | | ORM框架 | 采用 Mybatis-Plus 基于对象几乎不用写SQL全java操作 功能强大插件众多
例如分页插件 乐观锁插件等等 | 采用 Mybatis 基于XML需要手写SQL | -| SQL监控 | 采用 p6spy 可输出完整SQL与执行时间监控 | log输出 需手动拼接sql与参数无法快速查看调试问题 | +| SQL监控 | 内置 MyBatis 完整 SQL 输出工具,可输出完整SQL、Mapper ID与执行时间监控 | log输出 需手动拼接sql与参数无法快速查看调试问题 | | 数据分页 | 采用 Mybatis-Plus 分页插件
框架对其进行了扩展 对象化分页对象 支持多种方式传参 支持前端多排序 复杂排序 | 采用 PageHelper 仅支持单查询分页 参数只能从param传 只能单排序 功能扩展性差 体验不好 | | 数据权限 | 采用 Mybatis-Plus 插件 自行分析拼接SQL 无感式过滤
只需为Mapper设置好注解条件 支持多种自定义 不限于部门角色 | 采用 注解+aop 实现 基于部门角色 生成的sql兼容性差 不支持其他业务扩展
生成sql后需手动拼接到具体业务sql上 对于多个Mapper查询不起作用 | | 数据脱敏 | 采用 注解 + jackson 序列化期间脱敏 支持不同模块不同的脱敏条件
支持多种策略 如身份证、手机号、地址、邮箱、银行卡等 可自行扩展 | 无 | diff --git a/pom.xml b/pom.xml index 65bf1b477..48fd0963c 100644 --- a/pom.xml +++ b/pom.xml @@ -30,7 +30,6 @@ 3.5.16 1.5.7 4.5.0 - 3.9.1 8.7.3-20260306 3.0.2 7.17.28 @@ -242,13 +241,6 @@ ${mybatis-plus-join.version} - - - p6spy - p6spy - ${p6spy.version} - - software.amazon.awssdk diff --git a/ruoyi-admin/src/main/resources/application-dev.yml b/ruoyi-admin/src/main/resources/application-dev.yml index bdd5c3f86..90173874c 100644 --- a/ruoyi-admin/src/main/resources/application-dev.yml +++ b/ruoyi-admin/src/main/resources/application-dev.yml @@ -61,14 +61,20 @@ snail-ai: read-timeout-ms: 60000 chat-timeout-ms: 300000 +--- # MyBatis Plus 配置 +mybatis-plus: + sql-log: + # 完整 SQL 输出开关 + enabled: true + # 输出方式,可选 console、log + output: console + --- # 数据源配置 spring: datasource: type: com.zaxxer.hikari.HikariDataSource # 动态数据源文档 https://www.kancloud.cn/tracy5546/dynamic-datasource/content dynamic: - # 性能分析插件(有性能损耗 不建议生产环境使用) - p6spy: true # 设置默认的数据源或者数据源组,默认值即为 master primary: master # 严格模式 匹配不到数据源则报错 diff --git a/ruoyi-admin/src/main/resources/application-prod.yml b/ruoyi-admin/src/main/resources/application-prod.yml index cbecae3e4..5c95799cb 100644 --- a/ruoyi-admin/src/main/resources/application-prod.yml +++ b/ruoyi-admin/src/main/resources/application-prod.yml @@ -64,14 +64,20 @@ snail-ai: read-timeout-ms: 60000 chat-timeout-ms: 300000 +--- # MyBatis Plus 配置 +mybatis-plus: + sql-log: + # 完整 SQL 输出开关 + enabled: false + # 输出方式,可选 console、log + output: console + --- # 数据源配置 spring: datasource: type: com.zaxxer.hikari.HikariDataSource # 动态数据源文档 https://www.kancloud.cn/tracy5546/dynamic-datasource/content dynamic: - # 性能分析插件(有性能损耗 不建议生产环境使用) - p6spy: false # 设置默认的数据源或者数据源组,默认值即为 master primary: master # 严格模式 匹配不到数据源则报错 diff --git a/ruoyi-common/ruoyi-common-mybatis/pom.xml b/ruoyi-common/ruoyi-common-mybatis/pom.xml index f2bf5d8c9..af3e623f1 100644 --- a/ruoyi-common/ruoyi-common-mybatis/pom.xml +++ b/ruoyi-common/ruoyi-common-mybatis/pom.xml @@ -58,11 +58,6 @@ mybatis-plus-join-boot-starter - - - p6spy - p6spy - diff --git a/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/config/MybatisPlusConfig.java b/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/config/MybatisPlusConfig.java index 9b1a610c6..36992b4f3 100644 --- a/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/config/MybatisPlusConfig.java +++ b/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/config/MybatisPlusConfig.java @@ -10,12 +10,16 @@ import com.baomidou.mybatisplus.extension.plugins.inner.OptimisticLockerInnerInt import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; import org.dromara.common.core.factory.YmlPropertySourceFactory; import org.dromara.common.mybatis.aspect.DataPermissionPointcutAdvisor; +import org.dromara.common.mybatis.config.properties.SqlLogProperties; import org.dromara.common.mybatis.handler.InjectionMetaObjectHandler; import org.dromara.common.mybatis.handler.MybatisExceptionHandler; import org.dromara.common.mybatis.handler.PlusPostInitTableInfoHandler; import org.dromara.common.mybatis.interceptor.PlusDataPermissionInterceptor; +import org.dromara.common.mybatis.interceptor.SqlLogInterceptor; import org.mybatis.spring.annotation.MapperScan; import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.PropertySource; import org.springframework.context.annotation.Role; @@ -30,6 +34,7 @@ import org.springframework.transaction.annotation.EnableTransactionManagement; @EnableTransactionManagement(proxyTargetClass = true) @MapperScan("${mybatis-plus.mapperPackage}") @PropertySource(value = "classpath:common-mybatis.yml", factory = YmlPropertySourceFactory.class) +@EnableConfigurationProperties(SqlLogProperties.class) public class MybatisPlusConfig { /** @@ -82,6 +87,15 @@ public class MybatisPlusConfig { return new OptimisticLockerInnerInterceptor(); } + /** + * 完整 SQL 日志拦截器 + */ + @Bean + @ConditionalOnProperty(prefix = "mybatis-plus.sql-log", name = "enabled", havingValue = "true") + public SqlLogInterceptor sqlLogInterceptor(SqlLogProperties sqlLogProperties) { + return new SqlLogInterceptor(sqlLogProperties); + } + /** * 元对象字段填充控制器 */ diff --git a/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/config/properties/SqlLogProperties.java b/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/config/properties/SqlLogProperties.java new file mode 100644 index 000000000..fe07f0105 --- /dev/null +++ b/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/config/properties/SqlLogProperties.java @@ -0,0 +1,25 @@ +package org.dromara.common.mybatis.config.properties; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * SQL 日志配置。 + * + * @author Lion Li + */ +@Data +@ConfigurationProperties(prefix = "mybatis-plus.sql-log") +public class SqlLogProperties { + + /** + * 是否开启完整 SQL 输出。 + */ + private Boolean enabled = false; + + /** + * 输出方式,可选 console、log。 + */ + private String output = "console"; + +} diff --git a/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/interceptor/SqlLogInterceptor.java b/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/interceptor/SqlLogInterceptor.java new file mode 100644 index 000000000..6470860af --- /dev/null +++ b/ruoyi-common/ruoyi-common-mybatis/src/main/java/org/dromara/common/mybatis/interceptor/SqlLogInterceptor.java @@ -0,0 +1,361 @@ +package org.dromara.common.mybatis.interceptor; + +import cn.hutool.core.date.DateUtil; +import cn.hutool.core.util.StrUtil; +import com.baomidou.mybatisplus.core.toolkit.PluginUtils; +import lombok.extern.slf4j.Slf4j; +import org.apache.ibatis.executor.statement.StatementHandler; +import org.apache.ibatis.mapping.BoundSql; +import org.apache.ibatis.mapping.MappedStatement; +import org.apache.ibatis.mapping.ParameterMapping; +import org.apache.ibatis.mapping.ParameterMode; +import org.apache.ibatis.plugin.Interceptor; +import org.apache.ibatis.plugin.Intercepts; +import org.apache.ibatis.plugin.Invocation; +import org.apache.ibatis.plugin.Signature; +import org.apache.ibatis.reflection.MetaObject; +import org.apache.ibatis.session.Configuration; +import org.apache.ibatis.session.ResultHandler; +import org.apache.ibatis.type.TypeHandlerRegistry; +import org.dromara.common.mybatis.config.properties.SqlLogProperties; + +import java.lang.reflect.InvocationTargetException; +import java.sql.Statement; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.temporal.TemporalAccessor; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.locks.ReentrantLock; + +/** + * 完整 SQL 日志拦截器。 + * + * @author Lion Li + */ +@Slf4j(topic = "SQL_FULL") +@Intercepts({ + @Signature(type = StatementHandler.class, method = "query", args = {Statement.class, ResultHandler.class}), + @Signature(type = StatementHandler.class, method = "update", args = {Statement.class}), + @Signature(type = StatementHandler.class, method = "batch", args = {Statement.class}), + @Signature(type = StatementHandler.class, method = "queryCursor", args = {Statement.class}) +}) +public class SqlLogInterceptor implements Interceptor { + + /** + * 单条日志分片长度。 + */ + private static final int CHUNK_SIZE = 8000; + + /** + * SQL 空白字符匹配。 + */ + private static final String BLANK_REGEX = "\\s+"; + + /** + * 日期时间格式。 + */ + private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + /** + * 日期格式。 + */ + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + /** + * 时间格式。 + */ + private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HH:mm:ss"); + + /** + * 控制台输出锁,避免多线程 SQL 日志互相穿插。 + */ + private static final ReentrantLock CONSOLE_LOCK = new ReentrantLock(); + + /** + * SQL 日志配置。 + */ + private final SqlLogProperties sqlLogProperties; + + public SqlLogInterceptor(SqlLogProperties sqlLogProperties) { + this.sqlLogProperties = sqlLogProperties; + } + + @Override + public Object intercept(Invocation invocation) throws Throwable { + StatementHandler statementHandler = PluginUtils.realTarget(invocation.getTarget()); + BoundSql boundSql = statementHandler.getBoundSql(); + MappedStatement mappedStatement = PluginUtils.mpStatementHandler(statementHandler).mappedStatement(); + long startTime = System.currentTimeMillis(); + try { + Object result = invocation.proceed(); + printSql(mappedStatement, boundSql, System.currentTimeMillis() - startTime, null); + return result; + } catch (Throwable e) { + printSql(mappedStatement, boundSql, System.currentTimeMillis() - startTime, e); + throw e; + } + } + + /** + * 输出 SQL。 + * + * @param mappedStatement 映射语句 + * @param boundSql 绑定 SQL + * @param elapsedTime 执行耗时 + * @param throwable 执行异常 + */ + private void printSql(MappedStatement mappedStatement, BoundSql boundSql, long elapsedTime, Throwable throwable) { + String fullSql = buildFullSql(mappedStatement.getConfiguration(), boundSql); + String message = buildLogMessage(mappedStatement, elapsedTime, fullSql, throwable); + printChunk(message); + } + + /** + * 构建日志内容。 + * + * @param mappedStatement 映射语句 + * @param elapsedTime 执行耗时 + * @param fullSql 完整 SQL + * @param throwable 执行异常 + * @return 日志内容 + */ + private String buildLogMessage(MappedStatement mappedStatement, long elapsedTime, String fullSql, Throwable throwable) { + if (isLogOutput()) { + String message = StrUtil.format("Consume Time:{} ms {} Mapper ID:{} Execute SQL:{}", + elapsedTime, DateUtil.now(), mappedStatement.getId(), fullSql); + String errorMessage = formatThrowable(throwable); + if (StrUtil.isNotBlank(errorMessage)) { + message = message + " Execute Error:" + errorMessage; + } + return message; + } + String message = StrUtil.format("Consume Time:{} ms {}\nMapper ID:{}\nExecute SQL:{}", + elapsedTime, DateUtil.now(), mappedStatement.getId(), fullSql); + String errorMessage = formatThrowable(throwable); + if (StrUtil.isNotBlank(errorMessage)) { + message = message + "\nExecute Error:" + errorMessage; + } + return message; + } + + /** + * 格式化执行异常。 + * + * @param throwable 执行异常 + * @return 异常信息 + */ + private String formatThrowable(Throwable throwable) { + if (throwable == null) { + return StrUtil.EMPTY; + } + Throwable realThrowable = unwrapThrowable(throwable); + String message = realThrowable.getMessage(); + if (StrUtil.isBlank(message)) { + return StrUtil.EMPTY; + } + return realThrowable.getClass().getName() + ": " + message; + } + + /** + * 解包反射调用异常。 + * + * @param throwable 执行异常 + * @return 实际异常 + */ + private Throwable unwrapThrowable(Throwable throwable) { + if (throwable instanceof InvocationTargetException invocationTargetException + && invocationTargetException.getTargetException() != null) { + return invocationTargetException.getTargetException(); + } + return throwable; + } + + /** + * 构建完整 SQL。 + * + * @param configuration MyBatis 配置 + * @param boundSql 绑定 SQL + * @return 完整 SQL + */ + private String buildFullSql(Configuration configuration, BoundSql boundSql) { + String sql = boundSql.getSql().replaceAll(BLANK_REGEX, " ").trim(); + List parameters = buildParameterValues(configuration, boundSql); + return replacePlaceholders(sql, parameters); + } + + /** + * 构建参数值集合。 + * + * @param configuration MyBatis 配置 + * @param boundSql 绑定 SQL + * @return 参数值集合 + */ + private List buildParameterValues(Configuration configuration, BoundSql boundSql) { + List parameterMappings = boundSql.getParameterMappings(); + List parameters = new ArrayList<>(parameterMappings.size()); + Object parameterObject = boundSql.getParameterObject(); + TypeHandlerRegistry typeHandlerRegistry = configuration.getTypeHandlerRegistry(); + MetaObject metaObject = parameterObject == null ? null : configuration.newMetaObject(parameterObject); + for (ParameterMapping parameterMapping : parameterMappings) { + if (parameterMapping.getMode() == ParameterMode.OUT) { + continue; + } + String propertyName = parameterMapping.getProperty(); + Object value; + if (boundSql.hasAdditionalParameter(propertyName)) { + value = boundSql.getAdditionalParameter(propertyName); + } else if (parameterObject == null) { + value = null; + } else if (typeHandlerRegistry.hasTypeHandler(parameterObject.getClass())) { + value = parameterObject; + } else if (metaObject != null && metaObject.hasGetter(propertyName)) { + value = metaObject.getValue(propertyName); + } else { + value = null; + } + parameters.add(formatParameter(value)); + } + return parameters; + } + + /** + * 替换 SQL 占位符。 + * + * @param sql SQL 模板 + * @param parameters 参数集合 + * @return 完整 SQL + */ + private String replacePlaceholders(String sql, List parameters) { + if (parameters.isEmpty()) { + return sql; + } + StringBuilder builder = new StringBuilder(sql.length() + parameters.size() * 8); + int parameterIndex = 0; + boolean inSingleQuote = false; + boolean inDoubleQuote = false; + for (int i = 0; i < sql.length(); i++) { + char current = sql.charAt(i); + if (current == '\'' && !inDoubleQuote) { + inSingleQuote = !inSingleQuote; + } else if (current == '"' && !inSingleQuote) { + inDoubleQuote = !inDoubleQuote; + } + if (current == '?' && !inSingleQuote && !inDoubleQuote && parameterIndex < parameters.size()) { + builder.append(parameters.get(parameterIndex++)); + } else { + builder.append(current); + } + } + return builder.toString(); + } + + /** + * 格式化参数值。 + * + * @param value 参数值 + * @return SQL 参数文本 + */ + private String formatParameter(Object value) { + if (value == null) { + return "null"; + } + if (value instanceof Number || value instanceof Boolean) { + return value.toString(); + } + if (value instanceof Date date) { + return quote(DateUtil.formatDateTime(date)); + } + if (value instanceof LocalDateTime localDateTime) { + return quote(localDateTime.format(DATE_TIME_FORMATTER)); + } + if (value instanceof LocalDate localDate) { + return quote(localDate.format(DATE_FORMATTER)); + } + if (value instanceof LocalTime localTime) { + return quote(localTime.format(TIME_FORMATTER)); + } + if (value instanceof TemporalAccessor) { + return quote(value.toString()); + } + if (value instanceof Enum enumValue) { + return quote(enumValue.name()); + } + return quote(value.toString()); + } + + /** + * 包装字符串参数。 + * + * @param value 字符串值 + * @return SQL 字符串参数 + */ + private String quote(String value) { + return "'" + value.replace("'", "''") + "'"; + } + + /** + * 分片输出日志,避免日志链路截断超长 SQL。 + * + * @param message 日志内容 + */ + private void printChunk(String message) { + if (!isLogOutput()) { + printConsole(message); + return; + } + if (message.length() <= CHUNK_SIZE) { + print(message); + return; + } + String sqlLogId = UUID.randomUUID().toString(); + int total = (message.length() + CHUNK_SIZE - 1) / CHUNK_SIZE; + for (int i = 0; i < total; i++) { + int start = i * CHUNK_SIZE; + int end = Math.min(start + CHUNK_SIZE, message.length()); + print(StrUtil.format("sqlLogId={} part={}/{} {}", sqlLogId, i + 1, total, message.substring(start, end))); + } + } + + /** + * 输出 SQL 日志。 + * + * @param message 日志内容 + */ + private void print(String message) { + if (isLogOutput()) { + log.info(message); + return; + } + printConsole(message); + } + + /** + * 输出控制台日志。 + * + * @param message 日志内容 + */ + private void printConsole(String message) { + CONSOLE_LOCK.lock(); + try { + System.err.println(message); + System.err.println(); + } finally { + CONSOLE_LOCK.unlock(); + } + } + + /** + * 是否使用日志系统输出。 + * + * @return true 使用日志系统输出,false 使用控制台输出 + */ + private boolean isLogOutput() { + return StrUtil.equalsIgnoreCase("log", sqlLogProperties.getOutput()); + } + +} diff --git a/ruoyi-common/ruoyi-common-mybatis/src/main/resources/common-mybatis.yml b/ruoyi-common/ruoyi-common-mybatis/src/main/resources/common-mybatis.yml index 14662e16a..247f4a80a 100644 --- a/ruoyi-common/ruoyi-common-mybatis/src/main/resources/common-mybatis.yml +++ b/ruoyi-common/ruoyi-common-mybatis/src/main/resources/common-mybatis.yml @@ -13,9 +13,7 @@ mybatis-plus: # MyBatis 自动映射时未知列或未知属性处理策 # NONE:不做处理 WARNING:打印相关警告 FAILING:抛出异常和详细信息 autoMappingUnknownColumnBehavior: NONE - # 更详细的日志输出 会有性能损耗 org.apache.ibatis.logging.stdout.StdOutImpl - # 关闭日志记录 (可单纯使用 p6spy 分析) org.apache.ibatis.logging.nologging.NoLoggingImpl - # 默认日志输出 org.apache.ibatis.logging.slf4j.Slf4jImpl + # 关闭默认日志输出 org.apache.ibatis.logging.nologging.NoLoggingImpl logImpl: org.apache.ibatis.logging.nologging.NoLoggingImpl global-config: # 是否打印 Logo banner diff --git a/ruoyi-common/ruoyi-common-mybatis/src/main/resources/spy.properties b/ruoyi-common/ruoyi-common-mybatis/src/main/resources/spy.properties deleted file mode 100644 index f3ed7d8eb..000000000 --- a/ruoyi-common/ruoyi-common-mybatis/src/main/resources/spy.properties +++ /dev/null @@ -1,20 +0,0 @@ -# p6spy 性能分析插件配置文件 -modulelist=com.baomidou.mybatisplus.extension.p6spy.MybatisPlusLogFactory,com.p6spy.engine.outage.P6OutageFactory -# 自定义日志打印 -logMessageFormat=com.baomidou.mybatisplus.extension.p6spy.P6SpyLogger -#日志输出到控制台 -appender=com.baomidou.mybatisplus.extension.p6spy.StdoutLogger -# 使用日志系统记录 sql -#appender=com.p6spy.engine.spy.appender.Slf4JLogger -# 取消JDBC URL前缀 -useprefix=true -# 配置记录 Log 例外,可去掉的结果集有error,info,batch,debug,statement,commit,rollback,result,resultset. -excludecategories=info,debug,result,commit,resultset -# 日期格式 -dateformat=yyyy-MM-dd HH:mm:ss -# SQL语句打印时间格式 -databaseDialectTimestampFormat=yyyy-MM-dd HH:mm:ss -# 是否过滤 Log -filter=true -# 过滤 Log 时所排除的 sql 关键字,以逗号分隔 -exclude=