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

第 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,什么也查不到,从而保证了安全。