В данной статье будет рассмотрен способ применения 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)
aleksandy
17.10.2022 08:23+4Ох уж эти Spring Java Developer-ы...
Такая организация кода - говнокод, хоть с записями, хоть с обыкновенными классами. Транзакции должны управляться на уровне инфраструктурных фасадов, а не репозиториев.
В наипростейшей updateTagContent() открывается 2 транзакции, между которыми в конкурентном окружении может вклиниться удаление и таки никакого обновления не случится.
isden Автор
17.10.2022 09:55Транзакции должны управляться на уровне инфраструктурных фасадов, а не репозиториев.
А в документации пишут "CRUD methods on repository instances are transactional by default."
открывается 2 транзакции, между которыми
А разве сам метод не будет выполняться одной транзакцией (с вложенными из репозитория)?
Throwable
17.10.2022 09:28-1Меня всегда удивляет боязнь девелоперов вместо DTO отдавать сразу Entities. Начинают что-то говорить, что это неправильно, и что-то про разные модели и секьюрити. Но на практике это те же самые POJO, и в 99% случаев из fieldset-ы совпадают. А при грамотно выставленных EntityGraphs можно четко контролировать отдаваемый контент. Поэтому чаще всего следуя каким-то искусственным паттернам, сами себе усложняем жизнь.
Arty_Fact
17.10.2022 13:04Ну не знаю. Все-таки у многих объектов есть дополнительные филды, типа created, updated, author и т. д., которые нужны в работе, но не нужны фронтенду, и DTO сильно помогает. Ну и плюс сразу защищаешься от сложностей при переименовывании полей. Удобно, как мне кажется.
Throwable
17.10.2022 19:37Для сильно служебных полей есть @JsonIgnore и ещё куча способов исключить их из сериализации. Хорошая модель данных организована так, чтобы отделить процессинг от стейта: не загромождать сами Entities промежуточными состояниями и служебными данными, а выделить их в отдельный объект.
Согласно моему горькому опыту в любом проекте очень быстро замусоривается DTO, всевозможными мепперами и промежуточными сервисами. Доходит до того, что на одну entity получается с десяток схожих DTO. В то же время успешный рефакторинг на раздачу Entities уменьшил кодовую базу сразу на 90%.
isden Автор
17.10.2022 20:32А я видел проекты где DTO долго и успешно используются, без этих всяких ужасов.
Наверное все сильно зависит от команды и от структуры/архитектуры проекта.
Arty_Fact
18.10.2022 12:48Даже не могу представить зачем при одном API может понадобиться десяток схожих DTO. Я могу понять два схожих DTO: для запроса и для ответа. Но зачем еще куча?
Ну и конечно не представляю, что там за код такой был, на 90 % состоящий из мапперов и DTO. Взглянуть бы на проект до рефакторинга.Throwable
19.10.2022 09:27+1Там до Api как такового никому особо дела не было. Проект прошел по куче рук, и каждый реализовал тикет как мог. Для каждой новой фичи на бэке тупо писался свой контроллер, сервис, DTO и маппер. В итоге все т.н. "api" -- это куча findByXxx, которые отдают схожие DTO с немного разным набором филдов под каждый конкретный кейс.
isden Автор
19.10.2022 10:19+1Ну так тут очевидно проблема была совсем не в паттерне DTO как таковом.
Antharas
Это все конечно замечательно, синтаксический сахар и все вот это вот удобство.. но вы же, наверно, вкурсе о больших накладных расходах при создании record-классов? Нет? Проведите стресс тест системы, будете удивлены. Крайне не советую в принципе использовать в высоконагруженной системе все эти удобства.
isden Автор
А где про это почитать?
Везде где я читал пишут, что +- как у обычных классов.
Сейчас погуглил, сходу то же самое нашлось.
Antharas
Увы, материалы сам когда-то искал, так и не нашел. Провел пару тестов в нагрузке (tcp - 10к клиентов с рейтом в 30-40rps) - профайлер показал деградацию в Record<init> (jdk 19).
isden Автор
Ок, спасибо. Если будет свободное время, попробую потестировать разницу в производительности.
Но кмк, если бы с этим были серьезные проблемы, об этом бы точно писали и обсуждали.
Antharas
Да, имхо единичный "мой" случай, дособираю статистику и вышлю багрепортом.
Hixon10
> вы же, наверно, вкурсе о больших накладных расходах при создании record-классов
Больших расходов по сравнению с чем? С созданием обычных объектов, или не пулом объектов?