Как стать автором
Поиск
Написать публикацию
Обновить

Схема GraphQL

Время на прочтение24 мин
Количество просмотров1.1K

Введение

В этой части цикла мы поговорим о центральном элементе GraphQL — схеме. Именно она является точкой соприкосновения клиента и сервера. И если нет схемы — то нет и API.

Схема в GraphQL — это описание структуры API, написанное на специальном языке SDL (Schema Definition Language). Она определяет:

  • Какие типы данных существуют в системе;

  • Какие операции (запросы, мутации, подписки) доступны клиентам;

  • Какие поля можно запрашивать у каждого типа;

  • Какие аргументы принимают операции и поля.

Схема выступает контрактом между клиентом и сервером. Вот как происходит взаимодействие:

Рис.1 - Базовая концепция взаимодействия клиента и сервера через GraphQL
Рис.1 - Базовая концепция взаимодействия клиента и сервера через GraphQL

Валидация запроса и ответа на соответствие схеме происходят всегда. Такой подход обеспечивает типобезопасность и предсказуемость API — клиент всегда знает, какие данные он может запросить и в каком формате получит ответ.

Схема выполняет несколько критических функций:

1. Валидация запросов:

  • Каждый запрос проверяется на соответствие схеме перед выполнением;

  • Невалидные запросы отклоняются на этапе парсинга с детальными ошибками.

2. Типизация данных:

  • Схема подразумевает строгую типизацию всех полей и аргументов.

3. Документирование:

  • Схема сама по себе является полным описанием API;

  • Поддерживает дополнительные описания для объектов схемы;

  • Доступны функции самодиагностики и получения полной информации о схеме.

4. Поддержка безопасного доступа к API:

  • Определение прав доступа на уровне типов и полей;

  • Ограничение сложности запросов;

  • Защита от нежелательных запросов через валидацию.

5. Помощь в разработке:

  • Инструменты автоматизации; 

  • Возможности для мониторинга;

  • Контрактовка взаимодействия между клиентом и сервером;

  • Инструменты расширения, такие как Union, Interface, Federation (расширение объектов схемы и даже объединение нескольких схем в одну).

Типы GraphQL

В GraphQL строгая типизация полей — каждое поле в GraphQL-схеме должно принадлежать одному из следующих типов:

  • Скалярные типы: IntFloatStringBooleanID;

  • Кастомные скалярные типы: например, DateDateTime;

  • Enum;

  • Сложные типы: ObjectInterfaceUnionInput;

Рассмотрим каждый тип подробнее.

Скалярные типы

GraphQL предоставляет набор встроенных скалярных типов:

  • Int — целое число со знаком (32-битное);

  • Float — число с плавающей точкой двойной точности со знаком;

  • String — последовательность символов в UTF-8 кодировке;

  • Boolean — логический тип (true или false);

  • ID — скалярный тип для уникальных идентификаторов, часто используется для повторного получения объекта или в качестве ключа для кеша. Тип ID сериализуется так же, как String, однако его определение как ID указывает на то, что он не предназначен для чтения человеком.

Кастомные скалярные типы

В большинстве библиотек для работы с GraphQL есть возможность задать собственный скалярный тип.

Например, можно определить тип Date (в схеме он будет объявлен как scalarDate) и добавить текстовое описание, что в этом поле ожидается дата в формате «DD.MM.YYYY». В коде приложения будет описано, как должен сериализоваться, десериализоваться и валидироваться этот тип, чтобы поле в итоге содержало нужный формат.

Enum

GraphQL поддерживает перечисляемые типы — Enum.

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

enum OrderStatus {
  NEW
  PROCESSING
  COMPLETED
  CANCELLED
}

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

Enum по конвенции может содержать:

  • Только латинские буквы (A-Z);

  • Цифры (0-9);

  • Подчеркивание (_);

  • Только UPPER_CASE (наиболее распространено, но не обязательно).

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

Для этого GraphQL предоставляет составные типы, которые делятся на:

  1. Типы выходных параметров: ObjectInterfaceUnion;

  2. Тип входных параметров: Input.

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

Рассмотрим все сложные типы и их взаимодействие между собой.

Object

Базовый компонент GraphQL схемы — это тип Object. Он представляет собой объект, который можно получить из сервиса. Внутри объекта хранятся поля и их значения. Поля могут быть скалярными, enum, ссылаться на другие объекты или иметь тип Union или Interface.

В примере с заказами первая версия объекта заказа может выглядеть так:

type Order {
  id: ID!
  date: Date!
  status: OrderStatus!
  items: [OrderItem!]!
  client: Client!
  totalAmount: String!
}

Поля в объекте Order имеют разные типы:

Поле

Тип

id

Скалярный тип ID

date

Пользовательский (кастомный) скалярный тип

items

Массив объектов объектного типа OrderItem

client

Ссылка на объектный тип Client

totalAmount

Скалярный тип String

Квадратными скобками [] обозначается массив, а восклицательным знаком ! — обязательность поля. В нашем примере все поля обязательны и элементы массива OrderItem не могут быть null.

Полная схема, необходимая для реализации объекта Order, будет выглядеть так:

type Order {
  id: ID!
  date: Date!
  status: OrderStatus!
  items: [OrderItem!]!
  client: Client!
  totalAmount: String!
}

scalar Date

enum OrderStatus {
  NEW
  PROCESSING
  COMPLETED
  CANCELLED
}

type OrderItem {
  id: ID!
  product: Product!    
  quantity: Int!      
  price: String!       
  subtotal: String!    
}

type Client {
 id: ID!
 name: String!
 email: String!
 phone: String!
 address: String
}

type Product {
 id: ID!
 name: String!
 description: String
 price: String!
 category: ID!
}

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

Interface

Если в нескольких объектах есть общие поля, которые возвращают одни и те же данные — их можно выделить в интерфейс.

Например, нам нужно описать в схеме 2 разных вида продуктов: съедобные (food) и несъедобные (non-food). У каждого вида есть характеристики.

Характеристики съедобных продуктов:

  • Идентификатор;

  • Название;

  • Описание;

  • Цена;

  • Категория;

  • Срок годности.

Характеристики несъедобных продуктов:

  • Идентификатор;

  • Название;

  • Описание;

  • Цена;

  • Категория;

  • Гарантийный период.

В этих списках 5 из 6 характеристик общие. И если бизнес захочет убрать какую-то из них или добавить новую — то, вероятно, сразу для всех видов продуктов.

Чтобы решить эту задачу, мы можем сделать общий объект, который отдаёт сразу все возможные поля:

type Product {
 id: ID!
 name: String!
 description: String
 price: String!
 category: ID!
 expirationDate: String
 warrantyPeriod: String
}

Однако для съедобных продуктов срок годности expirationDate — это обязательное поле. Но в схеме мы не можем это отобразить, потому что для несъедобных это поле должно быть пустым. Таким образом, мы лишаем схему прозрачности. И легко представить, какой хаос начнётся, когда характеристик будет не 7, а 20.

Если такой вариант нас не устраивает, и мы хотим, чтобы схема точно отображала предметную область, то можно сделать два разных объекта:

type FoodProduct  {
 id: ID!
 name: String!
 description: String
 price: String!
 category: ID!
 expirationDate: String!
}

type NonFoodProduct {
 id: ID!
 name: String!
 description: String
 price: String!
 category: ID!
 warrantyPeriod: String!
}

Но тут, очевидно, происходит дублирование полей. И если понадобится переименовать поле id — надо не забыть это сделать сразу в обоих объектах. То есть нужно помнить об этой бизнес-связи при любом изменении общих полей.

GraphQL для таких ситуаций предлагает использовать абстрактный тип — Interface type.

Интерфейс может содержать поля с теми же типами, что и объект, то есть: ScalarObjectEnumInterface или Union.

Обычно в интерфейс выносятся общие характеристики:

interface Product {
 id: ID!
 name: String!
 description: String
 price: String!
 category: ID!
}

Уникальные характеристики описываются в объектах:

type FoodProduct implements Product {
 id: ID!
 name: String!
 description: String
 price: String!
 category: ID!
 # Дополнительные поля
 expirationDate: String!
}

type NonFoodProduct implements Product {
 id: ID!
 name: String!
 description: String
 price: String!
 category: ID!
 # Дополнительные поля
 warrantyPeriod: String!
}

Интерфейс обеспечивает обязательность поля expirationDate у съедобных продуктов.

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

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

{
 products {
   id
   name
   price
   description
   ... on FoodProduct {
     expirationDate
   }
   ... on NonFoodProduct {
     warrantyPeriod
   }
 }
}

Этот запрос вернёт все продукты и для съедобных добавит к общим полям интерфейса поле expirationDate, а для несъедобных — warrantyPeriod.Интерфейс гарантирует наличие общих полей во всех реализующих типах. При этом каждый тип может добавлять свои поля, но не может убрать поля интерфейса.

Также тип может реализовывать несколько интерфейсов. А интерфейс внутри себя ссылаться на другой интерфейс.

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

Union

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

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

type ProductItem {
 id: ID!
 product: Product!
 quantity: Int!
 price: String!
 subtotal: String!
}

type ServiceItem {
 id: ID!
 service: Service!
 duration: Int!
 date: String!
 price: String!
 subtotal: String!
}

Тогда наш тип Order в поле items должен возвращать объекты из обоих типов. И это можно сделать с помощью объединения:

union OrderLineItem = ProductItem | ServiceItem

type Order {
  id: ID!
  date: Date!
  status: OrderStatus!
  items: [OrderLineItem!]!
  client: Client!
  totalAmount: String!
}

Запрос данных из разных типов Union’а делается через выражение ... on, как и в интерфейсах:

{
 order(id: "123") {
   id
   date
   status
   items {
     ... on ProductItem {
       id
       product {
         name
       }
       quantity
       price
     }
     ... on ServiceItem {
       id
       service {
         name
       }
       duration
       date
       price
     }
   }
   totalAmount
 }
}

Однако для объединений выражение ... on в запросе обязательно, так как у типов из Union нет общих полей, даже если по факту названия каких-то полей совпадают. В нашем примере у типов совпадают поля idsubtotal и price, но их нельзя запросить без указания типа.

Таким образом, объединения полезны, когда нам надо для одного поля вернуть несколько разнородных объектов.

Формального ограничения на количество типов в Union нет, но здравый смысл и ограничения реализации GraphQL сервера скорее всего остановят вас на 3-4 типах в одном объединении.

Итак, мы рассмотрели сложные типы для выходных параметров. Теперь рассмотрим сложный тип, предназначенный для описания входных объектов.

Input

Если нужно объединить в какую-то сложную структуру входные параметры - используется Input.

По структуре и схеме Input очень похож на объект. Например, так будет выглядеть схема для входных данных при создании заказа:

input CreateOrderInput {
 clientId: ID!
 items: [OrderItemInput!]!
 comment: String
}

input OrderItemInput {
 productId: ID!
 quantity: Int!
}

Мы видим, что основной input CreateOrderInput имеет поле с массивом объектов с типом input OrderItemInput. То есть вложенность работает аналогично с объектами.

Но Input не может быть вложен ни в объект, ни в интерфейс, ни в объединение. Так и внутри Input могут содержаться только скаляры, Enum и Input.

Таким образом, если тип не может быть использован в качестве входного или выходного параметра, то он не может быть использован и как вложенный элемент этого параметра.

Тип поля

Может быть выходным параметром

Может быть входным параметром

Скаляр (Int, Float, String, Boolean, ID + кастомные)

+

+

Enum

+

+

Object

+

-

Interface

+

-

Union

+

-

Input

-

+

Итак, мы изучили все доступные в GraphQL типы. Как видно из примеров, поля могут возвращать как единичное значение указанного типа, так и список значений этого типа. Рассмотрим подробнее, как описываются списки значений.

Списки в GraphQL

Списки могут реализовываться через массивы и соединения (Connection). Рассмотрим, чем отличаются два этих способа и в каких случаях используются.

Массивы

Мы уже упоминали, что квадратные скобки [] у типа указывают на то, что поле возвращает список значений.

Массивами могут быть поля любого типа:

type Order {
  id: ID!
  date: Date!
  status: OrderStatus!
  items: [OrderLineItem!]!
  client: Client!
  totalAmount: String!
  partnerIds: [Int!]! # Массив целых чисел (ID поставщиков)
}

Обязательность (NOT NULL) в массивах указывается как для всего списка, так и для каждого элемента в списке. Рассмотрим возможные комбинации обязательности массива и его элементов:

Синтаксис

Поле может быть null

Элементы могут быть null

Допустимые значения

Недопустимые значения

[Type]

Да

Да

null, [], [obj1, null, obj3]

-

[Type!]

Да

Нет

null, [], [obj1, obj2, obj3]

[obj1, null, obj3]

[Type]!

Нет

Да

[], [obj1, null, obj3]

null

[Type!]!

Нет

Нет

[], [obj1, obj2, obj3]

null, [obj1, null, obj3]

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

  1. Отсутствие пагинации.

  2. Невозможность частичной загрузки данных.

  3. Сложность отслеживания изменений.

  4. Отсутствие метаданных о списке (общее количество, дополнительная информация).

Для решения этих ограничений в GraphQL была разработана концепция Connection (соединений).

Connection

Этот паттерн основан на спецификации Relay и предоставляет стандартный способ работы со списками объектов. Для объекта, по которому мы хотим предоставлять соединение, создаются дополнительные объекты, которые содержат поля для пагинации и метаинформацию.

