Раздел 9 · Backend

GraphQL, gRPC и кодогенерация

middle~40 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

GraphQL, gRPC и кодогенерация

REST не единственный способ устроить API. В этом уроке два главных конкурента: GraphQL, отдающий форму ответа на откуп клиенту, и gRPC, превращающий API в типизированные процедуры поверх HTTP/2. Третья тема склеивает всё: контракт (схема, proto, OpenAPI) как источник истины, из которого генерируются клиенты и типы.

Чтобы сравнение было честным, держим тот же домен, что в уроке про REST: события и билеты.

GraphQL: клиент решает, какие данные ему нужны

Боль, из которой вырос GraphQL: REST-эндпоинт отдаёт фиксированную форму. Мобильному списку билетов нужны три поля, а GET /me/tickets отдаёт тридцать (overfetching). Экрану билета нужны билет, событие и продавец, а это три запроса (underfetching). GraphQL переворачивает договор: сервер описывает граф данных схемой, клиент запрашивает ровно ту форму, которая ему нужна, одним запросом.

# схема: сервер описывает, ЧТО доступно
type Event {
  id: ID!
  title: String!
  date: String!
  tickets: [Ticket!]!
}

type Ticket {
  id: ID!
  seat: String!
  price: Int!
  event: Event!
}

type Query {
  myTickets(first: Int, after: String): [Ticket!]!
  event(id: ID!): Event
}

type Mutation {
  buyTicket(eventId: ID!, seat: String!): Ticket!
}
# запрос: клиент описывает, ЧТО ему нужно
query {
  myTickets(first: 10) {
    seat
    price
    event { title date }
  }
}

Ответ повторяет форму запроса: JSON с полями seat, price и вложенным event. Ни одного лишнего байта, ни одного лишнего запроса. Единая точка входа POST /graphql, типизированная схема с интроспекцией (клиент может спросить у сервера, какие типы и поля существуют), и фрагменты с директивами для переиспользования кусков запросов. Это модель.

Resolvers и проблема N+1

Сервер исполняет запрос через резолверы: на каждое поле своя функция. myTickets достаёт билеты, Ticket.event достаёт событие конкретного билета. И здесь зарыта знаменитая грабля.

Запросили 100 билетов с событиями. Резолвер myTickets сделал один запрос к БД. А дальше движок вызвал Ticket.event для каждого билета: ещё 100 запросов. Это N+1 во всей красе, и в GraphQL он возникает не по неосторожности, как в ORM, а по самой модели исполнения: резолвер поля видит только свой билет и ничего не знает о соседях.

Лечится батчингом через DataLoader:

import DataLoader from 'dataloader';

// один на HTTP-запрос: собирает id событий со всех резолверов
const eventLoader = new DataLoader(async (ids: readonly string[]) => {
  const rows = await db.query('SELECT * FROM events WHERE id = ANY($1)', [ids]);
  const byId = new Map(rows.map((row) => [row.id, row]));
  return ids.map((id) => byId.get(id) ?? null); // порядок ответа = порядок ключей
});

const resolvers = {
  Ticket: {
    // вместо db.query на каждый билет: load копит ключи и батчит
    event: (ticket) => eventLoader.load(ticket.eventId),
  },
};

Сто вызовов load за один тик event loop склеиваются в один WHERE id = ANY(...). Сто один запрос превратился в два. Два правила, без которых DataLoader стреляет в ногу: loader создаётся на каждый входящий запрос (иначе его кеш переживёт запрос и отдаст чужие или устаревшие данные), и функция батча обязана вернуть результаты ровно в порядке ключей.

Когда GraphQL окупается

Цена входа заметная: схема, резолверы, DataLoader, своя история с кешированием (HTTP-кеш почти не работает: всё POST на один URL), своя защита от злых запросов (глубина, сложность, persisted queries). Окупается, когда:

  • клиентов много и они разные: web, iOS, Android, партнёры, и каждому нужна своя форма данных;
  • API агрегирует несколько источников: GraphQL-слой собирает граф из трёх сервисов и двух БД;
  • фронту нужна скорость итераций: новая фича без нового эндпоинта.

Не окупается: один фронт, простой CRUD, команда из трёх человек. Там GraphQL это REST с лишними шагами.

Ошибки в GraphQL: errors[] против errors-as-data

В REST статус ответа очевиден. В GraphQL транспортный статус почти всегда 200, а внутри тела два поля: data и errors. И это место, где проектируют плохо чаще всего.

По умолчанию ошибка резолвера попадает в массив errors, а соответствующее поле в data становится null:

{
  "data": { "myTickets": null },
  "errors": [
    {
      "message": "Database connection lost",
      "path": ["myTickets"],
      "extensions": { "code": "INTERNAL_ERROR", "traceId": "a1b2c3" }
    }
  ]
}

Заметь: data и errors могут прийти одновременно. Это partial result: половина графа загрузилась, половина упала. Клиент обязан проверять оба поля, и большинство клиентов этого не делает.

Проблема массива errors глубже неудобства: он нетипизирован. Схема ничего не говорит о том, какие ошибки бывают у buyTicket, и клиент узнаёт о «SOLD_OUT» из документации или из продакшена. Поэтому зрелый подход разделяет ошибки на два сорта:

  • Технические (база упала, нет прав на запрос, кривой синтаксис): остаются в errors, с машиночитаемым extensions.code и traceId для поиска в логах.
  • Бизнесовые (распродано, место занято, рефанд просрочен): это не ошибки исполнения, а законные исходы операции. Им место в схеме, подход называется errors-as-data:
type BuyTicketSuccess { ticket: Ticket! }
type SoldOut { waitlistAvailable: Boolean! }
type SeatTaken { suggestedSeats: [String!]! }

union BuyTicketResult = BuyTicketSuccess | SoldOut | SeatTaken

type Mutation {
  buyTicket(eventId: ID!, seat: String!): BuyTicketResult!
}
mutation {
  buyTicket(eventId: "7", seat: "A12") {
    ... on BuyTicketSuccess { ticket { id seat } }
    ... on SoldOut { waitlistAvailable }
    ... on SeatTaken { suggestedSeats }
  }
}

Теперь все исходы видны в типах, клиент не может забыть обработать SoldOut (кодогенерация превратит union в discriminated union в TS, и switch без ветки не скомпилируется), а у каждого исхода свои полезные данные. Это та же идея, что result-типы вместо исключений из урока про functional core, только на уровне API-контракта.

HTTP-статус при этом остаётся 200 для любых бизнес-исходов: транспорт доехал, операция исполнилась, результат в теле. Статусы 4xx и 5xx остаются транспорту: невалидный запрос, нет аутентификации, сервер упал.

дальше после входа

Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.

Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.