Java(12):12 Mybatis - ORM框架的优雅美学


第 1 章 [奠基] 项目启动与首次数据库交互

摘要: 本章节将带领您MyBatis 的初学者——踏上持久层框架的学习之旅。我们将首先探讨直接使用 JDBC 的痛点,从而理解 MyBatis 存在的 核心价值。随后,您将亲手从零开始,利用 LombokSLF4JAssertJ 等现代化工具库,高效地完成一个 MyBatis 项目的搭建,并最终执行您的第一个数据库查询操作。完成本章后,您将对 MyBatis 的基本工作流程和现代化开发实践有一个扎实且清晰的认识。


1.1. [背景] 为什么选择 MyBatis?

在我们直接投入 MyBatis 的学习之前,有必要先回顾一下它所要解决的问题。在没有持久层框架的时代,我们通常使用原生的 JDBC (Java Database Connectivity) 来与数据库交互。

原生 JDBC 开发的痛点

虽然 JDBC 是 Java 连接数据库的基石,但直接使用它会导致大量模板化的、重复的、且容易出错的代码。

1. 硬编码的 SQL 与繁琐的参数设置

SQL 语句散落在 Java 代码的各个角落,一旦需要修改,就必须深入业务代码,违反了软件设计的 开闭原则。同时,为 PreparedStatement 的占位符 ? 挨个赋值的过程也极其繁琐。

1
2
3
4
5
6
7
8
9
// 示例:原生 JDBC 的插入操作
// SQL 语句写死在 Java 程序中
String sql = "insert into t_user(username, password) values(?,?)";
PreparedStatement ps = conn.prepareStatement(sql);
// 繁琐的赋值过程
ps.setString(1, "zhangsan");
ps.setString(2, "123456");
// 执行 SQL
int count = ps.executeUpdate();

2. 手动封装的结果集

ResultSet 查询结果手动转换(封装)为 Java 对象(POJO)的过程,充满了大量的 get/set 操作。这部分代码不仅重复度高,而且当数据库表字段增减时,维护起来非常痛苦。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 示例:原生 JDBC 的结果集封装
List<User> userList = new ArrayList<>();
while(rs.next()){
// 手动获取每一列数据
String username = rs.getString("username");
String password = rs.getString("password");
// 手动创建对象并赋值
User user = new User();
user.setUsername(username);
user.setPassword(password);
// 添加到集合
userList.add(user);
}

MyBatis 的出现,正是为了将开发者从这些枯燥的工作中解放出来,让我们能更专注于 SQL 本身,而不是与 JDBC API 的繁琐搏斗。


1.2. [实践] 环境准备与项目搭建

现在,我们开始搭建“银行系统”项目。第一步是建立数据库表和配置项目环境。

1. 数据库初始化

首先,请连接到您的 MySQL 数据库,并执行以下 SQL 脚本来创建我们的数据库和表,并插入初始数据。

1
2
3
4
5
6
7
8
9
10
11
12
CREATE DATABASE IF NOT EXISTS `bank_db` DEFAULT CHARACTER SET utf8mb4;
USE `bank_db`;

CREATE TABLE `t_account` (
`id` int NOT NULL AUTO_INCREMENT COMMENT '主键',
`actno` varchar(255) DEFAULT NULL COMMENT '账号',
`balance` double(10,2) DEFAULT NULL COMMENT '余额',
PRIMARY KEY (`id`)
);

INSERT INTO `t_account` (id, actno, balance) VALUES (1, 'act-001', 50000.00);
INSERT INTO `t_account` (id, actno, balance) VALUES (2, 'act-002', 10000.00);

2. Maven 项目配置

我们创建一个标准的 Maven 项目。关键在于配置 pom.xml 文件,它像一个“项目说明书”,告诉 Maven 我们项目需要哪些“积木”——也就是依赖库。这包括 MyBatis 自身、数据库驱动、我们用来简化代码的 Lombok,以及强大的日志和测试工具。

文件路径: [您的项目根目录]/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
<project ...>
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>mybatis-bank-demo</artifactId>
<version>1.0-SNAPSHOT</version>
<packaging>jar</packaging>

<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
</properties>

<dependencies>
<dependency>
<groupId>org.mybatis</groupId>
<artifactId>mybatis</artifactId>
<version>3.5.15</version>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<version>8.2.0</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>2.0.12</version>
</dependency>
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.4.14</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.32</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.26</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<version>5.10.2</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.25.3</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>

1.3. [配置] 核心配置文件与工具类

MyBatis 的工作离不开两个核心的 XML 文件:一个用于全局配置,另一个用于编写 SQL。

1. 全局配置文件 mybatis-config.xml

这个文件是 MyBatis 的“大本营”或“交通枢纽”。我们在这里告诉 MyBatis 三件关键事情:① 如何连接数据库 (dataSource),② 事务如何管理 (transactionManager),以及 ③ 去哪里寻找我们编写的 SQL (mappers)。

文件路径: src/main/resources/mybatis-config.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE configuration
PUBLIC "-//mybatis.org//DTD Config 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration>
<environments default="development">
<environment id="development">
<transactionManager type="JDBC"/>
<dataSource type="POOLED">
<property name="driver" value="com.mysql.cj.jdbc.Driver"/>
<property name="url" value="jdbc:mysql://localhost:3306/bank_db"/>
<property name="username" value="root"/>
<property name="password" value="root"/>
</dataSource>
</environment>
</environments>
<mappers>
<mapper resource="mappers/AccountMapper.xml"/>
</mappers>
</configuration>

2. 工具类封装 SqlSessionUtil

为了避免每次都重复创建重量级的 SqlSessionFactory,我们遵循单例模式的最佳实践,将其封装成一个工具类。这个类在程序启动时就初始化好 SqlSessionFactory,并提供一个简单的静态方法来获取 SqlSession。我们还会用 @Slf4j 注解来轻松地集成日志功能。

文件路径: src/main/java/com/example/bank/utils/SqlSessionUtil.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
package com.example.bank.utils;

import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.io.Resources;
import org.apache.ibatis.session.SqlSession;
import org.apache.ibatis.session.SqlSessionFactory;
import org.apache.ibatis.session.SqlSessionFactoryBuilder;
import java.io.IOException;
import java.io.InputStream;

@Slf4j
public class SqlSessionUtil {

private static final SqlSessionFactory sqlSessionFactory;

static {
try {
InputStream inputStream = Resources.getResourceAsStream("mybatis-config.xml");
sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);
} catch (IOException e) {
log.error("初始化 SqlSessionFactory 失败!", e);
throw new ExceptionInInitializerError(e);
}
}

public static SqlSession openSession() {
return sqlSessionFactory.openSession();
}
}

1.4. [实践] 第一个查询操作

万事俱备,我们来完成第一个查询需求:根据账号查询账户信息。

1. 创建 POJO (使用 Lombok)

首先,我们需要一个 Java 类来映射数据库中的 t_account 表,这个类通常被称为 POJO。借助 Lombok 的 @Data 注解,我们无需手动编写任何 getter/settertoString 方法,代码会非常整洁。

文件路径: src/main/java/com/example/bank/pojo/Account.java

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

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class Account {
private Integer id;
private String actno;
private Double balance;
}

2. 编写 Mapper 接口与 XML

MyBatis 最优雅的工作方式就是通过 Mapper 接口。我们只需要定义一个接口,并在对应的 XML 文件中编写 SQL。MyBatis 会在运行时为我们神奇地“实现”这个接口。请注意它们之间的“契约”:XML 中的 namespace 必须精确匹配接口的全路径名,<select> 标签的 id 必须精确匹配接口中的方法名。

文件路径 (接口): src/main/java/com/example/bank/mapper/AccountMapper.java

1
2
3
4
5
6
7
package com.example.bank.mapper;

import com.example.bank.pojo.Account;

public interface AccountMapper {
Account selectByActno(String actno);
}

文件路径 (XML): src/main/resources/mappers/AccountMapper.xml (注意,您需要手动创建 mappers 文件夹)

1
2
3
4
5
6
7
8
9
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.bank.mapper.AccountMapper">
<!-- namespace 属性指定了 Mapper 接口的完全限定名,用于 MyBatis 查找对应的 XML 配置文件。
id 属性用于唯一标识 SQL 语句,而 resultType 指定了查询结果应该映射到的 Java 类型。 -->
<select id="selectByActno" resultType="com.example.bank.pojo.Account">
select id, actno, balance from t_account where actno = #{actno}
</select>
</mapper>

这是最核心的一点:MyBatis 的 XML 配置文件定义了 SQL 语句和 Mapper 接口方法的对应关系,使用户可以以声明式的方式编写 SQL,而无需在 Java 代码中直接编写。

3. 编写并执行单元测试 (使用 AssertJ)

现在,是时候验证我们的成果了。我们将编写一个 JUnit 5 测试。在这个测试中,我们会使用 try-with-resources 语句来确保 SqlSession 能够被自动关闭,这是一种很好的资源管理习惯。

同时,我们会用 AssertJ 提供的流式断言来验证查询结果,这比传统的 if-elseSystem.out.println 可靠且易读得多。

文件路径: src/test/java/com/example/bank/test/AccountMapperTest.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
package com.example.bank.test;

import com.example.bank.mapper.AccountMapper;
import com.example.bank.pojo.Account;
import com.example.bank.utils.SqlSessionUtil;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.session.SqlSession;
import org.junit.jupiter.api.Test;

import static org.assertj.core.api.AssertionsForClassTypes.assertThat;

@Slf4j
public class AccountMapperTest {
@Test
public void testSelectByActno() {
try (SqlSession sqlSession = SqlSessionUtil.openSession()) {
// 1. 创建 AccountMapper 接口的代理对象
AccountMapper accountMapper = sqlSession.getMapper(AccountMapper.class);
// 2. 调用 selectByActno 方法
Account account = accountMapper.selectByActno("act-001");
// 3. 判断账号信息
assertThat(account).isNotNull();
assertThat(account.getActno()).isEqualTo("act-001");
assertThat(account.getBalance()).isEqualTo(50000.00);

log.info("查询成功: {}", account);
}
}
}

4. 配置日志并查看输出

最后,为了让程序“开口说话”,我们需要配置一个日志系统。下面的 logback.xml 配置将告诉程序:将所有来自我们 mapper 包的、级别为 TRACE 的详细日志(包括执行的 SQL 和参数)都打印到控制台。

文件路径: src/main/resources/logback.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>

<logger name="com.example.bank.mapper" level="TRACE" />

<root level="INFO">
<appender-ref ref="STDOUT" />
</root>
</configuration>

运行测试后,您将在控制台看到类似下面的输出,标志着您的第一个 MyBatis 查询已成功执行!

1
2
3
4
5
6
15:07:03.394 [main] DEBUG c.e.b.m.AccountMapper.selectByActno - ==>  Preparing: select id, actno, balance from t_account where actno = ?
15:07:03.451 [main] DEBUG c.e.b.m.AccountMapper.selectByActno - ==> Parameters: act-001(String)
15:07:03.513 [main] TRACE c.e.b.m.AccountMapper.selectByActno - <== Columns: id, actno, balance
15:07:03.514 [main] TRACE c.e.b.m.AccountMapper.selectByActno - <== Row: 3, act-001, 50000.0
15:07:03.517 [main] DEBUG c.e.b.m.AccountMapper.selectByActno - <== Total: 1
15:07:03.586 [main] INFO c.e.bank.test.AccountMapperTest - 查询成功: Account(id=3, actno=act-001, balance=50000.0)

恭喜!您已经成功完成了 MyBatis 的入门第一步,并学会了如何结合现代化工具库来提升开发效率。接下来,我们将基于这个项目,逐步实现更复杂的业务功能。


第 2 章 [核心] 实现转账功能与 CRUD 操作

摘要: 在上一章,我们成功搭建了项目并完成了首次查询。现在,是时候为我们的银行应用添加真正的业务功能了。本章将围绕核心的“转账”场景,引导您掌握 MyBatis 的 CUD (增/改/删) 操作。我们将学习并采用业界推荐的 Mapper 代理模式 进行开发,同时并列对比传统的 XML 和现代的 注解 两种 SQL 实现方式。最后,我们将深入探讨 MyBatis 中多种参数的传递机制,特别是 @Param 注解的使用,以及 #{}${} 的本质区别——这是每个 MyBatis 开发者必须掌握的关键知识点。


2.1. [模式] Mapper 代理开发模式

在上一章的测试中,我们通过以下代码获取 Mapper 并执行了查询:

1
2
3
4
// Chapter 1 Test Code Snippet
SqlSession sqlSession = SqlSessionUtil.openSession();
AccountMapper accountMapper = sqlSession.getMapper(AccountMapper.class);
Account account = accountMapper.selectByActno("act-001");

这种 sqlSession.getMapper(AccountMapper.class) 的方式,就是 MyBatis 的 Mapper 代理模式。它是官方推荐的、也是目前业界最主流的开发方式。它允许我们像调用一个普通的 Java 接口方法一样执行 SQL,而无需关心底层的实现细节。

原理点 · 面试题: 为什么我们只写 Mapper 接口而不用写实现类?MyBatis 是如何做到的?

这是因为 MyBatis 在调用 sqlSession.getMapper(YourMapper.class) 时,内部使用了 Java 的 动态代理 技术。

它会根据您提供的接口,在内存中动态地创建一个该接口的代理对象。当您调用接口中的任何方法(如 selectByActno)时,这个代理对象会拦截该调用,然后根据“接口的全限定名 + 方法名”(例如 com.example.bank.mapper.AccountMapper.selectByActno)作为唯一的 statementId,去 AccountMapper.xml 中查找并执行与之对应的 SQL 语句。这使得我们的代码可以完全面向接口编程,更加优雅和解耦。


2.2. [实践] 完成核心 CRUD

文件路径: src/main/resources/mappers/AccountMapper.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.bank.mapper.AccountMapper">

<select id="selectByActno" resultType="com.example.bank.pojo.Account">
select id, actno, balance from t_account where actno = #{actno}
</select>

<update id="update">
update t_account set balance = #{balance} where actno = #{actno}
</update>

</mapper>

文件路径: src/main/java/com/example/bank/mapper/AccountMapper.java

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

import com.example.bank.pojo.Account;
import org.apache.ibatis.annotations.Update;

public interface AccountMapper {

Account selectByActno(String actno);
// 对于简单的 SQL 语句,可以直接在接口将 SQL 直接写好,就无需单开一个 xml 格式的文本,但这个仅适合简单的 SQL 语句
@Update("update t_account set balance = #{balance} where actno = #{actno}")
int update(Account account);
}

3. 编写 update 功能的测试

现在,我们在测试类中验证 update 方法是否正常工作。

关键点: 所有的数据修改操作(增、删、改)执行完毕后,默认情况下事务是 不会自动提交 的。您必须手动调用 sqlSession.commit() 来持久化更改。

文件路径: src/test/java/com/example/bank/test/AccountMapperTest.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
// ... (imports)
@Slf4j
public class AccountMapperTest {

// ... (testSelectByActno 方法)

@Test
public void testUpdate() {
try (SqlSession sqlSession = SqlSessionUtil.openSession()) {
// 1. 创建 AccountMapper 接口的代理对象
AccountMapper accountMapper = sqlSession.getMapper(AccountMapper.class);

// 2.准备一个待更新的对象
Account account = new Account(null,"act-001",9999999.99);

// 3.执行更新
int count = accountMapper.update(account);

// 4.判断更新结果
assertThat(count).isEqualTo(1);

// 5.提交事务
sqlSession.commit();

}
}
}

4. 补充 insertdelete 操作

同理,为了功能完整,我们为银行应用添加开户 (insert) 和销户 (delete) 功能。

  • AccountMapper.java 添加接口方法:
    1
    2
    3
    4
    5
    // 插入一条账户记录
    int insert(Account account);

    // 根据账号删除记录
    int deleteByActno(String actno);
  • 添加对应的 SQL 映射:

文件路径: src/main/resources/mappers/AccountMapper.xml

1
2
3
4
5
6
7
<insert id="insert">
insert into t_account (actno, balance) values (#{actno}, #{balance})
</insert>

<delete id="deleteByActno">
delete from t_account where actno = #{actno}
</delete>

文件路径: src/main/java/com/example/bank/mapper/AccountMapper.java

1
2
3
4
5
@Insert("insert into t_account (actno, balance) values (#{actno}, #{balance})")
int insert(Account account);

@Delete("delete from t_account where actno = #{actno}")
int deleteByActno(String actno);

2.3. [核心] 掌握参数处理

到目前为止,我们的方法都只接收一个参数。但在实际业务中,我们经常需要传递多个参数。

1. 多参数传递的问题

假设我们需要一个新功能:查询余额在某个范围内的所有账户。接口方法可能定义如下:

1
2
// 这是一个有问题的定义,MyBatis 不知道哪个是 minBalance,哪个是 maxBalance
List<Account> selectByBalanceRange(Double minBalance, Double maxBalance);

当 MyBatis 看到这个方法时

它无法将 minBalancemaxBalance 这两个参数名与 XML 中的 #{minBalance}#{maxBalance} 对应起来。

2. 解决方案:使用 @Param 注解

为了解决这个问题,MyBatis 提供了 @Param 注解,用于给参数“命名”。这是处理多参数时的 最佳实践

文件路径: src/main/java/com/example/bank/mapper/AccountMapper.java (修改)

1
2
3
4
5
6
7
8
9
import org.apache.ibatis.annotations.Param; // 引入 Param 注解
import java.util.List;

public interface AccountMapper {
// ... 其他方法

// 使用 @Param 为每个参数命名
List<Account> selectByBalanceRange(@Param("min") Double minBalance, @Param("max") Double maxBalance);
}

现在,MyBatis 就知道 minBalance 对应的是 #{min}maxBalance 对应的是 #{max}

文件路径: src/main/resources/mappers/AccountMapper.xml (添加)

1
2
3
<select id="selectByBalanceRange" resultType="com.example.bank.pojo.Account">
select * from t_account where balance between #{min} and #{max}
</select>

对我们的查询结果进行验证

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Test
public void testSelectByBalanceRange() {
try (SqlSession sqlSession = SqlSessionUtil.openSession()) {
// 1. 创建 AccountMapper 接口的代理对象
AccountMapper accountMapper = sqlSession.getMapper(AccountMapper.class);

// 2. 执行查询
List<Account> accounts = accountMapper.selectByBalanceRange(5000.00, 500000.00);

// 3. 判断查询结果
System.out.println(accounts);

}
}

3. 原理点 · #{}${} 的本质区别

这是 MyBatis 中一个极其重要的知识点,直接关系到应用的安全。
面试高频题: #{}${} 有什么区别?哪个会引起 SQL 注入?

  • #{} (占位符): 这是 推荐 的使用方式。MyBatis 在处理 #{} 时,会将其替换为 ? 占位符,并使用 PreparedStatement 来设置参数。这种方式可以 有效防止 SQL 注入,因为用户输入的内容被视为纯粹的数据,而不是 SQL 指令的一部分。
  • ${} (拼接符): 这是 不安全 的字符串替换。MyBatis 在处理 ${} 时,会直接将变量的 拼接到 SQL 语句中。如果这个值来自用户输入,将可能导致 SQL 注入 攻击。

结论: 永远优先使用 #{}。只有在极少数需要动态拼接 SQL 关键字(如表名、ORDER BY 的列名)的场景下,才考虑使用 ${},并且必须对输入源做严格的校验。

SQL 注入的危险示例:
假设我们错误地使用了 ${}:

1
2
3
<select id="selectByActno" resultType="com.example.bank.pojo.Account">
select * from t_account where actno = '${actno}'
</select>

如果一个恶意用户传入的 actnoact-001' OR '1'='1,那么拼接后的 SQL 将会是:

1
select * from t_account where actno = 'act-001' OR '1'='1'

这条 SQL 会绕过 where 条件,返回表中的所有数据,造成严重的数据泄露。而如果使用 #{}PreparedStatement 会将整个恶意字符串视为一个普通的值去匹配 actno,什么也查不到,从而保证了安全。


第 3 章 [核心] 事务控制与复杂查询

摘要: 一个应用程序的价值不仅在于实现功能,更在于其健壮性与灵活性。本章将聚焦于这两个关键品质。首先,我们将解决转账业务中的 原子性 问题,通过引入 事务控制ThreadLocal 模式,确保数据在任何情况下都保持一致。随后,为了满足日益复杂的报表查询需求,我们将深入探索 MyBatis 最强大的功能之一——动态 SQL。您将学会使用 <if>, <where>, <foreach> 等标签,让您的 SQL 语句能够根据不同的输入条件“智能”地变化。


3.1. [核心] MyBatis 的事务管理

当前的转账逻辑看似可行,但隐藏着巨大的风险。一个完整的转账操作包含“A 账户扣款”和“B 账户增款”两个步骤,它们必须 要么同时成功,要么同时失败。这就是事务的 原子性

3.1.1. 问题复现:一个危险的转账实现

为了管理业务逻辑,我们首先创建 Service 层。

文件路径 (接口): src/main/java/com/example/bank/service/AccountService.java

1
2
3
4
5
6
7
8
9
10
11
12
13
package com.example.bank.service;

import com.example.bank.pojo.Account;

public interface AccountService {
/**
* 转账业务方法
* @param fromActno 转出账号
* @param toActno 转入账号
* @param money 转账金额
*/
void transfer(String fromActno, String toActno, Double money);
}

文件路径 (实现类): src/main/java/com/example/bank/service/impl/AccountServiceImpl.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
package com.example.bank.service.impl;

import com.example.bank.mapper.AccountMapper;
import com.example.bank.pojo.Account;
import com.example.bank.service.AccountService;
import com.example.bank.utils.SqlSessionUtil;
import org.apache.ibatis.session.SqlSession;

public class AccountServiceImpl implements AccountService {

@Override
public void transfer(String fromActno, String toActno, Double money) {
// 在一个会话中执行两次更新
try (SqlSession sqlSession = SqlSessionUtil.openSession()) {
AccountMapper accountMapper = sqlSession.getMapper(AccountMapper.class);

// 1. 查询转出账户余额
Account fromAct = accountMapper.selectByActno(fromActno);
if (fromAct.getBalance() < money) {
throw new RuntimeException("余额不足");
}
// 2. 查询转入账户
Account toAct = accountMapper.selectByActno(toActno);

// 3. 更新双方余额
fromAct.setBalance(fromAct.getBalance() - money);
toAct.setBalance(toAct.getBalance() + money);

// 4. 执行更新
int count = accountMapper.update(fromAct);

// 模拟一个异常,模拟扣款后、增款前程序突然崩溃
String s = null;
s.toString();

count += accountMapper.update(toAct);

if (count != 2) {
throw new RuntimeException("转账异常,请联系柜台");
}

// 提交事务
sqlSession.commit();
}
}
}

当我们执行这个转账方法时,它会在 s.toString() 处抛出 NullPointerException。结果是 act-001 的钱被扣了,但由于没有 commit,看似没问题。但如果 openSession() 默认开启了自动提交(openSession(true)),或者在两次更新之间是网络 IO 等问题,就会出现数据不一致。

真正的核心问题是:我们需要一个机制,将两次 update 操作绑定在同一个事务中,统一提交或回滚。

3.1.2. 解决方案:ThreadLocal 与事务手动控制

要保证 Service 层中的多次数据库调用在同一个事务里,就必须保证它们使用的是 同一个 SqlSession 实例

原理点 · ThreadLocal

ThreadLocal 是 Java 提供的一个神奇工具,它可以为一个线程 独立 地存储一个变量的副本。在一个线程的生命周期内,无论你在哪个方法中访问这个 ThreadLocal 变量,拿到的都是同一个对象。这完美地解决了我们的问题:我们可以将 SqlSession 存入 ThreadLocal,这样,在同一次请求(即同一个线程)中,无论 Service 调用多少次 DAO,获取到的都是同一个 SqlSession,从而共享同一个数据库连接和事务。

我们来改造 SqlSessionUtil

文件路径 (修改): src/main/java/com/example/bank/utils/SqlSessionUtil.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
package com.example.bank.utils;

import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.io.Resources;
import org.apache.ibatis.session.SqlSession;
import org.apache.ibatis.session.SqlSessionFactory;
import org.apache.ibatis.session.SqlSessionFactoryBuilder;
import java.io.IOException;
import java.io.InputStream;

@Slf4j
public class SqlSessionUtil {

private static final SqlSessionFactory sqlSessionFactory;

// 创建一个 ThreadLocal 对象来存储 SqlSession
private static final ThreadLocal<SqlSession> localSession = new ThreadLocal<>();

static {
try {
InputStream inputStream = Resources.getResourceAsStream("mybatis-config.xml");
sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);
} catch (IOException e) {
log.error("初始化 SqlSessionFactory 失败!", e);
throw new ExceptionInInitializerError(e);
}
}

/**
* 获取一个新的 SqlSession 对象,并与当前线程绑定
* @return SqlSession 实例
*/
public static SqlSession openSession() {
SqlSession session = localSession.get();
if (session == null) {
session = sqlSessionFactory.openSession();
localSession.set(session); // 将 session 存入 ThreadLocal
}
return session;
}

/**
* 关闭与当前线程绑定的 SqlSession
*/
public static void closeSession() {
SqlSession session = localSession.get();
if (session != null) {
session.close();
localSession.remove(); // 必须移除,防止内存泄漏
}
}
}

3.1.3. 正确的事务处理代码

现在,我们可以在 Service 层通过 try-catch-finally 结构,实现精确的事务控制。

文件路径 (修改): src/main/java/com/example/bank/service/impl/AccountServiceImpl.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
package com.example.bank.service.impl;

import com.example.bank.mapper.AccountMapper;
import com.example.bank.pojo.Account;
import com.example.bank.service.AccountService;
import com.example.bank.utils.SqlSessionUtil;
import org.apache.ibatis.session.SqlSession;

public class AccountServiceImpl implements AccountService {

@Override
public void transfer(String fromActno, String toActno, Double money) {
// 在 Service 层控制事务
SqlSession sqlSession = SqlSessionUtil.openSession();
try {
AccountMapper accountMapper = sqlSession.getMapper(AccountMapper.class);
Account fromAct = accountMapper.selectByActno(fromActno);
if (fromAct.getBalance() < money) {
throw new RuntimeException("余额不足");
}
Account toAct = accountMapper.selectByActno(toActno);

fromAct.setBalance(fromAct.getBalance() - money);
toAct.setBalance(toAct.getBalance() + money);

int count = accountMapper.update(fromAct);

// 模拟异常
// String s = null;
// s.toString();

count += accountMapper.update(toAct);
if (count != 2) {
throw new RuntimeException("转账异常,请联系柜台");
}

// 所有操作成功,提交事务
sqlSession.commit();
} catch (Exception e) {
// 出现任何异常,回滚事务
sqlSession.rollback();
// 将异常继续向上抛出,通知调用者
throw new RuntimeException(e);
} finally {
// 无论成功与否,都要关闭会话
SqlSessionUtil.closeSession();
}
}
}

现在,如果您取消 String s = null; s.toString(); 的注释并再次运行测试,act-001 的扣款操作将会被回滚,数据库的数据将保持一致,我们的应用变得健壮了。

前瞻提示: 目前手动管理事务的 try-catch-finally 写法虽然能帮助我们理解原理,但在未来的 Spring / Spring Boot 整合开发中,这个过程将被极大地简化。届时,我们只需在一个方法上添加一个 @Transactional 注解,框架就会自动为我们完成事务的开启、提交和回滚。因此,您现在只需理解其原理,无需死记硬背此处的模板代码。


3.2. [核心] 动态 SQL

随着业务的发展,我们常常需要应对各种复杂的查询场景。例如,银行需要一个报表系统,可以根据一个或多个 不确定 的条件来筛选数据。这就是 MyBatis 强大的 动态 SQL 功能的用武之地。

3.2.1. 场景与准备工作

为了更好地演示动态查询,我们引入一个新的业务场景:汽车信息报表

1. 数据库准备

首先,我们在 bank_db 数据库中新建一张 t_car 表,并插入一些测试数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
CREATE TABLE `t_car` (
`id` bigint NOT NULL AUTO_INCREMENT,
`car_num` varchar(255) DEFAULT NULL,
`brand` varchar(255) DEFAULT NULL,
`guide_price` decimal(10,2) DEFAULT NULL,
`produce_time` varchar(255) DEFAULT NULL,
`car_type` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`)
);

INSERT INTO `t_car` (id, car_num, brand, guide_price, produce_time, car_type) VALUES
(1, '1001', '宝马530Li', 55.00, '2023-10-01', '燃油车'),
(2, '1002', '比亚迪汉', 23.00, '2024-05-11', '新能源'),
(3, '1003', '丰田凯美瑞', 21.00, '2022-01-20', '燃油车'),
(4, '1004', '特斯拉Model Y', 30.00, '2024-02-15', '新能源');

2. 创建实体类 POJO

文件路径: src/main/java/com/example/bank/pojo/Car.java

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

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class Car {
private Long id;
private String carNum;
private String brand;
private BigDecimal guidePrice; // 使用 BigDecimal 处理金额,更精确
private String produceTime;
private String carType;
}

3.2.2. <if><where> 标签

这是最基础的动态 SQL 组合,用于处理可选的查询条件。

  • if 标签:用于进行条件判断。
    • test 属性: 它的值是一个 OGNL 表达式。如果表达式的结果为 true,标签内部的 SQL 语句就会被包含进来。我们通常用它来判断传入的参数是否为 null 或空字符串。
  • where 标签:用于智能地包裹 if 判断。
    • 它非常智能,只会在其内部有任何 if 条件成立时,才会生成 WHERE 关键字。
    • 它会自动 移除 第一个 if 条件前多余的 ANDOR

需求: 根据用户可能输入的 brandcarType 进行查询。

文件路径 (接口): src/main/java/com/example/bank/mapper/CarMapper.java (新建)

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

import com.example.bank.pojo.Car;
import org.apache.ibatis.annotations.Param;
import java.util.List;

public interface CarMapper {
List<Car> selectByCondition(@Param("brand") String brand, @Param("carType") String carType);
}

文件路径 (XML): src/main/resources/mappers/CarMapper.xml (新建)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.bank.mapper.CarMapper">
<select id="selectByCondition" resultType="com.example.bank.pojo.Car">
select id, car_num, brand, guide_price, produce_time, car_type
from t_car
<where>
<if test="brand != null and brand != ''">
and brand = #{brand}
</if>
<if test="carType != null and carType != ''">
and car_type = #{carType}
</if>
</where>
</select>
</mapper>

请务必在 mybatis-config.xml<mappers> 标签中注册这个新的映射文件:

<mapper resource="mappers/CarMapper.xml"/>

1. 编写测试

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

import com.example.bank.mapper.CarMapper;
import com.example.bank.pojo.Car;
import com.example.bank.utils.SqlSessionUtil;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.session.SqlSession;
import org.junit.jupiter.api.Test;

import java.util.List;

import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat;

@Slf4j
public class CarMapperTest {

@Test
void testSelectByCondition() {
try (SqlSession sqlSession = SqlSessionUtil.openSession()) {
CarMapper carMapper = sqlSession.getMapper(CarMapper.class);
// 场景 1: 两个条件都非空
log.info("场景1: brand='比亚迪汉', carType='新能源'");
List<Car> cars1 = carMapper.selectByCondition("比亚迪汉", "新能源");
// [Car(id = 2, carNum = null, brand = 比亚迪汉, guidePrice = null, produceTime = null, carType = null)]
System.out.println(cars1);

// 场景 2: 只有 brand 非空
log.info("场景2: brand='宝马530Li', carType=null");
List<Car> cars2 = carMapper.selectByCondition("宝马530Li", null);
// [Car(id = 1, carNum = null, brand = 宝马 530Li, guidePrice = null, produceTime = null, carType = null)]
System.out.println(cars2);

// 场景 3: 两个条件都为空,查询所有
log.info("场景3: brand=null, carType=null");
List<Car> cars3 = carMapper.selectByCondition(null, null);
System.out.println(cars3);
}
}
}

3.2.3. <foreach> 标签

需求: 批量删除指定 ID 的汽车。SQL 语句类似 DELETE FROM t_car WHERE id IN (1, 2, 3)

<foreach> 标签用于遍历集合或数组,是实现批量操作(尤其是 IN 子句)的利器。

核心属性讲解:

  • collection: 必须。指定要遍历的参数,值与 Mapper 接口中 @Param 注解定义的值一致。
  • item: 必须。为集合中每个元素指定的变量名,在标签内部通过 #{} 来引用,例如 #{id}
  • open: 可选。表示整个遍历内容开始前要拼接的字符串,例如 (
  • close: 可选。表示整个遍历内容结束后要拼接的字符串,例如 )
  • separator: 可选。表示每次遍历元素之间要拼接的分隔符,例如 ,

文件路径 (接口): src/main/java/com/example/bank/mapper/CarMapper.java (添加方法)

1
int deleteByIds(@Param("ids") Long[] ids);

文件路径 (XML): src/main/resources/mappers/CarMapper.xml (添加)

1
2
3
4
5
6
<delete id="deleteByIds">
delete from t_car where id in
<foreach collection="ids" item="id" separator="," open="(" close=")">
#{id}
</foreach>
</delete>

1. 编写测试

文件路径: src/test/java/com/example/bank/test/CarMapperTest.java (添加测试方法)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Test
void testDeleteByIds() {
try (SqlSession sqlSession = SqlSessionUtil.openSession()) {
CarMapper carMapper = sqlSession.getMapper(CarMapper.class);
Long[] ids = {1L, 3L};
int count = carMapper.deleteByIds(ids);
assertThat(count).isEqualTo(2);
sqlSession.commit(); // 提交事务
log.info("成功批量删除 {} 条记录", count);
}
}
// 输出:
// 日志将显示: delete from t_car where id in ( ? , ? )
// INFO ... - 成功批量删除 2 条记录

3.2.4. <set> 标签

需求: 更新一辆汽车的信息,但只更新用户传入的字段,未传入的字段保持数据库原样(即“选择性更新”)。

<set> 标签与 <where> 类似,它会智能地生成 SET 关键字,并 移除 最后一个 if 条件后多余的逗号。

文件路径 (接口): src/main/java/com/example/bank/mapper/CarMapper.java (添加方法)

1
int updateSelective(Car car);

文件路径 (XML): src/main/resources/mappers/CarMapper.xml (添加)

1
2
3
4
5
6
7
8
9
10
11
<update id="updateSelective">
update t_car
<set>
<if test="carNum != null and carNum != ''">car_num = #{carNum},</if>
<if test="brand != null and brand != ''">brand = #{brand},</if>
<if test="guidePrice != null">guide_price = #{guidePrice},</if>
<if test="produceTime != null and produceTime != ''">produce_time = #{produceTime},</if>
<if test="carType != null and carType != ''">car_type = #{carType},</if>
</set>
where id = #{id}
</update>

1. 编写测试

文件路径: src/test/java/com/example/bank/test/CarMapperTest.java (添加测试方法)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Test
void testUpdateSelective() {
try (SqlSession sqlSession = SqlSessionUtil.openSession()) {
CarMapper carMapper = sqlSession.getMapper(CarMapper.class);
// 我们只想把 id = 2 的车的品牌改为 "比亚迪-汉 EV",其他字段不传值
Car carToUpdate = new Car();
carToUpdate.setId(2L);
carToUpdate.setBrand("比亚迪-汉EV");

int count = carMapper.updateSelective(carToUpdate);
assertThat(count).isEqualTo(1);
sqlSession.commit();
log.info("选择性更新成功");
}
}
// 输出:
// 日志将显示: update t_car SET brand = ? where id = ?
// INFO ... - 选择性更新成功

3.2.5. <sql><include> 标签

需求: 在多个查询中,我们都需要查询相同的字段列表,为了避免重复编写和方便维护,我们可以将其抽取出来。

  • sql 标签:用于定义一个可重用的 SQL 片段。
    • id 属性: 必须。为这个 SQL 片段指定一个唯一的名称。
  • include 标签:用于在其他地方引用 sql 标签定义的片段。
    • refid 属性: 必须。其值必须与要引用的 <sql> 标签的 id 一致。

文件路径 (XML): src/main/resources/mappers/CarMapper.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
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.bank.mapper.CarMapper">

<!-- 1. 定义可重用的SQL片段,用于存放表的列名 -->
<!-- 优点:当表字段变更时,只需修改此处,所有引用该片段的select语句都会生效,便于维护。 -->
<sql id="carColumns">
id, car_num, brand, guide_price, produce_time, car_type
</sql>

<!-- updateSelective 语句保持不变,因为它不涉及查询所有列,其动态性由 <set> 保证 -->

<!-- 2. 重构 selectByCondition,使用 <include> 引用SQL片段 -->
<select id="selectByCondition" resultType="com.example.bank.pojo.Car">
select <include refid="carColumns"/>
from t_car
<where>
<if test="brand != null and brand != ''">
and brand = #{brand}
</if>
<if test="carType != null and carType != ''">
and car_type = #{carType}
</if>
</where>
</select>

<!-- 3. 新增一个根据ID查询的示例,同样使用 <include> -->
<select id="selectById" resultType="com.example.bank.pojo.Car">
select <include refid="carColumns"/>
from t_car
where id = #{id}
</select>

<!-- deleteByIds 语句保持不变,因为它不涉及查询列 -->

</mapper>

这个重构不改变任何功能,因此之前的测试用例 testSelectByCondition 依然会通过,这恰好证明了 <sql><include> 在不影响业务逻辑的前提下,优化了代码结构。


第 4 章 [扩展性] 复杂关系映射与性能优化

摘要: 现实世界的业务远不止单表操作那么简单。本章我们将为银行应用引入“客户”实体,并深入探讨如何处理 “一个客户拥有多个账户” (一对多) 以及 “多个账户属于一个客户” (多对一) 这样的关联关系。您将掌握 MyBatis 中最强大的结果映射工具 <resultMap>,以及用于处理关联关系的 <association><collection> 标签。在此基础上,我们将进一步探讨性能优化这一高级主题,揭示著名的 N+1 查询问题,并学习如何通过 延迟加载一、二级缓存 来显著提升应用性能。


4.1. [基础] resultMap:复杂结果映射的基石

到目前为止,我们一直使用 resultType 来自动映射查询结果,这在列名与属性名(或开启驼峰映射后)能对应上的情况下非常方便。但当遇到多表查询、列名与属性名无规律对应时,我们就需要一个更强大的工具——<resultMap>

<resultMap> 允许我们手动定义数据库查询结果(ResultSet)的列与 Java 对象(POJO)的属性之间的映射关系。

核心标签与属性讲解:

  • <resultMap>: 定义一个结果映射集。
    • id: 必须。为此 <resultMap> 指定一个唯一的名称,供 <select> 标签引用。
    • type: 必须。指定要映射到的 POJO 类的全限定名或别名。
  • <id>: 用于映射主键字段,有助于 MyBatis 提高性能。
    • property: POJO 中的属性名。
    • column: 数据库结果集中的列名。
  • <result>: 用于映射普通字段。
    • property: POJO 中的属性名。
    • column: 数据库结果集中的列名。

需求: 假设 t_car 表的列名不规范(例如 car_number, car_brand),我们需要手动将其映射到 Car 对象的 carNum, brand 属性上。

文件路径 (XML): src/main/resources/mappers/CarMapper.xml

1
2
3
4
5
6
7
8
9
10
11
12
<resultMap id="carResultMap" type="com.example.bank.pojo.Car">
<id property="id" column="id"/>
<result property="carNum" column="car_num"/>
<result property="brand" column="brand"/>
<result property="guidePrice" column="guide_price"/>
<result property="produceTime" column="produce_time"/>
<result property="carType" column="car_type"/>
</resultMap>

<select id="selectById" resultMap="carResultMap">
select <include refid="carColumns" /> where id = #{id}
</select>

通过这种方式,无论列名多么不规范,我们都能精确地将其映射到正确的 Java 属性上。


4.2. [核心] 处理关联关系

现在,我们来升级我们的银行应用业务模型。

1. 场景与准备工作

一个客户(Customer)可以拥有多个账户(Account)。这是一个典型的“一对多”关系。反过来,一个账户(Account)只属于一个客户(Customer),这是一个“多对一”关系。

  • 数据库准备:
    我们新建 t_customer 表,并为 t_account 表添加外键 customer_id
1
2
3
4
5
6
7
8
9
10
11
12
13
14
-- 新建客户表
CREATE TABLE `t_customer` (
`id` int NOT NULL AUTO_INCREMENT,
`name` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`)
);
INSERT INTO `t_customer` (id, name) VALUES (1, '张三'), (2, '李四');

-- 为账户表添加外键
ALTER TABLE `t_account` ADD COLUMN `customer_id` INT NULL;
UPDATE `t_account` SET `customer_id` = 1 WHERE `actno` = 'act-001';
UPDATE `t_account` SET `customer_id` = 2 WHERE `actno` = 'act-002';
-- 假设我们给张三再开一个账户
INSERT INTO `t_account` (actno, balance, customer_id) VALUES ('act-003', 1000.00, 1);
  • POJO 准备:
    文件路径: src/main/java/com/example/bank/pojo/Customer.java (新建)
1
2
3
4
5
6
7
8
9
10
11
12
package com.example.bank.pojo;

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

@Data
public class Customer implements Serializable { // 实现 Serializable 为二级缓存做准备
private Integer id;
private String name;
private List<Account> accounts; // 一个客户拥有多个账户
}

文件路径: src/main/java/com/example/bank/pojo/Account.java (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
package com.example.bank.pojo;

import lombok.Data;
import java.io.Serializable;

@Data
public class Account implements Serializable { // 实现 Serializable
private Integer id;
private String actno;
private Double balance;
private Integer customerId;
private Customer customer; // 一个账户属于一个客户
}

2. 多对一 (<association>)

需求: 查询账户信息时,同时把所属的客户信息也查询出来。

我们采用“分步查询”的方式,这更灵活,且是实现后续“延迟加载”优化的前提。

  • association 标签讲解:
    • <association> 用于在 resultMap 中处理“拥有一个”(has-one)的关联关系。
    • property: POJO 中关联对象的属性名,例如 customer
    • select: 指定一个外部查询的 statementIdnamespace.id),MyBatis 会执行这个查询来加载关联对象。
    • column: 将主查询结果的某一列的值,作为参数传递给 select 指定的查询。

文件路径 (接口): src/main/java/com/example/bank/mapper/CustomerMapper.java (新建)

1
2
3
4
5
package com.example.bank.mapper;
import com.example.bank.pojo.Customer;
public interface CustomerMapper {
Customer selectById(Integer id);
}

文件路径 (XML): src/main/resources/mappers/CustomerMapper.xml (新建)

1
2
3
4
5
6
7
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.bank.mapper.CustomerMapper">
<select id="selectById" resultType="com.example.bank.pojo.Customer">
select * from t_customer where id = #{id}
</select>
</mapper>

文件路径 (XML): src/main/resources/mappers/AccountMapper.xml (修改)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.bank.mapper.AccountMapper">

<!-- 为了清晰,可以给ResultMap一个更具描述性的名字,当然,保持原样也可以 -->
<resultMap id="accountWithCustomerResultMap" type="com.example.bank.pojo.Account">
<id property="id" column="id"/>
<result property="actno" column="actno"/>
<result property="balance" column="balance"/>
<!-- 关联查询:根据当前查询出的customer_id,去调用CustomerMapper中的selectById方法 -->
<association property="customer"
select="com.example.bank.mapper.CustomerMapper.selectById"
column="customer_id" />
</resultMap>

<select id="selectByActnoWithCustomer" resultMap="accountWithCustomerResultMap">
<!-- 建议明确写出列名,避免 "select *" 带来的潜在问题,并确保 column="customer_id" 存在 -->
select id, actno, balance, customer_id from t_account where actno = #{actno}
</select>

</mapper>

文件路径 (接口): src/main/java/com/example/bank/mapper/AccountMapper.java (添加方法)

1
Account selectByActnoWithCustomer(String actno);

编写测试

文件路径: src/test/java/com/example/bank/test/AssociationTest.java (新建)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Slf4j
public class AssociationTest {
@Test
void testSelectByActnoWithCustomer() {
try (SqlSession sqlSession = SqlSessionUtil.openSession()) {
AccountMapper mapper = sqlSession.getMapper(AccountMapper.class);
Account account = mapper.selectByActnoWithCustomer("act-001");

assertThat(account).isNotNull();
assertThat(account.getCustomer()).isNotNull();
assertThat(account.getCustomer().getName()).isEqualTo("张三");
log.info("多对一分步查询成功: {}", account);
}
}
}
// 输出:
// 日志会显示执行了两条 SQL:一条查询 t_account,一条查询 t_customer

3. 一对多 (<collection>)

需求: 查询客户信息时,同时把他名下所有的账户列表也查询出来。

  • collection 标签讲解:
    • <collection> 用于处理“拥有多个”(has-many)的关联关系。
    • property: POJO 中集合属性的名称,例如 accounts
    • ofType: 集合的泛型类型,例如 com.example.bank.pojo.Account
    • selectcolumn 的作用与 <association> 相同。

文件路径 (接口): src/main/java/com/example/bank/mapper/AccountMapper.java (添加方法)

1
List<Account> selectByCustomerId(Integer customerId);

文件路径 (XML): src/main/resources/mappers/AccountMapper.xml (添加)

1
2
3
<select id="selectByCustomerId" resultType="com.example.bank.pojo.Account">
select * from t_account where customer_id = #{customerId}
</select>

文件路径 (XML): src/main/resources/mappers/CustomerMapper.xml (修改)

1
2
3
4
5
6
7
8
9
10
11
12
<resultMap id="customerWithAccountsMap" type="com.example.bank.pojo.Customer">
<id property="id" column="id"/>
<result property="name" column="name"/>
<collection property="accounts"
ofType="com.example.bank.pojo.Account"
select="com.example.bank.mapper.AccountMapper.selectByCustomerId"
column="id" />
</resultMap>

<select id="selectByIdWithAccounts" resultMap="customerWithAccountsMap">
select * from t_customer where id = #{id}
</select>

文件路径 (接口): src/main/java/com/example/bank/mapper/CustomerMapper.java (修改)

1
Customer selectByIdWithAccounts(Integer id);

编写测试

文件路径: src/test/java/com/example/bank/test/CollectionTest.java (新建)

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

import com.example.bank.mapper.CustomerMapper;
import com.example.bank.pojo.Customer;
import com.example.bank.utils.SqlSessionUtil;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.session.SqlSession;
import org.junit.jupiter.api.Test;


@Slf4j
public class CollectionTest {
@Test
void testSelectByIdWithAccounts() {
try (SqlSession sqlSession = SqlSessionUtil.openSession()) {
CustomerMapper mapper = sqlSession.getMapper(CustomerMapper.class);
Customer customer = mapper.selectByIdWithAccounts(1);
log.info("一对多分步查询成功: {}", customer);
}
}
}
// 输出:
// 日志会显示执行了两条 SQL:一条查询 t_customer,一条查询 t_account

4.3. [性能] 性能优化

分步查询虽然灵活,但可能会引发著名的 N+1 查询问题:查询 1 个主对象(例如 Customer),触发了 N 个查询关联对象(Account)的 SQL。如果我们要查询 100 个客户,就会执行 1 + 100 = 101 条 SQL,效率低下。

1. 延迟加载

延迟加载是解决 N+1 问题的有效手段。它的核心思想是:非必要,不加载。只有当你真正去访问关联对象时(例如调用 customer.getAccounts()),MyBatis 才会去执行加载关联对象的 SQL。

  • 如何开启:
    在全局配置文件中开启即可。

文件路径: src/main/resources/mybatis-config.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
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE configuration
PUBLIC "-//mybatis.org//DTD Config 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration>
<!-- settings 必须在 environments 和 mappers 之前 -->
<settings>
<setting name="lazyLoadingEnabled" value="true"/>
</settings>

<!-- environments 可以放在 settings 之后 -->
<environments default="development">
<environment id="development">
<transactionManager type="JDBC"/>
<dataSource type="POOLED">
<property name="driver" value="com.mysql.cj.jdbc.Driver"/>
<property name="url" value="jdbc:mysql://localhost:3306/bank_db"/>
<property name="username" value="root"/>
<property name="password" value="root"/>
</dataSource>
</environment>
</environments>

<!-- mappers 放在最后 -->
<mappers>
<mapper resource="mappers/AccountMapper.xml"/>
<mapper resource="mappers/CarMapper.xml"/>
<mapper resource="mappers/CustomerMapper.xml"/>
</mappers>
</configuration>

  • 演示:
    文件路径: src/test/java/com/example/bank/test/PerformanceTest.java (新建)

2. MyBatis 缓存

缓存是另一种重要的性能优化手段,它能减少与数据库的交互次数。

  • 一级缓存 (SqlSession 级别):

    • 原理: 默认开启,与 SqlSession 绑定。在同一个 SqlSession 的生命周期内,执行完全相同的查询(相同的 statementId 和参数),只有第一次会访问数据库,后续的都将直接从内存缓存中获取。
    • 演示:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    @Test
    void testL1Cache() {
    try (SqlSession sqlSession = SqlSessionUtil.openSession()) {
    CustomerMapper mapper = sqlSession.getMapper(CustomerMapper.class);
    log.info("--- 第一次查询 ---");
    mapper.selectById(1);
    log.info("--- 第二次查询,应命中一级缓存,不会再发 SQL ---");
    mapper.selectById(1);
    }
    }
    // 输出:
    // ... --- 第一次查询 ---
    // ... ==> Preparing: select * from t_customer where id = ?
    // ... --- 第二次查询,应命中一级缓存,不会再发 SQL ---
    // (此处无新的 SQL 日志)
  • 二级缓存 (SqlSessionFactory 级别):

    危险: 二级缓存是跨会话共享的,数据一致性较难保证,不同会话可能获取到旧数据。而且缓存过多数据会占用内存,不适合数据变化频繁的场景,所以默认不开启,让开发者根据实际需求灵活配置

    ✅ 适合开启二级缓存的场景

    查询多、更新少(典型的报表系统、字典表、配置类数据)

    单体应用或已配置了共享缓存方案(如整合 Redis)

    严格管理 Mapper 操作范围,不跨 Mapper 操作同一张表

    原理: 跨 SqlSession 共享的缓存,需要手动开启。当一个 SqlSession 关闭或提交时,它的一级缓存中的数据会被刷新到二级缓存中。

    开启步骤:

    1. mybatis-config.xml 中确保 cacheEnabledtrue (默认就是)。
    2. 在需要开启二级缓存的 Mapper XML 文件中添加 <cache/> 标签。
    3. 所有参与缓存的 POJO 必须实现 java.io.Serializable 接口(我们之前已完成)。

    文件路径: src/main/resources/mappers/CustomerMapper.xml (添加)

    1
    2
    3
    4
    <mapper namespace="com.example.bank.mapper.CustomerMapper">
    <cache/>
    ...
    </mapper>

第 5 章 [生产力] MyBatis 生态工具

摘要: 优秀的框架往往拥有一个繁荣的生态。MyBatis 也不例外,社区为其贡献了许多强大的工具来自动化常见的开发任务。本章我们将聚焦于两个最核心的生产力工具:首先,我们将学习如何使用 PageHelper 插件,用两行代码优雅地实现曾一度令人头疼的物理分页功能;接着,我们将掌握 MyBatis Generator 逆向工程,实现根据数据库表一键自动生成 POJO、Mapper 接口和 XML 文件,彻底告别重复的手动编码。


5.1. [分页] PageHelper - 优雅的分页助手

分页是 Web 开发中最常见的需求之一。如果没有工具,我们需要手动实现非常繁琐的逻辑。

5.1.1. 背景:手动分页的痛点

  • SQL 耦合: 我们需要在 SQL 语句中硬编码 LIMIT ?, ?,这意味着 Mapper 接口的每个分页方法都需要额外接收 startIndexpageSize 两个参数。
  • 功能割裂: 为了在页面上显示“共 X 页,共 Y 条”等信息,我们通常需要执行一条额外的 SELECT COUNT(*) 查询来获取总记录数。这两步操作在业务逻辑中是分开的,管理起来很麻烦。

PageHelper 的出现,完美地解决了这些问题。它以非侵入的方式,让我们对分页的处理变得极其简单。

5.1.2. PageHelper 快速上手

集成 PageHelper 只需两步。

1. 添加 Maven 依赖

首先,我们在 pom.xml 文件中添加 PageHelper 的依赖。

文件路径: pom.xml

1
2
3
4
5
<dependency>
<groupId>com.github.pagehelper</groupId>
<artifactId>pagehelper</artifactId>
<version>5.3.3</version>
</dependency>

2. 配置 MyBatis 插件

PageHelper 的工作原理是作为 MyBatis 的一个 拦截器 (Interceptor),它会拦截我们即将执行的 SQL 语句,并自动地在末尾追加上物理分页的关键字(如 LIMIT)。我们需要在全局配置文件中注册这个拦截器。

文件路径: src/main/resources/mybatis-config.xml

危险: 根据 MyBatis DTD 规范,正确的顺序应该是如下的代码块,一定要严格按照,非常严格,否则很容易导致报错

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
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE configuration
PUBLIC "-//mybatis.org//DTD Config 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-config.dtd">
<configuration>
<!-- 1. settings 必须在前面 -->
<settings>
<setting name="lazyLoadingEnabled" value="true"/>
</settings>

<!-- 2. plugins 必须在 environments 之前 -->
<plugins>
<plugin interceptor="com.github.pagehelper.PageInterceptor"/>
</plugins>

<!-- 3. environments 在 plugins 之后 -->
<environments default="development">
<environment id="development">
<transactionManager type="JDBC"/>
<dataSource type="POOLED">
<property name="driver" value="com.mysql.cj.jdbc.Driver"/>
<property name="url" value="jdbc:mysql://localhost:3306/bank_db"/>
<property name="username" value="root"/>
<property name="password" value="root"/>
</dataSource>
</environment>
</environments>

<!-- 4. mappers 必须放在最后 -->
<mappers>
<mapper resource="mappers/AccountMapper.xml"/>
<mapper resource="mappers/CarMapper.xml"/>
<mapper resource="mappers/CustomerMapper.xml"/>
</mappers>
</configuration>

5.1.3. 实践与演示

PageHelper 最具魅力的一点是:我们无需修改任何已有的 Mapper 接口和 SQL 语句。我们可以对任何一个返回 List 的查询方法进行分页。

1. 准备一个查询全部的方法

我们在 CarMapper 中准备一个查询所有汽车信息的方法。

文件路径 (接口): src/main/java/com/example/bank/mapper/CarMapper.java (添加方法)

1
List<Car> selectAll();

文件路径 (XML): src/main/resources/mappers/CarMapper.xml (添加)

1
2
3
<select id="selectAll" resultType="com.example.bank.pojo.Car">
select id, car_num, brand, guide_price, produce_time, car_type from t_car
</select>

2. 编写测试

现在,我们来见证 PageHelper 的神奇之处。

核心用法:

  1. 在执行查询 之前,调用 PageHelper.startPage(pageNum, pageSize) 来“声明”接下来的一次查询需要分页。
  2. 正常执行你的查询方法。
  3. new PageInfo<>(查询结果) 来包装 List,从中获取详尽的分页信息。

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

import com.example.bank.mapper.CarMapper;
import com.example.bank.pojo.Car;
import com.example.bank.utils.SqlSessionUtil;
import com.github.pagehelper.PageHelper;
import com.github.pagehelper.PageInfo;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.session.SqlSession;
import org.junit.jupiter.api.Test;
import java.util.List;

@Slf4j
public class PageHelperTest {

@Test
void testPageHelper() {
try (SqlSession sqlSession = SqlSessionUtil.openSession()) {
CarMapper carMapper = sqlSession.getMapper(CarMapper.class);

// 1. 开启分页 (查询第 2 页,每页 2 条数据)
int pageNum = 1;
int pageSize = 2;
PageHelper.startPage(pageNum, pageSize);

// 2. 正常执行查询
List<Car> cars = carMapper.selectAll();

// 3. 使用 PageInfo 包装查询结果,获取分页详细信息
// PageInfo 的构造方法中,第二个参数 5 表示导航分页的页码数量
// << 1 2 3 4 5 > >
PageInfo<Car> pageInfo = new PageInfo<>(cars, 5);

log.info("分页信息: {}", pageInfo);

}
}
}// 输出:
// 日志将显示 PageHelper 自动执行了 SELECT COUNT(*) 查询,并在原查询后追加了 LIMIT
// INFO ... - 分页信息: PageInfo{pageNum = 2, pageSize = 2, size = 2, startRow = 3,
// endRow = 4, total = 4, pages = 2, ...}

前瞻提示: 在 Spring Boot 项目中集成 PageHelper 更加简单,通常只需引入一个 pagehelper-spring-boot-starter 依赖,连 XML 中的插件配置都可以省略,真正做到开箱即用。


5.2. [提效] MyBatis Generator - 告别重复劳动 (终极讲解重构版)

对于一个拥有几十甚至上百张表的项目,手动为每张表创建 POJO、Mapper 接口和 XML 文件,是一项极其枯燥、耗时且容易出错的工作。MyBatis Generator (MBG) 就是为了解决这个问题而生的自动化工具。

5.2.1. Generator 配置详解

配置 MBG 分为两步:在 pom.xml 中引入插件,并编写一个 generatorConfig.xml 配置文件。

1. 配置 Maven 插件

首先,我们需要在项目的 pom.xml 中引入 mybatis-generator-maven-plugin 插件,并为其提供数据库驱动。

文件路径: pom.xml (在 <build> 标签内添加)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<build>
<plugins>
<plugin>
<groupId>org.mybatis.generator</groupId>
<artifactId>mybatis-generator-maven-plugin</artifactId>
<version>1.4.2</version>
<dependencies>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<version>8.2.0</version>
</dependency>
</dependencies>
<configuration>
<configurationFile>src/main/resources/generatorConfig.xml</configurationFile>
<overwrite>true</overwrite>
</configuration>
</plugin>
</plugins>
</build>

2. 详解 generatorConfig.xml

这个文件是 MBG 的“行动指南”,也是我们自定义生成规则的核心。下面,我们将逐一拆解它的关键配置项。

文件路径: src/main/resources/generatorConfig.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
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE generatorConfiguration
PUBLIC "-//mybatis.org//DTD MyBatis Generator Configuration 1.0//EN"
"http://mybatis.org/dtd/mybatis-generator-config_1_0.dtd">

<!--
这个文件是 MBG 的“行动指南”,也是我们自定义生成规则的核心。
下面,我们将逐一拆解它的关键配置项。
-->
<generatorConfiguration>
<!--
<context> 标签
这是最核心的根标签,包裹了所有的生成配置。
- id: 为这个配置上下文指定一个唯一的 ID。
- targetRuntime: **必须** 指定为 `MyBatis3`,以生成适用于现代 MyBatis 版本的代码。
-->
<context id="MySqlContext" targetRuntime="MyBatis3">

<!--
<jdbcConnection> 标签
用于配置数据库连接信息,MBG 需要连接数据库来读取表的元数据(字段、类型等)。
- driverClass: 数据库驱动的全限定名。
- connectionURL: 数据库的 JDBC 连接地址。
- userId 和 password: 数据库的用户名和密码。
-->
<jdbcConnection driverClass="com.mysql.cj.jdbc.Driver"
connectionURL="jdbc:mysql://localhost:3306/bank_db"
userId="root"
password="root"/>

<!--
<javaModelGenerator> 标签
用于配置 POJO 实体类 的生成规则。
- targetPackage: 指定生成的 POJO 类要放在哪个包下,例如 `com.example.bank.pojo.gen`。
- targetProject: 指定生成的代码要输出到哪个源码根目录,通常是 `src/main/java`。
-->
<javaModelGenerator targetPackage="com.example.bank.pojo.gen"
targetProject="src/main/java"/>

<!--
<sqlMapGenerator> 标签
用于配置 Mapper XML 文件 的生成规则。
- targetPackage: 指定生成的 XML 文件要放在哪个包路径下,例如 `com.example.bank.mapper.gen`。
- targetProject: 指定生成的资源文件要输出到哪个资源根目录,通常是 `src/main/resources`。
-->
<sqlMapGenerator targetPackage="com.example.bank.mapper.gen"
targetProject="src/main/resources"/>

<!--
<javaClientGenerator> 标签
用于配置 Mapper 接口 的生成规则。
- type: **必须** 指定为 `XMLMAPPER`,表示我们要为 XML 文件生成对应的 Java 接口。
- targetPackage: 指定生成的 Mapper 接口要放在哪个包下,例如 `com.example.bank.mapper.gen`。
- targetProject: 指定生成的代码要输出到哪个源码根目录,通常是 `src/main/java`。
-->
<javaClientGenerator type="XMLMAPPER"
targetPackage="com.example.bank.mapper.gen"
targetProject="src/main/java"/>

<!--
<table> 标签
用于指定要为哪张数据库表生成代码,可以配置多个 `<table>` 标签。
- tableName: **必须**,指定数据库中的表名。
- domainObjectName: **必须**,指定生成的 POJO 类的类名。
-->
<table tableName="t_account" domainObjectName="AccountGen"/>
<table tableName="t_customer" domainObjectName="CustomerGen"/>

</context>
</generatorConfiguration>

