【koa 源码阅读(一)】手把手教你搞懂 koa 中间件(洋葱模型)原理

这是我参与11月更文挑战的第25天,活动详情查看:2021最后一次更文挑战

我是小十七_,今天和大家一起阅读 koa 中间件源码,通俗易懂,包教包会~

Koa 的中间件不同于 Express,Koa 使用洋葱模型原理。它的源码只包含四个文件,对于初读源码的同学非常友好,今天我们只看主文件 - application.js,它已经包含了中间件是如何工作的核心逻辑。

image.png

前置准备

首先 clone koa 源码

git clone [email protected]:koajs/koa.git
npm install
复制代码

然后我们在项目的根目录添加一个 index.js 用于测试

// index.js
// 包括 koa 的入口文件
const Koa = require('./lib/application.js');
const app = new Koa();
const debug = require('debug')('koa');
app.use(async (ctx, next) => {
  console.log(1);
  await next();
  console.log(6);
  const rt = ctx.response.get('X-Response-Time');
  console.log(`${ctx.method} ${ctx.url} - ${rt}`);
});
// 时间日志记录
app.use(async (ctx, next) => {
  console.log(2);
  const start = Date.now();
  await next();
  console.log(5);
  const ms = Date.now() - start;
  ctx.set('X-Response-Time', `${ms}ms`);
});
app.use(async (ctx, next) => {
  console.log(3);
  ctx.body = 'Hello World';
  await next();
  console.log(4);
});

app.listen(3000);
复制代码

运行下面的命令来启动服务器:

node index.js
复制代码

接着访问 http://localhost:3000,你会看到 1, 2, 3, 4, 5, 6 输出。这称为洋葱模型(中间件)

洋葱模型的工作原理

让我们一起阅读 koa 的核心代码,看看中间件是如何工作的。在 index.js 中,我们这样使用中间件:

const app = new Koa();
app.use(// middleware);
app.use(// middleware);
app.listen(3000);
复制代码

让我们来看看 application.js,它在源代码的 lib 目录下,这里是与中间件相关的代码,我把代码进行了简化,保留了中间件的核心逻辑,并在代码中添加了一些注释。

module.exports = class Application extends Emitter {
  
  constructor() {
    super();
    this.proxy = false;
    // Step 0:初始化 middleware 中间件数组
    this.middleware = [];
  }

  use(fn) {
    // Step 1: 向数组里 push 中间件
    this.middleware.push(fn);
    return this;
  }

  listen(...args) {
    debug('listen');
    // Step 2: 调用 this.callback() 去组合所有的中间件
    const server = http.createServer(this.callback());
    return server.listen(...args);
  }

  callback() {
    // Step 3: 最重要的部分 - compose 函数, 它把所有
    // 中间件组合成一个大的函数,函数返回一个 promise,我们后面会继续讨论这个函数
    const fn = compose(this.middleware);
    if (!this.listenerCount('error')) this.on('error', this.onerror);
    const handleRequest = (req, res) => {
      const ctx = this.createContext(req, res);
      return this.handleRequest(ctx, fn);
    };

    return handleRequest;
  }

  handleRequest(ctx, fnMiddleware) {
    const res = ctx.res;
    res.statusCode = 404;
    const onerror = err => ctx.onerror(err);
    const handleResponse = () => respond(ctx);
    onFinished(res, onerror);
    // Step 4: Resolve 这个 promise
    return fnMiddleware(ctx).then(handleResponse).catch(onerror);
  }
}
复制代码

我们把代码简化为只有关于 compose 函数的部分的伪代码:

 listen(...args) {
    const server = http.createServer(this.callback());
  }
  callback() {
    // compose 函数
    const fn = compose(this.middleware);
    return this.handleRequest(ctx, fn);
  }
  handleRequest(ctx, fnMiddleware) {
    return fnMiddleware(ctx).then(handleResponse).catch(onerror);
  }
复制代码

从上面的代码可以猜测到:compose 函数,执行后返回了一个函数(这里叫 fn),fn 函数执行后,返回的是一个 promise。

关于 compose 函数

更多关于 compose 函数的信息,我们可以看一下 koa-compose 包的源码

module.exports = compose
function compose (middleware) {
  // 这里跳过类型检测的代码
  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)
      }
    }
  }
}
复制代码

和上面猜测的一样,我们简称 compose 返回的函数叫 fn,所有中间件都被 compose 传递到 了 fn 函数中,它返回了 dispatch(0),也就是立即执行了 dispatch 函数并返回了一个 promise。在了解 dispatch 函数的内容之前,我们先要了解 promise 的语法。

关于 Promise

通常我们会像这样使用 promise:

const promise = new Promise(function(resolve, reject) {
    if (success){
        resolve(value);
    } else {
        reject(error);
    }
});
复制代码

在 Koa 中,它是这样使用的:

let testPromise = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve('test success');
  }, 1000);
});
Promise.resolve(testPromise).then(function (value) {
  console.log(value); // "test success"
});
复制代码

因此,我们知道在 compose 函数中,它返回一个 promise

回到 Koa - compose 中间件

module.exports = compose
function compose (middleware) {
  // 这里跳过类型检测的代码
  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)
      }
    }
  }
}
复制代码

dispatch 是一个递归函数,它将循环所有中间件。其中我们简化一下递归的部分:

let fn = middleware[i]
fn(context, dispatch.bind(null, i + 1))
复制代码

这里 fn 是当前的中间件函数,执行了 fn,参数分别是 contextdispatch.bind(null, i + 1)(也就是我们传给中间价的 next),中间件执行这个函数,也就递归执行了 dispatch 函数,具体看下面的分析:

在我们的测试文件 index.js 中,我们有 3 个中间件,所有 3 个中间件都会在 await next() 之前执行这些代码;

app.use(async (ctx, next) => {
    console.log(2);
    const start = Date.now();
    await next(); // <- 停在这儿并且等待下一个中间件执行
    console.log(5);
    const ms = Date.now() - start;
    ctx.set('X-Response-Time', `${ms}ms`);
});
复制代码

我们可以看一下 index.js 中这三个中间件的执行顺序:

  • 执行 dispatch(0) 时,会执行 Promise.resolve(fn(context, dispatch.bind(null, 0 + 1)))
  • 第一个中间件内容将运行直到 await next()
  • next() = dispatch.bind(null, 0 + 1),这是第二个中间件
  • 第二个中间件将运行直到 await next()
  • next() = dispatch.bind(null, 1 + 1),这是第三个中间件
  • 第三个中间件将一直运行到 await next()
  • next() = dispatch.bind(null, 2 + 1),没有第四个中间件,会立即通过 if (!fn) return Promise.resolve() 返回,第三个中间件中的 await next() 被解析,剩余执行第三个中间件中的代码。
  • 第二个中间件中的 await next() 被解析,第二个中间件中的剩余代码被执行。
  • 第一个中间件中的 await next() 被解析,第一个中间件中的剩余代码被执行。

为什么使用洋葱模型?

如果我们在中间件中有 async/await,编码会更简单。当我们想为 api 请求编写一个时间记录器时,通过添加这个中间件可以非常容易:

app.use(async (ctx, next) => {
    const start = Date.now();
    await next(); // 你的 API 逻辑
    const ms = Date.now() - start;
    console.log('API response time:' + ms);
});
复制代码

Guess you like

Origin juejin.im/post/7034737459606847495