Java(13):13 Mybatis-Plus -ORM框架 MyBatis 最好的搭档
Java(13):13 Mybatis-Plus -ORM框架 MyBatis 最好的搭档
Prorise第一章: [基础] 快速入门与环境配置
摘要: 本章的目标是 用最快的速度搭建一个可以运行 Mybatis-Plus 的最小化 Spring Boot 3 项目。我们将聚焦于核心的依赖配置、数据源连接以及实体类和 Mapper 接口的基础定义,为后续所有章节的学习提供一个简洁、稳定的开发环境。
1.1. Mybatis-Plus 简介与核心优势
对于一位已经熟练掌握 Spring Boot 和原生 MyBatis 的开发者而言,MyBatis 的优点——完全掌控 SQL 的灵活性——毋庸置疑。但与此同时,其固有的开发痛点也同样突出。
“痛点回顾”: 大量的样板代码(Boilerplate Code)充斥在项目中。即便是一个最基础的单表 CRUD 操作,我们依然需要一步步地完成从 Mapper
接口定义到 XML
文件编写的全过程。这种重复性劳动在项目初期和快速迭代中,会显著拖慢开发效率。
为了更直观地展示原生 MyBatis 与 Mybatis-Plus (下文简称 MP) 在开发流程上的天壤之别,我们可以通过一个简单的“根据 ID 查询用户”功能进行对比:
- Mapper 层:在
UserMapper
接口中定义1
User selectById(Long id);
- XML 层:在
UserMapper.xml
中手写1
2
3<select id="selectById" resultType="com.demo.User">
SELECT * FROM user WHERE id = #{id}
</select> - Service 层:在
UserServiceImpl
中注入UserMapper
并调用1
User user = userMapper.selectById(id);
- Mapper 层:让
UserMapper
接口继承BaseMapper<User>
,无需额外方法。1
public interface UserMapper extends BaseMapper<User> {}
- XML 层:无需任何 XML 或 SQL。
- Service 层:直接调用继承自
IService
的现成方法。1
User user = userService.getById(id);
通过对比,MP 的核心价值主张显而易见:“只做增强,不做改变”。它完美继承了 MyBatis 的所有功能,并通过内置通用 Mapper
和 Service
,将我们从繁琐、重复的 CRUD 代码中彻底解放出来。这使得我们能更专注于复杂的业务逻辑,也正是我们称之为 MyBatis “最佳搭档” 的根本原因。
1.1.1. [面试题] MP、MyBatis 与 JPA 的技术选型对比
在项目中,当面临持久层框架选型时,你是如何看待 Mybatis-Plus、MyBatis 和 JPA (如 Hibernate) 这三者的?它们的优缺点和适用场景分别是什么?
好的面试官。这三者是 Java 持久化领域的代表,我的理解如下:
JPA 以 Hibernate 为代表,是一个全自动 ORM 框架。它的优点是自动化程度高、开发效率快,缺点是 SQL 黑盒、难以优化,因此最适用于业务简单的中后台系统。
MyBatis 是一个半自动 SQL 映射框架。它的优点是对 SQL 有绝对控制权、便于性能优化,缺点是样板代码多、开发效率较低,因此非常适用于 SQL 逻辑复杂、性能要求高的互联网应用。
Mybatis-Plus 是 MyBatis 的增强工具。它的优点是结合了 JPA 的便利和 MyBatis 的灵活,既能快速开发也能精细优化,缺点是学习曲线稍高,因此它适用于绝大多数需要兼顾开发效率和性能的现代 Java 项目。
总结得很好。
1.2. 项目环境搭建
为了让学习过程聚焦于 Mybatis-Plus 本身,我们将采用最简洁的 单模块 Spring Boot 项目结构。
1.2.1. 技术栈版本说明
本教程将基于 2025 年的主流稳定技术栈进行构建,具体版本如下:
技术栈 | 版本 | 说明 |
---|---|---|
JDK | 21 | Long-Term Support (LTS) 长期支持版 |
Spring Boot | 3.4.x | 现代 Java 应用开发的事实标准 |
Mybatis-Plus | 3.5.7+ | 适配 Spring Boot 3 的最新稳定版 |
MySQL Driver | 8.0.33 | 官方推荐的 MySQL 8+ 驱动 |
Maven | 3.8+ | 项目构建与依赖管理工具 |
1.2.2. Maven 依赖配置 (pom.xml
)
首先,创建一个标准的 Spring Boot Maven 项目,并在 pom.xml
中配置好我们的核心依赖。
文件路径: pom.xml
1 |
|
1.2.3. 数据源与 MP 基础配置 (application.yml
)
我们推荐使用 .yml
格式进行配置,因为它层级清晰,更具可读性。请在 src/main/resources/
目录下创建 application.yml
文件。
文件路径: src/main/resources/application.yml
1 | # 服务器端口配置 |
请注意: 在 application.yml
中,spring.datasource.password
字段需要替换为您自己本地 MySQL 数据库的真实密码。
1.3. 核心文件创建
项目的基础框架和配置已经就绪。现在,我们需要创建与数据库交互的核心文件,包括数据表结构、实体类(Entity)、数据访问接口(Mapper)以及配置启动类。
1.3.1. 数据库表结构 (tb_user
)
请在您的 MySQL 数据库中执行以下 SQL 脚本。这份脚本将创建我们项目所需的数据库和 tb_user
表。
设计说明:此表结构严格遵循了《阿里巴巴 Java 开发手册》的规约。我们预先定义了乐观锁 (version
)、逻辑删除 (is_deleted
) 以及审计字段 (gmt_create
, gmt_modified
),这体现了企业级表结构设计的专业性与前瞻性。我们将在后续章节中详细讲解这些字段的应用。
1 | -- 创建数据库(如果不存在) |
1.3.2. 实体类 (UserDO.java
)
实体类是数据库表在 Java 世界中的映射。按照规约,与数据库表直接对应的对象我们称之为 DO (Data Object)。
文件路径: src/main/java/com/example/mpstudy/domain/UserDO.java
1 | package com.example.mpstudy.domain; |
1.3.3. Mapper 接口 (UserMapper.java
)
Mapper 接口是数据访问层(DAO)的核心,它充当了 Java 代码与数据库 SQL 之间的桥梁。
文件路径: src/main/java/com/example/mpstudy/mapper/UserMapper.java
1 | package com.example.mpstudy.mapper; |
1.3.4. 启动类配置 (@MapperScan
)
最后一步,我们需要告诉 Spring Boot 在哪里可以找到我们刚刚创建的 Mapper 接口,以便为它们创建代理实现并纳入 IoC 容器管理。
文件路径: src/main/java/com/example/mpstudy/MpStudyApplication.java
1 | package com.example.mpstudy; |
@MapperScan("com.example.mpstudy.mapper")
: 这个注解的作用是扫描指定的包(com.example.mpstudy.mapper
),并将其中所有被 Mybatis 识别为 Mapper 的接口(通常是继承了 BaseMapper
的接口)自动注册为 Spring Bean。这样,我们就可以在 Service 层或其他地方通过 @Autowired
直接注入并使用它们了。
至此,我们的项目已完全准备就绪,所有基础配置和核心文件均已创建完毕。在下一章,我们将正式开始体验 Mybatis-Plus 强大而便捷的 CRUD 功能。
第二章: [核心] 通用 CRUD 与 Service 接口
摘要: 本章将讲解 Mybatis-Plus 效率革命的核心:通用 CRUD 功能。我们将学习如何通过继承 BaseMapper
和 ServiceImpl
接口,在不写一行 SQL 的情况下,实现单表的增、删、改、查及批量操作。
2.1. BaseMapper
内置方法详解
BaseMapper
是 Mybatis-Plus 实现通用 CRUD 的基石。我们在上一章让 UserMapper
接口继承了 BaseMapper<UserDO>
,这使得 UserMapper
立刻拥有了十几个功能强大的、无需任何 SQL 编写的数据库操作方法。
方法名称 | 描述 | 需要传入的参数 |
---|---|---|
insert(T entity) | 插入一条数据 | entity :需要插入的实体类对象 |
deleteById(Serializable id) | 根据 ID 删除数据 | id :要删除的记录的主键 ID |
updateById(T entity) | 根据 ID 更新数据 | entity :包含更新字段的实体类对象 |
selectById(Serializable id) | 根据 ID 查询数据 | id :要查询的记录的主键 ID |
selectList(Wrapper<T> query) | 根据条件查询数据 | query :查询条件,通常使用 QueryWrapper |
delete(Wrapper<T> query) | 根据条件删除数据 | query :删除条件,通常使用 QueryWrapper |
update(Wrapper<T> updateWrapper) | 根据条件更新数据 | updateWrapper :更新条件,通常使用 UpdateWrapper |
selectCount(Wrapper<T> query) | 根据条件统计数据 | query :查询条件,通常使用 QueryWrapper |
selectOne(Wrapper<T> query) | 根据条件查询一条数据 | query :查询条件,通常使用 QueryWrapper |
T
:表示一个实体类对象类型。例如,User
、Order
等。Serializable
:表示可以序列化的类型,通常是主键 ID。Wrapper<T>
:这是 MyBatis-Plus 提供的一个条件构造器类,常用的有QueryWrapper
(用于查询条件)和UpdateWrapper
(用于更新条件)。通过Wrapper
,你可以构建更加复杂的查询或更新条件,我们后续会详细讲解这个
为了验证这些方法的实际效果,我们将通过单元测试来进行演示。
首先,在 src/test/java/com/example/mpstudy/mapper/
目录下,创建一个测试类 UserMapperTest
。
文件路径: src/test/java/com/example/mpstudy/mapper/UserMapperTest.java
1 | package com.example.mpstudy.mapper; |
2.1.1. 插入 (insert
)
insert
方法用于向数据库中插入一条新的记录。
1 | // UserMapperTest.java |
1
2
3
4
5
6
7
8
9
10
11
12
----- 开始执行 insert 测试 -----
Creating a new SqlSession
SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@68a426c3] was not registered for synchronization because synchronization is not active
2025-08-22T09:22:23.605+08:00 INFO 9020 --- [ main] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Starting...
2025-08-22T09:22:23.805+08:00 INFO 9020 --- [ main] com.zaxxer.hikari.pool.HikariPool : HikariPool-1 - Added connection com.mysql.cj.jdbc.ConnectionImpl@51141f64
2025-08-22T09:22:23.809+08:00 INFO 9020 --- [ main] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Start completed.
JDBC Connection [HikariProxyConnection@1010480754 wrapping com.mysql.cj.jdbc.ConnectionImpl@51141f64] will not be managed by Spring
==> Preparing: INSERT INTO tb_user ( id, name, age, email ) VALUES ( ?, ?, ?, ? )
==> Parameters: 1958701250124349442(Long), Prorise(String), 18(Integer), prorise@163.com(String)
<== Updates: 1
Closing non transactional SqlSession [org.apache.ibatis.session.defaults.DefaultSqlSession@68a426c3]
受影响的行数: 1
2.1.2. 删除 (deleteById
, deleteByMap
, deleteBatchIds
)
Mybatis-Plus 提供了多种删除数据的方式。
1 |
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
-- ==> Preparing: DELETE FROM tb_user WHERE id=?
-- ==> Parameters: 1826451631520124929(Long)
-- <== Updates: 1
deleteById 受影响行数: 1
-- ==> Preparing: DELETE FROM tb_user WHERE id IN ( ? , ? )
-- ==> Parameters: 1(Long), 2(Long)
-- <== Updates: 2
deleteBatchIds 受影响行数: 2
-- ==> Preparing: DELETE FROM tb_user WHERE name = ? AND age = ?
-- ==> Parameters: Tom(String), 28(Integer)
-- <== Updates: 1
deleteByMap 受影响行数: 1
由于我们测试删除了部分数据,建议再重新向我们的数据库插入新数据回来
2.1.3. 修改 (updateById
)
updateById
会根据传入实体的 ID 去更新记录。
重要: updateById
方法默认会更新实体中 所有字段,即使字段值为 null
。这意味着如果您只想更新某个字段,需要先查询出完整记录,修改后再更新,否则其他字段可能被 null
覆盖。后续章节会讲解如何实现“部分更新”。
1 | // UserMapperTest.java |
1
2
3
4
-- ==> Preparing: UPDATE tb_user SET name=?, age=?, email=?, version=?, is_deleted=?, gmt_create=?, gmt_modified=? WHERE id=?
-- ==> Parameters: null, 22(Integer), null, null, null, null, null, 4(Long)
-- <== Updates: 1
updateById 受影响行数: 1
2.1.4. 查询 (selectById
, selectList
, selectBatchIds
, selectByMap
)
查询是最高频的操作,BaseMapper
同样提供了丰富的查询方法。
1 | // UserMapperTest.java |
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
-- ==> Preparing: SELECT id,name,age,email,version,is_deleted,gmt_create,gmt_modified FROM tb_user WHERE id=?
-- ==> Parameters: 5(Long)
-- <== Total: 1
selectById 查询结果: UserDO(id=5, name=Billie, age=24, email=test5@baomidou.com, version=1, isDeleted=0, gmtCreate=..., gmtModified=...)
-- ==> Preparing: SELECT id,name,age,email,version,is_deleted,gmt_create,gmt_modified FROM tb_user
-- ==> Parameters:
-- <== Total: 5
selectList 查询到的总数: 5
... (打印所有用户)
-- ==> Preparing: SELECT id,name,age,email,version,is_deleted,gmt_create,gmt_modified FROM tb_user WHERE id IN ( ? , ? )
-- ==> Parameters: 4(Long), 5(Long)
-- <== Total: 2
selectBatchIds 查询到的结果:
UserDO(id=4, name=Sandy, age=21, email=test4@baomidou.com, ...)
UserDO(id=5, name=Billie, age=24, email=test5@baomidou.com, ...)
-- ==> Preparing: SELECT id,name,age,email,version,is_deleted,gmt_create,gmt_modified FROM tb_user WHERE name = ?
-- ==> Parameters: Sandy(String)
-- <== Total: 1
selectByMap 查询到的结果:
UserDO(id=4, name=Sandy, age=21, email=test4@baomidou.com, ...)
2.2. IService
和 ServiceImpl
的应用
直接在业务逻辑中注入 Mapper
进行数据库操作是可行的,但这是一种不良实践。专业的开发模式要求在 Controller
(或业务逻辑) 与 Mapper
(数据访问) 之间设立一个 Service 层。
Service 层的职责:
- 封装业务逻辑:处理复杂的业务规则。
- 事务管理:确保多个数据库操作的原子性。
- 解耦:隔离上层应用与底层数据访问的细节。
Mybatis-Plus 同样为 Service 层提供了强大的代码简化方案:IService
接口和 ServiceImpl
实现类。
2.2.1. 业务层接口 (UserService
) 继承 IService
我们首先定义 UserService
接口,它继承 IService<UserDO>
。
文件路径: src/main/java/com/example/mpstudy/service/UserService.java
1 | package com.example.mpstudy.service; |
2.2.2. 业务类实现 (UserServiceImpl
) 继承 ServiceImpl
接著,我們創建 UserService
的實現類,它需要繼承 ServiceImpl
。
文件路径: src/main/java/com/example/mpstudy/service/impl/UserServiceImpl.java
1 | package com.example.mpstudy.service.impl; |
2.2.3. 常用 Service 方法 (save
, remove
, update
, get
, list
)
IService
提供了比 BaseMapper
更符合业务语义的方法名,如 save
对应 insert
,getById
对应 selectById
,list
对应 selectList
。
方法名称 | 描述 | 需要传入的参数 |
---|---|---|
save(T entity) | 插入单条数据 | entity :需要插入的实体类对象 |
saveBatch(Collection<T> list) | 批量插入数据 | list :需要插入的实体类对象集合 |
removeById(Serializable id) | 根据 ID 删除数据 | id :要删除的记录的主键 ID |
remove(Wrapper<T> query) | 根据条件删除数据 | query :删除条件,通常使用 QueryWrapper |
updateById(T entity) | 根据 ID 更新数据 | entity :包含更新字段的实体类对象 |
update(Wrapper<T> updateWrapper) | 根据条件更新数据 | updateWrapper :更新条件,通常使用 UpdateWrapper |
list() | 查询所有数据 | 无(无需参数,查询所有记录) |
list(Wrapper<T> query) | 根据条件查询数据 | query :查询条件,通常使用 QueryWrapper |
getById(Serializable id) | 根据 ID 查询数据 | id :要查询的记录的主键 ID |
count() | 查询数据条数 | 无(返回数据库中记录的总数) |
count(Wrapper<T> query) | 根据条件查询数据条数 | query :查询条件,通常使用 QueryWrapper |
getOne(Wrapper<T> query) | 根据条件查询一条数据 | query :查询条件,通常使用 QueryWrapper |
T
:表示一个实体类对象类型。例如,User
、Order
等。Serializable
:表示可以序列化的类型,通常是主键 ID。Wrapper<T>
:这是 MyBatis-Plus 提供的一个条件构造器类,常用的有QueryWrapper
(用于查询条件)和UpdateWrapper
(用于更新条件)。通过Wrapper
,你可以构建更加复杂的查询或更新条件,我们后续会详细讲解
为了测试 Service 层的功能,我们创建一个新的测试类 UserServiceTest
。
文件路径: src/test/java/com/example/mpstudy/service/UserServiceTest.java
1 | package com.example.mpstudy.service; |
1
2
3
4
5
6
7
8
9
-- ==> Preparing: SELECT id,name,age,email,version,is_deleted,gmt_create,gmt_modified FROM tb_user WHERE id=?
-- ==> Parameters: 5(Long)
-- <== Total: 1
getById 查询结果: UserDO(id=5, name=Billie, age=24, email=test5@baomidou.com, ...)
-- ==> Preparing: SELECT id,name,age,email,version,is_deleted,gmt_create,gmt_modified FROM tb_user
-- ==> Parameters:
-- <== Total: 5
list 查询到的总数: 5
2.2.4. 批量操作 (saveBatch
, updateBatchById
)
IService
也提供了高效的批量操作方法,它会在底层优化 SQL 的执行(例如,通过 Batch
模式),远比我们自己循环调用 save
或 update
要高效。
1 |
|
1
2
3
4
-- ==> Preparing: INSERT INTO tb_user ( id, name, age, email ) VALUES ( ?, ?, ?, ? )
-- ==> Parameters: 1826543997233205249(Long), BatchUser1(String), 25(Integer), b1@example.com(String)
-- ==> Parameters: 1826543997233205250(Long), BatchUser2(String), 26(Integer), b2@example.com(String)
批量新增是否成功: true
2.2.5. saveOrUpdate
方法详解
saveOrUpdate
是一个非常智能的方法,它可以根据实体对象的主键(ID)是否存在来自动判断是执行 插入 还是 更新 操作。
- 如果实体对象的 ID 为
null
,则执行insert
。 - 如果实体对象的 ID 不为
null
,则执行updateById
。
1 | // UserServiceTest.java |
1
2
3
4
5
6
7
8
9
-- ==> Preparing: INSERT INTO tb_user ( id, name, age, email ) VALUES ( ?, ?, ?, ? )
-- ==> Parameters: 1826545199839973378(Long), NewOrUpdateUser(String), 40(Integer), nou@example.com(String)
-- <== Updates: 1
插入操作是否成功: true, 用户ID: 1826545199839973378
-- ==> Preparing: UPDATE tb_user SET name=?, age=?, email=?, version=?, is_deleted=?, gmt_create=?, gmt_modified=? WHERE id=?
-- ==> Parameters: null, 41(Integer), null, null, null, null, null, 1826545199839973378(Long)
-- <== Updates: 1
更新操作是否成功: true
2.3. 自定义接口方法
尽管 Mybatis-Plus 提供的通用 BaseMapper
和 IService
已经能覆盖绝大多数单表操作,但在复杂的业务场景中,我们仍然需要编写自定义的 SQL,例如多表 JOIN
查询、复杂的统计或调用数据库函数等。
Mybatis-Plus 的一个核心优势在于它**“只做增强,不做改变”**。这意味着,我们可以无缝地回归到原生 MyBatis 的开发模式,定义自己的 Mapper 方法,并通过 XML 文件或注解来编写对应的 SQL 语句。
2.3.1. [实践] 自定义 Mapper 接口方法
接下来,我们将为 UserMapper
添加一个自定义方法 selectByName
,用于根据姓名查询用户信息,并为其编写对应的 XML 实现。
第一步:在 UserMapper
接口中定义抽象方法
文件路径: src/main/java/com/example/mpstudy/mapper/UserMapper.java
1 | package com.example.mpstudy.mapper; |
最佳实践: 当 Mapper 方法有多个参数时,强烈建议使用 MyBatis 提供的 @Param("...")
注解为每个参数命名。这能让 XML 文件中的 SQL 通过名称(如 #{name}
)清晰地引用到参数,避免因参数顺序问题导致的错误。
第二步:创建 Mapper XML 映射文件
我们需要在 resources
目录下创建一个与 UserMapper
接口对应的 XML 文件来存放我们的 SQL 语句。
文件路径: src/main/resources/mapper/UserMapper.xml
1 |
|
检查配置: 请确保您的 application.yml
文件中配置了 mybatis-plus.mapper-locations
属性,以便 Mybatis-Plus 能够找到您编写的 XML 文件。mybatis-plus.mapper-locations: classpath*:/mapper/**/*.xml
第三步:编写单元测试进行验证
现在,我们可以在 UserMapperTest
中调用这个新的自定义方法。
文件路径: src/test/java/com/example/mpstudy/mapper/UserMapperTest.java
1 | // UserMapperTest.java (添加新的测试方法) |
1
2
3
4
5
6
-- ==> Preparing: SELECT * FROM tb_user WHERE name = ?
-- ==> Parameters: Tom(String)
-- <== Total: 1
----- 开始执行自定义方法测试 -----
查询到的 Tom 的信息: UserDO(id=3, name=Tom, age=28, email=test3@baomidou.com, version=1, isDeleted=0, gmtCreate=..., gmtModified=...)
----- 自定义方法测试执行完毕 -----
通过以上步骤,我们成功地在 Mybatis-Plus 的体系中集成了自定义的 SQL 查询,这证明了 MP 的高度灵活性和兼容性。对于任何通用方法无法满足的复杂需求,您都可以放心地使用这种方式来解决。
第三章: [进阶] 从实体映射到复杂关联查询
摘要: 本章是 Mybatis-Plus 从入门到精通的关键。我们将首先掌握如何通过注解精准控制实体与表的映射关系;随后,深入学习 MP 的灵魂——条件构造器(Wrapper),用纯 Java 代码构建任意复杂的单表动态查询;最后,我们将回归 MyBatis 的 XML 精髓,解决 Wrapper 难以处理的多表 JOIN
及一对多、多对多等复杂关联查询场景。
3.1. 实体与表映射注解
在深入学习查询之前,我们必须先打好地基——确保我们的 Java 实体类能够精准地与数据库表结构对应起来。虽然 Mybatis-Plus 提供了强大的自动映射能力,但在实际项目中,类名与表名、属性名与字段名不一致的情况非常普遍。本节将深入讲解如何通过注解来解决这些映射问题。
3.1.1. 自动映射规则回顾
Mybatis-Plus 默认遵循“驼峰与下划线”的自动映射规则,这得益于其内置的 map-underscore-to-camel-case
配置默认为 true
。
- 表名映射: 实体类名
UserDO
会被自动映射为表名user_do
。 - 字段映射: 属性名
gmtCreate
会被自动映射为字段名gmt_create
。
正是因为有此规则,在前面的章节中,我们的 UserDO
即使不加任何注解也能正常工作(如果我们把表名和字段名都改成下划线格式)。但当默认规则不满足需求时,就需要手动配置了。
3.1.2. 表映射: @TableName
当实体类名与表名的映射不符合默认规则时(例如,类名为 UserDO
,而表名为 tb_user
),就需要使用 @TableName
注解来手动指定。
文件路径: src/main/java/com/example/mpstudy/domain/UserDO.java
1 | package com.example.mpstudy.domain; |
3.1.3. 字段映射: @TableField
@TableField
是一个功能强大的注解,用于处理实体属性与表字段之间的各种映射问题。
1. 字段名不匹配
当属性名和字段名的映射关系不符合默认规则时(例如,属性为 email
,但数据库字段为 user_email
),可以使用其 value
属性来指定。
1 | // UserDO.java |
2. 属性在表中不存在 (非表字段)
有时我们希望在实体类中定义一些不与数据库表字段对应的属性,例如用于临时计算或前端展示。可以使用 exist = false
来标记,告诉 MP 忽略这个属性。
1 | // UserDO.java |
1
2
3
4
5
-- 不加 @TableField(exist = false) 时,查询SQL会因试图查询不存在的列而报错:
-- SELECT id, name, ..., user_role FROM tb_user
-- 添加 @TableField(exist = false) 后,生成的查询SQL会自动忽略该字段:
-- SELECT id, name, ... FROM tb_user
3. 控制字段是否参与查询
对于一些敏感信息(如密码)或大字段(如文章内容),我们可能希望在常规列表查询中默认不返回它们,以提高性能和安全性。可以使用 select = false
来实现。
1 | // UserDO.java |
1
2
3
4
5
-- userMapper.selectList(null) 生成的SQL将不包含 password 字段:
-- SELECT id,name,age,email,version,is_deleted,gmt_create,gmt_modified FROM tb_user
-- 但 userMapper.selectById(1L) 仍然会查询所有字段,包括 password:
-- SELECT id,name,age,email,version,is_deleted,gmt_create,gmt_modified,password FROM tb_user WHERE id=?
3.1.4. 主键映射: @TableId
与主键生成策略 (IdType
)
@TableId
注解专门用于标识实体类中的主键属性。MP 默认会将名为 id
的属性视为主键,但显式使用 @TableId
是更规范的做法。它最重要的功能是可以通过 type
属性指定主键的生成策略。
IdType 枚举值 | 描述 | 适用场景 |
---|---|---|
AUTO | 数据库 ID 自增。将主键生成交由数据库的自增列处理。 | 本项目选用,兼容性好,便于测试。 |
ASSIGN_ID | 雪花算法。MP 默认策略,生成一个全局唯一的 Long 类型 ID。 | 分布式系统,需要全局唯一 ID 的场景。 |
INPUT | 用户手动输入。ID 需要由开发者在插入前手动设置。 | 业务主键明确,或由其他服务生成 ID 的场景。 |
ASSIGN_UUID | UUID。生成一个随机的 32 位字符串 ID,无序。 | 主键为 String 类型,需要唯一性的场景。 |
NONE | 无策略。未设置主键类型,会跟随全局配置。 | 不推荐单独使用。 |
在我们的 UserDO
中,已经根据您的要求配置为了 IdType.AUTO
。
文件路径: src/main/java/com/example/mpstudy/domain/UserDO.java
1 | // UserDO.java |
3.2. 条件构造器 Wrapper 详解
掌握了实体与表的映射关系后,我们便可以开始构建动态的、复杂的查询。虽然 BaseMapper
提供的 selectByMap
能实现简单的 AND
等值查询,但面对 LIKE
、>
、IN
、OR
等更丰富的查询逻辑时则无能为力。
为了解决这一痛点,Mybatis-Plus 提供了其设计的精髓——条件构造器(Wrapper)。它允许我们使用纯 Java 代码,以一种类型安全、可维护的方式构建任意复杂的查询条件,从而彻底告别手写 XML 中的动态 SQL。
重要信息: Wrapper 虽然简便,但遇上更加复杂的 Sql 我还是更乐意采取 Mybatis 的写法,复杂的Wrapper查询混杂在业务代码中可读性绝对比 xml 要差得多,所以我们介绍方面也只会介绍常见的,复杂的我们会跳过
3.2.1. QueryWrapper
vs LambdaQueryWrapper
(核心选择)
Wrapper 主要有两种实现:QueryWrapper
和 LambdaQueryWrapper
。
1 | // 使用字符串指定列名 "name" |
1 | // 使用方法引用 UserDO::getName |
3.2.2. Wrapper:UpdateWrapper
与 LambdaUpdateWrapper
QueryWrapper
专注于构建 SELECT
和 DELETE
语句的 WHERE
条件。但当我们执行 UPDATE
操作时,不仅需要 WHERE
条件,还需要指定 SET
子句(即要更新哪些字段以及更新成什么值)。
为此,Mybatis-Plus 提供了专门的 UpdateWrapper
和其 Lambda 版本 LambdaUpdateWrapper
。
核心区别: UpdateWrapper
在 QueryWrapper
的基础上,增加了 set()
和 setSql()
方法,用于动态构建 UPDATE
语句的 SET
部分。
1 | // 目标: 将名字为 "Jack" 的用户邮箱更新为 "new.jack@example.com" |
1 | // 目标: 将名字为 "Jack" 的用户邮箱更新为 "new.jack@example.com" |
一个更高级的用法 setSql()
: 当你需要执行如 age = age + 1
这样的 SQL 表达式时,set()
方法无法满足,这时可以使用 setSql()
。
1 | // 示例:将所有用户的年龄增加1岁 |
结论: 与查询时一样,在构建更新条件时,我们应 始终优先使用 LambdaUpdateWrapper
,以保证代码的类型安全和重构友好性。
3.2.3. Wrapper 家族总结
Mybatis-Plus 的 Wrapper 设计遵循了清晰的继承和分工。了解它们的家族关系可以帮助我们更好地选择和使用。
Wrapper
: 抽象顶级接口,定义了 Wrapper 的最基本规范。AbstractWrapper
: 核心抽象类,实现了大部分通用的WHERE
条件方法(如eq
,ne
,gt
,like
,in
,or
,orderBy
等)。QueryWrapper
和UpdateWrapper
都继承自它。QueryWrapper
: 专注于查询和删除。它继承了AbstractWrapper
的所有WHERE
条件方法,并增加了select()
方法来指定查询的字段(SELECT a, b, c
)。UpdateWrapper
: 专注于更新。它同样继承了AbstractWrapper
的所有WHERE
条件方法,并额外提供了set()
和setSql()
方法来构建SET
子句。
下面是一个清晰的对比表格,帮助你快速回顾和选择:
Wrapper 类型 | 主要用途 | 核心独有方法 | Lambda 版本 | 推荐使用场景 |
---|---|---|---|---|
QueryWrapper | 构建 SELECT 和 DELETE 语句的 WHERE 条件 | select(...) | LambdaQueryWrapper | 所有查询 (select... ) 和删除 (delete ) 操作。 |
UpdateWrapper | 构建 UPDATE 语句的 SET 和 WHERE 条件 | set(...) , setSql(...) | LambdaUpdateWrapper | 所有更新 (update ) 操作。 |
AbstractWrapper | 作为基类提供通用 WHERE 条件方法,一般不直接实例化使用 | (提供了所有通用的 WHERE 条件方法) | AbstractLambdaWrapper | 通常在编写自定义的通用方法时,可将其作为参数类型,以同时接收上述两种 Wrapper。 |
3.2.4. 基础条件查询 (eq
, ne
, gt
, lt
, between
)
接下来,我们将通过单元测试来实践最常用的一组查询方法。首先,在 src/test/java/com/example/mpstudy/
目录下创建一个新的测试类 WrapperTest
。
文件路径: src/test/java/com/example/mpstudy/WrapperTest.java
1 | package com.example.mpstudy; |
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
-- 第一个查询
-- ==> Preparing: SELECT id,name,age,email,version,is_deleted,gmt_create,gmt_modified FROM tb_user WHERE (age > ? AND id <= ?)
-- ==> Parameters: 20(Integer), 4(Long)
--- 年龄大于20且ID小于等于4的用户 ---
UserDO(id=1, name=BatchUser1, age=35, email=b1@example.com, version=1, isDeleted=0, gmtCreate=2025-08-22T10:00:18, gmtModified=2025-08-22T10:01:53)
UserDO(id=2, name=BatchUser2, age=36, email=b2@example.com, version=1, isDeleted=0, gmtCreate=2025-08-22T10:00:18, gmtModified=2025-08-22T10:01:53)
UserDO(id=3, name=Tom, age=28, email=test3@baomidou.com, version=1, isDeleted=0, gmtCreate=2025-08-22T10:00:18, gmtModified=2025-08-22T10:00:18)
UserDO(id=4, name=Sandy, age=21, email=test4@baomidou.com, version=1, isDeleted=0, gmtCreate=2025-08-22T10:00:18, gmtModified=2025-08-22T10:00:18)
-- 第二个查询
-- ==> Preparing: SELECT id,name,age,email,version,is_deleted,gmt_create,gmt_modified FROM tb_user WHERE (name = ?)
-- ==> Parameters: Tom(String)
--- 姓名为Tom的用户 ---
UserDO(id=3, name=Tom, age=28, email=test3@baomidou.com, ...)
-- 第三个查询
-- ==> Preparing: SELECT id,name,age,email,version,is_deleted,gmt_create,gmt_modified FROM tb_user WHERE (age BETWEEN ? AND ?)
-- ==> Parameters: 20(Integer), 30(Integer)
--- 年龄在20-30岁之间的用户 ---
UserDO(id=2, name=Jack, age=20, email=test2@baomidou.com, ...)
UserDO(id=3, name=Tom, age=28, email=test3@baomidou.com, ...)
UserDO(id=4, name=Sandy, age=21, email=test4@baomidou.com, ...)
UserDO(id=5, name=Billie, age=24, email=test5@baomidou.com, ...)
3.2.5. 模糊与判空查询 (like
, isNull
,isNotNull
)
模糊查询和空值判断是日常开发中不可或缺的查询类型。
1 |
|
1
2
3
4
5
6
7
8
9
10
11
-- 第一个查询 (like)
-- ==> Preparing: SELECT ... FROM tb_user WHERE (name LIKE ?)
-- ==> Parameters: %m%(String)
--- 姓名中包含 'm' 的用户 ---
UserDO(id=3, name=Tom, age=28, email=test3@baomidou.com, version=1, isDeleted=0, gmtCreate=2025-08-22T10:00:18, gmtModified=2025-08-22T10:00:18)
-- 第二个查询 (isNull)
-- ==> Preparing: SELECT ... FROM tb_user WHERE (email IS NULL)
-- ==> Parameters:
--- 邮箱地址为空的用户 ---
(根据我们的初始数据,没有邮箱为空的记录,所以这里返回空列表,符合预期)
3.2.6. IN 与子查询 (in
)
当查询条件涉及一个集合或另一个查询的结果时,就需要用到 IN
1 |
|
1
2
3
4
5
6
7
8
9
10
11
-- 第一个查询 (in)
-- ==> Preparing: SELECT ... FROM tb_user WHERE (id IN (?,?,?))
-- ==> Parameters: 1(Long), 3(Long), 5(Long)
--- ID为 1, 3, 5 的用户 ---
UserDO(id=1, name=Jone, age=18, email=test1@baomidou.com, ...)
UserDO(id=3, name=Tom, age=28, email=test3@baomidou.com, ...)
UserDO(id=5, name=Billie, age=24, email=test5@baomidou.com, ...)
[UserDO(id=1, name=Jone, age=18, email=test1@baomidou.com, version=1, isDeleted=0, gmtCreate=2025-08-22T14:23:04, gmtModified=2025-08-22T14:23:04), UserDO(id=2, name=Jack, age=20, email=test2@baomidou.com, version=1, isDeleted=0, gmtCreate=2025-08-22T14:23:04, gmtModified=2025-08-22T14:23:04), UserDO(id=3, name=Tom, age=28, email=test3@baomidou.com, version=1, isDeleted=0, gmtCreate=2025-08-22T14:23:04, gmtModified=2025-08-22T14:23:04), UserDO(id=4, name=Sandy, age=21, email=test4@baomidou.com, version=1, isDeleted=0, gmtCreate=2025-08-22T14:23:04, gmtModified=2025-08-22T14:23:04), UserDO(id=5, name=Billie, age=24, email=test5@baomidou.com, version=1, isDeleted=0, gmtCreate=2025-08-22T14:23:04, gmtModified=2025-08-22T14:23:04)]
3.2.7. 排序与分组 (orderBy
, groupBy
)
排序和分组是数据分析和展示中非常常见的操作。
1 | // WrapperTest.java |
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
-- 第一个查询 (orderBy)
-- ==> Preparing: SELECT ... FROM tb_user ORDER BY age DESC, id ASC
-- ==> Parameters:
--- 按年龄降序、ID升序排序 ---
UserDO(id=3, name=Tom, age=28, email=test3@baomidou.com, ...)
UserDO(id=5, name=Billie, age=24, email=test5@baomidou.com, ...)
UserDO(id=4, name=Sandy, age=21, email=test4@baomidou.com, ...)
UserDO(id=2, name=Jack, age=20, email=test2@baomidou.com, ...)
UserDO(id=1, name=Jone, age=18, email=test1@baomidou.com, ...)
-- 第二个查询 (groupBy)
-- ==> Preparing: SELECT age, COUNT(*) as count FROM tb_user GROUP BY age HAVING count > 1
-- ==> Parameters:
--- 按年龄分组,统计人数大于1的年龄段 ---
(根据我们的初始数据,每个年龄段都只有1人,所以这里返回空列表,符合预期)
3.2.8. 逻辑连接与嵌套 (and
, or
, nested
)
默认情况下,多个查询条件之间使用 AND
连接。当需要 OR
或者更复杂的括号嵌套逻辑时,MP 同样提供了简洁的实现方式。
1 | // WrapperTest.java |
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
-- 第一个查询 (or)
-- ==> Preparing: SELECT ... FROM tb_user WHERE (name LIKE ? OR age > ?)
-- ==> Parameters: J%(String), 25(Integer)
--- 姓名以 'J' 开头 或 年龄大于 25 的用户 ---
UserDO(id=1, name=Jone, age=18, email=test1@baomidou.com, ...)
UserDO(id=2, name=Jack, age=20, email=test2@baomidou.com, ...)
UserDO(id=3, name=Tom, age=28, email=test3@baomidou.com, ...)
-- 第二个查询 (nested)
-- ==> Preparing: SELECT ... FROM tb_user WHERE ((age < ? AND email LIKE ?) OR name = ?)
-- ==> Parameters: 25(Integer), %test%(String), Tom(String)
--- (年龄<25且邮箱含test) 或 (姓名为Tom) 的用户 ---
UserDO(id=1, name=Jone, age=18, email=test1@baomidou.com, ...)
UserDO(id=2, name=Jack, age=20, email=test2@baomidou.com, ...)
UserDO(id=3, name=Tom, age=28, email=test3@baomidou.com, ...)
UserDO(id=4, name=Sandy, age=21, email=test4@baomidou.com, ...)
UserDO(id=5, name=Billie, age=24, email=test5@baomidou.com, ...)
3.2.9. 结果集字段筛选 (select
)
默认情况下,Mybatis-Plus 会查询实体对应的所有字段(SELECT * ...
)。但在很多场景下,我们可能只需要其中的几个字段。只查询必要的字段是优化 SQL 的一个重要手段,可以减少网络 I/O 和内存占用。
LambdaQueryWrapper
的 select
方法允许我们精准地指定需要查询返回的字段。
1 | // WrapperTest.java |
1
2
3
4
5
6
7
8
-- ==> Preparing: SELECT id,name,email FROM tb_user
-- ==> Parameters:
--- 只查询 ID, 姓名, 邮箱 ---
UserDO(id=1, name=Jone, age=null, email=test1@baomidou.com, version=null, isDeleted=null, gmtCreate=null, gmtModified=null)
UserDO(id=2, name=Jack, age=null, email=test2@baomidou.com, version=null, isDeleted=null, gmtCreate=null, gmtModified=null)
UserDO(id=3, name=Tom, age=null, email=test3@baomidou.com, version=null, isDeleted=null, gmtCreate=null, gmtModified=null)
UserDO(id=4, name=Sandy, age=null, email=test4@baomidou.com, version=null, isDeleted=null, gmtCreate=null, gmtModified=null)
UserDO(id=5, name=Billie, age=null, email=test5@baomidou.com, version=null, isDeleted=null, gmtCreate=null, gmtModified=null)
观察结果: 可以看到,返回的 UserDO
对象中,只有我们指定的 id
, name
, email
字段有值,其他未查询的字段(如 age
, version
等)均为 null
。
3.3. 复杂关联查询 (XML 实现)
我们已经掌握了 Wrapper
在单表查询中的强大威力。但对于多表 JOIN
,特别是需要将结果映射成嵌套对象(如一个部门包含一个用户列表)的“一对多”或“一对一”场景,Wrapper
的能力就有所局限。
在这种场景下,最成熟、最优雅的解决方案是回归并利用 MyBatis 原生、功能最强大的 XML <resultMap>
。
3.3.1. 准备工作:构建关联模型
为了实践关联查询,我们将构建一个经典的“部门-用户”业务场景。
第一步:数据库准备
执行以下 SQL,创建 tb_department
表,并为 tb_user
表添加 department_id
关联字段。
1 | -- 创建部门表 |
文件路径:src/main/java/com/example/mpstudy/domain/DepartmentDO.java
1 | package com.example.mpstudy.domain; |
第二步:创建视图对象 (VO)
为了封装关联查询的返回结果,我们创建两个 VO (View Object)。
1. UserVO
(用于一对一): 封装“查询用户及其所属部门”的结果。
文件路径: src/main/java/com/example/mpstudy/domain/vo/UserVO.java
1 | package com.example.mpstudy.domain.vo; |
2. DepartmentVO
(用于一对多): 封装“查询部门及其下属所有用户”的结果。
文件路径: src/main/java/com/example/mpstudy/domain/vo/DepartmentVO.java
1 | package com.example.mpstudy.domain.vo; |
3.3.2. [实践] 一对一关联查询 (使用 <association>
)
目标:查询一个用户,并将其所属的部门信息一并查出,封装到 UserVO
中。
第一步:在 UserMapper
中定义方法
文件路径: src/main/java/com/example/mpstudy/mapper/UserMapper.java
1 | package com.example.mpstudy.mapper; |
第二步:在 UserMapper.xml
中配置映射
文件路径: src/main/resources/mapper/UserMapper.xml
1 |
|
第三步:编写单元测试
文件路径: src/test/java/com/example/mpstudy/mapper/UserMapperTest.java
1 | // UserMapperTest.java |
1
2
3
4
5
-- ==> Preparing: SELECT u.id AS user_id, ... d.id AS dept_id, ... FROM tb_user u LEFT JOIN tb_department d ON u.department_id = d.id WHERE u.id = ?
-- ==> Parameters: 1(Long)
-- <== Total: 1
查询到的用户信息: Jone
该用户所属的部门: DepartmentDO(id=1, name=研发部)
3.3.3. [实践] 一对多关联查询 (使用 <collection>
)
目标:查询一个部门,并将其下属的所有用户信息一并查出,封装到 DepartmentVO
中。
第一步:创建 DepartmentMapper
接口及方法
文件路径: src/main/java/com/example/mpstudy/mapper/DepartmentMapper.java
1 | package com.example.mpstudy.mapper; |
第二步:编写 DepartmentMapper.xml
文件路径: src/main/resources/mapper/DepartmentMapper.xml
1 |
|
第三步:编写单元测试
文件路径: src/test/java/com/example/mpstudy/mapper/DepartmentMapperTest.java
1 | package com.example.mpstudy.mapper; |
1
2
3
4
5
6
7
8
-- ==> Preparing: SELECT d.id AS dept_id, ... u.id AS user_id, ... FROM tb_department d LEFT JOIN tb_user u ON d.id = u.department_id WHERE d.id = ?
-- ==> Parameters: 1(Long)
-- <== Total: 3
部门名称: 研发部
该部门下的用户列表:
UserDO(id=1, name=Jone, age=18, email=test1@baomidou.com, ...)
UserDO(id=2, name=Jack, age=20, email=test2@baomidou.com, ...)
UserDO(id=3, name=Tom, age=28, email=test3@baomidou.com, ...)
3.3.4. [面试题] 在 Mybatis-Plus 中如何优雅地处理多表关联查询?
在使用 Mybatis-Plus 时,如果遇到需要多表 JOIN 的复杂查询,你的技术方案是什么?
我的方案会根据查询结果的复杂度来选择。
如果只是简单的 JOIN,并且返回的是一个扁平化的结果(即所有字段都放在一个没有嵌套对象的VO中),那么最直接的方式就是自定义一个 Mapper 方法,并在 XML 中编写对应的 JOIN SQL,然后定义一个 ResultMap 来完成结果集到 VO 的映射。
如果需要处理“一对一”或“一对多”的嵌套对象映射,就像刚才的“部门-用户”场景,那么最佳实践是使用 MyBatis 原生的 <resultMap>
,并结合 <association>
(用于一对一) 和 <collection>
(用于一对多) 标签。这种方式声明清晰,能够让 MyBatis 自动完成复杂的对象组装,是处理这类需求最优雅、最标准的方式。
我会尽量避免使用 Wrapper 去构造非常复杂的多表 JOIN。虽然技术上可能实现,但这会让 Java 代码变得臃肿且难以阅读,违背了 Wrapper 专注于简化单表操作的设计初衷。让 Wrapper 负责单表,让 XML 负责多表,是职责最清晰的分工。
第四章:[高级应用] 核心特性与实用工具
摘要: 在掌握了 Mybatis-Plus 的查询能力之后,本章我们将深入探索一系列在企业级开发中至关重要的高级特性与实用工具。我们将学习如何通过乐观锁、逻辑删除、自动填充等功能,让数据模型更加健壮和智能,并利用 ActiveRecord 模式和 SimpleQuery 工具类等技巧,进一步提升我们的开发效率和代码优雅度。
在本章中,我们将循序渐进,探索 Mybatis-Plus 在企业级应用中的强大功能:
- 首先,我们将学习 ActiveRecord 模式,体验一种让实体类“拥有生命”的编程范式。
- 接着,我们将掌握 分页查询,学习如何配置分页并快速应用到项目中
- 最后,我们将掌握 SimpleQuery 工具类,学习如何用一行代码完成对查询结果集的常见转换操作。
4.1. ActiveRecord 模式
在前面的章节中,我们已经习惯了通过 Mapper
或 Service
来操作数据 (mapper.selectById(1L)
)。现在,我们将探索一种截然不同的、更加面向对象的编程范式——ActiveRecord (简称 AR)。
痛点背景: 在一些业务逻辑非常简单的场景下,Controller
-> Service
-> Mapper
的标准分层调用链有时会显得过于繁琐。例如,仅仅是根据ID查询一个对象,就需要经过多层转发。我们不禁会想:这个调用链是否还有简化的空间?
解决方案: Mybatis-Plus 引入了 ActiveRecord 模式,其核心思想是让实体类自身具备 CRUD 的能力。通过让实体类继承 Model<T>
,我们可以直接在实体对象上调用 insert()
, selectById()
, updateById()
等方法,代码将变得极其简洁。
第一步:修改 UserDO
继承 Model<UserDO>
这是开启 AR 模式的唯一要求。
文件路径: src/main/java/com/example/mpstudy/domain/UserDO.java
1 | package com.example.mpstudy.domain; |
第二步:编写单元测试
为了演示 AR 模式,我们创建一个新的测试类 ActiveRecordTest
。
文件路径: src/test/java/com/example/mpstudy/ActiveRecordTest.java
1 | package com.example.mpstudy; |
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
-- 1. 新增操作
-- ==> Preparing: INSERT INTO tb_user ( name, age, email ) VALUES ( ?, ?, ? )
-- ==> Parameters: AR User(String), 35(Integer), ar@example.com(String)
-- <== Updates: 1
新增是否成功: true, 回填的ID为: 6
-- 2. 查询操作
-- ==> Preparing: SELECT ... FROM tb_user WHERE id=?
-- ==> Parameters: 6(Long)
-- <== Total: 1
查询到的用户: UserDO(id=6, name=AR User, age=35, email=ar@example.com, ...)
-- 3. 更新操作
-- ==> Preparing: UPDATE tb_user SET name=?, age=?, email=? WHERE id=?
-- ==> Parameters: AR User Updated(String), 35(Integer), ar@example.com(String), 6(Long)
-- <== Updates: 1
更新是否成功: true
-- 4. 删除操作
-- ==> Preparing: DELETE FROM tb_user WHERE id=?
-- ==> Parameters: 6(Long)
-- <== Updates: 1
删除是否成功: true
4.2. 分页查询: PaginationInnerInterceptor
在结束了 ActiveRecord 模式的探讨之后,我们来解决另一个在 Web 开发中无处不在的核心需求:分页查询。几乎所有的列表页面都需要分页,以避免一次性加载海量数据导致的性能问题和糟糕的用户体验。
痛点背景: 在原生 MyBatis 中实现分页是一件相当繁琐的事情。开发者通常需要手动编写两条 SQL:一条使用 LIMIT
关键字查询当前页的数据,另一条使用 SELECT COUNT(*)
查询总记录数。这两条 SQL 必须保持查询条件的一致性,维护起来非常不便且容易出错。
解决方案: Mybatis-Plus 提供了PaginationInnerInterceptor
分页插件,它能完美地解决这个问题。我们只需要通过简单的配置启用它,MP 就会自动拦截我们的查询请求,并以“无感”的方式将其改造为物理分页查询。它会自动帮我们完成两件事:
- 在原始 SQL 后拼接
LIMIT
子句。 - 在执行数据查询前,自动发送一条
COUNT
查询以获取总记录数。
第一步:配置分页插件
要启用分页功能,我们必须在 MybatisPlusInterceptor
中注册 PaginationInnerInterceptor
。
文件路径: src/main/java/com/example/mpstudy/config/MybatisPlusConfig.java
1 | package com.example.mpstudy.config; |
第二步:在单元测试中使用分页
配置完成后,我们就可以在代码中使用分页功能了。核心是使用 Page
对象来承载分页参数和查询结果。
文件路径: src/test/java/com/example/mpstudy/mapper/UserMapperTest.java
1 | // UserMapperTest.java (添加新的测试方法) |
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
-- MP 插件自动执行的第一条 SQL:查询总记录数
-- ==> Preparing: SELECT COUNT(*) AS total FROM tb_user
-- ==> Parameters:
-- <== Total: 1
-- MP 插件自动执行的第二条 SQL:查询分页数据
-- ==> Preparing: SELECT id,name... FROM tb_user LIMIT ?
-- ==> Parameters: 2(Long)
-- <== Total: 2
----- 分页查询结果 -----
总记录数: 5
总页数: 3
当前页码: 1
每页条数: 2
当前页数据:
UserDO(id=1, name=Jone, age=18, email=test1@baomidou.com, ...)
UserDO(id=2, name=Jack, age=20, email=test2@baomidou.com, ...)
4.3. SimpleQuery 工具类
在学习了 ActiveRecord 模式简化数据操作过程之后,我们接着来看一个能极大简化数据结果处理的利器——SimpleQuery
工具类。
痛点背景: 在业务开发中,我们经常会遇到这样的场景:通过 selectList
查询出一个 List<UserDO>
集合后,我们的目标并不是这个完整的对象列表,而是:
- 一个只包含所有用户 ID 的
List<Long>
。 - 一个以用户 ID 为 Key、用户对象为 Value 的
Map<Long, UserDO>
,以便进行快速查找。 - 一个按年龄分组的
Map<Integer, List<UserDO>>
,以便进行分类处理。
在没有 SimpleQuery
的情况下,我们需要手动编写繁琐的 Java Stream API 代码(如 .stream().map(...).collect(...)
)来完成这些转换,代码显得冗长且重复。
解决方案: Mybatis-Plus 贴心地提供了 SimpleQuery
工具类,它封装了这些最常见的结果集处理逻辑,让我们能用一行代码优雅地完成上述转换。
代码实践
为了演示 SimpleQuery
的用法,我们创建一个新的测试类。
文件路径: src/test/java/com/example/mpstudy/SimpleQueryTest.java
1 |
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
-- SimpleQuery.list() 执行的SQL
-- ==> Preparing: SELECT name FROM tb_user
-- <== Total: 5
--- 所有用户的姓名列表 ---
[Jone, Jack, Tom, Sandy, Billie]
-- SimpleQuery.keyMap() 执行的SQL
-- ==> Preparing: SELECT id,name,age... FROM tb_user
-- <== Total: 5
--- ID -> 用户的Map ---
{1=UserDO(id=1, name=Jone,...), 2=UserDO(id=2, name=Jack,...), 3=UserDO(id=3, name=Tom,...), 4=UserDO(id=4, name=Sandy,...), 5=UserDO(id=5, name=Billie,...)}
ID为3的用户信息: UserDO(id=3, name=Tom, age=28, email=test3@baomidou.com, ...)
-- SimpleQuery.map() 执行的SQL
-- ==> Preparing: SELECT id,name FROM tb_user
-- <== Total: 5
--- ID -> 姓名的Map ---
{1=Jone, 2=Jack, 3=Tom, 4=Sandy, 5=Billie}
分组查询实践 (group
)
SimpleQuery.group()
是一个特别有用的功能,可以替代 stream().collect(Collectors.groupingBy(...))
。
1 | // SimpleQueryTest.java |
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
-- 插入测试数据
-- ==> Preparing: INSERT INTO tb_user ( name, age, email ) VALUES ( ?, ?, ? )
-- ==> Parameters: Tom Jr.(String), 28(Integer), tomjr@example.com(String)
-- SimpleQuery.group() 执行的SQL
-- ==> Preparing: SELECT id,name,age... FROM tb_user
-- <== Total: 6
--- 按年龄分组的用户列表 ---
年龄: 18 -> [UserDO(id=1, name=Jone, age=18, ...)]
年龄: 20 -> [UserDO(id=2, name=Jack, age=20, ...)]
年龄: 21 -> [UserDO(id=4, name=Sandy, age=21, ...)]
年龄: 24 -> [UserDO(id=5, name=Billie, age=24, ...)]
年龄: 28 -> [UserDO(id=3, name=Tom, age=28, ...), UserDO(id=6, name=Tom Jr., age=28, ...)]
-- 清理测试数据
-- ==> Preparing: DELETE FROM tb_user WHERE id=?
-- ==> Parameters: 6(Long)
第五章:[高级] 企业级核心特性
摘要: 本章将深入探讨 Mybatis-Plus 提供的一系列旨在提升数据健壮、安全性与可维护性的企业级核心特性。我们将从数据保护的视角出发,依次学习 逻辑删除、乐观锁 和 公共字段自动填充 的实现原理与最佳实践。最后,我们将解决企业应用中常见的 动态数据源 需求,学习如何通过 MP 优雅地实现读写分离或多租户数据隔离。
在本章中,我们将循序渐进,探索 Mybatis-Plus 在企业级应用中的强大功能:
- 首先,我们将聚焦于 逻辑删除,学习如何安全地“删除”数据,同时保留其历史追溯性。
- 接着,我们将深入 乐观锁 机制,解决高并发场景下的数据一致性问题。
- 然后,我们将掌握 公共字段自动填充,将繁琐的审计字段(如创建/更新时间)交由框架自动管理。
- 最后,我们将挑战 动态数据源 的配置,为应用的水平扩展(如读写分离)打下坚实基础。
5.1. 逻辑删除
在掌握了基础的增删改查之后,我们必须重新审视一个基础却至关重要的操作——删除。在真实的生产环境中,直接从数据库中物理删除(DELETE FROM ...
)记录通常是一种被严格禁止的高危行为。
痛点背景: 设想一个电商平台的业务场景:某位客户在双十一期间购买了一件商品,但随后申请退货并注销了账户。如果我们采用物理删除,直接删除了该客户的订单记录,那么到了年底进行财务审计和销售数据分析时,这笔曾经真实发生过的交易数据就彻底丢失了,这将直接导致报表不准确,甚至可能引发财务问题。数据一旦被物理删除,其业务价值和可追溯性便永久丧失。
解决方案: Mybatis-Plus 提供了极其优雅的 逻辑删除 (Logical Delete) 方案。其核心思想是,将删除操作从 DELETE
“偷换”为 UPDATE
。我们不在数据库中真正删除该行数据,而是通过更新一个特定的状态字段(例如 is_deleted
)来将其标记为“已删除”。对于业务代码而言,这个过程是完全透明的、无感的,我们调用的仍然是 deleteById()
等标准方法,但 Mybatis-Plus 插件会在底层自动将 SQL 语句进行转换。
这样做的好处是显而易见的:
- 数据安全: 数据实体始终保留在数据库中,杜绝了误删除导致的数据灾难。
- 可追溯性: 所有记录,无论状态如何,都可用于数据分析、审计和问题排查。
5.1.1. 实践:为项目集成逻辑删除
现在,让我们通过三个步骤,为我们的 tb_user
表集成逻辑删除功能。
第一步:修改数据库表结构
我们需要为 tb_user
表添加一个用于标记删除状态的字段。
这一步我们在创建库表的时候就做过了,这边再重复一遍以便完整流程
1 | -- 为用户表添加逻辑删除标志字段 |
设计规约: 我们遵循业界通用实践,使用 is_deleted
字段,类型为 TINYINT(1)
。0
代表未删除(有效状态),1
代表已删除(无效状态)。
第二步:修改实体类 (UserDO.java
)
在实体类中添加对应的属性,并使用 @TableLogic
注解来开启逻辑删除功能。
文件路径: src/main/java/com/example/mpstudy/domain/UserDO.java
1 | // ... |
第三步:编写单元测试进行验证
我们通过一个单元测试来直观地感受逻辑删除的“魔力”。
文件路径: src/test/java/com/example/mpstudy/AdvancedFeatureTest.java
(我们创建一个新的测试类来存放高级特性的测试)
1 | package com.example.mpstudy; |
1
2
3
4
5
6
7
8
9
10
11
12
----- 开始执行逻辑删除测试 -----
-- ==> Preparing: UPDATE tb_user SET is_deleted=1 WHERE id=? AND is_deleted=0
-- ==> Parameters: 5(Long)
-- <== Updates: 1
逻辑删除影响的行数: 1
----- 验证标准查询 -----
-- ==> Preparing: SELECT id,name,age,email,department_id,version,is_deleted,gmt_create,gmt_modified FROM tb_user WHERE id=? AND is_deleted=0
-- ==> Parameters: 5(Long)
-- <== Total: 0
逻辑删除后,再次查询用户(ID=5)的结果: null
----- 逻辑删除测试执行完毕 -----
结果分析:
- SQL 变形: 从输出可以清晰地看到,我们调用的
deleteById()
方法,最终生成的 SQL 并不是DELETE
,而是UPDATE tb_user SET is_deleted=1 WHERE ...
。 - 查询过滤: 验证查询时,Mybatis-Plus 自动在
WHERE
条件中拼接了AND is_deleted=0
,这保证了业务代码在不知不觉中已经过滤掉了所有被“删除”的数据。
5.1.2 全局配置 vs 实体配置
配置方法: 直接在实体类的逻辑删除字段上添加
1 |
优点
- 灵活性高: 每个实体可独立定义字段名与值,适用于不同删除规约。
- 代码即文档: 规则写在实体类中,一目了然。
缺点
- 代码重复: 若项目统一规约,每个实体都得重复写一次。
配置方法: 在 application.yml
中添加全局配置
文件路径:src/main/resources/application.yml
1 | mybatis-plus: |
优点
- 一处配置,全局生效: 统一标准的大型项目省时省力。
- 约定优于配置: 新实体无需额外注解即可自动支持逻辑删除。
缺点
- 灵活性低: 若个别表有特需字段或值,全局配置无法满足。
最佳实践: 推荐采用 全局配置 的方式。它能强制项目遵循统一的数据设计规约,提升代码的一致性和可维护性。只有当遇到不符合全局规约的特殊表时,才在对应实体上使用 @TableLogic
注解进行覆盖。
5.1.3. 逻辑删除下的数据恢复
既然数据只是被标记,那么“恢复”数据也就变得非常简单。我们只需要将 is_deleted
字段的值从 1
更新回 0
即可。
1 | // AdvancedFeatureTest.java (添加新的测试方法) |
🤔 思考一下
逻辑删除虽然极大地提升了数据安全性,但它也引入了一个经典问题:唯一索引(UNIQUE KEY)。
假设 tb_user
表的 email
字段上有一个唯一索引。当用户 Jone
(email
为 test1@baomidou.com
)被逻辑删除后,is_deleted
变为 1
。此时,如果一个新用户尝试使用相同的邮箱 test1@baomidou.com
进行注册,数据库层面会发生什么?我们应该如何设计表结构来解决这个问题?
5.1.4 本节小结
- 核心思想: 逻辑删除通过将
DELETE
操作转换为UPDATE
操作,实现了数据的“软删除”,极大地保障了生产数据的安全性和可追溯性。 - 实现方式: 可通过在实体字段上添加
@TableLogic
注解(灵活、局部)或在application.yml
中进行全局配置(统一、高效),推荐后者。 - 无感集成: 一旦配置成功,所有 Mybatis-Plus 内置的查询方法都会自动在
WHERE
子句中加入逻辑删除字段的过滤条件(如AND is_deleted = 0
),对业务代码完全透明。
5.2. 乐观锁
上一节,我们学习了如何通过逻辑删除保护数据免于“丢失”;本节,我们将探讨如何保护数据免于在高并发场景下被“写坏”。这是保障数据一致性的核心议题。
痛点背景: 想象一个典型的电商秒杀场景:一件热门商品的库存仅剩 1 件。在同一瞬间,用户 A 和用户 B 都看到了库存为 1 并同时点击了“购买”按钮。他们的请求几乎同时到达服务器。
- 时刻 1: A 的请求线程读取数据库,获取到商品库存为
1
。 - 时刻 2: B 的请求线程也读取数据库,获取到商品库存同样为
1
。 - 时刻 3: A 的线程执行扣减逻辑 (
1 - 1 = 0
),并将库存0
写入数据库,下单成功。 - 时刻 4: B 的线程也执行扣减逻辑 (
1 - 1 = 0
),并将库存0
写入数据库,也提示下单成功。
最终结果是,两个用户都成功下单,但库存却变成了 0
,系统出现了超卖!A 用户的更新操作,被 B 用户的更新操作无情地覆盖了,这就是经典的“更新丢失”问题。
解决方案: 为了解决这类问题,我们通常会引入“锁”的机制。但传统的数据库悲观锁(悲观锁是一种并发控制策略,它假定数据在处理过程中很可能被其他事务修改,所以在操作数据前先加锁,阻止其他事务对该数据进行修改,直到当前事务结束才释放锁 。)会长时间锁定数据行,导致其他线程阻塞,在高并发下性能极差。
因此,Mybatis-Plus 采纳了一种更为高效的 乐观锁 方案。它不依赖数据库的锁机制,而是通过在表中增加一个 version
(版本号) 字段来实现。其核心思想是:
- 读取数据时: 同时读取出当前的
version
值。 - 更新数据时: 在
UPDATE
语句的WHERE
条件中,额外增加一个version
值的匹配,即WHERE id = ? AND version = [读取时的version值]
。 - 同时,在
SET
子句中,将version
值加1
。
如果更新成功(影响行数为 1),说明在此期间没有其他线程修改过数据。如果更新失败(影响行数为 0),则意味着在我准备更新的这段时间里,有另一个线程已经修改了数据并增加了 version
值,导致 WHERE
条件不匹配。此时,当前更新操作就会失败,从而避免了覆盖他人的修改。
Mybatis-Plus 将这一整套复杂的流程,简化为了一个插件和一个注解,开发者几乎无需关心底层实现。
5.2.1. 实践:集成乐观锁功能
接下来,我们通过三个步骤为项目启用乐观锁。
第一步:修改数据库表结构
为 tb_user
表添加 version
字段,用于实现乐观锁。
1 | -- 为用户表添加 version 字段 |
设计规约: version
字段通常为 INT
或 BIGINT
类型,必须设置 NOT NULL
,并建议默认值为 1
,代表数据的第一版。
第二步:修改实体类 (UserDO.java
)
在实体类中添加 version
属性,并使用 @Version
注解标记。
文件路径: src/main/java/com/example/mpstudy/domain/UserDO.java
1 | // ... |
第三步:配置乐观锁插件
和分页插件一样,乐观锁功能也需要通过拦截器来启用。
文件路径: src/main/java/com/example/mpstudy/config/MybatisPlusConfig.java
1 | package com.example.mpstudy.config; |
5.2.2. 单元测试:模拟并发冲突
现在,万事俱备。我们将通过一个单元测试来模拟“更新丢失”的场景,并验证乐观锁是否能成功阻止它。
文件路径: src/test/java/com/example/mpstudy/AdvancedFeatureTest.java
1 | package com.example.mpstudy; |
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
----- 开始执行乐观锁冲突测试 -----
-- ==> Preparing: SELECT ... FROM tb_user WHERE id=? AND is_deleted=0
-- ==> Parameters: 1(Long)
管理员A 查询到 Jone 的版本号: 1
-- ==> Preparing: SELECT ... FROM tb_user WHERE id=? AND is_deleted=0
-- ==> Parameters: 1(Long)
管理员B 查询到 Jone 的版本号: 1
管理员B 尝试更新...
-- ==> Preparing: UPDATE tb_user SET name=?, age=?, email=?, version=? WHERE id=? AND version=? AND is_deleted=0
-- ==> Parameters: Jone(String), 19(Integer), test1@baomidou.com(String), 2(Integer), 1(Long), 1(Integer)
-- <== Updates: 1
管理员B 更新结果 (影响行数): 1
管理员B 更新后,Jone 的新版本号: 2
管理员A 尝试更新...
-- ==> Preparing: UPDATE tb_user SET name=?, age=?, email=?, version=? WHERE id=? AND version=? AND is_deleted=0
-- ==> Parameters: Jone(String), 18(Integer), prorise@example.com(String), 2(Integer), 1(Long), 1(Integer)
-- <== Updates: 0
管理员A 更新结果 (影响行数): 0
----- 最终结果验证 -----
-- ==> Preparing: SELECT ... FROM tb_user WHERE id=? AND is_deleted=0
-- ==> Parameters: 1(Long)
数据库中 Jone 的最终年龄: 19
数据库中 Jone 的最终邮箱: test1@baomidou.com
数据库中 Jone 的最终版本号: 2
结果分析:
- B 的更新: 管理员 B 基于
version = 1
进行更新,WHERE
条件匹配成功,数据被更新,同时version
自动递增为2
。 - A 的更新: 管理员 A 仍然基于他最初读取的
version = 1
去尝试更新。但此时数据库中的version
已经是2
了,WHERE id=1 AND version=1
条件无法匹配到任何记录,所以更新失败,影响行数为0
。 - 最终一致性: 数据库的最终状态正确地反映了 B 的修改,而 A 的“过时”修改被成功阻止,数据的最终一致性得到了保证。
🤔 思考一下
当乐观锁更新失败时(即 updateById
返回 0
),对于应用程序来说,这意味着一次业务操作的失败。那么,在这种情况下,我们应该如何处理?仅仅简单地向用户抛出一个“操作失败,请重试”的提示就足够了吗?有没有更完善的处理机制?
5.2.3 本节小结
- 核心思想: 乐观锁通过引入
version
字段,以一种无阻塞的的方式解决了高并发下的“更新丢失”问题,是保障数据一致性的重要手段。
实现三步走:
- 数据库表中添加
version
字段; - 实体类中添加
@Version
注解; - 在
MybatisPlusConfig
中注册OptimisticLockerInnerInterceptor
拦截器。 - 工作原理: MP 会自动在
UPDATE
语句的SET
子句中将version
加一,并在WHERE
子句中比对原始version
值。如果version
不匹配,更新操作将失败(影响行数为 0),从而阻止脏写。
5.3. 自动填充公共字段
我们已经学习了如何保护数据不被丢失(逻辑删除)和不被并发写坏(乐观锁)。现在,我们将目光投向另一个维度:如何提升数据的完整性与可追溯性,同时将开发者从重复的模板代码中解放出来。
痛点背景: 几乎在所有的业务数据表中,我们都会设计一些“审计字段”,例如 create_time
(创建时间), update_by
(修改人) 等。在没有自动化机制的情况下,开发者需要在每一个 insert
和 update
的业务方法中,手动设置这些值。这种方式不仅高度重复、容易遗漏,也违反了DRY(Don’t Repeat Yourself)原则。
解决方案: Mybatis-Plus 提供了 `MetaObjectHandler` (元数据对象处理器) 这一优雅的AOP(面向切面编程)解决方案。我们可以创建一个全局的处理器,它会自动拦截 Mybatis-Plus 执行的 insert
和 update
操作,并为我们指定的字段赋予预设值。
为了清晰地展示 MP 的能力,我们将新增 create_operator
(创建人) 和 update_operator
(最后修改人) 两个字段来进行演示,这两个字段在数据库层面没有任何自动行为。
5.3.1. 实践:实现操作人信息的自动填充
接下来,我们将通过三个核心步骤,为项目实现操作人字段的自动填充。
第一步:修改数据库表结构
我们为 tb_user
表添加两个新的 VARCHAR
字段,用于记录操作员信息。
1 | -- 为用户表添加操作人审计字段 |
请注意,这两个新字段在数据库层面是完全普通的 VARCHAR
字段,没有设置任何默认值或自动更新触发器。这样可以确保后续的填充效果完全来自于我们的应用程序。
第二步:在实体类中标记填充时机
现在,我们修改 UserDO.java
,添加对应的属性,并使用 @TableField(fill = ...)
注解来“激活”自动填充。
文件路径: src/main/java/com/example/mpstudy/domain/UserDO.java
1 | // ... |
第三步:创建 MetaObjectHandler
实现类
这是自动填充的核心逻辑所在。我们需要创建一个类来实现 MetaObjectHandler
接口,并将其注册为 Spring Bean。
文件路径: src/main/java/com/example/mpstudy/handler/MyMetaObjectHandler.java
1 | package com.example.mpstudy.handler; |
5.3.2. 单元测试:验证自动填充效果
现在,我们来编写一个全新的测试,验证我们的 operator
字段是否能被精确填充。
文件路径: src/test/java/com/example/mpstudy/AdvancedFeatureTest.java
1 |
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
----- 开始执行操作人自动填充测试 -----
-- ... MyMetaObjectHandler 日志输出
-- INFO com.example.mpstudy.handler.MyMetaObjectHandler : start insert fill ....
-- ... Mybatis 日志输出
-- ==> Preparing: INSERT INTO tb_user ( name, age, email, create_operator, update_operator ) VALUES ( ?, ?, ?, ?, ? )
-- ==> Parameters: OperatorFillUser(String), 50(Integer), operatorfill@example.com(String), SYSTEM_INSERT(String), SYSTEM_INSERT(String)
插入后的用户信息: UserDO(..., createOperator=SYSTEM_INSERT, updateOperator=SYSTEM_INSERT)
----- 准备执行更新操作 -----
-- ... MyMetaObjectHandler 日志输出
-- INFO com.example.mpstudy.handler.MyMetaObjectHandler : start update fill ....
-- ... Mybatis 日志输出
-- ==> Preparing: UPDATE tb_user SET age=?, update_operator=? WHERE id=? AND is_deleted=0
-- ==> Parameters: 51(Integer), SYSTEM_UPDATE(String), ...(Long)
更新后的用户信息: UserDO(..., createOperator=SYSTEM_INSERT, updateOperator=SYSTEM_UPDATE)
5.3.3. [高级实战] 结合 ThreadLocal 填充动态操作人信息
痛点再探: 在上一节,我们在 MyMetaObjectHandler
中硬编码了操作人信息。但在真实业务中,create_operator
和 update_operator
必须是当前登录用户的动态信息。然而,MyMetaObjectHandler
是一个单例的 Spring Bean,它本身无法感知到当前是哪个用户的请求。如何将 Web 层的用户上下文安全、优雅地传递到持久层拦截器中,是我们需要解决的核心问题。
解决方案:Interceptor + ThreadLocal
黄金搭档
这是解决此类问题的业界标准方案。
ThreadLocal
: Java 提供的一种线程隔离机制。它为每个线程都维护一个独立的变量副本。在 Web 应用中,每个请求通常由一个独立的线程处理,因此ThreadLocal
成为在同一次请求的不同处理阶段之间传递数据的完美载体。HandlerInterceptor
(Spring MVC 拦截器): 它是请求处理的“守门员”,可以在请求到达 Controller 之前和处理完成之后执行特定逻辑。这使其成为设置和清理ThreadLocal
数据的理想场所。
我们将通过以下步骤,构建一个完整的动态填充方案:
第一步:创建 UserContextHolder
工具类
这个工具类将专门负责 ThreadLocal
变量的读写与清理。
文件路径: src/main/java/com/example/mpstudy/handler/UserContextHolder.java
1 | package com.example.mpstudy.handler; |
第二步:创建并注册 Web 拦截器
这个拦截器将在每个请求开始时捕获用户信息并存入 UserContextHolder
,在请求结束时将其清理。
1. 创建拦截器实现类
文件路径: src/main/java/com/example/mpstudy/config/AuthenticationInterceptor.java
1 | package com.example.mpstudy.config; |
2. 注册拦截器
文件路径: src/main/java/com/example/mpstudy/config/WebMvcConfig.java
1 | package com.example.mpstudy.config; |
第三步:改造 MyMetaObjectHandler
现在,让我们的填充处理器从 UserContextHolder
动态获取操作员信息,而不是使用硬编码的字符串。
文件路径: src/main/java/com/example/mpstudy/handler/MyMetaObjectHandler.java
1 | // ... (imports) |
健壮性设计: 在 MyMetaObjectHandler
中增加 null
判断是一个好习惯。这可以确保即使在非 Web 请求的上下文(如执行单元测试、定时任务等)中,自动填充功能也不会因为 getOperator()
返回 null
而抛出空指针异常。
至此,我们已经构建了一套完整的、生产级的动态审计字段填充方案。
5.3.4. 集成测试:验证动态填充效果
由于此功能依赖 Web 请求上下文,标准的单元测试无法触发拦截器。我们需要使用 MockMvc
来模拟一个真实的 HTTP 请求,进行集成测试。
第一步:创建一个简单的 UserController
我们需要一个 Controller 端点来接收我们的模拟请求。
文件路径: src/main/java/com/example/mpstudy/controller/UserController.java
1 | package com.example.mpstudy.controller; |
第二步:编写 MockMvc
测试
文件路径: src/test/java/com/example/mpstudy/controller/UserControllerTest.java
1 | package com.example.mpstudy.controller; |
通过 MockMvc
测试成功后,我们便完整地验证了从 Web 请求 -> 拦截器 -> ThreadLocal
-> MetaObjectHandler
-> 数据库 的整条链路。
5.3.5. 本节小结
- 核心思想: 自动填充机制通过 AOP 思想,将公共审计字段的赋值逻辑从业务代码中剥离,实现了逻辑解耦与自动化处理。
- 实现方式:
- 静态填充: 通过
@TableField(fill = ...)
和MetaObjectHandler
实现固定值的填充。 - 动态填充: 结合
HandlerInterceptor
+ThreadLocal
的设计模式,可以安全地将 Web 层的动态上下文(如当前操作员)传递给MetaObjectHandler
,实现动态值的填充。
- 静态填充: 通过
- 关键实践: 在
HandlerInterceptor
的afterCompletion
方法中必须调用ThreadLocal.remove()
来清理数据,这是防止内存泄漏的关键一步,也是生产级代码的必备规范。
5.4. 动态数据源
至此,我们探讨的所有特性都是在单个数据库源上对数据进行“精耕细作”。现在,我们要将视野拔高到架构层面,思考一个问题:当单一数据库实例的性能或业务隔离无法满足需求时,我们该怎么办?
痛点背景: 随着业务的飞速发展,单一数据库实例往往会成为整个系统的瓶颈。此时,我们会面临两种典型的架构演进需求:
- 读写分离: 在大多数应用中,读操作的频率远高于写操作。为了分摊数据库压力,一种经典的架构是将数据同步到一个或多个“从库”(Slave),实现主库写、从库读,从而极大地提升应用的并发承载能力。
- 多租户: 在SaaS应用中,为每个客户(租户)提供独立的数据库是一种常见的强隔离方案。应用程序需要根据当前登录的租户,动态地将SQL路由到对应的数据库。
解决方案: Mybatis-Plus 生态体系中功能强大的官方增强库—— dynamic-datasource-spring-boot-starter
,以一种声明式、无侵入的方式完美解决了上述问题。我们只需在配置文件中定义好所有的数据源,然后在需要切换的地方加上一个简单的 @DS
注解,框架就会自动为我们完成所有底层的切换工作。
5.4.1. 实践:为项目配置读写分离
我们将以最常见的读写分离场景为例,来实践动态数据源的配置。
第一步:引入正确的 Spring Boot 3 依赖
根据您的指正,我们在 pom.xml
文件中,添加适配 Spring Boot 3 的动态数据源启动器。
文件路径: pom.xml
1 | <dependency> |
第二步:准备从库(Slave)环境
这是成功测试的关键一步。我们必须创建一个与主库结构和数据完全一致的从库,用于模拟读操作。
重要操作: 请在您的 MySQL 服务中,新建一个名为 mybatis_plus_notes_slave
的数据库,然后执行以下完整的 SQL 脚本,以确保从库环境准备就绪。
第三步:配置多数据源 (application.yml
)
现在,我们重新组织 application.yml
,清晰地定义一个主库和一个从库。
文件路径: src/main/resources/application.yml
1 | # 服务器端口配置 |
第四步:在 Service 层使用 @DS
注解
通过 @DS
注解,我们精确地将“写”操作绑定到主库,将“读”操作路由到从库。
文件路径: src/main/java/com/example/mpstudy/service/impl/UserServiceImpl.java
1 | package com.example.mpstudy.service.impl; |
5.4.2. 测试:验证数据源切换
为了验证数据源切换成功,我们将使用 dynamic-datasource
库提供的上下文持有器 DynamicDataSourceContextHolder
,并调用其正确的 peek()
方法来获取当前数据源名称。
1. 临时为 Service 添加日志(用于测试验证)
文件路径: src/main/java/com/example/mpstudy/service/impl/UserServiceImpl.java
(临时修改)
1 | package com.example.mpstudy.service.impl; |
2. 编写单元测试
文件路径: src/test/java/com/example/mpstudy/service/UserServiceTest.java
1 | // UserServiceTest.java |
1
2
3
4
5
6
7
8
9
10
11
12
13
----- 开始执行读写分离测试 -----
>>> 正在执行写操作...
INFO c.e.m.service.impl.UserServiceImpl : 执行 save 操作,当前使用的数据源是: [master]
-- ==> Preparing: INSERT INTO tb_user ... (在 master 库执行)
>>> 正在执行读操作...
INFO c.e.m.service.impl.UserServiceImpl : 执行 getById 操作,当前使用的数据源是: [slave_1]
-- ==> Preparing: SELECT ... FROM tb_user WHERE id=? ... (在 slave_1 库执行)
>>> 正在执行删除操作...
// (removeById 继承自 ServiceImpl, 未加日志,但默认使用 master)
-- ==> Preparing: UPDATE tb_user SET is_deleted=1 WHERE id=? AND is_deleted=0 (在 master 库执行)
结果分析:
日志输出清晰地证明了我们的修正已完全成功。写操作 (save
, removeById
) 均在 master
数据源执行,而读操作 (getById
) 则被精确地路由到了 slave_1
数据源,整个过程完全自动化,符合预期!
5.5 核心速查表与面试题
核心速查表
分类 | 关键项 | 核心描述 |
---|---|---|
核心注解 | @TableLogic(value = "0", delval = "1") | (推荐全局配置) 在实体字段上标记逻辑删除,指定未删除和已删除的值。 |
@Version | 在实体字段上标记乐观锁版本号,必须配合拦截器使用。 | |
@TableField(fill=...) | 在实体字段上标记自动填充时机(INSERT , UPDATE , INSERT_UPDATE )。 | |
@DS("dataSourceName") | (推荐) 在类或方法上声明式地指定要使用的数据源。 | |
核心配置 | OptimisticLockerInnerInterceptor | 乐观锁功能的核心拦截器,必须注册。 |
MyMetaObjectHandler | 自动填充功能的逻辑实现类,必须实现接口并注册为 @Component 。 | |
dynamic-datasource-spring-boot3-starter | Spring Boot 3 环境下实现动态数据源的官方 starter。 | |
spring.datasource.dynamic | application.yml 中配置多数据源的根节点。 |
高频面试题与陷阱
请解释一下乐观锁和悲观锁的区别,以及它们各自的适用场景。
好的。悲观锁认为并发冲突总会发生,所以它在读取数据时就通过数据库的锁机制(如FOR UPDATE
)将数据锁定,直到事务结束才释放。这保证了数据绝对一致,但性能差,适用于写多读少的场景,如金融交易。
乐观锁则认为冲突是小概率事件,它在读取时不加锁,只在更新时通过版本号或时间戳等机制(CAS)去检查数据是否被修改过。如果冲突,则更新失败。它性能高,适用于读多写少的场景,如商品库存、用户信息修改等。Mybatis-Plus 的 @Version
就是乐观锁的典型实现。
很好,那么 Mybatis-Plus 的乐观锁更新失败后,业务层面应该如何处理?
一般会采用重试机制。捕获更新失败(返回影响行数为0),重新查询一次数据获得最新的版本号,再重新执行业务逻辑并尝试更新。为了防止死循环,通常会设置最大重试次数。
假设一个方法A被 @Transactional 和 @DS(“master”) 注解,它内部调用了另一个被 @DS(“slave”) 注解的方法B。请问,方法B的查询会在哪个库执行?
它仍然会在 master
库执行。因为标准的Spring事务一旦开启,就会将一个数据库连接绑定到当前线程。在这个事务的生命周期内,所有数据库操作都会复用这个连接,@DS
注解的切换功能会为了保证事务的原子性而失效。
那如果确实需要跨库事务,你有什么解决方案?
那就需要引入分布式事务解决方案了,例如使用遵循JTA规范的事务管理器,或者集成像Seata这样的分布式事务框架。但这会大大增加系统的复杂性。
第六章:[生态] 生产力工具与扩展
摘要: 在本章,我们将探索 Mybatis-Plus 生态中那些能让开发“事半功倍”的利器。内容将从高级类型映射入手,解决通用枚举和复杂对象(如JSON)的持久化难题;随后,我们将全面拥抱自动化,实践代码生成器与 MybatisX 插件;最后,我们会为项目装上“护盾”与“瞄准镜”,学习如何通过SQL分析与安全防护插件,保障代码质量与线上安全。
6.1. 枚举与自定义类型处理
痛点背景: 我们的 Java 世界是类型丰富的,我们用 Enum
来确保性别、状态等字段的类型安全和可读性;我们用 Map
或自定义对象来封装一组关联信息。但数据库的世界相对“朴素”,它只能存储数字、字符串等基本类型。如何在这两个世界之间建立一座优雅、自动的桥梁,是本节要解决的核心问题。
6.1.1. 通用枚举处理( @EnumValue )
在 Java 代码中使用 GenderEnum.MAN
远比使用 1
这样的“魔法数字”要清晰和安全得多。Mybatis-Plus 提供了 @EnumValue
注解,让这种优雅在持久层得以延续。
第一步:准备数据库与枚举类
为
tb_user
表添加gender
字段,我们将用TINYINT
类型来存储代表性别的数字。1
2
3use mybatis_plus_notes;
-- 为用户表添加性别字段
ALTER TABLE `tb_user` ADD COLUMN `gender` TINYINT(1) NULL COMMENT '性别(1-男, 0-女)' AFTER `age`;创建
GenderEnum
枚举。这是定义业务含义和数据库映射关系的核心。文件路径:
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
25
26package com.example.mpstudy.domain.enums;
import com.baomidou.mybatisplus.annotation.EnumValue;
import com.fasterxml.jackson.annotation.JsonValue;
import lombok.Getter;
public enum GenderEnum {
WOMAN(0, "女"),
MAN(1, "男");
// @EnumValue 是关键注解,它告诉 Mybatis-Plus,在持久化时,
// 应该使用这个字段(code)的值存入数据库。
private final int code;
// @JsonValue 注解用于 Spring MVC 返回 JSON 时,能将枚举序列化为我们想要的描述值 "女" 或 "男",
// 而不是默认的枚举名 "WOMAN" 或 "MAN",可以提升前端API的友好性。
private final String desc;
GenderEnum(int code, String desc) {
this.code = code;
this.desc = desc;
}
}
第二步:修改实体类
在 UserDO
中,将原来可能是 Integer
类型的 gender
属性,直接替换为我们刚刚创建的 GenderEnum
类型。
文件路径: src/main/java/com/example/mpstudy/domain/UserDO.java
1 | // ... |
第三步:编写单元测试进行验证
我们通过测试来验证 Mybatis-Plus 是否能智能地完成 GenderEnum.MAN
(Java对象) 与 1
(数据库值) 之间的自动转换。
文件路径: src/test/java/com/example/mpstudy/AdvancedFeatureTest.java
1 |
|
1
2
3
4
----- 开始执行枚举类型处理器测试 -----
-- ==> Preparing: INSERT INTO tb_user (..., age, gender, email, ...) VALUES (?, ?, ?, ?, ...)
-- ==> Parameters: ..., 25(Integer), 1(Integer), ...(String), ...
从数据库查询并映射回来的用户: UserDO(..., age=25, gender=MAN, email=null, ...)
结果分析: 如 代码运行结果
所示,在执行 INSERT
语句时,Mybatis-Plus 自动读取了 @EnumValue
标记的 code
字段值 1
并将其作为参数存入数据库。在 SELECT
查询后,它又根据数据库中存储的 1
自动匹配并实例化了 GenderEnum.MAN
枚举。整个过程对业务代码完全透明,代码的可读性和健壮性得到了极大的提升。
6.1.2. 自定义类型处理器 (TypeHandler)
对于比枚举更复杂的结构,比如需要将一个 Map
对象存入数据库的 JSON
字段,我们需要动用 Mybatis 原生就支持、并被 MP 继承的强大工具——TypeHandler
。
第一步:准备数据库与实体
为
tb_user
表添加contact_info
字段,我们将其类型设置为JSON
,这是现代数据库处理半结构化数据的最佳实践。1
2-- 为用户表添加联系方式JSON字段
ALTER TABLE `tb_user` ADD COLUMN `contact_info` JSON NULL COMMENT '联系方式(JSON)' AFTER `gender`;在
UserDO
中添加Map
类型属性。文件路径:
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// ...
import java.util.Map;
public class UserDO extends Model<UserDO> {
// ...
private GenderEnum gender;
// 我们希望将这个 Map 类型的字段,映射到数据库的 JSON 列
private Map<String, String> contactInfo;
private String email;
// ...
}
第二步:全局扫描并注册 TypeHandler
(核心配置)
为了让 Mybatis-Plus 知晓 Map
类型与数据库 JSON
类型之间应该由哪个“翻译官”来处理,最规范的做法是进行全局配置,让框架自动扫描并注册所有可用的 TypeHandler
。
文件路径: src/main/resources/application.yml
1 | mybatis-plus: |
最佳实践: 强烈推荐使用 type-handlers-package
进行全局配置。这遵循了“约定优于配置”的原则,一旦配置,项目中所有符合条件的 Map
到 JSON
的映射都会自动生效,无需在每个实体字段上重复添加注解,极大提升了代码的整洁性和可维护性。
第三步:开启 autoResultMap
(关键步骤)
即便我们已经全局注册了 TypeHandler
,但在处理查询结果时,我们还需要最后一步:告诉 Mybatis-Plus 在生成查询的 ResultMap
时,要智能地包含所有字段的 TypeHandler
信息。
文件路径: src/main/java/com/example/mpstudy/domain/UserDO.java
1 | // ... |
第四步:编写单元测试进行验证
现在,我们的配置已经完整且稳健,让我们通过测试来验证效果。
文件路径: src/test/java/com/example/mpstudy/AdvancedFeatureTest.java
1 |
|
1
2
3
4
5
----- 开始执行JSON类型处理器测试 -----
-- ==> Preparing: INSERT INTO tb_user (..., gender, contact_info, email, ...) VALUES (?, ?, ?, ?, ...)
-- ==> Parameters: ..., null, {"phone":"188-8888-8888","wechat":"Prorise-Plus"}(String), ...(String), ...
查询到的用户信息: UserDO(..., contactInfo={phone=188-8888-8888, wechat=Prorise-Plus}, ...)
其中的联系方式 (Map对象): {phone=188-8888-8888, wechat=Prorise-Plus}
结果分析: 在我们添加了健全的全局配置后,JacksonTypeHandler
依然完美地扮演了“数据翻译官”的角色。Map
对象与 JSON
字符串之间的双向转换被自动处理,我们的业务代码无需关心任何序列化细节。
6.2. 开发提速利器:MybatisX 插件深度使用
痛点背景: 遵循良好的分层架构固然重要,但这也带来了日常开发中的“流程摩擦”。每当我们需要核对一个 Mapper
方法对应的 SQL 时,都需要在项目目录中手动查找并打开 XML 文件,在数十个 SQL 标签中定位到那一个;每当我们需要为 Mapper
新增一个简单的查询方法,都必须在 Java 接口和 XML 文件之间来回切换,完成一系列模板化的声明和编写。这些琐碎的操作不断打断我们的心流,蚕食着宝贵的开发时间。
解决方案: 将工具深度融入开发环境。MybatisX
作为一款专门为 MyBatis/Mybatis-Plus 打造的 IDEA 插件,它将自己无缝集成到您的 IDE 中,通过提供无与伦比的便捷导航、代码智能生成和图形化逆向工程能力,彻底抹平了上述的流程摩擦。
6.2.1. 安装与配置
第一步: 打开 IDEA 的插件市场 Settings/Preferences -> Plugins
。
第二步: 在搜索框中输入 MybatisX
。
第三步: 点击 Install
并根据提示重启 IDEA。
安装完成后,MybatisX 无需额外配置,即可开箱即用。
6.2.2. 核心功能一:无缝跳转与关联
这是 MybatisX 最知名也是最常用的功能,它在您的 Mapper
接口和 XML
文件之间建立了一座“传送门”。
功能演示:
安装插件后,打开任意一个 Mapper
接口(如 UserMapper.java
)和它对应的 XML
文件(如 UserMapper.xml
)。您会发现行号的左侧多出了一排绿色的双向箭头图标。
- 从 Java 到 XML: 在
UserMapper.java
中,点击任一方法(如findUserWithDept
)旁边的>
箭头,IDE 将立刻跳转到UserMapper.xml
中id="findUserWithDept"
的<select>
标签上。 - 从 XML 到 Java: 反之,在
XML
文件中点击任一 SQL 标签(如<select>
)旁的<
箭头,IDE 也会立刻跳转回Mapper
接口中对应的方法声明。
除此之外,MybatisX 还会实时检查 XML
的 namespace
是否能正确关联到 Mapper
接口,如果关联错误,它会以高亮形式提示您,帮助您在编码阶段就发现潜在的配置问题。
6.2.3. 核心功能二:智能提示与一键生成 SQL
MybatisX 能够理解并解析您在 Mapper
接口中定义的方法名,并据此自动生成对应的 SQL 语句。
实战演练: 假设我们需要一个新功能:根据用户姓名查询,并按年龄降序排列,只返回第一条记录。
第一步: 在 UserMapper.java
中,定义一个符合 JPA 命名规范的方法。
文件路径: src/main/java/com/example/mpstudy/mapper/UserMapper.java
1 | public interface UserMapper extends BaseMapper<UserDO> { |
第二步: 将光标定位在新方法名上,按下快捷键 Alt + Enter
(Windows/Linux) 在弹出的菜单中选择 Generate statement in mapper xml
。
第三步:见证奇迹
MybatisX 会立即在 UserMapper.xml
文件中,为您生成完整且正确的 <select>
标签!
1 | <select id="findFirstByNameOrderByAgeDesc" resultType="com.example.mpstudy.domain.UserDO"> |
它不仅生成了基础的 SELECT
语句和 WHERE
条件,甚至正确地解析了 OrderByAgeDesc
和 findFirst
(对应 limit 1
),这极大地提升了编写简单自定义 SQL 的效率。
6.2.4. 核心功能三:GUI 代码生成器 (逆向工程)
如果您需要为一个或多个新表快速生成全套 Entity
, Service
, Controller
等代码,MybatisX 提供的图形化生成器是比 AutoGenerator
更轻量、更直观的选择。
实战演练: 为 tb_department
表生成全套代码。
第一步: 打开 IDEA 右侧的 Database
工具栏,并连接到您的数据库。
第二步: 展开数据库,找到 tb_department
表,右键点击它,在弹出的菜单中选择 MybatisX-Generator
。
第三步: 在弹出的图形化配置窗口中,完成关键配置。
- Module: 选择当前的项目模块。
- Base Package: 填写生成的代码要存放的父包名,例如
com.example.mpstudy.generated.dept
。 - Table Prefix: 填写需要移除的表前缀,例如
tb_
,这样生成的实体类名就是Department
而不是TbDepartment
。 - 勾选需要生成的文件
瞬间,所有与 Department
相关的分层代码都已为您生成完毕,可以直接投入使用。
6.3. SQL 分析与安全防护
痛点背景: 一个功能跑通,只是完成了开发的“上半场”。在“下半场”,我们需要关注的是代码的质量与安全。当应用变得复杂,我们如何快速定位是哪条 Mybatis-Plus 生成的 SQL 变慢了?又如何从机制上,避免新手开发者或代码缺陷导致 UPDATE
或 DELETE
语句漏写 WHERE
条件,从而引发全表更新/删除的生产事故?
本节,我们将为项目装上“护盾”与“瞄准镜”,学习如何通过 Mybatis-Plus 的插件生态来解决这两个核心问题。
6.3.1. SQL 性能分析 (P6Spy
)
P6Spy 是一个开源的 JDBC 驱动代理框架,它可以像一个安装在 JDBC 驱动前的“摄像头”,无侵入地拦截、记录并分析所有经过它的 SQL 语句。dynamic-datasource-starter
已经内置了对 P6Spy 的良好支持。
第一步:确认配置与依赖
请确保您的项目已满足以下两个条件(我们在解决之前的启动报错时已完成):
pom.xml
: 已经添加了p6spy
依赖。1
2
3
4
5<dependency>
<groupId>p6spy</groupId>
<artifactId>p6spy</artifactId>
<version>3.9.1</version>
</dependency>application.yml
: 在动态数据源配置下,已经开启了p6spy
集成。1
2
3
4
5spring:
datasource:
dynamic:
p6spy: true
# ...
第二步:添加 spy.properties
配置文件
为了让 P6Spy 的输出更美观、更具可读性,我们需要在 src/main/resources
目录下创建 spy.properties
文件,来定制其日志格式。
文件路径: src/main/resources/spy.properties
1 | # 使用 Mybatis-Plus 团队优化过的日志格式化工厂,输出格式更友好 |
第三步:运行并解读日志
现在,运行我们之前编写的任何一个会操作数据库的测试,例如 testEnumHandler
。您将在控制台看到由 P6Spy 打印出的、格式清晰的 SQL 日志。
1 | 2025-08-23 11:55:10 | SQL | connection 2 | took 3ms | statement |
这份日志包含了最有价值的信息:
took 3ms
: SQL 语句从发送到执行完毕所消耗的精确时间。这是我们判断慢查询、进行性能优化的核心依据。- 上方的 SQL: 预编译的、带
?
占位符的 SQL 模板。 - 下方的 SQL: 填充了真实参数后,最终在数据库执行的 SQL 语句。
有了 P6Spy,Mybatis-Plus 生成的每一条 SQL 都变得透明、可观测。
6.3.2. 安全防护 (BlockAttackInnerInterceptor
)
这是 Mybatis-Plus 提供的一个极其重要的“防御性”拦截器,是防止“删库跑路”式低级错误的第一道防线。
第一步:在 MybatisPlusConfig
中注册拦截器
我们需要将 BlockAttackInnerInterceptor
添加到 Mybatis-Plus 的拦截器链中。
文件路径: src/main/java/com/example/mpstudy/config/MybatisPlusConfig.java
1 |
|
注意: BlockAttackInnerInterceptor
应该被注册在拦截器链的较前位置,以便尽早拦截并阻止危险操作。
第二步:编写一个“危险”的测试来验证防护效果
我们将模拟一个开发者在执行 update
操作时,忘记传入 Wrapper
条件的场景。
文件路径: src/test/java/com/example/mpstudy/AdvancedFeatureTest.java
1 | // ... |
运行此测试,程序会立即失败并抛出 MybatisPlusException
,控制台会打印出我们预期的异常信息。SQL 语句根本没有机会被发送到数据库,从而有效地避免了一场潜在的生产事故。