Pull to refresh
140.83
hh.ru
HR Digital

Внедрение GraphQL на фронтенде без переломов

Reading time14 min
Views4.9K

Всем привет! Меня зовут Илья, я фронтенд-разработчик в hh.ru. В статье расскажу, как внедрить GraphQL на фронте, не переломав всё на своем пути.


В проекте мы используем React и Redux, для асинхронных запросов у нас есть собственная библиотека, а бэкенд работает на Java. Для получения данных используем страничные URL, а когда заходим на страницу, прямо по URl-у забираем все данные Аяксом. Это влечет за собой две проблемы — overfetching и underfetching. Проще говоря, либо у нас избыток данных, которые используются в данном рендере, либо их нехватка. Эту беду и призван решить GraphQL.


Если лень читать или больше нравится видеоформат — вам сюда.


Внедрение GraphQL на фронтенде без переломов


Начнем ресерч


Итак, мы вместе с бэкендом уходим на ресерч. В процессе бэк выбирает свой фреймворк, а мы смотрим, что у нас там есть на фронте. А там целых три фреймворка — Relay, Apollo и URQL.


Relay


https://relay.dev/


Relay — это библиотека Фейсбука, очень мощный инструмент. В нем целая плеяда возможностей, но он оказывает довольно сильное влияние на архитектуру фронтенда и даже бэкенда. Это нам не очень подходит.


Apollo


https://www.apollographql.com/docs/react/


У Apollo потрясающее комьюнити, очень много пакетов, плюшек и свистелок. Это очень подкупает, но поскольку это большой фреймворк, он теряет в гибкости. Впрочем, все еще оставаясь привлекательным. Советую посмотреть доклады Павла Черторогова, он очень здорово качает тему GraphQL, и Apollo в частности. Если вы начинаете делать что-то с нуля, я бы посоветовал использовать только Apollo, он дает офигенный старт, и в принципе создает хорошую базу для приложения или клиент-сервера на Node.js или BFF.


URQL


https://formidable.com/open-source/urql/docs/


Теперь про URQL. В отличие от двух предыдущих — это библиотека, и нам она очень понравилась. Она позволяет гибко все настроить и оказывает не такое сильное влияние на архитектуру в самом приложении. А поскольку у нас React и Redux, достаточно старый стек и приложению больше пяти лет, нам требуется гибкое решение, чтобы пробовать разные гипотезы. URQL хорошо подходит для использования GraphQL на маленьких и больших проектах. Он максимально гибкий и при этом имеет такие же плюшки, как Apollo. Да, их не настолько много, но комьюнити активно развивается.


Ниже приведена таблица с указанием плюсов и минусов URQL по отношению к другим фреймворкам.


https://formidable.com/open-source/urql/docs/comparison/#comparison-by-features


All features are marked to indicate the following:


  • ✅ Supported 1st-class and documented.
  • 🔶 Supported and documented, but requires custom user-code to implement.
  • 🟡 Supported, but as an unofficial 3rd-party library. (Provided it's commonly used)
  • 🛑 Not officially supported or documented.

Core Features


urql Apollo Relay
Extensible on a network level ✅ Exchanges ✅ Links ✅ Network Layers
Extensible on a cache / control flow level ✅ Exchanges 🛑 🛑
Base Bundle Size 5.9kB (7.1kB with bindings) 32.9kB 27.7kB (34.1kB with bindings)
Devtools
Subscriptions
Client-side Rehydration
Polled Queries 🔶
Lazy Queries
Stale while Revalidate / Cache and Network
Focus Refetching @urql/exchange-refocus 🛑 🛑
Stale Time Configuration @urql/exchange-request-policy 🛑
Persisted Queries @urql/exchange-persisted-fetch apollo-link-persisted-queries
Batched Queries 🛑 apollo-link-batch-http 🟡 react-relay-network-layer
Live Queries 🛑 🛑
Defer & Stream Directives 🛑 🟡 (unreleased)
Switching to GET method 🟡 react-relay-network-layer
File Uploads @urql/exchange-multipart-fetch 🟡 apollo-upload-client 🛑
Retrying Failed Queries @urql/exchange-retry apollo-link-retry DefaultNetworkLayer
Easy Authentication Flows @urql/exchange-auth 🛑 (no docs for refresh-based authentication) 🟡 react-relay-network-layer
Automatic Refetch after Mutation ✅ (with document cache) 🛑

Итого: в результате ресерча мы поняли, что нам не подходит ни Relay, ни Apollo, ни URQL. Бэкенд сообщил, что ему необходимо сделать достаточно много изменений в бэке и архитектуре, и, если бы они начали это делать одновременно с нашим фронтендом, то мы бы просто заморозили разработку.


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


Требования


  1. GraphQL клиент
  2. CI/CD и pre-commit
  3. Playground
  4. Unit-тесты
  5. Стабы
  6. GraphQL схема

Делаем GraphQL клиент


Мы используем на проекте axios и к нему написано много интерцептеров, которые также должны работать и для graphql запросов.


Для работы graphql на проекте понадобятся пакеты graphql, graphql-tag


Выполняем
yarn add graphql
yarn add -D graphql-tag


// Функция print работает с ast, преобразовывая его в строку запроса
import { print } from 'graphql/language/printer';
// Наша обертка над axios
import { HttpClient, HttpClientError } from 'Modules/http/httpClient';
import { graphQLEndPoint } from 'Utils/domain';

const httpGraphqlClient = new HttpClient({
  baseURL: graphQLEndPoint,
});

const getHttpConfig = ({ query, variables, ...rest }, operationName) => ({
  method: 'POST',
  data: {
    // в query попадает ast, которое подготовлено лоадером вебпак graphql-tag/loader
    // утилитой print из graphql ast преобразуем query в строку
    query: print(query),
    variables,
    // данные для мониторинга
    operationName,
  },
  params: {
    // дополнительно к запросу запишем параметр operationName, чтобы в логах видеть конкретную операцию graphqlEndpoint?operationName=xxx
    operationName,
  },
  ...rest,
});

const extractOperationName = (query) => {
  const { definitions } = query;
  const [def] = definitions;
  const {
    name: { value },
  } = def;
  return value;
};

const checkOperationType = (query, operationType) => {
  const { definitions } = query;
  const [def] = definitions;
  const { operation } = def;
  return operation === operationType;
};

const request = async (config) => {
  const { query, operationType } = config;
  const operationName = extractOperationName(query);

  if (!checkOperationType(query, operationType)) {
    throw new HttpClientError('fetcherGraphQL request invalid operation');
  }

  const { data } = await httpGraphqlClient.request(getHttpConfig(config, operationName));

  return data;
};

// Example 
async function fn() {
  const { data } = await request({ query: GraphQlQueryFile })
  return data;
}

Наша библиотека для асинхронных экшенов


Поговорим о нашей асинхронной библиотеке, чтобы вы понимали, как у нас работает код, и в чем фишка внедрения GraphQL на уровне сети. Для асинхронных операций у нас есть middleware. Называется она signal-middleware и находится в этом репозитории. Signal-middleware достаточно простая: у нас есть некоторый сигнал, мы на этот сигнал подписываемся по ключу и выполняем тот или иной action. Сюда мы можем заинжектить любые нужные нам функции. В данном случае сюда инжектится базово dispatch и, например, store.


Получили по ключику нашу функцию callback, исполнили, сделали запрос, записали ответ, и если что-то не так — обработали. В нашем случае библиотека доработана. Мы дополнительно провайдим наши клиенты для http-запросов. Добавив префикс :gql: в наименование сигнала, мы понимаем, какой http-клиент нужно заинжектить.


import { addReaction } from 'signal-middleware';

// http клиенты
import fetcher from 'Utils/fetcher/fetcher';
import fetcherGraphQL from 'Utils/fetcher/fetcherGraphQL';

// addReaction.js
export default (signalName, cancelType, signalReaction) => {
  let [cancelName, reactionHandler] = [cancelType, signalReaction];
  const processFetcher = signalName.includes(':gql:') ? fetcherGraphQL : fetcher;

  if (typeof cancelName === 'function') {
    cancelName = null;
    reactionHandler = cancelType;
  }

  const reaction = (storeUtils, ...args) =>
    reactionHandler(
      {
        ...storeUtils,
        fetcher: cancelName === null ? processFetcher : processFetcher.uniq(cancelName),
      },
      ...args
    );

  addReaction(signalName, reaction);
};

// signal.js
addReaction(`:gql:signal`, async ({ fetcher }) => {
    const data  = await fetcher.query(GraphQLQueryFile);
    //....
})

Проверки на CI/CD и pre-commit


Проверки на этапах CI/CD и pre-commit нужны в ситуации, если мы как-то ошибемся и наша схема не сойдется с бэкендом. Или что-то может пойти не так: мы передадим не тот аргумент, обратимся к полю, которого не существует или опечатаемся.


Настраиваем конфигурацию graphql на проекте, используя graphql-config


# .graphqlrc

# Путь до схемы
schema: "./.codegen/schema.graphql"
# Путь до операций и фрагментов
documents: "./app/**/**/*.graphql"

Далее подключаем плагин для eslint @graphql-eslint/eslint-plugin


Настраиваем конфигурацию:


// .eslintrc
    {

      "files": "*.graphql",
      "extends": "plugin:@graphql-eslint/operations-recommended",
      "plugins": ["@graphql-eslint"],
      "rules": {
        "prettier/prettier": [2, { "parser": "graphql" }],
        "spaced-comment": "off",
        "@graphql-eslint/executable-definitions": "error",
        "@graphql-eslint/fields-on-correct-type": "error",
        "@graphql-eslint/fragments-on-composite-type": "error",
        "@graphql-eslint/known-argument-names": "error",
        "@graphql-eslint/known-directives": "error",
        "@graphql-eslint/known-type-names": "error",
        "@graphql-eslint/no-anonymous-operations": "error",
        "@graphql-eslint/no-deprecated": "error",
        "@graphql-eslint/no-unused-variables": "error",
        "@graphql-eslint/provided-required-arguments": "error",
        "@graphql-eslint/scalar-leafs": "error",
        "@graphql-eslint/unique-argument-names": "error",
        "@graphql-eslint/unique-input-field-names": "error",
        "@graphql-eslint/unique-variable-names": "error",
        "@graphql-eslint/value-literals-of-correct-type": "error",
        "@graphql-eslint/variables-are-input-types": "error",
        "@graphql-eslint/variables-in-allowed-position": "error",
        "@graphql-eslint/require-id-when-available": "off",
        // Ограничиваем глубину запроса
        "@graphql-eslint/selection-set-depth": ["error", { "maxDepth": 20}],
        // Правила нейминга для операции и фрагментов
        "@graphql-eslint/naming-convention": [
          "error",
          {
            "VariableDefinition": "camelCase",
            "OperationDefinition": {
              "style": "PascalCase",
              "forbiddenPrefixes": ["Query", "Mutation", "Subscription", "Get"],
              "forbiddenSuffixes": ["Query", "Mutation", "Subscription"]
            },
            "FragmentDefinition": {
              "style": "camelCase",
              "forbiddenPrefixes": ["Fragment"],
              "forbiddenSuffixes": ["Fragment"]
            }
          }
        ],
        // Метчинг нейминга для файлов
        "@graphql-eslint/match-document-filename": [
          "error",
          {
            "fileExtension": ".graphql",
            "query": { "style": "camelCase", "suffix": ".query" },
            "mutation": { "style": "camelCase", "suffix": ".mutation" },
            "fragment": { "style": "camelCase", "suffix": ".fragment" }
          }
        ]
      }
    }

Напишем запрос:


query Person($id: Int!) {
  person(id: $id) {
    ... on PersonItem {
      area {
        someField
      }
    }
  }
}

Делаем наш обычный git-add и выполним команду lint-staged, который у нас запускает линтинг. Команда запустилась, но линтинг сообщает, что мы не можем это зафиксить: someField нет на типе Area.


  5:9  error  Cannot query field "someField" on type "Area"  @graphql-eslint/fields-on-correct-type
✖ 1 problem (1 error, 0 warnings)

Как результат, ошибку отловим на pre-commit или на этапе CI/CD.


Playground


В качестве плейграунда мы выбрали песочницу GraphiQL. Существует еще ряд графических редакторов для GraphQL, но этот понравился нам больше всего. Находится плейграунд в этом репозитории, он достаточно простой, кастомизированный, и нам этого достаточно.


Мы решили подключать его как cdn. У нас есть сборка, которую мы перекладываем в папочку build, подготовленные файлы — graphiql.min.jsи graphiql.min.css. И подключаем в html.


Заходим в плейграунд и выполняем простой запрос, который будет выглядеть так: выберем всех кандидатов, которые есть в базе.


graphiql


Запрос выполнен — получили ожидаемый результат (плейграунд с готовой документацией и хорошей интроспекцией). А написанный запрос точно так же переносится в код.


Unit-тесты


Для нашего GraphQL клиента написали небольшую функцию, с помощью которой можно замокать данные. Под капотом мы используем Nock, библиотеку для мок сети, к ней написали маленькую обертку.


Пример запроса:


query PersonExample {
  person {
    name
  }
}

// ...
const result = await request(PersonExample);
// Имя запроса будет подставлено параметром
// http://localhost/graphql?operationName=PersonExample
//...

Напишем функцию, которая будет перехватывать все запросы по параметру operationName.


import nock from 'nock';

import { graphQLEndPoint } from 'Utils/domain';

const [endPointUrl] = graphQLEndPoint.split('/graphql');

/**
 * Мок функция для удобной работы с сетевыми http-запросами graphql
 *
 * @param {string} requestName имя query или mutation для сопоставления запроса
 * @prop {object={}} params
 * @prop {boolean} [params.persist] сохранять все перехватчики
 */
export const createMockFetcherGraphQL = (requestName, { persist = false } = {}) => {
  const scope = persist ? nock(endPointUrl).persist() : nock(endPointUrl);
  //
  const request = scope.post(`/graphql?operationName=${requestName}`);

  return {
    /**
     * Прокси метод над nock.reply
     *
     * @see https://github.com/nock/nock#specifying-replies
     * @see https://github.com/nock/nock/blob/main/types/index.d.ts#L165-L184
     * @param {number} statusCode
     * @param {object|function} [reply={}] для удобства используем как body или callback для доступа к req
     * @returns
     */
    reply(statusCode, reply) {
      return request.reply(statusCode, reply);
    },
  };
};

/**
 * Сброс моков
 */
export const clearMocksFetcherGraphQL = () => {
  nock.cleanAll();
  nock.restore();
};

Теперь любой запрос на проекте можем перехватить c помощью createMockFetcherGraphQL('operationName') и подменить тело ответа.


Для наглядности рассмотрим пример, в котором хотим проверить, правильно ли прокидываются наши variables.


FetcherGraphQLGetTest#example


Подготавливаем сет данных expectVariables и expectResponse, в которых мы заинтересованы. Вызываем функцию, она должна сработать, когда у нас будет FetcherGraphQLGetTest.


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


Проверка прошла успешно. Именно таким образом мы можем мокать данные в unit-тестах.


Стабы


В нашем стандартном клиенте есть механизм стабов, поэтому хотелось бы видеть его и в GraphQL. Тут достаточно простая реализация: у нас есть некоторый запрос, мы смотрим на фиче-флаг — если он включен, отдаем стабы по operationName.


async function request(config) {
    //..
  try {
    if (featureEnabled(FEATURE_STUB)) {
      const result = await stubs(`graphql/${operationName}`, 'POST');
      try {
        if (result) {
            // result это объект new Response(стаб);
          const { data } = await result();
          return data;
        }
      } catch (err) {
        return Promise.reject(err.response.data);
      }
    }
  } catch (ignore) {}
  //...
}

Подготавливаем json файлы, которые будут подменять тело ответа при вызове stubs(graphql/operationName);


// Делаем проверку на окружение, чтобы стабы не попали в продакшн сборку
if (process.env.NODE_ENV === 'development') {
  // eslint-disable-next-line import/no-dynamic-require
  const requireHelper = (file) => require(`App/stubs/json/${file}.json`);
  hardcodedItems = [
    {
      regexp: 'example',
      action: createResponse(bindReceiver(requireHelper('get-example')), 200),
      method: 'GET',
      delay: 0,
    },
    {
      regexp: 'graphql/PersonsList',
      method: 'POST',
      action: createResponse(bindReceiver(requireHelper('persons.query')), 200),
      delay: 0,
    },
  ];
}

У нас есть подготовленный стаб для операции PersonsList.


Есть страничка “persons”. На скриншоте видно, что данные получаем из запроса graphql?operationName=PersonsList



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


stub


Такой механизм стабов помогает разрабатывать в отрыве от бэкенда, готовить компоненты к данным или пробовать на фронтенде сложную логику.


Более подробно об этой фиче рассказывает Никита Мостовой в докладе "Стабы для фронтенда".


Получение GraphQL схемы


Для этого написали простой скрипт, который делает запрос к graphql серверу и преобразовывает ответ в готовый SDL graphql.


const { getIntrospectionQuery, buildClientSchema, printSchema } = require('graphql/utilities');

async function fetchSchema() {
  try {
    // eslint-disable-next-line no-console
    console.log('fetchSchema', 'start', GRAPHQL_END_POINT_SCHEMA);
    const {
      data: { data: introspection },
    } = await axios({
      url: GRAPHQL_END_POINT_SCHEMA,
      method: 'POST',
      headers: {
        Accept: 'application/json',
        'Content-Type': 'application/json',
      },
      data: JSON.stringify({ query: getIntrospectionQuery(), operationName: 'IntrospectionQuery' }),
    });
    // eslint-disable-next-line no-console
    console.log('fetchSchema', 'ok');
    return buildClientSchema(introspection);
  } catch (e) {
    console.error(`fetchSchema ${e.message}`);
  }
}
//...

Полученную схему складываем в папку codegen.



По такому принципу мы и работаем с бэкендом. А если говорить о подходе сode-first, то мы просто переключаемся на стенды, где раскатана бэк-ветка с новой схемой, и оттуда забираем схему.


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


Боевая задача


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


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



Попробуем перенести все это на GraphQL и сделаем запрос в GraphQL Playground.


Пробуем в Playground


Пишем наш первый запрос, назовем его PersonsList. Начинаться он будет с ключевого слова query. Нам подсказывают, что у нас есть persons — то что нужно, здесь у нас будут кандидаты. Появился резолвер, у него есть три аргумента, но нам пока интересен только аргумент first. Он определяет, сколько элементов нам нужно выдать для рендера. Для наглядности возьмем три кандидата.



Посмотрим, что у нас внутри person: фильтры, pageinfo и items. Из айтемов нам нужны: firstName, lastName, age и id. Запишем в запрос:



Справа на скриншоте — тело ответа.


Окей, persons получили. Теперь попробуем сделать пагинацию. Проваливаемся в документацию и выбираем pageInfo:



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



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


Еще здесь есть аргумент after, в который мы вставим то, что скопировали из endCursor:



Запрос выполнился, появилась следующая пачка кандидатов. Пагинация работает как надо.


Следующая задача — сделать фильтры из сайдбара, который я демонстрировал выше. В рамках нашей тестовой задачи будем фильтровать кандидатов по возрасту. Для начала нам нужно понять, какие состояния у фильтров. Пишем filters и смотрим, что GraphQL предлагает нам по PersonsFilters:



Очень удобно, что можно провалиться прямо на ходу — интроспекция отличная. Можно посмотреть информацию о каждом поле. У нас есть ageFrom и поле used, которое говорит, какой возраст используется в данный момент.


Напишем, что наш query принимает аргументы и попробуем их передать. Начнем с $cursor, потому что мы уже его проработали. Обозначим, что это поле string. В after тоже будет приходить $cursor. Теперь попробуем его прокинуть.



Запрос выполнился, пришли те же самые кандидаты. А, значит, все работает. Теперь попробуем с фильтром. Впишем его в query, назовем $filterInput: PersonFilterInput. Передадим его в persons и выполним запрос.



Запрос прошел, но used пустой. Выходит, нужно прокинуть параметры для фильтра. Пишем ageFrom и фильтруем кандидатов от 25 лет. Запускаем.



Огонь! Отфильтровали новых кандидатов — все, кому меньше 25 лет, полностью исключены из выборки. Также в поле used появился флажок с активным фильтром. Итого: у нас получилось организовать фильтрацию и пагинацию — всё, что требовалось для выполнения задачи.


Пробуем в коде


Открываем наш асинхронный код, отвечающий за загрузку персон. Здесь у нас есть реакция, ключевой сигнал и тот же самый запрос, который делаем при страничном get. Мы что-то спрашиваем, обновляем и выполняем запрос:



Как это будет выглядеть в GraphQL? Да практически так же. У нас точно такой же сигнал и обработка. Единственное отличие — префикс, который позволяет прокидывать по другому префиксу правильный клиент для запроса. В нашем случае он называется fetcher:



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


Передаем в запрос PesonaListQuery и variables, которые мы прокидывали еще в playground.



Вот и вся наша реализация. Мы приложили минимум усилий, чтобы сделать GraphQL-запрос. У нас просто появился флажок, который спрашивает, что использовать для загрузки списка персон: GraphQL или старый страничный url. А сам запрос состоит из тех же самых полей.


Вместо заключения


У GraphQL есть свои преимущества и недостатки. Преимущества: бэкенд и фронтенд работают по строго типизированной схеме, документированный API из коробки и большое комьюнити. Недостатком является отсутствие в GraphQL классического подхода обработки ошибок (как с HTTP-кодами: мониторинг, подсчет SLA, откаты релизов при “пятисотках").


Итак, у нас получилось бесшовно внедрить GraphQL и получить плюшки фреймворков, не переломав всё на своем пути, а также предусмотреть последующую интеграцию во фреймворк или библиотеку.

Tags:
Hubs:
Total votes 7: ↑7 and ↓0+7
Comments5

Articles

Information

Website
hh.ru
Registered
Founded
Employees
501–1,000 employees
Location
Россия