1. Overview

前のチュートリアルでは、ModelMapperを使用してリストをマップする方法を見てきました。

In this tutorial, we’re going to show how to map our data between differently structured objects in ModelMapper.

ModelMapperのデフォルトの変換は通常の場合はかなりうまく機能しますが、デフォルトの構成を使用して処理するのに十分に類似していないオブジェクトを照合する方法に主に焦点を当てます。

So, we’ll set our sights on property mappings and configuration changes this time.

2. Mavenの依存関係

ModelMapper ライブラリの使用を開始するには、pom.xmlに依存関係を追加します。

<dependency>
    <groupId>org.modelmapper</groupId>
    <artifactId>modelmapper</artifactId>
    <version>2.4.4</version>
</dependency>

3. デフォルト設定

ModelMapperは、ソースオブジェクトと宛先オブジェクトが互いに類似している場合のドロップインソリューションを提供します。

Let’s have a look at Game and GameDTO, our domain object and corresponding data transfer object, respectively:

public class Game {

    private Long id;
    private String name;
    private Long timestamp;

    private Player creator;
    private List<Player> players = new ArrayList<>();

    private GameSettings settings;

    // constructors, getters and setters
}

public class GameDTO {

    private Long id;
    private String name;

    // constructors, getters and setters
}

GameDTO には2つのフィールドしか含まれていませんが、フィールドのタイプと名前はソースと完全に一致しています。

このような場合、ModelMapperは追加の構成なしで変換を処理します。

@BeforeEach
public void setup() {
    this.mapper = new ModelMapper();
}

@Test
public void whenMapGameWithExactMatch_thenConvertsToDTO() {
    // when similar source object is provided
    Game game = new Game(1L, "Game 1");
    GameDTO gameDTO = this.mapper.map(game, GameDTO.class);
    
    // then it maps by default
    assertEquals(game.getId(), gameDTO.getId());
    assertEquals(game.getName(), gameDTO.getName());
}

4. What Is Property Mapping in ModelMapper?

私たちのプロジェクトでは、ほとんどの場合、DTOをカスタマイズする必要があります。 Of course, this will result in different fields, hierarchies and their irregular mappings to each other. 場合によっては、単一のソースに対して複数のDTOが必要になることもあり、その逆もあります。

Therefore, property mapping gives us a powerful way to extend our mapping logic.

新しいフィールドcreationTimeを追加して、GameDTOをカスタマイズしましょう。

public class GameDTO {

    private Long id;
    private String name;
    private Long creationTime;

    // constructors, getters and setters
}

And we’ll map Game‘s timestamp field into GameDTO‘s creationTime field. Notice that the source field name is different from the destination field name this time.

To define property mappings, we’ll use ModelMapper’s TypeMap.

So, let’s create a TypeMap object and add a property mapping via its addMapping method:

@Test
public void whenMapGameWithBasicPropertyMapping_thenConvertsToDTO() {
    // setup
    TypeMap<Game, GameDTO> propertyMapper = this.mapper.createTypeMap(Game.class, GameDTO.class);
    propertyMapper.addMapping(Game::getTimestamp, GameDTO::setCreationTime);
    
    // when field names are different
    Game game = new Game(1L, "Game 1");
    game.setTimestamp(Instant.now().getEpochSecond());
    GameDTO gameDTO = this.mapper.map(game, GameDTO.class);
    
    // then it maps via property mapper
    assertEquals(game.getId(), gameDTO.getId());
    assertEquals(game.getName(), gameDTO.getName());
    assertEquals(game.getTimestamp(), gameDTO.getCreationTime());
}

4.1. ディープマッピング

マッピングにはさまざまな方法もあります。 For instance, ModelMapper can map hierarchies — fields at different levels can be mapped deeply.

Let’s define a String field named creator in GameDTO.

However, the source creator field on the Game domain isn’t a simple type but an object — Player:

public class Player {

    private Long id;
    private String name;
    
    // constructors, getters and setters
}

public class Game {
    // ...
    
    private Player creator;
    
    // ...
}

public class GameDTO {
    // ...
    
    private String creator;
    
    // ...
}

So, we won’t transfer the entire Player object’s data but only the name field, to GameDTO.

In order to define the deep mapping, we use TypeMap‘s addMappings method and add an ExpressionMap:

@Test
public void whenMapGameWithDeepMapping_thenConvertsToDTO() {
    // setup
    TypeMap<Game, GameDTO> propertyMapper = this.mapper.createTypeMap(Game.class, GameDTO.class);
    // add deep mapping to flatten source's Player object into a single field in destination
    propertyMapper.addMappings(
      mapper -> mapper.map(src -> src.getCreator().getName(), GameDTO::setCreator)
    );
    
    // when map between different hierarchies
    Game game = new Game(1L, "Game 1");
    game.setCreator(new Player(1L, "John"));
    GameDTO gameDTO = this.mapper.map(game, GameDTO.class);
    
    // then
    assertEquals(game.getCreator().getName(), gameDTO.getCreator());
}

4.2. プロパティをスキップする

DTO内のすべてのデータを公開したくない場合があります。 DTOを軽量に保つか、いくつかの適切なデータを隠すかにかかわらず、これらの理由により、DTOに転送するときに一部のフィールドが除外される可能性があります。

Luckily, ModelMapper supports property exclusion via skipping.

skip メソッドを使用して、idフィールドを転送から除外しましょう。

@Test
public void whenMapGameWithSkipIdProperty_thenConvertsToDTO() {
    // setup
    TypeMap<Game, GameDTO> propertyMapper = this.mapper.createTypeMap(Game.class, GameDTO.class);
    propertyMapper.addMappings(mapper -> mapper.skip(GameDTO::setId));
    
    // when id is skipped
    Game game = new Game(1L, "Game 1");
    GameDTO gameDTO = this.mapper.map(game, GameDTO.class);
    
    // then destination id is null
    assertNull(gameDTO.getId());
    assertEquals(game.getName(), gameDTO.getName());
}

したがって、GameDTOidフィールドはスキップされ、設定されません。

4.3. コンバーター

ModelMapperのもう1つのプロビジョニングは、Converterです。 We can customize conversions for specific sources to destination mappings.

GameドメインにPlayerのコレクションがあるとします。 PlayerのカウントをGameDTOに転送してみましょう。

最初のステップとして、GameDTOに整数フィールドtotalPlayersを定義します。

public class GameDTO {
    // ...

    private int totalPlayers;
  
    // constructors, getters and setters
}

それぞれ、 collectionToSize Converterを作成します。

Converter<Collection, Integer> collectionToSize = c -> c.getSource().size();

最後に、 ExpressionMap を追加しながら、usingメソッドを介してConverterを登録します。

propertyMapper.addMappings(
  mapper -> mapper.using(collectionToSize).map(Game::getPlayers, GameDTO::setTotalPlayers)
);

その結果、 GamegetPlayers()。size()GameDTOtotalPlayersフィールドにマップします。

@Test
public void whenMapGameWithCustomConverter_thenConvertsToDTO() {
    // setup
    TypeMap<Game, GameDTO> propertyMapper = this.mapper.createTypeMap(Game.class, GameDTO.class);
    Converter<Collection, Integer> collectionToSize = c -> c.getSource().size();
    propertyMapper.addMappings(
      mapper -> mapper.using(collectionToSize).map(Game::getPlayers, GameDTO::setTotalPlayers)
    );
    
    // when collection to size converter is provided
    Game game = new Game();
    game.addPlayer(new Player(1L, "John"));
    game.addPlayer(new Player(2L, "Bob"));
    GameDTO gameDTO = this.mapper.map(game, GameDTO.class);
    
    // then it maps the size to a custom field
    assertEquals(2, gameDTO.getTotalPlayers());
}

4.4. プロバイダー

別のユースケースでは、ModalMapperに初期化させるのではなく、宛先オブジェクトのインスタンスを提供する必要がある場合があります。 ここでプロバイダーが役に立ちます。

Accordingly, ModelMapper’s Provider is the built-in way to customize the instantiation of destination objects.

今回はゲームからDTOではなく、ゲームからゲームに変換してみましょう。

So, in principle, we have a persisted Game domain, and we fetch it from its repository.

After that, we update the Game instance by merging another Game object into it:

@Test
public void whenUsingProvider_thenMergesGameInstances() {
    // setup
    TypeMap<Game, Game> propertyMapper = this.mapper.createTypeMap(Game.class, Game.class);
    // a provider to fetch a Game instance from a repository
    Provider<Game> gameProvider = p -> this.gameRepository.findById(1L);
    propertyMapper.setProvider(gameProvider);
    
    // when a state for update is given
    Game update = new Game(1L, "Game Updated!");
    update.setCreator(new Player(1L, "John"));
    Game updatedGame = this.mapper.map(update, Game.class);
    
    // then it merges the updates over on the provided instance
    assertEquals(1L, updatedGame.getId().longValue());
    assertEquals("Game Updated!", updatedGame.getName());
    assertEquals("John", updatedGame.getCreator().getName());
}

4.5. 条件付きマッピング

ModelMapper also supports conditional mapping. One of its built-in conditional methods we can use is Conditions.isNull().

ソースのGameオブジェクトでnullの場合は、idフィールドをスキップしましょう。

@Test
public void whenUsingConditionalIsNull_thenMergesGameInstancesWithoutOverridingId() {
    // setup
    TypeMap<Game, Game> propertyMapper = this.mapper.createTypeMap(Game.class, Game.class);
    propertyMapper.setProvider(p -> this.gameRepository.findById(2L));
    propertyMapper.addMappings(mapper -> mapper.when(Conditions.isNull()).skip(Game::getId, Game::setId));
    
    // when game has no id
    Game update = new Game(null, "Not Persisted Game!");
    Game updatedGame = this.mapper.map(update, Game.class);
    
    // then destination game id is not overwritten
    assertEquals(2L, updatedGame.getId().longValue());
    assertEquals("Not Persisted Game!", updatedGame.getName());
}

Notice that by using the isNull conditional combined with the skip method, we guarded our destination id against overwriting with a null value.

Moreover, we can also define custom Conditions.

Let’s define a condition to check if the Game‘s timestamp field has a value:

Condition<Long, Long> hasTimestamp = ctx -> ctx.getSource() != null && ctx.getSource() > 0;

次に、whenメソッドを使用してプロパティマッパーで使用します。

TypeMap<Game, GameDTO> propertyMapper = this.mapper.createTypeMap(Game.class, GameDTO.class);
Condition<Long, Long> hasTimestamp = ctx -> ctx.getSource() != null && ctx.getSource() > 0;
propertyMapper.addMappings(
  mapper -> mapper.when(hasTimestamp).map(Game::getTimestamp, GameDTO::setCreationTime)
);

Finally, ModelMapper only updates the GameDTO‘s creationTime field if the timestamp has a value greater than zero:

@Test
public void whenUsingCustomConditional_thenConvertsDTOSkipsZeroTimestamp() {
    // setup
    TypeMap<Game, GameDTO> propertyMapper = this.mapper.createTypeMap(Game.class, GameDTO.class);
    Condition<Long, Long> hasTimestamp = ctx -> ctx.getSource() != null && ctx.getSource() > 0;
    propertyMapper.addMappings(
      mapper -> mapper.when(hasTimestamp).map(Game::getTimestamp, GameDTO::setCreationTime)
    );
    
    // when game has zero timestamp
    Game game = new Game(1L, "Game 1");
    game.setTimestamp(0L);
    GameDTO gameDTO = this.mapper.map(game, GameDTO.class);
    
    // then timestamp field is not mapped
    assertEquals(game.getId(), gameDTO.getId());
    assertEquals(game.getName(), gameDTO.getName());
    assertNotEquals(0L ,gameDTO.getCreationTime());
    
    // when game has timestamp greater than zero
    game.setTimestamp(Instant.now().getEpochSecond());
    gameDTO = this.mapper.map(game, GameDTO.class);
    
    // then timestamp field is mapped
    assertEquals(game.getId(), gameDTO.getId());
    assertEquals(game.getName(), gameDTO.getName());
    assertEquals(game.getTimestamp() ,gameDTO.getCreationTime());
}

5. マッピングの代替方法

プロパティマッピングは、明示的な定義を作成し、マッピングがどのように流れるかを明確に確認できるため、ほとんどの場合に適したアプローチです。

However, for some objects, especially when they have different property hierarchies, we can use the LOOSE matching strategy instead of TypeMap.

5.1. マッチング戦略LOOSE

緩いマッチングの利点を示すために、GameDTOにさらに2つのプロパティを追加しましょう。

public class GameDTO {
    //...
    
    private GameMode mode;
    private int maxPlayers;
    
    // constructors, getters and setters
}

Notice that mode and maxPlayers correspond to the properties of GameSettings, which is an inner object in our Game source class:

public class GameSettings {

    private GameMode mode;
    private int maxPlayers;

    // constructors, getters and setters
}

This way, we can perform a two-way mapping, both from Game to GameDTO and the other way around without defining any TypeMap:

@Test
public void whenUsingLooseMappingStrategy_thenConvertsToDomainAndDTO() {
    // setup
    this.mapper.getConfiguration().setMatchingStrategy(MatchingStrategies.LOOSE);
    
    // when dto has flat fields for GameSetting
    GameDTO gameDTO = new GameDTO();
    gameDTO.setMode(GameMode.TURBO);
    gameDTO.setMaxPlayers(8);
    Game game = this.mapper.map(gameDTO, Game.class);
    
    // then it converts to inner objects without property mapper
    assertEquals(gameDTO.getMode(), game.getSettings().getMode());
    assertEquals(gameDTO.getMaxPlayers(), game.getSettings().getMaxPlayers());
    
    // when the GameSetting's field names match
    game = new Game();
    game.setSettings(new GameSettings(GameMode.NORMAL, 6));
    gameDTO = this.mapper.map(game, GameDTO.class);
    
    // then it flattens the fields on dto
    assertEquals(game.getSettings().getMode(), gameDTO.getMode());
    assertEquals(game.getSettings().getMaxPlayers(), gameDTO.getMaxPlayers());
}

5.2. ヌルプロパティの自動スキップ

さらに、ModelMapperには、役立つグローバル構成がいくつかあります。 それらの1つは、setSkipNullEnabled設定です。

したがって、条件付きマッピングを記述せずにソースプロパティがnullの場合、自動的にスキップできます

@Test
public void whenConfigurationSkipNullEnabled_thenConvertsToDTO() {
    // setup
    this.mapper.getConfiguration().setSkipNullEnabled(true);
    TypeMap<Game, Game> propertyMap = this.mapper.createTypeMap(Game.class, Game.class);
    propertyMap.setProvider(p -> this.gameRepository.findById(2L));
    
    // when game has no id
    Game update = new Game(null, "Not Persisted Game!");
    Game updatedGame = this.mapper.map(update, Game.class);
    
    // then destination game id is not overwritten
    assertEquals(2L, updatedGame.getId().longValue());
    assertEquals("Not Persisted Game!", updatedGame.getName());
}

5.3. 循環参照オブジェクト

Sometimes, we need to deal with objects that have references to themselves.

一般に、これにより循環依存が発生し、有名なStackOverflowErrorが発生します。

org.modelmapper.MappingException: ModelMapper mapping errors:

1) Error mapping com.bealdung.domain.Game to com.bealdung.dto.GameDTO

1 error
	...
Caused by: java.lang.StackOverflowError
	...

したがって、別の構成 setPreferNestedProperties は、この場合に役立ちます。

@Test
public void whenConfigurationPreferNestedPropertiesDisabled_thenConvertsCircularReferencedToDTO() {
    // setup
    this.mapper.getConfiguration().setPreferNestedProperties(false);
    
    // when game has circular reference: Game -> Player -> Game
    Game game = new Game(1L, "Game 1");
    Player player = new Player(1L, "John");
    player.setCurrentGame(game);
    game.setCreator(player);
    GameDTO gameDTO = this.mapper.map(game, GameDTO.class);
    
    // then it resolves without any exception
    assertEquals(game.getId(), gameDTO.getId());
    assertEquals(game.getName(), gameDTO.getName());
}

したがって、falsesetPreferNestedPropertiesに渡すと、マッピングは例外なく機能します。

6. 結論

In this article, we explained how to customize class-to-class mappings with property mappers in ModelMapper.

We also saw some detailed examples of alternative configurations.

As always, all the source code for the examples is available over on GitHub.