第 1 章 [奠基]
项目启动与首次数据库交互 摘要 : 本章节将带领您MyBatis 的初学者 ——踏上持久层框架的学习之旅。我们将首先探讨直接使用 JDBC 的痛点,从而理解 MyBatis 存在的 核心价值 。随后,您将亲手从零开始,利用 Lombok
、SLF4J
、AssertJ
等现代化工具库,高效地完成一个 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 String sql = "insert into t_user(username, password) values(?,?)" ;PreparedStatement ps = conn.prepareStatement(sql);ps.setString(1 , "zhangsan" ); ps.setString(2 , "123456" ); int count = ps.executeUpdate();
2. 手动封装的结果集 将 ResultSet
查询结果手动转换(封装)为 Java 对象(POJO)的过程,充满了大量的 get/set
操作。这部分代码不仅重复度高,而且当数据库表字段增减时,维护起来非常痛苦。
1 2 3 4 5 6 7 8 9 10 11 12 13 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/setter
或 toString
方法,代码会非常整洁。
文件路径 : 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" > <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-else
或 System.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()) { AccountMapper accountMapper = sqlSession.getMapper(AccountMapper.class); Account account = accountMapper.selectByActno("act-001" ); 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 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) ; @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 @Slf4j public class AccountMapperTest { @Test public void testUpdate () { try (SqlSession sqlSession = SqlSessionUtil.openSession()) { AccountMapper accountMapper = sqlSession.getMapper(AccountMapper.class); Account account = new Account (null ,"act-001" ,9999999.99 ); int count = accountMapper.update(account); assertThat(count).isEqualTo(1 ); sqlSession.commit(); } } }
4. 补充 insert
与 delete
操作 同理,为了功能完整,我们为银行应用添加开户 (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 List<Account> selectByBalanceRange (Double minBalance, Double maxBalance) ;
当 MyBatis 看到这个方法时
它无法将 minBalance
和 maxBalance
这两个参数名与 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; import java.util.List;public interface AccountMapper { 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()) { AccountMapper accountMapper = sqlSession.getMapper(AccountMapper.class); List<Account> accounts = accountMapper.selectByBalanceRange(5000.00 , 500000.00 ); 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 >
如果一个恶意用户传入的 actno
是 act-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 { 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); 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(); } } }
当我们执行这个转账方法时,它会在 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; 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); } } public static SqlSession openSession () { SqlSession session = localSession.get(); if (session == null ) { session = sqlSessionFactory.openSession(); localSession.set(session); } return session; } 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) { 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); 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; private String produceTime; private String carType; }
3.2.2. <if>
与 <where>
标签 这是最基础的动态 SQL 组合,用于处理可选的查询条件。
if
标签:用于进行条件判断。test
属性 : 它的值是一个 OGNL 表达式。如果表达式的结果为 true
,标签内部的 SQL 语句就会被包含进来。我们通常用它来判断传入的参数是否为 null
或空字符串。where
标签:用于智能地包裹 if
判断。它非常智能,只会在其内部有任何 if
条件成立时,才会生成 WHERE
关键字。 它会自动 移除 第一个 if
条件前多余的 AND
或 OR
。 需求 : 根据用户可能输入的 brand
和 carType
进行查询。
文件路径 (接口) : 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); log.info("场景1: brand='比亚迪汉', carType='新能源'" ); List<Car> cars1 = carMapper.selectByCondition("比亚迪汉" , "新能源" ); System.out.println(cars1); log.info("场景2: brand='宝马530Li', carType=null" ); List<Car> cars2 = carMapper.selectByCondition("宝马530Li" , null ); System.out.println(cars2); 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); } }
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); Car carToUpdate = new Car (); carToUpdate.setId(2L ); carToUpdate.setBrand("比亚迪-汉EV" ); int count = carMapper.updateSelective(carToUpdate); assertThat(count).isEqualTo(1 ); sqlSession.commit(); log.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" > <sql id ="carColumns" > id, car_num, brand, guide_price, produce_time, car_type </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 > <select id ="selectById" resultType ="com.example.bank.pojo.Car" > select <include refid ="carColumns" /> from t_car where id = #{id} </select > </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 { 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 { 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
: 指定一个外部查询的 statementId
(namespace.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 id ="accountWithCustomerResultMap" type ="com.example.bank.pojo.Account" > <id property ="id" column ="id" /> <result property ="actno" column ="actno" /> <result property ="balance" column ="balance" /> <association property ="customer" select ="com.example.bank.mapper.CustomerMapper.selectById" column ="customer_id" /> </resultMap > <select id ="selectByActnoWithCustomer" resultMap ="accountWithCustomerResultMap" > 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); } } }
3. 一对多 (<collection>
) 需求 : 查询客户信息时,同时把他名下所有的账户列表也查询出来。
collection
标签讲解 :<collection>
用于处理“拥有多个”(has-many)的关联关系。property
: POJO 中集合属性的名称,例如 accounts
。ofType
: 集合的泛型类型,例如 com.example.bank.pojo.Account
。select
和 column
的作用与 <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); } } }
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 > <setting name ="lazyLoadingEnabled" value ="true" /> </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 > <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 ); } }
二级缓存 (SqlSessionFactory 级别) :
危险 : 二级缓存是跨会话共享的,数据一致性较难保证,不同会话可能获取到旧数据。而且缓存过多数据会占用内存,不适合数据变化频繁的场景,所以默认不开启,让开发者根据实际需求灵活配置
✅ 适合开启二级缓存的场景
查询多、更新少(典型的报表系统、字典表、配置类数据)
单体应用或已配置了共享缓存方案(如整合 Redis)
严格管理 Mapper 操作范围,不跨 Mapper 操作同一张表
原理 : 跨 SqlSession
共享的缓存,需要手动开启。当一个 SqlSession
关闭或提交时,它的一级缓存中的数据会被刷新到二级缓存中。
开启步骤 :
在 mybatis-config.xml
中确保 cacheEnabled
为 true
(默认就是)。 在需要开启二级缓存的 Mapper XML 文件中添加 <cache/>
标签。 所有参与缓存的 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 接口的每个分页方法都需要额外接收 startIndex
和 pageSize
两个参数。功能割裂 : 为了在页面上显示“共 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 > <settings > <setting name ="lazyLoadingEnabled" value ="true" /> </settings > <plugins > <plugin interceptor ="com.github.pagehelper.PageInterceptor" /> </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 > <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
(添加方法)
文件路径 (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
的神奇之处。
核心用法 :
在执行查询 之前 ,调用 PageHelper.startPage(pageNum, pageSize)
来“声明”接下来的一次查询需要分页。 正常执行你的查询方法。 用 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); int pageNum = 1 ; int pageSize = 2 ; PageHelper.startPage(pageNum, pageSize); List<Car> cars = carMapper.selectAll(); PageInfo<Car> pageInfo = new PageInfo <>(cars, 5 ); log.info("分页信息: {}" , pageInfo); } } }
前瞻提示 : 在 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" > <generatorConfiguration > <context id ="MySqlContext" targetRuntime ="MyBatis3" > <jdbcConnection driverClass ="com.mysql.cj.jdbc.Driver" connectionURL ="jdbc:mysql://localhost:3306/bank_db" userId ="root" password ="root" /> <javaModelGenerator targetPackage ="com.example.bank.pojo.gen" targetProject ="src/main/java" /> <sqlMapGenerator targetPackage ="com.example.bank.mapper.gen" targetProject ="src/main/resources" /> <javaClientGenerator type ="XMLMAPPER" targetPackage ="com.example.bank.mapper.gen" targetProject ="src/main/java" /> <table tableName ="t_account" domainObjectName ="AccountGen" /> <table tableName ="t_customer" domainObjectName ="CustomerGen" /> </context > </generatorConfiguration >
请注意,targetProject
和 targetPackage
会组合在一起决定最终的输出路径。例如 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()) { AccountGenMapper mapper = sqlSession.getMapper(AccountGenMapper.class); AccountGenExample example = new AccountGenExample (); example.createCriteria().andBalanceGreaterThan(20000.00 ); List<AccountGen> accounts = mapper.selectByExample(example); assertThat(accounts).hasSize(1 ); assertThat(accounts.get(0 ).getActno()).isEqualTo("act-001" ); log.info("MBG 生成的代码查询成功: {}" , accounts); } } }
可以看到,通过详细配置,MBG 成为了我们手中的一把利器。它为我们生成了功能完备、类型安全的数据访问层代码,使我们能从重复的劳动中解放出来,更专注于业务逻辑的实现。
第 6 章 [总结]
继往开来:回顾与展望 摘要 : 恭喜您!从 JDBC 的困境出发,到亲手搭建并完善一个银行应用,您已经系统地掌握了 MyBatis 从入门到进阶的核心技术。我们一起探索了 CRUD 操作 、动态 SQL 、复杂关系映射 、性能优化 以及 生态工具 。本章将不再引入新的功能,而是通过 学习路径回顾 、核心知识速查 和 未来技术展望 三个部分,来为您梳理知识体系,巩固学习成果,并清晰地规划出下一步的学习路径——拥抱 Spring Boot,让 MyBatis 在现代化企业级开发中发挥出更大的威力。
6.1. [回顾]
我们的 MyBatis 学习之旅 让我们通过一个时间线,回顾一下我们是如何一步步构建并完善我们的应用,以及在每个阶段掌握的关键技能。
我们学习了如何集成 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 的底层工作原理。在现代化的企业级开发中,这些工作几乎都由 Spring
或 Spring Boot
框架自动完成了。
Spring Boot 会为我们简化什么?
自动化配置 : 无需再编写 mybatis-config.xml
。数据库连接、驼峰映射、插件注册等都可以在 application.properties
或 application.yml
中通过几行配置完成。依赖管理 : 只需引入一个 mybatis-spring-boot-starter
,所有相关的依赖都会被自动管理。对象管理 : SqlSessionFactory
和 SqlSession
的创建、管理、关闭都由 Spring 容器自动完成。Mapper 接口可以直接通过 @Autowired
注解注入到 Service 层使用。声明式事务 : 这是最重要的简化。我们不再需要手动 commit
和 rollback
。对比一下我们的手动事务代码:
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 private AccountMapper accountMapper; @Transactional @Override public void transfer (...) { } }
可以看到,框架帮助我们处理了所有繁琐的底层细节,让我们可以更专注于业务逻辑本身。
总结 : 您已经为 MyBatis 打下了坚实的基础。您的下一个目标,就是学习如何将 MyBatis 整合进 Spring Boot 中。有了现在对底层原理的深刻理解,您在学习整合开发时将会事半功倍,游刃有余。祝您在技术的道路上不断精进,一帆风顺!