Skip to content

对象映射:MapperProvider 与 @EnhancedMapper ​

DDK 用一套「按类型对查找映射器」的机制处理 Entity ↔ PO 转换。这篇讲它的契约、用法,以及它为什么宁可在启动期炸掉,也不肯给你一个看起来能用的空对象。

一、四个组成部分 ​

ObjectMapper<S, T>     映射器接口,map(S) 与 map(List<S>)
@EnhancedMapper        标注在映射器上,声明它负责哪个 source → target
MapperProvider         按 (source, target) 查找映射器,找不到直接抛异常
MapperConfiguration    MapStruct 的共享配置(componentModel=spring 等)

映射器接口很简单:

java
public interface ObjectMapper<S, T> {
    T map(S source);
    List<T> map(List<S> sources);
}

二、用法 ​

给每个需要转换的类型对写一个映射器,用 @EnhancedMapper 声明它的职责。简单的结构可以让 MapStruct 生成:

java
@Mapper(config = MapperConfiguration.class)
@EnhancedMapper(source = User.class, target = UserPO.class,
                description = "领域实体 User 转持久化对象 UserPO")
public interface UserToPoMapper extends ObjectMapper<User, UserPO> {
}

反向要单独写一个——ObjectMapper<S, T> 是单向的,这是最常见的漏注册原因:

java
@Mapper(config = MapperConfiguration.class)
@EnhancedMapper(source = UserPO.class, target = User.class)
public interface PoToUserMapper extends ObjectMapper<UserPO, User> {
}

MapperConfiguration 统一了三项 MapStruct 行为,不需要每个映射器重复声明:

java
@MapperConfig(
    componentModel = "spring",                                        // 生成的实现是 Spring Bean
    unmappedTargetPolicy = ReportingPolicy.IGNORE,                    // 目标端多余字段不报错
    nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE  // null 不覆盖已有值
)
public class MapperConfiguration {
}

注意 unmappedTargetPolicy = IGNORE 的代价:字段漏映射不会有任何提示。Entity 加了字段但忘了改映射器,编译通过、运行不报错,数据静默丢失。团队更希望早失败的话,在具体映射器上覆盖为 ReportingPolicy.ERROR。

三、引入领域模型之后,推荐手写转换器 ​

GenericRepositoryImpl 用 MapStruct 自动生成实现的前提是「两边结构接近」。一旦领域模型有了聚合根和值对象,这个前提就不成立了:

  • 聚合根没有公开构造器和 setter,只能通过工厂或重建方法构造
  • 值对象是 record,可能要拆成多列
  • 标识是 UserId 而不是 Long,要取 value()
  • 枚举要转成 code

这些规则 MapStruct 需要一条条写 @Mapping 才能表达,代码量并不比手写少,可读性还更差。所以四层骨架里是手写的:

java
@Component
@EnhancedMapper(source = UserPO.class, target = User.class, description = "持久化对象转领域实体")
public class UserEntityConverter implements ObjectMapper<UserPO, User> {

    @Override
    public User map(UserPO source) {
        if (source == null) {
            return null;
        }
        return User.restore(                       // 走受控的重建方法,而不是逐字段 set
                UserId.of(source.getId()),
                source.getUsername(),
                source.getPassword(),
                toGender(source.getGender()),
                new PhoneNumber(source.getPhoneNumber()),
                Email.ofNullable(source.getEmail()),
                Boolean.TRUE.equals(source.getStatus()),
                source.getVersion());
    }

    @Override
    public List<User> map(List<UserPO> sources) {
        return sources == null ? List.of() : sources.stream().map(this::map).toList();
    }
}

restore 与 register 的区别值得单独强调:重建不产生领域事件。从数据库读出一个用户,并不意味着「用户刚刚注册」。用逐字段 set 很容易把这件事做错,走受控的重建方法就不会。

四、找不到映射器时会发生什么 ​

直接抛 MissingMapperException,并在消息里给出补救写法:

找不到 com.example.app.domain.model.entity.User -> com.example.app.infrastructure.orm.po.UserPO 的映射器。

请显式提供一个 MapStruct 映射器并用 @EnhancedMapper 标注:

    @Mapper(config = MapperConfiguration.class)
    @EnhancedMapper(source = User.class, target = UserPO.class)
    public interface UserToUserPOMapper extends ObjectMapper<User, UserPO> {
    }

注意映射是有方向的:Entity -> PO 和 PO -> Entity 是两个映射器,需要各注册一次。

它替换掉的是什么 ​

早期版本在查不到映射器时会退回一个 DefaultMapper:

java
@Mapper(config = MapperConfiguration.class)
public interface DefaultMapper extends ObjectMapper<Object, Object> {
}

MapStruct 拿到 Object → Object 这个签名时找不到任何属性可映射,于是生成了这样的实现:

java
@Override
public Object map(Object source) {
    if (source == null) {
        return null;
    }
    Object object = new Object();     // 造一个空对象
    return object;                     // 源对象的数据全部丢失
}

于是调用链变成:

java
P po = mapperProvider.lookup(eClass, pClass).map(entity);
//                                          ↑ 返回 new Object()
//     泛型擦除后编译器插入 checkcast 到 P
//     → ClassCastException: java.lang.Object cannot be cast to XxxPO

报错位置离真正的原因(忘了注册 Mapper)隔着好几层。 这不是理论风险:早期 archetype 里的 UserRepositoryImpl 就一个映射器都没注册,示例项目本身是坏的,而当时 ddk-mybatis 没有任何测试,所以一直没被发现。

DefaultMapper 现在已经删除——一个「把任何东西映射成空对象」的组件没有任何正确用途。

另外两种启动期失败 ​

这两种情况同样在启动时暴露,而不是等到运行时:

java
// 同一个映射方向注册了两次:运行时用哪一个取决于扫描顺序
throw new IllegalStateException("映射方向 " + key + " 被重复注册:" + ...);

// 标了 @EnhancedMapper 但没实现 ObjectMapper
throw new IllegalStateException("@EnhancedMapper 标注的 " + ... + " 没有实现 ObjectMapper");

五、两个已修复的注册问题 ​

5.1 类型键改用全限定名 ​

早期的 key 是简单类名拼接:

java
String keyPair = source.getSimpleName() + target.getSimpleName();

com.a.User → com.a.UserPO 和 com.b.User → com.b.UserPO 会算出同一个键 "UserUserPO",后注册的静默覆盖前一个。在有多个限界上下文的项目里,同名类几乎必然出现。现在是:

java
private static String keyOf(Class<?> source, Class<?> target) {
    return source.getName() + "->" + target.getName();
}

5.2 不再靠 @Component,扫描也推迟了 ​

MapperProvider 曾经标着 @Component,但它在库 jar 里——能不能被注册,取决于使用方的 component scan 有没有覆盖 com.ddk 包。业务应用的启动类通常在自己的包下,于是这个 Bean 根本不会出现,GenericRepositoryImpl 里的注入直接失败。

现在由 ddk-mybatis-starter 的自动配置注册:

java
@Bean
@ConditionalOnMissingBean
public MapperProvider mapperProvider(ApplicationContext context) {
    return new MapperProvider(context);
}

同时,扫描时机也从构造器挪到了 SmartInitializingSingleton.afterSingletonsInstantiated():

java
@Override
public void afterSingletonsInstantiated() {
    loadEnhancedMappers(context);
}

在构造器里调用 getBeansWithAnnotation() 会在自身还没构造完时触发全量 Bean 实例化,既有循环依赖风险,也可能漏掉此刻还没创建出来的映射器。

六、什么时候不该用这套机制 ​

  • 不要用它做 DTO 组装。 一个 DTO 往往由多个聚合拼装而成,那是应用层的编排逻辑,不是「类型对映射」。四层骨架里出站装配走的是普通的 UserAssembler Bean,没有注册进 MapperProvider——顺带还在那里做了手机号脱敏。
  • 性能敏感路径上注意批量方法。 map(List<S>) 是逐条循环调用 map(S),没有特殊优化。

相关文档 ​

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权