GraphQL Global Object Identification

10 min read

Global Object Identification — одна из лучших практик при построении качественного GraphQL API. Она призвана помочь клиентам упростить хранение, обновление и кэширование данных. В этой статье мы разберём, о чём данная спецификация и в чём её преимущества. 

Основная идея спецификации заключается в том, чтобы сделать поле id уникальным не только среди записей того же типа, но и среди всех остальных типов в API. Например, это значит, что у вас не может быть пользователя (тип User) с id=4 и статьи (тип Article) с таким же id.

Основные требования

Спецификация требует от разработчиков реализовать некоторые вещи. Разберём по порядку.

Интерфейс Node

Первое, что необходимо сделать —  создать интерфейс с именем Node и единственным полем — id типа ID!. Так это может выглядеть в GraphQL schema definition language (SDL):

""" An object with a Globally Unique ID """ interface Node { """ The ID of the object. """ id: ID! }

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

type User implements Node { """ User's ID """ id: ID! """ Username """ username: String! }

Ключевой момент заключается в том, что значение поля id должно быть уникально для всех типов, то есть не может быть двух сущностей разных типов (например, User и Article) с одинаковым id. В базе данных сущности могут иметь одинаковый id (например, порядковый номер), но ваш GraphQL API должен возвращать только уникальные значения. Как можно этого достичь, мы рассмотрим позже.

Запрос node

Следующий шаг — описать корневой запрос с именем node, который принимает аргумент id: ID! и возвращает Node (все имена и типы жестко заданы в спецификации, менять не рекомендуется).

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

""" API queries """ type Query { node(id: ID!): Node }

Теперь задача разработчика — написать этот резолвер. Суть резолвера — по id понять, что хочет получить пользователь, и вернуть это. Пример реализации разберём далее в статье.

Преимущества

Так ради чего все эти сложности?

Возможность запросить любой объект по его ID

Очевидное преимущество — благодаря запросу node(id: ID!) клиент имеет возможность запросить любую сущность, зная лишь её тип и id. На примере API GitHub, который в полной мере реализует данную спецификацию, посмотрим как это работает.

Допустим, мы знаем id MDEyOk9yZ2FuaXphdGlvbjE2MDYwODE1 и знаем, что за этим id скрывается организация. Тогда мы можем получить данные о ней с помощью такого запроса:

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

Улучшенное кэширование и работа со стором

Благодаря тому, что все id уникальные, клиентские GraphQL-библиотеки (например Relay) могут более эффективно построить своё внутреннее хранилище, работать с кэшом и автоматически обновлять записи после мутаций.

Давайте посмотрим как Relay организует своё хранилище с Global Object Identification:

Мы видим, что Relay хранит данные в словаре (Map). Ключи могут быть сгенерированы автоматически, если у типа нет нет id (например, client:TG9jYXRpb246NWQ4MzQ0M2UwY2I0MzMwMDNmMjIzYmU2:addresses:2, как у адресов), а могут представлять собой лишь id сущности, например, TG9jYXRpb246NWQ4MzQ0M2UwY2I0MzMwMDNmMjIzYmU4.

Представление сущности без id
Представление сущности c id

Какие преимущества это нам даёт?

  1. Если клиент в дальнейшем запрашивает какой-либо тип по id, то GraphQL-клиент может вернуть ему данные из кэша, вместо того чтобы делать новый запрос.
  2. Если в качестве результата мутации возвращать тип, который реализует Node, то GraphQL-клиент сможет автоматически обновить хранилище, после того как мутация выполнилась. В качестве примера рассмотрим запрос:
mutation UserEditMutation($input: UpdateUserInput!) { user { update(input: $input) { record { id permissions } } } }

Данный запрос обновляет данные пользователя и возвращает обновлённые данные (поле permissions — права пользователя).

Вот так выглядит возвращаемый тип данной мутации:

type UpdateUserResponse { """ Updated user id """ recordId: ID! """ Updated user """ record: User! }

Благодаря тому, что мы возвращаем тип User из мутации, который реализует интерфейс Node, GraphQL-клиент имеет возможность обновить данные в хранилище по id, который возвращает мутация, автоматически. Таким образом, мы можем выполнить мутацию и сразу получить свежие данные в нашем хранилище.

Детали реализации

Как добиться того, чтобы id был уникальным

Посмотрим, как решается это проблема на примере GitHub API (но вам не обязательно добиваться уникальности там же образом, можете придумать другое решение). 

Возьмём id MDEyOk9yZ2FuaXphdGlvbjE2MDYwODE1, с которым мы работали выше, и декодируем его из base64 (именно в этой кодировке обычно кодируют id в GraphQL). Получим следующий результат: 012:Organization16060815. Id на GitHub формируются в виде {type_id}:{type_name}{object_id}, то есть к id из базы данных дописывается ещё название типа, что даёт нам гарантию того, что этот id будет уникален во всём API.

Таким образом, идея заключается в том, чтобы добиться уникальности id с помощью соединения названия типа и идентификатора конкретной записи. Таким образом, даже если у двух сущностей разных типов в БД одинаковые id (пользователь и статья с id 4), то после такой операции они станут разными: User:4 и Article:4.

Также у вас может возникнуть вопрос, зачем кодировать id в base64. Причина, по которой это делается — сказать пользователям, что идентификатор является непрозрачным идентификатором (opaque identifier). Это означает, что клиент не должен пытаться сам составить id, чтобы запросить какую-либо сущность и рассматривать id как некую рандомную сущность.

Как приводить id к нужному формату (type + id)? 

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

1. Вручную приводить к нужному формату в каждом резолвере

Тут всё просто — при возвращении результата из резолвера типа уже возвращать id в нужном формате. Но есть и большая проблема, связанная с большим количеством ручной работы и дублирования кода.

2. Реализовать кастомную директиву и применять её ко всем id в схеме

Пример:

type UpdateUserResponse { """ Updated user id """ recordId: ID! @toGlobalId(type: "User") """ Updated user """ record: User! }

3. Автоматически приводить id к нужному формату с помощью schemaTransforms

Пакет @graphql-tools/schema предоставляет метод makeExecutableSchema, который получает на вход определения типов и резолверы и возвращает готовую схему. Затем готовую схему мы передаём при инициализации GraphQL сервера.

Этот метод также может принимать schemaTransforms — массив функций, которые принимают схему и возвращают изменённый её вариант. Можно написать функцию, которая будет автоматически трансформировать все id у типов, которые реализуют интерфейс Node. Пример такой функции:

/** * Auto-encode all id fields of any type that implements 'Node' interface * * @param schema - schema to transform */ export default function (schema: GraphQLSchema): GraphQLSchema { return mapSchema(schema, { [MapperKind.OBJECT_TYPE]: (fieldConfig) => { const typeName = fieldConfig.astNode?.name.value as NodeName; if (!typeName) { return fieldConfig; } const isImplementingNode = fieldConfig.getInterfaces().findIndex(i => i.name === 'Node') !== -1; if (!isImplementingNode) { return fieldConfig; } const field = fieldConfig.getFields()['id']; const { resolve = defaultFieldResolver } = field; field.resolve = async (parent, args, context: ResolverContextBase, info): Promise<unknown> => { const value = resolve(parent, args, context, info); return toGlobalId(typeName, value); }; return fieldConfig; }, }); }

И вот как мы можем её использовать при создании схемы:

import globalIdResolver from './globalIdResolver'; export default makeExecutableSchema({ typeDefs, resolvers, schemaTransforms: [ globalIdResolver, ], });

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

Как реализовать запрос node

Тут всё достаточно просто — необходимо декодировать id, который прислал клиент, и обратиться в нужную коллекцию в вашей БД за необходимой записью, чтобы вернуть её клиенту.

Пример:

/** * Node resolver according to Global Object Identification (https://graphql.org/learn/global-object-identification/) * * @param parent - this is the return value of the resolver for this field's parent * @param args - contains all GraphQL arguments provided for this field * @param context - this object is shared across all resolvers that execute for a particular operation */ async node(parent: undefined, args: QueryNodeArgs, { dataLoaders }: ResolverContextBase): Promise<unknown> { const { type, id } = fromGlobalId(args.id); const dataloaderName = camelCase(type) + 'ById' as FieldsWithDataLoader; /** * Load data via DataLoaders * @see https://github.com/graphql/dataloader */ const node = await dataLoaders[dataloaderName].load(id); return { ...node, __typename: type, }; }

Заключение

Global Object Identification — крайне полезная практика, которая поможет клиентам эффективнее работать с вашим API. К тому же, она не очень сложна в реализации.