Сегодня приложения зачастую имеют распределенный характер. Для подключения к другим сервисам нужно писать больше кода — и при этом стараться сделать его простым.
Чтобы воспользоваться данными из внешней службы, мы обычно преобразуем полезную нагрузку 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. Соединенные вместе, они действительно улучшат ваш код. Кодовая база становится легче для чтения, проще в обслуживании и, в конечном счете, так проще делиться ею с вашей командой.
Читайте также:
- Пишем асинхронный неблокирующий Rest API на Java
- Состояния потоков в Java
- Основы программирования UDP-сокетов на Java
Читайте нас в Telegram, VK и Яндекс.Дзен
Перевод статьи Seb Boursault: “(Not So Obvious) Tips To Write Better DTOs in Java”