Базовая структура Connection выглядит так:

type OrderConnection {
  edges: [OrderEdge]
  pageInfo: PageInfo!
}

type OrderEdge {
  node: Order
  cursor: String!
}

type PageInfo {
  hasNextPage: Boolean!
  hasPreviousPage: Boolean!
  startCursor: String
  endCursor: String
}

Также структура позволяет добавлять дополнительную информацию о списке:

type OrderConnection {
  edges: [OrderEdge]
  pageInfo: PageInfo!
  totalCount: Int! # Общее количество элементов
}

type OrderEdge {
  node: Order
  cursor: String!
  hasReviewed: Boolean!  # Оставил ли пользователь отзыв
}

Когда нужно вернуть соединение со списком заказов в схеме указывается именно тип OrderConnection.

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

type Client {
  id: ID!
  name: String!
  orders: OrderConnection! # Соединение с заказами с поддержкой пагинации
}

При использовании соединений объект PageInfo будет общим для всех списков. Connection и Edge требуется создавать для каждого объекта, по которому в схеме используется соединение. Это увеличивает сложность схемы, но предоставляет более гибкий и масштабируемый механизм для работы со списками, чем простые массивы.

Обычно в схеме GraphQL активно используются как соединения, так и массивы:

  • Массивы: для небольших списков со стабильным размером (например, productIds, статусы заказов, теги);

  • Connection: для больших списков, которые требуют пагинации или дополнительных метаданных (заказы клиента, история заказов).

Далее рассмотрим, как именно разные типы, массивы и соединения должны вызываться и фильтроваться.

Операции GraphQL

Помимо типов, схема определяет все возможные операции, доступные в GraphQL:

  • Query — получение данных;

  • Mutation — изменение данных;

  • Subscription — подписки на real-time обновления.

Рассмотрим подробнее каждую из операций.

Query

Операция для получения данных в GraphQL называется query.

Query часто сравнивают с запросом GET — так как смысл этой операции всегда сводится к получению данных. Если операция хоть что-то меняет в данных — это уже будет Mutation.

В первой статье цикла упоминалось, что при работе с HTTP чаще всего все операции GraphQL реализуются с помощью POST.

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

Так будет выглядеть схема для query, которая по id отдает данные о заказе:

type Query {
  order(id: ID!): Order!
}

А так клиент опишет этот query при отправке запроса:

{
  order(id: "123") {
    id
    date
    status
    totalAmount
  }
}

Здесь задаётся название запроса и его аргументы и перечисляются поля, которые должны вернуться в ответе.

В нашем примере только один аргумент — id. Но у Query может быть много аргументов и они могут быть скалярными, Enum или Input.

Например, в query, который возвращает массив заказов, логично сделать сразу много аргументов для фильтрации:

type Query {
  orders(
  id: ID
  orderStatus: OrderStatus
  itemNames: [String!]
  ): OrderConnection!
}

В описанном выше запросе мы можем передавать в качестве параметров для фильтра: id, статус заказа и название товаров внутри заказов.

Query могут возвращать не только объект, но и скалярные типы, EnumUnion или Interface. Например, мы можем сделать query, который проверяет, завершён ли заказ:

type Query {
  IsCompletedOrder(id: ID!): Boolean!
}

Схема для нашего примера интерфейса для съедобных и не съедобных продуктов будет выглядеть так:

# Корневой тип запроса
type Query {
  # Пагинируемый список продуктов
  products(first: Int, after: String): ProductConnection!
}

# Интерфейс для продуктов
interface Product {
  id: ID!
  name: String!
  price: Float!
  description: String
}

# Типы продуктов
type FoodProduct implements Product {
  id: ID!
  name: String!
  price: Float!
  description: String
  expirationDate: Date!
}

type NonFoodProduct implements Product {
  id: ID!
  name: String!
  price: Float!
  description: String
  warrantyPeriod: Int!
}

# Connection для интерфейса Product
type ProductConnection {
  edges: [ProductEdge]
  pageInfo: PageInfo!
  totalCount: Int!
}

type ProductEdge {
  node: Product  # Обратите внимание: node имеет тип интерфейса Product
  cursor: String!
}

type PageInfo {
  hasNextPage: Boolean!
  hasPreviousPage: Boolean!
  startCursor: String
  endCursor: String
}

scalar Date

Запрос на клиенте с учётом соединений будет выглядеть так:

{
  products(first: 10, after: "cursor123") {
    totalCount
    pageInfo {
      hasNextPage
      endCursor
    }
    edges {
      cursor
      node {
        id
        name
        price
        # Используем условные фрагменты для получения полей конкретных типов
        ... on FoodProduct {
          expirationDate
        }
        ... on NonFoodProduct {
          warrantyPeriod
        }
      }
    }
  }
}

Union-типы также используются в качестве ответа Query. Чаще всего — когда поле может возвращать один из нескольких типов. Например, результат поиска может быть либо пользователем, либо товаром.

Мы упоминали, что важным преимуществом в GraphQL являются вложенные структуры.

Рассмотрим описание вложенной структуры в Query на примере запроса для получения данных о клиенте:

type Query {
  client(id: ID!): Client
}

type Client {
  id: ID!
  name: String!
  # Вложенное поле
  orders(
    # Фильтр по статусу
    status: OrderStatus,
    # Поле для сортировки
    sortBy: OrderSortField,
    # Направление сортировки
    sortDirection: SortOrder
	  ): [Order!]!
}

enum OrderStatus {
  NEW
  PROCESSING
  COMPLETED
  CANCELLED
}

enum OrderSortField {
  DATE
}

enum SortOrder {
  ASC
  DESC
}

У вложенного объекта также могут быть указаны свои аргументы для фильтрации и сортировки, что очень удобно с точки зрения прозрачности и управляемости запросов.

При этом, как мы обсуждали, clientId передаётся в резолвер для orders от родительского резолвера, то есть отдельный фильтр для клиента в поле orders не нужен.

Также отметим, что массив объектов с заказами имеет смысл возвращать только в том случае, если мы знаем, что у одного клиента не бывает много заказов. Иначе лучше использовать OrderConnection.

Вложенные структуры — самый распространённый способ вернуть взаимосвязанные объекты в запросе.

Однако есть и другие варианты решения задачи. Например, Query может возвращать сразу несколько независимых полей:

type Query {
  client(id: ID!): Client
  orders(clientId: ID!): [Order!]!
}

Запрос на клиенте для этой схемы будет выглядеть так:

query($clientId: ID!) {
 # Получение данных клиента
 client(id: $clientId) {
   id
   name
   email
   phone
 }
 
 # Получение заказов клиента
 orders(clientId: $clientId ) {
   id
   date
   status
   totalAmount
 }
}

# Переменные
{
 "clientId": "CLT-123"
}

Здесь мы использовали переменную clientId. Переменные позволяют переиспользовать значения и облегчают читаемость запросов.

Подход с независимыми полями используется только в специфических случаях:

  1. Когда объекты концептуально не связаны. Например, получение настроек приложения и пользователя.

  2. Для агрегации данных из разных источников. Например, аналитика из нескольких сервисов.

  3. При работе с устаревшими API. Как временное решение при переходе на GraphQL.

Мы разобрались, как устроены операции по получению данных — Query.Их основные особенности:

  • Выполняются параллельно;

  • Не должны изменять данные;

  • Могут кэшироваться.

Query принимают в качестве входных параметров скалярные аргументы, Enum и реже Input. В качестве ответа Query возвращает скалярный тип, Enum, Object, Union или Interface.

Причём один и тот же Enum, Object, Union или Interface может возвращаться в совершенно разных query, а также в мутациях и подписках, о которых мы поговорим дальше.

Mutation

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

Схема мутаций очень похожа на схему query: они принимают на вход скалярные значения, Enum и Input, а на выход отдают один объект с типом: скалярное значение, EnumObjectUnion или Interface.

Использование Input распространено для мутаций, так как именно при создании каких-то объектов чаще всего требуется отдавать на вход сложные структуры.

Рассмотрим пример мутации, которая создаёт в нашей системе новый заказ:

input OrderItemInput {
 productId: ID!
 quantity: Int!
}

type Mutation {
  createOrder(
    clientId: ID!
    items: [OrderItemInput!]!
    comment: String
  ): Order!
}

Рекомендуется группировать параметры мутации в один объект типа Input:

input CreateOrderInput {
 clientId: ID!
 items: [OrderItemInput!]!
 comment: String
}

input OrderItemInput {
 productId: ID!
 quantity: Int!
}

type Mutation {
 createOrder(input: CreateOrderInput!): Order!
}

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

Запрос с такой мутацией описывается аналогично query:

mutation($input: CreateOrderInput!) {
 createOrder(input: $input) {
   id
   date
   status
   client {
     name
     phone
   }
   items {
     product {
       name
       price
     }
     quantity
     subtotal
   }
   totalAmount
 }
}

# Пример переменных
{
 "input": {
   "clientId": "CLT-123",
   "items": [
     {
       "productId": "PROD-1",
       "quantity": 2
     },
     {
       "productId": "PROD-2",
       "quantity": 1
     }
   ],
   "comment": "Доставка до 18:00"
 }
}

В качестве результата выполнения операции здесь возвращается объект типа order. Если операция не была выполнена успешно, то вернется ошибка.

Также можно возвращать скалярные типы. Например, ответом может быть просто поле Boolean: true в случае успешного создания заказа и false в случае неуспешного. Кажется логичным возвращать такой ответ в случае импорта сразу большого количества заказов:

type Mutation {
 importOrders(input: ImportOrdersInput!): Boolean!
}

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

type Mutation {
 importOrders(input: ImportOrdersInput!): ImportOrdersResult!
}

type ImportOrdersResult {
 success: Boolean!

 #ID созданных заказов
 successfulOrders: [ID!]!
}

Стандартный механизм ошибок GraphQL будет их возвращать в таком виде:

{
  "errors": [
    {
      "message": "Заказ не найден",
      "locations": [{ "line": 3, "column": 7 }],
      "path": ["createOrder"],
      "extensions": {
        "code": "NOT_FOUND",
        "details": "Order with id 123 not found"
      }
    }
  ],
  "data": null
}

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

Также можно заложить нужную структуру ошибок сразу в схему, используя Union для ответа мутации:

union CreateOrderResult = CreateOrderSuccess | CreateOrderError

type CreateOrderSuccess {
  order: Order!
}

type CreateOrderError {
  code: String!
  message: String!
  field: String
}

# Мутация
type Mutation {
  createOrder(input: CreateOrderInput!): CreateOrderResult!
}

Выделим основные особенности и отличия Mutation от Query:

  • Мутации выполняются последовательно;

  • Меняют данные;

  • Чаще используют Input;

  • Мутации нельзя кэшировать;

  • Требуют более тщательной обработки ошибок.

Subscription

Третий, наиболее редкий тип операций в GraphQL — это подписки. Подписки используются для получения клиентом обновлений с сервера в реальном времени. То есть, в отличие от запросов и мутаций, которые следуют модели «запрос-ответ», подписки устанавливают постоянное соединение между клиентом и сервером. Чаще всего подписки реализуются через WebSocket.

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

type OrderStatusUpdate {
  orderId: ID!
  oldStatus: OrderStatus!
  newStatus: OrderStatus!
  updatedAt: String!
}

type Subscription {
  orderStatusChanged(orderId: ID!): OrderStatusUpdate!
}

Теперь каждый клиент, который запустит у себя эту подписку, установит с сервером соединение. И будет получать выбранные им поля в объекте каждый раз, когда у заказа будет меняться статус.

Как видно из примера, схема подписки может включать фильтрацию событий, которые клиент хочет получать - в данном случае это фильтрация по id заказа, но можно было добавить еще фильтрацию по статусам, за которыми хочет следить клиент.

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

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

При использовании подписок важно учитывать следующее:

  1. Потребление ресурсов сервера, так как каждое WebSocket-соединение требует поддержания постоянного соединения.

  2. Необходимость правильной обработки отключений клиентов.

  3. Масштабирование системы при большом количестве подписчиков.

  4. Подписки не могут принимать переменные, которые меняются после инициализации подписки.

  5. Входные типы проверяются один раз при старте подписки.

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

Итак, мы рассмотрели все доступные в GraphQL операции и типы:

Операция

Входные типы

Выходные типы

Query

scalar, enum, input

scalar, object type, enum, union, interface

Mutation

scalar, enum, input

scalar, object type, enum, union, interface

Subscription

scalar, enum, input

scalar, object type, enum, union, interface

Теперь мы знаем как (и почему именно так) выглядит схема GraphQL. Далее мы коротко рассмотрим, какие инструменты для клиента предоставляет GraphQL и разберёмся в концептуальных и архитектурных аспектах реализации этой технологии.

Клиентские инструменты GraphQL

Хотя схема определяет структуру и доступные операции, именно язык GraphQL делает API мощным инструментом в руках клиента.

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

В этом разделе мы коротко рассмотрим основные клиентские инструменты GraphQL.

Имена операций

В примерах выше мы использовали анонимные запросы. Но обычно для каждой операции на клиенте создаётся отдельное название.

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

query GetOrder($orderId: ID!) {
  order(id: $orderId) {
    id
    date
    status
    totalAmount
  }
}

# Переменные
{
  "orderId": "123"
}

Имена операций в GraphQL:

  1. Придумываются клиентом и могут быть названы как угодно.

  2. Не определяются на сервере в схеме GraphQL.

  3. Не влияют на выполнение запроса с точки зрения сервера.

Название операций упрощает работу с кодом, логами и кешем.

Алиасы

Алиасы позволяют запрашивать одно и то же поле с разными параметрами или просто переименовывать поля в ответе. Это особенно важно, когда в одном запросе нужно вызвать одну и ту же операцию несколько раз:

query GetDashboardData {
  # Заказы с разными статусами
  newOrders: orders(status: NEW, limit: 5) {
    id
    date
    totalAmount
    client {
      name
    }
  }
  
  completedOrders: orders(status: COMPLETED, limit: 5) {
    id
    date
    totalAmount
    client {
      name
    }
  }

Без использования алиасов второй вызов перезаписал бы первый. С алиасами клиент может явно управлять названиями полей в ответе.

Фрагменты

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

Клиент определяет фрагменты в своем коде и переиспользует их в разных запросах:

# Определяем переиспользуемые фрагменты
fragment ClientInfo on Client {
  id
  name
  email
  phone
  address
}

fragment OrderSummary on Order {
  id
  date
  status
  totalAmount
}

fragment OrderItemDetails on OrderItem {
  id
  quantity
  price
  subtotal
  product {
    id
    name
    price
    description
  }
}

# Используем фрагменты в запросе
query GetOrderData($orderId: ID!) {
  # Полная информация о заказе
  order(id: $orderId) {
    ...OrderSummary
    
    client {
      ...ClientInfo
    }
    
    items {
      ...OrderItemDetails
    }
  }
  
  # Список последних заказов того же клиента
  recentOrders: orders(limit: 5) {
    ...OrderSummary
    client {
      name  # Только имя для списка
    }
  }
}

Фрагменты автоматически разворачиваются в соответствующие поля, обеспечивая консистентность данных во всех частях приложения. Если нужно добавить новое поле в ClientInfo, достаточно изменить фрагмент — изменения применятся везде, где он используется.

Директивы

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

Например, одна из встроенных директив запроса — @include . Она позволяет клиенту указать условие, при котором поле будет возвращено:

query GetClient($withAddress: Boolean!) {
  client(id: "CLT-123") {
    name
    email
    address @include(if: $withAddress)
  }
}

Здесь поле address будет включено в ответ только если переменная $withAddress равна true.

Аналогичная директива запроса @skip(if: Boolean!) указывает на пропуск поля при выполнении условия.

Эти директивы могут свободно использоваться клиентами без участия сервера.
Использование других директив требует доработок на стороне сервера. Например, часто используемая @deprecated, выставляется сервером и указывает на устаревшие поля, значения enum и иногда даже аргументы:

enum OrderStatus {
  NEW
  PROCESSING
  COMPLETED
  CANCELLED @deprecated(reason: "Use COMPLETED instead")
}

Эту директиву добавляют в схему разработчики API. Клиенты и инструменты работы с GraphQL автоматически отображают эту информацию при взаимодействии с устаревшими элементами.

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

Это инструмент для постепенной эволюции API, который помогает постепенно менять контракт между клиентом и сервером.

Ещё одна встроенная директива, которую добавляют разработчики схемы — это @specifiedBy(url: String!). Она указывает спецификацию для кастомных скалярных полей:

scalar DateTime @specifiedBy(url: "<https://tools.ietf.org/html/rfc3339>")
# Клиент точно знает: ISO 8601 формат "2023-12-25T10:30:00Z"

Таким образом директива делает кастомные скаляры самодокументируемыми.Мы рассмотрели основные встроенные директивы GraphQL:

  • Директивы запросов:      

    ○ @include(if: Boolean!) — включить поле при условии;

    ○ @skip(if: Boolean!) — пропустить поле при условии.

  • Директивы схемы:

    ○ @deprecated(reason: String) — пометить как устаревшее;

    ○ @specifiedBy(url: String!) — указать спецификацию для скаляра.

Разработчики GraphQL-сервера могут создавать кастомные директивы. Это пользовательские директивы, созданные для решения специфических задач проекта, таких как авторизация (@auth), кэширование (@cacheControl) или валидация (@constraint). В отличие от встроенных директив, кастомные требуют полной реализации на стороне сервера: нужно объявить директиву в схеме и написать логику её обработки.

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

Интроспекция схемы

Большинство GraphQL-серверов поддерживают интроспекцию схемы — механизм получения полной информации о доступных типах, полях и операциях. В режиме разработки это позволяет использовать такие инструменты, как GraphQL Playground или Apollo Explorer.

С помощью этих инструментов разработчики на клиенте могут:

  • автоматически дополнять запросы

  • исследовать структуру схемы

  • тестировать операции в реальном времени

  • видеть описания типов и аргументов.

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

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

Мы изучили основные элементы и возможности GraphQL схемы: типы данных, операции, директивы и клиентские инструменты. В целом, всё это отдалённо напоминает другие RPC-инструменты и не так сложно в усвоении.

Однако частая ошибка — проектировать GraphQL API как REST-like API. А это сокращает возможности GraphQL и иногда даже сводит на нет его преимущества. Потому что в основе GraphQL лежит совсем другая концепция и другой образ мысли, который надо применять при работе с данными.

Давайте разберёмся, что это за концепция и почему всё время упоминается какой-то граф.

А при чём здесь граф?

Мы изучили основные элементы и правила построения схемы GraphQL. Но не встретили ни одного упоминания графов и свойственных для них терминов — вершины, ребра, корневые и листовые вершины. Или встретили?

Давайте посмотрим, каким образом все элементы GraphQL выстраиваются в единую схему.

В качестве примера возьмём query для получения заказов и все вложенные объекты и их взаимосвязи:

type Order {
  id: ID!
  date: Date!
  status: OrderStatus!
  items: [OrderItem!]!
  client: Client!
  totalAmount: String!
}

scalar Date

enum OrderStatus {
  NEW
  PROCESSING
  COMPLETED
  CANCELLED
}

type OrderItem {
  id: ID!
  product: Product!    
  quantity: Int!      
  price: String!       
  subtotal: String!    
}

type Client {
 id: ID!
 name: String!
 email: String!
 phone: String!
 address: String
}

type Product {
 id: ID!
 name: String!
 description: String
 price: String!
 category: ID!
}

type Query {
  orders(
  id: ID
  orderStatus: OrderStatus
  itemNames: [String!]
  ): OrderConnection!
}

Теперь возьмём только элементы первого уровня и представим взаимосвязи между ними в виде диаграммы:

Рис.2 — Диаграмма
Рис.2 — Диаграмма

Мы получаем граф, в котором query является корневым элементом, а все типы — его потомками.

Если же мы в нашу схему добавим мутацию createOrder:

Рис.3 — Добавление мутации
Рис.3 — Добавление мутации

Мы видим, что мутация встраивается в граф, как новый корневой элемент и также является родительским элементом для type Order и всех его потомков.Таким образом, весь GraphQL API образует большой направленный граф, где операции — это корневые вершины.

Вот некоторые особенности этого графа:

  • Каждое поле в объекте может вести к другому объекту, создавая связи в графе;

  • Один объект может быть связан с несколькими другими (как Order связан с OrderItem и Client);

  • Связи могут быть как один-к-одному (Order -> Client), так и один-ко-многим (Order -> [OrderItem]);

  • Один и тот же объект может использоваться в разных частях схемы.

Наш граф является отображением схемы и её элементов. Эта концепция лежит в основе GraphQL API и обеспечивает:

  • Чёткую организацию API;

  • Гибкость в получении связанных данных: связь с родительским элементом сразу создает связи со всеми его потомками;

  • Возможность переиспользовать объекты;

  • Возможность получать только нужные поля;

  • Предсказуемость и типобезопасность.

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

Однако в официальной спецификации и повседневном использовании GraphQL практически не применяется терминология из теории графов, за исключением описаний типов для Connection.

GraphQL заменяет классические термины теории графов на свои:

  • «Типы» (Types) вместо «вершин» (nodes);

  • «Поля» (Fields) вместо «рёбер» (edges);

  • «Операции» вместо «корневых вершин»;

  • «Скалярные типы» вместо «листовых вершин».

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

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

Заключение

GraphQL схема — это мощный инструмент для проектирования современных API. Она обеспечивает:

  • Типобезопасность и валидацию запросов на этапе компиляции;

  • Гибкость в получении только необходимых данных;

  • Самодокументирование API через интроспекцию;

  • Единую точку входа для всех операций с данными.

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

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

Об авторе

Татьяна Сальникова

Продуктовый архитектор, автор воркшопов
Ведущий системный аналитик
Создавала системы для ритейла, маркетинга, строительства, финансового сектора. Основные направления: проектирование интеграций, API, архитектуры микросервисов

Теги:
Хабы:
+8
Комментарии1

Публикации

Ближайшие события