MyBatis Plus
MyBatis-Plus 是一个 MyBatis 的增强工具,在 MyBatis 的基础上只做增强不做改变,为简化开发、提高效率而生。
特性
- 无侵入:只做增强不做改变,引入它不会对现有工程产生影响,如丝般顺滑
- 损耗小:启动即会自动注入基本 CURD,性能基本无损耗,直接面向对象操作
- 强大的 CRUD 操作:内置通用 Mapper、通用 Service,仅仅通过少量配置即可实现单表大部分 CRUD 操作,更有强大的条件构造器,满足各类使用需求
- 支持 Lambda 形式调用:通过 Lambda 表达式,方便的编写各类查询条件,无需再担心字段写错
- 支持主键自动生成:支持多达 4 种主键策略(内含分布式唯一 ID 生成器 - Sequence),可自由配置,完美解决主键问题
- 支持 ActiveRecord 模式:支持 ActiveRecord 形式调用,实体类只需继承 Model 类即可进行强大的 CRUD 操作
- 支持自定义全局通用操作:支持全局通用方法注入( Write once, use anywhere )
- 内置代码生成器:采用代码或者 Maven 插件可快速生成 Mapper 、 Model 、 Service 、 Controller 层代码,支持模板引擎,更有超多自定义配置等您来使用
- 内置分页插件:基于 MyBatis 物理分页,开发者无需关心具体操作,配置好插件之后,写分页等同于普通 List 查询
- 分页插件支持多种数据库:支持 MySQL、MariaDB、Oracle、DB2、H2、HSQL、SQLite、Postgre、SQLServer 等多种数据库
- 内置性能分析插件:可输出 SQL 语句以及其执行时间,建议开发测试时启用该功能,能快速揪出慢查询
- 内置全局拦截插件:提供全表 delete 、 update 操作智能分析阻断,也可自定义拦截规则,预防误操作
基础配置
添加依赖
参考官网链接:地址
添加属性
<!-- 项目属性 -->
<properties>
<mybatis-plus.version>3.5.10</mybatis-plus.version>
<druid.version>1.2.24</druid.version>
</properties>2
3
4
5
添加依赖管理
<!-- Spring Boot 依赖管理 -->
<dependencyManagement>
<dependencies>
<!-- MyBatis Plus 依赖管理 -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-bom</artifactId>
<version>${mybatis-plus.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>2
3
4
5
6
7
8
9
10
11
12
13
添加依赖
<!-- Mybatis Plus 数据库框架 -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
</dependency>
<!-- MyBatis Plus 增加工具包 -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-jsqlparser</artifactId>
</dependency>
<!-- MyBatis Plus 代码生成器 -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-generator</artifactId>
</dependency>
<!-- 代码生成器的模板引擎 VelocityTemplateEngine -->
<dependency>
<groupId>org.apache.velocity</groupId>
<artifactId>velocity-engine-core</artifactId>
<version>2.3</version>
</dependency>
<!-- 代码生成器的模板引擎 FreemarkerTemplateEngine -->
<!--<dependency>
<groupId>org.freemarker</groupId>
<artifactId>freemarker</artifactId>
<version>2.3.31</version>
</dependency>-->
<!-- MyBatis-Plus SQL分析与打印功能 -->
<!-- 有性能损耗,在生产环境中谨慎使用 -->
<dependency>
<groupId>com.github.gavlyukovskiy</groupId>
<artifactId>p6spy-spring-boot-starter</artifactId>
<version>1.10.0</version>
</dependency>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
添加数据源依赖
以下任选一种数据库即可
- HikariCP
Mybatis-Plus依赖中默认已经包含了该依赖(在spring-boot-starter-jdbc中)
<!-- HikariCP 数据源 依赖 -->
<dependency>
<groupId>com.zaxxer</groupId>
<artifactId>HikariCP</artifactId>
</dependency>2
3
4
5
- Druid
<!-- Druid 数据源: 高性能数据库连接池 -->
<!-- https://mvnrepository.com/artifact/com.alibaba/druid-spring-boot-starter -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-3-starter</artifactId>
<version>${druid.version}</version>
</dependency>2
3
4
5
6
7
添加数据库驱动
根据实际情况选择数据库驱动
- MySQL
URL: jdbc:mysql://192.168.1.10:35725/kongyu
<!-- MySQL数据库驱动 -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
</dependency>2
3
4
5
- PostgreSQL
URL: jdbc:postgresql://192.168.1.10:32297/kongyu?currentSchema=public&stringtype=unspecified
<!-- Postgresql数据库驱动 -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
</dependency>2
3
4
5
- 达梦数据库
jdbc:dm://10.1.244.201:20026?schema=traffic_visualize&compatibleMode=mysql&characterEncoding=UTF-8&useUnicode=true&useSSL=false&tinyInt1isBit=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai
<!-- 达梦数据库驱动 -->
<dependency>
<groupId>com.dameng</groupId>
<artifactId>DmJdbcDriver18</artifactId>
<version>8.1.3.140</version>
</dependency>2
3
4
5
6
编辑配置文件
编辑 application.yml 配置文件
---
# 数据库的相关配置
spring:
datasource:
url: jdbc:mysql://192.168.1.10:35725/kongyu # MySQL数据库连接URL
#url: jdbc:postgresql://192.168.1.10:32297/kongyu?currentSchema=public&stringtype=unspecified # PostgreSQL数据库连接URL
username: root # 数据库用户名
password: Admin@123 # 数据库密码
# driver-class-name: com.mysql.cj.jdbc.Driver # 数据库驱动类,框架会自动适配
type: com.alibaba.druid.pool.DruidDataSource # 使用Druid数据源
# Druid连接池配置 https://github.com/alibaba/druid/tree/master/druid-spring-boot-starter
druid:
initial-size: 10 # 初始化连接池大小
min-idle: 10 # 最小空闲连接数
max-active: 1000 # 最大活跃连接数
max-wait: 10000 # 获取连接的最大等待时间,单位毫秒
async-init: true
# Mybatis Plus的配置 https://baomidou.com/reference
mybatis-plus:
global-config:
banner: false
configuration:
log-impl: org.apache.ibatis.logging.nologging.NoLoggingImpl2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
如果使用的是 HikariCP ,配置如下
spring:
datasource:
# ...
type: com.zaxxer.hikari.HikariDataSource # 使用 HikariCP 数据源
hikari:
maximum-pool-size: 1000 # 最大连接池大小
minimum-idle: 10 # 最小空闲连接数
idle-timeout: 30000 # 空闲连接超时时间,单位毫秒
connection-timeout: 30000 # 获取连接的最大等待时间,单位毫秒2
3
4
5
6
7
8
9
编辑 Configuration 文件
创建配置
创建 MyBatisPlusConfiguration
分页插件的DbType需要根据实际数据库类型填写,我这里是MySQL
package local.ateng.java.mybatis.config;
import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
@MapperScan("local.ateng.java.mybatis.**.mapper")
public class MyBatisPlusConfiguration {
/**
* 添加分页插件
* https://baomidou.com/plugins/pagination/
*/
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); // 如果配置多个插件, 切记分页最后添加
// 如果有多数据源可以不配具体类型, 否则都建议配上具体的 DbType
return interceptor;
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
创建代码生成器
参考官网文档:地址
使用的时候修改以下配置:
- 修改数据库的信息dataSource
- 需要生成的表GenerateTable
- 其他的根据实际情况修改
package local.ateng.java.mybatis.utils;
import com.baomidou.mybatisplus.generator.FastAutoGenerator;
import com.baomidou.mybatisplus.generator.config.OutputFile;
import java.io.File;
import java.net.URISyntaxException;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
/**
* 代码生成器
* 全新的 MyBatis-Plus 代码生成器,通过 builder 模式可以快速生成你想要的代码,快速且优雅,跟随下面的代码一睹为快。
* https://baomidou.com/guides/new-code-generator/
*
* @author 孔余
* @email 2385569970@qq.com
* @since 2025-01-13
*/
public class MybatisPlusGenerator {
// 根包名
private static final String BasePackage = "local.ateng.java.mybatis";
// 子包名,例如 ${BasePackage}.${ChildPackage} => ${BasePackage}.system
private static final String ChildPackage = "";
// 需要生成的表
private static final List<String> GenerateTable = Arrays.asList(
"my_user", "my_order"
);
public static void main(String[] args) {
PathEntity path = getPath();
FastAutoGenerator.create("jdbc:mysql://192.168.1.10:35725/kongyu", "root", "Admin@123")
.globalConfig(builder -> builder
.author("Ateng")
.outputDir(path.getSourceDir())
.commentDate("yyyy-MM-dd")
.disableOpenDir()
)
.packageConfig(builder -> builder
.parent(path.getBasePackage())
.entity("entity")
.mapper("mapper")
.service("service")
.serviceImpl("service.impl")
.xml("mapper.xml")
.pathInfo(Collections.singletonMap(OutputFile.xml, path.getMapperXmlPath())) // 设置 Mapper XML 文件生成路径
)
.strategyConfig(builder -> builder
.addInclude(GenerateTable) // 设置需要生成的表名
.entityBuilder() // Entity 策略配置
.enableLombok() // 启用 Lombok
.enableTableFieldAnnotation() // 启用字段注解
.controllerBuilder()// Controller 策略配置
.enableRestStyle() // 启用 REST 风格
)
.execute();
}
/**
* 获取当前模块的路径
*
* @return
*/
public static String getModulePath() {
// 获取当前类的路径
String path = null;
try {
path = MybatisPlusGenerator.class.getProtectionDomain().getCodeSource().getLocation().toURI().getPath();
} catch (URISyntaxException e) {
throw new RuntimeException(e);
}
// 获取父目录(模块目录)
File moduleDir = new File(path).getParentFile();
return moduleDir.getPath().replace("\\target", "");
}
/**
* 获取配置需要的路径
*/
public static PathEntity getPath() {
String sourceDir = getModulePath() + "/src/main/java";
String basePath = BasePackage.replaceAll("^\\.|\\.$", "");
String mapperPath = getModulePath() + "/src/main/resources/mapper";
if (!ChildPackage.isBlank()) {
basePath = basePath + "." + ChildPackage.replaceAll("^\\.|\\.$|^/|/$", "");
mapperPath = mapperPath + "/" + ChildPackage.replaceAll("^\\.|\\.$|^/|/$", "");
}
return new PathEntity(sourceDir, basePath, mapperPath);
}
/**
* 设置路径的类
*/
public static class PathEntity {
private String sourceDir;
private String basePackage;
private String mapperXmlPath;
public PathEntity(String sourceDir, String basePackage, String mapperXmlPath) {
this.sourceDir = sourceDir;
this.basePackage = basePackage;
this.mapperXmlPath = mapperXmlPath;
}
public String getSourceDir() {
return sourceDir;
}
public String getBasePackage() {
return basePackage;
}
public String getMapperXmlPath() {
return mapperXmlPath;
}
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
数据库表准备
创建表
创建表
-- 用户表
create table if not exists my_user
(
id bigint auto_increment
primary key comment '用户ID,主键,自增',
name varchar(50) not null comment '用户名',
age int null comment '用户年龄,允许为空',
score double default 0 comment '用户分数,默认为0',
birthday datetime(3) null comment '用户生日,允许为空',
province varchar(50) null comment '用户所在省份,允许为空',
city varchar(50) null comment '用户所在城市,允许为空',
create_time datetime(3) default CURRENT_TIMESTAMP(3) not null comment '记录创建时间,默认当前时间'
) comment '用户信息表,存储用户的基本信息';
-- 订单表
create table if not exists kongyu.my_order
(
id bigint auto_increment
primary key comment '订单ID,主键,自增',
user_id bigint not null comment '用户ID,外键,关联用户表',
date date not null comment '订单日期',
total_amount decimal(10, 2) not null comment '订单总金额,精确到小数点后两位',
constraint fk_my_order_user foreign key (user_id) references my_user (id) on delete cascade on update cascade
) comment '订单信息表,存储用户的订单数据';2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
插入数据
将项目模块下 /data 目录的SQL运行在数据库中
代码生成
配置数据库信息和需要生成的表后,运行代码生成器 MybatisPlusGenerator 。
生成后如下图所示:

基础查询
测试类准备
package local.ateng.java.mybatis;
import local.ateng.java.mybatis.service.IMyOrderService;
import local.ateng.java.mybatis.service.IMyUserService;
import lombok.RequiredArgsConstructor;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
/**
* 基础查询
*
* @author 孔余
* @email 2385569970@qq.com
* @since 2025-01-10
*/
@SpringBootTest
@RequiredArgsConstructor(onConstructor = @__(@Autowired))
public class BasicQueryTests {
private final IMyUserService myUserService;
private final IMyOrderService myOrderService;
@Test
void test() {
long count = myUserService.count();
System.out.println(count);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
条件查询
查询id是88的一条数据
@Test
void test01() {
// 查询id是88的一条数据
MyUser user = myUserService.lambdaQuery()
.eq(MyUser::getId, 88)
.one();
System.out.println(user);
}2
3
4
5
6
7
8
查询id是88到90(包含)这个范围内的数据
@Test
void test02() {
// 查询id是88到90(包含)这个范围内的数据
List<MyUser> userList = myUserService.lambdaQuery()
.between(MyUser::getId, 88, 90)
.list();
System.out.println(userList);
}2
3
4
5
6
7
8
查询所有的区域
@Test
void test03() {
// 查询所有的区域
List<MyUser> list = myUserService.query()
.select("DISTINCT city")
.list();
System.out.println(list.stream().map(MyUser::getCity).toList());
}2
3
4
5
6
7
8
查询创建时间是2024年8月的数据数量
@Test
void test04() {
// 查询创建时间是2024年8月的数据数量
QueryWrapper<MyUser> wrapper = new QueryWrapper<MyUser>()
.select("DATE_FORMAT(create_time, '%Y-%m') as month", "COUNT(*) as count")
.groupBy("DATE_FORMAT(create_time, '%Y-%m')")
.having("month = '2025-01'");
List<Map<String, Object>> list = myUserService.listMaps(wrapper);
System.out.println(list);
}2
3
4
5
6
7
8
9
10
查询并按照创建时间排序(降序),创建时间一样则按照id排序(降序)
@Test
void test05() {
// 查询并按照创建时间排序(降序),创建时间一样则按照id排序(降序)
List<MyUser> userList = myUserService.lambdaQuery()
.between(MyUser::getId, 88, 90)
.orderByDesc(MyUser::getCreateTime, MyUser::getId)
.list();
System.out.println(userList);
}2
3
4
5
6
7
8
9
分页查询
@Test
void test06() {
// 引入 MyBatis-Plus 分页插件
Page<MyUser> page = new Page<>(2, 10); // 第2页,每页10条记录
// 分页查询
page = myUserService.lambdaQuery()
.between(MyUser::getId, 88, 888)
.page(page);
// 获取分页结果
List<MyUser> users = page.getRecords(); // 分页数据
long total = page.getTotal(); // 总记录数
long pages = page.getPages(); // 总页数
// 输出查询结果
System.out.println(page);
System.out.println("Total: " + total);
System.out.println("Pages: " + pages);
users.forEach(user -> System.out.println(user));
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
JSON字段
创建表
创建表
drop table if exists my_json;
create table my_json
(
id bigint auto_increment primary key,
name varchar(16) not null comment '名称',
my_json_object json comment 'JSONObject数据',
my_json_array json comment 'JSONOArray数据'
) comment 'Json表';2
3
4
5
6
7
8
写入数据
INSERT INTO my_json (name, my_json_object, my_json_array) VALUES
('Alice', '{"age": 25, "city": "Shanghai"}', '["reading", "cycling"]'),
('Bob', '{"age": 30, "city": "Beijing"}', '["chess", "music"]'),
('Charlie', '{"age": 35, "city": "Guangzhou"}', '["food", "travel"]'),
('Diana', '{"age": 40, "city": "Shenzhen"}', '["movie", "art"]');2
3
4
5
使用IService的方式
需要 实体类配置 的注解属性
使用 MybatisPlusGenerator 生成代码,然后修改实体类的JSON字段配置
@TableName配置
添加 autoResultMap = true
@TableName(value = "my_json", autoResultMap = true)@TableField配置
添加 typeHandler = JacksonTypeHandler.class 或者 Fastjson2TypeHandler.class
@TableField(value = "my_json_object", typeHandler = JacksonTypeHandler.class)使用Mapper XML的方式
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="local.ateng.java.mybatis.mapper.MyJsonMapper">
<resultMap id="myJsonResultMap" type="local.ateng.java.mybatis.entity.MyJson">
<result column="my_json_object" property="myJsonObject" typeHandler="com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler"/>
<result column="my_json_array" property="myJsonArray" typeHandler="com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler"/>
</resultMap>
<select id="selectMyJson" resultMap="myJsonResultMap">
select id, name, my_json_object, my_json_array from my_json;
</select>
</mapper>2
3
4
5
6
7
8
9
10
11
12
13
多数据源
参考官网文档:地址
添加依赖
<!-- MyBatis Plus 多数据源 -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>dynamic-datasource-spring-boot3-starter</artifactId>
<version>4.3.1</version>
</dependency>2
3
4
5
6
编辑配置文件
编辑 application.yml 添加多数据源配置
原有的spring.datasource可以注释
---
# 数据库的相关配置
#spring:
# datasource:
# url: jdbc:mysql://192.168.1.10:35725/kongyu # MySQL数据库连接URL
# #url: jdbc:postgresql://192.168.1.10:32297/kongyu?currentSchema=public&stringtype=unspecified # PostgreSQL数据库连接URL
# username: root # 数据库用户名
# password: Admin@123 # 数据库密码
# # driver-class-name: com.mysql.cj.jdbc.Driver # 数据库驱动类,框架会自动适配
# type: com.alibaba.druid.pool.DruidDataSource # 使用Druid数据源
# # Druid连接池配置 https://github.com/alibaba/druid/tree/master/druid-spring-boot-starter
# druid:
# initial-size: 10 # 初始化连接池大小
# min-idle: 10 # 最小空闲连接数
# max-active: 1000 # 最大活跃连接数
# max-wait: 10000 # 获取连接的最大等待时间,单位毫秒
# async-init: true
spring:
datasource:
dynamic:
primary: mysql
strict: false
datasource:
mysql:
url: jdbc:mysql://192.168.1.10:35725/kongyu # MySQL数据库连接URL
#url: jdbc:postgresql://192.168.1.10:32297/kongyu?currentSchema=public&stringtype=unspecified # PostgreSQL数据库连接URL
username: root # 数据库用户名
password: Admin@123 # 数据库密码
# driver-class-name: com.mysql.cj.jdbc.Driver # 数据库驱动类,框架会自动适配
type: com.alibaba.druid.pool.DruidDataSource # 使用Druid数据源
# Druid连接池配置 https://github.com/alibaba/druid/tree/master/druid-spring-boot-starter
druid:
initial-size: 10 # 初始化连接池大小
min-idle: 10 # 最小空闲连接数
max-active: 1000 # 最大活跃连接数
max-wait: 10000 # 获取连接的最大等待时间,单位毫秒
async-init: true
doris:
type: com.alibaba.druid.pool.DruidDataSource
url: jdbc:mysql://192.168.1.12:9030/kongyu
username: admin
password: Admin@123
# Druid连接池配置 https://github.com/alibaba/druid/tree/master/druid-spring-boot-starter
druid:
initial-size: 10 # 初始化连接池大小
min-idle: 10 # 最小空闲连接数
max-active: 1000 # 最大活跃连接数
max-wait: 10000 # 获取连接的最大等待时间,单位毫秒
async-init: true2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
使用多数据源
创建测试类使用第二个指定的数据源
执行代码后输出的内容就是Doris中表的数据,详细使用参考官方文档
@Service
@DS("slave")
public class UserServiceImpl implements UserService {
@Autowired
private JdbcTemplate jdbcTemplate;
public List selectAll() {
return jdbcTemplate.queryForList("select * from user");
}
@Override
@DS("slave_1")
public List selectByCondition() {
return jdbcTemplate.queryForList("select * from user where age >10");
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
使用Mapper XML
基本使用
创建Mapper
package local.ateng.java.mybatis.mapper;
import com.alibaba.fastjson2.JSONObject;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import local.ateng.java.mybatis.entity.MyUser;
import org.apache.ibatis.annotations.Param;
import java.util.List;
/**
* <p>
* 用户信息表,存储用户的基本信息 Mapper 接口
* </p>
*
* @author 孔余
* @since 2025-01-13
*/
public interface MyUserMapper extends BaseMapper<MyUser> {
List<MyUser> selectAllUsers();
MyUser selectUserById(@Param("id") Long id);
// 根据查询条件获取用户及其订单信息
List<JSONObject> selectUsersWithOrders(@Param("orderId") Long orderId);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
创建Mapper.xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="local.ateng.java.mybatis.mapper.MyUserMapper">
<!-- 查询所有用户 -->
<select id="selectAllUsers" resultType="local.ateng.java.mybatis.entity.MyUser">
SELECT * FROM my_user;
</select>
<!-- 根据ID查询用户 -->
<select id="selectUserById" parameterType="java.lang.Long" resultType="local.ateng.java.mybatis.entity.MyUser">
SELECT * FROM my_user WHERE id = #{id};
</select>
<!-- 查询所有用户及其对应的订单信息 -->
<select id="selectUsersWithOrders" resultType="com.alibaba.fastjson2.JSONObject">
SELECT
u.id as id,
u.name,
u.age,
u.score,
u.birthday,
u.province,
u.city,
u.create_time,
o.id as order_id,
o.date as order_date,
o.total_amount as order_total_amount
FROM my_user u
LEFT JOIN my_order o ON u.id = o.user_id
WHERE 1=1
<if test="orderId != null">AND o.id = #{orderId}</if>
</select>
</mapper>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
测试使用
package local.ateng.java.mybatis;
import com.alibaba.fastjson2.JSONObject;
import local.ateng.java.mybatis.entity.MyUser;
import local.ateng.java.mybatis.mapper.MyUserMapper;
import lombok.RequiredArgsConstructor;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.List;
@SpringBootTest
@RequiredArgsConstructor(onConstructor = @__(@Autowired))
public class MapperTests {
private final MyUserMapper myUserMapper;
@Test
void test01() {
List<MyUser> list = myUserMapper.selectAllUsers();
System.out.println(list);
}
@Test
void test02() {
MyUser myUser = myUserMapper.selectUserById(1L);
System.out.println(myUser);
}
@Test
void test03() {
List<JSONObject> list = myUserMapper.selectUsersWithOrders(1L);
System.out.println(list);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
分页查询
创建Mapper
- 定义:在 Mapper 方法中传入
Page对象和查询参数,实现分页查询。
public interface MyUserMapper extends BaseMapper<MyUser> {
// 分页查询
IPage<JSONObject> selectUsersWithOrderPage(Page page, @Param("city") String city);
}2
3
4
5
执行过程:
- 自动执行总数查询(
COUNT),用于获取满足条件的总记录数。 - 执行带
LIMIT的分页查询,返回当前页数据。
原理: MyBatis-Plus 内置分页拦截器自动拦截查询,先执行总数查询,再追加分页 SQL(LIMIT offset, size),最后封装为 IPage 对象返回。
返回值: IPage 包含当前页数据列表、总记录数、总页数等信息,方便分页展示和逻辑处理。
优势: 免写复杂分页 SQL,减少错误,提升开发效率。
创建Mapper.xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="local.ateng.java.mybatis.mapper.MyUserMapper">
<select id="selectUsersWithOrderPage" resultType="com.alibaba.fastjson2.JSONObject">
SELECT
u.id as id,
u.name,
u.age,
u.score,
u.birthday,
u.province,
u.city,
u.create_time,
o.id as order_id,
o.date as order_date,
o.total_amount as order_total_amount
FROM my_user u
LEFT JOIN my_order o ON u.id = o.user_id
<where>
<if test="city != null">
u.city like concat('%', #{city}, '%')
</if>
</where>
</select>
</mapper>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
测试使用
@Test
void test05() {
IPage<JSONObject> page = myUserMapper.selectUsersWithOrderPage(new Page(1, 20), "重");
System.out.println(page);
}2
3
4
5
输出内容
2025-06-16T21:08:33.640+08:00 INFO 37408 --- [mybatis-plus] [ main] p6spy : #1750079313640 | took 18ms | statement | connection 0| url jdbc:mysql://192.168.1.10:35725/kongyu
SELECT COUNT(*) AS total FROM my_user u WHERE u.city LIKE concat('%', ?, '%')
SELECT COUNT(*) AS total FROM my_user u WHERE u.city LIKE concat('%', '重', '%');
2025-06-16T21:08:33.661+08:00 INFO 37408 --- [mybatis-plus] [ main] p6spy : #1750079313661 | took 5ms | statement | connection 0| url jdbc:mysql://192.168.1.10:35725/kongyu
SELECT
u.id as id,
u.name,
u.age,
u.score,
u.birthday,
u.province,
u.city,
u.create_time,
o.id as order_id,
o.date as order_date,
o.total_amount as order_total_amount
FROM my_user u
LEFT JOIN my_order o ON u.id = o.user_id
WHERE u.city like concat('%', ?, '%') LIMIT ?
SELECT
u.id as id,
u.name,
u.age,
u.score,
u.birthday,
u.province,
u.city,
u.create_time,
o.id as order_id,
o.date as order_date,
o.total_amount as order_total_amount
FROM my_user u
LEFT JOIN my_order o ON u.id = o.user_id
WHERE u.city like concat('%', '重', '%') LIMIT 3;
Page{records=[{"id":1,"name":"阿腾","age":25,"score":99.99,"birthday":"2025-01-24 00:00:00","province":"重庆","city":"重庆","create_time":"2025-01-24 22:33:08.822","order_id":542,"order_date":"2007-05-08","order_total_amount":398.58}, {"id":1,"name":"阿腾","age":25,"score":99.99,"birthday":"2025-01-24 00:00:00","province":"重庆","city":"重庆","create_time":"2025-01-24 22:33:08.822","order_id":973,"order_date":"2008-10-27","order_total_amount":830.81}, {"id":2,"name":"阿腾","age":25,"score":99.99,"birthday":"2025-01-24 00:00:00","province":"重庆","city":"重庆"}], total=85, size=3, current=1, orders=[], optimizeCountSql=true, searchCount=true, optimizeJoinOfCountSql=true, maxLimit=null, countId='null'}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
使用QueryWrapper
创建Mapper
重点: 参数名仍然必须是 "ew",MyBatis-Plus 才能识别并自动拼接条件。
public interface MyUserMapper extends BaseMapper<MyUser> {
// 分页查询,传入wrapper
IPage<JSONObject> selectUsersWithOrderPageWrapper(Page page, @Param("ew") QueryWrapper<MyUser> wrapper);
}2
3
4
5
创建Mapper.xml
传 wrapper 给自定义 SQL 时,在where条件中加 ${ew.sqlSegment}。
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="local.ateng.java.mybatis.mapper.MyUserMapper">
<select id="selectUsersWithOrderPageWrapper" resultType="com.alibaba.fastjson2.JSONObject">
SELECT
u.id as id,
u.name,
u.age,
u.score,
u.birthday,
u.province,
u.city,
u.create_time,
o.id as order_id,
o.date as order_date,
o.total_amount as order_total_amount
FROM my_user u
LEFT JOIN my_order o ON u.id = o.user_id
<where>
0 = 0 and
${ew.sqlSegment}
</where>
</select>
</mapper>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
测试使用
@Test
void test06() {
QueryWrapper<MyUser> wrapper = new QueryWrapper<>();
wrapper.like("city", "重");
wrapper.eq("u.id", 1);
wrapper.orderByAsc("u.id");
IPage<JSONObject> page = myUserMapper.selectUsersWithOrderPageWrapper(new Page(1, 3), wrapper);
System.out.println(page);
}2
3
4
5
6
7
8
9
输出内容
2025-06-16T21:08:02.429+08:00 INFO 32540 --- [mybatis-plus] [ main] p6spy : #1750079282429 | took 5ms | statement | connection 0| url jdbc:mysql://192.168.1.10:35725/kongyu
SELECT COUNT(*) AS total FROM my_user u WHERE (city LIKE ? AND u.id = ?)
SELECT COUNT(*) AS total FROM my_user u WHERE (city LIKE '%重%' AND u.id = 1);
2025-06-16T21:08:02.448+08:00 INFO 32540 --- [mybatis-plus] [ main] p6spy : #1750079282448 | took 2ms | statement | connection 0| url jdbc:mysql://192.168.1.10:35725/kongyu
SELECT
u.id as id,
u.name,
u.age,
u.score,
u.birthday,
u.province,
u.city,
u.create_time,
o.id as order_id,
o.date as order_date,
o.total_amount as order_total_amount
FROM my_user u
LEFT JOIN my_order o ON u.id = o.user_id
WHERE (city LIKE ? AND u.id = ?) LIMIT ?
SELECT
u.id as id,
u.name,
u.age,
u.score,
u.birthday,
u.province,
u.city,
u.create_time,
o.id as order_id,
o.date as order_date,
o.total_amount as order_total_amount
FROM my_user u
LEFT JOIN my_order o ON u.id = o.user_id
WHERE (city LIKE '%重%' AND u.id = 1) LIMIT 3;
Page{records=[{"id":1,"name":"阿腾","age":25,"score":99.99,"birthday":"2025-01-24 00:00:00","province":"重庆","city":"重庆","create_time":"2025-01-24 22:33:08.822","order_id":542,"order_date":"2007-05-08","order_total_amount":398.58}, {"id":1,"name":"阿腾","age":25,"score":99.99,"birthday":"2025-01-24 00:00:00","province":"重庆","city":"重庆","create_time":"2025-01-24 22:33:08.822","order_id":973,"order_date":"2008-10-27","order_total_amount":830.81}], total=1, size=3, current=1, orders=[], optimizeCountSql=true, searchCount=true, optimizeJoinOfCountSql=true, maxLimit=null, countId='null'}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
使用LambdaQueryWrapper
使用 LambdaQueryWrapper 比 QueryWrapper 的好处是,能将实体类字段名称自动映射为数据库表字段名称。
创建Mapper
重点: 参数名仍然必须是 "ew",MyBatis-Plus 才能识别并自动拼接条件。
public interface MyUserMapper extends BaseMapper<MyUser> {
// 分页查询,传入wrapper
IPage<JSONObject> selectUsersWithOrderPageWrapper(Page page, @Param(Constants.WRAPPER) Wrapper wrapper);
}2
3
4
5
创建Mapper.xml
传 wrapper 给自定义 SQL 时,在where条件中加 ${ew.sqlSegment}。
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="local.ateng.java.mybatis.mapper.MyUserMapper">
<select id="selectUsersWithOrderPageWrapper" resultType="com.alibaba.fastjson2.JSONObject">
SELECT
u.id as id,
u.name,
u.age,
u.score,
u.birthday,
u.province,
u.city,
u.create_time,
o.id as order_id,
o.date as order_date,
o.total_amount as order_total_amount
FROM my_user u
LEFT JOIN my_order o ON u.id = o.user_id
<where>
0 = 0 and
${ew.sqlSegment}
</where>
</select>
</mapper>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
测试使用
@Test
void test07() {
LambdaQueryWrapper<MyUser> wrapper = Wrappers.lambdaQuery();
wrapper.like(MyUser::getCity, "重");
Page<JSONObject> page = new Page(1, 3);
IPage<JSONObject> pageList = myUserMapper.selectUsersWithOrderPageWrapper(page, wrapper);
System.out.println(pageList);
}2
3
4
5
6
7
8
输出内容
2025-06-18T22:44:30.748+08:00 INFO 41124 --- [mybatis-plus] [ main] p6spy : #1750257870748 | took 13ms | statement | connection 0| url jdbc:mysql://192.168.1.10:35725/kongyu
SELECT COUNT(*) AS total FROM my_user u WHERE 0 = 0 AND (city LIKE ?)
SELECT COUNT(*) AS total FROM my_user u WHERE 0 = 0 AND (city LIKE '%重%');
2025-06-18T22:44:30.766+08:00 INFO 41124 --- [mybatis-plus] [ main] p6spy : #1750257870766 | took 2ms | statement | connection 0| url jdbc:mysql://192.168.1.10:35725/kongyu
SELECT
u.id as id,
u.name,
u.age,
u.score,
u.birthday,
u.province,
u.city,
u.create_time,
o.id as order_id,
o.date as order_date,
o.total_amount as order_total_amount
FROM my_user u
LEFT JOIN my_order o ON u.id = o.user_id
WHERE 0 = 0 and
(city LIKE ?) LIMIT ?
SELECT
u.id as id,
u.name,
u.age,
u.score,
u.birthday,
u.province,
u.city,
u.create_time,
o.id as order_id,
o.date as order_date,
o.total_amount as order_total_amount
FROM my_user u
LEFT JOIN my_order o ON u.id = o.user_id
WHERE 0 = 0 and
(city LIKE '%重%') LIMIT 3;
Page{records=[{"id":1,"name":"阿腾","age":25,"score":99.99,"birthday":"2025-01-24 00:00:00","province":"重庆","city":"重庆","create_time":"2025-01-24 22:33:08.822","order_id":542,"order_date":"2007-05-08","order_total_amount":398.58}, {"id":1,"name":"阿腾","age":25,"score":99.99,"birthday":"2025-01-24 00:00:00","province":"重庆","city":"重庆","create_time":"2025-01-24 22:33:08.822","order_id":973,"order_date":"2008-10-27","order_total_amount":830.81}, {"id":2,"name":"阿腾","age":25,"score":99.99,"birthday":"2025-01-24 00:00:00","province":"重庆","city":"重庆"}], total=85, size=3, current=1, orders=[], optimizeCountSql=true, searchCount=true, optimizeJoinOfCountSql=true, maxLimit=null, countId='null'}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
分页自定义Count
在一些复杂SQL情况下,MybatisPlus的分页查询Count可能会出现不正确的情况,这里可以使用 CTE 或者 自定义查询Count 来解决
CTE 的SQL示例
<select id="selectUsersWithOrderPageWrapper" resultType="com.alibaba.fastjson2.JSONObject">
with result as (
SELECT
u.id as id,
u.name,
u.age,
u.score,
u.birthday,
u.province,
u.city,
u.create_time,
o.id as order_id,
o.date as order_date,
o.total_amount as order_total_amount
FROM my_user u
LEFT JOIN my_order o ON u.id = o.user_id
)
select * from result
<where>
0 = 0 and
${ew.sqlSegment}
</where>
</select>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
自定义查询Count如下:
创建Mapper
重点: 参数名仍然必须是 "ew",MyBatis-Plus 才能识别并自动拼接条件。
public interface MyUserMapper extends BaseMapper<MyUser> {
// 分页查询,传入wrapper
IPage<JSONObject> selectUsersWithOrderPageWrapper(Page page, @Param("ew") QueryWrapper<MyUser> wrapper);
}2
3
4
5
创建Mapper.xml
注意 selectUsersWithOrderPageWrapperCount 用于后续配置分页查询Count
<select id="selectUsersWithOrderPageWrapperCount" resultType="java.lang.Long">
SELECT
COUNT(1) AS total
FROM my_user u
LEFT JOIN my_order o ON u.id = o.user_id
<where>
0 = 0 and
${ew.sqlSegment}
</where>
</select>
<select id="selectUsersWithOrderPageWrapper" resultType="com.alibaba.fastjson2.JSONObject">
SELECT
u.id as id,
u.name,
u.age,
u.score,
u.birthday,
u.province,
u.city,
u.create_time,
o.id as order_id,
o.date as order_date,
o.total_amount as order_total_amount
FROM my_user u
LEFT JOIN my_order o ON u.id = o.user_id
<where>
0 = 0 and
${ew.sqlSegment}
</where>
</select>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
测试使用
page.setCountId("selectUsersWithOrderPageWrapperCount"); 设置查询分页的Mapper id
@Test
void test06() {
QueryWrapper<MyUser> wrapper = new QueryWrapper<>();
wrapper.like("city", "重");
wrapper.eq("u.id", 1);
wrapper.orderByAsc("u.id");
Page<JSONObject> page = new Page(1, 3);
page.setCountId("selectUsersWithOrderPageWrapperCount");
IPage<JSONObject> pageList = myUserMapper.selectUsersWithOrderPageWrapper(page, wrapper);
System.out.println(pageList);
}2
3
4
5
6
7
8
9
10
11
输出内容
2025-06-17T21:07:45.371+08:00 INFO 21272 --- [mybatis-plus] [ main] p6spy : #1750165665371 | took 5ms | statement | connection 0| url jdbc:mysql://192.168.1.10:35725/kongyu
SELECT
COUNT(1)
FROM my_user u
LEFT JOIN my_order o ON u.id = o.user_id
WHERE 0 = 0 and
(city LIKE ? AND u.id = ?) ORDER BY u.id ASC
SELECT
COUNT(1)
FROM my_user u
LEFT JOIN my_order o ON u.id = o.user_id
WHERE 0 = 0 and
(city LIKE '%重%' AND u.id = 1) ORDER BY u.id ASC;
2025-06-17T21:07:45.389+08:00 INFO 21272 --- [mybatis-plus] [ main] p6spy : #1750165665389 | took 2ms | statement | connection 0| url jdbc:mysql://192.168.1.10:35725/kongyu
SELECT
u.id as id,
u.name,
u.age,
u.score,
u.birthday,
u.province,
u.city,
u.create_time,
o.id as order_id,
o.date as order_date,
o.total_amount as order_total_amount
FROM my_user u
LEFT JOIN my_order o ON u.id = o.user_id
WHERE 0 = 0 and
(city LIKE ? AND u.id = ?) ORDER BY u.id ASC LIMIT ?
SELECT
u.id as id,
u.name,
u.age,
u.score,
u.birthday,
u.province,
u.city,
u.create_time,
o.id as order_id,
o.date as order_date,
o.total_amount as order_total_amount
FROM my_user u
LEFT JOIN my_order o ON u.id = o.user_id
WHERE 0 = 0 and
(city LIKE '%重%' AND u.id = 1) ORDER BY u.id ASC LIMIT 3;
Page{records=[{"id":1,"name":"阿腾","age":25,"score":99.99,"birthday":"2025-01-24 00:00:00","province":"重庆","city":"重庆","create_time":"2025-01-24 22:33:08.822","order_id":542,"order_date":"2007-05-08","order_total_amount":398.58}, {"id":1,"name":"阿腾","age":25,"score":99.99,"birthday":"2025-01-24 00:00:00","province":"重庆","city":"重庆","create_time":"2025-01-24 22:33:08.822","order_id":973,"order_date":"2008-10-27","order_total_amount":830.81}], total=2, size=3, current=1, orders=[], optimizeCountSql=true, searchCount=true, optimizeJoinOfCountSql=true, maxLimit=null, countId='selectUsersWithOrderPageWrapperCount'}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
Mapper XML常用标签整理
#{} 和 ${} 的主要差异
#{} | ${} | |
|---|---|---|
| 作用 | 推荐,按占位绑定,由 PreparedStatement 设置 | 纯文本拼接,适用表/列动态拼接 |
| 风险 | 安全(防 SQL 注入) | 有风险(容易 SQL 注入) |
| 渲染 | 渲染时为 ? | 渲染时为具体文本 |
| 建议 | 优先使用 | 仅在需要时(如列名、表名拼接) |
基本标签(适用增删改查)
定义数据的读取语句
✅适用条件:按条件进行数据检索。 ✅作用:将数据从表中查出,可以绑定到对象或者 List。
<select id="findById" parameterType="java.lang.Long" resultMap="BaseResultMap">
SELECT * FROM user WHERE id = #{id}
</select>2
3
定义插入语句
✅适用条件:插入数据时使用。 ✅作用:将对象中准备好的数据插入到表中。
<insert id="insertUser" parameterType="User">
INSERT INTO user (username, password) VALUES (#{username}, #{password})
</insert>2
3
定义修改语句
✅适用条件:需要修改表中现有数据时。 ✅作用:按条件修改指定的数据列。
<update id="updateUsername" parameterType="User">
UPDATE user SET username = #{username} WHERE id = #{id}
</update>2
3
定义删除语句
✅适用条件:按条件删除数据时。 ✅作用:从表中移除符合条件的数据。
<delete id="deleteById" parameterType="long">
DELETE FROM user WHERE id = #{id}
</delete>2
3
动态标签(适用条件拼接)
转义操作
✅适用条件:需要写出 > 或 < 等需要转义的条件时。 ✅作用:保持语法简洁,与 MyBatis 无关,仅仅是为了避免解析错误。
<select id="findAllGreaterThanId" parameterType="java.lang.Long">
SELECT * FROM user WHERE id <![CDATA[ > ]]> #{id}
</select>2
3
按条件拼接片段
✅适用条件:需要有条件地拼接不同的 WHERE 子句时。 ✅作用:若条件为 true 则拼接其中的 SQL。
<select id="findByConditions" parameterType="User">
SELECT * FROM user WHERE 1 = 1
<if test="username != null">
AND username = #{username}
</if>
</select>2
3
4
5
6
按条件进行分枝处理
✅适用条件:有多个条件时,按第一个为 true 的条件拼接。 ✅作用:适用if-else结构。
<select id="findByOption" parameterType="User">
SELECT * FROM user WHERE 1 = 1
<choose>
<when test="username != null">
AND username = #{username}
</when>
<when test="email != null">
AND email = #{email}
</when>
<otherwise>
LIMIT 10
</otherwise>
</choose>
</select>2
3
4
5
6
7
8
9
10
11
12
13
14
智能拼接 AND/OR
✅适用条件:有条件时自动插入 WHERE ,且会移除最前多余的 AND/OR。 ✅作用:简化拼接语法。
<select id="findAllWithWhere" parameterType="User">
SELECT * FROM user
<where>
<if test="username != null">
AND username = #{username}
</if>
<if test="email != null">
OR email = #{email}
</if>
</where>
</select>2
3
4
5
6
7
8
9
10
11
按规则清理拼接
✅适用条件:需要按规则移除指定前后关键字时。 ✅作用:可以指定 suffixOverrides 或 prefixOverrides。
<select id="findAllWithTrim" parameterType="User">
SELECT * FROM user
<trim prefix="WHERE" prefixOverrides="AND|OR">
<if test="username != null">
AND username = #{username}
</if>
<if test="email != null">
OR email = #{email}
</if>
</trim>
</select>2
3
4
5
6
7
8
9
10
11
适用批量条件(in语法)
✅适用条件:需要对一个数组/ List进行批量拼接时。 ✅作用:可以轻松实现 in (...) 查询。
<select id="findByIds" parameterType="list">
SELECT * FROM user WHERE id IN
<foreach item="id" collection="list" open="(" separator="," close=")">
#{id}
</foreach>
</select>2
3
4
5
6
resultMap —列与对象的高度自由映射
✅适用条件:列名与对象属性不一一对应时,或者需要进行关联时。 ✅作用:可以进行一对一、一对多甚至是有参赋值。
| 标签 | 作用 |
|---|---|
<id> | 定义主键列对应哪个属性 |
<result> | 定义普通列与对象哪个属性对应 |
<association> | 定义一对一时的关联 |
<collection> | 定义一对多时的关联 |
<constructor> | 适用有参构造时进行赋值 |
一对一
association
✅适用条件:需要联合表进行关联时(1对1)。 ✅作用:可以轻松地将关联表的数据按对象进行嵌套。
<resultMap id="UserWithProfile" type="User">
<id column="id_field" property="id" />
<result column="username_field" property="username" />
<association property="profile" javaType="Profile">
<id column="profile_id_field" property="id" />
<result column="profile_name_field" property="profileName" />
</association>
</resultMap>2
3
4
5
6
7
8
9
直接映射嵌套属性
<resultMap id="UserWithProfileSimple" type="User">
<id column="id_field" property="id" />
<result column="username_field" property="username" />
<result column="profile_id_field" property="profile.id" />
<result column="profile_name_field" property="profile.profileName" />
</resultMap>2
3
4
5
6
一对多(collection)
✅适用条件:需要获取一对多的数据时(如一个用户有多个购买记录)。 ✅作用:可以将关联的数据按 List 映射到对象中。
<resultMap id="UserWithOrders" type="User">
<id column="id_field" property="id" />
<result column="username_field" property="username" />
<collection property="orders" ofType="Order">
<id column="order_id_field" property="id" />
<result column="order_number_field" property="orderNumber" />
</collection>
</resultMap>2
3
4
5
6
7
8
9
TypeHandler
UUIDTypeHandler
package local.ateng.java.mybatisjdk8.handler;
import org.apache.ibatis.type.BaseTypeHandler;
import org.apache.ibatis.type.JdbcType;
import java.nio.ByteBuffer;
import java.sql.*;
import java.util.UUID;
/**
* MyBatis 类型处理器:将 MySQL 的 binary(16) 字段与 Java 的 UUID 类型进行映射转换。
* <p>
* 用于处理数据库中使用 UUID_TO_BIN(uuid()) 生成的顺序 UUID(二进制格式),
* Java 端字段必须使用 {@link java.util.UUID} 类型。
* </p>
*
* <pre>
* 数据库字段类型:binary(16)
* Java 字段类型:java.util.UUID
* </pre>
* <p>
* 示例使用:
* <pre>
* @TableField(typeHandler = UUIDTypeHandler.class)
* private UUID uuid;
* </pre>
* <p>
* 注意:实体类字段必须为 {@code UUID} 类型,不能使用 {@code byte[]} 或 {@code String}。
*
* @author 孔余
* @since 2025-07-27
*/
@MappedJdbcTypes({JdbcType.VARCHAR, JdbcType.VARBINARY, JdbcType.OTHER}) // 数据库字段类型
@MappedTypes(UUID.class) // Java 类型
public class UUIDTypeHandler extends BaseTypeHandler<UUID> {
/**
* 将 Java UUID 类型参数设置到 PreparedStatement 中,以字节数组形式写入 binary(16) 字段。
*
* @param ps PreparedStatement 对象
* @param i 参数索引(从1开始)
* @param uuid 要写入的 UUID 值,不能为空
* @param jdbcType JDBC 类型(可为空)
* @throws SQLException SQL 操作异常
*/
@Override
public void setNonNullParameter(PreparedStatement ps, int i, UUID uuid, JdbcType jdbcType) throws SQLException {
try {
ps.setBytes(i, uuidToBytes(uuid));
} catch (Exception e) {
ps.setNull(i, Types.NULL);
}
}
/**
* 从结果集中获取 UUID 值(通过列名),并将 binary(16) 转为 UUID 类型。
*
* @param rs 结果集对象
* @param columnName 列名
* @return 对应的 UUID 值,如果字段为 null 则返回 null
* @throws SQLException SQL 操作异常
*/
@Override
public UUID getNullableResult(ResultSet rs, String columnName) throws SQLException {
byte[] bytes = rs.getBytes(columnName);
return bytes != null ? bytesToUUID(bytes) : null;
}
/**
* 从结果集中获取 UUID 值(通过列索引),并将 binary(16) 转为 UUID 类型。
*
* @param rs 结果集对象
* @param columnIndex 列索引(从1开始)
* @return 对应的 UUID 值,如果字段为 null 则返回 null
* @throws SQLException SQL 操作异常
*/
@Override
public UUID getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
byte[] bytes = rs.getBytes(columnIndex);
return bytes != null ? bytesToUUID(bytes) : null;
}
/**
* 从存储过程中获取 UUID 值(通过列索引),并将 binary(16) 转为 UUID 类型。
*
* @param cs CallableStatement 对象
* @param columnIndex 列索引(从1开始)
* @return 对应的 UUID 值,如果字段为 null 则返回 null
* @throws SQLException SQL 操作异常
*/
@Override
public UUID getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
byte[] bytes = cs.getBytes(columnIndex);
return bytes != null ? bytesToUUID(bytes) : null;
}
/**
* 将 UUID 对象转换为 16 字节的二进制数组。
*
* @param uuid 要转换的 UUID
* @return 二进制数组表示的 UUID(长度为16)
*/
private byte[] uuidToBytes(UUID uuid) {
ByteBuffer buffer = ByteBuffer.allocate(16);
buffer.putLong(uuid.getMostSignificantBits());
buffer.putLong(uuid.getLeastSignificantBits());
return buffer.array();
}
/**
* 将 16 字节的二进制数组转换为 UUID 对象。
*
* @param bytes 长度为16的字节数组
* @return 对应的 UUID 对象
*/
private UUID bytesToUUID(byte[] bytes) {
try {
if (bytes == null || bytes.length != 16) {
return null;
}
ByteBuffer buffer = ByteBuffer.wrap(bytes);
long high = buffer.getLong();
long low = buffer.getLong();
return new UUID(high, low);
} catch (Exception e) {
return null;
}
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
IPAddressTypeHandler
package local.ateng.java.mybatisjdk8.handler;
import org.apache.ibatis.type.BaseTypeHandler;
import org.apache.ibatis.type.JdbcType;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.sql.*;
/**
* MyBatis 类型处理器:将 MySQL 的 varbinary(16) 字段与 Java 中的 IP 字符串进行转换。
* <p>
* 在数据库中使用 INET6_ATON('127.0.01') | INET6_ATON('::1') 生成的 IPv4或者IPv6地址(二进制格式)
* 支持 IPv4(4字节)和 IPv6(16字节)地址的互相映射。
* 如果字段内容非法或解析异常,则返回 null。
* </p>
*
* <pre>
* 数据库字段类型:varbinary(16)
* Java 字段类型:String(如 "192.168.1.1" 或 "::1")
* </pre>
* <p>
* 示例使用:
* <pre>
* @TableField(typeHandler = IPAddressTypeHandler.class)
* private String ipAddress;
* </pre>
*
* @author 孔余
* @since 2025-07-27
*/
public class IPAddressTypeHandler extends BaseTypeHandler<String> {
/**
* 设置非空 IP 字符串参数到 PreparedStatement,写入为对应字节数组。
*
* @param ps PreparedStatement 对象
* @param i 参数索引
* @param ip IP 地址字符串(IPv4 或 IPv6)
* @param jdbcType JDBC 类型
* @throws SQLException SQL异常
*/
@Override
public void setNonNullParameter(PreparedStatement ps, int i, String ip, JdbcType jdbcType) throws SQLException {
try {
byte[] addressBytes = InetAddress.getByName(ip).getAddress();
ps.setBytes(i, addressBytes);
} catch (UnknownHostException e) {
ps.setNull(i, Types.NULL);
}
}
/**
* 通过列名获取 IP 字符串(从结果集)
*/
@Override
public String getNullableResult(ResultSet rs, String columnName) throws SQLException {
return toIpString(rs.getBytes(columnName));
}
/**
* 通过列索引获取 IP 字符串(从结果集)
*/
@Override
public String getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
return toIpString(rs.getBytes(columnIndex));
}
/**
* 从存储过程中通过列索引获取 IP 字符串
*/
@Override
public String getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
return toIpString(cs.getBytes(columnIndex));
}
/**
* 将 IP 地址字节数组转换为字符串(IPv4 或 IPv6),非法时返回 null。
*
* @param bytes IP 字节数组(应为 4 或 16 字节)
* @return 字符串形式的 IP 地址,或 null
*/
private String toIpString(byte[] bytes) {
if (bytes == null || bytes.length == 0) {
return null;
}
try {
// 自动兼容 IPv4(4字节)和 IPv6(16字节)
InetAddress byAddress = InetAddress.getByAddress(bytes);
return byAddress.getHostAddress();
} catch (UnknownHostException e) {
return null;
}
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
Base64TypeHandler
package local.ateng.java.mybatisjdk8.handler;
import org.apache.ibatis.type.BaseTypeHandler;
import org.apache.ibatis.type.JdbcType;
import java.sql.*;
import java.util.Base64;
/**
* MyBatis TypeHandler:用于将 MySQL 中的二进制字段(如 BLOB、BINARY、VARBINARY)与 Java 字段进行映射。
*
* <p>功能说明:
* <ul>
* <li>将数据库中的二进制数据(byte[])转换为 Base64 字符串,用于 Java 字段是 String 的情况</li>
* <li>将 Java 中的 Base64 字符串解码为 byte[] 后写入数据库</li>
* <li>支持查询时自动判断字段是否为 null,避免异常</li>
* </ul>
*
* <p>适用数据库字段类型:
* <ul>
* <li>BLOB</li>
* <li>BINARY(n)</li>
* <li>VARBINARY(n)</li>
* </ul>
*
* <p>适用 Java 字段类型:
* <ul>
* <li>String(Base64 格式)</li>
* </ul>
*
* <p>使用示例:
* <pre>{@code
* @TableField(typeHandler = Base64TypeHandler.class)
* private String binaryData;
* }</pre>
*
* <p>注意事项:
* <ul>
* <li>若字段为 null,查询时将返回 null,不抛出异常</li>
* <li>编码格式为标准 Base64,不包含换行</li>
* </ul>
*
* @author 孔余
* @since 2025-07-27
*/
public class Base64TypeHandler extends BaseTypeHandler<String> {
/**
* 设置非空参数:将 Base64 字符串解码为 byte[] 写入数据库
*
* @param ps PreparedStatement 对象
* @param i 参数索引(从1开始)
* @param parameter Base64 编码字符串
* @param jdbcType JDBC 类型(应为 BLOB)
* @throws SQLException SQL 异常
*/
@Override
public void setNonNullParameter(PreparedStatement ps, int i, String parameter, JdbcType jdbcType) throws SQLException {
try {
byte[] decoded = Base64.getDecoder().decode(parameter);
ps.setBytes(i, decoded);
} catch (Exception e) {
ps.setNull(i, Types.NULL);
}
}
/**
* 通过列名获取结果:将 byte[] 转为 Base64 字符串
*
* @param rs 结果集
* @param columnName 列名
* @return Base64 编码字符串,异常或为空时返回 null
* @throws SQLException SQL 异常
*/
@Override
public String getNullableResult(ResultSet rs, String columnName) throws SQLException {
try {
byte[] bytes = rs.getBytes(columnName);
return bytes != null ? Base64.getEncoder().encodeToString(bytes) : null;
} catch (Exception e) {
return null;
}
}
/**
* 通过列索引获取结果:将 byte[] 转为 Base64 字符串
*
* @param rs 结果集
* @param columnIndex 列索引(从1开始)
* @return Base64 编码字符串,异常或为空时返回 null
* @throws SQLException SQL 异常
*/
@Override
public String getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
try {
byte[] bytes = rs.getBytes(columnIndex);
return bytes != null ? Base64.getEncoder().encodeToString(bytes) : null;
} catch (Exception e) {
return null;
}
}
/**
* 通过 CallableStatement 获取结果:将 byte[] 转为 Base64 字符串
*
* @param cs CallableStatement 对象
* @param columnIndex 输出参数索引
* @return Base64 编码字符串,异常或为空时返回 null
* @throws SQLException SQL 异常
*/
@Override
public String getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
try {
byte[] bytes = cs.getBytes(columnIndex);
return bytes != null ? Base64.getEncoder().encodeToString(bytes) : null;
} catch (Exception e) {
return null;
}
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
GeometryTypeHandler
添加依赖
<!-- 地理空间数据处理库 -->
<dependency>
<groupId>org.locationtech.jts</groupId>
<artifactId>jts-core</artifactId>
<version>1.20.0</version>
</dependency>2
3
4
5
6
数据构建
GeometryFactory factory = new GeometryFactory(new PrecisionModel(), 4326);
Point point = factory.createPoint(new Coordinate(106.55, 29.56));2
实体类字段
/**
* 地理坐标(经纬度)
*/
@TableField(value = "location", typeHandler = GeometryTypeHandler.class)
private Geometry location;2
3
4
5
具体代码
package local.ateng.java.mybatisjdk8.handler;
import org.apache.ibatis.type.BaseTypeHandler;
import org.apache.ibatis.type.JdbcType;
import org.locationtech.jts.geom.Geometry;
import org.locationtech.jts.io.ByteOrderValues;
import org.locationtech.jts.io.WKBReader;
import org.locationtech.jts.io.WKBWriter;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.sql.*;
import java.util.Arrays;
/**
* MyBatis Plus 类型处理器:用于映射 MySQL 中的 Geometry 字段与 JTS 的 Geometry 对象
*
* <p>注意:</p>
* <ul>
* <li>写入时使用 WKB(带 SRID)格式</li>
* <li>读取时自动跳过前4字节的 SRID 并返回 Geometry 对象</li>
* <li>如解析失败,返回 null,不抛出异常</li>
* </ul>
*
* <p>建议 MySQL 字段类型为 <code>geometry SRID 4326</code></p>
*
* @author 孔余
* @since 2025-07-27
*/
@MappedJdbcTypes({JdbcType.OTHER}) // 数据库字段类型
@MappedTypes(Geometry.class) // Java 类型
public class GeometryTypeHandler extends BaseTypeHandler<Geometry> {
/**
* 设置非空参数到 PreparedStatement 中,使用带 SRID 的 WKB 格式。
*
* @param ps 预编译 SQL 语句
* @param i 参数索引(从1开始)
* @param parameter Geometry 参数
* @param jdbcType JDBC 类型(可为空)
* @throws SQLException SQL 异常
*/
@Override
public void setNonNullParameter(PreparedStatement ps, int i, Geometry parameter, JdbcType jdbcType) throws SQLException {
if (parameter == null) {
ps.setNull(i, Types.BINARY);
return;
}
try {
// 获取 SRID,默认 4326
int srid = parameter.getSRID() > 0 ? parameter.getSRID() : 4326;
// 使用 WKBWriter 生成 2D 小端 WKB,禁用 EWKB 扩展(Z/M/SRID)
WKBWriter wkbWriter = new WKBWriter(2, ByteOrderValues.LITTLE_ENDIAN, false);
byte[] wkb = wkbWriter.write(parameter);
// 拼接 SRID(4 字节小端序)和 WKB
ByteBuffer buffer = ByteBuffer.allocate(4 + wkb.length);
buffer.order(ByteOrder.LITTLE_ENDIAN);
buffer.putInt(srid);
buffer.put(wkb);
// 设置参数值为 MySQL 支持的 EWKB 格式二进制
ps.setBytes(i, buffer.array());
} catch (Exception e) {
// 保证接口契约,设置为 SQL NULL,避免报错
ps.setNull(i, Types.BINARY);
}
}
/**
* 从 ResultSet 中获取 Geometry 对象(按列名)
*
* @param rs 结果集
* @param columnName 列名
* @return Geometry 对象或 null
* @throws SQLException SQL 异常
*/
@Override
public Geometry getNullableResult(ResultSet rs, String columnName) throws SQLException {
return parseGeometry(rs.getBytes(columnName));
}
/**
* 从 ResultSet 中获取 Geometry 对象(按列索引)
*
* @param rs 结果集
* @param columnIndex 列索引(从1开始)
* @return Geometry 对象或 null
* @throws SQLException SQL 异常
*/
@Override
public Geometry getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
return parseGeometry(rs.getBytes(columnIndex));
}
/**
* 从 CallableStatement 中获取 Geometry 对象
*
* @param cs 存储过程调用
* @param columnIndex 列索引(从1开始)
* @return Geometry 对象或 null
* @throws SQLException SQL 异常
*/
@Override
public Geometry getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
return parseGeometry(cs.getBytes(columnIndex));
}
/**
* 解析 MySQL 返回的 GEOMETRY 字节流(包含 SRID 前缀)
*
* @param bytes GEOMETRY 字节流
* @return Geometry 对象或 null(如果失败)
*/
private Geometry parseGeometry(byte[] bytes) {
if (bytes == null || bytes.length < 5) {
return null;
}
try {
// 提取 SRID(前4字节)
ByteBuffer sridBuffer = ByteBuffer.wrap(bytes, 0, 4).order(ByteOrder.LITTLE_ENDIAN);
int srid = sridBuffer.getInt();
// 提取 WKB 并解析
byte[] wkb = Arrays.copyOfRange(bytes, 4, bytes.length);
WKBReader reader = new WKBReader();
Geometry geometry = reader.read(wkb);
geometry.setSRID(srid);
return geometry;
} catch (Exception e) {
return null; // 解析失败返回 null
}
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
FastjsonTypeHandler
package local.ateng.java.mybatis.handler;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.parser.Feature;
import com.alibaba.fastjson.serializer.SerializerFeature;
import com.baomidou.mybatisplus.extension.handlers.AbstractJsonTypeHandler;
import local.ateng.java.mybatis.entity.MyData;
import local.ateng.java.mybatis.entity.MyDataList;
import org.apache.ibatis.type.JdbcType;
import org.apache.ibatis.type.MappedJdbcTypes;
import org.apache.ibatis.type.MappedTypes;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.lang.reflect.Field;
import java.lang.reflect.Type;
/**
* 基于 Fastjson 的通用 JSON TypeHandler 实现
* <p>
* 用于实现数据库 JSON 字符串与 Java 对象之间的自动转换,兼容 MyBatis-Plus 的泛型处理机制
* <p>
* 特性说明:
* 1. 支持普通 Java Bean 的序列化与反序列化
* 2. 支持泛型类型(如 List、Map、自定义泛型包装类)
* 3. 在字段级别可通过反射自动解析泛型真实类型(依赖 Field 元信息)
* 4. 泛型推断仅在字段级 TypeHandler 生效,全局 @MappedTypes 无法感知泛型参数
* <p>
* 适用场景:
* - JSON 字段(VARCHAR / TEXT / JSON)映射为复杂对象结构
* - 需要强类型而非原始字符串处理 JSON 数据
*
* @author Ateng
* @since 2026-04-13
*/
@MappedJdbcTypes({JdbcType.VARCHAR, JdbcType.LONGVARCHAR, JdbcType.OTHER})
// 指定当前 TypeHandler 适用的 JDBC 类型(对应数据库 JSON 字段常见存储类型)
@MappedTypes({MyData.class, MyDataList.class})
// 指定当前 TypeHandler 绑定的 Java 类型(用于全局匹配,泛型类型在此无法生效)
public class FastjsonTypeHandler<T> extends AbstractJsonTypeHandler<T> {
private static final Logger log = LoggerFactory.getLogger(FastjsonTypeHandler.class);
/**
* 标准构造方法(MyBatis 使用)
* <p>
* 用于非泛型或无法获取字段上下文的场景
*
* @param type 目标 Java 类型
*/
public FastjsonTypeHandler(Class<?> type) {
super(type);
}
/**
* 扩展构造方法(MyBatis-Plus 使用)
* <p>
* 通过 Field 获取泛型信息,用于精确反序列化(如 List<T>、Wrapper<T>)
*
* @param type 目标 Java 类型
* @param field 当前字段反射对象
*/
public FastjsonTypeHandler(Class<?> type, Field field) {
super(type, field);
}
/**
* 将 JSON 字符串解析为对象
*
* @param json 数据库中存储的 JSON 字符串
* @return 解析后的 Java 对象,解析失败或为空则返回 null
*/
@Override
public T parse(String json) {
if (json == null || json.trim().isEmpty()) {
return null;
}
try {
Type fieldType = getFieldType();
return JSON.parseObject(
json,
fieldType,
// 允许 JSON 中包含注释(// 或 /* */)
Feature.AllowComment,
// 允许字段名不加双引号
Feature.AllowUnQuotedFieldNames,
// 允许单引号作为字符串定界符
Feature.AllowSingleQuotes,
// 字段名使用常量池优化内存
Feature.InternFieldNames,
// 允许多余的逗号
Feature.AllowArbitraryCommas,
// 忽略 JSON 中不存在的字段
Feature.IgnoreNotMatch,
// 将小数解析为 BigDecimal(而不是 Double)
Feature.UseBigDecimal,
// 允许 ISO 8601 日期格式(例如:2023-10-11T14:30:00Z)
Feature.AllowISO8601DateFormat
);
} catch (Exception e) {
log.error("Fastjson 反序列化失败,json={}", json, e);
return null;
}
}
/**
* 将对象序列化为 JSON 字符串,用于写入数据库
*
* @param obj Java 对象
* @return 序列化后的 JSON 字符串,失败或为空返回 null
*/
@Override
public String toJson(T obj) {
if (obj == null) {
return null;
}
try {
return JSON.toJSONString(
obj,
// 输出为 null 的字段,否则默认会被忽略
SerializerFeature.WriteMapNullValue,
// 禁用循环引用检测,避免出现 "$ref" 结构
SerializerFeature.DisableCircularReferenceDetect
);
} catch (Exception e) {
log.error("Fastjson 序列化失败,obj={}", obj, e);
return null;
}
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
Fastjson2TypeHandler
package local.ateng.java.mybatis.handler;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONReader;
import com.alibaba.fastjson2.JSONWriter;
import com.baomidou.mybatisplus.extension.handlers.AbstractJsonTypeHandler;
import local.ateng.java.mybatis.entity.MyData;
import local.ateng.java.mybatis.entity.MyDataList;
import org.apache.ibatis.type.JdbcType;
import org.apache.ibatis.type.MappedJdbcTypes;
import org.apache.ibatis.type.MappedTypes;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.lang.reflect.Field;
import java.lang.reflect.Type;
/**
* 基于 Fastjson2 的通用 JSON TypeHandler 实现
* <p>
* 用于实现数据库 JSON 字符串与 Java 对象之间的自动转换,兼容 MyBatis-Plus 的泛型处理机制
* <p>
* 特性说明:
* 1. 支持普通 Java Bean 的序列化与反序列化
* 2. 支持泛型类型(如 List、Map、自定义泛型包装类)
* 3. 在字段级别可通过反射自动解析泛型真实类型(依赖 Field 元信息)
* 4. 泛型推断仅在字段级 TypeHandler 生效,全局 @MappedTypes 无法感知泛型参数
* <p>
* 适用场景:
* - JSON 字段(VARCHAR / TEXT / JSON)映射为复杂对象结构
* - 需要强类型而非原始字符串处理 JSON 数据
*
* @author Ateng
* @since 2026-04-13
*/
@MappedJdbcTypes({JdbcType.VARCHAR, JdbcType.LONGVARCHAR, JdbcType.OTHER})
// 指定当前 TypeHandler 适用的 JDBC 类型(对应数据库 JSON 字段常见存储类型)
@MappedTypes({MyData.class, MyDataList.class})
// 指定当前 TypeHandler 绑定的 Java 类型(用于全局匹配,泛型类型在此无法生效)
public class Fastjson2TypeHandler<T> extends AbstractJsonTypeHandler<T> {
private static final Logger log = LoggerFactory.getLogger(Fastjson2TypeHandler.class);
/**
* 标准构造方法(MyBatis 使用)
* <p>
* 用于非泛型或无法获取字段上下文的场景
*
* @param type 目标 Java 类型
*/
public Fastjson2TypeHandler(Class<?> type) {
super(type);
}
/**
* 扩展构造方法(MyBatis-Plus 使用)
* <p>
* 通过 Field 获取泛型信息,用于精确反序列化(如 List<T>、Wrapper<T>)
*
* @param type 目标 Java 类型
* @param field 当前字段反射对象
*/
public Fastjson2TypeHandler(Class<?> type, Field field) {
super(type, field);
}
/**
* 将 JSON 字符串解析为对象
*
* @param json 数据库中存储的 JSON 字符串
* @return 解析后的 Java 对象,解析失败或为空则返回 null
*/
@Override
public T parse(String json) {
if (json == null || json.trim().isEmpty()) {
return null;
}
try {
Type fieldType = getFieldType();
return JSON.parseObject(
json,
fieldType,
// 默认下是camel case精确匹配,打开这个后,能够智能识别camel/upper/pascal/snake/Kebab五中case
JSONReader.Feature.SupportSmartMatch,
// 允许字段名不带引号
JSONReader.Feature.AllowUnQuotedFieldNames,
// 忽略无法序列化的字段
JSONReader.Feature.IgnoreNoneSerializable
);
} catch (Exception e) {
log.error("Fastjson2 反序列化失败,json={}", json, e);
return null;
}
}
/**
* 将对象序列化为 JSON 字符串,用于写入数据库
*
* @param obj Java 对象
* @return 序列化后的 JSON 字符串,失败或为空返回 null
*/
@Override
public String toJson(T obj) {
if (obj == null) {
return null;
}
try {
return JSON.toJSONString(
obj,
// 序列化输出空值字段
JSONWriter.Feature.WriteNulls,
// 基于字段反序列化
JSONWriter.Feature.FieldBased
);
} catch (Exception e) {
log.error("Fastjson2 序列化失败,obj={}", obj, e);
return null;
}
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
JacksonTypeHandler
ObjectMapper 构建工厂
package local.ateng.java.mybatis.config;
import com.fasterxml.jackson.annotation.*;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.json.JsonReadFeature;
import com.fasterxml.jackson.databind.*;
import com.fasterxml.jackson.databind.cfg.MapperConfig;
import com.fasterxml.jackson.databind.json.JsonMapper;
import com.fasterxml.jackson.databind.jsontype.PolymorphicTypeValidator;
import com.fasterxml.jackson.databind.module.SimpleModule;
import com.fasterxml.jackson.databind.ser.std.ToStringSerializer;
import com.fasterxml.jackson.datatype.jdk8.Jdk8Module;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalTimeDeserializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalTimeSerializer;
import com.fasterxml.jackson.module.paramnames.ParameterNamesModule;
import java.io.IOException;
import java.io.Serial;
import java.io.Serializable;
import java.math.BigInteger;
import java.text.SimpleDateFormat;
import java.time.*;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.List;
import java.util.Set;
import java.util.TimeZone;
/**
* Jackson ObjectMapper 统一构建工厂。
*
* <p>
* 该工厂用于集中管理 Spring Boot 3 / Jackson 2 的 JSON 序列化与反序列化策略,避免不同模块各自创建
* ObjectMapper 导致时间策略、类型信息、字段可见性、数值精度、枚举策略和容错边界不一致。
* </p>
*
* <p>
* 设计原则:Default 与 Storage 不做业务时间格式化,仅注册 Jackson 原生 JavaTimeModule;Web 输出业务格式时间;
* Audit 以稳定、可读、可追溯为主,默认不写入 @class,避免审计日志与 Java 类名强耦合。
* </p>
*
* <p>
* 使用建议:ObjectMapper 完成配置后应作为单例 Bean 复用,不要在高频路径中反复创建。开启 @class 的 Mapper
* 仅用于可信内部数据,例如 Redis、数据库 JSON 字段或内部消息,不建议直接处理外部请求体。
* </p>
*
* @author Ateng
* @since 2026-04-30
*/
public final class JacksonObjectMapperFactory {
/**
* 默认类型信息字段名。
*/
private static final String TYPE_PROPERTY_NAME = "@class";
/**
* 默认信任的业务包前缀。
*/
private static final List<String> TRUSTED_BASE_PACKAGES = List.of(
// 允许 io.github.atengk 包下的业务类型参与默认类型反序列化
"io.github.atengk",
// 允许 local.ateng.java 包下的业务类型参与默认类型反序列化
"local.ateng.java"
);
/**
* 允许参与默认类型反序列化的 JDK 包前缀。
*/
private static final List<String> TRUSTED_JDK_PACKAGE_PREFIXES = List.of(
// 允许 JDK 集合、日期容器和常用工具类型参与默认类型反序列化
"java.util.",
// 允许 Java 8 时间类型参与默认类型反序列化
"java.time.",
// 允许 BigDecimal、BigInteger 等高精度数值类型参与默认类型反序列化
"java.math.",
// 允许 Jackson Tree Model 节点类型参与默认类型反序列化
"com.fasterxml.jackson.databind.node."
);
/**
* 允许参与默认类型反序列化的 JDK 精确类名。
*/
private static final Set<String> TRUSTED_JDK_CLASS_NAMES = Set.of(
// 允许 Object 作为通用根类型
"java.lang.Object",
// 允许字符串类型
"java.lang.String",
// 允许 Boolean 包装类型
"java.lang.Boolean",
// 允许 Character 包装类型
"java.lang.Character",
// 允许 Byte 包装类型
"java.lang.Byte",
// 允许 Short 包装类型
"java.lang.Short",
// 允许 Integer 包装类型
"java.lang.Integer",
// 允许 Long 包装类型
"java.lang.Long",
// 允许 Float 包装类型
"java.lang.Float",
// 允许 Double 包装类型
"java.lang.Double",
// 允许 Void 类型
"java.lang.Void",
// 允许异常堆栈元素类型
"java.lang.StackTraceElement",
// 允许 Throwable 基类
"java.lang.Throwable",
// 允许 Exception 基类
"java.lang.Exception",
// 允许 RuntimeException 基类
"java.lang.RuntimeException"
);
/**
* JVM 基础类型数组名称。
*/
private static final Set<String> JVM_PRIMITIVE_ARRAY_NAMES = Set.of(
// 允许 boolean[] JVM 内部类型名
"[Z",
// 允许 byte[] JVM 内部类型名
"[B",
// 允许 short[] JVM 内部类型名
"[S",
// 允许 int[] JVM 内部类型名
"[I",
// 允许 long[] JVM 内部类型名
"[J",
// 允许 float[] JVM 内部类型名
"[F",
// 允许 double[] JVM 内部类型名
"[D",
// 允许 char[] JVM 内部类型名
"[C"
);
/**
* 默认时区。
*/
private static final ZoneId DEFAULT_ZONE_ID = ZoneId.of("Asia/Shanghai");
/**
* 日期时间格式,精确到秒。
*/
private static final String DATE_TIME_PATTERN = "yyyy-MM-dd HH:mm:ss";
/**
* 日期格式。
*/
private static final String DATE_PATTERN = "yyyy-MM-dd";
/**
* 时间格式。
*/
private static final String TIME_PATTERN = "HH:mm:ss";
/**
* 日期时间格式化器。
*/
private static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern(DATE_TIME_PATTERN);
/**
* ISO 日期时间格式化器。
*/
private static final DateTimeFormatter ISO_LOCAL_DATE_TIME_FORMATTER = DateTimeFormatter.ISO_LOCAL_DATE_TIME;
/**
* 日期格式化器。
*/
private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern(DATE_PATTERN);
/**
* 时间格式化器。
*/
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern(TIME_PATTERN);
/**
* 禁止实例化工具工厂。
*/
private JacksonObjectMapperFactory() {
}
/**
* 构建默认 ObjectMapper。
*
* <p>
* 默认场景不做业务时间格式化,不设置 SimpleDateFormat,不挂载自定义 LocalDateTimeSerializer。
* 该 Mapper 适合通用 JSON 处理、对象转换、内部工具方法和不希望时间被固定格式改写的场景。
* </p>
*
* @return 默认 ObjectMapper
*/
public static ObjectMapper buildDefaultObjectMapper() {
// 创建使用原生时间模块的基础 JsonMapper
JsonMapper objectMapper = baseBuilder(buildNativeJavaTimeModule())
// 构建默认 ObjectMapper 实例
.build();
// 默认场景使用公开成员可见性策略,避免直接暴露私有字段
applyWebVisibility(objectMapper);
// 返回配置完成的默认 ObjectMapper
return objectMapper;
}
/**
* 构建用于 Redis / 数据库存储的 ObjectMapper。
*
* <p>
* 存储场景不做业务时间格式化,保留 Jackson 原生 JavaTimeModule 行为,并开启 @class 类型信息,
* 用于恢复 Object、接口、抽象类、集合元素等真实类型。该 Mapper 仅建议用于可信内部数据。
* </p>
*
* @return 存储场景专用 ObjectMapper
*/
public static ObjectMapper buildStorageObjectMapper() {
// 创建使用原生时间模块的基础 JsonMapper
JsonMapper objectMapper = baseBuilder(buildNativeJavaTimeModule())
// 启用 BigDecimal 普通数字输出,避免科学计数法影响存储稳定性
.configure(JsonGenerator.Feature.WRITE_BIGDECIMAL_AS_PLAIN, true)
// 启用属性按字母排序,保证序列化结果稳定,便于缓存比对和签名计算
.enable(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY)
// 启用 Map 按 Key 排序,保证 Map 输出顺序稳定
.enable(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS)
// 构建存储场景 ObjectMapper 实例
.build();
// 存储场景使用字段优先可见性,尽量完整保存对象内部状态
applyFieldVisibility(objectMapper);
// 启用受限默认类型信息,并使用 @class 字段保存类型
applyDefaultTyping(objectMapper);
// 为 Throwable 增加混入配置,提升异常对象存储兼容性
applyThrowableMixIn(objectMapper);
// 返回配置完成的存储场景 ObjectMapper
return objectMapper;
}
/**
* 构建用于 Spring Web 的 ObjectMapper。
*
* <p>
* Web 场景需要格式化时间输出,LocalDateTime 输出为 yyyy-MM-dd HH:mm:ss,LocalDate 输出为 yyyy-MM-dd,
* LocalTime 输出为 HH:mm:ss,java.util.Date 也按统一业务格式输出。该 Mapper 同时处理 Long / BigInteger
* 前端精度问题和常见入参兼容问题。
* </p>
*
* @return Web 场景专用 ObjectMapper
*/
public static ObjectMapper buildWebObjectMapper() {
// 创建使用业务格式化时间模块的基础 JsonMapper
JsonMapper objectMapper = baseBuilder(buildFormattedJavaTimeModule())
// 允许 JSON 中存在 Java/C 风格注释,便于兼容部分前端或第三方输入
.enable(JsonReadFeature.ALLOW_JAVA_COMMENTS)
// 允许单引号字符串,兼容部分非严格 JSON 输入
.enable(JsonReadFeature.ALLOW_SINGLE_QUOTES)
// 允许数组或对象末尾存在多余逗号
.enable(JsonReadFeature.ALLOW_TRAILING_COMMA)
// 允许标量类型自动转换,例如字符串数字转换为数值类型
.enable(MapperFeature.ALLOW_COERCION_OF_SCALARS)
// 允许单个值按数组处理,提高接口入参兼容性
.enable(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY)
// 未知枚举值反序列化为 null,避免接口因枚举扩展直接失败
.enable(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_AS_NULL)
// 浮点数反序列化优先使用 BigDecimal,降低精度损失风险
.enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS)
// 整数反序列化优先使用 BigInteger,降低整数溢出风险
.enable(DeserializationFeature.USE_BIG_INTEGER_FOR_INTS)
// 枚举序列化使用 toString,便于枚举对外展示值与内部 name 解耦
.enable(SerializationFeature.WRITE_ENUMS_USING_TO_STRING)
// 枚举反序列化使用 toString,与序列化策略保持一致
.enable(DeserializationFeature.READ_ENUMS_USING_TO_STRING)
// 构建 Web 场景 ObjectMapper 实例
.build();
// 配置 java.util.Date 的业务格式化输出
configureFormattedLegacyDate(objectMapper);
// Web 场景使用公开成员可见性策略,契合 Controller DTO 常规设计
applyWebVisibility(objectMapper);
// Web 场景将 Long 和 BigInteger 序列化为字符串,避免 JavaScript 精度丢失
applyNumberSerialization(objectMapper);
// 返回配置完成的 Web 场景 ObjectMapper
return objectMapper;
}
/**
* 构建用于审计日志的 ObjectMapper。
*
* <p>
* 审计日志场景优先保证可读性、字段完整性、输出稳定性和跨系统检索友好性。因此该 Mapper 使用格式化时间、
* 字段优先可见性、稳定排序和大整数字符串输出。默认不写入 @class,避免审计日志暴露 Java 类名或强耦合类结构。
* </p>
*
* @return 审计日志专用 ObjectMapper
*/
public static ObjectMapper buildAuditObjectMapper() {
// 创建使用业务格式化时间模块的基础 JsonMapper
JsonMapper objectMapper = baseBuilder(buildFormattedJavaTimeModule())
// 启用 BigDecimal 普通数字输出,避免科学计数法影响审计可读性
.configure(JsonGenerator.Feature.WRITE_BIGDECIMAL_AS_PLAIN, true)
// 启用属性按字母排序,保证审计日志结构稳定
.enable(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY)
// 启用 Map 按 Key 排序,保证审计日志集合输出稳定
.enable(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS)
// 枚举序列化使用 toString,提升审计日志可读性
.enable(SerializationFeature.WRITE_ENUMS_USING_TO_STRING)
// 枚举反序列化使用 toString,保持审计回放策略一致
.enable(DeserializationFeature.READ_ENUMS_USING_TO_STRING)
// 浮点数反序列化优先使用 BigDecimal,降低审计回放精度损失
.enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS)
// 整数反序列化优先使用 BigInteger,降低审计回放整数溢出风险
.enable(DeserializationFeature.USE_BIG_INTEGER_FOR_INTS)
// 构建审计日志场景 ObjectMapper 实例
.build();
// 配置 java.util.Date 的业务格式化输出
configureFormattedLegacyDate(objectMapper);
// 审计日志使用字段优先可见性,尽量记录完整对象状态
applyFieldVisibility(objectMapper);
// 审计日志将 Long 和 BigInteger 序列化为字符串,避免跨系统查看时精度丢失
applyNumberSerialization(objectMapper);
// 为 Throwable 增加混入配置,提升异常审计日志可读性和完整性
applyThrowableMixIn(objectMapper);
// 返回配置完成的审计日志 ObjectMapper
return objectMapper;
}
/**
* 构建基础 JsonMapper Builder。
*
* @param javaTimeModule Java 8 时间模块
* @return JsonMapper Builder
*/
private static JsonMapper.Builder baseBuilder(JavaTimeModule javaTimeModule) {
// 使用 JsonMapper Builder 创建 Jackson 2 JSON 专用 Mapper 构建器
return JsonMapper.builder()
// 注册 JDK8 模块,支持 Optional 等 JDK8 类型
.addModule(new Jdk8Module())
// 注册构造参数名模块,支持基于 -parameters 的构造器反序列化
.addModule(new ParameterNamesModule(JsonCreator.Mode.DEFAULT))
// 注册调用方传入的 Java 8 时间模块
.addModule(javaTimeModule)
// 设置默认时区,保证传统 Date 与上下文时区一致
.defaultTimeZone(defaultTimeZone())
// 保留 null 字段,避免接口或存储结构因空值被隐藏
.serializationInclusion(JsonInclude.Include.ALWAYS)
// 禁用日期时间戳输出,原生时间模块会输出 ISO/default 字符串,Web/Audit 会输出业务格式字符串
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
// 禁用 Duration 时间戳输出,保持时间文本可读
.disable(SerializationFeature.WRITE_DURATIONS_AS_TIMESTAMPS)
// 空 Bean 不抛异常,避免无属性对象导致序列化失败
.disable(SerializationFeature.FAIL_ON_EMPTY_BEANS)
// 忽略未知字段,提升接口和存储结构演进兼容性
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
// 禁止反序列化时自动调整时区,避免时间偏移
.disable(DeserializationFeature.ADJUST_DATES_TO_CONTEXT_TIME_ZONE);
}
/**
* 构建原生 Java 8 时间模块。
*
* <p>
* 该模块不注册自定义 LocalDateTimeSerializer,不设置业务时间格式,用于 Default 与 Storage 场景,避免时间被
* yyyy-MM-dd HH:mm:ss 强制改写。
* </p>
*
* @return 原生 JavaTimeModule
*/
private static JavaTimeModule buildNativeJavaTimeModule() {
// 返回 Jackson 原生 JavaTimeModule
return new JavaTimeModule();
}
/**
* 构建业务格式化 Java 8 时间模块。
*
* @return 业务格式化 JavaTimeModule
*/
private static JavaTimeModule buildFormattedJavaTimeModule() {
// 创建 JavaTimeModule,用于统一 Java 8 时间类型序列化策略
JavaTimeModule module = new JavaTimeModule();
// 注册 LocalDateTime 序列化器,输出 yyyy-MM-dd HH:mm:ss
module.addSerializer(LocalDateTime.class, new LocalDateTimeSerializer(DATE_TIME_FORMATTER));
// 注册 LocalDateTime 反序列化器,兼容 yyyy-MM-dd HH:mm:ss 和 ISO_LOCAL_DATE_TIME
module.addDeserializer(LocalDateTime.class, new MultiPatternLocalDateTimeDeserializer());
// 注册 LocalDate 序列化器,输出 yyyy-MM-dd
module.addSerializer(LocalDate.class, new LocalDateSerializer(DATE_FORMATTER));
// 注册 LocalDate 反序列化器,输入 yyyy-MM-dd
module.addDeserializer(LocalDate.class, new LocalDateDeserializer(DATE_FORMATTER));
// 注册 LocalTime 序列化器,输出 HH:mm:ss
module.addSerializer(LocalTime.class, new LocalTimeSerializer(TIME_FORMATTER));
// 注册 LocalTime 反序列化器,输入 HH:mm:ss
module.addDeserializer(LocalTime.class, new LocalTimeDeserializer(TIME_FORMATTER));
// 注册 Instant 序列化器,输出 ISO_INSTANT,保留 UTC 时间语义
module.addSerializer(Instant.class, new InstantStringSerializer());
// 注册 Instant 反序列化器,输入 ISO_INSTANT
module.addDeserializer(Instant.class, new InstantStringDeserializer());
// 注册 OffsetDateTime 序列化器,输出 ISO_OFFSET_DATE_TIME,保留 offset 信息
module.addSerializer(OffsetDateTime.class, new OffsetDateTimeStringSerializer());
// 注册 OffsetDateTime 反序列化器,输入 ISO_OFFSET_DATE_TIME
module.addDeserializer(OffsetDateTime.class, new OffsetDateTimeStringDeserializer());
// 注册 ZonedDateTime 序列化器,输出 ISO_ZONED_DATE_TIME,保留 ZoneId 信息
module.addSerializer(ZonedDateTime.class, new ZonedDateTimeStringSerializer());
// 注册 ZonedDateTime 反序列化器,输入 ISO_ZONED_DATE_TIME
module.addDeserializer(ZonedDateTime.class, new ZonedDateTimeStringDeserializer());
// 返回配置完成的业务格式化 JavaTimeModule
return module;
}
/**
* 配置传统 Date 类型的业务格式。
*
* @param objectMapper ObjectMapper 实例
*/
private static void configureFormattedLegacyDate(ObjectMapper objectMapper) {
// 创建传统 Date 格式化器,统一输出 yyyy-MM-dd HH:mm:ss
SimpleDateFormat dateFormat = new SimpleDateFormat(DATE_TIME_PATTERN);
// 设置传统 Date 格式化器时区,避免服务器默认时区差异
dateFormat.setTimeZone(defaultTimeZone());
// 将 Date 格式化器应用到 ObjectMapper
objectMapper.setDateFormat(dateFormat);
}
/**
* 设置 Web 场景对象可见性。
*
* @param objectMapper ObjectMapper 实例
*/
private static void applyWebVisibility(ObjectMapper objectMapper) {
// 统一设置公开成员可见性
applyVisibility(
// 当前需要设置可见性的 ObjectMapper 实例
objectMapper,
// 字段可见性:仅公开字段参与序列化和反序列化
JsonAutoDetect.Visibility.PUBLIC_ONLY,
// getter 可见性:仅公开 getter 参与序列化
JsonAutoDetect.Visibility.PUBLIC_ONLY,
// setter 可见性:仅公开 setter 参与反序列化
JsonAutoDetect.Visibility.PUBLIC_ONLY,
// isGetter 可见性:仅公开 isXxx getter 参与序列化
JsonAutoDetect.Visibility.PUBLIC_ONLY,
// 构造器可见性:保持 Jackson 默认策略
JsonAutoDetect.Visibility.DEFAULT
);
}
/**
* 设置字段优先对象可见性。
*
* @param objectMapper ObjectMapper 实例
*/
private static void applyFieldVisibility(ObjectMapper objectMapper) {
// 统一设置字段优先可见性
applyVisibility(
// 当前需要设置可见性的 ObjectMapper 实例
objectMapper,
// 字段可见性:所有字段都参与序列化和反序列化
JsonAutoDetect.Visibility.ANY,
// getter 可见性:禁用 getter,避免重复输出或触发计算逻辑
JsonAutoDetect.Visibility.NONE,
// setter 可见性:禁用 setter,优先直接恢复字段状态
JsonAutoDetect.Visibility.NONE,
// isGetter 可见性:禁用 isXxx getter,避免布尔计算属性干扰存储结构
JsonAutoDetect.Visibility.NONE,
// 构造器可见性:禁用自动构造器探测,降低存储场景反射歧义
JsonAutoDetect.Visibility.NONE
);
}
/**
* 统一设置对象可见性策略。
*
* @param objectMapper ObjectMapper 实例
* @param fieldVisibility 字段可见性
* @param getterVisibility getter 可见性
* @param setterVisibility setter 可见性
* @param isGetterVisibility isGetter 可见性
* @param creatorVisibility 构造器可见性
*/
private static void applyVisibility(ObjectMapper objectMapper,
JsonAutoDetect.Visibility fieldVisibility,
JsonAutoDetect.Visibility getterVisibility,
JsonAutoDetect.Visibility setterVisibility,
JsonAutoDetect.Visibility isGetterVisibility,
JsonAutoDetect.Visibility creatorVisibility) {
// 将统一可见性检查器设置到 ObjectMapper
objectMapper.setVisibility(
// 获取当前序列化配置
objectMapper.getSerializationConfig()
// 获取默认可见性检查器
.getDefaultVisibilityChecker()
// 设置字段可见性策略
.withFieldVisibility(fieldVisibility)
// 设置 getter 可见性策略
.withGetterVisibility(getterVisibility)
// 设置 setter 可见性策略
.withSetterVisibility(setterVisibility)
// 设置 isGetter 可见性策略
.withIsGetterVisibility(isGetterVisibility)
// 设置构造器可见性策略
.withCreatorVisibility(creatorVisibility)
);
}
/**
* 统一数值序列化策略。
*
* <p>
* JavaScript 对超过安全整数范围的 long / BigInteger 存在精度风险,因此 Web 与审计输出统一写为字符串。
* BigDecimal 保持数值输出,并通过 WRITE_BIGDECIMAL_AS_PLAIN 避免科学计数法。
* </p>
*
* @param objectMapper ObjectMapper 实例
*/
private static void applyNumberSerialization(ObjectMapper objectMapper) {
// 创建数值兼容模块,用于注册前端精度安全序列化器
SimpleModule module = new SimpleModule("NumberCompatibilityModule");
// 使用 Jackson 内置字符串序列化器输出大整数
ToStringSerializer stringSerializer = ToStringSerializer.instance;
// Long 包装类型序列化为字符串,避免 JavaScript 精度丢失
module.addSerializer(Long.class, stringSerializer);
// long 基本类型序列化为字符串,避免 JavaScript 精度丢失
module.addSerializer(Long.TYPE, stringSerializer);
// BigInteger 序列化为字符串,避免超大整数跨端精度丢失
module.addSerializer(BigInteger.class, stringSerializer);
// 将数值兼容模块注册到 ObjectMapper
objectMapper.registerModule(module);
}
/**
* 启用默认多态能力,并限制反序列化来源类型范围。
*
* @param objectMapper ObjectMapper 实例
*/
private static void applyDefaultTyping(ObjectMapper objectMapper) {
// 非法或不可信子类型直接失败,避免静默降级带来安全风险
objectMapper.enable(DeserializationFeature.FAIL_ON_INVALID_SUBTYPE);
// 启用受限默认类型信息,并使用 @class 作为类型字段名
objectMapper.activateDefaultTypingAsProperty(
// 使用自定义白名单多态类型校验器
buildPolymorphicTypeValidator(),
// 仅对非 final 类型写入类型信息,兼顾类型恢复和 JSON 体积
ObjectMapper.DefaultTyping.NON_FINAL,
// 使用 @class 作为默认类型信息字段名
TYPE_PROPERTY_NAME
);
}
/**
* 为异常类型挂载混入配置。
*
* @param objectMapper ObjectMapper 实例
*/
private static void applyThrowableMixIn(ObjectMapper objectMapper) {
// 为 Throwable 绑定混入,处理异常对象字段、类型信息和循环引用
objectMapper.addMixIn(Throwable.class, ThrowableMixIn.class);
}
/**
* 构建多态类型校验器。
*
* @return 多态类型校验器
*/
private static PolymorphicTypeValidator buildPolymorphicTypeValidator() {
// 返回受限白名单多态类型校验器
return new TrustedPolymorphicTypeValidator();
}
/**
* 获取默认时区实例。
*
* @return 默认时区
*/
private static TimeZone defaultTimeZone() {
// 根据默认 ZoneId 获取 TimeZone 实例
return TimeZone.getTimeZone(DEFAULT_ZONE_ID);
}
/**
* 判断字符串是否为空白。
*
* @param value 待判断字符串
* @return true 表示字符串为 null、空串或仅包含空白字符
*/
private static boolean isBlank(String value) {
if (value == null) {
return true;
}
for (int i = 0; i < value.length(); i++) {
if (!Character.isWhitespace(value.charAt(i))) {
return false;
}
}
return true;
}
/**
* 判断类名是否属于可信范围。
*
* @param className 类名
* @return true 表示可信
*/
private static boolean isTrustedClassName(String className) {
if (isBlank(className)) {
return false;
}
if (JVM_PRIMITIVE_ARRAY_NAMES.contains(className)) {
return true;
}
String normalizedClassName = normalizeJvmArrayClassName(className.trim());
if (TRUSTED_JDK_CLASS_NAMES.contains(normalizedClassName)) {
return true;
}
if (normalizedClassName.startsWith("java.lang.")
&& (normalizedClassName.endsWith("Exception") || normalizedClassName.endsWith("Error"))) {
return true;
}
for (String packagePrefix : TRUSTED_JDK_PACKAGE_PREFIXES) {
if (normalizedClassName.startsWith(packagePrefix)) {
return true;
}
}
for (String basePackage : TRUSTED_BASE_PACKAGES) {
String normalizedPackage = basePackage.endsWith(".") ? basePackage : basePackage + ".";
if (normalizedClassName.startsWith(normalizedPackage)) {
return true;
}
}
return false;
}
/**
* 规范化 JVM 数组类名。
*
* @param className 类名
* @return 规范化后的类名
*/
private static String normalizeJvmArrayClassName(String className) {
String normalizedClassName = className;
while (normalizedClassName.startsWith("[")) {
if (JVM_PRIMITIVE_ARRAY_NAMES.contains(normalizedClassName)) {
return normalizedClassName;
}
if (normalizedClassName.startsWith("[L") && normalizedClassName.endsWith(";")) {
normalizedClassName = normalizedClassName.substring(2, normalizedClassName.length() - 1);
continue;
}
normalizedClassName = normalizedClassName.substring(1);
}
return normalizedClassName;
}
/**
* 可信多态类型校验器。
*
* @author Ateng
* @since 2026-04-30
*/
private static final class TrustedPolymorphicTypeValidator extends PolymorphicTypeValidator.Base implements Serializable {
/**
* 序列化版本号。
*/
@Serial
private static final long serialVersionUID = -8757087763320902321L;
/**
* 基于子类名称校验多态类型是否合法。
*
* @param config Mapper 配置
* @param baseType 基础类型
* @param subClassName 子类名称
* @return 多态类型合法性结果
*/
@Override
public Validity validateSubClassName(MapperConfig<?> config, JavaType baseType, String subClassName) {
return isTrustedClassName(subClassName) ? Validity.ALLOWED : Validity.DENIED;
}
/**
* 基于 JavaType 校验多态类型是否合法。
*
* @param config Mapper 配置
* @param baseType 基础类型
* @param subType 子类型
* @return 多态类型合法性结果
*/
@Override
public Validity validateSubType(MapperConfig<?> config, JavaType baseType, JavaType subType) {
Class<?> rawClass = subType.getRawClass();
while (rawClass.isArray()) {
rawClass = rawClass.getComponentType();
}
if (rawClass.isPrimitive()) {
return Validity.ALLOWED;
}
return isTrustedClassName(rawClass.getName()) ? Validity.ALLOWED : Validity.DENIED;
}
}
/**
* LocalDateTime 多格式反序列化器。
*
* @author Ateng
* @since 2026-04-30
*/
private static final class MultiPatternLocalDateTimeDeserializer extends JsonDeserializer<LocalDateTime> {
/**
* 将 JSON 字符串反序列化为 LocalDateTime。
*
* @param parser JSON 解析器
* @param context 反序列化上下文
* @return LocalDateTime 实例
* @throws IOException JSON 读取异常
*/
@Override
public LocalDateTime deserialize(JsonParser parser, DeserializationContext context) throws IOException {
String text = parser.getValueAsString();
if (isBlank(text)) {
return null;
}
String value = text.trim();
try {
return LocalDateTime.parse(value, DATE_TIME_FORMATTER);
} catch (DateTimeParseException ignored) {
return LocalDateTime.parse(value, ISO_LOCAL_DATE_TIME_FORMATTER);
}
}
}
/**
* Instant 字符串序列化器。
*
* @author Ateng
* @since 2026-04-30
*/
private static final class InstantStringSerializer extends JsonSerializer<Instant> {
/**
* 将 Instant 序列化为 ISO_INSTANT 字符串。
*
* @param value Instant 值
* @param generator JSON 生成器
* @param serializers 序列化上下文
* @throws IOException JSON 写入异常
*/
@Override
public void serialize(Instant value, JsonGenerator generator, SerializerProvider serializers) throws IOException {
if (value == null) {
generator.writeNull();
return;
}
generator.writeString(DateTimeFormatter.ISO_INSTANT.format(value));
}
}
/**
* Instant 字符串反序列化器。
*
* @author Ateng
* @since 2026-04-30
*/
private static final class InstantStringDeserializer extends JsonDeserializer<Instant> {
/**
* 将 JSON 字符串反序列化为 Instant。
*
* @param parser JSON 解析器
* @param context 反序列化上下文
* @return Instant 实例
* @throws IOException JSON 读取异常
*/
@Override
public Instant deserialize(JsonParser parser, DeserializationContext context) throws IOException {
String text = parser.getValueAsString();
return isBlank(text) ? null : Instant.parse(text.trim());
}
}
/**
* OffsetDateTime 字符串序列化器。
*
* @author Ateng
* @since 2026-04-30
*/
private static final class OffsetDateTimeStringSerializer extends JsonSerializer<OffsetDateTime> {
/**
* 将 OffsetDateTime 序列化为 ISO_OFFSET_DATE_TIME 字符串。
*
* @param value OffsetDateTime 值
* @param generator JSON 生成器
* @param serializers 序列化上下文
* @throws IOException JSON 写入异常
*/
@Override
public void serialize(OffsetDateTime value, JsonGenerator generator, SerializerProvider serializers) throws IOException {
if (value == null) {
generator.writeNull();
return;
}
generator.writeString(DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(value));
}
}
/**
* OffsetDateTime 字符串反序列化器。
*
* @author Ateng
* @since 2026-04-30
*/
private static final class OffsetDateTimeStringDeserializer extends JsonDeserializer<OffsetDateTime> {
/**
* 将 JSON 字符串反序列化为 OffsetDateTime。
*
* @param parser JSON 解析器
* @param context 反序列化上下文
* @return OffsetDateTime 实例
* @throws IOException JSON 读取异常
*/
@Override
public OffsetDateTime deserialize(JsonParser parser, DeserializationContext context) throws IOException {
String text = parser.getValueAsString();
return isBlank(text) ? null : OffsetDateTime.parse(text.trim(), DateTimeFormatter.ISO_OFFSET_DATE_TIME);
}
}
/**
* ZonedDateTime 字符串序列化器。
*
* @author Ateng
* @since 2026-04-30
*/
private static final class ZonedDateTimeStringSerializer extends JsonSerializer<ZonedDateTime> {
/**
* 将 ZonedDateTime 序列化为 ISO_ZONED_DATE_TIME 字符串。
*
* @param value ZonedDateTime 值
* @param generator JSON 生成器
* @param serializers 序列化上下文
* @throws IOException JSON 写入异常
*/
@Override
public void serialize(ZonedDateTime value, JsonGenerator generator, SerializerProvider serializers) throws IOException {
if (value == null) {
generator.writeNull();
return;
}
generator.writeString(DateTimeFormatter.ISO_ZONED_DATE_TIME.format(value));
}
}
/**
* ZonedDateTime 字符串反序列化器。
*
* @author Ateng
* @since 2026-04-30
*/
private static final class ZonedDateTimeStringDeserializer extends JsonDeserializer<ZonedDateTime> {
/**
* 将 JSON 字符串反序列化为 ZonedDateTime。
*
* @param parser JSON 解析器
* @param context 反序列化上下文
* @return ZonedDateTime 实例
* @throws IOException JSON 读取异常
*/
@Override
public ZonedDateTime deserialize(JsonParser parser, DeserializationContext context) throws IOException {
String text = parser.getValueAsString();
return isBlank(text) ? null : ZonedDateTime.parse(text.trim(), DateTimeFormatter.ISO_ZONED_DATE_TIME);
}
}
/**
* 异常对象序列化混入配置。
*
* <p>
* 该混入仅用于补充 Throwable 的序列化视图,不修改业务异常本身代码结构。
* </p>
*
* @author Ateng
* @since 2026-04-30
*/
@JsonAutoDetect(
fieldVisibility = JsonAutoDetect.Visibility.ANY,
getterVisibility = JsonAutoDetect.Visibility.PUBLIC_ONLY,
setterVisibility = JsonAutoDetect.Visibility.NONE,
isGetterVisibility = JsonAutoDetect.Visibility.NONE,
creatorVisibility = JsonAutoDetect.Visibility.NONE
)
@JsonTypeInfo(use = JsonTypeInfo.Id.CLASS, include = JsonTypeInfo.As.PROPERTY, property = TYPE_PROPERTY_NAME)
@JsonIdentityInfo(generator = ObjectIdGenerators.IntSequenceGenerator.class, property = "@id")
public static class ThrowableMixIn {
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
创建 TypeHandler
package local.ateng.java.mybatis.handler;
import com.baomidou.mybatisplus.extension.handlers.AbstractJsonTypeHandler;
import com.fasterxml.jackson.databind.JavaType;
import com.fasterxml.jackson.databind.ObjectMapper;
import local.ateng.java.mybatis.config.JacksonObjectMapperFactory;
import local.ateng.java.mybatis.entity.MyData;
import local.ateng.java.mybatis.entity.MyDataList;
import org.apache.ibatis.type.JdbcType;
import org.apache.ibatis.type.MappedJdbcTypes;
import org.apache.ibatis.type.MappedTypes;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.lang.reflect.Field;
import java.lang.reflect.Type;
import java.util.List;
/**
* 基于 Jackson 的通用 JSON TypeHandler 实现
* <p>
* 用于实现数据库 JSON 字符串与 Java 对象之间的自动转换,兼容 MyBatis-Plus 的泛型处理机制
* <p>
* 特性说明:
* 1. 支持普通 Java Bean 的序列化与反序列化
* 2. 支持泛型类型(如 List、Map、自定义泛型包装类)
* 3. 在字段级别可通过反射自动解析泛型真实类型(依赖 Field 元信息)
* 4. 泛型推断仅在字段级 TypeHandler 生效,全局 @MappedTypes 无法感知泛型参数
* <p>
* 适用场景:
* - JSON 字段(VARCHAR / TEXT / JSON)映射为复杂对象结构
* - 需要强类型而非原始字符串处理 JSON 数据
*
* @author Ateng
* @since 2026-04-13
*/
@MappedJdbcTypes({JdbcType.VARCHAR, JdbcType.LONGVARCHAR, JdbcType.OTHER})
// 指定当前 TypeHandler 适用的 JDBC 类型(对应数据库 JSON 字段常见存储类型)
@MappedTypes({MyData.class, MyDataList.class, List.class})
// 指定当前 TypeHandler 绑定的 Java 类型(用于全局匹配,泛型类型在此无法生效)
public class JacksonTypeHandler<T> extends AbstractJsonTypeHandler<T> {
private static final Logger log = LoggerFactory.getLogger(JacksonTypeHandler.class);
/**
* Jackson 的全局 ObjectMapper 实例
*/
private static volatile ObjectMapper OBJECT_MAPPER = buildDefaultObjectMapper();
/**
* 标准构造方法(MyBatis 使用)
* <p>
* 用于非泛型或无法获取字段上下文的场景
*
* @param type 目标 Java 类型
*/
public JacksonTypeHandler(Class<?> type) {
super(type);
}
/**
* 扩展构造方法(MyBatis-Plus 使用)
* <p>
* 通过 Field 获取泛型信息,用于精确反序列化(如 List<T>、Wrapper<T>)
*
* @param type 目标 Java 类型
* @param field 当前字段反射对象
*/
public JacksonTypeHandler(Class<?> type, Field field) {
super(type, field);
}
/**
* 将 JSON 字符串解析为对象
*
* @param json 数据库中存储的 JSON 字符串
* @return 解析后的 Java 对象,解析失败或为空则返回 null
*/
@Override
public T parse(String json) {
if (json == null || json.trim().isEmpty()) {
return null;
}
try {
Type fieldType = getFieldType();
JavaType javaType = OBJECT_MAPPER.getTypeFactory().constructType(fieldType);
return OBJECT_MAPPER.readValue(json, javaType);
} catch (Exception e) {
log.error("Jackson 反序列化失败,json={}", json, e);
return null;
}
}
/**
* 将对象序列化为 JSON 字符串,用于写入数据库
*
* @param obj Java 对象
* @return 序列化后的 JSON 字符串,失败或为空返回 null
*/
@Override
public String toJson(T obj) {
if (obj == null) {
return null;
}
try {
return OBJECT_MAPPER.writeValueAsString(obj);
} catch (Exception e) {
log.error("Jackson 序列化失败,obj={}", obj, e);
return null;
}
}
/**
* 构建默认 ObjectMapper。
*
* @return 默认 ObjectMapper
*/
private static ObjectMapper buildDefaultObjectMapper() {
return JacksonObjectMapperFactory.buildStorageObjectMapper();
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
注册全局TypeHandler
注册后,MyBatis 会自动根据 @MappedJdbcTypes 和 @MappedTypes 匹配,几乎不用额外写 typeHandler。
@Configuration
@MapperScan("local.ateng.java.mybatisjdk8.**.mapper")
public class MyBatisPlusConfiguration {
/**
* 自定义 MyBatis 全局配置。
* <p>
* 方式一:逐个注册 TypeHandler。
* 方式二:包扫描注册(推荐)。
* </p>
*
* @return ConfigurationCustomizer 配置定制器
*/
@Bean
public ConfigurationCustomizer configurationCustomizer() {
return configuration -> {
// 方式一:逐个注册自定义 TypeHandler
configuration.getTypeHandlerRegistry().register(GeometryTypeHandler.class);
configuration.getTypeHandlerRegistry().register(JacksonTypeHandler.class);
configuration.getTypeHandlerRegistry().register(UUIDTypeHandler.class);
// 方式二:包扫描注册,推荐用于统一管理 TypeHandler
// configuration.getTypeHandlerRegistry()
// .register("com.example.mybatis.handler");
};
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
关于JSON的TypeHandler
在Fastjson和Jackson中写入类名字段,主要的作用就是让泛型字段(List, Map)能够直接反序列化回来,但是结合MySQL的JSON字段就会有一些问题。这里建议的做法是,所有泛型字段都用一个实体类包装起来,并且不要写入类名字段(@type, @class)。
最佳实践
局部使用
支持 普通对象字段 和 泛型字段(List、Map),实体类需要开启 @TableName(value = "user", autoResultMap = true) 和 字段设置@TableField(value = "json_data", typeHandler = Fastjson2TypeHandler.class)。
直接全局或者局部配置 Fastjson2TypeHandler
- 自定义的
Fastjson2TypeHandler - 官方的 TypeHandler
- com.baomidou.mybatisplus.extension.handlers.Fastjson2TypeHandler
- com.baomidou.mybatisplus.extension.handlers.FastjsonTypeHandler
- com.baomidou.mybatisplus.extension.handlers.GsonTypeHandler
- com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler
全局使用
支持 普通对象字段,不支持 泛型字段(List、Map)。
普通字段只需要在 TypeHandler 的 @MappedTypes 添加需要匹配的 Java 类型,例如:@MappedTypes({MyData.class})
泛型字段(List、Map)需要额外处理,使用一个类包装起来,例如:
// 包装类
@Data
@AllArgsConstructor
@NoArgsConstructor
public class MyDataList implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
private List<MyData> dataList;
}
// 表对象类字段
@TableField("json_array")
private MyDataList jsonArray;2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
TypeHandler 的 Mapped 配置示例参考:
@MappedJdbcTypes({JdbcType.VARCHAR, JdbcType.LONGVARCHAR, JdbcType.OTHER})
// 指定当前 TypeHandler 适用的 JDBC 类型(对应数据库 JSON 字段常见存储类型)
@MappedTypes({MyData.class, MyDataList.class})
// 指定当前 TypeHandler 绑定的 Java 类型(用于全局匹配,泛型类型在此无法生效)2
3
4
全局注册TypeHandler配置示例参考:
/**
* 自定义 MyBatis 全局配置。
*
* @return ConfigurationCustomizer 配置定制器
*/
@Bean
public ConfigurationCustomizer configurationCustomizer() {
return configuration -> {
// 逐个注册自定义 TypeHandler
configuration.getTypeHandlerRegistry().register(Fastjson2TypeHandler.class);
configuration.getTypeHandlerRegistry().register(GeometryTypeHandler.class);
};
}2
3
4
5
6
7
8
9
10
11
12
13
Jackson
强烈建议使用 Jackson ObjectMapper 的序列化和反序列化。通过 JacksonObjectMapperFactory 可以获取到不同常见的 ObjectMapper。
在全局配置中,对于有泛型的,可以使用 JacksonObjectMapperFactory.buildStorageObjectMapper() 获取 ObjectMapper ,然后在 @MappedTypes 加上 List.class。
@MappedJdbcTypes({JdbcType.VARCHAR, JdbcType.LONGVARCHAR, JdbcType.OTHER})
@MappedTypes({MyData.class, MyDataList.class, List.class})
public class JacksonTypeHandler<T> extends AbstractJsonTypeHandler<T> {}2
3
拦截器Interceptor
参考:官网文档
常用的 SQL 拦截器场景
- 分页拦截器
- 自动对查询语句追加分页逻辑,返回分页数据。
- MyBatis-Plus 已自带
PaginationInnerInterceptor。
- 防止全表更新/删除拦截器
- 拦截
UPDATE/DELETE时如果没有WHERE条件,则抛出异常,避免生产事故。
- 拦截
- SQL 性能分析拦截器
- 打印 SQL 执行时间,超出阈值则报警或阻断,辅助开发调优。
- 多租户拦截器
- 自动在 SQL 中追加租户条件(如
tenant_id),保证数据隔离。
- 自动在 SQL 中追加租户条件(如
- 数据权限拦截器
- 根据用户角色/部门,动态拼接
WHERE条件,实现行级权限控制。
- 根据用户角色/部门,动态拼接
- SQL 审计拦截器
- 记录每次执行的 SQL、参数、执行时间等,常用于审计日志。
- 乐观锁拦截器
- 利用
version字段防止并发覆盖。MyBatis-Plus 自带OptimisticLockerInnerInterceptor。
- 利用
- 字段加解密拦截器
- 对敏感字段(如手机号、身份证号)在入库前加密,查询时解密。
- 自动填充拦截器
- 对
create_time、update_time、operator等公共字段自动填充。
- 对
- SQL 格式化拦截器(调试用)
- 将 SQL 美化输出,方便排查问题。
MyBatis-Plus 默认内置的常用拦截器
PaginationInnerInterceptor(分页)OptimisticLockerInnerInterceptor(乐观锁)BlockAttackInnerInterceptor(防全表更新/删除)TenantLineInnerInterceptor(多租户)
SQL 打印
注册的时候需要在最后注册
package local.ateng.java.mybatisjdk8.interceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.InnerInterceptor;
import net.sf.jsqlparser.parser.CCJSqlParserUtil;
import net.sf.jsqlparser.statement.Statement;
import org.apache.ibatis.executor.Executor;
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.reflection.MetaObject;
import org.apache.ibatis.session.Configuration;
import org.apache.ibatis.session.ResultHandler;
import org.apache.ibatis.session.RowBounds;
import org.apache.ibatis.type.TypeHandlerRegistry;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.sql.SQLException;
import java.text.SimpleDateFormat;
import java.time.*;
import java.time.format.DateTimeFormatter;
import java.util.*;
/**
* SqlAuditInnerInterceptor(改进版)
*
* <p>目的:
* <ul>
* <li>在 MyBatis-Plus 的 InnerInterceptor 层记录最终要执行的 SQL 字符串(将 ? 替换为真实参数值)</li>
* <li>在替换参数后使用 JSqlParser(CCJSqlParserUtil.parse)再次解析/规范化 SQL,以接近 JDBC 层/数据库实际执行的 SQL 表现</li>
* </ul>
*
* <p>特性与注意事项:
* <ol>
* <li>支持常用参数类型的格式化:Number/Boolean/String/Date/Java8Time/Enum/UUID/Collection/Array/byte[] 等</li>
* <li>对于二进制/大对象会避免直接把全部内容打印,防止日志爆炸</li>
* <li>JSqlParser 解析失败时会回退到拼接后的 SQL(不会抛异常)</li>
* <li>不修改 SQL 的执行流程,仅用于记录/打印(非阻断器)</li>
* <li>生产环境请注意:最终 SQL 中包含真实参数,可能包含敏感数据,建议按需开启或做脱敏</li>
* </ol>
*
* <p>依赖(示例):
* <pre>
* <dependency>
* <groupId>com.github.jsqlparser</groupId>
* <artifactId>jsqlparser</artifactId>
* <version>(请按项目实际版本填写,例如 4.x 系列)</version>
* </dependency>
* </pre>
*
* <p>注册示例(建议把会修改 SQL 的 InnerInterceptor 先添加,审计拦截器放最后):
* <pre>
* @Bean
* public MybatisPlusInterceptor mybatisPlusInterceptor(SqlAuditInnerInterceptor sqlAuditInnerInterceptor) {
* MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
* interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
* interceptor.addInnerInterceptor(new BlockAttackInnerInterceptor());
* interceptor.addInnerInterceptor(sqlAuditInnerInterceptor); // 放最后
* return interceptor;
* }
* </pre>
*
* @author 孔余
* @since 2025-09-16
*/
public class SqlPrintInnerInterceptor implements InnerInterceptor {
private static final Logger logger = LoggerFactory.getLogger(SqlPrintInnerInterceptor.class);
/**
* 是否启用 JSqlParser 解析(解析失败会回退)
* 在某些生产环境/极端 SQL 下可以关闭以避免兼容性问题。
*/
private boolean parseWithJsqlParser = true;
/**
* 构造函数(默认开启 JSqlParser)
*/
public SqlPrintInnerInterceptor() {
this(true);
}
public SqlPrintInnerInterceptor(boolean parseWithJsqlParser) {
this.parseWithJsqlParser = parseWithJsqlParser;
}
public void setParseWithJsqlParser(boolean parseWithJsqlParser) {
this.parseWithJsqlParser = parseWithJsqlParser;
}
// ======================================================
// 拦截点:查询与更新(MyBatis-Plus 常用的两个钩子)
// ======================================================
@Override
public void beforeQuery(Executor executor, MappedStatement ms, Object parameter,
RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) throws SQLException {
try {
String finalSql = buildFinalSql(ms, boundSql, parameter);
String displaySql = finalSql.length() > 200 ? finalSql.substring(0, 200) + "..." : finalSql;
logger.info("[SQL] id={} | sql={}", ms.getId(), displaySql);
} catch (Exception e) {
// 审计日志不能影响正常执行,捕获所有异常并用 warn 记录
logger.warn("[SQL] 构建/打印最终 SQL 失败,id={},原因:{}", ms.getId(), e.getMessage(), e);
}
}
@Override
public void beforeUpdate(Executor executor, MappedStatement ms, Object parameter) throws SQLException {
try {
BoundSql boundSql = ms.getBoundSql(parameter);
String finalSql = buildFinalSql(ms, boundSql, parameter);
String displaySql = finalSql.length() > 200 ? finalSql.substring(0, 200) + "..." : finalSql;
logger.info("[SQL] id={} | sql={}", ms.getId(), displaySql);
} catch (Exception e) {
logger.warn("[SQL] 构建/打印最终 SQL 失败,id={},原因:{}", ms.getId(), e.getMessage(), e);
}
}
// ======================================================
// 核心:把 BoundSql + 参数 -> 最终 SQL 字符串(并通过 JSqlParser 解析)
// ======================================================
private String buildFinalSql(MappedStatement ms, BoundSql boundSql, Object parameter) {
Configuration configuration = ms.getConfiguration();
String sql = boundSql.getSql();
if (sql == null) {
return "";
}
// 把多空白(换行、制表等)压缩成单个空格,便于输出
String normalizedSql = sql.replaceAll("\\s+", " ").trim();
List<ParameterMapping> parameterMappings = boundSql.getParameterMappings();
if (parameterMappings == null || parameterMappings.isEmpty()) {
// 无参数,直接尝试解析并返回
return tryParseSql(normalizedSql);
}
TypeHandlerRegistry typeHandlerRegistry = configuration.getTypeHandlerRegistry();
MetaObject metaObject = parameter == null ? null : configuration.newMetaObject(parameter);
StringBuilder sb = new StringBuilder(normalizedSql);
for (ParameterMapping pm : parameterMappings) {
// 跳过 OUT 模式参数
if (pm.getMode() == ParameterMode.OUT) {
continue;
}
String propName = pm.getProperty();
Object value;
// 1) 先尝试 AdditionalParameter(foreach 等会生成)
if (boundSql.hasAdditionalParameter(propName)) {
value = boundSql.getAdditionalParameter(propName);
} else if (parameter == null) {
value = null;
} else if (typeHandlerRegistry.hasTypeHandler(parameter.getClass())) {
// 传入的是单个基础类型参数(如 mapper.method(1))
value = parameter;
} else {
// 传入的是对象(POJO)或 Map 等,使用 MetaObject 获取对应属性
value = metaObject == null ? null : metaObject.getValue(propName);
}
String formatted = formatParameter(value);
// 将第一个 '?' 替换为 formatted(顺序替换)
int idx = sb.indexOf("?");
if (idx == -1) {
// 兜底:没有占位符(极少见),则在末尾追加
sb.append(" ").append(formatted);
} else {
sb.replace(idx, idx + 1, formatted);
}
}
String replacedSql = sb.toString();
return tryParseSql(replacedSql);
}
// ======================================================
// 参数格式化:把 Java 对象格式化为 SQL 可读的字面量(或合适的占位说明)
// ======================================================
private String formatParameter(Object value) {
if (value == null) {
return "NULL";
}
// 数字类型(Integer, Long, Double, BigDecimal, BigInteger 等)—— 不加引号
if (value instanceof Number) {
// BigDecimal 保留其 toString 表现(避免科学计数法)
return value.toString();
}
// 布尔类型:使用 1 / 0(兼容多数 MySQL 场景);如果你更喜欢 TRUE/FALSE,可改为 "TRUE"/"FALSE"
if (value instanceof Boolean) {
return ((Boolean) value) ? "1" : "0";
}
// 字符串类型:需要 SQL 单引号并转义单引号
if (value instanceof String) {
return "'" + escapeSql((String) value) + "'";
}
// Character -> 当作单字符字符串
if (value instanceof Character) {
return "'" + escapeSql(value.toString()) + "'";
}
// java.util.Date 及子类(包括 Timestamp) -> 'yyyy-MM-dd HH:mm:ss'
if (value instanceof Date) {
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
return "'" + sdf.format((Date) value) + "'";
}
// Java 8 时间 API
if (value instanceof LocalDateTime) {
return "'" + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").format((LocalDateTime) value) + "'";
}
if (value instanceof LocalDate) {
return "'" + DateTimeFormatter.ofPattern("yyyy-MM-dd").format((LocalDate) value) + "'";
}
if (value instanceof LocalTime) {
return "'" + DateTimeFormatter.ofPattern("HH:mm:ss").format((LocalTime) value) + "'";
}
if (value instanceof OffsetDateTime) {
return "'" + DateTimeFormatter.ISO_OFFSET_DATE_TIME.format((OffsetDateTime) value) + "'";
}
if (value instanceof ZonedDateTime) {
return "'" + DateTimeFormatter.ISO_ZONED_DATE_TIME.format((ZonedDateTime) value) + "'";
}
if (value instanceof Instant) {
return "'" + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
.withZone(ZoneId.systemDefault())
.format((Instant) value) + "'";
}
// Enum -> 使用 name()(并加引号)
if (value instanceof Enum) {
return "'" + escapeSql(((Enum<?>) value).name()) + "'";
}
// UUID -> 当作字符串
if (value instanceof UUID) {
return "'" + value.toString() + "'";
}
// 集合 -> (a, b, c) 样式
if (value instanceof Collection) {
Collection<?> coll = (Collection<?>) value;
if (coll.isEmpty()) {
return "(NULL)"; // 防止生成 IN ()
}
StringJoiner sj = new StringJoiner(", ", "(", ")");
for (Object o : coll) {
sj.add(formatParameter(o));
}
return sj.toString();
}
// 数组 -> (a, b, c)
if (value.getClass().isArray()) {
int len = java.lang.reflect.Array.getLength(value);
if (len == 0) {
return "(NULL)";
}
StringJoiner sj = new StringJoiner(", ", "(", ")");
for (int i = 0; i < len; i++) {
Object o = java.lang.reflect.Array.get(value, i);
sj.add(formatParameter(o));
}
return sj.toString();
}
// 二进制 -> 避免日志爆炸,打印长度提示
if (value instanceof byte[]) {
return "BINARY[" + ((byte[]) value).length + "]";
}
// Map(常见于 paramMap) -> 尽量友好展示(但不会直接嵌入 SQL)
if (value instanceof Map) {
// Map 通常不是直接出现在单个占位符中(MyBatis 会展开),但兜底展示
Map<?, ?> map = (Map<?, ?>) value;
return "'" + escapeSql(map.toString()) + "'";
}
// 其他类型(使用 toString,并加引号)
return "'" + escapeSql(String.valueOf(value)) + "'";
}
// ======================================================
// 使用 JSqlParser 解析/规范化 SQL;解析失败则回退到原始 SQL(不抛异常)
// ======================================================
private String tryParseSql(String sql) {
if (!parseWithJsqlParser) {
return sql;
}
try {
Statement stmt = CCJSqlParserUtil.parse(sql);
// Statement.toString() 会返回标准化后的 SQL(JSqlParser 的字符串表示)
String parsed = stmt.toString();
// 去除可能产生的多余空白(保持一致)
return parsed.replaceAll("\\s+", " ").trim();
} catch (Exception e) {
// 解析失败(可能是方言或 JSqlParser 的限制),调试级别记录失败原因,并返回原 SQL
logger.debug("[SQL] JSqlParser 解析 SQL 失败,使用原 SQL。原因:{}", e.getMessage());
return sql;
}
}
// ======================================================
// 简单 SQL 字符串转义(主要转义单引号为两个单引号)
// ======================================================
private String escapeSql(String input) {
if (input == null) {
return null;
}
// 把单引号转义成 SQL 风格的两个单引号
return input.replace("'", "''");
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
SQL 审计
代码
package local.ateng.java.mybatisjdk8.interceptor;
import net.sf.jsqlparser.parser.CCJSqlParserUtil;
import net.sf.jsqlparser.statement.Statement;
import org.apache.ibatis.executor.Executor;
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.session.RowBounds;
import org.apache.ibatis.type.TypeHandlerRegistry;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.text.SimpleDateFormat;
import java.time.*;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.concurrent.TimeUnit;
/**
* MyBatis 原生 SQL 耗时拦截器
*/
@Intercepts({
@Signature(type = Executor.class, method = "update", args = {MappedStatement.class, Object.class}),
@Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class})
})
public class SqlAuditInnerInterceptor implements Interceptor {
private static final Logger logger = LoggerFactory.getLogger(SqlAuditInnerInterceptor.class);
@Override
public Object intercept(Invocation invocation) throws Throwable {
long start = System.nanoTime();
// 执行 SQL
Object result = invocation.proceed();
long end = System.nanoTime();
long costMs = TimeUnit.NANOSECONDS.toMillis(end - start);
// 获取 SQL
Object[] args = invocation.getArgs();
MappedStatement ms = (MappedStatement) args[0];
Object parameter = args.length > 1 ? args[1] : null;
BoundSql boundSql = ms.getBoundSql(parameter);
// 拼接参数(可用之前 formatParameter 方法)
String finalSql = buildFinalSql(ms, boundSql, parameter);
String displaySql = finalSql.length() > 200 ? finalSql.substring(0, 200) + "..." : finalSql;
logger.info(
"SQL | id={} | type={} | cost={}ms | sql={}",
ms.getId(), ms.getSqlCommandType(), costMs, displaySql
);
return result;
}
// ======================================================
// 核心:把 BoundSql + 参数 -> 最终 SQL 字符串(并通过 JSqlParser 解析)
// ======================================================
private String buildFinalSql(MappedStatement ms, BoundSql boundSql, Object parameter) {
Configuration configuration = ms.getConfiguration();
String sql = boundSql.getSql();
if (sql == null) {
return "";
}
// 把多空白(换行、制表等)压缩成单个空格,便于输出
String normalizedSql = sql.replaceAll("\\s+", " ").trim();
List<ParameterMapping> parameterMappings = boundSql.getParameterMappings();
if (parameterMappings == null || parameterMappings.isEmpty()) {
// 无参数,直接尝试解析并返回
return tryParseSql(normalizedSql);
}
TypeHandlerRegistry typeHandlerRegistry = configuration.getTypeHandlerRegistry();
MetaObject metaObject = parameter == null ? null : configuration.newMetaObject(parameter);
StringBuilder sb = new StringBuilder(normalizedSql);
for (ParameterMapping pm : parameterMappings) {
// 跳过 OUT 模式参数
if (pm.getMode() == ParameterMode.OUT) {
continue;
}
String propName = pm.getProperty();
Object value;
// 1) 先尝试 AdditionalParameter(foreach 等会生成)
if (boundSql.hasAdditionalParameter(propName)) {
value = boundSql.getAdditionalParameter(propName);
} else if (parameter == null) {
value = null;
} else if (typeHandlerRegistry.hasTypeHandler(parameter.getClass())) {
// 传入的是单个基础类型参数(如 mapper.method(1))
value = parameter;
} else {
// 传入的是对象(POJO)或 Map 等,使用 MetaObject 获取对应属性
value = metaObject == null ? null : metaObject.getValue(propName);
}
String formatted = formatParameter(value);
// 将第一个 '?' 替换为 formatted(顺序替换)
int idx = sb.indexOf("?");
if (idx == -1) {
// 兜底:没有占位符(极少见),则在末尾追加
sb.append(" ").append(formatted);
} else {
sb.replace(idx, idx + 1, formatted);
}
}
String replacedSql = sb.toString();
return tryParseSql(replacedSql);
}
// ======================================================
// 参数格式化:把 Java 对象格式化为 SQL 可读的字面量(或合适的占位说明)
// ======================================================
private String formatParameter(Object value) {
if (value == null) {
return "NULL";
}
// 数字类型(Integer, Long, Double, BigDecimal, BigInteger 等)—— 不加引号
if (value instanceof Number) {
// BigDecimal 保留其 toString 表现(避免科学计数法)
return value.toString();
}
// 布尔类型:使用 1 / 0(兼容多数 MySQL 场景);如果你更喜欢 TRUE/FALSE,可改为 "TRUE"/"FALSE"
if (value instanceof Boolean) {
return ((Boolean) value) ? "1" : "0";
}
// 字符串类型:需要 SQL 单引号并转义单引号
if (value instanceof String) {
return "'" + escapeSql((String) value) + "'";
}
// Character -> 当作单字符字符串
if (value instanceof Character) {
return "'" + escapeSql(value.toString()) + "'";
}
// java.util.Date 及子类(包括 Timestamp) -> 'yyyy-MM-dd HH:mm:ss'
if (value instanceof Date) {
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
return "'" + sdf.format((Date) value) + "'";
}
// Java 8 时间 API
if (value instanceof LocalDateTime) {
return "'" + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").format((LocalDateTime) value) + "'";
}
if (value instanceof LocalDate) {
return "'" + DateTimeFormatter.ofPattern("yyyy-MM-dd").format((LocalDate) value) + "'";
}
if (value instanceof LocalTime) {
return "'" + DateTimeFormatter.ofPattern("HH:mm:ss").format((LocalTime) value) + "'";
}
if (value instanceof OffsetDateTime) {
return "'" + DateTimeFormatter.ISO_OFFSET_DATE_TIME.format((OffsetDateTime) value) + "'";
}
if (value instanceof ZonedDateTime) {
return "'" + DateTimeFormatter.ISO_ZONED_DATE_TIME.format((ZonedDateTime) value) + "'";
}
if (value instanceof Instant) {
return "'" + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
.withZone(ZoneId.systemDefault())
.format((Instant) value) + "'";
}
// Enum -> 使用 name()(并加引号)
if (value instanceof Enum) {
return "'" + escapeSql(((Enum<?>) value).name()) + "'";
}
// UUID -> 当作字符串
if (value instanceof UUID) {
return "'" + value.toString() + "'";
}
// 集合 -> (a, b, c) 样式
if (value instanceof Collection) {
Collection<?> coll = (Collection<?>) value;
if (coll.isEmpty()) {
return "(NULL)"; // 防止生成 IN ()
}
StringJoiner sj = new StringJoiner(", ", "(", ")");
for (Object o : coll) {
sj.add(formatParameter(o));
}
return sj.toString();
}
// 数组 -> (a, b, c)
if (value.getClass().isArray()) {
int len = java.lang.reflect.Array.getLength(value);
if (len == 0) {
return "(NULL)";
}
StringJoiner sj = new StringJoiner(", ", "(", ")");
for (int i = 0; i < len; i++) {
Object o = java.lang.reflect.Array.get(value, i);
sj.add(formatParameter(o));
}
return sj.toString();
}
// 二进制 -> 避免日志爆炸,打印长度提示
if (value instanceof byte[]) {
return "BINARY[" + ((byte[]) value).length + "]";
}
// Map(常见于 paramMap) -> 尽量友好展示(但不会直接嵌入 SQL)
if (value instanceof Map) {
// Map 通常不是直接出现在单个占位符中(MyBatis 会展开),但兜底展示
Map<?, ?> map = (Map<?, ?>) value;
return "'" + escapeSql(map.toString()) + "'";
}
// 其他类型(使用 toString,并加引号)
return "'" + escapeSql(String.valueOf(value)) + "'";
}
// ======================================================
// 使用 JSqlParser 解析/规范化 SQL;解析失败则回退到原始 SQL(不抛异常)
// ======================================================
private String tryParseSql(String sql) {
try {
Statement stmt = CCJSqlParserUtil.parse(sql);
// Statement.toString() 会返回标准化后的 SQL(JSqlParser 的字符串表示)
String parsed = stmt.toString();
// 去除可能产生的多余空白(保持一致)
return parsed.replaceAll("\\s+", " ").trim();
} catch (Exception e) {
// 解析失败(可能是方言或 JSqlParser 的限制),调试级别记录失败原因,并返回原 SQL
logger.debug("[SQL AUDIT] JSqlParser 解析 SQL 失败,使用原 SQL。原因:{}", e.getMessage());
return sql;
}
}
// ======================================================
// 简单 SQL 字符串转义(主要转义单引号为两个单引号)
// ======================================================
private String escapeSql(String input) {
if (input == null) {
return null;
}
// 把单引号转义成 SQL 风格的两个单引号
return input.replace("'", "''");
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
注册拦截器
@Bean
public SqlAuditInnerInterceptor sqlAuditInnerInterceptor() {
return new SqlAuditInnerInterceptor();
}2
3
4
动态 SQL 执行器
创建自定义异常
package local.ateng.java.mybatisjdk8.exception;
/**
* 动态 SQL 相关异常
*
* @author 孔余
* @since 2025-09-16
*/
public class DynamicSqlException extends RuntimeException {
public DynamicSqlException(String message) {
super(message);
}
public DynamicSqlException(String message, Throwable cause) {
super(message, cause);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
创建执行器
package local.ateng.java.mybatisjdk8.config;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.core.conditions.Wrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.core.toolkit.Constants;
import local.ateng.java.mybatisjdk8.exception.DynamicSqlException;
import org.apache.ibatis.executor.keygen.Jdbc3KeyGenerator;
import org.apache.ibatis.executor.keygen.NoKeyGenerator;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.mapping.ResultMap;
import org.apache.ibatis.mapping.SqlCommandType;
import org.apache.ibatis.mapping.SqlSource;
import org.apache.ibatis.session.Configuration;
import org.apache.ibatis.session.SqlSession;
import org.apache.ibatis.session.SqlSessionFactory;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.lang.reflect.Field;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
/**
* 动态 SQL 执行器
*
* <p>该组件用于在运行时动态构建并执行 MyBatis MappedStatement。
* 支持 SELECT、INSERT、UPDATE、DELETE 四种 SQL 类型,
* 并可结合 MyBatis-Plus 的 {@link TableId} 注解解析主键字段,
* 在插入操作中启用主键回填功能。</p>
*
* <p>核心功能包括:
* <ul>
* <li>运行时注册 {@link MappedStatement}</li>
* <li>动态生成唯一的 statementId,避免冲突</li>
* <li>支持批量插入与主键自动回填</li>
* <li>通过 {@link SqlSession} 直接执行 SQL</li>
* </ul>
* </p>
*
* @author 孔余
* @since 2025-09-16
*/
public class DynamicSqlExecutor {
private static final Logger log = LoggerFactory.getLogger(DynamicSqlExecutor.class);
/**
* 动态 Mapper 的命名空间
*/
private static final String NAMESPACE = "local.ateng.java.injector.DynamicMapper";
private final SqlSessionFactory sqlSessionFactory;
public DynamicSqlExecutor(SqlSessionFactory sqlSessionFactory) {
this.sqlSessionFactory = sqlSessionFactory;
}
/* ==================== SELECT ==================== */
/**
* 简单 SELECT
*
* @param sql SQL 语句,必须以 SELECT 开头
* @param resultType 结果类型(VO 或 Map)
* @param param 参数对象,可以为 null
* @param <T> 结果泛型
* @return 查询结果集合
*/
public <T> List<T> select(String sql, Class<T> resultType, Object param) {
return execute(sql, SqlCommandType.SELECT, resultType, param, false);
}
/**
* SELECT 支持 MP Wrapper 条件
*
* @param sql SQL 语句
* @param resultType 返回类型
* @param wrapper MP Wrapper 条件
*/
public <T> List<T> select(String sql, Class<T> resultType, Wrapper<?> wrapper) {
// 将 Wrapper 放入 paramMap
Map<String, Object> paramMap = Collections.singletonMap(Constants.WRAPPER, wrapper);
// 拼接 wrapper 条件到 SQL
String finalSql = sql;
if (wrapper != null && wrapper.getCustomSqlSegment() != null && !wrapper.getCustomSqlSegment().isEmpty()) {
finalSql = sql + " " + wrapper.getCustomSqlSegment();
}
return execute(finalSql, SqlCommandType.SELECT, resultType, paramMap, false);
}
/**
* SELECT 支持分页 IPage
*
* @param sql SQL 语句
* @param resultType 返回类型
* @param page 分页对象
* @param wrapper MyBatis Plus Wrapper 对象,用于拼接自定义条件,必须为 @Param("ew")
*
*/
public <T> IPage<T> selectPage(String sql, Class<T> resultType, IPage<T> page, Wrapper<?> wrapper) {
return selectPage(sql, resultType, page, wrapper, null);
}
/**
* 动态分页查询,支持 Wrapper、自定义查询条件(query map)和复杂 SQL(CTE + 动态标签)。
*
* @param sql 原始 SQL,可包含 <if>/<foreach>/<where>/<set> 等动态标签
* @param resultType 返回结果类型(VO 或 JSONObject)
* @param page 分页对象 IPage(例如 Page<T>),可为 null 表示不分页
* @param wrapper MyBatis Plus Wrapper 对象,用于拼接自定义条件,必须为 @Param("ew")
* @param query 自定义查询条件 Map
* @param <T> 泛型类型
* @return 分页结果 IPage<T> 或 List<T>(如果 page 为 null,则返回 List)
*/
public <T> IPage<T> selectPage(String sql,
Class<T> resultType,
IPage<T> page,
Wrapper<?> wrapper,
Map<String, Object> query) {
Map<String, Object> param = new HashMap<>();
param.put(Constants.WRAPPER, wrapper);
param.put("query", query);
param.put("page", page);
// 自动拼接 Wrapper 条件
String finalSql = sql;
if (wrapper != null && wrapper.getCustomSqlSegment() != null && !wrapper.getCustomSqlSegment().isEmpty()) {
finalSql = sql + " " + wrapper.getCustomSqlSegment();
}
// 动态标签处理(<if>/<foreach>等)和 XML 特殊字符转义
boolean containsDynamicTag = containsDynamicTag(finalSql);
String scriptSql = containsDynamicTag ? "<script>" + escapeXmlForScript(finalSql) + "</script>" : finalSql;
String msId = registerMappedStatement(scriptSql, SqlCommandType.SELECT, resultType, false, param);
try (SqlSession sqlSession = sqlSessionFactory.openSession()) {
List<T> records = sqlSession.selectList(msId, param);
page.setRecords(records);
return page;
} catch (Exception e) {
log.error("执行动态 SELECT 失败", e);
throw new DynamicSqlException("执行动态 SELECT 失败", e);
}
}
/* ==================== INSERT ==================== */
/**
* 单条插入,支持主键回写
*
* @param sql SQL 语句
* @param param 实体对象
* @return 影响行数
*/
public int insert(String sql, Object param) {
List<Integer> result = execute(sql, SqlCommandType.INSERT, int.class, param, true);
return result.stream().mapToInt(Integer::intValue).sum();
}
/**
* 批量插入(逐条执行,支持主键回写)
*
* @param sql SQL 语句
* @param list 实体对象集合
* @return 总影响行数
*/
public int insertBatch(String sql, List<?> list) {
List<Integer> result = execute(sql, SqlCommandType.INSERT, int.class, list, true);
return result.stream().mapToInt(Integer::intValue).sum();
}
/* ==================== UPDATE ==================== */
/**
* 单条更新
*
* @param sql SQL 语句
* @param param 参数对象
* @return 影响行数
*/
public int update(String sql, Object param) {
List<Integer> result = execute(sql, SqlCommandType.UPDATE, int.class, param, false);
return result.stream().mapToInt(Integer::intValue).sum();
}
/**
* 批量更新(逐条执行)
*
* @param sql SQL 语句
* @param list 参数集合
* @return 总影响行数
*/
public int updateBatch(String sql, List<?> list) {
List<Integer> result = execute(sql, SqlCommandType.UPDATE, int.class, list, false);
return result.stream().mapToInt(Integer::intValue).sum();
}
/* ==================== DELETE ==================== */
/**
* 单条删除
*
* @param sql SQL 语句
* @param param 参数对象
* @return 影响行数
*/
public int delete(String sql, Object param) {
List<Integer> result = execute(sql, SqlCommandType.DELETE, int.class, param, false);
return result.stream().mapToInt(Integer::intValue).sum();
}
/**
* 批量删除(逐条执行)
*
* @param sql SQL 语句
* @param list 参数集合
* @return 总影响行数
*/
public int deleteBatch(String sql, List<?> list) {
List<Integer> result = execute(sql, SqlCommandType.DELETE, int.class, list, false);
return result.stream().mapToInt(Integer::intValue).sum();
}
/**
* 动态执行 SQL(支持 SELECT / INSERT / UPDATE / DELETE)
* <p>
* - SELECT:返回查询结果列表
* - INSERT / UPDATE / DELETE:
* 如果参数是单对象,返回单条受影响行数的 List
* 如果参数是 List,根据 useBatchSql 判断:
* 1. useBatchSql = false → 逐条执行(支持主键回写)
* 2. useBatchSql = true → foreach 批量执行(高性能,不支持主键回写)
*
* @param sql 动态 SQL
* @param commandType SQL 类型
* @param resultType 结果映射类型
* @param param 参数对象(单对象或 List)
* @param enableKeyReturn 是否开启主键回写(仅对 INSERT 生效)
* @param <T> 返回结果类型
* @return 查询结果或受影响行数列表
*/
private <T> List<T> execute(String sql,
SqlCommandType commandType,
Class<T> resultType,
Object param,
boolean enableKeyReturn) {
String msId = registerMappedStatement(sql, commandType, resultType, enableKeyReturn, param);
try (SqlSession sqlSession = sqlSessionFactory.openSession(true)) {
switch (commandType) {
case SELECT:
return sqlSession.selectList(msId, param);
case INSERT:
return (List<T>) doExecuteBatch(sqlSession, msId, param, SqlCommandType.INSERT);
case UPDATE:
return (List<T>) doExecuteBatch(sqlSession, msId, param, SqlCommandType.UPDATE);
case DELETE:
return (List<T>) doExecuteBatch(sqlSession, msId, param, SqlCommandType.DELETE);
default:
throw new DynamicSqlException("不支持的 SQL 类型: " + commandType);
}
} catch (Exception e) {
log.error("执行动态 SQL 失败,类型 [{}],SQL [{}]", commandType, sql, e);
throw new DynamicSqlException("执行动态 SQL 失败: " + e.getMessage(), e);
}
}
/**
* 执行 INSERT / UPDATE / DELETE,支持单条或批量
*
* @param sqlSession MyBatis SqlSession
* @param msId MappedStatement ID
* @param param 参数对象或 List
* @param commandType SQL 类型
* @return 每条操作的受影响行数列表
*/
private List<Integer> doExecuteBatch(SqlSession sqlSession,
String msId,
Object param,
SqlCommandType commandType) {
// 单对象直接执行
if (!(param instanceof List)) {
return Collections.singletonList(executeOne(sqlSession, msId, param, commandType));
}
List<?> list = (List<?>) param;
// 默认逐条执行模式
List<Integer> results = new ArrayList<>();
for (Object obj : list) {
results.add(executeOne(sqlSession, msId, obj, commandType));
}
return results;
}
/**
* 执行单条 INSERT / UPDATE / DELETE
*/
private int executeOne(SqlSession sqlSession,
String msId,
Object param,
SqlCommandType commandType) {
switch (commandType) {
case INSERT:
return sqlSession.insert(msId, param);
case UPDATE:
return sqlSession.update(msId, param);
case DELETE:
return sqlSession.delete(msId, param);
default:
throw new DynamicSqlException("不支持的单条执行 SQL 类型: " + commandType);
}
}
/**
* 注册并构建 MyBatis 的 MappedStatement
* <p>
* 核心流程:
* 1. 对输入的 SQL 进行格式化(去除注释、压缩为单行)
* 2. 调用 validateSql 进行基础校验,防止非法 SQL
* 3. 判断是否包含动态 SQL 标签(如 <if>、<foreach>)
* 4. 根据动态标签的存在与否决定是否包装 <script>
* 5. 创建 SqlSource,并封装为 MappedStatement
* 6. 针对不同的 SQL 类型(SELECT / INSERT)进行结果映射或主键回写配置
*
* @param sql 原始 SQL 语句
* @param commandType SQL 命令类型(SELECT、INSERT、UPDATE、DELETE)
* @param resultType 查询结果映射的 Java 类型(仅在 SELECT 时生效)
* @param enableKeyReturn 是否启用自动主键回写(仅在 INSERT 时生效)
* @param param SQL 参数对象(可为 Map 或实体类)
* @param <T> 返回结果类型
* @return MappedStatement 的唯一 ID
*/
private <T> String registerMappedStatement(String sql,
SqlCommandType commandType,
Class<T> resultType,
boolean enableKeyReturn,
Object param) {
// 1. 格式化 SQL,去除注释并压缩为单行
String formattedSql = formatSql(sql);
// 2. 调用 SQL 校验方法,确保 SQL 符合当前 commandType 的约束
validateSql(formattedSql, commandType);
Configuration configuration = sqlSessionFactory.getConfiguration();
String msId = buildStatementId(sql, commandType, resultType);
// 3. 如果当前 SQL 已经注册过,则直接复用,避免重复注册
if (configuration.hasStatement(msId)) {
return msId;
}
// 4. 判断是否包含动态标签(如 <if>、<foreach> 等)
boolean containsDynamicTag = containsDynamicTag(formattedSql);
// 5. 构造最终 SQL,如果包含动态标签则必须包裹 <script>
String scriptSql;
if (containsDynamicTag) {
scriptSql = "<script>" + escapeXmlForScript(formattedSql) + "</script>";
} else {
scriptSql = formattedSql;
}
// 6. 基于最终 SQL 构建 SqlSource
SqlSource sqlSource = configuration.getDefaultScriptingLanguageInstance()
.createSqlSource(configuration, scriptSql, param == null ? Object.class : param.getClass());
// 7. 构建 MappedStatement
MappedStatement.Builder builder = new MappedStatement.Builder(configuration, msId, sqlSource, commandType);
// 8. 针对不同的 SQL 类型做额外处理
if (commandType == SqlCommandType.SELECT) {
// SELECT:需要配置结果映射
String resultMapId = msId + "-Inline";
ResultMap resultMap = new ResultMap.Builder(configuration, resultMapId, resultType, new ArrayList<>()).build();
builder.resultMaps(Collections.singletonList(resultMap));
} else if (commandType == SqlCommandType.INSERT) {
// INSERT:可选的自动主键回写配置
configureKeyGenerator(builder, param, enableKeyReturn);
}
// 9. 将构建完成的 MappedStatement 注册到 MyBatis Configuration
configuration.addMappedStatement(builder.build());
return msId;
}
/**
* 配置主键回填
*
* @param builder MappedStatement 构建器
* @param param 参数对象
* @param enableKeyReturn 是否启用主键回填
*/
private void configureKeyGenerator(MappedStatement.Builder builder, Object param, boolean enableKeyReturn) {
if (!enableKeyReturn || param == null) {
builder.keyGenerator(NoKeyGenerator.INSTANCE);
return;
}
Class<?> clazz = (param instanceof List && !((List<?>) param).isEmpty())
? ((List<?>) param).get(0).getClass()
: param.getClass();
Field keyField = Arrays.stream(clazz.getDeclaredFields())
.filter(f -> f.isAnnotationPresent(TableId.class))
.findFirst()
.orElse(null);
if (keyField != null) {
TableId tableId = keyField.getAnnotation(TableId.class);
if (tableId.type() == IdType.AUTO) {
builder.keyGenerator(Jdbc3KeyGenerator.INSTANCE);
builder.keyProperty(keyField.getName());
builder.keyColumn(tableId.value());
return;
}
}
builder.keyGenerator(NoKeyGenerator.INSTANCE);
}
/**
* 生成唯一的 statementId
*
* @param sql SQL 语句
* @param commandType SQL 类型
* @param resultType 返回类型
* @return 唯一 statementId
*/
private String buildStatementId(String sql, SqlCommandType commandType, Class<?> resultType) {
int hashCode = sql.hashCode();
return NAMESPACE + "." + commandType.name() + "_" + resultType.getSimpleName() + "_" + hashCode;
}
/**
* 校验 SQL 合法性
*
* @param sql SQL 语句
* @param type SQL 类型
*/
private void validateSql(String sql, SqlCommandType type) {
if (sql == null || sql.trim().isEmpty()) {
throw new DynamicSqlException("动态 SQL 不能为空");
}
// 去掉开头注释和空白,去掉 /* */ 和 -- 注释
String normalized = sql.trim()
.replaceAll("(?s)^(/\\*.*?\\*/|--.*?\\n)*", "")
.toLowerCase(Locale.ENGLISH);
switch (type) {
case SELECT:
if (!normalized.matches("^(with\\s+.*?select|select|\\().*")) {
throw new DynamicSqlException("SELECT SQL 必须以 SELECT、WITH 或括号开头");
}
break;
case INSERT:
if (!normalized.startsWith("insert")) {
throw new DynamicSqlException("INSERT SQL 必须以 INSERT 开头");
}
break;
case UPDATE:
if (!normalized.startsWith("update")) {
throw new DynamicSqlException("UPDATE SQL 必须以 UPDATE 开头");
}
break;
case DELETE:
if (!normalized.startsWith("delete")) {
throw new DynamicSqlException("DELETE SQL 必须以 DELETE 开头");
}
break;
default:
throw new DynamicSqlException("未知 SQL 类型: " + type);
}
}
/**
* 判断 SQL 是否包含动态标签
*/
private boolean containsDynamicTag(String sql) {
if (sql == null) {
return false;
}
// 扩展支持所有常用 MyBatis 标签
Pattern pattern = Pattern.compile("<\\s*(if|foreach|where|set|choose|trim|when|otherwise|bind).*?>", Pattern.DOTALL | Pattern.CASE_INSENSITIVE);
return pattern.matcher(sql).find();
}
/**
* 转义 XML 中特殊字符,但保留 MyBatis 动态标签
*/
private String escapeXmlForScript(String sql) {
if (sql == null || sql.isEmpty()) {
return sql;
}
// 动态标签列表
String[] tags = {"if", "foreach", "where", "set", "choose", "trim", "when", "otherwise", "bind"};
Map<String, String> placeholderMap = new HashMap<>();
// 使用正则暂存所有动态标签(支持嵌套)
String tagPattern = "<\\s*(?i)(" + String.join("|", tags) + ")\\b.*?>.*?</\\s*\\1\\s*>";
Pattern pattern = Pattern.compile(tagPattern, Pattern.DOTALL);
Matcher matcher = pattern.matcher(sql);
int index = 0;
StringBuffer sb = new StringBuffer();
while (matcher.find()) {
String tag = matcher.group();
String key = "%%DYNAMIC_TAG_" + UUID.randomUUID().toString() + "%%";
placeholderMap.put(key, tag);
matcher.appendReplacement(sb, key);
}
matcher.appendTail(sb);
// 转义剩余 SQL 特殊字符
String escaped = sb.toString()
.replace("&", "&")
.replace("<", "<")
.replace(">", ">");
// 恢复动态标签
for (Map.Entry<String, String> entry : placeholderMap.entrySet()) {
escaped = escaped.replace(entry.getKey(), entry.getValue());
}
return escaped;
}
/**
* 格式化 SQL,删除注释并压缩为一行
* - 移除单行注释 -- …
* - 移除块注释 /* … * /
* - 压缩多余空格
* - 保留动态标签
*/
private String formatSql(String sql) {
if (sql == null || sql.isEmpty()) {
return sql;
}
// 1. 删除块注释 /* ... */
String noBlockComments = sql.replaceAll("/\\*.*?\\*/", " ");
// 2. 删除单行注释 -- ...
String noLineComments = Arrays.stream(noBlockComments.split("\\r?\\n"))
.map(line -> {
int index = line.indexOf("--");
if (index >= 0) {
return line.substring(0, index);
} else {
return line;
}
})
.collect(Collectors.joining(" "));
// 3. 压缩多余空格
String compressed = noLineComments.replaceAll("\\s+", " ");
// 4. 保证动态标签前后有空格,便于 containsDynamicTag 正确识别
String tagsPattern = "(?i)<\\s*(if|foreach|where|set|choose|trim|when|otherwise|bind)\\b";
compressed = compressed.replaceAll(tagsPattern, " $0");
return compressed.trim();
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
配置执行器
package local.ateng.java.mybatisjdk8.config;
import org.apache.ibatis.session.SqlSessionFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* 动态 SQL 执行器配置
*
* @author 孔余
* @since 2025-09-16
*/
@Configuration
public class DynamicSqlConfiguration {
@Bean
public DynamicSqlExecutor dynamicSqlExecutor(SqlSessionFactory sqlSessionFactory) {
return new DynamicSqlExecutor(sqlSessionFactory);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
使用动态执行器
创建Service
在实际项目中,可以将该Service通过数据库将这些SQL存储起来。
package local.ateng.java.mybatisjdk8.service;
import com.alibaba.fastjson2.JSONObject;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import local.ateng.java.mybatisjdk8.config.DynamicSqlExecutor;
import local.ateng.java.mybatisjdk8.entity.Project;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Service
@RequiredArgsConstructor
public class DynamicService {
private final DynamicSqlExecutor dynamicSqlExecutor;
public List<JSONObject> testSelect() {
String sql = "SELECT 1 AS id";
List<JSONObject> result = dynamicSqlExecutor.select(sql, JSONObject.class, null);
return result;
}
public List<Project> list() {
String sql = "select * from project_mini limit 10";
List<Project> result = dynamicSqlExecutor.select(sql, Project.class, null);
return result;
}
public List<Project> listWrapper() {
String sql = "select * from project_mini";
QueryWrapper<Project> wrapper = new QueryWrapper<>();
wrapper.eq("status", 1);
List<Project> result = dynamicSqlExecutor.select(sql, Project.class, wrapper);
return result;
}
public List<Project> listLambdaWrapper() {
String sql = "select * from project_mini";
LambdaQueryWrapper<Project> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(Project::getStatus, 1);
List<Project> result = dynamicSqlExecutor.select(sql, Project.class, wrapper);
return result;
}
public List<Project> listParam() {
String sql = "select * from project_mini where status = #{status} limit 10";
Map<String, Object> param = new HashMap<>();
param.put("status", 1);
List<Project> result = dynamicSqlExecutor.select(sql, Project.class, param);
return result;
}
public List<Project> listParamDynamic() {
// 支持 <if> 动态拼接
String sql = ""
+ "SELECT * FROM project_mini "
+ "WHERE 1=1 "
+ " <if test='status != null'>AND status = #{status}</if> "
+ " <if test='name != null'>AND name LIKE CONCAT('%', #{name}, '%')</if> "
+ "LIMIT 10";
Map<String, Object> param = new HashMap<>();
param.put("status", 1);
// param.put("name", "测试"); // 不传 name 时自动忽略
return dynamicSqlExecutor.select(sql, Project.class, param);
}
public List<Project> listWithCte() {
// 使用 CTE 定义临时表 cte_projects
String sql = "-- CTE复杂SQL\n" +
"-- aaaa\n" +
"/*1111*/\n" +
"/* 1111 */\n"
+ "WITH cte_projects AS (\n" +
" SELECT \n" +
" *\n" +
" \t\t FROM project_mini\n" +
" WHERE status = #{status} -- 查询\n" +
")\n" +
"SELECT *\n" +
"FROM cte_projects\n" +
"WHERE id < 100\n" +
" and 1 > 0 -- 查询 \n" +
"LIMIT 10";
// 命名参数绑定
Map<String, Object> param = new HashMap<>();
param.put("status", 1);
// 调用 select 执行 CTE SQL
List<Project> result = dynamicSqlExecutor.select(sql, Project.class, param);
return result;
}
public List<Project> listWithCteMany() {
// 使用 CTE 定义临时表 cte_projects
String sql = "-- CTE复杂SQL\n" +
"-- aaaa\n" +
"/*1111*/\n" +
"/* 1111 */\n"
+ "WITH cte_projects AS (\n" +
" SELECT\n" +
" *\n" +
" FROM project_mini\n" +
" WHERE status = #{status}\n" +
"), result as (\n" +
" select *,1 as id2 from cte_projects\n" +
")\n" +
"SELECT *\n" +
"FROM result\n" +
"WHERE id < 100\n" +
" and 1 > 0\n" +
"LIMIT 10";
// 命名参数绑定
Map<String, Object> param = new HashMap<>();
param.put("status", 1);
// 调用 select 执行 CTE SQL
List<Project> result = dynamicSqlExecutor.select(sql, Project.class, param);
return result;
}
public List<Project> listWithCteAndIf() {
String sql = ""
+ "WITH cte_projects AS ( "
+ " SELECT * FROM project_mini "
+ " WHERE status = #{status} "
+ ") "
+ "SELECT * FROM cte_projects "
+ "WHERE id<100 "
+ "<if test='status != null'>AND status = #{status}</if> "
+ "LIMIT 10";
Map<String, Object> param = new HashMap<>();
param.put("status", 1);
return dynamicSqlExecutor.select(sql, Project.class, param);
}
public IPage<JSONObject> page() {
String sql = "WITH cte_projects AS ( " +
"SELECT * FROM project_mini " +
") SELECT * FROM cte_projects";
QueryWrapper<JSONObject> wrapper = new QueryWrapper<>();
wrapper.lt("id", 100); // 自动拼接到 SQL 末尾
IPage<JSONObject> page = new Page<>(1, 2);
IPage<JSONObject> resultPage = dynamicSqlExecutor.selectPage(sql, JSONObject.class, page, wrapper);
System.out.println("分页查询结果: " + resultPage.getRecords().size());
return resultPage;
}
public IPage<JSONObject> page2() {
String sql = "WITH cte_projects AS ( " +
"SELECT * FROM project_mini WHERE status = #{query._status} " +
") SELECT * FROM cte_projects";
Map<String, Object> query = new HashMap<>();
query.put("_status", 1);
QueryWrapper<JSONObject> wrapper = new QueryWrapper<>();
wrapper.lt("id", 100); // 自动拼接到 SQL 末尾
wrapper.orderByDesc("name");
IPage<JSONObject> page = new Page<>(7, 2);
IPage<JSONObject> resultPage = dynamicSqlExecutor.selectPage(sql, JSONObject.class, page, wrapper, query);
System.out.println("分页查询结果: " + resultPage.getRecords().size());
return resultPage;
}
public void insertByMap() {
String sql = "INSERT INTO project_mini (name) VALUES (#{name})";
Map<String, Object> param = new HashMap<>();
param.put("name", "测试项目-Map方式");
int rows = dynamicSqlExecutor.insert(sql, param);
System.out.println("受影响行数: " + rows);
}
public void insertByEntity() {
String sql = "INSERT INTO project_mini (name) VALUES (#{name})";
Project project = new Project();
project.setName("测试项目-实体方式");
int rows = dynamicSqlExecutor.insert(sql, project);
System.out.println("受影响行数: " + rows);
System.out.println(project);
}
public void batchInsertByMap() {
String sql = "INSERT INTO project_mini (name) VALUES (#{name})";
List<Map<String, Object>> params = new ArrayList<>();
for (int i = 1; i <= 3; i++) {
Map<String, Object> param = new HashMap<>();
param.put("name", "批量Map项目-" + i);
params.add(param);
}
int rows = dynamicSqlExecutor.insertBatch(sql, params);
System.out.println("批量插入行数: " + rows);
}
public void batchInsertByEntity() {
String sql = "INSERT INTO project_mini (name) VALUES (#{name})";
List<Project> projects = new ArrayList<>();
for (int i = 1; i <= 3; i++) {
Project project = new Project();
project.setName("批量实体项目-" + i);
projects.add(project);
}
int rows = dynamicSqlExecutor.insertBatch(sql, projects);
System.out.println("批量插入行数: " + rows);
System.out.println(projects);
}
public void updateByMap() {
String sql = "UPDATE project_mini SET name = #{name} WHERE id = #{id}";
Map<String, Object> param = new HashMap<>();
param.put("id", 146);
param.put("name", "更新后的项目名称");
int rows = dynamicSqlExecutor.update(sql, param);
System.out.println("更新影响行数: " + rows);
}
public void updateByEntity() {
String sql = "UPDATE project_mini SET name = #{name} WHERE id = #{id}";
Project project = new Project();
project.setId(145);
project.setName("更新后的实体项目");
int rows = dynamicSqlExecutor.update(sql, project);
System.out.println("更新影响行数: " + rows);
}
public void batchUpdate() {
String sql = "UPDATE project_mini SET name = #{name} WHERE id = #{id}";
List<Project> projects = new ArrayList<>();
for (int i = 3; i <= 5; i++) {
Project project = new Project();
project.setId(i);
project.setName("批量更新项目-" + i);
projects.add(project);
}
int rows = dynamicSqlExecutor.updateBatch(sql, projects);
System.out.println("批量更新影响行数: " + rows);
}
public void deleteByMap() {
String sql = "DELETE FROM project_mini WHERE id = #{id}";
Map<String, Object> param = new HashMap<>();
param.put("id", 145);
int rows = dynamicSqlExecutor.delete(sql, param);
System.out.println("删除影响行数: " + rows);
}
public void deleteByEntity() {
String sql = "DELETE FROM project_mini WHERE id = #{id}";
Project project = new Project();
project.setId(145);
int rows = dynamicSqlExecutor.delete(sql, project);
System.out.println("删除影响行数: " + rows);
}
public void batchDelete() {
String sql = "DELETE FROM project_mini WHERE id = #{id}";
List<Project> projects = new ArrayList<>();
for (int i = 12; i <= 15; i++) {
Project project = new Project();
project.setId(i);
projects.add(project);
}
int rows = dynamicSqlExecutor.deleteBatch(sql, projects);
System.out.println("批量删除影响行数: " + rows);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
创建Controller
package local.ateng.java.mybatisjdk8.controller;
import com.alibaba.fastjson2.JSONObject;
import com.baomidou.mybatisplus.core.metadata.IPage;
import local.ateng.java.mybatisjdk8.entity.Project;
import local.ateng.java.mybatisjdk8.service.DynamicService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/dynamic")
@RequiredArgsConstructor
public class DynamicController {
private final DynamicService dynamicService;
@GetMapping("/testSelect")
public List<JSONObject> testSelect() {
List<JSONObject> list = dynamicService.testSelect();
return list;
}
@GetMapping("/list")
public List<Project> list() {
List<Project> list = dynamicService.list();
return list;
}
@GetMapping("/listWrapper")
public List<Project> listWrapper() {
List<Project> list = dynamicService.listWrapper();
return list;
}
@GetMapping("/listLambdaWrapper")
public List<Project> listLambdaWrapper() {
List<Project> list = dynamicService.listLambdaWrapper();
return list;
}
@GetMapping("/listParam")
public List<Project> listParam() {
List<Project> list = dynamicService.listParam();
return list;
}
@GetMapping("/listParamDynamic")
public List<Project> listParamDynamic() {
List<Project> list = dynamicService.listParamDynamic();
return list;
}
@GetMapping("/listWithCte")
public List<Project> listWithCte() {
List<Project> list = dynamicService.listWithCte();
return list;
}
@GetMapping("/listWithCteMany")
public List<Project> listWithCteMany() {
List<Project> list = dynamicService.listWithCteMany();
return list;
}
@GetMapping("/listWithCteAndIf")
public List<Project> listWithCteAndIf() {
List<Project> list = dynamicService.listWithCteAndIf();
return list;
}
@GetMapping("/page")
public IPage<JSONObject> page() {
IPage<JSONObject> page = dynamicService.page();
return page;
}
@GetMapping("/page2")
public IPage<JSONObject> page2() {
IPage<JSONObject> page = dynamicService.page2();
return page;
}
@GetMapping("/insertByMap")
public void insertByMap() {
dynamicService.insertByMap();
}
@GetMapping("/insertByEntity")
public void insertByEntity() {
dynamicService.insertByEntity();
}
@GetMapping("/batchInsertByMap")
public void batchInsertByMap() {
dynamicService.batchInsertByMap();
}
@GetMapping("/batchInsertByEntity")
public void batchInsertByEntity() {
dynamicService.batchInsertByEntity();
}
@GetMapping("/updateByMap")
public void updateByMap() {
dynamicService.updateByMap();
}
@GetMapping("/updateByEntity")
public void updateByEntity() {
dynamicService.updateByEntity();
}
@GetMapping("/batchUpdate")
public void batchUpdate() {
dynamicService.batchUpdate();
}
@GetMapping("/deleteByMap")
public void deleteByMap() {
dynamicService.deleteByMap();
}
@GetMapping("/deleteByEntity")
public void deleteByEntity() {
dynamicService.deleteByEntity();
}
@GetMapping("/batchDelete")
public void batchDelete() {
dynamicService.batchDelete();
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135