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

GuDron

dumpz.ws
Admin
Регистрация
28 Янв 2020
Сообщения
7,564
Реакции
1,435
Credits
24,411

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

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

Итак, почему 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 базой данных будет такая:

410e2d681844055e2a97719bf2d5d5fb.png

Структура взаимодействия данны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.
 

GuDron

dumpz.ws
Admin
Регистрация
28 Янв 2020
Сообщения
7,564
Реакции
1,435
Credits
24,411

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 в наиболее популярном диапазоне значений:
413afed29de6f6d96958d6f970316158.png

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() и многие другие - фреймворк покрывает стандартные запросы разработчика чуть более чем полностью.

Для просмотра ссылки Войди или Зарегистрируйся