20 ноября 2024
Тэги: gradle, Hibernate, json, Kotlin, rest, Spring Data, SQL.
Рассмотрим пример rest-сервиса, написанного на Kotlin и работающего с базой данных с помощью Spring Data JPA. Rest-сервис состоит из трёх слоёв: слой работы с БД, сервисный слой и контроллер. Мы пойдём последовательно по слоям, начиная с нижнего.
В качестве примера возьмём сервис, работающий с музыкальными группами. У каждой группы будет три основных параметра: название, количество участников и дата основания.
Заготовку проекта удобно сгенерить через Spring Initializr. Там достаточно выбрать тип проекта – Gradle – Kotlin, язык – Kotlin. В качестве dependency надо добавить Spring Web (функциональность rest-контроллеров), Spring Data JPA (работа с БД), Validation (валидация входящих rest-запросов) и драйвер вашей СУБД. Для удобства мы будем использовать H2, т.к. она автоматически создаётся в оперативной памяти и не требует никакой настройки. Но вы можете использовать любую другую СУБД. Достаточно подключить нужный драйвер в файле build.gradle.kts и прописать параметры подключения в файле application.yml.
Затем нажимаем Generate – и скачиваем архив с заготовкой проекта. В итоге файл build.gradle.kts в секции dependencies помимо стандартных должен содержать следующие зависимости:
Обратите внимание, что драйвер H2 добавлен как runtimeOnly – это означает, что для компиляции он не требуется, т.к. драйвер мы пропишем в настройках подключения в декларативном виде.
Spring Boot позволяет автоматически создавать таблицу в БД и наполнять её первичными данными при старте приложения. В реальных проектах вы вряд ли так будете делать, но для учебного примера это очень удобно.
Создадим файл resources/schema.sql и поместим в нём скрипт для создания таблицы band:
Модификатор auto_increment означает поле, значение которого автоматически увеличивается на 1 с каждой новой записью.
Рядом создадим файл resources/data.sql, который будет наполнять нашу таблицу данными при старте приложения:
Наконец, пропишем параметры подключения к нашей БД в файле application.yml. В случае с H2 настройки можно скопировать «как есть»:
Здесь мы прописываем url подключения, имя пользователя и пароль, а также драйвер. Кроме того, добавляем параметр ddl-auto=none, чтобы наш механизм заполнения данными при старте приложения работал корректно.
Поскольку H2 держит данные в оперативной памяти, то после перезапуска приложения данные будут потеряны.
Для начала создадим класс, который будет мапиться на таблицу band.
Аннотация @Entity означает, что перед нами JPA-сущность (Java Persistence API). @Table позволяет задать имя таблицы. У нас оно задано явно, но если имя класса совпадает с именем таблицы, то этого можно не делать.
Для JPA-сущностей в Kotlin надо использовать обычные классы, а не data-классы. Кроме того, все поля в классе-сущности должны быть с модификатором var. Эти ограничения кажутся не логичными, однако изначально Spring Data JPA разрабатывался для Java и потому накладывает некоторые технические ограничения. Он требует, чтобы все классы-сущности были изменяемыми в любой момент. Иначе он будет создавать копию каждого объекта при любом изменении данных, что быстро приведёт к перерасходу оперативной памяти.
Аннотация @Id указывает на то поле, которое является идентификатором сущности (primary key в таблице). @GeneratedValue позволяет выбрать способ генерации идентификаторов. Поскольку у нас поле с автоинкрементом на уровне базы данных, мы выбираем GenerationType.IDENTITY. @Column позволяет задать имя столбца в таблице, который соответствует этому полю. Если имя поля и имя столбца совпадает, делать это не обязательно. Также имя поля в таблице, если оно содержит нижние подчёркивания (т.н. «snake case»), будет автоматически мапиться на «camelCase», принятый в Java и Kotlin.
Теперь создадим слой работы с базой данных. Поскольку Spring Data берёт много работы на себя, нам достаточно определить интерфейс, следуя соглашениям об именовании. Реализацию интерфейса делать не нужно.
Тут мы расширяем стандартный интерфейс CrudRepository, типизируя его нашей сущностью Band и типом идентификатора сущности Int. Этот интерфейс уже содержит все необходимые методы вроде поиска, сохранения и удаления сущности.
Мы же добавим в него только один кастомный метод findByOrderByName() – вернуть список всех сущностей и упорядочить их по имени.
Обратите внимание, что мы нигде явно не указываем запрос, с помощью которого вытаскиваем данные из базы. Как это работает? Благодаря соглашению об именовании. Общий шаблон имени метода такой: findBy + имя параметра (если есть) + OrderBy + имя поля, по которому нужно делать сортировку. Вы также можете задать произвольное имя метода и явно указать JPA-запрос с помощью аннотации @Query.
Теперь перейдём к сервисному слою с бизнес-логикой. Набор методов нашего сервиса имеет вид:
Обратите внимание, что методы create и update принимают одну и ту же сущность SaveBandRequest:
Эта сущность представляет собой тело json-запроса – data class с тремя полями и валидацией (см. Валидация бинов в Spring). Поскольку мы не пишем здесь get и set-методы в явном виде, каждую аннотацию снабжаем префиксом @get, указывающим, что аннотация относится именно к get-методу, а не к полю.
Также обратите внимание, что мы используем nullable типы данных с аннотацией @NotNull. Это сделано для корректной работы валидации в случае отсутствия параметра. Ведь если такое поле не придёт в запросе, то в случае с not-null типом мы не сможем десериализовать запрос.
Перейдём к реализации сервисного слоя.
Снабжаем класс spring-аннотацией @Service. В круглых скобках рядом с именем класса приведена краткая форма записи конструктора, который принимает BandRepository. Этот класс будет автоматически внедрён как зависимость в наш сервис.
Также сразу подключим логирование с помощью стандартного LoggerFactory.
Далее определим два метода поиска.
Делаем запись в логе, а затем вызываем соответствующий метод репозитория. В Kotlin можно напрямую подставлять в строку значение переменной, предваряя её имя знаком доллара.
Метод findByIdOrNull() является методом расширения для стандартного findById() и возвращает нам null, если запись с таким id не найдена. Мы хотим кидать исключение в этом случае, поэтому воспользуемся конструкцией «?:». Её правая часть, кидающая исключение, будет выполнена только если метод вернёт null. Если же метод вернёт сущность, то мы просто пробросим её дальше.
В именовании собственных методов я следую такой семантике: если метод возвращает nullable-значение (со знаком вопроса), то название метода начинается с find. Если же метод всегда возвращает not-null значение, то название метода начинается с get.
Исключение BandNotFoundException на Kotlin выглядит довольно компактно:
Мы наследуемся от RuntimeException, определяем в конструкторе параметр id и будем формировать текст сообщения с указанием этого id. Также добавим аннотацию @ResponseStatus, чтобы в случае этой ошибки клиент получал http-status 404 (сущность не найдена).
Вернёмся к сервисному слою и определим методы создания, обновления и удаления сущности:
Для создания новой записи в БД воспользуемся стандартным методом save(). В качестве параметра ему достаточно передать экземпляр класса Band.
Оператор «!!» используется для преобразования nullable-типов в not-null, когда мы явно уверены в том, что null тут никогда не будет. Нам это гарантирует валидация входящего запроса.
При обновлении мы сначала пытаемся найти сущность по указанному id и возвращаем ошибку в случае, если такой сущности нет. Если сущность найдена, устанавливаем новые значения на основе полей из запроса. Меняем все поля кроме id. Поскольку id сохраняется, Spring Data поймёт, что это не новая запись, а уже существующая, и выполнит её обновление.
При удалении также сначала ищем сущность, а затем её удаляем.
В конце объявляем companion object – это вложенный класс, который гарантированно создаётся в единственном экземпляре независимо от того, сколько будет создано экземпляров родительского класса (в нашем случае это BandService). В таком внутреннем классе принято объявлять все константы, т.к. kotlin не содержит ключевого слова static, но companion object по смыслу обеспечивает именно эту функциональность. Здесь мы объявляем логгер.
Теперь поднимемся на самый верхний слой, который непосредственно обрабатывает входящие запросы.
Тут мы используем спринговые аннотации @RestController и @RequestMapping и указываем базовый урл всех запросов (/bands).
Как и в сервисном слое, создаём конструктор, который принимает BandService. Spring автоматически внедрит этот бин в наш контроллер.
Далее определяем метод getAll(), причём в краткой форме. Kotlin позволяет не писать тело метода и тип возвращаемого значения, если нужно выполнить ровно одну инструкцию. В таком случае после круглых скобок просто пишем «=» и Kotlin сам выведет тип возвращаемого значения. С помощью аннотации @GetMapping указываем, что это обработчик для GET-запроса.
Метод getById() принимает на вход параметр id, который будет указан в урле GET-запроса. Связь параметра метода и части урла задаёт аннотация @PathVariable.
Далее определяем POST-запрос с помощью аннотации @PostMapping для добавления новых записей. Поскольку все параметры будут передаваться в теле запроса, пометим это с помощью аннотации @RequestBody, а также активируем механизм валидации с помощью @Valid. Если эту аннотацию не добавить, валидация работать не будет!
В качестве результата мы возвращаем некий текстовый статус с помощью data class StatusResponse. Это сделано для того, чтобы что-то возвращать в ответ, т.к. если клиенту ничего в ответ не приходит, это может сбивать с толку.
PUT-запрос используется для обновления имеющейся сущности. На вход он принимает тот же SaveBandRequest. При этом в урле нужно указывать id. Для DELETE-запроса тело запроса не требуется.
В результате мы получили полный набор операций для поиска, создания, обновления и удаления исполнителей в БД. Если мы запустим наше приложение, то уже сможем получить список всех музыкальных групп, которые были добавлены с помощью скрипты data.sql. Для этого выполним GET-запрос по урлу http://127.0.0.1:8080/bands. В ответ получим такой json:
Теперь давайте отредактируем запись с номером 1, отправив запрос PUT http://127.0.0.1:8080/bands/1 с таким содержимым:
В ответе получим:
Теперь если выполнить запрос на получение всего списка или запросить конкретную группу по id (GET http://127.0.0.1:8080/bands/1), то мы увидим изменённое название группы.
Наконец, давайте удалим группу с id=3 с помощью запроса DELETE http://127.0.0.1:8080/bands/3. В ответ получим:
После этого в списке всех групп станет на 1 элемент меньше.
Мы рассмотрели пример rest-сервиса, который реализует три слоя для работы с нашей сущностью: слой работы с базой, сервисный слой и контроллер. Если сравнивать код на Java и код на Kotlin, то последний выигрывает своей краткостью без ущерба читаемости. Также Kotlin явно разделяет nullable и не-nullable типы и вводит синтаксические конструкции для работы с ними. В остальном Kotlin полностью совместим с Java, причём до такой степени, что вы можете писать в одном проекте на обоих языках. Spring также полностью совместим с Kotlin.
Kotlin, Java, Spring, Spring Boot, Spring Data, SQL, PostgreSQL, Oracle, Linux, Hibernate, Collections, Stream API, многопоточность, файлы, Nginx, Apache, maven, gradle, JUnit, YouTube, новости, руководство, ООП, алгоритмы, головоломки, rest, GraphQL, Excel, XML, json, yaml.
03.09.2024 12:59 Синецкий Роман
Как выглядит метод с произвольным именем и явным JPA-запросом с помощью аннотации @Query?
У меня в классе есть параметр типа java.sql.Timestamp, по нему, к сожалению, не удаётся реализовать findByOrderByTime_now() в dao. Почему?
Можно ли сделать сортировку OrderBy в обратном порядке?
03.09.2024 13:11 devmark
Как называется это поле Timestamp? Вообще вместо него можно например LocalDateTime использовать. Обратная сортировка будет выглядеть как OrderByИмяПоляDesc.
03.09.2024 14:22 Синецкий Роман
Я понял что с параметром не получается использовать метод по соглашению из-за нижнего подчеркивания "_". В поле и в классе Report параметр был как time_now. Переименовал в Report как timeNow и указал аннотацию @Column(name = "time_now")
Работает нормально с методом findByOrderByTimeNow()
03.09.2024 15:27 Синецкий Роман
А как делается метод с явным JPA-запросом с помощью аннотации @Query? Интересно.
03.09.2024 15:48 devmark
будет что-то вроде @Query("select u from User u where u.id = :id order by u.created desc")
то есть в отличие от обычного native sql в запросах участвуют объекты.
20.11.2024 22:48 devmark
Статья и проект на github обновлены и переведены на Spring Boot 3.