前言
在我們日常開發的分層結構的應用程式中,為了各層之間互相解耦,一般都會定義不同的物件用來在不同層之間傳遞資料,因此,就有了各種 XXXDTO、XXXVO、XXXBO 等基於資料庫物件派生出來的物件,當在不同層之間傳輸資料時,不可避免地經常需要將這些物件進行相互轉換。
此時一般處理兩種處理方式:① 直接使用 Setter 和 Getter 方法轉換、② 使用一些工具類進行轉換(e.g. BeanUtil.copyProperties)。第一種方式如果物件屬性比較多時,需要寫很多的 Getter/Setter 程式碼。第二種方式看起來雖然比第一種方式要簡單很多,但是因為其使用了反射,效能不太好,而且在使用中也有很多陷阱。而今天要介紹的主角 MapStruct 在不影響效能的情況下,同時解決了這兩種方式存在的缺點。
MapStruct 是什麼
MapStruct 是一個程式碼生成器,它基於約定優於配置方法極大地簡化了 Java bean 型別之間對映的實現。自動生成的對映轉換程式碼只使用簡單的方法呼叫,因此速度快、型別安全而且易於理解閱讀,原始碼倉庫 Github 地址 MapStruct。總的來說,有如下三個特點:
- 基於註解
- 在編譯期自動生成對映轉換程式碼
- 型別安全、高效能、無依賴性
MapStruct 使用步驟
MapStruct 的使用比較簡單,只需如下三步即可。
① 引入依賴(這裡以 Gradle 方式為例,其它同理)
gradle
dependencies {
implementation 'org.mapstruct:mapstruct:1.4.2.Final'
annotationProcessor 'org.mapstruct:mapstruct-processor:1.4.2.Final'
}
② 建立相關轉換物件
/**
* @author mghio
* @since 2021-08-08
*/
@Data
public class Doctor {
private Integer id;
private String name;
}
/**
* @author mghio
* @since 2021-08-08
*/
@Data
public class DoctorDTO {
private Integer id;
private String name;
}
③ 建立轉換器類(Mapper)
需要注意的是,轉換器不一定都要使用 Mapper 作為結尾,只是官方示例推薦以 XXXMapper 格式命名轉換器名稱,這裡舉例的是最簡單的對映情況(欄位名稱和型別都完全匹配),只需要在轉換器類上新增 @Mapper 註解即可,轉換器程式碼如下所示:
/**
* @author mghio
* @since 2021-08-08
*/
@Mapper
public interface DoctorMapper {
DoctorMapper INSTANCE = Mappers.getMapper(DoctorMapper.class);
DoctorDTO toDTO(Doctor doctor);
}
通過下面這個簡單的測試來校驗轉換結果是否正確,測試程式碼如下:
/**
* @author mghio
* @since 2021-08-08
*/
public class DoctorTest {
@Test
public void testToDTO() {
Integer doctorId = 9527;
String doctorName = "mghio";
Doctor doctor = new Doctor();
doctor.setId(doctorId);
doctor.setName(doctorName);
DoctorDTO doctorDTO = DoctorMapper.INSTANCE.toDTO(doctor);
assertEquals(doctorId, doctorDTO.getId());
assertEquals(doctorName, doctorDTO.getName());
}
}
測試結果正常通過,說明使用 DoctorMapper 轉換器達到我們的預期結果。
MapStruct 實現淺析
在以上示例中,使用 MapStruct 通過簡單的三步就實現了 Doctor 到 DoctorDTO 的轉換,那麼,MapStruct 是如何做到的呢?其實通過我們定義的轉換器可以發現,轉換器是介面型別的,而我們知道在 Java 中,介面是無法提供功能的,只是定義規範,具體幹活的還是它的實現類。
因此我們可以大膽猜想,MapStruct 肯定給我們定義的轉換器介面(DoctorMapper)生成了實現類,而通過 Mappers.getMapper(DoctorMapper.class) 獲取到的轉換器實際上是獲取到了轉化器介面的實現類。下面通過在測試類中 debug 來驗證一下:
通過 debug 可以看出,DoctorMapper.INSTANCE 獲取到的是介面的實現類 DoctorMapperImpl。這個轉換器介面實現類是在編譯期自動生成的,Gradle 專案是在 build/generated/sources/anotationProcessor/Java 下(Maven 專案在 target/generated-sources/annotations 目錄下),生成以上示例轉換器介面的實現類原始碼如下:
可以發現,自動生成的程式碼和我們平時手寫的差不多,簡單易懂,程式碼完全在編譯期間生成,沒有執行時依賴。和使用反射的實現方式相比還有一個有點就是,出錯時很容易去 debug 實現原始碼來定位,而反射相對來說定位問題就要困難得多了。
常見使用場景介紹
① 物件屬性名稱和型別完全相同
從上文的示例可以看出,當屬性名稱和型別完全一致時,我們只需要定義一個轉換器介面並新增 @Mapper 註解即可,然後 MapStruct 會自動生成實現類完成轉換。示例程式碼如下:
/**
* @author mghio
* @since 2021-08-08
*/
@Data
public class Source {
private Integer id;
private String name;
}
/**
* @author mghio
* @since 2021-08-08
*/
@Data
public class Target {
private Integer id;
private String name;
}
/**
* @author mghio
* @since 2021-08-08
*/
@Mapper
public interface SourceMapper {
SourceMapper INSTANCE = Mappers.getMapper(SourceMapper.class);
Target toTarget(Source source);
}
② 物件屬性型別相同但是名稱不同
當物件屬性型別相同但是屬性名稱不一樣時,通過 @Mapping 註解來手動指定轉換。示例程式碼如下:
/**
* @author mghio
* @since 2021-08-08
*/
@Data
public class Source {
private Integer id;
private String sourceName;
}
/**
* @author mghio
* @since 2021-08-08
*/
@Data
public class Target {
private Integer id;
private String targetName;
}
/**
* @author mghio
* @since 2021-08-08
*/
@Mapper
public interface SourceMapper {
SourceMapper INSTANCE = Mappers.getMapper(SourceMapper.class);
@Mapping(source = "sourceName", target = "targetName")
Target toTarget(Source source);
}
③ 在 Mapper 中使用自定義轉換方法
有時候,對於某些型別(比如:一個類的屬性是自定義的類),無法以自動生成程式碼的形式進行處理。此時我們需要自定義型別轉換的方法,在 JDK 7 之前的版本,就需要使用抽象類來定義轉換 Mapper 了,在 JDK 8 以上的版本可以使用介面的預設方法來自定義型別轉換的方法。示例程式碼如下:
/**
* @author mghio
* @since 2021-08-08
*/
@Data
public class Source {
private Integer id;
private String sourceName;
private InnerSource innerSource;
}
/**
* @author mghio
* @since 2021-08-08
*/
@Data
public class InnerSource {
private Integer deleted;
private String name;
}
/**
* @author mghio
* @since 2021-08-08
*/
@Data
public class Target {
private Integer id;
private String targetName;
private InnerTarget innerTarget;
}
/**
* @author mghio
* @since 2021-08-08
*/
@Data
public class InnerTarget {
private Boolean isDeleted;
private String name;
}
/**
* @author mghio
* @since 2021-08-08
*/
@Mapper
public interface SourceMapper {
SourceMapper INSTANCE = Mappers.getMapper(SourceMapper.class);
@Mapping(source = "sourceName", target = "targetName")
Target toTarget(Source source);
default InnerTarget innerTarget2InnerSource(InnerSource innerSource) {
if (innerSource == null) {
return null;
}
InnerTarget innerTarget = new InnerTarget();
innerTarget.setIsDeleted(innerSource.getDeleted() == 1);
innerTarget.setName(innerSource.getName());
return innerTarget;
}
}
④ 多個物件轉換成一個物件返回
在一些實際業務編碼的過程中,不可避免地需要將多個物件轉化為一個物件的場景,MapStruct 也能很好的支援,對於這種最終返回資訊來源於多個類,我們可以通過配置來實現多對一的轉換。示例程式碼如下:
/**
* @author mghio
* @since 2021-08-08
*/
@Data
public class Doctor {
private Integer id;
private String name;
}
/**
* @author mghio
* @since 2021-08-09
*/
@Data
public class Address {
private String street;
private Integer zipCode;
}
/**
* @author mghio
* @since 2021-08-09
*/
@Mapper
public interface AddressMapper {
AddressMapper INSTANCE = Mappers.getMapper(AddressMapper.class);
@Mapping(source = "doctor.id", target = "personId")
@Mapping(source = "address.street", target = "streetDesc")
DeliveryAddressDTO doctorAndAddress2DeliveryAddressDTO(Doctor doctor, Address address);
}
從這個示例中的轉換器(AddressMapper)可以看出,當屬性名稱和型別完全匹配時同樣可以自動轉換,但是當來源物件有多個屬性名稱及型別完全和目標物件相同時,還是需要手動配置指定的,因為此時 MapStruct 也無法準確判斷應該使用哪個屬性轉換。
獲取轉換器(Mapper)的幾種方式
獲取轉換器的方式根據 @Mapper 註解的 componentModel 屬性不同而不同,支援以下四種不同的取值:
- default 預設方式,預設方式,使用工廠方式(Mappers.getMapper(Class) )來獲取
- cdi 此時生成的對映器是一個應用程式範圍的 CDI bean,使用 @Inject 註解來獲取
- spring Spring 的方式,可以通過 @Autowired 註解來獲取,在 Spring 框架中推薦使用此方式
- jsr330 生成的對映器用 @javax.inject.Named 和 @Singleton 註解,通過 @Inject 來獲取
① 通過工廠方式獲取
上文的示例中都是通過工廠方式獲取的,也就是使用 MapStruct 提供的 Mappers.getMapper(Class clazz) 方法來獲取指定型別的 Mapper。然後在呼叫的時候就不需要反覆建立物件了,方法的最終實現是通過我們定義介面的類載入器載入 MapStruct 生成的實現類(類名稱規則為:介面名稱 + Impl),然後呼叫該類的無參構造器建立物件。核心原始碼如下所示:
② 使用依賴注入方式獲取
對於依賴注入(dependency injection),使用 Spring 框架開發的朋友們應該很熟悉了,工作中經常使用。MapStruct 也支援依賴注入的使用方式,並且官方也推薦使用依賴注入的方式獲取。使用 Spring 依賴注入的方式只需要指定 @Mapper 註解的 componentModel = "spring" 即可,示例程式碼如下:
/**
* @author mghio
* @since 2021-08-08
*/
@Mapper(componentModel = "spring")
public interface SourceMapper {
SourceMapper INSTANCE = Mappers.getMapper(SourceMapper.class);
@Mapping(source = "sourceName", target = "targetName")
Target toTarget(Source source);
}
我們可以使用 @Autowired 獲取的原因是 SourceMapper 介面的實現類已經被註冊為容器中一個 Bean 了,通過如下生成的介面實現類的程式碼也可以看到,在類上自動加上了 @Component 註解。
最後還有兩個注意事項:① 當兩個轉換物件的屬性不一致時(比如 DoctorDTO 中不存在 Doctor 物件中的某個欄位),編譯時會出現警告提示。可以在@Mapping 註解中配置 ignore = true,或者當不一致字段比較多時,可以直接設定 @Mapper 註解的 unmappedTargetPolicy 屬性或unmappedSourcePolicy 屬性設定為 ReportingPolicy.IGNORE。② 如果你專案中也使用了 Lombok,需要注意一下 Lombok 的版本至少是 1.18.10 或者以上才行,否則會出現編譯失敗的情況。剛開始用的時候我也踩到這個坑了。。。
總結
本文介紹了物件轉換工具 Mapstruct 庫,以安全優雅的方式來減少我們的轉換樣板程式碼。從文中的示例中可以看出,Mapstruct 提供了大量的功能和配置,使我們能夠以簡單快捷的方式建立從簡單到複雜的對映器。文中所介紹到的只是 Mapstruct 庫的冰山一角,還有很多強大的功能文中沒有提到,感興趣的朋友可以自行檢視 官方使用指南。