Добавление базы данных в фреймворк на Rust

В одном из моих предыдущих рассказов (см. здесь) мы рассмотрели пример реализации небольшого веб-приложения с использованием Rocket framework.

В этом веб-приложении размещались активы клиентского приложения и предоставлялся небольшой API. Теперь мы собираемся расширить это, добавив базу данных (PostgreSQL) вместе с ORM с именем Diesel. Кроме того, мы рассмотрим, как объединить все это вместе в совместно используемое веб-приложение с помощью docker-compose.

Общая цель:

Подытожим здесь, какие части приложения мы планируем добавить. Помните, пока что наше приложение предоставляет конечную точку, которая позволяет вычислять выпуклую оболочку заданного набора точек.

Добавим следующие вещи:

  1. разрешить сохранение результата из вышеупомянутой конечной точки
  2. разрешить GET все результаты, чтобы перечислить их в пользовательском интерфейсе
  3. разрешить DELETE результатов
  4. разрешить 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) зависит от готовности базы данных к приему запросов. По этой причине мы делаем две вещи:

  1. Мы используем depends_on, который сообщает, что служба web зависит от службы db на уровне сборки. То есть первое не начинается, пока второе не будет готово.
  2. Служба 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 дает очень производительное и безопасное приложение, система типов может быть громоздкой для более крупных приложений. По этой причине мы наконец рассмотрим еще один подход в моем следующем посте.

Спасибо за прочтение!