轻松理解koa2的中间件执行机制

1,007 阅读6分钟

1、koa是什么?

官方网站如是说:Koa 是一个新的 web 框架,由 Express 幕后的原班人马打造, 致力于成为 web 应用和 API 开发领域中的一个更小、更富有表现力、更健壮的基石。

截屏2021-11-22 下午5.34.12.png

Express

Express是第一代最流行的web框架,它对Node.js的http进行了封装,用起来如下:

var express = require('express');
var app = express();

app.get('/', function (req, res) {
    res.send('Hello World!');
});

app.listen(3000, function () {
    console.log('Example app listening on port 3000!');
});

问题: Express是基于ES5的语法,实现异步代码的方法是回调。如果异步嵌套层次过多,就会陷入可怕的回调地狱。

koa 1.0

随着新版Node.js开始支持ES6,该团队基于ES6的generator重新编写了下一代web框架koa1.0。使用generator实现异步,代码看起来像同步的

var koa = require('koa');
var app = koa();

app.use('/test', function *() {
    yield doReadFile1();
    var data = yield doReadFile2();
    this.body = data;
});

app.listen(3000);

koa2

ES7草案引入了新的关键字asyncawait,可以轻松地把一个function变为异步模式。该团队又与时俱进,基于当时还是草案的ES7开发了koa2。

Koa 依赖 node v7.6.0 或 ES2015及更高版本和 async 方法支持.

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

app.use(async (ctx, next) => {
  console.log('收到一个请求')
  await next()
  ctx.body = 'Hello World';
})

app.listen(3000);

到此时,我们明白koa2的由来,并且可以启动一个koa2的web服务(运行此命令 node app.js)。

2、koa2中间件机制

很多博文介绍koa2会引用以下这张洋葱图,这张图告诉我们,当服务接受到一个Request,会依此从外层往里执行,输出响应Response的过程是从内往外执行。对应图中的业务,执行顺序依次是: Request -> Registry Manager -> Status Code Redirect -> Error Handler -> Cache -> Session -> Routes -> Pylons App

响应输出的过程如下: Pylons App -> Routes -> Session -> Cache -> Error Handler -> Status Code Redirect -> Response

截屏2021-11-22 下午6.02.31.png

洋葱的每一层都可以理解为一个中间件层,执行相对应的业务逻辑,那么是如何实现的呢?下面进入本文的重点。

// app.js
const Koa = require('koa')
const app = new Koa()

// 中间件midw1
function midw1 (ctx, next) {
  console.log('from midw1 前')
  next()
  console.log('from midw1 后')
}
// 中间件midw2
function midw2 (ctx, next) {
  console.log('from midw2 前')
  next()
  console.log('from midw2 后')
}

function process (ctx) {
  console.log('from core process')
}
app.use(midw1)
app.use(midw2)
app.use(process)

app.listen('7000')

// 在终端运行node app.js
// 当浏览器发起一个http://localhost:7000/ 请求时,输出如下:
// from midw1 前
// from midw2 前
// from core process
// from midw2 后
// from midw1 后

通过上述这个简短的demo可知:1. koa中间件的执行严格按照use注册顺序,最先注册的,最先获取Request,但是最后执行完,比作洋葱的最外层。2. 当执行过程中遇到next,就开始执行里层的中间件。next将每一个中间件的执行分为两段,从当前执行上下文转移到里层。如下图:

截屏2021-11-23 上午11.21.35.png

3、compose 的 v1.0

本文的重点为通过next实现koa的洋葱圈执行顺序,涉及到封装ctx的省略。根据上文的分析,不难推出,通过app.use方法,将中间件函数依次注册到一个队列。代码如下:

function midw1 (next) {
  console.log('from midw1 前')
  next()
  console.log('from midw1 后')
}
// 中间件midw2
function midw2 (next) {
  console.log('from midw2 前')
  next()
  console.log('from midw2 后')
}

function process () {
  console.log('from core process')
}

class App {
  midware = []
  use(fn) {
    if (typeof fn === 'function') {
      this.midware.push(fn)
    } else {
      throw new Error('fn必须是函数')
    }
  }
}

const app = new App()

app.use(midw1)
app.use(midw2)
app.use(process)

console.log(app.midware)
// [ [Function: midw1], [Function: midw2], [Function: process] ]

那么如何将app.midware中的函数实现遇到next就跳去执行下一个函数,直到执行到最后一个函数,再依次往前执行剩余部分。所以想到的第一个实现方法就是将app.midware中第i+1个函数作为第i个函数的next参数传入,于是就有了以下第一个版本

function compose(midwareArr) {
  let i = 0
  let fn = midwareArr[i]
  let next = midwareArr[++i]
  fn(next)
}
compose(app.midware)
// from midw1 前
// from midw2 前
// TypeError: next is not a function 

4、compose 的 v2.0

第一个版本实现了从第1层到第2层,那么如何将第3层的中间件传到第2层呢?第n+1个中间件函数传到第n层呢?这里要上一个高阶函数,注意了

function compose(midwareArr) {
  let i = 0
  function dispatch(i) {
    let fn = midwareArr[i]
    if (!fn) {
      return
    }
    // next = function(){dispatch(i)}
    return fn(function(){
      dispatch(i+1)
    })
  }
  return dispatch(i)
}
compose(app.midware)
// from midw1 前
// from midw2 前
// from core process
// from midw2 后
// from midw1 后

再次运行demo,输出已经实现预期了。这里细细品......

5、compose 的 v3.0

走到这里,万里长征差不多就走了一大半了。还有一些细节需要完善。如果同一个中间件函数里有多个next呢?测试代码如下。

function midw1 (next) {
  console.log('from midw1 前')
  next()
  console.log('from midw1 后')
  next()
  console.log('from midw1 第二个next后')
}
// 中间件midw2
function midw2 (next) {
  console.log('from midw2 前')
  next()
  console.log('from midw2 后')
}

function process () {
  console.log('from core process')
}

class App {
  midware = []
  use(fn) {
    if (typeof fn === 'function') {
      this.midware.push(fn)
    } else {
      throw new Error('fn必须是函数')
    }
  }
}

const app = new App()
app.use(midw1)
app.use(midw2)
app.use(process)

function compose(midwareArr) {
  let i = 0
  function dispatch(i) {
    let fn = midwareArr[i]
    if (!fn) {
      return
    }
    return fn(function(){
      dispatch(i + 1)
    })
  }
  return dispatch(i)
}
compose(app.midware)

输出如下,midw2、process 中间件执行了两次。第二次遇到next,又执行了一次function({dispatch(i)}

from midw1 前
from midw2 前
from core process
from midw2 后
from midw1 后
from midw2 前
from core process
from midw2 后

所以这里我们需要记录一下当前中间件已经执行过一次next了,再次执行要报错。改进代码如下:

function compose(midwareArr) {
  let i = 0
  let flag = []
  function dispatch(i) {
    let fn = midwareArr[i]
    if (!fn) {
      return
    }
    if (flag[i]){
      throw new Error('next 只能用一次')
    }
    flag[i] = true
    return fn(function(){
      dispatch(i + 1)
    })
  }
  return dispatch(i)
}

这个时候再测试一下同一中间件出现多个next的用例,OK了。再坚持一下,马上结束了哈哈。

6、终极版

next返回的是Promise实例,终极版出炉

function compose(midwareArr) {
  let i = 0
  let flag = []
  function dispatch(i) {
    let fn = midwareArr[i]
    if (!fn) {
      return Promise.resolve()
    }
    if (flag[i]){
      return Promise.reject(new Error('next 只能用一次'))
    }
    flag[i] = true
    return Promise.resolve(fn(function(){
      return dispatch(i + 1)
    }))
  }
  return dispatch(i)
}
compose(app.midware)

我们再用以下代码测试一次,到此为止,已经模拟实现了koa2的中间件执行方法compose

async function a (next) {
  console.log('a')
  let res = await next()
  console.log('aa')
  console.log(res)
}

function b(next) {
  console.log('b')
  next()
  console.log('bb')
  return new Promise(function(resolve, reject) {
    setTimeout(() => {
      resolve('jjjjjj')
    }, 3000);
  })
}
function c (next) {
  console.log('中心')
  next()
}

class App {
  midware = []
  use(fn) {
    if (typeof fn === 'function') {
      this.midware.push(fn)
    } else {
      throw new Error('fn必须是函数')
    }
  }
}

const app = new App()

app.use(a)
app.use(b)
app.use(c)

7、一起看看源码吧

下面是koa中compose函数实现的源码,源码是通过index记录next是否执行,i表示的是当前中间件在midware中的索引。

function compose(midware) {
  let index = -1
  function dispatch(i) {
    if (i <= index) {
      return Promise.reject(new Error('next() called multiple times'))
    }
    index = i
    let fn = midware[i]
    if (!fn) {
      return Promise.resolve()
    }
    return Promise.resolve(fn(function () {
      return dispatch(i + 1)
    }))
  }
  return dispatch(0)
}

compose(midware)

8、总结

今天我又博学了,hahaha!收到一个陌生人的点赞,开心了半天。我会继续努力的。如有错误,欢迎指正。

参考

  1. koa.bootcss.com/#applicatio…
  2. 廖雪峰的官方网站