GraphQL

GraphQL — язык запросов к API и среда выполнения этих запросов на сервере.

Клиент указывает, какие поля ему нужны, а сервер возвращает данные соответствующей формы.

Запрос:

query {
  user(id: "42") {
    id
    name
    email
  }
}

Ответ:

{
  "data": {
    "user": {
      "id": "42",
      "name": "Анна",
      "email": "anna@example.com"
    }
  }
}

Если клиенту не нужен email, поле можно не запрашивать:

query {
  user(id: "42") {
    id
    name
  }
}

Ответ:

{
  "data": {
    "user": {
      "id": "42",
      "name": "Анна"
    }
  }
}

GraphQL определяет:

GraphQL не является:

Типичная архитектура:

Клиент
   ↓ GraphQL document
GraphQL-сервер
   ↓ проверка по схеме
Резолверы
   ├── база данных
   ├── REST API
   ├── другой GraphQL API
   ├── Redis
   └── внешние сервисы

GraphQL чаще всего работает поверх HTTP через один endpoint:

POST /graphql

Однако GraphQL не привязан исключительно к HTTP. Операции также могут передаваться через WebSocket и другие механизмы.

Содержание


Схема GraphQL

Схема GraphQL описывает публичный контракт API:

Схема обычно записывается на языке SDL — Schema Definition Language.

Пример:

type User {
  id: ID!
  name: String!
  email: String!
  role: UserRole!
  posts: [Post!]!
}

type Post {
  id: ID!
  title: String!
  content: String!
  published: Boolean!
  author: User!
}

enum UserRole {
  USER
  EDITOR
  ADMIN
}

type Query {
  user(id: ID!): User
  users(limit: Int = 20): [User!]!
  post(id: ID!): Post
}

type Mutation {
  createUser(input: CreateUserInput!): CreateUserPayload!
  publishPost(id: ID!): PublishPostPayload!
}

input CreateUserInput {
  name: String!
  email: String!
  password: String!
}

type CreateUserPayload {
  user: User
  errors: [UserError!]!
}

type PublishPostPayload {
  post: Post
  errors: [UserError!]!
}

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

Такая схема сообщает клиенту:


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

GraphQL содержит несколько встроенных скалярных типов.

Тип Назначение
Int 32-битное целое число
Float Число с плавающей точкой
String Строка
Boolean Логическое значение
ID Идентификатор

Пример:

type Product {
  id: ID!
  name: String!
  price: Float!
  quantity: Int!
  available: Boolean!
}

ID сериализуется похожим образом на строку, но семантически обозначает идентификатор:

id: ID!

Клиенту не следует выполнять арифметику с ID, даже если сервер использует числовой идентификатор.


Пользовательские скаляры

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

Можно объявить пользовательский скаляр:

scalar DateTime
scalar JSON
scalar Decimal

Применение:

type Order {
  id: ID!
  createdAt: DateTime!
  metadata: JSON
  total: Decimal!
}

Сервер должен реализовать сериализацию и разбор такого значения.

Упрощённый скаляр даты:

import {
  GraphQLScalarType,
  Kind,
} from "graphql";

const dateTimeScalar =
  new GraphQLScalarType({
    name: "DateTime",

    description:
      "Дата и время в формате ISO 8601",

    serialize(value) {
      if (value instanceof Date) {
        return value.toISOString();
      }

      const date = new Date(value);

      if (
        Number.isNaN(date.getTime())
      ) {
        throw new TypeError(
          "Некорректное значение DateTime",
        );
      }

      return date.toISOString();
    },

    parseValue(value) {
      if (typeof value !== "string") {
        throw new TypeError(
          "DateTime должен быть строкой",
        );
      }

      const date = new Date(value);

      if (
        Number.isNaN(date.getTime())
      ) {
        throw new TypeError(
          "Некорректное значение DateTime",
        );
      }

      return date;
    },

    parseLiteral(ast) {
      if (ast.kind !== Kind.STRING) {
        return null;
      }

      const date = new Date(ast.value);

      if (
        Number.isNaN(date.getTime())
      ) {
        return null;
      }

      return date;
    },
  });

Подключение к резолверам:

const resolvers = {
  DateTime: dateTimeScalar,
};

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


Объектные типы

Объектный тип содержит поля:

type User {
  id: ID!
  name: String!
  email: String!
}

Клиент может запросить любое разрешённое сочетание полей:

query {
  user(id: "42") {
    id
    name
  }
}

Объектный тип не обязательно совпадает со структурой таблицы базы данных.

Например, GraphQL-тип:

type User {
  id: ID!
  displayName: String!
  avatarUrl: String
}

может собираться из:

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


Nullable и Non-Null

По умолчанию поля GraphQL могут иметь значение null.

type User {
  middleName: String
}

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

type User {
  id: ID!
  name: String!
}

Различие:

String  — строка или null
String! — обязательно строка

Если поле объявлено как обязательное:

name: String!

но резолвер вернул null, GraphQL зарегистрирует ошибку выполнения.


Null propagation

Рассмотрим схему:

type User {
  id: ID!
  profile: Profile!
}

type Profile {
  displayName: String!
}

Если displayName неожиданно равен null, ошибка распространяется до ближайшего nullable-поля.

Если user в Query допускает null:

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

результат может стать таким:

{
  "data": {
    "user": null
  },
  "errors": [
    {
      "message": "Cannot return null for non-nullable field Profile.displayName"
    }
  ]
}

Поэтому не следует ставить ! механически у каждого поля. Non-null является обещанием API, которое сервер должен выполнять.


Списки

Список обозначается квадратными скобками:

users: [User]

Возможные варианты:

[User]
[User!]
[User]!
[User!]!

Их смысл:

Тип Что допускается
[User] Сам список, его элементы и отдельные значения могут быть null
[User!] Список может быть null, элементы — нет
[User]! Список обязателен, но отдельные элементы могут быть null
[User!]! Список и все его элементы обязательны

Часто коллекции объявляются так:

users: [User!]!

Если элементов нет, сервер возвращает пустой массив:

{
  "data": {
    "users": []
  }
}

а не null.


Enum

Enum ограничивает значение заранее известным набором вариантов:

enum OrderStatus {
  CREATED
  PAID
  SHIPPED
  CANCELLED
}

Использование:

type Order {
  id: ID!
  status: OrderStatus!
}

Фильтр:

type Query {
  orders(
    status: OrderStatus
  ): [Order!]!
}

Запрос:

query {
  orders(status: PAID) {
    id
    status
  }
}

Enum безопаснее произвольной строки:

status: String

Схема заранее сообщает клиенту допустимые значения.


Input-типы

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

Для аргументов создаются input-типы:

input CreateProductInput {
  name: String!
  price: Float!
  categoryId: ID!
}

Мутация:

type Mutation {
  createProduct(
    input: CreateProductInput!
  ): CreateProductPayload!
}

Входной объект:

input UpdateProductInput {
  name: String
  price: Float
  categoryId: ID
}

Здесь поля необязательны, потому что обновление может быть частичным.

Следует определить семантику:

Например:

input UpdateProfileInput {
  displayName: String
  middleName: String
}

Переменные:

{
  "input": {
    "displayName": "Анна"
  }
}

могут означать, что middleName изменять не нужно.

А:

{
  "input": {
    "middleName": null
  }
}

может означать явное удаление отчества.

Эта семантика должна быть задокументирована.


Interface

Interface задаёт общий набор полей для нескольких типов.

interface Node {
  id: ID!
}

type User implements Node {
  id: ID!
  name: String!
}

type Product implements Node {
  id: ID!
  name: String!
  price: Float!
}

Запрос:

query {
  node(id: "42") {
    id
    __typename
  }
}

Поле __typename сообщает конкретный тип результата:

{
  "data": {
    "node": {
      "id": "42",
      "__typename": "User"
    }
  }
}

Запрос полей конкретного типа выполняется через inline fragment:

query {
  node(id: "42") {
    id
    __typename

    ... on User {
      name
    }

    ... on Product {
      name
      price
    }
  }
}

Серверу может потребоваться определить реальный тип:

const resolvers = {
  Node: {
    __resolveType(value) {
      if ("email" in value) {
        return "User";
      }

      if ("price" in value) {
        return "Product";
      }

      return null;
    },
  },
};

Union

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

union SearchResult =
    User
  | Product
  | Article

Запрос:

query {
  search(text: "graphql") {
    __typename

    ... on User {
      id
      name
    }

    ... on Product {
      id
      name
      price
    }

    ... on Article {
      id
      title
    }
  }
}

Резолвер типа:

const resolvers = {
  SearchResult: {
    __resolveType(value) {
      if (value.email) {
        return "User";
      }

      if (
        typeof value.price ===
        "number"
      ) {
        return "Product";
      }

      if (value.title) {
        return "Article";
      }

      return null;
    },
  },
};

Корневые типы

Основные корневые типы:

type Query
type Mutation
type Subscription

Query используется для чтения:

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

Mutation используется для изменения состояния:

type Mutation {
  createUser(
    input: CreateUserInput!
  ): CreateUserPayload!
}

Subscription используется для событий в реальном времени:

type Subscription {
  messageCreated(
    roomId: ID!
  ): Message!
}

Для базового API обязательным является только Query.


Запросы

Query — операция чтения данных.

Простой запрос:

query {
  products {
    id
    name
    price
  }
}

Именованный запрос:

query GetProducts {
  products {
    id
    name
    price
  }
}

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


Аргументы

query {
  product(id: "815") {
    id
    name
    price
  }
}

Аргументы могут находиться у любого поля:

query {
  user(id: "42") {
    posts(
      published: true
      limit: 10
    ) {
      id
      title
    }
  }
}

Схема:

type User {
  id: ID!
  posts(
    published: Boolean
    limit: Int = 20
  ): [Post!]!
}

Переменные

Не следует собирать GraphQL-запрос конкатенацией строк.

Нежелательно:

const query = `
  query {
    user(id: "${userId}") {
      id
      name
    }
  }
`;

Предпочтительно использовать переменные:

query GetUser($userId: ID!) {
  user(id: $userId) {
    id
    name
  }
}

Отдельный объект переменных:

{
  "userId": "42"
}

HTTP-запрос:

{
  "query": "query GetUser($userId: ID!) { user(id: $userId) { id name } }",
  "variables": {
    "userId": "42"
  },
  "operationName": "GetUser"
}

Переменные:

Переменные всё равно должны проверяться бизнес-логикой сервера.


Алиасы

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

query {
  firstUser: user(id: "42") {
    id
    name
  }

  secondUser: user(id: "81") {
    id
    name
  }
}

Ответ:

{
  "data": {
    "firstUser": {
      "id": "42",
      "name": "Анна"
    },
    "secondUser": {
      "id": "81",
      "name": "Иван"
    }
  }
}

Алиасы также позволяют изменить имя поля в результате:

query {
  displayTitle: applicationName
}

Фрагменты

Fragment позволяет повторно использовать набор полей.

fragment UserSummary on User {
  id
  name
  avatarUrl
}

Использование:

query GetArticle($articleId: ID!) {
  article(id: $articleId) {
    id
    title

    author {
      ...UserSummary
    }

    reviewers {
      ...UserSummary
    }
  }
}

Фрагменты особенно полезны в компонентном frontend-приложении, где каждый компонент объявляет необходимые ему поля.


Директивы

GraphQL поддерживает директивы, изменяющие выполнение или описание схемы.

Встроенные директивы:

@include
@skip
@deprecated

Пример:

query GetUser(
  $userId: ID!
  $withEmail: Boolean!
) {
  user(id: $userId) {
    id
    name

    email @include(
      if: $withEmail
    )
  }
}

Пропуск поля:

email @skip(if: $hideEmail)

Устаревшее поле схемы:

type User {
  fullName: String
    @deprecated(
      reason: "Используйте displayName"
    )

  displayName: String!
}

Мутации

Mutation — операция, которая изменяет состояние системы.

Пример схемы:

input CreateUserInput {
  name: String!
  email: String!
  password: String!
}

type CreateUserPayload {
  user: User
  errors: [UserError!]!
}

type Mutation {
  createUser(
    input: CreateUserInput!
  ): CreateUserPayload!
}

Операция:

mutation CreateUser(
  $input: CreateUserInput!
) {
  createUser(input: $input) {
    user {
      id
      name
      email
    }

    errors {
      code
      field
      message
    }
  }
}

Переменные:

{
  "input": {
    "name": "Анна",
    "email": "anna@example.com",
    "password": "long-secure-password"
  }
}

Успешный результат:

{
  "data": {
    "createUser": {
      "user": {
        "id": "42",
        "name": "Анна",
        "email": "anna@example.com"
      },
      "errors": []
    }
  }
}

Ошибка валидации:

{
  "data": {
    "createUser": {
      "user": null,
      "errors": [
        {
          "code": "EMAIL_INVALID",
          "field": "email",
          "message": "Некорректный email"
        }
      ]
    }
  }
}

Payload мутации

Мутация может возвращать объект напрямую:

type Mutation {
  createUser(
    input: CreateUserInput!
  ): User!
}

Но отдельный payload обычно легче расширять:

type CreateUserPayload {
  user: User
  errors: [UserError!]!
  clientMutationId: String
}

Позже можно добавить:

type CreateUserPayload {
  user: User
  errors: [UserError!]!
  confirmationRequired: Boolean!
}

без изменения корневой формы мутации.


Именование мутаций

Хорошие названия отражают бизнес-действие:

createUser
updateProfile
publishArticle
cancelOrder
markNotificationAsRead

Менее информативные варианты:

execute
process
change
updateData
performAction

GraphQL не требует, чтобы все изменения выглядели как универсальный CRUD.

Предметная мутация:

cancelOrder(id: ID!): CancelOrderPayload!

часто понятнее универсального обновления:

updateOrder(
  id: ID!
  input: {
    status: CANCELLED
  }
): Order!

Предметная операция позволяет централизованно проверить правила отмены заказа.


Запрос и мутация по HTTP

Типичный GraphQL-запрос:

POST /graphql HTTP/1.1
Content-Type: application/json
Authorization: Bearer access-token

Тело:

{
  "query": "query GetUser($id: ID!) { user(id: $id) { id name } }",
  "variables": {
    "id": "42"
  },
  "operationName": "GetUser"
}

Query иногда передаётся через GET:

GET /graphql?query=...

Это может упростить HTTP-кэширование, но URL имеет ограничения длины и не должен содержать чувствительные данные.

Мутации должны передаваться через POST, потому что они изменяют состояние.


Ответ GraphQL

Обычный успешный ответ:

{
  "data": {
    "user": {
      "id": "42",
      "name": "Анна"
    }
  }
}

Ответ с ошибкой:

{
  "data": {
    "user": null
  },
  "errors": [
    {
      "message": "Пользователь не найден",
      "path": ["user"],
      "extensions": {
        "code": "NOT_FOUND"
      }
    }
  ]
}

GraphQL поддерживает частичный результат:

{
  "data": {
    "user": {
      "id": "42",
      "name": "Анна",
      "privateNotes": null
    }
  },
  "errors": [
    {
      "message": "Недостаточно прав",
      "path": [
        "user",
        "privateNotes"
      ],
      "extensions": {
        "code": "FORBIDDEN"
      }
    }
  ]
}

Клиент должен учитывать, что в одном ответе могут одновременно присутствовать:

data
errors

HTTP-статусы GraphQL

GraphQL-ответ с ошибкой выполнения часто возвращается с HTTP-статусом 200, если сам GraphQL-запрос был корректно принят и выполнен:

