Статьи Утилиты Telegram YouTube RuTube Отзывы

CrudRepository на Kotlin

Исходники

20 ноября 2024

Тэги: gradle, Hibernate, json, Kotlin, rest, Spring Data, SQL.

Содержание

  1. Создаём таблицу в БД и наполняем её данными
  2. Класс-сущность для маппинга таблицы
  3. Слой работы с БД
  4. Сервисный слой
  5. Слой контроллера
  6. Тестируем работу сервиса
  7. Выводы

Рассмотрим пример 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 помимо стандартных должен содержать следующие зависимости:

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-data-jpa")
    implementation("org.springframework.boot:spring-boot-starter-validation")
    implementation("org.springframework.boot:spring-boot-starter-web")
    runtimeOnly("com.h2database:h2")
    // другие стандартные зависимости...
}

Обратите внимание, что драйвер H2 добавлен как runtimeOnly – это означает, что для компиляции он не требуется, т.к. драйвер мы пропишем в настройках подключения в декларативном виде.

Создаём таблицу в БД и наполняем её данными

Spring Boot позволяет автоматически создавать таблицу в БД и наполнять её первичными данными при старте приложения. В реальных проектах вы вряд ли так будете делать, но для учебного примера это очень удобно.

Создадим файл resources/schema.sql и поместим в нём скрипт для создания таблицы band:

create table band(
  id int auto_increment not null primary key,
  name varchar(50) not null,
  players_count int not null,
  created date not null
);

Модификатор auto_increment означает поле, значение которого автоматически увеличивается на 1 с каждой новой записью.

Рядом создадим файл resources/data.sql, который будет наполнять нашу таблицу данными при старте приложения:

insert into band (name, players_count, created) values ('Queen', 4, '1973-07-13');
insert into band (name, players_count, created) values ('Rolling Stones', 4, '1962-08-01');
insert into band (name, players_count, created) values ('Scorpions', 5, '1965-01-01');

Наконец, пропишем параметры подключения к нашей БД в файле application.yml. В случае с H2 настройки можно скопировать «как есть»:

spring:
  datasource:
    url: jdbc:h2:mem:mydb
    username: sa
    password: password
    driverClassName: org.h2.Driver
  jpa:
    hibernate:
      ddl-auto: none

Здесь мы прописываем url подключения, имя пользователя и пароль, а также драйвер. Кроме того, добавляем параметр ddl-auto=none, чтобы наш механизм заполнения данными при старте приложения работал корректно.

Поскольку H2 держит данные в оперативной памяти, то после перезапуска приложения данные будут потеряны.

Класс-сущность для маппинга таблицы

Для начала создадим класс, который будет мапиться на таблицу band.

@Entity
@Table(name = "band")
class Band(

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Int = 0,

    var name: String,

    @Column(name = "players_count")
    var playersCount: Int,

    var created: LocalDate
)

Аннотация @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 берёт много работы на себя, нам достаточно определить интерфейс, следуя соглашениям об именовании. Реализацию интерфейса делать не нужно.

interface BandRepository : CrudRepository<Band, Int> {

    fun findByOrderByName(): List<Band>
}

Тут мы расширяем стандартный интерфейс CrudRepository, типизируя его нашей сущностью Band и типом идентификатора сущности Int. Этот интерфейс уже содержит все необходимые методы вроде поиска, сохранения и удаления сущности.

Мы же добавим в него только один кастомный метод findByOrderByName() – вернуть список всех сущностей и упорядочить их по имени.

Обратите внимание, что мы нигде явно не указываем запрос, с помощью которого вытаскиваем данные из базы. Как это работает? Благодаря соглашению об именовании. Общий шаблон имени метода такой: findBy + имя параметра (если есть) + OrderBy + имя поля, по которому нужно делать сортировку. Вы также можете задать произвольное имя метода и явно указать JPA-запрос с помощью аннотации @Query.

Сервисный слой

Теперь перейдём к сервисному слою с бизнес-логикой. Набор методов нашего сервиса имеет вид:

class BandService {

    fun getAll(): List<Band>

    fun getById(id: Int): Band

    fun create(request: SaveBandRequest)

    fun update(id: Int, request: SaveBandRequest)

    fun delete(id: Int)
}

Обратите внимание, что методы create и update принимают одну и ту же сущность SaveBandRequest:

data class SaveBandRequest(

    @get:NotNull
    @get:Size(min = 1, max = 50)
    val name: String?,

    @get:Positive
    val playersCount: Int,

    @get:NotNull
    @get:Past
    val created: LocalDate?
)

Эта сущность представляет собой тело json-запроса – data class с тремя полями и валидацией (см. Валидация бинов в Spring). Поскольку мы не пишем здесь get и set-методы в явном виде, каждую аннотацию снабжаем префиксом @get, указывающим, что аннотация относится именно к get-методу, а не к полю.

Также обратите внимание, что мы используем nullable типы данных с аннотацией @NotNull. Это сделано для корректной работы валидации в случае отсутствия параметра. Ведь если такое поле не придёт в запросе, то в случае с not-null типом мы не сможем десериализовать запрос.

Перейдём к реализации сервисного слоя.

@Service
class BandService(private val bandRepository: BandRepository) {

    // реализация методов

    companion object {
        private val log = LoggerFactory.getLogger(BandService::class.java)
    }
}

Снабжаем класс spring-аннотацией @Service. В круглых скобках рядом с именем класса приведена краткая форма записи конструктора, который принимает BandRepository. Этот класс будет автоматически внедрён как зависимость в наш сервис.

Также сразу подключим логирование с помощью стандартного LoggerFactory.

Далее определим два метода поиска.

fun getAll(): List<Band> {
    log.info("Get all bands")
    return bandRepository.findByOrderByName()
}

fun getById(id: Int): Band {
    log.info("Get band with id=$id")
    return bandRepository.findByIdOrNull(id) ?: throw BandNotFoundException(id)
}

Делаем запись в логе, а затем вызываем соответствующий метод репозитория. В Kotlin можно напрямую подставлять в строку значение переменной, предваряя её имя знаком доллара.

Метод findByIdOrNull() является методом расширения для стандартного findById() и возвращает нам null, если запись с таким id не найдена. Мы хотим кидать исключение в этом случае, поэтому воспользуемся конструкцией «?:». Её правая часть, кидающая исключение, будет выполнена только если метод вернёт null. Если же метод вернёт сущность, то мы просто пробросим её дальше.

В именовании собственных методов я следую такой семантике: если метод возвращает nullable-значение (со знаком вопроса), то название метода начинается с find. Если же метод всегда возвращает not-null значение, то название метода начинается с get.

Исключение BandNotFoundException на Kotlin выглядит довольно компактно:

@ResponseStatus(HttpStatus.NOT_FOUND)
class BandNotFoundException(id: Int): RuntimeException("Band with id=$id not found")

Мы наследуемся от RuntimeException, определяем в конструкторе параметр id и будем формировать текст сообщения с указанием этого id. Также добавим аннотацию @ResponseStatus, чтобы в случае этой ошибки клиент получал http-status 404 (сущность не найдена).

Вернёмся к сервисному слою и определим методы создания, обновления и удаления сущности:

fun create(request: SaveBandRequest) {
    log.info("Create new band with name=${request.name}")
    bandRepository.save(
        Band(
            name = request.name!!,
            playersCount = request.playersCount,
            created = request.created!!
        )
    )
}

Для создания новой записи в БД воспользуемся стандартным методом save(). В качестве параметра ему достаточно передать экземпляр класса Band.

Оператор «!!» используется для преобразования nullable-типов в not-null, когда мы явно уверены в том, что null тут никогда не будет. Нам это гарантирует валидация входящего запроса.

fun update(id: Int, request: SaveBandRequest) {
    log.info("Update band with id=$id")
    val band = bandRepository.findByIdOrNull(id)
        ?: throw BandNotFoundException(id)
    band.name = request.name!!
    band.playersCount = request.playersCount
    band.created = request.created!!
    bandRepository.save(band)
}

При обновлении мы сначала пытаемся найти сущность по указанному id и возвращаем ошибку в случае, если такой сущности нет. Если сущность найдена, устанавливаем новые значения на основе полей из запроса. Меняем все поля кроме id. Поскольку id сохраняется, Spring Data поймёт, что это не новая запись, а уже существующая, и выполнит её обновление.

fun delete(id: Int) {
    log.info("Delete band with id=$id")
    val band = bandRepository.findByIdOrNull(id) ?: throw BandNotFoundException(id)
    bandRepository.delete(band)
}

При удалении также сначала ищем сущность, а затем её удаляем.

companion object {
    private val log = LoggerFactory.getLogger(BandService::class.java)
}

В конце объявляем companion object – это вложенный класс, который гарантированно создаётся в единственном экземпляре независимо от того, сколько будет создано экземпляров родительского класса (в нашем случае это BandService). В таком внутреннем классе принято объявлять все константы, т.к. kotlin не содержит ключевого слова static, но companion object по смыслу обеспечивает именно эту функциональность. Здесь мы объявляем логгер.

