1. 概述

在之前的教程中,我们已经学习了如何使用ModelMapper映射列表。本教程将重点展示如何在ModelMapper中处理结构不同的对象之间的数据映射。虽然ModelMapper的默认转换在典型场景下表现不错,但我们将主要关注那些差异较大、无法直接使用默认配置处理的对象匹配场景。因此,本次我们将聚焦于属性映射和配置调整。

2. Maven依赖

要开始使用ModelMapper库,我们需要在pom.xml中添加以下依赖:

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

3. 默认配置

当源对象和目标对象结构相似时,ModelMapper提供了开箱即用的解决方案。让我们看看领域对象Game和对应的数据传输对象GameDTO

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只包含两个字段,但字段类型和名称与源对象完全匹配。这种情况下,ModelMapper无需额外配置即可完成转换:

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

@Test
public void whenMapGameWithExactMatch_thenConvertsToDTO() {
    // 当源对象结构相似时
    Game game = new Game(1L, "Game 1");
    GameDTO gameDTO = this.mapper.map(game, GameDTO.class);
    
    // 默认映射生效
    assertEquals(game.getId(), gameDTO.getId());
    assertEquals(game.getName(), gameDTO.getName());
}

4. ModelMapper中的属性映射是什么?

在实际项目中,我们通常需要定制DTO。这会导致字段、层次结构以及它们之间的映射关系各不相同。有时,单个源对象还需要对应多个DTO(反之亦然)。属性映射为我们提供了强大的映射逻辑扩展能力

让我们通过给GameDTO添加新字段creationTime来定制它:

public class GameDTO {

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

    // constructors, getters and setters
}

现在需要将Gametimestamp字段映射到GameDTOcreationTime字段。注意这次源字段名与目标字段名不同

要定义属性映射,我们将使用ModelMapper的TypeMap

@Test
public void whenMapGameWithBasicPropertyMapping_thenConvertsToDTO() {
    // 设置
    TypeMap<Game, GameDTO> propertyMapper = this.mapper.createTypeMap(Game.class, GameDTO.class);
    propertyMapper.addMapping(Game::getTimestamp, GameDTO::setCreationTime);
    
    // 当字段名不同时
    Game game = new Game(1L, "Game 1");
    game.setTimestamp(Instant.now().getEpochSecond());
    GameDTO gameDTO = this.mapper.map(game, GameDTO.class);
    
    // 通过属性映射器完成映射
    assertEquals(game.getId(), gameDTO.getId());
    assertEquals(game.getName(), gameDTO.getName());
    assertEquals(game.getTimestamp(), gameDTO.getCreationTime());
}

4.1. 深层映射

映射还有多种方式。例如,ModelMapper可以处理层次结构——不同层级的字段可以进行深层映射

我们在GameDTO中定义一个名为creatorString字段。但源对象Game中的creator字段不是简单类型,而是一个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;
    
    // ...
}

我们不需要传输整个Player对象,只需将其name字段映射到GameDTO。**要定义深层映射,我们使用TypeMapaddMappings方法并添加ExpressionMap**:

@Test
public void whenMapGameWithDeepMapping_thenConvertsToDTO() {
    // 设置
    TypeMap<Game, GameDTO> propertyMapper = this.mapper.createTypeMap(Game.class, GameDTO.class);
    // 添加深层映射,将源对象的Player对象扁平化到目标对象的单个字段
    propertyMapper.addMappings(
      mapper -> mapper.map(src -> src.getCreator().getName(), GameDTO::setCreator)
    );
    
    // 当映射不同层次结构时
    Game game = new Game(1L, "Game 1");
    game.setCreator(new Player(1L, "John"));
    GameDTO gameDTO = this.mapper.map(game, GameDTO.class);
    
    // 验证
    assertEquals(game.getCreator().getName(), gameDTO.getCreator());
}

4.2. 跳过属性

有时我们不想在DTO中暴露所有数据。无论是为了保持DTO轻量还是隐藏敏感数据,这些原因都可能导致我们在转换时排除某些字段。幸运的是,ModelMapper支持通过跳过属性来排除字段

使用skip方法排除id字段的传输:

@Test
public void whenMapGameWithSkipIdProperty_thenConvertsToDTO() {
    // 设置
    TypeMap<Game, GameDTO> propertyMapper = this.mapper.createTypeMap(Game.class, GameDTO.class);
    propertyMapper.addMappings(mapper -> mapper.skip(GameDTO::setId));
    
    // 当跳过id字段时
    Game game = new Game(1L, "Game 1");
    GameDTO gameDTO = this.mapper.map(game, GameDTO.class);
    
    // 目标对象的id字段为null
    assertNull(gameDTO.getId());
    assertEquals(game.getName(), gameDTO.getName());
}

这样GameDTOid字段被跳过且未被设置。

4.3. 转换器(Converter)

ModelMapper的另一利器是Converter我们可以为特定的源到目标映射定制转换逻辑

假设Game领域对象中有一个Player集合。现在需要将Player的数量映射到GameDTO

首先在GameDTO中定义整型字段totalPlayers

public class GameDTO {
    // ...

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

然后创建collectionToSize转换器:

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

最后在添加ExpressionMap时通过using方法注册转换器:

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

这样就将GamegetPlayers().size()映射到GameDTOtotalPlayers字段:

@Test
public void whenMapGameWithCustomConverter_thenConvertsToDTO() {
    // 设置
    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)
    );
    
    // 当提供集合到大小的转换器时
    Game game = new Game();
    game.addPlayer(new Player(1L, "John"));
    game.addPlayer(new Player(2L, "Bob"));
    GameDTO gameDTO = this.mapper.map(game, GameDTO.class);
    
    // 将集合大小映射到自定义字段
    assertEquals(2, gameDTO.getTotalPlayers());
}

4.4. 提供者(Provider)

另一种场景是,有时我们需要为目标对象提供实例,而不是让ModelMapper初始化它。这时Provider就派上用场了。ModelMapper的Provider是定制目标对象实例化的内置方式

这次我们不映射到DTO,而是进行GameGame的转换。假设我们有一个持久化的Game领域对象,从仓库中获取它,然后通过合并另一个Game对象来更新它:

@Test
public void whenUsingProvider_thenMergesGameInstances() {
    // 设置
    TypeMap<Game, Game> propertyMapper = this.mapper.createTypeMap(Game.class, Game.class);
    // 从仓库获取Game实例的提供者
    Provider<Game> gameProvider = p -> this.gameRepository.findById(1L);
    propertyMapper.setProvider(gameProvider);
    
    // 当提供更新状态时
    Game update = new Game(1L, "Game Updated!");
    update.setCreator(new Player(1L, "John"));
    Game updatedGame = this.mapper.map(update, Game.class);
    
    // 在提供的实例上合并更新
    assertEquals(1L, updatedGame.getId().longValue());
    assertEquals("Game Updated!", updatedGame.getName());
    assertEquals("John", updatedGame.getCreator().getName());
}

4.5. 条件映射

ModelMapper还支持条件映射。内置条件方法之一是Conditions.isNull()

当源Game对象的idnull时跳过该字段:

@Test
public void whenUsingConditionalIsNull_thenMergesGameInstancesWithoutOverridingId() {
    // 设置
    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));
    
    // 当game没有id时
    Game update = new Game(null, "Not Persisted Game!");
    Game updatedGame = this.mapper.map(update, Game.class);
    
    // 目标对象的id不被覆盖
    assertEquals(2L, updatedGame.getId().longValue());
    assertEquals("Not Persisted Game!", updatedGame.getName());
}

通过结合isNull条件和skip方法,我们保护了目标对象的id不被null值覆盖。**此外,我们还可以定义自定义Condition**。

定义一个检查Gametimestamp字段是否有值的条件:

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)
);

这样ModelMapper只在timestamp值大于零时才更新GameDTOcreationTime字段:

@Test
public void whenUsingCustomConditional_thenConvertsDTOSkipsZeroTimestamp() {
    // 设置
    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)
    );
    
    // 当game的timestamp为零时
    Game game = new Game(1L, "Game 1");
    game.setTimestamp(0L);
    GameDTO gameDTO = this.mapper.map(game, GameDTO.class);
    
    // timestamp字段未被映射
    assertEquals(game.getId(), gameDTO.getId());
    assertEquals(game.getName(), gameDTO.getName());
    assertNotEquals(0L ,gameDTO.getCreationTime());
    
    // 当game的timestamp大于零时
    game.setTimestamp(Instant.now().getEpochSecond());
    gameDTO = this.mapper.map(game, GameDTO.class);
    
    // timestamp字段被映射
    assertEquals(game.getId(), gameDTO.getId());
    assertEquals(game.getName(), gameDTO.getName());
    assertEquals(game.getTimestamp() ,gameDTO.getCreationTime());
}

5. 替代映射方式

属性映射在大多数情况下是很好的方案,因为它允许显式定义并清晰展示映射流程。但对于某些对象,尤其是当它们具有不同的属性层次结构时,**我们可以使用LOOSE匹配策略替代TypeMap**。

5.1. LOOSE匹配策略

为展示松散匹配的优势,我们在GameDTO中添加两个属性:

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

注意modemaxPlayers对应GameSettings的属性,而GameSettings是源类Game的内部对象:

public class GameSettings {

    private GameMode mode;
    private int maxPlayers;

    // constructors, getters and setters
}

这样我们可以在不定义任何TypeMap的情况下实现双向映射(从GameGameDTO及反向):

@Test
public void whenUsingLooseMappingStrategy_thenConvertsToDomainAndDTO() {
    // 设置
    this.mapper.getConfiguration().setMatchingStrategy(MatchingStrategies.LOOSE);
    
    // 当DTO有GameSetting的扁平字段时
    GameDTO gameDTO = new GameDTO();
    gameDTO.setMode(GameMode.TURBO);
    gameDTO.setMaxPlayers(8);
    Game game = this.mapper.map(gameDTO, Game.class);
    
    // 无需属性映射器即可转换为内部对象
    assertEquals(gameDTO.getMode(), game.getSettings().getMode());
    assertEquals(gameDTO.getMaxPlayers(), game.getSettings().getMaxPlayers());
    
    // 当GameSetting的字段名匹配时
    game = new Game();
    game.setSettings(new GameSettings(GameMode.NORMAL, 6));
    gameDTO = this.mapper.map(game, GameDTO.class);
    
    // 在DTO上扁平化字段
    assertEquals(game.getSettings().getMode(), gameDTO.getMode());
    assertEquals(game.getSettings().getMaxPlayers(), gameDTO.getMaxPlayers());
}

5.2. 自动跳过null属性

此外,ModelMapper有一些有用的全局配置。其中之一是setSkipNullEnabled设置。这样我们可以自动跳过源对象中为null的属性,而无需编写任何条件映射

@Test
public void whenConfigurationSkipNullEnabled_thenConvertsToDTO() {
    // 设置
    this.mapper.getConfiguration().setSkipNullEnabled(true);
    TypeMap<Game, Game> propertyMap = this.mapper.createTypeMap(Game.class, Game.class);
    propertyMap.setProvider(p -> this.gameRepository.findById(2L));
    
    // 当game没有id时
    Game update = new Game(null, "Not Persisted Game!");
    Game updatedGame = this.mapper.map(update, Game.class);
    
    // 目标对象的id不被覆盖
    assertEquals(2L, updatedGame.getId().longValue());
    assertEquals("Not Persisted Game!", updatedGame.getName());
}

5.3. 循环引用对象

有时我们需要处理引用自身的对象。这通常会导致循环依赖,引发著名的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() {
    // 设置
    this.mapper.getConfiguration().setPreferNestedProperties(false);
    
    // 当game有循环引用: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);
    
    // 无异常完成转换
    assertEquals(game.getId(), gameDTO.getId());
    assertEquals(game.getName(), gameDTO.getName());
}

当给setPreferNestedProperties传入false时,映射可以正常完成而不会抛出异常。

6. 总结

本文介绍了如何使用ModelMapper的属性映射器定制类与类之间的映射。我们还通过详细示例探讨了替代配置方案。所有示例代码可在GitHub上获取。


原始标题:Guide to Using ModelMapper