Java對象屬性複製組件-Mapstruct的項目改造指南

本文介紹下Java對象屬性複製組件(MapStruct),以及項目中引入遇到的坑。

1. 問題背景

日常編程中,經常會碰到對象屬性複製的場景,就比如下面這樣一個常見的三層MVC架構。

前端請求通過VO對象接收,並通過DTO對象進行流轉,最後轉換成DO對象與數據庫DAO層進行交互,反之亦然。

當業務簡單的時候,可以通過手動編碼getter/setter函數來複制對象屬性。但是當業務變的複雜,對象屬性變得很多,那麼手寫複製屬性代碼不僅十分繁瑣,非常耗時間,並且還可能容易出錯。

爲了解決這個痛點,在項目初期,小輝項目的解決方法是隨手寫的轉換工具函數:根據變量名進行反射,對基礎類型和枚舉的變量進行賦值。

總結下目前該工具函數的優缺點:

優點:

  1. 開發效率高,隨時想要轉換的時候,傳入源對象以及指定class,調用下函數即可。

缺點:

  1. 項目中大量的反射會嚴重影響代碼執行效率

  2. 由於使用了反射,所以成員變量的使用被追蹤就很麻煩

  3. 轉換失敗只有在運行中報錯纔會發現

  4. 對於嵌套對象字段的情況無能爲力

  5. 只能對基礎類型進行復制

  6. 對字段名不一致的屬性無法賦值

2. 開源組件選擇

那如果想要更強大的功能,有哪些開源組件可以選擇呢?

下面小輝收集並盤點下相關開源組件的特點。

1. Apache BeanUtils

  1. 底層原理運用反射。

  2. 嵌套對象字段,將會與源對象使用同一對象,即使用淺拷貝。

  3. 字段名不一致的屬性無法被複制。

  4. 類型不一致的字段,將會進行默認類型轉化。

2. Spring BeanUtils:

  1. 底層原理同樣運用反射,但相比Apache BeanUtils減少了反射校驗,同時增加了緩存,所以提升了轉換速度。

  2. 嵌套對象字段,將會與源對象使用同一對象,即使用淺拷貝。

  3. 字段名不一致,屬性無法複製。

  4. 類型不一致的字段,將會進行默認類型轉化。

3. Cglib BeanCopier

  1. 字節碼技術動態生成一個代理類,代理類實現get和set方法。生成代理類過程存在一定開銷,但是一旦生成,我們可以緩存起來重複使用。相比前兩個更好用。

  2. 嵌套對象字段,將會與源對象使用同一對象,即使用淺拷貝。

  3. 字段名不一致,屬性無法複製。

  4. 類型不一致的字段,將會進行默認類型轉化。

4. Dozer

  1. 運用反射。

  2. 嵌套對象字段,不會與源對象使用同一對象,即深拷貝。

  3. 默認支持類型不一致(基本類型/包裝類型)轉換。

  4. 通過配置字段名的映射關係,不一樣字段的屬性也被複制。

5. orika

  1. 底層其使用了javassist生成字段屬性的映射的字節碼,然後直接動態加載執行字節碼文件,相比於使用反射的工具類,速度上會快很多。

  2. 支持深拷貝。

  3. 默認支持類型不一致(基本類型/包裝類型)轉換。

  4. 通過配置字段名的映射關係,不一樣字段的屬性也被複制。

上面介紹的這些工具類,不管使用反射,還是使用字節碼技術,這些都需要在代碼運行期間動態執行,所以相對於手寫硬編碼這種方式,上面這些工具類執行速度都會慢很多。

而MapStruct與上面五個組件原理都不同。

以上提到的屬性無法複製,都是在不使用手動寫Convert函數的情況下進行討論的

3. MapStruct

1. 爲什麼選擇MapStruct

接下來就要介紹MapStruct 這個工具類,這個工具類之所以運行速度與硬編碼差不多,這是因爲MapStruct在編譯期間就生成屬性複製的代碼,運行期間就無需使用反射或者字節碼技術,從而確保了高性能。

