Практика: разбор пакетов приборов учёта (Meter telemetry)
Проверка кадра одна для всех приборов, а парсер полезной нагрузки выбирает тип прибора из заголовка: это параметризованный фабричный метод, и новый тип прибора добавляется наследником, без правки конвейера.
Базовый пример: Фабричный метод. Там продукт выбирает класс наследника, а вариант известен заранее. Здесь вариант приходит в данных, в первом байте пакета, и фабричный метод получает его параметром.
Задача
Шлюз принимает пакеты от приборов учёта: электросчётчиков, водосчётчиков, датчиков температуры. Сетевой сервер отдаёт их пачкой в вебхуке, каждый пакет - hex-строка. Формат кадра у всех один: тип прибора, длина полезной нагрузки, нагрузка, контрольная сумма. А вот нагрузка у каждого своя: у электросчётчика накопленная энергия и мощность, у водосчётчика объём в литрах, у датчика температура со знаком в десятых долях градуса.
Первая версия разбирала всё одной функцией со switch по типу, и проверку контрольной суммы в ней делали только для электросчётчиков: для воды "забыли", и битые пакеты с эфира давали скачки расхода на графике. Новые приборы выходят с каждой прошивкой, и каждый раз правилась одна и та же функция.
Что урезано
- Кадр - четыре поля без серийного номера, времени, версии протокола и шифрования. Настоящие протоколы учёта сложнее, но разделение то же: кадр отдельно, нагрузка отдельно.
- Контрольная сумма - сумма байтов по модулю 256, а не CRC.
- Показания не пишутся в базу, а "очередь" для неизвестных типов - строка в выводе примера.
- Один прибор - один пакет, без пачек показаний и архивов за период.
Решение
Конвейер живёт в создателе PacketDecoder::decode: длина не меньше трёх байтов, объявленная длина нагрузки совпадает с фактической, контрольная сумма сходится. После этого конвейер берёт тип из первого байта и зовёт фабричный метод createParser(deviceType). Полученный продукт сообщает свой size(), и конвейер проверяет, что нагрузка ровно такой длины. Только потом продукт разбирает байты в список Reading.
Продукт - PayloadParser: ElectricityParser, WaterParser, TemperatureParser. Он знает раскладку байтов своего прибора и единицы измерения и ничего не знает о кадре.
Конкретный создатель MeterDecoder реализует фабричный метод через match по типу. Неизвестный тип - исключение UnknownDeviceType, отдельный подкласс BadPacket: вызывающий код отличает "пакет битый" от "прибор нам пока незнаком". Прошивка с новым типом прибора - наследник MeterDecoder, который знает новый тип и отдаёт остальные родителю.
Структура
Правила разделены по уровням, как в базовом примере: целостность кадра и соответствие длины - забота создателя, и она одна на все приборы; раскладка байтов и единицы - забота продукта.
Формат пакета
01 06 00BC614E 05DC 53
| | | | +-- контрольная сумма: сумма предыдущих байтов & 0xFF
| | | +-- мощность, uint16: 1500 W
| | +-- энергия, uint32: 12345678 Wh = 12345.678 kWh
| +-- длина нагрузки: 6 байтов
+-- тип прибора: 0x01 электросчётчикЧисла big-endian. Reading хранит значение целым числом и количество знаков после запятой: 12345678 и 3 - это 12345.678 kWh. Так показания не теряют точность на float и одинаково печатаются во всех языках; температура 0xFF83 читается со знаком и даёт -12.5 C.
Как это собирается
MeterDecoder создаётся один раз и живёт в контейнере. Обработчик вебхука получает пачку "устройство -> hex", декодирует hex стандартной библиотекой и отдаёт байты в decode. Хорошие показания идут в хранилище, а ошибки он разбирает по классу: UnknownDeviceType - пакет откладывается в очередь, пока шлюз не научится этому типу, остальные BadPacket - пакет отбрасывается, прибор пришлёт его снова.
В примере электросчётчик, водосчётчик и датчик на морозе разбираются, у второго датчика байт контрольной суммы повреждён в эфире, а газовый счётчик 0x04 вышел раньше, чем обновили шлюз.
Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.
Реализации
Продукт сообщает размер нагрузки, а проверяет его создатель: так ни один парсер не прочитает байты за концом пакета, даже если его автор забыл о проверке.
| Язык | Особенность |
|---|---|
| PHP | abstract class PacketDecoder с abstract protected function createParser(int $deviceType); пакет - бинарная строка, в тестах hex2bin; BadPacket и UnknownDeviceType - иерархия исключений |
| Go | Наследования нет: фабричный метод - поле newParser ParserFactory, новый тип оборачивает MeterParser; encoding/hex и encoding/binary; ошибка *PacketError с Unwrap к сентинелу ErrUnknownDeviceType |
| Python | ABC + @abstractmethod; bytes.fromhex и int.from_bytes(signed=True); распаковка заголовка device_type, declared, *_ = packet |
| TypeScript | protected abstract createParser(); продукты - объектные литералы; нагрузка отдаётся парсеру как DataView с getUint32 и getInt16 |
Запуск: make test T=patterns/creational/factory-method/practice-telemetry
Что проверяют тесты
Тесты одинаковы во всех языках, пакеты записаны hex-строками. Первая таблица - три прибора и три парсера, включая отрицательную температуру. Вторая - пять битых пакетов, и у каждого своя причина в тексте ошибки: слишком короткий, длина не сходится, контрольная сумма, размер нагрузки не тот для этого типа, неизвестный тип; последний отличается от остальных по классу ошибки. Третий тест добавляет газовый счётчик наследником MeterDecoder и показывает, что старые типы работают, а пакет газового счётчика с плохой суммой отвергается тем же конвейером, хотя его парсер о суммах не знает.
Где граница
- Типов десятки, и их добавляют не разработчики шлюза, а команды прошивок -
matchи наследники превращаются в узкое место. Нужен реестр "тип -> фабрика парсера", который заполняется при сборке приложения; фабричный метод остаётся, но читает реестр. - Формат нагрузки зависит не только от типа, но и от версии прошивки - параметров фабричного метода становится два, и это сигнал описывать раскладку данными (таблица полей, смещений, единиц), а не классами.
- Тип прибора один и новых не ожидается - создатель с фабричным методом не нужен, хватит функции проверки кадра и одного парсера.
- Если кадров несколько видов (с серийным номером и без, пачки показаний), меняется уже сам конвейер - это вопрос к Шаблонному методу или к отдельному слою разбора кадра.
Отличия между языками
- PHP - бинарный пакет - обычная строка, байт читается
ord;unpackвозвращаетarray|false, поэтому big-endian собран маленькимBytes::unsigned, а знак температуры восстановлен руками. - Go - фабричный метод - функция типа
ParserFactory, а "наследник" - другая функция, которая обрабатывает новый тип и отдаёт остальныеMeterParser. Сумма считается вbyteи переполняется сама,& 0xFFне нужен. - Python -
int.from_bytesчитает и беззнаковые, и знаковые числа, отдельной функции не нужно;PayloadParser-Protocol, парсеры его не наследуют. - TypeScript - в тестах и примере hex разбирает
Bufferиз Node, а код работает сUint8ArrayиDataView, поэтому не привязан к Node;getInt16сам учитывает знак.
Связанные темы
- Фабричный метод (базовый пример)
- Практика Фабричного метода: импорт прайс-листов - продукт с настройками и выбор создателя по расширению файла
- Шаблонный метод (Template Method) -
decodeи есть шаблонный метод, фабричный метод - один его шаг