Практика: приём вебхуков АТС (PBX webhooks)
Облачная АТС шлёт события о звонках и повторяет их, пока не получит 2xx: подпись, дедупликация и фильтр тестовых номеров стоят звеньями цепочки, а заканчивается она маршрутизатором, который по типу события выбирает, что записать в CRM.
Базовый пример: Цепочка обязанностей. Там конец цепочки - ошибка "никто не взял", а каждое звено отвечает только "моё или дальше". Здесь последним стоит маршрутизатор, который берёт всё, что дошло, звенья отвечают HTTP-статусами, от которых зависит, будет ли АТС слать событие снова, а дедупликация решает, запомнить ли событие, уже после ответа следующих звеньев.
Задача
Отдел продаж подключил облачную АТС. На каждый звонок она шлёт POST с JSON: начало звонка, конец с длительностью, пропущенный. CRM должна завести звонок клиенту, а по пропущенному поставить задачу перезвонить. Первая версия обработчика была одной функцией: разобрать тело, записать в CRM, ответить 200.
Потом всплыло всё сразу. Адрес вебхука нашли в логах прокси, и кто-то начал слать фальшивые пропущенные: АТС подписывает тело HMAC, но подпись никто не проверял. АТС повторяет событие, если не дождалась ответа за пару секунд, и в CRM появились дубли задач. Мониторинг АТС раз в минуту звонит с тестового номера, и у менеджеров копились задачи "перезвонить +70000000000". А когда CRM лежала, обработчик отвечал 200, и события терялись навсегда. Каждая такая забота - пять строк в начале функции, и их порядок оказался важен.
Что урезано
- Сети нет: подпись приходит параметром, а не заголовком, тело - строка. Секрет общий для всех событий, ротации ключей нет.
- Хранилище увиденных событий - множество в памяти. В проекте это Redis с
SET NXи сроком жизни ключа: повторы АТС приходят в пределах суток. - CRM - заглушка: список записей и флаг
down, которым тест имитирует недоступность. - Нет очереди: в проекте вебхук быстро кладут в очередь и отвечают 200, а CRM пишет воркер. Цепочка от этого не меняется, меняется только последнее звено.
Решение
Каждая забота - звено с общим контрактом WebhookHandler (вебхук -> ответ) и ссылкой на следующего. Общая часть звена - базовый Link: "передай дальше, а если некому - 500". Link::chain (в Go Chain, в Python chain) собирает звенья по списку и ставит в конец маршрутизатор.
VerifySignatureсчитает HMAC-SHA256 тела по секрету и сравнивает с подписью за постоянное время. Не совпало - 401.Deduplicateпомнит обработанные id событий. Повтор получает 200duplicate <id>и дальше не идёт. Новое событие идёт дальше, и только если ответ 2xx, id запоминается: событие, на котором упала CRM, при повторе обработается заново.SkipTestNumbersотвечает 204 на звонки с тестового номера или на него.Routerстоит в конце и сам звеном не является: у него нет следующего. По типу события он берёт обработчик из словаря, а неизвестный тип подтверждает 202, чтобы АТС не повторяла событие, которое мы всё равно не умеем обрабатывать. Обработчики пишут вCrm; если она недоступна - 503, и АТС придёт снова.
Перед цепочкой стоит WebhookEndpoint: он разбирает JSON в CallEvent (битое тело - 400) и передаёт дальше Webhook с подписью, сырым телом и событием. Сырое тело нужно подписи: HMAC считается по байтам, и заново собранный JSON с ней не совпадёт. Решения по полям события принимаются только после проверки подписи.
Структура
Цепочка и словарь здесь работают вместе, и это не случайность. Проверки идут друг за другом, и каждая может остановить вебхук - это работа цепочки. Выбор обработчика по типу события - это поиск по ключу, и базовая статья прямо говорит, что для него цепочка не нужна. Поэтому маршрутизатор один и стоит последним, а не "звено на каждый тип".
Ответы и повторы
Для АТС важен не текст ответа, а его класс: на 2xx она успокаивается, на всё остальное повторяет. Поэтому статусы - часть логики звеньев:
| Ситуация | Ответ | Что сделает АТС |
|---|---|---|
| записали в CRM | 200 recorded <type> | ничего |
| повтор уже записанного | 200 duplicate <id> | ничего: первый ответ до неё не дошёл, второй дошёл |
| тестовый номер | 204 | ничего |
| неизвестный тип события | 202 | ничего: повтор не поможет |
| CRM недоступна | 503 | повторит, и дедупликация пропустит повтор |
| подпись не сошлась | 401 | повторит, но это уже не наша забота |
| битое тело | 400 | повторит, это ошибка на стороне АТС |
Порядок звеньев тоже решает. Подпись стоит перед дедупликацией: иначе фальшивый вебхук с угаданным id занял бы его в хранилище, и настоящее событие получило бы duplicate. Дедупликация стоит перед фильтром тестовых номеров, чтобы повторы тестовых звонков отсекались дешёвой проверкой, но может стоять и после: здесь порядок не про безопасность.
Как это собирается
Цепочка собирается один раз при старте сервиса: секрет приходит из окружения, список тестовых номеров - из конфига, Router::forCrm регистрирует три обработчика (CallRouter в Go, call_router в Python, callRouter в TypeScript). Дальше HTTP-обработчик маршрута /webhooks/pbx берёт подпись из заголовка и тело как есть и отдаёт их WebhookEndpoint (в Go Endpoint). Хранилище Deduplicate общее для всех запросов, поэтому звено - один объект на весь сервис.
В примере минута трафика АТС. Начало звонка записывается. Конец звонка приходит, когда CRM лежит: 503, событие не запомнено. АТС повторяет - запись есть, и ещё один повтор (ответ потерялся по дороге) получает duplicate. Звонок мониторинга отсечён, фальшивый вебхук получил 401, новый тип call_transferred подтверждён и пропущен. В CRM ровно две записи.
Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.
Реализации
Звенья одинаковы по смыслу во всех языках: тексты ответов, статусы, условие "запомнить только после 2xx". Конкретное звено переопределяет handle, делает свою проверку и зовёт базовую передачу дальше, как Manager в базовом примере. Deduplicate - единственное звено, которому важно, что ответили звенья после него.
| Язык | Особенность |
|---|---|
| PHP | Абстрактный Link с parent::handle(); Link::chain выставляет приватное next из статического метода; обработчики маршрутов - Closure(CallEvent): void |
| Go | Общий код звена - встраиваемая структура successor с методом pass; неэкспортируемый setNext в интерфейсе Link не даёт собрать цепочку из чужих типов; ошибка CRM - сентинел ErrCrmUnavailable |
| Python | Базовый класс Link с super().handle(); сборка - функция chain; hmac.compare_digest для сравнения подписи |
| TypeScript | Абстрактный класс и override handle; Link.chain через reduceRight; timingSafeEqual из node:crypto требует буферы одной длины |
Запуск: make test T=patterns/behavioral/chain-of-responsibility/practice-webhook
Что проверяют тесты
Тесты одинаковы во всех языках. Подпись тест считает той же функцией sign, что и АТС, секрет s3cret, тестовый номер +70000000000.
- Таблица из шести доставок: каждое звено отвечает за себя. Верный пропущенный - 200 и одна запись в CRM; чужой секрет - 401; битый JSON и тело без
type- 400; тестовый номер - 204; неизвестный тип - 202. Во всех случаях, кроме первого, CRM пуста. - Повтор уже записанного события получает 200
duplicate evt-1, а в CRM по-прежнему одна запись. - Пока CRM лежит, событие получает 503; после подъёма тот же повтор записывается. Это доказывает, что дедупликация запоминает id только после успешного ответа.
- Фальшивое событие
evt-9с чужим секретом получает 401, а настоящееevt-9после него записывается: подпись стоит перед дедупликацией.
Где граница
- Событий много и CRM медленная - запись уезжает в очередь, а маршрутизатор только кладёт задачу и отвечает 200. Дедупликация тогда нужна и в воркере: очередь тоже доставляет "хотя бы один раз".
- Хранилище id в памяти работает, пока процесс один. При нескольких репликах проверка и запись id должны быть одной атомарной операцией в общем хранилище, иначе два одновременных повтора пройдут оба.
- Если вебхуков несколько (АТС, платёжка, почта) и у всех подпись и повторы, звенья переезжают в middleware фреймворка, а маршрутизатор становится обычным роутером по пути и типу. Это уже форма первой практики.
- Если источник один и не повторяет события, цепочка избыточна: проверка подписи и
matchпо типу в одной функции понятнее.
Отличия между языками
- PHP - классическая форма GoF, как в базовом примере, только следующее звено ставит не конструктор, а
Link::chain: так сборка читается списком, а не матрёшкой. Приватноеnextдоступно статическому методу того же класса. Для подписи -hash_hmacиhash_equals, для тела -json_decodeсJSON_THROW_ON_ERROR. - Go - наследования нет, общий код звена живёт во встраиваемой структуре
successor: звено получаетpassиsetNext, аHandleпишет само.Deduplicateдержит множество под мьютексом, потому что HTTP-сервер зовёт обработчик из многих горутин. JSON декодируется в структуру с тегами, подпись -crypto/hmacиhmac.Equal. - Python - базовый класс
Linkс атрибутом_nextиsuper().handle(). КонтрактHandler-Protocol, поэтомуRouterподходит в конец цепочки без наследования. Полеfrom- ключевое слово, отсюдаfrom_number. - TypeScript - абстрактный класс с
override, сборка черезreduceRight.JSON.parseвозвращаетany, поэтому тело приводится кunknownи проверяется по полям.timingSafeEqualбросает на буферах разной длины, поэтому длина проверяется отдельно.
Связанные темы
- Цепочка обязанностей (базовый пример)
- Практика: middleware входящего запроса - та же цепочка в форме middleware, звенья дополняют запрос, конец - обработчик маршрута
- Практика Стратегии: расчёт доставки - реестр по коду, как у маршрутизатора, только выбор по правилам из конфига
- Заместитель (Proxy) - проверка подписи и дедупликация по отдельности часто живут заместителем перед обработчиком