オブジェクト間のマッピング(Object Mapping)に便利なツールを紹介

はじめに

TOAST Notificationのサービス内では、APIのバージョン別のオブジェクトと、ビジネスロジック、レイヤー間のオブジェクトとオブジェクト間のマッピング時に、MapStructを使用しています。今回はオブジェクト間マッピングライブラリーであるMapStructについて紹介します。

問題

Springフレームワークでの開発を例に挙げてみましょう。Controller、Service、Repositoryなどのレイヤー間でデータを送受信するときやビジネスロジックでは、1つのオブジェクトをタイプの異なるオブジェクトに型変換したり、複数のオブジェクトを別のオブジェクトに結合させることが頻繁にあります。

このような作業を、開発者がすべて自分で行うとき、主にこのような問題点があります。

  • 面白味がなく繰り返しコードの重複が発生しやすい
  • ミスが発生しやすい
  • 結果として、生産性が落ちる
  • ビジネスロジックに混ざるとコードが複雑になる

オブジェクト間マッピングライブラリは、このような問題を解決してくれる非常に便利なツールです。ここで、次のように手書きで作成されたオブジェクトの変換コードがあるとします。比較的単純なオブジェクトなのでコードは短いですが、フィールドがいくつか増えるだけで可読性が落ち、開発者には疲れる作業になるでしょう。

MessageEntity toMessageEntity(Message message) {
    return MessageEntity
            .builder()
            .id(message.getId())
            .to(message.getTo())
            .body(message.getBody())
            .messageType(message.getMessageType())
            .status(message.getStatus())
            .createdDateTime(message.getCreatedDateTime())
            .build();
}

解決方法

MapStructとは?

MapStructは、Javaでオブジェクト間のマッピングに対するコードを自動的に作成するマッピングライブラリです。Annotation Processorを使ってコンパイル時にマッピングコードを作成するもので、多くの利点があります。

  1. コンパイル時にエラーを確認できる
  2. リフレクション(Reflction)を使用していないため、マッピング速度が速い
  3. デバッグがしやすい
  4. 作成されたマッピングコードを目で直接確認できる

Googleトレンドでも、MapStructがJavaのマッピングライブラリの中で最も多く使われているようです。

開始する

次のような簡単なメッセージ送信APIがあるとします。APIリクエストの本文オブジェクトをDBエンティティオブジェクトにマッピングするサンプルです。

curl -X POST http://127.0.0.1/v1.0/messages -d '{"to":"jinho-shin","title":"タイトル","body":"内容","messageType":"AD"}'

public class V1_0SendMessage {
    private String to;
    private String title;
    private String body;
    private String messageType;
}

public class MessageEntity {
    private String id = RandomStringUtils.randomAlphanumeric(16);
    private String to;
    private String title;
    private String body;
    private String messageType;
    private String status = "READY";
    private String statusMessage;
    private OffsetDateTime createdDateTime = OffsetDateTime.now();
    private OffsetDateTime updatedDateTime = OffsetDateTime.now();
}

APIリクエストの本文オブジェクト「V1_0SendMessage」をDBエンティティオブジェクト「MessageEntity」にマッピングするためのコードです。MapStructのマッパー表示のため「@Mapper」とインタフェースを定義します。また、メソッドパラメータにはソースオブジェクトである「V1_0SendMessage」を定義し、リターンタイプにはターゲットオブジェクトである「MessageEntity」を定義します。

@Mapper
public interface MessageMapper {
    MessageEntity toMessageEntity(V1_0SendMessage v1_0SendMessage);
}

実際のオブジェクトマッピングに使用されるコードは、MapStructが作成します。次はMapStructで作成されたマップコードです。

@Generated(
    value = "org.mapstruct.ap.MappingProcessor", // MapStructのAnnotation Processor
    date = "2019-11-10T14:39:59+0900",
    comments = "version: 1.3.0.Final, compiler: javac, environment: Java 1.8.0_202 (AdoptOpenJdk)"
)
@Component
public class MessageMapperImpl implements MessageMapper {

    @Override
    public MessageEntity toMessageEntity(V1_0SendMessage v1_0SendMessage) {
        if ( v1_0SendMessage == null ) {
            return null; // 基本的にソースがnullならターゲットもnull。後述の「Policy&Strategy」で設定できる。
        }

        MessageEntity messageEntity = new MessageEntity();

        messageEntity.setTo( v1_0SendMessage.getTo() );
        messageEntity.setTitle( v1_0SendMessage.getTitle() );
        messageEntity.setBody( v1_0SendMessage.getBody() );
        messageEntity.setMessageType( v1_0SendMessage.getMessageType() );

        return messageEntity;
    }
}

機能紹介

ここからは、TOAST Notificationで実際にMapStructを使ってみて、有用だった機能や直面した問題、その解決方法について紹介します。

1.異なる属性のマッピング

次のように「EmailMessageEntity」の「emailAddress」属性を「MessageEntity」の「to」属性にマッピングしなければならないとします。

public class EmailMessageEntity {
    private String id = RandomStringUtils.randomAlphanumeric(16);
    private String emailAddress;
    private String title;
    private String body;
    private String messageType;
    private String status = "READY";
    private String statusMessage;
    private OffsetDateTime createdDateTime = OffsetDateTime.now();
    private OffsetDateTime updatedDateTime = OffsetDateTime.now();
}

次のように、マッピングメソッド「@Mapping」に、ソースとターゲットのプロパティを設定すればよいでしょう。

@Mapper
public interface MessageMapper {
    @Mapping(source = "emailAddress", target = "to")
    MessageEntity toMessageEntity(EmailMessageEntity emailMessageEntity);
}

以下は、MapStructが実装された「MessageMapperImpl」の一部です。

@Override
public MessageEntity toMessageEntity(EmailMessageEntity emailMessageEntity) {
    if ( emailMessageEntity == null ) {
        return null;
    }

    MessageEntity messageEntity = new MessageEntity();

    messageEntity.setTo( emailMessageEntity.getEmailAddress() ); // emailAddress属性をto属性にマッピング
    messageEntity.setId( emailMessageEntity.getId() );
    messageEntity.setTitle( emailMessageEntity.getTitle() );
    messageEntity.setBody( emailMessageEntity.getBody() );
    messageEntity.setMessageType( emailMessageEntity.getMessageType() );
    messageEntity.setStatus( emailMessageEntity.getStatus() );
    messageEntity.setStatusMessage( emailMessageEntity.getStatusMessage() );
    messageEntity.setCreatedDateTime( emailMessageEntity.getCreatedDateTime() );
    messageEntity.setUpdatedDateTime( emailMessageEntity.getUpdatedDateTime() );

    return messageEntity;
}

2.オブジェクトの結合#1

メッセージ送信結果のオブジェクトをマッピングする必要があるとしましょう。
下記はメッセージ結果「MessageResult」オブジェクトです。

public class MessageResult {
    private String id;
    private String to;
    private String title;
    private String body;
    private String messageType;
    private String status;
    private String statusMessage;
    private OffsetDateTime createdDateTime;
    private OffsetDateTime updatedDateTime;
    private String sender;
    private int senderReplyCode;
    private Collection<Exception> exceptions;
}

次のように「MessageEntity」、「sender」(送信サーバー)、「senderReplyCode」(送信サーバーの応答)、「exceptions」(エラー)を連結するマッピングメソッドを定義します。

@Mapper
public interface MessageMapper {
    MessageResult toMessageResult(MessageEntity messageEntity, String sender, int senderReplyCode, Collection<Exception> exceptions);
}

以下は、MapStructが実装されたMessageMapperImplの一部です。

@Override
public MessageResult toMessageResult(MessageEntity messageEntity, String sender, int senderReplyCode, Collection<Exception> exceptions) {
    if ( messageEntity == null && sender == null && exceptions == null ) {
        return null;
    }

    MessageResult messageResult = new MessageResult();

    if ( messageEntity != null ) {
        messageResult.setId( messageEntity.getId() );
        messageResult.setTo( messageEntity.getTo() );
        messageResult.setTitle( messageEntity.getTitle() );
        messageResult.setBody( messageEntity.getBody() );
        messageResult.setMessageType( messageEntity.getMessageType() );
        messageResult.setStatus( messageEntity.getStatus() );
        messageResult.setStatusMessage( messageEntity.getStatusMessage() );
        messageResult.setCreatedDateTime( messageEntity.getCreatedDateTime() );
        messageResult.setUpdatedDateTime( messageEntity.getUpdatedDateTime() );
    }
    if ( sender != null ) {
        messageResult.setSender( sender );
    }
    if ( exceptions != null ) {
        Collection<Exception> collection = exceptions;
        if ( collection != null ) {
            messageResult.setExceptions( new ArrayList<Exception>( collection ) );
        }
    }
    messageResult.setSenderReplyCode( senderReplyCode );

    return messageResult;
}

3.オブジェクトの結合#2

次のようなテンプレートを利用したメッセージがあるとしましょう。

public class TemplateMessage {
    private String id;
    private String to;
    private TemplateEntity templateEntity;
    private String messageType;
    private String status;
    private OffsetDateTime createdDateTime;
}

この「TemplateMessage」は「MessageEntity」と「TemplateEntity」を結合して作成します。

public class TemplateEntity {
    private String templateId;
    private String template;
    private Map<String, String> templateParameters;
}

マッピングメソッドは、次のように定義できます。

@Mapper
public interface MessageMapper {
    @Mapping(source = "templateEntity", target = "template") // templateEntityをtemplate属性に
    TemplateMessage toTemplateMessage(MessageEntity messageEntity, TemplateEntity templateEntity);
}

以下は、MapStructが実装された「MessageMapperImpl」の一部です。

@Override
public TemplateMessage toTemplateMessage(MessageEntity messageEntity, TemplateEntity templateEntity) {
    if ( messageEntity == null && templateEntity == null ) {
        return null;
    }

    TemplateMessage templateMessage = new TemplateMessage();

    if ( messageEntity != null ) {
        templateMessage.setId( messageEntity.getId() );
        templateMessage.setTo( messageEntity.getTo() );
        templateMessage.setMessageType( messageEntity.getMessageType() );
        templateMessage.setStatus( messageEntity.getStatus() );
        templateMessage.setStatusMessage( messageEntity.getStatusMessage() );
        templateMessage.setCreatedDateTime( messageEntity.getCreatedDateTime() );
        templateMessage.setUpdatedDateTime( messageEntity.getUpdatedDateTime() );
    }
    if ( templateEntity != null ) {
        templateMessage.setTemplate( templateEntity );
    }

    return templateMessage;
}

4.属性を無視する

次のようなオブジェクトがあるとしましょう。

public class TemplateEntity {
    private String templateId;
    private String template;
    private Map<String, String> templateParameters;
    private OffsetDateTime createdDateTime;
    private OffsetDateTime updatedDateTime;
}
public class V1_0Template {
    private String template;
    private Map<String, String> templateParameters;
}

マッピング時のターゲットオブジェクトに対するマッピングポリシーを厳格化するため、ターゲットオブジェクトにおいてマッピング時にマッピングされていない属性があった場合、次のようにコンパイルエラーを発生させるポリシーを設定したと仮定します。

@Mapper(unmappedTargetPolicy = ReportingPolicy.ERROR) // ポリシーの説明は次章参照
public interface TemplateMapper {
    TemplateEntity toTemplateEntity(V1_0Template template);
}

ソースオブジェクトである「V1_0Template」をターゲットオブジェクト「TemplateEntity」にマッピングすると、コンパイル時に次のようなコンパイルエラーが発生します。

Error:(8, 20) java: Unmapped target properties: "templateId, createdDateTime, updatedDateTime".

このとき、次のように「@Mapping」にマッピングされない属性は無視するように指定できます。

@Mapper(unmappedTargetPolicy = ReportingPolicy.ERROR)
public interface TemplateMapper {
    @Mapping(target = "templateId", ignore = true)
    @Mapping(target = "createdDateTime", ignore = true)
    @Mapping(target = "updatedDateTime", ignore = true)
    TemplateEntity toTemplateEntity(V1_0Template template);
}

5.直接実装

まれに、MapStructでマッピングコードが実装されていなかったり、または自分で実装しなければならないケースがあります。MapStructは基本メソッドを用いてマッピングメソッドを直接実装できるようにサポートしています。

下記は、オブジェクトを「String」にマッピングするため、メソッドを直接実装したサンプルです。

@Mapper
public interface JsonMapper {
    ObjectMapper OBJECT_MAPPER = new ObjectMapper();

    default String toString(Object obj) {
        try {
            return OBJECT_MAPPER.writeValueAsString(obj);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
}

6. java.lang.StackOverflowError

最後に、MapStructを使って発生したエラーについて説明します。

「TemplateEntity」の「templateParameters」属性を「Map」ではなく「JSON String」で保存します。ソースオブジェクトである「V1_0Template」の「templateParameters」属性は「Map」ですが、上記で実装した「JsonMapper」を使って「Map」から「JSON String」に変換することができます。

public class TemplateEntity {
    private String templateId;
    private String template;
    private String templateParameters;
    private OffsetDateTime createdDateTime;
    private OffsetDateTime updatedDateTime;
}

そして、次のように「MapStructMapperConfig」でMapStructの「Mapper」の設定を共通化しました。「Mapper」が多くなると「Mapper」別に設定することで重複が大きく発生するため、設定を共通化したと仮定します。

/**
* 特定のタイプやオブジェクト間のマッピングをMapStructでできない、または別のMapperを利用する必要がある場合は、「uses」を使用できる。
* 「uses = JsonMapper.class」に指定すると、Stringに変換が必要なときJsonMapperを使用する。
* /
@MapperConfig(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.ERROR, uses = JsonMapper.class)
public class MapStructMapperConfig {

}

各MapperでMapStructMapperConfigの設定を使用するようにします。

@Mapper(config = MapStructMapperConfig.class)
public interface TemplateMapper {
     ...

@Mapper(config = MapStructMapperConfig.class)
public interface JsonMapper {
    ...

しかし、コンパイルを進行すると、次のように「java.lang.StackOverflowError」が発生します。

"Error:(12, 8) java: Internal error in the mapping processor: java.lang.StackOverflowError  "

スタックオーバーフロー(Stack Overflow)の原因は、「JsonMapper」において「MapStructMapperConfig」の設定を使用しつつ、「uses = JsonMapper.class」設定が再び「JsonMapper」に適用され、循環(Cycle)が発生したためです。これを解決するには、次のように「MapStructMapperConfig」を使用しないように循環を切る必要があります。

@Mapper(componentModel = "spring")
public interface JsonMapper {
     ...

Policy&Strategy

マッピングポリシー(Policy)と戦略(Strategy)を設定することができます。以下はいくつかの有用なマッピングポリシーと戦略についての説明です。

ポリシー 説明
unmappedSourcePolicy IGNORE(default)
WARN
ERROR
ソース(Source)のフィールドがターゲット(Target)にマッピングされないときのポリシー
例:ERRORに設定すると、マッピング時にSource.aFieldが使用されていない場合、コンパイルエラーを発生させる。
unmappedTargetPolicy IGNORE,
WARN(default)
ERROR
ターゲットのフィールドがマッピングされないときのポリシー
例:ERRORに設定すると、マッピング時、Target.aFieldに値がマッピングされていない場合、コンパイルエラーを発生させる。
typeConversionPolicy IGNORE(default)
WARN
ERROR
タイプ変換時、流出の可能性があるときのポリシー
例:ERRORに設定すると、longからintに値を渡すとき、値に流出の可能性がある場合、コンパイルエラーを発生させる。
戦略 説明
nullValueMappingStrategy RETURN_NULL(default)
RETURN_DEFAULT
ソースがnullのときのポリシー
nullValuePropertyMappingStrategy SET_TO_NULL(default)
SET_TO_DEFAULT
IGNORE
ソースのフィールドがnullのときのポリシー

結論

ここまで、MapStructの機能と一部の使用方法について紹介しました。MapStructはオブジェクト間のマッピングにおいて、多くの部分を自動化してくれる素晴らしいツールだと思います。
去年の1年間(2018年9月〜2019年9月)において、MapStructの「Maven Central Repository」のダウンロード数も着実に増加しています。今後、MapStructもLombokのように広く使用されるように予想されます。

MapStructのさらに詳しい内容は、ホームページやガイドのドキュメントをご確認お願いします。

TOAST Meetup 編集部

TOASTの技術ナレッジやお得なイベント情報を発信していきます
pagetop