• home > webfront > engineer > Architecture >

    从源码分析express/koa/redux/axios等中间件的实现方式

    Author:zhoulujun Date:

    在前端比较熟悉的框架如express、koa、redux和axios中,都提供了中间件或拦截器的功能。中间件机制其实是非框架强相关的,不同的框架可能适合不同的中间件机制,本文将从源码出发,分析这几个框架中对应中间件的实。

    本篇此篇的增强笔记: https://www.shymean.com/article/从源码分析几种中间件的实现方式


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

    参考


    1 express

    提到 express、koa、egg, 就不得不提到中间件,接下来就简单的介绍一下他们的中间件的简单应用与部分常用函数的实现。

    Express是一个最小且灵活的Web应用程序框架,为Web和移动应用程序提供了一组强大的功能,它的行为就像一个中间件(几乎是Node.js Web中间件的标准),可以帮助管理服务器和路由

    处理 Web 请求时,我们常常需要进行验证请求来源、检查登录状态、确定是否有足够权限、打印日志等操作,而这些重复的操作如果写在具体的路由处理函数中,明显会导致代码冗余,这个时候,我们就可以将这些通用的流程抽象为中间件函数,减少重复代码。

    web中间价作用示意图nodejs中间件作用示意图

    我们可以将 Web 请求想象为一条串联的管道,在管道中有多个关卡,请求数据由源头起,依次流过各关卡,每个关卡独立运作,既可以直接响应数据,又可以对请求稍作调整,并使之流向下一关卡,这个关卡,就是中间件。

    中间的核心思想其实就是任务处理机制:后一个任务需要等到前一个处理完再执行,而前一个任务的存在性又不确定

    使用方法:

    const express = require('express');
    const app = express();
    const port = 3000;
    const indexRouter = require('./routes/index');
    const usersRouter = require('./routes/users');
    
    app.use(express.json());
    app.use(express.urlencoded({  extended: false}));
    app.use(express.static(path.join(__dirname, 'public')));
    
    app.set('views', path.join(__dirname, 'views'));
    app.set('view engine', 'jade');
    
    app.get('/', (req, res) => res.send('Hello World!'));
    app.get('/user', (req, res) => res.json({ user: 'andy' }));
    app.get('/test', (req, res) => res.status(403).send('403'));
    
    app.listen(port, () => console.log(`Example app listening on port ${port}!`));

    从以上两段代码可以看出,express实例app主要有3个核心方法:

    1. app.use([path,] callback [, callback...])注册中间件,当所请求路径的基数匹配时,将执行中间件函数。

      • 单个中间件函数

      • 一系列中间件函数(以逗号分隔)

      • 中间件函数数组

      • 以上所有的组合

      • path:调用中间件功能的路径

      • callback:回调函数,可以是:

    2. app.get()app.post()use()方法类似,都是实现中间件的注册,只不过与http请求进行了绑定,只有使用了相应的http请求方法才会触发中间件注册

    3. app.listen()创建httpServer,传递server.listen()需要的参数

    基于以上express代码的功能分析,可以看出express的实现有三个关键点:

    • 中间件函数的注册

    • 中间件函数中核心的next机制

    • 路由处理,主要是路径匹配

    express.js架构图


    1.1 注册中间件

    通过app.use的方法注册中间件,从 app.use([path,] callback [, callback...]) 可以看出,中间件可以是函数数组,也可以是单个函数

    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数组保存了相关的中间件函数。

    可以看见,express的中间件实现思路是通过闭包维持了遍历中间件列表的游标,每次调用next方法时,会通过移动游标的方法找到下一个中间件并在handle_request中执行。

    因此,可以理解为express中间件是基于回调函数的,每个中间件执行的都是同一个next方法,但每次调用next都会按顺序执行中间件列表。

    1.2. 执行中间件

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


    next机制实现

    express的中间件函数参数是:req, res, next,next参数是一个函数,只有调用它才可以使中间件函数一个一个按照顺序执行下去,与ES6的Generator中的next()类似。 而回到我们的实现上,其实就是要实现一个next()函数,这个函数需要:

    1. 从中间件队列数组里每次按次序取出一个中间件

    2. 把next()函数传入到取出的中间件中。由于中间件数组是公用的,每次执行next(),都会从中间件数组中取出第一个中间件函数执行,从而实现了中间件按次序的效果

    // 核心的next机制
    handle(req, res, stack) {
      const next = () => {
    // 中间件队列出队,拿到第一个匹配的中间件,stack数组是同一个,所以每执行一次next(),都会取出下一个中间件
        const middleware = stack.shift();
        if (middleware) {
    // 执行中间件函数
          middleware(req, res, next);
        }
      }
    // 立马执行
      next();
    }


    错误处理中间件

    当你的 app 处于错误模式时,所有的常规中间件都会被跳过而直接执行 Express 错误处理中间件。想要进入错误模式,只需在调用 next 时附带一个参数。这是调用错误对象的一种惯例,例如:next(new Error("Something bad happened!"))

    express中间价正常执行流程.pngexpress中间价错误流程.png

    虽然 Express 没有做出强制规定,但是一般错误处理中间件都会放在中间件栈的最下面。这样所有之前的常规中间件发生错误时都会被该错误处理中间件所捕获。

    Express 的错误处理中间件只会捕获由 next 触发的错误,对于 throw 关键字触发的异常则不在处理范围内。对于这些异常 Express 有自己的保护机制,当请求失败时 app 会返回一个 500 错误并且整个服务依旧在持续运行。然而,对于语法错误这类异常将会直接导致服务奔溃。

    app.use(function(req, res, next) {
      res.sendFile(filePath, function(err) {
        if (err) {
          next(new Error("Error sending file!"));
        }
      });
    });
    app.use(function(err, req, res, next) {
        // 记录错误
        console.error(err);
        // 继续到下一个错误处理中间件
        next(err);
    });

    错误处理中间件不管所在位置如何它都只能通过带参 next 进行触发

    仿制Express

    下面看一位知乎道友的实现——Express中间件原理解析与实现 https://juejin.cn/post/6884592895911788552

    const http = require('http');
    const slice = Array.prototype.slice;
    
    class LikeExpress {
      constructor() {
        // 存放路径和中间件,all中存放的是调用use传入的路径和中间件
        this.routes = {
          all: [],
          get: [],
          post: [],
        };
      }
    
      // 分离出路径和中间件,info.path中存放路径,info.stack 中存放中间件
      register(path) {
        const info = {};
        if (typeof path === 'string') {
          info.path = path;
          // info.stack 存放所有的中间件
          // 如果第一个参数是路由在取中间件时就要从数组的第2个位置开始取
          // slice.call(arguments, 1) 的作用就是取arguments从第二个位置开始之后的所有元素都取出来并变成数组的形式。
          info.stack = slice.call(arguments, 1);
        } else {
          // 如果第一个参数不是一个路由,那么我们就假定第一个参数是一个根路由
          info.path = '/';
          info.stack = slice.call(arguments, 0);
        }
        return info;
      }
    
      use() {
        // 实际使用时,参数是通过use传递进来的
        // 将所有的参数传入到register函数中
        const info = this.register.apply(this, arguments);
        // info 是一个对象,info.path 中存放的是路径,info.stack 中存放的是中间件
        this.routes.all.push(info);
      }
    
      get() {
        const info = this.register.apply(this, arguments);
        this.routes.get.push(info);
      }
    
      post() {
        const info = this.register.apply(this, arguments);
        this.routes.post.push(info);
      }
    
      // 匹配use,get或post方法会执行的中间件
      match(method, url) {
        let stack = [];
        if (url === '/favicon.ico') {
          return stack;
        }
        // 获取routes
        let curRoutes = [];
        // concat 是数组中的一个方法,如果没有参数,那么会生成一个当前数组的副本并将其赋值给前面的变量,如果有参数会将参数加入到生成的副本的后面然后将其赋值给变量
        // 如果是use,那么就把use中的路径和中间列表复制到curRoutes中
        // 如果方法是get或post那么下面这句话,由于this.routes.all是undefined,所以会将当前curRoutes生成一个副本赋值给curRoutes,其实一点变化都没有
        curRoutes = curRoutes.concat(this.routes.all);
        // 如果是get或post,那么就把相应的路径和中间件复制到curRoutes中
        curRoutes = curRoutes.concat(this.routes[method]);
    
        curRoutes.forEach((routeInfo) => {
          // url='/api/get-cookie' routeInfo.path='/'
          // url='/api/get-cookie' routeInfo.path='/api'
          // url='api/get-cookie' routeInfo.path='/api/get-cookie'
          if (url.indexOf(routeInfo.path) === 0) {
            // 匹配成功
            stack = stack.concat(routeInfo.stack);
          }
        });
        return stack;
      }
    
      // 核心的 next 机制
      handle(req, res, stack) {
        const next = () => {
          // 拿到第一个匹配的中间件
          // shift 取得数组的第一项
          const middleware = stack.shift();
          if (middleware) {
            // 执行中间件函数
            middleware(req, res, next);
          }
        };
        // 定义完后立即执行
        next();
      }
    
    
      // callback是一个(req, res) => {} 的函数,结合http-test中的代码去理解
      callback() {
        return (req, res) => {
          // res.json 是一个函数,在express中使用时传入一个对象即可在屏幕中显示出来,这里实现了这个功能
          res.json = (data) => {
            res.setHeader('Content-type', 'application/json');
            res.end(JSON.stringify(data));
          };
    
          const url = req.url;
          const method = req.method.toLowerCase();
          // 找到需要执行的中间件(通过路径和method,有可能一个需要执行的中间件也没有)
          const resultList = this.match(method, url);
          this.handle(req, res, resultList);
        };
      }
    
      // express 中listen的作用不仅仅是监听端口,还要创建服务器
      listen(...args) {
        const server = http.createServer(this.callback());
        server.listen(...args);
      }
    }
    
    module.exports = () => new LikeExpress();

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

    2. Koa

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

    Koa 是一个新的 web 框架,由 Express幕后的原班人马打造,致力于成为web应用和API开发领域中的一个更小、更富有表现力、更健壮的基石。 通过利用 async 函数,Koa帮你丢弃回调函数,并有力地增强错误处理。Koa并没有捆绑任何中间件而是提供了一套优雅的方法,帮助您快速而愉快地编写服务端应用程序。

    使用案例:

    const Koa = require('koa');
    const app = new Koa();
    
    app.use(async ctx => {
      console.log('我是中间件1!')
      await next();
      console.error('第一个中间件执行结束')
    });
    app.use(async (ctx, next) => {
      console.log('我是中间件2!');
      ctx.response.status = 200;
      ctx.response.body = '<h2>response content</h2>';
      await next();
      console.error('第二个中间件执行结束')
    });
    
    app.listen(3000);

    koa是一个拥有洋葱模型中间件的http处理库,一个请求,经过一系列的中间件,最后生成响应。

    Koa的大致实现原理:context上下文的保存和传递,中间件的管理和next方法的实现

    koa中间件洋葱模型koa架构解读


    2.1. Application类

    koa 总共四个文件: application.js,context.js,request.js,response.js 加起来 2000 行代码左右。

    具体参看:https://chenshenhai.github.io/koa2-note/note/start/info.html

    • application.js 是整个koa2 的入口文件,封装了context,request,response,以及最核心的中间件处理流程。

    • context.js 处理应用上下文,里面直接封装部分request.js和response.js的方法

    • request.js 处理http请求

    • response.js 处理http响应

    package.jsonmain字段开始,找到整个库的入口文件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实例对象暴露了uselisten两个方法。此外由于继承了Emitter类,app实例也可以使用诸如onemit等事件通信方法。

    application.js 导出一个 Application 类,其它都是导出一个原型对象。我们从 koa 这个包导出的 Koa 类其实就是 Application 类,它抽象了服务器应用。context.js,request.js,response.js 这三个文件导出的都是原型对象,为了叙述方便,分别称导出的对象为 contextProtype, requestPrototype,responsePrototype。为什么叫原型对象。每因为当接受到一次请求时,我们在中间件访问的 ctx, ctx.request, ctx.response 都是通过 Object.create(contextProtype),Object.create(requestPrototype), Object.create(responsePrototype) 构造的,它们都是作为原型嘛。其它我就不展开说了,例如中间件是怎么实现的,也就是 koa compose 是怎么合并各个中间件成一个中间件函数以及会达到洋葱模型这种执行顺序的,有兴趣可以直接去看 koa-compose 的源码,代码 50 行不到,推荐阅读:https://github.com/niexq/koaComposeTest

    koa-compose源码

    function compose (middleware) {
      return function (context, next) {
        // last called middleware # 记录当前运行的middleware的下标
        let index = -1
        // 关键函数dispatch
        function dispatch (i) {
          // 验证给定的中间件方法中,不能多次next()
          if (i <= index) return Promise.reject(new Error('next() called multiple times'))
          // index向后移动
          index = i
          // 找出数组中存放的相应的中间件
          let fn = middleware[i]
          if (i === middleware.length) fn = next;
          // 最后一个中间件调用next 也不会报错
          if (!fn) return Promise.resolve()
          try {
            return Promise.resolve(fn(
                // 继续传context
                context, 
                // next方法,允许进入下一个中间件。
                dispatch.bind(null, i + 1)
             )
            );
          } catch (err) {
            return Promise.reject(err)
          }
        }
        // 开始运行第一个中间件
        return dispatch(0)
      }
    }

    compose方法中,递归调用dispatch函数,它将遍历整个middleware,然后将context和dispatch(i + 1)传给middleware中的方法, 这里的dispatch(i + 1)就是中间件方法的第二个入参next,通过next巧妙的把下一个中间件fn作为next的返回值。

    简单来说 dispatch(n)对应着第 n 个中间件的执行,而 dispatch(n)又拥有执行 dispatch(n + 1)的权力

    所以在真正运行的时候,中间件并不是在平级的运行,而是嵌套的高阶函数

    dispatch(0)包含着 dispatch(1),而 dispatch(1)又包含着 dispatch(2) 在这个模式下,我们很容易联想到try catch的机制,它可以 catch 住函数以及函数内部继续调用的函数的所有error。

    源码解读:https://blog.shenfq.com/posts/2018/koa2源码解析.html

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

    2.2. 中间件

    大概过程:我们koa常用的app.use方法就是将一系列中间件的方法存进了一个数组,app.listen底层用http.createServer(this.callback())进行封装,传进createServer的回调函数通过compose来处理中间件集合(就是递归遍历中间件数组的过程),通过req,res(这两个对象封装了node的原生http对象)创建上下文,并返回一个处理请求的函数(参数是上下文,中间件集合(类似一个链表))

    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); // 此处创建context
                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) {  // 关键函数dispatch
                // 验证给定的中间件方法中,不能多次next()
                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() // 必须在此处暂停等待下一个中间件执行完毕,
    }

    koa本身几乎不带任何其他的库,如果需要使用路由、错误处理、认证等功能需要自己安装并引入,什么都需要自己DIY。

    常用的中间价原理解析:《Koa2 中间件原理解析 —— 看了就会写 https://juejin.cn/post/6844903683373662222

    实际开发项目还需要使用社区的一些开源的中间件如 @koa/router 处理路由,koa-bodyparser 解析 json 请求体,@koa/multer 处理 multipart 的表单,@koa/cors 处理跨域,joi 处理参数校验,jsonwebtoken 来做 JWT 认证,以及自己封装一些中间件例如处理全局异常。然后还要用上很多开源工具比如 ORM 工具 sequelize,mongoose,测试工具 supertest, powerassert 等。所以一般用 koa 做开发前期肯定在配置各种中间件和工具库,每次都搞一遍太麻烦了

    而使用nest.js时就不需要考虑这些问题了,依赖注入,pipe,guard,interceptor等机制,基本覆盖各种开发需要,开箱即用。

    egg.js

    egg.js是在koa的基础上做了一层很好的面向大型企业级应用的框架封装,现在也有了非常好的TS特性支持。

     koa 是 egg 的底层框架,而且 egg 的核心开发者之一。

    egg.js更多的是按照洋葱模型的开发方式,和AOP编程还是有点区别的。

    Egg.js 是一个约定大于配置的框架

    Egg 奉行『约定优于配置』,按照一套统一的约定进行应用开发,团队内部采用这种方式可以减少开发人员的学习成本,开发人员不再是『钉子』,可以流动起来。

    nest 是一个真正意义上的 node 服务端框架。你在开发服务器需要的一切东西都给你准备好了,你只要照着它的风格使用对应的 module 就好了。

    依靠 TypeScript 的静态类型检查就是能在开发期间就避免很多低级错误,这是很重要的!但,

    目前用 TypeScript 重构 egg 没有任何好处——Typescript 白嫖一个装饰器特性不用来搞 javaer 天天吹牛逼的 IOC 简直浪费。

    依靠 TypeScript 的静态类型检查就是能在开发期间就避免很多低级错误,这是很重要的另外,由于装饰器是框架的一部分,所以框架本身管理项

    很多后端框架喜欢使用装饰器来搞 IOC,因为采用集中声明式的书写依赖明显比手动编写 controller 逻辑代码更不容易产生错误。另外,由于装饰器是框架的一部分,所以框架本身管理项目的依赖就变得更容易和全面。再则装饰器设计的好的确实可以让代码变得非常精简,提高编码的愉悦性


    Nest

    Nest 是一个用于构建高效,可扩展的 Node.js 服务器端应用程序的框架。它使用渐进式 JavaScript,内置并完全支持 TypeScript(但仍然允许开发人员使用纯 JavaScript 编写代码)并结合了 OOP(面向对象编程),FP(函数式编程)和 FRP(函数式响应编程)的元素

    在底层,Nest使用强大的 HTTP Server 框架,如 Express(默认)和 Fastify。Nest 在这些框架之上提供了一定程度的抽象,同时也将其 API 直接暴露给开发人员。这样可以轻松使用每个平台的无数第三方模块。

    NestJS 和 AngularJS,Spring 类似,都是基于控制反转(IoC = Inversion of Control)原则来设计的框架,并且都使用了依赖注入(DI = Dependency Injection)的方式来解决耦合的问题。

    nest 有些人说是 node 版的 spring boot,我觉得不对,应该说 node 版的 angular。

    3. Redux

    可以通过中间件来扩展redux,完成特性功能(中间件会在每次dispatch的时候执行),例如我想在dispatch(action)发起前打印出日志,发起后再打印出日志以供调试,中间件就是干这事的。具体可以查看:http://cn.redux.js.org/understanding/history-and-design/middleware

    Redux 引入中间件机制,其实就是为了在 dispatch 前后,统一“做爱做的事”。。。

    摘自https://github.com/kenberkeley/redux-simple-tutorial/blob/master/redux-advanced-tutorial.md

    下面是一个日志中间件的定义和使用

    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)才会执行下一个中间件

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

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


    3.1. 柯里化与组合

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

    // 普通实现
    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)))
    }


    想要理解 redux 中的中间件机制,需要先理解一个方法:compose

    function compose(...funcs: Function[]) {
      return funcs.reduce((a, b) => (...args: any) => a(b(...args)));
    }

    简单理解的话,就是compose(fn1, fn2, fn3) (...args) = > fn1(fn2(fn3(...args)))

    它是一种高阶聚合函数,相当于把 fn3 先执行,然后把结果传给 fn2 再执行,再把结果交给 fn1 去执行。


    以下面为例

    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,注意调用的顺序


    3.2. 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调用下一个中间件,否则会出现死循环。

    redux的简单实现

    实现这个 mini-redux

    function compose(...funcs) {
      return funcs.reduce((a, b) => (...args) => a(b(...args)));
    }
    
    function createStore(reducer, middlewares) {
      let currentState;
    
      function dispatch(action) {
        currentState = reducer(currentState, action);
      }
    
      function getState() {
        return currentState;
      }
      // 初始化一个随意的dispatch,要求外部在type匹配不到的时候返回初始状态
      // 在这个dispatch后 currentState就有值了。
      dispatch({ type: "INIT" });
    
      let enhancedDispatch = dispatch;
      // 如果第二个参数传入了middlewares
      if (middlewares) {
        // 用compose把middlewares包装成一个函数
        // 让dis
        enhancedDispatch = compose(...middlewares)(dispatch);
      }
    
      return {
        dispatch: enhancedDispatch,
        getState
      };
    }

    接着写两个中间件

    const otherDummyMiddleware = dispatch => {
      // 返回一个新的dispatch
      return action => {
        console.log(`type in dummy is ${type}`);
        return dispatch(action);
      };
    };
    
    // 这个dispatch其实是otherDummyMiddleware执行后返回otherDummyDispatch
    const typeLogMiddleware = dispatch => {
      // 返回一个新的dispatch
      return ({ type, ...args }) => {
        console.log(`type is ${type}`);
        return dispatch({ type, ...args });
      };
    };
    
    // 中间件从右往左执行。
    const counterStore = createStore(counterReducer, [
      typeLogMiddleware,
      otherDummyMiddleware
    ]);
    
    console.log(counterStore.getState().count);
    counterStore.dispatch({ type: "add", payload: 2 });
    console.log(counterStore.getState().count);
    
    // 输出:
    // 0
    // type is add
    // type in dummy is add
    // 2

    单向数据流-从共享状态管理:flux/redux/vuex漫谈异步数据处理 https://www.zhoulujun.cn/html/webfront/ECMAScript/vue/8440.html

    我们再来看一下vuex

    vuex

    vuex 提供了一个 api 用来在 action 被调用前后插入一些逻辑:https://vuex.vuejs.org/zh/api/#subscribeaction

    store.subscribeAction({
      before: (action, state) => {
        console.log(`before action ${action.type}`);
      },
      after: (action, state) => {
        console.log(`after action ${action.type}`);
      }
    });

    其实这有点像 AOP(面向切面编程)的编程思想。

    在调用store.dispatch({ type: 'add' })的时候,会在执行前后打印出日志

    仿制Vuex

    import {
      Actions,
      ActionSubscribers,
      ActionSubscriber,
      ActionArguments
    } from "./vuex.type";
    
    class Vuex {
      state = {};
    
      action = {};
    
      _actionSubscribers = [];
    
      constructor({ state, action }) {
        this.state = state;
        this.action = action;
        this._actionSubscribers = [];
      }
    
      dispatch(action) {
        // action前置监听器
        this._actionSubscribers.forEach(sub => sub.before(action, this.state));
    
        const { type, payload } = action;
    
        // 执行action
        this.action[type](this.state, payload).then(() => {
          // action后置监听器
          this._actionSubscribers.forEach(sub => sub.after(action, this.state));
        });
      }
    
      subscribeAction(subscriber) {
        // 把监听者推进数组
        this._actionSubscribers.push(subscriber);
      }
    }
    
    const store = new Vuex({
      state: {
        count: 0
      },
      action: {
        async add(state, payload) {
          state.count += payload;
        }
      }
    });
    
    store.subscribeAction({
      before: (action, state) => {
        console.log(`before action ${action.type}, before count is ${state.count}`);
      },
      after: (action, state) => {
        console.log(`after action ${action.type},  after count is ${state.count}`);
      }
    });
    
    store.dispatch({
      type: "add",
      payload: 2
    });

    当然 Vuex 在实现插件功能的时候,选择性的将 type payload 和 state 暴露给外部,而不再提供进一步的修改能力,这也是框架内部的一种权衡,当然我们可以对 state 进行直接修改,但是不可避免的会得到 Vuex 内部的警告,因为在 Vuex 中,所有 state 的修改都应该通过 mutations 来进行,但是 Vuex 没有选择把 commit 也暴露出来,这也约束了插件的能力。


    4. axios的拦截器

    首先来看看axios的构造函数

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

    axios 的拦截器机制用流程图来表示其实就是这样的:

    axios 的拦截器机制用流程图

    先简单看一下 axios 官方提供的拦截器示例:

    axios.interceptors.request.use(
      function(config) {
        // 在发送请求之前做些什么
        return config;
      },
      function(error) {
        // 对请求错误做些什么
        return Promise.reject(error);
      }
    );
    
    // 添加响应拦截器
    axios.interceptors.response.use(
      function(response) {
        // 对响应数据做点什么
        return response;
      },
      function(error) {
        // 对响应错误做点什么
        return Promise.reject(error);
      }
    );

    可以看出,不管是 request 还是 response 的拦截求,都会接受两个函数作为参数,一个是用来处理正常流程,一个是处理失败流程,这让人想到了什么?

    没错,promise.then接受的同样也是这两个参数。

    axios 内部正是利用了 promise 的这个机制,把 use 传入的两个函数作为一个intercetpor,每一个intercetpor都有resolved和rejected两个方法。

    axios.interceptors.response.use(func1, func2) { // 在内部存储为
        resolved: func1,
        rejected: func2
    }

    把axios.interceptor.request.use转为axios.useRequestInterceptor来简单实现

    // 先构造一个对象 存放拦截器
    axios.interceptors = {
      request: [],
      response: []
    };
    
    // 注册请求拦截器
    axios.useRequestInterceptor = (resolved, rejected) => {
      axios.interceptors.request.push({ resolved, rejected });
    };
    
    // 注册响应拦截器
    axios.useResponseInterceptor = (resolved, rejected) => {
      axios.interceptors.response.push({ resolved, rejected });
    };
    
    // 运行拦截器
    axios.run = config => {
      const chain = [
        {
          resolved: axios,
          rejected: undefined
        }
      ];
    
      // 把请求拦截器往数组头部推
      axios.interceptors.request.forEach(interceptor => {
        chain.unshift(interceptor);
      });
    
      // 把响应拦截器往数组尾部推
      axios.interceptors.response.forEach(interceptor => {
        chain.push(interceptor);
      });
    
      // 把config也包装成一个promise
      let promise = Promise.resolve(config);
    
      // 暴力while循环解忧愁
      // 利用promise.then的能力递归执行所有的拦截器
      while (chain.length) { // 核心 精髓
        const { resolved, rejected } = chain.shift();
        promise = promise.then(resolved, rejected);
      }
    
      // 最后暴露给用户的就是响应拦截器处理过后的promise
      return promise;
    };

    从axios.run这个函数看运行时的机制,首先构造一个chain作为 promise 链,并且把正常的请求也就是我们的请求参数 axios 也构造为一个拦截器的结构,接下来

    • 把 request 的 interceptor 给 unshift 到chain顶部

    • 把 response 的 interceptor 给 push 到chain尾部


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

    4.1. 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主要是用来保存拦截器,然后提供一些遍历或操作拦截器的方法而已。


    4.2. 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就实现了一个完整的拦截器系统。

    4.3. 取消请求

    可以看见,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;
      }
    };

    可见看见,如果调用了传入executorcancel方法,在后续的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);
        // ...
      })
    }




    5. 总结

    在上面分析了四种框架实现中间件的方式,每种实现方式都有一些差异

    • express通过闭包保存遍历中间件列表的游标,并在每一次手动调用next时移动游标的位置,通过函数的调用栈实现中间件

    • koa的中间件实现与express基本一致,通过闭包保存游标koa的特点在于每个next都会返回一个Promise对象,因此如果需要按正常顺序执行中间件,需要通过await的方式等待下一个中间件运行完毕

    • redux通过组合的方式实现中间件,每个中间件的返回值都是一个与原始store.dispatch方法签名相同的方法,通过遍历中间件,返回一个组合后的增强版dispatch方法

      • redux的中间件机制本质上就是用高阶函数不断的把 dispatch 包装再包装,形成套娃

    • vuex的实现最为简单,就是提供了两个回调函数,vuex 内部在合适的时机去调用(我个人感觉大部分的库提供这样的机制也足够了)。

    • axios的拦截器是一种比较特殊的中间件,由于每个中间件的执行依赖于上一个中间件的返回值,且可能是异步运行的,因此在每次触发请求时,都会遍历中间件构造一个Promise链,通过promise运行特点实现拦截器

      • axios 把用户注册的每个拦截器构造成一个 promise.then 所接受的参数,在运行时把所有的拦截器按照一个 promise 链的形式以此执行。

        • 在发送到服务端之前,config 已经是请求拦截器处理过后的结果

        • 服务器响应结果后,response 会经过响应拦截器,最后用户拿到的就是处理过后的结果

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

    • 中间件实际上就是函数,多个中间件之间的执行顺序取决于具体的实现

    • 两个中间件之间存在某些关联,如获取返回值、主动调用下一个中间件等

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

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


    参考文章:

    https://www.shymean.com/article/从源码分析几种中间件的实现方式

    https://www.zhihu.com/question/323525252/answer/937101214

    Express中间件原理解析与实现 https://juejin.cn/post/6884592895911788552

    前端开发之中间件模式 https://juejin.cn/post/6844903790731067406

    Koa的洋葱中间件,Redux的中间件,Axios的拦截器让你迷惑吗?实现一个精简版的就彻底搞懂了。 https://github.com/sl1673495/tiny-middlewares

    Express 实战(四):中间件 https://juejin.cn/post/6844903495846330382

    一幅图明白React-Redux的原理 https://juejin.cn/post/6844903589953929229

    Redux中间件实现机制 https://juejin.cn/post/6844903543711727624




    转载本站文章《从源码分析express/koa/redux/axios等中间件的实现方式》,
    请注明出处:https://www.zhoulujun.cn/html/webfront/engineer/Architecture/8681.html