Зачем я добавил Go-сервис туда, где его не просили
- четверг, 1 октября 2026 г. в 00:00:04
Недавно мне дали тестовое задание на проектирование интеграции между 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 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 при этом имеет смысл вынести в конфигурацию. Не потому, что «всё должно быть конфигурируемым», а потому что параметры взаимодействия с внешней системой могут потребовать изменения без изменения самого алгоритма обработки.
На самом деле 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. Подойдут 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
Обе схемы могут быть оправданы в разных условиях. Вопрос не в количестве компонентов. Вопрос в том, какую конкретную проблему каждый из них решает.