另外,由於編譯期間就生成了代碼,所以如果有任何問題,編譯期間就可以提前暴露,這對於開發人員來講就可以提前解決問題,而不用等到代碼應用上線了,運行之後才發現錯誤。

所以,爲了克服項目中當前函數的被提到的五個缺點,筆者引入了MapStruct。

2. 如何引入MapStruct

只需要引入MapStruct的依賴,同時由於MapStruct需要在編譯器期間生成代碼,所以我們需要maven-compiler-plugin插件中配置。

如果項目中沒有用到lombok,下面的lombok相關配置可以刪除;如果用到lombok,由於MapStruct和Lombok都會在編譯期間生成代碼,爲解決衝突使用如下配置即可。

// pom.xml
<dependency>
<groupId>org.MapStruct</groupId>
<artifactId>MapStruct</artifactId>
<version>1.4.1.Final</version>
</dependency>
// pom.xml
// 爲了防止lombokMapStruct的衝突,在pom.xml加入如下配置
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>${plugin.compiler.version}</version>
<configuration>
<source>1.8</source>
<target>1.8</target>
<annotationProcessorPaths>
<path>
<groupId>org.MapStruct</groupId>
<artifactId>MapStruct-processor</artifactId>
<version>${MapStruct.version}</version>
</path>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</path>
<!-- other annotation processors -->
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>

3. MapStruct的常見使用方法

使用MapStruct很簡單,只需要創建一個mapper文件,然後在需要使用轉換的地方,注入調用即可。

下面列舉了兩個文件,涵蓋項目中絕大多數的mapper文件寫法。

DO轉成DTO的mapper:

/**
* componentModel = "spring":表明該類是一個 spring 組件,之後調用處只需要使用@Autowired,即可引入該類實例
* NullValuePropertyMappingStrategy.IGNORE:如果遇到舊對象屬性爲null,則跳過該屬性賦值給新對象
*/
@Mapper(componentModel = "spring", nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE)
public interface UserTransMapper {
/**
* 這個對象可用於非Spring環境下獲取當前對象實例。如果在Spring環境下,該行代碼可刪除
*/
UserTransMapper INSTANCE = Mappers.getMapper(UserTransMapper.class);

/**
* Userinfo對象中非null的屬性轉化爲UserDto的對象
* @param userInfo 從數據庫讀取的用戶信息
* @return
*/
UserDto userInfo2userDto(UserInfo userInfo);

/**
* Userinfo對象中非null的屬性更新到UserDto的對象
* @param userInfo 從數據庫讀取的用戶信息
* @param userDto 用戶信息的dto
* 如果改voidUserDto,則函數會返回更新後的UserDto對象
*/
void updateUserInfo2userDto(UserInfo userInfo, @MappingTarget UserDto userDto);

/**
* UserDto對象中非null的屬性轉化爲LoginEventDto的對象
* @param userDto 用戶信息的dto
* @return LoginEventDto繼承UserDto
*/
LoginEventDto userDto2loginEventDto(UserDto userDto);
}

DTO轉成VO的mapper:

@Mapper(componentModel = "spring", nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE)
public interface UserTransMapper {

/**
* UserDto對象中非null的屬性轉化爲UserInfoVo的對象
* @param userDto 用戶信息的dto
* @return UserInfoVo繼承與UserBaseInfoVo,都是用了@Data,沒有異常報錯。
*/
UserInfoVo userDto2userVo(UserDto userDto);

/**
* 直接寫嵌套List等集合類,同樣可以生效
* @param userDtoList
* @return
*/
List<UserInfoVo> userDto2userVo(List<UserDto> userDtoList);

/**
* 如果UserDto存在成員變量是類UserSubDto,而UserInfoVo存在成員變量是類UserSubVo,想在上面轉化的同時,讓這兩個成員變量進行賦值,只需要定義下面的函數即可。
*
* @param userSubDto 用戶信息的dto中的成員變量,類型爲UserSubDto
* @return
*/
UserSubVo userSubDto2userSubVo(UserSubDto userSubDto);

/**
* UserDto對象和FollowInfoDto對象中非null的屬性轉化爲UserInfoVo的對象
* @param userDto 用戶信息的dto
* @param followInfoDto 關注粉絲的dto
* @param hn 房子數量
* @return
*/
@Mappings({
@Mapping(source = "userDto.regionId",target = "regionId"),
@Mapping(source = "followInfoDto.price", target = "price", numberFormat = "0.00"),
@Mapping(source = "hn",target = "houseNumber")
})
/**
* @Mapping也就是手動映射字段的操作,使用簡單,讀者可自行研究
*/
UserInfoVo userDto2userVo(UserDto userDto, FollowInfoDto followInfoDto, Integer hn);
/**
* 假設從映射PersonPersonDto需要一些MapStruct無法生成的特殊邏輯,可以定義一個default函數
*/
default PersonDto personToPersonDto(Person person) {
// 手動寫映射邏輯
}
}

4. 項目改造與踩坑提示

這次改造中相關依賴的版本:

  1. lombok版本1.16.22,改造時升級爲1.18.12

  2. 項目原有依賴fastjson版本1.2.62

  3. 引入MapStruct版本爲1.4.1.Final

說明:

  1. 之所以要升級lombok版本,是因爲上面UserDto對象轉化爲LoginEventDto對象時,原有項目只在UserDto上添加@Builder,但是繼承類LoginEventDto無法繼承@Builder,導致MapStruct實例化的時候實例一個UserDto對象。
    解決方法:在繼承層次結構的所有類(即LoginEventDto和UserDto)都需要使用@SuperBuilder可以,(類UserDto的@Builder要去掉)但這個@SuperBuilder只在更高的lombok版本纔有,所以才升級了lombok版本。

  2. 項目中使用了fastjson,因此業務代碼中出現很多處需要反射調用無參構造函數。但在上面一步升級lombok的過程中,lombok對於@Builder的實現出現了一些修改:在1.16.22的生成代碼中,是存在private級別的無參構造函數;而在1.18.12的生成代碼中,並沒有私有無參構造函數,從而導致了業務代碼大量出現缺少默認構造函數的報錯。
    解決方法:@Builder註解跟構造函數之間的衝突很常見。最佳實踐是:在所有使用@Builder或者@SupserBuilder的類,增加@NoArgsConstructor和@AllArgsConstructor。

雖然本文極力推薦MapStruct,但如果是老項目的話,尤其是大項目的話,還是考慮下改造後的測試成本。本人在第一次引入的時候,過於自信,在父pom引入MapStruct並提升了lombok版本,直接導致開發環境的微服務集體報錯。後來改爲在單個微服務實驗,並且放在開發環境長期觀察(主要這個改動影響測試覆蓋面太大,也不想讓QA爲了技術優化來加班),之後纔敢放到生產。

當然如果是新項目,非常推薦嘗試下MapStruct。

5. Q&A

  1. 在項目引入MapStruct時,有人會提出現在反射的性能消耗已經很低了,Spring、Mybatis等各種框架中大量使用反射,爲什麼還要使用MapStruct這種編譯期生成代碼的組件?
    主要有如下考慮:
    1.反射本身的性能損耗還是很大的,但由於開源庫對反射進行了緩存等優化處理,才減少反射對性能損耗的影響。然而,相比調用MapStruct生成的方法,優化後的性能還是差很多。
    2.開源庫使用反射是爲了通用性考慮,但在具體的業務場景,對象之間的轉換是很確定的。
    3.MapStruct組件本身使用很簡單(看完這篇博客之後,可以解決大部分應用場景)。同時, MapStruct組件還能處理一些反射無法處理或者更加靈活解決一些應用問題。

參考

  1. https://github.com/MapStruct/MapStruct-examples

  2. http://www.kailing.pub/MapStruct1.3/index.html

  3. https://mapstruct.org/documentation/stable/reference/html/


本文分享自微信公衆號 - 全菜工程師小輝(mseddl)。
如有侵權,請聯繫 [email protected] 刪除。
本文參與“OSC源創計劃”,歡迎正在閱讀的你也加入,一起分享。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章