使用 ModelMapper 的一次踩坑經歷
警告:本文程式碼較多,請耐心觀看
在實際專案中,我們常常需要把兩個相似的物件相互轉換,其目的是在對外提供資料時需要將一部分敏感資料(例如:密碼、加密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時一定要注意欄位名,如果有相近的欄位名,必須認真核對匹配是否正確,必要時就採用嚴格匹配策略。
對ModelMapper的基礎使用方法不熟悉的同學可以訪問它的官網檢視相關文件:
http://modelmapper.org/
最後,感謝 贊 和 轉 發。
