Архитектура движка подбора билетов: три GDS, железная дорога и маршрут, который меняют в дороге
Сотруднику нужно долететь до одного города, доехать поездом до другого и вернуться тем же путём. Одна командировка, билеты из разных систем бронирования. А потом он уже в дороге пишет в телеграм: «планы изменились, летим не туда, перебронируй».
Три системы под самолёты (Amadeus, Sabre, Travelport) исторически несовместимы: разные XML-диалекты одного и того же понятия перелёта, выросшие из мейнфреймов 60–80-х, каждый со своими причудами и полями, которых нет у соседа. Плюс отдельная, никак не связанная с ними система бронирования под железную дорогу: свой формат, своя логика мест и классов, ничего общего по структуре с авиационными GDS. Запросить всё это параллельно, свести разноформатные ответы в одно и собрать валидный маршрут по стыковкам уже само по себе задача не для россыпи if и ручных мапперов.
А дальше человек в дороге меняет одно плечо маршрута. Один перелёт уже случился, трогать нельзя. Один впереди, подлежит замене. И весь маршрут не по шаблону «туда-обратно», а любой длины и состава: сегодня две пересадки, завтра четыре, послезавтра прямо в пути вставили лишний вылет.
Это не про «дёрнуть API и показать список». Это про архитектуру, у которой три несущих оси, и обычно строят только одну. Разберём каждую по механике, с кодом, и честно очертим границу, где задача GDS кончается и начинается задача движка.
Три оси задачи
- Форма данных. Маршрут это не пара
outbound/return, а дерево разнотипных плеч (самолёт ≠ поезд) переменной длины, которое меняется посреди собственной жизни. Куда это положить, чтобы новый вид транспорта не означал миграцию, а запрос «маршруты с непройденным ж/д-плечом в отмене» оставался запросом в БД, а не выборкой всего в память. - Поток. Сбор из четырёх систем разом, нормализация четырёх форматов в один, построение стыковок, ветка «менять/нельзя менять». Либо это читаемый DSL на языке интеграционных паттернов (Scatter-Gather, Normalizer, Content-Based Router: словарь Apache Camel), либо код, который через полгода не восстановит и автор.
- Согласование. Переоформление в дороге, когда маршрут собран из независимых билетов разных источников, ни один из которых не видит соседей. Это не 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, ещё на Хабре.