golang

Зачем я добавил Go-сервис туда, где его не просили

  • четверг, 1 октября 2026 г. в 00:00:04
https://habr.com/ru/articles/1088652/

Недавно мне дали тестовое задание на проектирование интеграции между 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

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