Стили интеграции
Как системы обмениваются данными: синхронно или через сообщения и файлы, по какому протоколу, как пережить сбои соседа и не создать дубли и как подключить ERP, учётные системы и EDI-провайдеров. На собеседовании здесь проверяют не знание API, а умение объяснить, чем платишь за каждый выбор.
Перед этой темой: Web API.
Чем синхронная интеграция отличается от асинхронной и когда какую выбрать?
Заголовок раздела «Чем синхронная интеграция отличается от асинхронной и когда какую выбрать?»Что это
- Синхронная интеграция — вызывающий отправляет запрос и ждёт ответа: REST, gRPC, SOAP.
- Асинхронная интеграция — отправитель кладёт сообщение в очередь или файл в папку обмена и продолжает работу. Получатель обработает его позже.
Когда что выбирать
| Ситуация | Стиль |
|---|---|
| Ответ нужен сейчас: цена, остаток, авторизация платежа | синхронно |
| Данные не должны потеряться, ответ не срочен: заказы, накладные | асинхронно |
| Большие пакеты раз в день: справочники, выгрузки в учёт | файлы или пакетная загрузка |
Частая ошибка
Интегрироваться через общую базу: читать и писать прямо в таблицы чужой системы. Переименование колонки у соседа ломает ваш код, а его бизнес-правила обходятся.
Как устроено
| Стиль | Чем связаны системы | Главный минус |
|---|---|---|
| Файловый обмен | форматом файла и расписанием | данные устаревают до следующей выгрузки |
| Общая база | схемой таблиц | схему нельзя менять без соседей |
| Удалённый вызов (REST, gRPC, SOAP) | временем: оба должны работать | отказ получателя — отказ вызывающего |
| Сообщения через брокер | форматом сообщения | задержка, дубли, порядок |
Когда что выбирать
- Пользователь ждёт ответа, операция короткая — синхронный вызов с таймаутом.
- Операция долгая, но клиенту нужен результат — асинхронный запрос-ответ: сервер сразу отвечает 202 Accepted с адресом, по которому клиент узнаёт статус.
- Событие интересно нескольким системам — сообщение в брокер, а не вызов каждой.
Ловушки
- Таймаут — не отказ. Если ответ не пришёл, неизвестно, выполнилась ли операция. Повтор без защиты создаёт дубль.
- Асинхронно — не значит быстрее. Сообщение может лежать в очереди минуты, поэтому за глубиной очереди следят.
- Недописанный файл. Получатель забирает файл, пока его ещё пишут. Файл пишут под временным именем и переименовывают в конце.
Правило
По умолчанию между системами — сообщения или API, общая база — никогда. Синхронно — только там, где без ответа нельзя продолжить.
Под капотом
- Синхронный вызов держит ресурсы вызывающего — поток, соединение, транзакцию — всё время ожидания. Медленный сосед исчерпывает их и превращает чужую задержку в ваш отказ.
- Брокер хранит сообщение, пока получатель его не подтвердит. Отказ получателя становится задержкой, а не ошибкой у отправителя.
- Общая база связывает системы сильнее всего: сосед зависит от схемы, блокировок, индексов и даже от вашего окна обслуживания.
Цена
- Доступность синхронной цепочки — произведение доступностей звеньев: 0,999⁵ ≈ 0,995, то есть почти 44 часа простоя в год вместо 9.
- Задержки цепочки складываются, а хвост задержки определяет самый медленный участник.
- Асинхронность покупается согласованностью в конечном счёте (eventual consistency): какое-то время системы видят разные данные.
Компромиссы
- Синхронный вызов проще писать, отлаживать и объяснять пользователю: ошибка видна сразу.
- Асинхронный обмен требует корреляции запросов и ответов, обработки дублей, мониторинга очередей и «застрявших» сообщений. Брокеры и гарантии доставки — тема «Очереди и Kafka».
- Файлы до сих пор нормальный выбор для больших пакетов и партнёров без API, но нужна договорённость о формате, именах и квитанциях.
Как проверить в проде
- Для синхронных зависимостей — p99 и доля ошибок исходящих вызовов, метрика
http.client.request.duration. - Для асинхронных — глубина очереди, возраст самого старого сообщения и число сообщений в очереди ошибок.
Итог: синхронная интеграция переносит чужие сбои к вам сразу, асинхронная — превращает их в задержку; общая база превращает чужую схему в ваш контракт.
using System.Threading.Channels;
var builder = WebApplication.CreateBuilder(args);builder.Services.AddSingleton(Channel.CreateUnbounded<string>());builder.Services.AddHostedService<ExportWorker>();
var app = builder.Build();
// Синхронно: ответ нужен сейчас, клиент ждётapp.MapGet("/stock/{sku}", (string sku) => new { sku, quantity = 42 });
// Асинхронно: принять, поставить в очередь и сразу ответить 202 Acceptedapp.MapPost("/exports/{docId}", async (string docId, Channel<string> queue) =>{ await queue.Writer.WriteAsync(docId); return Results.Accepted($"/exports/{docId}"); // по этому адресу клиент узнает статус});
app.Run();
// Получатель разбирает очередь в своём темпе.// Канал в памяти теряет сообщения при перезапуске — в проде здесь брокер.public class ExportWorker(Channel<string> queue, ILogger<ExportWorker> logger) : BackgroundService{ protected override async Task ExecuteAsync(CancellationToken stoppingToken) { await foreach (var docId in queue.Reader.ReadAllAsync(stoppingToken)) logger.LogInformation("Exported {DocId}", docId); }}- Почему общая база — плохая интеграция? — Схема становится контрактом с соседом: её нельзя менять, а бизнес-правила владельца данных обходятся.
- Что делать, если операция длится минуты, а клиент вызывает её по HTTP? — Асинхронный запрос-ответ: 202 Accepted, адрес статуса и опрос или callback.
- Как доступность зависит от длины синхронной цепочки? — Перемножается: каждое звено снижает общую доступность.
- Чем опасен файловый обмен? — Недописанные файлы, повторная загрузка того же файла и потерянные файлы без квитанции.
- Асинхронная интеграция всегда лучше? — Нет: она добавляет задержку, дубли и сложность отладки; там, где ответ нужен сразу, честнее синхронный вызов.
REST, gRPC или SOAP: чем отличаются и что когда выбрать?
Заголовок раздела «REST, gRPC или SOAP: чем отличаются и что когда выбрать?»Что это
- REST — URL обозначает ресурс, HTTP-метод — действие, тело обычно JSON. Подробно — в теме Web API.
- gRPC — клиент вызывает метод удалённого сервиса как обычный метод. Контракт описан в файле
.proto, по нему генерируются классы сервера и клиента. - SOAP — запрос и ответ — XML-конверт (envelope), контракт описан в WSDL. Так работают сервисы WCF и многие корпоративные системы.
Как пользоваться
- REST — контроллеры или Minimal API, клиент —
HttpClient. - gRPC — пакет
Grpc.AspNetCore, файл.proto,AddGrpc()иMapGrpcService<T>(). - SOAP-клиент — dotnet-svcutil генерирует прокси по WSDL.
Частая ошибка
Выбрать gRPC для API, которое вызывает браузер. Браузер не умеет вызывать gRPC напрямую, понадобится gRPC-Web или прокси.
Как устроено
| Стиль | Контракт и формат | Сильная сторона |
|---|---|---|
| REST | OpenAPI (необязательно), JSON | понятен всем, работает в браузере |
| gRPC | .proto (обязательно), Protobuf, HTTP/2 |
скорость, генерация клиента, потоки |
| SOAP | WSDL и XSD, XML | строгая схема, стандарты WS-* |
У gRPC четыре вида вызовов: unary (запрос-ответ), потоковый от сервера, потоковый от клиента и двунаправленный.
Когда что выбирать
| Признак | gRPC | REST |
|---|---|---|
| Клиент — браузер | только через gRPC-Web | да |
| Внутренние вызовы с высокой нагрузкой | да | можно, но тяжелее |
| Потоки в реальном времени | да | частично: SSE, WebSocket |
| Публичный API для партнёров | редко | да |
Ловушки
- Дедлайн. У gRPC-вызова нет дедлайна по умолчанию: без
deadlineвызов может ждать бесконечно. - Номера полей. В
.protoполе определяется номером. Сменить номер или переиспользовать удалённый — сломать совместимость. - WCF в .NET Core. Клиентские пакеты
System.ServiceModel.Http,System.ServiceModel.NetTcpподдерживают только подмножество возможностей WCF из .NET Framework, часть WS-* недоступна.
Правило
Внешний и браузерный API — REST, внутренний поток вызовов — gRPC, SOAP — только на стыке с тем, кто его требует.
Под капотом
- gRPC-вызов — отдельный поток (stream) HTTP/2. Сообщения идут с префиксом длины, а статус вызова — в trailers (
grpc-status), поэтому прокси должен пропускать HTTP/2 целиком. - Одно HTTP/2-соединение мультиплексирует много вызовов. За L4-балансировщиком все вызовы клиента уйдут на один сервер — нужен L7-балансировщик или балансировка на клиенте.
- Protobuf кодирует поле номером и типом. Неизвестные поля старый клиент пропускает, поэтому добавление поля совместимо, а смена номера — нет.
Цена
- Бинарный Protobuf не прочитать в логе или Fiddler без схемы.
- gRPC-Web в браузере поддерживает только unary и потоки от сервера.
- SOAP — самый тяжёлый: XML-конверт, пространства имён, иногда WS-Security с подписью каждого сообщения.
Компромиссы
- gRPC JSON transcoding (.NET 7+) публикует gRPC-сервис ещё и как REST по аннотациям в
.proto: один код, два протокола. - CoreWCF — порт серверной части WCF от сообщества .NET Foundation с поддержкой Microsoft. Он закрывает перенос, но не всё: распределённые транзакции и часть WS-* не поддерживаются.
- Клиенты к чужим SOAP-сервисам генерируют dotnet-svcutil; актуальная версия пакетов
System.ServiceModel.*— 10.x, вышла вместе с .NET 10.
Как проверить в проде
- dotnet-counters показывает счётчики
Grpc.AspNetCore.ServerиGrpc.Net.Client: число вызовов, ошибок и превышений дедлайна. - В трассировке вызовы gRPC помечены тегами
grpc.methodиgrpc.status_code, а у SOAP разбирают fault-ответы: ошибка приходит как<Fault>в теле.
Итог: gRPC — производительность и строгий контракт ценой браузера и читаемости, REST — универсальность, SOAP — совместимость; CoreWCF — мост для переноса, а не выбор для нового сервиса.
syntax = "proto3";option csharp_namespace = "Stock";
service StockService { rpc GetBalance (BalanceRequest) returns (BalanceReply); // unary rpc WatchBalances (WatchRequest) returns (stream BalanceReply); // поток от сервера}
message BalanceRequest { string sku = 1; int32 store_id = 2; }message BalanceReply { string sku = 1; int32 quantity = 2; }message WatchRequest { int32 store_id = 1; }using Grpc.Core;using Stock; // классы сгенерированы из .proto при сборке
var builder = WebApplication.CreateBuilder(args);builder.Services.AddGrpc();
var app = builder.Build();app.MapGrpcService<StockGrpcService>();app.Run();
public class StockGrpcService : StockService.StockServiceBase{ public override Task<BalanceReply> GetBalance(BalanceRequest request, ServerCallContext context) => Task.FromResult(new BalanceReply { Sku = request.Sku, Quantity = 42 });}Клиент — фрагмент: всегда с дедлайном.
using Grpc.Core;using Grpc.Net.Client;using Stock;
using var channel = GrpcChannel.ForAddress("https://stock.example.com");var client = new StockService.StockServiceClient(channel);try{ var reply = await client.GetBalanceAsync( new BalanceRequest { Sku = "SKU-1", StoreId = 17 }, deadline: DateTime.UtcNow.AddSeconds(2)); // без дедлайна вызов ждёт бесконечно Console.WriteLine(reply.Quantity); // 42}catch (RpcException ex) when (ex.StatusCode == StatusCode.DeadlineExceeded){ Console.WriteLine("Stock service timeout");}using System.ServiceModel;
var binding = new BasicHttpBinding(BasicHttpSecurityMode.Transport);var address = new EndpointAddress("https://erp.example.com/InvoiceService.svc");var factory = new ChannelFactory<IInvoiceService>(binding, address);
IInvoiceService client = factory.CreateChannel();string status = await client.GetStatusAsync("INV-1001"); // XML-конверт уходит по HTTPSConsole.WriteLine(status);
((IClientChannel)client).Close();factory.Close();
// Контракт повторяет WSDL; обычно его генерирует dotnet-svcutil[ServiceContract]public interface IInvoiceService{ [OperationContract] Task<string> GetStatusAsync(string invoiceNumber);}- Почему gRPC нельзя вызвать из браузера напрямую? — Браузер не даёт управлять HTTP/2 и читать trailers; нужен gRPC-Web или JSON transcoding.
- Какие виды вызовов есть в gRPC? — Unary, поток от сервера, поток от клиента и двунаправленный.
- Что будет, если сменить номер поля в
.proto? — Старые клиенты прочитают поле не туда или потеряют его: это ломающее изменение. - Как поднять старый WCF-сервис на .NET 10? — CoreWCF на сервере; клиенты — пакеты
System.ServiceModel.*и dotnet-svcutil. - Почему gRPC за L4-балансировщиком нагружает один сервер? — Все вызовы мультиплексируются в одно долгоживущее HTTP/2-соединение.
Как сделать вызов внешнего сервиса устойчивым?
Заголовок раздела «Как сделать вызов внешнего сервиса устойчивым?»Что это
Временный сбой — ошибка, которая пройдёт сама: обрыв сети, перезапуск сервиса, ответ 503. Устойчивый вызов переживает такие сбои и не зависает, если сосед упал надолго.
Как пользоваться
- Регистрируйте клиента через
IHttpClientFactory:builder.Services.AddHttpClient<ErpClient>(...). - Добавьте пакет
Microsoft.Extensions.Http.Resilienceи вызовите.AddStandardResilienceHandler(). - Передавайте
CancellationTokenво все вызовы, чтобы отмена запроса останавливала и исходящий вызов.
Частая ошибка
Повторять запрос в цикле без паузы и без ограничения. Если сосед перегружен, такие повторы добивают его окончательно.
Как устроено
AddStandardResilienceHandler выстраивает пять стратегий, от внешней к внутренней:
| Стратегия | Что делает | По умолчанию |
|---|---|---|
| Rate limiter | ограничивает одновременные запросы | 1000, без очереди |
| Total timeout | лимит на вызов со всеми попытками | 30 с |
| Retry | повтор при 5xx, 408, 429 и сетевых ошибках | 3 раза, экспонента от 2 с, jitter |
| Circuit breaker | размыкается при доле ошибок | 10% при 100+ запросах за 30 с, пауза 5 с |
| Attempt timeout | лимит на одну попытку | 10 с |
Circuit breaker — автомат с тремя состояниями: закрыт (вызовы идут), разомкнут (вызовы сразу отклоняются), полуоткрыт (пробный вызов решает, закрыться ли).
Когда что выбирать
- Любой HTTP-клиент — стандартный обработчик, параметры подстраивают под SLA соседа.
- Нужен запасной ответ: кеш, значение по умолчанию — свой конвейер через
AddResilienceHandlerсо стратегией fallback. - Вызов не через
HttpClient, например WCF или SFTP, — Polly v8 напрямую:ResiliencePipelineBuilder.
Ловушки
- Повтор POST. Стандартный обработчик повторяет все методы. Для неидемпотентных —
options.Retry.DisableForUnsafeHttpMethods()или ключ идемпотентности. - Несколько обработчиков. Два resilience-обработчика на одном клиенте повторяют друг друга. Документация советует один.
HttpClient.Timeout. Он по умолчанию 100 с и действует поверх конвейера; его делают не меньше общего таймаута.
Правило
Таймаут — всегда, повтор — только идемпотентного и с jitter, circuit breaker — на каждую внешнюю зависимость.
Под капотом
- Обработчик —
DelegatingHandlerв цепочкеHttpClient, внутри — конвейер Polly v8. Стратегия, добавленная первой, — внешняя. - Таймаут в Polly v8 кооперативный: отменяет
CancellationToken, и код должен его уважать. При срабатывании —TimeoutRejectedException. - Circuit breaker в Polly v8 считает долю ошибок за окно (
FailureRatio,MinimumThroughput,SamplingDuration). Классического счётчика подряд идущих ошибок в v8 нет. Bulkhead из Polly v7 заменён ограничителем конкурентности (AddConcurrencyLimiter, пакетPolly.RateLimiting).
Цена
- Каждый повтор — дополнительная нагрузка на соседа, который и так не справляется. Повторы на нескольких уровнях перемножаются.
- Повторы удлиняют ответ: три попытки с паузами 2, 4 и 8 с вместе с таймаутами легко превышают терпение пользователя. Общий таймаут ограничивает это.
- Разомкнутый circuit breaker — быстрый отказ, который надо обработать: ответ 503, fallback или сообщение в очередь на потом.
Компромиссы
- Где повторять. На одном уровне — обычно ближе всего к вызову. Сервис посередине цепочки ошибку пробрасывает, а не повторяет.
- Hedging.
AddStandardHedgingHandlerшлёт параллельную копию медленного запроса и берёт первый ответ. Это снижает хвост задержки ценой лишней нагрузки, и только для идемпотентных операций. Retry-After. Стандартный повтор учитывает этот заголовок в ответах 429 и 503: сосед сам говорит, когда возвращаться.
Как проверить в проде
- Polly публикует метрику
resilience.polly.strategy.eventsи логи событий: повторы, срабатывания таймаутов, размыкания breaker. - Сравнивают число исходящих попыток с числом логических вызовов: их отношение показывает долю повторов. Рост этой доли — первый признак проблем у соседа.
Итог: устойчивость — это бюджет времени, ограниченные повторы на одном уровне и быстрый отказ при больном соседе; без идемпотентности повторы превращаются в дубли.
using Microsoft.Extensions.Http.Resilience;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient<ErpClient>(c => c.BaseAddress = new Uri("https://erp.example.com")) .AddStandardResilienceHandler(options => { options.AttemptTimeout.Timeout = TimeSpan.FromSeconds(5); // одна попытка options.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(20); // все попытки вместе options.Retry.MaxRetryAttempts = 3; // экспонента и jitter включены по умолчанию options.Retry.DisableForUnsafeHttpMethods(); // POST, PUT, PATCH, DELETE не повторяем });
var app = builder.Build();
app.MapGet("/orders/{id}/erp-status", (string id, ErpClient erp, CancellationToken ct) => erp.GetOrderStatusAsync(id, ct));
app.Run();
public class ErpClient(HttpClient http){ public Task<string> GetOrderStatusAsync(string id, CancellationToken ct) => http.GetStringAsync($"/orders/{id}/status", ct);}using Polly;using Polly.CircuitBreaker;using Polly.Retry;
var pipeline = new ResiliencePipelineBuilder() .AddTimeout(TimeSpan.FromSeconds(30)) // внешняя: бюджет на все попытки .AddRetry(new RetryStrategyOptions { MaxRetryAttempts = 3, BackoffType = DelayBackoffType.Exponential, // 1 с, 2 с, 4 с UseJitter = true, // разброс, чтобы клиенты не били хором Delay = TimeSpan.FromSeconds(1) }) .AddCircuitBreaker(new CircuitBreakerStrategyOptions { FailureRatio = 0.5, MinimumThroughput = 10, SamplingDuration = TimeSpan.FromSeconds(30), BreakDuration = TimeSpan.FromSeconds(15) }) .AddTimeout(TimeSpan.FromSeconds(5)) // внутренняя: одна попытка .Build();
var status = await pipeline.ExecuteAsync(async ct => await GetInvoiceStatusAsync("INV-1001", ct));Console.WriteLine(status); // Accepted
static async Task<string> GetInvoiceStatusAsync(string number, CancellationToken ct){ await Task.Delay(100, ct); // здесь вызов SOAP-сервиса или SFTP return "Accepted";}- Почему повтор без jitter опасен? — Все клиенты повторяют в одни и те же моменты и бьют восстановившийся сервис синхронной волной.
- Чем таймаут попытки отличается от общего таймаута? — Первый ограничивает одну попытку, второй — весь вызов вместе с повторами и паузами.
- Что делает circuit breaker в полуоткрытом состоянии? — Пропускает пробный вызов: успех замыкает цепь, ошибка снова размыкает.
- Можно ли повторять POST? — Только если операция идемпотентна, например с ключом идемпотентности; иначе —
DisableForUnsafeHttpMethods. - Что заменило bulkhead в Polly v8? — Ограничитель конкурентности
AddConcurrencyLimiterиз пакетаPolly.RateLimiting.
Что такое идемпотентность и как обеспечить её в интеграциях?
Заголовок раздела «Что такое идемпотентность и как обеспечить её в интеграциях?»Что это
Идемпотентная операция — та, которую можно выполнить несколько раз с тем же результатом, что и один раз.
| Операция | Идемпотентна? |
|---|---|
| Установить статус «Отгружено» | да |
| Удалить заказ 42 | да |
| Прибавить 10 к остатку | нет |
| Создать заказ без ключа | нет: каждый повтор — новый заказ |
Зачем
Сеть ненадёжна. Если ответ потерялся, отправитель не знает, выполнилась ли операция, и повторяет её. Брокер после сбоя тоже доставит сообщение ещё раз.
Как пользоваться
Получатель хранит id обработанных сообщений. Если id уже есть — сообщение подтверждают и пропускают.
Частая ошибка
Считать, что повторов «не бывает». Они случаются при каждом сбое, а дубль накладной или списания находят только при сверке.
Как устроено
| Способ | Как работает | Пример |
|---|---|---|
| Естественная идемпотентность | операция задаёт состояние, а не меняет его | UPDATE ... SET status = 'Shipped', upsert по номеру документа |
| Дедупликация по id сообщения | таблица обработанных id с уникальным ключом | обработчик сообщений из брокера |
| Ключ идемпотентности | клиент присылает ключ, сервер хранит результат первой обработки | POST создания платежа |
Как работает Idempotency-Key в HTTP API, разобрано в теме Web API.
Когда что выбирать
- Можно переписать операцию как «установить» — это самый дешёвый путь, хранилище не нужно.
- Обработчик сообщений — дедупликация по id сообщения или бизнес-ключу документа.
- Создание сущности или платёж по API — ключ идемпотентности и сохранённый ответ.
Ловушки
- Проверка и запись раздельно. «Проверил, что ключа нет, потом обработал» — два параллельных повтора пройдут оба. Нужна атомарная вставка ключа с уникальным индексом.
- Метка до эффекта. Если сначала записать «обработано», а потом упасть, сообщение потеряется. Метка и эффект — в одной транзакции.
- Ключ без содержимого. Тот же ключ с другим телом запроса — ошибка клиента, а не повтор. Сервер сравнивает хеш тела и отвечает ошибкой.
Правило
Каждый обработчик входящих данных проектируют так, будто каждое сообщение придёт дважды.
Под капотом
- Надёжная дедупликация — уникальный индекс:
INSERTключа в той же транзакции, что и бизнес-изменение. Конфликт ключа — признак дубля, транзакцию откатывают, а сообщение подтверждают. - Для HTTP-ключа хранят состояние «в обработке» и готовый ответ. Повтор во время обработки получает 409, после — сохранённый ответ.
- Таблица обработанных сообщений у получателя — часть паттерна inbox; вместе с outbox его разбирают в теме «Очереди и Kafka».
Цена
- Хранилище ключей растёт с трафиком: нужен срок хранения (TTL) и регулярная очистка.
- Срок хранения ключа должен быть больше окна повторов отправителя. Если партнёр может перезагрузить файл через неделю, ключ, удалённый через сутки, не спасёт.
- Ключ в той же базе, что и данные, удобен для транзакции, но нагружает её при большом трафике.
Компромиссы
- Ключ из id сообщения или из бизнеса. Id сообщения ловит технические повторы, но не повторную отправку того же документа с новым id. Бизнес-ключ — номер документа и отправитель — ловит оба.
- Порядок. Дедупликация не спасает от старого сообщения, пришедшего после нового. Изменения состояния несут версию, и получатель применяет только более новую.
- Внешние эффекты. Отправку SMS или платёж не откатить транзакцией. Ключ передают провайдеру, если он его поддерживает, или ведут журнал отправленного и сверяют его.
Как проверить в проде
- Считают срабатывания дедупликации: рост доли дублей показывает проблемы у отправителя или брокера.
- Периодический запрос ищет дубли по бизнес-ключу — номеру документа, внешнему id — в целевых таблицах.
Итог: идемпотентность держится на атомарном уникальном ключе, разумном сроке его хранения и версиях против неправильного порядка.
var handler = new PriceHandler();var msg = new PriceChanged("m-1", "SKU-1", 49.90m, Version: 7);
Console.WriteLine(handler.Handle(msg)); // AppliedConsole.WriteLine(handler.Handle(msg)); // Duplicate: то же сообщение пришло повторноConsole.WriteLine(handler.Handle(msg with { MessageId = "m-0", Price = 45m, Version = 6 })); // Stale
public record PriceChanged(string MessageId, string Sku, decimal Price, long Version);
public class PriceHandler{ // В проде — таблицы с уникальным ключом, обновляемые в одной транзакции private readonly HashSet<string> _processed = []; private readonly Dictionary<string, (decimal Price, long Version)> _prices = [];
public string Handle(PriceChanged m) { if (!_processed.Add(m.MessageId)) return "Duplicate"; // дедупликация по id if (_prices.TryGetValue(m.Sku, out var current) && current.Version >= m.Version) return "Stale"; // старая версия пришла позже новой _prices[m.Sku] = (m.Price, m.Version); // «установить», а не «прибавить» return "Applied"; }}- Какие HTTP-методы идемпотентны? — GET, HEAD, OPTIONS, TRACE, PUT и DELETE; POST и PATCH — нет.
- Почему метку «обработано» пишут в одной транзакции с изменением? — Иначе сбой между ними даёт либо дубль, либо потерянное сообщение.
- Сколько хранить ключи идемпотентности? — Дольше максимального окна повторов отправителя, затем удалять по TTL.
- Что делать, если сообщения приходят не по порядку? — Передавать версию или время изменения и отбрасывать устаревшие.
- Как сделать идемпотентной отправку SMS? — Ключ на стороне провайдера или журнал отправленных с уникальным ключом и проверкой перед отправкой.
Как интегрироваться с внешними и legacy-системами (ERP, учётные системы, EDI-провайдеры)?
Заголовок раздела «Как интегрироваться с внешними и legacy-системами (ERP, учётные системы, EDI-провайдеры)?»Что это
- Legacy- и внешние системы — ERP, учётные системы, системы партнёров. Их нельзя поменять, у них свои форматы и ограничения.
- EDI (electronic data interchange) — обмен стандартными электронными документами между компаниями: заказ, уведомление об отгрузке, счёт. Обычно через EDI-провайдера.
Как пользоваться
- Сделайте адаптер — отдельный класс или сервис, который единственный знает формат внешней системы.
- Адаптер переводит чужие данные в ваши классы и обратно.
- Каждый входящий документ сохраните как есть, до разбора.
Частая ошибка
Протащить чужую модель в свой код: поля DOC_NO, RCV_FLAG и коды ERP расползаются по всей системе, и замена ERP превращается в переписывание.
Как устроено
- Anti-corruption layer (ACL). Слой между доменом и внешней системой: переводит модели, коды, единицы измерения и ошибки. Домен говорит на своём языке.
- Транспорт. API или SOAP для онлайн-запросов; файлы через SFTP или общую папку для пакетов; AS2 — HTTP с подписью, шифрованием и квитанцией MDN, классика EDI.
- Форматы EDI. UN/EDIFACT — сообщения
ORDERS(заказ),DESADV(уведомление об отгрузке),RECADV(приёмка),INVOIC(счёт). Многие провайдеры работают и с XML-форматами.
Когда что выбирать
| Ситуация | Подход |
|---|---|
| Одна-две внешние системы | адаптер точка-точка |
| Много партнёров с одинаковыми документами | провайдер EDI и одна внутренняя модель документа |
| Справочники и большие объёмы раз в сутки | пакетная загрузка файлов через staging |
Ловушки
- Нет квитанции — нет доставки. Файл, положенный на SFTP, ещё не обработан. Ждут подтверждения: MDN,
CONTRL, ответный статус. - Повторная загрузка. Тот же файл пришёл дважды — загрузку делают идемпотентной по номеру документа и отправителю.
- Тихие ошибки разбора. Документ, который не разобрался, должен попасть в очередь ошибок с алертом, а не в лог.
Правило
Сырое — сохранить, перевести — в адаптере, состояние — отслеживать, расхождения — ловить сверкой.
Под капотом
- Каждый документ — конечный автомат: получен, разобран, загружен, подтверждён или ошибка. Статус и корреляционный id хранят в журнале обмена.
- Входящий файл сохраняют в архив до разбора. После исправления маппинга документ переобрабатывают из архива, не прося партнёра прислать его снова.
- Сверка (reconciliation) — периодическое сравнение двух сторон: количество, суммы и номера документов за период. Она ловит то, что пропустили квитанции.
Цена
- Точка-точка: n систем, связанных каждая с каждой, — до n(n−1)/2 интеграций, и у каждой свой маппинг.
- Шина сокращает число связей до одного адаптера на систему, но добавляет центральный компонент, его лицензию, отдельную команду и единую точку отказа.
- Каноническая модель снижает число маппингов, но её изменение затрагивает всех участников.
Компромиссы
- ESB или «умные концы, глупые трубы». BizTalk — хаб с адаптерами, маршрутизацией, оркестрациями и встроенной поддержкой EDI и AS2. Современный подход: брокер только доставляет, а логика перевода живёт в небольших сервисах-адаптерах.
- Свой EDI или провайдер. Провайдер берёт на себя транспорт, форматы конкретных сетей и квитанции. Вы платите зависимостью и форматом провайдера.
- Постепенная замена legacy — тема «Архитектурные стили».
Как проверить в проде
- Алерт на документы без квитанции дольше SLA и на рост очереди ошибок разбора.
- Ежедневная сверка с отчётом расхождений: отправлено и получено, суммы по счетам, документы без ответа.
Итог: надёжная интеграция с legacy держится на изоляции модели, журнале статусов, архиве для повторной обработки и сверке, а шина — компромисс между числом связей и централизацией.
using System.Globalization;
var row = new ErpShipmentRow("0000123456", "20261009", "Y", "4820000000017;12|4820000000024;6");var delivery = ErpShipmentTranslator.ToDomain(row);
Console.WriteLine($"{delivery.Number}: {delivery.Lines.Count} lines, received={delivery.IsReceived}");// 123456: 2 lines, received=True
// Модель внешней системы — как есть, со всеми её особенностямиpublic record ErpShipmentRow(string DocNo, string DocDate, string RcvFlag, string Lines);
// Своя модельpublic record Delivery(long Number, DateOnly Date, bool IsReceived, IReadOnlyList<DeliveryLine> Lines);public record DeliveryLine(string Gtin, int Quantity);
// Anti-corruption layer: перевод в одном месте, домен не знает о форматах ERPpublic static class ErpShipmentTranslator{ public static Delivery ToDomain(ErpShipmentRow row) => new( long.Parse(row.DocNo, CultureInfo.InvariantCulture), DateOnly.ParseExact(row.DocDate, "yyyyMMdd", CultureInfo.InvariantCulture), row.RcvFlag == "Y", row.Lines.Split('|') .Select(line => line.Split(';')) .Select(p => new DeliveryLine(p[0], int.Parse(p[1], CultureInfo.InvariantCulture))) .ToList());}-- Отправленные документы, на которые за 4 часа не пришла квитанцияSELECT d.doc_number, d.partner_id, d.sent_atFROM edi_outbound AS dLEFT JOIN edi_ack AS a ON a.doc_id = d.idWHERE a.doc_id IS NULL AND d.sent_at < now() - interval '4 hours'ORDER BY d.sent_at;- Что такое anti-corruption layer и где его границы? — Слой перевода между доменом и внешней системой; за его пределы не выходят чужие модели, коды и ошибки.
- Чем AS2 отличается от SFTP? — AS2 — HTTP с подписью, шифрованием и квитанцией MDN; SFTP — передача файлов по SSH без подтверждения обработки.
- Как найти «потерянный» документ? — По журналу статусов и корреляционному id; системно — алертом на документы без квитанции и ежедневной сверкой.
- Зачем хранить сырой входящий файл? — Чтобы переобработать его после исправления маппинга и разбирать споры с партнёром.
- Когда нужна шина, а когда хватит точка-точка? — Шина окупается при многих системах и общих документах; на двух-трёх связях она лишняя.