Архитектура движка подбора билетов: три GDS, железная дорога и маршрут, который меняют в дороге

redb.Routeredb

Сотруднику нужно долететь до одного города, доехать поездом до другого и вернуться тем же путём. Одна командировка, билеты из разных систем бронирования. А потом он уже в дороге пишет в телеграм: «планы изменились, летим не туда, перебронируй».

Три системы под самолёты (Amadeus, Sabre, Travelport) исторически несовместимы: разные XML-диалекты одного и того же понятия перелёта, выросшие из мейнфреймов 60–80-х, каждый со своими причудами и полями, которых нет у соседа. Плюс отдельная, никак не связанная с ними система бронирования под железную дорогу: свой формат, своя логика мест и классов, ничего общего по структуре с авиационными GDS. Запросить всё это параллельно, свести разноформатные ответы в одно и собрать валидный маршрут по стыковкам уже само по себе задача не для россыпи if и ручных мапперов.

А дальше человек в дороге меняет одно плечо маршрута. Один перелёт уже случился, трогать нельзя. Один впереди, подлежит замене. И весь маршрут не по шаблону «туда-обратно», а любой длины и состава: сегодня две пересадки, завтра четыре, послезавтра прямо в пути вставили лишний вылет.

Это не про «дёрнуть API и показать список». Это про архитектуру, у которой три несущих оси, и обычно строят только одну. Разберём каждую по механике, с кодом, и честно очертим границу, где задача GDS кончается и начинается задача движка.

Три оси задачи

  1. Форма данных. Маршрут это не пара outbound/return, а дерево разнотипных плеч (самолёт ≠ поезд) переменной длины, которое меняется посреди собственной жизни. Куда это положить, чтобы новый вид транспорта не означал миграцию, а запрос «маршруты с непройденным ж/д-плечом в отмене» оставался запросом в БД, а не выборкой всего в память.
  2. Поток. Сбор из четырёх систем разом, нормализация четырёх форматов в один, построение стыковок, ветка «менять/нельзя менять». Либо это читаемый DSL на языке интеграционных паттернов (Scatter-Gather, Normalizer, Content-Based Router: словарь Apache Camel), либо код, который через полгода не восстановит и автор.
  3. Согласование. Переоформление в дороге, когда маршрут собран из независимых билетов разных источников, ни один из которых не видит соседей. Это не reissue внутри одного PNR, это распределённая транзакция поверх систем, которые друг о друге не знают.

Ось потока живёт в redb.Route (это Apache Camel под .NET), ось формы в типизированном хранилище redb, ось согласования на стыке того и другого. По порядку.

Ось формы: канонная модель и маршрут-дерево

Первое архитектурное решение принимается до единой строчки интеграции: завести каноничную модель (Canonical Data Model) и не пускать диалекты источников дальше границы. Amadeus, Sabre, Travelport и ж/д говорят по-разному, но внутри системы ходит один Segment и один Offer. Всё, что за границей адаптера (построение стыковок, цена, хранилище, кабинет), работает только с каноном.

public sealed class Segment                     // одно плечо, независимо от источника
{
    public TransportKind Kind { get; set; }     // Flight | Rail
    public string From { get; set; } = "";       // IATA/станция отправления
    public string To { get; set; } = "";
    public DateTimeOffset DepartAt { get; set; }
    public DateTimeOffset ArriveAt { get; set; }
    public string Source { get; set; } = "";      // amadeus | sabre | travelport | rail
    public string SourceRef { get; set; } = "";   // PNR/локатор в системе-источнике
    public FareInfo Fare { get; set; } = new();
    public bool Departed { get; set; }            // плечо уже пройдено: заморожено
}

Маршрут это не плоский список, а дерево: командировка ветвится на «туда» и «обратно», multi-city даёт несколько ветвей, а внутри ветви идут плечи по порядку. redb хранит деревья нативно, поэтому Itinerary ложится ровно так, как выглядит в жизни:

public sealed class Itinerary
{
    public string PassengerId { get; set; } = "";
    public ItineraryStatus Status { get; set; }   // Searching → Offered → Booked → InTransit → Reissuing
    public List<Leg> Legs { get; set; } = new();  // упорядоченные, число не фиксировано
}

