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

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

摘要: 本章将揭开 MyBatis-Plus (MP) 效率革命的核心面纱——通用 CRUD。我们将不再编写重复的 SQL,而是通过继承 BaseMapperServiceImpl,在分钟级内完成标准的数据访问层与业务层构建。同时,我们将验证 MP 的“无侵入”特性,演示如何在享受便捷的同时,依然能够灵活编写复杂的自定义 SQL。

本章学习路径

  1. Mapper 革命:掌握 BaseMapper 的核心原理,通过一行代码解锁 17 个数据库操作方法。
  2. CRUD 实战:深度测试增删改查,理解 MP 如何处理逻辑删除(@TableLogic)与动态更新。
  3. 架构升级:从 Dao 层迈向 Service 层,利用 IService 实现事务封装与批量操作优化。
  4. 边界突破:当通用方法无法满足需求时,如何优雅地回退到原生 MyBatis 编写自定义 SQL。

2.1. BaseMapper:零 SQL 的奥秘

在上一章,我们创建了一个空的 UserMapper 接口。

1
public interface UserMapper extends BaseMapper<UserDO> {}

这行看似简单的代码背后,MP 利用 Java 泛型反射机制,在启动时自动解析 UserDO 的字节码,分析 @TableName@TableId 等注解,动态生成了对应的 SQL 语句(如 INSERT INTO tb_user...),并注入到 MyBatis 的容器中。

这就是 BaseMapper 的核心魔法:以类为表,以属性为字段,自动映射。

2.1.1. [新增] 插入与主键回填

首先,我们测试最基础的插入操作。

场景描述:向数据库插入一条新用户,并验证 ID 是否自动生成,以及插入后是否能直接获取到该 ID。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@SpringBootTest
class UserMapperTest {

@Autowired
private UserMapper userMapper;

@Test
void testInsert() {
System.out.println("----- [1] 开始执行 Insert 测试 -----");

// 1. 构建对象
// 注意:我们没有设置 ID,因为在 Ch1 中配置了 @TableId(type = IdType.AUTO)
UserDO user = new UserDO();
user.setName("Prorise");
user.setAge(30);
user.setEmail("prorise@example.com");

// 2. 执行插入
// insert 方法返回受影响的行数
int result = userMapper.insert(user);

// 3. 验证结果
// MP 会自动将数据库生成的主键回填到 user 对象中
System.out.println("受影响行数: " + result);
System.out.println("回填的主键 ID: " + user.getId());
}
}
1
2
3
4
5
6
----- [1] 开始执行 Insert 测试 -----
==> Preparing: INSERT INTO tb_user ( name, age, email, version, is_deleted, gmt_create, gmt_modified ) VALUES ( ?, ?, ?, ?, ?, ?, ? )
==> Parameters: Prorise(String), 30(Integer), prorise@example.com(String), 1(Integer), 0(Integer), 2025-12-14...(Timestamp), 2025-12-14...(Timestamp)
<== Updates: 1
受影响行数: 1
回填的主键 ID: 6 <-- 注意:这里获取到了数据库自增的 ID

深度解析

  • 字段自动填充:注意 SQL 中的 versionis_deletedgmt_create 等字段。虽然我们在 Java 代码中只设置了 name/age/email,但由于数据库设置了 DEFAULT 值(或者使用了 MP 的自动填充功能,将在后续章节讲解),数据完整性得到了保证。
  • 主键回填:这是 JDBC 的特性,MP 完美封装了它。在执行 insert 后,user.getId() 会立即有值,无需再次查询。

2.1.2. [删除] 逻辑删除的“骗局”

在企业级开发中,物理删除(DELETE FROM)是非常危险的操作。我们在第一章中配置了 @TableLogic,现在是见证它生效的时刻。

场景描述:调用删除接口,观察实际执行的 SQL 语句是否变成了 UPDATE。

1
2
3
4
5
6
7
8
9
10
11
12
13
@Test
void testDelete() {
System.out.println("----- [2] 开始执行 Delete 测试 -----");

// 1. 根据 ID 删除
// 这里的 1L 是初始化数据中的 Jone
int result = userMapper.deleteById(1L);
System.out.println("deleteById 受影响行数: " + result);

// 2. 批量删除
List<Long> ids = Arrays.asList(2L, 3L);
userMapper.deleteBatchIds(ids);
}

