Данный документ подготовлен для понимания того как работают композиции 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 не вопрос, забирайте :)
Изначально документ планировался с целью раскрыть композицию
middleware
(функцияcompose
) и показать какKoa
ее создает. Поскольку большую часть логики мы раскидываем вmiddleware
, знать как это реализовано долг каждого уважающего себя программиста.P.S Также я писал фундаментальную статью про promise, мало ли вдруг пригодится, как никак в
compose
используется их принцип.