在專案開發當中,很多軟體架構都會做一些物件上的資料轉換

最常見的是,往往我們會需要把 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;
}
view raw People.java hosted with ❤ by GitHub

然後我們有一個 PeopleDto 物件

import lombok.Data;
@Data
public class PeopleDto {
private Integer id;
private String name;
private Integer age;
private String address;
}
view raw PeopleDto.java hosted with ❤ by GitHub

我們如果要把 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;
}
view raw People2.java hosted with ❤ by GitHub
import lombok.Data;
@Data
public class PeopleDto {
private Integer id;
private String name;
private Integer age;
private String address;
private Long updateTime;
}
view raw PeopleDto2.java hosted with ❤ by GitHub

那我們就可以把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 常見的用法,大多情境都可以根據自己的狀況再去調整

arrow
arrow
    創作者介紹
    創作者 Mark Zhang 的頭像
    Mark Zhang

    讀處

    Mark Zhang 發表在 痞客邦 留言(0) 人氣()