Эта страница переведена с английского языковой моделью. При любом расхождении верен английский оригинал.
Запрос и ответ
Это руководство отправляет запрос другому сервису через NATS и ждёт ответа, отвечает на запросы в их инбокс, обрабатывает запрос, на который никто не отвечает, и масштабирует отвечающую сторону.
NATS сам сопоставляет ответ с запросом. Запрос содержит инбокс, уникальный для него субъект, и ответ публикуется в этот инбокс.
Пример - два сервиса в одном приложении. Сервис цен отвечает на вопросы о ценах, а сервис заказов спрашивает у него цену каждого заказа.
Отвечающая сторона
- Макросы
- Вручную
/// What the orders service asks the pricing service.
#[derive(Debug, PartialEq, Serialize, Deserialize)]
struct Ask {
sku: String,
}
/// The pricing service's answer, sent to the inbox of the request.
#[derive(Debug, PartialEq, Serialize, Deserialize, Outgoing)]
struct Price {
sku: String,
cents: u64,
}
/// The catalogue the pricing service answers from.
fn price_of(sku: &str) -> Option<u64> {
match sku {
"book" => Some(1250),
"pen" => Some(150),
_ => None,
}
}
#[derive(OutSlot)]
#[publishes(Price)]
struct Answers;
#[subscriber(CoreSubject::new("prices.quote").queue_group("pricing"))]
async fn quote(
ask: &Ask,
ctx: &mut Context<'_>,
Out(answers): Out<impl Publisher, Answers>,
) -> HandlerOutcome {
let Some(inbox) = ctx.headers().reply_to().map(str::to_owned) else {
tracing::warn!(ask.sku, "a question without an inbox dropped");
return HandlerOutcome::drop();
};
let Some(cents) = price_of(&ask.sku) else {
tracing::info!(ask.sku, "no price for the item, no answer");
return HandlerOutcome::drop();
};
let price = Price {
sku: ask.sku.clone(),
cents,
};
if answers.message(&price).to(inbox).publish().await.is_err() {
return HandlerOutcome::retry();
}
HandlerOutcome::ack()
}
/// What the orders service asks the pricing service.
#[derive(Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[schemars(crate = "ruststream::schemars")]
struct Ask {
sku: String,
}
/// The pricing service's answer, sent to the inbox of the request.
#[derive(Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[schemars(crate = "ruststream::schemars")]
struct Price {
sku: String,
cents: u64,
}
impl OutgoingDestination for Price {
type Form = CallerName;
}
impl MessageHeaders for Price {
type Contract = NoHeaders;
}
impl MessageInfo for Price {
const NAME: &'static str = "Price";
}
/// The catalogue the pricing service answers from.
fn price_of(sku: &str) -> Option<u64> {
match sku {
"book" => Some(1250),
"pen" => Some(150),
_ => None,
}
}
struct Answers;
impl OutSlot for Answers {
const NAME: &'static str = "Answers";
type Destination = Reads;
}
impl PublishedThrough<Answers> for Price {}
struct Quote;
impl<Sent> Handle<Ask, (), Outs<(Sent,)>> for Quote
where
Sent: OutEntry<Answers, Wire: Publisher>,
{
async fn handle(
&self,
ask: &Ask,
outs: &Outs<(Sent,)>,
ctx: &mut Context<'_>,
) -> Result<(), HandlerOutcome> {
let Some(inbox) = ctx.headers().reply_to().map(str::to_owned) else {
tracing::warn!(ask.sku, "a question without an inbox dropped");
return Err(HandlerOutcome::drop());
};
let Some(cents) = price_of(&ask.sku) else {
tracing::info!(ask.sku, "no price for the item, no answer");
return Err(HandlerOutcome::drop());
};
let price = Price {
sku: ask.sku.clone(),
cents,
};
if outs
.get(Answers)
.message(&price)
.to(inbox)
.publish()
.await
.is_err()
{
return Err(HandlerOutcome::retry());
}
Ok(())
}
}
Запрос приходит с инбоксом в заголовке reply-to, и ctx.headers().reply_to() читает его.
quote публикует цену через свой слот Answers с to(inbox), потому что адрес известен только для каждого запроса отдельно.
Поэтому Price не объявляет собственного субъекта.
На товар без цены ответа нет, и ожидание запрашивающей стороны заканчивается по тайм-ауту.
Отвечающая сторона читает prices.quote в очереди pricing.
Каждая реплика сервиса цен входит в очередь, и каждый запрос достаётся одной из них.
На тот же запрос ответит любая отвечающая сторона NATS, например nats reply prices.quote '{"sku":"book","cents":1250}' из командной строки.
Запрашивающая сторона
- Макросы
- Вручную
/// How long the orders service waits for a price.
const PRICING_TIMEOUT: Duration = Duration::from_secs(2);
/// How long an order waits before it asks for its price again.
const RETRY_DELAY: Duration = Duration::from_secs(30);
#[derive(Debug, PartialEq, Serialize, Deserialize, Outgoing)]
#[outgoing(name = "orders.placed")]
struct Order {
id: u64,
sku: String,
}
#[derive(Debug, PartialEq, Serialize, Deserialize, Outgoing)]
#[outgoing(name = "orders.priced")]
struct Priced {
order: u64,
cents: u64,
}
#[derive(OutSlot)]
struct Pricing;
#[subscriber(
JetStreamSubject::new("orders.placed", "ORDERS").durable("pricer"),
reply
)]
async fn price_order(
order: &Order,
Out(pricing): Out<impl RequestReply, Pricing>,
) -> Result<Priced, HandlerOutcome> {
let Ok(ask) = serde_json::to_vec(&Ask {
sku: order.sku.clone(),
}) else {
return Err(HandlerOutcome::drop());
};
let request = OutgoingMessage::new("prices.quote", ask.as_slice());
let reply = match pricing.request(request, PRICING_TIMEOUT).await {
Ok(reply) => reply,
Err(err) => {
tracing::warn!(order.id, %err, "no price, the order asks again later");
return Err(HandlerOutcome::retry_after(RETRY_DELAY));
}
};
let Ok(price) = serde_json::from_slice::<Price>(reply.payload()) else {
return Err(HandlerOutcome::drop());
};
tracing::info!(order.id, price.cents, "order priced");
Ok(Priced {
order: order.id,
cents: price.cents,
})
}
/// How long the orders service waits for a price.
const PRICING_TIMEOUT: Duration = Duration::from_secs(2);
/// How long an order waits before it asks for its price again.
const RETRY_DELAY: Duration = Duration::from_secs(30);
#[derive(Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[schemars(crate = "ruststream::schemars")]
struct Order {
id: u64,
sku: String,
}
impl OutgoingDestination for Order {
type Form = FixedName;
const DESTINATION: &'static str = "orders.placed";
}
impl MessageHeaders for Order {
type Contract = NoHeaders;
}
impl MessageInfo for Order {
const NAME: &'static str = "Order";
}
#[derive(Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
#[schemars(crate = "ruststream::schemars")]
struct Priced {
order: u64,
cents: u64,
}
impl OutgoingDestination for Priced {
type Form = FixedName;
const DESTINATION: &'static str = "orders.priced";
}
impl MessageHeaders for Priced {
type Contract = NoHeaders;
}
impl MessageInfo for Priced {
const NAME: &'static str = "Priced";
}
struct Pricing;
impl OutSlot for Pricing {
const NAME: &'static str = "Pricing";
type Destination = Reads;
}
struct PriceOrder;
impl<Asks> Handle<Order, Priced, Outs<(Asks,)>> for PriceOrder
where
Asks: OutEntry<Pricing, Wire: RequestReply>,
{
async fn handle(
&self,
order: &Order,
outs: &Outs<(Asks,)>,
_ctx: &mut Context<'_>,
) -> Result<Priced, HandlerOutcome> {
let Ok(ask) = serde_json::to_vec(&Ask {
sku: order.sku.clone(),
}) else {
return Err(HandlerOutcome::drop());
};
let request = OutgoingMessage::new("prices.quote", ask.as_slice());
let reply = match outs.get(Pricing).request(request, PRICING_TIMEOUT).await {
Ok(reply) => reply,
Err(err) => {
tracing::warn!(order.id, %err, "no price, the order asks again later");
return Err(HandlerOutcome::retry_after(RETRY_DELAY));
}
};
let Ok(price) = serde_json::from_slice::<Price>(reply.payload()) else {
return Err(HandlerOutcome::drop());
};
tracing::info!(order.id, price.cents, "order priced");
Ok(Priced {
order: order.id,
cents: price.cents,
})
}
}
price_order читает новые заказы из стрима и спрашивает цену каждого в prices.quote.
Слот Pricing ограничен типом Out<impl RequestReply, _>: тело обработчика заявляет, что ему нужен запрос и ответ, и не называет брокер.
Приложение связывает слот с обычной политикой Publish, издатель которой в NATS поддерживает запрос и ответ.
request публикует вопрос с новым инбоксом и возвращает первое сообщение, пришедшее в этот инбокс.
Вопрос - сырое сообщение, поэтому тело обработчика само кодирует его и декодирует ответ, здесь через serde_json.
Ответ на заказ, Priced, уходит, как только цена известна.
Запрос может вернуть ошибку двумя способами.
Если субъект не читает ни одна подписка, сервер сразу отвечает «no responders».
Если отвечающая сторона есть, но молчит, запрос возвращает ошибку NatsError::RequestTimeout по истечении тайм-аута, здесь двух секунд.
В обоих случаях заказ ждёт в стриме 30 секунд и спрашивает снова.
Приложение
- Макросы
- Вручную
#[ruststream::app]
fn app() -> impl App<State = ()> {
RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker(
NatsBroker::new("nats://localhost:4222"),
|b| {
b.include(quote).out(Answers, Publish).build();
b.include(price_order)
.out_reply(Publish)
.out(Pricing, Publish)
.build();
},
)
}
fn app() -> impl App<State = ()> {
RustStream::new(AppInfo::new("orders", "0.1.0")).with_broker(
NatsBroker::new("nats://localhost:4222"),
|b| {
b.include(
subscriber(
CoreSubject::new("prices.quote").queue_group("pricing"),
Quote,
)
.build(),
)
.out(Answers, Publish)
.build();
b.include(
subscriber(
JetStreamSubject::new("orders.placed", "ORDERS").durable("pricer"),
PriceOrder,
)
.reply()
.build(),
)
.out_reply(Publish)
.out(Pricing, Publish)
.build();
},
)
}
fn main() -> ExitCode {
run_main(app)
}
Оба слота принимают обычную политику Publish: один публикует ответы, другой задаёт вопросы.
Хук запуска спрашивает так же: через b.after_startup(Publish, ..) и request на издателе, который он получает.
Тесты в файле примера запускают обе стороны в одном приложении внутри процесса. NATS внутри процесса направляет запрос отвечающей стороне самого приложения, отвечает «no responders», если субъект не читает ни одна подписка, и завершает по тайм-ауту запрос к молчащей отвечающей стороне.