Не самые очевидные советы по написанию DTO на Java

Сегодня приложения зачастую имеют распределенный характер. Для подключения к другим сервисам нужно писать больше кода  —  и при этом стараться сделать его простым.

Чтобы воспользоваться данными из внешней службы, мы обычно преобразуем полезную нагрузку JSON в объект передачи данных (Data Transfer Object, DTO). Код, обрабатывающий DTO, быстро усложняется, но с этим могут помочь несколько советов. Вполне возможно писать DTO, с которыми легче взаимодействовать и которые облегчают написание и чтение кода. Если объединить их вместе  —  можно упростить себе работу.

Сериализация DTO “по учебнику”

Начнем с типичного способа работы с JSON. Вот структура JSON. Этот JSON представляет пиццу “Реджина”.

{   
"name": "Regina",
"ingredients": ["Ham", "Mushrooms", "Mozzarella", "Tomato purée"]
}

Чтобы воспользоваться этими данными у себя в приложении, я создам простой DTO с именем PizzaDto.

import java.util.List;

public static class PizzaDto {
private String name;
private List<String> ingredients;

public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
}

public List<String> getIngredients() {
return ingredients;
}

public void setIngredients(List<String> ingredients) {
this.ingredients = ingredients;
}
}

PizzaDto  —  «старый добрый Java-объект», POJO: объект со свойствами, геттерами, сеттерами и всем остальным. Он отражает структуру JSON, поэтому преобразование между объектом и JSON занимает всего одну строку. Вот пример этого с библиотекой Jackson:

String json = """
{
"name": "Regina",
"ingredients": [ "Ham", "Mushrooms", "Mozzarella", "Tomato purée" ]
}
""";

// из JSON в объект
PizzaDto dto = new ObjectMapper().readValue(json, PizzaDto.class);

// из объекта JSON
json = new ObjectMapper().writeValueAsString(dto);

Преобразование простое и прямолинейное. В чем же тогда проблема?

В реальной жизни DTO бывают довольно сложными. Код для создания и инициализации DTO может включать вплоть до десятков строк. Иногда больше. Это проблема, потому что сложный код содержит больше ошибок и менее чувствителен к изменениям.

Моя первая попытка упростить создание DTO  —  воспользоваться неизменяемым DTO: таким, который нельзя модифицировать после создания.

Такой подход может показаться странным, если вы не знакомы с этой идеей, поэтому давайте сосредоточимся на ней поподробнее.

Создание неизменяемых DTO

Если говорить просто, то объект неизменяемый, если его состояние не может поменяться после сборки.

Давайте перепишем PizzaDto, чтобы сделать его неизменяемым.

import java.util.List;

public class PizzaDto {

private final String name;
private final List<String> ingredients;

public PizzaDto(String name, List<String> ingredients) {
this.name = name;
if (ingredients != null) {
ingredients = List.copyOf(ingredients);
}
this.ingredients = ingredients;
}

public String getName() {
return name;
}

public List<String> getIngredients() {
return ingredients;
}
}

У неизменяемого объекта нет сеттера. Все его свойства  — окончательные и должны быть инициализированы при построении.

Как вы можете видеть, список ингредиентов не хранится как есть. Вместо этого для сохранения неизменяемой копии входных данных используется List.copyOf(). Это не позволяет клиентам изменять ингредиенты, хранящиеся в DTO.

dto
.getIngredients()
.remove("Mushrooms"); // вызывает UnsupportedOperationException

Это важно, потому что пицца “Реджина” без грибов  —  уже определенно не пицца “Реджина”.

Если серьезнее, то Джошуа Блох, автор книги “Java: эффективное программирование”, дает такую рекомендацию для создания неизменяемых классов:

“Если в вашем классе есть какие-либо поля, которые ссылаются на изменяемые объекты, убедитесь, что клиенты класса не могут получать ссылки на эти объекты”.  —  Джошуа Блох

Если какое-либо свойство вашего DTO является изменяемым, вам необходимо сделать защитные копии. С их помощью вы предотвратите модификацию вашего DTO извне.

Примечание: начиная с Java 16, существует более краткий способ создания неизменяемых классов через записи.

Хорошо. Теперь у нас есть неизменяемый DTO. Но как это упрощает код?

Преимущества неизменяемости

Неизменяемость приносит много преимуществ, но вот мое любимое: неизменяемые переменные не имеют побочных эффектов.

Рассмотрим на примере. В этом фрагменте кода есть ошибка:

var pizza = make();
verify(pizza);
serve(pizza);

После выполнения этого кода пицца не содержит ожидаемого состояния. Какая строка вызвала проблему?

Попробуем два ответа: сначала с изменяемой переменной, а затем с неизменяемой.

Первый ответ  —  с изменяемой пиццей. pizza создается с помощью make(), но ее можно изменить в рамках verify() и serve(). Таким образом, к ошибке может приводить любая строка из трех.

Теперь второй ответ  —  с неизменяемой пиццей. make() возвращает пиццу, но verify() и serve() не могут ее изменить. К проблеме может приводить только make(). Здесь гораздо меньше пространства для расследования. Ошибку легче найти.

С неизменяемыми переменными отладка становится проще. Но это еще не все.

Когда пицца не валидна, метод verify(), вероятно, создает исключение, чтобы прервать процесс. Изменим это. Нам нужно, чтобы метод verify() исправлял невалидные пиццы.

Поскольку pizza  —  неизменяемый объект, verify() не может просто исправить его. Придется создавать и возвращать измененную пиццу, а клиентский код необходимо адаптировать:

var pizza = make();
pizza = verify(pizza);
serve(pizza);

В этой новой версии очевидно, что метод verify() возвращает новую исправленную пиццу. Неизменяемость делает код более понятным. Его становится легче читать и легче развивать.

Возможно, вы не знаете, но мы и так каждый день пользуемся неизменяемыми объектами. java.lang.String, java.math.BigDecimal, java.io.File — все они неизменяемые. 

Есть и другие преимущества В своей книге Джошуа Блох просто рекомендует “минимизировать изменчивость”.

“Неизменяемые классы проще проектировать, реализовывать и использовать, чем изменяемые классы. Они менее подвержены ошибкам и более безопасны”. —  Джошуа Блох

Теперь возникает интересный вопрос: можем ли мы поступать так же с DTO?

Неизменяемые DTO… А это осмысленно?

Цель DTO  —  передача данных между процессами. Объект инициализируется, а затем его состояние не должно меняться. Либо он будет сериализован в JSON, либо будет использоваться клиентом. Это делает неизменность естественной. Неизменяемый DTO будет передавать данные между процессами с гарантией.

Тогда почему я сначала написал изменяемое PizzaDTO, а не неизменяемое? Дело в уверенности, что моей библиотеке JSON требуются геттеры и сеттеры для DTO.

Как оказалось, это не соответствует истине.

Неизменяемые DTO с Jackson

Jackson  —  самая распространенная JSON-библиотека для Java.

Когда у DTO есть геттеры и сеттеры, Jackson может сопоставить объект с JSON без какой-либо дополнительной настройки. Но с неизменяемыми объектами Jackson нуждается в небольшой помощи. Ему нужно знать, как собирать объект.

Конструктор объекта должен быть снабжен аннотацией @JsonCreator, а каждый аргумент  —  @JsonProperty. Добавим эти аннотации в конструктор DTO.

// новый импорт:
// import com.fasterxml.jackson.annotation.*;

@JsonCreator
public PizzaDto(
@JsonProperty("name") String name,
@JsonProperty("ingredients") List<String> ingredients) {
this.name = name;
if (ingredients != null) {
ingredients = List.copyOf(ingredients);
}
this.ingredients = ingredients;
}

Вот и все. Теперь нас есть неизменяемый DTO, который Jackson может преобразовать в JSON и обратно в объект.

Неизменяемые DTO с Gson и Moshi

Есть две альтернативы Jackson: Gson и Moshi.

С помощью этих библиотек еще проще преобразовать JSON в неизменяемый DTO, потому что им не нужны никакие дополнительные аннотации.

Но почему Jackson вообще требует аннотаций, в отличие от Gson и Moshi?

Никакой магии. Дело в том, что, когда Gson и Moshi генерируют объект из JSON, они создают и инициализируют его путем отражения. Кроме того, они не задействуют конструкторы.

Я не большой поклонник такого подхода. Он вводит в заблуждение, потому что разработчик может вложить некоторую логику в конструктор и никогда не узнать, что он не вызывается. По сравнению с этим, Jackson представляется гораздо более безопасным.

Избегайте нулевых значений

У Jackson есть еще одно преимущество. Если поместить в конструктор некоторую логику, он будет вызываться всегда, независимо от того, создан ли DTO кодом приложения или сгенерирован из JSON.

Можно воспользоваться этим преимуществом для избегания значений null и улучшить конструктор для инициализации полей с ненулевыми значениями.

В приведенном ниже фрагменте кода поля инициализируются пустыми значениями, когда входные данные равны нулю.

// новый импорт :
// import static org.apache.commons.lang3.ObjectUtils.firstNonNull;

@JsonCreator
public PizzaDto(
@JsonProperty("name") String name,
@JsonProperty("ingredients") List<String> ingredients) {
this.name = firstNonNull(name, ""); // replace null by empty String
this.ingredients = List.copyOf(
firstNonNull(ingredients, List.of()) // replace null by empty List
);
}

В большинстве случаев пустые значения и null не отличаются в поведении. Если заменить нулевые значения пустыми, клиенты смогут пользоваться свойствами DTO без предварительной проверки на null-значения. Кроме того, это снижает шанс появления NullPointerException.

Так вы напишете меньше кода и повысите надежность. Что может быть лучше?

И последнее по счету, но не по важности: создавайте DTO со строителями

Есть еще один совет, как упростить инициализацию DTO. В комплекте с каждым DTO я создаю Builder. Он предоставляет свободный API для облегчения инициализации DTO.

Вот пример создания PizzaDto через сборщик:

var pizza = new PizzaDto.Builder()
.name("Regina")
.ingredients("Mozzarella cheese", "Basil leaves", "Olive oil", "Tomato purée")
.build();

С помощью сложных DTO разработчики делают код более выразительным. Этот шаблон настолько великолепен, что Джошуа Блох почти начинает с него свою книгу “Java: эффективное программирование”.

“Такой клиентский код легко писать и, что более важно, читать”.  —  Джошуа Блох

Как это работает? Объект builder просто хранит значения, пока мы не вызовем build(), который фактически создает нужный объект с сохраненными значениями.

Вот пример для PizzaDto:

public static final class Builder {

private String name;
private List<String> ingredients;

public Builder name(String name) {
this.name = name;
return this;
}

public Builder ingredients(List<String> ingredients) {
this.ingredients = ingredients;
return this;
}

/**
* перегружает{@link Builder#ingredients(List)} чтобы тот принимал String varargs
*/
public Builder ingredients(String... ingredients) {
return ingredients(List.of(ingredients));
}

public PizzaDto build() {
return new PizzaDto(name, ingredients);
}
}

Некоторые пользуются Lombok для создания конструкторов во время компиляции. Это упрощает DTO.

Я предпочитаю генерировать код конструктора с помощью плагина Builder generator IntelliJ. Затем можно добавить перегрузки методов, как в предыдущем фрагменте кода. Конструктор таким образом становится более гибким, а клиентский код  —  более компактным.

Заключение

Вот основные советы, которые я держу в голове при написании DTO. Соединенные вместе, они действительно улучшат ваш код. Кодовая база становится легче для чтения, проще в обслуживании и, в конечном счете, так проще делиться ею с вашей командой.

Читайте также:

Читайте нас в TelegramVK и Яндекс.Дзен


Перевод статьи Seb Boursault: “(Not So Obvious) Tips To Write Better DTOs in Java”

Предыдущая статьяОбъяснение понятий вероятности: оценка максимального правдоподобия
Следующая статьяRemote First: программисты не должны работать в офисе