[扩展] 进阶映射与自定义增强

第六章:[扩展] 进阶映射与自定义增强

摘要: Mybatis-Plus (MP) 的强大不仅在于其内置功能,更在于其卓越的 扩展能力。本章我们将跳出常规的 CRUD,深入 MP 的底层机制。我们将首先解决 Java 丰富类型与数据库贫乏类型 的映射难题(枚举与 JSON);接着,我们将解锁 MP 的 SQL 注入器,通过自定义通用方法实现真正的“批量插入”;最后,我们将构建 安全与观测 防线,保障生产环境的代码质量。

本章学习路径

  1. 高级映射:利用 @EnumValueTypeHandler,实现枚举与 JSON 的自动化持久化,告别手动转换。
  2. 性能观测:集成 P6Spy,捕捉带有真实参数的 SQL,精准定位慢查询。
  3. 安全底座:配置 BlockAttack 插件,从底层拦截全表更新/删除操作,防止“删库跑路”。
  4. 底层扩展:自定义 SQL 注入器,扩展 BaseMapper 的能力,实现高性能的 insertBatch

6.1. 高级类型映射

痛点背景
Java 的世界是面向对象的,我们有 GenderEnumMap<String, Object> 等丰富的类型;而数据库的世界是扁平的,通常只有 TINYINTVARCHAR。在传统开发中,我们需要手动转换:存的时候把枚举转 int,取的时候把 int 转枚举;存 JSON 时手动序列化,取时手动反序列化。这种重复劳动不仅低效,还容易产生“脏数据”。

MP 提供了优雅的 自动化映射机制,帮我们在两个世界间架起桥梁。

6.1.1. 通用枚举处理 (@EnumValue)

场景:数据库性别字段存的是 12,但 Java 业务代码中我们希望直接操作 GenderEnum.MALE,而不是魔法数字。

步骤 1:定义枚举类

我们需要一个实现了“数据值”与“展示值”绑定的枚举。

文件路径: src/main/java/com/example/mpstudy/domain/enums/GenderEnum.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package com.example.mpstudy.domain.enums;

import com.baomidou.mybatisplus.annotation.EnumValue;
import com.fasterxml.jackson.annotation.JsonValue;
import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
@AllArgsConstructor
public enum GenderEnum {
MALE(1, "男"),
FEMALE(2, "女");

// [核心] @EnumValue: 标记数据库存储的值
// 插入时,MP 会取这个字段的值(1 或 2)存入数据库
// 查询时,MP 会根据数据库的值反序列化为枚举对象
@EnumValue
private final int code;

// [可选] @JsonValue: 标记前端展示的值
// 返回 JSON 给前端时,展示 "男" 或 "女",而不是 "MALE"
@JsonValue
private final String desc;
}

步骤 2:实体类引用

直接将字段类型定义为枚举,无需任何额外的 XML 配置。

文件路径: src/main/java/com/example/mpstudy/domain/UserDO.java

1
2
3
4
5
6
7
8
9
10
11
@Data
@TableName("tb_user")
public class UserDO {
// ... 其他字段

/**
* 性别
* 直接使用枚举类型,MP 的 TypeHandler 会自动处理
*/
private GenderEnum gender;
}

步骤 3:验证效果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Test
void testEnum() {
// 1. 插入:直接 set 枚举对象
UserDO user = new UserDO().setGender(GenderEnum.MALE);
userMapper.insert(user);
// 实际执行 SQL: INSERT INTO ... (gender) VALUES (1)

// 2. 查询:自动转回枚举
UserDO result = userMapper.selectById(user.getId());
System.out.println("用户性别枚举: " + result.getGender()); // 输出: MALE
System.out.println("用户性别描述: " + result.getGender().getDesc()); // 输出: 男

// 断言验证
assert result.getGender() == GenderEnum.MALE;
}

6.1.2. JSON 自动映射 (JacksonTypeHandler)

场景:MySQL 5.7+ 支持 JSON 类型。我们希望将 Java 的 MapList 直接存入数据库的 JSON 字段,取出时自动转回对象。

步骤 1:全局注册 TypeHandler

为了避免在每个字段上都写 @TableField(typeHandler=...),我们推荐使用配置自动扫描。

文件路径: src/main/resources/application.yml

1
2
3
4
mybatis-plus:
# 自动扫描 MP 内置的 TypeHandler 包
# 这样 JacksonTypeHandler 就会被自动加载
type-handlers-package: com.baomidou.mybatisplus.extension.handlers

