GraphQL поверх REST: как я добавил слой GraphQL к Airlines API и перевёл фронт на один roundtrip

Зачем GraphQL рядом с готовым REST
У карты авиамаршрутов уже был рабочий REST API с десятком эндпоинтов: аэропорты, авиакомпании, маршруты, BFS-поиск между городами. Клиентское приложение при старте делало три параллельных запроса: список авиакомпаний с агрегатами, крупные аэропорты, средние аэропорты. При открытии карточки аэропорта — ещё два параллельных: метаданные аэропорта и маршрутная сеть.
Проблема не в количестве — это вполне нормально для REST. Проблема в том, что клиент каждый раз получал больше данных, чем использовал, и при этом не мог объединить независимые запросы в один roundtrip без сторонней оркестрации.
GraphQL решает именно это: клиент сам описывает нужные поля, а несколько независимых запросов можно упаковать в один.
Что добавилось на бэкенде
Три новых файла в backend/src/graphql/:
typeDefs.js ← SDL-схема (20 типов)
resolvers.js ← резолверы с SQL-запросами
index.js ← graphql-yoga middleware
И две строки в index.js — импорт и монтирование:
js
import { airlinesGraphql } from './graphql/index.js';
app.use('/api/airlines/graphql', airlinesGraphql);
Никаких изменений в существующих контроллерах и REST-маршрутах. Всё старое осталось работать как прежде.
Схема: что умеет GraphQL endpoint
graphql
type Query {
routeStats: RouteStats
routeStatsByAirline(lang: String): [AirlineRouteStat!]!
airports(country: String, type: String, initial: String,
prefix: String, iataOnly: Boolean, limit: Int ...): AirportsResult!
airport(iata: String!, lang: String): Airport
airportBatch(iatas: [String!]!): [Airport!]!
airportInitials(iataOnly: Boolean): JSON!
cities(initial: String, limit: Int ...): CitiesResult!
citySearch(name: String!, limit: Int): [City!]!
cityInitials: JSON!
airlineDestinations(code: String!): AirlineDestinations
airportNetwork(code: String!): AirportNetwork
cityRoutes(depCity: String!, depCountry: String!,
arrCity: String!, arrCountry: String!, maxStops: Int): [CityRouteGroup!]!
}
Из нестандартного — скалярный тип JSON для возврата словарей инициалов ({ "A": 42, "B": 18, ... }), где заранее неизвестен полный набор ключей.
Типы данных спроектированы camelCase по GraphQL-конвенции, тогда как в БД всё snake_case — маппинг происходит в резолверах.
Резолверы: те же SQL-запросы, никакого дублирования логики
Главное решение — резолверы напрямую используют databaseService с теми же SQL-запросами и теми же ключами кеша, что и контроллеры. Кешированные ответы работают для обоих слоёв.
Пример резолвера airlineDestinations — три параллельных SQL-запроса, которые раньше были разбросаны по контроллеру:
js
airlineDestinations: async (_parent, { code }) => {
const c = code.toUpperCase();
const [departures, arrivals, pairs] = await Promise.all([
databaseService.select(
`SELECT r.dep_iata as iata, w.name as airport_name, w.municipality as city,
count(DISTINCT r.arr_iata)::int as destinations, count(*)::int as flights
FROM "public"."routes" r
LEFT JOIN "public"."world_airport" w ON w.iata_code = r.dep_iata
WHERE r.airline_iata = ? OR r.airline_icao = ?
GROUP BY r.dep_iata, r.dep_icao, w.name, w.municipality, w.country_name
ORDER BY destinations DESC`,
[c, c], `airline-${c}-departures`, 3600,
),
// ... arrivals и pairs аналогично
]);
return {
airline: c,
summary: { departureAirports: departures.length, ... },
departures: departures.map(d => ({ iata: d.iata, airportName: d.airport_name, ... })),
// ...
};
},
Ключевая деталь — databaseService.select() принимает cache key и TTL. Запрос airline-SU-departures с TTL 3600 секунд работает одинаково из REST и из GraphQL. Кеш не дублируется.
airportBatch: N запросов → один
Раньше при загрузке маршрутов авиакомпании клиент делал Promise.all() с отдельным /airports/iata/:code на каждый незнакомый аэропорт. Десятки параллельных запросов для новой авиакомпании.
Теперь — один airportBatch:
js
airportBatch: async (_parent, { iatas }) => {
const codes = iatas.map(c => c.toUpperCase());
const ph = codes.map(() => '?').join(',');
const rows = await databaseService.select(
`SELECT * FROM "public"."world_airport" WHERE iata_code IN (${ph})`,
codes, null, 0,
);
return rows.map(r => mapAirport({ ...r, _translation: null }));
},
На клиенте:
ts
const loadMissingAirports = async (iatas: string[]) => {
const data = await gql<{ airportBatch: any[] }>(
`query AirportBatch($iatas: [String!]!) {
airportBatch(iatas: $iatas) { iataCode icaoCode name municipality countryName latitudeDeg longitudeDeg }
}`,
{ iatas },
);
for (const raw of data.airportBatch ?? []) {
const airport = Airport.fromApiResponse(airportFromGql(raw));
if (airport?.hasCoords()) airportCache[airport.iata] = airport;
}
};
BFS-поиск маршрутов между городами в GraphQL
Это самый объёмный резолвер — алгоритм обхода в ширину по аэропортам с кешированием промежуточных результатов. Раньше он жил только в контроллере. Теперь продублирован в резолвере (300 строк), потому что функция была приватной и не экспортировалась.
Логика та же: раскрываем frontier аэропортов слой за слоем, кешируем маршруты из каждого аэропорта в routeCache, останавливаемся при достижении аэропортов города назначения. Максимум 50 путей на каждый уровень пересадок, максимум 400 узлов в frontier.
js
cityRoutes: async (_parent, { depCity, depCountry, arrCity, arrCountry, maxStops = 4 }) => {
const [depAirports, arrAirports] = await Promise.all([
getCityAirportCodes(depCity, depCountry),
getCityAirportCodes(arrCity, arrCountry),
]);
let frontier = new Map();
for (const ap of depAirports) frontier.set(ap, [ap]);
const routeCache = new Map();
for (let stop = 0; stop <= maxStops && frontier.size > 0; stop++) {
const toFetch = [...lastAirports].filter(ap => !routeCache.has(ap));
// один SQL на весь уровень, не N запросов по одному
const rows = await databaseService.select(
`SELECT DISTINCT dep_iata, arr_iata, airline_iata
FROM "public"."routes" WHERE dep_iata IN (${ph})`,
toFetch, null, 0,
);
// BFS expansion...
}
// ...
};
Миграция клиента: убрали rest-pipeline-js
На клиенте удалён createRestClient и PipelineOrchestrator из rest-pipeline-js. Вместо этого — минималистичный хелпер:
ts
async function gql<T = any>(query: string, variables?: Record<string, any>): Promise<T> {
const res = await fetch(GRAPHQL_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, variables }),
});
const json = await res.json();
if (json.errors?.length) throw new Error(json.errors[0].message);
return json.data as T;
}
Никаких дополнительных зависимостей. GraphQL — это просто POST-запрос с JSON-телом.
Поскольку GraphQL возвращает camelCase-поля, а модели Airport и Company ожидают snake_case-интерфейс, добавлены адаптеры:
ts
function airportFromGql(raw: any): AirportApiResponse {
return {
iata_code: raw.iataCode,
icao_code: raw.icaoCode,
latitude_deg: raw.latitudeDeg,
longitude_deg: raw.longitudeDeg,
country_name: raw.countryName,
// ...
};
}
Модели и компоненты не тронуты — адаптер на границе стора.
Начальная загрузка: три запроса → один
Самый заметный результат — loadInitialData. Раньше три параллельных REST-запроса через оркестратор. Теперь один GraphQL-запрос с алиасами:
graphql
query LoadInitialData {
routeStatsByAirline {
airlineIata airlineIcao airlineName airlineCountry
airportsCount routesCount routes depAirports arrAirports
}
largeAirports: airports(country: "RU", type: "large_airport", limit: 200) {
data { iataCode icaoCode name municipality countryName latitudeDeg longitudeDeg }
}
mediumAirports: airports(country: "RU", type: "medium_airport", limit: 200) {
data { iataCode icaoCode name municipality countryName latitudeDeg longitudeDeg }
}
}
GraphQL-сервер выполняет три резолвера параллельно. Клиент получает всё одним roundtrip.
Аналогично карточка аэропорта — запрос метаданных и маршрутной сети объединён в один:
graphql
query AirportInfo($code: String!) {
airport(iata: $code, lang: "ru") {
iataCode name municipality countryName latitudeDeg longitudeDeg
translation { lang name city countryName }
}
airportNetwork(code: $code) {
airlines { iata icao airlineName destinations flights }
}
}
GraphiQL в dev-режиме
graphql-yoga включает интерактивную площадку по умолчанию. В dev-режиме она доступна на /api/airlines/graphql — можно исследовать схему, выполнять запросы, смотреть типы. В production отключается через graphiql: process.env.NODE_ENV !== 'production'.
Профиты: что конкретно изменилось
1. Меньше roundtrip на критических путях
Самое ощутимое изменение — количество HTTP-запросов при ключевых пользовательских сценариях:
| Сценарий | REST (было) | GraphQL (стало) |
|---|---|---|
| Открытие приложения | 4 запроса (stats + airlines + airports_large + airports_medium) | 1 запрос |
| Клик по аэропорту | 2 параллельных запроса (airport + network) | 1 запрос |
| Выбор авиакомпании (новые аэропорты) | N запросов по одному на каждый аэропорт | 1 batch-запрос |
Вместо PipelineOrchestrator, который делал параллельные REST-запросы и ждал все, — один POST с JSON-телом. Латентность на мобильных соединениях снижается непропорционально: каждый HTTP-запрос несёт накладные расходы на установку соединения, TLS handshake и заголовки.
2. Нет overfetching: клиент запрашивает ровно то, что нужно
REST-эндпоинт /airports возвращает полный объект аэропорта — 22 поля, включая elevation_ft, home_link, wikipedia_link, keywords и другие, которые нужны только в детальной карточке. При начальной загрузке для рендера карты нужны только координаты, IATA, название и страна — 6 полей.
GraphQL-запрос при старте:
graphql
largeAirports: airports(country: "RU", type: "large_airport", limit: 200) {
data { iataCode icaoCode name municipality countryName latitudeDeg longitudeDeg }
}
Сервер возвращает ровно 7 полей. При переходе на карточку аэропорта — уже полный набор включая переводы. Два разных запроса, два разных профиля полей — без изменений на сервере.
3. Схема — это живая документация
До GraphQL единственная документация по API — это код контроллеров и неявное знание разработчика. GraphQL SDL — это декларативный контракт, который всегда актуален, потому что генерируется из работающего кода:
graphql
type AirlineDestinations {
airline: String
summary: DestinationsSummary
departures: [DepartureAirport!]!
arrivals: [ArrivalAirport!]!
routes: [RoutePair!]!
}
Что получает поле departures, чем RoutePair отличается от DepartureAirport, какие поля обязательны — всё это видно прямо в схеме без чтения SQL. GraphiQL превращает схему в интерактивный справочник с автодополнением.
4. Параллельное выполнение резолверов на сервере
GraphQL-сервер автоматически выполняет независимые резолверы параллельно. Запрос LoadInitialData с тремя полями — routeStatsByAirline, largeAirports, mediumAirports — выполняет три резолвера одновременно на стороне сервера. Клиент пишет просто:
graphql
query {
routeStatsByAirline { ... }
largeAirports: airports(country: "RU", type: "large_airport") { ... }
mediumAirports: airports(country: "RU", type: "medium_airport") { ... }
}
Никакой оркестрации на клиенте, никакого Promise.all — это забота сервера.
5. Один batch-запрос вместо N параллельных
Паттерн «загрузить N аэропортов по IATA» появлялся в трёх местах: при выборе авиакомпании, при поиске маршрутов между городами и при ensureAirports. Каждый раз это был Promise.all() с отдельным HTTP-запросом на каждый аэропорт.
Для авиакомпании с широкой сетью — например, «Аэрофлот» с ~200 уникальными аэропортами вылета — при первом выборе это означало 200 параллельных запросов. Браузеры ограничивают параллельные соединения к одному хосту (6 у Chrome), поэтому часть запросов ждала в очереди.
С airportBatch весь список уходит одним SQL IN (...) и возвращается одним ответом.
6. Строгая типизация на всём пути
REST-контроллер возвращает res.json(...) — произвольный объект, тип которого клиент угадывает по документации или экспериментально. GraphQL-схема определяет контракт точно: какие поля есть, какие nullable, какие гарантированно не null (!).
На клиенте — дженерик-хелпер gql<T>(). Типы GraphQL-ответа описаны инлайн или могут быть сгенерированы из схемы автоматически. Если схема изменится, TypeScript поймает несоответствие на этапе компиляции, а не в runtime.
7. Аддитивность: REST остался работать
Критически важный профит для постепенной миграции. GraphQL добавлен как отдельный endpoint, не заменяя ни одного REST-маршрута. Оба слоя используют один databaseService с общим кешем — запрос airline-SU-departures с TTL 3600 секунд прогревается тем, кто обратился первым, и отдаётся из кеша для обоих.
Это значит: можно мигрировать клиент постепенно, можно оставить часть интеграций на REST, можно добавлять новые GraphQL-типы не ломая старые эндпоинты.
Что получилось в итоге
REST-эндпоинты остались нетронутыми — /api/airlines/airports, /api/airlines/routes и всё остальное работает как прежде. GraphQL живёт рядом как дополнительный слой, через тот же databaseService с общим кешем.
Клиент делает меньше roundtrip на критических путях, запрашивает ровно те поля, которые нужны, а схема служит живой документацией с интерактивной площадкой в dev-режиме. Три параллельных REST-запроса при старте превратились в один GraphQL, N запросов на аэропорты — в один batch. Без переписывания контроллеров, без новых клиентских зависимостей — просто fetch с JSON-телом.