複製的程式碼總是報錯 手動寫的正常_Java物件屬性複製元件Mapstruct的專案改造指南...
技術標籤:複製的程式碼總是報錯 手動寫的正常
本文介紹下Java物件屬性複製元件(MapStruct),以及專案中引入遇到的坑。
1. 問題背景
日常程式設計中,經常會碰到物件屬性複製的場景,就比如下面這樣一個常見的三層MVC架構。
前端請求通過VO物件接收,並通過DTO物件進行流轉,最後轉換成DO物件與資料庫DAO層進行互動,反之亦然。
當業務簡單的時候,可以通過手動編碼getter/setter函式來複制物件屬性。但是當業務變的複雜,物件屬性變得很多,那麼手寫複製屬性程式碼不僅十分繁瑣,非常耗時間,並且還可能容易出錯。
為了解決這個痛點,在專案初期,小輝專案的解決方法是隨手寫的轉換工具函式:根據變數名進行反射,對基礎型別和列舉的變數進行賦值。
總結下目前該工具函式的優缺點:
優點:
開發效率高,隨時想要轉換的時候,傳入源物件以及指定class,呼叫下函式即可。
缺點:
專案中大量的反射會嚴重影響程式碼執行效率
由於使用了反射,所以成員變數的使用被追蹤就很麻煩
轉換失敗只有在執行中報錯才會發現
對於巢狀物件欄位的情況無能為力
只能對基礎型別進行復制
對欄位名不一致的屬性無法賦值
2. 開源元件選擇
那如果想要更強大的功能,有哪些開源元件可以選擇呢?
下面小輝收集並盤點下相關開源元件的特點。
1. Apache BeanUtils
底層原理運用反射。
巢狀物件欄位,將會與源物件使用同一物件,即使用淺拷貝。
欄位名不一致的屬性無法被複制。
型別不一致的欄位,將會進行預設型別轉化。
2. Spring BeanUtils:
底層原理同樣運用反射,但相比Apache BeanUtils減少了反射校驗,同時增加了快取,所以提升了轉換速度。
巢狀物件欄位,將會與源物件使用同一物件,即使用淺拷貝。
欄位名不一致,屬性無法複製。
型別不一致的欄位,將會進行預設型別轉化。
3. Cglib BeanCopier
位元組碼技術動態生成一個代理類,代理類實現get和set方法。生成代理類過程存在一定開銷,但是一旦生成,我們可以快取起來重複使用。相比前兩個更好用。
巢狀物件欄位,將會與源物件使用同一物件,即使用淺拷貝。
欄位名不一致,屬性無法複製。
型別不一致的欄位,將會進行預設型別轉化。
4. Dozer
運用反射。
巢狀物件欄位,不會與源物件使用同一物件,即深拷貝。
預設支援型別不一致(基本型別/包裝型別)轉換。
通過配置欄位名的對映關係,不一樣欄位的屬性也被複制。
5. orika
底層其使用了javassist生成欄位屬性的對映的位元組碼,然後直接動態載入執行位元組碼檔案,相比於使用反射的工具類,速度上會快很多。
支援深拷貝。
預設支援型別不一致(基本型別/包裝型別)轉換。
通過配置欄位名的對映關係,不一樣欄位的屬性也被複制。
上面介紹的這些工具類,不管使用反射,還是使用位元組碼技術,這些都需要在程式碼執行期間動態執行,所以相對於手寫硬編碼這種方式,上面這些工具類執行速度都會慢很多。
而MapStruct與上面五個元件原理都不同。
以上提到的屬性無法複製,都是在不使用手動寫Convert函式的情況下進行討論的
3. MapStruct
1. 為什麼選擇MapStruct
接下來就要介紹MapStruct 這個工具類,這個工具類之所以執行速度與硬編碼差不多,這是因為MapStruct在編譯期間就生成屬性複製的程式碼,執行期間就無需使用反射或者位元組碼技術,從而確保了高效能。
另外,由於編譯期間就生成了程式碼,所以如果有任何問題,編譯期間就可以提前暴露,這對於開發人員來講就可以提前解決問題,而不用等到程式碼應用上線了,執行之後才發現錯誤。
所以,為了克服專案中當前函式的被提到的五個缺點,筆者引入了MapStruct。
2. 如何引入MapStruct
只需要引入MapStruct的依賴,同時由於MapStruct需要在編譯器期間生成程式碼,所以我們需要maven-compiler-plugin外掛中配置。
如果專案中沒有用到lombok,下面的lombok相關配置可以刪除;如果用到lombok,由於MapStruct和Lombok都會在編譯期間生成程式碼,為解決衝突使用如下配置即可。
// pom.xml
org.MapStructMapStruct1.4.1.Final
// pom.xml
// 為了防止lombok和MapStruct的衝突,在pom.xml加入如下配置
org.apache.maven.plugins
maven-compiler-plugin
${plugin.compiler.version}
1.8
1.8
org.MapStruct
MapStruct-processor
${MapStruct.version}
org.projectlombok
lombok
${lombok.version}
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
* 如果改void為UserDto,則函式會返回更新後的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 userDto2userVo(List 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);
/**
* 假設從對映Person到PersonDto需要一些MapStruct無法生成的特殊邏輯,可以定義一個default函式
*/
default PersonDto personToPersonDto(Person person) {
// 手動寫對映邏輯
}
}
4. 專案改造與踩坑提示
這次改造中相關依賴的版本:
lombok版本1.16.22,改造時升級為1.18.12
專案原有依賴fastjson版本1.2.62
引入MapStruct版本為1.4.1.Final
說明:
之所以要升級lombok版本,是因為上面UserDto物件轉化為LoginEventDto物件時,原有專案只在UserDto上新增@Builder,但是繼承類LoginEventDto無法繼承@Builder,導致MapStruct例項化的時候例項一個UserDto物件。
解決方法:在繼承層次結構的所有類(即LoginEventDto和UserDto)都需要使用@SuperBuilder可以,(類UserDto的@Builder要去掉)但這個@SuperBuilder只在更高的lombok版本才有,所以才升級了lombok版本。專案中使用了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
在專案引入MapStruct時,有人會提出現在反射的效能消耗已經很低了,Spring、Mybatis等各種框架中大量使用反射,為什麼還要使用MapStruct這種編譯期生成程式碼的元件?
主要有如下考慮:
1.反射本身的效能損耗還是很大的,但由於開源庫對反射進行了快取等優化處理,才減少反射對效能損耗的影響。然而,相比呼叫MapStruct生成的方法,優化後的效能還是差很多。
2.開源庫使用反射是為了通用性考慮,但在具體的業務場景,物件之間的轉換是很確定的。
3.MapStruct元件本身使用很簡單(看完這篇部落格之後,可以解決大部分應用場景)。同時, MapStruct元件還能處理一些反射無法處理或者更加靈活解決一些應用問題。
參考
https://github.com/MapStruct/MapStruct-examples
http://www.kailing.pub/MapStruct1.3/index.html
https://mapstruct.org/documentation/stable/reference/html/