从源码分析几种中间件的实现方式
在前端比较熟悉的框架如express
、koa
、redux
和axios
中,都提供了中间件或拦截器的功能,本文将从源码出发,分析这几个框架中对应中间件的实现原理。
参考
Koa
koa对外暴露的API很少,也很便于我们使用
const Koa = require('koa');
const app = new Koa();
app.use(async ctx => {
ctx.body = 'Hello World';
});
app.listen(3000);
Application类
从package.json
的main
字段开始,找到整个库的入口文件lib/application.js
// lib/application.js
module.exports = class Application extends Emitter {
constructor(options) {
super();
options = options || {};
// ...初始化相关参数
this.middleware = [];
this.context = Object.create(context);
this.request = Object.create(request); // request包含header、url、method等多个接口
this.response = Object.create(response);// response包含status、headers等接口
}
use(fn) {
// 检测fn是不是合格的中间件
this.middleware.push(fn);
return this;
}
// 通过createServer启动一个node服务
listen(...args) {
const server = http.createServer(this.callback());
return server.listen(...args);
}
}
忽略大部分代码之后,可以看见整个koa源码是非常精简的,主要就提供了一个Application
类,每个app实例对象暴露了use
和listen
两个方法。此外由于继承了Emitter
类,app实例也可以使用诸如on
、emit
等事件通信方法。
在listen
方法中通过 http.createServer
启动了一个监听指定端口号的服务。接下来看看传入http.createServer
中this.callback
的逻辑
中间件
class Application extends Emitter {
// ...
callback() {
// 组合中间件
const fn = compose(this.middleware);
if (!this.listenerCount('error')) this.on('error', this.onerror);
const handleRequest = (req, res) => {
// `createContext`封装了`http.createServer`中的`request`和`response`对象,并将其挂载到了context上,
// 这也是我们为什么能拿到`ctx.request`和`ctx.response`的原因
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);
// respond实际上是封装了的响应处理函数,在内部调用ctx.resoponse.end(ctx.body)的方式将数据返回给浏览器
const handleResponse = () => respond(ctx);
onFinished(res, onerror);
// 开始执行组合后的中间件函数
return fnMiddleware(ctx).then(handleResponse).catch(onerror);
}
}
可见,整个流程大致为
- 通过
compose(this.middleware)
组合了整个中间件链,返回fnMiddleware
- 接收到请求时,会调用
handleRequest
,- 首先调用
createContext
封装本次请求context, - 然后调用
this.handleRequest(ctx, fnMiddleware)
处理本次请求
- 首先调用
- 处理本次请求的具体逻辑在
fnMiddleware(ctx).then(handleResponse).catch(onerror)
中
因此我们目前只需要弄明白compose
中组合中间件的方式,就能大致了解整个koa的工作方式了。
compse是引入的koa-compose
,其实现大致如下
function compose(middleware) {
// ...检查中间件的类型:middleware列表必须为数组,每个中间件必须为函数
// 返回的就是上面的fnMiddleware,执行fnMiddleware后返回的实际上是一个promise对象
return function (context, next) {
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]
// 如果middleware列表已经调用完毕,如果传入了额外的next,则下一次会调用next方法,
// 可以看见在上面的fnMiddleware中此处并没有传值
if (i === middleware.length) fn = next
// 如果无fn了,则表示中间件已经从第一个依次执行到最后一个中间件了
if (!fn) return Promise.resolve()
try {
// 把ctx和next传入到中间件中,可以看见我们在中间件中调用的next实际上就是dispatch.bind(null, i + 1))
return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
} catch (err) {
return Promise.reject(err)
}
}
}
}
从上面的代码中我们可以看见,每个中间件的格式为
function mid(ctx, next){}
// next被包装成dispatch.bind(null, i + 1))的性能
在中间件逻辑中,需要手动调用next
才会执行下一个中间件。
此外,每个dispatch返回的实际上是一个promise,
var p = fn(context, dispatch.bind(null, i + 1))
Promise.resolve(p) === p // 如果fn返回的是一个promise对象,则此处返回true
因此如果希望实现洋葱模型的中间件调用顺序,就必须等待dispatch执行完毕才行,否则中间件执行顺序就会发生错乱,可能导致调用handleResponse无法获取正确的ctx.body等问题
async function mid(ctx, next){
await next() // 必须在此处暂停等待下一个中间件执行完毕,
}
express
express内置了router
等接口,因此源码整体比Koa要大很多。
注册中间件
同样通过app.use
的方法注册中间件,
app.use = function use(fn) {
var offset = 0;
var path = '/';
// ...处理fns、patch等参数
var fns = flatten(slice.call(arguments, offset));
// setup router
this.lazyrouter();
var router = this._router;
fns.forEach(function (fn) {
// non-express app
if (!fn || !fn.handle || !fn.set) {
return router.use(path, fn);
}
// ... 构造mounted_app方法,然后调用 router.use(path, mounted_app)
}, this);
return this;
};
发现app.use中实际上是调用了router.use
router.use = function use(fn) {
var offset = 0;
var path = '/';
var callbacks = flatten(slice.call(arguments, offset));
for (var i = 0; i < callbacks.length; i++) {
var fn = callbacks[i];
// 中间件是一个Layer对象,其中包含了当前路由匹配相关的正则,以及layer.handle = fn
var layer = new Layer(path, {
sensitive: this.caseSensitive,
strict: false,
end: false
}, fn);
layer.route = undefined;
// 使用一个数组将中间件对象保存起来
this.stack.push(layer);
}
return this;
};
可以看见,每个Router对象都通过一个statck数组保存了相关的中间件函数。
执行中间件
从app.listen
开始启动服务器,等待接收网络请求
var app = function(req, res, next) {
app.handle(req, res, next);
};
app.listen = function listen() {
var server = http.createServer(this);
return server.listen.apply(server, arguments);
};
app.handle = function handle(req, res, callback) {
var router = this._router;
// final handler
var done = callback || finalhandler(req, res, {
env: this.get('env'),
onerror: logerror.bind(this)
});
router.handle(req, res, done);
};
可以看见,调用中间件的逻辑最后放在了router.handle
中
router.handle = function handle(req, res, out) {
var self = this;
var idx = 0;
var stack = self.stack;
var done = restore(out, req, 'baseUrl', 'next', 'params');
// ... 省略处理req等方法的代码
next();
function next(err) {
// no more matching layers
if (idx >= stack.length) {
setImmediate(done, layerError);
return;
}
// 从 req 获取path,然后根据path从stack中找到下一个匹配的layer
// ... 根据path从stack中找到对应的layer
while (match !== true && idx < stack.length) {
layer = stack[idx++]; // next通过闭包维持了对于idx游标的引用,当调用next时,就会从下一个中间件开始查找
match = matchLayer(layer, path);
route = layer.route
// ...
}
// ...处理参数等
self.process_params(layer, paramcalled, req, res, function (err) {
if (err) {
return next(layerError || err);
}
if (route) {
// 执行中间件方法,同时传入next参数
return layer.handle_request(req, res, next);
}
});
}
};
Layer.prototype.handle_request = function handle(req, res, next) {
var fn = this.handle; // 前面注册的中间件方法
// ...
try {
fn(req, res, next); // 真正执行中间件的地方
} catch (err) {
next(err);
}
};
可以看见,express的中间件实现思路是通过闭包维持了遍历中间件列表的游标,每次调用next方法时,会通过移动游标的方法找到下一个中间件并在handle_request
中执行。
因此,可以理解为express中间件是基于回调函数的,每个中间件执行的都是同一个next方法,但每次调用next都会按顺序执行中间件列表。
Redux
可以通过中间件来扩展redux,完成特性功能,下面是一个日志中间件的定义和使用
function logger({ getState, dispatch }) {
// next代表下一个中间件,action表示当前接收到的动作
return next => action => {
console.log("before change", action);
// 调用 middleware 链中下一个 middleware 的 dispatch。
let val = next(action);
console.log("after change", getState(), val);
return val;
};
}
let createStoreWithMiddleware = Redux.applyMiddleware(logger)(Redux.createStore)
// 可以把中间件看做是增强版的createStore
let store = createStoreWithMiddleware(reducer, initState);
// 也可以使用createStore第三个参数enhancer
// let store = Redux.createStore(reducer,initState,Redux.applyMiddleware(logger));
可见看见中间件的一些特征
- 中间件接收参数包括
getState
和dispatch
- 中间件返回的是一个函数,该函数接收下一个中间件next作为参数,并返回一个接收action的新的dispatch方法
- 只有手动调用
next(action)
才会执行下一个中间件
简而言之,一个最基本的中间件应该是下面这个样子的,通过柯里化的方式定义中间件
const pureMiddleware = ({dispatch, getState}) => next => action => next(action)
柯里化与组合
柯里化是函数式编程里面的一个概念,其功能是把多个参数的函数编程一个接收单一参数的函数,并返回一个接收余下参数的新函数
// 普通实现
const add = (a, b) => a + b
// 柯里化实现
const add = (a) => (b) => a + b
const add10 = add(10) // 一个可以复用的add(10)函数
add10(20) // 30
add10(30) // 30
const add100 = add(100) // 依次类推,可以生成高度复用的新函数
此外需要了解compose
的概念,compose
是函数式编程里面的组合,其功能是将多个单功能的函数合并为一个函数
// 组合函数, 如compose(f, g, h)会返回 (...args) => f(g(h(...args)))
export default function compose(...funcs) {
if (funcs.length === 0) {
return arg => arg
}
if (funcs.length === 1) {
return funcs[0]
}
// 返回的是一个组合后的函数
// 调用时,funcs列表中的方法,从后向前依次调用,并将上一个方法的返回值作为作为下一个方法的参数
return funcs.reduce((a, b) => (...args) => a(b(...args)))
}
以下面为例
var funcs = [
(a)=>{
console.log(a)
return a+1
},
(b)=>{
console.log(b)
return b+2
}
]
var res = compose(...funcs)
console.log(res(1)) // 1,3,4,注意调用的顺序
applyMiddleware
现在来看applyMiddleware
的源码,这里的代码十分精彩,短短几行就实现了一个完整的中间件系统
export default function applyMiddleware(...middlewares) {
// 返回的是一个接收createStore参数的闭包,中间件通过middlewares参数列表维护
return createStore => (...args) => {
// 创建原始的store
const store = createStore(...args)
let dispatch = () => {
throw new Error(
'Dispatching while constructing your middleware is not allowed. ' +
'Other middleware would not be applied to this dispatch.'
)
}
// 每个中间件都包含统一参数:getState和dispatch
const middlewareAPI = {
getState: store.getState,
dispatch: (...args) => dispatch(...args) // 这里使用闭包,让每个中间件维持对组合dispatch的引用
}
// 初始化store时,中间件按参数顺序依次调用,每个中间件返回的是 next => action => next(action) 统一格式
const chain = middlewares.map(middleware => middleware(middlewareAPI))
// compose(...chain)返回的是一个组合的 next => action => next(action) 方法
// compose(...chain)(store.dispatch)会将store.dispatch作为最后一个next中间件传入,返回一个组合后的dispatch
dispatch = compose(...chain)(store.dispatch)
// compose是按照从右向左的顺序支持函数列表,因此当在视图中调用dispatch(action)时,只有在最后一个中间件中调用next(action)才会触发真实的store.dispatch(action)
// 在此之前state未更新,在此之后state已更新,最后一个中间件执行完毕,控制权返回上一个中间件的next后面部分,依次退出调用栈
// 如果前面某个中间件未调用next,则后面所有的中间件都不会执行
return {
...store,
dispatch // 返回一个增强了dispatch的store
}
}
}
集合中间件的格式,我们可以了解到
chain
中保存的都是next => action => {next(action)}
的方法- 通过
compose
返回了一个组合函数,将store.dispatch
作为参数传递给组合函数,组合函数执行时会逆序调用chain中的方法,并将上一个方法的返回值作为作为下一个方法 - 这里的上一个方法就是
action => {next(action)}
,跟原始的store.dispatch
结构一致,因此组合函数最后的返回值可以理解为是经过组合函数包装后的dispatch
所以根据源码,则中间件的执行顺序应该是
正常同步调用next,在dispatch前执行next前面的代码部分,在dispatch后执行next后面的部分
mid1 before next -> mid2 before next -> mid3 before next-> dispatch -> mid3 after next -> mid2 after next -> mid1 after next
正常同步调用,如果在mid2中未调用next,则
mid1 before next -> mid2 full -> mid1 after next
如果在mid2中是异步调用next,则
mid1 before nex -> mid2 full sync -> mid1 after next -> mid2 async before next -> mid3 before next -> dispatch -> mid3 after next -> mid2 async after next
此外需要注意的是,在中间件的执行中,不能手动调用传入的组合dispatch
,而应该通过next调用下一个中间件,否则会出现死循环。
axios的拦截器
首先来看看axios的构造函数
function Axios(instanceConfig) {
this.defaults = instanceConfig;
this.interceptors = {
request: new InterceptorManager(),
response: new InterceptorManager()
};
}
InterceptorManager
然后来看看InterceptorManager
这个拦截器管理类
function InterceptorManager() {
this.handlers = [];
}
// 添加拦截器
InterceptorManager.prototype.use = function use(fulfilled, rejected) {
this.handlers.push({
fulfilled: fulfilled,
rejected: rejected
});
return this.handlers.length - 1;
};
// 取消拦截器
InterceptorManager.prototype.eject = function eject(id) {
if (this.handlers[id]) {
this.handlers[id] = null;
}
};
// 遍历拦截器
InterceptorManager.prototype.forEach = function forEach(fn) {
utils.forEach(this.handlers, function forEachHandler(h) {
if (h !== null) {
fn(h);
}
});
};
可见InterceptorManager
主要是用来保存拦截器,然后提供一些遍历或操作拦截器的方法而已。
request
我们知道,在一次完整的请求过程中,会依次触发:请求拦截器->网络请求->响应拦截器->响应回调等过程。
网络请求库拦截器的特殊性在于
- 请求拦截器作用主要是获编辑请求信息,如配置公共的参数、修改Header等
- 响应拦截器主要是根据响应内容,做一些公共的逻辑处理,如错误提示、登录鉴权等
- 拦截器可能是异步执行的,且后一个拦截器可能需要上一个拦截器的返回值
我们来看看axios触发网络请求的方法Axios.prototype.request
Axios.prototype.request = function request(config) {
// ...初始化配置
// 构建任务链
var chain = [dispatchRequest, undefined];
var promise = Promise.resolve(config);
// 注册请求拦截器
this.interceptors.request.forEach(function unshiftRequestInterceptors(interceptor) {
chain.unshift(interceptor.fulfilled, interceptor.rejected);
});
// 注册响应拦截器
this.interceptors.response.forEach(function pushResponseInterceptors(interceptor) {
chain.push(interceptor.fulfilled, interceptor.rejected);
});
while (chain.length) {
promise = promise.then(chain.shift(), chain.shift());
}
return promise;
};
可以看见上面实现的中间件逻辑方式:按顺序构造一个Promise链
首先遍历请求拦截器和响应拦截器,填充整个chain,其内容大致如下
[
// 请求拦截器是通过unshift逆序调用的
request2.fulfilled, request2.rejected,
request1.fulfilled, request1.rejected,
... ,
dispatchRequest, undefined, // dispatchRequest是真实发送网络请求的地方
// 响应拦截器通过push按顺序调用的
response1.fulfilled, response1.rejected,
response2.fulfilled, response2.rejected,
... ,
]
然后遍历整个chain,构造Promise链,并返回最后的promise对象
while (chain.length) {
promise = promise.then(chain.shift(), chain.shift());
}
至此,axios就实现了一个完整的拦截器系统。
取消请求
可以看见,axios的拦截器是一个比较特殊的中间件,并没有next
等手动调用下一个中间件的方式。这应该算是网络请求库的特定需求导致的。
由于Promise是不能被取消的(需要了解cancelable promises proposal
,目前该提案已被取消),那么axios是如何实现取消请求的呢?
查看文档示例,可以使用下面两种方式取消
var CancelToken = axios.CancelToken;
var source = CancelToken.source();
axios.get('/user/12345', {
cancelToken: source.token
}).catch(function(thrown) {
if (axios.isCancel(thrown)) {
console.log('Request canceled', thrown.message);
} else {
// 处理错误
}
});
// 取消请求(message 参数是可选的)
// 使用同一个 cancel token 取消多个请求
source.cancel('Operation canceled by the user.');
也可以使用下面方式
var CancelToken = axios.CancelToken;
var cancel;
axios.get('/user/12345', {
cancelToken: new CancelToken(function executor(c) {
// executor 函数接收一个 cancel 函数作为参数
cancel = c;
})
});
// 取消请求
cancel();
查看CancelToken
源码
function CancelToken(executor) {
if (typeof executor !== 'function') {
throw new TypeError('executor must be a function.');
}
var resolvePromise;
this.promise = new Promise(function promiseExecutor(resolve) {
resolvePromise = resolve;
});
var token = this;
executor(function cancel(message) {
if (token.reason) {
// Cancellation has already been requested
return;
}
// 设置reason
token.reason = new Cancel(message);
resolvePromise(token.reason);
});
}
CancelToken.prototype.throwIfRequested = function throwIfRequested() {
if (this.reason) {
throw this.reason;
}
};
可见看见,如果调用了传入executor
的cancel
方法,在后续的dispatchRequest
中会判断是否存在reason
来决定是否取消本次请求。
查看发送请求的源码dispatchRequest
function throwIfCancellationRequested(config) {
if (config.cancelToken) {
config.cancelToken.throwIfRequested();
}
}
function dispatchRequest(config) {
// 判断是否cancelToken是否已经执行了cancel方法,如果已执行,则抛出异常终止后续的promise
throwIfCancellationRequested(config);
// ...网路请求逻辑
return adapter(config).then(function onAdapterResolution(response) {
throwIfCancellationRequested(config);
// ...
},function(){
throwIfCancellationRequested(config);
// ...
})
}
总结
在上面分析了四种框架实现中间件的方式,每种实现方式都有一些差异
- express通过闭包保存遍历中间件列表的游标,并在每一次手动调用next时移动游标的位置,通过函数的调用栈实现中间件
- koa的中间件实现与express基本一致,通过闭包保存游标;koa的特点在于每个next都会返回一个Promise对象,因此如果需要按正常顺序执行中间件,需要通过await的方式等待下一个中间件运行完毕
- redux通过组合的方式实现中间件,每个中间件的返回值都是一个与原始
store.dispatch
方法签名相同的方法,通过遍历中间件,返回一个组合后的增强版dispatch方法 - axios的拦截器是一种比较特殊的中间件,由于每个中间件的执行依赖于上一个中间件的返回值,且可能是异步运行的,因此在每次触发请求时,都会遍历中间件构造一个Promise链,通过promise运行特点实现拦截器。
但这四种中间件实际上也存在某些相似点
- 中间件实际上就是函数,多个中间件之间的执行顺序取决于具体的实现
- 两个中间件之间存在某些关联,如获取返回值、主动调用下一个中间件等
我认为,中间件都是为了分隔业务逻辑,通过将不同的逻辑放在独立的中间件中,并组合中间件的方式,尽可能实现逻辑的复用,这种设计比单纯在业务代码中复用函数更为清晰明了。
中间件在面向对象中可以理解为装饰器,在函数式编程中可以理解为组合。通过本文的总结,对于常见的中间件实现有了比较清晰的了解。
你要请我喝一杯奶茶?
版权声明:自由转载-非商用-保持署名和原文链接。
本站文章均为本人原创,参考文章我都会在文中进行声明,也请您转载时附上署名。