第六章. MapStruct-Plus:业务对象(BO)与持久化对象(PO)的深度映射
摘要:本章我们将深入核心业务层,解决从业务对象 (BO) 到数据库持久化对象 (PO) 的落地难题。为了拒绝繁琐的手写转换逻辑,我们将引入 Hutool 工具库,结合 MapStruct Plus 的 Java 表达式 (Expression) 能力,实现一行代码完成复杂类型(如 Map 到 JSON)的序列化。同时,我们将搭建 H2 内存数据库 环境,确保每一行代码都能进行真实的 SQL 交互验证。
本章学习路径
- 环境构建:引入 H2 Database 和 Hutool,配置自动建表脚本,打造“开箱即用”的验证环境。
- 标准定义:基于 MyBatis-Plus 规范定义 PO,理解数据库“扁平结构”与对象“立体结构”的差异。
- 极简映射:利用 MSP 的
expression 特性结合 JSONUtil,通过注解实现复杂字段的序列化与反序列化。 - 枚举处理:使用
@AutoEnumMapper 解决 Java 枚举与数据库 TinyInt 之间的自动转换。 - 闭环验证:通过模拟 Service 层的实战操作,验证数据在“对象 <-> 数据库”之间的完整流转。
在上一章中,我们完成了 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> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-spring-boot3-starter</artifactId> <version>3.5.7</version> </dependency>
<dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <scope>runtime</scope> </dependency>
<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: url: jdbc:h2:mem:testdb;MODE=MySQL;DB_CLOSE_DELAY=-1 driver-class-name: org.h2.Driver username: sa password: sql: init: schema-locations: classpath:schema.sql mode: always
mybatis-plus: configuration: 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, status INT NULL DEFAULT 0, 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_info 是 VARCHAR 类型,那么 PO 里的字段就必须是 String,而不能是 Map 或 Object。这种数据类型的差异正是我们需要解决的核心问题。
文件路径: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") public class UserPO { @TableId(type = IdType.AUTO) private Long id; private String username; private String phone; private String source; private Integer status;
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> { }
|
在上一节中,我们定义了结构扁平的 PO,其中 extraInfo 是一个 JSON 字符串。但在业务层(BO),我们希望操作的是一个灵活的 Map<String, Object>。传统做法是手写一个 Converter 类,注入 Jackson 进行解析。但在本节,我们将利用 MapStruct Plus 的 Expression 能力,配合 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") public enum UserStatus { DISABLE(0, "禁用"), ENABLE(1, "启用"); private final int code; private final String desc; }
|
6.3.2. BO 定义:一行代码搞定 JSON 转换
这是本章的核心。我们将定义 UserBO,并使用 MapStruct 的 expression 功能调用 Hutool 的静态方法。
设计思路:
- 正向映射 (BO -> PO):需要将 BO 的
Map 转换为 PO 的 String。使用 JSONUtil.toJsonStr()。 - 反向映射 (PO -> BO):需要将 PO 的
String 还原为 BO 的 Map。使用 JSONUtil.toBean()。 - 依赖导入:因为生成的代码需要调用
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
@AutoMapper(target = UserPO.class, imports = {JSONUtil.class, Map.class}) public class UserBO {
private String username; private String phone; private String source;
private UserStatus status;
@AutoMapping(target = "extraInfo", expression = "java(JSONUtil.toJsonStr(source.getExtra()))") @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,你需要:
- 编写一个
JsonConverter 类。 - 注入 ObjectMapper。
- 处理
try-catch 异常。 - 在 Mapper 接口中通过
@Mapper(uses = JsonConverter.class) 引用它。
现在,利用 Hutool 对异常的静默处理(Runtime Exception)和静态方法特性,我们将 20 行代码压缩到了 1 行,我们感受到了
- Expression 的威力:
expression="java(...)" 允许直接嵌入 Java 代码,是处理特殊映射逻辑的“逃生舱”。 - Import 的必要性:在使用 Expression 调用静态方法时,必须在
@AutoMapper(imports = {...}) 中注册该类,否则生成的代码会因找不到类而编译失败。 - 反向映射陷阱:在
@ReverseAutoMapping 中,source 关键字指的是 入参对象(即 PO),这一点在编写表达式时容易混淆。
速查代码:
1 2 3 4
| @AutoMapping(target = "jsonStr", expression = "java(JSONUtil.toJsonStr(source.getMap()))")
@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() { UserBO bo = new UserBO(); bo.setUsername("Hutool_Fan"); bo.setSource("WEB"); bo.setStatus(UserStatus.ENABLE); bo.setExtra(MapUtil.builder("vipLevel", (Object)"SVIP") .put("score", 999) .build());
UserPO po = converter.convert(bo, UserPO.class); userMapper.insert(po); System.out.println(">>> [1] 落库 PO: " + po);
UserPO dbPO = userMapper.selectById(po.getId()); UserBO restoredBO = converter.convert(dbPO, UserBO.class); System.out.println(">>> [2] 还原 BO: " + restoredBO);
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})
|
现象解读:
- SQL 日志:可以看到
status 被存为了 1,extraInfo 被存为了 JSON 字符串。 - 对象还原:还原后的 BO 中,
status 变回了枚举 ENABLE,extra 变回了 Map 结构。
6.5. 本章总结与持久层映射速查
本章我们深入了业务核心层,解决了 BO(业务对象)与 PO(持久化对象)之间“结构不对等”的难题。通过引入 Hutool 工具库与 MSP 的 Expression 能力,我们将原本复杂的序列化逻辑压缩到了注解之中。
遇到以下 3 种持久化映射场景时,请直接 Copy 下方的标准代码模版:
6.5.1. 场景一:一行代码实现 JSON 序列化 (Map -> String)
需求:业务对象 BO 中是灵活的 Map 或 List,但数据库 PO 中存的是 JSON 字符串。
方案:使用 expression 配合 JSONUtil.toJsonStr。
1 2 3 4 5 6 7 8 9 10
| @AutoMapper(target = UserPO.class, imports = {JSONUtil.class, Map.class}) public class 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 {
@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
@AutoEnumMapper("code") public enum UserStatus { DISABLE(0, "禁用"), ENABLE(1, "启用"); private final int code; private final String desc; }
|