Эта страница переведена с английского языковой моделью. При любом расхождении верен английский оригинал.
Запрос и ответ
Это руководство показывает, как один сервис вызывает другой через RabbitMQ и ждёт ответа.
Сервис заказов, прежде чем принять заказ, спрашивает у сервиса склада, есть ли товар в наличии. Вызов идёт через direct reply-to RabbitMQ: объявлять очередь ответов не нужно, и вести вручную таблицу ожидающих вызовов тоже не нужно.
Отвечающая сторона
- Макросы
- Вручную
/// The stock of one SKU. An RPC reply goes where the request asked, so the type names no
/// destination.
#[derive(Debug, PartialEq, Serialize, Deserialize, Outgoing)]
struct Stock {
sku: String,
available: bool,
}
#[subscriber("inventory.check", reply("inventory.check.unanswered"))]
async fn check_stock(request: &CheckStock) -> Stock {
tracing::info!(request.sku, request.quantity, "stock checked");
Stock {
sku: request.sku.clone(),
available: request.quantity <= 10,
}
}
/// The inventory service's routes: each reply goes to the requester's own address.
fn inventory() -> Router<LapinBroker, impl RouterDef<LapinBroker>> {
Router::new()
.include(check_stock)
.out_reply(Publish::default())
.transform(DirectReplyTo)
.build()
}
/// The stock of one SKU. An RPC reply goes where the request asked, so the type names no
/// destination.
#[derive(Debug, PartialEq, Serialize, Deserialize, Outgoing, JsonSchema)]
#[schemars(crate = "ruststream::schemars")]
struct Stock {
sku: String,
available: bool,
}
struct Check;
impl Handle<CheckStock, Stock> for Check {
fn handle(
&self,
request: &CheckStock,
_outs: &(),
_ctx: &mut Context<'_>,
) -> impl Future<Output = Result<Stock, HandlerOutcome>> {
tracing::info!(request.sku, request.quantity, "stock checked");
ready(Ok(Stock {
sku: request.sku.clone(),
available: request.quantity <= 10,
}))
}
}
/// The inventory service's routes: each reply goes to the requester's own address.
fn inventory() -> Router<LapinBroker, impl RouterDef<LapinBroker>> {
Router::new()
.include(
subscriber("inventory.check", Check)
.reply()
.to("inventory.check.unanswered")
.build(),
)
.out_reply(Publish::default())
.transform(DirectReplyTo)
.build()
}
Сервис склада отвечает обычным обработчиком, который возвращает ответ.
DirectReplyTo в издателе ответов отправляет каждый ответ по адресу, который запрос указал в свойстве reply-to, и копирует в ответ correlation-id запроса.
Stock не называет адреса назначения, потому что каждый ответ уходит туда, куда попросил его запрос.
Имя в reply("inventory.check.unanswered") - это адрес ответа на запрос без reply-to, и именно его показывает документ AsyncAPI сервиса.
Маршруты собраны в Router, поэтому сервис склада монтирует их в собственном приложении через b.include_router(inventory()).
Пример запускает оба сервиса в одном процессе и монтирует оба роутера.
Запрашивающая сторона
- Макросы
- Вручную
/// How long an order waits for the inventory service.
const RPC_TIMEOUT: Duration = Duration::from_secs(2);
/// How long an order waits before it asks again when the inventory service did not answer.
const RETRY: Duration = Duration::from_secs(30);
#[derive(Debug, PartialEq, Serialize, Deserialize, Outgoing)]
struct Order {
id: u64,
sku: String,
quantity: u32,
}
/// Asks the inventory service and waits for its answer.
async fn ask_stock(
inventory: &impl RequestReply,
order: &Order,
) -> Result<Stock, Box<dyn Error + Send + Sync>> {
let request = JsonCodec.encode(&CheckStock {
sku: order.sku.clone(),
quantity: order.quantity,
})?;
let reply = inventory
.request(
OutgoingMessage::new("inventory.check", request.as_ref()),
RPC_TIMEOUT,
)
.await?;
Ok(JsonCodec.decode(reply.payload())?)
}
#[subscriber("orders")]
async fn place_order(order: &Order, Out(inventory): Out<impl RequestReply>) -> HandlerOutcome {
match ask_stock(inventory, order).await {
Ok(stock) if stock.available => {
tracing::info!(order.id, "order accepted");
HandlerOutcome::ack()
}
Ok(_) => {
tracing::warn!(order.id, "order rejected, out of stock");
HandlerOutcome::drop()
}
Err(err) => {
tracing::warn!(order.id, %err, "inventory unavailable, asking again later");
HandlerOutcome::retry_after(RETRY)
}
}
}
/// The orders service's routes: the requester is a policy, paired with the connection at startup.
fn orders() -> Router<LapinBroker, impl RouterDef<LapinBroker>> {
Router::new()
.include(place_order)
.out(DefaultSlot, Request::default())
.build()
}
#[ruststream::app]
fn app() -> impl App {
let broker = LapinBroker::new("amqp://localhost:5672").declare_topology(true);
// The example runs both services in one process; each service mounts its own router.
RustStream::new(AppInfo::new("shop", "0.1.0")).with_broker(broker, |b| {
b.include_router(orders());
b.include_router(inventory());
})
}
/// How long an order waits for the inventory service.
const RPC_TIMEOUT: Duration = Duration::from_secs(2);
/// How long an order waits before it asks again when the inventory service did not answer.
const RETRY: Duration = Duration::from_secs(30);
#[derive(Debug, PartialEq, Serialize, Deserialize, Outgoing, JsonSchema)]
#[schemars(crate = "ruststream::schemars")]
struct Order {
id: u64,
sku: String,
quantity: u32,
}
/// Asks the inventory service and waits for its answer.
async fn ask_stock(
inventory: &impl RequestReply,
order: &Order,
) -> Result<Stock, Box<dyn Error + Send + Sync>> {
let request = JsonCodec.encode(&CheckStock {
sku: order.sku.clone(),
quantity: order.quantity,
})?;
let reply = inventory
.request(
OutgoingMessage::new("inventory.check", request.as_ref()),
RPC_TIMEOUT,
)
.await?;
Ok(JsonCodec.decode(reply.payload())?)
}
struct PlaceOrder;
impl<Inventory> Handle<Order, (), Outs<(Inventory,)>> for PlaceOrder
where
Inventory: OutEntry<DefaultSlot, Wire: RequestReply>,
{
async fn handle(
&self,
order: &Order,
outs: &Outs<(Inventory,)>,
_ctx: &mut Context<'_>,
) -> Result<(), HandlerOutcome> {
match ask_stock(outs.get(DefaultSlot), order).await {
Ok(stock) if stock.available => {
tracing::info!(order.id, "order accepted");
Ok(())
}
Ok(_) => {
tracing::warn!(order.id, "order rejected, out of stock");
Err(HandlerOutcome::drop())
}
Err(err) => {
tracing::warn!(order.id, %err, "inventory unavailable, asking again later");
Err(HandlerOutcome::retry_after(RETRY))
}
}
}
}
/// The orders service's routes: the requester is a policy, paired with the connection at startup.
fn orders() -> Router<LapinBroker, impl RouterDef<LapinBroker>> {
Router::new()
.include(subscriber("orders", PlaceOrder).build())
.out(DefaultSlot, Request::default())
.build()
}
fn app() -> impl App {
let broker = LapinBroker::new("amqp://localhost:5672").declare_topology(true);
// The example runs both services in one process; each service mounts its own router.
RustStream::new(AppInfo::new("shop", "0.1.0")).with_broker(broker, |b| {
b.include_router(orders());
b.include_router(inventory());
})
}
fn main() -> ExitCode {
run_main(app)
}
place_order ограничивает свой слот типом Out<impl RequestReply>, а точка монтирования связывает его с политикой Request, то есть LapinRequest.
Каждый запрос уходит в обменник по умолчанию с ключом маршрутизации inventory.check и поэтому попадает в одноимённую очередь.
В его свойстве reply-to стоит amq.rabbitmq.reply-to RabbitMQ, а correlation-id каждый раз новый.
Ответ с тем же идентификатором завершает вызов, а ответ с другим идентификатором отбрасывается.
Здесь запрос и ответ - байты, поэтому ask_stock кодирует и декодирует их через JsonCodec.
RPC_TIMEOUT ограничивает ожидание.
Если вовремя никто не ответил, вызов возвращает ошибку AmqpError::RequestTimeout.
Тогда place_order не угадывает остаток, а просит обработать заказ снова через 30 секунд с retry_after.
Отсутствие товара - ответ бизнес-логики, поэтому такой заказ отбрасывается.
Политика принимает настройки публикации: exchange, priority и expiration.
По умолчанию запрос не сохраняется на диск: после истечения времени ожидания его никто не ждёт, и переживать перезапуск ему незачем.
persistent(true) делает его постоянным.
Гарантии доставки
Direct reply-to работает по схеме at-most-once.
Адрес ответа существует только в канале запрашивающей стороны на одном узле RabbitMQ, и ничего не ставится в устойчивую очередь.
Ответ, который был в пути, когда канал закрылся, теряется, и вызывающая сторона получает истечение времени ожидания.
Сам запрос - обычное сообщение в обычной очереди: пока сервис склада не работает, запрос ждёт в inventory.check, и ответ на него может прийти, когда вызывающая сторона уже перестала ждать.
Поэтому запрос к складу можно безопасно повторить: второй ответ ничего не меняет.
Запрашивающая сторона читает ответы в собственном канале, который открывает её первый запрос.
Другой стек на другой стороне
Обе стороны говорят на обычном AMQP 0.9.1.
Отвечающая сторона на другом клиенте читает очередь запросов, публикует ответ в обменник по умолчанию с ключом маршрутизации из reply-to и копирует correlation-id.
Отвечающая сторона на RustStream с DirectReplyTo так же отвечает запрашивающей стороне на другом клиенте.
Тестирование
RabbitMQ внутри процесса принадлежит приложению, которое его подключило, поэтому запрашивающая и отвечающая стороны общаются, только если работают в одном приложении. Тесты запускают приложение примера, которое монтирует оба роутера, и проверяют, что заказ с товаром в наличии принимается, а заказ без товара отбрасывается. Третий тест монтирует только роутер заказов и останавливает часы: никто не отвечает, время ожидания запроса истекает, и заказ просит обработать его позже. RabbitMQ внутри процесса, как и сервер, даёт каждому запросу собственный адрес ответа и сопоставляет с запросом ответ. Обвязку разбирает руководство Тестирование сервиса на RabbitMQ.