【若川视野 x 源码共读】第5期 | koa-compose

1,045 阅读4分钟

本文参加了由公众号@若川视野 发起的每周源码共读活动,点击了解详情一起参与。

学习目标

  • 了解koa的洋葱模型
  • 了解 koa-compose 作用
  • koa的实现原理
  • nodejs调试技巧

源码地址

github.com/koajs/koa

线上阅读

本文调试仓库地址

调试方案

首先,将项目clone到本地

git clone git@github.com:baosisi07/koa-analysis.git

1. VS Code本地调试

VS Code内置了debug调试配置,可在本地新增launch.json文件,选择不同的调试配置。具体步骤如下:

如果项目目录下没有launch.json时:

image.png 按图中步骤操作后,选择node.js

image.png

然后,目录下会多出文件 .vscode/launch.json。内容大致如下,调试的文件为koa/examples/middleware/app.js

{
    // Use IntelliSense to learn about possible attributes.
    // Hover to view descriptions of existing attributes.
    // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
    "version": "0.2.0",
    "configurations": [
        {
            "name": "Launch Program",
            "program": "${workspaceFolder}/examples/middleware/app.js",
            "request": "launch",
            "skipFiles": [
                "<node_internals>/**"
            ],
            "type": "node"
        }
    ]
}

此时,快捷键Command+Shift+P调出操作命令列表,搜索选择如下所示:

image.png 然后选择json中配置好的debug名称:

image.png

如果你本地的node管理用的是nvm,那么会报如下错误:

image.png

这是因为没有指定node的版本,debug无法运行,添加如下配置即可,按实际安装使用的版本配置runtimeVersion,我本地用的是16.14.0

{
 "name": "Launch Program",
 ...
 "runtimeVersion": "16"
 }

配置完成就可以愉快地调试了✌️!

添加断点——> 运行debug并调试

image.png

2. chrome调试

  1. 首先打开chrome浏览器,地址栏输入chrome://inspect,然后添加监听配置,如下图所示:

image.png

  1. 项目下输入命令 node --inspect examples/middleware/app.js
  2. 回到浏览器,打开inspect,并打上断点调试

image.png

image.png

koa洋葱模型

这是洋葱模型最经典的图

image.png

可以简单地将 next() 之前的任意代码视为“捕获”阶段,中间件以一个类似于栈的结构组成并依次执行。下图为实际执行中间件的顺序,可以体会到上图的含义 iShot2022-06-08_15.28.12.gif

可以从下图清晰地了解到koa中间件的执行流程:

image.png 我们从中间件一的 beforeNext 任务开始执行,然后按照紫色箭头的执行步骤完成中间件的任务调度。要完成任务调度,我们就需要不断地取出中间件来执行,这就是koa-compose的核心功能。

koa主体流程

  • new Koa() 时,生成Application类的一个实例,而Application继承自nodejs内置模块events,实例的初始化包含middlewarecontextrequestresponse等属性
  • app.use(fn) 实际是往middleware属性push中间件函数,可以理解为注册中间件,为了实现链式调用,还要返回当前实例自身this
  • app.listen(arg)实际是http.createServer(app.callback()).listen(arg)的语法糖
  • app.callback()实际是转换了函数入参(req,res)(ctx, fn) 主要代码如下:
const Emitter = require('events');
module.exports = class Application extends Emitter {
  constructor(options) {
    super();
    options = options || {};
    this.proxy = options.proxy || false;
    this.subdomainOffset = options.subdomainOffset || 2;
    this.proxyIpHeader = options.proxyIpHeader || 'X-Forwarded-For';
    this.maxIpsCount = options.maxIpsCount || 0;
    this.env = options.env || process.env.NODE_ENV || 'development';
    if (options.keys) this.keys = options.keys;
    this.middleware = [];
    this.context = Object.create(context);
    this.request = Object.create(request);
    this.response = Object.create(response);
    if (util.inspect.custom) {
      this[util.inspect.custom] = this.inspect;
    }
  }
  
  listen(...args) {
    const server = http.createServer(this.callback());
    return server.listen(...args);
  }
  
  use(fn) {
    this.middleware.push(fn);
    return this;
  }
  callback() {
    const fn = compose(this.middleware);
    const handleRequest = (req, res) => {
      const ctx = this.createContext(req, res);
      return this.handleRequest(ctx, fn);
    };
    return handleRequest;
  }

  handleRequest(ctx, fnMiddleware) {
    const onerror = err => ctx.onerror(err);
    const handleResponse = () => respond(ctx);
    return fnMiddleware(ctx).then(handleResponse).catch(onerror);
  }

  createContext(req, res) {
    const context = Object.create(this.context);
    const request = context.request = Object.create(this.request);
    const response = context.response = Object.create(this.response);
    context.app = request.app = response.app = this;
    context.req = request.req = response.req = req;
    context.res = request.res = response.res = res;
    request.ctx = response.ctx = context;
    request.response = response;
    response.request = request;
    context.originalUrl = request.originalUrl = req.url;
    context.state = {};
    return context;
  }
};

listen函数可以简单梳理为:

 listen(...args) {
     const fnMiddleware = compose(this.middleware);
     const handleRequest = (req, res) => {
         const ctx = this.createContext(req, res);
         const onerror = err => ctx.onerror(err);
         const handleResponse = () => respond(ctx);
         return fnMiddleware(ctx).then(handleResponse).catch(onerror); 
    };
    const server = http.createServer(handleRequest);
    return server.listen(...args);
  }

koa-compose

这里的 compose(this.middleware) 是我们今天讨论的重点,它相当于一个任务调度系统,控制着中间件的执行。 先看下代码实现:

function compose (middleware) {
  middleware = flatten(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函数接受中间件数组middleware作为参数
  • 执行compose函数返回一个类似中间件的函数,参数context, next

在执行中间件之前,注册完中间件之后,callback()函数执行后返回的handleRequest(ctx, fn)被执行,而此函数会调用中间件函数fn(如下图1中的fnMiddleware),也就是调用compose返回的函数(如下图2),此时会执行dispatch(0)

图1: image.png

图2: image.png

dispatch(0)

Untitled Diagram.drawio.png 由上图可知,当在第一个中间件内部调用 next 函数,其实就是继续调用 dispatch 函数,此时参数 i 的值为 1

dispatch(1)

Untitled Diagram.drawio (1).png 由上图可知,当在第二个中间件内部调用 next 函数,仍然是调用 dispatch 函数,此时参数 i 的值为 2

dispatch(2)

Untitled Diagram.drawio (2).png 由上图可知,当在第三个中间件内部没有调用 next 函数,这时候不会调用 dispatch 函数,直接返回异步任务结果,回到第二个中间件的next()之后继续执行。

同样地,中间件二执行结束也返回异步任务结果,回到第一个中间件的next()之后继续执行,直到所有的中间件都执行完毕。

理解了就可以把代码重写了一遍了,简化代码可参考:koa-compose简化版

总结

koa-compose理解起来确实有些难,需要平时的积累,对异步任务有深刻的理解,对async/awaitPromise都要有深入的研究,才能比较轻松的理解koa-compose的实现及原理。加油学习吧!