第四章:[高级应用] 核心特性与实用工具

第四章:[高级应用] 插件体系与工程化实战

摘要: 在掌握了 Mybatis-Plus (MP) 的基础 CRUD 和动态查询后,本章将进入 企业级工程化 的深水区。我们将深入探索 MP 的 插件体系,通过配置 分页插件 解决海量数据查询问题,利用 乐观锁插件 解决并发更新难题。同时,我们将引入 ActiveRecord 模式SimpleQuery 工具类,体验如何进一步压缩代码行数,提升开发效率。

本章学习路径

  1. 模式革新:体验 ActiveRecord (AR) 模式,让实体类“拥有生命”,直接操作数据库。
  2. 物理分页:配置 PaginationInnerInterceptor,彻底告别手动写 LIMITCOUNT 的历史。
  3. 并发安全:启用 乐观锁插件,通过 version 机制优雅解决“丢失更新”问题。
  4. 自动填充:通过 MetaObjectHandler 实现创建时间、更新时间的自动化维护。
  5. 数据清洗:掌握 SimpleQuery,用一行代码替代繁琐的 Java Stream 操作。

4.1. ActiveRecord (AR) 模式

在标准的三层架构(Controller -> Service -> Dao)中,即便是最简单的“根据 ID 查询用户”,也需要跨越三层调用。这在大型项目中是必要的解耦,但在微服务或简单脚本中,显得有些繁琐。

ActiveRecord (AR) 是一种领域驱动设计(DDD)风格的编程范式。它的核心思想是:实体类(Model)不仅承载数据,还具备操作数据的方法

4.1.1. 开启 AR 模式

要使用 AR,只需让实体类继承 Model<T>

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
package com.example.mpstudy.domain;

import com.baomidou.mybatisplus.extension.activerecord.Model;
import lombok.Data;
import lombok.EqualsAndHashCode;

@Data
// [关键点 1] 继承 Model <T>,泛型指定为当前类
// [关键点 2] 必须重写 equals 和 hashCode,Lombok 用注解即可解决
@EqualsAndHashCode(callSuper = true)
@TableName("tb_user")
public class UserDO extends Model<UserDO> {
// 属性定义保持不变...
}

4.1.2. AR 实战演练

现在,我们的 UserDO 对象已经“活”了,它不再需要依赖 UserMapper 就能自我管理。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@SpringBootTest
public class ActiveRecordTest {

@Test
void testAr() {
// 1. 插入:直接 new 一个对象并调用 insert()
UserDO user = new UserDO().setName("AR-User").setAge(30);
boolean success = user.insert();
System.out.println("插入成功? " + success + ", ID=" + user.getId());

// 2. 查询:利用对象调用 selectById
// 注意:这里需要一个新的对象实例,或者使用静态方法(如果在 Kotlin 中)
UserDO result = new UserDO().selectById(user.getId());
System.out.println("查询结果: " + result);

// 3. 更新:在查询出的对象上修改,然后调用 updateById
result.setName("AR-Updated");
result.updateById();

// 4. 删除
result.deleteById();
}
}

选型建议:AR 模式非常适合 原型开发、脚本工具、简单的配置表管理。但在复杂的业务系统中,为了保持分层架构的清晰和可维护性,我们依然推荐使用标准的 Service -> Mapper 模式。


4.2. 物理分页插件 (PaginationInnerInterceptor)

在 Web 开发中,分页是必不可少的。在原生 MyBatis 中,我们通常需要手动写两条 SQL:一条查数据(带 LIMIT),一条查总数(COUNT)。这不仅繁琐,而且容易导致条件不一致。

MP 提供了 分页拦截器,能在 SQL 执行前自动拦截,动态添加 LIMIT 语句,并自动发起 COUNT 查询。

4.2.1. 开启分页插件

在 Spring Boot 中,我们需要通过配置类注册 MP 的拦截器核心组件。

文件路径: src/main/java/com/example/mpstudy/config/MybatisPlusConfig.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
26
package com.example.mpstudy.config;

import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class MybatisPlusConfig {

/**
* MP 插件核心配置
* 所有的增强功能(分页、乐观锁、租户等)都需要添加到 interceptor 中
*/
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();

// 添加分页插件
// DbType.MYSQL: 指定数据库类型,不同数据库的 LIMIT 语法不同
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));

