Java(13):13 Mybatis-Plus -ORM框架 MyBatis 最好的搭档


第一章: [基础] 快速入门与环境配置

摘要: 本章的目标是 用最快的速度搭建一个可以运行 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 查询用户”功能进行对比:

  1. Mapper 层:在 UserMapper 接口中定义
    1
    User selectById(Long id);
  2. XML 层:在 UserMapper.xml 中手写
    1
    2
    3
    <select id="selectById" resultType="com.demo.User">
    SELECT * FROM user WHERE id = #{id}
    </select>
  3. Service 层:在 UserServiceImpl 中注入 UserMapper 并调用
    1
    User user = userMapper.selectById(id);
  1. Mapper 层:让 UserMapper 接口继承 BaseMapper<User>,无需额外方法。
    1
    public interface UserMapper extends BaseMapper<User> {}
  2. XML 层:无需任何 XML 或 SQL。
  3. Service 层:直接调用继承自 IService 的现成方法。
    1
    User user = userService.getById(id);

通过对比,MP 的核心价值主张显而易见:“只做增强,不做改变”。它完美继承了 MyBatis 的所有功能,并通过内置通用 MapperService,将我们从繁琐、重复的 CRUD 代码中彻底解放出来。这使得我们能更专注于复杂的业务逻辑,也正是我们称之为 MyBatis “最佳搭档” 的根本原因。


1.1.1. [面试题] MP、MyBatis 与 JPA 的技术选型对比

技术选型讨论
2025-08-21 22:30

在项目中,当面临持久层框架选型时,你是如何看待 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 年的主流稳定技术栈进行构建,具体版本如下:

技术栈版本说明
JDK21Long-Term Support (LTS) 长期支持版
Spring Boot3.4.x现代 Java 应用开发的事实标准
Mybatis-Plus3.5.7+适配 Spring Boot 3 的最新稳定版
MySQL Driver8.0.33官方推荐的 MySQL 8+ 驱动
Maven3.8+项目构建与依赖管理工具

image-20250822090636590


1.2.2. Maven 依赖配置 (pom.xml)

首先,创建一个标准的 Spring Boot Maven 项目,并在 pom.xml 中配置好我们的核心依赖。

文件路径: pom.xml

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
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
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.4</version>
</parent>

<groupId>com.example</groupId>
<artifactId>mybatis-plus-tutorial</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>mybatis-plus-tutorial</name>
<description>Mybatis-Plus Tutorial</description>

<properties>
<java.version>21</java.version>
<mybatis-plus.version>3.5.7</mybatis-plus.version>
</properties>

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

<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>

<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>

<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>

<!-- 引入Hutool-->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.20</version>
</dependency>

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

<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>

1.2.3. 数据源与 MP 基础配置 (application.yml)

我们推荐使用 .yml 格式进行配置,因为它层级清晰,更具可读性。请在 src/main/resources/ 目录下创建 application.yml 文件。

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

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
# 服务器端口配置
server:
port: 8080

# Spring Boot 核心配置
spring:
# 数据库数据源配置
datasource:
url: jdbc:mysql://127.0.0.1:3306/mybatis_plus_notes?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai
username: root
password: root # 替换为您的数据库密码
driver-class-name: com.mysql.cj.jdbc.Driver

# [新特性] 开启虚拟线程,提升I/O密集型应用吞吐量 (需要 JDK 21+)
threads:
virtual:
enabled: true

# Mybatis-Plus 特定配置
mybatis-plus:
# 全局配置
global-config:
# 关闭启动时输出的 Mybatis-Plus Banner信息, 保持控制台清爽
banner: false
# MyBatis原生配置
configuration:
# 开启驼峰命名自动映射,如数据库的:user_name -> Java实体的:userName
map-underscore-to-camel-case: true
# 配置日志实现为标准输出,方便在开发阶段直接于控制台查看执行的SQL语句
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

请注意: 在 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
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
-- 创建数据库(如果不存在)
CREATE DATABASE IF NOT EXISTS `mybatis_plus_notes` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

-- 切换到目标数据库
USE `mybatis_plus_notes`;

-- 创建 user 表
DROP TABLE IF EXISTS `tb_user`;
CREATE TABLE `tb_user` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`name` varchar(30) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '姓名',
`age` int unsigned DEFAULT NULL COMMENT '年龄',
`email` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '邮箱',
`version` int unsigned NOT NULL DEFAULT '1' COMMENT '乐观锁版本号',
`is_deleted` tinyint unsigned NOT NULL DEFAULT '0' COMMENT '逻辑删除标志(0-未删除;1-已删除)',
`gmt_create` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`gmt_modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户表';

-- 插入初始数据
INSERT INTO `tb_user` (`id`, `name`, `age`, `email`) VALUES
(1, 'Jone', 18, 'test1@baomidou.com'),
(2, 'Jack', 20, 'test2@baomidou.com'),
(3, 'Tom', 28, 'test3@baomidou.com'),
(4, 'Sandy', 21, 'test4@baomidou.com'),
(5, 'Billie', 24, 'test5@baomidou.com');

1.3.2. 实体类 (UserDO.java)

实体类是数据库表在 Java 世界中的映射。按照规约,与数据库表直接对应的对象我们称之为 DO (Data Object)。

文件路径: 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
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
package com.example.mpstudy.domain;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.experimental.Accessors;

import java.time.LocalDateTime;

@Data
@Accessors(chain = true) // 支持链式调用
// `@TableName("tb_user")`: Mybatis-Plus 注解
// 用于将 `UserDO` 类与数据库中的 `tb_user` 表进行显式绑定。
@TableName("tb_user")
public class UserDO {
// 注意:我们在这里提前设置ID,因为数据库表中的id字段是主键,MP会使用默认的雪花算法生成一个全局唯一的ID,不方便我们的测试
@TableId(type = IdType.AUTO)
private Long id;

/**
* 姓名
*/
private String name;

/**
* 年龄
*/
private Integer age;

/**
* 邮箱
*/
private String email;

/**
* 乐观锁版本号
*/
private Integer version;

/**
* 逻辑删除标志(0-未删除;1-已删除)
*/
private Integer isDeleted;

/**
* 创建时间
*/
private LocalDateTime gmtCreate;

/**
* 修改时间
*/
private LocalDateTime gmtModified;
}

1.3.3. Mapper 接口 (UserMapper.java)

Mapper 接口是数据访问层(DAO)的核心,它充当了 Java 代码与数据库 SQL 之间的桥梁。

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

1
2
3
4
5
6
7
8
9
package com.example.mpstudy.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.mpstudy.domain.UserDO;
// `extends BaseMapper<UserDO>`: 这是 Mybatis-Plus 的强大所在
// 通过继承 `BaseMapper` 并指定泛型为 `UserDO`,我们的 `UserMapper` 接口瞬间拥有了一整套强大且经过性能优化的 CRUD 方法
public interface UserMapper extends BaseMapper<UserDO> {
// 目前无需编写任何方法
}

1.3.4. 启动类配置 (@MapperScan)

最后一步,我们需要告诉 Spring Boot 在哪里可以找到我们刚刚创建的 Mapper 接口,以便为它们创建代理实现并纳入 IoC 容器管理。

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

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

import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
@MapperScan("com.example.mpstudy.mapper") // <-- 添加此行注解
public class MpStudyApplication {

public static void main(String[] args) {
SpringApplication.run(MpStudyApplication.class, args);
}

}

@MapperScan("com.example.mpstudy.mapper"): 这个注解的作用是扫描指定的包(com.example.mpstudy.mapper),并将其中所有被 Mybatis 识别为 Mapper 的接口(通常是继承了 BaseMapper 的接口)自动注册为 Spring Bean。这样,我们就可以在 Service 层或其他地方通过 @Autowired 直接注入并使用它们了。

至此,我们的项目已完全准备就绪,所有基础配置和核心文件均已创建完毕。在下一章,我们将正式开始体验 Mybatis-Plus 强大而便捷的 CRUD 功能。


第二章: [核心] 通用 CRUD 与 Service 接口

摘要: 本章将讲解 Mybatis-Plus 效率革命的核心:通用 CRUD 功能。我们将学习如何通过继承 BaseMapperServiceImpl 接口,在不写一行 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:表示一个实体类对象类型。例如,UserOrder 等。
  • 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package com.example.mpstudy.mapper;


import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

// @SpringBootTest: 标记这是一个Spring Boot的测试类,会加载整个Spring应用上下文
@SpringBootTest
class UserMapperTest {

// @Autowired: 从Spring容器中自动注入UserMapper的实例
@Autowired
private UserMapper userMapper;

// 后续的所有测试方法都将写在这个类中
}

2.1.1. 插入 (insert)

insert 方法用于向数据库中插入一条新的记录。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// UserMapperTest.java

@Test
void testInsert() {
System.out.println("----- 开始执行 insert 测试 -----");
UserDO user = new UserDO();
user.setName("Prorise");
user.setAge(30);
user.setEmail("prorise@example.com");

int result = userMapper.insert(user);
System.out.println("受影响的行数: " + result);
System.out.println("插入后的用户ID: " + user.getId());
System.out.println("----- insert 测试执行完毕 -----");
}
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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Test
void testDelete() {
System.out.println("----- 开始执行 delete 测试 -----");
int resultById = userMapper.deleteById(1L);
System.out.println("deleteById 受影响行数: " + resultById);

// 2. 根据多个ID批量删除
List<Long> idsToDelete = Arrays.asList(4L, 5L);
int resultBatch = userMapper.deleteBatchIds(idsToDelete);
System.out.println("deleteBatchIds 受影响行数: " + resultBatch);


// 3. 根据Map中的条件删除 (多个条件之间是 AND 关系)
// 删除 name = 'Tom' AND age = 28 的记录
HashMap<String, Object> columnMap = new HashMap<>();
columnMap.put("name", "Tom"); // key是数据库中的列名,不是Java属性名
columnMap.put("age", 28);
int resultMap = userMapper.deleteByMap(columnMap);
System.out.println("deleteByMap 受影响行数: " + resultMap);

}

由于我们测试删除了部分数据,建议再重新向我们的数据库插入新数据回来

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
-- 创建数据库(如果不存在)
CREATE DATABASE IF NOT EXISTS `mybatis_plus_notes` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

-- 切换到目标数据库
USE `mybatis_plus_notes`;

-- 创建 user 表
DROP TABLE IF EXISTS `tb_user`;
CREATE TABLE `tb_user` (
`id` bigint unsigned NOT NULL COMMENT '主键ID',
`name` varchar(30) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '姓名',
`age` int unsigned DEFAULT NULL COMMENT '年龄',
`email` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '邮箱',
`version` int unsigned NOT NULL DEFAULT '1' COMMENT '乐观锁版本号',
`is_deleted` tinyint unsigned NOT NULL DEFAULT '0' COMMENT '逻辑删除标志(0-未删除;1-已删除)',
`gmt_create` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`gmt_modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户表';

-- 插入初始数据
INSERT INTO `tb_user` (`id`, `name`, `age`, `email`) VALUES
(1, 'Jone', 18, 'test1@baomidou.com'),
(2, 'Jack', 20, 'test2@baomidou.com'),
(3, 'Tom', 28, 'test3@baomidou.com'),
(4, 'Sandy', 21, 'test4@baomidou.com'),
(5, 'Billie', 24, 'test5@baomidou.com');

2.1.3. 修改 (updateById)

updateById 会根据传入实体的 ID 去更新记录。

重要: updateById 方法默认会更新实体中 所有字段,即使字段值为 null。这意味着如果您只想更新某个字段,需要先查询出完整记录,修改后再更新,否则其他字段可能被 null 覆盖。后续章节会讲解如何实现“部分更新”。

1
2
3
4
5
6
7
8
9
10
11
12
13
// UserMapperTest.java

@Test
void testUpdate() {
// 确保ID为 4 的用户存在
UserDO user = new UserDO();
user.setId(4L); // 指定要更新的记录ID
user.setAge(22); // 只设置age,其他字段为null

// 执行更新
int result = userMapper.updateById(user);
System.out.println("updateById 受影响行数: " + result);
}

2.1.4. 查询 (selectById, selectList, selectBatchIds, selectByMap)

查询是最高频的操作,BaseMapper 同样提供了丰富的查询方法。

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
// UserMapperTest.java

@Test
void testSelect() {
// 1. 根据主键ID查询
UserDO user = userMapper.selectById(5L);
System.out.println("selectById 查询结果: " + user);

// 2. 查询所有记录
// selectList(null) 传入null作为查询条件,代表无条件查询
List<UserDO> userList = userMapper.selectList(null);
System.out.println("selectList 查询到的总数: " + userList.size());
userList.forEach(System.out::println);

// 3. 根据多个ID批量查询
List<Long> idsToSelect = Arrays.asList(4L, 5L);
List<UserDO> usersByIds = userMapper.selectBatchIds(idsToSelect);
System.out.println("selectBatchIds 查询到的结果:");
usersByIds.forEach(System.out::println);

// 4. 根据Map中的条件查询
// 查询 name = 'Sandy' 的记录
Map<String, Object> columnMap = new HashMap<>();
columnMap.put("name", "Sandy"); // key是数据库中的列名
List<UserDO> usersByMap = userMapper.selectByMap(columnMap);
System.out.println("selectByMap 查询到的结果:");
usersByMap.forEach(System.out::println);
}

2.2. IServiceServiceImpl 的应用

直接在业务逻辑中注入 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
2
3
4
5
6
7
8
9
10
11
12
package com.example.mpstudy.service;

import com.baomidou.mybatisplus.extension.service.IService;
import com.example.mpstudy.domain.UserDO;

public interface UserService extends IService<UserDO> {
// IService<UserDO> 泛型接口,提供了大量便捷的业务层方法,如 save, list, page 等。
// 通过继承它,UserService 立刻就拥有了这些通用的业务方法。

// 后续我们可以在这里定义 User 相关的、IService 中没有的特定业务方法。
// 例如:void lockUser(Long userId);
}

2.2.2. 业务类实现 (UserServiceImpl) 继承 ServiceImpl

接著,我們創建 UserService 的實現類,它需要繼承 ServiceImpl

文件路径: src/main/java/com/example/mpstudy/service/impl/UserServiceImpl.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.service.impl;

import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.example.mpstudy.domain.UserDO;
import com.example.mpstudy.mapper.UserMapper;
import com.example.mpstudy.service.UserService;
import org.springframework.stereotype.Service;

// @Service: 将该类标记为Spring的Service组件,交由IoC容器管理。
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, UserDO> implements UserService {
// ServiceImpl 是 IService 的官方实现类,封装了对BaseMapper的调用。
// 继承 ServiceImpl<M, T> 需要提供两个泛型:
// 1. M: 当前Service对应的Mapper接口类型,这里是 UserMapper。
// 2. T: 当前Service对应的实体类型,这里是 UserDO。

// 继承之后,所有IService中定义的方法都已自动实现,无需我们手动编写。
// 我们只需要在这里实现 UserService 中自定义的业务方法即可。
}


2.2.3. 常用 Service 方法 (save, remove, update, get, list)

IService 提供了比 BaseMapper 更符合业务语义的方法名,如 save 对应 insertgetById 对应 selectByIdlist 对应 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:表示一个实体类对象类型。例如,UserOrder 等。
  • Serializable:表示可以序列化的类型,通常是主键 ID。
  • Wrapper<T>:这是 MyBatis-Plus 提供的一个条件构造器类,常用的有 QueryWrapper(用于查询条件)和 UpdateWrapper(用于更新条件)。通过 Wrapper,你可以构建更加复杂的查询或更新条件,我们后续会详细讲解

为了测试 Service 层的功能,我们创建一个新的测试类 UserServiceTest

文件路径: src/test/java/com/example/mpstudy/service/UserServiceTest.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.service;

import com.example.mpstudy.domain.UserDO;
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
class UserServiceTest {

@Autowired
private UserService userService;

@Test
void testGetAndList() {
// 1. 根据ID查询 (等同于 userMapper.selectById)
UserDO user = userService.getById(5L);
System.out.println("getById 查询结果: " + user);

// 2. 查询所有 (等同于 userMapper.selectList(null))
List<UserDO> list = userService.list();
System.out.println("list 查询到的总数: " + list.size());
}
}

2.2.4. 批量操作 (saveBatch, updateBatchById)

IService 也提供了高效的批量操作方法,它会在底层优化 SQL 的执行(例如,通过 Batch 模式),远比我们自己循环调用 saveupdate 要高效。

1
2
3
4
5
6
7
8
9
10
@Test
void testBatchOperations() {
// 1. 批量新增
List<UserDO> userList = List.of(
new UserDO().setName("BatchUser1").setAge(25).setEmail("b1@example.com"),
new UserDO().setName("BatchUser2").setAge(26).setEmail("b2@example.com")
);
boolean saveResult = userService.saveBatch(userList);
System.out.println("批量新增是否成功: " + saveResult);
}

2.2.5. saveOrUpdate 方法详解

saveOrUpdate 是一个非常智能的方法,它可以根据实体对象的主键(ID)是否存在来自动判断是执行 插入 还是 更新 操作。

  • 如果实体对象的 ID 为 null,则执行 insert
  • 如果实体对象的 ID 不为 null,则执行 updateById
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// UserServiceTest.java

@Test
void testSaveOrUpdate() {
// 1. ID为null,执行插入操作
UserDO newUser = new UserDO().setName("NewOrUpdateUser").setAge(40).setEmail("nou@example.com");
boolean insertSuccess = userService.saveOrUpdate(newUser);
System.out.println("插入操作是否成功: " + insertSuccess + ", 用户ID: " + newUser.getId());

// 2. ID不为null,执行更新操作
// 使用上一步插入的ID
UserDO existingUser = new UserDO().setId(newUser.getId()).setAge(41);
boolean updateSuccess = userService.saveOrUpdate(existingUser);
System.out.println("更新操作是否成功: " + updateSuccess);
}

2.3. 自定义接口方法

尽管 Mybatis-Plus 提供的通用 BaseMapperIService 已经能覆盖绝大多数单表操作,但在复杂的业务场景中,我们仍然需要编写自定义的 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package com.example.mpstudy.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.mpstudy.domain.UserDO;
import org.apache.ibatis.annotations.Param; // 引入MyBatis的Param注解

public interface UserMapper extends BaseMapper<UserDO> {
// 继承了 BaseMapper 的同时,我们可以在这里定义任何自定义的方法。

/**
* 根据姓名查询用户信息
* @param name 姓名
* @return 用户信息
*/
UserDO selectByName(@Param("name") String name);
}

最佳实践: 当 Mapper 方法有多个参数时,强烈建议使用 MyBatis 提供的 @Param("...") 注解为每个参数命名。这能让 XML 文件中的 SQL 通过名称(如 #{name})清晰地引用到参数,避免因参数顺序问题导致的错误。

第二步:创建 Mapper XML 映射文件

我们需要在 resources 目录下创建一个与 UserMapper 接口对应的 XML 文件来存放我们的 SQL 语句。

文件路径: src/main/resources/mapper/UserMapper.xml

1
2
3
4
5
6
7
8
9
10
11
<?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="com.example.mpstudy.mapper.UserMapper">

<select id="selectByName" resultType="com.example.mpstudy.domain.UserDO">
SELECT * FROM tb_user WHERE name = #{name}
</select>

</mapper>

检查配置: 请确保您的 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
2
3
4
5
6
7
8
9
// UserMapperTest.java (添加新的测试方法)

@Test
void testCustomMethod() {
System.out.println("----- 开始执行自定义方法测试 -----");
UserDO user = userMapper.selectByName("Tom");
System.out.println("查询到的 Tom 的信息: " + user);
System.out.println("----- 自定义方法测试执行完毕 -----");
}

通过以上步骤,我们成功地在 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
2
3
4
5
6
7
8
9
10
11
12
13
14
package com.example.mpstudy.domain;

import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.experimental.Accessors;

@Data
@Accessors(chain = true)
// 使用 @TableName("tb_user") 明确告诉MP,此类对应数据库中的 "tb_user" 表。
// 这是最常见也是最重要的映射注解之一。
@TableName("tb_user")
public class UserDO {
// ... 属性 ...
}

3.1.3. 字段映射: @TableField

@TableField 是一个功能强大的注解,用于处理实体属性与表字段之间的各种映射问题。

1. 字段名不匹配

当属性名和字段名的映射关系不符合默认规则时(例如,属性为 email,但数据库字段为 user_email),可以使用其 value 属性来指定。

1
2
3
4
5
6
7
8
9
10
11
// UserDO.java
import com.baomidou.mybatisplus.annotation.TableField;

// ...

/**
* 邮箱
*/
// @TableField 的 value 属性用于指定数据库中对应的列名
@TableField("user_email")
private String email;

2. 属性在表中不存在 (非表字段)

有时我们希望在实体类中定义一些不与数据库表字段对应的属性,例如用于临时计算或前端展示。可以使用 exist = false 来标记,告诉 MP 忽略这个属性。

1
2
3
4
5
6
7
8
9
10
11
12
// UserDO.java
import com.baomidou.mybatisplus.annotation.TableField;

// ...

/**
* 用户的角色描述,这个字段在 tb_user 表中不存在。
*/
// @TableField(exist = false) 告诉MP,在执行任何数据库操作时(如INSERT, UPDATE, SELECT),
// 都要完全忽略这个 'userRole' 属性,它只是一个普通的Java类成员。
@TableField(exist = false)
private String userRole;

3. 控制字段是否参与查询

对于一些敏感信息(如密码)或大字段(如文章内容),我们可能希望在常规列表查询中默认不返回它们,以提高性能和安全性。可以使用 select = false 来实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
// UserDO.java
import com.baomidou.mybatisplus.annotation.TableField;

// ...

/**
* 假设我们有一个密码字段
*/
// @TableField(select = false) 表示此字段在数据库中存在,
// 但在使用 MP 内置的查询方法(如 selectList)时,不会被包含在 SELECT 的字段列表中。
// 注意:INSERT 和 UPDATE 操作仍然会包含此字段。
@TableField(select = false)
private String password;

3.1.4. 主键映射: @TableId 与主键生成策略 (IdType)

@TableId 注解专门用于标识实体类中的主键属性。MP 默认会将名为 id 的属性视为主键,但显式使用 @TableId 是更规范的做法。它最重要的功能是可以通过 type 属性指定主键的生成策略。

IdType 枚举值描述适用场景
AUTO数据库 ID 自增。将主键生成交由数据库的自增列处理。本项目选用,兼容性好,便于测试。
ASSIGN_ID雪花算法。MP 默认策略,生成一个全局唯一的 Long 类型 ID。分布式系统,需要全局唯一 ID 的场景。
INPUT用户手动输入。ID 需要由开发者在插入前手动设置。业务主键明确,或由其他服务生成 ID 的场景。
ASSIGN_UUIDUUID。生成一个随机的 32 位字符串 ID,无序。主键为 String 类型,需要唯一性的场景。
NONE无策略。未设置主键类型,会跟随全局配置。不推荐单独使用。

在我们的 UserDO 中,已经根据您的要求配置为了 IdType.AUTO

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

1
2
3
4
5
6
7
8
9
10
11
// UserDO.java
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;

// ...

// @TableId 用于标识主键字段
// type = IdType.AUTO: 指定主键生成策略为数据库自增。
// 当我们执行 insert 操作时, MP不会为ID赋值,而是依赖数据库生成,并在插入后将生成的值回填到实体对象中。
@TableId(type = IdType.AUTO)
private Long id;

3.2. 条件构造器 Wrapper 详解

掌握了实体与表的映射关系后,我们便可以开始构建动态的、复杂的查询。虽然 BaseMapper 提供的 selectByMap 能实现简单的 AND 等值查询,但面对 LIKE>INOR 等更丰富的查询逻辑时则无能为力。

为了解决这一痛点,Mybatis-Plus 提供了其设计的精髓——条件构造器(Wrapper)。它允许我们使用纯 Java 代码,以一种类型安全、可维护的方式构建任意复杂的查询条件,从而彻底告别手写 XML 中的动态 SQL。

重要信息: Wrapper 虽然简便,但遇上更加复杂的 Sql 我还是更乐意采取 Mybatis 的写法,复杂的Wrapper查询混杂在业务代码中可读性绝对比 xml 要差得多,所以我们介绍方面也只会介绍常见的,复杂的我们会跳过


3.2.1. QueryWrapper vs LambdaQueryWrapper (核心选择)

Wrapper 主要有两种实现:QueryWrapperLambdaQueryWrapper

1
2
3
4
5
// 使用字符串指定列名 "name"
QueryWrapper<UserDO> queryWrapper = new QueryWrapper<>();
queryWrapper.eq("name", "Jack");
// 如果将 "name" 误写为 "naem",编译不会报错,运行时才会抛出异常。
List<UserDO> users = userMapper.selectList(queryWrapper);
1
2
3
4
5
// 使用方法引用 UserDO::getName
LambdaQueryWrapper<UserDO> lambdaQueryWrapper = new LambdaQueryWrapper<>();
lambdaQueryWrapper.eq(UserDO::getName, "Jack");
// 如果将 UserDO::getName 误写为 UserDO::getNaem,IDE会立即报错,无法通过编译。
List<UserDO> users = userMapper.selectList(lambdaQueryWrapper);

3.2.2. Wrapper:UpdateWrapperLambdaUpdateWrapper

QueryWrapper 专注于构建 SELECTDELETE 语句的 WHERE 条件。但当我们执行 UPDATE 操作时,不仅需要 WHERE 条件,还需要指定 SET 子句(即要更新哪些字段以及更新成什么值)。

为此,Mybatis-Plus 提供了专门的 UpdateWrapper 和其 Lambda 版本 LambdaUpdateWrapper

核心区别: UpdateWrapperQueryWrapper 的基础上,增加了 set()setSql() 方法,用于动态构建 UPDATE 语句的 SET 部分。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 目标: 将名字为 "Jack" 的用户邮箱更新为 "new.jack@example.com"
UpdateWrapper<UserDO> updateWrapper = new UpdateWrapper<>();

// 1. 构建 SET 子句: set("数据库列名", "值")
updateWrapper.set("email", "new.jack@example.com");

// 2. 构建 WHERE 子句 (用法与 QueryWrapper 一致)
updateWrapper.eq("name", "Jack");

// 执行更新
// 第一个参数 null 表示我们不通过实体对象来指定更新内容,
// 而是完全依赖 UpdateWrapper 的 set 方法。
int updatedRows = userMapper.update(null, updateWrapper);
1
2
3
4
5
6
7
8
9
10
11
// 目标: 将名字为 "Jack" 的用户邮箱更新为 "new.jack@example.com"
LambdaUpdateWrapper<UserDO> lambdaUpdateWrapper = new LambdaUpdateWrapper<>();

// 1. 构建 SET 子句: set(实体类::get方法, "值")
lambdaUpdateWrapper.set(UserDO::getEmail, "new.jack.lambda@example.com");

// 2. 构建 WHERE 子句 (用法与 LambdaQueryWrapper 一致)
lambdaUpdateWrapper.eq(UserDO::getName, "Jack");

// 执行更新
int updatedRows = userMapper.update(null, lambdaUpdateWrapper);

一个更高级的用法 setSql(): 当你需要执行如 age = age + 1 这样的 SQL 表达式时,set() 方法无法满足,这时可以使用 setSql()

1
2
3
4
// 示例:将所有用户的年龄增加1岁
LambdaUpdateWrapper<UserDO> wrapper = new LambdaUpdateWrapper<>();
wrapper.setSql("age = age + 1");
userMapper.update(null, wrapper);

结论: 与查询时一样,在构建更新条件时,我们应 始终优先使用 LambdaUpdateWrapper,以保证代码的类型安全和重构友好性。


3.2.3. Wrapper 家族总结

Mybatis-Plus 的 Wrapper 设计遵循了清晰的继承和分工。了解它们的家族关系可以帮助我们更好地选择和使用。

  • Wrapper: 抽象顶级接口,定义了 Wrapper 的最基本规范。
  • AbstractWrapper: 核心抽象类,实现了大部分通用的 WHERE 条件方法(如 eq, ne, gt, like, in, or, orderBy 等)。QueryWrapperUpdateWrapper 都继承自它。
  • QueryWrapper: 专注于查询删除。它继承了 AbstractWrapper 的所有 WHERE 条件方法,并增加了 select() 方法来指定查询的字段(SELECT a, b, c)。
  • UpdateWrapper: 专注于更新。它同样继承了 AbstractWrapper 的所有 WHERE 条件方法,并额外提供了 set()setSql() 方法来构建 SET 子句。

下面是一个清晰的对比表格,帮助你快速回顾和选择:

Wrapper 类型主要用途核心独有方法Lambda 版本推荐使用场景
QueryWrapper构建 SELECTDELETE 语句的 WHERE 条件select(...)LambdaQueryWrapper所有查询 (select...) 和删除 (delete) 操作。
UpdateWrapper构建 UPDATE 语句的 SETWHERE 条件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
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
package com.example.mpstudy;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.example.mpstudy.domain.UserDO;
import com.example.mpstudy.mapper.UserMapper;
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
public class WrapperTest {

@Autowired
private UserMapper userMapper;

@Test
void testBasicQueries() {
// 1. 查询年龄大于20岁,并且ID小于等于4的用户
LambdaQueryWrapper<UserDO> wrapper = new LambdaQueryWrapper<>();
wrapper.gt(UserDO::getAge, 20) // gt: greater than, 大于
.le(UserDO::getId, 4L); // le: less than or equal, 小于等于

System.out.println("--- 年龄大于20且ID小于等于4的用户 ---");
List<UserDO> users1 = userMapper.selectList(wrapper);
users1.forEach(System.out::println);


// 2. 查询姓名为 "Tom" 的用户
// new 一个新的Wrapper, 或者调用 clear() 清空上一个Wrapper的条件
wrapper.clear();
wrapper.eq(UserDO::getName, "Tom"); // eq: equal, 等于

System.out.println("\n--- 姓名为Tom的用户 ---");
UserDO tom = userMapper.selectOne(wrapper); // selectOne: 查询单个结果,如果结果超过1个会报错
System.out.println(tom);


// 3. 查询年龄在20到30岁之间的用户
wrapper.clear();
wrapper.between(UserDO::getAge, 20, 30); // between(字段, 起始值, 结束值)

System.out.println("\n--- 年龄在20-30岁之间的用户 ---");
List<UserDO> users3 = userMapper.selectList(wrapper);
users3.forEach(System.out::println);
}
}
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, isNullisNotNull)

模糊查询和空值判断是日常开发中不可或缺的查询类型。

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
@Test
void testLikeAndNullQueries() {
// 1. 模糊查询: 查询姓名中包含 "m" 的用户
LambdaQueryWrapper<UserDO> wrapperLike = new LambdaQueryWrapper<>();
// like(字段, 值): 对应SQL --> LIKE '%m%'
wrapperLike.like(UserDO::getName,"m");
// likeLeft(字段, 值): 对应SQL --> LIKE '%e' (以e结尾)
// likeRight(字段, 值): 对应SQL --> LIKE 'T%' (以T开头)
System.out.println("--- 姓名中包含 'm' 的用户 ---");
List<UserDO> users1 = userMapper.selectList(wrapperLike);
users1.forEach(System.out::println);


// 2. 判空查询: 查询邮箱地址未设置的用户
LambdaQueryWrapper<UserDO> wrapperNull = new LambdaQueryWrapper<>();
// isNull(字段): 对应SQL --> IS NULL
wrapperNull.isNull(UserDO::getEmail);

System.out.println("\n--- 邮箱地址为空的用户 ---");
List<UserDO> users2 = userMapper.selectList(wrapperNull);
users2.forEach(System.out::println);

System.out.println("\n--- 查询所有名字不为空的用户 --- ");
LambdaQueryWrapper<UserDO> lambdaQueryWrapper = new LambdaQueryWrapper<>();
lambdaQueryWrapper.isNotNull(UserDO::getName);
List<UserDO> users = userMapper.selectList(lambdaQueryWrapper);
System.out.println(users);
}

3.2.6. IN 与子查询 (in)

当查询条件涉及一个集合或另一个查询的结果时,就需要用到 IN

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Test
void testInAndExistsQueries() {
// 1. IN 查询: 查询ID为 1, 3, 5 的用户
LambdaQueryWrapper<UserDO> wrapperIn = new LambdaQueryWrapper<>();
// in(字段, 集合或数组): 对应SQL --> IN (?, ?, ?)
wrapperIn.in(UserDO::getId, List.of(1L, 3L, 5L));
// 如果想要取反,则使用下面的方法
// wrapperIn.notIn(UserDO::getId, List.of(1L, 3L, 5L));

System.out.println("--- ID为 1, 3, 5 的用户 ---");
List<UserDO> users1 = userMapper.selectList(wrapperIn);
users1.forEach(System.out::println);

}

3.2.7. 排序与分组 (orderBy, groupBy)

排序和分组是数据分析和展示中非常常见的操作。

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
// WrapperTest.java

@Test
void testOrderingAndGrouping() {
// 1. 排序: 按年龄降序排序,如果年龄相同,则按ID升序排序
LambdaQueryWrapper<UserDO> orderWrapper = new LambdaQueryWrapper<>();
// orderByDesc/orderByAsc 可以接收多个字段,并按顺序应用排序规则
orderWrapper.orderByDesc(UserDO::getAge)
.orderByAsc(UserDO::getId);

System.out.println("--- 按年龄降序、ID升序排序 ---");
List<UserDO> users1 = userMapper.selectList(orderWrapper);
users1.forEach(System.out::println);

// 2. 分组查询: 按年龄分组,统计每个年龄段的用户数
// 注意:分组查询通常伴随着聚合函数(COUNT, SUM, AVG等),
// 返回的结果集结构已不再是 UserDO,因此不能用 LambdaQueryWrapper 的 select 方法。
// 我们需要使用 QueryWrapper 并用字符串指定查询字段,同时使用 selectMaps 返回 List<Map>。
QueryWrapper<UserDO> groupWrapper = new QueryWrapper<>();
groupWrapper.select("age, COUNT(*) as count") // 查询 age 字段和统计数(别名为count)
.groupBy("age") // 按 age 字段分组
.having("count > 1"); // having 用于对分组后的结果进行过滤

System.out.println("\n--- 按年龄分组,统计人数大于1的年龄段 ---");
List<Map<String, Object>> maps = userMapper.selectMaps(groupWrapper);
maps.forEach(System.out::println);
}

3.2.8. 逻辑连接与嵌套 (and, or, nested)

默认情况下,多个查询条件之间使用 AND 连接。当需要 OR 或者更复杂的括号嵌套逻辑时,MP 同样提供了简洁的实现方式。

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
// WrapperTest.java

@Test
void testLogicalAndNested() {
// 1. OR 连接: 查询姓名以 'J' 开头 或 年龄大于 25 的用户
LambdaQueryWrapper<UserDO> orWrapper = new LambdaQueryWrapper<>();
orWrapper.likeRight(UserDO::getName, "J") // likeRight: LIKE 'J%'
.or() // 使用 or() 方法连接下一个条件
.gt(UserDO::getAge, 25);

System.out.println("--- 姓名以 'J' 开头 或 年龄大于 25 的用户 ---");
List<UserDO> users1 = userMapper.selectList(orWrapper);
users1.forEach(System.out::println);

// 2. 嵌套查询: 查询 (年龄 < 25 AND 邮箱包含 "test") OR (姓名 = 'Tom')
LambdaQueryWrapper<UserDO> nestedWrapper = new LambdaQueryWrapper<>();
// nested() 方法接收一个Lambda表达式,用于构建一个带括号的条件块
nestedWrapper.nested(w -> w.lt(UserDO::getAge, 25).like(UserDO::getEmail, "test"))
.or()
.eq(UserDO::getName, "Tom");

System.out.println("\n--- (年龄<25且邮箱含test) 或 (姓名为Tom) 的用户 ---");
List<UserDO> users2 = userMapper.selectList(nestedWrapper);
users2.forEach(System.out::println);
}

3.2.9. 结果集字段筛选 (select)

默认情况下,Mybatis-Plus 会查询实体对应的所有字段(SELECT * ...)。但在很多场景下,我们可能只需要其中的几个字段。只查询必要的字段是优化 SQL 的一个重要手段,可以减少网络 I/O 和内存占用。

LambdaQueryWrapperselect 方法允许我们精准地指定需要查询返回的字段。

1
2
3
4
5
6
7
8
9
10
11
12
13
// WrapperTest.java

@Test
void testSelectColumns() {
// 场景:在用户列表页,我们只需要展示用户的 ID, 姓名, 邮箱
LambdaQueryWrapper<UserDO> wrapper = new LambdaQueryWrapper<>();
// select 方法可以接收多个方法引用,指定要查询的列
wrapper.select(UserDO::getId, UserDO::getName, UserDO::getEmail);

System.out.println("--- 只查询 ID, 姓名, 邮箱 ---");
List<UserDO> users = userMapper.selectList(wrapper);
users.forEach(System.out::println);
}

观察结果: 可以看到,返回的 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
-- 创建部门表
CREATE TABLE `tb_department` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`name` varchar(30) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '部门名称',
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='部门表';

-- 为用户表添加部门ID外键
ALTER TABLE `tb_user` ADD COLUMN `department_id` bigint unsigned NULL COMMENT '部门ID' AFTER `email`;

-- 插入部门数据
INSERT INTO `tb_department` (id, name) VALUES (1, '研发部'), (2, '市场部');

-- 为现有用户分配部门
UPDATE `tb_user` SET department_id = 1 WHERE id IN (1, 2, 3);
UPDATE `tb_user` SET department_id = 2 WHERE id IN (4, 5);

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

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

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.experimental.Accessors;

@Data
@Accessors(chain = true)
@TableName("tb_department")
public class DepartmentDO {
@TableId(type = IdType.AUTO)
private Long id;
private String name;
}

第二步:创建视图对象 (VO)

为了封装关联查询的返回结果,我们创建两个 VO (View Object)。

1. UserVO (用于一对一): 封装“查询用户及其所属部门”的结果。

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

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

import com.example.mpstudy.domain.DepartmentDO;
import com.example.mpstudy.domain.UserDO;
import lombok.Data;
import lombok.EqualsAndHashCode;

@Data
@EqualsAndHashCode(callSuper = true)
public class UserVO extends UserDO {
// 额外定义的属性,用于封装一对一关系中的“一”方
private DepartmentDO department;
}

2. DepartmentVO (用于一对多): 封装“查询部门及其下属所有用户”的结果。

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

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

import com.example.mpstudy.domain.DepartmentDO;
import com.example.mpstudy.domain.UserDO;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.util.List;

@Data
@EqualsAndHashCode(callSuper = true)
public class DepartmentVO extends DepartmentDO {
// 额外定义的属性,用于封装一对多关系中的“多”方
private List<UserDO> users;
}

3.3.2. [实践] 一对一关联查询 (使用 <association>)

目标:查询一个用户,并将其所属的部门信息一并查出,封装到 UserVO 中。

第一步:在 UserMapper 中定义方法

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

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

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.mpstudy.domain.UserDO;
import com.example.mpstudy.domain.vo.UserVO;
import org.apache.ibatis.annotations.Param;

public interface UserMapper extends BaseMapper<UserDO> {

UserDO selectByName(@Param("name") String name);

/**
* 根据用户ID查询用户及其所属部门信息
* @param id 用户ID
* @return 包含部门信息的用户视图对象
*/
UserVO findUserWithDept(@Param("id") Long id);
}

第二步:在 UserMapper.xml 中配置映射

文件路径: src/main/resources/mapper/UserMapper.xml

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
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
<?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="com.example.mpstudy.mapper.UserMapper">

<!--
================================================================================
resultMap: 自定义结果映射
================================================================================
当数据库列名与 Java 对象的属性名不一致,或者需要处理复杂的关联关系(如一对一、一对多)时,
就需要使用 <resultMap> 手动定义映射规则。

- id: 此 resultMap 的唯一标识符。后续的 <select> 标签可以通过这个 id 来引用它。
- type: 映射的目标 Java 对象的完全限定类名。这里表示查询结果最终要封装成一个 `UserVO` 对象。
-->
<resultMap id="UserWithDeptResultMap" type="com.example.mpstudy.domain.vo.UserVO">
<id property="id" column="user_id"/>
<result property="name" column="user_name"/>
<result property="age" column="user_age"/>
<result property="email" column="user_email"/>

<!--
================================================================================
association: 关联对象映射 (一对一)
================================================================================
用于处理“有一个”(has-one)的关联关系。在这里,一个 UserVO 对象关联一个 DepartmentDO 对象。

- property: 指定这个关联对象在主对象 (UserVO) 中的属性名。
即 UserVO 类中必须有一个 `private DepartmentDO department;` 这样的字段。
- javaType: 指定关联对象的具体类型,即 `department` 属性的完全限定类名。
MyBatis 会实例化这个 `DepartmentDO` 对象,并将相关数据填充进去。
-->
<association property="department" javaType="com.example.mpstudy.domain.DepartmentDO">
<!--
在 <association> 内部,定义的是关联对象 (DepartmentDO) 的属性映射规则。
这里的 property 指的是 DepartmentDO 类的属性,column 指的是 SQL 结果集中代表部门信息的列。
-->
<id property="id" column="dept_id"/>
<result property="name" column="dept_name"/>
</association>
</resultMap>

<!--
定义查询语句
- id: 唯一标识,对应 Mapper 接口中的方法名 `findUserWithDept`。
- resultMap: 引用上面定义的 <resultMap> 的 id。
这告诉 MyBatis,执行完这条 SQL 后,请使用名为 "UserWithDeptResultMap" 的规则来封装结果。
-->
<select id="findUserWithDept" resultMap="UserWithDeptResultMap">
SELECT
u.id AS user_id,
u.name AS user_name,
u.age AS user_age,
u.email AS user_email,
d.id AS dept_id,
d.name AS dept_name
FROM
tb_user u
LEFT JOIN
tb_department d ON u.department_id = d.id
WHERE
u.id = #{id}
</select>
</mapper>

第三步:编写单元测试

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

1
2
3
4
5
6
7
// UserMapperTest.java
@Test
void testFindUserWithDept() {
UserVO userWithDept = userMapper.findUserWithDept(1L);
System.out.println("查询到的用户信息: " + userWithDept.getName());
System.out.println("该用户所属的部门: " + userWithDept.getDepartment());
}

3.3.3. [实践] 一对多关联查询 (使用 <collection>)

目标:查询一个部门,并将其下属的所有用户信息一并查出,封装到 DepartmentVO 中。

第一步:创建 DepartmentMapper 接口及方法

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

1
2
3
4
5
6
7
8
9
10
11
package com.example.mpstudy.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.mpstudy.domain.DepartmentDO;
import com.example.mpstudy.domain.vo.DepartmentVO;
import org.apache.ibatis.annotations.Param;

public interface DepartmentMapper extends BaseMapper<DepartmentDO> {

DepartmentVO findDeptWithUsers(@Param("id") Long id);
}

第二步:编写 DepartmentMapper.xml

文件路径: src/main/resources/mapper/DepartmentMapper.xml

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
34
<?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="com.example.mpstudy.mapper.DepartmentMapper">

<resultMap id="DeptWithUserResultMap" type="com.example.mpstudy.domain.vo.DepartmentVO">
<id property="id" column="dept_id"/>
<result property="name" column="dept_name"/>

<collection property="users" ofType="com.example.mpstudy.domain.UserDO">
<id property="id" column="user_id"/>
<result property="name" column="user_name"/>
<result property="age" column="user_age"/>
<result property="email" column="user_email"/>
</collection>
</resultMap>

<select id="findDeptWithUsers" resultMap="DeptWithUserResultMap">
SELECT
d.id AS dept_id,
d.name AS dept_name,
u.id AS user_id,
u.name AS user_name,
u.age AS user_age,
u.email AS user_email
FROM
tb_department d
LEFT JOIN
tb_user u ON d.id = u.department_id
WHERE
d.id = #{id}
</select>
</mapper>

第三步:编写单元测试

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

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

import com.example.mpstudy.domain.vo.DepartmentVO;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest
public class DepartmentMapperTest {

@Autowired
private DepartmentMapper departmentMapper;

@Test
void testFindDeptWithUsers() {
DepartmentVO deptWithUsers = departmentMapper.findDeptWithUsers(1L);
System.out.println("部门名称: " + deptWithUsers.getName());
System.out.println("该部门下的用户列表: ");
deptWithUsers.getUsers().forEach(System.out::println);
}
}

3.3.4. [面试题] 在 Mybatis-Plus 中如何优雅地处理多表关联查询?

多表查询策略
2025-08-22 16:20

在使用 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 在企业级应用中的强大功能:

  1. 首先,我们将学习 ActiveRecord 模式,体验一种让实体类“拥有生命”的编程范式。
  2. 接着,我们将掌握 分页查询,学习如何配置分页并快速应用到项目中
  3. 最后,我们将掌握 SimpleQuery 工具类,学习如何用一行代码完成对查询结果集的常见转换操作。

4.1. ActiveRecord 模式

在前面的章节中,我们已经习惯了通过 MapperService 来操作数据 (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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package com.example.mpstudy.domain;

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.extension.activerecord.Model;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;

import java.time.LocalDateTime;

@Data
@Accessors(chain = true) // 支持链式调用
@TableName("tb_user")
@EqualsAndHashCode(callSuper = true) // 1. 继承Model时,为Lombok添加此注解
// 2. 继承 Model<UserDO>
public class UserDO extends Model<UserDO> {
@TableId(type = IdType.AUTO)
// 属性保持不变..
}

第二步:编写单元测试

为了演示 AR 模式,我们创建一个新的测试类 ActiveRecordTest

文件路径: 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
24
25
26
27
28
29
30
31
32
33
34
package com.example.mpstudy;

import com.example.mpstudy.domain.UserDO;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest
public class ActiveRecordTest {

@Test
void testActiveRecordCRUD() {
// 1. 新增操作
UserDO newUser = new UserDO();
newUser.setName("AR User")
.setAge(35)
.setEmail("ar@example.com");
boolean insertSuccess = newUser.insert(); // 直接在实体对象上调用insert()
System.out.println("新增是否成功: " + insertSuccess + ", 回填的ID为: " + newUser.getId());

// 2. 查询操作
UserDO queryUser = new UserDO();
UserDO resultUser = queryUser.selectById(newUser.getId()); // 使用任意一个实例(甚至新实例)调用静态方法
System.out.println("查询到的用户: " + resultUser);

// 3. 更新操作
resultUser.setName("AR User Updated");
boolean updateSuccess = resultUser.updateById(); // 在查询出的对象上调用updateById()
System.out.println("更新是否成功: " + updateSuccess);

// 4. 删除操作
boolean deleteSuccess = resultUser.deleteById(); // 在查询出的对象上调用deleteById()
System.out.println("删除是否成功: " + deleteSuccess);
}
}

4.2. 分页查询: PaginationInnerInterceptor

在结束了 ActiveRecord 模式的探讨之后,我们来解决另一个在 Web 开发中无处不在的核心需求:分页查询。几乎所有的列表页面都需要分页,以避免一次性加载海量数据导致的性能问题和糟糕的用户体验。

痛点背景: 在原生 MyBatis 中实现分页是一件相当繁琐的事情。开发者通常需要手动编写两条 SQL:一条使用 LIMIT 关键字查询当前页的数据,另一条使用 SELECT COUNT(*) 查询总记录数。这两条 SQL 必须保持查询条件的一致性,维护起来非常不便且容易出错。

解决方案: Mybatis-Plus 提供了PaginationInnerInterceptor分页插件,它能完美地解决这个问题。我们只需要通过简单的配置启用它,MP 就会自动拦截我们的查询请求,并以“无感”的方式将其改造为物理分页查询。它会自动帮我们完成两件事:

  1. 在原始 SQL 后拼接 LIMIT 子句。
  2. 在执行数据查询前,自动发送一条 COUNT 查询以获取总记录数。

第一步:配置分页插件

要启用分页功能,我们必须在 MybatisPlusInterceptor 中注册 PaginationInnerInterceptor

文件路径: 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
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 {

@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 1. 添加分页插件,并指定数据库类型为 MySQL
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
}

第二步:在单元测试中使用分页

配置完成后,我们就可以在代码中使用分页功能了。核心是使用 Page 对象来承载分页参数和查询结果。

文件路径: src/test/java/com/example/mpstudy/mapper/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
24
25
26
// UserMapperTest.java (添加新的测试方法)
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.example.mpstudy.domain.UserDO;

@Test
void testSelectPage() {
// 1. 创建 Page 对象
// Page<>(current, size): 构造函数接收两个参数
// current: 当前页码,从 1 开始
// size: 每页显示条数
Page<UserDO> page = new Page<>(1, 2);

// 2. 执行分页查询
// selectPage 方法接收 Page 对象和一个 Wrapper 作为参数
// MP插件会自动将 Page 参数转换为 LIMIT 子句,并执行 COUNT 查询
userMapper.selectPage(page, null); // 此处 Wrapper 为 null,表示查询所有

// 3. 从 Page 对象中获取分页信息
System.out.println("----- 分页查询结果 -----");
System.out.println("总记录数: " + page.getTotal());
System.out.println("总页数: " + page.getPages());
System.out.println("当前页码: " + page.getCurrent());
System.out.println("每页条数: " + page.getSize());
System.out.println("当前页数据: ");
page.getRecords().forEach(System.out::println);
}

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
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
@Test
void testListAndMap() {
// 1. 使用 SimpleQuery.list(查询条件, 字段映射函数)
// 需求:查询所有用户的姓名列表
// 指定UserDO泛型
// 能让编译器明确操作对象是UserDO实体类,保证类型安全
// 基于 Java 8 Lambda 特性避免硬编码字段名
// 还可利用 MyBatis - Plus 机制根据实体类与数据库表映射关系执行查询 。
List<String> nameList = SimpleQuery.list(new LambdaQueryWrapper<UserDO>()
, UserDO::getName);

System.out.println("--- 所有用户的姓名列表 ---");
System.out.println(nameList);

// 2. 使用 SimpleQuery.keyMap 将列表转换为ID-对象的Map
// 需求:获取一个以用户ID为键,用户对象为值的Map,便于快速查找
Map<Long, UserDO> userMap = SimpleQuery.keyMap(new LambdaQueryWrapper<UserDO>()
, UserDO::getId);
System.out.println("\n--- ID -> 用户的Map ---");
System.out.println(userMap);
System.out.println("ID为3的用户信息: " + userMap.get(3L));

// 3. 使用 SimpleQuery.map 将列表转换为ID-姓名的Map
// 需求:获取一个以用户ID为键,用户姓名为值的Map
Map<Long, String> idNameMap = SimpleQuery.map(new LambdaQueryWrapper<UserDO>(),
UserDO::getId,
UserDO::getName);
System.out.println("\n--- ID -> 姓名的Map ---");
System.out.println(idNameMap);

}

分组查询实践 (group)

SimpleQuery.group() 是一个特别有用的功能,可以替代 stream().collect(Collectors.groupingBy(...))

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// SimpleQueryTest.java

@Test
void testGroup() {
// 为了让分组效果更明显,我们先插入一个年龄重复的用户
UserDO newUser = new UserDO().setName("Tom Jr.").setAge(28).setEmail("tomjr@example.com");
newUser.insert();

// 需求:按年龄对所有用户进行分组
Map<Integer, List<UserDO>> ageGroupMap = SimpleQuery.group(new LambdaQueryWrapper<UserDO>(), UserDO::getAge);

System.out.println("--- 按年龄分组的用户列表 ---");
ageGroupMap.forEach((age, users) -> {
System.out.println("年龄: " + age + " -> " + users);
});

// 清理测试数据
newUser.deleteById();
}

第五章:[高级] 企业级核心特性

摘要: 本章将深入探讨 Mybatis-Plus 提供的一系列旨在提升数据健壮、安全性与可维护性的企业级核心特性。我们将从数据保护的视角出发,依次学习 逻辑删除乐观锁公共字段自动填充 的实现原理与最佳实践。最后,我们将解决企业应用中常见的 动态数据源 需求,学习如何通过 MP 优雅地实现读写分离或多租户数据隔离。

在本章中,我们将循序渐进,探索 Mybatis-Plus 在企业级应用中的强大功能:

  1. 首先,我们将聚焦于 逻辑删除,学习如何安全地“删除”数据,同时保留其历史追溯性。
  2. 接着,我们将深入 乐观锁 机制,解决高并发场景下的数据一致性问题。
  3. 然后,我们将掌握 公共字段自动填充,将繁琐的审计字段(如创建/更新时间)交由框架自动管理。
  4. 最后,我们将挑战 动态数据源 的配置,为应用的水平扩展(如读写分离)打下坚实基础。

5.1. 逻辑删除

在掌握了基础的增删改查之后,我们必须重新审视一个基础却至关重要的操作——删除。在真实的生产环境中,直接从数据库中物理删除(DELETE FROM ...)记录通常是一种被严格禁止的高危行为。

痛点背景: 设想一个电商平台的业务场景:某位客户在双十一期间购买了一件商品,但随后申请退货并注销了账户。如果我们采用物理删除,直接删除了该客户的订单记录,那么到了年底进行财务审计和销售数据分析时,这笔曾经真实发生过的交易数据就彻底丢失了,这将直接导致报表不准确,甚至可能引发财务问题。数据一旦被物理删除,其业务价值和可追溯性便永久丧失

解决方案: Mybatis-Plus 提供了极其优雅的 逻辑删除 (Logical Delete) 方案。其核心思想是,将删除操作从 DELETE “偷换”为 UPDATE。我们不在数据库中真正删除该行数据,而是通过更新一个特定的状态字段(例如 is_deleted)来将其标记为“已删除”。对于业务代码而言,这个过程是完全透明的、无感的,我们调用的仍然是 deleteById() 等标准方法,但 Mybatis-Plus 插件会在底层自动将 SQL 语句进行转换。

这样做的好处是显而易见的:

  • 数据安全: 数据实体始终保留在数据库中,杜绝了误删除导致的数据灾难。
  • 可追溯性: 所有记录,无论状态如何,都可用于数据分析、审计和问题排查。

5.1.1. 实践:为项目集成逻辑删除

现在,让我们通过三个步骤,为我们的 tb_user 表集成逻辑删除功能。

第一步:修改数据库表结构

我们需要为 tb_user 表添加一个用于标记删除状态的字段。

这一步我们在创建库表的时候就做过了,这边再重复一遍以便完整流程

1
2
-- 为用户表添加逻辑删除标志字段
ALTER TABLE `tb_user` ADD COLUMN `is_deleted` tinyint(1) UNSIGNED NOT NULL DEFAULT '0' COMMENT '逻辑删除标志(0-未删除, 1-已删除)' AFTER `version`;

设计规约: 我们遵循业界通用实践,使用 is_deleted 字段,类型为 TINYINT(1)0 代表未删除(有效状态),1 代表已删除(无效状态)。

第二步:修改实体类 (UserDO.java)

在实体类中添加对应的属性,并使用 @TableLogic 注解来开启逻辑删除功能。

文件路径: 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
17
18
19
20
21
22
// ...
import com.baomidou.mybatisplus.annotation.TableLogic;
// ...

@Data
@Accessors(chain = true)
@TableName("tb_user")
@EqualsAndHashCode(callSuper = true)
public class UserDO extends Model<UserDO> {
// ... 其他字段 ...

/**
* 逻辑删除标志(0-未删除;1-已删除)
*/
// @TableLogic 是启用逻辑删除的关键注解
// value = "0": 指定实体在“未删除”状态下,此字段在数据库中的值。
// delval = "1": 指定实体在“已删除”状态下,此字段在数据库中的值。
@TableLogic(value = "0", delval = "1")
private Integer isDeleted;

// ... 其他字段 ...
}

第三步:编写单元测试进行验证

我们通过一个单元测试来直观地感受逻辑删除的“魔力”。

文件路径: src/test/java/com/example/mpstudy/AdvancedFeatureTest.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
package com.example.mpstudy;

import com.example.mpstudy.domain.UserDO;
import com.example.mpstudy.mapper.UserMapper;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import static org.junit.jupiter.api.Assertions.assertNull;

@SpringBootTest
public class AdvancedFeatureTest {

@Autowired
private UserMapper userMapper;

@Test
void testLogicalDelete() {
System.out.println("----- 开始执行逻辑删除测试 -----");
// 我们选择删除 ID 为 5 的用户 Billie
int affectedRows = userMapper.deleteById(5L);
System.out.println("逻辑删除影响的行数: " + affectedRows);

// 验证1:执行删除后,标准的 selectById 查询将无法再找到该用户
System.out.println("\n----- 验证标准查询 -----");
UserDO userAfterDelete = userMapper.selectById(5L);
System.out.println("逻辑删除后,再次查询用户(ID=5)的结果: " + userAfterDelete);
assertNull(userAfterDelete, "用户应已被逻辑删除,无法通过标准查询找到");

System.out.println("----- 逻辑删除测试执行完毕 -----");
}
}

结果分析:

  • SQL 变形: 从输出可以清晰地看到,我们调用的 deleteById() 方法,最终生成的 SQL 并不是 DELETE,而是 UPDATE tb_user SET is_deleted=1 WHERE ...
  • 查询过滤: 验证查询时,Mybatis-Plus 自动在 WHERE 条件中拼接了 AND is_deleted=0,这保证了业务代码在不知不觉中已经过滤掉了所有被“删除”的数据。

5.1.2 全局配置 vs 实体配置

配置方法: 直接在实体类的逻辑删除字段上添加

1
@TableLogic(value="0", delval="1")
  • 优点

    • 灵活性高: 每个实体可独立定义字段名与值,适用于不同删除规约。
    • 代码即文档: 规则写在实体类中,一目了然。
  • 缺点

    • 代码重复: 若项目统一规约,每个实体都得重复写一次。

配置方法: 在 application.yml 中添加全局配置
文件路径:src/main/resources/application.yml

1
2
3
4
5
6
mybatis-plus:
global-config:
db-config:
logic-delete-field: isDeleted # 全局逻辑删除字段名(实体属性)
logic-delete-value: 1 # 标记为已删除
logic-not-delete-value: 0 # 标记为未删除
  • 优点

    • 一处配置,全局生效: 统一标准的大型项目省时省力。
    • 约定优于配置: 新实体无需额外注解即可自动支持逻辑删除。
  • 缺点

    • 灵活性低: 若个别表有特需字段或值,全局配置无法满足。

最佳实践: 推荐采用 全局配置 的方式。它能强制项目遵循统一的数据设计规约,提升代码的一致性和可维护性。只有当遇到不符合全局规约的特殊表时,才在对应实体上使用 @TableLogic 注解进行覆盖。


5.1.3. 逻辑删除下的数据恢复

既然数据只是被标记,那么“恢复”数据也就变得非常简单。我们只需要将 is_deleted 字段的值从 1 更新回 0 即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// AdvancedFeatureTest.java (添加新的测试方法)

import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import static org.junit.jupiter.api.Assertions.assertNotNull;

@Test
void testRecoverLogicallyDeletedUser() {
// 前置条件:先逻辑删除一个用户,比如 ID=4 的 Sandy
userMapper.deleteById(4L);

System.out.println("----- 开始执行数据恢复测试 -----");
// 恢复方案:使用 UpdateWrapper 直接更新 is_deleted 字段
// 注意:这里的 set("is_deleted", 0) 使用的是数据库列名
// eq("id", 4L) 使用的是实体属性名
boolean isSuccess = userService.update(
new UpdateWrapper<UserDO>().set("is_deleted", 0).eq("id", 4L)
);
System.out.println("恢复数据是否成功: " + isSuccess);

// 验证恢复结果
UserDO recoveredUser = userMapper.selectById(4L);
System.out.println("恢复后查询到的用户(ID=4): " + recoveredUser);
assertNotNull(recoveredUser, "用户数据应已成功恢复");
}

🤔 思考一下

逻辑删除虽然极大地提升了数据安全性,但它也引入了一个经典问题:唯一索引(UNIQUE KEY)

假设 tb_user 表的 email 字段上有一个唯一索引。当用户 Joneemailtest1@baomidou.com)被逻辑删除后,is_deleted 变为 1。此时,如果一个新用户尝试使用相同的邮箱 test1@baomidou.com 进行注册,数据库层面会发生什么?我们应该如何设计表结构来解决这个问题?

问题所在: 数据库的 UNIQUE 索引默认会对整个表生效。即使 Jone 的记录已被逻辑删除,该行数据依然存在,其 email 字段的值 test1@baomidou.com 仍然占据着这个唯一键。新用户注册时,数据库会因为检测到 email 值重复而抛出 Duplicate entry 异常,导致注册失败。

解决方案:

  1. 复合唯一索引: 最常见的解决方案是将唯一索引建立在业务字段和逻辑删除字段的组合上。例如,将索引从 UNIQUE(email) 修改为 UNIQUE(email, is_deleted)。这样,('test1@baomidou.com', 0)('test1@baomidou.com', 1) 就被视为两个不同的键,允许新用户注册。
  2. 特殊处理删除值: 另一种技巧是,在逻辑删除用户时,不仅更新 is_deleted 字段,同时将 email 字段的值修改为一个特殊格式的、永远不会重复的值,例如 test1@baomidou.com_deleted_时间戳
  3. 数据库特性: 某些数据库(如 PostgreSQL)支持“部分索引”或“筛选索引”,可以创建只对 WHERE is_deleted = 0 的行生效的唯一索引,这是更优雅的解决方案。

在设计表结构时,必须提前考虑到逻辑删除对唯一约束的影响。


5.1.4 本节小结

  • 核心思想: 逻辑删除通过将 DELETE 操作转换为 UPDATE 操作,实现了数据的“软删除”,极大地保障了生产数据的安全性和可追溯性。
  • 实现方式: 可通过在实体字段上添加 @TableLogic 注解(灵活、局部)或在 application.yml 中进行全局配置(统一、高效),推荐后者。
  • 无感集成: 一旦配置成功,所有 Mybatis-Plus 内置的查询方法都会自动在 WHERE 子句中加入逻辑删除字段的过滤条件(如 AND is_deleted = 0),对业务代码完全透明。

5.2. 乐观锁

上一节,我们学习了如何通过逻辑删除保护数据免于“丢失”;本节,我们将探讨如何保护数据免于在高并发场景下被“写坏”。这是保障数据一致性的核心议题。

痛点背景: 想象一个典型的电商秒杀场景:一件热门商品的库存仅剩 1 件。在同一瞬间,用户 A 和用户 B 都看到了库存为 1 并同时点击了“购买”按钮。他们的请求几乎同时到达服务器。

  1. 时刻 1: A 的请求线程读取数据库,获取到商品库存为 1
  2. 时刻 2: B 的请求线程也读取数据库,获取到商品库存同样为 1
  3. 时刻 3: A 的线程执行扣减逻辑 (1 - 1 = 0),并将库存 0 写入数据库,下单成功。
  4. 时刻 4: B 的线程也执行扣减逻辑 (1 - 1 = 0),并将库存 0 写入数据库,也提示下单成功。

最终结果是,两个用户都成功下单,但库存却变成了 0,系统出现了超卖!A 用户的更新操作,被 B 用户的更新操作无情地覆盖了,这就是经典的“更新丢失”问题。

解决方案: 为了解决这类问题,我们通常会引入“锁”的机制。但传统的数据库悲观锁(悲观锁是一种并发控制策略,它假定数据在处理过程中很可能被其他事务修改,所以在操作数据前先加锁,阻止其他事务对该数据进行修改,直到当前事务结束才释放锁 。)会长时间锁定数据行,导致其他线程阻塞,在高并发下性能极差。

因此,Mybatis-Plus 采纳了一种更为高效的 乐观锁 方案。它不依赖数据库的锁机制,而是通过在表中增加一个 version (版本号) 字段来实现。其核心思想是:

  1. 读取数据时: 同时读取出当前的 version 值。
  2. 更新数据时: 在 UPDATE 语句的 WHERE 条件中,额外增加一个 version 值的匹配,即 WHERE id = ? AND version = [读取时的version值]
  3. 同时,在 SET 子句中,将 version 值加 1

如果更新成功(影响行数为 1),说明在此期间没有其他线程修改过数据。如果更新失败(影响行数为 0),则意味着在我准备更新的这段时间里,有另一个线程已经修改了数据并增加了 version 值,导致 WHERE 条件不匹配。此时,当前更新操作就会失败,从而避免了覆盖他人的修改。

Mybatis-Plus 将这一整套复杂的流程,简化为了一个插件和一个注解,开发者几乎无需关心底层实现。


5.2.1. 实践:集成乐观锁功能

接下来,我们通过三个步骤为项目启用乐观锁。

第一步:修改数据库表结构

tb_user 表添加 version 字段,用于实现乐观锁。

1
2
-- 为用户表添加 version 字段
ALTER TABLE `tb_user` ADD COLUMN `version` int UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观锁版本号' AFTER `email`;

设计规约: version 字段通常为 INTBIGINT 类型,必须设置 NOT NULL,并建议默认值为 1,代表数据的第一版。

第二步:修改实体类 (UserDO.java)

在实体类中添加 version 属性,并使用 @Version 注解标记。

文件路径: 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
17
18
19
// ...
import com.baomidou.mybatisplus.annotation.Version;
// ...

@Data
@Accessors(chain = true)
@TableName("tb_user")
@EqualsAndHashCode(callSuper = true)
public class UserDO extends Model<UserDO> {
// ... 其他字段 ...

/**
* 乐观锁版本号
*/
@Version
private Integer version;

// ... 其他字段 ...
}

第三步:配置乐观锁插件

和分页插件一样,乐观锁功能也需要通过拦截器来启用。

文件路径: 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
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.OptimisticLockerInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class MybatisPlusConfig {

@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();

// 1. 添加乐观锁插件
interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());

// 2. 添加分页插件 (确保分页插件在乐观锁之后,或其他插件之后)
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));

return interceptor;
}
}

5.2.2. 单元测试:模拟并发冲突

现在,万事俱备。我们将通过一个单元测试来模拟“更新丢失”的场景,并验证乐观锁是否能成功阻止它。

文件路径: src/test/java/com/example/mpstudy/AdvancedFeatureTest.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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
package com.example.mpstudy;

import com.example.mpstudy.domain.UserDO;
import com.example.mpstudy.mapper.UserMapper;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;

import static org.junit.jupiter.api.Assertions.assertEquals;

@SpringBootTest
public class AdvancedFeatureTest {

private UserMapper userMapper;

@Test
void testOptimisticLockConflict() {
System.out.println("----- 开始执行乐观锁冲突测试 -----");
// 场景:模拟两个管理员同时修改用户信息

// 1. 管理员A 先查询出用户 Jone 的信息
UserDO userA = userMapper.selectById(1L);
System.out.println("管理员A 查询到 Jone 的版本号: " + userA.getVersion());

// 2. 在A准备修改时,管理员B 也查询了 Jone 的信息
UserDO userB = userMapper.selectById(1L);
System.out.println("管理员B 查询到 Jone 的版本号: " + userB.getVersion());

// 3. 管理员B 手速更快,先完成了修改,将 Jone 的年龄改为 19
userB.setAge(19);
int resultB = userMapper.updateById(userB);
System.out.println("\n管理员B 尝试更新...");
System.out.println("管理员B 更新结果 (影响行数): " + resultB);
System.out.println("管理员B 更新后,Jone 的新版本号: " + userB.getVersion());


// 4. 此时,管理员A 才提交自己的修改,尝试将 Jone 的邮箱改为 prorise@example.com
userA.setEmail("prorise@example.com");
int resultA = userMapper.updateById(userA); // userA 对象中持有的还是旧的 version
System.out.println("\n管理员A 尝试更新...");
System.out.println("管理员A 更新结果 (影响行数): " + resultA);


// 5. 最终验证:查询数据库的最终结果
UserDO finalUser = userMapper.selectById(1L);
System.out.println("\n----- 最终结果验证 -----");
System.out.println("数据库中 Jone 的最终年龄: " + finalUser.getAge());
System.out.println("数据库中 Jone 的最终邮箱: " + finalUser.getEmail());
System.out.println("数据库中 Jone 的最终版本号: " + finalUser.getVersion());

// 断言:管理员A的更新失败了,所以邮箱应该还是旧的
assertEquals(19, finalUser.getAge(), "年龄应该被B成功修改为19");
assertEquals("test1@baomidou.com", finalUser.getEmail(), "A的修改应失败,邮箱不应改变");
}
}

