Java 對象轉換方案分析與 mapstruct 實踐
一 前言
隨着系統模塊分層不斷細化,在 Java 日常開發中不可避免地涉及到各種對象的轉換,如:DO、DTO、VO 等等,編寫映射轉換代碼是一個繁瑣重複且還易錯的工作,一個好的工具輔助,減輕了工作量、提升開發工作效率的同時還能減少 bug 的發生。
二 常用方案及分析
1 fastjson
CarDTO entity = JSON.parseObject(JSON.toJSONString(carDO), CarDTO.class);
這種方案因爲通過生成中間 json 格式字符串,然後再轉化成目標對象,性能非常差,同時因爲中間會生成 json 格式字符串,如果轉化過多,gc 會非常頻繁,同時針對複雜場景支持能力不足,基本很少用。
2 BeanUtil 類
BeanUtil.copyProperties() 結合手寫 get、set,對於簡單的轉換直接使用 BeanUtil,複雜的轉換自己手工寫 get、set。該方案的痛點就在於代碼編寫效率低、冗餘繁雜還略顯醜陋,並且 BeanUtil 因爲使用了反射 invoke 去賦值性能不高。
只能適合 bean 數量較少、內容不多、轉換不頻繁的場景。
apache.BeanUtils
org.apache.commons.beanutils.BeanUtils.copyProperties(do, entity);
這種方案因爲用到反射的原因,同時本身設計問題,性能比較差。集團開發規約明確規定禁止使用。
spring.BeanUtils
org.springframework.beans.BeanUtils.copyProperties(do, entity);
這種方案針對 apache 的 BeanUtils 做了很多優化,整體性能提升不少,不過還是使用反射實現比不上原生代碼處理,其次針對複雜場景支持能力不足。
3 beanCopier
BeanCopier copier = BeanCopier.create(CarDO.class, CarDTO.class, false);
copier.copy(do, dto, null);
這種方案動態生成一個要代理類的子類, 其實就是通過字節碼方式轉換成性能最好的 get 和 set 方式, 重要的開銷在創建 BeanCopier,整體性能接近原生代碼處理,比 BeanUtils 要好很多,尤其在數據量很大時,但是針對複雜場景支持能力不足。
4 各種 Mapping 框架
分類
Object Mapping 技術從大的角度來說分爲兩類,一類是運行期轉換,另一類則是編譯期轉換:
-
運行期反射調用 set/get 或者是直接對成員變量賦值。這種方式通過 invoke 執行賦值,實現時一般會採用 beanutil, Javassist 等開源庫。運行期對象轉換的代表主要是 Dozer 和 ModelMaper。
-
編譯期動態生成 set/get 代碼的 class 文件,在運行時直接調用該 class 的 set/get 方法。該方式實際上仍會存在 set/get 代碼,只是不需要開發人員自己寫了。這類的代表是:MapStruct,Selma,Orika。
分析
-
無論哪種 Mapping 框架,基本都是採用 xml 配置文件 or 註解的方式供用戶配置,然後生成映射關係。
-
編譯期生成 class 文件方式需要 DTO 仍然有 set/get 方法,只是調用被屏蔽;而運行期反射方式在某些直接填充 field 的方案中,set/get 代碼也可以省略。
-
編譯期生成 class 方式會有源代碼在本地,方便排查問題。
-
編譯期生成 class 方式因爲在編譯期纔出現 java 和 class 文件,所以熱部署會受到一定影響。
-
反射型由於很多內容是黑盒,在排查問題時,不如編譯期生成 class 方式方便。參考 GitHub 上工程 java-object-mapper-benchmark 可以看出主要框架性能比較。
-
反射型調用由於是在運行期根據映射關係反射執行,其執行速度會明顯下降 N 個量級。
-
通過編譯期生成 class 代碼的方式,本質跟直接寫代碼區別不大,但由於代碼都是靠模板生成,所以代碼質量沒有手工寫那麼高,這也會造成一定的性能損失。
綜合性能、成熟度、易用性、擴展性,mapstruct 是比較優秀的一個框架。
三 Mapstruct 使用指南
1 Maven 引入
...
<properties>
<org.mapstruct.version>1.4.2.Final</org.mapstruct.version>
</properties>
...
<dependencies>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>${org.mapstruct.version}</version>
</dependency>
</dependencies>
...
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.8.1</version>
<configuration>
<source>1.8</source>
<!-- depending on your project -->
<target>1.8</target>
<!-- depending on your project -->
<annotationProcessorPaths>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${org.mapstruct.version}</version>
</path>
<!-- other annotation processors -->
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>
2 簡單入門案例
DO 和 DTO
這裏用到了 lombok 簡化代碼,lombok 的原理也是在編譯時去生成 get、set 等被簡化的代碼。
@Data
public class Car {
private String make;
private int numberOfSeats;
private CarType type;
}
@Data
public class CarDTO {
private String make;
private int seatCount;
private String type;
}
定義 Mapper
@Mapper 中描述映射,在編輯的時候 mapstruct 將會根據此描述生成實現類:
-
當屬性與其目標實體副本同名時,它將被隱式映射。
-
當目標實體中的屬性具有不同名稱時,可以通過 @Mapping 註釋指定其名稱。
@Mapper
public interface CarMapper {
@Mapping(source = "numberOfSeats", target = "seatCount")
CarDTO CarToCarDTO(Car car); }
使用 Mapper
通過 Mappers 工廠生成靜態實例使用。
@Mapper
public interface CarMapper {
CarMapper INSTANCE = Mappers.getMapper(CarMapper.class);
@Mapping(source = "numberOfSeats", target = "seatCount")
CarDTO CarToCarDTO(Car car);
}
Car car = new Car(...);
CarDTO carDTO = CarMapper.INSTANCE.CarToCarDTO(car);
getMapper 會去 load 接口的 Impl 後綴的實現類。
通過生成 spring bean 注入使用,Mapper 註解加上 spring 配置,會自動生成一個 bean,直接使用 bean 注入即可訪問。
@Mapper(componentModel = "spring")
public interface CarMapper {
@Mapping(source = "numberOfSeats", target = "seatCount")
CarDTO CarToCarDTO(Car car);
}
自動生成的 MapperImpl 內容
如果配置了 spring bean 訪問會在註解上自動加上 @Component。
3 進階使用
逆向映射
如果是雙向映射,例如 從 DO 到 DTO 以及從 DTO 到 DO,正向方法和反向方法的映射規則通常是相似的,並且可以通過切換源和目標來簡單地逆轉。
使用註解 @InheritInverseConfiguration 指示方法應繼承相應反向方法的反向配置。
@Mapper
public interface CarMapper {
CarMapper INSTANCE = Mappers.getMapper(CarMapper.class);
@Mapping(source = "numberOfSeats", target = "seatCount")
CarDTO CarToCarDTO(Car car);
@InheritInverseConfiguration
Car CarDTOToCar(CarDTO carDTO);
}
更新 bean 映射
有些情況下不需要映射轉換產生新的 bean,而是更新已有的 bean。
@Mapper
public interface CarMapper {
CarMapper INSTANCE = Mappers.getMapper(CarMapper.class);
@Mapping(source = "numberOfSeats", target = "seatCount")
void updateDTOFromCar(Car car, @MappingTarget CarDTO carDTO);
集合映射
集合類型(List,Set,Map 等)的映射以與映射 bean 類型相同的方式完成,即通過在映射器接口中定義具有所需源類型和目標類型的映射方法。MapStruct 支持 Java Collection Framework 中的多種可迭代類型。
生成的代碼將包含一個循環,該循環遍歷源集合,轉換每個元素並將其放入目標集合。如果在給定的映射器或其使用的映射器中找到用於集合元素類型的映射方法,則將調用此方法以執行元素轉換,如果存在針對源元素類型和目標元素類型的隱式轉換,則將調用此轉換。
@Mapper
public interface CarMapper {
CarMapper INSTANCE = Mappers.getMapper(CarMapper.class);
@Mapping(source = "numberOfSeats", target = "seatCount")
CarDTO CarToCarDTO(Car car);
List<CarDTO> carsToCarDtos(List<Car> cars);
Set<String> integerSetToStringSet(Set<Integer> integers);
@MapMapping(valueDateFormat = "dd.MM.yyyy")
Map<String, String> longDateMapToStringStringMap(Map<Long, Date> source);
}
編譯時生成的實現類:
多個源參數映射
MapStruct 還支持具有多個源參數的映射方法。例如,將多個實體組合成一個數據傳輸對象。
在原案例新增一個 Person 對象,CarDTO 中新增 driverName 屬性,根據 Person 對象獲得。
@Mapper
public interface CarMapper {
CarMapper INSTANCE = Mappers.getMapper(CarMapper.class);
@Mapping(source = "car.numberOfSeats", target = "seatCount")
@Mapping(source = "person.name", target = "driverName")
CarDTO CarToCarDTO(Car car, Person person); }
編譯生成的代碼:
默認值和常量映射
如果相應的源屬性是 null ,則可以指定默認值以將預定義值設置爲目標屬性。在任何情況下,都可以指定常量來設置這樣的預定義值。默認值和常量被指定爲字符串值。當目標類型是原始類型或裝箱類型時,String 值將採用字面量,在這種情況下允許位 / 八進制 / 十進制 / 十六進制模式,只要它們是有效的文字即可。在所有其他情況下,常量或默認值會通過內置轉換或調用其他映射方法進行類型轉換,以匹配目標屬性所需的類型。
@Mapper
public interface SourceTargetMapper {
SourceTargetMapper INSTANCE = Mappers.getMapper( SourceTargetMapper.class );
@Mapping(target = "stringProperty", source = "stringProp", defaultValue = "undefined")
@Mapping(target = "longProperty", source = "longProp", defaultValue = "-1")
@Mapping(target = "stringConstant", constant = "Constant Value")
@Mapping(target = "integerConstant", constant = "14")
@Mapping(target = "longWrapperConstant", constant = "3001")
@Mapping(target = "dateConstant", dateFormat = "dd-MM-yyyy", constant = "09-01-2014")
@Mapping(target = "stringListConstants", constant = "jack-jill-tom")
Target sourceToTarget(Source s);
}
自定義映射方法或映射器
在某些情況下,可能需要手動實現 MapStruct 無法生成的從一種類型到另一種類型的特定映射。
可以在 Mapper 中定義默認實現方法,生成轉換代碼將調用相關方法:
@Mapper
public interface CarMapper {
CarMapper INSTANCE = Mappers.getMapper(CarMapper.class);
@Mapping(source = "numberOfSeats", target = "seatCount")
@Mapping(source = "length", target = "lengthType")
CarDTO CarToCarDTO(Car car);
default String getLengthType(int length) {
if (length > 5) {
return "large";
} else {
return "small";
}
}
}
也可以定義其他映射器,如下案例 Car 中 Date 需要轉換成 DTO 中的 String:
public class DateMapper {
public String asString(Date date) {
return date != null ? new SimpleDateFormat( "yyyy-MM-dd" ).format( date ) : null;
}
public Date asDate(String date) {
try {
return date != null ? new SimpleDateFormat( "yyyy-MM-dd" ).parse( date ) : null;
} catch ( ParseException e ) {
throw new RuntimeException( e );
}
}
}
@Mapper(uses = DateMapper.class)
public interface CarMapper {
CarMapper INSTANCE = Mappers.getMapper(CarMapper.class);
@Mapping(source = "numberOfSeats", target = "seatCount")
CarDTO CarToCarDTO(Car car);
}
編譯生成的代碼:
若遇到多個類似的方法調用時會出現模棱兩可,需使用 @qualifiedBy 指定:
@Mapper
public interface CarMapper {
CarMapper INSTANCE = Mappers.getMapper(CarMapper.class);
@Mapping(source = "numberOfSeats", target = "seatCount")
@Mapping(source = "length", target = "lengthType", qualifiedByName = "newStandard")
CarDTO CarToCarDTO(Car car);
@Named("oldStandard")
default String getLengthType(int length) {
if (length > 5) {
return "large";
} else {
return "small";
}
}
@Named("newStandard")
default String getLengthType2(int length) {
if (length > 7) {
return "large";
} else {
return "small";
}
}
}
表達式自定義映射
通過表達式,可以包含來自多種語言的結構。
目前僅支持 Java 作爲語言。例如,此功能可用於調用構造函數,整個源對象都可以在表達式中使用。應注意僅插入有效的 Java 代碼:MapStruct 不會在生成時驗證表達式,但在編譯期間生成的類中會顯示錯誤。
@Data
@AllArgsConstructor
public class Driver {
private String name;
private int age;
}
@Mapper
public interface CarMapper {
CarMapper INSTANCE = Mappers.getMapper(CarMapper.class);
@Mapping(source = "car.numberOfSeats", target = "seatCount")
@Mapping(target = "driver", expression = "java( new com.alibaba.my.mapstruct.example4.beans.Driver(person.getName(), person.getAge()))")
CarDTO CarToCarDTO(Car car, Person person);
}
默認表達式是默認值和表達式的組合:
@Mapper( imports = UUID.class )
public interface SourceTargetMapper {
SourceTargetMapper INSTANCE = Mappers.getMapper( SourceTargetMapper.class );
@Mapping(target="id", source="sourceId", defaultExpression = "java( UUID.randomUUID().toString() )")
Target sourceToTarget(Source s);
}
裝飾器自定義映射
在某些情況下,可能需要自定義生成的映射方法,例如在目標對象中設置無法由生成的方法實現設置的附加屬性。
實現起來也很簡單,用裝飾器模式實現映射器的一個抽象類,在映射器 Mapper 中添加註解 @DecoratedWith 指向裝飾器類,使用時還是正常調用。
@Mapper
@DecoratedWith(CarMapperDecorator.class)
public interface CarMapper {
CarMapper INSTANCE = Mappers.getMapper(CarMapper.class);
@Mapping(source = "numberOfSeats", target = "seatCount")
CarDTO CarToCarDTO(Car car);
}
public abstract class CarMapperDecorator implements CarMapper {
private final CarMapper delegate;
protected CarMapperDecorator(CarMapper delegate) {
this.delegate = delegate;
}
@Override
public CarDTO CarToCarDTO(Car car) {
CarDTO dto = delegate.CarToCarDTO(car);
dto.setMakeInfo(car.getMake() + " " + new SimpleDateFormat( "yyyy-MM-dd" ).format(car.getCreateDate()));
return dto;
}
}
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/DJIO5Y9LjnQ6mHJLzmO2Ng