Pruebas de brokers

Probar funciones de MQTT 5: user properties, expiry y topic aliases

Tests prácticos de funciones de MQTT 5 con MQTT.js y paho-mqtt: user properties, expiración de mensajes y sesiones, topic aliases, request/response y no-local.

Actualizado · 10 min de lectura

Probar funciones de MQTT 5: user properties, expiry y topic aliases

MQTT 5 añadió funciones que cambian el comportamiento de las aplicaciones: metadatos en user properties, mensajes que expiran, paquetes más cortos gracias a los topic aliases, sesiones con tiempo de vida. Cada una depende de que tanto la librería cliente como el broker la implementen bien, así que prueba las funciones de MQTT 5 de extremo a extremo antes de depender de ellas.

Este artículo te da tests pequeños y ejecutables con MQTT.js 5 (Node.js) y paho-mqtt 2.x (Python). Cada test publica, observa lo que llega y hace una aserción, de modo que puede ejecutarse en CI igual que en tu portátil.

Qué probar y qué significa «pasa»

FunciónTestCondición de éxito
User propertiesPublicar con pares clave/valorEl suscriptor recibe los mismos pares
Content type, correlation dataPublicar con ambos definidosEl suscriptor recibe ambos sin cambios
Message expiry (retenido)Mensaje retenido con expiry de 3 s, suscribirse a los 5 sNo se entrega nada
Message expiry (en cola)Encolar mensajes para una sesión offlineEl mensaje no expirado llega con un intervalo reducido; el expirado se descarta
Session expiryReconectar con clean_start=Falsesession present es true y llegan los mensajes en cola
Topic aliasesRegistrar un alias y publicar con topic vacíoEl suscriptor ve el nombre completo del topic
No localSuscribirse y publicar en la misma conexión con nlEl cliente no recibe su propio mensaje

Primero, confirma que el broker acepta conexiones MQTT 5 y revisa sus límites en el CONNACK (topic alias maximum, maximum QoS) con la sonda de funciones del broker. Un test que falla porque el broker anuncia TopicAliasMaximum 0 es un hallazgo de configuración, no un bug.

Preparación

Instalar ambas librerías
npm install mqtt@5
pip install "paho-mqtt>=2.0"

Todos los ejemplos usan broker.connectmq.com:1883 y un prefijo de topic aleatorio bajo testmqtt/mqtt5/ para que ejecuciones paralelas no vean los mensajes de las demás. Añade username y password ($MQTT_USER / $MQTT_PASS) si tu broker los exige.

MQTT.js: propiedades, expiry, alias y no-local

El script siguiente ejecuta cuatro tests en secuencia y termina con código distinto de cero al primer fallo. nextMessage espera un mensaje o agota el tiempo, lo que hace que «no debe llegar nada» sea tan fácil de comprobar como «esto debe llegar».

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()));

Ejecútalo con node mqtt5.test.mjs. Conviene conocer algunos detalles:

  • Las user properties en MQTT.js son un objeto plano. Si una clave aparece varias veces en el cable, su valor se convierte en un array: MQTT 5 permite claves duplicadas y su orden debe conservarse.
  • El orden importa en el test de expiry. Registra el listener de mensajes antes de suscribirte, porque un mensaje retenido puede llegar antes de que subscribeAsync se resuelva.
  • Topic alias fuera de rango. MQTT.js se niega a enviar un alias mayor que el TopicAliasMaximum del broker y emite un error. Un broker que lo recibe igualmente desconecta con 0x94 Topic Alias invalid. Más en el test de topic alias.
  • No local es una opción de suscripción, definida por suscripción. Las demás opciones (retain as published, retain handling) se explican en opciones de suscripción MQTT.

paho-mqtt: session expiry y expiración de mensajes en cola

El test de MQTT 5 más valioso es también el que más se omite: qué pasa con los mensajes mientras un dispositivo está offline. El script crea una sesión persistente, se desconecta, publica dos mensajes (uno con expiry de 60 s y otro de 2 s), espera y se reconecta.

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")

Salida esperada: la segunda conexión imprime session present: True, y msg-0 llega con un expiry de unos 54–55 segundos. El broker debe reenviar el tiempo de vida restante, no el valor original: si ves 60, el broker no lo está decrementando y los consumidores no pueden saber cuán antiguo es el mensaje. Si session present es False, el broker no conservó la sesión: revisa su session expiry máximo y si el client ID cambió. El test de session expiry profundiza en casos límite como el expiry al desconectar.

Request/response con response topic y correlation data

MQTT 5 estandariza request/response: quien pide define un response topic y correlation data; quien responde publica la respuesta en ese topic y copia la correlation data. El test 1 de arriba ya comprueba que el broker reenvía ambas propiedades. Para probar un responder real, suscríbete al response topic, envía una petición y comprueba que la respuesta trae la misma correlation data dentro de tu timeout. El patrón y un ejemplo completo están en el test de request/response MQTT.

Probar flotas mixtas MQTT 3.1.1 y MQTT 5

La mayoría de flotas son mixtas durante años. Añade una ejecución más del test de propiedades con el suscriptor conectado como 3.1.1 (protocolVersion: 4 en MQTT.js, protocol=mqtt.MQTTv311 en paho):

  • El mensaje debe seguir llegando.
  • packet.properties no existe: los clientes 3.1.1 nunca ven user properties, content type ni correlation data.
  • El message expiry sigue funcionando, porque lo aplica el broker.

Si tu backend depende de user properties para enrutar o trazar, esta ejecución muestra qué dispositivos tendrán que recurrir al payload. Consulta MQTT 5 vs 3.1.1 para ver la lista completa de diferencias.

Mira los tests de MQTT 5 en el navegador

Las funciones de MQTT 5 son tan fiables como la implementación más débil entre tu publisher, el broker y tu suscriptor. Unos cientos de líneas de tests (ida y vuelta de propiedades, expiry, alias, sesiones) detectan las diferencias pronto. Mantenlos en CI y vuelve a ejecutarlos tras cada actualización del broker o de la librería.

Preguntas frecuentes

¿Funcionan las propiedades de MQTT 5 si el suscriptor usa MQTT 3.1.1?

El mensaje se entrega, pero un suscriptor 3.1.1 no puede recibir propiedades: user properties, content type, response topic y correlation data se descartan para esa conexión. El message expiry sigue aplicándose en el broker.

¿Por qué no expira mi mensaje retenido?

Comprueba que publicaste con MQTT 5 (en conexiones 3.1.1 las propiedades se ignoran), que el expiry interval está en el PUBLISH y no en el CONNECT, y que tu broker implementa la expiración de mensajes retenidos. Prueba con un intervalo corto y un suscriptor nuevo.

¿Tengo que gestionar los topic aliases yo mismo?

No necesariamente. MQTT.js puede asignar y reutilizar alias automáticamente (autoAssignTopicAlias, autoUseTopicAlias) y muchas librerías resuelven los alias entrantes por ti. Gestiónalos a mano solo cuando necesites un control exacto del ancho de banda.