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

Когда у вас есть сложное веб-приложение со множеством взаимосвязанных частей, вполне может случиться так, что избыточные вызовы API будут выполняться без уважительной причины, замедляя работу и создавая неудобства для пользователя - как это можно решить?

Эта проблема

В нашем конкретном случае наше веб-приложение было в стиле приборной панели, с множеством вкладок и выбираемыми параметрами, и плохим поведением было следующее: когда пользователь выбирал вкладку, несколько вызовов API выходили, чтобы получить все данные, необходимые для страницы, но если пользователь перешел на другую вкладку, а позже вернулся к исходной, те же самые вызовы API исчезли бы снова. Большинство данных, обрабатываемых приложением, были в основном постоянными (т. Е. Не в реальном времени) и не предполагалось, что они изменятся во время сеанса; было только (очень мало) вызовов API, которые действительно нужно было отправить повторно, в то время как в большинстве случаев повторное использование предыдущего ответа было бы вполне приемлемым.

В этой статье мы сначала рассмотрим некоторые возможные способы решения этой проблемы (от которых отказались); затем мы перейдем к технике функционального программирования, которая обеспечила наилучшее решение, и закончим рассмотрением «ловушки» (с ее решением, конечно!), которая могла бы иметь серьезные последствия, если бы ее не поймали. Применение техники, показанной в этой статье, может помочь вам ускорить любое веб-приложение с очень небольшими изменениями кода; хорошее вложение!

Некоторые (отброшенные) решения

Давайте начнем с рассмотрения некоторых возможных решений проблемы повторяющихся звонков - и посмотрим, почему мы поступили иначе.

Первый (довольно очевидный!) Способ избежать повторных вызовов - это изменить сервер, чтобы включить кеширование. Это наиболее стандартный способ решения проблемы, но в нашей ситуации мы не были владельцами серверной части, и поэтому было невозможно изменить заголовки кеширования. Таким образом, с учетом этого ограничения нам пришлось выбрать «только интерфейсное» решение.

Другой способ избежать повторных вызовов - это проверить, прежде чем делать что-либо еще, если требуемые данные уже доступны из-за более раннего вызова, и если да, пропустите вызов - если хотите, ручной кеш. Это сработает, но потребует добавления некоторой структуры данных для хранения данных о вызовах (или, возможно, использования CacheStorage), проверки того, был ли уже сделан необходимый вызов перед его повторением, правильного обновления структуры для будущих вызывающих абонентов после успешного выполнения. вызов ... слишком много кода, требующего изменений, слишком много работы и слишком подвержено ошибкам!

В качестве альтернативы кешу, описанному выше, и учитывая, что мы уже работали с глобальным хранилищем (мы использовали Svelte, но то же самое применимо к React, Vue и многим другим) вместо проверки наличия вызов API был выполнен, посмотрите, доступны ли уже необходимые данные, но на самом деле это также потребовало бы изменений кода, и вероятность ошибок все еще присутствовала бы.

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

Чтобы увидеть, как мы наконец решили эту проблему с помощью вызовов API, мы сейчас отвлечемся и поговорим о числах Фибоначчи и о том, как их вычислять - и да, я обещаю, что в конечном итоге это обретет смысл!

В стороне: числа Фибоначчи (?!)

Числа Фибоначчи (0, 1, 1, 2, 3, 5, 8, 13 и т. Д.) Хорошо известны, по крайней мере, потому, что они часто используются для обучения рекурсии, для решения задач программирования или для оценки точек пользовательских историй в Agile. методологии. Их стандартное рекурсивное определение таково: первые два члена Fib (0) и Fib (1) равны 0 и 1 соответственно, а последующие члены в серии представляют собой сумму двух предыдущих, или, другими словами, Fib (n ) = Fib (n -2) + Fib (n -1) для n ›1.

Как мы видим ниже, реализовать это в JavaScript довольно просто.

let fib = (n) => {
    if (n === 0) {
        return 0;
    } else if (n === 1) {
        return 1;
    } else {
        return fib(n - 2) + fib(n - 1);
    }
};

Этот код ясный, простой и правильный… но медленный! Как придешь? Я провел несколько экспериментов с более высокими значениями n, и следующая диаграмма (взята из моей книги Освоение функционального программирования на JavaScript для Packt Publishing) показывает, что требуемое время растет экспоненциально , а все, что для этого потребуется, это несколько сумм… что происходит?

Чтобы понять проблему, давайте посмотрим, какие вычисления необходимы для простого примера fib(6). На следующем изображении (также из вышеупомянутой книги) показаны все необходимые вызовы.

Ага! Теперь проблема очевидна: много повторных звонков. Например, для вычисления fib(6) требуется вычисление какfib(4), так и fib(5), но вычисление последнего снова требует вычисления fib(4). Избыточность ухудшается с дальнейшими вызовами: просто обратите внимание, как часто мы, например, повторно вычисляем fib(2) или fib(1). Мы тратим время на переделку работы, которую делали раньше; как мы можем это решить?

Запоминание: общее решение

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

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

Мы хотим написать memoize() функцию более высокого порядка, которая будет принимать любую общую функцию и создавать новую версию кэширования, которую можно использовать вместо оригинала, с теми же результатами, но с повышенной производительностью. Для этого есть несколько доступных решений (например, хорошо названный fast-memoize - и посмотрите эту статью, если вам интересно, как была написана эта функция; ее стоит прочитать), но позже мы увидим, почему нам пришлось написать свое собственное. В любом случае придумать эту функцию не так уж и сложно; вот что мы написали.

const memoize = (fn) => {
    let cache = {};
    return (...args) => {
        let strX = JSON.stringify(args);
        return strX in cache 
          ? cache[strX] 
          : (cache[strX] = fn(...args));
    };
};

Как это работает? Мы используем объект cache для сохранения вычисленных значений; карта тоже подошла бы. Мы используем JSON.stringify(), чтобы получить строку из аргументов функции, и, прежде чем что-либо делать, мы изучаем кеш: если мы находим там значение, мы просто возвращаем его, а если его там нет, мы вызываем исходную функцию и сохраняем результат в кеш. Мы сразу видим это в действии.

fib = memoize(fib);
console.log(fib(100)); // ultra fast!

Мы заменили исходную функцию fib на мемоизированную, и теперь такой вызов, как fib(100), выполняется практически мгновенно! Как мемоизирование будет работать для нас? Посмотрим, как мы это применили ... и какую проблему мы упустили!

Первое решение - с подвохом!

Итак, теперь мы можем начать думать о решении нашей проблемы с производительностью. В приложении все вызовы API для получения чего-либо от сервера выполняются через обещания; глобальная функция makeCall() округляет необходимые параметры для вызова и использует Axios для выполнения вызова и возврата обещания. Итак, с точки зрения реального кода все вызовы API выглядят как следующий фрагмент.

const getSomething= (parameters) => {
    // ...set up options object (headers, etc.)
    return makeCall(urlForSomething, options);
}

В чем заключалась наша идея, чтобы избежать дублирования вызовов API? Мы запомнили функцию, которая выполняет фактический вызов, поэтому при повторном вызове она вернет то же обещание, что и раньше, вместо (снова) вызова серверной части. Функция, которая будет использовать Axios для фактического вызова, была переименована в originalMakeCall, а имя makeCall было присвоено мемоизированной функции.

const originalMakeCall= (url, options) => { 
   … 
   return axios.get(…); 
}
const makeCall= memoize(originalMakeCall);

Это отлично работает, и нам не пришлось трогать остальную часть приложения! Каждый раз, когда вызывается makeCall(), благодаря мемоизации, если вы снова и снова вызываете один и тот же API с одними и теми же аргументами, на сервер отправляется только один вызов (первый!); следующие возвращают кешированное значение, то есть обещание.

Конечно, мы знали, что для некоторых конечных точек API мы не должны использовать мемоизацию, но это было легко исправить: мы просто изменили необходимые вызовы, чтобы вместо вызова makeCall() они вызывали originalMakeCall() - просто! И, конечно же, мы не использовали мемоизированный вызов ни для чего, кроме вызовов GET - все вызовы POST, PUT, DELETE и другие вызовы остались бы такими же, как раньше, без мемоизированных.

Однако мы кое-что упустили ...

Полное решение

Что мы забыли? Решение, описанное выше, работает отлично, но есть одна загвоздка: что произойдет, если вызов API завершится неудачно, и мы попытаемся выполнить вызов снова? Ответ: вообще ничего! Запомнившаяся функция makeCall() будет продолжать возвращать (отклоненное) исходное обещание, поэтому не будет возможности повторить вызов. Что мы можем сделать?

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

const promiseMemoize = (fn) => {
  let cache = {}
  return (...args) => {
    let strX = JSON.stringify(args);
    return strX in cache ? cache[strX]
      : (cache[strX] = fn(...args).catch((x) => {
          delete cache[strX];
          return x;
        }))
  } 
}

Обратите внимание на измененные строки: мы добавили .catch() к обещанию, поэтому в случае неудачи оно удалит соответствующую запись из кеша. Мы также переименовали функцию в promiseMemoize(), чтобы было понятно, что это совсем другое дело, а не ваш стандартный повседневный мемоизирующий код. С помощью этой новой функции, если вызов завершится неудачно и вы попытаетесь его повторить, новая попытка не найдет обещание в кеше, и API будет опрошен снова: проблема решена!

После включения этого изменения (и внимательного отношения к тому, где и когда мы использовали мемоизированные вызовы) мы заметили явное повышение скорости, когда пользователь переходил с одной вкладки на другую. Мы также могли сделать кое-что еще: мы могли делать предварительные вызовы API-интерфейсов, запрашивая данные, которые потребуются пользователю для другой вкладки, чтобы эти ответы были в кеше, и будущие вызовы имели бы мгновенный ответ - еще один хороший результат для Пользователь!

Резюме

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

Показанная здесь модификация может быть применена в большинстве веб-приложений и обеспечивает простой способ повышения производительности - хороший выигрыш!