Эта страница переведена с английского языковой моделью. При любом расхождении верен английский оригинал.
Доставка всем подписчикам разом через SNS
Это руководство публикует сообщение в тему SNS один раз, а SNS доставляет копию в каждую очередь SQS, подписанную на эту тему.
Связка SNS и SQS - это способ, которым сервисы AWS доставляют сообщения всем подписчикам разом: одна тема и по очереди на каждого потребителя.
Крейт публикует в темы, а каждую очередь за темой сервис читает обычным образом.
Для публикации в SNS нужна фича sns:
[dependencies]
ruststream-sqs-sns = { version = "0.7", features = ["sns"] }
Пример - магазин. Он принимает заказ и объявляет о нём в теме, а оплата и доставка читают это объявление каждая из своей очереди.
Ответ в тему
- Макросы
- Вручную
#[derive(Debug, PartialEq, Serialize, Deserialize, Outgoing)]
#[outgoing(name = "shop-orders")]
struct PlaceOrder {
id: u64,
amount_cents: u64,
}
/// The announcement names the topic, so the handler answers with a bare `reply` clause.
#[derive(Debug, PartialEq, Serialize, Deserialize, Outgoing)]
#[outgoing(name = "shop-order-events")]
struct OrderPlaced {
id: u64,
amount_cents: u64,
}
#[subscriber(SqsQueue::new("shop-orders").create_if_missing(), reply)]
async fn accept(order: &PlaceOrder) -> OrderPlaced {
tracing::info!(order.id, "order accepted");
OrderPlaced {
id: order.id,
amount_cents: order.amount_cents,
}
}
#[derive(Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[schemars(crate = "ruststream::schemars")]
struct PlaceOrder {
id: u64,
amount_cents: u64,
}
impl OutgoingDestination for PlaceOrder {
type Form = FixedName;
const DESTINATION: &'static str = "shop-orders";
}
impl MessageHeaders for PlaceOrder {
type Contract = NoHeaders;
}
impl MessageInfo for PlaceOrder {
const NAME: &'static str = "PlaceOrder";
}
/// The announcement names the topic it is published to.
#[derive(Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[schemars(crate = "ruststream::schemars")]
struct OrderPlaced {
id: u64,
amount_cents: u64,
}
impl OutgoingDestination for OrderPlaced {
type Form = FixedName;
const DESTINATION: &'static str = "shop-order-events";
}
impl MessageHeaders for OrderPlaced {
type Contract = NoHeaders;
}
impl MessageInfo for OrderPlaced {
const NAME: &'static str = "OrderPlaced";
}
struct Accept;
impl Handle<PlaceOrder, OrderPlaced> for Accept {
fn handle(
&self,
order: &PlaceOrder,
_outs: &(),
_ctx: &mut Context<'_>,
) -> impl Future<Output = Result<OrderPlaced, HandlerOutcome>> {
tracing::info!(order.id, "order accepted");
ready(Ok(OrderPlaced {
id: order.id,
amount_cents: order.amount_cents,
}))
}
}
accept отвечает на каждый заказ сообщением OrderPlaced, и тип называет, куда оно уходит: shop-order-events.
Очередь это или тема, решает точка монтирования.
Очереди за темой
- Макросы
- Вручную
#[subscriber(SqsQueue::new("shop-billing").create_if_missing())]
async fn bill(order: &OrderPlaced) -> HandlerOutcome {
tracing::info!(order.id, order.amount_cents, "order billed");
HandlerOutcome::ack()
}
#[subscriber(SqsQueue::new("shop-shipping").create_if_missing())]
async fn ship(order: &OrderPlaced) -> HandlerOutcome {
tracing::info!(order.id, "order shipped");
HandlerOutcome::ack()
}
struct Bill;
impl Handle<OrderPlaced> for Bill {
fn handle(
&self,
order: &OrderPlaced,
_outs: &(),
_ctx: &mut Context<'_>,
) -> impl Future<Output = Result<(), HandlerOutcome>> {
tracing::info!(order.id, order.amount_cents, "order billed");
ready(Ok(()))
}
}
struct Ship;
impl Handle<OrderPlaced> for Ship {
fn handle(
&self,
order: &OrderPlaced,
_outs: &(),
_ctx: &mut Context<'_>,
) -> impl Future<Output = Result<(), HandlerOutcome>> {
tracing::info!(order.id, "order shipped");
ready(Ok(()))
}
}
bill и ship - обычные обработчики очередей.
Каждая очередь получает собственную копию каждого объявления, поэтому оплата и доставка работают в своём темпе и обрабатывают сообщения заново независимо друг от друга.
Подписка очередей
- Макросы
- Вручную
/// Subscribes the billing and shipping queues to the topic, with raw delivery.
///
/// `broker` is a clone of the app's broker, so it uses the connection the app already holds.
async fn subscribe_queues(broker: SqsBroker) -> io::Result<()> {
let connected = broker.connect().await.map_err(io::Error::other)?;
for queue in ["shop-billing", "shop-shipping"] {
connected
.subscribe_queue_to_topic("shop-order-events", queue)
.await
.map_err(io::Error::other)?;
}
Ok(())
}
/// Subscribes the billing and shipping queues to the topic, with raw delivery.
///
/// `broker` is a clone of the app's broker, so it uses the connection the app already holds.
async fn subscribe_queues(broker: SqsBroker) -> io::Result<()> {
let connected = broker.connect().await.map_err(io::Error::other)?;
for queue in ["shop-billing", "shop-shipping"] {
connected
.subscribe_queue_to_topic("shop-order-events", queue)
.await
.map_err(io::Error::other)?;
}
Ok(())
}
subscribe_queue_to_topic подписывает очередь на тему с прямой доставкой сообщений (raw message delivery).
Поэтому копия приходит обычным сообщением SQS: полезная нагрузка - это тело, а каждый заголовок - атрибут сообщения.
Без прямой доставки SNS обернул бы сообщение в собственный JSON-конверт.
SNS доставляет сообщения в очередь, только если политика доступа очереди разрешает теме отправлять в неё.
Перед подпиской вызов добавляет в политику такое правило и сохраняет правила, которые в ней уже есть.
Вызов создаёт тему, если её нет, а повторная подписка той же очереди ничего не меняет.
Топология обычно относится к инфраструктуре и создаётся через Terraform или консоль. Здесь её создаёт хук старта на копии брокера приложения, которая использует соединение, уже открытое приложением.
Приложение
- Макросы
- Вручную
fn service(broker: SqsBroker) -> RustStream {
let topology = broker.clone();
RustStream::new(AppInfo::new("shop", "0.1.0"))
.after_startup(async move |_state| subscribe_queues(topology).await)
.with_broker(broker, |b| {
// The reply goes to the topic named by `OrderPlaced`, through SNS.
b.include(accept).out_reply(SnsPublish::default());
b.include(bill);
b.include(ship);
})
}
#[ruststream::app]
fn app() -> impl App {
service(
SqsBroker::new()
.endpoint("http://localhost:4566")
.region("us-east-1")
.test_credentials(),
)
}
fn service(broker: SqsBroker) -> RustStream {
let topology = broker.clone();
RustStream::new(AppInfo::new("shop", "0.1.0"))
.after_startup(async move |_state| subscribe_queues(topology).await)
.with_broker(broker, |b| {
// The reply goes to the topic named by `OrderPlaced`, through SNS.
b.include(
subscriber("shop-orders", Accept)
.map_source(|_| SqsQueue::new("shop-orders").create_if_missing())
.reply()
.build(),
)
.out_reply(SnsPublish::default());
b.include(
subscriber("shop-billing", Bill)
.map_source(|_| SqsQueue::new("shop-billing").create_if_missing())
.build(),
);
b.include(
subscriber("shop-shipping", Ship)
.map_source(|_| SqsQueue::new("shop-shipping").create_if_missing())
.build(),
);
})
}
fn app() -> RustStream {
service(
SqsBroker::new()
.endpoint("http://localhost:4566")
.region("us-east-1")
.test_credentials(),
)
}
fn main() -> ExitCode {
run_main(app)
}
.out_reply(SnsPublish::default()) привязывает ответ к SnsPublish, и ответ становится вызовом SNS Publish в тему.
Без этого шага тот же ответ уходит через политику брокера по умолчанию, SqsPublish, и попадает в очередь с именем shop-order-events.
SnsPublish открывает тему по имени через CreateTopic, который возвращает существующую тему, если она есть, или принимает ARN темы в качестве имени.
Та же политика работает в слоте Out и в хуке старта, а ConnectedSqsBroker::sns_publisher даёт издателя вне приложения.
Тема, имя которой оканчивается на .fifo, - это тема FIFO.
Она передаёт группу сообщений публикации подписанным на неё очередям FIFO, с теми же шагами группы, которые принимает очередь FIFO.
Очередь FIFO подписывается только на тему FIFO.
Для публикации сервису нужны sns:CreateTopic и sns:Publish.
Если сервис сам подписывает очереди, ему нужны ещё sns:Subscribe на теме и sqs:GetQueueAttributes и sqs:SetQueueAttributes на каждой очереди.
Тестирование доставки всем подписчикам
Файл примера запускает одно тело теста дважды.
Внутри процесса аккаунт доставляет сообщения темы в очереди, подписанные через subscribe_queue_to_topic, поэтому хук настраивает тест так же, как промышленную среду.
Очередь, которую оператор подписал вне сервиса, называется на политике через fans_out_to - шаг фичи testing.
Если задана переменная SQS_TEST_ENDPOINT, то же тело выполняется на LocalStack через TestApp::start_live:
cargo test --features sns
SQS_TEST_ENDPOINT=http://localhost:4566 cargo test --features sns -- --test-threads=1
Обвязку и режим работы со стендом разбирает руководство Тестирование сервиса на SQS.