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 中的逻辑(需要外部协作)

    • 跨聚合的操作 :如下单需要同时操作 OrderInventory
    • 依赖外部资源:需要调用 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
相关推荐
sugar__salt8 天前
从零理解 LLM 流式输出:Vue3 + DeepSeek API 实战
前端·人工智能·状态模式·sse·流式输出
ttwuai8 天前
Go 后台接口 401/403 排查:JWT 过期、刷新请求和权限码怎么定位
开发语言·golang·状态模式
ji_shuke9 天前
Vue3 前端批量打印 PDF/图片踩坑记:跨域、合并打印、对话框闪退与按钮一直 loading
前端·pdf·状态模式·pdf打印
ttwuai9 天前
Go 后台富文本图片上传失败排查:前端限制、接口和存储目录要一起看
前端·golang·状态模式
从此以后自律9 天前
三大传参注解完整详解
状态模式
ttod_qzstudio10 天前
【软考设计模式】状态模式:状态驱动行为自动转换与代码填空精讲
设计模式·状态模式
折哥的程序人生 · 物流技术专研10 天前
Java 23 种设计模式:从踩坑到精通 | 番外:状态 vs 策略 —— if-else 消除的两条路,你走对了吗?
java·spring·状态模式·策略模式·java面试·java设计模式·从踩坑到精通
xiaotie100510 天前
给deerflow2.0增加模型Token 使用数量显示的功能
状态模式
前端H14 天前
微前端 v3 架构升级:从加载隔离到状态协同
前端·架构·状态模式