mybatis log sql 生成

36 阅读5分钟

mybatis log sql 生成

虽然 idea 中默认有 sql 格式化功能,但是生产中有 sql debug 日志, 每次都要填充参数,每次都要格式化,非常麻烦.

使用 thymeleaf

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-thymeleaf</artifactId>
    </dependency>
    <dependency>
        <groupId>cn.hutool</groupId>
        <artifactId>hutool-all</artifactId>
        <version>5.8.38</version>
    </dependency>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <version>1.18.42</version>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

<build>
    <finalName>sqlformatter</finalName>
    <plugins>
        <plugin>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-maven-plugin</artifactId>
            <configuration>
                <!-- 替换为你的主类全限定名,例如 com.example.Application -->
                <mainClass>com.example.coop.BootCoopApplication</mainClass>
            </configuration>
            <executions>
                <execution>
                    <goals>
                        <goal>repackage</goal>
                    </goals>
                </execution>
            </executions>
        </plugin>
    </plugins>
</build>

接口实现

@RestController
public class SqlGenController {

    @Autowired
    private SqlGenService sqlGenService;

    @PostMapping("/mybatis/sqllog/format")
    public String genSql(@RequestBody LogDto dto) {
        if (StrUtil.isBlank(dto.getData())) {
            throw new RuntimeException("数据不能为空");
        }
        return sqlGenService.genSql(dto.getData());
    }
    
}

//返回页面
@Controller
public class SqlFormatController {

    @GetMapping("/sql-format")
    public String index() {
        return "mybatis-sql-formatter";
    }
}

//service 实现
@Service
public class SqlGenService {

    private static final Pattern PARAM_PATTERN = Pattern.compile("([^,]+?)\\s*\\(([^)]+)\\)");
    // 常见换行符模式
    public static final String PREPARING = "Preparing";
    public static final String PARAMETERS = "Parameters";

    // 匹配 Preparing: 后面的内容
    private static final Pattern PREPARING_PATTERN =
            Pattern.compile("Preparing:\\s*(.+?)(?:\\s*Parameters:|$)", Pattern.CASE_INSENSITIVE | Pattern.DOTALL);

    // 匹配 Parameters: 后面的内容
    private static final Pattern PARAMETERS_PATTERN =
            Pattern.compile("Parameters:\\s*(.+?)(?:\\s*Preparing:|$)", Pattern.CASE_INSENSITIVE | Pattern.DOTALL);


    public String genSql(String data) {
        Pair<String, String> pair = splitSql(data);
        List<String> params = parseParameters(pair.getValue());
        return replacePlaceholders(pair.getKey(), params);
    }

    /**
     * 按顺序替换 Preparing 中的 ? 占位符
     */
    private static String replacePlaceholders(String preparing, List<String> paramList) {
        StringBuilder sqlBuilder = new StringBuilder(preparing);
        int placeholderIndex = 0; // 当前要替换的参数索引

        for (int i = 0; i < sqlBuilder.length() && placeholderIndex < paramList.size(); i++) {
            // 找到 ? 占位符
            if (sqlBuilder.charAt(i) == '?') {
                // 替换为格式化后的参数
                sqlBuilder.replace(i, i + 1, paramList.get(placeholderIndex));
                placeholderIndex++;
            }
        }

        return sqlBuilder.toString();
    }


    private static List<String> parseParameters(String parameters) {
        List<String> paramList = new ArrayList<>();
        Matcher matcher = PARAM_PATTERN.matcher(parameters.trim());

        while (matcher.find()) {
            String value = matcher.group(1).trim(); // 参数值(如:18、'2025-01-01'、null)
            String type = matcher.group(2).trim();  // 参数类型(如:Integer、Timestamp、String)
            // 根据类型格式化参数
            String formattedParam = formatParam(value, type);
            paramList.add(formattedParam);
        }

        return paramList;
    }

