常用開發庫 - 告別BeanUtils拷貝,MapStruct工具庫最全詳解

pdai發表於2021-03-16

常用開發庫 - MapStruct工具庫詳解

MapStruct是一款非常實用Java工具,主要用於解決物件之間的拷貝問題,比如PO/DTO/VO/QueryParam之間的轉換問題。區別於BeanUtils這種通過反射,它通過編譯器編譯生成常規方法,將可以很大程度上提升效率。@pdai

為什麼會引入MapStruct這類工具

首先看下這類工具出現的背景。@pdai

JavaBean 問題引入

在開發的時候經常會有業務程式碼之間有很多的 JavaBean 之間的相互轉化,比如PO/DTO/VO/QueryParam之間的轉換問題。之前我們的做法是:

  • 拷貝技術

    • org.apache.commons.beanutils.PropertyUtils.copyProperties
    • org.apache.commons.beanutils.BeanUtils.copyProperties
    • org.springframework.beans.BeanUtils.copyProperties
    • net.sf.cglib.beans.BeanCopier
  • 純get/set

    • 輔助IDE外掛拷貝物件時可以自動set所有方法欄位 (這種方式可能有些開發人員不清楚)
    • 不僅看上去冗餘新增新的欄位時依然需要手動
    • 開發效率比較低

MapStruct 帶來的改變

MapSturct 是一個生成型別安全, 高效能且無依賴的 JavaBean 對映程式碼的註解處理器(annotation processor)。

工具可以幫我們實現 JavaBean 之間的轉換, 通過註解的方式。

同時, 作為一個工具類,相比於手寫, 其應該具有便捷, 不容易出錯的特點。

MapStruct入門例子

這裡展示最基本的PO轉VO的例子,使用的是IDEA + Lombok + MapStruct

Pom.xml

注意:基於當前IDEA設定並不需要mapstruct-processor的依賴

一般來說會載入兩個包:

  • org.mapstruct:mapstruct: 包含Mapstruct核心,比如註解等;如果是mapstruct-jdk8會引入一些jdk8的語言特性;
  • org.mapstruct:mapstruct-processor: 處理註解用的,可以根據註解自動生成mapstruct的mapperImpl類

如下示例基於IDEA實現,可以在build階段的annotationProcessorPaths中配置mapstruct-processor的path。

<packaging>jar</packaging>
<properties>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <maven.compiler.source>1.8</maven.compiler.source>
    <maven.compiler.target>1.8</maven.compiler.target>
    <org.mapstruct.version>1.4.0.Beta3</org.mapstruct.version>
    <org.projectlombok.version>1.18.12</org.projectlombok.version>
</properties>
<dependencies>
    <dependency>
        <groupId>org.mapstruct</groupId>
        <artifactId>mapstruct</artifactId>
        <version>${org.mapstruct.version}</version>
    </dependency>

    <!-- lombok dependencies should not end up on classpath -->
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <version>${org.projectlombok.version}</version>
        <scope>provided</scope>
    </dependency>

    <!-- fastjson -->
    <dependency>
        <groupId>com.alibaba</groupId>
        <artifactId>fastjson</artifactId>
        <version>1.2.71</version>
    </dependency>
    <dependency>
        <groupId>junit</groupId>
        <artifactId>junit</artifactId>
        <version>4.12</version>
        <scope>test</scope>
    </dependency>
</dependencies>

<build>
    <pluginManagement>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.8.1</version>
                <configuration>
                    <source>1.8</source>
                    <target>1.8</target>
                    <!-- See https://maven.apache.org/plugins/maven-compiler-plugin/compile-mojo.html -->
                    <!-- Classpath elements to supply as annotation processor path. If specified, the compiler   -->
                    <!-- will detect annotation processors only in those classpath elements. If omitted, the     -->
                    <!-- default classpath is used to detect annotation processors. The detection itself depends -->
                    <!-- on the configuration of annotationProcessors.                                           -->
                    <!--                                                                                         -->
                    <!-- According to this documentation, the provided dependency processor is not considered!   -->
                    <annotationProcessorPaths>
                        <path>
                            <groupId>org.mapstruct</groupId>
                            <artifactId>mapstruct-processor</artifactId>
                            <version>${org.mapstruct.version}</version>
                        </path>
                        <path>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                            <version>${org.projectlombok.version}</version>
                        </path>
                    </annotationProcessorPaths>
                </configuration>
            </plugin>
        </plugins>
    </pluginManagement>
</build>

Entity

這裡面假設基於一些業務需求採用的是MySQL,且將一些擴充套件的資料放在了config欄位中,並以JSON轉String儲存。

@Data
@Accessors(chain = true)
public class User {
    private Long id;
    private String username;
    private String password; // 密碼
    private Integer sex;  // 性別
    private LocalDate birthday; // 生日
    private LocalDateTime createTime; // 建立時間
    private String config; // 其他擴充套件資訊,以JSON格式儲存
}

VO 類

最後真正展示的應該:

  • 不顯示密碼;
  • 將日期轉換;
  • config要轉成物件的list;
@Data
@Accessors(chain = true)
public class UserVo {
    private Long id;
    private String username;
    private String password;
    private Integer gender;
    private LocalDate birthday;
    private String createTime;
    private List<UserConfig> config;
    @Data
    public static class UserConfig {
        private String field1;
        private Integer field2;
    }
}

mapper(或者converter)

注意:

  • 這裡沒用@Mappings,且看最後編譯出的類檔案,會自動加
  • 密碼需要ignore
@Mapper
public interface UserConverter {
    UserConverter INSTANCE = Mappers.getMapper(UserConverter.class);

    @Mapping(target = "gender", source = "sex")
    @Mapping(target = "createTime", dateFormat = "yyyy-MM-dd HH:mm:ss")
    UserVo do2vo(User var1);

    @Mapping(target = "sex", source = "gender")
    @Mapping(target = "password", ignore = true)
    @Mapping(target = "createTime", dateFormat = "yyyy-MM-dd HH:mm:ss")
    User vo2Do(UserVo var1);

    List<UserVo> do2voList(List<User> userList);

    default List<UserVo.UserConfig> strConfigToListUserConfig(String config) {
        return JSON.parseArray(config, UserVo.UserConfig.class);
    }

    default String listUserConfigToStrConfig(List<UserVo.UserConfig> list) {
        return JSON.toJSONString(list);
    }
}

測試類

@Test
public void do2VoTest() {
    User user = new User()
            .setId(1L)
            .setUsername("zhangsan")
            .setSex(1)
            .setPassword("abc123")
            .setCreateTime(LocalDateTime.now())
            .setBirthday(LocalDate.of(1999, 9, 27))
            .setConfig("[{\"field1\":\"Test Field1\",\"field2\":500}]");

    UserVo userVo = UserConverter.INSTANCE.do2vo(user);

    // asset
    assertNotNull(userVo);
    assertEquals(userVo.getId(), user.getId());

    // print
    System.out.println(user);
    System.out.println(userVo);
//        User(id=1, username=zhangsan, password=abc123, sex=1, birthday=1999-09-27, createTime=2020-08-17T14:54:01.528, config=[{"field1":"Test Field1","field2":500}])
//        UserVo(id=1, username=zhangsan, password=abc123, gender=1, birthday=1999-09-27, createTime=2020-08-17 14:54:01, config=[UserVo.UserConfig(field1=Test Field1, field2=500)])
}

@Test
public void vo2DoTest() {
    UserVo.UserConfig userConfig = new UserVo.UserConfig();
    userConfig.setField1("Test Field1");
    userConfig.setField2(500);

    UserVo userVo = new UserVo()
            .setId(1L)
            .setUsername("zhangsan")
            .setGender(2)
            .setCreateTime("2020-01-18 15:32:54")
            .setBirthday(LocalDate.of(1999, 9, 27))
            .setConfig(Collections.singletonList(userConfig));
    User user = UserConverter.INSTANCE.vo2Do(userVo);

    // asset
    assertNotNull(userVo);
    assertEquals(userVo.getId(), user.getId());

    // print
    System.out.println(user);
    System.out.println(userVo);
}

MapStrcut實現的原理?

MapStruct 來生成的程式碼, 其類似於人手寫。 速度上可以得到保證。

前面例子中生成的程式碼可以在編譯後看到, 在 target/generated-sources/annotations 裡可以看到; 同時真正在程式碼包執行的可以在target/classes包中看到。

編譯後的類

  • 編譯後的class位置

  • 編譯後的內容
public class UserConverterImpl implements UserConverter {

    @Override
    public UserVo do2vo(User var1) {
        if ( var1 == null ) {
            return null;
        }

        UserVo userVo = new UserVo();

        userVo.setGender( var1.getSex() );
        if ( var1.getCreateTime() != null ) {
            userVo.setCreateTime( DateTimeFormatter.ofPattern( "yyyy-MM-dd HH:mm:ss" ).format( var1.getCreateTime() ) );
        }
        userVo.setId( var1.getId() );
        userVo.setUsername( var1.getUsername() );
        userVo.setPassword( var1.getPassword() );
        userVo.setBirthday( var1.getBirthday() );
        userVo.setConfig( strConfigToListUserConfig( var1.getConfig() ) );

        return userVo;
    }

    @Override
    public User vo2Do(UserVo var1) {
        if ( var1 == null ) {
            return null;
        }

        User user = new User();

        user.setSex( var1.getGender() );
        if ( var1.getCreateTime() != null ) {
            user.setCreateTime( LocalDateTime.parse( var1.getCreateTime(), DateTimeFormatter.ofPattern( "yyyy-MM-dd HH:mm:ss" ) ) );
        }
        user.setId( var1.getId() );
        user.setUsername( var1.getUsername() );
        user.setBirthday( var1.getBirthday() );
        user.setConfig( listUserConfigToStrConfig( var1.getConfig() ) );

        return user;
    }

    @Override
    public List<UserVo> do2voList(List<User> userList) {
        if ( userList == null ) {
            return null;
        }

        List<UserVo> list = new ArrayList<UserVo>( userList.size() );
        for ( User user : userList ) {
            list.add( do2vo( user ) );
        }

        return list;
    }
}

這裡面用了什麼機制?

這和Lombok實現機制一致。

核心之處就是對於註解的解析上。JDK5引入了註解的同時,也提供了兩種解析方式。

  • 執行時解析

執行時能夠解析的註解,必須將@Retention設定為RUNTIME, 比如@Retention(RetentionPolicy.RUNTIME),這樣就可以通過反射拿到該註解。java.lang,reflect反射包中提供了一個介面AnnotatedElement,該介面定義了獲取註解資訊的幾個方法,Class、Constructor、Field、Method、Package等都實現了該介面,對反射熟悉的朋友應該都會很熟悉這種解析方式。

  • 編譯時解析

編譯時解析有兩種機制,分別簡單描述下:

1)Annotation Processing Tool

apt自JDK5產生,JDK7已標記為過期,不推薦使用,JDK8中已徹底刪除,自JDK6開始,可以使用Pluggable Annotation Processing API來替換它,apt被替換主要有2點原因:

  • api都在com.sun.mirror非標準包下
  • 沒有整合到javac中,需要額外執行

2)Pluggable Annotation Processing API

JSR 269: Pluggable Annotation Processing API自JDK6加入,作為apt的替代方案,它解決了apt的兩個問題,javac在執行的時候會呼叫實現了該API的程式,這樣我們就可以對編譯器做一些增強,這時javac執行的過程如下:

Lombok本質上就是一個實現了“JSR 269 API”的程式。在使用javac的過程中,它產生作用的具體流程如下:

  • javac對原始碼進行分析,生成了一棵抽象語法樹(AST)
  • 執行過程中呼叫實現了“JSR 269 API”的Lombok程式
  • 此時Lombok就對第一步驟得到的AST進行處理,找到@Data註解所在類對應的語法樹(AST),然後修改該語法樹(AST),增加getter和setter方法定義的相應樹節點
  • javac使用修改後的抽象語法樹(AST)生成位元組碼檔案,即給class增加新的節點(程式碼塊)

從上面的Lombok執行的流程圖中可以看出,在Javac 解析成AST抽象語法樹之後, Lombok 根據自己編寫的註解處理器,動態地修改 AST,增加新的節點(即Lombok自定義註解所需要生成的程式碼),最終通過分析生成JVM可執行的位元組碼Class檔案。使用Annotation Processing自定義註解是在編譯階段進行修改,而JDK的反射技術是在執行時動態修改,兩者相比,反射雖然更加靈活一些但是帶來的效能損耗更加大。

MapStruct更多例子

:::tip
一般特性和例子最好直接參考官網例子, 這裡會差異化的體現一些常見的用法。@pdai
:::

自定義屬性的轉化

注意在不同的JDK版本中做法不太一樣。@pdai

  • JDK 8以上版本

一般常用的型別欄位轉換 MapStruct都能替我們完成,但是有一些是我們自定義的物件型別,MapStruct就不能進行欄位轉換,這就需要我們編寫對應的型別轉換方法,筆者使用的是JDK8,支援介面中的預設方法,可以直接在轉換器中新增自定義型別轉換方法。

上述例子中User物件的config屬性是一個JSON字串,UserVo物件中是List型別的,這需要實現JSON字串與物件的互轉。

default List<UserConfig> strConfigToListUserConfig(String config) {
  return JSON.parseArray(config, UserConfig.class);
}

default String listUserConfigToStrConfig(List<UserConfig> list) {
  return JSON.toJSONString(list);
}
  • JDK 8 以下版本

如果是 JDK8以下的,不支援預設方法,可以另外定義一個 轉換器,然後再當前轉換器的 @Mapper 中通過 uses = XXX.class 進行引用。

定義好方法之後,MapStruct當匹配到合適型別的欄位時,會呼叫我們自定義的轉換方法進行轉換。

轉為多個物件

比如上面例子中User可以轉為UserQueryParam, 業務功能上比如通過UserQueryParam裡面的引數進行查詢使用者的。

@Data
@Accessors(chain = true)
public class UserQueryParam {
    private Long id;
    private String username;
}

新增轉換方法

UserQueryParam vo2QueryParam(User var1);

Spring中使用MapStruct

除了UserConverter.INSTANCE這種方式還可以注入Spring容器中使用。

  • componentModel

當新增componentModel="spring"時,它會在實現類上自動新增@Component註解,這樣就能被Spring記性component scan,從而載入到springContext中,進而被@Autowird注入使用。(其它還有jsr330cdi標準,基本上使用componentModel="spring"就夠了)。

@Mapper(componentModel="spring")
public interface UserConverter {

}
  • 引入和測試
@Slf4j
@RunWith(SpringRunner.class)
@SpringBootTest
public class UserConverterTest {

  @Resource
  private UserConverter userConverter;

  // test methods

}

多個物件轉一個物件

比如上述例子中User購買了東西,需要郵寄到他的地址Address,這時需要展示UserWithAddress的資訊:

  • Address
@Data
public class Address {
    private String street;
    private Integer zipCode;
    private Integer houseNo;
    private String description;
}
  • UserWithAddressVo
@Data
public class UserWithAddressVo {

    private String username;
    private Integer sex;
    private String street;
    private Integer zipCode;
    private Integer houseNumber;
    private String description;
}
  • converter方法
@Mapping(source = "person.description", target = "description")
@Mapping(source = "address.houseNo", target = "houseNumber")
UserWithAddressVo userAndAddress2Vo(User user, Address address);

注意:在多對一轉換時, 遵循以下幾個原則

  • 當多個物件中, 有其中一個為 null, 則會直接返回 null
  • 如一對一轉換一樣, 屬性通過名字來自動匹配。 因此, 名稱和型別相同的不需要進行特殊處理
  • 當多個原物件中,有相同名字的屬性時,需要通過 @Mapping 註解來具體的指定, 以免出現歧義(不指定會報錯)。 如上面的 description

屬性也可以直接從傳入的引數來賦值。

@Mapping(source = "person.description", target = "description")
@Mapping(source = "hn", target = "houseNumber")
UserWithAddressVo userAndAddressHn2Vo(User user, Integer hn);

MapStruct再深入理解

:::tip
在瞭解基本的MapStruct使用之後,我們將從多個角度來深入理解MapStruct這個工具。@pdai
:::

IntelliJ IDEA 中對MapStruct的支援如何?

通常來說IDE對於MapStruct這類工具的支援體現在兩方面,一個是Maven的整合,另一個是編輯時的提示(Hit); 相關的支援可以參考官網。@pdai

Maven支援

  • 在IntelliJ 2018.1.1之前, 注意在早期的版本中artifactId還需要加jdk版本,比如mapstruct-jdk8
<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>
  • 在IntelliJ 2018.1.1之後是可以不新增mapstruct-processor
<properties>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    <maven.compiler.source>1.8</maven.compiler.source>
    <maven.compiler.target>1.8</maven.compiler.target>
    <org.mapstruct.version>1.4.0.Beta3</org.mapstruct.version>
</properties>
<dependencies>
    <dependency>
        <groupId>org.mapstruct</groupId>
        <artifactId>mapstruct</artifactId>
        <version>${org.mapstruct.version}</version>
    </dependency>
</dependencies>

<build>
    <pluginManagement>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.8.1</version>
                <configuration>
                    <source>1.8</source>
                    <target>1.8</target>
                    <!-- See https://maven.apache.org/plugins/maven-compiler-plugin/compile-mojo.html -->
                    <!-- Classpath elements to supply as annotation processor path. If specified, the compiler   -->
                    <!-- will detect annotation processors only in those classpath elements. If omitted, the     -->
                    <!-- default classpath is used to detect annotation processors. The detection itself depends -->
                    <!-- on the configuration of annotationProcessors.                                           -->
                    <!--                                                                                         -->
                    <!-- According to this documentation, the provided dependency processor is not considered!   -->
                    <annotationProcessorPaths>
                        <path>
                            <groupId>org.mapstruct</groupId>
                            <artifactId>mapstruct-processor</artifactId>
                            <version>${org.mapstruct.version}</version>
                        </path>
                    </annotationProcessorPaths>
                </configuration>
            </plugin>
        </plugins>
    </pluginManagement>
</build>

編輯器支援

  • 編輯器支援:自動補全

  • 編輯器支援:連線跳轉

  • 編輯器支援:查詢使用方式

Eclipse 中對MapStruct的支援如何?

必須保證你使用的Eclipse中包含m2e-apt外掛,且儘可能的升級這個外掛到最新的版本,這個外掛主要用於自動應用annotation processor相關的配置。

Maven支援

同時在pom.xml中推薦你加入如下配置, 原因請看官方給的如下注釋:

<properties>
    <!-- automatically run annotation processors within the incremental compilation -->
    <m2e.apt.activation>jdt_apt</m2e.apt.activation>
</properties>

編輯器支援

  • 自動補全

  • 快速修復

與其它屬性拷貝框架效能到底相差多少?

基於我們對它原理的理解,我們知道mapstrcut最後執行時依然是get/set,所以效能是比較高的。同時我們也知道反射優化是可以解決一部分效能問題的,那麼通過反射方式進行的屬性拷貝和get/set這種效能相差多少呢?

有哪些屬性拷貝方式呢?

綜合我們前面的文章,常用的util包中有如下屬性拷貝類:

  • org.apache.commons.beanutils.PropertyUtils.copyProperties
  • org.apache.commons.beanutils.BeanUtils.copyProperties
  • org.springframework.beans.BeanUtils.copyProperties
  • net.sf.cglib.beans.BeanCopier

使用屬性拷貝和set/get方式效能差異

  • 10000次

  • 1000次

  • 10次

  • 結論
    • property少,寫起來也不麻煩,就直接用傳統的getter/setter,效能最好
    • property多,轉換不頻繁,那就省點事吧,使用org.apache.commons.beanutils.BeanUtils.copyProperties
    • property多,轉換很頻繁,為效能考慮,使用net.sf.cglib.beans.BeanCopier.BeanCopier,效能近乎getter/setter。但是BeanCopier的建立時消耗較大,所以不要頻繁建立該實體,最好的處理方式是靜態化或者快取起來。

更多測試對比可以參考這裡

和MapStruct類似框架的對比?

我們再看下是否有其它類似的框架呢?這裡主要來源這篇文章

其它類似方案

  • Dozer

Dozer 是一個對映框架,它使用遞迴將資料從一個物件複製到另一個物件。框架不僅能夠在 bean 之間複製屬性,還能夠在不同型別之間自動轉換。

更多關於 Dozer 的內容可以在官方文件中找到: http://dozer.sourceforge.net/documentation/gettingstarted.html ,或者你也可以閱讀這篇文章:https://www.baeldung.com/dozer

  • Orika

Orika 是一個 bean 到 bean 的對映框架,它遞迴地將資料從一個物件複製到另一個物件。

Orika 的工作原理與 Dozer 相似。兩者之間的主要區別是 Orika 使用位元組碼生成。這允許以最小的開銷生成更快的對映器。

更多關於 Orika 的內容可以在官方文件中找到:https://orika-mapper.github.io/orika-docs/,或者你也可以閱讀這篇文章:https://www.baeldung.com/orika-mapping。

  • ModelMapper

ModelMapper 是一個旨在簡化物件對映的框架,它根據約定確定物件之間的對映方式。它提供了型別安全的和重構安全的 API。

更多關於 ModelMapper 的內容可以在官方文件中找到:http://modelmapper.org/

  • JMapper

JMapper 是一個對映框架,旨在提供易於使用的、高效能的 Java bean 之間的對映。該框架旨在使用註釋和關係對映應用 DRY 原則。該框架允許不同的配置方式:基於註釋、XML 或基於 api。

更多關於 JMapper 的內容可以在官方文件中找到:https://github.com/jmapper-framework/jmapper-core/wiki。

效能對比

對於效能測試,我們可以使用 Java Microbenchmark Harness,關於如何使用它的更多資訊可以在 這篇文章:https://www.baeldung.com/java-microbenchmark-harness 中找到。

測試結果(某一種)

所有的基準測試都表明,根據場景的不同,MapStruct 和 JMapper 都是不錯的選擇,儘管 MapStruct 對 SingleShotTime 給出的結果要差得多。

其它常見問題?

  • 當兩個物件屬性不一致時,比如User物件中某個欄位不存在與UserVo當中時,在編譯時會有警告提示,可以在@Mapping中配置 ignore = true,當欄位較多時,可以直接在@Mapper中設定unmappedTargetPolicy屬性或者unmappedSourcePolicy屬性為 ReportingPolicy.IGNORE即可。

  • 如果專案中也同時使用到了 Lombok,一定要注意 Lombok的版本要等於或者高於1.18.10,否則會有編譯不通過的情況發生。

參考文章

更多

更多文章請參考 Java 全棧知識體系

相關文章