public abstract class Leg { public bool Departed { get; set; } public string Source { get; set; } = ""; }
public sealed class FlightLeg : Leg { public string Pnr = ""; public string FareBasis = ""; public string Cabin = ""; }
public sealed class RailLeg   : Leg { public string Carriage = ""; public string SeatClass = ""; }

В реляционной модели это больное место. Список разнотипных плеч переменной длины раскладывают либо в широкую таблицу с nullable-колонками под все виды транспорта разом (и половина всегда пустая), либо в разнородную кашу «ключ-значение», либо заводят миграцию под каждый новый вид перевозки. redb хранит Itinerary типизированным объектом: плечи это props, FlightLeg и RailLeg сохраняются со своими типами, RTTI не теряется. Добавили BusLeg завтра, миграции нет, схема расширилась сама.

Отдельно про то, как это ускоряет саму разработку. В такой предметной области модель не устаканивается за один заход: сегодня к FlightLeg добавляется Cabin, завтра появляется BusLeg, послезавтра плечо получает под-структуру для багажа. В классической схеме каждый такой шаг это миграция: написать, отревьюить, прогнать на всех окружениях, не забыть откат, поймать разъехавшуюся staging-базу. Здесь миграций нет вообще: класс и есть схема. Меняете доменную модель в C#, SaveAsync сам раскладывает новую форму, старые объекты продолжают читаться. На стадии, когда структура маршрута ещё в движении, это снимает главный тормоз итерации: правишь тип и работаешь дальше, а не обслуживаешь миграционную ленту под каждую догадку.

Ключевое для эксплуатации: запросы остаются серверными. Диспетчеру нужно «все маршруты, где есть непройденное ж/д-плечо в статусе отмены перевозчиком»:

var stuck = await redb.Query<Itinerary>()
    .Where(i => i.Status == ItineraryStatus.InTransit
             && i.Legs.Any(l => l is RailLeg && !l.Departed && l.Source == "rail"))
    .ToListAsync();

Это уходит в вашу PostgreSQL или MS SQL как SQL, а не тянет все маршруты в память под фильтр в C#. Хранилище живёт в той базе, что уже в проде, рядом с остальными таблицами, с настоящими внешними ключами и общими транзакциями. Объект, который собрал маршрут, и объект, который показывает его в кабинете, это один тип от бэкенда до браузера. Сохранение одной строкой, апдейт летит диффом (только изменённые поля):

await redb.SaveAsync(itinerary);   // вставка или обновление, авто-дифф по _hash

Ось потока: Scatter-Gather, Normalizer, частичный результат

Теперь сбор. Запрос уходит во все источники параллельно, ответы сводятся в один. В каноне это Scatter-Gather, и в redb.Route он first-class элемент DSL, а не рукописный Task.WhenAll с прикрученным сбором и обработкой падений:

From("direct://search")
    .ScatterGather()
        .To("direct://src-amadeus")
        .To("direct://src-sabre")
        .To("direct://src-travelport")
        .To("direct://src-rail")
    .Timeout(TimeSpan.FromSeconds(8))       // медленный GDS не топит весь поиск
    .AggregationStrategy(new OfferMerge())  // разноформатные ответы → единый набор Offer
    .To("direct://build-itineraries");

Каждый src-* это Normalizer: отдельный маршрут, который знает диалект своей системы и обязан вернуть канон. Он вызывает источник по SOAP/HTTP через коннектор redb.Route.Http, разбирает ответ своим Unmarshal и раскладывает поля через строковый expression-движок (XPath/JSONPath прекомпилируются один раз при сборке маршрута):

From("direct://src-amadeus")
    .OnException<SourceUnavailable>().To("direct://dead-offers").MarkHandled().End()  // источник упал → в DLQ, не роняем Gather
    .To("https://amadeus/air?wsdl-style=soap")     // вызов источника
    .Unmarshal(typeof(AmadeusSoap))                // XML-диалект Amadeus → промежуточный объект
    .Process(new AmadeusToCanonical())             // → List<Segment> в каноне
    .WireTap("direct://audit-raw");                // сырой ответ в аудит (разбор споров позже)

Два момента, на которых обычно и разваливается наивная реализация:

  • Частичный результат. Amadeus ответил, Travelport завис. Timeout на Gather закрывает окно, OnException увёл упавший источник в DLQ, а поиск завершается с тем, что успело прийти, вместо того чтобы упасть целиком или ждать самого медленного. Пользователь видит предложения, а не спиннер.
  • Нормализация на границе. AmadeusToCanonical, SabreToCanonical, RailToCanonical это единственное место в системе, где живёт диалект источника. Пятый источник это ещё один Normalizer-маршрут и ещё один .To(...) в Gather, а не правка сборщика и не новый if в двадцати местах.

OfferMerge (AggregationStrategy) сливает канонные сегменты всех источников в один пул. После него система больше не знает и не хочет знать, кто прислал сегмент: дальше работают только Segment и Offer.

Построение стыковок: где кончается GDS и начинается движок

Собрать валидный маршрут из пула сегментов это не сортировка. Сегменты стыкуются, если аэропорт прилёта одного совпадает с аэропортом вылета следующего и между ними легальное время пересадки (MCT, minimum connection time), а плечи разных видов транспорта стыкуются по вокзалу/аэропорту и запасу на трансфер. Здесь и проходит граница, которую важно назвать честно, иначе задача выглядит выдуманной.

У Amadeus давно есть Amadeus Ticket Changer (ATC): он автоматически пересчитывает тариф при добровольном и вынужденном reissue по правилам Category 31/33 ATPCO и учитывает policy перевозчика. Это решённая задача, и на практике с неё и начинают: сначала смотрят, что каждый GDS предлагает сам по себе, потому что это уже оптимальные, провалидированные через тариф связки (through-fare), и в первую очередь оценивают именно их.

Проблема начинается на втором шаге. Когда self-connect выгоднее (иногда через-тариф от одного GDS дешевле, иногда комбинация из разных источников), маршрут собран из независимых билетов, и ни один GDS не видит два других. ATC сам предупреждает о своей границе: билеты, выписанные через другие GDS, гарантией не покрываются. Движок берёт ровно то, чего не видит ни один источник: строит self-connect из разных систем, сравнивает его с through-fare и ранжирует по тому, что важнее заказчику (гарантированная стыковка одним источником или экономия ценой риска на пересадке). Через-тарифные связки приходят готовыми от источника, self-connect движок стыкует сам:

From("direct://build-itineraries")
    .Process(new ConnectionBuilder(minConnectionRules))  // пул Segment → кандидаты-маршруты
    .Split(e => ((IReadOnlyList<Itinerary>)e.In.Body!))   // каждый кандидат отдельно
        .Process(new FeasibilityCheck())                  // MCT, смена терминала, ночь между плечами
        .Process(new RankByPolicy())                      // through-fare vs self-connect по policy клиента
    .End()
    .To("direct://present");

Ось согласования: переоформление в дороге как saga

Самое сложное это изменение в пути. Прилетело сообщение «перебронируй», и у маршрута из независимых билетов часть плеч заморожена (уже пройдены), часть подлежит замене. Reissue внутри одного PNR тут не работает: билеты в разных системах, и замена одного плеча это распределённая транзакция поверх источников, которые не знают друг о друге. Значит нужна saga с компенсацией: каждое сменяемое плечо переоформляется в своём источнике, и если один шаг не прошёл, откатываем то, что успели.

И вход в этот процесс идёт из телеграма, то есть at-least-once: одно и то же «перебронируй» может прийти дважды (ретрай клиента, дубль вебхука). Значит на входе Idempotent Consumer, иначе один запрос переоформит маршрут дважды.

From("telegram://reissue")
    .IdempotentConsumer(e => e.In.GetHeader<string>("update_id"))   // дубль вебхука не переоформит дважды
    .Enrich("direct://load-itinerary")                             // подтянуть текущее дерево из redb
    .Saga()
        .Split(e => ((Itinerary)e.In.Body!).Legs.Where(l => !l.Departed))  // только будущие плечи
            .Choice()
                .When(e => Reprice((Leg)e.In.Body!) is { Cheaper: true })
                    .To("direct://swap-leg")               // переоформить в источнике этого плеча
                .Otherwise()
                    .To("direct://keep-leg")
            .EndChoice()
        .End()
        .Compensation("direct://rollback-swaps")           // не прошло одно плечо → откат остальных
    .End()
    .To("direct://save-and-notify");

Заморозка прошлого не пожелание, а инвариант: Departed-плечо неизменяемо, saga его даже не берёт в Split. Дерево маршрута после переоформления сохраняется одной SaveAsync, и авто-дифф пишет в БД только изменённые узлы, а не всё дерево заново. Оптимистичная блокировка по _hash ловит гонку, если фоновый пересчёт цены столкнётся с ручным переоформлением.

Четвёртая ось, о которой вспоминают позже: цена и 1С

Подбор это ещё не вся задача. Рассчитать стоимость по тарифной сетке, свести публичные и внутренние (private) тарифы, наложить систему скидок и связать всё с 1С отдельная работа, и она такая же разноформатная, как сбор из GDS. Тариф источника, наценка агентства, корпоративная скидка клиента сходятся в одном месте, и это снова Content-Based Router поверх того же expression-движка, а не спагетти из вложенных if:

From("direct://price")
    .Choice()
        .When(e => Client(e).HasPrivateFares).Process(new ApplyPrivateFare())
        .Otherwise().Process(new ApplyPublicFare())
    .EndChoice()
    .Process(new ApplyAgencyMarkup())
    .Process(new ApplyCorporateDiscount())
    .To("direct://sync-1c");        // выгрузка в 1С тем же коннектором (HTTP или очередь)

Результат расчёта ложится на тот же канонный Offer рядом с маршрутом, а выгрузка в 1С это коннектор redb.Route (HTTP или очередь, смотря как у вас поднят обмен), а не самописный клиент. Одна модель предложения едет от GDS через цену до документа в 1С, не пересобираясь на каждом стыке из чужого формата в свой.

Что даёт связность: наблюдаемость и аудит бесплатно

Когда все четыре оси на одном наборе, сквозные вещи появляются сами. Один распределённый трейс покрывает поиск от direct://search через все четыре источника до сохранения: видно, кто из GDS тормозил и почему предложение оказалось именно таким. WireTap в каждом Normalizer складывает сырой ответ источника в аудит, и разбор спора «почему клиенту показали этот тариф» это чтение записи, а не археология по логам. Статистика эндпоинтов (сколько запросов к каждому источнику, доля таймаутов, ошибки) видна в дашборде redb.Tsak рядом с остальными маршрутами. Ничего из этого не пришлось встраивать: это свойства DSL, а не отдельная обвязка на каждый источник.

Сборка воедино

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

  • Форма это типизированное дерево в redb: маршрут любой длины из разнотипных плеч ложится объектом, RTTI сохраняется, новый вид транспорта не требует миграции, запросы остаются серверным SQL в вашей же базе.
  • Поток это redb.Route: Scatter-Gather с частичным результатом, Normalizer на границе каждого источника, Content-Based Router на построении стыковок, ретраи и DLQ как элементы DSL.
  • Согласование это saga с компенсацией и Idempotent Consumer на входе: переоформление поверх независимых билетов с заморозкой пройденных плеч.
  • Стыки наружу (GDS, 1С) это коннекторы, а не самописные клиенты на каждый диалект.

Граница у архитектуры честная: through-fare reissue внутри одного GDS решает сам GDS (ATC, Category 31/33), и движок его не дублирует. Движок берёт то, чего не видит ни один источник по отдельности: self-connect из разных систем и его пересборку в дороге. Именно на этой части и окупается связный инструментарий вместо шва посередине.

Главное, что видно из этой раскладки: на экосистеме redb такой движок собирается проще, структурнее и читабельнее, чем из россыпи самописных клиентов, ORM-миграций и ручного маппинга. Каждая ось ложится на готовый инструмент со своим словарём (EIP для потока, типизированное дерево для формы, saga для согласования), а не на очередной слой клея. Архитектура получается на языке, который понимает и тот, кто её не писал, а разработка идёт без миграционной ленты и без пересборки модели на каждом стыке. Задача остаётся сложной по сути, GDS никуда не делись, но перестаёт быть сложной на ровном месте: сложность уходит туда, где она настоящая (стыковки, тарифы, согласование источников), а не растекается по интеграционному клею.

Если вы делали интеграцию с GDS или ж/д, интересно, на какой из осей ломались вы. Обычно это одна из трёх, и почти всегда чинят только одну.


Другие мои статьи: redb.ru/articles, ещё на Хабре.