javascript

Работа с даными при построениии API на основе GraphQL

  • среда, 24 октября 2018 г. в 00:18:15
https://habr.com/post/427399/
  • TypeScript
  • Node.JS
  • JavaScript
  • API


Преамбула


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


GraphQL замечательный инструмент. Думаю, о его преимуществах уже знают и понимают многие. Тем не менее, есть некоторые нюансы, которые следует знать, когда вы строите свои API на основе GraphQL.


Например, GraphQL позволяет возвращать потребителю (пользователю или программе) запросившем данные только ту их часть, в которой этот потребитель заинтересован. Тем не менее, при построении сервера довольно легко совершить оплошность, которая приводит к тому, что внутри сервера (который может быть, в том числе, — распределенным) данные будут курсировать полными "пачками". В первую очередь это связано с тем, что "из коробки" сам GraphQL не предоставляет удобных инструментов для разбора входящего запроса, а те интерфейсы, которые в нем заложены недостаточно документированы.


Источник проблемы


Давайте рассмотрим типичный пример неоптимальной реализации (откройте картинку в отдельном окне если плохо читается):


image


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


В точке обмена данными между consumer'ом и API, GraphQL прекрасно делает всю необходимую нам работу — только запрошенные данные будут отпправлены в ответ на запрос. Проблема в данном случае находится в точке выборки данных из базы — т.е. во внутренней реализации нашего сервера, и заключается она в том, что на каждый входящий запрос мы выбираем из базы все данные пользователя, не взирая на то, что их часть нам не нужна. Это генерирует излишнюю нагрузку на базу данных и приводит к циркуляции излишнего трафика внутри системы. При значительном количестве запросов можно получить сущетсенную оптимизацию, изменив подход к выборке данных и выбирать только те поля, которые были запрошены. При этом абсолютно не важно, что у нас выступает источником данных — реляционная база, NoSQL технологии или другой сервис (внутренний или внешний). Подобному неоптимальному поведению могут быть подвержены любые реализации. MySQL в данном случае выбран просто в качестве примера.


Решение


Возможно оптимизировать данное поведение сервера, если проанализировать аргументы, которые приходят в resolve() функцию:


async resolve(source, args, context, info) {
    // ...
}

Именно последний аргумент, info представляет для нас, в данном случае, особый интерес. Обратимся к документации и разберем подробно из чего же состоит resolve() функция и интересующий нас агрумент:


type GraphQLFieldResolveFn = (
  source?: any,
  args?: {[argName: string]: any},
  context?: any,
  info?: GraphQLResolveInfo
) => any

type GraphQLResolveInfo = {
  fieldName: string,
  fieldNodes: Array<Field>,
  returnType: GraphQLOutputType,
  parentType: GraphQLCompositeType,
  schema: GraphQLSchema,
  fragments: { [fragmentName: string]: FragmentDefinition },
  rootValue: any,
  operation: OperationDefinition,
  variableValues: { [variableName: string]: any },
}

Итак, первые три аргумента, переданые в "резолвер" это source — данные переданные от родительской ноды в дереве GraphQL схемы, args — аргументы запроса (которые приходят из query), и context — определяемый разработчиком объект контекста выполнения, зачастую призванный передавать некие глобальные данные в "резолверы". И, наконец, четвертый аргумент — это мета-информация о запросе.


Что мы можем извлечь из GraphQLResolveInfo для решения нашей задачи?


Наиболее интересные его части — это:


  • fieldName — текущее имя поля их GraphQL схемы. Т.е. оно соответствует тому имени поля, которое задано в схеме для данного резолвера. Если мы ловим info объект на поле users, как в нашем примере выше, то именно "users" и будет содержаться в качестве значения fieldName
  • fieldNodes — коллекция (массив) нод, которые были ЗАПРОШЕНЫ в query. Как раз то, что требуется!
  • fragments — коллекция фрагментов запроса (в случае, если запрос был фрагментирован). Также важная информация для извлечения конечных полей данных.

Итак, в качестве решения, мы должны разобрать агрумент info и выбрать список полей, которые к нам пришли из query, а далее передать их в SQL-запрос. К сожалению, GraphQL пакет от Facebook "из коробки" не дает нам ничего, чтобы упростить эту задачу. В целом же, как показала практика, данная задача не столь тривиальна, если учесть тот факт, что запросы могут быть фрагментированы. А к тому же, подобный разбор имеет универсальное решение, которое впоследствии просто копируется от проекта к проекту.


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


const { fieldsList } = require('graphql-fields-list');
// ...
async resolve(source, args, context, info) {
  const requestedFields = fieldsList(info);
  return await database.query(`SELECT ${requestedFields.join(',')} FROM users`)
}

fieldsList(info) в данном случае делает за нас всю работу и возвращает "плоский" массив дочерних полей для данного резолвера, т.е. наш итоговый SQL-запрос будет выглядеть так:


SELECT id, name, phone FROM users;

Если же мы изменим входящий запрос на:


query UserListQuery {
  users {
    id
    name
    phone
    email
  }
}

то SQL-запрос превратится в:


SELECT id, name, phone, email FROM users;

Однако не всегда можно обойтись таким простым вызовом. Зачастую, реальные приложения гораздо сложнее по своей структуре. В некоторых реализациях нам может потребоваться описывать резолвер на верхнем уровне по отношению к данным в итоговой GraphQL схеме. Например, в случае, если мы решили использовать библиотеку Relay мы захотим использовать готовый механизм для разбивки коллекций объектов данных по страницам, что приводит к тому, что наша GraphQL схема будет выстроена по определенным правилам. Например, переработаем нашу схему таким образом (TypeScript):


import { GraphQLObjectType, GraphQLSchema, GraphQLString } from 'graphql';
import { 
    connectionDefinitions,
    connectionArgs,
    nodeDefinitions,
    fromGlobalId,
    globalIdField,
    connectionFromArray,
    GraphQLResolveInfo,
} from 'graphql-relay';
import { fieldsList } from 'graphql-fields-list';

export const { nodeInterface, nodeField } = nodeDefinitions(async (globalId: string) => {
    const { type, id } = fromGlobalId(globalId);
    let node: any = null;
    if (type === 'User') {
        node = await database.select(`SELECT id FROM user WHERE id="${id}"`);
    }
    return node;
});

const User = new GraphQLObjectType({
    name: 'User',
    interfaces: [nodeInterface],
    fields: {
        id: globalIdField('User', (user: any) => user.id),
        name: { type: GraphQLString },
        email: { type: GraphQLString },
        phone: { type: GraphQLString },
        address: { type: GraphQLString },
    }
});

export const { connectionType: userConnection } =
    connectionDefinitions({ nodeType: User });

const Query = new GraphQLObjectType({
    name: 'Query',
    fields: {
        node: nodeField,
        users: {
            type: userConnection,
            args: { ...connectionArgs },
            async resolve(
                source: any,
                args:  {[argName: string]: any},
                context: any,
                info: GraphQLResolveInfo,
            ) {
                // TODO: implement
            },
    },
});

export const schema = new GraphQLSchema({
    query: Query
});

При этом connectionDefinition из Relay добавит в схему ноды edges, node, pageInfo и cursor, т.е. нам нужно будет теперь наши запросы перестроить иначе (мы не будем сейчас останавливаться на постраничном выводе):


query UserListQuery {
  users {
    edges {
      node {
        id
        name
        phone
        email
      }
    }
  }
}

А значит, resolve() функция, реализованная на ноде users теперь должна будет определить какие поля запрошены не для нее самой, но для ее вложенной дочерней ноды node, которая, как мы видим, находится по отношению к users по пути edges.node.


fieldsList из библиотеки graphql-fields-list поможет решить и эту проблему, для этого следует передать ему соответсвующую опцию path. Например, вот какой может быть реализация в нашем случае:


async resolve(
    source: any,
    args:  {[argName: string]: any},
    context: any,
    info: GraphQLResolveInfo,
) {
    const fields = fieldsList(info, { path: 'edges.node' });
    return connectionFromArray(
        await database.query(`SELECT ${fields.join(',')} FROM users`),
        args
    );
}

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


CREATE TABLE users (
  id BIGINT PRIMARY KEY 
      AUTO_INCREMENT,
  fullName VARCHAR(255),
  email VARCHAR(255),
  phoneNumber VARCHAR(15),
  address VARCHAR(255)
);

В этом случае поля из запроса GraphQL перед тем как встроить в SQL-запрос следует переименовать. fieldsList поможет и в этом, если ему передать карту переобразований имен в соответствующей опции transform:


async resolve(
    source: any,
    args:  {[argName: string]: any},
    context: any,
    info: GraphQLResolveInfo,
) {
    const fields = fieldsList(info, {
        path: 'edges.node',
        transform: { phone: 'phoneNumber', name: 'fullName' },
    });
    return connectionFromArray(
        await database.query(`SELECT ${fields.join(',')} FROM users`),
        args
    );
}

И тем не менее, иногда, преобразования в плоский массив полей недостаточно (например, если источник данных возвращает сложную структуру с вложенностями). В этом случае придет на помощь функция fieldsMap из библиотеки graphql-fields-list, которая возвращает все дерево запрошенных полей в виде объекта:


const { fieldsMap } = require(`graphql-fields-list`);
// ... some resolver implementation on `users`:
resolve(arc, args, ctx, info) {
   const map = fieldsMap(info);
   /*
    RESULT:
    {
      edges: {
        node: {
          id: false,
          name: false,
          phone: false,
        }
      }
    }
   */
}

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


const { fieldsMap } = require(`graphql-fields-list`);
// ... some resolver implementation on `users`:
resolve(arc, args, ctx, info) {
   const map = fieldsMap(info, 'edges.node');
   /*
    RESULT:
    {
      id: false,
      name: false,
      phone: false,
    }
   */
}

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


Фрагментация запросов


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


query UsersFragmentedQuery { 
  users {
    id
    ...NamesFramgment
    ...ContactsFragment
  }
}
fragment NamesFragment on User {
    name
}
fragment AddressFragment on User {
    address
}
fragment ContactsFragment on User {
  phone
  email
  ...AddressFragment
}

Не следует переживать в этом случае, и fieldsList(info), и fieldsMap(info) в данном случае вернут ожидаемый результат, так как учитывают возможность фрагментирования запросов. Так, fieldsList(info) вернет ['id', 'name', 'phone', 'email', 'address'], а fieldsMap(info), соответственно, вернет:


{
  id: false,
  name: false,
  phone: false,
  email: false,
  address: false
}

P.S.


Надеюсь данная статья помогла пролить свет на некоторые нюансы по работе с GraphQL на сервере, а библиотека [graphql-fields-list] сможет помочь вам в будущем создавать оптимальные решения.


UPD 1


Выпущена версия 1.1.0 библиотеки — добавлена поддержка директив @skip и @include в запросах. По умолчанию опция включена, при необходимости отключить используйте так:


fieldsList(info, { withDirectives: false })
fieldsMap(info, null, false);