步骤 2:配置实体类(关键!)

这里有一个新手必踩的坑:必须开启 autoResultMap

文件路径: src/main/java/com/example/mpstudy/domain/UserDO.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// [核心] autoResultMap = true
// 原因:MyBatis 默认生成的 ResultMap 不包含 TypeHandler 信息。
// 如果不开启,查询时 JSON 数据无法自动转换回 Map,会得到 null。
@TableName(value = "tb_user", autoResultMap = true)
public class UserDO {

// ... 其他字段

/**
* 联系方式
* 数据库类型: JSON
* 映射类型: Map <String, String>
*/
@TableField(typeHandler = JacksonTypeHandler.class)
private Map<String, String> contactInfo;
}

步骤 3:验证效果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Test
void testJson() {
UserDO user = new UserDO();
// 直接存入 Map
user.setContactInfo(Map.of("phone", "13800138000", "wechat", "mp_helper"));

userMapper.insert(user);
// 实际执行 SQL: INSERT INTO ... (contact_info) VALUES ('{"phone": "...", "wechat": "..."}')

// 查询验证
UserDO result = userMapper.selectById(user.getId());
System.out.println("微信: " + result.getContactInfo().get("wechat"));
// 输出: mp_helper
}

6.2. 安全与观测:给系统装上监控

在生产环境中,代码的健壮性和可观测性往往比功能实现更重要。

6.2.1. SQL 性能透视镜 (P6Spy)

MyBatis 默认的控制台日志只打印 SQL 模板(WHERE id = ?)和参数,无法显示 真实执行 SQL耗时。这让我们难以直接复制 SQL 去数据库排查慢查询。P6Spy 是一个 JDBC 代理,能截获并统计 SQL。

配置步骤

1. 引入依赖 & 开启配置
(在上一章的动态数据源配置中,我们已经设置了 p6spy: true。如果是单数据源,需引入 p6spy 依赖并修改 driver 为 com.p6spy.engine.spy.P6SpyDriver

2. 精细化日志格式
src/main/resources 下新建 spy.properties

1
2
3
4
5
6
7
8
# 1. 使用 MP 团队提供的单行日志格式(方便 grep 和阅读)
logMessageFormat=com.baomidou.mybatisplus.extension.p6spy.P6SpyLogger
# 2. 输出到控制台
appender=com.baomidou.mybatisplus.extension.p6spy.StdoutLogger
# 3. 自动检测日期格式
dateformat=yyyy-MM-dd HH:mm:ss
# 4. 排除心跳检测等无关 SQL
excludecategories=info,debug,result,commit,resultset

运行效果

1
2025-08-23 12:00:00 | took 15ms | statement | SELECT * FROM tb_user WHERE id = 1
  • took 15ms:这是最关键的指标。如果某个简单的查询耗时超过 500ms,你就需要警惕了。
  • statement:这是可以直接复制到 Navicat 执行的完整 SQL。

6.3.2. 删库跑路防御盾 (BlockAttack)

风险:开发人员在写更新语句时,不小心传了一个 null 的 Wrapper:userMapper.update(user, null);
这会生成 UPDATE tb_user SET ...(无 WHERE 条件),导致全表数据被覆盖!

解决方案:配置 BlockAttackInnerInterceptor。它是 MP 提供的一个 SQL 分析拦截器,如果发现 Update/Delete 语句没有 Where 条件,直接抛出异常。

配置代码
文件路径: src/main/java/com/example/mpstudy/config/MybatisPlusConfig.java

1
2
3
4
5
6
7
8
9
10
11
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();

// [核心] 添加防全表攻击插件
// 建议把它放在拦截器链的最前面
interceptor.addInnerInterceptor(new BlockAttackInnerInterceptor());

// ... 其他插件 (分页、多租户等)
return interceptor;
}

验证测试

1
2
3
4
5
6
7
8
@Test
void testBlockAttack() {
// 模拟恶意全表更新
assertThrows(MybatisPlusException.class, () -> {
// wrapper 为 null,意图更新所有人的年龄
userMapper.update(new UserDO().setAge(0), null);
});
}

结果:抛出 Prohibition of table update operation 异常,SQL 被拦截,未发送到数据库。


6.3. [高阶] 自定义 SQL 注入器

痛点背景
MP 的 IService.saveBatch 方法虽然方便,但它本质上是在 for 循环中执行单条 INSERT(或者基于 JDBC 的 Batch 重写),在数据量极大时性能依然有瓶颈。
MySQL 原生支持 INSERT INTO values (),(),() 语法,性能极高。MP 内置了这个方法(insertBatchSomeColumn),但默认没有注入到 BaseMapper 中。本节我们将通过 扩展 SQL 注入器,把它加进去。

