V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
V2EX 提问指南
Jackeyzhe
V2EX  ›  问与答

使用 ModelMapper 的一次踩坑经历

  •  
  •   Jackeyzhe · 2018-09-13 00:26:25 +08:00 · 1190 次点击
    这是一个创建于 2125 天前的主题,其中的信息可能已经有所发展或是发生改变。

    在实际项目中,我们常常需要把两个相似的对象相互转换,其目的是在对外提供数据时需要将一部分敏感数据(例如:密码、加密 token 等)隐藏起来。最普通的方法是,新建一个对象,将需要的值逐个 set 进去。如果有多组需要这样转换的对象,那么就需要做很多只是 get/set 这样无意义的工作。

    在这样的背景下,ModelMapper 诞生了,它是一个简单、高效、智能的对象映射工具。它的使用非常简单,首先添加 maven 依赖

    <dependency>
      <groupId>org.modelmapper</groupId>
      <artifactId>modelmapper</artifactId>
      <version>2.1.1</version>
    </dependency>
    

    然后就可以直接 new 出一个 ModelMapper 对象,并且调用其 map 方法将指定对象的值映射到另一个对象上了。

    使用方法今天不做过多介绍,大家可以自行 Google,找到 ModelMapper 的相关文档进行学习。今天要分享的时前几天无意间踩到的一个坑。我有两个类,PostDO 和 PostVO (这里只截取了部分字段,因此两个类的含义也不做解释了):

    public class PostDO {
        private Long id;
        private String commentId;
        private Long postId;
        private int likeNum;
    }
    
    public class PostVO {
        private Long id;
        private boolean like;
        private int likeNum;
    }
    

    在一个方法中,我试图将 PostDO 的一个对象映射到 PostVO,因此我进行如下操作:

    public class Application {
        public static void main(String[] args) {
            ModelMapper modelMapper = new ModelMapper();
            PostDO postDO = PostDO.builder().id(3L).likeNum(0).build();
            PostVO postVO = modelMapper.map(postDO, PostVO.class);
            System.out.println(postVO);
        }
    }
    

    执行结果是这样的:

    PostVO(id=3, like=false, likeNum=0)
    

    无异常,项目中 likeNum 字段的值是随着项目的进行递增的。当 likeNum 增加到 2 时,异常出现了:

    Exception in thread "main" org.modelmapper.MappingException: ModelMapper mapping errors:
    
    1) Converter org.modelmapper.internal.converter.BooleanConverter@497470ed failed to convert int to boolean.
    
    1 error
    	at org.modelmapper.internal.Errors.throwMappingExceptionIfErrorsExist(Errors.java:380)
    	at org.modelmapper.internal.MappingEngineImpl.map(MappingEngineImpl.java:79)
    	at org.modelmapper.ModelMapper.mapInternal(ModelMapper.java:554)
    	at org.modelmapper.ModelMapper.map(ModelMapper.java:387)
    	at Application.main(Application.java:7)
    Caused by: org.modelmapper.MappingException: ModelMapper mapping errors:
    
    1) Error mapping 2 to boolean
    
    1 error
    	at org.modelmapper.internal.Errors.toMappingException(Errors.java:258)
    	at org.modelmapper.internal.converter.BooleanConverter.convert(BooleanConverter.java:49)
    	at org.modelmapper.internal.converter.BooleanConverter.convert(BooleanConverter.java:27)
    	at org.modelmapper.internal.MappingEngineImpl.convert(MappingEngineImpl.java:298)
    	at org.modelmapper.internal.MappingEngineImpl.map(MappingEngineImpl.java:108)
    	at org.modelmapper.internal.MappingEngineImpl.setDestinationValue(MappingEngineImpl.java:238)
    	at org.modelmapper.internal.MappingEngineImpl.propertyMap(MappingEngineImpl.java:184)
    	at org.modelmapper.internal.MappingEngineImpl.typeMap(MappingEngineImpl.java:148)
    	at org.modelmapper.internal.MappingEngineImpl.map(MappingEngineImpl.java:113)
    	at org.modelmapper.internal.MappingEngineImpl.map(MappingEngineImpl.java:70)
    	... 3 more
    

    提示 int 类型不能转换成 boolean 型,很明显。ModelMapper 是将 like 字段映射到 likeNum 了。那么 ModelMapper 究竟是怎样进行映射的呢,我们一起来看一下 ModelMapper 的源码。

    ModelMapper 利用反射机制,获取到目标类的字段,并生成期望匹配的键值对,类似于这样。

    键值对

    接着对这些键值对进行遍历,逐个寻找源类中可以匹配的字段。首先会根据目标字段判断是否存在对应的映射,

    Mapping existingMapping = this.typeMap.mappingFor(destPath);
    if (existingMapping == null) {
        this.matchSource(this.sourceTypeInfo, mutator);
        this.propertyNameInfo.clearSource();
        this.sourceTypes.clear();
    }
    

    如果不存在,就调用 matchSource 方法,在源类中根据匹配规则寻找可以匹配的字段。匹配过程中,首先会判断目标字段的类型是否在类型列表中存在,如果存在,则可以根据名称,加入匹配的 mappings 中。如果不存在,则需要判断 converterStore 中是否存在能够应用于该字段的转换器。

    if (this.destinationTypes.contains(destinationMutator.getType())) {
        this.mappings.add(new PropertyMappingImpl(this.propertyNameInfo.getSourceProperties(), this.propertyNameInfo.getDestinationProperties(), true));
    } else {
        TypeMap<?, ?> propertyTypeMap = this.typeMapStore.get(accessor.getType(), destinationMutator.getType(), (String)null);
        PropertyMappingImpl mapping = null;
        if (propertyTypeMap != null) {
            Converter<?, ?> propertyConverter = propertyTypeMap.getConverter();
            if (propertyConverter == null) {
                this.mergeMappings(propertyTypeMap);
            } else {
                this.mappings.add(new PropertyMappingImpl(this.propertyNameInfo.getSourceProperties(), this.propertyNameInfo.getDestinationProperties(), propertyTypeMap.getProvider(), propertyConverter));
            }
    
            doneMatching = this.matchingStrategy.isExact();
        } else {
            Iterator var9 = this.converterStore.getConverters().iterator();
    
            while(var9.hasNext()) {
                ConditionalConverter<?, ?> converter = (ConditionalConverter)var9.next();
                MatchResult matchResult = converter.match(accessor.getType(), destinationMutator.getType());
                if (!MatchResult.NONE.equals(matchResult)) {
                    mapping = new PropertyMappingImpl(this.propertyNameInfo.getSourceProperties(), this.propertyNameInfo.getDestinationProperties(), false);
                    if (MatchResult.FULL.equals(matchResult)) {
                        this.mappings.add(mapping);
                        doneMatching = this.matchingStrategy.isExact();
                        break;
                    }
    
                    if (!this.configuration.isFullTypeMatchingRequired()) {
                        this.partiallyMatchedMappings.add(mapping);
                        break;
                    }
                }
            }
        }
    
        if (mapping == null) {
            this.intermediateMappings.put(accessor, new PropertyMappingImpl(this.propertyNameInfo.getSourceProperties(), this.propertyNameInfo.getDestinationProperties(), false));
        }
    }
    

    默认的转换器有 11 中:

    转换器

    找到对应的 converter 后,converter 的 map 方法返回一个 MatchResult,MatchResult 有三种结果:FULL、PARTIAL 和 NONE (即全部匹配,部分匹配和不匹配)。注意,这里有一个部分匹配,也就是我所踩到的坑,在对 like 进行匹配是,likeNum 就被定义为部分匹配。因此,当 likeNum 大于 2 时,就不能被转换成 boolean 类型。

    这里解决方法有两种,一种是在设置中,规定必须字段名完全匹配;另一种就是将匹配策略定义为严格。

    设置方法如下:

    modelMapper.getConfiguration().setFullTypeMatchingRequired(true);
    modelMapper.getConfiguration().setMatchingStrategy(MatchingStrategies.STRICT);
    

    到这里,ModelMapper 会选出较为合适的源字段,但是如果匹配要求不高的话,ModelMapper 可能会筛选出多个符合条件的字段,因此,还需要进一步过滤。

    PropertyMappingImpl mapping;
    if (this.mappings.size() == 1) {
        mapping = (PropertyMappingImpl)this.mappings.get(0);
    } else {
        mapping = this.disambiguateMappings();
        if (mapping == null && !this.configuration.isAmbiguityIgnored()) {
            this.errors.ambiguousDestination(this.mappings);
        }
    }
    

    这里我们看到,如果匹配到的结果只有 1 个,那么就返回这个结果;如果有多个,则会调用 disambiguateMappings 方法,去掉有歧义的结果。我们来看一下这个方法。

    private PropertyMappingImpl disambiguateMappings() {
        List<ImplicitMappingBuilder.WeightPropertyMappingImpl> weightMappings = new ArrayList(this.mappings.size());
        Iterator var2 = this.mappings.iterator();
    
        while(var2.hasNext()) {
            PropertyMappingImpl mapping = (PropertyMappingImpl)var2.next();
            ImplicitMappingBuilder.SourceTokensMatcher matcher = this.createSourceTokensMatcher(mapping);
            ImplicitMappingBuilder.DestTokenIterator destTokenIterator = new ImplicitMappingBuilder.DestTokenIterator(mapping);
    
            while(destTokenIterator.hasNext()) {
                matcher.match(destTokenIterator.next());
            }
    
            double matchRatio = (double)matcher.matches() / ((double)matcher.total() + (double)destTokenIterator.total());
            weightMappings.add(new ImplicitMappingBuilder.WeightPropertyMappingImpl(mapping, matchRatio));
        }
    
        Collections.sort(weightMappings);
        if (((ImplicitMappingBuilder.WeightPropertyMappingImpl)weightMappings.get(0)).ratio == ((ImplicitMappingBuilder.WeightPropertyMappingImpl)weightMappings.get(1)).ratio) {
            return null;
        } else {
            return ((ImplicitMappingBuilder.WeightPropertyMappingImpl)weightMappings.get(0)).mapping;
        }
    }
    

    ModelMapper 定义了一个权重,来判断源字段是否有歧义,这里根据驼峰式的规则(也可以设置为下划线),将源和目标字段名称进行拆分,根据 匹配数量 /源 token 数+目标 token 数,得到一个匹配的比率,比率越大,说明匹配度越高。最终取得匹配权重最大的那个字段。其他字段被认为是有歧义的。

    截至目前,默认的 ModelMapper 的 map 方法的工作原理已经介绍完了,中间可能有些遗漏的细节,或者哪里有说的不明白的地方,欢迎大家和我一起讨论。大家在用到 ModelMapper 时一定要注意字段名,如果有相近的字段名,必须认真核对匹配是否正确,必要时就采用严格匹配策略。

    目前尚无回复
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   823 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 34ms · UTC 21:10 · PVG 05:10 · LAX 14:10 · JFK 17:10
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.