60

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

 4 years ago
source link: https://blog.5udou.cn/blog/Zai-Ye-Bu-Pa-Mian-Shi-Guan-Wen-Ni-expressHe-koaDe-Qu-Bie-Liao-98?
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

用了那么多年的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,这几个实例之间的关系决定了中间件初始化后形成一个数据模型,画了下面一张图片来展示:

addWaterMarkBottom

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

addWaterMarkBottom

结合二者,我们来聊聊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()实现了这一切,这里做了一张流程图,希望对你理解这个有点用处:

addWaterMarkBottom

我们再把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的区别了~


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK