本页译自英文,由模型翻译。若与英文原文存在差异,以英文原文为准。
其他语言的对端
本指南把一个 RustStream 服务连接到用其他语言编写的对端:一个向它推送任务的 PUSH,一个读取它的通告的 SUB,以及一个向它发出请求的 DEALER。
这里的对端使用 pyzmq,即 libzmq 的 Python 绑定。 用 C、C++ 或其他语言编写的对端,用自己的绑定组成同样的帧。
服务
- 宏
- 手写
use ruststream_zeromq::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Debug, PartialEq, Deserialize)]
struct Job {
id: u64,
items: u32,
}
/// Announced on the fan-out, under a name a `jobs.` subscriber matches.
#[derive(Debug, PartialEq, Serialize, Outgoing)]
#[outgoing(name = "jobs.done")]
struct JobDone {
id: u64,
items: u32,
}
#[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,
}
#[subscriber("jobs", reply)]
async fn work(job: &Job) -> JobDone {
tracing::info!(job.id, job.items, "job worked");
JobDone {
id: job.id,
items: job.items,
}
}
#[subscriber("score", reply("reply"))]
async fn score(request: &ScoreRequest) -> Score {
tracing::info!(request.customer, request.amount_cents, "scoring");
Score {
customer: request.customer,
approved: request.amount_cents <= 50_000,
}
}
/// The service on the three endpoints it binds: jobs in, announcements out, requests answered.
fn service(jobs: &str, events: &str, scoring: &str) -> RustStream {
let events = ZmqFanout::new(ZmqEndpoint::bind(events)).bindable();
let announce = events.bind(ZmqFanoutPublish);
RustStream::new(AppInfo::new("worker", "0.1.0"))
.with_broker(ZmqQueue::new(ZmqEndpoint::bind(jobs)), |b| {
b.include(work).out_reply(announce);
})
.with_broker(events, |_b| {})
.with_broker(ZmqRpc::new(ZmqEndpoint::bind(scoring)), |b| {
b.include(score)
.out_reply(ZmqRpcPublish)
.transform(ReplyToRequester)
.out_retry(ZmqRpcPublish)
.to("score.retry");
})
}
#[ruststream::app]
fn app() -> impl App<State = ()> {
service(
"tcp://0.0.0.0:5555",
"tcp://0.0.0.0:5556",
"tcp://0.0.0.0:5557",
)
}
use ruststream_zeromq::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Debug, PartialEq, Deserialize, JsonSchema)]
#[schemars(crate = "ruststream::schemars")]
struct Job {
id: u64,
items: u32,
}
/// Announced on the fan-out, under a name a `jobs.` subscriber matches.
#[derive(Debug, PartialEq, Serialize, JsonSchema)]
#[schemars(crate = "ruststream::schemars")]
struct JobDone {
id: u64,
items: u32,
}
// `#[outgoing(name = "jobs.done")]` by hand.
impl OutgoingDestination for JobDone {
type Form = FixedName;
const DESTINATION: &'static str = "jobs.done";
}
impl MessageHeaders for JobDone {
type Contract = NoHeaders;
}
#[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 Work;
impl Handle<Job, JobDone> for Work {
fn handle(
&self,
job: &Job,
_outs: &(),
_ctx: &mut Context<'_>,
) -> impl Future<Output = Result<JobDone, HandlerOutcome>> {
tracing::info!(job.id, job.items, "job worked");
ready(Ok(JobDone {
id: job.id,
items: job.items,
}))
}
}
struct ScoreIt;
impl Handle<ScoreRequest, Score> for ScoreIt {
fn handle(
&self,
request: &ScoreRequest,
_outs: &(),
_ctx: &mut Context<'_>,
) -> impl Future<Output = Result<Score, HandlerOutcome>> {
tracing::info!(request.customer, request.amount_cents, "scoring");
ready(Ok(Score {
customer: request.customer,
approved: request.amount_cents <= 50_000,
}))
}
}
/// The service on the three endpoints it binds: jobs in, announcements out, requests answered.
fn service(jobs: &str, events: &str, scoring: &str) -> RustStream {
let events = ZmqFanout::new(ZmqEndpoint::bind(events)).bindable();
let announce = events.bind(ZmqFanoutPublish);
RustStream::new(AppInfo::new("worker", "0.1.0"))
.with_broker(ZmqQueue::new(ZmqEndpoint::bind(jobs)), |b| {
b.include(subscriber("jobs", Work).reply().build())
.out_reply(announce);
})
.with_broker(events, |_b| {})
.with_broker(ZmqRpc::new(ZmqEndpoint::bind(scoring)), |b| {
b.include(subscriber("score", ScoreIt).reply().to("reply").build())
.out_reply(ZmqRpcPublish)
.transform(ReplyToRequester)
.out_retry(ZmqRpcPublish)
.to("score.retry");
})
}
fn app() -> RustStream {
service(
"tcp://0.0.0.0:5555",
"tcp://0.0.0.0:5556",
"tcp://0.0.0.0:5557",
)
}
fn main() -> ExitCode {
run_main(app)
}
服务绑定三个端点。
端口 5555 上的 jobs 是一个 ZmqQueue,接收 PUSH 发送的任务。
每个处理完的任务都以 jobs.done 的名称,在端口 5556 的 ZmqFanout 上通告。
端口 5557 上的 score 在 ZmqRpc 上应答请求;ReplyToRequester 就是请求与回复中的那个转换。
三个帧
每条消息由三帧组成(线上格式):
frame 0: name UTF-8; on PUB/SUB also what a subscriber's prefix is matched against
frame 1: headers UTF-8 "name: value" lines separated by \n; may be empty
frame 2: payload what the codec wrote; JSON by default
只发送名称和载荷两帧的对端,发送的是不带消息头的消息。
消息头帧中的空行会被跳过,因此行尾多一个换行符也没有关系。
如果消息头帧或名称不是 UTF-8,或者某个消息头行没有 :,这次投递就在订阅上以 ZmqError::Wire 失败。
服务会检查它发布的消息头,保证每个消息头都能原样读回。
如果消息头不是文本、含有换行、名称中带有 :,或者以空白开头或结尾,发布就以 ZmqError::Send 失败,错误中写明该消息头和目的地。
向服务推送的 PUSH
import json
import zmq
push = zmq.Context().socket(zmq.PUSH)
push.connect("tcp://127.0.0.1:5555")
push.send_multipart([
b"jobs",
b"content-type: application/json",
json.dumps({"id": 1, "items": 3}).encode(),
])
push.close(linger=1000)
名称帧是 jobs,但已绑定的 PULL 会接收推送给它的每条消息,不管名称是什么。
读取通告的 SUB
import json
import zmq
sub = zmq.Context().socket(zmq.SUB)
sub.connect("tcp://127.0.0.1:5556")
sub.setsockopt(zmq.SUBSCRIBE, b"jobs.")
name, headers, payload = sub.recv_multipart()
print(name.decode(), json.loads(payload))
订阅是名称帧的前缀,这一点与 RustStream 观察者相同(基于 PUB/SUB 的广播)。
SUB 收到它加入广播之后发布的通告,之前的一概收不到。
先启动 watch.py,运行 push.py,一秒后再运行一次,这样就有一条通告在 SUB 加入之后发布。
watch.py 打印它收到的第一条通告后退出:jobs.done {'id': 1, 'items': 3}。
向服务发出请求的 DEALER
import json
import zmq
dealer = zmq.Context().socket(zmq.DEALER)
dealer.connect("tcp://127.0.0.1:5557")
dealer.send_multipart([
b"score",
b"correlation-id: 42",
json.dumps({"customer": 3, "amount_cents": 1250}).encode(),
])
name, headers, payload = dealer.recv_multipart()
print(name, headers, json.loads(payload))
ZmqRpc 另一侧的请求方是一个 DEALER,它发送这三帧。
ROUTER 在它们前面加上 DEALER 的身份,并给请求加上 reply-to 消息头。
答案以三帧返回,第 0 帧是字面值 reply(信封)。
消息头带着请求的 correlation-id,因此同时有多个请求在途的 DEALER,能把每个答案与对应的请求匹配起来:
b'reply' b'correlation-id: 42' {'customer': 3, 'approved': True}
编解码器
载荷帧就是编解码器写出的内容,因此对端与服务约定的是编解码器,而不是 Rust 类型。
默认是 JSON;msgpack 和 cbor 是 ruststream 的 feature。
对端自己组帧的字节,放在 #[derive(Outgoing, Serialized)] newtype 中原样通过(线上格式)。
版本与安全
客户端以 ZMTP 3.0 问候每个对端。
libzmq 4 使用 ZMTP 3.1,并接受 3.0 的对端;zeromq 客户端针对 libzmq 做过测试。
握手使用 NULL 机制,因此对端无需凭据即可加入。
流量是明文:在生产环境中运行讲解了应在什么环境中运行它。
生成文档中的约定
启用 asyncapi feature 后,生成的文档告诉对端如何加入每个通道(AsyncAPI)。
服务器条目报告传输方式、地址和本服务所在的一端。
通道报告它的套接字对,在 PUSH/PULL 和 PUB/SUB 上还报告 nameFrame:对端发送或订阅时所用的第 0 帧。
应答方的回复通道指向 reply-to 消息头,而不是一个地址。
测试帧
进程内测试像对端那样推送一个任务,并检查发布在 jobs.done 上的通告。
第二个测试用 TestApp::start_live 在真实套接字上运行服务。
一个原始的 zeromq DEALER 扮演外部对端:它手工组成请求帧,并检查答案的三帧。
应答方绑定一个测试自己的套接字路径,因此并行运行的测试互不干扰。