Что такое Dozer – это mapper

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

Пример маппингов с использованием 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-тест и посмотрим в консоль: