Добавление базы данных в фреймворк на Rust
В одном из моих предыдущих рассказов (см. здесь) мы рассмотрели пример реализации небольшого веб-приложения с использованием Rocket framework.
В этом веб-приложении размещались активы клиентского приложения и предоставлялся небольшой API. Теперь мы собираемся расширить это, добавив базу данных (PostgreSQL) вместе с ORM с именем Diesel. Кроме того, мы рассмотрим, как объединить все это вместе в совместно используемое веб-приложение с помощью docker-compose.
Общая цель:
Подытожим здесь, какие части приложения мы планируем добавить. Помните, пока что наше приложение предоставляет конечную точку, которая позволяет вычислять выпуклую оболочку заданного набора точек.
Добавим следующие вещи:
- разрешить сохранение результата из вышеупомянутой конечной точки
- разрешить
GET
все результаты, чтобы перечислить их в пользовательском интерфейсе - разрешить
DELETE
результатов - разрешить
UPDATE
отображать имя результата
Настраивать
Конечно, при разработке полезно иметь базу данных PostgreSQL, работающую в фоновом режиме. Вам не нужно устанавливать что-либо локально в вашей системе, вместо этого просто используйте настроенный образ докера:
docker pull postgres:14.2 docker run --name postgres -e POSTGRES_PASSWORD=mysecretpassword POSTGRES_USER=convexhull -p 5432:5432 -d postgres:14.2
Вы можете использовать любую другую версию, кроме 14.2, или даже не использовать этот термин, чтобы по умолчанию использовать версию :latest
.
Вышеупомянутое создает базу данных PostgreSQL с именем convexhull
с пользователем convexhull
и паролем mysecretpassword
. Он работает на порту 5432
, который сопоставлен с локальным портом некоторого номера.
Diesel ORM поставляется с интерфейсом командной строки, который я предлагаю установить локально. Для этого вам может потребоваться сначала установить в вашей системе следующий клиент PostgreSQL: libpq-dev
После этого вы можете установить CLI, используя cargo
:
cargo install diesel_cli --no-default-features --features postgres
Имея все это, из папки проекта можно запустить
diesel setup
Это помещает файл diesel.toml
и папку миграции в наш проект. Папка миграции содержит два файла, up.sql
и down.sql
. Эти файлы используются для переноса базы данных из одной версии в другую и обратно. Таким образом, общий договор заключается в том, что все, что производит up.sql
, должно быть возвращено в down.sql
.
SQL-схема
Каждый, кто когда-либо управлял БД в более крупном проекте, знает, что все дело в создании хороших схем SQL и использовании как можно меньшего количества индексов, но столько, сколько необходимо. Наша схема будет небольшой, чтобы обучать принципам Дизеля. Во-первых, мы скажем Diesel сгенерировать файлы миграции для нашей схемы:
diesel migrate generate convex_hulls
Это создает соответствующие файлы up/down.sql
в папке migrations/XXX_convex_hulls
. Мы добавим следующее определение данных в up.sql
:
CREATE TABLE convex_hulls ( "id" INTEGER PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY, "name" TEXT, "created" TIMESTAMP NOT NULL ); CREATE TABLE points( "id" INTEGER PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY, "input" JSON NOT NULL, "output" JSON NOT NULL, "convex_hull_id" INTEGER NOT NULL REFERENCES convex_hulls ON DELETE CASCADE );
И down.sql
:
DROP TABLE IF EXISTS points; DROP TABLE IF EXISTS convex_hulls;
Таким образом, с ConvexHull
может быть связан Point
.
Теперь мы можем указать Diesel запустить миграцию, набрав:
diesel migration run
и переделать это (на всякий случай):
diesel migration redo
Во время разработки вы обнаружите, что используете последний комментарий каждый раз, когда меняете модель данных. В то же время файл с именем schema.rs
обновляется соответственно. создается. Стоит взглянуть на этот файл, чтобы получить представление о том, что сценарии миграции действительно создают сопоставление, как и ожидалось. Ресурсы, определенные здесь, предназначены для предоставления ссылок на имена таблиц, столбцов и т. д. из вашего кода. Таким образом, такие имена никогда не становятся жестко закодированными и защищены компилятором от опечаток!
Использование Diesel в приложении
Чтобы использовать Diesel, мы сначала должны добавить следующие зависимости к нашему Cargo.toml
:
[dependencies] serde = { version = "1.0.136", features = ["derive"] } rocket = { version = "0.5.0-rc.1", features= ["json"] } diesel = { version = "1.4.4", features = ["postgres", "serde_json"] } serde_json = { version = "1.0.48", features = ["preserve_order"]} dotenv = "0.15.0" diesel_migrations = "1.4.0"
Более того, нам нужно создать файл .env
со следующим содержимым:
DB_HOST=localhost POSTGRES_PASSWORD=mysecretpassword POSTGRES_USER=convexhull DATABASE_URL=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${DB_HOST}/convexhull
Diesel загружает эти значения при запуске приложения и использует указанную выше запись для подключения к нашей базе данных.
Добавление конечных точек CRUD
Конечные точки, которые мы собираемся добавить на сервер Rocket, будут выглядеть так:
В этом нет ничего нового, учитывая объяснения, которые мы предоставили в соответствующей предыдущей статье.
Как обычно, все эти конечные точки должны быть зарегистрированы как маршруты:
Как видите, конечные точки используют типы ресурсов, объявленные в файле models.rs
, и делегируют методы, предоставляемые convex_hull_service
. Оба будут решаться дальше.
Добавление объектов базы данных
Сущности базы данных определены в models.rs
со следующим содержимым:
Вот еще немного вместе.
Прежде всего, для обоих ConvexHull
и Point
существует соответствующий NewConvexHull
соответственно. NewPoint
структура. Они немного отстают в определении типа и используются для хранения новых экземпляров. Более того, первый выводит Queryable
, а второй Insertable
. Это позволяет использовать эти сущности для получения данных, соответственно. вставка данных. Кроме того, мы сопоставляем имя соответствующей таблицы с сущностью с помощью макроса #[table_name = ...]
.
Во-вторых, типы, которые используются в структуре, должны соответствовать типам, которые мы использовали в нашем файле миграции up.sql
. Список сопоставлений типов из SQL в Rust и обратно, выполненных Diesel, можно найти здесь.
Помните, что таблица points
имеет внешний ключ к таблице convex_hulls
и предназначена для соответствия 1–1. Мы должны отразить это с помощью подходящих отображений ассоциаций. По этой причине Дизель предоставляет две черты, то есть Associations
и Identifiable
. Первый всегда находится на стороне, которая содержит внешний ключ. Последний находится на сайте, на который указывает внешний ключ.
Поскольку мы собираемся использовать эти сущности напрямую в наших конечных точках, мы получили черты Serialize
и Deserialize
из serde
.
Добавление бизнес-уровня
Все методы, которым делегируются конечные точки CRUD, будут определены в convex_hull_service.rs
.
Эти методы должны установить соединение с базой данных. Для этого в файле db.rs
прописан следующий метод:
Очевидно, это использует предоставленное значение DATABASE_URL
в .env
для подключения к базе данных.
Содержание convex_hull_service.rs
таково:
Начнем сначала с простых.
get_convex_hulls
: Это просто вызывает метод load
из структуры table
convex_hull
и предоставляет ссылку на соединение: convex_hulls::table.load(&connection)
.
delete_convex_hull
: Здесь мы вызываем метод diese::delete
, передавая соответствующий объект базы данных. Последнее достигается с помощью метода find
на table
convex_hulls
: diesel::delete(convex_hulls::table.find(...)
.
get_points
: Сначала мы получаем объект ConvexHull
с помощью id
, то есть convex_hulls::table.find(...)
, а затем используем отношение родитель-потомок между этими таблицами для получения связанного объекта Point
: Point::belonging_to(&convex_hull).first(&connection)
.
update_convex_hull
: мы используем метод diesel::update
, который получает объект ConvexHull
и обновляет соответствующее поле на .set(convex_hulls::columns::name.eq(convex_hull.name))
. Примечательно, что у Diesel есть дескриптор для каждого столбца (здесь name
), который можно получить из структуры columns
convex_hulls
.
create_convex_hull
: Здесь нам нужно поместить операции в транзакцию. Причина в отношениях родителей и детей. Чтобы создать Point
для ConvexHull
, последний сначала должен быть вставлен в базу данных, так как первому потребуется его id
. Транзакция получена от connection
по
connection.transaction::<(ConvexHull, Point), Error, _>(|| { … })
и обеспечивает откат всех изменений в случае сбоя какой-либо из вставок. Внутри transaction
мы используем следующий метод для вставки соответствующего объекта:
diesel::insert_into(convex_hulls::table).values(&new_convex_hull)
Таким образом, insert_into
ожидает целевую таблицу (здесь convex_hulls::table
) в качестве параметра, а затем сущность, которая будет вставлена (здесь new_convex_hull
). Обратите внимание, что последний относится к типу NewConvexHull
, производному от признака Insertable
.
Подробнее о том, как извлекать сущности из иерархии родитель-потомок, вы можете прочитать здесь.
Важно отметить, что Diesel обрабатывает связанные таблицы отдельно, вместо того чтобы иметь концепцию так называемой обратной связи. Например, соответствие 1–1, которое мы имеем между таблицами convex_hulls
и points
, на уровне типов отражается только наличием convex_hulls_id
в структуре Point
. В Diesel это отношение отражает тип кортежа (ConvexHull, Point)
. Хотя это очень логичная концепция, существуют сценарии, в которых это может привести к громоздкому коду.
Вы могли заметить повторяющиеся вызовы db::create_connection()
. Для ознакомления этого достаточно, но в рабочем коде вам лучше получать соединения из управляемого пула.
Расширение интерфейса
Поскольку эта статья не о фронтенде, я не буду приводить здесь много подробностей. Просто помните, что сервер Rocket размещает интерфейсные ресурсы как продукт приложения Vue, созданного с помощью Vite. Регистрация маршрута для статических ресурсов была описана в предыдущей статье. По сути, теперь клиент будет адаптирован для использования всех предоставленных конечных точек CRUD и будет выглядеть следующим образом:
Создание файла docker-compose
На данный момент у нас есть сервер, который предоставляет несколько конечных точек и размещает интерфейсные ресурсы. Кроме того, у нас есть база данных, которая поддерживает сохранение некоторых наших сущностей. У Docker есть фантастический инструмент под названием docker-compose
, который объединяет все это вместе и делает его доступным для совместного использования, хотя задействовано несколько серверов, то есть несколько образов Docker.
Docker-compose является дополнением к движку docker и его нужно устанавливать отдельно (см. здесь). Мы добавляем в проект файл с именем docker-compose.yml
, который предназначен для описания всех компонентов (серверов) приложения. Его содержание таково:
version: "3" services: web: build: . ports: - "8000:8000" environment: - DB_HOST=db depends_on: - "db" command: ["./wait-for-it.sh", "db:5432", "--", "./target/release/convex-hull"] db: image: postgres:14.2 environment: - POSTGRES_PASSWORD=mysecretpassword - POSTGRES_USER=convexhull
Итак, у нас есть две службы: одна называется web
, сборка которой описана в локальном Dockerfile
, а другая называется db
. Последнее вместо build
относится к image
. Каждая служба будет работать в своем собственном процессе, и мы можем прикрепить к нему environment
переменных. Кроме того, и это очень важно для нас, служба web
(сервер Rocket) зависит от готовности базы данных к приему запросов. По этой причине мы делаем две вещи:
- Мы используем
depends_on
, который сообщает, что службаweb
зависит от службыdb
на уровне сборки. То есть первое не начинается, пока второе не будет готово. - Служба
web
имеетcommand
, который переопределяет все вDockerfile
внутриCMD
. Этиcommand
выполняются после завершения сборки.wait-fot-it
— это служебная функция, которая ожидает, пока хостdb
примет запросы на порт5432
. Только после этого он продолжает выполнять вторую часть, то есть запускает экземпляр Rocker.
Мы можем сделать сборку docker-compose и запустить экземпляры, набрав:
docker-compose up
Это запустит контейнер в текущем терминале, и вы можете остановить его, как обычно.
Последнее замечание, которое я должен сделать о миграции баз данных. Запущенная база данных не будет содержать всех необходимых определений таблиц. По этой причине необходимо указать Diesel выполнять всю необходимую миграцию всякий раз, когда сервер запускается.
В исходном коде вы найдете фактический вызов для запуска Rocket, завернутый следующим образом:
match embedded_migrations::run(&db::create_connection()) { Ok(_) => rocket::build()... ... }
Этот embedded_migrations
модуль, который становится доступным после выполнения макроса embed_migrations!();
из крейта diesel_migrations
. Это обеспечивает актуальность базы данных для всех миграций, определенных в папке migration
.
Запуск кода
Чтобы получить весь код, вы можете сделать следующее (требуется: git, docker, docker-compose):
git clone https://github.com/applied-math-coding/convex-hull.git git checkout v2.0 // brings you to the correct version docker-compose up // builds and runs the app // then you can got to http://localhost:8000
Надо признать, что это было очень много. Но это связано не с Diesel или Rocket, а с тем обстоятельством, что мы создали полнофункциональное веб-приложение.
Последнее замечание по уходу. Хотя все вышесказанное легко читается, все не так просто, как кажется. В частности, когда имеешь дело с ассоциациями, нужно убедиться, что они сочетаются друг с другом. Опять же, это не специфично для Diesel, но обстоятельство, с которым вы сталкиваетесь, вероятно, со всеми ORM.
Хотя использование Diesel поверх Rocket дает очень производительное и безопасное приложение, система типов может быть громоздкой для более крупных приложений. По этой причине мы наконец рассмотрим еще один подход в моем следующем посте.
Спасибо за прочтение!