มาใช้ MapStruct ใน Java กันเถอะ
MapStruct เป็นเครื่องมือ code generator ที่จะสร้าง implemenation code สำหรับ mapping ระหว่าง Java Type โดยใช้ convention ด้วยวิธี configuration ระหว่าง field ของ Type (Class) ซึ่ง mapping code ที่ได้สามารถเรียกใช้ method ได้เลย และมีความเร็ว, type-safe และ ง่ายต่อการทำความเข้าใจ
Multi-layer application บ่อยครั้งจะต้องมีการ map ระหว่าง Object model (เช่น entity กับ DTO) การเขียน method แบบนี้เป็นงานที่ น่าเบื่อ และ มีโอกาสเกิด error ได้ ซึ่ง MapStruct จะมาช่วยให้งานนี้ง่ายขึ้น โดยการสร้างโค้ดให้อัตโนมัติ
MapStruct แตกต่างจาก mapping framework อื่น ด้วยการ generate code ในตอน compile-time ซึ่งแน่ใจว่าได้ code ที่มีประสิทธิภาพสูง โดยที่ feedback developer ได้เร็ว และ error checking
MapStruct เป็น Annotation processor ซึ่งใช้ร่วมกับ Java compiler และสามารถใช้กับ command-line build อย่าง Maven หรือ Gradle ได้ด้วย จาก IDE ที่ใช้ และสามารถเพิ่ม implementation ที่ต้องการได้อีกด้วย
Install
MapStruct สามารถใช้ dependency management ทั้ง Maven และ Gradle
Maven
<properties>
<java.version>1.8</java.version>
<maven.compiler.plugin>3.8.1</maven.compiler.plugin>
<org.mapstruct.version>1.3.0.Final</org.mapstruct.version>
</properties><dependencies>
.....
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>${org.mapstruct.version}</version>
</dependency>
.....
</dependencies>
<build>
<plugins>
....
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>${maven.compiler.plugin}</version>
<configuration>
<source>${java.version}</source>
<target>${java.version}</target>
<annotationProcessorPaths>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${org.mapstruct.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>
Gradle
plugins {
...
id 'net.ltgt.apt' version "0.8"
}
....
dependencies {
...
compile 'org.mapstruct:mapstruct:1.3.0.Final'
apt 'org.mapstruct:mapstruct-processor:1.3.0.Final'
}
Mapper
หลังจากที่เพิ่ม MapStruct dependency เข้าไปใน project เรียบร้อยแล้วก็ลองสร้าง Mapper เพื่อ map ข้อมูลจาก DTO ไปเป็น Entity ซึ่งในบทความนี้จะสร้างตัวอย่างง่ายๆ ด้วย class FooDto (DTO type) และ class Foo (Entity) โดยจะ mapp ไปกลับระหว่าง 2 class นี้ (จะซ่อน Constructor/Getter/Setter ของ class ไว้)
public class FooDto {
private String br;
private int bz;
// Constructor/Getter/Setter
}public class Foo {
private String bar;
private String baz;
// Constructor/Getter/Setter
}
สร้าง Mapper ด้วยการสร้าง interface FooMapper โดยใช้ @Mapper
annotation ซึ่งเป็น interface ที่กำหนดให้ MapStruct สร้าง generated code โดยกำหนดว่าจะ map field ไหนของ Source type กับ Target type บ้าง
@Mapper
public interface FooMapper {
FooMapper INSTANCE = Mappers.getMapper(FooMapper.class);
@Mapping(source = "br", target = "bar")
@Mapping(source = "bz", target = "baz")
Foo fooDtoToFoo(FooDto dto);
@Mapping(source = "bar", target = "br")
@Mapping(source = "baz", target = "bz")
FooDto fooToFooDto(Foo foo);
}
ในตัวอย่างนี้จะ map field “br” และ “bz” ของ class FooDto กับ field “bar” และ “baz” ของ class Foo ตามลำดับ ด้วย @Mapping
annotation ซึ่ง field “bz” ของ class FooDto เป็นชนิด Integer แต่ “baz” ของ class Foo เป็นชนิด String เพื่อแสดงให้เห็นว่า MapStruct สามารถสร้าง mapper สำหรับชนิดของข้อมูลที่แตกต่างกันได้
เมื่อ compile project ซึ่งในบทความนี้ใช้ maven จึงใช้คำสั่ง mvn clean compile
maven compiler จะเรียกใช้ annotation processor ของ MapStruct เพื่อสร้าง generated code เมื่อ compile สำเร็จจะได้ code ใน
/target/generated-sources/annotations/{package-name}/
ดังนี้
@Generated(
value = "org.mapstruct.ap.MappingProcessor",
date = "2019-06-16T13:46:04+0700",
comments = "version: 1.3.0.Final, compiler: javac, environment: Java 11.0.2 (Oracle Corporation)"
)
public class FooMapperImpl implements FooMapper {
@Override
public Foo fooDtoToFoo(FooDto dto) {
if ( dto == null ) {
return null;
}
Foo foo = new Foo();
foo.setBar( dto.getBr() );
foo.setBaz( String.valueOf( dto.getBz() ) );
return foo;
}
@Override
public FooDto fooToFooDto(Foo foo) {
if ( foo == null ) {
return null;
}
FooDto fooDto = new FooDto();
fooDto.setBr( foo.getBar() );
if ( foo.getBaz() != null ) {
fooDto.setBz( Integer.parseInt( foo.getBaz() ) );
}
return fooDto;
}
}
จะเห็นได้ว่า MapStruct จะ convert type ให้ด้วย ลองทดสอบ Mapper ด้วย JUnit
public class FooMapperTest {
@Test
public void shouldMapFooDtoToFoo() {
// given
FooDto fooDto = new FooDto("BAR", 8888);
// when
Foo foo = FooMapper.INSTANCE.fooDtoToFoo(fooDto);
// then
assertEquals("BAR", foo.getBar());
assertEquals("8888", foo.getBaz());
}
@Test
public void shouldMapFooToFooDto() {
// given
Foo foo = new Foo("BAR", "8888");
// when
FooDto fooDto = FooMapper.INSTANCE.fooToFooDto(foo);
// then
assertEquals("BAR", fooDto.getBr());
assertEquals(8888, fooDto.getBz());
}
}
แต่ถ้า 2 type มีชื่อของ field เหมือนกัน ก็ไม่จำเป็นต้องใช้ @Mapping
annotation โดยที่ MapStruct จะทำการ map ชื่อ field ให้เอง
เปลี่ยนชื่อ field ของ class FooDto ให้เหมือนกับ class Foo
public class FooDto {
private String bar;
private String baz;
// Constructor/Getter/Setter
}
แล้วลบ @Mapping
annotation ทิ้ง
@Mapper
public interface FooMapper {
FooMapper INSTANCE = Mappers.getMapper(FooMapper.class);
Foo fooDtoToFoo(FooDto dto);
}
จะได้ generated code ดังนี้ จะเห็นได้ว่า MapStruct map field ที่มีชื่อเดียวกันให้เองอัตโนมัติ
public class FooMapperImpl implements FooMapper {
@Override
public Foo fooDtoToFoo(FooDto dto) {
if ( dto == null ) {
return null;
}
Foo foo = new Foo();
foo.setBar( dto.getBar() );
foo.setBaz( dto.getBaz() );
return foo;
}
}
แต่ถ้าไม่ต้อง map บาง field แต่ field ที่เหลือ map ทั้งหมด สามารถกำหนด ignore
ของ target field ให้เป็น true
@Mapper
public interface FooMapper {
FooMapper INSTANCE = Mappers.getMapper(FooMapper.class);
@Mapping(target = "baz", ignore = true)
Foo fooDtoToFoo(FooDto dto);
}
จะเห็นได้ว่าใน generated code จะละเว้น filed ที่กำหนด ignore
และเหลือแต่ field ที่เหลือที่จะทำการ map โดย MapStruct
public class FooMapperImpl implements FooMapper {
@Override
public Foo fooDtoToFoo(FooDto dto) {
if ( dto == null ) {
return null;
}
Foo foo = new Foo();
foo.setBar( dto.getBar() );
return foo;
}
}
แต่ถ้าต้องการ map ระหว่าง sub-field ของ source กับ field ของ target ก็สามารถทำได้โดยกำหนด reference อ้างอิงไปถึง sub-field นั้น
@Mapper
public interface FooMapper {
FooMapper INSTANCE = Mappers.getMapper(FooMapper.class);
@Mapping(source = "dto.bar.etc", target = "bar")
Foo fooDtoToFoo(FooDto dto);
}
จากตัวอย่างเป็นกรณีที่ field bar
ของ FooDto เปลี่ยนเป็น type Bar ซึ่งมี field etc
เป็น sub-field
public class FooDto {
private Bar bar;
private String baz;
// Constructor/Getter/Setter
}public class Bar {
private String etc;
// Constructor/Getter/Setter
}
เมื่อ compile แล้วจะได้ generated code ที่ map field bar
ของ FooDto
public class FooMapperImpl implements FooMapper {
@Override
public Foo fooDtoToFoo(FooDto dto) {
if ( dto == null ) {
return null;
}
Foo foo = new Foo();
foo.setBar( dtoBarEtc( dto ) );
foo.setBaz( dto.getBaz() );
return foo;
}
private String dtoBarEtc(FooDto fooDto) {
if ( fooDto == null ) {
return null;
}
Bar bar = fooDto.getBar();
if ( bar == null ) {
return null;
}
String etc = bar.getEtc();
if ( etc == null ) {
return null;
}
return etc;
}
}
Mapping Collection
นอกจาก MapStruct จะใช้สำหรับ map type เดี่ยวๆ ได้แล้ว ก็ยังสามารถ map field ที่เป็น collection ได้อีกด้วย โดยกำหนดให้ parameter ของ mapping method เป็น collection ซึ่งในตัวอย่างคือ List
และ method return เป็น collection ด้วย
@Mapper
public interface FooMapper {
FooMapper INSTANCE = Mappers.getMapper(FooMapper.class);
List<Foo> fooDtosToFoos(List<FooDto> dtos);
}
จะได้ generated code ซึ่งจะเห็นได้ว่า MapStruct สร้าง protected method เพื่อ map field ของ 2 type และ วนลูป collection เพื่อ map ทีละ element ของ collection
public class FooMapperImpl implements FooMapper {
@Override
public List<Foo> fooDtosToFoos(List<FooDto> dtos) {
if ( dtos == null ) {
return null;
}
List<Foo> list = new ArrayList<Foo>( dtos.size() );
for ( FooDto fooDto : dtos ) {
list.add( fooDtoToFoo( fooDto ) );
}
return list;
}
protected Foo fooDtoToFoo(FooDto fooDto) {
if ( fooDto == null ) {
return null;
}
Foo foo = new Foo();
foo.setBar( fooDto.getBar() );
foo.setBaz( fooDto.getBaz() );
return foo;
}
}
นอกจาก List แล้ว MapStruct ก็สามารถ map Map ได้ด้วย
@Mapper
public interface FooMapper {
FooMapper INSTANCE = Mappers.getMapper(FooMapper.class);
Map<Integer, Foo> fooDtoMapToFooMap(Map<String, FooDto> dtoMap);
}
จะได้ generated code ที่วนลูป mapping แต่ละ element ของ Map และแปลง type ของ Key ให้ด้วย
public class FooMapperImpl implements FooMapper {
@Override
public Map<Integer, Foo> fooDtoMapToFooMap(Map<String, FooDto> dtoMap) {
if ( dtoMap == null ) {
return null;
}
Map<Integer, Foo> map = new HashMap<Integer, Foo>( Math.max( (int) ( dtoMap.size() / .75f ) + 1, 16 ) );
for ( java.util.Map.Entry<String, FooDto> entry : dtoMap.entrySet() ) {
Integer key = Integer.parseInt( entry.getKey() );
Foo value = fooDtoToFoo( entry.getValue() );
map.put( key, value );
}
return map;
}
protected Foo fooDtoToFoo(FooDto fooDto) {
if ( fooDto == null ) {
return null;
}
Foo foo = new Foo();
foo.setBar( fooDto.getBar() );
foo.setBaz( fooDto.getBaz() );
return foo;
}
}
Custom Mapping
บางครั้งอาจจะต้องการด้วย logic พิเศษแต่ถ้าใช้ MapStruct อาจจะไม่รองรับเงื่อนไขนั้น ก็สามารถทำได้โดย mapping customization ด้วย Decorator ซึ่งสร้าง abstract class decorator ที่ implement interface Mapper และ delegate mapper มาเพิ่มเติม logic ที่ต้องการ
public abstract class FooMapperDecorator implements FooMapper {
private final FooMapper delegate;
public FooMapperDecorator(FooMapper delegate) {
this.delegate = delegate;
}
@Override
public Foo fooDtoToFoo(FooDto dto) {
Foo foo = delegate.fooDtoToFoo(dto);
foo.setBar(dto.getBar() + "/" + dto.getBar());
return foo;
}
}
และกำหนด decorator class ให้กับ interface Mapper
@Mapper
@DecoratedWith(FooMapperDecorator.class)
public interface FooMapper {
FooMapper INSTANCE = Mappers.getMapper(FooMapper.class);
Foo fooDtoToFoo(FooDto dto);
}
เมื่อ compile แล้วจะได้ generated code เพิ่มมา 1 class คือ FooMapperImpl_
ที่เป็น class ที่ใช้ในการ map ในขั้นแรก
public class FooMapperImpl_ implements FooMapper {
@Override
public Foo fooDtoToFoo(FooDto dto) {
if ( dto == null ) {
return null;
}
Foo foo = new Foo();
foo.setBar( dto.getBar() );
foo.setBaz( dto.getBaz() );
return foo;
}
}
และส่วน FooMapperImpl
นั้นจะ extents จาก abstract class Decorator และ implement interface Mapper
public class FooMapperImpl extends FooMapperDecorator
implements FooMapper {
private final FooMapper delegate;
public FooMapperImpl() {
this( new FooMapperImpl_() );
}
private FooMapperImpl(FooMapperImpl_ delegate) {
super( delegate );
this.delegate = delegate;
}
}
Use with Lombok
MapStruct จะมีปัญหาถ้าใช้ร่วมกับ Lombok ดังนั้นใน StackOverflow แนะวิธีแก้ปัญหาไว้ใน maven 2 วิธีคือ
- เพิ่ม Lombok dependency ใน
annotationProcessorPaths
ของ maven compiler (เลือกใช้วิธีนี้) - เพิ่ม
mapstruct-processor
dependency ใน dependencies tag และลบannotationProcessorPaths
ซึ่งวิธีนี้ maven compiler จะใช้ annotation processor ทั้งหมดที่อยู่ใน dependencies
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>${maven.compiler.plugin}</version>
<configuration>
<source>${java.version}</source>
<target>${java.version}</target>
<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.lombok.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>
หลังจากเพิ่ม Lombok ใน annotationProcessorPaths
ก็จะทำให้ใช้ MapStruct ร่วมกับ Lombok ได้แล้ว
สรุป
MapStruct เป็นเครื่องมือที่ช่วยให้ Java Developer ได้สร้าง mapping class ระหว่าง Type ของ Java ได้อย่างง่าย และมีความสามารถที่หลากหลาย และยังมี feature อื่นอีกของ MapStruct ที่บทความนี้ไม่ครอบคลุม ซึ่งสามารถอ่านได้ที่ reference guide