侧边栏

从源码分析几种中间件的实现方式

发布于 | 分类于 技术原理

在前端比较熟悉的框架如expresskoareduxaxios中,都提供了中间件拦截器的功能,本文将从源码出发,分析这几个框架中对应中间件的实现原理。

参考

Koa

koa对外暴露的API很少,也很便于我们使用

js
const Koa = require('koa');
const app = new Koa();

app.use(async ctx => {
  ctx.body = 'Hello World';
});

app.listen(3000);

Application类

package.jsonmain字段开始,找到整个库的入口文件lib/application.js

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实例对象暴露了uselisten两个方法。此外由于继承了Emitter类,app实例也可以使用诸如onemit等事件通信方法。

listen方法中通过 http.createServer启动了一个监听指定端口号的服务。接下来看看传入http.createServerthis.callback的逻辑

中间件

js
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,其实现大致如下

js
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)
            }
        }
    }
}

从上面的代码中我们可以看见,每个中间件的格式为

js
function mid(ctx, next){}
// next被包装成dispatch.bind(null, i + 1))的性能

在中间件逻辑中,需要手动调用next才会执行下一个中间件。

此外,每个dispatch返回的实际上是一个promise,

js
var p = fn(context, dispatch.bind(null, i + 1)) 
Promise.resolve(p) === p // 如果fn返回的是一个promise对象,则此处返回true

因此如果希望实现洋葱模型的中间件调用顺序,就必须等待dispatch执行完毕才行,否则中间件执行顺序就会发生错乱,可能导致调用handleResponse无法获取正确的ctx.body等问题

js
async function mid(ctx, next){
    await next() // 必须在此处暂停等待下一个中间件执行完毕,
}

express

express内置了router等接口,因此源码整体比Koa要大很多。

注册中间件

同样通过app.use的方法注册中间件,

js
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

js
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开始启动服务器,等待接收网络请求

js
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

js
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,完成特性功能,下面是一个日志中间件的定义和使用

js
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));

可见看见中间件的一些特征

  • 中间件接收参数包括getStatedispatch
  • 中间件返回的是一个函数,该函数接收下一个中间件next作为参数,并返回一个接收action的新的dispatch方法
  • 只有手动调用next(action)才会执行下一个中间件

简而言之,一个最基本的中间件应该是下面这个样子的,通过柯里化的方式定义中间件

js
const pureMiddleware = ({dispatch, getState}) => next => action => next(action)

柯里化与组合

柯里化是函数式编程里面的一个概念,其功能是把多个参数的函数编程一个接收单一参数的函数,并返回一个接收余下参数的新函数

js
// 普通实现
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是函数式编程里面的组合,其功能是将多个单功能的函数合并为一个函数

js
 // 组合函数, 如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)))
}

以下面为例

js
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的源码,这里的代码十分精彩,短短几行就实现了一个完整的中间件系统

js
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的构造函数

js
function Axios(instanceConfig) {
  this.defaults = instanceConfig;
  this.interceptors = {
    request: new InterceptorManager(),
    response: new InterceptorManager()
  };
}

InterceptorManager

然后来看看InterceptorManager这个拦截器管理类

js
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

js
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,其内容大致如下

js
[
    // 请求拦截器是通过unshift逆序调用的
    request2.fulfilled, request2.rejected, 
    request1.fulfilled, request1.rejected, 
    ... , 
    dispatchRequest, undefined, // dispatchRequest是真实发送网络请求的地方
    // 响应拦截器通过push按顺序调用的
    response1.fulfilled, response1.rejected, 
    response2.fulfilled, response2.rejected, 
    ... ,
]

然后遍历整个chain,构造Promise链,并返回最后的promise对象

js
while (chain.length) {
    promise = promise.then(chain.shift(), chain.shift());
}

至此,axios就实现了一个完整的拦截器系统。

取消请求

可以看见,axios的拦截器是一个比较特殊的中间件,并没有next等手动调用下一个中间件的方式。这应该算是网络请求库的特定需求导致的。

由于Promise是不能被取消的(需要了解cancelable promises proposal,目前该提案已被取消),那么axios是如何实现取消请求的呢?

查看文档示例,可以使用下面两种方式取消

js
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.');

也可以使用下面方式

js
var CancelToken = axios.CancelToken;
var cancel;

axios.get('/user/12345', {
  cancelToken: new CancelToken(function executor(c) {
    // executor 函数接收一个 cancel 函数作为参数
    cancel = c;
  })
});

// 取消请求
cancel();

查看CancelToken源码

js
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;
  }
};

可见看见,如果调用了传入executorcancel方法,在后续的dispatchRequest中会判断是否存在reason来决定是否取消本次请求。

查看发送请求的源码dispatchRequest

js
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运行特点实现拦截器。

但这四种中间件实际上也存在某些相似点

  • 中间件实际上就是函数,多个中间件之间的执行顺序取决于具体的实现
  • 两个中间件之间存在某些关联,如获取返回值、主动调用下一个中间件等

我认为,中间件都是为了分隔业务逻辑,通过将不同的逻辑放在独立的中间件中,并组合中间件的方式,尽可能实现逻辑的复用,这种设计比单纯在业务代码中复用函数更为清晰明了。

中间件在面向对象中可以理解为装饰器,在函数式编程中可以理解为组合。通过本文的总结,对于常见的中间件实现有了比较清晰的了解。

你要请我喝一杯奶茶?

版权声明:自由转载-非商用-保持署名和原文链接。

本站文章均为本人原创,参考文章我都会在文中进行声明,也请您转载时附上署名。