本页译自英文,由模型翻译。若与英文原文存在差异,以英文原文为准。
请求与回复
本指南让一个服务通过 RabbitMQ 调用另一个服务,并等待回复。
订单服务在接受订单之前,先询问库存服务某件商品是否有货。 调用经由 RabbitMQ 的 direct reply-to 进行:不需要声明回复队列,也不需要手工维护一张待处理调用表。
应答方
- 宏
- 手写
/// 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 设为 RabbitMQ 的 amq.rabbitmq.reply-to,并带有一个新生成的 correlation-id。
id 相同的回复完成这次调用;id 不同的回复被丢弃。
这里的请求和回复都是字节,所以 ask_stock 用 JsonCodec 对它们编码和解码。
RPC_TIMEOUT 限定等待时间。
没有人及时应答时,调用返回 AmqpError::RequestTimeout 错误。
这时 place_order 用 retry_after 要求 30 秒后再投递这个订单,而不是去猜测库存。
商品缺货是业务上的答复,所以订单被丢弃。
这个策略接受发布所用的设置:exchange、priority 和 expiration。
请求默认是非持久化的:超时之后没人再等它,所以它在重启后存活下来也没有意义。
persistent(true) 让它持久化。
投递保证
direct reply-to 是至多一次的。
回复地址存在于请求方位于某个 RabbitMQ 节点上的信道里,没有任何东西被持久地排入队列。
信道关闭时仍在途中的回复会丢失,调用方看到的是超时。
请求本身是普通队列里的一条普通消息:库存服务停机时,它在 inventory.check 中等待,可能在调用方放弃之后才得到应答。
所以库存查询可以安全地重复:应答两次不会改变任何东西。
请求方在自己的信道上读取回复,这个信道由它的第一个请求打开。
另一端使用其他技术栈
双方说的都是普通的 AMQP 0.9.1。
用其他客户端编写的应答方消费请求队列,把回复发布到默认交换机,路由键取自 reply-to,并复制 correlation-id。
带 DirectReplyTo 的 RustStream 应答方也以同样的方式回复用其他客户端编写的请求方。
测试
进程内 RabbitMQ 属于连接它的那个应用,所以请求方和应答方必须运行在同一个应用里才能通信。 测试启动示例中挂载了两个路由器的应用,检查有货的订单被接受、缺货的订单被丢弃。 第三个测试在暂停的时钟上只挂载订单路由器:没有人应答,请求超时,订单稍后再次询问。 进程内 RabbitMQ 像服务器一样为每个请求分配自己的回复地址,并按关联 id 匹配回复。 测试 RabbitMQ 服务讲解了测试套件。