Java Bean マッパーのコードジェネレータ MapStruct の基本

f:id:Naotsugu:20220301210415p:plain


MapStruct とは

Java Bean 間のプロパティのコピーを簡素化するコードジェネレーターです。

Commons Beanutils は実行時にリフレクションによりプロパティのコピーを行うのに対して、MapStruct はアノテーションプロセッサによりコンパイル時にマッピングコードを生成します。 生成されるマッピングコードは平易なメソッド呼び出しを使用するため、高速で、型安全で、理解しやすいものになります。

多層アプリケーションでしばしば行われる Entity と DTO の変換処理などのマッピングコードの作成を大幅に自動化することができます。 特に昨今のマイクロサービス化では、このようなマッピング作業が多く発生しますし、GraalVM による Native Image 化ではリフレクションの利用を避け、コンパイル時の静的な解決によせる動きが加速しています。 このような背景もあり、ここ数年で MapStruct の利用が増えています。


MapStruct の導入

Gradle Kotlin DSL を例にします。

プロジェクトを作成します。

$ mkdir mapstruct-example
$ cd mapstruct-example
$ gradle init --type java-application --dsl kotlin --test-framework junit-jupiter

依存は以下のように定義します。

dependencies {
    implementation("org.mapstruct:mapstruct:1.5.0.Beta2")
    annotationProcessor("org.mapstruct:mapstruct-processor:1.5.0.Beta2")
    testAnnotationProcessor("org.mapstruct:mapstruct-processor:1.5.0.Beta2")
}

2022年2月時点の安定版は 1.4.2.Final となります。ここではベータ版の 1.5.0.Beta2 を使うものとします。 build.gradle.kts 全体としては以下のような感じになります。

plugins {
    application
}

repositories {
    mavenCentral()
}

dependencies {
    implementation("org.mapstruct:mapstruct:1.5.0.Beta2")
    testImplementation("org.junit.jupiter:junit-jupiter:5.8.2")
    testImplementation("org.assertj:assertj-core:3.22.0")
    annotationProcessor("org.mapstruct:mapstruct-processor:1.5.0.Beta2")
    testAnnotationProcessor("org.mapstruct:mapstruct-processor:1.5.0.Beta2")
}

application {
    mainClass.set("mapstruct.example.App")
}

tasks.named<Test>("test") {
    useJUnitPlatform()
}


MapStruct の簡単な使い方

Customer の内容を、同名のフィールドを持つ CustomerDto にコピーする例を考えます。

Customer は以下。

public class Customer {
    private Long id;
    private String name;

    public Customer(Long id, String name) {
        this.id = id;
        this.name = name;
    }

    public Long getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    @Override
    public String toString() {
        return String.format("Customer{id=%d, name=%s}", id, name);
    }
}

CustomerDto は、無精にパブリックフィールドだけで定義します。

public class CustomerDto {
    public Long id;
    public String name;

    @Override
    public String toString() {
        return String.format("CustomerDto{id=%d, name=%s}", id, name);
    }
}

MapStruct では @Mapper でアノテートしたインターフェースまたは抽象クラスを作成する必要があります。

import org.mapstruct.Mapper;

@Mapper
public interface CustomerMapper {
    CustomerDto customerToCustomerDto(Customer customer);
}

メソッド名は何でも構いません。


以下のようにすれば、プロパティがコピーできます。

CustomerMapper mapper = Mappers.getMapper(CustomerMapper.class);

Customer customer = new Customer(1L, "Bob");
CustomerDto dto = mapper.customerToCustomerDto(customer);
System.out.println(dto);

CustomerDto{id=1, name=Bob} が得られます。

この時マッパーは以下のようなクラスが自動生成されます。

public class CustomerMapperImpl implements CustomerMapper {

    public CustomerDto customerToCustomerDto(Customer customer) {
        if (customer == null) {
            return null;
        } else {
            CustomerDto customerDto = new CustomerDto();
            customerDto.id = customer.getId();
            customerDto.name = customer.getName();
            return customerDto;
        }
    }
}


DTO のフィールドを final にしてみましょう。

public class CustomerDto {
    public final Long id;
    public final String name;

    public CustomerDto(Long id, String name) {
        this.id = id;
        this.name = name;
    }

    @Override
    public String toString() {
        return String.format("CustomerDto{id=%d, name=%s}", id, name);
    }
}

この時マッパーは以下のようなクラスが自動生成されます。

public class CustomerMapperImpl implements CustomerMapper {
    public CustomerDto customerToCustomerDto(Customer customer) {
        if (customer == null) {
            return null;
        } else {
            Long id = null;
            String name = null;
            id = customer.getId();
            name = customer.getName();
            CustomerDto customerDto = new CustomerDto(id, name);
            return customerDto;
        }
    }
}

コンストラクタ経由でDTOを生成するコードとなっているのが分かります。

なお、コンストラクタが複数存在する場合には @Default という名前のアノテーションが付与されているコンストラクタが利用されます。 どんなパッケージでも良く、単に @Default という名前だけを見ます。


マッパーには複数の変換メソッドを定義できますし、default メソッドとして独自の変換ロジックを書くこともできます。

@Mapper
public interface CustomerMapper {

    CustomerDto customerToCustomerDto(Customer customer);

    Customer customerDtoToCustomer(CustomerDto dto);

    default CustomerDto personToCustomerDto(Person person) {
        // ...
    }
}

また、以下のように定義することで、リスト間の変換もサポートできます。

@Mapper
public interface CustomerMapper {

    CustomerDto customerToCustomerDto(Customer customer);

    List<CustomerDto> customersToCustomerDtos(List<Customer> customers);

}


@Mapping でプロパティをマップする

先ほどの例は、プロパティ名が同一でしたが、異なる名称の場合は @Mapping で明示的にマッピングを定義する必要があります。

DTO が name ではなく customerName というフィールドだった場合、

public class CustomerDto {
    public final Long id;
    public final String customerName;
...
}

@Mapping にて以下のようにマッピングを定義します。

@Mapper
public interface CustomerMapper {
    @Mapping(target = "customerName", source = "name")
    CustomerDto customerToCustomerDto(Customer customer);
}

先の例と同様に、以下のようにすれば

CustomerMapper mapper = Mappers.getMapper(CustomerMapper.class);

Customer customer = new Customer(1L, "Bob");
CustomerDto dto = mapper.customerToCustomerDto(customer);
System.out.println(dto);

CustomerDto{id=1, customerName=Bob} の結果が得られます。


様々な @Mapping 定義

複数のオブジェクトから1つの DTO に集約したいケースでは以下のようにマッピングを定義することができます。

@Mapper
public interface AddressMapper {

    @Mapping(target = "description", source = "person.description")
    @Mapping(target = "houseNumber", source = "address.houseNo")
    DeliveryAddressDto personAndAddressToDeliveryAddressDto(Person person, Address address);
}


target = "." としてネストしたオブジェクトのマッピング定義を簡略化することもできます。

CustomerDto が AccountDto を持つような構造を考えます。

public class AccountDto {
    public final String name;

    public AccountDto(String name) {
        this.name = name;
    }
}
public class CustomerDto {
    public final Long id;
    public final AccountDto account;

    public CustomerDto(Long id, AccountDto account) {
        this.id = id;
        this.account = account;
    }
}

この場合は、以下のようにマッピング定義を行うことで、AccountDto の内容が自動的に Customer の同名のプロパティにマッピングされます。

@Mapper
public interface CustomerMapper {
    @Mapping( target = ".", source = "account" )
    Customer customerDtoToCustomer(CustomerDto customerDto);
}


マッピング定義には、デフォルト値(defaultValue)や定数値(constant)を指定することができます。

@Mapping(target = "stringProperty", source = "stringProp", defaultValue = "undefined")
@Mapping(target = "longProperty", source = "longProp", defaultValue = "-1")
@Mapping(target = "stringConstant", constant = "Constant Value")

さらに expression として変換ロジックを加えることもできます。

@Mapper( imports = TimeAndFormat.class )
public interface SourceTargetMapper {

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

    @Mapping(target = "timeAndFormat",
         expression = "java( new TimeAndFormat( s.getTime(), s.getFormat() ) )")
    Target sourceToTarget(Source s);
}

その他、 dateFormatnumberFormat など各種変換機能が準備されています。


@MappingTarget でオブジェクトを更新する

先ほどまでの例では、入力から新しいインスタンスを生成する例でしたが、@MappingTarget を指定することで、オブジェクトの更新を行うことができます。

@Mapper
public interface CustomerMapper {
    void updateCustomerFromDto(CustomerDto dto, @MappingTarget Customer customer);
}

戻り値に更新先のクラスを指定することもできます。

@Mapper
public interface CustomerMapper {
    Customer updateCustomerFromDto(CustomerDto dto, @MappingTarget Customer customer);
}


よくある例としては、Customer エンティティの更新時に、id 以外を更新するケースとして以下のマッパーを利用します。

@Mapper
public interface CustomerFullUpdateMapper {
    @Mapping(target = "id", ignore = true)
    void mapFullUpdate(Customer input, @MappingTarget Customer target);
}

また、null 値は更新対象外としたいケースでは以下のように nullValuePropertyMappingStrategy を指定します。

@Mapper(nullValuePropertyMappingStrategy = IGNORE)
public interface CustomerPartialUpdateMapper {
    void mapPartialUpdate(Customer input, @MappingTarget Customer target);
}


@Mapper インスタンスの取得

シンプルなケースでは、以下のようにマッパーのインターフェースでインスタンスを定義します。

@Mapper
public interface CustomerMapper {

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

    CustomerDto customerToCustomerDto(Customer customer);
}

抽象クラスを利用する場合には以下のような定義になります。

@Mapper
public abstract class CustomerMapper {

    public static final CustomerMapper INSTANCE = Mappers.getMapper(CustomerMapper.class);

    CustomerDto customerToCustomerDto(Customer customer);
}

通常は、CDI 環境で利用する方が多いかと思います。その場合は以下のように ComponentModel.CDI を指定することで @ApplicationScoped の CDI ビーンとして利用できます。

@Mapper(componentModel = MappingConstants.ComponentModel.CDI)
public interface CustomerMapper {

    CustomerDto customerToCustomerDto(Customer customer);
}

以下のようにインジェクトすることになるでしょう。

@Inject
private CustomerMapper mapper;


まとめ

アノテーションプロセッサによりコンパイル時にマッピングコードを生成する MapStruct の使い方について簡単に説明しました。

ここで紹介した他、各種の変換オプションが用意されています。

詳細は本家のドキュメントを参照してください。