第六章. MapStruct-Plus:业务对象(BO)与持久化对象(PO)的深度映射

第六章. MapStruct-Plus:业务对象(BO)与持久化对象(PO)的深度映射

摘要:本章我们将深入核心业务层,解决从业务对象 (BO) 到数据库持久化对象 (PO) 的落地难题。为了拒绝繁琐的手写转换逻辑,我们将引入 Hutool 工具库,结合 MapStruct Plus 的 Java 表达式 (Expression) 能力,实现一行代码完成复杂类型(如 Map 到 JSON)的序列化。同时,我们将搭建 H2 内存数据库 环境,确保每一行代码都能进行真实的 SQL 交互验证。

本章学习路径

  1. 环境构建:引入 H2 Database 和 Hutool,配置自动建表脚本,打造“开箱即用”的验证环境。
  2. 标准定义:基于 MyBatis-Plus 规范定义 PO,理解数据库“扁平结构”与对象“立体结构”的差异。
  3. 极简映射:利用 MSP 的 expression 特性结合 JSONUtil,通过注解实现复杂字段的序列化与反序列化。
  4. 枚举处理:使用 @AutoEnumMapper 解决 Java 枚举与数据库 TinyInt 之间的自动转换。
  5. 闭环验证:通过模拟 Service 层的实战操作,验证数据在“对象 <-> 数据库”之间的完整流转。

6.1. 基础设施搭建:H2 与 Hutool

在上一章中,我们完成了 DTO 到 BO 的数据清洗与转换,确保了进入业务层的数据是干净的。但在实际开发中,业务逻辑处理完的数据最终需要落地到数据库,这就涉及到了数据库环境的搭建和工具库的选型。本节我们将引入 H2 内存数据库和 Hutool 工具包,为后续的持久化实战打下坚实的基础。

6.1.1. 引入核心依赖

我们需要引入三个关键组件来支撑本章的实战:MyBatis-Plus 负责 ORM 映射,H2 负责提供无需安装的运行时数据库,Hutool 则用来简化 Java 代码。

文件路径pom.xml

请在项目的 <dependencies> 节点中添加以下配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<dependencies>
<!-- 1. MyBatis Plus: 生产级 ORM 框架 -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>3.5.7</version>
</dependency>

<!-- 2. H2 Database: 运行时内存数据库,模拟 MySQL -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>

<!-- 3. Hutool: Java 工具包之王 (本章核心辅助) -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.26</version>
</dependency>
</dependencies>

关键点解析

  • H2 Database:它是一个纯 Java 编写的关系型数据库,支持内存模式。这意味着我们不需要你在本地安装 MySQL 即可运行本章代码,且每次重启后数据会自动重置,非常适合单元测试和教学演示。
  • Hutool:在这个场景中,我们需要它的 JSONUtil 来替代笨重的 Jackson 或 Gson 进行手动配置,实现“一行代码”处理 JSON 转换。

6.1.2. 配置数据库与自动建表

为了让 H2 模拟 MySQL 的行为,并能够打印出直观的 SQL 日志,我们需要对 Spring Boot 进行配置。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
spring:
datasource:
# jdbc:h2:mem:testdb -> 在内存中创建名为 testdb 的数据库
# MODE=MySQL -> 开启 MySQL 兼容模式
url: jdbc:h2:mem:testdb;MODE=MySQL;DB_CLOSE_DELAY=-1
driver-class-name: org.h2.Driver
username: sa
password:
sql:
init:
# 每次启动时运行 schema.sql 重置表结构
schema-locations: classpath:schema.sql
mode: always

mybatis-plus:
configuration:
# 开启标准输出日志,让我们能看到真实的 SQL 语句
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

接下来,我们需要定义表结构。为了演示复杂类型映射,我们在表中特意设计了一个 extra_info 字段来存储 JSON 字符串。

文件路径src/main/resources/schema.sql

1
2
3
4
5
6
7
8
9
10
11
12
13
DROP TABLE IF EXISTS sys_user;

CREATE TABLE sys_user (
id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
username VARCHAR(50) NULL DEFAULT NULL,
phone VARCHAR(20) NULL DEFAULT NULL,
source VARCHAR(20) NULL DEFAULT NULL,
-- 状态:数据库存 Int (0/1),Java 用枚举
status INT NULL DEFAULT 0,
-- 扩展信息:数据库存 JSON 字符串,Java 用 Map
extra_info VARCHAR(1000) NULL DEFAULT NULL,
PRIMARY KEY (id)
);

6.2. 持久化层建设:PO 与 Mapper

在上一节中,我们搭建好了底层的数据库环境。但在 Java 世界中,我们需要一个对象来“镜像”数据库表结构,以便 ORM 框架进行操作。本节我们将按照 MyBatis-Plus 的规范定义持久化对象 (PO),并理解它与业务对象 (BO) 在结构上的根本差异。

6.2.1. 定义 UserPO

PO (Persistent Object) 的设计原则是“完全忠实于数据库表结构”。既然数据库里的 extra_infoVARCHAR 类型,那么 PO 里的字段就必须是 String,而不能是 MapObject。这种数据类型的差异正是我们需要解决的核心问题。

文件路径src/main/java/com/example/demo/infrastructure/po/UserPO.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.demo.infrastructure.po;

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

@Data
@TableName("sys_user") // 1. 指定映射的数据库表名
public class UserPO {

// 2. 指定主键策略为自增
@TableId(type = IdType.AUTO)
private Long id;

private String username;

private String phone;

private String source;

/** 对应数据库 INT */
private Integer status;

/**
* 对应数据库 VARCHAR
* 注意:这里必须是 String,不能是 Map
*/
private String extraInfo;
}

6.2.2. 定义 Mapper 接口

有了 PO,我们还需要一个数据访问接口。得益于 MyBatis-Plus,我们只需继承 BaseMapper 即可获得涵盖增删改查的几十种通用方法。

文件路径src/main/java/com/example/demo/infrastructure/mapper/UserMapper.java

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

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.demo.infrastructure.po.UserPO;
import org.apache.ibatis.annotations.Mapper;

@Mapper
public interface UserMapper extends BaseMapper<UserPO> {
// 无需手写任何 SQL,MP 会自动生成
}

6.3. Hutool + MSP:极简转换实战

在上一节中,我们定义了结构扁平的 PO,其中 extraInfo 是一个 JSON 字符串。但在业务层(BO),我们希望操作的是一个灵活的 Map<String, Object>。传统做法是手写一个 Converter 类,注入 Jackson 进行解析。但在本节,我们将利用 MapStruct Plus 的 Expression 能力,配合 Hutool,直接在注解中完成这一复杂的序列化逻辑。

6.3.1. 准备枚举与 Hutool

业务逻辑中经常使用枚举来表示状态,而数据库通常存储 TINYINT。为了实现自动转换,我们需要定义一个包含标准接口的枚举。

文件路径src/main/java/com/example/demo/domain/enums/UserStatus.java

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

import io.github.linpeilie.annotations.AutoEnumMapper;
import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
@AllArgsConstructor
@AutoEnumMapper("code") // 关键:告诉 MSP 在转换时提取 code 字段的值
public enum UserStatus {
DISABLE(0, "禁用"),
ENABLE(1, "启用");

private final int code;
private final String desc;
}

6.3.2. BO 定义:一行代码搞定 JSON 转换

这是本章的核心。我们将定义 UserBO,并使用 MapStruct 的 expression 功能调用 Hutool 的静态方法。

设计思路

  1. 正向映射 (BO -> PO):需要将 BO 的 Map 转换为 PO 的 String。使用 JSONUtil.toJsonStr()
  2. 反向映射 (PO -> BO):需要将 PO 的 String 还原为 BO 的 Map。使用 JSONUtil.toBean()
  3. 依赖导入:因为生成的代码需要调用 JSONUtil,必须显式通过 imports 属性告知 MSP。

文件路径src/main/java/com/example/demo/domain/bo/UserBO.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.demo.domain.bo;

import cn.hutool.json.JSONUtil;
import com.example.demo.domain.enums.UserStatus;
import com.example.demo.infrastructure.po.UserPO;
import io.github.linpeilie.annotations.AutoMapper;
import io.github.linpeilie.annotations.AutoMapping;
import io.github.linpeilie.annotations.ReverseAutoMapping;
import lombok.Data;
import java.util.Map;

@Data
// 1. 核心配置:指定目标 PO,并导入 JSONUtil 类以供表达式使用
@AutoMapper(target = UserPO.class, imports = {JSONUtil.class, Map.class})
public class UserBO {

private String username;
private String phone;
private String source;

/**
* 场景1:枚举自动映射
* BO (Enum) <-> PO (Integer)
* MSP 扫描到 @AutoEnumMapper 后会自动处理
*/
private UserStatus status;

/**
* 场景2:JSON 序列化 (Map <-> String)
*/

// 正向:BO -> PO
// target="extraInfo": PO 中的字段名
// expression: 这里的 source 代表 BO 对象
@AutoMapping(target = "extraInfo", expression = "java(JSONUtil.toJsonStr(source.getExtra()))")

// 反向:PO -> BO
// target="extra": BO 中的字段名
// expression: 这里的 source 代表 PO 对象 (注意反向时 source 含义变化)
@ReverseAutoMapping(target = "extra", expression = "java(JSONUtil.toBean(source.getExtraInfo(), Map.class))")
private Map<String, Object> extra;
}

6.3.3. 为什么这样写?

这里使用了 expression = "java(...)" 语法。这是一个非常强大的功能,它允许我们在注解中直接编写 Java 代码片段。MapStruct 在生成代码时,会直接将这段字符串“复制粘贴”到 Mapper 实现类中。

如果不使用 Hutool 和 expression,你需要:

  1. 编写一个 JsonConverter 类。
  2. 注入 ObjectMapper。
  3. 处理 try-catch 异常。
  4. 在 Mapper 接口中通过 @Mapper(uses = JsonConverter.class) 引用它。

现在,利用 Hutool 对异常的静默处理(Runtime Exception)和静态方法特性,我们将 20 行代码压缩到了 1 行,我们感受到了

  1. Expression 的威力expression="java(...)" 允许直接嵌入 Java 代码,是处理特殊映射逻辑的“逃生舱”。
  2. Import 的必要性:在使用 Expression 调用静态方法时,必须在 @AutoMapper(imports = {...}) 中注册该类,否则生成的代码会因找不到类而编译失败。
  3. 反向映射陷阱:在 @ReverseAutoMapping 中,source 关键字指的是 入参对象(即 PO),这一点在编写表达式时容易混淆。

速查代码

1
2
3
4
// 正向:对象 -> JSON 串
@AutoMapping(target = "jsonStr", expression = "java(JSONUtil.toJsonStr(source.getMap()))")
// 反向:JSON 串 -> 对象
@ReverseAutoMapping(target = "map", expression = "java(JSONUtil.toBean(source.getJsonStr(), Map.class))")

6.4. 全链路闭环验证

在上一节中,我们完成了极其优雅的映射配置。现在,代码写得再漂亮,也必须经得起运行时的检验。本节我们将编写一个 Controller 来模拟业务流程,验证数据从创建、落库、回查到还原的完整生命周期。

6.4.1. 编写验证逻辑

我们将模拟一个典型的业务场景:创建一个包含复杂信息的 UserBO,将其保存到数据库(转为 PO),然后立即读出来(还原为 BO),验证数据是否无损。

文件路径src/main/java/com/example/demo/controller/PersistenceController.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
package com.example.demo.controller;

import cn.hutool.core.map.MapUtil;
import com.example.demo.domain.bo.UserBO;
import com.example.demo.domain.enums.UserStatus;
import com.example.demo.infrastructure.mapper.UserMapper;
import com.example.demo.infrastructure.po.UserPO;
import io.github.linpeilie.Converter;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
public class PersistenceController {

private final Converter converter;
private final UserMapper userMapper;

@GetMapping("/test/db")
public String testDb() {
// --- 1. 构造业务对象 (BO) ---
UserBO bo = new UserBO();
bo.setUsername("Hutool_Fan");
bo.setSource("WEB");
bo.setStatus(UserStatus.ENABLE); // 枚举值:启用

// 使用 Hutool 快速构建 Map,模拟动态扩展字段
bo.setExtra(MapUtil.builder("vipLevel", (Object)"SVIP")
.put("score", 999)
.build());

// --- 2. 转换并落库 (BO -> PO -> DB) ---
// 这一步自动触发 JSONUtil.toJsonStr
UserPO po = converter.convert(bo, UserPO.class);

userMapper.insert(po); // 真实写入 H2 数据库

// 打印 PO,此时 extraInfo 应为 JSON 字符串
System.out.println(">>> [1] 落库 PO: " + po);

// --- 3. 回查与还原 (DB -> PO -> BO) ---
// 从数据库查出来,验证数据持久化状态
UserPO dbPO = userMapper.selectById(po.getId());

// 这一步自动触发 JSONUtil.toBean (逆向映射)
UserBO restoredBO = converter.convert(dbPO, UserBO.class);
System.out.println(">>> [2] 还原 BO: " + restoredBO);

// --- 4. 验证核心逻辑 ---
if (!"SVIP".equals(restoredBO.getExtra().get("vipLevel"))) {
throw new RuntimeException("Map 反序列化失败!");
}
if (restoredBO.getStatus() != UserStatus.ENABLE) {
throw new RuntimeException("枚举转换失败!");
}

return "验证通过!请查看控制台 SQL 日志";
}
}