6.3.1. 创建自定义 BaseMapper

我们需要定义一个新的 Mapper 父类,包含我们想要扩展的方法。

文件路径: src/main/java/com/example/mpstudy/base/MyBaseMapper.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package com.example.mpstudy.base;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import java.util.List;

/**
* 自定义通用 Mapper,扩展 BaseMapper 的功能
* @param <T> 实体类型
*/
public interface MyBaseMapper<T> extends BaseMapper<T> {

/**
* 真正的批量插入 (MySQL 语法)
* INSERT INTO table (c1, c2) VALUES (v1, v2), (v3, v4)
*
* @param entityList 数据列表
* @return 影响行数
*/
int insertBatchSomeColumn(List<T> entityList);
}

6.3.2. 配置 SQL 注入器

我们需要告诉 MP,启动时除了加载默认的 CRUD 方法,还要把我们的 insertBatchSomeColumn 加载进去。

文件路径: src/main/java/com/example/mpstudy/config/MySqlInjector.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package com.example.mpstudy.config;

import com.baomidou.mybatisplus.core.injector.AbstractMethod;
import com.baomidou.mybatisplus.core.injector.DefaultSqlInjector;
import com.baomidou.mybatisplus.core.metadata.TableInfo;
import com.baomidou.mybatisplus.extension.injector.methods.InsertBatchSomeColumn;
import org.springframework.stereotype.Component;

import java.util.List;

@Component
public class MySqlInjector extends DefaultSqlInjector {

@Override
public List<AbstractMethod> getMethodList(Class<?> mapperClass, TableInfo tableInfo) {
// 1. 获取 MP 默认的方法列表 (selectById, insert 等)
List<AbstractMethod> methodList = super.getMethodList(mapperClass, tableInfo);

// 2. 添加我们想要的内置扩展方法
// InsertBatchSomeColumn 是 MP 提供的但未默认启用的高效批量插入
methodList.add(new InsertBatchSomeColumn());

return methodList;
}
}

6.3.3. 业务 Mapper 继承新父类

修改 UserMapper,让它继承 MyBaseMapper 而不是 BaseMapper

文件路径: src/main/java/com/example/mpstudy/mapper/UserMapper.java

1
2
3
4
// 改为继承 MyBaseMapper
public interface UserMapper extends MyBaseMapper<UserDO> {
// 此时 UserMapper 已经拥有了 insertBatchSomeColumn 方法
}

6.3.4. 性能对比测试

文件路径: src/test/java/com/example/mpstudy/BatchInsertTest.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Test
void testRealBatchInsert() {
// 构造 1000 条数据
List<UserDO> users = new ArrayList<>();
for (int i = 0; i < 1000; i++) {
users.add(new UserDO().setName("User" + i).setAge(18));
}

long start = System.currentTimeMillis();

// 调用我们扩展的方法
userMapper.insertBatchSomeColumn(users);

long end = System.currentTimeMillis();
System.out.println("耗时: " + (end - start) + "ms");
}

6.4. 本章总结与进阶速查

6.4.1. 场景化速查

场景一:存储复杂 JSON 对象

  • 方案:JacksonTypeHandler
  • 关键点@TableName(autoResultMap = true) 必须加,否则查出来是 null。

场景二:海量数据写入

  • 方案:扩展 InsertBatchSomeColumn
  • 关键点:不要用 IService.saveBatch(伪批量),要用自定义注入器实现的真批量。
  • 限制:仅适用于 MySQL 等支持多值 Insert 语法的数据库。

场景三:排查慢 SQL

  • 方案:P6Spy
  • 操作:看日志中的 took xx ms,定位耗时源头。

6.4.2. 核心避坑指南

  1. JSON 查不到数据

    • 现象:数据库有 JSON,Java 里的 Map 是 null。
    • 原因:99% 是因为没加 @TableName(autoResultMap = true)。MyBatis 默认的 ResultMap 不会处理 TypeHandler,必须开启自动构建。
  2. 枚举插入报错

    • 现象Data truncated for column 'gender'
    • 原因:数据库字段长度不够,或者枚举的 @EnumValue 值与数据库类型不匹配(如枚举是 String,库是 Int)。
  3. 自定义注入器不生效

    • 原因MySqlInjector 类忘记加 @Component 注解,导致 Spring 没扫描到它。