Практика: команды прибору учёта по радио (Device commands)
Команда прибору - объект с параметрами и ожидаемым ответом: её ставят в очередь, отправляют по одной, ждут подтверждения, повторяют по таймауту и отменяют, пока она не ушла в эфир.
Базовый пример: Команда. Там команда выполняется сразу и на месте, а получатель всегда отвечает. Здесь получатель - прибор на другом конце радиоканала: он спит, теряет кадры и отвечает когда проснётся, поэтому команде нужно уметь ждать, повторяться и проверять, что ответ пришёл именно на неё.
Задача
Электросчётчики в квартирах связаны с сервером по радиоканалу через базовую станцию. Оператор из биллинга меняет тариф, синхронизирует часы прибора и отключает реле должнику. Прибор выходит на связь раз в несколько минут, канал медленный, кадры теряются, а два кадра подряд прибор не переварит: следующую команду можно слать только после ответа на предыдущую.
Первая версия слала команду сразу из обработчика кнопки и считала её выполненной. Тариф иногда не менялся, а оператор об этом не знал. Повтор "на всякий случай" в цикле забивал эфир. Хуже всего с реле: должник оплатил, оператор нажал "отменить отключение", но команда уже ушла, и квартиру всё равно обесточили. Нужен один объект на каждый запрос, у которого есть состояние: ждёт, в эфире, подтверждён, не дошёл, отменён.
Что урезано
- Одна очередь на один прибор. В проекте их столько же, сколько приборов, а базовая станция решает, когда прибор слышит эфир.
- Кадр и ответ - строки вида
SET_TARIFF 2иTARIFF 2. Настоящий протокол двоичный, с номером пакета, CRC и шифрованием. - Радио - заглушка
RecordingRadio, которая только записывает отправленные кадры. Ответы прибора тест передаёт в очередь сам. - Время - целые тики, которые передаются в
tick(now)явно. Системных часов в коде нет, поэтому сценарии с таймаутом проверяются без ожидания. - Ответ прибора с отказом ("тариф 9 не поддерживается") не разобран: команда либо подтверждена, либо не дошла.
Решение
Команда MeterCommand знает две вещи: кадр для эфира frame() и ответ, который её подтверждает, expectedReply(). Команды - SetTariff, SyncTime и DisconnectRelay, все параметры они получают при создании. SyncTime получает время значением, а не читает часы: команда - это данные, которые можно положить в очередь и отправить позже.
Очередь CommandQueue - это вызывающий из базового примера, только с памятью. На каждую команду она заводит Ticket с номером, статусом и числом попыток. В эфире одновременно одна команда. Очередь не знает, что значит тариф или реле: она сравнивает ответ с expectedReply() и считает тики.
Структура
Жизнь команды в очереди
Статусы 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 в панели кода.
Реализации
| Язык | Особенность |
|---|---|
| PHP | enum Status: string, Ticket с public private(set) и методами перехода, AlreadySent - исключение |
| Go | Команды - структуры-значения с методами; AlreadySentError с полями ID и Status, проверка через errors.As |
| Python | Protocol для команд и радио, команды - 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 - команды -
frozendataclass и подходят подProtocolбез наследования;StrEnumпечатается как своё значение, поэтому сообщение ошибки собирается f-строкой. - TypeScript - статус - объединение литералов, и
tscне даст присвоить"sent". Наружуticket()отдаётReadonly<Ticket>, а изменяет билеты только сама очередь.
Связанные темы
- Команда (базовый пример)
- Практика: редактор с undo/redo - другая сторона команды: история, отмена и повтор вместо очереди и подтверждения
- Outbox - та же идея "сначала записать запрос, потом доставить", но для событий между сервисами