1. Введение
В предыдущем уроке мы видели, как сопоставлять списки с помощью ModelMapper .
В этом уроке мы собираемся показать , как сопоставлять наши данные между объектами с различной структурой в ModelMapper .
Хотя преобразование ModelMapper по умолчанию работает довольно хорошо в типичных случаях, мы в первую очередь сосредоточимся на том, как сопоставлять объекты, которые недостаточно похожи для обработки с использованием конфигурации по умолчанию.
Следовательно, на этот раз мы сосредоточимся на сопоставлении свойств и изменениях конфигурации.
2. Зависимость от Maven
Чтобы начать использовать библиотеку ModelMapper , мы добавим зависимость в наш pom.xml
:
<dependency>
<groupId>org.modelmapper</groupId>
<artifactId>modelmapper</artifactId>
<version>2.4.4</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() {
// 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. Что такое сопоставление свойств в ModelMapper
В наших проектах большую часть времени нам нужно настраивать наши DTO. Конечно, это приведет к различным полям, иерархиям и их нерегулярным сопоставлениям друг с другом. Иногда нам также нужно более одного DTO для одного источника и наоборот.
Таким образом, сопоставление свойств дает нам мощный способ расширить нашу логику сопоставления .
Давайте настроим наш GameDTO
, добавив новое поле createTime
:
public class GameDTO {
private Long id;
private String name;
private Long creationTime;
// constructors, getters and setters
}
И мы сопоставим поле
временной метки
Game
с полем createTime GameDTO
. Как мы заметили, на этот раз имя исходного поля отличается от имени поля назначения .
`` ****
Чтобы определить сопоставления свойств, мы будем использовать TypeMap ModelMapper.
Итак, давайте создадим объект TypeMap
и добавим сопоставление свойств с помощью его метода addMapping
:
@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. Глубокие сопоставления
Существуют также различные способы картографирования. Например, ModelMapper может отображать иерархии — поля на разных уровнях могут быть глубоко отображены .
Давайте определим поле String с именем
Creator
в GameDTO
. Однако поле создателя
источника в домене Game
имеет не простой тип, а объект — 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;
// ...
}
Таким образом, мы будем передавать в GameDTO не все данные объекта
Player
, а только поле имени
. Чтобы определить глубокое сопоставление, мы используем метод addMappings
TypeMap
и добавляем 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.
К счастью, ModelMapper поддерживает исключение свойств путем пропуска .
Исключим из передачи поле id
с помощью метода skip
:
@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());
}
Поэтому поле id
GameDTO
пропускается и не устанавливается.
4.3. Конвертер
Еще одним преимуществом ModelMapper является Converter
. Мы можем настроить преобразования для конкретных сопоставлений источника и назначения .
Предположим, у нас есть коллекция Player
в домене Game .
Давайте передадим количество Player
в GameDTO
.
В качестве первого шага мы определяем целочисленное поле totalPlayers
в GameDTO
:
public class GameDTO {
// ...
private int totalPlayers;
// constructors, getters and setters
}
Соответственно создаем collectionToSize
Converter
:
Converter<Collection, Integer> collectionToSize = c -> c.getSource().size();
Наконец, мы регистрируем наш конвертер
с помощью метода using
во время добавления нашего ExpressionMap
:
propertyMapper.addMappings(
mapper -> mapper.using(collectionToSize).map(Game::getPlayers, GameDTO::setTotalPlayers)
);
В результате мы сопоставляем getPlayers
().size()
Game
с полем
totalPlayers GameDTO :
@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 инициализировать его. Вот где Провайдер
пригодится.
Соответственно, Provider
ModelMapper — это встроенный способ настройки создания экземпляров целевых объектов `` .
Давайте сделаем преобразование, но не Game
to DTO, а Game
to Game
на этот раз.
Итак, в принципе, у нас есть постоянный игровой
домен, и мы получаем его из его репозитория. После этого мы обновляем экземпляр Game
, сливая в него другой объект Game :
@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 также поддерживает условное сопоставление . Один из его встроенных условных методов, которые мы можем использовать, — это Conditions.isNull()
.
Давайте пропустим поле id
, если оно равно null
в нашем исходном игровом
объекте:
@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());
}
Как мы заметили, используя условное выражение isNull
в сочетании с методом skip , мы защитили наш
идентификатор
назначения от перезаписи нулевым
значением.
Более того, мы также можем определить пользовательские Conditions
. Давайте определим условие, чтобы проверить, имеет ли поле временной метки
игры
значение: ``
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 обновляет поле createTime
GameDTO
только в том случае, если отметка времени
имеет значение больше нуля:
@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. Альтернативные способы отображения
Отображение свойств в большинстве случаев является хорошим подходом, потому что оно позволяет нам делать явные определения и четко видеть, как происходит отображение.
Однако для некоторых объектов, особенно когда у них разные иерархии свойств, мы можем использовать стратегию LOOSE
вместо TypeMap
.
5.1. Стратегия сопоставления LLOSE
Чтобы продемонстрировать преимущества свободного сопоставления, давайте добавим в GameDTO
еще два свойства :
public class GameDTO {
//...
private GameMode mode;
private int maxPlayers;
// constructors, getters and setters
}
Мы должны заметить, что режим
и maxPlayers
соответствуют свойствам GameSettings,
который является внутренним объектом в нашем исходном классе Game :
public class GameSettings {
private GameMode mode;
private int maxPlayers;
// constructors, getters and setters
}
Таким образом, мы можем выполнить двухстороннее отображение , как из Game
в GameDTO
, так и наоборот, не определяя никакого 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 есть некоторые глобальные конфигурации, которые могут быть полезны. Одним из них является настройка setSkipNullEnabled
.
Таким образом, мы можем автоматически пропустить исходные свойства, если они равны нулю
, без записи условного сопоставления :
@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. Объекты с круговыми ссылками
Иногда нам нужно иметь дело с объектами, которые имеют ссылки на самих себя. Как правило, это приводит к циклической зависимости и вызывает знаменитую ошибку 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());
}
Поэтому, когда мы передаем false
в setPreferNestedProperties
, сопоставление работает без каких-либо исключений.
6. Заключение
В этой статье мы объяснили, как настраивать сопоставления классов с помощью преобразователей свойств в ModelMapper. Также мы увидели несколько подробных примеров альтернативных конфигураций.
Как всегда, весь исходный код примеров доступен на GitHub .