Эта страница переведена с английского языковой моделью. При любом расхождении верен английский оригинал.
Начало работы
Это руководство устанавливает ruststream-sqlx, создаёт таблицу очереди и запускает на ней первый сервис, на SQLite и без сервера.
Установка
[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 |
any | AnyPool, который обслуживает диалект той базы данных, к которой пул обращается |
chrono, time | столбцы времени в типах этого крейта |
json | столбец заголовков в JSON |
testing | режим в процессе, на котором работает TestApp, и тестовый переключатель outbox |
asyncapi | таблицы в генерируемом документе AsyncAPI |
Таблица
Очередь - это собственная таблица сервиса, и создаёт её сервис:
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 захватывает строки под арендой.
Формы, которые может принять таблица, сравнивает руководство Долгие задачи, сбои и форма захвата.
Первый сервис
- Макросы
- Вручную
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);
})
}
use ruststream_sqlx::prelude::*;
use sqlx::Connection;
use sqlx::Sqlite;
use sqlx::sqlite::SqlitePoolOptions;
/// One row of `thumbnail_jobs`.
#[derive(sqlx::FromRow)]
struct MakeThumbnail {
id: i64,
attempt: i16,
payload: Vec<u8>,
}
impl InboxTable for MakeThumbnail {
type Id = i64;
type Table = InboxSpec<(Attempt, Lease<DateTime<Utc>>, Payload)>;
const TABLE: Self::Table = InboxSpec::new("thumbnail_jobs", Column::new("id").generated())
.attempt(Column::new("attempt").generated())
.lease(Column::new("locked_until"))
.payload(Column::new("payload"));
fn id(&self) -> &i64 {
&self.id
}
}
impl AttemptRow for MakeThumbnail {
type Attempt = i16;
fn attempt(&self) -> &i16 {
&self.attempt
}
}
impl PayloadRow for MakeThumbnail {
type Column = Vec<u8>;
fn payload(&self) -> &[u8] {
&self.payload
}
}
/// 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, JsonSchema)]
#[schemars(crate = "ruststream::schemars")]
struct Image {
path: String,
}
// `#[outgoing(name = "thumbnails")]` by hand: the type names the queue it is published to.
impl OutgoingDestination for Image {
type Form = FixedName;
const DESTINATION: &'static str = "thumbnails";
}
impl MessageHeaders for Image {
type Contract = NoHeaders;
}
struct Thumbnail;
impl Handle<Image, (), (), InboxContext> for Thumbnail {
async fn handle(
&self,
image: &Image,
_outs: &(),
ctx: &mut Context<'_, InboxContext>,
) -> Result<(), HandlerOutcome> {
let Ctx(attempt) = Ctx::<keys::Attempt>::from_context(ctx).await?;
if image.path.is_empty() {
tracing::warn!("an image without a path, dropped");
return Err(HandlerOutcome::drop());
}
tracing::info!(path = %image.path, ?attempt, "making a thumbnail");
Ok(())
}
}
/// 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| {
let thumbnail = subscriber("thumbnails", Thumbnail)
.map_source(|_| InboxQueue::<MakeThumbnail>::new("thumbnails"));
b.include(thumbnail.build());
})
}
#[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 в собственной транзакции, как показывает руководство Постановка задач в очередь.
Запуск сервиса
- Макросы
- Вручную
#[ruststream::app]
fn app() -> impl App {
service("sqlite:thumbnails.db?mode=rwc")
}
#[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 в той же транзакции (Постановка задач в очередь) |