ГлавнаяАкадемияNode-RED: установка, flows, msg/JSON, отладка → Практика: Создание «контракта сообщения» для датчика

Практика: Создание «контракта сообщения» для датчика

Урок 3 · Node-RED: установка, flows, msg/JSON, отладка · 30 мин · theory

Что такое «контракт сообщения» и зачем он нужен?

В экосистеме Node-RED объект `msg` является универсальной единицей информации, кровью, циркулирующей по артериям и венам ваших потоков автоматизации. Однако без строгих правил эта циркуляция быстро превращается в хаос. Именно здесь на сцену выходит концепция «контракта сообщения» (Message Contract).

> 💡 Подсказка: Думайте о каждом `msg`, передаваемом между узлами, как о внутреннем API-вызове. Он должен быть таким же ясным, документированным и предсказуемым.

Контракт сообщения — это формальное, предопределенное соглашение о структуре и содержании объекта `msg`, особенно его ключевых свойств, таких как `msg.payload` и `msg.topic`. Это как техническое задание для данных: оно точно описывает, какие поля должны присутствовать, какого они типа и что означают.

Представьте себе взаимодействие двух программных модулей. Если один модуль ожидает получить на вход число, а другой передает ему строку "двадцать три", система даст сбой. В веб-разработке эту проблему решают с помощью API-контрактов (например, OpenAPI/Swagger), которые жестко документируют форматы запросов и ответов. В Node-RED мы применяем тот же принцип к объектам `msg`.