6.4.2. 运行结果预期

启动项目,访问 http://localhost:8080/test/db。请观察控制台输出:

1
2
3
4
5
6
==>  Preparing: INSERT INTO sys_user ... VALUES (?, ?, ?, ?, ?)
==> Parameters: Hutool_Fan(String), ..., 1(Integer), {"vipLevel":"SVIP","score":999}(String)
...
>>> [1] 落库 PO: UserPO(..., status=1, extraInfo={"vipLevel":"SVIP","score":999})
...
>>> [2] 还原 BO: UserBO(..., status=ENABLE, extra={vipLevel=SVIP, score=999})

现象解读

  1. SQL 日志:可以看到 status 被存为了 1extraInfo 被存为了 JSON 字符串。
  2. 对象还原:还原后的 BO 中,status 变回了枚举 ENABLEextra 变回了 Map 结构。

6.5. 本章总结与持久层映射速查

本章我们深入了业务核心层,解决了 BO(业务对象)与 PO(持久化对象)之间“结构不对等”的难题。通过引入 Hutool 工具库与 MSP 的 Expression 能力,我们将原本复杂的序列化逻辑压缩到了注解之中。

遇到以下 3 种持久化映射场景时,请直接 Copy 下方的标准代码模版:

6.5.1. 场景一:一行代码实现 JSON 序列化 (Map -> String)

需求:业务对象 BO 中是灵活的 MapList,但数据库 PO 中存的是 JSON 字符串。
方案:使用 expression 配合 JSONUtil.toJsonStr

1
2
3
4
5
6
7
8
9
10
// 1. 核心:必须在 imports 中导入 JSONUtil 和 Map,否则编译报错找不到类
@AutoMapper(target = UserPO.class, imports = {JSONUtil.class, Map.class})
public class UserBO {

// 2. 正向映射:BO -> PO
// target="extraInfo": PO 的字段名
// source: 代表当前的 UserBO 对象
@AutoMapping(target = "extraInfo", expression = "java(JSONUtil.toJsonStr(source.getExtra()))")
private Map<String, Object> extra;
}

6.5.2. 场景二:一行代码实现 JSON 反序列化 (String -> Map)

需求:从数据库查出 JSON 字符串后,自动还原为 BO 中的 Map 对象。
方案:使用 @ReverseAutoMapping 配合 JSONUtil.toBean

1
2
3
4
5
6
7
8
9
@AutoMapper(target = UserPO.class, imports = {JSONUtil.class, Map.class})
public class UserBO {

// 3. 反向映射:PO -> BO
// target="extra": BO 的字段名
// source: 代表入参的 UserPO 对象 (注意:此处 source 含义变了)
@ReverseAutoMapping(target = "extra", expression = "java(JSONUtil.toBean(source.getExtraInfo(), Map.class))")
private Map<String, Object> extra;
}

6.5.3. 场景三:枚举自动映射 (Enum <-> int)

需求:Java 代码中使用语义清晰的 Enum,数据库中使用节省空间的 TINYINT
方案:使用 @AutoEnumMapper 指定取值字段。

1
2
3
4
5
6
7
8
9
10
11
@Getter
@AllArgsConstructor
// 1. 指定映射时取哪个字段的值 (例如数据库存的是 0/1,对应 code)
@AutoEnumMapper("code")
public enum UserStatus {
DISABLE(0, "禁用"),
ENABLE(1, "启用");

private final int code; // 对应数据库的值
private final String desc;
}