Статьи

CrudRepository на Kotlin

Вернуться назад Исходники

29 октября 2020

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

Содержание

  1. Маппинг таблицы
  2. Слой работы с БД
  3. Сервисный слой
  4. Слой контроллера
  5. Выводы

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

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

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

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

Заготовку проекта удобно сгенерить через Spring Initializr. Там достаточно выбрать тип проекта - gradle, язык - kotlin. В качестве dependency надо добавить Spring Web (функциональность rest-контроллеров), Spring Data JPA (работа с БД), Validation (валидация входящих rest-запросов) и PostgreSQL Driver (драйвер нашей СУБД). Затем нажимаем Generate - и вы уже скачали архив с заготовкой вашего проекта. В итоге файл build.gradle.kts в секции dependencies помимо стандартных должен также содержать следующие зависимости:

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-data-jpa")
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("org.springframework.boot:spring-boot-starter-validation")
    runtimeOnly("org.postgresql:postgresql")

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

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

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

@Entity
@Table(name = "band") // не обязательно
data class Band(

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

        val name: String,

        @Column(name = "players_count") // не обязательно
        val playersCount: Int,

        val created: LocalDate
)

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

Ключевое слово val означает переменную, значение которой задаётся только 1 раз и потом его изменить уже нельзя. Kotlin предполагает использование функционального стиля, поэтому очень желательно, чтобы все объекты были неизменяемыми (т.е. инициализировались один раз при создании). Тип переменной в Kotlin записывается после её имени через двоеточие.

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

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

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

interface BandDao : CrudRepository<Band, Int> {

    fun findByOrderByName(): List<Band>
}

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

В Kotlin метод начинается с ключевого слова fun. Затем перечисляются параметры этого метода. После круглых скобок указывается тип возвращаемого значения (если есть). Мы же добавим только один кастомный метод findByOrderByName() - вернуть список всех сущностей и упорядочить их по имени.

Обратите внимание, что мы нигде явно не указываем запрос, с помощью которого вытаскиваем данные из базы. Как это работает? Благодаря соглашению об именовании. Общий шаблон имени метода такой: 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 {
}

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

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

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

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

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

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

Метод getByIdOrNull() является методом расширения для стандартного 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.findByIdOrNull(id) ?: throw BandNotFoundException(id)
    bandDao.save(
            band.copy(
                    name = request.name!!,
                    playersCount = request.playersCount,
                    created = request.created!!
            )
    )
}

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

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

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

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

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

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

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

@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.



Комментарии

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

×

devmark.ru