请注意,targetProjecttargetPackage 会组合在一起决定最终的输出路径。例如 targetProject="src/main/java"targetPackage="com.example.bank.pojo.gen" 会将文件生成在 src/main/java/com/example/bank/pojo/gen/ 目录下。

5.2.2. 执行生成与验证

1. 执行生成

在 IDEA 的 Maven 面板中,找到 mybatis-generator 插件,并双击 generate 目标来执行。

执行成功后,您的项目目录中会自动生成如下文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
src
├── main
│ ├── java
│ │ └── com
│ │ └── example
│ │ └── bank
│ │ ├── mapper
│ │ │ └── gen
│ │ │ ├── AccountGenMapper.java
│ │ │ └── CustomerGenMapper.java
│ │ └── pojo
│ │ └── gen
│ │ ├── AccountGen.java
│ │ ├── AccountGenExample.java
│ │ ├── CustomerGen.java
│ │ └── CustomerGenExample.java
│ └── resources
│ └── com
│ └── example
│ └── bank
│ └── mapper
│ └── gen
│ ├── AccountGenMapper.xml
│ └── CustomerGenMapper.xml

2. 验证生成的代码

MBG 生成的代码非常强大,它不仅包含了基础的 CRUD,还有一个 Example 类,可以让我们以面向对象的方式构建复杂的 WHERE 子句(这种方式被称为 QBC - Query By Criteria)。

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

import com.example.bank.mapper.gen.AccountGenMapper;
import com.example.bank.pojo.gen.AccountGen;
import com.example.bank.pojo.gen.AccountGenExample;
import com.example.bank.utils.SqlSessionUtil;
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.session.SqlSession;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;

@Slf4j
public class GeneratorTest {
@Test
void testGeneratorCode() {
try (SqlSession sqlSession = SqlSessionUtil.openSession()) {
// MBG 生成的 Mapper 接口可以直接使用
AccountGenMapper mapper = sqlSession.getMapper(AccountGenMapper.class);

// 使用 Example 类构建查询条件:查询余额大于 20000 的账户
AccountGenExample example = new AccountGenExample();
example.createCriteria().andBalanceGreaterThan(20000.00);

// 执行查询,无需手写 SQL
List<AccountGen> accounts = mapper.selectByExample(example);

assertThat(accounts).hasSize(1);
assertThat(accounts.get(0).getActno()).isEqualTo("act-001");
log.info("MBG 生成的代码查询成功: {}", accounts);
}
}
}
// 输出:
// INFO ... - MBG 生成的代码查询成功: [AccountGen{id = 1, actno ='act-001', balance = 50000.0, customerId = 1}]

可以看到,通过详细配置,MBG 成为了我们手中的一把利器。它为我们生成了功能完备、类型安全的数据访问层代码,使我们能从重复的劳动中解放出来,更专注于业务逻辑的实现。


第 6 章 [总结] 继往开来:回顾与展望

摘要: 恭喜您!从 JDBC 的困境出发,到亲手搭建并完善一个银行应用,您已经系统地掌握了 MyBatis 从入门到进阶的核心技术。我们一起探索了 CRUD 操作动态 SQL复杂关系映射性能优化 以及 生态工具。本章将不再引入新的功能,而是通过 学习路径回顾核心知识速查未来技术展望 三个部分,来为您梳理知识体系,巩固学习成果,并清晰地规划出下一步的学习路径——拥抱 Spring Boot,让 MyBatis 在现代化企业级开发中发挥出更大的威力。


6.1. [回顾] 我们的 MyBatis 学习之旅

让我们通过一个时间线,回顾一下我们是如何一步步构建并完善我们的应用,以及在每个阶段掌握的关键技能。

2025

第五章: 生产力

  • 我们学习了如何集成 PageHelper 插件,用两行代码实现了优雅的物理分页。
  • 我们掌握了 MyBatis Generator,根据数据库表一键生成了所有基础的数据访问层代码,极大提升了开发效率。

第四章: 扩展性

  • 我们深入学习了 <resultMap>,并用 <association><collection> 成功处理了客户与账户间的“一对多/多对一”关联关系。
  • 我们揭示了 N+1 查询问题,并通过 延迟加载一、二级缓存 对应用进行了性能优化。

第三章: 健壮性

  • 我们为转账业务引入了 事务控制,通过改造 SqlSessionUtil 并结合 ThreadLocal 模式,确保了业务操作的原子性。
  • 我们为了满足报表需求,全面学习了 MyBatis 最强大的 动态 SQL 功能,包括 <if>, <where>, <foreach> 等标签。

第二章: 核心

  • 我们实现了应用的第一个核心功能“转账”,并在此过程中掌握了完整的 CRUD 操作。
  • 我们学习并采用了 Mapper 代理模式,并对比了 XML注解 两种实现方式。
  • 我们厘清了 @Param 注解的用法,以及 #{}${} 的本质区别与安全风险。

第一章: 奠基

  • 我们理解了 MyBatis 的价值,并从零开始,利用现代化工具库搭建了第一个 MyBatis 项目。
  • 我们掌握了 MyBatis 的基本工作流,配置了核心文件,并成功执行了第一次数据库查询。

6.2. [核心] 关键配置与最佳实践速查

为了方便您日后查阅,这里将整个学习过程中的核心配置和最佳实践汇总成速查表。

1. mybatis-config.xml 核心标签

标签功能描述
settings全局配置开关。例如:mapUnderscoreToCamelCase (驼峰映射), lazyLoadingEnabled (延迟加载)。
typeAliases为 POJO 类起别名,简化 XML 中的书写。
plugins注册插件,例如 PageHelper 分页拦截器。
environments配置数据库环境(数据源、事务管理器)。
mappers注册 Mapper 映射文件。

2. Mapper XML 核心标签

标签功能描述
<select>定义查询操作。
<insert>定义插入操作,可通过 useGeneratedKeys 获取自增主键。
<update>定义更新操作。
<delete>定义删除操作。
<resultMap>(核心) 定义复杂的列-属性映射关系。
<association>(核心) 用于处理“多对一”关联关系。
<collection>(核心) 用于处理“一对多”关联关系。
<if> <where>(核心) 用于构建动态条件的查询语句。
<foreach>(核心) 用于遍历集合,实现 IN 子句等批量操作。

3. 最佳实践清单

  • 开发模式: 始终使用 Mapper 代理模式(接口 + XML/注解)进行开发。
  • 参数安全: 永远优先使用 #{} 作为参数占位符以防止 SQL 注入。
  • 事务控制: 事务应在 Service 层 进行管理,以确保业务的原子性。在非 Spring 环境下,使用 ThreadLocal 模式管理 SqlSession 是保证事务统一的最佳实践。
  • 动态查询: 充分利用动态 SQL 标签(<where>, <set>, <foreach>)来构建灵活、健壮的 SQL。
  • 性能考量: 对于关联查询,优先考虑使用 延迟加载 来避免 N+1 问题。对于高频访问的数据,合理开启 二级缓存

6.3. [展望] 下一步:拥抱 Spring Boot

我们本次学习中手动编写的 SqlSessionUtil 工具类、以及在 Service 层手动控制事务的 try-catch-finally 代码块,都是为了让您深刻理解 MyBatis 的底层工作原理。在现代化的企业级开发中,这些工作几乎都由 SpringSpring Boot 框架自动完成了。

Spring Boot 会为我们简化什么?

  • 自动化配置: 无需再编写 mybatis-config.xml。数据库连接、驼峰映射、插件注册等都可以在 application.propertiesapplication.yml 中通过几行配置完成。
  • 依赖管理: 只需引入一个 mybatis-spring-boot-starter,所有相关的依赖都会被自动管理。
  • 对象管理: SqlSessionFactorySqlSession 的创建、管理、关闭都由 Spring 容器自动完成。Mapper 接口可以直接通过 @Autowired 注解注入到 Service 层使用。
  • 声明式事务: 这是最重要的简化。我们不再需要手动 commitrollback

对比一下我们的手动事务代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 我们的手动事务代码
public void transfer(...) {
SqlSession sqlSession = SqlSessionUtil.openSession();
try {
// ... 业务逻辑 ...
sqlSession.commit();
} catch (Exception e) {
sqlSession.rollback();
throw e;
} finally {
SqlSessionUtil.closeSession();
}
}

在 Spring Boot 中,它会简化成这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class AccountServiceImpl implements AccountService {

@Autowired // Mapper 对象由 Spring 自动注入
private AccountMapper accountMapper;

@Transactional // 只需一个注解,Spring 就会自动管理事务
@Override

public void transfer(...) {
// ... 纯粹的业务逻辑,无需任何 SqlSession 操作 ...
}
}

可以看到,框架帮助我们处理了所有繁琐的底层细节,让我们可以更专注于业务逻辑本身。

总结: 您已经为 MyBatis 打下了坚实的基础。您的下一个目标,就是学习如何将 MyBatis 整合进 Spring Boot 中。有了现在对底层原理的深刻理解,您在学习整合开发时将会事半功倍,游刃有余。祝您在技术的道路上不断精进,一帆风顺!