Эта страница переведена с английского языковой моделью. При любом расхождении верен английский оригинал.
Состояние устройств: сохранённые сообщения и последняя воля
Это руководство хранит текущее состояние каждого устройства на сервере, чтобы читатель, который подключается позже, сразу его видел, и сообщает, работает ли сам сервис.
Истории MQTT не хранит, за одним исключением: сохранённого сообщения. Сервер хранит последнее сохранённое сообщение каждой темы и отправляет его каждой новой подписке, фильтр которой подходит к этой теме. Последняя воля - это сообщение, которое сервер публикует за клиента, если его соединение завершилось без штатного отключения.
Пример - монитор шлюзов.
Шлюзы периодически присылают сигналы присутствия (heartbeat) с уровнем заряда батареи.
Сервис хранит состояние каждого шлюза, ok или low-battery, сохранённым сообщением в gateways/<id>/state, а панели мониторинга читают его оттуда.
Состояние каждого шлюза
- Макросы
- Вручную
/// The state of one gateway, as plain text a dashboard shows as it is. The type carries its own
/// bytes, so no codec runs on them, and the `{gateway}` level becomes a setter.
#[derive(Outgoing, Serialized)]
#[outgoing(name = "gateways/{gateway}/state")]
struct GatewayState(Vec<u8>);
#[derive(OutSlot)]
#[publishes(GatewayState)]
struct States;
/// Turns each heartbeat into the gateway's state.
#[subscriber(MqttFilter::new("gateways/+/heartbeat").qos(Qos::AtLeastOnce))]
async fn track(heartbeat: &Heartbeat, Out(states): Out<impl Publisher, States>) -> HandlerOutcome {
let state = if heartbeat.battery_percent < 20 {
"low-battery"
} else {
"ok"
};
let announced = states
.message(&GatewayState(state.as_bytes().to_vec()))
.to()
.gateway(&heartbeat.gateway)
.publish()
.await;
if let Err(err) = announced {
tracing::warn!(heartbeat.gateway, %err, "state not announced");
return HandlerOutcome::retry();
}
HandlerOutcome::ack()
}
/// An empty retained payload deletes the retained message, so a removed gateway leaves no state.
#[subscriber("fleet/decommissioned")]
async fn forget(gone: &Decommissioned, Out(states): Out<impl Publisher, States>) -> HandlerOutcome {
let cleared = states
.message(&GatewayState(Vec::new()))
.to()
.gateway(&gone.gateway)
.publish()
.await;
if let Err(err) = cleared {
tracing::warn!(gone.gateway, %err, "state not cleared");
return HandlerOutcome::retry();
}
HandlerOutcome::ack()
}
/// The state of one gateway, as plain text a dashboard shows as it is. The type carries its own
/// bytes, so no codec runs on them, and the `{gateway}` level becomes a setter.
#[derive(Outgoing, Serialized)]
#[outgoing(name = "gateways/{gateway}/state")]
struct GatewayState(Vec<u8>);
#[derive(OutSlot)]
#[publishes(GatewayState)]
struct States;
/// Turns each heartbeat into the gateway's state.
struct Track;
impl<Entry> Handle<Heartbeat, (), Outs<(Entry,)>> for Track
where
Entry: OutEntry<States, Wire: Publisher>,
{
async fn handle(
&self,
heartbeat: &Heartbeat,
outs: &Outs<(Entry,)>,
_ctx: &mut Context<'_>,
) -> Result<(), HandlerOutcome> {
let state = if heartbeat.battery_percent < 20 {
"low-battery"
} else {
"ok"
};
let announced = outs
.get(States)
.message(&GatewayState(state.as_bytes().to_vec()))
.to()
.gateway(&heartbeat.gateway)
.publish()
.await;
if let Err(err) = announced {
tracing::warn!(heartbeat.gateway, %err, "state not announced");
return Err(HandlerOutcome::retry());
}
Ok(())
}
}
/// An empty retained payload deletes the retained message, so a removed gateway leaves no state.
struct Forget;
impl<Entry> Handle<Decommissioned, (), Outs<(Entry,)>> for Forget
where
Entry: OutEntry<States, Wire: Publisher>,
{
async fn handle(
&self,
gone: &Decommissioned,
outs: &Outs<(Entry,)>,
_ctx: &mut Context<'_>,
) -> Result<(), HandlerOutcome> {
let cleared = outs
.get(States)
.message(&GatewayState(Vec::new()))
.to()
.gateway(&gone.gateway)
.publish()
.await;
if let Err(err) = cleared {
tracing::warn!(gone.gateway, %err, "state not cleared");
return Err(HandlerOutcome::retry());
}
Ok(())
}
}
Состояние - это простой текст, который панель показывает как есть. Поэтому у GatewayState стоит #[derive(Serialized)], тип сам хранит свои байты, а кодек к ним не применяется.
Его тема - шаблон имени: {gateway} становится сеттером, поэтому .to().gateway(..) указывает тему одного шлюза.
track публикует состояние шлюза, от которого пришёл сигнал.
forget публикует пустую полезную нагрузку в тему состояния шлюза, который выводят из эксплуатации.
Пустое сохранённое сообщение удаляет сохранённое сообщение своей темы, и сервер перестаёт отдавать это состояние новым подписчикам.
Сохранённые публикации
- Макросы
- Вручную
fn service(broker: MqttBroker) -> RustStream {
RustStream::new(AppInfo::new("gateway-monitor", "0.1.0")).with_broker(broker, |b| {
// Runs once the broker is connected, and replaces the will's `offline` of an earlier run.
b.after_startup(
Publish::default().qos(Qos::AtLeastOnce).retain(true),
async move |publisher| -> Result<(), PublishError<MqttError>> {
publisher
.message(&ServiceStatus(b"online".to_vec()))
.publish()
.await
},
);
// Every state goes out retained, so a dashboard that starts later reads it at once.
let retained = Publish::default().qos(Qos::AtLeastOnce).retain(true);
b.include(track)
.out(States, retained)
.out_retry(Publish::default())
.to("gateways/retry/heartbeat")
.build();
b.include(forget).out(States, retained).build();
})
}
#[ruststream::app]
fn app() -> impl App<State = ()> {
service(broker("mqtt://localhost:1883", "gateway-monitor"))
}
fn service(broker: MqttBroker) -> RustStream {
RustStream::new(AppInfo::new("gateway-monitor", "0.1.0")).with_broker(broker, |b| {
// Runs once the broker is connected, and replaces the will's `offline` of an earlier run.
b.after_startup(
Publish::default().qos(Qos::AtLeastOnce).retain(true),
async move |publisher| -> Result<(), PublishError<MqttError>> {
publisher
.message(&ServiceStatus(b"online".to_vec()))
.publish()
.await
},
);
// Every state goes out retained, so a dashboard that starts later reads it at once.
let retained = Publish::default().qos(Qos::AtLeastOnce).retain(true);
let heartbeats = MqttFilter::new("gateways/+/heartbeat").qos(Qos::AtLeastOnce);
b.include(
subscriber("gateways/+/heartbeat", Track)
.map_source(|_| heartbeats)
.build(),
)
.out(States, retained)
.out_retry(Publish::default())
.to("gateways/retry/heartbeat")
.build();
b.include(subscriber("fleet/decommissioned", Forget).build())
.out(States, retained)
.build();
})
}
fn app() -> impl App<State = ()> {
service(broker("mqtt://localhost:1883", "gateway-monitor"))
}
fn main() -> ExitCode {
run_main(app)
}
retain(true) в политике публикации делает сохранённой каждую публикацию через слот.
Оба обработчика используют слот States и одну политику, поэтому состояние и его удаление публикуются одинаково.
Сохранённым можно сделать и отдельное сообщение: шаг retain у публикации переопределяет политику для этого вызова.
Совместная подписка не получает сохранённых сообщений, когда подписывается. Панель, которая читает состояния, подписывается вне любой группы совместной подписки.
В сети и не в сети
- Макросы
- Вручную
/// The service's own status, which dashboards read to tell whether it is running.
#[derive(Outgoing, Serialized)]
#[outgoing(name = "services/gateway-monitor/status")]
struct ServiceStatus(Vec<u8>);
/// If the connection ends without a clean disconnect, the server publishes `offline` on the
/// status topic, retained.
fn broker(url: &str, client_id: &str) -> MqttBroker {
MqttBroker::new(url, client_id).last_will(
"services/gateway-monitor/status",
"offline",
Qos::AtLeastOnce,
true,
)
}
/// The service's own status, which dashboards read to tell whether it is running.
#[derive(Outgoing, Serialized)]
#[outgoing(name = "services/gateway-monitor/status")]
struct ServiceStatus(Vec<u8>);
/// If the connection ends without a clean disconnect, the server publishes `offline` on the
/// status topic, retained.
fn broker(url: &str, client_id: &str) -> MqttBroker {
MqttBroker::new(url, client_id).last_will(
"services/gateway-monitor/status",
"offline",
Qos::AtLeastOnce,
true,
)
}
last_will принимает тему, полезную нагрузку, качество обслуживания и флаг сохранения последней воли.
Брокер передаёт последнюю волю серверу при подключении.
Если соединение завершается без пакета протокола DISCONNECT, потому что процесс аварийно завершился или отказала сеть, сервер публикует последнюю волю: здесь offline, сохранённым сообщением в services/gateway-monitor/status.
Штатная остановка отправляет DISCONNECT, и сервер отбрасывает последнюю волю.
Вторая половина - хук after_startup в приложении выше.
Он выполняется, когда брокер подключён, и публикует online сохранённым сообщением в ту же тему.
Поэтому в теме статуса всегда лежит последнее из двух, и подписавшаяся панель сразу его читает.
Проверка состояния
Внутрипроцессные тесты проверяют, что публикует сервис: состояние каждого шлюза, пустую полезную нагрузку для удалённого шлюза и online при запуске.
Внутрипроцессный сервер хранит сохранённые сообщения так же, как Mosquitto.
Последнюю волю публикует сервер, когда соединение обрывается, поэтому один тест работает на Mosquitto:
MQTT_TEST_URL=mqtt://localhost:1883 cargo test -- --test-threads=1
Он запускает сервис, штатно останавливает его и после этого запускает панель: панель получает online и состояние шлюза, оба сохранённые.
Затем он завершает очередной запуск сервиса без DISCONNECT, и панель получает offline.
В конце он удаляет шлюз теста, и его состояние на сервере стирается.