← назад к разделу

Представьте экран профиля в мобильном приложении: нужны имя пользователя, аватар и число заказов. В привычном REST вы дёргаете /users/42 — и получаете всё поле в поле: адрес, телефон, дату регистрации, настройки уведомлений. Половина ответа не нужна, но она приехала по проводу. А чтобы получить заказы, приходится делать ещё один запрос к /users/42/orders. GraphQL появился в Facebook именно из этой боли: пусть клиент сам опишет, какие данные ему нужны, и получит их одним запросом. Разберёмся, как это устроено и чем приходится платить.

Ниже — тот же экран профиля в REST и в GraphQL, и цена, которую сервер платит за гибкость.

REST: экран профиля — три обращения подряд, каждое ждёт предыдущегоGET /users/4220 полей, нужны 2GET /users/42/ordersждёт первогоGET /orders/{id}/itemsждёт второгоитого: 3 круга по сети, 18 полей из 20 — лишние GraphQL: клиент описывает форму данных — один POST /graphqlquery { user(id: "42") {name avatarUrlorders { status amount } } }1 круг по сети4 поля: 2 из user + 2 из ordersни телефона, ни настроек цена: тот же запрос, но на списке из 20 пользователейusers { orders }наивные резолверы21 к базе1 на список + 20 на заказы dataloader батчитWHERE user_id IN (...)2 к базетот же ответ, 2 запроса вместо 21а HTTP-кэш по URL всё равно потерян: у всех один POST /graphql

Три круга по сети превращаются в один, и лишние поля не едут. Но тот же запрос на списке из 20 пользователей даёт 21 обращение к базе вместо двух — пока резолверы не батчат через dataloader.

Обязательно

Две проблемы REST: over-fetching и under-fetching

У жёстких REST-ответов есть два симметричных недостатка.

Over-fetching — пришло лишнее. Endpoint возвращает фиксированный набор полей, и если вам нужны только два из двадцати, остальные восемнадцать всё равно сериализуются, летят по сети и парсятся. На мобильной сети это заметно.

Under-fetching — пришло недостаточно, и приходится делать несколько запросов. Экран профиля с заказами — это /users/42, потом /users/42/orders, потом, может, /orders/{id}/items для каждого заказа. Так рождается «водопад» запросов: каждый следующий ждёт предыдущего, и экран собирается медленно.

REST борется с этим костылями — специальные endpoint'ы «под экран», параметры вроде ?fields=name,avatar. Работает, но контракт начинает обрастать частными случаями. GraphQL решает обе проблемы одним приёмом.

Идея: один endpoint и форма данных

В GraphQL, в отличие от REST, нет множества URL под каждый ресурс. Есть один endpoint (обычно /graphql), и клиент шлёт туда запрос, который описывает форму нужных данных — какие поля и какие вложенные объекты вернуть.

Вы не думаете «какой URL дёрнуть». Вы думаете «какой кусок графа данных мне нужен» и рисуете его прямо в запросе. Сервер возвращает JSON ровно той же формы — ничего лишнего, ничего недостающего.

Схема и резолверы

Клиент просит user { name orders { total } }, и сервер должен знать две вещи: что такое orders у user и откуда взять total для каждого заказа. Первое описывает схема: строго типизированное описание того, какие данные существуют и как они связаны; второе делают резолверы, о них ниже. Это контракт, аналог .proto в gRPC — источник правды, по которому проверяется каждый запрос.

type User {
  id: ID!
  name: String!
  avatarUrl: String
  orders: [Order!]!
}

type Order {
  id: ID!
  status: String!
  amount: Int!
}

type Query {
  user(id: ID!): User
}

Восклицательный знак ! значит «поле не может быть null». Query — точка входа для чтения.

За каждым полем стоит резолвер — функция, которая знает, как это поле добыть: сходить в базу, вызвать другой сервис, посчитать. Резолвер user достанет пользователя по id; резолвер orders внутри User — подтянет его заказы. Сервер соединяет резолверы по дереву запроса и собирает ответ.

запрос обход дерева user { name orders } резолвер name поле готовой строки резолвер avatarUrl поле готовой строки резолвер orders свой запрос в базу у каждого заказа резолвер status резолвер amount

Каждое поле запроса тянет свой резолвер: name и avatarUrl берутся из уже прочитанной строки, orders идёт за данными сам, и у каждого заказа снова свои резолверы.

Query, mutation, subscription

В GraphQL три типа операций.

  • Query — чтение. «Дай мне вот эти данные». Не меняет состояние, как GET в REST.
  • Mutation — изменение. Создать заказ, отменить, обновить профиль — всё, что меняет данные.
  • Subscription — подписка на события. Сервер сам присылает обновления, когда что-то произошло (новое сообщение, смена статуса заказа) — обычно поверх WebSocket.

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

Пример: запрос и ответ

Тот самый экран профиля — один запрос вместо трёх:

query {
  user(id: "42") {
    name
    avatarUrl
    orders {
      status
      amount
    }
  }
}

Ответ приходит ровно той же формы — только запрошенные поля:

{
  "data": {
    "user": {
      "name": "Анна",
      "avatarUrl": "https://.../42.png",
      "orders": [
        { "status": "paid", "amount": 1990 },
        { "status": "shipped", "amount": 3500 }
      ]
    }
  }
}

Ни телефона, ни настроек — их никто не просил. И заказы приехали вместе с пользователем, без второго обращения к серверу. Обе боли REST закрыты одним запросом.

Чем платят за гибкость

Гибкость не бесплатна — она сдвигает сложность на сервер. Прежде чем брать GraphQL, стоит понимать цену.

Кэширование сложнее. REST-GET кэшируется прокси и CDN по URL из коробки: одинаковый адрес — одинаковый ответ. В GraphQL запрос обычно уходит одним POST на один /graphql, тело у всех разное — по URL кэшировать нечего. Приходится кэшировать на уровне приложения или клиента, а это заметно больше работы. Вернуть себе HTTP-кэш всё-таки можно: спецификация разрешает и GET, и если заранее зарегистрировать запросы на сервере, клиент будет слать вместо текста короткий идентификатор — такой адрес кэшируется как обычный REST. Но настраивать это придётся отдельно.

Проблема N+1 на резолверах. Запросили список из 20 пользователей и у каждого — заказы. Наивный резолвер orders сходит в базу 20 раз, по разу на пользователя, — плюс один запрос на сам список. Получаем 21 обращение вместо двух. Стандартное лекарство — dataloader: он собирает все id за один «тик», батчит их в один запрос к базе (WHERE user_id IN (...)) и раскладывает результат обратно. Про природу этой проблемы — в статье про N+1 в Hibernate.

Контроль нагрузки. Раз клиент сам строит запрос, он может построить и очень тяжёлый: глубокую вложенность или список из миллиона элементов. Один кривой запрос способен положить сервер. Поэтому в GraphQL добавляют ограничения — максимальную глубину запроса, лимит сложности, тайм-ауты. В REST такого класса риска почти нет: набор ответов там фиксирован заранее.

Версионирование через эволюцию схемы. В REST плодят /v1, /v2. В GraphQL версий обычно нет: схему развивают на месте — добавляют новые поля, а устаревшие помечают @deprecated, но не удаляют, пока есть клиенты. Это удобно, но требует дисциплины: сломать старый клиент легко, если убрать поле раньше времени.

Ошибки: код всегда 200

Самое важное отличие, которое отменяет привычку ветвиться по коду ответа: GraphQL отвечает 200 OK почти всегда, в том числе когда что-то сломалось. Ошибки живут в теле ответа, в массиве errors.

{
  "data": {
    "order": {
      "id": "ord-42",
      "customer": null
    }
  },
  "errors": [
    {
      "message": "Сервис клиентов недоступен",
      "path": ["order", "customer"],
      "extensions": { "code": "UPSTREAM_UNAVAILABLE", "retryable": true }
    }
  ]
}

Читается так: заказ получили, клиента получить не смогли, и в path указано, какая именно ветка ответа пустая. Это и есть частичный ответ — законное состояние, а не сбой: половина запроса выполнена.

Отсюда три правила для клиента. Проверять errors всегда, а не только при не-двухсотом коде. Отличать «поле пустое, потому что так есть» от «поле пустое, потому что ошибка» — по наличию записи в errors с соответствующим путём. И не полагаться на код ответа: 400 приходит только при синтаксически неверном запросе, 500 — при падении самого сервера.

Для сервера правило другое: машинный код ошибки кладут в extensions — стандарт этого не требует, но без него клиенту остаётся разбирать человеческое сообщение. Туда же кладут признак повторяемости и идентификатор трассировки. То есть всё то, что в REST лежит в теле ошибки, здесь переезжает в extensions — и это надо описать в контракте, потому что схема этого не описывает.

Пагинация: курсоры, edges и pageInfo

В GraphQL есть устоявшееся соглашение о постраничном выводе, и оно курсорное — то самое, что в REST приходится описывать самому.

query {
  orders(first: 20, after: "Y3Vyc29yOjIw") {
    edges {
      cursor
      node { id status totalAmount }
    }
    pageInfo { hasNextPage endCursor }
  }
}

Три элемента: edges (список связей, у каждой свой курсор), node (сам объект) и pageInfo с признаком «есть ещё» и курсором последнего элемента. Выглядит громоздко и решает те же задачи, что курсорная пагинация в REST: страницы не сдвигаются при вставке новых записей, глубина не стоит ничего.

Зачем лишний уровень edges: на связи можно повесить данные об отношении, а не об объекте — когда добавлен в список, кто добавил, какой вес у связи. В простых случаях это ненужная обёртка, и тогда отдают просто список; но если API публичный, соглашение обычно соблюдают целиком, потому что на него рассчитаны клиентские библиотеки.

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

Сохранённые запросы: как вернуть кэш

«Кэширование сложнее» — проблема, у которой есть штатное лекарство, и его стоит назвать.

Обычный запрос идёт POST с телом — значит, не кэшируется ни браузером, ни сетью доставки. Сохранённые запросы переворачивают это: текст запроса заранее известен серверу (его зарегистрировали при сборке клиента или при первом обращении), и клиент присылает только отпечаток запроса плюс переменные. А раз тело маленькое, запрос уходит через GET — и снова становится кэшируемым.

GET /graphql?extensions={"persistedQuery":{"version":1,"sha256Hash":"a4f2…"}}&variables={"id":"ord-42"}

Что это даёт, кроме кэша: размер запроса перестаёт зависеть от размера выборки (полезно для мобильных клиентов), а сервер может разрешить только зарегистрированные запросы — то есть закрыть возможность прислать произвольный дорогой запрос. Для публичного API это часто главный довод: гибкость GraphQL остаётся у своих клиентов, а снаружи доступен фиксированный набор.

Цена: клиент и сервер должны согласовать список запросов при сборке (появляется шаг в конвейере), а отладка усложняется — в журнале виден отпечаток, а не текст.

Как считают сложность запроса

Ограничения «по глубине и сложности» полезны только с числами, а числа берутся из правил.

Глубина — самое простое: сколько уровней вложенности разрешено. order → customer → orders → customer это четыре уровня; предел в 8–10 отсекает рекурсивные запросы, не мешая обычным. Считается статически, до выполнения.

Сложность — оценка стоимости, и её назначают полям вручную. Каждому полю дают вес: скаляр — 0 или 1, поле, требующее отдельного обращения к базе — больше, список — вес элемента, умноженный на запрошенное количество (first). Сумма по дереву запроса и есть его сложность; предел ставят так, чтобы обычные запросы проходили с запасом, а «дай мне тысячу заказов, у каждого клиента, у каждого его заказы» — не проходил.

orders(first: 100) { … }        → 100 × (вес заказа)
  customer { … }                → + 100 × (вес клиента, потому что на каждый заказ)

Отсюда и главный вывод, который не видно из общих слов: сложность считается до выполнения и потому защищает по-настоящему — запрос отклоняется, не начав работу. Таймаут выполнения тоже нужен, но он ловит уже начатое.

Практический порядок внедрения: сначала предел глубины (дешево, ловит грубые случаи), потом предел числа элементов в списках (обязательный first с максимумом), потом сложность с весами. И обязательно — журнал отклонённых запросов, чтобы понять, не режете ли вы своих.

Авторизация: право проверяется в каждом резолвере

В REST право проверяется на ручке: есть доступ к GET /orders/{id} — отдаём весь ответ. В GraphQL клиент сам собирает форму ответа, и поэтому проверка переезжает внутрь: на каждое поле, которое может оказаться чужим.

Пример, на котором это видно: запрос order { id, customer { email, phone } }. Право «видеть заказ» есть, а право «видеть телефон клиента» — нет. Значит, проверка должна стоять на резолвере телефона, а не на входе в запрос. Если её там нет, поле утечёт — и никакая проверка на уровне ручки этого не поймает, потому что ручка одна и общая.

Отсюда три практических следствия, из-за которых авторизация в GraphQL дороже.

Права описывают по полям, а не по операциям. Это больше правил и больше тестов; зато они точнее.

Ошибка доступа частична. Правильный ответ — отдать то, что можно, и положить в errors запись с путём к запрещённому полю. Отказать всему запросу тоже можно, но это хуже для клиента.

Легко пропустить путь. Одно и то же поле бывает доступно разными путями (order.customer.email и customer.email), и проверка нужна в резолвере поля, а не в резолвере запроса, — иначе один путь закрыт, другой открыт.

Практический вывод: в GraphQL авторизацию проектируют вместе со схемой, а не добавляют после. И проверяют её тестами по полям — в REST для этого хватало тестов по ручкам.

Как это выглядит в Java-проекте

Одной фразой для ориентира: схема лежит ресурсом (*.graphqls), поддержка GraphQL в Spring поднимает одну ручку и сопоставляет поля схемы с методами — тип и поле указываются аннотацией сопоставления (@QueryMapping, @SchemaMapping), а пакетная загрузка связанных сущностей (чтобы не получить N+1 по резолверам) делается загрузчиками, которые собирают ключи и читают их одним запросом. Проблема N+1 здесь ровно та же, что в ORM, и решается тем же приёмом — разбор в статье про N+1.

Где это применяется

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

  • Мобильные и веб-приложения с богатыми экранами, где каждый экран собирает свою комбинацию полей — и хочется избежать «водопада» запросов на медленной сети.
  • Агрегация данных из нескольких источников за одним фасадом: клиент делает один запрос, а сервер сам ходит в разные сервисы и базы.
  • Быстро меняющийся фронтенд: новые экраны просят новые срезы данных, не дожидаясь новых endpoint'ов от бэкенда.

Где GraphQL — не лучший выбор:

  • Простое CRUD-API с предсказуемыми ответами — REST будет проще и дешевле, а бесплатное HTTP-кэширование останется при вас.
  • Внутренние высоконагруженные вызовы между сервисами, где важны скорость и строгий контракт, — там уместнее gRPC.
  • Файлы, выгрузки, потоковая отдача — не сильная сторона GraphQL.

Где спотыкаются начинающие

  • Забывают про N+1. Схема красивая, запрос элегантный, а под капотом сотни обращений к базе. Dataloader (батчинг) — не опция, а норма для любых списков со вложенностью.
  • Оставляют запросы без ограничений. Без лимита глубины и сложности один тяжёлый клиентский запрос кладёт сервер. Ограничения ставят сразу, а не после первого инцидента.
  • Ждут «бесплатного» кэша как в REST. По одному POST на /graphql HTTP-кэш не работает; кэширование надо продумывать отдельно.
  • Удаляют поля из схемы сгоряча. Клиент, который его запрашивал, немедленно ломается. Сначала @deprecated, потом — только когда никто не пользуется.
  • Тащат GraphQL в простое API «потому что модно», получая сложность резолверов и потерю HTTP-кэша там, где хватило бы REST.
Дополнительно: при первом чтении можно пропустить

Глубже: REST, gRPC, GraphQL или вебхук: одна таблица решенийрасширенное

Каждая статья раздела честно говорит, когда её стиль не брать, а собирать выбор приходится читателю. Вот он в одном месте. Вопросы задают по порядку, и первый же уверенный ответ обычно решает.

ВопросRESTgRPCGraphQLВебхук
Кто клиентбраузер, партнёры, кто угодносвои сервисысвои фронтенды с разными экранамичужой сервер, который ждёт событий
Кто инициаторклиент спрашиваетклиент спрашиваетклиент спрашиваетвы сообщаете
Форма данныхфиксированная на ресурсфиксированная на методклиент выбирает поляфиксированная на событие
Много вызовов на экранплохо, нужны агрегирующие ручкинормально, вызовы дешёвыерешается одним запросомне про это
Нужен HTTP-кэш и CDNда, GET кэшируетсянетнет, один POSTнет
Нужен стримингSSE или WebSocket рядомвстроен, в обе стороныподписки поверх WebSocketэто и есть поток событий
Строгий контракт и генерацияOpenAPI, по желаниюобязательно, .protoсхема, обязательносхема события в OpenAPI
Отладка «на коленке»curl и глазаgrpcurl, бинарный форматконсоль GraphiQLжурнал доставок
ОшибкиHTTP-статус и problem+jsongrpc-status и Status.details200 и массив errorsответ получателя и повторы
Ценасамая низкаягенерация и прокси для браузерарезолверы, N+1, лимиты сложностиочередь, повторы, подпись

Как этим пользоваться. Публичный контур почти всегда REST: его умеют все, он кэшируется, у него понятные ошибки. Внутри системы между своими сервисами, где вызовов много и важен строгий контракт, gRPC. Фасад для нескольких фронтендов с разными экранами, которые собирают разные срезы одних данных, GraphQL, и он живёт поверх REST или gRPC внутренних сервисов, а не вместо них. Событие, о котором надо сообщить чужой системе, вебхук, и рядом с ним выгрузка по курсору для догона.

Стили сочетаются, а не исключают друг друга: типичная система это REST наружу, gRPC внутри, вебхуки к партнёрам и, если фронтендов больше одного, GraphQL-фасад перед REST. Ошибка не в выборе одного из них, а в попытке сделать один стиль ответом на все четыре вопроса: gRPC для браузера, GraphQL для двух вызовов в секунду между сервисами, опрос вместо вебхука для сорока тысяч интеграций.

Коротко

  • GraphQL решает over-fetching и under-fetching: один endpoint, клиент называет форму данных, сервер собирает через резолверы.
  • Плата: N+1 без батчинга, лимиты глубины и сложности обязательны, HTTP-кэш не работает, ошибки приходят с 200 в массиве errors.
  • Место GraphQL это фасад для нескольких фронтендов поверх внутренних сервисов, а не замена REST для CRUD.
  • Выбор стиля по вопросам: кто клиент и инициатор, форма данных, число вызовов на экран, кэш, стриминг, контракт; REST наружу, gRPC внутри, GraphQL как фасад, вебхук для событий чужим системам.
  • GraphQL отвечает 200 даже на сбой: ошибки лежат в errors с путём к пустой ветке, частичный ответ — норма, а машинный код и признак повторяемости кладут в extensions.
  • Пагинация курсорная по соглашению (edges, node, pageInfo), общее число в него не входит намеренно; сохранённые запросы возвращают кэш и позволяют разрешить только зарегистрированные запросы.
  • Сложность считают до выполнения: вес поля, умноженный на запрошенное количество, плюс предел глубины и обязательный максимум в списках.
  • Авторизация переезжает в резолверы полей: право проверяется на каждое поле и на каждый путь к нему, а отказ отдают частичным ответом.

Что почитать дальше

  • gRPC: соседний стиль для внутренних вызовов, где важнее скорость и строгий контракт, а не выбор полей.
  • REST: точка отсчёта, от которой стоит отталкиваться, прежде чем брать GraphQL.
  • HATEOAS: ветка REST, где клиента избавляют от знания путей ссылками, а не схемой.
  • Базы данных: резолверы упираются в базу, там и считается цена N+1.
  • Таймауты, повторы и идемпотентность: надёжность вызовов, которая от стиля контракта не зависит.
  • Системный дизайн: куда выбор стиля встраивается при проектировании системы целиком.