handbookНаписать

patterns/structural/adapter/practice/README.md · практика · 4 языка · 36 тестов

база: Адаптер

Практика: 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 где-то в логах шлюза. Ответ шлюза теряет число и получает причину, а сам код остаётся в тексте ошибки, чтобы его можно было найти в логах.

Структура ​

uses

wraps

translates

throws

«interface»

Notifier

+send(phone, text)

DeliveryFailed

+reason: Reason

+retryable() : bool

«interface»

LegacySmsApi

+submit(sender, msisdn, body) : int

SmsFormats

+msisdn(phone) : string

+failure(code) : DeliveryFailed

SmsGatewayAdapter

-api: LegacySmsApi

-sender: string

+send(phone, text)

OrderNotifications

-notifier: Notifier

+shipped(orderId, phone) : string

Контракт — это не только 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 он не глотает и пробрасывает дальше: непонятный отказ должен быть виден. Кодов шлюза в клиенте нет ни одного, поэтому смена провайдера его не касается.

ЯзыкОсобенность
PHPenum Reason, исключение с readonly причиной, таблица кодов в константе класса; подделка шлюза — анонимный класс в тесте
GoLegacySMS — интерфейс на стороне потребителя, SDK удовлетворяет ему неявно; ошибка *DeliveryError разбирается через errors.As
PythonLegacySmsApi и 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 и чужие ошибки пробрасывает как есть.

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

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