Иногда в проектах требуется скопировать атрибуты одного объекта в другой, временами не напрямую, а с некоторыми преобразованиями, например извлечением подстроки или наоборот, объединением нескольких полей родительского объекта в единое поле дочернего. Такие преобразования называются маппингами (mappings). Чаще всего они применяются там, где необходимо передать Java-объект без избыточных чувствительных данных. Об этом ниже.
Data transfer object (DTO)
DTO – это паттерн проектирования и (как вывод) объект, который передаёт данные между процессами. Причина использования таких объектов чаще всего вызвана объективными физическими ограничениями системы. Передача данных через удалённые интерфейсы (например веб-сервисы) является затратной операцией, а большинство накладных расходов скрывается именно в обмене данными между клиентом и сервером. Разумно попытаться сократить их и снизить количество вызовов, упаковывая данные сразу нескольких запросов в один объект (DTO). То есть DTO позволяет упаковать большее количество данных в один удалённый вызов.
DTO отличается от бизнес-объектов и DAO-объектов тем, что не определяет никакого поведения, кроме хранения, получения, сериализации и десериализации собственных данных. Другими словами DTO – это объекты, которые не должны содержать какой-либо бизнес-логики, но могут иметь встроенные механизмы сериализации/десериализации. DTO также может пригодится при обмене данными между front- и back end’ами системы, изолируя данные front части от чувстивтельной информации, используемой в back-end’е.
А причём здесь Dozer?
Dozer – это mapper, который умеет рекурсивно копировать данные между объектами. Обычно речь идёт об объектах разных типов. Dozer хорошо подходит для формирования DTO-объектов.
Dozer поддерживает простой маппинг по полям, сложные маппинги, двунаправленный маппинг и многое другое. Еще он умеет выполнять автоматические преобразования типов и поддерживает кастомные трансформации с использованием XML-конфигураций.
Под капотом Dozer вовсю трудится Java Reflection, что даёт ему ряд преимуществ и (пару) недостатков. К последним, к сожалению, относится в том числе производительность (об этом чуть ниже).
Чаще всего Dozer применяется на границах системы (выход/выход). Он гарантирует, что внутренние доменные объекты из баз данных не добегут до уровней представления вашего приложения или к соседним системам. Он также поможет при маппинге доменных объектов к формату запросов к внешним API и наоборот.

С остальными подробностями вы можете ознакомиться в официальной документации к Dozer (из которой я щедро черпал вдохновение при написании этой статьи). Я же перейду к примерам его использования.
Пример маппингов с использованием Dozer
В качестве IDE я использую замечательную Intellij Idea, для сборки и управления зависимостями – Maven. Мы будем также использовать Spring Boot и Lombok, pom.xml проекта приведён ниже. Обратите внимание, что артефакт dozer-spring требуется для интеграции Dozer и Spring (в нашем случае – Spring Boot), используемая версия Dozer – 5.5.1.
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>java-msk-ru</groupId>
<artifactId>dozer</artifactId>
<version>1.0-SNAPSHOT</version>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.0.3.RELEASE</version>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.2</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>net.sf.dozer</groupId>
<artifactId>dozer</artifactId>
<version>5.5.1</version>
</dependency>
<!-- https://mvnrepository.com/artifact/net.sf.dozer/dozer-spring -->
<dependency>
<groupId>net.sf.dozer</groupId>
<artifactId>dozer-spring</artifactId>
<version>5.5.1</version>
</dependency>
<!-- https://mvnrepository.com/artifact/junit/junit -->
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.10</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-test</artifactId>
<version>2.0.3.RELEASE</version>
</dependency>
</dependencies>
<properties>
<java.version>1.8</java.version>
</properties>
</project>
Мы будем конфигурировать Dozer при помощи XML. В папке resources проекта создадим файл dozermapper.xml со следующим содержанием:
<?xml version="1.0" encoding="UTF-8"?>
<mappings xmlns="http://dozer.sourceforge.net"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://dozer.sourceforge.net
http://dozer.sourceforge.net/schema/beanmapping.xsd">
<mapping type="one-way">
<class-a></class-a>
<class-b></class-b>
</mapping>
</mappings>
В application.properties проекта пропишем строку dozer.configuration=dozermapper.xml. В дальнейшем мы привяжем этот параметр к одному из бинов интеграции с Dozer.
Начнём с класса SpringBootApplication.java, не забудьте поместить его в пакет (Spring Boot не запустится из пакета по умолчанию):
package app;
import org.dozer.DozerBeanMapper;
import org.dozer.spring.DozerBeanMapperFactoryBean;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.SpringApplication;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Scope;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.Resource;
@org.springframework.boot.autoconfigure.SpringBootApplication
public class SpringBootApplication {
// wiring the dozer config to the field
@Value(value = "${dozer.configuration}")
private String dozerCfg;
// defining DozerBeanMapperFactoryBean
@Bean(name = "dozerBeanMapperFactoryBean")
@Scope("singleton")
public DozerBeanMapperFactoryBean dozerBeanFactory() {
DozerBeanMapperFactoryBean dozerFactoryBean = new DozerBeanMapperFactoryBean();
Resource[] resources = new Resource[1];
resources[0] = new ClassPathResource(dozerCfg);
dozerFactoryBean.setMappingFiles(resources);
return dozerFactoryBean;
}
// defining DozerBeanMapper
@Bean(name = "dozerBeanMapper")
@Scope("singleton")
public DozerBeanMapper dozerBeanMapper() throws Exception {
return (DozerBeanMapper) dozerBeanFactory().getObject();
}
public static void main(String[] args) throws Exception {
ApplicationContext context = SpringApplication.run(SpringBootApplication.class, args);
}
}
Остановимся подробнее на методе dozerBeanFactory(). Как видно из названия, это фабрика для получения экземпляров объектов типа DozerBeanMapper, при помощи которых и осуществляется отображение полей между объектами. В документации Dozer сказано, что этот бин должен быть singleton‘ом, чтобы избежать загрузки конфигурации при каждом получении инстанса (скоуп указан явно, для наглядности). Фабрика поддерживает Spring Resources, что позволяет загружать XML-файлы по classpath-маске, что очень удобно. Второй компонент – экземпляр DozerBeanMapper (к слову, тоже singleton), определённый в методе dozerBeanMapper(). Обратите внимание, что метод getObject() может выбрасывать Exception, который в продакшн-коде желательно неким образом обрабатывать.
Благодаря рефлексии, Dozer умеет читать и писать приватные поля, а также поля, для которых не указаны геттеры и сеттеры. Однако поведение по умолчанию предполагает, что классы, задействованные в маппингах, всё же икапсулируют поля соответствующим образом (геттеры/сеттеры заданы). Dozer использует конструктор без параметров для создания новых экземпляров, однако есть возможность задавать кастомные фабрики для создания классов, участвующих в маппинге.
Объекты маппинга
Создадим два класса, между которыми и будем настраивать маппинг.
package app.domain;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.ToString;
/**
* This class is for ClientSrc details.
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
@ToString
public class ClientSrc {
private String firstName;
private String surname;
private String city;
private String phone;
private double weight;
private double height;
}
package app.domain;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.ToString;
@Data
@AllArgsConstructor
@NoArgsConstructor
@ToString
public class ClientDTO {
// direct mapping
private String name;
// direct mapping
private String surname;
// equals name + " " + surname
private String info;
// maps like: OHIO -> OH
private String city;
private double phone;
private double weight;
private double height;
}
ClientSrc – исходный класс, ClientDTO – конечный класс.
По умолчанию Dozer будет рекурсивно обходить поля исходного объекта и для полей с одинаковыми именами – копировать их значения в целевой объект. В нашем случае можно ожидать автоматический маппинг в поля surname, city, phone, weight и height. Поле name останется пустым, так как в ClientSrc оно определено как firstName. Отредактируем dozermapper.xml и добавим в него новое соответствие:
<?xml version="1.0" encoding="UTF-8"?>
<mappings xmlns="http://dozer.sourceforge.net"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://dozer.sourceforge.net
http://dozer.sourceforge.net/schema/beanmapping.xsd">
<mapping type="one-way">
<class-a>app.domain.ClientSrc</class-a>
<class-b>app.domain.ClientDTO</class-b>
<field>
<a>firstName</a>
<b>name</b>
</field>
</mapping>
</mappings>
Создадим новый unit-тест для проверки получившегося маппинга:
package app;
import app.domain.ClientDTO;
import app.domain.ClientSrc;
import lombok.extern.slf4j.Slf4j;
import org.dozer.DozerBeanMapper;
import org.junit.Assert;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;
@SpringBootTest
@RunWith(SpringRunner.class)
@Slf4j
public class SpringBootApplicationTest {
@Autowired
DozerBeanMapper dozerBeanMapper;
private ClientSrc getClientSrc() {
return new ClientSrc("Alex", "Sergeenko", "Voronezh", "888", 73.5d, 175d);
}
@Test
public void testMappings() {
ClientDTO clientDTO = dozerBeanMapper.map(getClientSrc(), ClientDTO.class);
log.warn(clientDTO.toString());
Assert.assertNotNull(clientDTO);
}
}
Вывод на консоль:

Поле info экземпляра ClientDTO осталось пустым. Передадим в него конкатенацию строк name + surname, используя только функционал Dozer. В этом случае нам пригодится так называемый Custom Converter.
package app.converter;
import app.domain.ClientDTO;
import app.domain.ClientSrc;
import org.dozer.DozerConverter;
import sun.reflect.generics.reflectiveObjects.NotImplementedException;
import java.util.Objects;
// it is needed to extend the DozerConverter here
public class NameSurnameToInfoConverter extends DozerConverter<ClientSrc, String> {
public NameSurnameToInfoConverter() {
super(ClientSrc.class, String.class);
}
// method converts from the source to the destination
@Override
public String convertTo(ClientSrc clientSrc, String info) {
// the name of a mapping parameter defined in dozermapper.xml
if (getParameter().equals("nameSurnameToInfo")) {
if (Objects.nonNull(clientSrc)) {
return clientSrc.getFirstName() + " " + clientSrc.getSurname();
}
}
return null;
}
// method converts from the destination to the source
@Override
public ClientSrc convertFrom(String source, ClientSrc destination) {
throw new NotImplementedException();
}
}
Класс конвертер должен наследовать DozerConverter и переопределять два абстрактных метода – convertTo и convertFrom. Первый метод вызывается при прямом маппинге (ClientSrc -> ClientDTO), второй – при обратном.
Изменим dozermapper.xml:
<?xml version="1.0" encoding="UTF-8"?>
<mappings xmlns="http://dozer.sourceforge.net"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://dozer.sourceforge.net
http://dozer.sourceforge.net/schema/beanmapping.xsd">
<mapping type="one-way">
<class-a>app.domain.ClientSrc</class-a>
<class-b>app.domain.ClientDTO</class-b>
<field>
<a>firstName</a>
<b>name</b>
</field>
<field custom-converter="app.converter.NameSurnameToInfoConverter" custom-converter-param="nameSurnameToInfo">
<a>this</a>
<b>info</b>
</field>
</mapping>
</mappings>
Тег custom-converter определяет класс, методы которого будут вызваны во время маппинга, custom-converter-param позволяет передавать параметры из XML-конфигурации в метод конвертера (в данном случае мы используем его только для примера). Использование параметра позволяет уменьшить дублирование кода в маппингах. Обратите внимание, что в качестве исходного поля передаётся this, то есть ссылка на экземпляр ClientSrc.
Снова запустим наш unit-тест:

В поле info теперь передаётся конкатенация полей firstName и surname исходного ClientSrc.
Вложенные маппинги
Создадим новый класс Car и добавим его в качестве поля в ClientSrc:
package app.domain;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Car {
private String model;
private String power;
private String year;
}
В ClientDTO создадим новое поле String car:
package app.domain;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.ToString;
@Data
@AllArgsConstructor
@NoArgsConstructor
@ToString
public class ClientDTO {
// direct mapping
private String name;
// direct mapping
private String surname;
// equals name + " " + surname
private String info;
// maps like: OHIO -> OH
private String city;
private double phone;
private double weight;
private double height;
private String car;
}
Добавим вложенный маппинг из поля ClientSrc.Car.model в поле ClientDTO.Car:
<field>
<a>car.model</a>
<b>car</b>
</field>
Внесём изменения в unit-тест, добавив экземпляр Car к исходному ClientSrc:
package app;
import app.domain.Car;
import app.domain.ClientDTO;
import app.domain.ClientSrc;
import lombok.extern.slf4j.Slf4j;
import org.dozer.DozerBeanMapper;
import org.junit.Assert;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;
@SpringBootTest
@RunWith(SpringRunner.class)
@Slf4j
public class SpringBootApplicationTest {
@Autowired
DozerBeanMapper dozerBeanMapper;
private ClientSrc getClientSrc() {
return new ClientSrc(getCar(), "Alex", "Sergeenko", "Voronezh", "888", 73.5d, 175d);
}
private Car getCar() {
return new Car("Tesla X", "250", "2018");
}
@Test
public void testMappings() {
ClientDTO clientDTO = dozerBeanMapper.map(getClientSrc(), ClientDTO.class);
log.warn(clientDTO.toString());
Assert.assertNotNull(clientDTO);
}
}
Вновь запустим unit-тест и посмотрим в консоль:
