June 9, 2021

Проблемы с middlewares в Express.js

Express.js это низкоуровневый фреймворк. Он полностью построен на концепции middleware. По сути приложение на Express это серия вызовов middleware-функций. Express не даёт никаких абстракций поверх этого и не даёт никаких рекомендаций по их использованию.

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

Сильная связанность между middlewares

Так как middlewares выполняются по цепочке, то велик соблазн делать их очень маленькими выполняющими только одну функцию и передавать результат дальше. Например, представим что мы хотим сделать валидацию данных в теле запроса и возвращать ошибки если валидация не прошла. Можно сделать две middleware validateBody и sendValidationErrors. Использоваться будут так:

app.post(
  '/comments'
  validateBody,
  sendValidationErrors,
  addCommentHandler
);

Проблема в том что они не имеют смысла друг без друга и всегда должны добавляться вместе. Легко ошибиться и добавить только одну или добавить в неправильном порядке и т.д. Решение тут либо объединить их в одну middleware, либо вообще не использовать для этого middleware (об этом будет дальше).

Использование middlewares для общих функций

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

Решение здесь - выносить общий код не в middleware, а в обычные функции.

Пример до:

app.get(
  '/route',
  middleware1,
  middleware2,
  middleware3,
  myHandler
);

Пример после:

function myHandler() {
  func1();
  func2();
  func3();
  // ...
}

Использование middlewares для разделения кода хендлера

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

async function checkSpam(req, res, next) {
  const isSpam = await spamChecker.check(req.body);
  if (isSpam) {
    throw new Error('spam!');
  }

  next();
}

function addCommentHandler() {
  // ...
}

app.post(
  '/comments'
  checkSpam,
  addCommentHandler
);

Кроме всех проблем из пункта "Использование middlewares для общих функций" сюда добавляются новые: при взгляде на файл с роутами для комментариев уже невозможно сразу сказать что из этого обработчики роутов, а что middleware. Даже наличие параметра next ничего не гарантирует, так как он может быть и в обычном обработчике. Придется вчитываться либо в комментарии к функциям (если они написаны и актуальны) либо в их код. Дополнительно к этому цепочки middlewares очень хрупкие и их легко сломать если забыть в какой-то ситуации вызвать next. При рефакторинге кода разделенного на middlewares надо быть особенно внимательным.

Свалка в объектах req и res

Каждая middleware имеет доступ к объектам реквеста и респонса и в любой момент может там что-то добавить, изменить или удалить. Через какое-то время уже совершенно невозможно отследить какой обработчик куда и что добавил. Отладка становится сложной, количество случайных багов растёт.

Goto программирование

Любая middleware может вызвать next и передать ему объект ошибки. В результате все последующие обработчики будут пропущены и выполняться начнут обработчики ошибок. Есть соблазн написать много кастомных обработчиков ошибок чтобы там ловить разные типы ошибок и как-то их обрабатывать.

app.get(
  '/route',
  middleware1,
  middleware2,
  middleware3,
  logError,
  handleAuthError,
  handleCommonError,
  myHandler
);

В итоге получаем классическое goto программирование. В таком коде легко делать баги и его тяжело отлаживать.

Слишком сильная связанность кода приложения с кодом фреймворка

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

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