Пишем приложение на JetBrains Exposed

от автора

При всём разнообразии фреймворков для работы с базой данной, стоящих и постоянно развивающихся не так уж и много. И если про Hibernate знают все, а про JOOQ знают очень многие, то слабая популярность Exposed скорее связана с его ориентацией на Kotlin. Если Вы только-только пришли в Kotlin из Java, Вам архитектурные подходы, заложенные в Exposed (переполнение лямбдами и функциями-замыканиями, к примеру) могут показаться дичью, но пугаться не стоит: чем дальше Вы будете осваивать Kotlin, тем привычнее для Вас будут конструкции Exposed.

Какое-то время назад здесь уже была статья про Exposed, от компании Otus, но с тех пор прошло больше года и многие практики пользования фреймворком нужно освежить — даже пока я писал эту статью, многое поменялось!

Итак, почему Exposed?

Вообще, тема подключения к внешним интерфейсам, коим является и база данных, является весьма дискуссионной для разработчиков. Угодить с выбором нужного фреймворка бывает тяжело даже в рамках одной команды — что уж говорить о сообществе в целом. В идеале, хотелось бы писать те же запросы в базу на своей любимой джаве или котлине, но реляционные базы данных используют SQL, и здесь возникает вопрос — в каком месте произойдёт граница перехода от Java / Kotlin в SQL и обратно? Одна крайность — Hibernate, c его полной ориентацией на ООП и вытекающими отсюда особенностями использования, другая — JDBC, которая вышла в 1997 году, не обновлялась с 2017 года и использует SQL-запросы в строковых литералах. Все компромиссные фреймворки вроде JOOQ, QueryDSL и Speedment пытаются срастить ООП и реляционный подход через какие-то свои API. И, как мы видим по появлению и развитию Exposed, попытки эти продолжаются.

Дорожная карта поста.

Итак, то мы сделаем в рамках поста?

  1. Подключим библиотеки и настроим проект.

  2. Подготовим все структуры данных, а именно: бизнес-сущности, API, таблицы базы данных.

  3. Опишем мапинг между структурами данных.

  4. Напишем запросы.

Этого будет достаточно, чтобы Вы попробовали новый фреймворк на вкус. В конце поста будет ссылка на готовый проект, как обычно.

Подключение библиотек.

Ещё какой-то год назад, нам требовалось подключать целый ворох библиотек и писать целую этажерку настроек. За год произошла логичная эволюция до привычного стартера и теперь для работы с Exposed достаточно добавить один импорт:

implementation("org.jetbrains.exposed:exposed-spring-boot-starter:0.38.2")

Всё. Exposed подключён и готов выполнять запросы.

Да, раньше нужно было или прописывать подключение к базе данных -> потом придумали библиотеку, которая позволяла использовать подключение из конфигов Спринга -> теперь это всё в стартере. Раньше, для того, чтобы использовать спринговую аннотацию Transactional, нужно было подключать библиотеку — теперь это всё в стартере. В общем, чувствуется поддержка Jetbrains. Да, не хватает обработки дат и времени «из коробки», и такие библиотеки пока всё-таки придётся затаскивать отдельно, но, судя по тенденции, надобность в скором времени пропадёт и в этом.

Идём дальше.

Готовим структуры данных.

Любое приложение следует начинать писать с сущностей. С этого начнём и мы. Структура взаимодействия c базой данных будет такая:

Структура взаимодействия данныx
Структура взаимодействия данныx

Бизнес-сущность.

data class User(       val id: UUID? = null,       val name: String? = null,       val contacts: List<Contact>? = null )  data class Contact(     val id: UUID? = null,     val type: ContactType,     val value: String? = null,     val userId: UUID )  enum class ContactType {     PHONE, EMAIL }

Таблица базы данных (миграция).

create table users ( id   uuid default gen_random_uuid() primary key,     name varchar(512) );  create table contacts (     id      uuid default gen_random_uuid() primary key,     type    varchar(128) not null,     value   varchar(256),     user_id uuid         constraint contacts_user_id_fkey references users );

Эти сущности находятся на разных концах структуры приложения. Их будет связывать Table API (репозиторная сущность), описывающая таблицу на языке Kotlin.

Table API (репозиторные сущности).

object UserTable : IdTable<UUID>("users") {   override val id = uuid("id").entityId()       val name = varchar("name", 512).nullable() }  object ContactTable : IdTable<UUID>("contacts") {     override val id = ContactEntity.uuid("id").entityId()     val type = varchar("type", 128)     val value = varchar("value", 256).nullable()     val userId = reference("user_id", UserEntity) }

Здесь необходимо пояснение.

Несомненный плюс фреймворка состоит в том, что репозиторные сущности создаются из синглтонов вне орбиты Spring и могут быть использованы в любом месте приложения, не ограничиваясь компонентами Spring — в качестве примера наши маперы не будут компонентами Spring, но при этом вовсю будут использовать API Exposed.

Также, при создании Table API Exposed не генерирует дополнительных классов, как это делает JOOQ — и это тоже несомненный плюс. Вы просто описываете репозиторную сущность и работаете с ней. Единственно, Вам нужно унаследовать Вашу сущность от org.jetbrains.exposed.sql.Table.

У Table существует наследник IdTable, а у него — ещё несколько, которые задают тип id в наиболее популярном диапазоне значений:

Table, его наследники и внуки
Table, его наследники и внуки

Мы могли бы сразу использовать UUIDTable вместо IdTable<UUID>, но тогда нам пришлось бы отдать фреймворку управление генерацией первичного ключа, а у нас первичный ключ генерируется в базе данных.

Мапинг между сущностями.

В этой части будет обещанная демонстрация работы Table API вне пределов Спринга.

Отличие Exposed от других фреймворков для работы с базой данных заключается в том, что репозиторную сущность нельзя набить данными и таким образом отправить их в базу или получить оттуда данные. Она лишь предоставляет API для работы с той или иной таблицей. Для транспортировки данных из бизнес-сущности в таблицу и из таблицы в бизнес-сущность существуют две структуры: ResultRow (для получения данных из базы) и Statement (для отправки данных в базу).

Мапинг из бизнес-сущности.

Для транспортировки данных в базу существует абстрактный класс Statement. Через цепочку наследований его реализуют, в том числе, InsertStatement и UpdateStatement, которые мы рассмотрим в рамках транспортировки данных в запросах insert и update.

Мы рассмотрим два подхода передачи данных — через мапер (на примере ContactMapper) и более лаконичный, напрямую в insert и update-запросах (на примере UserRepository).

В любом случае, функции insert и update используют Statement как параметр. Напишем функции, которые будут наполнять реализации Statement значениями из бизнес-сущности.

В проекте маперы разложены по отдельным файлам, равно как и все сущности, но для наглядности будет удобным разместить маперы в одном кодовом блоке, благо они у нас гомеопатических размеров.

fun Contact.toInsertStatement(statement: InsertStatement<Number>): InsertStatement<Number> = statement.also {     it[ContactTable.type] = this.type.name     it[ContactTable.value] = this.value     it[ContactTable.userId] = this.userId }  fun Contact.toUpdateStatement(statement: UpdateStatement): UpdateStatement = statement.also {     it[ContactTable.type] = this.type.name     it[ContactTable.value] = this.value     it[ContactTable.userId] = this.userId }

Statement содержит в себе Map<Column<*>, Any?>, что позволяет нам наполнить колонки значениями из бизнес-сущности. Всё просто.

Мапинг в бизнес-сущность.

В качестве результата запроса Exposed возвращает List<ResultRow> как массив значений колонок строки. Задача мапера — намапить данные колонок, полученные в ResultRow, на бизнес-сущность.

fun ResultRow.toUser(): User = User(     id = this[UserEntity.id].value,     name = this[UserEntity.name],     contacts = this[UserEntity.id].value.let {         ContactEntity.select { ContactEntity.userId eq it }.map { it.toContact() }     } )  fun ResultRow.toContact(): Contact = Contact(     id = this[ContactEntity.id].value,     type = ContactType.valueOf(this[ContactEntity.type]),     value = this[ContactEntity.value],     userId = this[ContactEntity.userId].value )

Мы берём ResultRow, достаём значение по типу колонки и присваиваем полученное значение нужному полю.

И всё.

Что же касается поля User.contacts, здесь можно пойти несколькими путями. Можно через джоин таблиц, а можно через подзапрос. Я описал способ с подзапросом как наиболее простой. В проекте, который Вы скачаете в конце поста, есть также способ получения множества через джоин таблиц.

Итак, мы полностью подготовили проект к самой сути поста — выполнению запросов в базу данных.

Запросы в базу данных.

Первое и беспрекословное правило выполнения запросов через Exposed: все запросы явно должны вызываться под транзакцией. Тут два пути: вызов функции-замыкания transaction, вот таким образом:

override fun get(id: UUID): User? = transaction {}

или через спринговую аннотацию:

@Transactional override fun delete(id: UUID) {}

В проекте-примере реализованы оба подхода, но я предпочитаю первый.

SELECT

Открыв транзакцию, мы пишем сам запрос. Семантика запроса напоминает SQL. Например:

override fun get(id: UUID): User? = transaction {     UserEntity.select { UserEntity.id eq id }.firstOrNull()?.toUser() }

Запрос выше будет соответствовать SQL-запросу

select * from users where id = ?

с дальнейшим получением первого результата и мапингом в User.

Да, на данный момент, Exposed не понимает, сколько строчек мы хотим получить в ответе — одну или множество (как это понимает, к примеру, JOOQ с его fetch(), fetchOne() и fetchOptional()). Равно как обычный SQL-запрос вернёт нам множество в ответ на select-запрос, так это сделает и Exposed. В таком случае, приходится применять костылёк в виде first() или firstOrNull(). Или установить в запросе limit(1), как делает под капотом тот же JOOQ.

Запрос на получение нескольких записей будет, в целом, проще:

override fun getAll(limit: Int): List<User> = transaction {     UserEntity.selectAll()         .limit(limit)         .map { it.toUser() } }

В примере я указал limit, но этот параметр необязателен. Другой пример:

override fun getAll(userId: UUID): List<Contact> = transaction {     ContactEntity.select { ContactEntity.userId eq userId }         .map { it.toContact() } }

DELETE

    override fun delete(id: UUID) {         transaction {             ContactEntity.deleteWhere { ContactEntity.id eq id }         }     }

Такой запрос возвращает количество записей, удалённых по условию.

INSERT

override fun insert(contact: Contact): Contact = transaction {     ContactEntity.insert { contact.toInsertStatement(it) }         .resultedValues?.first()?.toContact()         ?: throw NoSuchElementException("Error saving user: ${objectMapperKt.writeValueAsString(contact)}") }

Как уже было описано в разделе маперов, функция insert() является функцией высшего порядка и использует в качестве параметра функцию с единственным параметромInsertStatement.

fun <T : Table> T.insert(body: T.(InsertStatement<Number>) -> Unit): InsertStatement<Number>

Всё, что нам остаётся сделать — это замапить бизнес-сущность в InsertStatement, что мы и сделали ранее в мапере. InsertStatement содержит также два поля: insertedCount и resultedValues, первое из которых возвращает количество добавленных в базу записей, а второе — сами записи. В нашем примере мы не будем использовать insertedCount, а вот resultedValues нам пригодится. Сохранённые данные возвращаются в уже привычной структуре List<ResultRow>, что очень удобно — мы можем переиспользовать мапер из таблицы в бизнес-сущность, написанный ранее.

Поскольку функция insert является лямбдой, мы можем существенно сократить количество кода и проинициализировать поля InsertStatement прямо в функции (что я и обещал сделать в разделе маперов на примере UserRepository):

override fun insert(user: User): User = transaction {     UserTable.insert {         it[name] = user.name     }         .resultedValues?.first()?.toUser()         ?: throw NoSuchElementException(             "Error saving user: ${objectMapperKt.writeValueAsString(user)}. Statement result is null."         ) }

Да, мы просто берём единственный параметр функции insert() и инициализируем его поля. Всё просто.

UPDATE

Функция update() отличается от функции insert() тем, что в ней — три параметра вместо одного.

fun <T : Table> T.update(where: (SqlExpressionBuilder.() -> Op<Boolean>)? = null, limit: Int? = null, body: T.(UpdateStatement) -> Unit): Int

Параметр limit мы использовать не будем, нам интересны два других, каждый из которых также является функцией. В первой функции мы указываем условия, по которым будут отобраны записи для обновления, во второй — саму суть обновления. Выглядит это так:

override fun update(contact: Contact) {     transaction {         ContactTable.update({ ContactTable.id eq contact.id!! }) { contact.toUpdateStatement(it) }     } }

Это в случае, если у нас есть мапер в Statement. Если мапера нет, можно также осуществить мапинг напрямую, как мы это делали с insert().

override fun update(user: User): User = transaction {     UserTable.update({ UserTable.id eq user.id }) {         it[name] = user.name     }     UserTable.select { UserTable.id eq user.id }         .firstOrNull()?.toUser()         ?: throw NoSuchElementException(             "Error updating user: ${objectMapperKt.writeValueAsString(user)}. Statement result is null."         ) }

Заключение

Вот, собственно, и всё.

Конечно, существуют самые разные практики, да и функций для взаимодействия с базой данных гораздо больше. Разобравшись самостоятельно, Вы наверняка обнаружите batchInsert(), deleteIgnoreWhere(), andWhere() и многие другие — фреймворк покрывает стандартные запросы разработчика чуть более чем полностью.

Есть вопросы и дополнения — пишите, постараюсь помочь.

Обещанный репозиторий тут: https://github.com/promoscow/exposed-example

Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Вы уже пощупали JetBrains Exposed?
20% Да, и мне понравилось. 1
20% Да, и мне не понравилось. 1
60% Нет, и не планирую. 3
0% Нет, но планирую. 0
Проголосовали 5 пользователей. Воздержались 2 пользователя.

ссылка на оригинал статьи https://habr.com/ru/post/654163/


Комментарии

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

Ваш адрес email не будет опубликован. Обязательные поля помечены *