かんがるーさんの日記

最近自分が興味をもったものを調べた時の手順等を書いています。今は Spring Boot をいじっています。

Spring Boot + npm + Geb で入力フォームを作ってテストする ( 番外編 )( ModelMapper メモ書き )

概要

記事一覧はこちらです。

最近 POJO 間のデータコピーに ModelMapper を使用していますが、使っていて気づいた点やつまずいた点をメモしておきます。

尚、ModelMapper の利用には rozidan/modelmapper-spring-boot-starter を利用しています(利用しなくても ModelMapper を使うのは難しくありませんが少し便利になる感じです)。

参照したサイト・書籍

目次

  1. String --> int 変換は何も定義しなくてもやってくれる
  2. skip に指定したフィールドがプリミティブ型だと実行時に NullPointerException が発生する
  3. 部分的に特別な処理をしたい時には setPreConverter で定義する
  4. コピー元からコピー先へ通常のフィールドコピーが行われるフィールドに preConverter で特別な処理をする場合には、一緒に skip も指定して通常のフィールドコピーが行われないようにする
  5. sourceType, destinationType に指定するクラスが同じで変換ルールが異なる TypeMap を作りたい場合には name を設定する
  6. rozidan/modelmapper-spring-boot-starter を使わずに ModelMapper を使用するには?
  7. 最後に

本文

String --> int 変換は何も定義しなくてもやってくれる

String 型のフィールドを int 型のフィールドにコピーする場合、何か変換処理を入れないといけないのかと思っていましたが、何もしなくても変換してくれます。

@RunWith(SpringRunner.class)
@SpringBootTest
public class ModelMapperTest {

    @Autowired
    private ModelMapper modelMapper;

    @Data
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor
    static class SrcData {
        private String age;
    }

    @Data
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor
    static class DstData {
        private int age;
    }

    @Test
    public void string2IntTest() throws Exception {
        SrcData srcData = SrcData.builder()
                .age("25")
                .build();
        // String 型 --> int には何も用意しなくても自動で変換してくれる
        DstData dstData = modelMapper.map(srcData, DstData.class);
        assertThat(dstData.getAge()).isEqualTo(Integer.parseInt(srcData.getAge()));
    }

}

上のテストを実行すると成功します。

f:id:ksby:20171021145514p:plain

skip に指定したフィールドがプリミティブ型だと実行時に NullPointerException が発生する

テストクラス内でプリミティブ型のフィールドの setter を skip に指定しても正常に動作するのですが(assertThat まで実行されます)、

@RunWith(SpringRunner.class)
@SpringBootTest
public class ModelMapperTest {

    @Autowired
    private ModelMapper modelMapper;

    @Data
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor
    static class SrcData {
        private String age;
    }

    @Data
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor
    static class DstData {
        // コピー先をプリミティブ型(int)にする
        private int age;
    }

    @Component
    static class SrcData2DstDataTypeMap extends TypeMapConfigurer<SrcData, DstData> {
        @Override
        public void configure(TypeMap<SrcData, DstData> typeMap) {
            // プリミティブ型(int) の setter を skip に指定する
            typeMap.addMappings(mapping -> mapping.skip(DstData::setAge));
        }
    }

    @Test
    public void string2IntTest() throws Exception {
        SrcData srcData = SrcData.builder()
                .age("25")
                .build();
        DstData dstData = modelMapper.map(srcData, DstData.class);
        assertThat(dstData.getAge()).isEqualTo(Integer.parseInt(srcData.getAge()));
    }

}

f:id:ksby:20171021153543p:plain

以下のような Controller クラスを作成して、

@Controller
@RequestMapping("/sample")
public class SampleController {

    private final ModelMapper modelMapper;

    public SampleController(ModelMapper modelMapper) {
        this.modelMapper = modelMapper;
    }

    @Data
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor
    static class SrcData {
        private String age;
    }

    @Data
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor
    static class DstData {
        // コピー先をプリミティブ型(int)にする
        private int age;
    }

    @Component
    static class SrcData2DstDataTypeMap extends TypeMapConfigurer<SrcData, DstData> {
        @Override
        public void configure(TypeMap<SrcData, DstData> typeMap) {
            // プリミティブ型(int) の setter を skip に指定する
            typeMap.addMappings(mapping -> mapping.skip(DstData::setAge));
        }
    }

    @RequestMapping
    @ResponseBody
    public String index() {
        SrcData srcData = SrcData.builder()
                .age("25")
                .build();
        DstData dstData = modelMapper.map(srcData, DstData.class);
        return "sample";
    }

}

Tomcat を起動しようとすると NullPointerException が発生して起動しません。

f:id:ksby:20171021155408p:plain

これは skip に指定するフィールドがプリミティブ型であることが原因です。参照型に変更するとこのエラーは出ません。

    @Data
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor
    static class DstData {
        // コピー先をプリミティブ型(int)にする --> 参照型に変更する
        // private int age;
        private Integer age;
    }

int --> Integer に変更すると Tomcat は起動します。

f:id:ksby:20171021160945p:plain

部分的に特別な処理をしたい時には setPreConverter で定義する

コピー先にだけ存在するフィールドに、コピー元のフィールドの値を見て値をセットしたい場合、setPreConverter で定義します。

以下の処理を行うテストクラスを書いてみます。

  • DstData.name に SrcData.firstName + " " + SrcData.lastName をセットします。
  • DstData.name が空でない場合には DstData.isEmptyNameFlg に true を、そうでない場合には false をセットします。
  • setPreConverter で定義された処理の後に通常のフィールドの値のコピーは行われるので、firstName, lastName はそのまま SrcData --> DstData へコピーされます。
@RunWith(SpringRunner.class)
@SpringBootTest
public class ModelMapperTest {

    @Autowired
    private ModelMapper modelMapper;

    @Data
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor
    static class SrcData {
        private String firstName;
        private String lastName;
    }

    @Data
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor
    static class DstData {
        private String firstName;
        private String lastName;
        private String name;
        // コピー時に name に値がセットされれば true, 空なら false をセットする
        private boolean isEmptyNameFlg;
    }

    @Component
    static class GlobalConfiguration extends ConfigurationConfigurer {
        @Override
        public void configure(Configuration configuration) {
            // デフォルトの MatchingStrategies.STANDARD だと DstData.name のコピー元のフィールドとして
            // SrcData.firstName, SrcData.lastName の2つがあると判断されるため、MatchingStrategies.STRICT
            // に変更する
            configuration.setMatchingStrategy(MatchingStrategies.STRICT);
        }
    }

    @Component
    static class SrcData2DstDataTypeMap extends TypeMapConfigurer<SrcData, DstData> {
        @Override
        public void configure(TypeMap<SrcData, DstData> typeMap) {
            // setPreConverter に処理を定義して、コピー元に対応するフィールドがない
            // name, isEmptyNameFlg に値をセットする
            typeMap.setPreConverter(context -> {
                SrcData srcData = context.getSource();
                DstData dstData = context.getDestination();
                dstData.setName(String.format("%s %s"
                        , srcData.getFirstName(), srcData.getLastName()));
                dstData.setEmptyNameFlg(StringUtils.isNotEmpty(dstData.getName()));
                return context.getDestination();
            });
        }
    }

    @Test
    public void string2IntTest() throws Exception {
        SrcData srcData = SrcData.builder()
                .firstName("taro")
                .lastName("tanaka")
                .build();
        DstData dstData = modelMapper.map(srcData, DstData.class);
        assertThat(dstData.getFirstName()).isEqualTo(srcData.getFirstName());
        assertThat(dstData.getLastName()).isEqualTo(srcData.getLastName());
        assertThat(dstData.getName()).isEqualTo(
                String.format("%s %s", srcData.getFirstName(), srcData.getLastName()));
        assertThat(dstData.isEmptyNameFlg()).isTrue();
    }

}

上のテストを実行すると成功します。

f:id:ksby:20171021190710p:plain

コピー元からコピー先へ通常のフィールドコピーが行われるフィールドに preConverter で特別な処理をする場合には、一緒に skip も指定して通常のフィールドコピーが行われないようにする

コピー元とコピー先に同じフィールドが存在するがコピー時に setPreConverter に処理を定義して特殊な処理を行う場合、処理対象のフィールドが通常のフィールドのコピーの対象にならないよう skip で指定する必要があります。

@RunWith(SpringRunner.class)
@SpringBootTest
public class ModelMapperTest {

    @Autowired
    private ModelMapper modelMapper;

    @Data
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor
    static class SrcData {
        private String firstName;
        private String lastName;
    }

    @Data
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor
    static class DstData {
        private String firstName;
        private String lastName;
    }

