Проектирование приложения для работы с различными серверами GraphQL
«Программировать против интерфейсов, а не реализаций» — это практика вызова функциональности не напрямую, а через контракт, который перечисляет необходимые входные данные и ожидаемый результат, скрывая детали реализации. Эта стратегия помогает отвязать приложение от конкретной реализации, провайдера или стека, позволяя переключаться между ними без изменения кода приложения.
Мы можем применить эту стратегию и с GraphQL. GraphQL способен выступать посредником между приложением и сервером, позволяя нам выполнять все необходимые изменения только в GraphQL queries, оставляя бизнес-логику нетронутой.
Запрос 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 |
|---|---|---|
| Поле категорий поста | categories | postCategories |
| Аргумент поля для ограничения числа результатов | first | pagination.limit |
Поле id объекта представляет | его уникальный глобальный ID | его уникальный ID для своего типа |
| Форма запроса | глубже из-за edges.node | более плоская |
Простая замена запроса первого сервера на эквивалентный запрос второго внутри приложения не сработает. Причина в том, что логика по-прежнему будет обращаться к данным ответа согласно форме и полям исходного запроса.
Одно из возможных решений — также заменить логику получения данных на стороне клиента. Например, следующую логику:
const categories = data?.data.categories.edges.map(({ node = {} }) => node);...можно заменить вот так:
const categories = data?.data.postCategories;Но именно этого мы и хотим избежать. Мы хотим свести изменения к минимуму, изменяя только интерфейс (GraphQL запрос) и сохраняя бизнес-логику без изменений.
К счастью, можно преодолеть различия, изменяя только GraphQL queries, выполнив следующие шаги:
- Хранение GraphQL queries отдельно от приложения
- Адаптация имён полей с помощью псевдонимов
- Адаптация формы ответа с помощью поля
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 на другой.