    /**
     * 根据参数类型格式化参数(核心:适配不同数据类型的 SQL 语法)
     */
    private static String formatParam(String value, String type) {
        // 1. 处理 NULL 值(无论类型,直接返回 NULL)
        if (value == null || value.equalsIgnoreCase("null")) {
            return "NULL";
        }

        // 2. 数值类型(整数、浮点数、布尔)
        if (isNumericType(type)) {
            // 布尔类型:MySQL 用 1/0,其他数据库可调整
            if (type.equalsIgnoreCase("Boolean")) {
                return value.equalsIgnoreCase("true") ? "1" : "0";
            }
            // 整数/浮点数:直接返回值(无需加引号)
            return value;
        }

        // 3. 字符串/日期/枚举类型(需加单引号,处理单引号转义)
        // 转义规则:将单个单引号 ' 替换为 ''(数据库标准转义)
        String escapedValue = value.replace("'", "''");
        return "'" + escapedValue + "'";
    }

    /**
     * 判断是否为数值类型(无需加引号)
     */
    private static boolean isNumericType(String type) {
        return type.equalsIgnoreCase("Integer")
                || type.equalsIgnoreCase("Long")
                || type.equalsIgnoreCase("Double")
                || type.equalsIgnoreCase("Float")
                || type.equalsIgnoreCase("Short")
                || type.equalsIgnoreCase("Byte")
                || type.equalsIgnoreCase("Boolean")
                || type.equalsIgnoreCase("BigDecimal");
    }

    private Pair<String, String> splitSql(String data) {
        String[] split = data.split("\\r?\\n|\\r");
        if (split.length == 0) {
            throw new RuntimeException("只支持一组Preparing和Parameters,使用换行区分");
        }
        String[] array = Arrays.stream(split).filter(StrUtil::isNotBlank).toArray(String[]::new);
        if (array.length != 2) {
            throw new RuntimeException("只支持一组Preparing和Parameters,使用换行区分");
        }

        if (!array[0].trim().contains(PREPARING)) {
            throw new RuntimeException("第一行必须是Preparing开头");
        }
        if (!array[1].trim().contains(PARAMETERS)) {
            throw new RuntimeException("第二行必须是Parameters开头");
        }
        String preparing = extractPreparing(array[0]);
        String parameters = extractParameters(array[1]);
        if (StrUtil.isBlank(preparing) || StrUtil.isBlank(parameters)) {
            throw new RuntimeException("要生成的SQL数据格式错误");
        }
        return Pair.of(preparing, parameters);
    }

    /**
     * 提取 Preparing: 后面的 SQL 语句
     */
    public static String extractPreparing(String logText) {
        Matcher matcher = PREPARING_PATTERN.matcher(logText);
        if (matcher.find()) {
            return matcher.group(1).trim();
        }
        return "";
    }

    /**
     * 提取 Parameters: 后面的参数列表
     */
    public static String extractParameters(String logText) {
        Matcher matcher = PARAMETERS_PATTERN.matcher(logText);
        if (matcher.find()) {
            return matcher.group(1).trim();
        }
        return "";
    }
}


@Data
public class LogDto {
    private String data;
}


其他:

@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(value = Exception.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public ResponseResult<String> exceptionHandler(Exception e) {
        log.info("internal.error: ", e);
        return ResponseResult.fail(e.getMessage());
    }

    @ExceptionHandler(value = RuntimeException.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public ResponseResult<String> runTimeHandler(RuntimeException e) {
        log.info("runTimeHandler.error: ", e);
        return ResponseResult.fail(e.getMessage());
    }

}

@Component
@ControllerAdvice
public class ResponseBodyWriteAdvice implements ResponseBodyAdvice<Object> {
    private ObjectMapper objectMapper;

    public ResponseBodyWriteAdvice(ObjectMapper objectMapper) {
        this.objectMapper = objectMapper;
    }

    @Override
    public boolean supports(MethodParameter returnType, Class<? extends HttpMessageConverter<?>> converterType) {
        return true;
    }

    @Override
    public Object beforeBodyWrite(Object body, MethodParameter returnType, MediaType selectedContentType, Class<? extends HttpMessageConverter<?>> selectedConverterType, ServerHttpRequest request, ServerHttpResponse response) {
        if (body instanceof ResponseResult) {
            return body;
        } else if (body instanceof String) {
            // 将 Content-Type 设为 application/json,返回类型是String时,默认 Content-Type = text/plain
            HttpHeaders headers = response.getHeaders();
            // 设置返回 json 格式时,处理中文乱码
            headers.setContentType(MediaType.APPLICATION_JSON_UTF8);
            try {
                return objectMapper.writeValueAsString(ResponseResult.success(body));
            } catch (JsonProcessingException e) {
                throw new RuntimeException(e);
            }
        }
        return ResponseResult.success(body);
    }
}

@Data
public class ResponseResult<T> implements Serializable {
    public static final String SUC = "1";
    public static final String FAIL = "0";
    private String code;
    private String msg;
    private T data;

    public ResponseResult() {
    }

    public ResponseResult(String code, String msg, T data) {
        this.code = code;
        this.msg = msg;
        this.data = data;
    }

    @JsonIgnore
    public T getCheckedData() {
        if (!this.code.equals(SUC)) {
            throw new RuntimeException("调用异常");
        }
        return data;
    }

    public static <T> ResponseResult<T> success(T data) {
        return new ResponseResult<>(SUC, null, data);
    }

    public static <T> ResponseResult<T> fail(String msg) {
        return new ResponseResult<>(FAIL, msg, null);
    }

    public static <T> ResponseResult<T> fail(String code,String msg) {
        return new ResponseResult<>(code, msg, null);
    }

}

thymeleaf 模板

在 template 文件夹下创建 mybatis-sql-formatter.html, 页面由 deepseek 生成:

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>MyBatis SQL 日志格式化工具</title>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body {
            background: #2b2b2b;
            color: #ccc;
            font-family: Consolas, "Microsoft YaHei", monospace;
            padding: 20px;
        }
        .container {
            width: 100%;
            margin: 0 auto;
        }
        h2 {
            color: #2e7df7;
            margin-bottom: 20px;
            border-bottom: 1px solid #444;
            padding-bottom: 10px;
        }
        .input-area, .result-area {
            margin-bottom: 20px;
        }
        textarea {
            width: 100%;
            height: 200px;
            background: #333;
            color: #ccc;
            border: 1px solid #555;
            border-radius: 4px;
            padding: 10px;
            font-size: 14px;
            resize: vertical;
        }
        button {
            background: #2e7df7;
            color: white;
            border: none;
            border-radius: 4px;
            padding: 10px 20px;
            cursor: pointer;
            font-size: 16px;
            transition: background 0.3s;
        }
        button:hover {
            background: #1a68e0;
        }
        .result-area h3 {
            color: #fff;
            margin-bottom: 10px;
        }
        .help {
            margin-top: 30px;
            padding: 15px;
            background: #3a3a3a;
            border-radius: 4px;
        }
        .help h3 {
            color: #2e7df7;
            margin-bottom: 10px;
        }
        .help p {
            line-height: 1.6;
            margin-bottom: 5px;
        }
        .error {
            color: #ff4d4f;
            margin: 10px 0;
            font-size: 14px;
        }
        .loading {
            color: #2e7df7;
            margin: 10px 0;
            display: none;
        }
    </style>
</head>
<body>
<div class="container">
    <h2>MyBatis SQL 日志格式化工具</h2>

    <!-- 日志输入区 -->
    <div class="input-area">
            <textarea id="logContent" placeholder="请粘贴 MyBatis 日志中的 Preparing 和 Parameters 相关行(示例如下):
[DEBUG] ==> Preparing: SELECT id FROM user WHERE age > ? AND name = ?
[DEBUG] ==> Parameters: 18(Integer), 张三(String)"></textarea>
        <div class="loading" id="loading">正在格式化...(请稍候)</div>
        <div class="error" id="errorMsg"></div>
        <button onclick="formatSql()">格式化 SQL</button>
    </div>

    <!-- 结果展示区 -->
    <div class="result-area" id="resultArea" style="display: none;">
        <h3>格式化结果(可直接复制执行,目前只支持 mysql):</h3>
        <textarea id="executableSql" readonly></textarea>
    </div>

    <!-- 使用帮助 -->
    <div class="help">
        <h3>使用帮助:</h3>
        <p>1. 复制日志中包含 <code>Preparing:</code><code>Parameters:</code> 的两行内容(可包含日志前缀,如 [DEBUG]);</p>
        <p>2. 粘贴到上方输入框,点击「格式化 SQL」按钮(无需刷新页面);</p>
        <p>3. 下方将显示可直接在数据库执行的 SQL,支持字符串、数值、日期、NULL 等类型自动适配;</p>
        <p>4. 支持特殊字符转义(如单引号 ' 自动转为 '')。</p>
    </div>
</div>
<!-- 引入依赖:sql-formatter(使用unpkg稳定CDN,指定具体版本) -->
<!--<script src="https://unpkg.com/sql-formatter@12.0.5/dist/sql-formatter.min.js"></script>
&lt;!&ndash; 引入 jQuery &ndash;&gt;
<script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.6.4/jquery.min.js"></script>-->
<!-- 引入 jQuery(CDN 方式,无需本地下载) -->
<script src="/js/jquery.min.js"></script>
<script src="/js/sql-formatter.min.js"></script>
<script>
    // AJAX 格式化 SQL
    function formatSql() {
        // 1. 获取输入值并清空状态
        const logContent = $("#logContent").val().trim();
        const $loading = $("#loading");
        const $errorMsg = $("#errorMsg");
        const $resultArea = $("#resultArea");
        const $executableSql = $("#executableSql");

        $errorMsg.text("");
        $resultArea.hide();
        $loading.show();

        // 2. 校验输入
        if (!logContent) {
            $loading.hide();
            $errorMsg.text("❌ 请输入日志内容!");
            return;
        }

        // 3. 发送 AJAX POST 请求(JSON 格式传参)
        $.ajax({
            url: "/mybatis/sqllog/format", // 后端 AJAX 接口地址
            type: "POST",
            contentType: "application/json", // 告诉后端请求体是 JSON
            data: JSON.stringify({ 'data': logContent }), // 构造 JSON 参数
            dataType: "json", // 期望后端返回 JSON
            success: function (response) {
                if (response.code) {
                    // 成功:显示结果
                    // 3. 前端格式化 SQL(自定义配置)
                    if (response.data){
                        var rawSql = response.data;
                        const formattedSql = sqlFormatter.format(rawSql, {
                            language: 'mysql', // 选择的 SQL 方言
                            indent: '  ', // 缩进:2个空格(可改为 '\t' 制表符)
                            uppercase: true, // 关键字大写(如 SELECT、FROM)
                            linesBetweenQueries: 1, // 查询之间的空行数
                            denseOperators: false // 运算符前后留空格(如 a + b 而非 a+b)
                        });
                        $executableSql.val(formattedSql);
                    }else {
                        $executableSql.val(response.data);
                    }
                    $resultArea.show();
                    // 自动选中结果(方便复制)
                    $executableSql[0].select();
                } else {
                    // 失败:显示错误信息
                    $errorMsg.text("❌ " + response.msg);
                }
                $loading.hide();
            },
            error: function (xhr, status, error) {
                $loading.hide();
                $errorMsg.text("❌ 网络错误:" + error + ",请刷新页面重试!");
            }
        });
    }

    // 快捷键支持:Ctrl+Enter 触发格式化
    $("#logContent").keydown(function (e) {
        if (e.ctrlKey && e.keyCode === 13) { // Ctrl+Enter
            formatSql();
        }
    });
</script>
</body>
</html>

配置

spring.application.name=sqlformatter
spring.web.resources.static-locations=classpath:/static/
spring.thymeleaf.encoding=UTF-8
server.port=9001

image.png