Слой контроллера

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

@RestController
@RequestMapping("/bands")
class BandController(
    private val bandService: BandService,
) {

    @GetMapping
    fun getAll() = bandService.getAll()

Тут мы используем спринговые аннотации @RestController и @RequestMapping и указываем базовый урл всех запросов (/bands).

Как и в сервисном слое, создаём конструктор, который принимает BandService. Spring автоматически внедрит этот бин в наш контроллер.

Далее определяем метод getAll(), причём в краткой форме. Kotlin позволяет не писать тело метода и тип возвращаемого значения, если нужно выполнить ровно одну инструкцию. В таком случае после круглых скобок просто пишем «=» и Kotlin сам выведет тип возвращаемого значения. С помощью аннотации @GetMapping указываем, что это обработчик для GET-запроса.

@GetMapping("/{id}")
fun getById(@PathVariable("id") id: Int): Band {
    return bandService.getById(id)
}

Метод getById() принимает на вход параметр id, который будет указан в урле GET-запроса. Связь параметра метода и части урла задаёт аннотация @PathVariable.

@PostMapping
fun create(@Valid @RequestBody request: SaveBandRequest): StatusResponse {
    bandService.create(request)
    return StatusResponse("Created")
}

Далее определяем POST-запрос с помощью аннотации @PostMapping для добавления новых записей. Поскольку все параметры будут передаваться в теле запроса, пометим это с помощью аннотации @RequestBody, а также активируем механизм валидации с помощью @Valid. Если эту аннотацию не добавить, валидация работать не будет!

data class StatusResponse(
    val status: String,
)

В качестве результата мы возвращаем некий текстовый статус с помощью data class StatusResponse. Это сделано для того, чтобы что-то возвращать в ответ, т.к. если клиенту ничего в ответ не приходит, это может сбивать с толку.

@PutMapping("/{id}")
fun update(
    @PathVariable("id") id: Int,
    @Valid @RequestBody request: SaveBandRequest,
): StatusResponse {
    bandService.update(id, request)
    return StatusResponse("Updated")
}

@DeleteMapping("/{id}")
fun delete(
    @PathVariable("id") id: Int,
): StatusResponse {
    bandService.delete(id)
    return StatusResponse("Deleted")
}

PUT-запрос используется для обновления имеющейся сущности. На вход он принимает тот же SaveBandRequest. При этом в урле нужно указывать id. Для DELETE-запроса тело запроса не требуется.

Тестируем работу сервиса

В результате мы получили полный набор операций для поиска, создания, обновления и удаления исполнителей в БД. Если мы запустим наше приложение, то уже сможем получить список всех музыкальных групп, которые были добавлены с помощью скрипты data.sql. Для этого выполним GET-запрос по урлу http://127.0.0.1:8080/bands. В ответ получим такой json:

[
    {
        "id": 1,
        "name": "Queen",
        "playersCount": 4,
        "created": "1973-07-13"
    },
    {
        "id": 2,
        "name": "Rolling Stones",
        "playersCount": 4,
        "created": "1962-08-01"
    },
    {
        "id": 3,
        "name": "Scorpions",
        "playersCount": 5,
        "created": "1965-01-01"
    }
]

Теперь давайте отредактируем запись с номером 1, отправив запрос PUT http://127.0.0.1:8080/bands/1 с таким содержимым:

{
    "name": "Queen modified",
    "playersCount": 4,
    "created": "1973-07-13"
}

В ответе получим:

{
    "status": "Updated"
}

Теперь если выполнить запрос на получение всего списка или запросить конкретную группу по id (GET http://127.0.0.1:8080/bands/1), то мы увидим изменённое название группы.

Наконец, давайте удалим группу с id=3 с помощью запроса DELETE http://127.0.0.1:8080/bands/3. В ответ получим:

{
    "status": "Deleted"
}

После этого в списке всех групп станет на 1 элемент меньше.

Выводы

Мы рассмотрели пример rest-сервиса, который реализует три слоя для работы с нашей сущностью: слой работы с базой, сервисный слой и контроллер. Если сравнивать код на Java и код на Kotlin, то последний выигрывает своей краткостью без ущерба читаемости. Также Kotlin явно разделяет nullable и не-nullable типы и вводит синтаксические конструкции для работы с ними. В остальном Kotlin полностью совместим с Java, причём до такой степени, что вы можете писать в одном проекте на обоих языках. Spring также полностью совместим с Kotlin.


См. также


Комментарии

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.

Добавить комментарий

×

devmark.ru