    @Component
    static class SrcData2DstDataTypeMap extends TypeMapConfigurer<SrcData, DstData> {
        @Override
        public void configure(TypeMap<SrcData, DstData> typeMap) {
            typeMap.setPreConverter(context -> {
                SrcData srcData = context.getSource();
                DstData dstData = context.getDestination();
                // firstName はコピー時に先頭に文字数を追加する
                dstData.setFirstName(srcData.getFirstName().length() + ":" + srcData.getFirstName());
                return context.getDestination();
            });
            // setPreConverter で firstName のコピー処理をしているので、通常のフィールドコピーの対象外にする
            typeMap.addMappings(mapping -> mapping.skip(DstData::setFirstName));
        }
    }

    @Test
    public void string2IntTest() throws Exception {
        SrcData srcData = SrcData.builder()
                .firstName("taro")
                .lastName("tanaka")
                .build();
        DstData dstData = modelMapper.map(srcData, DstData.class);
        // firstName には "taro" の文字数が追加されて "4:taro" がセットされているはず
        assertThat(dstData.getFirstName()).isEqualTo("4:" + srcData.getFirstName());
        assertThat(dstData.getLastName()).isEqualTo(srcData.getLastName());
    }

}

上のテストを実行すると成功します。

f:id:ksby:20171021200144p:plain

ちなみに skip をコメントアウトすると

    @Component
    static class SrcData2DstDataTypeMap extends TypeMapConfigurer<SrcData, DstData> {
        @Override
        public void configure(TypeMap<SrcData, DstData> typeMap) {
            typeMap.setPreConverter(context -> {
                SrcData srcData = context.getSource();
                DstData dstData = context.getDestination();
                // firstName はコピー時に先頭に文字数を追加する
                dstData.setFirstName(srcData.getFirstName().length() + ":" + srcData.getFirstName());
                return context.getDestination();
            });
            // setPreConverter で firstName のコピー処理をしているので、通常のフィールドコピーの対象外にする
//            typeMap.addMappings(mapping -> mapping.skip(DstData::setFirstName));
        }
    }

フィールドのコピー処理が行われるのでテストは失敗します。

f:id:ksby:20171021200449p:plain

sourceType, destinationType に指定するクラスが同じで変換ルールが異なる TypeMap を作りたい場合には name を設定する

SrcData --> DstData へデータをコピーするのは同じですが、内部の処理が異なる TypeMap を2つ定義したいような場合には、TypeMap に名前を付けるようにします。そして ModelMapper#map メソッドを呼ぶ時に、第3引数に使用する TypeMap 名を指定します。

@RunWith(SpringRunner.class)
@SpringBootTest
public class ModelMapperTest {

    @Autowired
    private ModelMapper modelMapper;

    @Data
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor
    static class SrcData {
        private String firstName;
        private String lastName;
    }

    @Data
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor
    static class DstData {
        private String firstName;
        private String lastName;
    }

    /**
     * DstData.firstName に SrcData.firstName + " " + SrcData.lastName をセットする
     * DstData.lastName には何もコピーしない
     */
    @Component
    static class CopyFirstNameOnlyTypeMap extends TypeMapConfigurer<SrcData, DstData> {
        // ①外から参照できるよう public static final String の定数に TypeMap 名を定義する
        public static final String TYPEMAP_NAME = "CopyFirstNameOnlyTypeMap";

        // ②getTypeMapName メソッドをオーバーライドして、TypeMap 名を返す
        @Override
        public String getTypeMapName() {
            return TYPEMAP_NAME;
        }

        @Override
        public void configure(TypeMap<SrcData, DstData> typeMap) {
            typeMap.setPreConverter(context -> {
                SrcData srcData = context.getSource();
                DstData dstData = context.getDestination();
                dstData.setFirstName(String.format("%s %s", srcData.getFirstName(), srcData.getLastName()));
                return context.getDestination();
            });
            typeMap.addMappings(mapping -> mapping.skip(DstData::setFirstName));
            typeMap.addMappings(mapping -> mapping.skip(DstData::setLastName));
        }
    }

    /**
     * DstData.firstName には何もコピーしない
     * DstData.lastName に SrcData.firstName + " " + SrcData.lastName をセットする
     */
    @Component
    static class CopyLastNameOnlyTypeMap extends TypeMapConfigurer<SrcData, DstData> {
        public static final String TYPEMAP_NAME = "CopyLastNameOnlyTypeMap";

        @Override
        public String getTypeMapName() {
            return TYPEMAP_NAME;
        }

        @Override
        public void configure(TypeMap<SrcData, DstData> typeMap) {
            typeMap.setPreConverter(context -> {
                SrcData srcData = context.getSource();
                DstData dstData = context.getDestination();
                dstData.setLastName(String.format("%s %s", srcData.getFirstName(), srcData.getLastName()));
                return context.getDestination();
            });
            typeMap.addMappings(mapping -> mapping.skip(DstData::setFirstName));
            typeMap.addMappings(mapping -> mapping.skip(DstData::setLastName));
        }
    }

    @Test
    public void string2IntTest() throws Exception {
        SrcData srcData = SrcData.builder()
                .firstName("taro")
                .lastName("tanaka")
                .build();
        // ③map メソッドの第3引数に使用する TypeMap 名を指定する
        // ※TypeMap Bean をインジェクションして、getTypeMapName メソッドを呼んでもよい
        DstData dstDataFirst = modelMapper.map(srcData, DstData.class, CopyFirstNameOnlyTypeMap.TYPEMAP_NAME);
        assertThat(dstDataFirst.getFirstName()).isEqualTo(srcData.getFirstName() + " " + srcData.getLastName());
        assertThat(dstDataFirst.getLastName()).isNull();

        DstData dstDataLast = modelMapper.map(srcData, DstData.class, CopyLastNameOnlyTypeMap.TYPEMAP_NAME);
        assertThat(dstDataLast.getFirstName()).isNull();
        assertThat(dstDataLast.getLastName()).isEqualTo(srcData.getFirstName() + " " + srcData.getLastName());
    }

}

上のテストを実行すると成功します。

f:id:ksby:20171021212245p:plain

rozidan/modelmapper-spring-boot-starter を使わずに ModelMapper を使用するには?

Java Config で modelMapper Bean を定義し、

@Configuration
public class ModelMapperConfig {

    @Bean
    public ModelMapper modelMapper() {
        ModelMapper modelMapper = new ModelMapper();
        modelMapper.getConfiguration().setMatchingStrategy(MatchingStrategies.STRICT);
        return modelMapper;
    }

}

@Component アノテーションを付加したクラスのコンストラクタで ModelMapper#createTypeMap メソッドを呼び出して TypeMap を生成・登録します。あとは使用したい箇所で ModelMapper#map メソッドを呼び出します。

@RunWith(SpringRunner.class)
@SpringBootTest
public class ModelMapperTest {

    @Autowired
    private ModelMapper modelMapper;

    @Data
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor
    static class SrcData {
        private String firstName;
        private String lastName;
    }

    @Data
    @Builder
    @NoArgsConstructor
    @AllArgsConstructor
    static class DstData {
        private String firstName;
        private String lastName;
    }

    @Component
    static class SrcData2DstDataTypeMap {
        private SrcData2DstDataTypeMap(ModelMapper modelMapper) {
            TypeMap<SrcData, DstData> typeMap
                    = modelMapper.createTypeMap(SrcData.class, DstData.class);

            // setPreConverter に処理を定義して、コピー元に対応するフィールドがない
            // name, isEmptyNameFlg に値をセットする
            typeMap.setPreConverter(context -> {
                SrcData srcData = context.getSource();
                DstData dstData = context.getDestination();
                // firstName はコピー時に先頭に文字数を追加する
                dstData.setFirstName(srcData.getFirstName().length() + ":" + srcData.getFirstName());
                return context.getDestination();
            });
            // setPreConverter で firstName のコピー処理をしているので、通常のフィールドコピーの対象外にする
            typeMap.addMappings(mapping -> mapping.skip(DstData::setFirstName));
        }
    }

    @Test
    public void string2IntTest() throws Exception {
        SrcData srcData = SrcData.builder()
                .firstName("taro")
                .lastName("tanaka")
                .build();
        DstData dstData = modelMapper.map(srcData, DstData.class);
        // firstName には "taro" の文字数が追加されて "4:taro" がセットされているはず
        assertThat(dstData.getFirstName()).isEqualTo("4:" + srcData.getFirstName());
        assertThat(dstData.getLastName()).isEqualTo(srcData.getLastName());
    }

}

rozidan/modelmapper-spring-boot-starter を入れると、modelMapper Bean を自動生成してくれるのと、ModelMapper#createTypeMap の呼び出しを自動でやってくれます。

最後に

  • ModelMapper はいろいろ機能がありますが、modelMapper.map(...) を呼び出してコピーするか、単純なコピーでない場合には TypeMap を作成すればとりあえず使えるという印象です。
  • Matching strategy は個人的には STRICT にしておいた方が問題がない気がするのですが、まだそんなに使い込んでいる訳ではないので何とも言えないですね。
  • POJO をコピーするのに Dozer もありますが、XML で定義しないといけないし、ModelMapper でもそんなに困らない気がしていて、個人的には ModelMapper でいいんじゃないかなと思っています。

履歴

2017/10/22
初版発行。