本页译自英文,由模型翻译。若与英文原文存在差异,以英文原文为准。
主题、通配符与共享订阅
本指南逐个读取 MQTT 主题,也一次读取多个主题;它让服务的多个实例分担工作,并挂载相互重叠的过滤器。
主题是由 / 分隔的若干层级组成的名字,例如 devices/dev42/telemetry。
服务器把消息交给此刻过滤器与其主题匹配的每个订阅。
服务器不为以后保存任何东西,只有两个例外:每个主题一条保留消息(设备状态),以及持久会话的队列(投递保证)。
示例是一个设备群服务。 它存储每台设备的遥测数据,跟踪某一台设备的每条消息,并安排固件推送。
共享组
store 读取每台设备的遥测数据。
服务以多个实例运行,每条读数只能存储一次。
- 宏
- 手写
/// Every replica of the service joins the share group `ingest`, so each reading is stored once.
#[subscriber(MqttFilter::new("devices/+/telemetry").qos(Qos::AtLeastOnce).shared("ingest"))]
async fn store(telemetry: &Telemetry, Ctx(topic): Ctx<DeliveryTopic>) -> HandlerOutcome {
tracing::info!(%topic, telemetry.temperature, "reading stored");
HandlerOutcome::ack()
}
/// Every replica of the service joins the share group `ingest`, so each reading is stored once.
/// A body typed over `MqttContext` reads the topic the reading arrived on.
struct Store;
impl Handle<Telemetry, (), (), MqttContext> for Store {
fn handle(
&self,
telemetry: &Telemetry,
_outs: &(),
ctx: &mut Context<'_, MqttContext>,
) -> impl Future<Output = Result<(), HandlerOutcome>> {
let topic = ctx.context(DeliveryTopic);
tracing::info!(%topic, telemetry.temperature, "reading stored");
ready(Ok(()))
}
}
fn fleet() -> MqttFilter {
MqttFilter::new("devices/+/telemetry")
.qos(Qos::AtLeastOnce)
.shared("ingest")
}
MqttFilter 读取过滤器匹配的每个主题。
+ 恰好匹配一个层级,所以 devices/+/telemetry 读取每台设备的遥测数据,不会读取更深的层级。
shared("ingest") 订阅 $share/ingest/devices/+/telemetry,服务器把每条消息交给该组中的一个会话。
每个实例用自己的客户端 ID 连接,并加入同一个组,所以各实例分摊这些读数。
组外的订阅,或者另一个组中的订阅,仍会得到自己的一份。
处理器看不到 $share/.. 前缀,也看不到过滤器。
Ctx<DeliveryTopic> 是这条读数到达时所在的主题,例如 devices/dev42/telemetry,所以设备的 id 唾手可得。
按设备保持顺序
服务器把共享组的每条读数交给任意一个成员,因此同一设备的读数可能落到不同的副本上。
按设备的顺序只在一个订阅内部成立:workers(n, by_key) 让键相同的读数依次执行,不同的键并行执行。
键是 partition-key 头,即 PARTITION_KEY_HEADER,设备在每条读数上设置它,例如自己的 id。
需要按设备保持读数顺序的订阅,应在共享组之外读取。
多层通配符
trace 把某一台设备的每条消息记入日志,无论消息属于哪一类。
- 宏
- 手写
/// A payload taken as the bytes it is, because one device sends messages of several kinds.
#[derive(Deserialized)]
struct Raw<'a>(&'a [u8]);
/// Logs every message of the device under investigation, whatever its kind.
#[subscriber(MqttFilter::new("devices/dev42/#"))]
async fn trace(message: &Raw<'_>, Ctx(topic): Ctx<DeliveryTopic>) -> HandlerOutcome {
tracing::debug!(%topic, bytes = message.0.len(), "dev42 traced");
HandlerOutcome::ack()
}
/// A payload taken as the bytes it is, because one device sends messages of several kinds.
#[derive(Deserialized)]
struct Raw<'a>(&'a [u8]);
/// Logs every message of the device under investigation, whatever its kind.
struct Trace;
impl Handle<Raw<'_>, (), (), MqttContext> for Trace {
fn handle(
&self,
message: &Raw<'_>,
_outs: &(),
ctx: &mut Context<'_, MqttContext>,
) -> impl Future<Output = Result<(), HandlerOutcome>> {
let topic = ctx.context(DeliveryTopic);
tracing::debug!(%topic, bytes = message.0.len(), "dev42 traced");
ready(Ok(()))
}
}
fn one_device() -> MqttFilter {
MqttFilter::new("devices/dev42/#")
}
# 匹配主题的剩余部分,层级数不限,它只能位于过滤器的末尾。
devices/dev42/# 读取 devices/dev42/telemetry、devices/dev42/telemetry/raw 和这台设备的其他所有主题。
设备发送多种消息,所以 trace 按原样把载荷当作字节接收,不经过任何编解码器。
以 $ 开头的主题,例如服务器自己的 $SYS/.. 统计信息,只有同样以 $ 开头的过滤器才能匹配。
因此单独的 # 永远读不到它们。
单个主题
- 宏
- 手写
#[derive(Debug, PartialEq, Deserialize, Serialize, Outgoing)]
#[outgoing(name = "fleet/firmware")]
struct FirmwareRelease {
version: String,
}
/// A bare string is one topic, read at the default quality of service.
#[subscriber("fleet/firmware")]
async fn schedule_rollout(release: &FirmwareRelease) -> HandlerOutcome {
tracing::info!(release.version, "rollout scheduled");
HandlerOutcome::ack()
}
#[derive(Debug, PartialEq, Deserialize, Serialize, Outgoing, JsonSchema)]
#[schemars(crate = "ruststream::schemars")]
#[outgoing(name = "fleet/firmware")]
struct FirmwareRelease {
version: String,
}
/// Mounted under a bare name, which is one topic read at the default quality of service.
struct ScheduleRollout;
impl Handle<FirmwareRelease> for ScheduleRollout {
fn handle(
&self,
release: &FirmwareRelease,
_outs: &(),
_ctx: &mut Context<'_>,
) -> impl Future<Output = Result<(), HandlerOutcome>> {
tracing::info!(release.version, "rollout scheduled");
ready(Ok(()))
}
}
#[subscriber(..)] 中的裸字符串是一个主题,按 MqttTopic 的默认设置读取。
MqttTopic::new("fleet/firmware").qos(..) 写出的是带有设置的同一个订阅。
MqttTopic 不接受通配符:名字里带通配符时,启动会在任何 I/O 之前失败,错误信息会指出 MqttFilter。
通配符只属于订阅,所以一次发布只指定一个主题,向带 + 或 # 的主题发布会返回错误。
应用
- 宏
- 手写
#[ruststream::app]
fn app() -> impl App<State = ()> {
RustStream::new(AppInfo::new("fleet", "0.1.0")).with_broker(
MqttBroker::new("mqtt://localhost:1883", "fleet-svc"),
|b| {
// A filter names no topic of its own, so each one names where a retry copy goes:
// a topic it matches and no other filter of the service does.
b.include(store)
.out_retry(Publish::default())
.to("devices/retry/telemetry");
b.include(trace)
.out_retry(Publish::default())
.to("devices/dev42/retry");
b.include(schedule_rollout);
},
)
}
fn app() -> impl App<State = ()> {
RustStream::new(AppInfo::new("fleet", "0.1.0")).with_broker(
MqttBroker::new("mqtt://localhost:1883", "fleet-svc"),
|b| {
// A filter names no topic of its own, so each one names where a retry copy goes:
// a topic it matches and no other filter of the service does.
b.include(
subscriber("devices/+/telemetry", Store)
.map_source(|_| fleet())
.build(),
)
.out_retry(Publish::default())
.to("devices/retry/telemetry");
b.include(
subscriber("devices/dev42/#", Trace)
.map_source(|_| one_device())
.build(),
)
.out_retry(Publish::default())
.to("devices/dev42/retry");
b.include(subscriber("fleet/firmware", ScheduleRollout).build());
},
)
}
fn main() -> ExitCode {
run_main(app)
}
过滤器匹配许多主题,所以其投递的延迟副本没有唯一的主题可去。
挂载点用 out_retry(Publish::default()).to(..) 指定一个主题,否则服务拒绝启动。
副本会到达过滤器与其主题匹配的每个订阅,所以这里的每个主题只被它自己的过滤器匹配,不被其他过滤器匹配。
重试与死信主题讲解这些副本。
相互重叠的过滤器
store 和 trace 都匹配 devices/dev42/telemetry,它们共用一个连接。
该主题上的一条读数,会让它们各运行一次。
服务器为每个匹配的订阅发送一份副本,Broker 根据每份副本携带的订阅标识符区分它们。
对服务器来说,订阅标识符是可选功能。
在不提供它的服务器上,服务启动失败,错误信息会指出这两个过滤器。
同一个过滤器在一个服务中挂载两次,对服务器来说就是一个订阅。 它的处理器轮流处理投递,而不是各得一份副本。
测试路由
示例文件中的测试在进程内运行应用。
它们向 devices/dev42/telemetry、devices/dev7/telemetry 和 devices/dev42/telemetry/raw 发布消息,并检查哪些处理器运行了。
进程内服务器像 Mosquitto 那样匹配主题,包括通配符和重叠。
多个实例共享一个组,需要多个连接连到同一个服务器,所以分摊本身只能在真实 Broker 上看到。