本页译自英文,由模型翻译。若与英文原文存在差异,以英文原文为准。
请求与回复
本指南在 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 是一个普通的回复处理器:它返回答案,或者丢弃没有金额的请求。
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> 参数,挂载点用 .out(DefaultSlot, ask) 把它绑定到一个 ZmqRpc 策略。
scoring Broker 拨号连接评分服务,没有订阅;它只发出请求。
request 通过自己的 DEALER 套接字发送请求,并返回带有相同 correlation-id 的答案。
check 自己编码请求、解码答案,所用的 JSON 与评分服务读写的一致。
超时
在 ASK_TIMEOUT 内没有得到答案的请求以 ZmqError::RequestTimeout 失败。
这时 check 返回 retry_after(ASK_AGAIN),订单在十秒后回来。
max_attempts 把尝试次数限制为三次。
这就是 ZeroMQ 指南中的 Lazy Pirate 模式,由框架负责计数。
超时的请求可能已经到达了应答方,因此请求必须可以安全地发送两次。
request 和回复发布都不是取消安全的。
处理器通过请求的超时来放弃,而不是通过 select! 分支(取消)。
测试
评分服务的测试像请求方的 DEALER 那样发送请求,并检查 score 如何结算它们。
结账服务的测试在进程内运行,没有评分服务,因此它的请求会超时。
测试检查 check 要求了重试,并且副本在 ASK_AGAIN 之后回来,重试次数为一。
时钟是暂停的,因此超时和延迟瞬间就过去了。
测试 ZeroMQ 服务讲解了测试套件。