JS Common Middleware

背景

最近有个库需要新增一个功能, 影响面比较广, 但是这个功能又同时是可选的, 如果关闭后希望对原有逻辑没有任何影响, 所以就考虑使用插件化注入和中间件调用的方式来实现了.

而提到中间件, 目前前端比较熟知的就是ExpressKoa的中间件机制了. Express的中间件是依次顺序调用的, 而Koa是洋葱圈模型, 从可控性角度来看, 洋葱圈模型的自由程度更高一点, 所以在实际开发中, 可以参考Koa的中间件机制来实现

实现

调研

先分析下Koa的中间件是如何实现的, 然后参考实现我们自己的中间件

Koa-compose

Koa里中间件最核心的组装方法就是compose函数了, 它传入一个数组, 能实现嵌套调用传值, 源码也十分简洁, 这里简单分析下调用顺序和含义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
'use strict';

/**
* Expose compositor.
*/

module.exports = compose;

/**
* Compose `middleware` returning
* a fully valid middleware comprised
* of all those which are passed.
*
* @param {Array} middleware
* @return {Function}
* @api public
*/

function compose(middleware) {
if (!Array.isArray(middleware)) throw new TypeError('Middleware stack must be an array!');
for (const fn of middleware) {
if (typeof fn !== 'function') throw new TypeError('Middleware must be composed of functions!');
}

/**
* @param {Object} context
* @return {Promise}
* @api public
*/

return function (context, next) {
// last called middleware #
let index = -1;
return dispatch(0);
function dispatch(i) {
if (i <= index) return Promise.reject(new Error('next() called multiple times'));
index = i;
let fn = middleware[i];
if (i === middleware.length) fn = next;
if (!fn) return Promise.resolve();
try {
return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
} catch (err) {
return Promise.reject(err);
}
}
};
}

类型检查和注释可以忽略, 只分析核心内容

compose方法接受一个数组, 返回了一个函数, 这个函数在调用后返回dispath(0)方法, 而核心实现内容在dispath方法中

dispatch首先检查了一下当前执行的函数是否之前已经执行过, 如果没有执行过, 则记录当前执行的index, 用于下一次检查

然后根据下标获取到第i个中间件函数, 而如果此时已经执行了全部的中间件, 那么fn就是next方法, 就是需要使用中间件包裹的最原始的函数

后续就比较简单了: 如果fn不存在, 直接返回Promise.resolve(), 否则将context和下一次需要执行的中间件函数传入fn中, 这样fn里就能获取到contextnext函数, 可以自由处理. 而且全程使用Promise包裹, 可以更好的控制异步操作

Koa-application

再看看在Koa里的调用方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class Application extends Emitter {
listen(...args) {
debug('listen');
const server = http.createServer(this.callback());
return server.listen(...args);
}
callback() {
const fn = compose(this.middleware);

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

const handleRequest = (req, res) => {
const ctx = this.createContext(req, res);
return this.handleRequest(ctx, fn);
};

return handleRequest;
}
handleRequest(ctx, fnMiddleware) {
const res = ctx.res;
res.statusCode = 404;
const onerror = (err) => ctx.onerror(err);
const handleResponse = () => respond(ctx);
onFinished(res, onerror);
return fnMiddleware(ctx).then(handleResponse).catch(onerror);
}
}

server启动时调用callback, 返回了handleRequest函数, handleRequest函数构造了ctx对象, 和中间件函数一起传给this.handleRequest执行(这里可以看见handleRequest并没有使用到compose返回函数的第二个next参数).

context

在以上代码分析中, 可以看到Koa里比较关键的一个点是使用了构造的context对象进行透传, 并在中间件执行时也传入, 这样中间件函数里就不用传参, 而是通过context对象来获取需要的参数. 但是对于其他库这样可能不够合适, 对于TypeScript的类型定义和调用来说也不够友好, 所以需要考虑将context概念去掉, 还原回函数参数调用的模式

开发

分析以上调用栈, 可以发现handleRequest返回的是一个无参数的调用方法, 那么只需要从这一步开始改造即可

外部调用形式

先构造出外部正常使用的调用代码

1
2
const fn = compose(middleware);
const result = fn(data);

再构造中间件里调用的代码

1
2
3
4
5
6
7
8
const middleware = function (next) {
return async function (...args) {
console.log(`Start middleware`);
const result = await next(...args);
console.log(`Finish middleware`);
return result;
};
};

compose

对比Koacompose函数, 可以发现我们的middleware返回的是一个带参数的异步函数A, 这个返回的函数A执行后才是真实的逻辑, 传参也是传入这个A函数. 而Koa返回的是一个无参数的异步函数B, 而且B函数执行后就是真实的中间件逻辑. 所以只需要将Koacompose里返回的相关调用修改为函数A调用模式即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
const compose = (middleware) => {
if (!Array.isArray(middleware)) throw new TypeError('Middleware stack must be an array!');
for (const fn of middleware) {
if (typeof fn !== 'function') throw new TypeError('Middleware must be composed of functions!');
}

return (next) => {
// last called middleware #
let index = -1;
const dispatch =
(i: number) =>
(...args) => {
if (i <= index) return Promise.reject(new Error('next() called multiple times'));
index = i;
const fn = middleware[i];
if (i === middleware.length) {
if (next) {
return Promise.resolve(next(...args));
}
}
if (!fn) return Promise.resolve();
try {
return Promise.resolve(fn(dispatch(i + 1))(...args));
} catch (err) {
return Promise.reject(err);
}
};
return dispatch(0);
};
};

完成后, 可以看到需要修改的内容其实很少, 主要就是将next函数改造为支持传参的模式, 并且在fn(dispatch)调用得到的A函数之后, 传入args执行真正的中间件逻辑即可

使用

1
2
3
4
5
6
7
8
9
10
11
12
13
const middwareFn = (next) => {
return async function (...args) {
console.log(`Start middleware`);
const result = await next(...args);
console.log(`Finish middleware`);
return result;
};
};
const fnMiddleware = compose([middwareFn, middwareFn]);

const result = await fnMiddleware(async ({ data }) => {
return await 'success';
})({ data });

TypeScript支持

再对compose函数补完类型定义即可, 其中T是需要经过middleware的实际函数签名, 传入后能实现后续的next函数都能自动识别到此函数参数和返回值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
export type AnyFunction = (...args: any) => Promise<any>;

export type NextFunction<T extends AnyFunction> = (...args: Parameters<T>) => ReturnType<T>;

export type MiddlewareFunction<T extends AnyFunction> = (next: NextFunction<T>) => NextFunction<T>;

export type MiddlewareCompose<T extends AnyFunction> = (middleware: MiddlewareFunction<T>[]) => (next: NextFunction<T>) => NextFunction<T>;

const compose: MiddlewareCompose<AnyFunction> = <T extends AnyFunction>(
middleware: MiddlewareFunction<T>[],
): ((next: NextFunction<T>) => NextFunction<T>) => {
if (!Array.isArray(middleware)) throw new TypeError('Middleware stack must be an array!');
for (const fn of middleware) {
if (typeof fn !== 'function') throw new TypeError('Middleware must be composed of functions!');
}

return (next: NextFunction<T>) => {
// last called middleware #
let index = -1;
const dispatch: (i: number) => NextFunction<T> =
(i: number) =>
(...args: Parameters<T>): ReturnType<T> => {
if (i <= index) return Promise.reject(new Error('next() called multiple times')) as ReturnType<T>;
index = i;
const fn = middleware[i];
if (i === middleware.length) {
if (next) {
return Promise.resolve(next(...args)) as ReturnType<T>;
}
}
if (!fn) return Promise.resolve() as ReturnType<T>;
try {
return Promise.resolve(fn(dispatch(i + 1))(...args)) as ReturnType<T>;
} catch (err) {
return Promise.reject(err) as ReturnType<T>;
}
};
return dispatch(0);
};
};

export default compose;

小结

实现完核心的compose后, 再完善plugin-setup相关的逻辑, 就可以实现插件注入和中间件拦截了, 对于插件的开发和核心库的维护来说也更独立和简单了

其他

对比Redux Compose

Redux里也有链式调用的compose实现, 但是对比起Koa的来说, 它是通过reduce来实现将后一个函数参数调用后的结果作为下一个函数的参数, 无法通过next函数来实现调用链的控制. 在上面的compose实现中, 可以在任何中间件里结束这次调用并返回结果, 但是在redux中是无法实现的.

其他资料

作者

Mosby

发布于

2021-07-07

许可协议

评论