Концепции, идеи, стратегии
Концепции, идеи, стратегииПроектирование приложения для работы с различными серверами GraphQL

Проектирование приложения для работы с различными серверами GraphQL

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

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

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

Запрос GraphQL выступает как интерфейс между клиентом и сервером

Ответ на запрос будет иметь ту же форму, что и сам запрос. Для следующего GraphQL запроса:

{
  post(by: { id: 1 }) {
    id
    title
  }
}

...ответ будет таким:

{
  "data": {
    "post": {
      "id": 1,
      "title": "Hello world!"
    }
  }
}

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

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

Queries зависят от схемы GraphQL

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

Например, сервер GraphQL, использующий Cursor Connections Specification, может выполнять следующий запрос:

{
  categories(first: 10000) {
    edges {
      node {
        categoryId
        description
        id
        name
        slug
      }
    }
  }
}

А другой сервер, использующий пагинацию в стиле WordPress (например, Gato GraphQL), выполнит тот же запрос вот так:

{
  postCategories(pagination: { limit: 10000 }) {
    id
    description
    globalID
    name
    slug
  }
}

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

ХарактеристикаСервер №1Сервер №2
Поле категорий постаcategoriespostCategories
Аргумент поля для ограничения числа результатовfirstpagination.limit
Поле id объекта представляетего уникальный глобальный IDего уникальный ID для своего типа
Форма запросаглубже из-за edges.nodeболее плоская

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

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

const categories = data?.data.categories.edges.map(({ node = {} }) => node);

...можно заменить вот так:

const categories = data?.data.postCategories;

Но именно этого мы и хотим избежать. Мы хотим свести изменения к минимуму, изменяя только интерфейс (GraphQL запрос) и сохраняя бизнес-логику без изменений.

К счастью, можно преодолеть различия, изменяя только GraphQL queries, выполнив следующие шаги:

  1. Хранение GraphQL queries отдельно от приложения
  2. Адаптация имён полей с помощью псевдонимов
  3. Адаптация формы ответа с помощью поля self

Посмотрим, как с помощью этих 3 шагов можно адаптировать приложение для работы с другим сервером GraphQL.

Хранение GraphQL queries отдельно от приложения

Отделение GraphQL queries от логики приложения предполагает:

  • Хранение каждого GraphQL запроса (или их группы) в отдельном файле, а всех файлов — в специальной папке
  • Экспорт queries и их импорт в приложение

Например, можно поместить каждый GraphQL запрос в отдельный файл в src/data и экспортировать его:

// file `src/data/categories.js`
export const QUERY_ALL_CATEGORIES = gql`
  {
    categories(first: 10000) {
      edges {
        node {
          databaseId
          description
          id
          name
          slug
        }
      }
    }
  }
`;

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

import { QUERY_ALL_CATEGORIES } from 'data/categories';
 
export async function getAllCategories() {
  const apolloClient = getApolloClient();
 
  const data = await apolloClient.query({
    query: QUERY_ALL_CATEGORIES,
  });
 
  const categories = data?.data.categories.edges.map(({ node = {} }) => node);
 
  return {
    categories,
  };
}

Благодаря такой структуре все изменения нужно вносить только в файлы в директории src/data.

Адаптация имён полей с помощью псевдонимов

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

Таким образом, поля postCategories, id и globalID можно получить под именами, которые ожидает приложение: categories, categoryId и id соответственно:

{
  categories: postCategories(pagination: { limit: 10000 }) {
    categoryId: id
    description
    id: globalID
    name
    slug
  }
}

Обратите внимание, что поле categories имеет аргумент first, тогда как соответствующее поле postCategories использует аргумент pagination.limit. Однако, поскольку аргументы поля не отражаются в имени поля в ответе, беспокоиться о них не нужно.

Адаптация формы ответа с помощью поля self

Последняя задача несколько сложнее: нам нужно изменить форму ответа, добавив дополнительные уровни для edges и node, предусмотренные спецификацией Cursor Connections.

Для этого мы введём поле self во все типы схемы GraphQL, которое возвращает тот же объект, к которому оно применяется:

type QueryRoot {
  self: QueryRoot!
}
 
type Post {
  self: Post!
}
 
type User {
  self: User!
}

Поле self позволяет добавлять дополнительные уровни к запросу, не покидая запрашиваемый объект. Выполнение следующего запроса:

{
  __typename
  self {
    __typename
  }
  
  post(by: { id: 1 }) {
    self {
      id
      __typename
    }
  }
  
  user(by: { id: 1 }) {
    self {
      id
      __typename
    }
  }
}

...даёт такой ответ:

{
  "data": {
    "__typename": "QueryRoot",
    "self": {
      "__typename": "QueryRoot"
    },
    "post": {
      "self": {
        "id": 1,
        "__typename": "Post"
      }
    },
    "user": {
      "self": {
        "id": 1,
        "__typename": "User"
      }
    }
  }
}

Теперь мы можем использовать self для искусственного добавления уровней nodes и edge:

{
  categories: self {
    edges: postCategories(pagination: { limit: 10000 }) {
      node: self {
        categoryId: id
        description
        id: globalID
        name
        slug
      }
    }
  }
}

Тип объекта в схеме GraphQL для edges и для self очевидно различается. Но это не важно для приложения, поскольку оно не взаимодействует с реальным объектом, смоделированным в сервере GraphQL. Вместо этого оно получает данные как JSON-объект, и этот фрагмент данных для поля из объекта PostConnection или из объекта Post будет одинаковым.

Обратите внимание, что поле categories разрешается через self, а edges — через postCategories, а не наоборот. Это нужно для того, чтобы кардинальность возвращаемых элементов соответствовала той, что определена полями, использующими спецификацию Cursor Connections:

type RootQuery {
  categories: RootQueryToCategoryConnection
}
 
type RootQueryToCategoryConnection {
  edges: [RootQueryToCategoryConnectionEdge]
}
 
type RootQueryToCategoryConnectionEdge {
  node: Category
}

Если бы адаптированный GraphQL запрос был составлен наоборот (то есть запрашивал categories: postCategories и edges: self), обращение к данным завершилось бы ошибкой, поскольку data.categories было бы массивом, а значит, data.categories.edges вызвало бы ошибку при выполнении:

const categories = data?.data.categories.edges.map(({ node = {} }) => node);

Адаптация всех queries

После применения той же стратегии ко всем GraphQL queries в src/data приложение сможет легко переключаться с одного сервера GraphQL на другой.