В данной статье будет рассмотрен способ применения Java records в качестве DTO (data transfer objects).


Используем Spring Boot / Hibernate.


Представленный далее код не предназначен для продакшена. Это, скорее, размышления на тему. Возможно кому-то будет интересно и полезно.


Цель — за пределами сервисного слоя использовать только DTO и не таскать сущности с persistence context'ом по бизнес-логике.


Обычно использование паттерна DTO подразумевает применение отдельных функций для преобразования модели в DTO и обратно.


По сути имеем три задачи — сохранение/выборку данных и удобный способ работы с ними.


В демо проекте реализованы две сущности — Note и Tag, со связями ManyToMany.


Далее описаны способы выборки и обновления данных на примере Tag.


Для выборки данных в repository используем JPQL Constructor Expressions:


    @Transactional(readOnly = true)
    @Query(value = "SELECT new dev.isdn.demo.records_dto.app.domain.tag.TagDto(t.id, t.name, t.color) FROM tags t WHERE t.id = :id")
    Optional<TagDto> findById(@Param("id") long id);

Получаем результат сразу в record, завернутый в Optional. Если выборка пустая — то получаем пустой Optional.


Соответственно, в сервисном слое просто передаем результат:


    public Optional<TagDto> getTagById(long tagId) {
        return repository.findById(tagId);
    }

Для сохранения в базу нужно будет сделать несколько дополнительных действий:


Optional.ofNullable(tagDto)
.flatMap(t -> repository.getTagById(t.id())) // проверяем наличие записи с таким ID в базе и преобразуем DTO в entity
.flatMap(t -> setTagNameAndColor(t, content.name(), content.color())) // обновляем entity

В данном случае очень удобно использовать фичи Optional для проверки результата на каждом шаге.


Метод setTagNameAndColor() выполняет проверку входных данных и обновляет сущность.


В итоге сделал вот такой сервисный метод в декларативном стиле:


    @Transactional
    public Optional<TagDto> updateTagContent(TagDto tag, TagContent content) {
        return Functions.checkTagDto.apply(tag)
                .flatMap(t -> repository.getTagById(t.id()))
                .flatMap(t -> setTagNameAndColor(t, content.name(), content.color()))
                .map(repository::saveAndFlush)
                .flatMap(t -> repository.findById(t.getId()));
    }

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


Функция checkTagDto делает простую проверку Optional.ofNullable(tag).filter(t -> t.id() > 0).


Более сложные выборки из базы в repository можно делать аналогично.
Например, получение связанных данных:


    @QueryHints(value = {
            @QueryHint(name = HINT_FETCH_SIZE, value = "100"),
            @QueryHint(name = READ_ONLY, value = "true")
    })
    @Transactional(readOnly = true)
    @Query(value = "SELECT new dev.isdn.demo.records_dto.app.domain.tag.TagDto(t.id, t.name, t.color) FROM tags t INNER JOIN t.notes n WHERE n.id = :noteId")
    Stream<TagDto> findAllByNoteId(@Param("noteId") long noteId);

    @QueryHints(value = {
            @QueryHint(name = HINT_FETCH_SIZE, value = "100"),
            @QueryHint(name = READ_ONLY, value = "true")
    })
    @Transactional(readOnly = true)
    @Query(value = "SELECT new dev.isdn.demo.records_dto.app.domain.note.NoteDto(n.id, n.created, n.modified, n.content) FROM notes n INNER JOIN n.tags t WHERE t.id = :tagId")
    Stream<NoteDto> findAllByTagId(@Param("tagId") long tagId);

Теперь о том, как с этим работать.


Простой пример REST контроллера:


    @PutMapping(PREFIX + VERSION + "/tags/{id}")
    TagDto updateTagContent(@PathVariable long id, @RequestBody TagContent content) {
        TagDto tag = tagService.getTagById(id).orElseThrow(() -> new NoSuchItemException("tag " + id));
        return tagService.updateTagContent(tag, content).orElseThrow(() -> new NotUpdatedException("tag " + id));
    }

    @GetMapping(PREFIX + VERSION + "/tags/{id}/notes")
    List<NoteDto> getTagNotes(@PathVariable long id) {
        TagDto tag = tagService.getTagById(id).orElseThrow(() -> new NoSuchItemException("tag " + id));
        return noteService.getTagNotes(tag);
    }

Полностью код можно посмотреть вот здесь.

Комментарии (16)


  1. Antharas
    16.10.2022 23:24
    -1

    Это все конечно замечательно, синтаксический сахар и все вот это вот удобство.. но вы же, наверно, вкурсе о больших накладных расходах при создании record-классов? Нет? Проведите стресс тест системы, будете удивлены. Крайне не советую в принципе использовать в высоконагруженной системе все эти удобства.


    1. isden Автор
      16.10.2022 23:28
      +4

      о больших накладных расходах при создании record-классов

      А где про это почитать?
      Везде где я читал пишут, что +- как у обычных классов.
      Сейчас погуглил, сходу то же самое нашлось.


      1. Antharas
        16.10.2022 23:44
        +1

        Увы, материалы сам когда-то искал, так и не нашел. Провел пару тестов в нагрузке (tcp - 10к клиентов с рейтом в 30-40rps) - профайлер показал деградацию в Record<init> (jdk 19).


        1. isden Автор
          16.10.2022 23:53

          Ок, спасибо. Если будет свободное время, попробую потестировать разницу в производительности.
          Но кмк, если бы с этим были серьезные проблемы, об этом бы точно писали и обсуждали.


          1. Antharas
            16.10.2022 23:55
            +1

            Да, имхо единичный "мой" случай, дособираю статистику и вышлю багрепортом.


    1. Hixon10
      17.10.2022 01:11
      -2

      > вы же, наверно, вкурсе о больших накладных расходах при создании record-классов
      Больших расходов по сравнению с чем? С созданием обычных объектов, или не пулом объектов?


  1. aleksandy
    17.10.2022 08:23
    +4

    Ох уж эти Spring Java Developer-ы...

    Такая организация кода - говнокод, хоть с записями, хоть с обыкновенными классами. Транзакции должны управляться на уровне инфраструктурных фасадов, а не репозиториев.

    В наипростейшей updateTagContent() открывается 2 транзакции, между которыми в конкурентном окружении может вклиниться удаление и таки никакого обновления не случится.


    1. isden Автор
      17.10.2022 09:55

      Транзакции должны управляться на уровне инфраструктурных фасадов, а не репозиториев.

      А в документации пишут "CRUD methods on repository instances are transactional by default."


      открывается 2 транзакции, между которыми

      А разве сам метод не будет выполняться одной транзакцией (с вложенными из репозитория)?


  1. Throwable
    17.10.2022 09:28
    -1

    Меня всегда удивляет боязнь девелоперов вместо DTO отдавать сразу Entities. Начинают что-то говорить, что это неправильно, и что-то про разные модели и секьюрити. Но на практике это те же самые POJO, и в 99% случаев из fieldset-ы совпадают. А при грамотно выставленных EntityGraphs можно четко контролировать отдаваемый контент. Поэтому чаще всего следуя каким-то искусственным паттернам, сами себе усложняем жизнь.



    1. Arty_Fact
      17.10.2022 13:04

      Ну не знаю. Все-таки у многих объектов есть дополнительные филды, типа created, updated, author и т. д., которые нужны в работе, но не нужны фронтенду, и DTO сильно помогает. Ну и плюс сразу защищаешься от сложностей при переименовывании полей. Удобно, как мне кажется.


      1. Throwable
        17.10.2022 19:37

        Для сильно служебных полей есть @JsonIgnore и ещё куча способов исключить их из сериализации. Хорошая модель данных организована так, чтобы отделить процессинг от стейта: не загромождать сами Entities промежуточными состояниями и служебными данными, а выделить их в отдельный объект.

        Согласно моему горькому опыту в любом проекте очень быстро замусоривается DTO, всевозможными мепперами и промежуточными сервисами. Доходит до того, что на одну entity получается с десяток схожих DTO. В то же время успешный рефакторинг на раздачу Entities уменьшил кодовую базу сразу на 90%.


        1. isden Автор
          17.10.2022 20:32

          А я видел проекты где DTO долго и успешно используются, без этих всяких ужасов.
          Наверное все сильно зависит от команды и от структуры/архитектуры проекта.


        1. Arty_Fact
          18.10.2022 12:48

          Даже не могу представить зачем при одном API может понадобиться десяток схожих DTO. Я могу понять два схожих DTO: для запроса и для ответа. Но зачем еще куча?

          Ну и конечно не представляю, что там за код такой был, на 90 % состоящий из мапперов и DTO. Взглянуть бы на проект до рефакторинга.


          1. Throwable
            19.10.2022 09:27
            +1

            Там до Api как такового никому особо дела не было. Проект прошел по куче рук, и каждый реализовал тикет как мог. Для каждой новой фичи на бэке тупо писался свой контроллер, сервис, DTO и маппер. В итоге все т.н. "api" -- это куча findByXxx, которые отдают схожие DTO с немного разным набором филдов под каждый конкретный кейс.


            1. isden Автор
              19.10.2022 10:19
              +1

              Ну так тут очевидно проблема была совсем не в паттерне DTO как таковом.