В данной статье будет рассмотрен способ применения 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);
}
Полностью код можно посмотреть вот здесь.