CrudRepository на Kotlin

Вернуться назад

1 мая 2019

Ранее я уже писал статью CrudRepository в Spring Data, в которой рассматривался пример rest-сервиса, работающего с базой данных. Теперь хочу показать аналогичный пример, но вместо Java написать его на Kotlin, который стремительно набирает популярность. Rest-сервис состоит из трёх слоёв: слой работы с БД, сервисный слой и контроллер. Мы пойдём последовательно по слоям, начиная с нижнего.

В качестве примера возьмём сервис, работающий с музыкальными группами. У группы есть три основных параметра: название, количество участников и дата основания. Структура таблицы в postgres может выглядеть следующим образом:

create table band
(
  id serial,
  name character varying(50not null,
  players_count integer not null,
  created date not null,
  constraint band_pk primary key (id)
);

Тип данных serial означает поле, значение которого автоматически увеличивается на 1 с каждой новой записью.

Заготовку проекта удобно сгенерить через start.spring.io. Там достаточно выбрать тип проекта - maven project, язык - kotlin. В качестве dependency добавить Web и JPA. Затем нажимаем Generate Project и вы уже скачали архив с заготовкой вашего проекта. Помимо указанных dependency для kotlin будут добавлены ещё несколько служебных, а также maven-плагины для его компиляции.

В секцию dependencies нам нужно добавить драйвер postgres, т.к. мы планируем работать именно с этой базой:

<dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <version>42.2.5</version>
</dependency>

Итоговый pom-файл проекта вы можете посмотреть на github. Ссылка в конце статьи.

Маппинг таблицы

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

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

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

        var name: String,

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

        var created: LocalDate
)

Аннотация @Entity означает, что перед нами JPA-сущность (Java Persistence API). @Table позволяет задать имя таблицы. У нас оно задано явно, но если имя класса совпадает с именем таблицы, то этого можно не делать. data class - это особый вид классов в Kotlin, поля которых автоматически снабжаются методами get и set. Также для них генерятся equals(), hashCode() и toString(), что очень удобно для классов-сущностей.

Ключевое слово var означает переменную, значение которой может изменяться после инициализации. В отличие от val, значение которой задаётся только 1 раз и потом его изменить уже нельзя. Тип переменной в Kotlin записывается после её имени через двоеточие. Наличие «?» рядом с типом означает, что данная переменная может хранить null. Иначе присваивать null нельзя. Таким образом гарантируется защита от NullPointerException.

Аннотация @Id указывает на то поле, которое является идентификатором сущности (primary key в таблице). @GeneratedValue позволяет выбрать стратегию генерации идентификаторов. Поскольку у нас поле с автоинкрементом на уровне базы данных, мы выбираем GenerationType.IDENTITY. @Column позволяет задать имя столбца в таблице, который соответствует этому полю. Если имя поля и имя столбца совпадает, делать это не обязательно.

Слой работы с БД

Теперь создадим слой работы с базой данных. Поскольку Spring Data берёт много работы на себя, нам достаточно определить интерфейс, следуя соглашениям об именовании. Реализацию интерфейса делать не нужно.

interface BandDao : CrudRepository<Band, Int> {

    fun findAllByOrderByName(): List<Band>

    fun getById(id: Int): Band?
}

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

В Kotlin метод начинается с ключевого слова fun. Затем перечисляются параметры этого метода. После круглых скобок указывается тип возвращаемого значения (если есть). Мы же для удобства добавили ещё парочку методов. findAllByOrderByName() - вернуть список всех сущностей и упорядочить их по имени. А также getById который возвращает nullable-тип Band. Если сущность не найдена, метод вернёт null.

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

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

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

interface BandService {

    fun findAll(): List<Band>

    fun findById(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 BandServiceImpl(private val bandDao: BandDao) : BandService {

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

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

Далее идёт companion object - это внутренний singleton-класс, предназначенный для статичных полей. Дело в том, что в Kotlin нельзя использовать модификатор static. В данном случае мы создаём логгер и присваиваем его полю log, которое является единым для всех экземпляров BandServiceImpl, сколько бы их ни было создано.

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

override fun findAll(): List<Band> {
    log.info("Find all bands")
    return bandDao.findAllByOrderByName()
}

override fun findById(id: Int): Band {
    log.info("Find band with id=$id")
    return bandDao.getById(id) ?: throw BandNotFoundException(id)
}

В отличие от аннотации @Override в Java, для указания переопределённого метода в Kotlin есть специальное ключевое слово.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@RestController
@RequestMapping("/bands", produces = [MediaType.APPLICATION_JSON_VALUE])
class BandController(private val bandService: BandService) {

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

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

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

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

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

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

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

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

В качестве результата мы возвращаем некий текстовый статус с помощью 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-запроса тело запроса не требуется.

В результате мы получили полный набор операций для поиска, создания, обновления и удаления исполнителей в БД. Если мы запустим наше приложение, то все эти запросы будут доступны по базовому урлу http://127.0.0.1:8080/bands/. Там, где требуется указывать id, просто добавляем его к урлу.

Выводы

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

Тэги: Kotlin, PostgreSQL, SQL, Spring Boot, Spring Data, rest.


Исходники


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

Ваше имя:
Текст комментария: