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
}
现在需要将Game
的timestamp
字段映射到GameDTO
的creationTime
字段。注意这次源字段名与目标字段名不同。
要定义属性映射,我们将使用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
中定义一个名为creator
的String
字段。但源对象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
。**要定义深层映射,我们使用TypeMap
的addMappings
方法并添加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());
}
这样GameDTO
的id
字段被跳过且未被设置。
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)
);
这样就将Game
的getPlayers().size()
映射到GameDTO
的totalPlayers
字段:
@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,而是进行Game
到Game
的转换。假设我们有一个持久化的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
对象的id
为null
时跳过该字段:
@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
**。
定义一个检查Game
的timestamp
字段是否有值的条件:
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
值大于零时才更新GameDTO
的creationTime
字段:
@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
}
注意mode
和maxPlayers
对应GameSettings
的属性,而GameSettings
是源类Game
的内部对象:
public class GameSettings {
private GameMode mode;
private int maxPlayers;
// constructors, getters and setters
}
这样我们可以在不定义任何TypeMap
的情况下实现双向映射(从Game
到GameDTO
及反向):
@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上获取。