HTTP/1.1 200 OK
Content-Type: application/json
{
  "data": {
    "user": null
  },
  "errors": [
    {
      "message": "Пользователь не найден"
    }
  ]
}

Транспортные и синтаксические ошибки могут использовать другие статусы:

400 — некорректный GraphQL-документ или запрос
401 — не пройдена аутентификация на уровне endpoint
405 — неподдерживаемый HTTP-метод
500 — внутренняя ошибка инфраструктуры

Клиент не должен полагаться только на response.ok. Нужно также анализировать поле errors.


Резолверы

Resolver — функция, которая получает значение конкретного поля.

Схема:

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

Резолвер:

const resolvers = {
  Query: {
    user(
      parent,
      args,
      context,
      info,
    ) {
      return userRepository.findById(
        args.id,
      );
    },
  },
};

Резолверы организуются по типам:

const resolvers = {
  Query: {
    user() {},
    users() {},
  },

  Mutation: {
    createUser() {},
  },

  User: {
    posts() {},
  },
};

Аргументы резолвера

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

function resolver(
  parent,
  args,
  context,
  info,
) {
  // ...
}

parent

Результат родительского резолвера.

Схема:

type User {
  id: ID!
  posts: [Post!]!
}

Резолвер:

const resolvers = {
  User: {
    posts(parent) {
      return postRepository
        .findByAuthorId(
          parent.id,
        );
    },
  },
};

Здесь parent — объект пользователя:

{
  id: "42",
  name: "Анна"
}

args

Аргументы поля:

user(id: ID!): User
user(parent, args) {
  return userRepository.findById(
    args.id,
  );
}

Для запроса:

user(id: "42")

значение args будет примерно таким:

{
  id: "42",
}

context

Контекст, общий для выполнения одной GraphQL-операции.

В нём обычно находятся:

user(parent, args, context) {
  context.logger.info(
    {
      userId: args.id,
    },
    "User requested",
  );

  return context.repositories
    .userRepository
    .findById(args.id);
}

Контекст нельзя делать глобальным объектом с данными конкретного пользователя. Новый контекст создаётся для каждого запроса.

info

Содержит техническую информацию:

function resolver(
  parent,
  args,
  context,
  info,
) {
  console.log(info.fieldName);
}

info используется инструментами трассировки, оптимизации и анализа запроса. В обычной бизнес-логике он требуется редко.


Default resolver

Если отдельный резолвер поля не объявлен, GraphQL обычно пытается взять одноимённое свойство из родительского объекта.

Схема:

type User {
  id: ID!
  name: String!
}

Корневой резолвер:

const resolvers = {
  Query: {
    user() {
      return {
        id: "42",
        name: "Анна",
      };
    },
  },
};

Отдельные резолверы для User.id и User.name не нужны.

Если имя поля GraphQL отличается от внутреннего свойства, нужен резолвер:

type User {
  displayName: String!
}
const resolvers = {
  User: {
    displayName(parent) {
      return parent.display_name;
    },
  },
};

Асинхронные резолверы

Резолвер может возвращать обычное значение или Promise.

const resolvers = {
  Query: {
    async user(
      parent,
      { id },
      context,
    ) {
      return context.repositories
        .userRepository
        .findById(id);
    },
  },
};

GraphQL дождётся выполнения Promise.

Поля одного уровня могут выполняться параллельно, если между ними нет зависимости.

Запрос:

query {
  currentUser {
    id
  }

  products {
    id
  }
}

Резолверы currentUser и products могут выполняться независимо.

Мутации верхнего уровня выполняются последовательно в порядке документа:

mutation {
  first: updateUser(
    id: "42"
    input: {
      name: "Анна"
    }
  ) {
    user {
      id
    }
  }

  second: publishArticle(
    id: "81"
  ) {
    article {
      id
    }
  }
}

Но вложенные поля результата могут выполняться параллельно.


Разделение ответственности

Резолвер не должен содержать всю бизнес-логику.

Нежелательно:

const resolvers = {
  Mutation: {
    async createOrder(
      parent,
      { input },
      context,
    ) {
      const user =
        await context.database.query(
          "SELECT * FROM users WHERE id = ?",
          [context.user.id],
        );

      let total = 0;

      for (const item of input.items) {
        total +=
          item.price *
          item.quantity;
      }

      await context.database.query(
        "INSERT INTO orders ...",
      );

      await context.emailClient.send({
        to: user.email,
      });

      return {
        order: {
          total,
        },
      };
    },
  },
};

Предпочтительно использовать резолвер как адаптер:

const resolvers = {
  Mutation: {
    async createOrder(
      parent,
      { input },
      context,
    ) {
      return context.useCases
        .createOrder
        .execute({
          currentUser:
            context.currentUser,
          input,
        });
    },
  },
};

Бизнес-логика находится в отдельном сценарии:

class CreateOrder {
  constructor({
    orderRepository,
    productRepository,
  }) {
    this.orderRepository =
      orderRepository;

    this.productRepository =
      productRepository;
  }

  async execute({
    currentUser,
    input,
  }) {
    // Валидация, авторизация,
    // расчёты и сохранение
  }
}

Контекст и аутентификация

Контекст создаётся при каждом запросе:

async function createContext({
  request,
}) {
  const token =
    extractBearerToken(request);

  const currentUser = token
    ? await tokenService
        .verifyAccessToken(token)
    : null;

  return {
    currentUser,
    repositories,
    services,
    logger:
      logger.child({
        requestId:
          request.headers.get(
            "x-request-id",
          ),
      }),
  };
}

Проверка аутентификации:

function requireAuthentication(
  context,
) {
  if (!context.currentUser) {
    throw new GraphQLError(
      "Требуется аутентификация",
      {
        extensions: {
          code:
            "UNAUTHENTICATED",
        },
      },
    );
  }

  return context.currentUser;
}

Использование:

const resolvers = {
  Query: {
    profile(
      parent,
      args,
      context,
    ) {
      const currentUser =
        requireAuthentication(
          context,
        );

      return context.repositories
        .userRepository
        .findById(
          currentUser.id,
        );
    },
  },
};

Авторизация

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

Права проверяются для конкретной операции и ресурса.

const resolvers = {
  Mutation: {
    async deleteUser(
      parent,
      { id },
      context,
    ) {
      const currentUser =
        requireAuthentication(
          context,
        );

      if (
        !currentUser.permissions
          .includes(
            "user:delete",
          )
      ) {
        throw new GraphQLError(
          "Недостаточно прав",
          {
            extensions: {
              code: "FORBIDDEN",
            },
          },
        );
      }

      return context.services
        .userService
        .deleteUser(id);
    },
  },
};

Для поля с чувствительными данными может потребоваться отдельная проверка:

const resolvers = {
  User: {
    email(
      user,
      args,
      context,
    ) {
      const currentUser =
        requireAuthentication(
          context,
        );

      const canRead =
        currentUser.id === user.id ||
        currentUser.permissions
          .includes(
            "user:email:read:any",
          );

      if (!canRead) {
        throw new GraphQLError(
          "Недостаточно прав",
          {
            extensions: {
              code: "FORBIDDEN",
            },
          },
        );
      }

      return user.email;
    },
  },
};

Скрытие поля во frontend не является авторизацией.


Ошибки в резолверах

Техническая ошибка:

throw new GraphQLError(
  "Не удалось выполнить операцию",
  {
    extensions: {
      code: "INTERNAL_SERVER_ERROR",
    },
  },
);

Ошибка отсутствия аутентификации:

throw new GraphQLError(
  "Требуется аутентификация",
  {
    extensions: {
      code: "UNAUTHENTICATED",
    },
  },
);

Недостаточно прав:

throw new GraphQLError(
  "Недостаточно прав",
  {
    extensions: {
      code: "FORBIDDEN",
    },
  },
);

Не следует отправлять клиенту:

Внутренняя ошибка записывается в лог с requestId, а клиенту возвращается безопасное сообщение.


Проблема N+1

Одна из основных проблем GraphQL — N+1 queries.

Запрос:

query {
  posts {
    id
    title

    author {
      id
      name
    }
  }
}

Наивная реализация:

const resolvers = {
  Query: {
    posts() {
      return postRepository
        .findAll();
    },
  },

  Post: {
    author(post) {
      return userRepository
        .findById(
          post.authorId,
        );
    },
  },
};

Если найдено 100 публикаций:

1 запрос для posts
+
100 запросов для author
=
101 запрос

Это и есть N+1.


DataLoader

DataLoader группирует несколько загрузок в один batch и кэширует результаты в пределах запроса.

Установка:

npm install dataloader

Создание loader:

import DataLoader from "dataloader";

function createUserLoader(
  userRepository,
) {
  return new DataLoader(
    async (userIds) => {
      const users =
        await userRepository
          .findByIds(userIds);

      const usersById =
        new Map(
          users.map((user) => [
            user.id,
            user,
          ]),
        );

      return userIds.map(
        (userId) =>
          usersById.get(userId) ??
          null,
      );
    },
  );
}

Контекст:

function createContext() {
  return {
    loaders: {
      userById:
        createUserLoader(
          userRepository,
        ),
    },
  };
}

Резолвер:

const resolvers = {
  Post: {
    author(
      post,
      args,
      context,
    ) {
      return context.loaders
        .userById
        .load(post.authorId);
    },
  },
};

Теперь запросы могут быть объединены:

SELECT *
FROM users
WHERE id IN (...)

Порядок результатов DataLoader

Batch-функция должна вернуть массив:

Вход:

["42", "7", "81"]

Результат должен соответствовать:

[
  user42,
  user7,
  user81,
]

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


DataLoader создаётся на запрос

Нельзя бездумно использовать один глобальный DataLoader для всех пользователей:

const globalUserLoader =
  new DataLoader(...);

Его внутренний кэш может:

Обычно DataLoader создаётся для каждой GraphQL-операции:

async function context() {
  return {
    loaders: {
      userById:
        createUserLoader(
          userRepository,
        ),
    },
  };
}

Пагинация

Нельзя без ограничения возвращать всю таблицу:

type Query {
  users: [User!]!
}

Для небольших данных можно использовать offset-пагинацию:

type Query {
  users(
    page: Int = 1
    limit: Int = 20
  ): UserPage!
}

type UserPage {
  items: [User!]!
  pageInfo: PageInfo!
}

type PageInfo {
  page: Int!
  limit: Int!
  total: Int!
  pages: Int!
}

Для часто изменяемых наборов обычно надёжнее курсорная пагинация:

type Query {
  users(
    first: Int = 20
    after: String
  ): UserConnection!
}

type UserConnection {
  edges: [UserEdge!]!
  pageInfo: ConnectionPageInfo!
}

type UserEdge {
  cursor: String!
  node: User!
}

type ConnectionPageInfo {
  hasNextPage: Boolean!
  endCursor: String
}

Запрос:

query GetUsers(
  $first: Int!
  $after: String
) {
  users(
    first: $first
    after: $after
  ) {
    edges {
      cursor

      node {
        id
        name
      }
    }

    pageInfo {
      hasNextPage
      endCursor
    }
  }
}

Сервер должен ограничивать максимальный размер страницы:

function normalizePageSize(first) {
  if (!Number.isInteger(first)) {
    return 20;
  }

  return Math.min(
    Math.max(first, 1),
    100,
  );
}

Apollo Server

Apollo Server — GraphQL-сервер для JavaScript и Node.js.

Установка базовых пакетов:

npm install @apollo/server graphql

Минимальный сервер

import {
  ApolloServer,
} from "@apollo/server";

import {
  startStandaloneServer,
} from "@apollo/server/standalone";

const typeDefs = `#graphql
  type User {
    id: ID!
    name: String!
    email: String!
  }

  type Query {
    user(id: ID!): User
    users: [User!]!
  }
`;

const users = [
  {
    id: "1",
    name: "Анна",
    email: "anna@example.com",
  },
  {
    id: "2",
    name: "Иван",
    email: "ivan@example.com",
  },
];

const resolvers = {
  Query: {
    user(parent, { id }) {
      return (
        users.find(
          (user) => user.id === id,
        ) ?? null
      );
    },

    users() {
      return users;
    },
  },
};

const server = new ApolloServer({
  typeDefs,
  resolvers,
});

const { url } =
  await startStandaloneServer(
    server,
    {
      listen: {
        port: 4000,
      },

      context: async ({
        req,
      }) => {
        return {
          authorization:
            req.headers.authorization,
        };
      },
    },
  );

console.log(
  `GraphQL server: ${url}`,
);

Endpoint будет доступен примерно по адресу:

http://localhost:4000/

Для полноценного приложения Apollo Server обычно интегрируют с HTTP-фреймворком и общей инфраструктурой middleware.


Полный пример схемы

const typeDefs = `#graphql
  scalar DateTime

  enum UserRole {
    USER
    EDITOR
    ADMIN
  }

  type User {
    id: ID!
    name: String!
    email: String!
    role: UserRole!
    createdAt: DateTime!
    posts(
      first: Int = 20
    ): [Post!]!
  }

  type Post {
    id: ID!
    title: String!
    content: String!
    published: Boolean!
    author: User!
    createdAt: DateTime!
  }

  input CreatePostInput {
    title: String!
    content: String!
  }

  input UpdatePostInput {
    title: String
    content: String
  }

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

  type CreatePostPayload {
    post: Post
    errors: [UserError!]!
  }

  type Query {
    currentUser: User
    post(id: ID!): Post
    posts(
      first: Int = 20
      after: String
    ): [Post!]!
  }

  type Mutation {
    createPost(
      input: CreatePostInput!
    ): CreatePostPayload!

    updatePost(
      id: ID!
      input: UpdatePostInput!
    ): Post!

    publishPost(
      id: ID!
    ): Post!
  }
`;

Резолверы

import {
  GraphQLError,
} from "graphql";

const resolvers = {
  DateTime: dateTimeScalar,

  Query: {
    currentUser(
      parent,
      args,
      context,
    ) {
      if (!context.currentUser) {
        return null;
      }

      return context.repositories
        .userRepository
        .findById(
          context.currentUser.id,
        );
    },

    post(
      parent,
      { id },
      context,
    ) {
      return context.repositories
        .postRepository
        .findById(id);
    },

    posts(
      parent,
      { first, after },
      context,
    ) {
      const limit =
        Math.min(
          Math.max(first, 1),
          100,
        );

      return context.repositories
        .postRepository
        .findPage({
          limit,
          after,
        });
    },
  },

  Mutation: {
    async createPost(
      parent,
      { input },
      context,
    ) {
      if (!context.currentUser) {
        throw new GraphQLError(
          "Требуется аутентификация",
          {
            extensions: {
              code:
                "UNAUTHENTICATED",
            },
          },
        );
      }

      try {
        const post =
          await context.useCases
            .createPost
            .execute({
              currentUser:
                context.currentUser,
              input,
            });

        return {
          post,
          errors: [],
        };
      } catch (error) {
        if (
          error.name ===
          "ValidationError"
        ) {
          return {
            post: null,
            errors:
              error.details,
          };
        }

        throw error;
      }
    },

    updatePost(
      parent,
      { id, input },
      context,
    ) {
      if (!context.currentUser) {
        throw new GraphQLError(
          "Требуется аутентификация",
          {
            extensions: {
              code:
                "UNAUTHENTICATED",
            },
          },
        );
      }

      return context.useCases
        .updatePost
        .execute({
          currentUser:
            context.currentUser,
          postId: id,
          input,
        });
    },

    publishPost(
      parent,
      { id },
      context,
    ) {
      if (!context.currentUser) {
        throw new GraphQLError(
          "Требуется аутентификация",
          {
            extensions: {
              code:
                "UNAUTHENTICATED",
            },
          },
        );
      }

      return context.useCases
        .publishPost
        .execute({
          currentUser:
            context.currentUser,
          postId: id,
        });
    },
  },

  User: {
    posts(
      user,
      { first },
      context,
    ) {
      const limit =
        Math.min(
          Math.max(first, 1),
          100,
        );

      return context.repositories
        .postRepository
        .findByAuthorId(
          user.id,
          {
            limit,
          },
        );
    },
  },

  Post: {
    author(
      post,
      args,
      context,
    ) {
      return context.loaders
        .userById
        .load(post.authorId);
    },
  },
};

Контекст Apollo Server

async function createGraphQLContext({
  req,
}) {
  const authorization =
    req.headers.authorization;

  let currentUser = null;

  if (authorization) {
    const [scheme, token] =
      authorization.split(" ");

    if (
      scheme?.toLowerCase() ===
        "bearer" &&
      token
    ) {
      try {
        currentUser =
          await tokenService
            .verifyAccessToken(
              token,
            );
      } catch {
        currentUser = null;
      }
    }
  }

  return {
    currentUser,
    repositories,
    useCases,

    loaders: {
      userById:
        createUserLoader(
          repositories
            .userRepository,
        ),
    },

    logger:
      logger.child({
        requestId:
          req.headers[
            "x-request-id"
          ],
      }),
  };
}

Контекст:


Аналоги Apollo Server

Для JavaScript также существуют другие GraphQL-серверы и инструменты:

Выбор зависит от:

Бизнес-логика не должна быть жёстко привязана к Apollo. Резолверы должны вызывать прикладные сервисы, которые можно использовать и из другого транспорта.


Apollo Client

Apollo Client — клиентская библиотека для выполнения GraphQL-операций и управления нормализованным кэшем.

Она предоставляет:

Установка базовых пакетов:

npm install \
  @apollo/client \
  graphql

Создание клиента

import {
  ApolloClient,
  HttpLink,
  InMemoryCache,
} from "@apollo/client";

const client =
  new ApolloClient({
    link: new HttpLink({
      uri:
        "https://api.example.com/graphql",

      credentials:
        "include",
    }),

    cache:
      new InMemoryCache(),
  });

Apollo Client с Bearer-токеном

import {
  ApolloClient,
  HttpLink,
  InMemoryCache,
} from "@apollo/client";

import {
  setContext,
} from "@apollo/client/link/context";

const httpLink = new HttpLink({
  uri: "https://api.example.com/graphql",
});

const authLink = setContext(
  (_, previousContext) => {
    const accessToken = getAccessToken();

    return {
      headers: {
        ...previousContext.headers,

        authorization: accessToken
          ? `Bearer ${accessToken}`
          : "",
      },
    };
  },
);

const client = new ApolloClient({
  link: authLink.concat(httpLink),
  cache: new InMemoryCache(),
});

Функция getAccessToken() должна возвращать актуальный access-токен или null:

let accessToken = null;

export function setAccessToken(token) {
  accessToken = token;
}

export function getAccessToken() {
  return accessToken;
}

Тогда после входа токен можно сохранить в памяти:

setAccessToken(loginResult.accessToken);

При каждом GraphQL-запросе authLink добавит заголовок:

Authorization: Bearer access-token

Если токен отсутствует, заголовок Authorization лучше вообще не добавлять:

const authLink = setContext(
  (_, previousContext) => {
    const accessToken = getAccessToken();

    return {
      headers: {
        ...previousContext.headers,

        ...(accessToken
          ? {
              authorization:
                `Bearer ${accessToken}`,
            }
          : {}),
      },
    };
  },
);

Если аутентификация основана на HttpOnly cookie, authLink обычно не нужен. Достаточно разрешить браузеру отправлять cookie:

import {
  ApolloClient,
  HttpLink,
  InMemoryCache,
} from "@apollo/client";

const httpLink = new HttpLink({
  uri: "https://api.example.com/graphql",
  credentials: "include",
});

const client = new ApolloClient({
  link: httpLink,
  cache: new InMemoryCache(),
});

При междоменном запросе сервер должен корректно настроить CORS:

Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true

При использовании credentials нельзя указывать:

Access-Control-Allow-Origin: *

Observability

Для GraphQL полезно собирать:

Структурированный лог:

logger.info(
  {
    operationName:
      "GetUserProfile",
    operationType:
      "query",
    durationMs: 84,
    requestId,
    userId:
      currentUser?.id,
    errorCount: 0,
  },
  "GraphQL operation completed",
);

Не следует использовать в labels метрик:

Подходящие labels:

operation_name
operation_type
result
service

При этом число возможных operation_name должно быть контролируемым. Именованные и persisted operations упрощают мониторинг.


GraphQL и REST

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


Модель endpoint

REST:

GET    /users/42
GET    /users/42/posts
POST   /posts
PATCH  /posts/815
DELETE /posts/815

GraphQL:

POST /graphql

В GraphQL конкретная операция находится в теле запроса:

query GetUser {
  user(id: "42") {
    id
    name
  }
}

Получение связанных данных

REST может потребовать несколько запросов:

GET /users/42
GET /users/42/posts
GET /posts/815/comments

GraphQL может получить связанные данные одной операцией:

query {
  user(id: "42") {
    id
    name

    posts(first: 10) {
      id
      title

      comments(first: 5) {
        id
        text
      }
    }
  }
}

Это уменьшает число сетевых запросов, но не гарантирует уменьшение числа запросов к базе. Без DataLoader и оптимизации сервер может создать N+1.


Overfetching

REST endpoint может возвращать больше данных, чем нужно:

{
  "id": "42",
  "name": "Анна",
  "email": "anna@example.com",
  "role": "editor",
  "settings": {},
  "createdAt": "...",
  "updatedAt": "..."
}

Если экрану нужны только:

id
name

остальные поля являются overfetching.

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

query {
  user(id: "42") {
    id
    name
  }
}

Underfetching

В REST один endpoint может не содержать все нужные связи, поэтому клиент делает дополнительные запросы.

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

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


Версионирование

REST часто использует версию в URL:

/api/v1/users
/api/v2/users

GraphQL обычно развивает одну схему:

  1. Добавляет новые поля.
  2. Помечает старые через @deprecated.
  3. Собирает статистику использования.
  4. Удаляет поле после миграции клиентов.

Пример:

type User {
  fullName: String
    @deprecated(
      reason: "Используйте displayName"
    )

  displayName: String!
}

Это не означает, что GraphQL вообще не требует версионирования. Несовместимые изменения всё равно нужно управляемо внедрять.


HTTP-семантика

REST естественно использует:

GraphQL переносит значительную часть семантики внутрь тела:

operation
fields
arguments
errors

Поэтому инфраструктура может видеть только:

POST /graphql

без дополнительной GraphQL-инструментации.


Кэширование

REST-ресурс:

GET /products/815

легко кэшировать по URL.

GraphQL-запросы могут иметь:

Apollo Client решает часть задачи через нормализованный клиентский кэш. Для CDN часто используются persisted queries или GET-запросы.


Загрузка файлов

REST естественно работает с:

multipart/form-data

GraphQL не имеет встроенного стандартного бинарного типа для загрузки файлов.

Практичный подход:

  1. Через GraphQL получить разрешение или подписанный URL.
  2. Загрузить файл напрямую в объектное хранилище.
  3. Через GraphQL подтвердить или связать файл с сущностью.

Пример:

mutation {
  createUploadUrl(
    input: {
      fileName: "avatar.png"
      contentType: "image/png"
    }
  ) {
    uploadUrl
    fileId
  }
}

Сам файл загружается отдельным HTTP-запросом.


Ошибки

REST:

HTTP/1.1 404 Not Found

GraphQL:

{
  "data": {
    "user": null
  },
  "errors": [
    {
      "message": "Пользователь не найден",
      "extensions": {
        "code": "NOT_FOUND"
      }
    }
  ]
}

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


Когда GraphQL действительно нужен

GraphQL особенно полезен, если одновременно присутствуют несколько условий.

Много разных клиентов

Например:

Web-приложение
Мобильное приложение
Панель администратора
Партнёрский интерфейс

Каждому клиенту требуется свой набор полей и связей.

Web:

user {
  id
  name
  avatarUrl
}

Административный интерфейс:

user {
  id
  name
  email
  role
  status
  createdAt
}

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


Сложный связанный граф данных

Например:

Проекты
├── участники
├── задачи
│   ├── исполнитель
│   ├── комментарии
│   └── вложения
└── события

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


Частые изменения требований frontend

Frontend-команды могут выбирать новые поля без создания отдельного REST endpoint:

query {
  project(id: "42") {
    id
    name
    taskCount
    activeMembers {
      id
      name
    }
  }
}

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


Медленные или ограниченные сети

Одна GraphQL-операция может заменить несколько последовательных HTTP-запросов.

Это полезно для:


Нужен строгий исследуемый контракт

GraphQL-схема предоставляет:

Это улучшает взаимодействие frontend и backend-команд.


GraphQL как API aggregation layer

GraphQL-сервер может объединять:

User Service
Order Service
Catalog Service
Payment Service
REST APIs
Databases

Клиент видит единую предметную схему:

query {
  currentUser {
    id
    orders {
      id
      products {
        id
        name
      }
    }
  }
}

Но GraphQL не устраняет сложности распределённой системы. Серверу всё равно нужно обрабатывать:


Mutation:

```graphql
mutation CreateUser(
  $input: CreateUserInput!
) {
  createUser(input: $input) {
    user {
      id
      name
    }

    errors {
      code
      field
      message
    }
  }
}

Резолвер:

const resolvers = {
  Query: {
    user(
      parent,
      { id },
      context,
    ) {
      return context.repositories
        .userRepository
        .findById(id);
    },
  },
};

Аргументы резолвера:

parent  — результат родительского поля
args    — аргументы GraphQL-поля
context — пользователь, сервисы, loaders, logger
info    — информация о выполнении и AST

Apollo Server:

const server =
  new ApolloServer({
    typeDefs,
    resolvers,
  });

Apollo Client:

const client =
  new ApolloClient({
    link: httpLink,
    cache:
      new InMemoryCache(),
  });

Основные правила: