handbookНаписать

patterns/behavioral/chain-of-responsibility/practice-webhook/README.md · практика · 4 языка · 38 тестов

Практика: приём вебхуков АТС (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 событий. Повтор получает 200 duplicate <id> и дальше не идёт. Новое событие идёт дальше, и только если ответ 2xx, id запоминается: событие, на котором упала CRM, при повторе обработается заново.
  • SkipTestNumbers отвечает 204 на звонки с тестового номера или на него.
  • Router стоит в конце и сам звеном не является: у него нет следующего. По типу события он берёт обработчик из словаря, а неизвестный тип подтверждает 202, чтобы АТС не повторяла событие, которое мы всё равно не умеем обрабатывать. Обработчики пишут в Crm; если она недоступна - 503, и АТС придёт снова.

Перед цепочкой стоит WebhookEndpoint: он разбирает JSON в CallEvent (битое тело - 400) и передаёт дальше Webhook с подписью, сырым телом и событием. Сырое тело нужно подписи: HMAC считается по байтам, и заново собранный JSON с ней не совпадёт. Решения по полям события принимаются только после проверки подписи.

Структура ​

chain

builds

next

WebhookEndpoint

-chain: WebhookHandler

+receive(signature, body) : Response

Webhook

+signature: string

+body: string

+event: CallEvent

CallEvent

+id: string

+type: string

+from: string

+to: string

+durationSec: int

«interface»

WebhookHandler

+handle(webhook) : Response

«abstract»

Link

-next: WebhookHandler

+chain(links, end) : WebhookHandler

+handle(webhook) : Response

VerifySignature

Deduplicate

SkipTestNumbers

Router

-routes: Map<string, handler>

+on(type, handler) : Router

+handle(webhook) : Response

Crm

+entries: string[]

+down: bool

+record(entry)

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

Ответы и повторы ​

Для АТС важен не текст ответа, а его класс: на 2xx она успокаивается, на всё остальное повторяет. Поэтому статусы - часть логики звеньев:

СитуацияОтветЧто сделает АТС
записали в CRM200 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 бросает на буферах разной длины, поэтому длина проверяется отдельно.

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

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