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
相关推荐
breeze jiang9 小时前
React Todos 前端独立开发全解:用 vite-plugin-mock + axios 封装,再也不等后端接口
前端·react.js·状态模式
土豆~1 天前
文件名没后缀就打不开:前端文件预览的内容嗅探改造
前端·状态模式
莫得感情 o2 天前
设计模式 18 · 状态模式
设计模式·状态模式
weixin_445476685 天前
LIMS 3.0 测试环境部署过程记录
linux·状态模式
xiaoxiangsiyan6 天前
运维之前端反调试学习
运维·前端·学习·状态模式
热爱编程的小李6 天前
UniApp 实现 H5/Android/uniapp原生 三端 MinIO 预签名 PUT 直传方案
状态模式
Crazy________8 天前
k8s部署若依微服务架构流程,v3.6.6
运维·云原生·容器·kubernetes·状态模式
fīɡЙtīиɡ ℡8 天前
怎么是用JWT
java·状态模式
立心者010 天前
D Gaussian splatting : 部署模型网页展示
状态模式
一水12 天前
TTFT优化:一个方案的5次推倒重来
java·ai·状态模式