Переосмысление DTO в Java

Привет, Хабр! Представляю вашему вниманию любительский перевод статьи “Rethinking the Java DTO” Стивена Уотермана, где автор рассматривает интересный и нестандартный подход к использованию DTO в Java.




Я провел 12 недель в рамках программы подготовки выпускников Scott Logic, работая с другими выпускниками над внутренним проектом. И был момент, который застопорил меня больше других: структура и стиль написания наших DTO. Это вызывало массу споров и обсуждений на протяжении всего проекта, но в итоге я понял, что мне нравится использовать DTO.


Данный подход не является единственно верным решением, но он довольно интересный и отлично подходит для разработки с использованием современных IDE. Надеюсь, что изначальный шок пройдет и вам он тоже понравится.


Что такое DTO (Data Transfer Object)?


Зачастую, в клиент-серверных приложениях, данные на клиенте (слой представления) и на сервере (слой предметной области) структурируются по-разному. На стороне сервера это дает нам возможность комфортно хранить данные в базе данных или оптимизировать использование данных в угоду производительности, в то же время заниматься “user-friendly” отображением данных на клиенте, и, для серверной части, нужно найти способ как переводить данные из одного формата в другой. Конечно, существуют и другие архитектуры приложений, но мы остановимся на текущей в качестве упрощения. DTO-подобные объекты могут использоваться между любыми двумя слоями представления данных.



DTO — это так называемый value-object на стороне сервера, который хранит данные, используемые в слое представления. Мы разделим DTO на те, что мы используем при запросе (Request) и на те, что мы возвращаем в качестве ответа сервера (Response). В нашем случае, они автоматически сериализуются и десериализуются фреймворком Spring.


Представим, что у нас есть endpoint и DTO для запроса и ответа:


// Getters & Setters, конструкторы, валидация и документация опущены
public class CreateProductRequest {
    private String name;
    private Double price;
}

public class ProductResponse {
    private Long id;
    private String name;
    private Double price;
}

@PostMapping("/products")
public ResponseEntity<ProductResponse> createProduct(
    @RequestBody CreateProductRequest request
) { /*...*/ }

Что делают хорошие DTO?


Во-первых, очень важно понимать, что вы не обязаны использовать DTO. Это прежде всего паттерн и ваш код может работать отлично и без него.


  • Если вы используете одно представление данных на оба слоя, вы вполне можете использовать ваши сущности в качестве DTO.
  • Если вы хотите вручную заниматься сериализацией ваших сущностей в JSON, то я не могу вас остановить!

Они также помогают документировать слой представления в человеко читаемом виде. Мне нравится использовать DTO и, я думаю, вы тоже могли бы их использовать, ведь это к тому же способствует уменьшению зацепления (decoupling) между слоем представления и предметным слоем, позволяя приложению быть более гибким и уменьшая сложность его дальнейшей разработки.


Тем не менее, не все DTO являются хорошими. Хорошие DTO помогают создавать API согласно лучшим практикам и в соответствии с принципам чистого кода.


Они должны позволять разработчикам писать API, которое внутренне согласовано. Описание параметра на одной из конечных точек (endpoint) должно применяться и к параметрам с тем же именем на всех связанных точках. В качестве примера, возьмём вышепредставленный фрагмент кода. Если поле price при запросе определено как “цена с НДС”, то и в ответе определение поля price не должно измениться. Согласованное API предотвращает ошибки, которые могли возникнуть из-за различий между конечными точками, и в то же время облегчает введение новых разработчиков в проект.


DTO должны быть надёжными и сводить к минимуму необходимость в написании шаблонного кода. Если при написании DTO легко допустить ошибку, то вам нужно прилагать дополнительные усилия, чтобы ваше API оставалось согласованным. DTO должны “легко читаться”, ведь даже если у нас есть хорошее описание данных из слоя представления — оно будет бесполезно, если его тяжело найти.


Давайте посмотрим на примеры DTO, а потом определим, соответствуют ли они нашим требованиям.


Покажи нам код!


Этот код на первый взгляд может показаться довольно странным, но не переживайте. В оставшейся части поста я объясню почему я остановился на данной реализации и какие преимущества она дает. Надеюсь, что вам станет всё понятно и вы оцените данный подход.


Он частично основывается на реальном коде из нашего проекта для выпускников, переведенный в контекст интернет-магазина. В нём каждый продукт имеет название, розничную и оптовую цену. Для хранения цены мы используем тип данных Double, но в реальных проектах вы должны использовать BigDecimal.


public enum ProductDTO {;
    private interface Id { @Positive Long getId(); }
    private interface Name { @NotBlank String getName(); }
    private interface Price { @Positive Double getPrice(); }
    private interface Cost { @Positive Double getCost(); }

    public enum Request{;
        @Value public static class Create implements Name, Price, Cost {
            String name;
            Double price;
            Double cost;
        }
    }

    public enum Response{;
        @Value public static class Public implements Id, Name, Price {
            Long id;
            String name;
            Double price;
        }

        @Value public static class Private implements Id, Name, Price, Cost {
            Long id;
            String name;
            Double price;
            Double cost;
        }
    }
}

Мы создаем по одному файлу для каждого контроллера, который содержит базовый enum без значений, в нашем случае это ProductDTO. Внутри него, мы разделяем DTO на те, что относятся к запросам (Request) и на те, что относятся к ответу (Response). На каждый endpoint мы создаем по Request DTO и столько Response DTO сколько нам необходимо. В нашем случае у нас два Response DTO, где Public хранит данные для любого пользователя и Private который дополнительно содержит оптовую цену продукта.


Для каждого параметра мы создаем отдельный интерфейс с таким же именем. Каждый интерфейс содержит один-единственный метод — геттер для параметра, который он определяет. Любая валидация осуществляется через метод интерфейса. В нашем примере, аннотация @NotBlank проверяет что название продукта в DTO не содержит пустую строку.


Для каждого поля который входит в DTO мы реализовываем соответствующий интерфейс. В нашем случае аннотация @Value из библиотеки Lombok делает это за нас, автоматически генерируя геттеры.


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


“Это ужасно!”


Это на самом деле выглядит странно и здесь много необычных моментов. Давайте обсудим несколько из них подробнее.


Три enum и ни один из них не имеет значений! На самом деле мы используем небольшую хитрость для создания namespace-а, т.е. мы можем обращаться к DTO как ProductDTO.Request.Create. Данный “трюк” возможен благодаря тому, что мы ставим ; после каждого enum. Точка с запятой указывает на конец (пустого) списка значений! Использование таких namespace-ов ускоряет поиск нужного DTO, а также можно воспользоваться подсказками в IDE для получения полного списка. Есть и другие возможности добиться того же эффекта, но текущий подход выглядит лаконично, ведь нам не нужно будет использовать конструкции вроде new ProductDTO() и new Create(). Честно говоря, это моё личное предпочтение и вы можете организовать классы как вам угодно.


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


Мы не реализовали методы интерфейсов. Да, выглядит немного странно и я хотел бы найти решение получше. Сейчас мы используем автогенерацию геттеров при помощи Lombok для закрытия контракта и это небольшой хак. Выглядело бы лучше, если бы мы могли объявлять поля сразу в интерфейсе, что позволяло бы создавать DTO в одной строчке кода. Однако, в java нет возможности интерфейсам иметь не статические поля. Если вы будете использовать этот подход в других языках, то возможно ваш код будет более лаконичным.


Это (почти) идеально


Давайте вернемся к нашим требованиям к созданию хорошего DTO. Соотвествует ли им наш подход?


Согласованный синтаксис


Мы определенно улучшили согласованность синтаксиса и это главное почему мы могли бы начать использовать данный паттерн. Каждый API параметр теперь имеет свой синтаксис, определенный через интерфейс. Если DTO содержит опечатку в имени параметра или некорректный тип — код просто не скомпилируется и IDE выдаст вам ошибку. Для примера:


@Value public static class PatchPrice implements Id, Price {
    String id;    // Должен быть тип Long;
    Double prise; // Опечатка в слове price
}

PatchPrice is not abstract and does not override abstract method getId() in Id
PatchPrice is not abstract and does not override abstract method getPrice() in Price

К тому же, когда мы используем валидацию на уровне интерфейса, мы исключаем ситуацию, когда один и тот же параметр на одном endpoint проходит валидацию и не проходит её на другом.


Согласованная семантика


Такой стиль написания DTO улучшает понимание кода через наследование документации. Каждый параметр имеет свою семантику которая определена в геттер методах соответствующего ему интерфейса. Пример:


private interface Cost {
    /**
     * The amount that it costs us to purchase this product
     * For the amount we sell a product for, see the {@link Price Price} parameter.
     * <b>This data is confidential</b>
     */
    @Positive Double getCost();
}

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



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


Читабельность & Поддерживаемость


Будем честны: в нашем подходе достаточно много шаблонного кода. У нас есть 4 интерфейса, без которых не обойтись, и каждый DTO имеет длинную строку с перечислением интерфейсов. Мы можем вынести интерфейсы в отдельный пакет, что поможет избежать лишних “шумов” в коде c описанием DTO. Но даже после этого, бойлерплейт остается главным недостатком данного подхода, что может оказаться веской причиной для того чтобы использовать другой стиль. Для меня, эти затраты все еще стоят того.


Наш стиль проявляет себя с лучшей стороны, когда нам нужно создать новый DTO. Вы просто пишете @Value public static class [name] implements, перечисляете нужные вам интерфейсы. Далее, добавляете поля пока ваша IDE не перестанет ругаться. Готово! У вас есть DTO с валидацией и документацией.


К тому же, мы видим всю структуру наших DTO классов. Посмотрите на код и вы увидите все что вам нужно знать из сигнатуры класса. Каждое поле указано в списке реализованных интерфейсов. Достаточно нажать ctrl + q в IntelliJ и вы увидите список полей.



В нашем подходе мы пишем валидацию единоразово, т.к. она реализуется через методы интерфейса. Создали новое DTO — получили валидацию в подарок, после реализации интерфейса.


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


markup = (sale_price - cost_price) / cost_price

В java, мы можем реализовать это используя обобщение:


public static <T extends Price & Cost> Double getMarkup(T dto){
    return (dto.getPrice() - dto.getCost()) / dto.getCost();
}

Входной аргумент имеет тип T, который является обобщением с пересечением типов. dto обязано реализовать оба интерфейса Price и Cost — это означает, что мы не можем использовать данный метод для Public ответа (т.к. он не реализовывает интерфейс Cost). В стандартном подходе, мы должны были бы добавить метод с двумя аргументами и перегруженные методы для каждого dto (пример). Это переносит работу на вызывающую сторону и добавляет риски возникновения ошибок.


Вывод


Я не жду, что вы сразу же пойдете переписывать все ваши DTO. Но есть несколько деталей которые вы можете почерпнуть для себя:


  1. Установите единственный источник информации о вашем API параметре.
  2. Маленькие интерфейсы лучше.
  3. Попробуйте быть странным, возможно, вам понравится!



P.S. Спасибо, что дочитали до конца мой первый пост на Хабре. Буду рад любой критике относительно перевода, т.к. приходилось немного отходить от оригинала из-за нехватки знаний и опыта.

AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама

Комментарии 12

    +2
    Не слишком ли много повторяющегося boilerplate code требует этот подход?
      +1

      А сколько шаблонного кода требует Java для описания стандартных DTO?


      По мне, этот подход решает конкретные (и не надуманые) задачи. Плюс создает некоторое подобие type alias из Haskell (еще один аналог — DOMAIN в SQL), что значительно (субъективно) усиливает систему типов Java.

        +1

        Не так уж и много если использовать Mapstruct и Lombok. Хотя решение автора с валидацией в интерфейсе довольно элегантное.

      0
      Я правильно понимаю, что мне ничего не мешает дописать в Create, Public или Private любое поле без интерфейса, и я это никак не замечу кроме код ревью?
        0

        Да, можете дописать что угодно, это обычный статический класс. Тут так же как и с любым паттерном программирования, т.е это скорее «набросок» того как можно решать такие проблемы, нежели готовая «картина».

        +2

        Чаще всего ваши DTO так или иначе повторяют Domain model. Поэтому по большей части приходится дублировать свойства плюс писать ненужные мэпперы к каждому классу. Мы стараемся сразу определить у domain-класса группы полей, которые потом будут использоваться в DTO при помощи соответствующих интерфейсов:


        class User implements BasicInfo, ExtendedInfo, SecurityInfo {
           ...
        }
        interface BasicInfo {
            String getId();
            String getName();
            String getEmail();
            LocalDate getBirthDate();
        }
        interface SecurityInfo {
            EnumSet<Role> getRoles();
            LocalDateTime getLastLoginTime();
        }

        Сериализатору указывается интерфейс DTO, и он пишет только соответствующий сет свойств.
        В итоге:


        • исключает дублирование объектов и свойств
        • не нужны мепперы (филдсет сериализуется автоматически)
        • поддержка IDE и все такое
          0
          не нужны мепперы (филдсет сериализуется автоматически)

          Получается, что котроллер фактически отдаёт наружу доменный объект, я правильно понимаю?

            0

            Не совсем. Сериализатор настроен так, чтобы отдавать только сет свойств, указанных в возвращаемом интерфейсе.


            @GET
            @Path("/users/{userId}")
            public BasicInfo getUserBasicInfo(@PathParam("userId") String userId) {
                User user = userDao.findById(userId);
                return user;
            }

            В этом случае контроллер отдаст JSON:


            {
                "id": "12345",
                "name": "Vasya",
                "email" : "vasya@mysite.com",
                "birthDate" : "1970-01-01"
            }
              +1

              Понимаете, какая тут загогулина: фактические userDao.findById(userId) возвращает сущность (если я правильно понял userDao — это реализация JpaRepository).


              Теперь представьте, что одним из свойств, входящих во множество, отдаваемое клиенту, является ленивой дочерней сущностью или @Lob. В этом случае мы получим LazyInitException с сообщением "No session", т.к. область действия транзакции прекращается по выходу из findById.


              И тут одной из трёх: либо добавлять @Transactional на контроллер, что является грубейшим нарушением принципа разделения слоёв приложения, либо явно загружать ленивую сущность (нужно приседать с графами или явно прописывать fetch), либо возвращать не сущность, а DTO.


              Поэтому идея кажется сомнительной, как и внедрение дао/репозиториев в контроллер.

                0
                добавлять @Transactional на контроллер, что является грубейшим нарушением принципа разделения слоёв приложения

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


                В данном случае не вижу ничего зазорного, чтобы контроллер был @Transactional. Даже если некоторые properties грузятся как lazy, при сериализации они корректно догрузятся.


                либо явно загружать ленивую сущность (нужно приседать с графами или явно прописывать fetch)

                Суперинтерфейсы (BasicInfo, ExtendedInfo, SecurityInfo, etc...) как раз по сути определяют наборы свойств, которые необходимо вытащить в каждом конкретном случае, поэтому ничто не мешает модифицировать репозиторий и сделать автоматическую генерацию графа для каждого из суперинтерфейсов.


                // dao-метод возвращает список User entity с загруженными свойствами только для суперинтерфейса T
                <T super User> List<T> findAll(Class<T> superClass);
                ...
                List<BasicInfo> userInfos = findAll(BasicInfo.class);

                Вот вам и автоматизация DTO.

                  0

                  Ну не знаю, как по мне принцип единой ответственности и прочее, как и ТБ писаны кровью, чересчур вольное обращение с ними чревато.


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


                  Бенчмарк показывает существенную дороговизну проекций по сравнению с DTO


                                                     (count)       Score      Error   Units
                  
                  findAllByName                            1      16.188 ±    0.643   us/op
                  findAllByNameUO                          1      13.991 ±    0.208   us/op
                  findAllByName                          100     235.077 ±    2.407   us/op
                  findAllByNameUO                        100      65.713 ±    1.618   us/op
                  
                  findAllByName:·gc.alloc.rate.norm        1   20842.539 ±   24.394    B/op
                  findAllByNameUO:·gc.alloc.rate.norm      1   13802.823 ±   29.680    B/op
                  findAllByName:·gc.alloc.rate.norm      100  519894.926 ± 1588.438    B/op
                  findAllByNameUO:·gc.alloc.rate.norm    100   41812.605 ±   40.003    B/op
          0
          Для хранения цены мы используем тип данных Double, но в реальных проектах вы должны использовать BigDecimal.

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

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

          Самое читаемое