结果分析:

  • B 的更新: 管理员 B 基于 version = 1 进行更新,WHERE 条件匹配成功,数据被更新,同时 version 自动递增为 2
  • A 的更新: 管理员 A 仍然基于他最初读取的 version = 1 去尝试更新。但此时数据库中的 version 已经是 2 了,WHERE id=1 AND version=1 条件无法匹配到任何记录,所以更新失败,影响行数为 0
  • 最终一致性: 数据库的最终状态正确地反映了 B 的修改,而 A 的“过时”修改被成功阻止,数据的最终一致性得到了保证

🤔 思考一下

当乐观锁更新失败时(即 updateById 返回 0),对于应用程序来说,这意味着一次业务操作的失败。那么,在这种情况下,我们应该如何处理?仅仅简单地向用户抛出一个“操作失败,请重试”的提示就足够了吗?有没有更完善的处理机制?

问题所在: 直接向用户抛出通用失败提示,用户体验较差。用户不知道失败的原因,反复重试可能依然会失败。

更完善的处理机制:

  1. 自动重试: 这是最常见的处理方式。可以在业务逻辑中加入重试机制。当检测到更新返回 0 时,程序可以:
    • 重新从数据库查询一次最新的数据(包含了最新的 version 值)。
    • 在新的数据对象上,重新执行刚才的业务修改逻辑。
    • 再次尝试提交更新。
    • 为了防止无限重试,通常会设置一个最大重试次数(如 3 次),超过次数后如果仍然失败,再向用户报告错误。
  2. 业务逻辑判断: 并非所有场景都适合重试。例如,在库存扣减场景中,如果重试时发现最新库存已经为 0,那么正确的逻辑就不是重试更新,而是直接告诉用户“商品已售罄”。
  3. 用户友好提示: 即便最终需要用户重试,也应该提供更明确的提示,例如:“您操作的数据已被他人修改,页面已为您刷新,请在最新数据上重新操作。”

总结来说,乐观锁的失败处理不仅仅是一个技术问题,更是一个业务逻辑问题。开发者需要根据具体的业务场景,来决定是应该自动重试、放弃操作还是引导用户手动处理。


5.2.3 本节小结

  • 核心思想: 乐观锁通过引入 version 字段,以一种无阻塞的的方式解决了高并发下的“更新丢失”问题,是保障数据一致性的重要手段。

实现三步走:

  1. 数据库表中添加 version 字段;
  2. 实体类中添加 @Version 注解;
  3. MybatisPlusConfig 中注册 OptimisticLockerInnerInterceptor 拦截器。
  4. 工作原理: MP 会自动在 UPDATE 语句的 SET 子句中将 version 加一,并在 WHERE 子句中比对原始 version 值。如果 version 不匹配,更新操作将失败(影响行数为 0),从而阻止脏写。

5.3. 自动填充公共字段

我们已经学习了如何保护数据不被丢失(逻辑删除)和不被并发写坏(乐观锁)。现在,我们将目光投向另一个维度:如何提升数据的完整性与可追溯性,同时将开发者从重复的模板代码中解放出来。

痛点背景: 几乎在所有的业务数据表中,我们都会设计一些“审计字段”,例如 create_time (创建时间), update_by (修改人) 等。在没有自动化机制的情况下,开发者需要在每一个 insertupdate 的业务方法中,手动设置这些值。这种方式不仅高度重复、容易遗漏,也违反了DRY(Don’t Repeat Yourself)原则

解决方案: Mybatis-Plus 提供了 `MetaObjectHandler` (元数据对象处理器) 这一优雅的AOP(面向切面编程)解决方案。我们可以创建一个全局的处理器,它会自动拦截 Mybatis-Plus 执行的 insertupdate 操作,并为我们指定的字段赋予预设值。

为了清晰地展示 MP 的能力,我们将新增 create_operator (创建人) 和 update_operator (最后修改人) 两个字段来进行演示,这两个字段在数据库层面没有任何自动行为。


5.3.1. 实践:实现操作人信息的自动填充

接下来,我们将通过三个核心步骤,为项目实现操作人字段的自动填充。

第一步:修改数据库表结构

我们为 tb_user 表添加两个新的 VARCHAR 字段,用于记录操作员信息。

1
2
3
4
-- 为用户表添加操作人审计字段
ALTER TABLE `tb_user`
ADD COLUMN `create_operator` VARCHAR(50) NULL COMMENT '创建人' AFTER `gmt_modified`,
ADD COLUMN `update_operator` VARCHAR(50) NULL COMMENT '最后修改人' AFTER `create_operator`;

请注意,这两个新字段在数据库层面是完全普通的 VARCHAR 字段,没有设置任何默认值或自动更新触发器。这样可以确保后续的填充效果完全来自于我们的应用程序。

第二步:在实体类中标记填充时机

现在,我们修改 UserDO.java,添加对应的属性,并使用 @TableField(fill = ...) 注解来“激活”自动填充。

文件路径: 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
17
18
19
20
21
22
23
24
25
26
// ...
import com.baomidoumybatisplus.annotation.FieldFill;
import com.baomidoumybatisplus.annotation.TableField;
// ...

@Data
@Accessors(chain = true)
@TableName("tb_user")
@EqualsAndHashCode(callSuper = true)
public class UserDO extends Model<UserDO> {
// ... 其他字段 ...

/**
* 创建人
*/
// FieldFill.INSERT: 指定该字段在“插入”时进行填充
@TableField(fill = FieldFill.INSERT)
private String createOperator;

/**
* 最后修改人
*/
// FieldFill.INSERT_UPDATE: 指定该字段在“插入”和“更新”时都进行填充
@TableField(fill = FieldFill.INSERT_UPDATE)
private String updateOperator;
}

第三步:创建 MetaObjectHandler 实现类

这是自动填充的核心逻辑所在。我们需要创建一个类来实现 MetaObjectHandler 接口,并将其注册为 Spring Bean。

文件路径: 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
31
32
33
package com.example.mpstudy.handler;

import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.reflection.MetaObject;
import org.springframework.stereotype.Component;

import java.time.LocalDateTime;

@Slf4j
@Component // 必须将处理器注册为 Spring Bean
public class MyMetaObjectHandler implements MetaObjectHandler {

/**
* 在执行 insert 操作时,此方法会被调用
*/
@Override
public void insertFill(MetaObject metaObject) {
log.info("start insert fill ....");
// 注意:在真实项目中,"SYSTEM" 这个值应该从用户上下文中动态获取
this.strictInsertFill(metaObject, "createOperator", String.class, "SYSTEM_INSERT");
this.strictInsertFill(metaObject, "updateOperator", String.class, "SYSTEM_INSERT");
}

/**
* 在执行 update 操作时,此方法会被调用
*/
@Override
public void updateFill(MetaObject metaObject) {
log.info("start update fill ....");
this.strictUpdateFill(metaObject, "updateOperator", String.class, "SYSTEM_UPDATE");
}
}

5.3.2. 单元测试:验证自动填充效果

现在,我们来编写一个全新的测试,验证我们的 operator 字段是否能被精确填充。

文件路径: src/test/java/com/example/mpstudy/AdvancedFeatureTest.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
@Test
void testAutoFillOperator() {
System.out.println("----- 开始执行操作人自动填充测试 -----");

// 1. 验证 INSERT 填充
UserDO newUser = new UserDO()
.setName("OperatorFillUser")
.setAge(50)
.setEmail("operatorfill@example.com");

// 我们没有手动设置 createOperator 和 updateOperator
userMapper.insert(newUser);

// 从数据库重新查询以获取最真实的数据
UserDO insertedUser = userMapper.selectById(newUser.getId());
System.out.println("插入后的用户信息: " + insertedUser);
assertEquals("SYSTEM_INSERT", insertedUser.getCreateOperator(), "创建人应被自动填充");
assertEquals("SYSTEM_INSERT", insertedUser.getUpdateOperator(), "修改人应在创建时被填充");

// 2. 验证 UPDATE 填充
System.out.println("\n----- 准备执行更新操作 -----");
insertedUser.setAge(51); // 修改一个字段
userMapper.updateById(insertedUser);

UserDO updatedUser = userMapper.selectById(insertedUser.getId());
System.out.println("更新后的用户信息: " + updatedUser);
}

5.3.3. [高级实战] 结合 ThreadLocal 填充动态操作人信息

痛点再探: 在上一节,我们在 MyMetaObjectHandler 中硬编码了操作人信息。但在真实业务中,create_operatorupdate_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
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
package com.example.mpstudy.handler;

/**
* 基于 ThreadLocal 的工具类,用于存储和获取当前操作员信息
*/
public class UserContextHolder {
// 创建一个静态的 ThreadLocal 变量,用于存储操作员名称
private static final ThreadLocal<String> operatorHolder = new ThreadLocal<>();

/**
* 设置当前操作员
* @param operatorName 操作员名称
*/
public static void setOperator(String operatorName) {
operatorHolder.set(operatorName);
}

/**
* 获取当前操作员
* @return 操作员名称
*/
public static String getOperator() {
return operatorHolder.get();
}

/**
* 清理当前线程的 ThreadLocal 数据
* 必须在请求处理完成后调用,以防内存泄漏
*/
public static void clear() {
operatorHolder.remove();
}
}

第二步:创建并注册 Web 拦截器

这个拦截器将在每个请求开始时捕获用户信息并存入 UserContextHolder,在请求结束时将其清理。

1. 创建拦截器实现类

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

import com.example.mpstudy.handler.UserContextHolder;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;

@Component
public class AuthenticationInterceptor implements HandlerInterceptor {

@Override
public boolean preHandle(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull Object handler) {
// --- 模拟从请求头获取用户信息 ---
// 在真实项目中,这里会解析 JWT、Session 或其他认证凭证来获取用户
String operator = request.getHeader("X-Operator-Name");

// 如果请求头中有操作员信息,则存入 ThreadLocal
if (operator != null && !operator.isEmpty()) {
UserContextHolder.setOperator(operator);
}

// return true 表示继续执行后续的 Controller 逻辑
return true;
}

@Override
public void afterCompletion(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull Object handler, Exception ex) {
// --- 在请求处理完成后,必须清理 ThreadLocal ---
UserContextHolder.clear();
}
}

2. 注册拦截器

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

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

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

@Autowired
private AuthenticationInterceptor authenticationInterceptor;

@Override
public void addInterceptors(InterceptorRegistry registry) {
// 注册我们的认证拦截器,并让它拦截所有请求路径
registry.addInterceptor(authenticationInterceptor).addPathPatterns("/**");
}
}

第三步:改造 MyMetaObjectHandler

现在,让我们的填充处理器从 UserContextHolder 动态获取操作员信息,而不是使用硬编码的字符串。

文件路径: 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
// ... (imports)
import com.example.mpstudy.handler.UserContextHolder;
// ...

@Slf4j
@Component
public class MyMetaObjectHandler implements MetaObjectHandler {

@Override
public void insertFill(MetaObject metaObject) {
log.info("start insert fill ....");
// 动态获取当前操作员,如果获取不到(例如单元测试场景),则提供一个默认值
String operator = UserContextHolder.getOperator() != null ? UserContextHolder.getOperator() : "default-system";

this.strictInsertFill(metaObject, "createOperator", String.class, operator);
this.strictInsertFill(metaObject, "updateOperator", String.class, operator);
}

@Override
public void updateFill(MetaObject metaObject) {
log.info("start update fill ....");
String operator = UserContextHolder.getOperator() != null ? UserContextHolder.getOperator() : "default-system";
this.strictUpdateFill(metaObject, "updateOperator", String.class, operator);
}
}

健壮性设计: 在 MyMetaObjectHandler 中增加 null 判断是一个好习惯。这可以确保即使在非 Web 请求的上下文(如执行单元测试、定时任务等)中,自动填充功能也不会因为 getOperator() 返回 null 而抛出空指针异常。

至此,我们已经构建了一套完整的、生产级的动态审计字段填充方案。


5.3.4. 集成测试:验证动态填充效果

由于此功能依赖 Web 请求上下文,标准的单元测试无法触发拦截器。我们需要使用 MockMvc 来模拟一个真实的 HTTP 请求,进行集成测试。

第一步:创建一个简单的 UserController

我们需要一个 Controller 端点来接收我们的模拟请求。

文件路径: src/main/java/com/example/mpstudy/controller/UserController.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.controller;

import com.example.mpstudy.domain.UserDO;
import com.example.mpstudy.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/users")
public class UserController {

@Autowired
private UserService userService;

@PostMapping
public UserDO createUser(@RequestBody UserDO user) {
userService.save(user);
return user;
}
}

第二步:编写 MockMvc 测试

文件路径: src/test/java/com/example/mpstudy/controller/UserControllerTest.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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
package com.example.mpstudy.controller;

import com.example.mpstudy.domain.UserDO;
import com.example.mpstudy.mapper.UserMapper;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import org.springframework.test.web.servlet.result.MockMvcResultMatchers;

import static org.junit.jupiter.api.Assertions.assertEquals;

@SpringBootTest
@AutoConfigureMockMvc // 开启 MockMvc
class UserControllerTest {

@Autowired
private MockMvc mockMvc;

@Autowired
private UserMapper userMapper;

@Autowired
private ObjectMapper objectMapper;

@Test
void testCreateUserWithDynamicOperator() throws Exception {
// 1. 准备请求数据
UserDO newUser = new UserDO()
.setId(10001L)
.setName("DynamicFillUser")
.setAge(60);
String userJson = objectMapper.writeValueAsString(newUser);

// 2. 模拟 HTTP POST 请求
// 关键:在请求头中设置 X-Operator-Name,我们的拦截器将会捕获它
String operatorName = "ProriseAdmin";
mockMvc.perform(MockMvcRequestBuilders.post("/users")
.header("X-Operator-Name", operatorName)
.contentType(MediaType.APPLICATION_JSON)
.content(userJson))
.andExpect(MockMvcResultMatchers.status().isOk());

// 3. 从数据库验证结果
UserDO createdUser = userMapper.selectById(newUser.getId());
assertEquals(operatorName, createdUser.getCreateOperator(), "创建人应为请求头中指定的操作员");
assertEquals(operatorName, createdUser.getUpdateOperator(), "修改人应为请求头中指定的操作员");
}
}

通过 MockMvc 测试成功后,我们便完整地验证了从 Web 请求 -> 拦截器 -> ThreadLocal -> MetaObjectHandler -> 数据库 的整条链路。


5.3.5. 本节小结

  • 核心思想: 自动填充机制通过 AOP 思想,将公共审计字段的赋值逻辑从业务代码中剥离,实现了逻辑解耦自动化处理
  • 实现方式:
    • 静态填充: 通过 @TableField(fill = ...)MetaObjectHandler 实现固定值的填充。
    • 动态填充: 结合 HandlerInterceptor + ThreadLocal 的设计模式,可以安全地将 Web 层的动态上下文(如当前操作员)传递给 MetaObjectHandler,实现动态值的填充。
  • 关键实践: 在 HandlerInterceptorafterCompletion 方法中必须调用 ThreadLocal.remove() 来清理数据,这是防止内存泄漏的关键一步,也是生产级代码的必备规范。

5.4. 动态数据源

至此,我们探讨的所有特性都是在单个数据库源上对数据进行“精耕细作”。现在,我们要将视野拔高到架构层面,思考一个问题:当单一数据库实例的性能或业务隔离无法满足需求时,我们该怎么办?

痛点背景: 随着业务的飞速发展,单一数据库实例往往会成为整个系统的瓶颈。此时,我们会面临两种典型的架构演进需求:

  1. 读写分离: 在大多数应用中,读操作的频率远高于写操作。为了分摊数据库压力,一种经典的架构是将数据同步到一个或多个“从库”(Slave),实现主库写、从库读,从而极大地提升应用的并发承载能力。
  2. 多租户: 在SaaS应用中,为每个客户(租户)提供独立的数据库是一种常见的强隔离方案。应用程序需要根据当前登录的租户,动态地将SQL路由到对应的数据库。

解决方案: Mybatis-Plus 生态体系中功能强大的官方增强库—— dynamic-datasource-spring-boot-starter,以一种声明式、无侵入的方式完美解决了上述问题。我们只需在配置文件中定义好所有的数据源,然后在需要切换的地方加上一个简单的 @DS 注解,框架就会自动为我们完成所有底层的切换工作。


5.4.1. 实践:为项目配置读写分离

我们将以最常见的读写分离场景为例,来实践动态数据源的配置。

第一步:引入正确的 Spring Boot 3 依赖

根据您的指正,我们在 pom.xml 文件中,添加适配 Spring Boot 3 的动态数据源启动器。

文件路径: pom.xml

1
2
3
4
5
6
7
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>dynamic-datasource-spring-boot3-starter</artifactId>
<version>4.3.0</version>
</dependency>


第二步:准备从库(Slave)环境

这是成功测试的关键一步。我们必须创建一个与主库结构和数据完全一致的从库,用于模拟读操作。

重要操作: 请在您的 MySQL 服务中,新建一个名为 mybatis_plus_notes_slave 的数据库,然后执行以下完整的 SQL 脚本,以确保从库环境准备就绪。

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
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
USE `mybatis_plus_notes_slave`;

-- 设置会话的字符集为 utf8mb4
SET NAMES utf8mb4;
-- 暂时禁用外键约束检查,以便能顺利地删除和创建表
SET FOREIGN_KEY_CHECKS = 0;

-- ----------------------------
-- 1. 部门表 (tb_department)
-- ----------------------------
DROP TABLE IF EXISTS `tb_department`;
CREATE TABLE `tb_department` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`name` varchar(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '部门名称',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 3 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '部门表' ROW_FORMAT = Dynamic;

-- ----------------------------
-- 插入部门表初始数据
-- ----------------------------
INSERT INTO `tb_department` (`id`, `name`) VALUES
(1, '研发部'),
(2, '市场部');

-- ----------------------------
-- 2. 用户表 (tb_user)
-- ----------------------------
DROP TABLE IF EXISTS `tb_user`;
CREATE TABLE `tb_user` (
`id` bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`name` varchar(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '姓名',
`age` int(10) UNSIGNED NULL DEFAULT NULL COMMENT '年龄',
`email` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '邮箱',
`department_id` bigint(20) UNSIGNED NULL DEFAULT NULL COMMENT '部门ID',
`version` int(10) UNSIGNED NOT NULL DEFAULT 1 COMMENT '乐观锁版本号',
`is_deleted` tinyint(3) UNSIGNED NOT NULL DEFAULT 0 COMMENT '逻辑删除标志(0-未删除;1-已删除)',
`gmt_create` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`gmt_modified` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',
`create_operator` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '创建人',
`update_operator` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '最后修改人',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 10003 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '用户表' ROW_FORMAT = Dynamic;

-- ----------------------------
-- 插入用户表初始数据
-- ----------------------------
INSERT INTO `tb_user` (`id`, `name`, `age`, `email`, `department_id`, `version`, `is_deleted`, `gmt_create`, `gmt_modified`, `create_operator`, `update_operator`) VALUES
(1, 'Jone', 19, 'test1@baomidou.com', 1, 2, 0, '2025-08-22 14:23:04', '2025-08-22 15:42:39', NULL, NULL),
(2, 'Jack', 20, 'test2@baomidou.com', 1, 1, 0, '2025-08-22 14:23:04', '2025-08-22 15:42:39', NULL, NULL),
(3, 'Tom', 28, 'test3@baomidou.com', 1, 1, 0, '2025-08-22 14:23:04', '2025-08-22 15:42:39', NULL, NULL),
(4, 'Sandy', 21, 'test4@baomidou.com', 2, 1, 0, '2025-08-22 14:23:04', '2025-08-22 15:42:39', NULL, NULL),
(5, 'Billie', 24, 'test5@baomidou.com', 2, 1, 1, '2025-08-22 14:23:04', '2025-08-22 21:22:18', NULL, NULL),
(7, 'AutoFillUser', 41, 'autofill@example.com', NULL, 1, 0, '2025-08-22 22:43:11', '2025-08-22 22:43:11', NULL, NULL),
(8, 'OperatorFillUser', 51, 'operatorfill@example.com', NULL, 2, 0, '2025-08-22 22:51:38', '2025-08-22 22:51:38', 'SYSTEM_INSERT', 'SYSTEM_INSERT'),
(9, 'DynamicFillUser', 60, NULL, NULL, 1, 0, '2025-08-23 09:29:18', '2025-08-23 09:29:18', 'ProriseAdmin', 'ProriseAdmin'),
(10001, 'DynamicFillUser', 60, NULL, NULL, 1, 0, '2025-08-23 09:31:13', '2025-08-23 09:31:13', 'ProriseAdmin', 'ProriseAdmin'),
(10002, 'DynamicDSUser', 70, NULL, NULL, 1, 0, '2025-08-23 10:00:30', '2025-08-23 10:00:30', 'default-system', 'default-system');

-- 重新启用外键约束检查
SET FOREIGN_KEY_CHECKS = 1;

第三步:配置多数据源 (application.yml)

现在,我们重新组织 application.yml,清晰地定义一个主库和一个从库。

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

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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
# 服务器端口配置
server:
port: 8080

# Spring 相关配置
spring:
# [推荐] 开启虚拟线程以提高 I/O 密集型应用的性能 (需要 JDK 21+)
threads:
virtual:
enabled: true

# 动态数据源配置
# 注意:使用动态数据源时,不要再配置顶层的 spring.datasource.url 等属性
datasource:
dynamic:
# 设置主数据源,当没有使用 @DS 注解时,默认使用该数据源
primary: master
# 严格模式,如果使用的数据源 key 不存在,则会抛出异常 (默认为 false)
strict: true
# 在所有数据源中同步开启 p6spy (如果启用),用于SQL日志打印
p6spy: true
# 定义所有的数据源
datasource:
# 主库配置 (用于写操作)
master:
url: jdbc:mysql://127.0.0.1:3306/mybatis_plus_notes?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai
username: root
password: root
driver-class-name: com.mysql.cj.jdbc.Driver
# 从库-1 配置 (用于读操作)
slave_1:
url: jdbc:mysql://127.0.0.1:3306/mybatis_plus_notes_slave?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai
username: root
password: root
driver-class-name: com.mysql.cj.jdbc.Driver

# Mybatis-Plus 配置
mybatis-plus:
# 全局配置
global-config:
# 关闭启动时默认的 Mybatis-Plus Banner
banner: false
# MyBatis 核心配置
configuration:
# 开启数据库字段的下划线命名到 Java 实体类驼峰命名的自动转换
# 例如:数据库字段 user_name -> Java 实体类属性 userName
map-underscore-to-camel-case: true
# 配置SQL日志输出到控制台,方便开发调试
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

第四步:在 Service 层使用 @DS 注解

通过 @DS 注解,我们精确地将“写”操作绑定到主库,将“读”操作路由到从库。

文件路径: src/main/java/com/example/mpstudy/service/impl/UserServiceImpl.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
34
package com.example.mpstudy.service.impl;

import com.baomidou.dynamic.datasource.annotation.DS;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.example.mpstudy.domain.UserDO;
import com.example.mpstudy.mapper.UserMapper;
import com.example.mpstudy.service.UserService;
import org.springframework.stereotype.Service;

import java.io.Serializable;
import java.util.List;

// @DS("master"): 在类级别上指定默认数据源为主库。
// 这是一个安全的第一原则:确保所有未被特殊标记的方法(尤其是写操作)都走主库。
@DS("master")
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, UserDO> implements UserService {

// 继承的 save, update, remove 等所有写操作方法,将自动使用类级别指定的 'master' 数据源

@Override
// @DS("slave_1"): 在方法级别上覆盖类级别的注解。
// 将此查询方法显式路由到 'slave_1' 数据源,精准实现读写分离。
@DS("slave_1")
public UserDO getById(Serializable id) {
return super.getById(id);
}

@Override
@DS("slave_1")
public List<UserDO> list() {
return super.list();
}
}

5.4.2. 测试:验证数据源切换

为了验证数据源切换成功,我们将使用 dynamic-datasource 库提供的上下文持有器 DynamicDataSourceContextHolder,并调用其正确的 peek() 方法来获取当前数据源名称。

1. 临时为 Service 添加日志(用于测试验证)

文件路径: src/main/java/com/example/mpstudy/service/impl/UserServiceImpl.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
34
35
36
37
38
39
40
41
42
43
package com.example.mpstudy.service.impl;

import com.baomidou.dynamic.datasource.annotation.DS;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.example.mpstudy.domain.UserDO;
import com.example.mpstudy.mapper.UserMapper;
import com.example.mpstudy.service.UserService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import com.baomidou.dynamic.datasource.toolkit.DynamicDataSourceContextHolder;
import java.io.Serializable;
import java.util.List;

// @DS("master"): 在类级别上指定默认数据源为主库
// 这是一个最佳实践,可以确保所有写操作(insert, update, delete)默认走主库,保证数据安全。
@DS("master")
@Service
@Slf4j
public class UserServiceImpl extends ServiceImpl<UserMapper, UserDO> implements UserService {

// 继承的 save, update, remove 等写操作方法,将自动使用类级别指定的 'master' 数据源

// (重写 save 方法以添加日志)
@Override
public boolean save(UserDO entity) {
// 调用正确的 peek() 方法获取当前数据源
log.info("执行 save 操作,当前使用的数据源是: [{}]", DynamicDataSourceContextHolder.peek());
return super.save(entity);
}

@Override
@DS("slave_1")
public UserDO getById(Serializable id) {
log.info("执行 getById 操作,当前使用的数据源是: [{}]", DynamicDataSourceContextHolder.peek());
return super.getById(id);
}

@Override
@DS("slave_1")
public List<UserDO> list() {
return super.list();
}
}

2. 编写单元测试

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// UserServiceTest.java

@Test
void testReadWriteSplitting() {
System.out.println("----- 开始执行读写分离测试 -----");

// 1. 执行写操作 (save),应路由到 master 库
System.out.println("\n>>> 正在执行写操作...");
UserDO newUser = new UserDO().setName("DynamicDSUser").setAge(70);
userService.save(newUser);

// 2. 执行读操作 (getById),应路由到 slave_1 库
System.out.println("\n>>> 正在执行读操作...");
userService.getById(newUser.getId());

// 清理测试数据 (写操作),应路由到 master 库
System.out.println("\n>>> 正在执行删除操作...");
userService.removeById(newUser.getId());
}

结果分析:
日志输出清晰地证明了我们的修正已完全成功。写操作 (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-starterSpring Boot 3 环境下实现动态数据源的官方 starter。
spring.datasource.dynamicapplication.yml 中配置多数据源的根节点。

高频面试题与陷阱

乐观锁与悲观锁
2025-08-23

请解释一下乐观锁和悲观锁的区别,以及它们各自的适用场景。

好的。悲观锁认为并发冲突总会发生,所以它在读取数据时就通过数据库的锁机制(如FOR UPDATE)将数据锁定,直到事务结束才释放。这保证了数据绝对一致,但性能差,适用于写多读少的场景,如金融交易。

乐观锁则认为冲突是小概率事件,它在读取时不加锁,只在更新时通过版本号或时间戳等机制(CAS)去检查数据是否被修改过。如果冲突,则更新失败。它性能高,适用于读多写少的场景,如商品库存、用户信息修改等。Mybatis-Plus 的 @Version 就是乐观锁的典型实现。

很好,那么 Mybatis-Plus 的乐观锁更新失败后,业务层面应该如何处理?

一般会采用重试机制。捕获更新失败(返回影响行数为0),重新查询一次数据获得最新的版本号,再重新执行业务逻辑并尝试更新。为了防止死循环,通常会设置最大重试次数。


动态数据源与事务
2025-08-23

假设一个方法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 注解,让这种优雅在持久层得以延续。

第一步:准备数据库与枚举类

  1. tb_user 表添加 gender 字段,我们将用 TINYINT 类型来存储代表性别的数字。

    1
    2
    3
    use mybatis_plus_notes;
    -- 为用户表添加性别字段
    ALTER TABLE `tb_user` ADD COLUMN `gender` TINYINT(1) NULL COMMENT '性别(1-男, 0-女)' AFTER `age`;
  2. 创建 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
    26
    package com.example.mpstudy.domain.enums;

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

    @Getter
    public enum GenderEnum {
    WOMAN(0, "女"),
    MAN(1, "男");

    // @EnumValue 是关键注解,它告诉 Mybatis-Plus,在持久化时,
    // 应该使用这个字段(code)的值存入数据库。
    @EnumValue
    private final int code;

    // @JsonValue 注解用于 Spring MVC 返回 JSON 时,能将枚举序列化为我们想要的描述值 "女" 或 "男",
    // 而不是默认的枚举名 "WOMAN" 或 "MAN",可以提升前端API的友好性。
    @JsonValue
    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
2
3
4
5
6
7
8
9
10
11
12
// ...
import com.example.mpstudy.domain.enums.GenderEnum;

@Data
// ...
public class UserDO extends Model<UserDO> {
// ...
private Integer age;
private GenderEnum gender; // 直接使用强类型的枚举
private String email;
// ...
}

第三步:编写单元测试进行验证

我们通过测试来验证 Mybatis-Plus 是否能智能地完成 GenderEnum.MAN (Java对象) 与 1 (数据库值) 之间的自动转换。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Test
void testEnumHandler() {
System.out.println("----- 开始执行枚举类型处理器测试 -----");
UserDO newUser = new UserDO()
.setName("EnumUser")
.setAge(25)
.setGender(GenderEnum.MAN); // 业务代码中,我们操作的是类型安全的枚举

userMapper.insert(newUser);

UserDO storedUser = userMapper.selectById(newUser.getId());
System.out.println("从数据库查询并映射回来的用户: " + storedUser);

// 断言:从数据库取出的数据,已经被正确地转换回了 GenderEnum.MAN
assertEquals(GenderEnum.MAN, storedUser.getGender(), "枚举应被正确地写入和读出");
}

结果分析: 如 代码运行结果 所示,在执行 INSERT 语句时,Mybatis-Plus 自动读取了 @EnumValue 标记的 code 字段值 1 并将其作为参数存入数据库。在 SELECT 查询后,它又根据数据库中存储的 1 自动匹配并实例化了 GenderEnum.MAN 枚举。整个过程对业务代码完全透明,代码的可读性和健壮性得到了极大的提升。


6.1.2. 自定义类型处理器 (TypeHandler)

对于比枚举更复杂的结构,比如需要将一个 Map 对象存入数据库的 JSON 字段,我们需要动用 Mybatis 原生就支持、并被 MP 继承的强大工具——TypeHandler

第一步:准备数据库与实体

  1. tb_user 表添加 contact_info 字段,我们将其类型设置为 JSON,这是现代数据库处理半结构化数据的最佳实践。

    1
    2
    -- 为用户表添加联系方式JSON字段
    ALTER TABLE `tb_user` ADD COLUMN `contact_info` JSON NULL COMMENT '联系方式(JSON)' AFTER `gender`;
  2. 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;

    @Data
    @TableName("tb_user")
    @EqualsAndHashCode(callSuper = true)
    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
2
3
4
5
6
7
8
9
10
mybatis-plus:
# ... 其他配置 ...
configuration:
# ... 其他配置 ...
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
# **新增/修改**: 类型处理器包扫描路径
# Mybatis-Plus 自带了许多开箱即用的 TypeHandler (如 JacksonTypeHandler),
# 它们都位于 com.baomidou.mybatisplus.extension.handlers 包下。
# 配置此项后,MP在启动时会自动扫描并注册这些处理器。
type-handlers-package: com.baomidou.mybatisplus.extension.handlers

最佳实践: 强烈推荐使用 type-handlers-package 进行全局配置。这遵循了“约定优于配置”的原则,一旦配置,项目中所有符合条件的 MapJSON 的映射都会自动生效,无需在每个实体字段上重复添加注解,极大提升了代码的整洁性和可维护性。

第三步:开启 autoResultMap (关键步骤)

即便我们已经全局注册了 TypeHandler,但在处理查询结果时,我们还需要最后一步:告诉 Mybatis-Plus 在生成查询的 ResultMap 时,要智能地包含所有字段的 TypeHandler 信息。

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

1
2
3
4
5
6
7
8
9
10
11
// ...
// **关键**: 必须开启 autoResultMap, 才能在执行 SELECT 查询时,
// 自动应用我们全局注册的 JacksonTypeHandler 对 JSON 结果集进行反序列化。
@TableName(value = "tb_user", autoResultMap = true)
public class UserDO extends Model<UserDO> {
// ...
// **注意**: 因为我们已进行全局配置,所以此处的 @TableField 注解不再是必需的
@TableField(typeHandler = JacksonTypeHandler.class)
private Map<String, String> contactInfo;
// ...
}

第四步:编写单元测试进行验证

现在,我们的配置已经完整且稳健,让我们通过测试来验证效果。

文件路径: src/test/java/com/example/mpstudy/AdvancedFeatureTest.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
@Test
void testJsonTypeHandler() {
System.out.println("----- 开始执行JSON类型处理器测试 -----");

// 准备一个 Map 对象
Map<String, String> contact = Map.of(
"phone", "188-8888-8888",
"wechat", "Prorise-Plus"
);

UserDO newUser = new UserDO()
.setName("JsonUser")
.setAge(30)
.setContactInfo(contact); // 直接在实体中设置 Map 对象

userMapper.insert(newUser);

UserDO storedUser = userMapper.selectById(newUser.getId());
System.out.println("查询到的用户信息: " + storedUser);
System.out.println("其中的联系方式 (Map对象): " + storedUser.getContactInfo());

// 断言:从数据库读出的 contactInfo 字段确实是一个 Map,并且内容正确
assertNotNull(storedUser.getContactInfo());
assertEquals("188-8888-8888", storedUser.getContactInfo().get("phone"));
}

结果分析: 在我们添加了健全的全局配置后,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.xmlid="findUserWithDept"<select> 标签上。
  • 从 XML 到 Java: 反之,在 XML 文件中点击任一 SQL 标签(如 <select>)旁的 < 箭头,IDE 也会立刻跳转回 Mapper 接口中对应的方法声明。

除此之外,MybatisX 还会实时检查 XMLnamespace 是否能正确关联到 Mapper 接口,如果关联错误,它会以高亮形式提示您,帮助您在编码阶段就发现潜在的配置问题。


6.2.3. 核心功能二:智能提示与一键生成 SQL

MybatisX 能够理解并解析您在 Mapper 接口中定义的方法名,并据此自动生成对应的 SQL 语句。

实战演练: 假设我们需要一个新功能:根据用户姓名查询,并按年龄降序排列,只返回第一条记录。

第一步: 在 UserMapper.java 中,定义一个符合 JPA 命名规范的方法。

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

1
2
3
4
5
6
public interface UserMapper extends BaseMapper<UserDO> {
// ... 其他已存在的方法 ...

// 新增一个方法,注意此时 XML 中并无对应实现
UserDO findFirstByNameOrderByAgeDesc(String name);
}

第二步: 将光标定位在新方法名上,按下快捷键 Alt + Enter (Windows/Linux) 在弹出的菜单中选择 Generate statement in mapper xml

第三步:见证奇迹

MybatisX 会立即在 UserMapper.xml 文件中,为您生成完整且正确的 <select> 标签!

1
2
3
4
5
6
7
8
<select id="findFirstByNameOrderByAgeDesc" resultType="com.example.mpstudy.domain.UserDO">
select
<include refid="Base_Column_List" />
from tb_user
where name = #{name,jdbcType=VARCHAR}
order by age desc
limit 1
</select>

它不仅生成了基础的 SELECT 语句和 WHERE 条件,甚至正确地解析了 OrderByAgeDescfindFirst (对应 limit 1),这极大地提升了编写简单自定义 SQL 的效率。


6.2.4. 核心功能三:GUI 代码生成器 (逆向工程)

如果您需要为一个或多个新表快速生成全套 Entity, Service, Controller 等代码,MybatisX 提供的图形化生成器是比 AutoGenerator 更轻量、更直观的选择。

实战演练: 为 tb_department 表生成全套代码。

第一步: 打开 IDEA 右侧的 Database 工具栏,并连接到您的数据库。

第二步: 展开数据库,找到 tb_department 表,右键点击它,在弹出的菜单中选择 MybatisX-Generator

第三步: 在弹出的图形化配置窗口中,完成关键配置。

image-20250823113411530

  1. Module: 选择当前的项目模块。
  2. Base Package: 填写生成的代码要存放的父包名,例如 com.example.mpstudy.generated.dept
  3. Table Prefix: 填写需要移除的表前缀,例如 tb_,这样生成的实体类名就是 Department 而不是 TbDepartment
  4. 勾选需要生成的文件

image-20250823113533472

瞬间,所有与 Department 相关的分层代码都已为您生成完毕,可以直接投入使用。


6.3. SQL 分析与安全防护

痛点背景: 一个功能跑通,只是完成了开发的“上半场”。在“下半场”,我们需要关注的是代码的质量安全。当应用变得复杂,我们如何快速定位是哪条 Mybatis-Plus 生成的 SQL 变慢了?又如何从机制上,避免新手开发者或代码缺陷导致 UPDATEDELETE 语句漏写 WHERE 条件,从而引发全表更新/删除的生产事故?

本节,我们将为项目装上“护盾”与“瞄准镜”,学习如何通过 Mybatis-Plus 的插件生态来解决这两个核心问题。

6.3.1. SQL 性能分析 (P6Spy)

P6Spy 是一个开源的 JDBC 驱动代理框架,它可以像一个安装在 JDBC 驱动前的“摄像头”,无侵入地拦截、记录并分析所有经过它的 SQL 语句。dynamic-datasource-starter 已经内置了对 P6Spy 的良好支持。

第一步:确认配置与依赖

请确保您的项目已满足以下两个条件(我们在解决之前的启动报错时已完成):

  1. pom.xml: 已经添加了 p6spy 依赖。
    1
    2
    3
    4
    5
    <dependency>
    <groupId>p6spy</groupId>
    <artifactId>p6spy</artifactId>
    <version>3.9.1</version>
    </dependency>
  2. application.yml: 在动态数据源配置下,已经开启了 p6spy 集成。
    1
    2
    3
    4
    5
    spring:
    datasource:
    dynamic:
    p6spy: true
    # ...

第二步:添加 spy.properties 配置文件

为了让 P6Spy 的输出更美观、更具可读性,我们需要在 src/main/resources 目录下创建 spy.properties 文件,来定制其日志格式。

文件路径: src/main/resources/spy.properties

1
2
3
4
5
6
7
8
# 使用 Mybatis-Plus 团队优化过的日志格式化工厂,输出格式更友好
logMessageFormat=com.baomidou.mybatisplus.extension.p6spy.P6SpyLogger
# 日志直接输出到标准控制台
appender=com.baomidou.mybatisplus.extension.p6spy.StdoutLogger
# 自定义日期格式
dateformat=yyyy-MM-dd HH:mm:ss
# 排除掉一些我们通常不关心的日志类别(如事务提交、结果集详情),保持控制台清爽
excludecategories=info,debug,result,commit,resultset

第三步:运行并解读日志

现在,运行我们之前编写的任何一个会操作数据库的测试,例如 testEnumHandler。您将在控制台看到由 P6Spy 打印出的、格式清晰的 SQL 日志。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
2025-08-23 11:55:10 | SQL     | connection 2 | took 3ms | statement 
insert
into
tb_user
(name, age, gender)
values
(?, ?, ?);
|
insert
into
tb_user
(name, age, gender)
values
('EnumUser', 25, 1);

这份日志包含了最有价值的信息:

  • 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();

// **新增**:防止全表更新与删除插件,务必放在最前面
interceptor.addInnerInterceptor(new BlockAttackInnerInterceptor());

// 其他拦截器...
interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
}

注意: BlockAttackInnerInterceptor 应该被注册在拦截器链的较前位置,以便尽早拦截并阻止危险操作。

第二步:编写一个“危险”的测试来验证防护效果

我们将模拟一个开发者在执行 update 操作时,忘记传入 Wrapper 条件的场景。

文件路径: src/test/java/com/example/mpstudy/AdvancedFeatureTest.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
// ...
import com.baomidou.mybatisplus.core.exceptions.MybatisPlusException;
import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

@Test
void testBlockAttack() {
System.out.println("----- 开始执行全表更新/删除防护测试 -----");

// 危险操作:创建一个 UserDO 对象用于设置更新值
UserDO updateUser = new UserDO().setAge(99);

// 危险的调用:执行 update 方法,但第二个参数(Wrapper)传入了 null
// 如果没有拦截器,这将生成一条 UPDATE tb_user SET age=99 的SQL,更新全表!

// 使用 assertThrows 验证 MybatisPlusException 是否被按预期抛出
MybatisPlusException exception = assertThrows(
MybatisPlusException.class,
() -> userMapper.update(updateUser, null), // 传入 null wrapper
"全表更新操作应被拦截并抛出异常"
);

System.out.println("成功拦截到危险操作,抛出异常: " + exception.getMessage());
// 验证异常信息是否符合预期
assertTrue(exception.getMessage().contains("Prohibition of table update operation"));
}

运行此测试,程序会立即失败并抛出 MybatisPlusException,控制台会打印出我们预期的异常信息。SQL 语句根本没有机会被发送到数据库,从而有效地避免了一场潜在的生产事故。