中间件的几种方式了解一下

820 阅读7分钟

引子

想给工具库的http请求方法加上abort方法,就去参考了一下axios的源码,顺便看了一下axios的拦截器实现,加上之前对redux,express,koa-compose的中间有一点了解,于是有了这篇 主动思考 我们根据使用过的中间件先思考一下,实现一些简单的中间件,再对比社区的一些中间件实现,进一步思考。

同步

const fns = [fn1, fn2, fn3];
for(var fn of fns){
    fn()
}

异步

假设如下代码:异步乘2

const double = (val) => {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve(val * 2);
      console.log(val * 2);
    }, 1000);
  });
};
const fn1 = double;
const fn2 = double;
const fn3 = double;
const fns = [fn1, fn2, fn3];

我们只要一直.then()下去就能保证顺序执行,目标代码如下:

fn1(2)
  .then((result) => {
    return fn2(result);
  })
  .then((result) => {
    return fn3(result);
  })
  .then((result) => {
    console.log(result);
    return result;
  });

为了方便使用,我们需要写一个生成器,能够生成如上的代码。也很简单

function compose(fns) {
  return (val) => {
    let p = Promise.resolve(val);
    // 循环调用.then()
    for (let fn of fns) {
      // 确保fn()返回promise
      p = p.then((res) => Promise.resolve(fn(res)));
    }
    return p;
  };
}
const c = compose(fns);

参考社区

Axios

// 使用中间件
InterceptorManager.prototype.use = function use(fulfilled, rejected, options) {
  this.handlers.push({
    fulfilled: fulfilled,
    rejected: rejected,
    synchronous: options ? options.synchronous : false,
    runWhen: options ? options.runWhen : null
  });
  return this.handlers.length - 1;
};

添加中间件

  1. fulfilled:成功回调
  2. rejected:失败回调
  3. synchronous:是否同步执行request中间件
  4. runWhen:运行时判断是否将添加该中间件
module.exports = function dispatchRequest(config) {
  var adapter = config.adapter || defaults.adapter;
  return adapter(config).then(function onAdapterResolution(response) {
    return response;
  }, function onAdapterRejection(reason) {
    return Promise.reject(reason);
  });
};
  1. dispatchRequest:实际的请求函数
  2. adapter:适配器,适配node和browser端的请求。
Axios.prototype.request = function request(config) {
  // filter out skipped interceptors
  var requestInterceptorChain = [];
  var synchronousRequestInterceptors = true;
  this.interceptors.request.forEach(function unshiftRequestInterceptors(interceptor) {
    if (typeof interceptor.runWhen === 'function' && interceptor.runWhen(config) === false) {
      return;
    }

    synchronousRequestInterceptors = synchronousRequestInterceptors && interceptor.synchronous;

    requestInterceptorChain.unshift(interceptor.fulfilled, interceptor.rejected);
  });

  var responseInterceptorChain = [];
  this.interceptors.response.forEach(function pushResponseInterceptors(interceptor) {
    responseInterceptorChain.push(interceptor.fulfilled, interceptor.rejected);
  });

  var promise;
  // 如果不是同步拦截器,request和response拦截器都是用promise.then调用
  if (!synchronousRequestInterceptors) {
    var chain = [dispatchRequest, undefined];

    Array.prototype.unshift.apply(chain, requestInterceptorChain);
    chain.concat(responseInterceptorChain);

    promise = Promise.resolve(config);
    while (chain.length) {
      promise = promise.then(chain.shift(), chain.shift());
    }

    return promise;
  }

  // 如果是同步拦截器,request先同步调用,再promise.then调用请求和响应拦截器
  var newConfig = config;
  // 构建prmise链的关键代码
  while (requestInterceptorChain.length) {
    var onFulfilled = requestInterceptorChain.shift();
    var onRejected = requestInterceptorChain.shift();
    try {
      newConfig = onFulfilled(newConfig);
    } catch (error) {
      onRejected(error);
      break;
    }
  }

  try {
    promise = dispatchRequest(newConfig);
  } catch (error) {
    return Promise.reject(error);
  }
  // 构建promise链的关键代码
  while (responseInterceptorChain.length) {
    promise = promise.then(responseInterceptorChain.shift(), responseInterceptorChain.shift());
  }

  return promise;
};

跟我们主动思考中的异步方法是一种思路,实现简单,容易理解:

  1. 定义request拦截器和response拦截器,手动插在dispatchRequest前后。
  2. 根据request拦截器是否有同步的
  3. 如果request拦截器都是异步的,通过while循环,构建一条promise.then(fulfilled, rejected).then(fulfilled, rejected)的链式调用。
  4. 如果request拦截器有一个是同步的,就会先通过while循环执行完request拦截器,拿到新的配置,再通过while循环构建一条promise.then(fulfilled, rejected).then(fulfilled, rejected)的链式调用。

Redux

compose源码如下

function compose(...funcs) {
  if (funcs.length === 0) {
    return (arg) => arg;
  }

  if (funcs.length === 1) {
    return funcs[0];
  }

  return funcs.reduce((a, b) => (...args) => a(b(...args)));
}

举个🌰:

// 中间件1
function add(next) {
  return (num) => {
    next(num + 1);
  };
}
// 中间件2
function multiple(next) {
  return (num) => {
    setTimeout(() => {
      next(num * 2);
    }, 2000);
  };
}
// 目标函数
function printf(num){
    console.log(num)
}

const compute = compose(add, multiple)(printf)
compute(1)

拆解一下:

const compute = (...args) => {
  return ((next) => {
    return (num) => {
      next(num + 1);
    };
  })(
    ((next) => {
      return (num) => {
        setTimeout(() => {
          next(num * 2);
        }, 2000);
      };
    })(...args)
  );
};

简化一下结构如下

(...args) =>
  (function m1(next) {
    // ...
  })(
    (function m2(next) {
      // ...
    })(
      (function printf(next) {
        // ...
      })(...args)
    )
  );

使用Array.prototype.reduce方法,将第下一个中间件作为上一个中间件的参数传进去,也就是将

  1. 组合:compose([add, multiple])(printf)转换成 add(mulitple(printf)),步骤拆解如下:
  2. printf作为mutiplenext参数,先执行mutiple(next)(num)=>{setTimeout(()=>{console.log(num*2)})}
  3. 将返回结果作为addnext参数,(num) => setTimeOut(()=>console.log((num+1)*2))
  4. add(next)最终返回一个函数compute
  5. 执行:最终调用compute()时按addmultipleprintf执行

简单来说,反向装载,正向执行。且具有特点

  1. 组织灵活:既可以添加一层包裹函数公用参数,也可以next传递参数
  2. 静态生成:compose过程即生成函数的过程

其实这玩意叫pipe:具体可以参考JavaScript中的pipe

为什么定义redux中间件一定要返回一个函数,而koa-compose,expres不用?

Express

express的中间件弯弯绕绕,app.handle -> router.handle -> (layer.handle_request -> (route.dispatch -> layer.handle_request)...)...

Layer是存储path和handle(一个或多个中间件)关系的一个对象

  1. 一个:指app.use的全局中间件,此时handle为自己定义的middleware函数
  2. 多个:指router.get()的情况,handle为route.dispatch,然后通过递归调用自己定义的middleware函数

核心是一个两层的递归,简化一下代码如下:(并不一样)

function express() {
  var funcs = []; // 待执行的函数数组

  var app = function (req, res) {
    var i = 0;

    function next() {
      var task = funcs[i++]; // 取出函数数组里的下一个函数
      if (!task) {
        // 如果函数不存在,return
        return;
      }
      task(req, res, next); // 否则,执行下一个函数,注意这里没有return
    }

    next();
  };

  /**
   * use方法就是把函数添加到函数数组中
   * @param task
   */
  app.use = function (task) {
    funcs.push(task);
  };

  return app; // 返回实例
}
  1. 数组存储:使用数组存储全部的中间件函数。
  2. 函数都是中间件:把app.getapp.userouter.get中的函数都当成了中间件。
  3. 执行器:通过调用next去执行下一个中间件。
    1. next:()=>task[0]()
    2. next:()=>task[1]()
    3. next:()=>task[2]() 第一次执行next,其实是执行task[0],接着在task[0]中执行next其实是执行task[1]...,一直到最后一个task

express和Redux有什么不同?

Koa-compose

这里用koa-compose做举例:

import compose from 'koa-compose';
async m1(ctx, next){
    console.log('first before');
    await next();
    console.log('first after');
}
async m1(ctx , next){
    console.log('second before');
    await next();
    console.log('second after');
}
const c = compose([m1, m2])
c(null, ()=>{
    console.log("done")
})
// 运行结果
/**
* first before
* second before
* done
* second after
* first after
* /

源码如下:

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!')
  }

  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
      // 终止条件,返回promise
      if (!fn) return Promise.resolve()
      try {
        // 确保fn()返回promise,并修改中间件的next参数
        return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
      } catch (err) {
        return Promise.reject(err)
      }
    }
  }
}

解析源代码:

  1. 终止返回:如果!fn,返回Promise.resolve()
  2. 递归返回:Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
  3. 洋葱心:i === middleware.lengthfn=next
  4. 通过不断赋值中间件的next函数(非闭包中的外部目标函数next),next=m1,next=m2,最终next=next实现正向调用,另外await后面的代码相当于在promise.then中调用。

把上面的🌰拆解一下,经过koa-compose转化后执行的代码如下:

Promise.resolve(m1(context, () => {
  return Promise.resolve(m2(ctx, () => {
    return Promise.resolve(next(ctx, () => {
      return Promise.resolve();
    }));
  }));
}));

有点抽象,将m1,m2,next展开简化如下(省略了传参):
Promise.resolve(
    (async () => {
      console.log('first before');
      await Promise.resolve(
        (async () => {
          console.log('second before');
          await Promise.resolve(
            (() => {
              console.log('done');
            })()
          )
          console.log('second after')
        })()
      )
      console.log('first after')
    })()
  )
  1. koa和express的实现思路是一样的,为什么express不是洋葱模型?
  2. 为什么一定要返回promise.resolve()?

解答疑问

  1. 为什么定义redux中间件一定要返回一个函数,而koa-compose,expres不用? 答:因为redux多了一个组合的过程,组合时需要将下一个中间件的执行结果作为上一个中间件的next参数也就是说中间件执行结果不是函数,传给上一个中间件就会报错:next is not a function

  2. express和Redux有什么不同? 答:next介入方式不同,可以理解为redux是静态生成,在compose(...middlewares)(doSth)时已经确定next是什么,因而确定了执行顺序,而express是执行动态确定next是什么

  3. koa和express有什么不同?为什么express不是洋葱模型? 答:因为expres调用task(res, req, next)时没有return,最终调用不是await promise,而是await undefind,所以没有形成洋葱模型。

  4. 为什么一定要返回promise.resolve()? 答:compose的结果一定要返回一个promise情况,防止只有请求中间件且为同步函数时报错。我们先去掉promise.resolve(),分别跑一下koa-compose、koa的测试用例:

  • koa-compose的测试用例

image.png 报错:next function 应该返回 promise

  • koa的测试用例
 it('should merge properties', () => {
    app1.use((ctx, next) => {
      assert.equal(ctx.msg, 'hello');
      ctx.status = 204;
    });

    return request(app1.listen())
      .get('/')
      .expect(204);
  });

image.png 报了handleRequest的错误,看一下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);
 }

从这里也可以看出koa洋葱模型的最后一步,在中间件执行完之后再处理响应。而express在最后一个中间件中就处理了响应。

总结

四种中间件的区别

  1. axios最简单,直接promise链式调用
  2. redux语法需要返回函数,写的时候不是很方便,但是思路特殊,利用reduce,静态生成,将下一个中间的执行结果作为上一个中间件的next参数。
  3. express和koa-compose思路相同,都是利用递归(执行器)动态执行,从第一个中间件执行到最后一个中间件,区别是koa-compose通过return一个promise实现洋葱模型。

中间件的实现,本质都是使用了这两个跟执行顺序有关的东西:

  1. 函数调用栈:通过next介入执行顺序
  2. promise.then:通过promise.then实现异步顺序调用或洋葱模型。