概要
記事一覧はこちらです。
最近 POJO 間のデータコピーに ModelMapper を使用していますが、使っていて気づいた点やつまずいた点をメモしておきます。
尚、ModelMapper の利用には rozidan/modelmapper-spring-boot-starter を利用しています(利用しなくても ModelMapper を使うのは難しくありませんが少し便利になる感じです)。
参照したサイト・書籍
目次
- String --> int 変換は何も定義しなくてもやってくれる
- skip に指定したフィールドがプリミティブ型だと実行時に NullPointerException が発生する
- 部分的に特別な処理をしたい時には setPreConverter で定義する
- コピー元からコピー先へ通常のフィールドコピーが行われるフィールドに preConverter で特別な処理をする場合には、一緒に skip も指定して通常のフィールドコピーが行われないようにする
- sourceType, destinationType に指定するクラスが同じで変換ルールが異なる TypeMap を作りたい場合には name を設定する
- rozidan/modelmapper-spring-boot-starter を使わずに ModelMapper を使用するには?
- 最後に
本文
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())); } }
上のテストを実行すると成功します。
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())); } }
以下のような 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 が発生して起動しません。
これは skip に指定するフィールドがプリミティブ型であることが原因です。参照型に変更するとこのエラーは出ません。
@Data @Builder @NoArgsConstructor @AllArgsConstructor static class DstData { // コピー先をプリミティブ型(int)にする --> 参照型に変更する // private int age; private Integer age; }
int --> Integer に変更すると Tomcat は起動します。
部分的に特別な処理をしたい時には 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(); } }
上のテストを実行すると成功します。
コピー元からコピー先へ通常のフィールドコピーが行われるフィールドに 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()); } }
上のテストを実行すると成功します。
ちなみに 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)); } }
フィールドのコピー処理が行われるのでテストは失敗します。
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()); } }
上のテストを実行すると成功します。
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
初版発行。