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?
- 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>
- 新增 interface
public interface UserMapper { }
- 新增
@Mapper
註解import org.mapstruct.Mapper; @Mapper public interface UserMapper { }
- 宣告 Mapper 實例
import org.mapstruct.Mapper; import org.mapstruct.factory.Mappers; @Mapper public interface UserMapper { UserMapper INSTANCE = Mappers.getMapper(UserMapper.class); }
- 新增 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); }
- 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
- 欄位名稱不同
上文說到若欄位名稱相同,框架能自動映射
一旦欄位名稱不同框架就無法判斷了
因此需要告訴 Mapstruct
source 來源的欄位名稱與 target 目標的欄位名稱@Mapping(target = "username", source = "name") @Mapping(target = "userType", source = "type")
- 略過
反之如果不希望來源欄位自動帶到目標欄位
就可以請框架忽略若干屬性@Mapping(target = "name", ignore = true)
- 聚合物件
有時候一個 DTO 是由多個物件的資料串成
Mapstruct 也能處理
只要傳入多個物件參數
再透過物件.欄位
的方式指定欄位名稱即可@Mapping(target = "username", source = "user.name") @Mapping(target = "userNation", source = "nation.name") UserDto toDto(UserEntity user, NationEntity nation);
- 固定值
若要寫死欄位可以使用 constant
雖然只能傳入字串
但框架會自動判斷目標的型別
String、Boolean、Integer、BigDecimal 等都會自動轉換
非常人性化@Mapping(target = "string", constant = "abc") // 固定字串 @Mapping(target = "num", constant = "2") // 固定數值
- 預設值
固定值是不論來源,一律寫死目標欄位
預設值是當來源為空時,才會啟用@Mapping(target = "userStatus", source = "status", defaultValue = "INACTIVE")
- 處理簡易邏輯
剛剛談的都是單純取值設值
但實際開發更常見的是要經過加工
這時候可以呼叫 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 })
- 處理複雜邏輯
雖說不論多長的程式碼都能放在 expression 中
但如果太長會非常不易閱讀
因此會分離成一支方法(這邏輯跟寫其他程式一樣)
然後透過 qualifiedByName 呼叫@Mapping(target = "type", source = "type", qualifiedByName = "mapTypeToString") @Named("mapTypeToString") default String mapTypeToString(FinancialInstitutionType type) { if (type == null) { return null; } return type.getChineseName(); }
- 條件
有些需求要根據特定條件才設定值就可以用 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);
- 處理空值
前面提到的預設值隱含的就是空值處理
此外 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)")
- 集合
@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(); }
- 日期
// 日期轉字串 @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();
}
當然這只能映射查詢結果中的欄位
無法進行複雜的轉換或關聯物件的映射