Иногда в проектах требуется скопировать атрибуты одного объекта в другой, временами не напрямую, а с некоторыми преобразованиями, например извлечением подстроки или наоборот, объединением нескольких полей родительского объекта в единое поле дочернего. Такие преобразования называются маппингами (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-тест и посмотрим в консоль: