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

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

Зачем GraphQL рядом с готовым REST

У карты авиамаршрутов уже был рабочий REST API с десятком эндпоинтов: аэропорты, авиакомпании, маршруты, BFS-поиск между городами. Клиентское приложение при старте делало три параллельных запроса: список авиакомпаний с агрегатами, крупные аэропорты, средние аэропорты. При открытии карточки аэропорта — ещё два параллельных: метаданные аэропорта и маршрутная сеть.

Проблема не в количестве — это вполне нормально для REST. Проблема в том, что клиент каждый раз получал больше данных, чем использовал, и при этом не мог объединить независимые запросы в один roundtrip без сторонней оркестрации.

GraphQL решает именно это: клиент сам описывает нужные поля, а несколько независимых запросов можно упаковать в один.

Что добавилось на бэкенде

Три новых файла в backend/src/graphql/:

Copy
typeDefs.js   ← SDL-схема (20 типов)
resolvers.js  ← резолверы с SQL-запросами
index.js      ← graphql-yoga middleware

И две строки в index.js — импорт и монтирование:

js Copy
import { airlinesGraphql } from './graphql/index.js';
app.use('/api/airlines/graphql', airlinesGraphql);

Никаких изменений в существующих контроллерах и REST-маршрутах. Всё старое осталось работать как прежде.

Схема: что умеет GraphQL endpoint

graphql Copy
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 Copy
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 Copy
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 Copy
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 Copy
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 Copy
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 Copy
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 Copy
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 Copy
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 Copy
largeAirports: airports(country: "RU", type: "large_airport", limit: 200) {
  data { iataCode icaoCode name municipality countryName latitudeDeg longitudeDeg }
}

Сервер возвращает ровно 7 полей. При переходе на карточку аэропорта — уже полный набор включая переводы. Два разных запроса, два разных профиля полей — без изменений на сервере.

3. Схема — это живая документация

До GraphQL единственная документация по API — это код контроллеров и неявное знание разработчика. GraphQL SDL — это декларативный контракт, который всегда актуален, потому что генерируется из работающего кода:

graphql Copy
type AirlineDestinations {
  airline: String
  summary: DestinationsSummary
  departures: [DepartureAirport!]!
  arrivals: [ArrivalAirport!]!
  routes: [RoutePair!]!
}

Что получает поле departures, чем RoutePair отличается от DepartureAirport, какие поля обязательны — всё это видно прямо в схеме без чтения SQL. GraphiQL превращает схему в интерактивный справочник с автодополнением.

4. Параллельное выполнение резолверов на сервере

GraphQL-сервер автоматически выполняет независимые резолверы параллельно. Запрос LoadInitialData с тремя полями — routeStatsByAirline, largeAirports, mediumAirports — выполняет три резолвера одновременно на стороне сервера. Клиент пишет просто:

graphql Copy
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-телом.

Читать далее

19.05.2026

Admin-панель мониторинга: дашборд, аналитика, логи и API-эксплорер

Обзор admin-панели для Node.js-сервиса: от сводного дашборда с метриками сервера и кэша до интерактивного API-эксплорера, в котором можно формировать запросы и смотреть ответы прямо в браузере.

Метки
adminmonitoringnodejsvue3devtools
19.05.2026

Мониторинг с алертами в Telegram: гибкие правила и синтетические проверки в admin-панели

Добавил в admin-панель модуль мониторинга — несколько Telegram-ботов, три типа правил алертов с фильтрами и пороговыми значениями, синтетические HTTP-проверки с уведомлениями по провалам.

Метки
monitoringtelegramalertsdevopsnodejs