Управление кэшем приложений с помощью react-query. И генерация кода из OpenAPI.


Введение

В этой статье я хотел бы рассмотреть следующие аспекты:

  • Что такое кэш приложений.
  • react-query как способ управления кэшем приложений.
  • как на проекте мы используем генерацию кода из Open API в npm package с пользовательскими react-query хуками и далее распределяем код между двумя клиентами Web i Mobile.

До недавнего времени веб-приложение в проекте, над которым я работаю, использовало Redux в качестве основного менеджера состояний, но теперь мы полностью перешли на react-query. Давайте рассмотрим, что я лично считаю недостатками Redux и почему react-query?

Почему Redux по умолчанию взяли на вооружение многие проекты? Я отвечу так: благодаря Redux у нас есть архитектура. То есть у нас есть Store, в котором мы храним состояние всего приложения, у нас есть Actions, которые мы диспетчируем, когда нам нужно изменить состояние хранилища. А все асинхронные операции мы выполняем через костыльное middleware, используя в основном Thunk, Saga и т.д.

Теперь мы понимаем, что круто то, что Redux помогает сделать архитектуру — что с ней не так. Повторюсь это мой личный опыт с ним можно не соглашаться.

Недостатки Redux:

1. Многословность.

Не очень здорово, когда нужно разработать какой-то модуль в существующем приложении, постоянно писать кучу кода. Переключение между различными модулями с. Action_type, создателями действий, Thunks и т.д.
Написание меньшего количества boilerplates не только увеличивает шанс сделать меньше ошибок, но и повышает читабельность кода — а это очень круто, так как читать и понимать приходится чаще, чем писать.

«Весь код глючит. Поэтому логично, что чем больше кода вам приходится писать, тем более глючными будут ваши приложения». — РИЧ ХАРРИС

2. Все впихивается внутрь.

Когда вы работаете над большим проектом с несколькими разработчиками. Опять же, это мой опыт. Элемент спешки и дедлайнов побуждает разработчиков начать хранить все в глобальном хранилище, даже если это не нужно. Условно синхронные «ручки», которые переключают частное поведение ui в отдельных модулях. Запросы к серверу, которые также используются в одном модуле. Все это перемещается в глобальное хранилище и может затуманить код, увеличив его связность.

3. Redux создает неочевидные скрытые зависимости.

Пример получения данных о пользователях в компоненте Home.js:

React.useEffect(() => {
      dispatch(getUserData()); 
  }, []);
Войти в полноэкранный режим Выходим из полноэкранного режима

А затем, получив данные, мы используем их во многих других компонентах (Transactions, Items, Menu …). В данном случае это создает скрытую зависимость, потому что при рефакторинге кода, если мы удалим этот dispatch(getUserData()) только в одном месте, это сломает userData во всех остальных местах приложения.
И что более важно, механизм сохранения данных, которые мы получили от сервера, не удобен. Нам постоянно нужно следить за достоверностью этих данных и не забывать обновлять их, если мы знаем, что они изменились на сервере.

И здесь мы приходим к двум концепциям данных в приложении. Мы можем разделить данные на State и Cache.

Состояние — это данные, которые необходимо сохранять и изменять в течение жизни приложения.
Кэш — это данные, полученные извне, допустим, http-запрос.

И в redux мы смешиваем и храним их в состоянии только потому, что они используются в других местах приложения.
Так что 90% данных, которые мы используем в приложении — это кэш.

На этом этапе я хочу перейти к библиотеке управления кэшем react-query. Сделаем краткий обзор и посмотрим, как можно улучшить работу разработчиков с кэшем с помощью этой библиотеки.

Обзор React-Query

Как написано на официальном сайте: Получайте, кэшируйте и обновляйте данные в ваших приложениях React и React Native, не затрагивая «глобальное состояние». По своей сути, это пользовательские хуки, которые берут под контроль кэш, предоставляя нам множество классных возможностей, таких как кэширование, оптимистичное обновление и т.д. … И что мне нравится, так это то, что это удаляет множество промежуточных абстракций, уменьшая количество написанного кода. Давайте рассмотрим пример.

Здесь все просто, мы оборачиваем корень нашего приложения в QueryClientProvider:

import { QueryClient, QueryClientProvider } from 'react-query'
  const queryClient = new QueryClient()
  export default function App() {
   return (
     <QueryClientProvider client={queryClient}>
       <ExampleFirst />
     </QueryClientProvider>
   )
 }
Вход в полноэкранный режим Выход из полноэкранного режима

Теперь мы делаем запрос в компоненте с помощью axios get, который передаем в useQuery:

import {  useQuery } from 'react-query'
import axios from 'axios'

 function ExampleFirst() {
   const { isLoading, error, data } = useQuery('repoData', async () =>
    const res = await axios.get('https://api.github.com/repos/react-query')
    return res.data
   )

   if (isLoading) return 'Loading...'
   if (error) return 'An error has occurred: ' + error.message

   return (
     <div>
       <h1>{data.name}</h1>
       <p>{data.description}</p>
       <strong>👀 {data.subscribers_count}</strong>{' '}
       <strong> {data.stargazers_count}</strong>{' '}
       <strong>🍴 {data.forks_count}</strong>
     </div>
   )
 }
Войти в полноэкранный режим Выйти из полноэкранного режима

Мы обернули наш запрос в хук useQuery и получили API для работы с данными, а контроль над загрузкой, обработкой и перехватом ошибок оставляем хуку. useQuery принимает в качестве первого параметра уникальный ключ запроса. react-query управляет кэшированием запросов на основе ключей запросов. Ключи запросов могут быть простыми, как строка, или сложными, как массив из нескольких строк и вложенных объектов. Второй параметр — это наш запрос get, который возвращает обещание. А третий, необязательный, — это объект с дополнительными конфигурациями.

Как видите, это очень похоже на код, когда мы учились работать с серверными запросами в React, но на реальном проекте все получилось иначе 🙂 И мы начали применять большой слой абстракций поверх нашего кода для отлова ошибок, загрузки состояния и всего остального. В react-query эти абстракции вынесены под капот и оставляют нам чисто удобные API для работы.

По сути, это основной пример использования хуков react-query для get-запросов. На самом деле, API того, что возвращает хук, гораздо больше, но в большинстве случаев мы используем эти несколько { isLoading, error, data }.

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

Для запросов с модификацией данных существует хук useMutation. Пример:

export default function App() {
  const [todo, setTodo] = useState("");

  const mutation = useMutation(
    async () =>
      axios.post("https://jsonplaceholder.typicode.com/todos", {
          userId: 1,
          title: todo,
        }),
    {
      onSuccess(data) {
        console.log("Succesful", data);
      },
      onError(error) {
        console.log("Failed", error);
      },
      onSettled() {
        console.log("Mutation completed.");
      }
    }
  );

  async function addTodo(e) {
    e.preventDefault();
    mutation.mutateAsync();
  }

  return (
    <div>
      <h1>useMutations() Hook</h1>
      <h2>Create, update or delete data</h2>
      <h3>Add a new todo</h3>
      <form onSubmit={addTodo}>
        <input
          type="text"
          value={todo}
          onChange={(e) => setTodo(e.target.value)}
        />
        <button>Add todo</button>
      </form>
      {mutation.isLoading && <p>Making request...</p>}
      {mutation.isSuccess && <p>Todo added!</p>}
      {mutation.isError && <p>There was an error!</p>}
    </div>
  );
}
Вход в полноэкранный режим Выход из полноэкранного режима

Опять же, мы передаем axios.post(...) в хук, и можем напрямую работать с API {isLoading, isSuccess, isError} и другими значениями, которые предоставляет useMutation. А саму мутацию мы вызываем с помощью mutation.mutateAsync (). В этом примере мы видим, что в качестве второго параметра мы передаем объект с функциями:

  • это сработает при успешном завершении post-запроса и вернет полученные данные:
onSuccess(data) {
        console.log("Succesful", data);
  }
Вход в полноэкранный режим Выйти из полноэкранного режима
  • сработает при возникновении ошибки, вернет ошибку:
onError(error) {
        console.log("Failed", error);
      },
Enter fullscreen mode Выйти из полноэкранного режима
  • будет работать в любом случае, после срабатывания запроса:
onSettled() {
        console.log("Mutation completed.");
      }
Войти в полноэкранный режим Выйти из полноэкранного режима

В этот объект мы можем поместить дополнительные ключи для управления процессом получения данных.

useMutation будет отслеживать состояние мутации так же, как это делает useQuery для запросов. Это даст вам поля isLoading, isFalse и isSuccess, чтобы вы могли легко отображать происходящее для ваших пользователей. Разница между useMutation и useQuery заключается в том, что useQuery является декларативным, а useMutation — императивным. Под этим я подразумеваю, что запросы useQuery в основном выполняются автоматически. Вы определяете зависимости, но useQuery позаботится о немедленном выполнении запроса, а также выполнит интеллектуальные фоновые обновления, если это необходимо. Это отлично подходит для запросов, поскольку мы хотим, чтобы то, что мы видим на экране, было синхронизировано с фактическими данными из бэкенда. Для мутаций это не подходит. Представьте, что каждый раз, когда вы фокусируете окно браузера, создается новая задача. Поэтому вместо того, чтобы сразу запускать мутацию, React Query предоставляет вам функцию, которую вы можете вызывать всякий раз, когда хотите мутировать.

Также рекомендуется создать пользовательский хук, в который мы поместим наш хук react-query:

const transformTodoNames = (data: Todos) =>
  data.map((todo) => todo.name.toUpperCase())
export const useTodosQuery = () =>
  useQuery(['todos'], fetchTodos, {
    select: transformTodoNames,
  })
Войти в полноэкранный режим Выйти из полноэкранного режима

Это удобно, потому что:

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

И на этом знакомство с react-query закончено. Я хотел бы показать вам, как мы можем пойти еще дальше с react-query и генерировать наши хуки из схемы OpenAPI.

Генерация кода из OpenAPI

Как мы видим, все запросы — это отдельные хуки, не привязанные к абстракциям магазина. Поэтому, если у нас есть валидная схема OpenApi с бэкендом, мы можем генерировать код наших хуков прямо из схемы и поместить его в отдельный пакет npm. Что это нам даст:

  • сократим количество ручной работы и написания шаблонов;
  • упростить архитектуру приложения;
  • меньше кода === меньше ошибок
  • мы будем повторно использовать код на веб-клиенте и на мобильном react native клиенте.

Из Википедии: Спецификация OpenAPI, первоначально известная как Swagger, — это спецификация машиночитаемых файлов с интерфейсами для описания, создания, потребления и визуализации веб-сервисов REST.

Я не хочу заострять внимание на схеме OpenApi, об этом лучше почитать на определенных ресурсах. Но мы будем считать, что у нас есть актуальная json-схема OpenAPI наших REST-запросов. Далее приведен пример нашей пользовательской библиотеки, которую мы используем в нашем проекте. Я быстро пройдусь по основным моментам, чтобы передать общую идею. Создадим новый проект со следующей структурой:

src/operations/index.ts:

export * from './operations'; 
Вход в полноэкранный режим Выход из полноэкранного режима

В .openapi-web-sdk-generatorrc.yaml нам нужно настроить параметры:

generators:
  - path: "@straw-hat/openapi-web-sdk-generator/dist/generators/react-query-fetcher"
    config:
      outputDir: "src/operations"
      packageName: "@super/test"
Войти в полноэкранный режим Выйти из полноэкранного режима

package.json:

{
  "name": "@super/test",
  "version": "1.0",
  "description": "test",
  "license": "UNLICENSED",
  "scripts": {
    "prepack": "yarn build",
    "codegen:sdk": "sht-openapi-web-sdk-generator local --config='./specification/openapi.json'"
  },
  "type": "commonjs",
  "main": "dist/index.js",
  "typings": "dist/index.d.ts",
  "files": [
    "dist",
  ],
  "dependencies": {
    "@straw-hat/react-query-fetcher": "^1.3.1"
  },
  "peerDependencies": {
    "@straw-hat/fetcher": "^4.8.1",
    "react": "^17.0.2",
    "react-dom": "^17.0.2",
    "react-query": "^3.34.8"
  },
  "devDependencies": {
    "@straw-hat/fetcher": "^4.8.2",
    "@straw-hat/openapi-web-sdk-generator": "^2.4.2",
    "@straw-hat/tsconfig": "^3.0.2",
    "@types/jest": "^27.4.0",
    "react": "^17.0.2",
    "react-dom": "^17.0.2",
    "react-query": "^3.34.12"
  }
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Мы используем один пакет для генерации кода, все остальные нужны для того, чтобы наши сгенерированные хуки получили зависимости после генерации:

@straw-hat/openapi-web-sdk-generator
Войти в полноэкранный режим Выйти из полноэкранного режима

Если мы посмотрим, на чем основан этот пакет, то увидим, что мы используем oclif — это основанный на node.js инструмент для создания CLI.

Mustache.js — это шаблонизатор для создания js-шаблонов. cosmiconfig — это инструмент для удобной работы с конфигурацией.

В package.json мы настраиваем конфигурацию:

"oclif": {
    "commands": "./dist/commands",
    "bin": "sht-openapi-web-sdk-generator",
    "plugins": [
      "@oclif/plugin-help"
    ]
  }
Входить в полноэкранный режим Выходить из полноэкранного режима

Заглянем в ./dist/commands, там у нас есть файл local.ts:

import { flags } from '@oclif/command';
import { OpenapiWebSdkGenerator } from '../openapi-web-sdk-generator';
import { readOpenApiFile } from '../helpers';
import { BaseCommand } from '../base-command';

export default class LocalCommand extends BaseCommand {
  static override description = 'Generate the code from a local OpenAPI V3 file.';

  static override flags = {
    config: flags.string({
      required: true,
      description: 'OpenAPI V3 configuration file.',
    }),
  };

  async run() {
    const { flags } = this.parse(LocalCommand);

    const generator = new OpenapiWebSdkGenerator({
      context: process.cwd(),
      document: await readOpenApiFile(flags.config),
      config: this.configuration,
    }).loadGenerators();

    return Promise.all(generator.generate());
  }
}
Войти в полноэкранный режим Выход из полноэкранного режима

Мы наследуем LocalCommand от BaseComand — этот абстрактный класс BaseCommand extends Command является классом, который служит основой для каждой команды oclif. А в функции run() мы устанавливаем конфиг и возвращаем Promise.all(generator.generate()); generator — это экземпляр класса OpenapiWebSdkGenerator с описанием логики генератора. Это будет наша команда генерации кода.

Теперь посмотрим, что представляют собой наши классы, из которых мы генерируем код: src/generators/react-query-fetcher.

Вот как мы генерируем код из шаблона:

import { CodegenBase } from '../../codegen-base';
import { OperationObject, PathItemObject } from '../../types';
import { forEachHttpOperation, getOperationDirectory, getOperationFileRelativePath } from '../../helpers';
import path from 'path';
import { OutputDir } from '../../output-dir';
import { TemplateDir } from '../../template-dir';
import { camelCase, pascalCase } from 'change-case';
import { OpenAPIV3 } from 'openapi-types';

const templateDir = new TemplateDir(
  path.join(__dirname, '..', '..', '..', 'templates', 'generators', 'react-query-fetcher')
);

function isQuery(operationMethod: string) {
  return OpenAPIV3.HttpMethods.GET.toUpperCase() == operationMethod.toUpperCase();
}

export interface ReactQueryFetcherCodegenOptions {
  outputDir: string;
  packageName: string;
}

export default class ReactQueryFetcherCodegen extends CodegenBase<ReactQueryFetcherCodegenOptions> {
  private readonly packageName: string;
  readonly #outputDir: OutputDir;

  constructor(opts: ReactQueryFetcherCodegenOptions) {
    super(opts);
    this.#outputDir = new OutputDir(this.options.outputDir);
    this.packageName = opts.packageName;
  }

  #processOperation = async (args: {
    operationMethod: string;
    operationPath: string;
    pathItem: PathItemObject;
    operation: OperationObject;
  }) => {
    const operationDirPath = getOperationDirectory(args.pathItem, args.operation);
    const operationFilePath = `use-${getOperationFileRelativePath(operationDirPath, args.operation)}`;
    const functionName = camelCase(args.operation.operationId);
    const typePrefix = pascalCase(args.operation.operationId);
    const pascalFunctionName = pascalCase(args.operation.operationId);
    const operationIndexImportPath = path.relative(
      this.#outputDir.resolveDir('index.ts'),
      this.#outputDir.resolve(operationFilePath)
    );

    await this.#outputDir.createDir(operationDirPath);

    const sourceCode = isQuery(args.operationMethod)
      ? await templateDir.render('query-operation.ts.mustache', {
          functionName,
          typePrefix,
          pascalFunctionName,
          importPath: this.packageName,
        })
      : await templateDir.render('mutation-operation.ts.mustache', {
          functionName,
          typePrefix,
          pascalFunctionName,
          importPath: this.packageName,
        });

    await this.#outputDir.writeFile(`${operationFilePath}.ts`, sourceCode);
    await this.#outputDir.formatFile(`${operationFilePath}.ts`);

    await this.#outputDir.appendFile(
      'index.ts',
      await templateDir.render('index-export-statement.ts.mustache', {
        operationImportPath: operationIndexImportPath,
      })
    );
  };

  async generate() {
    await this.#outputDir.resetDir();
    await forEachHttpOperation(this.document, this.#processOperation);
    await this.#outputDir.formatFile('index.ts');
  }
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Мы видим, что в соответствии с различными условиями, которые мы берем из схемы, мы генерируем шаблоны useQuery или useMutation из шаблона query-operation.ts.mustache или mutation-operation.ts.mustache, соответственно:

import type { Fetcher } from '@straw-hat/fetcher';
import type { UseFetcherQueryArgs } from '@straw-hat/react-query-fetcher';
import type { {{{typePrefix}}}Response, {{{typePrefix}}}Params } from '{{{importPath}}}';
import { createQueryKey, useFetcherQuery } from '@straw-hat/react-query-fetcher';
import { {{{functionName}}} } from '{{{importPath}}}';

type Use{{{pascalFunctionName}}}Params = Omit<{{{typePrefix}}}Params, 'options'>;

type Use{{{pascalFunctionName}}}Args<TData, TError> = Omit<
  UseFetcherQueryArgs<{{{typePrefix}}}Response, TError, TData, Use{{{pascalFunctionName}}}Params>,
  'queryKey' | 'endpoint'
>;

const QUERY_KEY = ['{{{functionName}}}'];

export function use{{{pascalFunctionName}}}QueryKey(params?: Use{{{pascalFunctionName}}}Params) {
  return createQueryKey(QUERY_KEY, params);
}

export function use{{{pascalFunctionName}}}<TData = {{{typePrefix}}}Response, TError = unknown>(
  client: Fetcher,
  args: Use{{{pascalFunctionName}}}Args<TData, TError>,
) {
  return useFetcherQuery<{{{typePrefix}}}Response, TError, TData, Use{{{pascalFunctionName}}}Params>(client, {
    ...args,
    queryKey: QUERY_KEY,
    endpoint: {{{functionName}}},
  });
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Отлично! Очень поверхностно разобрались, как работает генерация нашего кода.

Завершение работы и запуск генератора

Вернемся к тестовому проекту. Берем схему OpenAPI и помещаем ее в папку спецификации:

Осталось выполнить команду в консоли:

yarn codegen:sdk
Войти в полноэкранный режим Выйти из полноэкранного режима

В консоли мы видим что-то вроде:

Все наши пользовательские хуки сгенерированы, и мы можем видеть их в папке operations:

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

Оцените статью
Procodings.ru
Добавить комментарий