在專案開發當中,很多軟體架構都會做一些物件上的資料轉換
最常見的是,往往我們會需要把 Entity 的物件轉換成對應的 DTO 物件給前端
最簡單也最常見的寫法就是依序把 Entity 的 value 用 get method 取出來用再用 DTO 物件的 set method 把資料塞進去
但其實有時候寫著寫著也是覺得很麻煩,也挺累的,有時候寫起來程式也臭臭長長的看了就討厭
所以今天要介紹的是使用 MapStruct 這個工具來幫我們快速地進行資料上的轉換,以加快我們的開發速度
首先必須在 Sprint Boot 的專案當中加入對應的依賴
如果是Maven專案的話 pom.xml 可以參考以下設定
<properties> | |
<java.version>11</java.version> | |
<org.projectlombok.version>1.18.20</org.projectlombok.version> | |
<org.mapstruct.version>1.4.2.Final</org.mapstruct.version> | |
<lombok.mapstruct.binding.version>0.2.0</lombok.mapstruct.binding.version> | |
</properties> | |
<dependencies> | |
... | |
<dependency> | |
<groupId>org.projectlombok</groupId> | |
<artifactId>lombok</artifactId> | |
<version>${org.projectlombok.version}</version> | |
<scope>provided</scope> | |
</dependency> | |
<!-- MapStruct dependencies --> | |
<dependency> | |
<groupId>org.mapstruct</groupId> | |
<artifactId>mapstruct</artifactId> | |
<version>${org.mapstruct.version}</version> | |
</dependency> | |
<dependency> | |
<groupId>org.projectlombok</groupId> | |
<artifactId>lombok-mapstruct-binding</artifactId> | |
<version>${lombok.mapstruct.binding.version}</version> | |
<scope>provided</scope> | |
</dependency> | |
</dependencies> | |
<build> | |
<plugins> | |
... | |
<plugin> | |
<groupId>org.apache.maven.plugins</groupId> | |
<artifactId>maven-compiler-plugin</artifactId> | |
<version>3.8.1</version> | |
<configuration> | |
<source>${java.version}</source> | |
<target>${java.version}</target> | |
<annotationProcessorPaths> | |
<path> | |
<groupId>org.projectlombok</groupId> | |
<artifactId>lombok</artifactId> | |
<version>${org.projectlombok.version}</version> | |
</path> | |
<path> | |
<groupId>org.mapstruct</groupId> | |
<artifactId>mapstruct-processor</artifactId> | |
<version>${org.mapstruct.version}</version> | |
</path> | |
<path> | |
<groupId>org.projectlombok</groupId> | |
<artifactId>lombok-mapstruct-binding</artifactId> | |
<version>${lombok.mapstruct.binding.version}</version> | |
</path> | |
</annotationProcessorPaths> | |
</configuration> | |
</plugin> | |
</plugins> | |
</build> |
因為我們專案當中也使用到了 lombok,所以我們必須透過一些設定確保 lombok 和 mapStruct 是可以同時使用的
那如果使用 Gradle 的話設定就相對單純,可以參考如下
ext { | |
mapstructVersion = "1.5.5.Final" | |
lombokMapstructBindingVersion = "0.2.0" | |
} | |
dependencies { | |
... | |
// lombok | |
compileOnly 'org.projectlombok:lombok' | |
annotationProcessor 'org.projectlombok:lombok' | |
// MapStruct | |
implementation "org.mapstruct:mapstruct:${mapstructVersion}" | |
annotationProcessor "org.mapstruct:mapstruct-processor:${mapstructVersion}" | |
annotationProcessor "org.projectlombok:lombok-mapstruct-binding:${lombokMapstructBindingVersion}" | |
} |
Entity 和 DTO 的欄位一模一樣
首先我們針對一個最常見,也最簡單的情境來示範 MapStruct 的轉換
我們有一個 People 的 Entity 物件,如下
import lombok.Data; | |
@Data | |
public class People { | |
private Integer id; | |
private String name; | |
private Integer age; | |
private String address; | |
} |
然後我們有一個 PeopleDto 物件
import lombok.Data; | |
@Data | |
public class PeopleDto { | |
private Integer id; | |
private String name; | |
private Integer age; | |
private String address; | |
} |
我們如果要把 People 轉換成 PeopleDto,我們就建立一個 Mapper 如下
import com.mark.springbootmall.dto.PeopleDto; | |
import com.mark.springbootmall.model.People; | |
import org.mapstruct.Mapper; | |
@Mapper(componentModel = "spring") | |
public interface PeopleMapper { | |
PeopleDto toDto(People people); | |
} |
因為 People 和 PeopleDto 的欄位成員變數名稱全部都一樣,所以這樣寫就能夠做轉換了
那接著我們就寫一個單元測試來驗證這件事情
import com.mark.springbootmall.dto.PeopleDto; | |
import com.mark.springbootmall.model.People; | |
import org.junit.jupiter.api.Test; | |
import org.springframework.beans.factory.annotation.Autowired; | |
import org.springframework.boot.test.context.SpringBootTest; | |
import static org.assertj.core.api.Assertions.assertThat; | |
import static org.junit.jupiter.api.Assertions.assertEquals; | |
@SpringBootTest | |
class PeopleMapperTest { | |
@Autowired | |
private PeopleMapper peopleMapper; | |
@Test | |
public void testToDto() { | |
// Create a People entity | |
People people = new People(); | |
people.setId(1); | |
people.setName("John Doe"); | |
people.setAge(30); | |
people.setAddress("123 Main St"); | |
// Map to PeopleDto | |
PeopleDto peopleDto = peopleMapper.toDto(people); | |
// Assert the values are correctly mapped | |
assertThat(peopleDto).isNotNull(); | |
assertEquals(peopleDto.getId(), people.getId()); | |
assertEquals(peopleDto.getName(), people.getName()); | |
assertEquals(peopleDto.getAge(), people.getAge()); | |
assertEquals(peopleDto.getAddress(), people.getAddress()); | |
} | |
} |
這樣執行測試後,就能pass這個測試案例了
DTO的欄位需要客製化轉換
接下來第二種情境也很常見,當我們要將 Entity 物件轉換成 DTO 時,可能需要做一些處理才能符合DTO要的格式或需求
以下範例 People 物件有一個 updateTime 是 OffsetDateTime 的型別,而 PeopleDto 的 updateTime 型別是 Long
也就是說這邊的需求是要將時間格式轉換成毫秒的形式提供Client使用
import lombok.Data; | |
import java.time.OffsetDateTime; | |
@Data | |
public class People { | |
private Integer id; | |
private String name; | |
private Integer age; | |
private String address; | |
private OffsetDateTime updateTime; | |
} |
import lombok.Data; | |
@Data | |
public class PeopleDto { | |
private Integer id; | |
private String name; | |
private Integer age; | |
private String address; | |
private Long updateTime; | |
} |
那我們就可以把Mapper調整成如下
@Mapper(componentModel = "spring") | |
public interface PeopleMapper { | |
@Mapping(target = "updateTime", source = "updateTime", qualifiedByName = "formatUpdateTime") | |
PeopleDto toDto(People people); | |
@Named("formatUpdateTime") | |
default Long formatUpdateTime(OffsetDateTime offsetDateTime) { | |
if (offsetDateTime == null) { | |
return null; | |
} else { | |
return offsetDateTime.toInstant().atOffset(ZoneOffset.UTC).toInstant().toEpochMilli(); | |
} | |
} | |
} |
targer指的是目標轉換欄位名稱,以我們這邊的案例就是 PeopleDto 物件的 updateTime欄位
source指的是來源欄位名稱,我們就是要用來源資料做加工處理,以我們這邊的案例就是 People 物件的 updateTime欄位
透過我們自定義的方法來實作客製化的轉換邏輯,以上面例子,就是透過 formatUpdateTime 來做轉換
接著一樣撰寫一個測試來驗證
import com.mark.springbootmall.dto.PeopleDto; | |
import com.mark.springbootmall.model.People; | |
import org.junit.jupiter.api.Test; | |
import org.springframework.beans.factory.annotation.Autowired; | |
import org.springframework.boot.test.context.SpringBootTest; | |
import java.time.OffsetDateTime; | |
import java.time.ZoneOffset; | |
import static org.junit.jupiter.api.Assertions.assertEquals; | |
@SpringBootTest | |
class PeopleMapperTest { | |
@Autowired | |
private PeopleMapper peopleMapper; | |
@Test | |
public void testToDto() { | |
// Given | |
People people = new People(); | |
people.setId(1); | |
people.setName("John Doe"); | |
people.setAge(30); | |
people.setAddress("123 Main St"); | |
people.setUpdateTime(OffsetDateTime.of(2023, 6, 21, 12, 0, 0, 0, ZoneOffset.UTC)); | |
// When | |
PeopleDto peopleDto = peopleMapper.toDto(people); | |
// Then | |
assertEquals(1, peopleDto.getId()); | |
assertEquals("John Doe", peopleDto.getName()); | |
assertEquals(30, peopleDto.getAge()); | |
assertEquals("123 Main St", peopleDto.getAddress()); | |
assertEquals(OffsetDateTime.of(2023, 6, 21, 12, 0, 0, 0, ZoneOffset.UTC).toInstant().toEpochMilli(), peopleDto.getUpdateTime()); | |
} | |
} |
DTO欄位資料透過其他物件組合
上面的範例都還是使用帶入的來源原始資料直接進行加工處理,
但有時候DTO物件的欄位是需要透過額外的物件來一起做資料處理組合出最終的結果
舉例來說,假設我們有一個 Map<Integer, String>,這個 Map 的 Key 是 People 的 id,而 value 則是我們要對應到 PeopleDto 的 name 的字串
所以狀況是,我們需要將 Map<Integer, String> 帶進去 toDto method,透過 id 去 Map 查詢對應的字串,對應的字串就是最後 PeopleDto 的 name
所以將PeopleMapper調整如下程式碼:
import com.mark.springbootmall.dto.PeopleDto; | |
import com.mark.springbootmall.model.People; | |
import org.mapstruct.Context; | |
import org.mapstruct.Mapper; | |
import org.mapstruct.Mapping; | |
import org.mapstruct.Named; | |
import java.time.OffsetDateTime; | |
import java.time.ZoneOffset; | |
import java.util.Map; | |
@Mapper(componentModel = "spring") | |
public interface PeopleMapper { | |
@Mapping(target = "updateTime", source = "updateTime", qualifiedByName = "formatUpdateTime") | |
@Mapping(target = "name", source = "id", qualifiedByName = "peopleName") | |
PeopleDto toDto(People people, @Context Map<Integer, String> appendStringMap); | |
@Named("formatUpdateTime") | |
default Long formatUpdateTime(OffsetDateTime offsetDateTime) { | |
if (offsetDateTime == null) { | |
return null; | |
} else { | |
return offsetDateTime.toInstant().atOffset(ZoneOffset.UTC).toInstant().toEpochMilli(); | |
} | |
} | |
@Named("peopleName") | |
default String peopleName(Integer id, @Context Map<Integer, String> appendStringMap) { | |
String appendString = appendStringMap.get(id); | |
return appendString != null ? appendString : ""; | |
} | |
} |
targer是 PeopleDto 物件的 name欄位
source是People 物件的 id 欄位
會透過我們自定義的 peopleName 使用 source (people.id) 和帶進來的 @Context 進行處理
對應的測試如下
import com.mark.springbootmall.dto.PeopleDto; | |
import com.mark.springbootmall.model.People; | |
import org.junit.jupiter.api.Test; | |
import org.springframework.beans.factory.annotation.Autowired; | |
import org.springframework.boot.test.context.SpringBootTest; | |
import java.time.OffsetDateTime; | |
import java.time.ZoneOffset; | |
import java.util.HashMap; | |
import java.util.Map; | |
import static org.junit.jupiter.api.Assertions.assertEquals; | |
@SpringBootTest | |
class PeopleMapperTest { | |
@Autowired | |
private PeopleMapper peopleMapper; | |
@Test | |
public void testToDto() { | |
// Given | |
People people = new People(); | |
people.setId(1); | |
people.setName("John Doe"); | |
people.setAge(30); | |
people.setAddress("123 Main St"); | |
people.setUpdateTime(OffsetDateTime.of(2023, 6, 21, 12, 0, 0, 0, ZoneOffset.UTC)); | |
Map<Integer, String> appendStringMap = new HashMap<>(); | |
appendStringMap.put(1, "第一筆資料"); | |
// When | |
PeopleDto peopleDto = peopleMapper.toDto(people, appendStringMap); | |
// Then | |
assertEquals(1, peopleDto.getId()); | |
assertEquals("第一筆資料", peopleDto.getName()); | |
assertEquals(30, peopleDto.getAge()); | |
assertEquals("123 Main St", peopleDto.getAddress()); | |
assertEquals(OffsetDateTime.of(2023, 6, 21, 12, 0, 0, 0, ZoneOffset.UTC).toInstant().toEpochMilli(), peopleDto.getUpdateTime()); | |
} | |
} |
這種情境比較常是拿著自己本身的id去關聯別的物件來做轉換
目標欄位由2個以上來源的欄位組合
當我們需要將來源物件兩個以上的資料帶進去我們自定義的轉換介面時,可以參考如下範例作法
承上面範例,我們這次一樣會帶入Map<Integer, String>,而最後的 PeopleDto 的 name 是由 People 的 name 去 append 使用 People 的 id 去Map查到對應的 value
這時候我們的 peopleName 這個方法就必須帶入兩個參數,People 的 id 和 name,程式寫法參考如下
import com.mark.springbootmall.dto.PeopleDto; | |
import com.mark.springbootmall.model.People; | |
import org.mapstruct.Context; | |
import org.mapstruct.Mapper; | |
import org.mapstruct.Mapping; | |
import org.mapstruct.Named; | |
import java.time.OffsetDateTime; | |
import java.time.ZoneOffset; | |
import java.util.Map; | |
@Mapper(componentModel = "spring") | |
public interface PeopleMapper { | |
@Mapping(target = "updateTime", source = "updateTime", qualifiedByName = "formatUpdateTime") | |
@Mapping(target = "name", expression = "java(peopleName(people.getId(), people.getName(), appendStringMap))") | |
PeopleDto toDto(People people, @Context Map<Integer, String> appendStringMap); | |
@Named("formatUpdateTime") | |
default Long formatUpdateTime(OffsetDateTime offsetDateTime) { | |
if (offsetDateTime == null) { | |
return null; | |
} else { | |
return offsetDateTime.toInstant().atOffset(ZoneOffset.UTC).toInstant().toEpochMilli(); | |
} | |
} | |
@Named("peopleName") | |
default String peopleName(Integer id, String name, @Context Map<Integer, String> appendStringMap) { | |
String appendString = appendStringMap.get(id); | |
String finalName = name + " " + appendString; | |
return appendString != null ? finalName : ""; | |
} | |
} |
測試如下,可以看到 peopleDto.getName() 取出來的資料應該要是 "John Doe 第一筆資料"
import com.mark.springbootmall.dto.PeopleDto; | |
import com.mark.springbootmall.model.People; | |
import org.junit.jupiter.api.Test; | |
import org.springframework.beans.factory.annotation.Autowired; | |
import org.springframework.boot.test.context.SpringBootTest; | |
import java.time.OffsetDateTime; | |
import java.time.ZoneOffset; | |
import java.util.HashMap; | |
import java.util.Map; | |
import static org.junit.jupiter.api.Assertions.assertEquals; | |
@SpringBootTest | |
class PeopleMapperTest { | |
@Autowired | |
private PeopleMapper peopleMapper; | |
@Test | |
public void testToDto() { | |
// Given | |
People people = new People(); | |
people.setId(1); | |
people.setName("John Doe"); | |
people.setAge(30); | |
people.setAddress("123 Main St"); | |
people.setUpdateTime(OffsetDateTime.of(2023, 6, 21, 12, 0, 0, 0, ZoneOffset.UTC)); | |
Map<Integer, String> appendStringMap = new HashMap<>(); | |
appendStringMap.put(1, "第一筆資料"); | |
// When | |
PeopleDto peopleDto = peopleMapper.toDto(people, appendStringMap); | |
// Then | |
assertEquals(1, peopleDto.getId()); | |
assertEquals("John Doe 第一筆資料", peopleDto.getName()); | |
assertEquals(30, peopleDto.getAge()); | |
assertEquals("123 Main St", peopleDto.getAddress()); | |
assertEquals(OffsetDateTime.of(2023, 6, 21, 12, 0, 0, 0, ZoneOffset.UTC).toInstant().toEpochMilli(), peopleDto.getUpdateTime()); | |
} | |
} |
結語
以上示範了幾種常見的 MapStruct 常見的用法,大多情境都可以根據自己的狀況再去調整