Эта страница переведена с английского языковой моделью. При любом расхождении верен английский оригинал.
Запрос и ответ
Это руководство отвечает на запросы на DEALER/ROUTER и спрашивает другой сервис изнутри обработчика.
В примере два сервиса. Сервис оценки отвечает, одобрена ли сумма заказа для клиента. Сервис оформления заказов спрашивает его о каждом принятом заказе.
Отвечающая сторона
- Макросы
- Вручную
use ruststream_zeromq::rpc::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Debug, PartialEq, Deserialize)]
struct ScoreRequest {
customer: u64,
amount_cents: u64,
}
// No destination of its own: the ROUTER addresses each answer per request.
#[derive(Debug, PartialEq, Serialize, Outgoing)]
struct Score {
customer: u64,
approved: bool,
}
// The answer goes to the peer that asked, so the clause's name is only the fallback.
#[subscriber("score", reply("reply"))]
async fn score(request: &ScoreRequest) -> Result<Score, HandlerOutcome> {
if request.amount_cents == 0 {
tracing::warn!(request.customer, "a request without an amount, dropped");
return Err(HandlerOutcome::drop());
}
tracing::info!(request.customer, request.amount_cents, "scoring");
Ok(Score {
customer: request.customer,
approved: request.amount_cents <= 50_000,
})
}
#[ruststream::app]
fn app() -> impl App {
// The responder is the stable side: it binds, and every requester dials it.
RustStream::new(AppInfo::new("scoring", "0.1.0")).with_broker(
ZmqRpc::new(ZmqEndpoint::bind("tcp://0.0.0.0:5557")),
|b| {
// A responder addresses no retry copies, so the mount names where they go.
b.include(score)
.out_reply(Publish)
.transform(ReplyToRequester)
.out_retry(Publish)
.to("score.retry");
},
)
}
use ruststream_zeromq::rpc::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Debug, PartialEq, Deserialize, JsonSchema)]
#[schemars(crate = "ruststream::schemars")]
struct ScoreRequest {
customer: u64,
amount_cents: u64,
}
#[derive(Debug, PartialEq, Serialize, JsonSchema)]
#[schemars(crate = "ruststream::schemars")]
struct Score {
customer: u64,
approved: bool,
}
// `#[derive(Outgoing)]` with no name by hand: the ROUTER addresses each answer per request.
impl OutgoingDestination for Score {
type Form = CallerName;
}
impl MessageHeaders for Score {
type Contract = NoHeaders;
}
struct ScoreIt;
impl Handle<ScoreRequest, Score> for ScoreIt {
fn handle(
&self,
request: &ScoreRequest,
_outs: &(),
_ctx: &mut Context<'_>,
) -> impl Future<Output = Result<Score, HandlerOutcome>> {
if request.amount_cents == 0 {
tracing::warn!(request.customer, "a request without an amount, dropped");
return ready(Err(HandlerOutcome::drop()));
}
tracing::info!(request.customer, request.amount_cents, "scoring");
ready(Ok(Score {
customer: request.customer,
approved: request.amount_cents <= 50_000,
}))
}
}
fn app() -> impl App {
// The responder is the stable side: it binds, and every requester dials it.
RustStream::new(AppInfo::new("scoring", "0.1.0")).with_broker(
ZmqRpc::new(ZmqEndpoint::bind("tcp://0.0.0.0:5557")),
|b| {
// The answer goes to the peer that asked, so the `to` name is only the fallback.
// A responder addresses no retry copies, so the mount names where they go.
b.include(subscriber("score", ScoreIt).reply().to("reply").build())
.out_reply(Publish)
.transform(ReplyToRequester)
.out_retry(Publish)
.to("score.retry");
},
)
}
fn main() -> ExitCode {
run_main(app)
}
ZmqRpc - паттерн DEALER/ROUTER.
Отвечающая сторона устойчива, поэтому она привязывается к адресу, а каждая запрашивающая сторона подключается к ней.
score - обычный обработчик в reply-форме: он возвращает ответ или отбрасывает запрос без суммы.
Сокет ROUTER знает, какой узел отправил каждый запрос.
Он записывает этот узел в заголовок запроса reply-to, и ответ должен уйти туда.
Поэтому Score не объявляет своего адреса назначения, а reply("reply") называет только запасной адрес.
Преобразование ниже заменяет его для каждого запроса.
Отвечающая сторона называет, куда уходят её копии для повтора: .out_retry(Publish).to("score.retry").
У копии запроса нет своего адреса, поэтому регистрацию без этого шага старт отвергает.
Пакеты отвечающая сторона тоже не принимает: .batch(..) на ней не компилируется, потому что каждый ответ уходит своему узлу.
Преобразование
- Макросы
- Вручную
/// Answers at the `reply-to` address the ROUTER stamped on the request, under the request's
/// `correlation-id`.
struct ReplyToRequester;
impl<C, Options> PublishTransform<ForReply<C>, Options> for ReplyToRequester {
type Destination = Names;
fn apply(
&self,
out: &mut Outgoing<'_>,
_options: &mut Option<Options>,
cx: &PublishContext<'_, C>,
) {
if let Some(reply_to) = cx.headers().get_shared("reply-to")
&& let Ok(reply_to) = Str::try_from(reply_to)
{
out.set_name(reply_to);
}
if let Some(correlation) = cx.headers().get_shared("correlation-id") {
out.headers_mut()
.insert(Str::from_static("correlation-id"), correlation);
}
}
}
/// Answers at the `reply-to` address the ROUTER stamped on the request, under the request's
/// `correlation-id`.
struct ReplyToRequester;
impl<C, Options> PublishTransform<ForReply<C>, Options> for ReplyToRequester {
type Destination = Names;
fn apply(
&self,
out: &mut Outgoing<'_>,
_options: &mut Option<Options>,
cx: &PublishContext<'_, C>,
) {
if let Some(reply_to) = cx.headers().get_shared("reply-to")
&& let Ok(reply_to) = Str::try_from(reply_to)
{
out.set_name(reply_to);
}
if let Some(correlation) = cx.headers().get_shared("correlation-id") {
out.headers_mut()
.insert(Str::from_static("correlation-id"), correlation);
}
}
}
ReplyToRequester - преобразование публикации на ответе.
Оно читает заголовок reply-to запроса и отправляет ответ туда.
Ещё оно копирует correlation-id запроса: по нему запрашивающая сторона сопоставляет ответ с запросом.
Оно объявляет Destination = Names, потому что само выбирает адрес назначения.
Так преобразование может поступать, только если тип ответа не объявляет адреса, поэтому тип ответа с именем и это преобразование вместе не компилируются.
Ответ узлу, который уже отключился, отвергается с ошибкой, в которой назван его адрес. Так отвечающая сторона узнаёт, что ответ никуда не дошёл. На широковещательной конечной точке сообщение без совпавшего фильтра отбрасывается молча.
Запрашивающая сторона
- Макросы
- Вручную
use std::time::Duration;
use ruststream::OutgoingMessage;
use ruststream_zeromq::prelude::*;
use serde::{Deserialize, Serialize};
/// How long one request waits for its answer.
const ASK_TIMEOUT: Duration = Duration::from_secs(2);
/// When the order is asked about again after the scoring service did not answer.
const ASK_AGAIN: Duration = Duration::from_secs(10);
#[derive(Debug, PartialEq, Deserialize)]
struct Order {
id: u64,
customer: u64,
amount_cents: u64,
}
#[derive(Serialize)]
struct ScoreRequest {
customer: u64,
amount_cents: u64,
}
#[derive(Deserialize)]
struct Score {
approved: bool,
}
#[subscriber("orders")]
async fn check(order: &Order, Out(scoring): Out<impl RequestReply>) -> HandlerOutcome {
let Ok(request) = serde_json::to_vec(&ScoreRequest {
customer: order.customer,
amount_cents: order.amount_cents,
}) else {
return HandlerOutcome::drop();
};
let request = OutgoingMessage::new("score", request.as_slice());
let answer = match scoring.request(request, ASK_TIMEOUT).await {
Ok(answer) => answer,
Err(err) => {
tracing::warn!(order.id, %err, "no score, asking again later");
return HandlerOutcome::retry_after(ASK_AGAIN);
}
};
let Ok(score) = serde_json::from_slice::<Score>(answer.payload()) else {
return HandlerOutcome::drop();
};
tracing::info!(order.id, score.approved, "order checked");
HandlerOutcome::ack()
}
#[ruststream::app]
fn app() -> impl App {
// The scoring service binds; every requester dials it.
let scoring = ZmqRpc::new(ZmqEndpoint::connect("tcp://scoring:5557")).bindable();
let ask = scoring.bind(ZmqRpcPublish);
RustStream::new(AppInfo::new("checkout", "0.1.0"))
.with_broker(
ZmqQueue::new(ZmqEndpoint::bind("tcp://0.0.0.0:5555")),
|b| {
b.include(check)
.max_attempts(nonzero!(3u32))
.out(DefaultSlot, ask)
.build();
},
)
.with_broker(scoring, |_b| {})
}
use std::time::Duration;
use ruststream::OutgoingMessage;
use ruststream_zeromq::prelude::*;
use serde::{Deserialize, Serialize};
/// How long one request waits for its answer.
const ASK_TIMEOUT: Duration = Duration::from_secs(2);
/// When the order is asked about again after the scoring service did not answer.
const ASK_AGAIN: Duration = Duration::from_secs(10);
#[derive(Debug, PartialEq, Deserialize, JsonSchema)]
#[schemars(crate = "ruststream::schemars")]
struct Order {
id: u64,
customer: u64,
amount_cents: u64,
}
#[derive(Serialize)]
struct ScoreRequest {
customer: u64,
amount_cents: u64,
}
#[derive(Deserialize)]
struct Score {
approved: bool,
}
struct Check;
impl<Entry> Handle<Order, (), Outs<(Entry,)>> for Check
where
Entry: OutEntry<DefaultSlot, Wire: RequestReply>,
{
async fn handle(
&self,
order: &Order,
outs: &Outs<(Entry,)>,
_ctx: &mut Context<'_>,
) -> Result<(), HandlerOutcome> {
let request = serde_json::to_vec(&ScoreRequest {
customer: order.customer,
amount_cents: order.amount_cents,
})
.map_err(|_| HandlerOutcome::drop())?;
let request = OutgoingMessage::new("score", request.as_slice());
let answer = match outs.get(DefaultSlot).request(request, ASK_TIMEOUT).await {
Ok(answer) => answer,
Err(err) => {
tracing::warn!(order.id, %err, "no score, asking again later");
return Err(HandlerOutcome::retry_after(ASK_AGAIN));
}
};
let score = serde_json::from_slice::<Score>(answer.payload())
.map_err(|_| HandlerOutcome::drop())?;
tracing::info!(order.id, score.approved, "order checked");
Ok(())
}
}
fn app() -> impl App {
// The scoring service binds; every requester dials it.
let scoring = ZmqRpc::new(ZmqEndpoint::connect("tcp://scoring:5557")).bindable();
let ask = scoring.bind(ZmqRpcPublish);
RustStream::new(AppInfo::new("checkout", "0.1.0"))
.with_broker(
ZmqQueue::new(ZmqEndpoint::bind("tcp://0.0.0.0:5555")),
|b| {
b.include(subscriber("orders", Check).build())
.max_attempts(nonzero!(3u32))
.out(DefaultSlot, ask)
.build();
},
)
.with_broker(scoring, |_b| {})
}
fn main() -> ExitCode {
run_main(app)
}
check принимает параметр Out<impl RequestReply>, а точка монтирования связывает его с политикой ZmqRpc через .out(DefaultSlot, ask).
Брокер scoring подключается к сервису оценки и подписки не имеет: он только спрашивает.
request отправляет запрос через собственный сокет DEALER и возвращает ответ с тем же correlation-id.
check сам кодирует запрос и декодирует ответ в JSON, который читает и пишет сервис оценки.
Таймауты
Если ответ не пришёл за ASK_TIMEOUT, request возвращает ошибку ZmqError::RequestTimeout.
Тогда check возвращает retry_after(ASK_AGAIN), и заказ возвращается через десять секунд.
max_attempts ограничивает число попыток тремя.
Это паттерн Lazy Pirate из руководства ZeroMQ, только попытки считает фреймворк.
Запрос с истёкшим таймаутом мог всё же дойти до отвечающей стороны, поэтому повторная отправка запроса должна быть безопасной.
Ни request, ни публикацию ответа нельзя безопасно отменить.
Обработчик прекращает ожидание по таймауту запроса, а не через ветку select! (отмена).
Тестирование
Тесты сервиса оценки отправляют запросы так, как это сделал бы DEALER запрашивающей стороны, и проверяют, как score завершил их доставку.
Тест оформления заказа работает во внутрипроцессном режиме без сервиса оценки, поэтому его запрос истекает по таймауту.
Он проверяет, что check попросил повтора и что копия вернулась через ASK_AGAIN со счётчиком повторов, равным единице.
Часы остановлены, поэтому таймаут и задержка проходят мгновенно.
Обвязку разбирает руководство Тестирование сервиса на ZeroMQ.