MapStruct 解了物件對映的毒

JaJian發表於2020-11-09

前言

MVC模式是目前主流專案的標準開發模式,這種模式下框架的分層結構清晰,主要分為Controller,Service,Dao。分層的結構下,各層之間的資料傳輸要求就會存在差異,我們不能用一個物件來貫穿3層,這樣不符合開發規範且不夠靈活。

我們常常會遇到層級之間欄位格式需求不一致的情況,例如資料庫中某個欄位是datetime日期格式,這個時間戳在資料庫中的儲存值為2020-11-06 23:59:59.999999,但是傳遞給前端的時候要求介面返回yyyy-MM-dd的格式,或者有些資料在資料庫中是逗號拼接的String型別,但是前端需要的是切割後的List型別等等。

所以我們提出了層級間的物件模型,就是我們常見的VO,DTO,DO,PO等等。這種區分層級物件模型的方式雖然清晰化了我們各層級間的物件傳遞,但是物件模型間的相互轉換和值拷貝確是讓人感覺很麻煩,拷貝來拷貝去,來來回回,過程重複乏味,編寫此類對映程式碼是一項繁瑣且容易出錯的任務。

最簡單粗糙的拷貝方法就是不斷的new物件然後物件間的 setter 和 getter,這種方式應對欄位屬性少的還可以,如果屬性欄位很多那麼大段的set,get的程式碼就顯得很不雅美。因此需要藉助物件拷貝工具,目前市場上的也蠻多的像BeanCopy,Dozer等等,但是這些我感覺都不夠好,今天我推薦一個實體對映工具就是 MapStruct

介紹

MapStruct的官網地址是 https://mapstruct.org/MapStruct,是一個快速安全的bean 對映程式碼生成器,只需要通過簡單的註解就可以實現物件間的屬性轉換,是一款 Apache LICENSE 2.0 授權的開源產品,Github的原始碼地址是 https://github.com/mapstruct。

通過官網的三連問(What,Why,How)我們可以大概的瞭解到 MapStruct 的作用,它的優勢以及它是如何實現的。

從上面的三連問中我們可以得到如下資訊:

  • 基於約定優於配置的方法
    MapStruct 極大地簡化了 Java bean 型別之間的對映的實現,通過簡單的註解就可以工作。生成的對映程式碼使用普通的方法呼叫而不是反射,因此速度快,型別安全且易於理解。

  • 在編譯時生成 Bean 對映
    與其他對映框架相比,MapStruct 在編譯時生成 Bean 對映,這樣可以確保高效能,而且開發人員可以快速的得到反饋和徹底的錯誤檢查。

  • 一個註釋處理器
    MapStruct 是一個註釋處理器,已插入 Java 編譯器,可用於命令列構建(Maven,Gradle等),也可用於您首選的IDE中(IDEA,Eclipse等)。

程式碼編寫

MapStruct 需要 Java 1.8或更高版本。對於Maven-based 的專案,在pom 檔案中新增如下依賴即可

<!-- 指定版本-->
<properties>
    <org.mapstruct.version>1.4.1.Final</org.mapstruct.version>
</properties>
<!-- 新增依賴 -->
<dependencies>
   <dependency>
	<groupId>org.mapstruct</groupId>
	<artifactId>mapstruct</artifactId>
	<version>${org.mapstruct.version}</version>
   </dependency>
   <dependency>
	<groupId>org.mapstruct</groupId>
	<artifactId>mapstruct-processor</artifactId>
	<version>${org.mapstruct.version}</version>
   </dependency>
</dependencies>

基本的依賴引入後就可以編寫程式碼了,簡單的定義一個對映類,為了與 Mybatis中的 mapper 介面區分,我們可以取名為 xxObjectConverter

例如汽車物件的對映類名為 CarObjectConverter,我們有兩個物件模型 DO 和 DTO,它們內部的屬性欄位如下:

資料庫對應的持久化物件模型 CarDo

public class Car {
    @ApiModelProperty(value = "主鍵id")
    private Long id;
	
    @ApiModelProperty(value = "製造商")
    private String manufacturers;
	
    @ApiModelProperty(value = "銷售渠道")
    private String saleChannel;

    @ApiModelProperty(value = "生產日期")
    private Date productionDate;
    ...
}

層級間傳輸的物件模型 CarDto

public class CarDto {
    @ApiModelProperty(value = "主鍵id")
    private Long id;
	
    @ApiModelProperty(value = "製造商")
    private String maker;
	
    @ApiModelProperty(value = "銷售渠道")
    private List<Integer> saleChannel;

    @ApiModelProperty(value = "生產日期")
    private Date productionDate;
    ...
}

再編寫具體的 MapStruct 物件對映器

@Mapper
public interface CarObjectConverter{

    CarObjectConverter INSTANCE = Mappers.getMapper(CarObjectConverter.class);

    @Mapping(target = "maker", source = "manufacturers")
    CarDto carToCarDto(Car car);

}

對於欄位名相同的可以不用額外的指定對映規則,但是欄位名不同的屬性則需要指出欄位的對映規則,如上我們持久層 DO 的製造商的欄位名是manufacturers 而層級間傳輸的DTO模型中則是maker,我們就需要在對映方法上通過@Mapping註解指出對映規則,我個人習慣是喜歡將target寫在前面,source寫在後面,這樣是與對映物件的位置保持一致,差異欄位多的時候方便對比且不易混淆。

開發過程中還會經常遇到一些日期格式的轉換,就如開篇時說的那種,這時我們也可以指定日期的對映規則

@Mapper
public interface CarObjectConverter{

    CarObjectConverter INSTANCE = Mappers.getMapper(CarObjectConverter.class);

    @Mapping(target = "maker", source = "manufacturers")
    @Mapping(target = "productionDate", dateFormat = "yyyy-MM-dd", source = "productionDate")
    CarDto carToCarDto(Car car);

}

這些都還是一些簡單的欄位的對映,但有時候我們兩個物件模型間的欄位型別不一致,如上汽車的銷售渠道欄位saleChannel,這個在資料庫中是字串逗號拼接的值1,2,3,而我們傳遞出去的需要是 List 的 Integer 型別,這種複雜的如何對映呢?

也是有方法的,我們先編寫一個將字串逗號分隔然後轉成 List 的工具方法,如下

public class CollectionUtils {

    public static List<Integer> list2String(String str) {
        if (StringUtils.isNoneBlank(str)) {
            return Arrays.asList(str.split(",")).stream().map(s -> Integer.valueOf(s.trim())).collect(Collectors.toList());
        }
        return null;
    }
}

然後在對映Mapping中使用表示式即可

@Mapper
public interface CarObjectConverter {

    CarObjectConverter INSTANCE = Mappers.getMapper(CarObjectConverter.class);

    @Mapping(target = "maker", source = "manufacturers")
    @Mapping(target = "productionDate", dateFormat = "yyyy-MM-dd", source = "productionDate")
    @Mapping(target = "saleChannel", expression = "java(com.jiajian.demo.utils.CollectionUtils.list2String(car.getSaleChannel()))")
    CarDto carToCarDto(Car car);

}

這樣就完成了所有欄位的對映工作,我們在需要物件模型轉換的地方按照如下方式呼叫即可

CarDto carDto = CarObjectConverter.INSTANCE.carToCarDto(car);

這種是單體物件之間的 Copy 很多時候我們需要 List 物件模型間的轉換,只需要再寫一個方法carToCarDtos即可

@Mapper
public interface CarObjectConverter{

    CarObjectConverter INSTANCE = Mappers.getMapper(CarObjectConverter.class);

    @Mapping(target = "maker", source = "manufacturers")
    @Mapping(target = "productionDate", dateFormat = "yyyy-MM-dd", source = "productionDate")
    @Mapping(target ="saleChannel", expression = "java(com.jiajian.demo.utils.CollectionUtils.list2String(car.getSaleChannel()))")
    CarDto carToCarDto(Car car);

    List<CarDto> carToCarDtos(List<Car> carList);

}

探個究竟

會不會好奇這是怎麼實現的,我們只是建立了一個介面然後在介面方法上加一個註解並在註解裡面指定欄位的對映規則就可以實現物件屬性間的拷貝,這是怎麼做到的呢?

我們這裡通過 MapStruct 建立的只是一個介面,要實現具體的功能介面必有實現。

MapStruct 會在我們程式碼編譯的時候為我們建立一個實現類,而這個實現類裡面通過欄位的setter, getter方法來實現欄位的賦值,從而實現物件的對映。

這裡需要注意一點:如果你修改了任一對映物件,記得需要先執行mvn clean再啟動專案,否則除錯的時候會報錯。

結尾

MapStrut 的功能遠不至於上面介紹的這些,我只是挑出幾個常用的語法進行示例講解,如果讀者感興趣想深入的瞭解更多可以參考官方的參考文件,Reference Guide

遇見 MapStruct 後我就開始在專案中拋棄掉了原來的那些 BeanCopyUtils 的工具,相對而言 MapStruct 確實更簡潔且易使用而且定製功能也很強。

從編譯檔案可以看出 MapStruct 是通過setter,getter來實現屬性值的拷貝,然後這種方式不是最簡單又最安全高效的嗎?只是 MapStruct 更好的幫助我們實現了,避免了專案中冗餘的重複程式碼,大道至簡。

相關文章