handbookНаписать

patterns/creational/factory-method/practice-telemetry/README.md · практика · 4 языка · 39 тестов

Практика: разбор пакетов приборов учёта (Meter telemetry) ​

Проверка кадра одна для всех приборов, а парсер полезной нагрузки выбирает тип прибора из заголовка: это параметризованный фабричный метод, и новый тип прибора добавляется наследником, без правки конвейера.

Базовый пример: Фабричный метод. Там продукт выбирает класс наследника, а вариант известен заранее. Здесь вариант приходит в данных, в первом байте пакета, и фабричный метод получает его параметром.

Задача ​

Шлюз принимает пакеты от приборов учёта: электросчётчиков, водосчётчиков, датчиков температуры. Сетевой сервер отдаёт их пачкой в вебхуке, каждый пакет - hex-строка. Формат кадра у всех один: тип прибора, длина полезной нагрузки, нагрузка, контрольная сумма. А вот нагрузка у каждого своя: у электросчётчика накопленная энергия и мощность, у водосчётчика объём в литрах, у датчика температура со знаком в десятых долях градуса.

Первая версия разбирала всё одной функцией со switch по типу, и проверку контрольной суммы в ней делали только для электросчётчиков: для воды "забыли", и битые пакеты с эфира давали скачки расхода на графике. Новые приборы выходят с каждой прошивкой, и каждый раз правилась одна и та же функция.

Что урезано ​

  • Кадр - четыре поля без серийного номера, времени, версии протокола и шифрования. Настоящие протоколы учёта сложнее, но разделение то же: кадр отдельно, нагрузка отдельно.
  • Контрольная сумма - сумма байтов по модулю 256, а не CRC.
  • Показания не пишутся в базу, а "очередь" для неизвестных типов - строка в выводе примера.
  • Один прибор - один пакет, без пачек показаний и архивов за период.

Решение ​

Конвейер живёт в создателе PacketDecoder::decode: длина не меньше трёх байтов, объявленная длина нагрузки совпадает с фактической, контрольная сумма сходится. После этого конвейер берёт тип из первого байта и зовёт фабричный метод createParser(deviceType). Полученный продукт сообщает свой size(), и конвейер проверяет, что нагрузка ровно такой длины. Только потом продукт разбирает байты в список Reading.

Продукт - PayloadParser: ElectricityParser, WaterParser, TemperatureParser. Он знает раскладку байтов своего прибора и единицы измерения и ничего не знает о кадре.

Конкретный создатель MeterDecoder реализует фабричный метод через match по типу. Неизвестный тип - исключение UnknownDeviceType, отдельный подкласс BadPacket: вызывающий код отличает "пакет битый" от "прибор нам пока незнаком". Прошивка с новым типом прибора - наследник MeterDecoder, который знает новый тип и отдаёт остальные родителю.

Структура ​

uses

returns

creates

creates

creates

throws

«abstract»

PacketDecoder

#createParser(deviceType) : PayloadParser

+decode(packet) : Reading[]

MeterDecoder

#createParser(deviceType) : PayloadParser

«interface»

PayloadParser

+size() : int

+parse(payload) : Reading[]

ElectricityParser

WaterParser

TemperatureParser

Reading

+quantity: string

+value: int

+decimals: int

+unit: string

BadPacket

UnknownDeviceType

Правила разделены по уровням, как в базовом примере: целостность кадра и соответствие длины - забота создателя, и она одна на все приборы; раскладка байтов и единицы - забота продукта.

Формат пакета ​

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 в панели кода.

Реализации ​

Продукт сообщает размер нагрузки, а проверяет его создатель: так ни один парсер не прочитает байты за концом пакета, даже если его автор забыл о проверке.

ЯзыкОсобенность
PHPabstract class PacketDecoder с abstract protected function createParser(int $deviceType); пакет - бинарная строка, в тестах hex2bin; BadPacket и UnknownDeviceType - иерархия исключений
GoНаследования нет: фабричный метод - поле newParser ParserFactory, новый тип оборачивает MeterParser; encoding/hex и encoding/binary; ошибка *PacketError с Unwrap к сентинелу ErrUnknownDeviceType
PythonABC + @abstractmethod; bytes.fromhex и int.from_bytes(signed=True); распаковка заголовка device_type, declared, *_ = packet
TypeScriptprotected 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 сам учитывает знак.

Связанные темы ​

© 2014-2026 Тимур ТурдыевСайт и справочник работают на VPS от AdminVPS