MapStruct 的转换实践

第一部分:MapStruct 在四者之间的最佳转换实践

在分层架构中,MapStruct 的核心价值在于用编译期代码生成替代运行期反射,兼顾了手写代码的高性能和自动化工具的便捷。

1. 基础配置与约定

首先,在 pom.xml 中引入依赖。同时,如果项目使用 Lombok,请确保其版本在 1.18.10 及以上,以避免兼容性问题。

核心约定: 对于属性名和类型完全一致 的字段,无需任何配置,MapStruct 会自动完成映射。你只需关注有差异的部分。

2. 四层对象转换的典型实践

我们通过代码来看各层之间如何转换。

  • Entity/Domain (Model) → DTO

    在 Service 层,将领域模型转换为 DTO 用于内部传输或 RPC 调用,核心是过滤敏感字段(如密码)。

    java 复制代码
    @Mapper(componentModel = "spring")
    public interface UserMapper {
        // 将 User 转换为 UserDTO,忽略 password 字段
        @Mapping(target = "password", ignore = true)
        UserDTO toDto(User user);
    }

    ignore = true 是过滤敏感信息最直接的方式。

  • Request DTO → Entity/Domain (Model)

    在 Controller 层,将前端传来的 DTO 转换为领域模型。

    java 复制代码
    @Mapper(componentModel = "spring")
    public interface UserMapper {
        // 将 UserCreateDTO 转换为 User 实体
        User toEntity(UserCreateDTO dto);
    }
  • Domain (Model) → VO

    在 Controller 层,将领域模型转换为最终返回给前端的 VO,主要处理字段映射 和类型格式化。

    java 复制代码
    @Mapper(componentModel = "spring")
    public interface UserMapper {
        // 将 User 转换为 UserVO
        @Mapping(source = "username", target = "displayName") // 字段名不同
        @Mapping(source = "birthday", target = "birthdayStr", dateFormat = "yyyy-MM-dd") // 日期格式化
        @Mapping(target = "password", ignore = true) // 忽略敏感字段
        UserVO toVo(User user);
    }

    当源和目标字段名不同时,用 @Mapping 指定映射关系。

3. 高级实践:复用与自定义
  • 定义 BaseMapping 基类

    抽取通用的转换逻辑(如统一的日期格式),避免重复配置。

    java 复制代码
    @MapperConfig
    public interface BaseMapping<SOURCE, TARGET> {
        @Mapping(target = "createTime", dateFormat = "yyyy-MM-dd HH:mm:ss")
        TARGET sourceToTarget(SOURCE source);
        // ... 反向及集合转换方法
    }

    之后,具体的 Mapper 可以通过 @Mapper(config = BaseMapping.class) 来继承这些配置。

  • 处理复杂类型转换

    对于 MapStruct 无法自动映射的复杂类型,可以定义自定义方法来完成。

    java 复制代码
    @Mapper(componentModel = "spring")
    public interface UserMapper {
        // 自定义方法:将 JSON 字符串解析为 List<UserConfig>
        default List<UserConfig> stringToConfigList(String config) {
            // ... 解析逻辑
        }
        // 映射时会自动调用上面的自定义方法
        UserVO toVo(User user);
    }
4. 异常处理:忽略未映射的字段

为避免因字段遗漏导致编译警告,可以在 @Mapper 上设置策略。

java 复制代码
@Mapper(componentModel = "spring", 
        unmappedTargetPolicy = ReportingPolicy.IGNORE)
public interface UserMapper {
    // ...
}

最佳实践总结 :遵循"一方向一 Mapper "的原则,为每个转换方向(如 toDto, toVo)定义独立方法,保持职责清晰。严禁让 VO 继承 DTO 或 Model,它们应是完全独立的 POJO。


第二部分:充血模型(Rich Domain Model)下 Model 如何写业务逻辑

充血模型的核心思想是将数据(状态)和行为(业务逻辑)封装在同一个领域对象中。

充血模型与贫血模型的根本区别在于业务逻辑的归属:

  • 贫血模型:Model 只是"数据袋子",所有业务逻辑在 Service 层实现。
  • 充血模型:Model 自身包含业务逻辑,Service 层仅做协调和编排。
1. 充血模型代码示例

下面是一个充血模型下 Order 类的写法:

java 复制代码
public class Order {
    // === 状态(数据) ===
    private Long id;
    private OrderStatus status;
    private List<OrderItem> items;
    private Customer customer;
    private Money totalAmount;

    // === 行为(业务逻辑) ===
    
    // 1. 业务规则:订单能否被取消
    public boolean canBeCancelled() {
        return this.status == OrderStatus.PENDING 
                || this.status == OrderStatus.PAID;
    }

    // 2. 业务操作:添加商品
    public void addItem(Product product, int quantity) {
        // 业务逻辑:检查库存、计算价格等
        if (quantity <= 0) {
            throw new IllegalArgumentException("数量必须大于0");
        }
        OrderItem item = new OrderItem(product, quantity);
        this.items.add(item);
        // 充血模型:状态变更由自身方法触发
        this.recalculateTotalAmount(); 
    }

    // 3. 业务操作:取消订单
    public void cancel() {
        if (!this.canBeCancelled()) {
            throw new IllegalStateException("当前状态不可取消");
        }
        this.status = OrderStatus.CANCELLED;
        // 可以触发领域事件,通知库存服务等
    }

    // 4. 内部方法:重新计算总价
    private void recalculateTotalAmount() {
        this.totalAmount = this.items.stream()
                .map(OrderItem::getSubTotal)
                .reduce(Money.ZERO, Money::add);
    }
}
2. 职责划分:业务逻辑放哪里?

这是实践中最容易困惑的地方。核心原则是根据逻辑的内聚性来判断:

  • 放在 Model 中的逻辑(内聚性高):

    • 校验逻辑 :与自己状态相关的校验(如 canBeCancelled())。
    • 计算逻辑 :基于自身数据计算(如 recalculateTotalAmount())。
    • 状态变更 :改变自身状态的操作(如 addItem(), cancel())。
  • 放在 Domain Service 中的逻辑(需要外部协作):

    • 跨聚合的操作 :如下单需要同时操作 Order 和 Inventory。
    • 依赖外部资源:需要调用 Repository 或外部服务。
    java 复制代码
    @Service
    public class OrderDomainService {
        private final OrderRepository orderRepo;
        private final InventoryService invService;
    
        @Transactional
        public void placeOrder(Order order) {
            // 1. 调用 Order 自身逻辑
            order.calculatePrice(); 
            
            // 2. 跨聚合的协作(由 Domain Service 协调)
            for (OrderItem item : order.getItems()) {
                invService.deductStock(item.getProductId(), item.getQuantity());
            }
            
            // 3. 持久化
            orderRepo.save(order);
        }
    }
3. "结构充血" vs "行为充血"

针对"模型因包含过多逻辑而臃肿"的问题,DDD 社区提出了更精细的实践:

  • "行为充血"(不推荐) :将 placeOrder 这样的复杂流程全放进 Order 对象,导致模型臃肿。
  • "结构充血"(推荐) :将稳定性高的规则 (如 canBeCancelled)放在 Model 中;将易变的复杂流程 (如 placeOrder)上浮到 Domain Service 或流程编排引擎中。Model 的核心价值在于提供清晰、表达业务语义的"领域语言"。

最佳实践总结 :采用充血模型时,要避免将 Model 变成万能类 。核心准则是:"我"自己的事(校验、计算、状态变更)在 Model 内解决;"我们"的事(跨对象、跨聚合的协作)交给 Domain Service 处理。

总结:MapStruct + 充血模型的协同

当采用充血模型时,Model 包含了业务逻辑。MapStruct 在其中的职责更加清晰和重要:

  1. 保护领域模型 :严禁 将 Model 直接暴露给外部。Controller 接收的是 Request DTO,返回的是 VO,这些都需要通过 MapStruct 与 Model 进行转换。
  2. 防腐层 :MapStruct 在 Model 和外部世界之间建立了一道"防腐层"。外部的变化(如 API 接口变更)只会影响 DTO/VO 和 Mapper,而不会污染核心的、包含业务逻辑的 Model。
相关推荐
牧瀬クリスだ9 小时前
Spring统一功能处理
java·spring boot·spring·状态模式
半生过往13 天前
前端工程师学习智能体开发(一)
前端·学习·状态模式
Wang's Blog14 天前
Java框架快速入门: Spring Security+OAuth2之单点登录(SSO)实战
服务器·spring·状态模式
JianZhen✓15 天前
解决SPA发版旧版本残留+动态路由打包失效的完整方案(附落地代码)
前端·状态模式
2501_9339232515 天前
统一功能:统一返回格式+统一异常处理
spring·java-ee·状态模式·统一数据返回
小肥君16 天前
前端测试websocket
前端·websocket·状态模式
Wang's Blog16 天前
Java框架快速入门: Spring Security+OAuth2之前后端分离多模块工程配置
java·spring·状态模式
weixin_4935036718 天前
Vue3 前端生成 PDF:会员证书与活动签到表的三种打印方案与踩坑记录
前端·pdf·状态模式
晴天1618 天前
前端跨域方案解析:JSONP 的原理、实战与演进
前端·状态模式