Ключевые преимущества внедрения контрактов

  • Повышение читаемости и предсказуемости. Когда вы смотрите на поток, вы точно знаете, какие данные приходят в узел и какие выходят из него. Вам не нужно запускать отладчик для каждого узла, чтобы понять, в каком формате сейчас находится `msg.payload`.
  • Упрощение отладки. 90% ошибок в сложных потоках связаны с несоответствием форматов данных. Если узел `A` отправляет `msg.payload` в виде строки `"true"`, а узел `B` ожидает булево значение `true`, логика сломается. Контракт и валидация на входе в узел `B` немедленно выявят эту проблему.
  • Масштабируемость и поддерживаемость. Когда ваш проект вырастает из нескольких узлов в десятки потоков, управлять хаотичными структурами `msg` становится невозможно. Единый контракт позволяет легко добавлять новые устройства и логику, поскольку правила игры уже определены.
  • Облегчение командной работы. Если над проектом работает несколько инженеров, контракт становится их общим языком. Каждый знает, как подготовить данные для модуля коллеги и в каком виде он получит результат.
  • Последствия отсутствия контракта

    Без формализованного соглашения ваши потоки быстро превращаются в "спагетти-код". Данные передаются в сыром, необработанном виде (`msg.payload = 25.5`), затем в другом узле преобразуются в объект (`{ "temp": 25.5 }`), а в третьем — снова в строку (`"Current temperature is 25.5 C"`). Это приводит к:

    | Аспект | Без контракта | С контрактом |

    | ------------------ | ---------------------------------------------- | ---------------------------------------------------------- |

    | `msg.payload` | Простое значение: `1023`, `"ON"`, `true` | Структурированный JSON: `{ "value": true, "source": "sw-01" }` |

    | Читаемость | Низкая. Нужно смотреть в отладчик каждый шаг. | Высокая. Структура `msg` понятна из документации или кода. |

    | Отладка | Сложная. Ошибка формата может "всплыть" далеко от источника. | Простая. Валидация на входе узла сразу выявляет проблему. |

    | Масштабирование | Практически невозможно. Каждый новый узел — это новый хаос. | Легкое. Новая логика работает с уже стандартизированными данными. |

    Внедрение контрактов сообщений — это инвестиция в будущее вашего проекта, которая многократно окупается за счет экономии времени на отладку и поддержку.

    ---

    Проектирование контракта: от сырых данных к структурированному объекту

    Теория важна, но давайте перейдем к практике. Процесс создания контракта — это инженерная задача, которая начинается с анализа исходных данных и заканчивается формированием четкой структуры.

    > 🔗 Связанный материал: Мы подробно разбирали структуру `msg.payload` и `msg.topic` в предыдущих уроках. Рекомендуем освежить знания перед продолжением.

    Рассмотрим реальный пример: у нас есть датчик температуры, подключенный к контроллеру по шине Modbus. При опросе соответствующего регистра мы получаем `msg` от узла `node-red-contrib-modbus`, где полезная нагрузка выглядит так:

    {
    

    "data": [246],

    "buffer": {

    "type": "Buffer",

    "data": [0, 246]

    }

    }

    Датчик, согласно его документации, отдает температуру в виде целого числа, умноженного на 10. То есть `246` означает `24.6 °C`. Что мы можем сделать с этим значением? Просто передать его дальше? Это плохая практика. Давайте спроектируем полноценный контракт.

    1. Анализ и определение обязательных полей

    Сырое значение `246` не несет в себе никакой информации, кроме самой величины. Чтобы сделать эти данные по-настоящему полезными, нам нужно ответить на несколько вопросов:

    На основе этих вопросов мы формируем список полей нашего будущего контракта:

    📋 Ключевые понятия: обязательные поля контракта

    2. Проектирование структуры `msg.payload`

    Теперь упакуем эти поля в удобный и самодокументируемый JSON-объект, который будет жить в `msg.payload`.

    {
    

    "source_id": "modbus-sensor-sn-12345",

    "location": "living_room",

    "type": "temperature",

    "value": 24.6,

    "unit": "°C",

    "timestamp": 1678886400000

    }

    Такая структура обладает огромными преимуществами:

    3. Проектирование `msg.topic` для маршрутизации

    Как мы уже знаем из предыдущих уроков, `msg.topic` — это ключ к эффективной маршрутизации. Он должен иметь иерархическую структуру, отражающую суть данных. Для нашего примера идеальный `topic` будет выглядеть так:

    `telemetry/living_room/temperature`

    Такая структура позволяет гибко фильтровать сообщения:

    Итак, мы прошли путь от непонятного `[246]` до полностью структурированного, понятного и готового к использованию сообщения. На следующем шаге мы реализуем это преобразование в коде.

    ---

    Реализация контракта в узле function: пишем код

    Узел `function` — это швейцарский нож инженера автоматизации в Node-RED. Он позволяет с помощью JavaScript выполнять любые преобразования данных. Именно он является идеальным инструментом для реализации нашего контракта сообщения.

    > ⚠️ Внимание: Всегда предусматривайте обработку ошибок внутри узла `function`. Что произойдет, если сенсор вернет некорректные данные? Ваш код должен уметь обрабатывать `null`, `undefined` или неверный формат, чтобы избежать остановки всего потока.

    Наша задача — создать узел `function`, который будет стоять сразу после узла `Modbus-Getter`. На вход он будет получать сырой `msg`, а на выходе отдавать `msg`, полностью соответствующий спроектированному контракту.

    1. Подготовка потока

    Создадим простой поток для демонстрации:

    `[Modbus-Getter]` -> `[Function: "Apply Contract"]` -> `[Debug]`

    2. Написание JavaScript-кода

    Откроем узел "Apply Contract" и вставим следующий код. Комментарии подробно объясняют каждый шаг.

    // --- Константы и конфигурация ---
    

    // В реальном проекте эти значения лучше выносить в переменные окружения потока,

    // чтобы легко переиспользовать код для разных датчиков.

    const SENSOR_ID = "modbus-sensor-sn-12345";

    const SENSOR_LOCATION = "living_room";

    const MEASUREMENT_TYPE = "temperature";

    const MEASUREMENT_UNIT = "°C";

    // --- Входные данные ---

    // Узел modbus-getter возвращает массив в msg.payload.data

    const rawData = msg.payload.data;

    // --- 1. Валидация входных данных ---

    // Проверяем, что данные вообще пришли и имеют ожидаемую структуру.

    if (!Array.isArray(rawData) || rawData.length === 0) {

    // Если данные некорректны, генерируем ошибку.

    // Узел Catch сможет ее перехватить и записать в лог.

    node.error("Invalid or empty data from Modbus sensor", msg);

    // Обновляем статус узла для визуальной диагностики.

    node.status({fill:"red", shape:"dot", text:"Invalid data"});

    return null; // Важно! Прерываем выполнение и не передаем msg дальше.

    }

    // Извлекаем значение.

    const rawValue = rawData[0];

    if (typeof rawValue !== 'number') {

    node.error("Raw value is not a number: " + rawValue, msg);

    node.status({fill:"red", shape:"dot", text:"Wrong value type"});

    return null;

    }

    // --- 2. Преобразование и обогащение данных ---

    // Применяем коэффициент, согласно документации на датчик.

    const finalValue = rawValue / 10.0;

    // Дополнительная валидация: проверяем, что значение находится в разумных пределах.

    // Это защитит от ошибок датчика (например, если он вернет -9999).

    if (finalValue < -40 || finalValue > 85) {

    node.error("Temperature value out of range: " + finalValue, msg);

    node.status({fill:"red", shape:"dot", text:"Value out of range"});

    return null;

    }

    // --- 3. Формирование нового объекта msg по контракту ---

    // 3.1. Формируем msg.payload

    msg.payload = {

    source_id: SENSOR_ID,

    location: SENSOR_LOCATION,

    type: MEASUREMENT_TYPE,

    value: finalValue,

    unit: MEASUREMENT_UNIT,

    timestamp: Date.now() // Добавляем текущую временную метку

    };

    // 3.2. Формируем msg.topic

    msg.topic = `telemetry/${SENSOR_LOCATION}/${MEASUREMENT_TYPE}`;

    // 3.3. (Опционально) Добавляем метаданные

    // Это может быть полезно для отладки и аудита.

    msg.meta = {

    original_payload: rawData,

    processing_node: "Apply Contract Function"

    };

    // --- Визуальный статус ---

    // Обновляем статус узла, чтобы в редакторе было видно последнее успешное значение.

    node.status({fill:"green", shape:"dot", text: `${finalValue} ${MEASUREMENT_UNIT}`});

    // --- Возвращаем преобразованный msg ---

    // Теперь этот msg отправится на следующий узел в потоке.

    return msg;

    После выполнения этого кода `msg`, который выйдет из узла `function`, будет выглядеть так:

    `msg.topic`: `telemetry/living_room/temperature` `msg.payload`:
    {
    

    "source_id": "modbus-sensor-sn-12345",

    "location": "living_room",

    "type": "temperature",

    "value": 24.6,

    "unit": "°C",

    "timestamp": 1678886400000

    }

    Мы не просто преобразовали значение, а создали полноценный информационный объект, который легко обрабатывать, хранить и анализировать.

    ---

    Пример: контракт для мультисенсора (температура и влажность)

    Часто одно физическое устройство измеряет сразу несколько параметров. Классический пример — датчик DHT22 или SHT31, который отдает и температуру, и влажность. Как адаптировать наш контракт для такого случая? Распространенная ошибка — создавать два отдельных потока и отправлять два разных `msg`. Гораздо более элегантное и правильное решение — использовать вложенную структуру в `msg.payload`.

    Предположим, наш новый датчик (назовем его `multi-sensor-01`) отдает нам объект вида `{ "temp": 23.5, "hum": 45.2 }`.

    1. Адаптация контракта для множественных значений

    Вместо одного поля `value`, мы создадим объект `values`, где каждый ключ — это тип измерения.

    {
    

    "source_id": "multi-sensor-01",

    "location": "bedroom",

    "values": {

    "temperature": 23.5,

    "humidity": 45.2

    },

    "units": {

    "temperature": "°C",

    "humidity": "%"

    },

    "timestamp": 1678887000000

    }

    Такая структура имеет несколько преимуществ:

    `msg.topic` в данном случае может остаться общим: `telemetry/bedroom/environment`, так как он описывает источник и местоположение, а конкретные типы данных уже содержатся внутри `payload`.

    2. Реализация в Node-RED

    Код в узле `function` будет похож на предыдущий, но с адаптацией под новую структуру.

    // Входящий msg.payload от гипотетического узла: { "temp": 23.5, "hum": 45.2 }
    

    const rawPayload = msg.payload;

    // Валидация

    if (typeof rawPayload.temp !== 'number' || typeof rawPayload.hum !== 'number') {

    node.error("Invalid data from multi-sensor", msg);

    return null;

    }

    // Формирование нового payload по контракту

    msg.payload = {

    source_id: "multi-sensor-01",

    location: "bedroom",

    values: {

    temperature: rawPayload.temp,

    humidity: rawPayload.hum

    },

    units: {

    temperature: "°C",

    humidity: "%"

    },

    timestamp: Date.now()

    };

    // Формирование topic

    msg.topic = "telemetry/bedroom/environment";

    node.status({ fill: "green", shape: "dot", text: `T: ${rawPayload.temp}°C, H: ${rawPayload.hum}%` });

    return msg;

    3. Разделение логики с помощью узла `switch`

    Теперь, когда у нас есть одно сообщение с несколькими значениями, как нам обработать их по-разному? Например, мы хотим отправлять температуру в одну базу данных, а влажность — в другую, или включать увлажнитель, если влажность упала ниже 40%.

    Здесь нам поможет узел `switch`, но настроенный не на `msg.topic`, а на проверку наличия нужных данных в `msg.payload`. Однако это не самый эффективный путь. Более изящное решение — создать "разветвитель" (demultiplexer), который из одного сложного сообщения сделает несколько простых, но уже стандартизированных.

    Поток может выглядеть так:

    `[Multi-Sensor]` -> `[Apply Multi-Contract]` -> `[Function: "Demultiplexer"]` -> `[... дальнейшая логика]`

    Код для узла `"Demultiplexer"`:

    // Этот узел получает на вход msg, соответствующий контракту мультисенсора.
    

    // Он имеет два выхода.

    const basePayload = msg.payload;

    // Создаем сообщение для температуры

    const tempMsg = {

    topic: `telemetry/${basePayload.location}/temperature`,

    payload: {

    source_id: basePayload.source_id,

    location: basePayload.location,

    type: "temperature",

    value: basePayload.values.temperature,

    unit: basePayload.units.temperature,

    timestamp: basePayload.timestamp

    }

    };

    // Создаем сообщение для влажности

    const humMsg = {

    topic: `telemetry/${basePayload.location}/humidity`,

    payload: {

    source_id: basePayload.source_id,

    location: basePayload.location,

    type: "humidity",

    value: basePayload.values.humidity,

    unit: basePayload.units.humidity,

    timestamp: basePayload.timestamp

    }

    };

    // Возвращаем массив сообщений. Каждое отправится на свой выход.

    // tempMsg -> на выход 1

    // humMsg -> на выход 2

    return [ tempMsg, humMsg ];

    Этот паттерн позволяет сохранить атомарность передачи данных от сенсора, но затем работать с каждым параметром индивидуально, используя уже универсальные, ранее разработанные потоки.

    ---

    Итоги и лучшие практики

    Сегодня мы сделали важнейший шаг от простого "соединения узлов" к осознанному проектированию надежных и масштабируемых систем автоматизации. Внедрение контрактов сообщений — это не усложнение, а профессиональная необходимость.

    Краткие итоги: Лучшие практики, которые стоит взять на вооружение:
  • Документируйте свои контракты. Лучшее место для этого — узел `comment`, размещенный рядом с узлом `function`, который этот контракт создает. Опишите в нем структуру `payload` и `topic`.
  •     // Message Contract for Temperature Sensors

    // topic: telemetry/{location}/{type}

    // payload: {

    // "source_id": "string",

    // "location": "string",

    // "type": "string" ("temperature"),

    // "value": number,

    // "unit": "string" ("°C"),

    // "timestamp": number (epoch ms)

    // }

  • Придерживайтесь единого стандарта во всем проекте. Все датчики температуры должны использовать один и тот же контракт. Все реле — другой, но тоже единый. Это создаст унифицированную среду.
  • Создайте "библиотеку" типовых `function`. Если у вас много однотипных устройств, создайте эталонный узел `function` для преобразования их данных. Затем просто копируйте его, при необходимости меняя лишь ID и `location` (в идеале, вынеся их в переменные окружения потока).
  • Валидируйте, валидируйте и еще раз валидируйте. Не доверяйте входящим данным. Никогда. Проверка на `null`, `undefined`, тип и диапазон значений должна стать вашей привычкой.
  • Что дальше?

    В следующем уроке мы увидим, как стандартизированные объекты `msg` радикально упрощают интеграцию с внешними системами. Когда `msg.payload` имеет предсказуемую JSON-структуру, его становится тривиально просто записывать в базы данных (например, MySQL или InfluxDB), отправлять в системы мониторинга (Grafana) или передавать другим сервисам через MQTT. Контракт сообщения — это фундамент, на котором строятся все сложные и интересные интеграции.