Broker-Tests

MQTT-5-Features testen: User Properties, Expiry, Topic Aliases

Praktische Tests für MQTT-5-Features mit MQTT.js und paho-mqtt: User Properties, Message und Session Expiry, Topic Aliases, Request/Response und No Local.

Aktualisiert · 10 Min. Lesezeit

MQTT-5-Features testen: User Properties, Expiry, Topic Aliases

MQTT 5 hat Features eingeführt, die das Verhalten von Anwendungen verändern — Metadaten in User Properties, ablaufende Nachrichten, kürzere Pakete durch Topic Aliases, Sessions mit Lebensdauer. Jedes davon funktioniert nur, wenn Client-Bibliothek und Broker es korrekt implementieren. Deshalb gilt: MQTT-5-Features Ende zu Ende testen, bevor du dich darauf verlässt.

Dieser Artikel liefert kleine, lauffähige Tests mit MQTT.js 5 (Node.js) und paho-mqtt 2.x (Python). Jeder Test publiziert, beobachtet, was ankommt, und prüft das Ergebnis per Assertion — er läuft also in der CI genauso wie auf deinem Laptop.

Was getestet wird und was „bestanden“ heißt

FeatureTestBestanden, wenn
User PropertiesMit Schlüssel/Wert-Paaren publizierenDer Subscriber erhält dieselben Paare
Content Type, Correlation DataMit beiden gesetzt publizierenDer Subscriber erhält beide unverändert
Message Expiry (retained)Retained Message mit 3 s Expiry, nach 5 s abonnierenNichts wird zugestellt
Message Expiry (Queue)Nachrichten für eine Offline-Session einreihenDie nicht abgelaufene kommt mit verringertem Intervall an, die abgelaufene wird verworfen
Session ExpiryMit clean_start=False neu verbindensession present ist true und gequeuete Nachrichten kommen an
Topic AliasesAlias registrieren, dann mit leerem Topic publizierenDer Subscriber sieht den vollen Topic-Namen
No LocalAuf derselben Verbindung mit nl abonnieren und publizierenDer Client erhält seine eigene Nachricht nicht

Prüfe zuerst, ob der Broker MQTT-5-Verbindungen annimmt, und sieh dir seine CONNACK-Limits (Topic Alias Maximum, Maximum QoS) mit der Broker-Feature-Sonde an. Ein Test, der scheitert, weil der Broker TopicAliasMaximum 0 meldet, ist ein Konfigurationsbefund und kein Bug.

Vorbereitung

Beide Bibliotheken installieren
npm install mqtt@5
pip install "paho-mqtt>=2.0"

Alle Beispiele nutzen broker.connectmq.com:1883 und ein zufälliges Topic-Präfix unter testmqtt/mqtt5/, damit parallele Läufe nicht die Nachrichten der anderen sehen. Ergänze username und password ($MQTT_USER / $MQTT_PASS), wenn dein Broker sie verlangt.

MQTT.js: Properties, Expiry, Aliases und No Local

Das folgende Script führt vier Tests nacheinander aus und beendet sich beim ersten Fehler mit einem Exit-Code ungleich null. nextMessage wartet auf eine Nachricht oder läuft in einen Timeout — damit ist „es darf nichts ankommen“ genauso leicht zu prüfen wie „das muss ankommen“.

mqtt5.test.mjs
import mqtt from 'mqtt';
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';

const URL = 'mqtt://broker.connectmq.com:1883';
const opts = { protocolVersion: 5, username: process.env.MQTT_USER, password: process.env.MQTT_PASS };
const base = `testmqtt/mqtt5/${randomUUID().slice(0, 8)}`;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

const nextMessage = (client, timeoutMs = 5000) =>
  new Promise((resolve, reject) => {
    const timer = setTimeout(() => reject(new Error('timeout')), timeoutMs);
    client.once('message', (topic, payload, packet) => {
      clearTimeout(timer);
      resolve({ topic, payload, packet });
    });
  });

const sub = await mqtt.connectAsync(URL, opts);
const pub = await mqtt.connectAsync(URL, opts);

// 1. User properties, content type, correlation data
await sub.subscribeAsync(`${base}/props`, { qos: 1 });
let incoming = nextMessage(sub);
await pub.publishAsync(`${base}/props`, '{"temp":21.5}', {
  qos: 1,
  properties: {
    userProperties: { source: 'ci', 'trace-id': 'abc123' },
    contentType: 'application/json',
    responseTopic: `${base}/reply`,
    correlationData: Buffer.from('req-42'),
  },
});
const { packet } = await incoming;
assert.equal(packet.properties.userProperties.source, 'ci');
assert.equal(packet.properties.userProperties['trace-id'], 'abc123');
assert.equal(packet.properties.contentType, 'application/json');
assert.equal(packet.properties.correlationData.toString(), 'req-42');
console.log('PASS user properties / content type / correlation data');

// 2. Retained message expiry
await pub.publishAsync(`${base}/expiry`, 'short-lived', {
  qos: 1, retain: true, properties: { messageExpiryInterval: 3 },
});
await sleep(5000);
const late = await mqtt.connectAsync(URL, opts);
incoming = nextMessage(late, 3000);
await late.subscribeAsync(`${base}/expiry`, { qos: 1 });
await assert.rejects(incoming, /timeout/, 'expired retained message was delivered');
console.log('PASS retained message expired');

// 3. Topic alias: register alias 1, then publish with an empty topic
const aliasClient = mqtt.connect(URL, opts);
const connack = await new Promise((resolve, reject) => {
  aliasClient.once('connect', resolve);
  aliasClient.once('error', reject);
});
const aliasMax = connack.properties?.topicAliasMaximum ?? 0;
if (aliasMax === 0) {
  console.log('SKIP topic alias: broker advertises TopicAliasMaximum 0');
} else {
  const long = `${base}/alias/site-01/building-a/floor-3/sensor-temperature`;
  await sub.subscribeAsync(long, { qos: 1 });
  incoming = nextMessage(sub);
  await aliasClient.publishAsync(long, 'first', { qos: 1, properties: { topicAlias: 1 } });
  assert.equal((await incoming).topic, long);
  incoming = nextMessage(sub);
  await aliasClient.publishAsync('', 'second', { qos: 1, properties: { topicAlias: 1 } });
  assert.equal((await incoming).topic, long, 'broker did not resolve the alias');
  console.log(`PASS topic alias (broker allows ${aliasMax})`);
}

// 4. No local: a client must not receive its own publish
await pub.subscribeAsync(`${base}/nolocal`, { qos: 1, nl: true });
incoming = nextMessage(pub, 2000);
await pub.publishAsync(`${base}/nolocal`, 'echo?', { qos: 1 });
await assert.rejects(incoming, /timeout/, 'received own message despite no-local');
console.log('PASS no local');

await Promise.all([sub, pub, late, aliasClient].map((c) => c.endAsync()));

Starte es mit node mqtt5.test.mjs. Ein paar Details solltest du kennen:

  • User Properties sind in MQTT.js ein einfaches Objekt. Taucht ein Schlüssel auf dem Draht mehrfach auf, wird sein Wert zum Array — MQTT 5 erlaubt doppelte Schlüssel, und ihre Reihenfolge muss erhalten bleiben.
  • Beim Expiry-Test zählt die Reihenfolge. Registriere den Message-Listener vor dem Abonnieren, denn eine Retained Message kann ankommen, bevor subscribeAsync aufgelöst ist.
  • Topic Alias außerhalb des Bereichs. MQTT.js weigert sich, einen Alias über dem TopicAliasMaximum des Brokers zu senden, und wirft einen Fehler. Ein Broker, der trotzdem einen erhält, trennt mit 0x94 Topic Alias invalid. Mehr dazu im Topic-Alias-Test.
  • No Local ist eine Subscription Option und wird pro Abo gesetzt. Die anderen Optionen (Retain as Published, Retain Handling) erklärt MQTT Subscription Options.

paho-mqtt: Session Expiry und Ablauf gequeueter Nachrichten

Der wertvollste MQTT-5-Test ist zugleich der, den viele auslassen: Was passiert mit Nachrichten, während ein Gerät offline ist? Das Script legt eine persistente Session an, trennt die Verbindung, publiziert zwei Nachrichten (eine mit 60 s Expiry, eine mit 2 s), wartet und verbindet sich neu.

session_expiry_test.py
import time, uuid
import paho.mqtt.client as mqtt
from paho.mqtt.packettypes import PacketTypes
from paho.mqtt.properties import Properties

HOST, PORT = "broker.connectmq.com", 1883
TOPIC = f"testmqtt/mqtt5/{uuid.uuid4().hex[:8]}/queued"
CLIENT_ID = f"mqtt5-session-{uuid.uuid4().hex[:8]}"
received = []

def on_connect(client, userdata, flags, reason_code, properties):
    print("connected:", reason_code, "| session present:", flags.session_present)

def on_message(client, userdata, msg):
    expiry = getattr(msg.properties, "MessageExpiryInterval", None)
    user = getattr(msg.properties, "UserProperty", None)
    print(f"got {msg.payload.decode()!r}  expiry left: {expiry}  user properties: {user}")
    received.append(msg.payload.decode())

def session_client():
    c = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=CLIENT_ID,
                    protocol=mqtt.MQTTv5)
    c.on_connect, c.on_message = on_connect, on_message
    props = Properties(PacketTypes.CONNECT)
    props.SessionExpiryInterval = 120   # keep the session 2 minutes after disconnect
    c.connect(HOST, PORT, clean_start=False, properties=props)
    c.loop_start()
    time.sleep(1)
    return c

# 1. Create the session and subscription, then go offline
s = session_client()
s.subscribe(TOPIC, qos=1)
time.sleep(1)
s.disconnect(); s.loop_stop()

# 2. Publish while the subscriber is offline
pub = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, protocol=mqtt.MQTTv5)
pub.connect(HOST, PORT); pub.loop_start()
for i, expiry in enumerate((60, 2)):
    props = Properties(PacketTypes.PUBLISH)
    props.MessageExpiryInterval = expiry
    props.UserProperty = ("test", f"msg-{i}")
    pub.publish(TOPIC, f"msg-{i}", qos=1, properties=props).wait_for_publish()
pub.disconnect(); pub.loop_stop()

time.sleep(5)  # longer than the 2 s expiry

# 3. Reconnect: msg-0 arrives with less than 60 s left, msg-1 has expired
s = session_client()
time.sleep(3)
s.disconnect(); s.loop_stop()
assert received == ["msg-0"], received
print("PASS session kept, unexpired message delivered, expired message dropped")

Erwartete Ausgabe: Die zweite Verbindung meldet session present: True, und msg-0 kommt mit einem Expiry von etwa 54–55 Sekunden an. Der Broker muss die verbleibende Lebensdauer weitergeben, nicht den ursprünglichen Wert — siehst du 60, zählt der Broker nicht herunter, und nachgelagerte Konsumenten können nicht erkennen, wie alt die Nachricht ist. Ist session present False, hat der Broker die Session nicht behalten: Prüfe seine maximale Session Expiry und ob sich die Client ID geändert hat. Der Session-Expiry-Test geht tiefer auf Sonderfälle wie Expiry beim Disconnect ein.

Request/Response mit Response Topic und Correlation Data

MQTT 5 standardisiert Request/Response: Der Anfragende setzt ein Response Topic und Correlation Data; der Antwortende publiziert seine Antwort auf dieses Topic und kopiert die Correlation Data zurück. Test 1 oben prüft bereits, ob der Broker beide Properties weiterleitet. Um einen echten Responder zu testen, abonnierst du das Response Topic, sendest eine Anfrage und prüfst, ob die Antwort innerhalb deines Timeouts dieselbe Correlation Data enthält. Muster und vollständiges Beispiel findest du im MQTT-Request/Response-Test.

Gemischte Flotten mit MQTT 3.1.1 und MQTT 5 testen

Die meisten Flotten bleiben jahrelang gemischt. Ergänze einen weiteren Lauf des Property-Tests, bei dem sich der Subscriber mit 3.1.1 verbindet (protocolVersion: 4 in MQTT.js, protocol=mqtt.MQTTv311 in paho):

  • Die Nachricht muss trotzdem ankommen.
  • packet.properties fehlt: 3.1.1-Clients sehen nie User Properties, Content Type oder Correlation Data.
  • Message Expiry funktioniert weiterhin, weil der Broker sie durchsetzt.

Verlässt sich dein Backend für Routing oder Tracing auf User Properties, zeigt dieser Lauf, welche Geräte auf den Payload ausweichen müssen. Die vollständige Liste der Unterschiede steht in MQTT 5 vs. 3.1.1.

MQTT-5-Tests im Browser beobachten

MQTT-5-Features sind nur so zuverlässig wie die schwächste Implementierung zwischen Publisher, Broker und Subscriber. Ein paar hundert Zeilen Tests — Properties hin und zurück, Expiry, Aliases, Sessions — decken Unterschiede früh auf. Behalte sie in der CI und führe sie nach jedem Broker- oder Bibliotheks-Upgrade erneut aus.

Häufig gestellte Fragen

Funktionieren MQTT-5-Properties, wenn der Subscriber MQTT 3.1.1 nutzt?

Die Nachricht wird zugestellt, aber ein 3.1.1-Subscriber kann keine Properties empfangen: User Properties, Content Type, Response Topic und Correlation Data fallen für diese Verbindung weg. Message Expiry gilt auf Broker-Seite trotzdem.

Warum läuft meine Retained Message nicht ab?

Prüfe, ob du mit MQTT 5 publiziert hast (auf 3.1.1-Verbindungen werden Properties ignoriert), ob das Expiry Interval am PUBLISH und nicht am CONNECT gesetzt ist und ob dein Broker Expiry für Retained Messages implementiert. Teste mit einem kurzen Intervall und einem frischen Subscriber.

Muss ich Topic Aliases selbst verwalten?

Nicht unbedingt. MQTT.js kann Aliase automatisch vergeben und wiederverwenden (autoAssignTopicAlias, autoUseTopicAlias), und viele Bibliotheken lösen eingehende Aliase selbst auf. Verwalte sie nur dann manuell, wenn du die Bandbreite exakt steuern musst.