Перейти к основному содержимому
Нормативная версия документации - английская

Эта страница переведена с английского языковой моделью. При любом расхождении верен английский оригинал.

Начало работы

Это руководство устанавливает ruststream-sqlx, создаёт таблицу очереди и запускает на ней первый сервис, на SQLite и без сервера.

Установка​

Cargo.toml
[dependencies]
ruststream = { version = "0.7", features = ["macros", "json", "logging"] }
ruststream-sqlx = { version = "0.7", features = ["inbox", "sqlite", "chrono"] }
sqlx = { version = "0.9", features = ["runtime-tokio", "sqlite", "derive", "chrono"] }
chrono = "0.4"
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
tracing = "0.1"

[dev-dependencies]
ruststream-sqlx = { version = "0.7", features = ["inbox", "sqlite", "chrono", "testing"] }

ruststream - это сам фреймворк. Фича macros даёт атрибуты, json - кодек по умолчанию, а logging выводит журнал сервиса в терминал. ruststream-sqlx обслуживает очередь, а sqlx - клиент, через который сервис уже работает со своей базой данных. Фича testing добавляет режим в процессе, в котором работают тесты.

Фич по умолчанию у крейта нет. Сервис включает то, чем пользуется:

ФичаЧто она даёт
inboxочередь задач в таблицах сервиса, которую обслуживает SqlxBroker
outboxтранзакционный outbox поверх любого брокера RustStream
postgres, mysql, sqliteвстроенный диалект и его драйвер sqlx; mysql обслуживает MySQL и MariaDB
anyAnyPool, который обслуживает диалект той базы данных, к которой пул обращается
chrono, timeстолбцы времени в типах этого крейта
jsonстолбец заголовков в JSON
testingрежим в процессе, на котором работает TestApp, и тестовый переключатель outbox
asyncapiтаблицы в генерируемом документе AsyncAPI

Таблица​

Очередь - это собственная таблица сервиса, и создаёт её сервис:

src/main.rs
const SCHEMA: &str = "CREATE TABLE IF NOT EXISTS thumbnail_jobs (
id INTEGER PRIMARY KEY,
attempt INTEGER NOT NULL DEFAULT 1,
locked_until TEXT,
payload BLOB NOT NULL
)";

Каждая строка - одна задача. id идентифицирует строку, attempt считает её доставки, а payload хранит закодированное сообщение. locked_until хранит аренду строки в работе: в SQLite нет блокировок строк, поэтому очередь на SQLite захватывает строки под арендой. Формы, которые может принять таблица, сравнивает руководство Долгие задачи, сбои и форма захвата.

Первый сервис​

src/main.rs
use ruststream_sqlx::prelude::*;
use sqlx::Connection;
use sqlx::Sqlite;
use sqlx::sqlite::SqlitePoolOptions;

/// One row of `thumbnail_jobs`.
#[derive(Inbox, sqlx::FromRow)]
#[inbox(table = "thumbnail_jobs")]
struct MakeThumbnail {
#[field(id, generated)]
id: i64,
#[field(attempt, generated)]
attempt: i16,
#[field(locked_until)]
locked_until: Option<DateTime<Utc>>,
#[field(payload)]
payload: Vec<u8>,
}

/// A publish to `thumbnails` writes a row with the service's own statement.
impl Publish<Sqlite> for MakeThumbnail {
async fn publish(
conn: &mut SqliteConnection,
message: &OutgoingMessage<'_>,
) -> Result<(), sqlx::Error> {
sqlx::query("INSERT INTO thumbnail_jobs (payload) VALUES (?)")
.bind(message.payload())
.execute(conn)
.await?;
Ok(())
}
}

/// The job, as the payload column holds it.
#[derive(Debug, PartialEq, Deserialize, Serialize, Outgoing)]
#[outgoing(name = "thumbnails")]
struct Image {
path: String,
}

#[subscriber(InboxQueue::<MakeThumbnail>::new("thumbnails"))]
async fn thumbnail(image: &Image, Ctx(attempt): Ctx<keys::Attempt>) -> HandlerOutcome {
if image.path.is_empty() {
tracing::warn!("an image without a path, dropped");
return HandlerOutcome::drop();
}
tracing::info!(path = %image.path, ?attempt, "making a thumbnail");
HandlerOutcome::ack()
}

/// The service on the database `url` names.
fn service(url: &str) -> RustStream {
let schema_url = url.to_owned();
// Nothing connects here: the broker builds its pool when the service starts.
let broker = SqlxBroker::from_url(url, SqlitePoolOptions::new());
RustStream::new(AppInfo::new("thumbnails", "0.1.0"))
// The table, created before the subscription checks it.
.on_startup(async move |()| {
let mut conn = SqliteConnection::connect(&schema_url).await?;
sqlx::raw_sql(SCHEMA).execute(&mut conn).await?;
conn.close().await
})
.with_broker(broker.route::<MakeThumbnail>("thumbnails"), |b| {
b.include(thumbnail);
})
}

#[derive(Inbox)] описывает таблицу: #[inbox(table = ..)] называет её, а каждый #[field(..)] задаёт столбцу его роль. generated отмечает столбец, который заполняет база данных. В ручной форме то же описывает InboxSpec. InboxQueue::<MakeThumbnail>::new("thumbnails") - это подписка: она захватывает строки thumbnail_jobs и передаёт полезную нагрузку каждой из них кодеку. Строку завершает то, что возвращает обработчик: ack() удаляет её, а drop() завершает её так же, но без миниатюры. Ctx<keys::Attempt> читает, какая это по счёту доставка строки.

service принимает URL базы данных. SqlxBroker::from_url записывает его вместе с параметрами пула и ни к чему не подключается, поэтому приложение строится ещё до запуска рантайма. on_startup создаёт таблицу до того, как подписка её проверит.

Публикация в thumbnails записывает строку. route связывает это имя с MakeThumbnail, а вставляет строку собственный оператор Publish сервиса. Ответ обработчика и тест публикуют так же. Код вне обработчика, например конечная точка HTTP, записывает строку через insert из derive в собственной транзакции, как показывает руководство Постановка задач в очередь.

Запуск сервиса​

src/main.rs
#[ruststream::app]
fn app() -> impl App {
service("sqlite:thumbnails.db?mode=rwc")
}

#[ruststream::app] порождает main, который строит приложение и запускает его. Брокер строит свой пул при старте сервиса и закрывает его при остановке.

cargo run

Сервис захватывает строки по мере их появления в thumbnails.db. Строку, которую вставила другая программа, он видит в пределах интервала опроса, по умолчанию одной секунды. Ctrl+C останавливает его.

Первый тест​

В файле примера есть и два теста. Каждый открывает базу данных SQLite в памяти и запускает на ней тот же service через TestApp::start. Тест публикует изображение через маршрут, как в промышленной среде, и проверяет, как thumbnail завершил строку и что осталось в таблице:

cargo test

Эти тесты разбирает руководство Тестирование сервиса на SQL.

Переход на Postgres или MySQL​

Та же структура обслуживает PgPool или MySqlPool: брокер берёт диалект своего пула. Включите postgres или mysql в обоих крейтах, постройте такой пул и напишите Publish<Postgres> или Publish<MySql> с параметрами-заполнителями этой базы данных. На сервере таблица без locked_until захватывает строки под блокировкой строк, которую захват держит, пока работает обработчик. Постройте индекс по порядку захвата: (group, priority, retry_after, id) из тех столбцов, что есть в таблице. Зачем он нужен, объясняет руководство Работа в промышленной среде.

Переход с самописной очереди​

Очередь на sqlx обычно - это цикл SELECT .. FOR UPDATE SKIP LOCKED и несколько служебных столбцов. Вот куда переходит каждая часть:

Самописная очередьruststream-sqlx
цикл захвата и его операторподписка InboxQueue; оператор строит диалект
DELETE или UPDATE .. SET done после работыисход обработчика
столбец attempts, который увеличивают вручную#[field(attempt)] и Ctx<keys::Attempt>
столбец run_at для отложенного запуска#[field(retry_after)] и retry_after(d) (Переобработка)
столбец locked_until и задача, которая освобождает зависшие строкиформа аренды (Долгие задачи)
таблица dead letter и перемещение в неёmax_attempts(n) и dead_letter
задача, вставленная вместе с данными, которые она обслуживаетinsert в той же транзакции (Постановка задач в очередь)