使用 MapStruct 簡化 Java 物件轉換

Why?

在應用程式的溝通中
往往牽涉到不同物件的轉換
例如 DTO 與 Entity 的互轉
傳統是用 getter、setter 處理

User user = new User();
user.setName(userDto.getName());
user.setEmail(userDto.getEmail());
//...

當然進階一點可以用 builder

User.builder()
    .name(userDto.getName())
    .email(userDto.getEmail())
    //...
    .build();

若是更複雜的邏輯可能會寫成 mapper 方法或物件
但是隨著專案規模漸長
這樣的手續會很繁瑣
MapStruct 就是為此而生的工具

How?

  1. Maven
    <dependency>
        <groupId>org.mapstruct</groupId>
        <artifactId>mapstruct</artifactId>
        <version>1.5.5.Final</version> </dependency>
    <dependency>
        <groupId>org.mapstruct</groupId>
        <artifactId>mapstruct-processor</artifactId>
        <version>1.5.5.Final</version> <scope>provided</scope>
    </dependency>
    
  2. 新增 interface
    public interface UserMapper {
    
    }
    
  3. 新增 @Mapper 註解
    import org.mapstruct.Mapper;
    
    @Mapper
    public interface UserMapper {
    
    }
    
  4. 宣告 Mapper 實例
    import org.mapstruct.Mapper;
    import org.mapstruct.factory.Mappers;
    
    @Mapper
    public interface UserMapper {
        UserMapper INSTANCE = Mappers.getMapper(UserMapper.class);
    }
    
  5. 新增 method
    import org.mapstruct.Mapper;
    import org.mapstruct.factory.Mappers;
    
    import java.util.List;
    import java.util.Set;
    
    @Mapper
    public interface UserMapper {
        UserMapper INSTANCE = Mappers.getMapper(UserMapper.class);
    
        UserDto toDto(User entity);
        User toEntity(UserDto dto);
    
        List<UserDto> toDto(List<User> entities);
        List<User> toEntity(List<UserDto> dtos);
    
        Set<UserDto> toDto(Set<User> entities);
        Set<User> toEntity(Set<UserDto> dtos);
    }
    
  6. MapStruct 於編譯時期自動生成實作
    @Generated(
        value = "org.mapstruct.ap.MappingProcessor",
        date = "2024-12-20T14:30:34+0800",
        comments = "version: 1.5.5.Final, compiler: javac, environment: Java 21.0.5 (Eclipse Adoptium)"
    )
    public class UserMapperImpl implements UserMapper {
    
        @Override
        public UserDto toDto(User entity) {
            if ( entity == null ) {
                return null;
            }
    
            UserDto userDto = new UserDto();
    
            userDto.setName( entity.getName() );
            userDto.setPassword( entity.getPassword() );
            userDto.setEmail( entity.getEmail() );
    
            return userDto;
        }
        //...
    }
    

所產生的程式碼並無神奇之處
就是傳統的 getter、setter 或 builder
只是由框架代勞可以省去不少功夫

此外因為是純 Java 程式碼
不是使用反射技術,所以效能較佳

只要兩個物件欄位名稱一樣就能自動對應
不過實務上的映射當然不會那麼單純
不然何必轉換物件😂
因此還需要額外設定

設定以 annotation 為主,不會太難
否則就偏離想要省事的初衷了

Use Case

  1. 欄位名稱不同
    上文說到若欄位名稱相同,框架能自動映射
    一旦欄位名稱不同框架就無法判斷了
    因此需要告訴 Mapstruct
    source 來源的欄位名稱與 target 目標的欄位名稱

    @Mapping(target = "username", source = "name")
    @Mapping(target = "userType", source = "type")
    
  2. 略過
    反之如果不希望來源欄位自動帶到目標欄位
    就可以請框架忽略若干屬性

    @Mapping(target = "name", ignore = true)
    
  3. 聚合物件
    有時候一個 DTO 是由多個物件的資料串成
    Mapstruct 也能處理
    只要傳入多個物件參數
    再透過 物件.欄位 的方式指定欄位名稱即可

    @Mapping(target = "username", source = "user.name")
    @Mapping(target = "userNation", source = "nation.name")
    UserDto toDto(UserEntity user, NationEntity nation);
    
  4. 固定值
    若要寫死欄位可以使用 constant
    雖然只能傳入字串
    但框架會自動判斷目標的型別
    String、Boolean、Integer、BigDecimal 等都會自動轉換
    非常人性化

    @Mapping(target = "string", constant = "abc") // 固定字串
    @Mapping(target = "num", constant = "2") // 固定數值
    
  5. 預設值
    固定值是不論來源,一律寫死目標欄位
    預設值是當來源為空時,才會啟用

    @Mapping(target = "userStatus", source = "status", defaultValue = "INACTIVE")
    
  6. 處理簡易邏輯
    剛剛談的都是單純取值設值
    但實際開發更常見的是要經過加工
    這時候可以呼叫 expression
    語法是將 java code 包入 java()

    @Mapping(target = "type", expression = "java(entity.getType() == null ? null : entity.getType().getChineseName())")
    

    注意 expression 用到的類別必須引入或者要寫 full name
    否則會找不到

    @Mapper(imports = {
        LocalDate.class,
        Collecitons.class
    })
    
  7. 處理複雜邏輯
    雖說不論多長的程式碼都能放在 expression 中
    但如果太長會非常不易閱讀
    因此會分離成一支方法(這邏輯跟寫其他程式一樣)
    然後透過 qualifiedByName 呼叫

    @Mapping(target = "type", source = "type", qualifiedByName = "mapTypeToString")
    
    @Named("mapTypeToString")
    default String mapTypeToString(FinancialInstitutionType type) {
        if (type == null) {
            return null;
        }
        return type.getChineseName();
    }
    
  8. 條件
    有些需求要根據特定條件才設定值就可以用 conditionExpression
    表達式結果為 true 才會執行映射

    @Mapping(target = "id", source = "nationalId", conditionExpression = "java(source.getType() == UserType.NATIVE)")
    @Mapping(target = "id", source = "passportNo", conditionExpression = "java(source.getType() == UserType.FOREIGNER)")
    UserDto toDto(UserEntity source);
    
  9. 處理空值
    前面提到的預設值隱含的就是空值處理
    此外 Mapstruct 針對空值還可以設定其他處理方式

    @Mapping(target = "userStatus", source = "status", 
    nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.SET_TO_NULL)
    

    預設是 SET_TO_NULL,所以來源欄位為 null 則目標欄位為 null

    @Mapping(target = "userStatus", source = "status", 
    nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.SET_TO_DEFAULT)
    

    SET_TO_DEFAULT 則是給預設值
    List, Map, Array, String…都會幫我們 new 出來
    所以不必再給 defaultValue

    @Mapping(target = "userStatus", source = "status", 
    nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE)
    

    IGNORE 就是忽略不處理,維持原樣

    因此若設定了 NullValuePropertyMappingStrategy 就不需要這樣寫

    @Mapping(target = "userStatus", source = "status", conditionExpression = "java(source.getStatus() != null)")
    
  10. 集合
    @Mapping(target = "list", expression = "java(Collections.emptyList())") // 空集合
    @Mapping(target = "list", expression = "java(List.of(\"a\", \"b\", \"c\"))") // 簡易的固定集合
    
    // 複雜的固定集合
    @Mapping(target = "list", expression = "java(myList())") 
    
    default List<String> myList() {
        // business logic
        return list;
    }
    
    // 集合映射
    @Mapping(target = "aList", source = "bList")
    Aoo toAoo(Boo source);
    
    AooItem toAooItem(BooItem source);
    
    // 過濾集合
    @Mapping(target = "aList", source = "bList", qualifiedByName = "filterList")
    Aoo toAoo(Boo source);
    
    AooItem toAooItem(BooItem source);
    
    @Named("filterList")
    default List<AooItem> filterList(List<BooItem> source) {
        if (source == null)
            return Collections.emptyList();
        return source.stream()
                    .filter(BooItem::isValid)
                    .map(this::toItem)
                    .toList();
    }
    
  11. 日期
    // 日期轉字串
    @Mapping(target = "dateString", source = "localDate", dateFormat = "yyyy-MM-dd")
    // 字串轉日期
    @Mapping(target = "localDate", source = "dateString", dateFormat = "yyyy-MM-dd")
    // 字串轉日期,空字串處理
    @Mapping(target = "localDate", source = "dateString", conditionExpression = "java(dateString != null && !dateString.isEmpty())",  dateFormat = "yyyyMMdd")
    @Mapping(target = "localDate", constantNull = "true", conditionExpression = "java(dateString == null || dateString.isEmpty())")
    // 字串轉日期,異常處理
    @Mapping(target = "localDate", expression = "java(stringToLocalDate(source.getDateString()))")
    
    default LocalDate stringToLocalDate(String string) {
        if (string == null || string.trim().isEmpty())
            return null;
        try {
            return LocalDate.parse(string, DateTimeFormatter.ofPattern("yyyy-MM-dd"));
        } catch (DateTimeParseException e) {
            return null;
        }
    }
    // 若為空,給予現在日期
    @Mapping(target = "localDate", source = "localDate", defaultExpression = "java(LocalDate.now())")
    

JPA

一般做法都是在 service 層使用 MapStruct 轉換
如果在 repository 層使用 JPQL
就能在查詢的同時將結果直接轉為 DTO

public interface UserRepository extends JpaRepository<User, String> {
    @Query("SELECT new com.syun.dto.UserDto(user.id, user.username) FROM User user")
    List<UserDto> findAllUserDto();
}

當然這只能映射查詢結果中的欄位
無法進行複雜的轉換或關聯物件的映射

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *