本页译自英文,由模型翻译。若与英文原文存在差异,以英文原文为准。
快速开始
本指南安装 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 feature 加入测试所运行的进程内模式。
本 crate 没有默认 feature。服务按需启用:
| Feature | 提供的内容 |
|---|---|
inbox | 服务自有表中的任务队列,由 SqlxBroker 提供服务 |
outbox | 基于任意 RustStream Broker 的事务性 outbox |
postgres、mysql、sqlite | 一种内置方言及其 sqlx 驱动;mysql 同时服务 MySQL 和 MariaDB |
any | AnyPool,由它所连到的数据库的方言提供服务 |
chrono、time | 以对应 crate 的类型表示的时间列 |
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 在自己的事务中写入该行,任务入队一页演示了这种做法。
运行服务
- 宏
- 手写
#[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,由它构建应用并运行。Broker 在服务启动时构建自己的连接池,在服务停止时关闭它。
cargo run
thumbnails.db 中出现新行时,服务就领取它们。其他程序插入的行,会在轮询间隔内被发现,未设置时为一秒。Ctrl+C 停止服务。
第一个测试
示例文件中还有两个测试。每个测试打开一个内存中的 SQLite 数据库,然后用 TestApp::start 在它上面启动同一个 service。测试像生产环境一样通过路由发布一张图片,然后检查 thumbnail 如何结算它,以及表中还剩下什么:
cargo test
在 SQL 上测试服务逐一讲解这些测试。
迁移到 Postgres 或 MySQL
同一个结构体也能服务 PgPool 或 MySqlPool:Broker 采用它的连接池所对应的方言。在两个 crate 中启用 postgres 或 mysql,构建对应的连接池,再用该数据库的占位符编写 Publish<Postgres> 或 Publish<MySql>。在服务器上,没有 locked_until 的表在行锁下领取它的行,处理器运行期间一直持有这个锁。为领取顺序建立索引,即表中已有的列按 (group, priority, retry_after, id) 排列;原因见在生产环境中运行。
从手写队列迁移
基于 sqlx 的队列通常是一个 SELECT .. FOR UPDATE SKIP LOCKED 循环,再加几列簿记信息。下面是每个部分在本 crate 中的位置:
| 手写实现 | ruststream-sqlx |
|---|---|
| 领取循环及其语句 | 一个 InboxQueue 订阅;语句由方言构建 |
处理完成后的 DELETE 或 UPDATE .. SET done | 处理器的结果 |
手动递增的 attempts 列 | #[field(attempt)] 和 Ctx<keys::Attempt> |
用于延后执行的 run_at 列 | #[field(retry_after)] 和 retry_after(d)(重试) |
locked_until 列和一个回收进程 | 租约方式(长任务) |
| 死信表及转移到它的操作 | max_attempts(n) 和 dead_letter |
| 与它所服务的数据一起插入的任务 | 在同一个事务中调用 insert(任务入队) |