核心原理:由于我们在实体类 isDeleted 字段上添加了 @TableLogic,MP 的 deleteById 方法被“偷梁换柱”了。它执行的是更新操作,将 is_deleted 标记为 1。同时,后续所有的 select 操作都会自动加上 WHERE is_deleted=0,从而在业务层面实现数据的“隐身”。


2.1.3. [更新] 动态 SQL 的智慧

updateById 是一个智能方法,它只会更新那些“非 null”的字段。

场景描述:只修改用户的年龄,不修改邮箱和名字。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Test
void testUpdate() {
System.out.println("----- [3] 开始执行 Update 测试 -----");

// 1. 创建对象,仅设置 ID 和需要修改的字段
UserDO user = new UserDO();
user.setId(4L); // 假设 ID 4 存在
user.setAge(99);
// user.setEmail(null); // 显式为 null 或不设置的字段,不会被更新

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

注意updateById 的默认策略是 忽略 null 值。如果你真的想把某个字段更新为数据库的 NULL(例如清除邮箱),通过 updateById 传入 null 是无效的。这种情况需要使用 UpdateWrapper(将在第三章讲解)或配置全局策略 update-strategy


2.2. Service 层:架构的升维

在简单的 Demo 中,直接在 Controller 调用 Mapper 是可以的。但在真实项目中,我们需要 Service 层 来承载业务逻辑、事务控制和缓存处理。

MP 提供了 IService<T> 接口和 ServiceImpl<M, T> 实现类,它们是对 BaseMapper 的进一步封装,提供了更贴近业务语义的方法(如 save, saveBatch, saveOrUpdate)。

2.2.1. 定义 Service 接口与实现

这是一次标准化的代码结构搭建,请务必遵循。

步骤 1:定义接口
文件路径: src/main/java/com/example/mpstudy/service/UserService.java

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

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

/**
* Service 接口
* 继承 IService <T>,T 为实体类
*/
public interface UserService extends IService<UserDO> {
// 可以在此定义特定的业务方法,如:
// boolean register(UserDTO userDTO);
}

步骤 2:实现接口
文件路径: src/main/java/com/example/mpstudy/service/impl/UserServiceImpl.java

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

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

/**
* Service 实现类
* 1. @Service: 注册 Bean
* 2. extends ServiceImpl <Mapper, Entity>: 注入 Mapper 能力
* 3. implements UserService: 实现接口
*/
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, UserDO> implements UserService {
// ServiceImpl 内部已经注入了 UserMapper (baseMapper),可以直接使用
}

2.2.2. 批量操作的性能优化 (saveBatch)

在原生 MyBatis 中,批量插入通常需要手写 <foreach> 标签,容易出错且性能受限于 SQL 长度。MP 的 saveBatch 方法在底层通过 JDBC 的 Rewrite Batched Statements 进行了优化。

场景描述:模拟导入 1000 个用户,对比循环插入与批量插入。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@SpringBootTest
class UserServiceTest {

@Autowired
private UserService userService;

@Test
void testSaveBatch() {
// 1. 构造 1000 条数据
List<UserDO> userList = new ArrayList<>();
for (int i = 0; i < 1000; i++) {
UserDO user = new UserDO();
user.setName("Batch-" + i);
user.setAge(20);
userList.add(user);
}

// 2. 批量保存
// saveBatch 默认分批次执行,每批 1000 条(可配置)
boolean success = userService.saveBatch(userList);
System.out.println("批量插入结果: " + success);
}
}

2.2.3. 智能存储 (saveOrUpdate)

这是 ETL(数据抽取转换加载)任务中的神器。

场景描述:尝试保存一个用户对象,如果它有 ID 且数据库存在,则更新;否则插入。

1
2
3
4
5
6
7
8
9
10
@Test
void testSaveOrUpdate() {
// 场景 A: 新用户(无 ID) -> 执行 INSERT
UserDO userA = new UserDO().setName("NewUser");
userService.saveOrUpdate(userA);

// 场景 B: 老用户(有 ID) -> 先查询 ID 是否存在,存在则 UPDATE
UserDO userB = new UserDO().setId(userA.getId()).setName("UpdatedUser");
userService.saveOrUpdate(userB);
}

2.3. 混合开发:自定义 SQL

MP 再强大,也不可能覆盖所有复杂的 SQL 场景(如多表关联查询、复杂的报表统计)。MP 的设计哲学是 开放 的,它允许你随时切回原生 MyBatis。

2.3.1. [实战] 定义 XML Mapper

需求:通过用户名称查询用户(虽然这个可以用 MP Wrapper 实现,但为了演示流程,我们假设这是一个非常复杂的 SQL)。

步骤 1:定义接口方法
UserMapper.java 中添加:

1
2
3
4
public interface UserMapper extends BaseMapper<UserDO> {
// 这里可以混合使用 Mybatis 的注解或 XML
UserDO selectByName(@Param("name") String name);
}

步骤 2:创建 XML 文件
文件路径: src/main/resources/mapper/UserMapper.xml

重要:确保 application.yml 中配置了 mybatis-plus.mapper-locations,否则 MP 找不到这个 XML。

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

<!-- 就像写原生 MyBatis 一样 -->
<select id="selectByName" resultType="com.example.mpstudy.domain.UserDO">
SELECT * FROM tb_user
WHERE name = #{name}
AND is_deleted = 0 <!-- 别忘了手动处理逻辑删除! -->
</select>

</mapper>

步骤 3:配置路径 (如果尚未配置)

1
2
mybatis-plus:
mapper-locations: classpath*:/mapper/**/*.xml

测试验证

1
2
3
4
5
@Test
void testCustomSql() {
UserDO user = userMapper.selectByName("Prorise");
System.out.println(user);
}

2.4. 本章小结

本章我们实现了从“手写 SQL”到“方法调用”的跨越。

核心要点

  1. BaseMapper:提供了 insert, deleteById, updateById, selectById 等原子操作,且自动处理主键回填。
  2. 逻辑删除deleteById 实际执行的是 UPDATE,前提是配置了 @TableLogic
  3. IServicesaveBatchsaveOrUpdate 是业务层的利器,能大幅减少代码逻辑。
  4. 混合模式:不要被 MP 限制住,复杂的查询依然推荐使用 XML 手写 SQL,两者可以完美共存。

2.5. 本章总结与 CRUD 速查

2.5.1. 场景一:标准 Service 层 CRUD

需求:快速实现对单表的用户管理业务。
方案:继承 ServiceImpl

代码模版

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 1. Controller 调用
@RestController
@RequestMapping("/user")
public class UserController {
@Autowired
private UserService userService;

// 新增
@PostMapping
public boolean save(@RequestBody UserDO user) {
return userService.save(user);
}

// 查询详情
@GetMapping("/{id}")
public UserDO get(@PathVariable Long id) {
return userService.getById(id);
}
}

2.5.2. 场景二:批量数据处理

需求:从 Excel 导入 5000 条用户数据。
方案:使用 IService.saveBatch,避免在 for 循环中调用 save

代码模版

1
2
3
4
5
6
7
public void importUsers(List<UserDO> excelData) {
// 默认批次大小为 1000,可传入第二个参数调整,如 saveBatch(excelData, 500);
boolean success = userService.saveBatch(excelData);
if(!success) {
throw new RuntimeException("批量导入失败");
}
}

2.5.3. 核心避坑指南

  1. updateById 不更新 null 值

    • 现象:想把 email 字段清空,传入 email=null,但数据库未发生变化。
    • 原因:MP 默认更新策略是 NOT_NULL(忽略 null 字段)。
    • 对策:使用 UpdateWrapper 强制更新,或者在字段上加 @TableField(updateStrategy = FieldStrategy.IGNORED)(不推荐全局开启)。
  2. 自定义 XML 报错 Invalid bound statement

    • 现象:调用自定义的 selectByName 方法时抛出异常。
    • 原因:Mapper 接口的方法名与 XML 中的 id 不一致,或者 mapper-locations 路径配置错误导致 XML 没被加载。
    • 对策:检查 YAML 配置路径,确保 XML 文件在 build 后的 target/classes 目录下存在。
  3. 逻辑删除的大坑

    • 现象:手写 XML SQL 时,查出了已删除的数据。
    • 原因:MP 的自动逻辑删除过滤只对 MP 内置方法(selectById 等)有效,对自己手写的 SQL 无效
    • 对策:在手写 XML 时,必须手动添加 WHERE is_deleted = 0

下章预告:掌握了 CRUD 只是第一步。在实际业务中,我们往往需要执行复杂的条件查询,比如“查询年龄大于 20 且 名字包含 ‘Jack’ 或 邮箱不为空的用户”。不仅如此,我们还需要处理分页。在下一章,我们将解锁 MP 最强大的武器——条件构造器 (Wrapper)分页插件