> Hello World !!!

     

@syaku

MapStruct & lombok

MapStruct & lombok

사용 전에 주의사항을 공유하기 위해 작성하였고 사용법은 부가적으로 작성하였다.

주의사항

  • lombok 을 사용할 경우 라이브러리 의존성 설정시 순서가 중요하다
  • lombok 과 함께 사용하기 위해 최소 버전은 아래와 같다.
    • lombok 1.16.14 or later, MapStruct 1.2.0 or later
  • MapStruct 는 getter & setter 를 기반으로 맵핑되기 때문에 getter & setter 는 필수이다.
    • 내가 사용한 1.4.2 버전은 빌더 패턴을 사용한 클래스도 사용할 수 있도록 개선되었다.
    • setter 가 없는 경우 객체 업데이트에 사용되는 @MappingTarge 사용할 수 없다.
    • 불변 클래스는 MapStruct 사용이 적합하지 않다.
      • 본문 맨 하단에 불변 클래스에 대한 해결 방안을 마련해두었다.
  • 활발하게 업데이트를 하고 있어 최신 MapStruct 를 사용하는 것을 권장한다.

장점

  • ModelMapper 는 런타임에 리플렉션으로 데이터를 맵핑한다.
  • 컴파일시 오류를 검출하므로 안정적으로 타입을 맵핑한다.

설치

  • Gradle 5.0 or later
dependencies {
        implementation "org.mapstruct:mapstruct:1.4.2.Final"
    implementation "org.mapstruct.extensions.spring:mapstruct-spring-annotations:0.1.0"

    compileOnly "org.projectlombok:lombok:${lombokVersion}"
    testCompileOnly "org.projectlombok:lombok:${lombokVersion}"

    annotationProcessor "org.mapstruct:mapstruct-processor:1.4.2.Final"
    annotationProcessor "org.mapstruct.extensions.spring:mapstruct-spring-annotations:0.1.0"
    annotationProcessor "org.projectlombok:lombok:${lombokVersion}"
    annotationProcessor "org.projectlombok:lombok-mapstruct-binding:0.2.0"

    testAnnotationProcessor "org.mapstruct:mapstruct-processor:1.4.2.Final"
    testAnnotationProcessor "org.mapstruct.extensions.spring:mapstruct-spring-annotations:0.1.0"
    testAnnotationProcessor "org.projectlombok:lombok:${lombokVersion}"
    testAnnotationProcessor "org.projectlombok:lombok-mapstruct-binding:0.2.0"
}

IntelliJ 를 사용할 경우 MapStruct Support Plugin 을 설치한다. 맵핑 설정에 관련하여 힌트를 주거나 자동으로 생성해주는 역할을 지원한다.

개발

Setter 에 의한 Mapper

Model Code

테스트에 사용될 model class 이며 간소화하기 위해 inner class 사용했다.

@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class Account {

    @NoArgsConstructor
    @AllArgsConstructor
    @Getter @Setter @EqualsAndHashCode @ToString
    public static class Dto {
        private String username;
        private String name;
        private UUID uid;
    }

    @NoArgsConstructor
    @AllArgsConstructor
    @Getter @Setter @EqualsAndHashCode @ToString
    public static class Domain {
        private Long id;
        private String username;
        private String name;
        private Boolean disabled;
        private Boolean blocked;
        private LocalDateTime registeredOn;
        private LocalDateTime updatedOn;
        private LocalDateTime lastAccessedOn;
        private UUID uid;
    }
}

Mapper Code

@Mapper
public interface AccountMapper {
    AccountMapper INSTANCE = Mappers.getMapper(AccountMapper.class);

    @Mapping(target = "updatedOn", ignore = true)
    @Mapping(target = "registeredOn", ignore = true)
    @Mapping(target = "lastAccessedOn", ignore = true)
    @Mapping(target = "id", ignore = true)
    @Mapping(target = "disabled", ignore = true)
    @Mapping(target = "blocked", ignore = true)
    Domain toDomain(Dto source);

    @BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE)
    @Mapping(target = "updatedOn", ignore = true)
    @Mapping(target = "registeredOn", ignore = true)
    @Mapping(target = "lastAccessedOn", ignore = true)
    @Mapping(target = "id", ignore = true)
    @Mapping(target = "disabled", ignore = true)
    @Mapping(target = "blocked", ignore = true)
    @Mapping(target = "uid", ignore = true)
    void update(Dto source, @MappingTarget Domain target);
}

위 자바 코드가 빌드되면 아래와 같은 자바 코드가 자동으로 만들어진다.

@Generated(
    value = "org.mapstruct.ap.MappingProcessor",
    date = "2021-09-06T22:45:59+0900",
    comments = "version: 1.4.2.Final, compiler: IncrementalProcessingEnvironment from gradle-language-java-6.5.jar, environment: Java 11.0.10 (AdoptOpenJDK)"
)
public class AccountMapperImpl implements AccountMapper {

    @Override
    public Domain toDomain(Dto source) {
        if ( source == null ) {
            return null;
        }

        Domain domain = new Domain();

        domain.setUsername( source.getUsername() );
        domain.setName( source.getName() );
        domain.setUid( source.getUid() );

        return domain;
    }

    @Override
    public void update(Dto source, Domain target) {
        if ( source == null ) {
            return;
        }

        if ( source.getUsername() != null ) {
            target.setUsername( source.getUsername() );
        }
        if ( source.getName() != null ) {
            target.setName( source.getName() );
        }
    }
}

Test Code

public class AccountMapperTest {
    private Dto dto;
    @BeforeEach
    void init() {
        dto = new Dto("few", "boat", UUID.randomUUID());
    }

    @Test
    void toDomain() {
        Domain domain = AccountMapper.INSTANCE.toDomain(dto);

        assertEquals(dto.getName(), domain.getName());
        assertEquals(dto.getUsername(), domain.getUsername());
        assertEquals(dto.getUid(), domain.getUid());
    }

    @Test
    @DisplayName("dto 의 값을 domain 에 대입하여 업데이트한다. 단 uid 는 업데이트 하지 않는 다.")
    void update() {
        Domain domain = new Domain();
        domain.setId(1L);
        domain.setName("account");
        domain.setUsername("brave");
        domain.setUid(UUID.randomUUID());

        AccountMapper.INSTANCE.update(dto, domain);

        assertEquals(dto.getName(), domain.getName());
        assertEquals(dto.getUsername(), domain.getUsername());
        assertEquals(1L, domain.getId());
        assertNotEquals(dto.getUid(), domain.getUid());
    }

    @Test
    @DisplayName("update() 테스트와 같지만 dto 에 null 값은 업데이트를 무시한다.")
    void null_ignore_and_update() {
        Dto aDto = new Dto(null, "boat", UUID.randomUUID());

        Domain domain = new Domain();
        domain.setId(1L);
        domain.setName("account");
        domain.setUsername("brave");
        domain.setUid(UUID.randomUUID());

        AccountMapper.INSTANCE.update(aDto, domain);

        assertEquals(aDto.getName(), domain.getName());
        assertEquals("brave", domain.getUsername());
        assertEquals(1L, domain.getId());
        assertNotEquals(aDto.getUid(), domain.getUid());
    }
}

불변 클래스에 의한 Mapper

Model Code

테스트에 사용될 model class 이며 간소화하기 위해 inner class 사용했다.

@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class Sample {

    @Value
    @Builder
    @AllArgsConstructor(access = AccessLevel.PRIVATE)
    public static class Dto {
        String username;
        String name;
        UUID uid;
    }

    @Value
    @Builder
    @AllArgsConstructor(access = AccessLevel.PRIVATE)
    public static class Domain {
        Long id;
        String username;
        String name;
        Boolean disabled;
        Boolean blocked;
        LocalDateTime registeredOn;
        LocalDateTime updatedOn;
        LocalDateTime lastAccessedOn;
        UUID uid;
    }
}

Mapper Code

setter 없기때문에 @MappingTarget 을 사용할 수 없다. 그래서 defaultExpression 이용하여 null 인 경우 domain 을 유지하도록 하였다.

@Mapper
public interface SampleMapper {

    SampleMapper INSTANCE = Mappers.getMapper(SampleMapper.class);

    @Mapping(target = "updatedOn", ignore = true)
    @Mapping(target = "registeredOn", ignore = true)
    @Mapping(target = "lastAccessedOn", ignore = true)
    @Mapping(target = "id", ignore = true)
    @Mapping(target = "disabled", ignore = true)
    @Mapping(target = "blocked", ignore = true)
    @Mapping(source = "uid", target = "uid")
    @Mapping(source = "username", target = "username")
    @Mapping(source = "name", target = "name")
    Domain convert(Dto dto);

    @Mapping(source = "domain.updatedOn", target = "updatedOn")
    @Mapping(source = "domain.registeredOn", target = "registeredOn")
    @Mapping(source = "domain.lastAccessedOn", target = "lastAccessedOn")
    @Mapping(source = "domain.id", target = "id")
    @Mapping(source = "domain.disabled", target = "disabled")
    @Mapping(source = "domain.blocked", target = "blocked")
    @Mapping(source = "dto.uid", target = "uid", defaultExpression = "java(domain.getUid())")
    @Mapping(source = "dto.username", target = "username", defaultExpression = "java(domain.getUsername())")
    @Mapping(source = "dto.name", target = "name", defaultExpression = "java(domain.getName())")
    Domain updateDomain(Dto dto, Domain domain);
}

Test Code

@Slf4j
class SampleMapperTest {

    @Test
    void toDomain() {
        Dto dto = Dto.builder()
            .username("bundle")
            .name("rotten")
            .uid(UUID.randomUUID())
            .build();
        Domain domain = SampleMapper.INSTANCE.convert(dto);

        assertNotNull(domain);
        assertEquals(dto.getName(), domain.getName());
        assertEquals(dto.getUsername(), domain.getUsername());
        assertEquals(dto.getUid(), domain.getUid());
        assertNull(domain.getId());
    }

    @Test
    void updateDomain() {
        Dto dto = Dto.builder()
            .username(null)
            .name("rotten")
            .uid(UUID.randomUUID())
            .build();

        Domain domain = Domain.builder()
            .id(100L)
            .name("explode")
            .username("fortune")
            .disabled(true)
            .blocked(true)
            .registeredOn(LocalDateTime.now())
            .uid(UUID.randomUUID())
            .build();

        Domain expected = SampleMapper.INSTANCE.updateDomain(dto, domain);

        assertNotNull(expected.getUsername());
        assertEquals("fortune", expected.getUsername());

        assertEquals(100L, expected.getId());
        assertEquals(dto.getName(), expected.getName());
        assertEquals(dto.getUid(), expected.getUid());
    }
}