Embedded

MQTT: концепция протокола, брокер, топики, QoS

Введение

Прямое HTTP-взаимодействие из предыдущей статьи требует, чтобы клиент (Qt-приложение) точно знал IP-адрес каждого устройства и инициировал запрос сам — что плохо масштабируется на множество устройств и не позволяет устройству самому, по своей инициативе, сообщить об изменении данных. MQTT — легковесный протокол публикации/подписки (publish/subscribe), спроектированный именно для IoT-сценариев с множеством устройств, передающих небольшие сообщения через центральный посредник — брокер. В статье разберём ключевые концепции протокола перед переходом к практической реализации.

Концепция

В архитектуре MQTT все участники (как устройства, так и приложения) подключаются к центральному брокеру (например, Mosquitto, статья 312), а не друг к другу напрямую. Издатели (publishers) отправляют сообщения в именованные «топики» (topics, иерархические строки вида home/livingroom/temperature), а подписчики (subscribers) указывают брокеру, на какие топики (включая шаблоны с подстановочными символами + и #) они хотят получать сообщения — брокер сам заботится о доставке каждого опубликованного сообщения всем текущим подписчикам соответствующего топика, и издателю не нужно знать, кто и сколько подписчиков получит сообщение. QoS (Quality of Service) — уровень гарантии доставки сообщения: 0 (без подтверждения, «выстрелил и забыл»), 1 (гарантированная доставка, но возможны дубликаты), 2 (гарантированная доставка ровно один раз, но с большими накладными расходами).

Пример кода: MQTT

# Иерархическая структура топиков — типичное соглашение для IoT-проекта

home/livingroom/temperature       — публикуется датчиком температуры гостиной
home/livingroom/humidity          — публикуется тем же или другим датчиком
home/kitchen/temperature          — аналогичный датчик в другой комнате
home/livingroom/light/set         — команда УПРАВЛЕНИЯ светом (подписывается устройство-исполнитель)
home/livingroom/light/state       — текущее СОСТОЯНИЕ света (публикуется устройством для всех интересующихся)

# Подстановочные символы при подписке:
home/+/temperature    — все температурные топики из ЛЮБОЙ одной комнаты (+ заменяет один уровень)
home/#                 — АБСОЛЮТНО все топики, начинающиеся с home/ (# заменяет любое количество уровней)
# Сравнение уровней QoS — практическое значение для типичных IoT-сценариев

QoS 0 (At most once)  — подходит для часто обновляемых, некритичных данных
                          (показания температуры каждую секунду — пропуск одного
                          сообщения не страшен, следующее придёт уже через секунду)

QoS 1 (At least once) — подходит для важных, но не катастрофичных команд
                          (команда "включить свет" — лучше получить дважды,
                          чем не получить вовсе)

QoS 2 (Exactly once)   — подходит для критичных, неидемпотентных операций
                          (например, "снять средства со счёта" — дублирование
                          было бы серьёзной ошибкой; для большинства простых
                          IoT-сценариев такая гарантия избыточна)

Пояснения к коду

Иерархическая структура топиков, организованная по аналогии с путями файловой системы (home/livingroom/temperature), — общепринятое, хотя не строго обязательное протоколом соглашение, делающее систему топиков масштабируемой и интуитивно понятной при добавлении новых комнат или типов датчиков. Разделение light/set (команда) и light/state (фактическое состояние) — устоявшийся паттерн MQTT-проектирования, явно разделяющий «что должно произойти» от «что фактически произошло», что важно, поскольку устройство может не успеть выполнить команду немедленно, или команда может не дойти вовсе.

Подстановочные символы +/# дают мощный способ подписаться сразу на целую категорию топиков без необходимости в отдельной подписке на каждый конкретный — Qt-приложение, отображающее общий дашборд всех комнат, может подписаться на home/+/temperature одной подпиской вместо отдельной подписки на каждую известную комнату по отдельности, и автоматически начнёт получать данные даже от новой комнаты, добавленной в систему позже, без изменения кода подписки.

Подводные камни

  • Выбор QoS 2 «на всякий случай» для всех сообщений без реальной необходимости — более высокий QoS требует дополнительного обмена подтверждениями между клиентом и брокером, что увеличивает задержку и нагрузку на сеть; для типичных IoT-данных (показания датчиков, обновляемые часто) QoS 0 или 1 почти всегда достаточен, и выбор QoS должен соответствовать реальной критичности конкретного типа сообщения, а не быть выбран максимальным «для надёжности» без необходимости.
  • Непонимание разницы между «сообщение опубликовано» и «сообщение гарантированно доставлено получателю» — даже QoS 1/2 гарантируют доставку только до брокера и от брокера до подписчика, находящегося на связи в момент доставки; если подписчик был отключён в момент публикации сообщения (если не настроена retained-доставка или persistent-подписка отдельно), он может никогда не узнать об этом конкретном сообщении, даже при максимальном QoS.
  • Слишком «плоская» или, наоборот, излишне глубоко вложенная структура именования топиков, спроектированная без продумывания наперёд — изменение структуры топиков после того, как несколько устройств уже развёрнуты и используют старые имена, требует синхронного обновления прошивок всех устройств и кода приложения; продуманное, последовательное соглашение об именовании топиков с самого начала проекта (как иерархия home/комната/тип_данных в примере) экономит значительные усилия на рефакторинг в будущем.
  • Путаница между «топиком» как простым каналом сообщений и ожиданием от MQTT гарантий, свойственных полноценным очередям сообщений с персистентным хранением (как в более тяжеловесных системах вроде RabbitMQ/Kafka) — базовый MQTT-брокер обычно не хранит долгую историю сообщений автоматически (за исключением последнего retained-сообщения на топик при соответствующей настройке), и для проектов, которым нужна полная история всех сообщений за длительный период, нужна отдельная система хранения (например, запись каждого полученного сообщения в базу данных на стороне Qt-приложения, как и делалось в цикле про базы данных), а не ожидание, что сам MQTT-брокер выполнит эту функцию.