美文网首页豆米的前端博客
再也不怕面试官问你express和koa的区别了

再也不怕面试官问你express和koa的区别了

作者: 小兀666 | 来源:发表于2019-10-17 09:07 被阅读0次

    前言

    用了那么多年的express.js,终于有时间来深入学习express,然后顺便再和koa2的实现方式对比一下。

    老实说,还没看express.js源码之前,一直觉得express.js还是很不错的,无论从api设计,还是使用上都是可以的。但是这次阅读完express代码之后,我可能改变想法了。

    虽然express.js有着精妙的中间件设计,但是以当前js标准来说,这种精妙的设计在现在可以说是太复杂。里面的层层回调和递归,不花一定的时间还真的很难读懂。而koa2的代码呢?简直可以用四个字评论:精简彪悍!仅仅几个文件,用上最新的js标准,就很好实现了中间件,代码读起来一目了然。

    老规矩,读懂这篇文章,我们依然有一个简单的demo来演示: express-vs-koa

    1、express用法和koa用法简单展示

    如果你使用express.js启动一个简单的服务器,那么基本写法应该是这样:

    const express = require('express')
    
    const app = express()
    const router = express.Router()
    
    app.use(async (req, res, next) => {
      console.log('I am the first middleware')
      next()
      console.log('first middleware end calling')
    })
    app.use((req, res, next) => {
      console.log('I am the second middleware')
      next()
      console.log('second middleware end calling')
    })
    
    router.get('/api/test1', async(req, res, next) => {
      console.log('I am the router middleware => /api/test1')
      res.status(200).send('hello')
    })
    
    router.get('/api/testerror', (req, res, next) => {
      console.log('I am the router middleware => /api/testerror')
      throw new Error('I am error.')
    })
    
    app.use('/', router)
    
    app.use(async(err, req, res, next) => {
      if (err) {
        console.log('last middleware catch error', err)
        res.status(500).send('server Error')
        return
      }
      console.log('I am the last middleware')
      next()
      console.log('last middleware end calling')
    })
    
    app.listen(3000)
    console.log('server listening at port 3000')
    

    换算成等价的koa2,那么用法是这样的:

    const koa = require('koa')
    const Router = require('koa-router')
    
    const app = new koa()
    const router = Router()
    
    app.use(async(ctx, next) => {
      console.log('I am the first middleware')
      await next()
      console.log('first middleware end calling')
    })
    
    app.use(async (ctx, next) => {
      console.log('I am the second middleware')
      await next()
      console.log('second middleware end calling')
    })
    
    router.get('/api/test1', async(ctx, next) => {
      console.log('I am the router middleware => /api/test1')
      ctx.body = 'hello'
    })
    
    router.get('/api/testerror', async(ctx, next) => {
      throw new Error('I am error.')
    })
    
    app.use(router.routes())
    
    app.listen(3000)
    console.log('server listening at port 3000')
    
    

    如果你还感兴趣原生nodejs启动服务器是怎么使用的,可以参考demo中的这个文件:node.js

    于是二者的使用区别通过表格展示如下:

    koa(Router = require('koa-router')) express(假设不使用app.get之类的方法)
    初始化 const app = new koa() const app = express()
    实例化路由 const router = Router() const router = express.Router()
    app级别的中间件 app.use app.use
    路由级别的中间件 router.get router.get
    路由中间件挂载 app.use(router.routes()) app.use('/', router)
    监听端口 app.listen(3000) app.listen(3000)

    上表展示了二者的使用区别,从初始化就看出koa语法都是用的新标准。在挂载路由中间件上也有一定的差异性,这是因为二者内部实现机制的不同。其他都是大同小异的了。

    那么接下去,我们的重点便是放在二者的中间件的实现上。

    2、express.js中间件实现原理

    我们先来看一个demo,展示了express.js的中间件在处理某些问题上的弱势。demo代码如下:

    const express = require('express')
    
    const app = express()
    
    const sleep = (mseconds) => new Promise((resolve) => setTimeout(() => {
      console.log('sleep timeout...')
      resolve()
    }, mseconds))
    
    app.use(async (req, res, next) => {
      console.log('I am the first middleware')
      const startTime = Date.now()
      console.log(`================ start ${req.method} ${req.url}`, { query: req.query, body: req.body });
      next()
      const cost = Date.now() - startTime
      console.log(`================ end ${req.method} ${req.url} ${res.statusCode} - ${cost} ms`)
    })
    app.use((req, res, next) => {
      console.log('I am the second middleware')
      next()
      console.log('second middleware end calling')
    })
    
    app.get('/api/test1', async(req, res, next) => {
      console.log('I am the router middleware => /api/test1')
      await sleep(2000)
      res.status(200).send('hello')
    })
    
    app.use(async(err, req, res, next) => {
      if (err) {
        console.log('last middleware catch error', err)
        res.status(500).send('server Error')
        return
      }
      console.log('I am the last middleware')
      await sleep(2000)
      next()
      console.log('last middleware end calling')
    })
    
    app.listen(3000)
    console.log('server listening at port 3000')
    
    

    该demo中当请求/api/test1的时候打印结果是什么呢?

    I am the first middleware
    ================ start GET /api/test1
    I am the second middleware
    I am the router middleware => /api/test1
    second middleware end calling
    ================ end GET /api/test1 200 - 3 ms
    sleep timeout...
    

    如果你清楚这个打印结果的原因,想必对express.js的中间件实现有一定的了解。

    我们先看看第一节demo的打印结果是:

    I am the first middleware
    I am the second middleware
    I am the router middleware => /api/test1
    second middleware end calling
    first middleware end calling
    

    这个打印符合大家的期望,但是为什么刚才的demo打印的结果就不符合期望了呢?二者唯一的区别就是第二个demo加了异步处理。有了异步处理,整个过程就乱掉了。因为我们期望的执行流程是这样的:

    I am the first middleware
    ================ start GET /api/test1
    I am the second middleware
    I am the router middleware => /api/test1
    sleep timeout...
    second middleware end calling
    ================ end GET /api/test1 200 - 3 ms
    

    那么是什么导致这样的结果呢?我们在接下去的分析中可以得到答案。

    2.1、express挂载中间件的方式

    要理解其实现,我们得先知道express.js到底有多少种方式可以挂载中间件进去?熟悉express.js的童鞋知道吗?知道的童鞋可以心里默默列举一下。

    目前可以挂载中间件进去的有:(HTTP Method指代那些http请求方法,诸如Get/Post/Put等等)

    • app.use
    • app.[HTTP Method]
    • app.all
    • app.param
    • router.all
    • router.use
    • router.param
    • router.[HTTP Method]

    2.2、express中间件初始化

    express代码中依赖于几个变量(实例):app、router、layer、route,这几个实例之间的关系决定了中间件初始化后形成一个数据模型,画了下面一张图片来展示:

    image

    图中存在两块Layer实例,挂载的地方也不一样,以express.js为例子,我们通过调试找到更加形象的例子:

    image

    结合二者,我们来聊聊express中间件初始化。为了方便,我们把上图1叫做初始化模型图,上图2叫做初始化实例图

    看上面两张图,我们抛出下面几个问题,搞懂问题便是搞懂了初始化。

    • 初始化模型图Layer实例为什么分两种?
    • 初始化模型图Layer实例中route字段什么时候会存在?
    • 初始化实例图中挂载的中间件为什么有7个?
    • 初始化实例图中圈2和圈3的route字段不一样,而且name也不一样,为什么?
    • 初始化实例图中的圈4里也有Layer实例,这个时候的Layer实例和上面的Layer实例不一样吗?

    首先我们先输出这样的一个概念:Layer实例是path和handle互相映射的实体,每一个Layer便是一个中间件。

    这样的话,我们的中间件中就有可能嵌套中间件,那么对待这种情形,express就在Layer中做手脚。我们分两种情况挂载中间件:

    1. 使用app.userouter.use来挂载的
      • app.use经过一系列处理之后最终也是调用router.use
    2. 使用app.allapp.[Http Method]app.routerouter.allrouter.[Http Method]router.route来挂载的
      • app.allapp.[Http Method]app.routerouter.allrouter.[Http Method]经过一系列处理之后最终也是调用router.route

    因此我们把焦点聚焦在router.userouter.route这两个方法。

    2.2.1、router.use

    该方法的最核心一段代码是:

    for (var i = 0; i < callbacks.length; i++) {
      var fn = callbacks[i];
    
      if (typeof fn !== 'function') {
        throw new TypeError('Router.use() requires a middleware function but got a ' + gettype(fn))
      }
    
      // add the middleware
      debug('use %o %s', path, fn.name || '<anonymous>')
    
      var layer = new Layer(path, {
        sensitive: this.caseSensitive,
        strict: false,
        end: false
      }, fn);
    
      // 注意这个route字段设置为undefined
      layer.route = undefined;
    
      this.stack.push(layer);
    }
    

    此时生成的Layer实例对应的便是初始化模型图1指示的多个Layer实例,此时以express.js为例子,我们看初始化实例图圈1的所有Layer实例,会发现除了我们自定义的中间件(共5个),还有两个系统自带的,看初始化实例图的Layer的名字分别是:queryexpressInit。二者的初始化是在[application.js]中的lazyrouter方法:

    app.lazyrouter = function lazyrouter() {
      if (!this._router) {
        this._router = new Router({
          caseSensitive: this.enabled('case sensitive routing'),
          strict: this.enabled('strict routing')
        });
    
        this._router.use(query(this.get('query parser fn'))); // 最终调用的就是router.use方法
        this._router.use(middleware.init(this)); // 最终调用的就是router.use方法
      }
    };
    

    于是回答了我们刚才的第三个问题。7个中间件,2个系统自带、3个APP级别的中间、2个路由级别的中间件

    2.2.2、router.route

    我们说过app.allapp.[Http Method]app.routerouter.allrouter.[Http Method]经过一系列处理之后最终也是调用router.route的,所以我们在demo中的express.js,使用了两次app.get,其最后调用了router.route,我们看该方法核心实现:

    proto.route = function route(path) {
      var route = new Route(path);
    
      var layer = new Layer(path, {
        sensitive: this.caseSensitive,
        strict: this.strict,
        end: true
      }, route.dispatch.bind(route));
    
      layer.route = route;
    
      this.stack.push(layer);
      return route;
    };
    

    这么简单的实现,与上一个方法的实现唯一的区别就是多了new Route这个。通过二者对比,我们可以回答上面的好几个问题:

    • 初始化模型图Layer实例为什么分两种? 因为调用方式的不同决定了Layer实例的不同,第二种Layer实例是挂载在route实例之下的。
    • 初始化模型图Layer实例中route字段什么时候会存在?使用router.route的时候就会存在
    • 初始化实例图中圈2和圈3的route字段不一样,而且name也不一样,为什么?圈2的Layer因为我们使用箭头函数,不存在函数名,所以name是anonymous,但是圈3因为使用的router.route,所以其统一的回调函数都是route.dispath,因此其函数名字都统一是bound dispatch,同时二者的route字段是否赋值也一目了然

    最后一个问题,既然实例化route之后,route有了自己的Layer,那么它的初始化又是在哪里的?初始化核心代码:

    // router/route.js/Route.prototype[method]
    for (var i = 0; i < handles.length; i++) {
        var handle = handles[i];
    
        if (typeof handle !== 'function') {
          var type = toString.call(handle);
          var msg = 'Route.' + method + '() requires a callback function but got a ' + type
          throw new Error(msg);
        }
    
        debug('%s %o', method, this.path)
    
        var layer = Layer('/', {}, handle);
        layer.method = method;
    
        this.methods[method] = true;
        this.stack.push(layer);
      }
    

    可以看到新建的route实例,维护的是一个path,对应多个method的handle的映射。每一个method对应的handle都是一个layer,path统一为/。这样就轻松回答了最后一个问题了。

    至此,再回去看初始化模型图,相信大家可以有所明白了吧~

    2.3、express中间件的执行逻辑

    整个中间件的执行逻辑无论是外层Layer,还是route实例的Layer,都是采用递归调用形式,一个非常重要的函数next()实现了这一切,这里做了一张流程图,希望对你理解这个有点用处:

    image

    我们再把express.js的代码使用另外一种形式实现,这样你就可以完全搞懂整个流程了。

    为了简化,我们把系统挂载的两个默认中间件去掉,把路由中间件去掉一个,最终的效果是:

    ((req, res) => {
      console.log('I am the first middleware');
      ((req, res) => {
        console.log('I am the second middleware');
        (async(req, res) => {
          console.log('I am the router middleware => /api/test1');
          await sleep(2000)
          res.status(200).send('hello')
        })(req, res)
        console.log('second middleware end calling');
      })(req, res)
      console.log('first middleware end calling')
    })(req, res)
    

    因为没有对await或者promise的任何处理,所以当中间件存在异步函数的时候,因为整个next的设计原因,并不会等待这个异步函数resolve,于是我们就看到了sleep函数的打印被放在了最后面,并且第一个中间件想要记录的请求时间也变得不再准确了~

    但是有一点需要申明的是虽然打印变得奇怪,但是绝对不会影响整个请求,因为response是在我们await之后,所以请求是否结束还是取决于我们是否调用了res.send这类函数

    至此,希望整个express中间件的执行流程你可以熟悉一二,更多细节建议看看源码,这种精妙的设计确实不是这篇文章能够说清楚的。本文只是想你在面试的过程中可以做到有话要说~

    接下去,我们分析牛逼的Koa2,这个就不需要费那么大篇幅去讲,因为实在是太太容易理解了。

    3、koa2中间件

    koa2中间件的主处理逻辑放在了koa-compose,也就是仅仅一个函数的事情:

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

    每个中间件调用的next()其实就是这个:

    dispatch.bind(null, i + 1)
    

    还是利用闭包和递归的性质,一个个执行,并且每次执行都是返回promise,所以最后得到的打印结果也是如我们所愿。那么路由的中间件是否调用就不是koa2管的,这个工作就交给了koa-router,这样koa2才可以保持精简彪悍的风格。

    再贴出koa中间件的执行流程吧:

    middleware

    最后

    有了这篇文章,相信你再也不怕面试官问你express和koa的区别了~

    参考

    1. koa
    2. express
    3. http

    相关文章

      网友评论

        本文标题:再也不怕面试官问你express和koa的区别了

        本文链接:https://www.haomeiwen.com/subject/tjxmmctx.html