Недавно мне дали тестовое задание на проектирование интеграции между 1С и Yandex 360. На первый взгляд задача выглядела достаточно прямолинейно: 1С формирует письмо, дальше его нужно передать в почтовую систему, отправить и получить информацию о результате. Но в исходном ТЗ не было отдельного сервиса на Go. Я добавил его сам. И в этой статье хочу разобрать не столько само тестовое задание, сколько процесс принятия архитектурного решения: зачем вообще добавлять ещё один компонент и в какой момент он действительно оправдан. Потому что сделать архитектуру из нескольких сервисов несложно. Гораздо сложнее объяснить, зачем каждый из них нужен.
Что было в задаче
Есть 1С, из которой необходимо отправлять электронные письма через Yandex 360.
При этом недостаточно просто сделать:
1С → Yandex 360 → письмо отправлено
В процессе возникают вполне обычные для интеграций проблемы:
внешний сервис может быть временно недоступен;
запрос может завершиться таймаутом;
ответ может не прийти, хотя операция уже была выполнена;
одну и ту же операцию нельзя бездумно выполнять повторно;
нужно понимать текущее состояние письма;
нужна история обработки;
после перезапуска сервиса состояние не должно потеряться;
необходимо получать информацию о результате отправки.
И вот здесь начинается самое интересное.
Самый простой вариант
Первое, что приходит в голову:
1С │ ▼ Yandex 360
1С формирует письмо и непосредственно обращается к внешней системе. Для небольшой системы это вполне может быть нормальным решением. Я бы не стал автоматически добавлять между ними ещё один сервис только потому, что «так правильнее». Но у прямого взаимодействия появляется несколько вопросов.
Что делать с повторной отправкой?
Допустим, 1С отправила запрос, Yandex 360 принял письмо, Но ответ до 1С не дошёл из-за сетевого сбоя.
1С видит:
timeout
и повторяет запрос.
Что произойдёт?
Если архитектура никак не учитывает повторную обработку одной и той же операции, получатель потенциально может получить два одинаковых письма. Самое неприятное здесь в том, что для 1С первый запрос выглядит неуспешным, хотя операция на самом деле могла выполниться.
Это классическая проблема распределённых систем: мы не всегда можем точно определить, что произошло на другой стороне после сетевого сбоя.
Я решил разделить ответственность
Вместо прямого взаимодействия я предложил промежуточный сервис:
┌─────────────┐ │ 1С │ └──────┬──────┘ │ ▼ ┌─────────────┐ │ Go-сервис │ └──────┬──────┘ │ ┌────▼─────┐ │ DB │ └────┬──────┘ │ ▼ ┌─────────────┐ │ Yandex 360 │ └─────────────┘
Важно ещё раз подчеркнуть: Go-сервиса в ТЗ не было. Это моё архитектурное решение. Поэтому его появление нужно было обосновать. Главная идея была достаточно простой: не заставлять 1С заниматься всей механикой взаимодействия с внешним почтовым сервисом.
Для 1С интерфейс можно свести примерно к следующему:
«Вот письмо с таким идентификатором. Прими его в обработку».
А дальше уже сервис занимается своей частью работы:
сохраняет письмо, ставит его в очередь, выполняет отправку, повторяет попытки при необходимости, хранит текущее состояние, сохраняет историю, взаимодействует с Yandex 360, восстанавливает обработку после перезапуска.
Получается граница ответственности:
1С │ │ «Отправь это письмо» ▼ Go │ ├── сохранить ├── поставить в очередь ├── отправить ├── повторить при необходимости ├── проверить результат └── сохранить историю
При этом 1С не нужно знать, сколько раз сервис пытался отправить письмо, почему произошёл timeout и что происходило с worker'ом после перезапуска.
Почему я не стал добавлять отдельный брокер
Здесь начинается первый архитектурный компромисс. Можно было сразу добавить RabbitMQ, Kafka или другой брокер:
1С │ ▼ Go API │ ▼ RabbitMQ │ ▼ Worker │ ▼ Yandex 360
Выглядит уже гораздо более «архитектурно». Но возникает простой вопрос: а действительно ли нам это нужно? Если объём сообщений и требования к системе не требуют отдельного брокера, мы получаем дополнительную инфраструктуру, которую тоже нужно: устанавливать, настраивать мониторить, обновлять, резервировать, восстанавливать.
И самое главное — появляется ещё один компонент, за который нужно отвечать. Поэтому в тестовом я выбрал более простой вариант: состояние очереди хранится в базе данных. То есть база здесь — не просто хранилище писем. Она одновременно хранит состояние обработки.
Например, условно:
ready ↓ sending ↓ sent
или:
ready ↓ sending ↓ failed ↓ sending ↓ sent
Для поставленной задачи этого было достаточно.
Почему GUID не стал первичным ключом
У письма есть внешний идентификатор — GUID.
На первый взгляд логично сделать:
guid PRIMARY KEY
Но я предпочёл разделить внешний и внутренний идентификаторы:
id — внутренний идентификатор записи в БД guid — идентификатор, пришедший из внешней системы
Это разные сущности по смыслу.
id нужен самой базе данных.
guid нужен для связи с внешней системой и, в том числе, для контроля повторной обработки.
Например, 1С может повторно прислать письмо с тем же GUID.
Сервис должен уметь определить:
Такая логическая операция уже существует.
И вместо создания второй записи обработать существующую. При этом наличие GUID само по себе не решает проблему дублей при взаимодействии с внешним сервисом. Для этого нужна вся совокупность механизмов: состояние операции, проверки, правила повторной обработки и понимание того, на каком этапе произошёл сбой.
Текущее состояние и история — не одно и то же
Следующий вопрос: достаточно ли одного поля status?
Например:
emails.status = sent
Этого достаточно, если нас интересует только текущее состояние.
Но со временем появляются вопросы: когда письмо было создано? когда началась отправка сколько было попыток? когда произошла ошибка? когда была выполнена повторная попытка когда операция завершилась?
Поэтому я разделил текущее состояние и историю.
Условно:
emails
хранит текущее состояние.
А:
email_events
хранит события.
Например:
created ↓ ready ↓ sending ↓ failed ↓ retry ↓ sending ↓ sent
В итоге можно быстро узнать текущее состояние:
письмо отправлено
и отдельно восстановить историю:
первая попытка завершилась ошибкой, после чего была выполнена повторная.
Что происходит при ошибке?
Предположим, Yandex 360 временно недоступен.
Worker пытается отправить письмо:
sending ↓ timeout ↓ failed
Но failed в данном случае не обязательно означает:
больше никогда не пытаться.
Можно хранить количество попыток и повторять обработку.
Например:
attempt = 1 attempt = 2 attempt = 3
Количество попыток и параметры timeout при этом имеет смысл вынести в конфигурацию. Не потому, что «всё должно быть конфигурируемым», а потому что параметры взаимодействия с внешней системой могут потребовать изменения без изменения самого алгоритма обработки.
Самый неприятный случай — timeout
На самом деле retry сам по себе не решает проблему.
Представим:
Go → Yandex 360
Yandex 360 принял запрос.
Но ответ потерялся.
Go получает:
timeout
Что теперь делать?
Если просто повторить запрос:
retry
мы потенциально можем получить дубль. Но и не повторять запрос автоматически тоже нельзя: письмо могло не дойти.
Получается неприятная ситуация:
мы не знаем, завершилась операция или нет.
Именно здесь особенно важна идемпотентность и возможность определить состояние конкретной операции. Поэтому архитектура должна учитывать не только успешные ответы, но и неопределённые состояния. Это, пожалуй, одна из самых важных вещей в интеграциях: ошибка сетевого запроса не всегда означает ошибку бизнес-операции.
Что произойдёт после перезапуска?
Ещё один вопрос, который легко забыть в тестовом:
Что произойдёт, если worker упал?
Если очередь находится только в памяти процесса:
Go memory ↓ queue
после перезапуска состояние потеряно.
Если состояние обработки находится в БД:
DB │ ├── ready ├── sending ├── failed └── sent
после перезапуска можно определить, какие записи ещё требуют обработки.
Конечно, здесь тоже нужно отдельно продумать состояние sending: если процесс умер непосредственно во время отправки, нельзя просто слепо считать такую запись неотправленной и повторить запрос. Но само хранение состояния в БД уже позволяет переживать обычный перезапуск процесса без потери очереди.
А зачем тогда отдельная таблица событий?
Потому что текущее состояние и история решают разные задачи.
Например:
emails.status = sent
говорит только:
сейчас письмо отправлено.
А журнал событий позволяет ответить на вопросы:
created sending failed retry sending sent
Это полезно не только для отладки.
История помогает анализировать: количество ошибок, количество повторных попыток, время обработки, причины сбоев, поведение внешнего сервиса. То есть события становятся не просто логом для разработчика, а частью данных системы.
А что делать со статистикой?
Здесь я тоже старался не добавлять отдельные компоненты без необходимости. Если необходимые данные уже находятся в БД, а задача — мониторинг и статистика, отдельный API только ради Grafana может оказаться лишним.
В таком варианте:
DB │ └── Grafana
может быть вполне достаточно.
Получается ещё один простой принцип:
если существующий компонент уже решает задачу, не нужно автоматически создавать новый.
Почему Go?
И здесь важно не превратить статью в рекламу языка. Подобный сервис можно написать не только на Go. Подойдут PHP, Java, Python, Node.js и другие технологии.
Go в данном случае удобен для небольшого отдельного сервиса с: HTTP API, worker'ом, сетевыми запросами, длительно работающим процессом, небольшим количеством зависимостей. Но выбор языка здесь вторичен.
Главный вопрос был не:
«На каком языке написать микросервис?»
А:
«Нужен ли здесь вообще отдельный сервис?»
Это принципиально разные вопросы.
Когда такой сервис был бы лишним?
Например, если система: отправляет небольшое количество писем, не требует сложной обработки ошибок, допускает повторные отправки, не требует истории, не нуждается в отдельном мониторинге, может выполнять всю необходимую логику непосредственно в 1С.
Тогда схема:
1С → Yandex 360
может быть вполне нормальной. Добавлять Go только ради красивой архитектурной диаграммы смысла нет.
Где проходит граница?
Для меня она проходит примерно здесь.
Если дополнительный сервис решает конкретные проблемы:
идемпотентность + очередь + retry + восстановление + история + изоляция внешнего API
его появление можно обосновать.
Если же аргумент звучит:
«Давайте сделаем микросервис, потому что микросервисы — это современно»,
то это уже архитектура ради архитектуры. Причём проблема не в микросервисах как таковых. Проблема в отсутствии причины.
Что в итоге получилось
В моём варианте архитектура выглядит примерно так:
┌──────────────┐ │ 1С │ └──────┬───────┘ │ ▼ ┌──────────────┐ │ Go API │ └──────┬───────┘ │ ┌──────▼───────┐ │ DB │ │ │ │ emails │ │ email_events │ │ campaigns │цц │ sync_state │ └──────┬───────┘ │ ▼ ┌──────────────┐ │ Worker │ └──────┬───────┘ │ ▼ ┌──────────────┐ │ Yandex 360 │ └──────────────┘
цПри этом каждый компонент появился не потому, что его хотелось добавить в архитектурную схему. У каждого есть конкретная задача.
И главный вывод
Для меня это тестовое получилось интересным именно потому, что в нём не было готового требования:
«Добавьте сервис на Go».
Его пришлось придумать самому и затем объяснить. И это, пожалуй, одна из самых полезных вещей в архитектуре: не просто придумать сложную систему, а суметь объяснить, зачем каждый её компонент вообще существует.
Иногда подходящей архитектурой будет:
1С → сервис → очередь → worker → внешний API
А иногда:
1С → внешний API
Обе схемы могут быть оправданы в разных условиях. Вопрос не в количестве компонентов. Вопрос в том, какую конкретную проблему каждый из них решает.

