Школа
системного анализа
и проектирования
Автор: татьяна сальникова

Введение в GraphQL

Введение

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

Согласно официальной документации, GraphQL — это язык запросов для API-интерфейсов и среда, в которой они выполняются. С помощью GraphQL можно получить данные из API и передать их в приложение так же, как и с помощью REST-like API, JSON-RPC, SOAP и т. д.

Однако GraphQL приводит API в определённый — графовый — вид, что позволяет довольно гибко обращаться с данными:

■ Разные клиенты могут запрашивать только нужные им поля из одного ресурса;
■ Вложенные структуры описывают сложные многоуровневые объекты в одном запросе;
■ Избыточные данные исключаются клиентом из ответов сервера.

Такая гибкость позволяет обойти ряд ограничений REST API и других инструментов RPC.

Именно для этого Facebook в 2012 году создал GraphQL. Разработчики социальной сети столкнулись с уникальными вызовами: миллиарды пользователей, терабайты данных и множество клиентских приложений — мобильные версии, веб-интерфейс, десктопные приложения — все они отображают одну и ту же информацию, но требуют разные наборы данных.
Более того, одно и то же приложение во множестве случаев предоставляет одну и ту же информацию, но в разных форматах. Например, данные о пользователе отображаются на его странице, а также когда этот пользователь показан в списках участников группы, чьих-то друзей и т. д. Атрибуты для каждого запроса могут быть разными, но объект или ресурс один и тот же. REST-решения перестали справляться с этими задачами в масштабе Facebook. Так появился GraphQL.

В этой статье мы на примере разберём, c какими проблемами в структуре данных мы сталкиваемся в REST-like API и как такие задачи решает GraphQL. А также поверхностно рассмотрим реализацию в коде и поверх HTTP, и основные ограничения GraphQL.

Ограничения REST API

Как упоминалось выше, GraphQL был разработан для обхода некоторых ограничений REST-like API.

Давайте рассмотрим пример с заказом в интернет-магазине. Если мы хотим сделать запрос, который возвращает нам объект заказа, то в REST-like API это будет выглядеть так:
GET /api/orders/ORD-2024-1234 HTTP/1.1
Host: api.example.com
Accept: application/json

HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-cache
Date: Sat, 02 Nov 2024 14:30:00 GMT
Content-Length: 541

{
  "status": "success",
  "data": {
    "order": {
      "id": "ORD-2024-1234",
      "client": {
        "id": "CLT-789",
        "name": "Иван Петров",
        "email": "ivan@example.com",
        "phone": "+7 (999) 123-45-67"
      },
      "date": "2024-11-02T14:30:00Z",
      "status": "processing",
      "items": [
        {
          "id": "ITEM-001",
          "name": "Смартфон iPhone 13",
          "quantity": 1,
          "price": "75000.00",
          "subtotal": "75000.00"
        },
        {
          "id": "ITEM-002", 
          "name": "Защитное стекло",
          "quantity": 2,
          "price": "990.50",
          "subtotal": "1981.00"
        }
      ],
      "totalAmount": "76981.00",
      "currency": "RUB"
    }
  }
}
Видно, что ответ довольно объёмный, особенно если учесть, что в заказе может быть больше 10 товаров. В большей части вариантов использования запроса параметр items[] вообще не нужен. Например, когда мы узнаём статус заказа, или когда хотим получить контакты клиента заказа, дату формирования или итоговую сумму заказа. Более того, список товаров в оформленном заказе — довольно статичная информация. И даже когда он нужен, в части случаев он может браться из кэша.

Чтобы сократить объём ответа и упростить логику сервера при сборе данных, мы можем сделать отдельные запросы для получения информации о клиенте и товарах по номеру заказа:
GET /api/orders/ORD-2024-1234/client

GET /api/orders/ORD-2024-1234/items
Тогда, если необходимо получить полную информацию по заказу, нужно сделать сразу 3 запроса. И если у вашего приложения несколько клиентов, и каждому из них нужно своё представление данных о заказе — недовольными останутся все.

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

Проблема с пользователями решается разработкой трёх отдельных API, но это требует много ресурсов.

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

Итак, мы можем сказать, что при определённых масштабах и специфике домена компании сталкиваются с такими ограничениями REST-like API, как:
■ Over-fetching — получение избыточных данных;
■ Under-fetching — недостаток данных, требующий дополнительных запросов;
■ Множественные запросы для получения связанных данных.

Далее рассмотрим, какое решение этих проблем предлагает GraphQL.

Принцип работы GraphQL

В основе концепции GraphQL лежит довольно очевидное решение — каждый клиент в каждой конкретной ситуации сам выбирает, какой набор данных из объекта он хочет получить. То есть при запросе клиент напрямую указывает, какие поля из объекта order в данный момент ему нужны.

Так будет выглядеть схема GraphQL-запроса для мобильного приложения:
query GetOrderForMobile($orderId: ID!) {
  order(id: $orderId) {
    items {
      name
    }
    totalAmount
    currency
  }
}
И такой для него придёт ответ:
{
  "data": {
    "order": {
      "items": [
        {
          "name": "Смартфон iPhone 13"
        },
        {
          "name": "Защитное стекло"
        }
      ],
      "totalAmount": "76981.00",
      "currency": "RUB"
    }
  }
}
А вот запрос веб-клиента:
query GetOrderForWeb($orderId: ID!) {
  order(id: $orderId) {
    id
    date
    status
    client {
      phone
    }
    items {
      name
    }
  }
}
И ответ на него:
{
  "data": {
    "order": {
      "id": "ORD-2024-1234",
      "date": "2024-11-02T14:30:00Z",
      "status": "PROCESSING",
      "client": {
        "phone": "+7 (999) 123-45-67"
      },
      "items": [
        {
          "name": "Смартфон iPhone 13"
        },
        {
          "name": "Защитное стекло"
        }
      ]
    }
  }
}
То есть клиент в своём запросе может указать любой набор полей из схемы и в ответе вернутся только эти поля.

Общая схема в нашем случае будет выглядеть так:
query GetOrder($orderId: ID!) {
  order(id: $orderId) {
    id
    date
    status
    client {
      id
      name
      email
      phone
    }
    items {
      id
      name
      quantity
      price
      subtotal
    }
    totalAmount
    currency
  }
}
Но каждый конкретный запрос будет содержать только тот набор полей из общей схемы, который выберет клиент.

GraphQL-запросы строятся на основе схемы — строгого описания типов данных и связей между ними. Например, Order, Client и Item — это типы, которые определяются в схеме и описывают, какие поля у них есть и какого они типа (например, String, Int, Float, ID и т.д.). Схему мы подробно рассмотрим в следующей статье цикла.

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

В официальной документации есть «живые» примеры, на которых можно потренироваться писать GraphQL-запросы для получения разных полей одного объекта. Либо это можно сделать в тестовых или открытых GraphQL API.

В следующем разделе разберёмся с базовыми принципами реализации такого подхода в коде.

Реализация GraphQL-запроса

Кратко рассмотрим реализацию в коде запросов, возвращающих только те данные, которые указал клиент.

Для получения значений полей из БД в коде приложения на сервере нужны специальные функции — resolvers. Эти функции пишут разработчики API на выбранном ими языке программирования. Далее в примерах мы будем использовать JavaScript, но на практике это может быть любой язык, у которого есть библиотека для работы с GraphQL.

Для полей, требующих специальной логики получения данных, создаются отдельные резолверы.

Резолверы:
■ Выполняются только для запрошенных полей;
■ Имеют доступ к контексту запроса (авторизация, настройки и т.д.);
■ Могут быть асинхронными;
■ Могут извлекать или обновлять данные из REST API, БД или любого другого сервиса.

Рассмотрим, какие резолверы могут быть разработаны в примере с заказом в интернет-магазине:

1. Root resolver получает основные данные заказа. Результатом его выполнения может быть такая структура:
 {
  id: "12345",
  date: "2025-04-28",
  status: "SHIPPED",
  clientId: "C-789",
  itemIds: ["ITEM-001", "ITEM-002"],
  totalAmount: "76981.00",
  currency: "RUB"
}
2. Вложенные резолверы для объектов, которые мы хотим получать только в том случае, если их запросил клиент.

Например, мы можем сделать вложенные резолверы client и items:
client: (parent, args, context, info) => {
      return {
        id: "C-789",
        name: "Иван Петров",
        email: "ivan@example.com",
        phone: "+7 123 456 7890"
      };
    },
items: (parent, args, context, info) => {
      return [
        {
          id: "ITEM-001",
          name: "Смартфон iPhone 13",
          quantity: 1,
          price: "75000.00",
          subtotal: "75000.00"
        },
        {
          id: "ITEM-002",
          name: "Защитное стекло",
          quantity: 2,
          price: "990.50",
          subtotal: "1981.00"
        }
      ];
    }
Резолверы принимают следующие аргументы:
■ parent — весь объект ответа родительского резолвера;
■ args — параметры поля резолвера: сортировка, фильтрация, пагинация;
■ context — контекст выполнения запроса, например, данные текущего авторизованного пользователя или доступ к базе данных;
■ info — информация, специфичная для конкретного поля и относящаяся к текущей операции, используется для сложных запросов.

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

То есть для запроса ниже будет вызываться корневой резолвер и вложенный резолвер items:
query GetOrderForMobile($orderId: ID!) {
  order(id: $orderId) {
    items {
      name
    }
    totalAmount
    currency
  }
}
Резолвер client здесь выполняться не будет, так как ни одно из его полей не было запрошено клиентом. Таким образом, не будет ни избыточных данных, ни лишних запросов в БД.
Рис.1 — Схема сбора данных резолверами
Если мы знаем, что заказ без имени, телефона или email покупателя запрашивают крайне редко, то мы можем обойтись без отдельного резолвера client и доставать данные о покупателе сразу в корневом резолвере. Также это будет иметь смысл, если нам выгоднее один раз загрузить все данные: например, если запрос для заказа и так их вытягивает из БД, данные можно кешировать и пр.

В GraphQL значение каждого поля в запросе достается с помощью резолверов, но не каждое поле обязательно обрабатывается своим резолвером.

Фактически работает следующий механизм:
  1. Выполняется резолвер родительского объекта.
  2. Если родительский резолвер уже вернул данные, содержащие значения для дочерних полей, то GraphQL сначала попытается использовать эти значения.
  3. Резолвер для конкретного поля вызывается только если:
■ Данные для этого поля не были возвращены родительским резолвером;
■ Для поля явно определён специальный резолвер, который должен выполнить дополнительную логику.

Это называется «механизмом разрешения по умолчанию» (default field resolution) и является важной оптимизацией в GraphQL. То есть при грамотном использовании резолверы оптимизируют нагрузку на БД и не запрашивают ненужные данные.

В следующем разделе мы рассмотрим, как GraphQL работает поверх HTTP.

GraphQL и HTTP

GraphQL не привязан к конкретному транспортному протоколу, но на практике почти всегда используется поверх HTTP.

Как правило, все GraphQL-запросы отправляются с POST и сейчас станет понятно, почему. Так будет выглядеть GraphQL-запрос от веб-клиента, отправленный через HTTP:
POST /graphql HTTP/1.1
Host: api.example.com
Content-Type: application/json
Accept: application/json
Content-Length: 183

{
  "query": "query GetOrderForWeb($orderId: ID!) { order(id: $orderId) { id date status client { phone } items { name } } }",
  "variables": {
    "orderId": "ORD-2024-1234"
  }
}
Как можно заметить, все параметры и схема ответа отправляются в теле запроса.

В какой-то момент объём запроса может стать проблемой. Несмотря на возможности GraphQL, делать очень длинные схемы со множеством вложенных массивов не рекомендуется.

Естественно, это не единственные проблемы и сложности, с которыми приходится сталкиваться разработчикам GraphQL API. Ниже коротко обсудим основные ограничения этой технологии.

Ограничения GraphQL

Мы уже рассмотрели ряд преимуществ GraphQL, но у этой технологии есть и свои ограничения, которые важно учитывать при проектировании API:

■ N+1 проблема
При работе с вложенными данными один корневой запрос может породить множество последовательных обращений к базе данных (по одному на каждый элемент списка). Это может серьёзно повлиять на производительность. Поэтому часто необходимо оптимизировать запросы, например, с помощью специальной библиотеки DataLoader или батчинга.

■ Сложность кеширования
Из-за того, что все GraphQL-запросы отправляются на один эндпоинт, невозможно использовать кеширование на уровне URL, как в REST. Для реализации кеша приходится использовать дополнительные решения: например, прокси, кастомные ключи кеша или клиентские библиотеки вроде Apollo Client с нормализацией.

■ Повышенные риски DoS-атак
GraphQL позволяет делать очень глубокие и широкие запросы, включая фрагменты и рекурсивные структуры. Без ограничения глубины или сложности запроса можно легко перегрузить сервер. Поэтому стоит применять лимиты (depth, complexity) и валидаторы.

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

Трудоёмкость внедрения на бэкенде
Несмотря на удобство для клиента, разработка схем, резолверов, безопасных механизмов и системы логирования требует времени и опыта. Это может быть избыточным для простых API.

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

Заключение

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

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

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

Полезные материалы

Официальная документация и черновик спецификации
Руководство по GraphQL
■ Книга «GraphQL» Алекс Бэнкс, Ева Порселло
■ Вебинар «Особенности использования GraphQL»
■ Инструменты для работы с GraphQL:
  • GraphiQL — IDE для интерактивного тестирования запросов
  • Apollo Studio — облачный инструмент для анализа и отладки схем
  • Postman и Insomnia — поддерживают GraphQL-запросы из коробки

Об авторе

■ Другие статьи по теме Интеграция

Показать еще