Еще один способ использования Java records как DTO

В данной статье будет рассмотрен способ применения 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);
    }

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

@isden
16.10.2022 23:41 UTC
Первоисточник

Комментарии

@Antharas
16.10.2022 20:24 UTC
-1

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

@isden
16.10.2022 20:28 UTC
+4
о больших накладных расходах при создании record-классов

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

16.10.2022 20:44 UTC
+1

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

16.10.2022 20:53 UTC
0

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

16.10.2022 20:55 UTC
+1

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

@Hixon10
16.10.2022 22:11 UTC
-2
> вы же, наверно, вкурсе о больших накладных расходах при создании record-классов
Больших расходов по сравнению с чем? С созданием обычных объектов, или не пулом объектов?
@aleksandy
17.10.2022 05:23 UTC
+4

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

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

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

@isden
17.10.2022 06:55 UTC
0
Транзакции должны управляться на уровне инфраструктурных фасадов, а не репозиториев.

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


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

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

31.10.2022 06:50 UTC
0

транзакций врядли 2 откроется, если не переопределять propagation, но думаю, лучше просто использовать @Transactional(readOnly = true) над сервисами для get, а не над репозиториями, потому что если эти методы репозиториев будут переиспользовать для update методов, то сущность не проапдейтится

31.10.2022 06:59 UTC
0
а не над репозиториями

Цитата:


By default, CRUD methods on repository instances inherited from SimpleJpaRepository are transactional. For read operations, the transaction configuration readOnly flag is set to true. All others are configured with a plain @Transactional so that default transaction configuration applies.

https://docs.spring.io/spring-data/jpa/docs/current/reference/html/#transactions


если эти методы репозиториев будут переиспользовать для update методов, то сущность не проапдейтится

Как так? Помечать RO методы, которые делают апдейт? О_о

31.10.2022 09:06 UTC
0

у тебя есть метод с readonly, возвращающий сущность Optional<Tag> getTagById(long id); https://github.com/isdn/records-dto/blob/01c1cd4c298499f250cb2b7113d91745ad47c6bf/src/main/java/dev/isdn/demo/records_dto/app/domain/tag/TagRepository.java#L28 и ты его используешь только в update https://github.com/isdn/records-dto/blob/01c1cd4c298499f250cb2b7113d91745ad47c6bf/src/main/java/dev/isdn/demo/records_dto/app/domain/tag/TagService.java#L43

При readonly dirty-check не должен работать https://vladmihalcea.com/spring-read-only-transaction-hibernate-optimization/

тебя спасло только, что в сервисе над update висит обычный не readonly transactional. Отсюда вопрос, зачем над getTagById висит readonly, если можно сразу над сервисом разруливать транзакцию

01.11.2022 11:59 UTC
0
и ты его используешь только в update

Не только. См. код.


При readonly dirty-check не должен работать https://vladmihalcea.com/spring-read-only-transaction-hibernate-optimization/

Цитата:


Prior to Spring 5.1, when using Hibernate, the readOnly attribute of the @Transactional annotation was only setting the current Session flush mode to FlushType.MANUAL, therefore disabling the automatic dirty checking mechanism.
However, because the readOnly attribute did not propagate to the underlying Hibernate Session, I decided to create the SPR-16956 issue and provided a Pull Request as well, which after being Jürgenized, it got integrated, and available starting with Spring Framework 5.1.

В pom.xml версия Spring Boot 2.7.4, в которой Spring Framework 5.3.23.


тебя спасло только, что в сервисе над update висит обычный не readonly transactional.

Это сделано намеренно.


Отсюда вопрос, зачем над getTagById висит readonly, если можно сразу над сервисом разруливать транзакцию

Да в принципе можно было бы и убрать, т.к. там по дефолту readOnly. Просто для наглядности, это все-таки демонстрационный проект.
К тому же, getTagById используется еще и в других местах.

@Throwable
17.10.2022 06:28 UTC
-2

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

@Arty_Fact
17.10.2022 10:04 UTC
0
Ну не знаю. Все-таки у многих объектов есть дополнительные филды, типа created, updated, author и т. д., которые нужны в работе, но не нужны фронтенду, и DTO сильно помогает. Ну и плюс сразу защищаешься от сложностей при переименовывании полей. Удобно, как мне кажется.
17.10.2022 16:37 UTC
0

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

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

17.10.2022 17:32 UTC
0

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

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

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

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

19.10.2022 07:19 UTC
+2

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

20.10.2022 06:29 UTC
0

Это понятно. Просто изначально вопрос был, если модели данных практически полностью совпадает с API, то зачем делать ещё один слой, вместо того, чтобы отдать сразу Entities? Пример из прошлого: инвентарная система с огромным количеством разных Entities и API для доступа ко всем ним.

20.10.2022 07:12 UTC
+1
Пример из прошлого: инвентарная система с огромным количеством разных Entities и API для доступа ко всем ним.

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

@aleksandy
23.10.2022 18:02 UTC
0

при грамотно выставленных EntityGraphs

Я тебе один умный вещь скажу, только ты не обижайся. (с) Не везде используется hibernate. Во-вторых, EntityGraphs, емнип, не позволяют исключить из загрузки "примитивные" типы данных, не вложенные сущности. Возьмём наипростейший пример с пользователем и его банковским счётом. У счёта, как правило, куча атрибутов, а на форме оплаты из личного кабинета - тупой комбобокс с выбором номера счёта, с которого оплату следует списать. И вот вопрос, а зачем мне для отображения грузить из БД всю трехомудь, если надо-то только номер и идентификатор?

24.10.2022 06:20 UTC
0

EntityGraphs, емнип, не позволяют исключить из загрузки "примитивные" типы данных,

Может, и уже достаточно давно.

Возьмём наипростейший пример с пользователем и его банковским счётом

Это понятно. Но это не единственный кейс, хоть и распространенный. Если у вас инвентарная система с сотнями сущностей, и нужно организовать универсальный доступ к этим данным, при этом основной потребитель даже не фронт, а другие сервисы... задолбаетесь строчить тонны ненужных DTO и маппингов. Проще выставить всю модель наружу, при этом контролируя доступ к определенным полям. В качестве референтов можно указать Hateos и JPARS.

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