对象映射:MapperProvider 与 @EnhancedMapper
DDK 用一套「按类型对查找映射器」的机制处理 Entity ↔ PO 转换。这篇讲它的契约、用法,以及它为什么宁可在启动期炸掉,也不肯给你一个看起来能用的空对象。
一、四个组成部分
ObjectMapper<S, T> 映射器接口,map(S) 与 map(List<S>)
@EnhancedMapper 标注在映射器上,声明它负责哪个 source → target
MapperProvider 按 (source, target) 查找映射器,找不到直接抛异常
MapperConfiguration MapStruct 的共享配置(componentModel=spring 等)映射器接口很简单:
public interface ObjectMapper<S, T> {
T map(S source);
List<T> map(List<S> sources);
}二、用法
给每个需要转换的类型对写一个映射器,用 @EnhancedMapper 声明它的职责。简单的结构可以让 MapStruct 生成:
@Mapper(config = MapperConfiguration.class)
@EnhancedMapper(source = User.class, target = UserPO.class,
description = "领域实体 User 转持久化对象 UserPO")
public interface UserToPoMapper extends ObjectMapper<User, UserPO> {
}反向要单独写一个——ObjectMapper<S, T> 是单向的,这是最常见的漏注册原因:
@Mapper(config = MapperConfiguration.class)
@EnhancedMapper(source = UserPO.class, target = User.class)
public interface PoToUserMapper extends ObjectMapper<UserPO, User> {
}MapperConfiguration 统一了三项 MapStruct 行为,不需要每个映射器重复声明:
@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 才能表达,代码量并不比手写少,可读性还更差。所以四层骨架里是手写的:
@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:
@Mapper(config = MapperConfiguration.class)
public interface DefaultMapper extends ObjectMapper<Object, Object> {
}MapStruct 拿到 Object → Object 这个签名时找不到任何属性可映射,于是生成了这样的实现:
@Override
public Object map(Object source) {
if (source == null) {
return null;
}
Object object = new Object(); // 造一个空对象
return object; // 源对象的数据全部丢失
}于是调用链变成:
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 现在已经删除——一个「把任何东西映射成空对象」的组件没有任何正确用途。
另外两种启动期失败
这两种情况同样在启动时暴露,而不是等到运行时:
// 同一个映射方向注册了两次:运行时用哪一个取决于扫描顺序
throw new IllegalStateException("映射方向 " + key + " 被重复注册:" + ...);
// 标了 @EnhancedMapper 但没实现 ObjectMapper
throw new IllegalStateException("@EnhancedMapper 标注的 " + ... + " 没有实现 ObjectMapper");五、两个已修复的注册问题
5.1 类型键改用全限定名
早期的 key 是简单类名拼接:
String keyPair = source.getSimpleName() + target.getSimpleName();com.a.User → com.a.UserPO 和 com.b.User → com.b.UserPO 会算出同一个键 "UserUserPO",后注册的静默覆盖前一个。在有多个限界上下文的项目里,同名类几乎必然出现。现在是:
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 的自动配置注册:
@Bean
@ConditionalOnMissingBean
public MapperProvider mapperProvider(ApplicationContext context) {
return new MapperProvider(context);
}同时,扫描时机也从构造器挪到了 SmartInitializingSingleton.afterSingletonsInstantiated():
@Override
public void afterSingletonsInstantiated() {
loadEnhancedMappers(context);
}在构造器里调用 getBeansWithAnnotation() 会在自身还没构造完时触发全量 Bean 实例化,既有循环依赖风险,也可能漏掉此刻还没创建出来的映射器。
六、什么时候不该用这套机制
- 不要用它做 DTO 组装。 一个 DTO 往往由多个聚合拼装而成,那是应用层的编排逻辑,不是「类型对映射」。四层骨架里出站装配走的是普通的
UserAssemblerBean,没有注册进MapperProvider——顺带还在那里做了手机号脱敏。 - 性能敏感路径上注意批量方法。
map(List<S>)是逐条循环调用map(S),没有特殊优化。
相关文档
- 领域模型基类 — Entity / ValueObject / Identifier 正是让自动映射不再可行的原因
- 通用仓储 GenericRepository — 映射器的主要调用方