Перейти к основному содержимому

Руководство по использованию ModelMapper

· 10 мин. чтения

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 .