Skip to content

Instantly share code, notes, and snippets.

@dSalieri
Last active December 21, 2022 00:56
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save dSalieri/11b24c77691ee9c024e0c32e051913d0 to your computer and use it in GitHub Desktop.
Save dSalieri/11b24c77691ee9c024e0c32e051913d0 to your computer and use it in GitHub Desktop.
Middleware в Koa, koa-router

Данный документ подготовлен для понимания того как работают композиции middleware в Koa

Также этот документ описывает кое-какие части механизмов Koa и koa-router


Композиции middlewares в Koa образуются через интерфейс Koa.prototype.use:

const app = new Koa();

/// Добавление middleware
app
  .use((ctx, next) => {
    console.log("Step 1")
  })
  .use((ctx, next) => {
    console.log("Step 2")
  })

Таким образом когда сервер будет запущен через метод Koa.prototype.listen:

const app = new Koa();

/// Добавление middleware
app
  .use((ctx, next) => {
    console.log("Step 1")
  })
  .use((ctx, next) => {
    console.log("Step 2")
  })
  .use((ctx, next) => {
    console.log("Step 3")
  });

/// Запуск сервера
app.listen(3000);

у нас уже будет создана цепочка middleware, которая сработает в той последовательности в какой мы объявляли наши middleware (это только если в каждом middleware метод next будет вызван; каждый next это функция на следующий middleware)

const app = new Koa();

/// Добавление middleware
app
  .use((ctx, next) => {
    console.log("Step 1")
    next();
  })
  .use((ctx, next) => {
    console.log("Step 2")
    next();
  })
  .use((ctx, next) => {
    console.log("Step 3")
    next();
  });

/// Запуск сервера
app.listen(3000);

Вы могли подумать что в next можно передать аргументы, но нет нельзя, точнее можно, но этими аргументами управляет библиотека Koa, у нее есть специальный модуль koa-compose (ниже будет его код). Если вы хотите модифицировать этот модуль то пожалуйста, но в сыром виде вы не управляете аргументами в next

Как выглядит метод use библиотеки Koa

/// Да, вот такой забавный метод, он просто пополняет список middleware экземпляра Koa
 use (fn) {
    if (typeof fn !== 'function') throw new TypeError('middleware must be a function!')
    this.middleware.push(fn)
    return this
  }

И еще взгляните как активируются все эти middleware:

/// Метод запуска сервера
listen (...args) {
   const server = http.createServer(this.callback())
   return server.listen(...args)
}

/// Функция-callback, которая как вы видите выше выступает в качестве обработчика node модуля http создания сервера http.createServer
callback () {
  /// А вот и наша композиция middleware
  const fn = this.compose(this.middleware)

  if (!this.listenerCount('error')) this.on('error', this.onerror)

  const handleRequest = (req, res) => {
    const ctx = this.createContext(req, res)
    /// Эта строчка очень интересная - передает объект ctx и fn (как вы видите fn это наши middleware)
    return this.handleRequest(ctx, fn)
  }
  /// Как видите вызов этой функции возвращает функцию, которая будет передана в http.createServer
  return handleRequest
}

/// Как вы заметили это метод, который вызывается в рамках функции обратного вызова http.createServer(handleRequest), но не путайте handleRequest - функция, this.handleRequest - метод
handleRequest (ctx, fnMiddleware) {
   const res = ctx.res
   res.statusCode = 404
   const onerror = err => ctx.onerror(err)
   const handleResponse = () => respond(ctx)
   /// Если объект res (поток) получает ошибку на событии end или finish, тогда выполняется обработчик onerror
   /// Этот этап относится конкретно к объекту res (обработка ошибок на потоке)
   onFinished(res, onerror)
   /// fnMiddleware это promise (смотрите исходник compose, ниже он есть)
   /// Если в композиции fnMiddleware не произойдет ошибок, выполнится handleRespond, в противном случае произойдет onerror
   return fnMiddleware(ctx).then(handleResponse).catch(onerror)
 }

Пару слов о koa-router:

Так как роутер совмещается с приложением на Koa, то вот это вам следует знать, чтобы понимать как этот модуль так легко работает с ним.

Скорее всего вы видели что-то такое:

const app = new Koa();
const router = new Router()

app.use(logger);
app.use(router.routes())
app.listen(3000);

Так вот вызов метода routes возвращает вам middleware все с теми же параметрами ctx и next, только этот middleware реализован самим модулем koa-router. Далее такой middleware становится в цепочку middleware нашего Koa приложения и будет вызван в порядке его определения после других middleware. Этот middleware совершает проверки связанные с маршрутизацией. Если маршрутизация успешна, то создает свою композицию middleware как делает это Koa, в такой композиции в качестве первого middleware опять служебный middleware, он задает путь и параметры в объект экземпляра Koa, чтобы мы могли потом этим воспользоваться в наших middleware. Если проверки провалились тогда прекращает свою работу и перенаправляет приложение на следующий middleware.

Вот пример того как выглядит метод Router.prototype.routes

Router.prototype.routes = Router.prototype.middleware = function () {
  const router = this;

  let dispatch = function dispatch(ctx, next) {
    /// Запрашиваемый путь и проверка "слоев" на совпадение пути
    /// Слой - специальный объект, который создается при вызове http-методов (get, post, put, delete) или use
    /// Он содержит всю необходимую информацию для объекта роутера (опции, имя, методы, имена параметров, стек middleware, путь и регулярное выражение описывающее путь)
    const path = router.opts.routerPath || ctx.routerPath || ctx.path;
    const matched = router.match(path, ctx.method);
    let layerChain;

    /// ctx.match содержит объект метода Route.prototype.match
    /// Условие проходит в том случае если данная функция dispatch вызывалась дважды
    if (ctx.matched) {
      ctx.matched.push.apply(ctx.matched, matched.path);
    } else {
      ctx.matched = matched.path;
    }

    ctx.router = router;

    /// Прекратить маршрутизацию если маршрут не совпал
    /// Переход через к следующему middleware
    if (!matched.route) return next();

    /// Если мы здесь то продолжаем процесс маршрутизации
    const matchedLayers = matched.pathAndMethod;
    const mostSpecificLayer = matchedLayers[matchedLayers.length - 1];
    ctx._matchedRoute = mostSpecificLayer.path;
    if (mostSpecificLayer.name) {
      ctx._matchedRouteName = mostSpecificLayer.name;
    }

    /// Создаем цепочку middleware для данного маршрута
    layerChain = matchedLayers.reduce(function (memo, layer) {
      /// Для каждого слоя создается первый служебный middleware
      /// Это нужно чтобы инициализировать полезные значение для использования в следующих middleware
      /// Значения:
      /// ctx.captures - захваченные значения параметров,
      /// ctx.params - объект с параметрами где ключ это название параметра, а значение его актуальное значение по шаблону параметра,
      /// ctx.routerPath - шаблон пути переданный в middleware в http-методе или all или use,
      /// ctx.routerName - имя роутера по заданному маршруту в http-методе или all
      memo.push(function memo(ctx, next) {
        ctx.captures = layer.captures(path, ctx.captures);
        ctx.params = ctx.request.params = layer.params(path, ctx.captures, ctx.params);
        ctx.routerPath = layer.path;
        ctx.routerName = layer.name;
        ctx._matchedRoute = layer.path;
        if (layer.name) {
          ctx._matchedRouteName = layer.name;
        }

        return next();
      });
      /// Вот тут происходит пристыковка служебного middleware с объявлеными вами
      /// Например: route.get('/article', (ctx, next) => {...}, (ctx, next) => {...})
      /// Вот эти два пристыкуются к служебному middleware
      return memo.concat(layer.stack);
    }, []);

    /// Создание композиции из набора middleware
    return compose(layerChain)(ctx, next);
  };

  dispatch.router = this;

  return dispatch;
};

А вот пример метода Router.prototype.use:

Router.prototype.use = function () {
  const router = this;
  const middleware = Array.prototype.slice.call(arguments);
  let path;

  /// Эта секция проверяет первый аргумент переданный в use
  /// И если там массив и первый аргумент строка, тогда
  /// В цикле для каждого пути вызвать router.use([p].concat(middleware.slice(1)))
  /// Эта запись пристыковывает middlewares к каждому пути отдельно
  // support array of paths
  if (Array.isArray(middleware[0]) && typeof middleware[0][0] === 'string') {
    let arrPaths = middleware[0];
    for (let i = 0; i < arrPaths.length; i++) {
      const p = arrPaths[i];
      router.use.apply(router, [p].concat(middleware.slice(1)));
    }

    return this;
  }

  /// Если мы здесь значит условие выше не сработало мы не получили массив первым аргументом и в нем первый строчный элемент
  /// Тогда проверяем является ли первый аргумент строкой
  const hasPath = typeof middleware[0] === 'string';
  /// Если да, то сдвигаем массив (смещаем его влево), а результат сохраняем в переменную
  if (hasPath) path = middleware.shift();

  for (let i = 0; i < middleware.length; i++) {
    const m = middleware[i];
    /// Если middleware это роутер т.е функция dispatch, появляется при вызове new Router().routes()
    if (m.router) {
      /// Эта весьма запутанная конструкция создает независимую копию экземпляра Router
      /// Чтобы не модифицировать поля оригинала, который может использоваться где-нибудь еще
      const cloneRouter = Object.assign(Object.create(Router.prototype), m.router, {
        stack: m.router.stack.slice(0),
      });

      /// В объекте роутера проходимся по всему стеку, который содержит слои
      for (let j = 0; j < cloneRouter.stack.length; j++) {
        const nestedLayer = cloneRouter.stack[j];
        const cloneLayer = Object.assign(Object.create(Layer.prototype), nestedLayer);

        /// Если путь был передан то добавляем префикс
        /// Функция установки префикса не только обновляет path cloneLayer, но и очищает его от парметров
        if (path) cloneLayer.setPrefix(path);
        /// Функция установки префикса, все тоже самое, но это обновление префикса от самих настроек роутера
        if (router.opts.prefix) cloneLayer.setPrefix(router.opts.prefix);
        /// Добавляем наши клонированные и обновленные путями слои во внешний роутер
        router.stack.push(cloneLayer);
        cloneRouter.stack[j] = cloneLayer;
      }

      /// Теперь если у внешнего роутера были вызовы с методом param
      /// То происходит вызов на клонированном роутере эти param
      /// Вообще метод param используется для того чтобы пристыковать дополнительные middleware, которые связываются с конкретным слоем
      /// Пристыковка идет в начало стека
      if (router.params) {
        function setRouterParams(paramArr) {
          const routerParams = paramArr;
          for (let j = 0; j < routerParams.length; j++) {
            const key = routerParams[j];
            cloneRouter.param(key, router.params[key]);
          }
        }
        setRouterParams(Object.keys(router.params));
      }
    } else {
      /// Если middleware не является роутером, тогда выполняется эта секция
      const keys = [];
      pathToRegexp(router.opts.prefix || '', keys);
      const routerPrefixHasParam = router.opts.prefix && keys.length;
      router.register(path || '([^/]*)', [], m, { end: false, ignoreCaptures: !hasPath && !routerPrefixHasParam });
    }
  }
  return this;
};

И наконец реализация функции compose от библиотеки koa-compose.

/// Это актуальная реализация композиции в Koa
function compose(middleware) {
  return function (context, next) {
    let index = -1;
    return dispatch(0);
    function dispatch(i) {
      /// Если каким-то образом index становится равен или больше чем следующий i, то считается что есть повторный вызов уже вызванного next
      /// На мой личный взгляд упростить понимание алгоритма можно было бы обычным итератором
      if (i <= index) return Promise.reject(new Error('next() called multiple times'));
      index = i;
      /// достается middleware по индексу (рекурсивно передавая следующий индекс)
      let fn = middleware[i];
      /// когда все middlewares закончились достается из аргумента next
      if (i === middleware.length) fn = next;
      /// когда пусто - обычное резрешение promise со значением undefined
      if (!fn) {
        return Promise.resolve();
      }
      /// Ключевая операция
      /// Пытаемся разрешить promise вызванной функции fn
      /// В нее передается аргумент context и вызов функции dispatch с переданным в нее индексом следующего middleware
      /// Если в результате разрешения мы получаем ошибку то перехватываем ее в catch
      try {
        return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
      } catch (err) {
        return Promise.reject(err);
      }
    }
  };
}

/// Работает принцип вложенных promise, самый внешний разрешается только тогда
/// когда вложенный на один уровень вниз promise разрешается
/// поэтому если мы делаем await самого первого middleware то ждем завершения
/// цепочки завершения всех остальных перед тем как вернуться в самый первый middleware
/// В next не имеет смысла передавать аргументы так как next это функция dispatch, которая
/// привязывает к себе аргумент в виде индекса определенного middleware
/// При использовании middleware вы принимаете аргументы от функции dispatch, которая в middleware является next
compose([
  async (ctx, next) => {
    console.log('hello 1', ctx, next);
    await next();
    console.log('hello 1 end');
  },
  async (ctx, next) => {
    console.log('hello 2', ctx, next);
    await next();
    console.log('hello 2 end');
  },
  async (ctx, next) => {
    console.log('hello 3', ctx, next);
    await next();
    console.log('hello 3 end');
  },
])({ value: 'data here' }, (ctx, next) => console.log('argument middleware', ctx, next));

P.S Документ может быть расширен, последнее обновление документа 02.11.2022

@tadjik1
Copy link

tadjik1 commented Dec 19, 2022

это прекрасно, спасибо вам большое за ваш труд! с вашего позволения заберу часть текста в учебник для будущих поколений студентов :)

@dSalieri
Copy link
Author

@tadjik1 не вопрос, забирайте :)

Изначально документ планировался с целью раскрыть композицию middleware (функция compose) и показать как Koa ее создает. Поскольку большую часть логики мы раскидываем в middleware, знать как это реализовано долг каждого уважающего себя программиста.

P.S Также я писал фундаментальную статью про promise, мало ли вдруг пригодится, как никак в compose используется их принцип.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment