Практика: SMS через старый шлюз (SMS gateway adapter)
Старый SMS-шлюз подключается к интерфейсу уведомлений: адаптер переводит номера телефонов в формат шлюза, а числовые коды ответа в причины, по которым приложение решает, что делать дальше.
Базовый пример: Адаптер. Там адаптер переводит одно число в другое и ошибаться не может. В жизни старый API отвечает кодами, принимает данные только в своём формате и падает по-разному, и главное в адаптере — не дать этим подробностям расползтись по приложению.
Задача
Магазин шлёт SMS о заказах через шлюз, с которым работает уже лет десять. Его SDK принимает номер только цифрами с кодом страны (79161234567) и возвращает число: 0 — принято, 101 — плохой номер, 102 — абонент отписался, 201 — превышен лимит, 503 — шлюз занят. Номера в базе лежат как люди их ввели: +7 (916) 123-45-67, 8 916 123-45-67.
Первая версия звала SDK прямо из сервиса заказов: чистка номера регуляркой, if ($code === 201 || $code === 503) для повтора, 102 молча пропускался. Потом те же проверки скопировали в рассылку акций, и там 102 забыли: отписавшимся пытались слать снова и снова. Когда встал вопрос о втором провайдере, оказалось, что коды старого шлюза знает половина кода.
Старый API в примере описан интерфейсом LegacySmsApi ровно в форме SDK: отправитель, номер цифрами, текст на входе, число на выходе. Настоящий клиент шлюза реализует его как есть, а тесты подставляют подделку.
Что урезано
- Нет HTTP-клиента шлюза: его место занимает интерфейс
LegacySmsApi, в тестах — подделка, которая запоминает вызовы и возвращает заданный код. - Только российские мобильные номера в двух формах:
+7и8плюс десять цифр. В проекте это библиотека разбора номеров (libphonenumber). - Нет длины сообщения, кодировки (GSM-7 или UCS-2) и деления на части: старые шлюзы требуют и этого, перевод живёт там же, в адаптере.
- Нет очереди повторов: клиент только сообщает, что сообщение надо отправить позже.
Решение
Адаптер SmsGatewayAdapter реализует Notifier и делает три вещи: переводит номер, зовёт submit() и переводит ненулевой код в исключение DeliveryFailed. Всё, что он знает о шлюзе, — это LegacySmsApi и отправитель SHOP, заданный при создании.
Номер проверяется до вызова шлюза: заведомо неверный номер не тратит запрос и не превращается в код 101 где-то в логах шлюза. Ответ шлюза теряет число и получает причину, а сам код остаётся в тексте ошибки, чтобы его можно было найти в логах.
Структура
Контракт — это не только Notifier::send(), но и язык ошибок: DeliveryFailed с причиной Reason (InvalidRecipient, OptedOut, Temporary, Rejected) и признаком retryable(). Причины описывают, что делать приложению, а не что случилось в шлюзе: повторять можно только Temporary, а неизвестный код становится Rejected и не теряется.
Перевод форматов
Перевод вынесен из адаптера в SmsFormats (в Go, Python и TypeScript это две функции модуля: ToMSISDN/to_msisdn/toMsisdn и Failure/failure): msisdn() убирает пробелы, скобки и дефисы и приводит +7 и 8 к 7, failure() достаёт причину из таблицы кодов. Таблица — данные, а не цепочка if: новый код шлюза добавляется строкой, а всё незнакомое по умолчанию считается Rejected.
Так перевод можно читать и тестировать отдельно от вызова шлюза, а адаптер остаётся коротким: взять номер, отправить, перевести ответ.
Как это собирается
Всё связывается один раз при запуске воркера: клиент SDK шлюза, вокруг него SmsGatewayAdapter с отправителем SHOP, поверх - OrderNotifications. В примере SDK заменён заглушкой, которая отвечает кодом по номеру и считает вызовы, а в приложении это настоящий клиент из DI-контейнера. Дальше воркер берёт пачку отправленных заказов из очереди и для каждого зовёт shipped().
На выходе видно все пути: sent, skipped для отписки и для номера 123-45, retry для занятого шлюза. Непонятный код 999 клиент пробрасывает, и уже воркер ловит DeliveryFailed, пишет ошибку с кодом и идёт дальше, не роняя пачку. Плохой номер не дошёл до шлюза: вызовов четыре на пять заказов.
Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.
Реализации
Клиент OrderNotifications сообщает, что заказ отправлен, и решает по причине: sent, skipped для плохого номера или отписки, retry для временного сбоя. Rejected он не глотает и пробрасывает дальше: непонятный отказ должен быть виден. Кодов шлюза в клиенте нет ни одного, поэтому смена провайдера его не касается.
| Язык | Особенность |
|---|---|
| PHP | enum Reason, исключение с readonly причиной, таблица кодов в константе класса; подделка шлюза — анонимный класс в тесте |
| Go | LegacySMS — интерфейс на стороне потребителя, SDK удовлетворяет ему неявно; ошибка *DeliveryError разбирается через errors.As |
| Python | LegacySmsApi и Notifier — Protocol, подделка — dataclass без наследования; raise без аргумента пробрасывает Rejected |
| TypeScript | Причины — объединение строковых литералов, подделка — объектный литерал; instanceof отделяет свои ошибки от чужих |
Запуск: make test T=patterns/structural/adapter/practice
Что проверяют тесты
Тесты одинаковы во всех языках и не трогают сеть: шлюз подменён подделкой, которая запоминает вызовы и возвращает заданный код.
- Три формы одного номера доходят до шлюза одинаково:
79161234567, с отправителемSHOP. - Короткий, иностранный и номер с буквами отклоняются как
InvalidRecipient, и шлюз при этом не вызывается ни разу. - Таблица кодов:
101и102— окончательные отказы,201и503—Temporaryи можно повторить,999—Rejected, и код виден в тексте ошибки. - Клиент с подделкой
Notifierрешает по причине:sent,skipped,retry, аRejectedпробрасывает. Шлюза в этом тесте нет совсем. - Клиент, адаптер и подделка шлюза вместе: код
201превращается вretry, а шлюз получает номер цифрами и текст про заказA-17.
Где граница
- Провайдеров стало два и больше, с переключением при сбое: адаптер на каждого плюс отдельный объект, который выбирает провайдера и повторяет. Повторы и переключение не дело адаптера, иначе он становится Декоратором или Фасадом.
- Старый API большой (статусы доставки, баланс, шаблоны), а нужна пара операций: нужен Фасад над ним, а адаптер к своему интерфейсу — уже поверх фасада.
- Шлюз один и меняться не будет, а SMS шлёт одно место в коде: прямой вызов SDK с переводом кодов в одной функции проще.
- Если новый провайдер говорит на языке приложения, адаптер ему не нужен: он реализует
Notifierсам.
Отличия между языками
- PHP — интерфейсы номинальные, поэтому
LegacySmsApiв проекте реализует обёртка над SDK или сам SDK, если он позволяет.enumдаёт закрытый набор причин, аreadonlyсвойство в исключении не даёт его поменять по дороге. - Go — имена по правилам Go:
SMSGatewayAdapter,LegacySMS, ошибкаDeliveryError. ИнтерфейсLegacySMSобъявлен рядом с адаптером, и настоящий клиент шлюза подходит к нему без единой правки: это идиоматичный способ отрезать чужой пакет. Ошибка — тип*DeliveryError, клиент достаёт причину черезerrors.As, даже если ошибку обернули по дороге. - Python —
Protocolи утиная типизация делают подмену бесплатной: подделка шлюза — обычный dataclass с методомsubmit.retryable— свойство, а не метод. - TypeScript — причина — строковый литерал, и компилятор не даст опечататься в
"temporary". Вcatchпопадаетunknown, поэтому клиент сначала проверяетinstanceof DeliveryFailedи чужие ошибки пробрасывает как есть.
Связанные темы
- Адаптер (базовый пример)
- Практика Одиночки: тарифы, загруженные один раз — та же идея подмены: зависимость приходит в конструкторе и меняется на подделку в тестах