return interceptor;
}
}

4.2.2. 分页查询实战

配置完成后,使用 Page<T> 对象作为参数即可触发分页。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Test
void testPage() {
// 1. 创建分页参数对象
// current: 第 1 页, size: 每页 2 条
Page<UserDO> page = new Page<>(1, 2);

// 2. 执行查询
// selectPage(page 对象, Wrapper 条件)
// 这里的返回值 result 实际上就是传入的 page 对象,两者是同一个引用
Page<UserDO> result = userMapper.selectPage(page, null);

// 3. 获取分页数据
System.out.println("总页数: " + result.getPages());
System.out.println("总记录数: " + result.getTotal());
System.out.println("当前页数据: ");
result.getRecords().forEach(System.out::println);
}

4.3. 乐观锁插件 (OptimisticLocker)

痛点背景:在并发场景下,A 和 B 两个用户同时读取了 ID = 1 的用户数据(Age = 20)。A 将 Age 修改为 21,B 将 Age 修改为 30。A 先提交,B 后提交。最终数据库变成了 30,A 的修改被 B 覆盖了,这就是著名的 丢失更新 问题。

解决方案:乐观锁机制。在数据库增加一个 version 字段。每次更新时,检查版本号是否一致,如果一致则更新并版本号+1,否则更新失败。

4.3.1. 配置乐观锁

步骤 1:注册插件
MybatisPlusConfig 中追加乐观锁拦截器。

1
2
3
4
5
6
7
8
9
10
11
// MybatisPlusConfig.java

@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));

// [新增] 添加乐观锁插件
interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
return interceptor;
}

步骤 2:实体类标识
在实体类的版本字段上添加 @Version 注解。

1
2
3
4
5
6
7
8
// UserDO.java

/**
* 乐观锁版本号
* 默认值为 1
*/
@Version
private Integer version;

4.3.2. 并发更新实战

为了验证效果,我们需要模拟“读取-修改-写入”的过程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Test
void testOptimisticLocker() {
// 1. 模拟用户 A 查询数据
UserDO userA = userMapper.selectById(1L); // 假设此时 version = 1

// 2. 模拟用户 B 查询同一条数据
UserDO userB = userMapper.selectById(1L); // 此时 version = 1

// 3. 用户 A 修改并提交
userA.setAge(88);
userMapper.updateById(userA);
// SQL: UPDATE tb_user SET age = 88, version = 2 WHERE id = 1 AND version = 1
// 成功,数据库 version 变为 2

// 4. 用户 B 修改并提交
userB.setAge(99);
int result = userMapper.updateById(userB);
// SQL: UPDATE tb_user SET age = 99, version = 2 WHERE id = 1 AND version = 1
// 失败!因为此时数据库 version 已经是 2 了,WHERE version = 1 匹配不到记录

if (result == 0) {
System.out.println("用户 B 更新失败,因为数据已被其他人修改!");
}
}

4.4. 自动填充 (MetaObjectHandler)

痛点背景:在企业级开发中,gmt_create(创建时间)和 gmt_modified(修改时间)是每张表的标配。如果每次 insertupdate 都要手动写 user.setGmtCreate(LocalDateTime.now()),不仅代码冗余,还容易遗漏。

解决方案
MP 提供了 MetaObjectHandler 接口,可以在 SQL 执行前自动填充指定字段。

4.4.1. 实现填充处理器

创建一个 Handler 类,实现 MetaObjectHandler 接口。

文件路径: src/main/java/com/example/mpstudy/handler/MyMetaObjectHandler.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
26
27
28
29
30
package com.example.mpstudy.handler;

import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import org.apache.ibatis.reflection.MetaObject;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;

@Component // 交给 Spring 管理
public class MyMetaObjectHandler implements MetaObjectHandler {

@Override
public void insertFill(MetaObject metaObject) {
// 插入时自动填充:gmtCreate 和 gmtModified
// 参数 1: 实体属性名 (是 Java 属性名,不是数据库字段名)
// 参数 2: 填充的值
// 参数 3: 元数据对象
this.strictInsertFill(metaObject, "gmtCreate", LocalDateTime.class, LocalDateTime.now());
this.strictInsertFill(metaObject, "gmtModified", LocalDateTime.class, LocalDateTime.now());

// 如果有 version 和 isDeleted 也可以在这里初始化
this.strictInsertFill(metaObject, "version", Integer.class, 1);
this.strictInsertFill(metaObject, "isDeleted", Integer.class, 0);
}

@Override
public void updateFill(MetaObject metaObject) {
// 更新时自动填充:gmtModified
this.strictUpdateFill(metaObject, "gmtModified", LocalDateTime.class, LocalDateTime.now());
}
}

4.4.2. 实体类绑定

UserDO 中,使用 @TableField 告诉 MP 哪些字段需要填充,以及何时填充。

1
2
3
4
5
6
7
// UserDO.java

@TableField(fill = FieldFill.INSERT) // 仅插入时填充
private LocalDateTime gmtCreate;

@TableField(fill = FieldFill.INSERT_UPDATE) // 插入和更新时都填充
private LocalDateTime gmtModified;

此后,执行 insertupdate 时,无需手动设置时间,MP 会自动处理。


4.5. SimpleQuery 工具类

痛点背景:我们经常需要把查询结果 (List<UserDO>) 转换成 Map<Id, User>,或者提取出 List<Id>。虽然 Java 8 Stream 可以做到(如 .stream().map().collect()),但代码仍然偏长。

解决方案
SimpleQuery 是 MP 对 Stream 操作的封装,用一行代码实现常见的集合转换。

4.5.1. 常用 API 实战

文件路径: src/test/java/com/example/mpstudy/SimpleQueryTest.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
26
27
28
29
30
31
32
33
@Test
void testSimpleQuery() {
// 1. list(): 查询某一列,返回 List <Value>
// 需求:获取所有用户的邮箱列表
List<String> emails = SimpleQuery.list(
new LambdaQueryWrapper<UserDO>(), // 查询条件
UserDO::getEmail // 提取字段
);
System.out.println("邮箱列表: " + emails);

// 2. keyMap(): 查询结果转 Map <Key, Entity>
// 需求:将用户转为 Map,Key 为 ID,Value 为用户对象
Map<Long, UserDO> idUserMap = SimpleQuery.keyMap(
new LambdaQueryWrapper<UserDO>(),
UserDO::getId
);
System.out.println("用户Map: " + idUserMap);

// 3. map(): 查询结果转 Map <Key, Value>
// 需求:获取 Map <ID, Name>
Map<Long, String> idNameMap = SimpleQuery.map(
new LambdaQueryWrapper<UserDO>(),
UserDO::getId, // Key
UserDO::getName // Value
);

// 4. group(): 分组查询 Map <GroupKey, List<Entity> >
// 需求:按年龄分组
Map<Integer, List<UserDO>> ageGroup = SimpleQuery.group(
new LambdaQueryWrapper<UserDO>(),
UserDO::getAge
);
}

4.6. 本章总结与特性速查

4.6.1. 场景化速查

场景一:前端列表分页

1
2
3
4
5
// Controller 层
public IPage<UserDO> page(int current, int size) {
return userMapper.selectPage(new Page<>(current, size), null);
}
// 返回的 JSON 包含:records(数据), total(总数), pages(总页数)

场景二:防止数据被覆盖 (乐观锁)

  1. DB 加 version 列 (default 1)。
  2. 实体类加 @Version
  3. 配置 OptimisticLockerInnerInterceptor
  4. 更新前 必须先查询(拿到当前 version),再更新。

场景三:自动记录操作时间

  1. 实现 MetaObjectHandler
  2. 实体字段加 @TableField(fill = ...)
  3. 告别 setCreateTime

4.6.2. 核心避坑指南

  1. 分页失效

    • 现象:查询出来了所有数据,LIMIT 没生效。
    • 原因:忘记配置 MybatisPlusInterceptor Bean,或者忘记把 PaginationInnerInterceptor add 进去。
  2. 乐观锁无效

    • 现象:并发更新时数据依然被覆盖。
    • 原因update(entity, wrapper) 方法 不支持 乐观锁。必须使用 updateById(entity),且 entity 中必须包含从数据库查出来的 version 值。
  3. ActiveRecord 的滥用

    • 建议:AR 模式虽然写起来爽,但它让实体类耦合了 Dao 层逻辑。在大型项目中,建议严格遵守 Service -> Mapper 的调用链,保持实体类的纯洁性(POJO)。