handbookНаписать

patterns/behavioral/command/practice-device/README.md · практика · 4 языка · 31 тест

база: Команда+ практика: редактор с undo/redo

Практика: команды прибору учёта по радио (Device commands) ​

Команда прибору - объект с параметрами и ожидаемым ответом: её ставят в очередь, отправляют по одной, ждут подтверждения, повторяют по таймауту и отменяют, пока она не ушла в эфир.

Базовый пример: Команда. Там команда выполняется сразу и на месте, а получатель всегда отвечает. Здесь получатель - прибор на другом конце радиоканала: он спит, теряет кадры и отвечает когда проснётся, поэтому команде нужно уметь ждать, повторяться и проверять, что ответ пришёл именно на неё.

Задача ​

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

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

Что урезано ​

  • Одна очередь на один прибор. В проекте их столько же, сколько приборов, а базовая станция решает, когда прибор слышит эфир.
  • Кадр и ответ - строки вида SET_TARIFF 2 и TARIFF 2. Настоящий протокол двоичный, с номером пакета, CRC и шифрованием.
  • Радио - заглушка RecordingRadio, которая только записывает отправленные кадры. Ответы прибора тест передаёт в очередь сам.
  • Время - целые тики, которые передаются в tick(now) явно. Системных часов в коде нет, поэтому сценарии с таймаутом проверяются без ожидания.
  • Ответ прибора с отказом ("тариф 9 не поддерживается") не разобран: команда либо подтверждена, либо не дошла.

Решение ​

Команда MeterCommand знает две вещи: кадр для эфира frame() и ответ, который её подтверждает, expectedReply(). Команды - SetTariff, SyncTime и DisconnectRelay, все параметры они получают при создании. SyncTime получает время значением, а не читает часы: команда - это данные, которые можно положить в очередь и отправить позже.

Очередь CommandQueue - это вызывающий из базового примера, только с памятью. На каждую команду она заводит Ticket с номером, статусом и числом попыток. В эфире одновременно одна команда. Очередь не знает, что значит тариф или реле: она сравнивает ответ с expectedReply() и считает тики.

Структура ​

transmit(frame)

«interface»

MeterCommand

+frame() : string

+expectedReply() : string

SetTariff

-tariff: int

SyncTime

-unixTime: int

DisconnectRelay

Ticket

+id: int

+command: MeterCommand

+status: Status

+attempts: int

+sentAt: int

«interface»

Radio

+transmit(frame)

RecordingRadio

+frames: string[]

CommandQueue

-timeoutTicks: int

-maxAttempts: int

+enqueue(command) : int

+cancel(id)

+receive(reply) : bool

+tick(now)

+ticket(id) : Ticket

Жизнь команды в очереди ​

Статусы Ticket: pending -> in_flight -> done или failed; из pending ещё можно в cancelled.

  • enqueue ставит команду в конец со статусом pending и возвращает номер.
  • tick(now) ведёт очередь. Если в эфире ничего нет, отправляет первую pending. Если команда в эфире и с отправки прошло timeoutTicks, отправляет её ещё раз, а после maxAttempts попыток помечает failed и в том же тике отправляет следующую.
  • receive(reply) подтверждает команду в эфире, только если ответ совпал с её expectedReply(). Чужой или запоздавший ответ (TARIFF 3 на SET_TARIFF 2) игнорируется и возвращает false. Следующая команда уйдёт на ближайшем тике.
  • cancel(id) работает только для pending. Команду в эфире отменить нельзя: кадр уже у прибора или в пути, и честный ответ - ошибка AlreadySent с номером и статусом, а не тихое "отменено".

Повтор по таймауту не меняет команду: тот же объект даёт тот же кадр. Поэтому прибор, который получил оба кадра, выполнит одно и то же дважды, и это безопасно только для идемпотентных команд: "тариф 2", а не "следующий тариф".

Как это собирается ​

В приложении очередь живёт рядом со шлюзом базовой станции, одна на прибор. Экран биллинга создаёт команды из действий оператора и зовёт enqueue, номер команды показывается в интерфейсе, по нему же работает кнопка "отменить". Планировщик шлюза зовёт tick со своим счётчиком тиков, а обработчик входящих кадров передаёт ответы прибора в receive. Радио RecordingRadio в проекте - клиент базовой станции.

В примере оператор ставит тариф, синхронизацию часов и отключение реле, а потом отменяет отключение: клиент оплатил, пока прибор спал. Тариф подтверждается с первого раза, первый кадр SYNC_TIME теряется, и очередь отправляет его повторно через три тика. Реле в эфир не уходит. Попытка отменить уже выполненный тариф получает понятную ошибку.

Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.

Реализации ​

ЯзыкОсобенность
PHPenum Status: string, Ticket с public private(set) и методами перехода, AlreadySent - исключение
GoКоманды - структуры-значения с методами; AlreadySentError с полями ID и Status, проверка через errors.As
PythonProtocol для команд и радио, команды - frozen dataclass, StrEnum для статуса
TypeScriptКлассы команд, статус - объединение строковых литералов, ticket() отдаёт Readonly<Ticket>

Запуск: make test T=patterns/behavioral/command/practice-device

Что проверяют тесты ​

Тесты одинаковы во всех языках, таймаут 3 тика, не больше 3 попыток. Таблица из трёх случаев гоняет одну команду SET_TARIFF 2 по тикам от 0 до 10: ответ на первую попытку - done с одной попыткой; ответ на тике 4, после повтора на тике 3, - done с двумя; ответа нет - кадр уходит на тиках 0, 3 и 6, а на тике 9 команда failed. Число кадров в радио совпадает с числом попыток, то есть лишних повторов нет.

Остальные тесты: вторая команда не уходит в эфир, пока первая не подтверждена; ответ TARIFF 3 не подтверждает SET_TARIFF 2; отменённое отключение реле так и не попадает в радио; отмена команды в эфире падает с ошибкой, где названы номер и статус.

Где граница ​

  • Приборов тысячи, а базовая станция одна: очередь на прибор остаётся, но над ними появляется планировщик эфира с приоритетами (отключение реле важнее синхронизации часов) и окнами связи.
  • Очередь должна пережить перезапуск сервера: Ticket становится строкой в таблице, а tick - запросом "что в эфире и что просрочено". Это уже ближе к Outbox с подтверждением от получателя.
  • Прибор отвечает на команду промежуточными статусами (принял, выполняю, готово): Ticket превращается в конечный автомат, см. Состояние.
  • Команда одна и подтверждать её не нужно (широковещательная рассылка времени): очередь избыточна, хватит отправки кадра.

Отличия между языками ​

  • PHP - public private(set) открывает статус и попытки для чтения, а менять их можно только методами transmitted и close. Неизвестный номер - ?? throw прямо в выражении.
  • Go - ошибка отмены - тип AlreadySentError, а не сентинел: вызывающему нужен статус команды, и он достаёт его через errors.As. Неизвестный номер - паника, потому что номера выдаёт только Enqueue.
  • Python - команды - frozen dataclass и подходят под Protocol без наследования; StrEnum печатается как своё значение, поэтому сообщение ошибки собирается f-строкой.
  • TypeScript - статус - объединение литералов, и tsc не даст присвоить "sent". Наружу ticket() отдаёт Readonly<Ticket>, а изменяет билеты только сама очередь.

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

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