Koa 源码剖析 & 实现

May 19, 2020

NodeJS

前言

Koa 作为搭建 NodeJS 服务时最常用的 web 框架,其源码并不复杂,但却实现了两个核心要点:

  • 中间件(Middleware)流程控制,又称“洋葱式模型”
  • 将 http.createServer 方法中的 request(IncomingMessage object)和 response(ServerResponse object)挂载到上下文(ctx)中

无论你在准备面试,或想提升编码能力,那么理解 Koa 源码是一个不错的选择。所以,我们的目标是:

只关注核心功能点,最大程度地精简代码,亲自实现一个 Koa。

准备工作

首先,确保你的工作目录如下:

├── lib
│   ├── application.js
│   ├── context.js
│   ├── request.js
│   └── response.js
└── app.js

随后,编写一个入门级别的 Hello World 服务:

// app.js
const Koa = require("./lib/application.js");
const app = new Koa();

app.use(async ctx => {
  ctx.body = "Hello World";
});

app.listen(3000);

当然这个服务暂时无法运行。

Application

application.js 作为入口文件,它导出了一个 Class(本质为构造函数),用于创建 Koa 实例。

const http = require("http");

class Koa {
  constructor() {
    // 存放中间件函数
    this.middleware = [];
  }
  use(fn) {
    this.middleware.push(fn);
    // 链式调用
    return this;
  }
  listen(...args) {
    const server = http.createServer(this.callback());
    return server.listen(...args);
  }
  callback() {
    // 处理具体的请求和响应……
  }
}

module.exports = Koa;

由于 NodeJS 原生提供的 request 对象 和 response 对象上能使用的方法较少,于是 Koa 分别在这两个对象上作了相应的拓展。

并且为了简化 API,将这两个对象封装并挂载到 Koa 的会话上下文(Context)中。

Request

// request.js
module.exports = {
  get url() {
    return this.req.url;
  },
  get method() {
    return this.req.method;
  },
};

Response

// response.js
module.exports = {
  get body() {
    return this._body;
  },
  set body(val) {
    this._body = val;
  },
};

Context

context 对象为被访问的属性设置 gettersetter,并委托给 request 对象和 response 对象执行。

// context.js
module.exports = {
  get url() {
    return this.request.url;
  },
  get body() {
    return this.response.body;
  },
  set body(val) {
    this.response.body = val;
  },
  get method() {
    return this.request.method;
  },
};

当设置的访问器过多,你可以采用另一种写法:

// context.js
const reqGetters = ["url", "method"],
  resAccess = ["body"],
  proto = {};

for (let name of reqGetters) {
  proto.__defineGetter__(name, function() {
    return this["request"][name];
  });
}
for (let name of resAccess) {
  proto.__defineGetter__(name, function() {
    return this["response"][name];
  });
  proto.__defineSetter__(name, function(val) {
    return (this["response"][name] = val);
  });
}
module.exports = proto;

同时,更改 application.js

const http = require("http");
const request = require("./request");
const response = require("./response");
const context = require("./context");

class Koa {
  constructor() {
    this.request = request;
    this.response = response;
    this.context = context;
    // 存放中间件函数
    this.middleware = [];
  }
  callback() {
    const handleRequest = (req, res) => {
      const ctx = this.createContext(req, res);
      this.middleware[0](ctx);
      res.end(ctx.body);
    };
    return handleRequest;
  }
  createContext(req, res) {
    // 将拓展后的请求和响应挂载到 context 上
    const context = Object.create(this.context);
    const request = (context.request = Object.create(this.request));
    const response = (context.response = Object.create(this.response));
    // 挂载原生请求、响应
    context.req = request.req = req;
    context.res = response.res = res;

    return context;
  }
}

module.exports = Koa;

运行 app.js,浏览器访问显示 "Hello World"

中间件

Koa 中间件机制源于 compose 函数:它是一个高阶函数,能将多个顺序执行的函数组合成一个函数,内层函数的返回值作为外层函数的参数。

举个栗子 🌰:

function lower(str) {
  return str.toLowerCase();
}

function join(arr) {
  return arr.join(",");
}

function padStart(str) {
  return str.padStart(str.length + 6, "apple,");
}

// 我想顺序调用 join() lower() padStart()

padStart(lower(join(["BANANA", "ORANGE"])));
// apple,banana,orange

当然,你需要一个 compose 函数来自动实现以上操作,而不是手动。

function compose(...funcs) {
  return args => funcs.reduceRight((composed, f) => f(composed), args);
}

const fn = compose(padStart, lower, join);
fn(["BANANA", "ORANGE"]);
// apple,banana,orange

更改你的 app.js 代码:

app
  .use((ctx, next) => {
    console.log(1);
    next();
    console.log(5);
  })
  .use((ctx, next) => {
    console.log(2);
    next();
    console.log(4);
  })
  .use((ctx, next) => {
    console.log(3);
    next();
    console.log(4);
  });

Koa 期望终端中打印:1 2 3 4 5 6,所以你需要实现 Koa 的 compose 函数,以便多个中间件顺序调用,并接受 next() 调用下一个中间件函数的操作,这样一结合就形成了“洋葱式模型”:Request 由于 next() 的存在,它会递归进入下一个中间件函数被处理,直至不存在下一个中间件函数,递归结束,执行栈依次返回到上一个中间件函数继续处理,直至执行栈为空,返回 Response

继续查看源码,你会发现 Koa 使用了 koa-compose 库,其代码也很精简。

在你的 Application.js 中添加以下内容:

class Boa {
  callback() {
    const fn = this.compose(this.middleware);
    const handleRequest = (req, res) => {
      const ctx = this.createContext(req, res);
      return this.handleRequest(ctx, fn);
    };
    return handleRequest;
  }
  handleRequest(ctx, fnMiddleware) {
    const handleResponse = () => this.respond(ctx);
    return fnMiddleware(ctx).then(handleResponse);
  }
  respond(ctx) {
    const { res, body } = ctx;
    res.end(body === undefined ? "Not Found" : body);
  }
  compose(middleware) {
    return function(context, next) {
      return dispatch(0);
      function dispatch(i) {
        let fn = middleware[i];
        //  所有中间件函数执行完毕,fn = undefined,结束递归
        if (i === middleware.length) fn = next;
        if (!fn) return Promise.resolve();
        // 递归调用下一个中间件函数
        return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
      }
    };
  }
}

为了更好的理解“洋葱式模型”,你可以使用 VSCode 的 Run 面板进行调试。

return Promise.resolve(fn(context, dispatch.bind(null, i + 1))); // 在此处设置断点

并在根目录下配置 .vscode/launch.json

{
  // 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": [
    {
      "type": "node",
      "request": "launch",
      "name": "Launch Program",
      "skipFiles": ["<node_internals>/**"],
      "program": "${workspaceFolder}\\app.js"
    }
  ]
}

运行 F5,访问 http://localhost:3000 命中断点,使用 Step Over 和 Step Into 来将程序运行至第三个 next() 内部,你将会看到如下图所示的调用栈:

虽然 koa-compose 的实现方式与 compose 有所不同,但核心思想是一致的:一旦调用了 dispatch(0),中间件函数就会自动递归执行,依次调用 dispatch(1) dispatch(2) dispatch(3),而在 compose() 中:

const fn = compose(padStart, lower, join);
fn(["BANANA", "ORANGE"]);
// 一旦调用 fn(),便会递归执行,依次调用 join(), lower(), padStart()

compose 中的数据流是单向的,而 Koa-compose 中 next() 的引入使得 Koa 的数据流是双向的(数据从外到内,再从内到外),就好比你用一根针 💉 贯穿一颗洋葱 🧅,先经过表皮深入核心,再由核心离开表皮,这就是 Koa 中间件的独特之处(“洋葱式模型”):源于 compose,优于 compose.

处理异步

在 NodeJS 中,充斥着大量的 I/O 以及网络请求,它们都属于 异步请求

Koa 则使用了 async 函数和 await 操作符,丢弃了回调函数,以更优雅的方式去处理异步操作。

更改你的 app.js 代码:

const fs = require("fs");
const promisify = require("util").promisify;

// 异步读取根目录下 demo.txt 的内容
const readTxt = async () => {
  const promisifyReadFile = promisify(fs.readFile);
  const data = await promisifyReadFile("./demo.txt", { encoding: "utf8" });
  return data ? data : "no content";
};

app
  .use((ctx, next) => {
    console.log("start");
    next();
    console.log("end");
  })
  .use(async (ctx, next) => {
    const data = await next();
    ctx.body = data;
  })
  .use(readTxt);

在根目录下创建 demo.txt

you are the best.

现在,你的应用中包含了一个新的中间件函数 readTxt(),其内部的 fs.readFile() 属于异步 I/O 的范畴。

根据 Koa 的指导思想:利用 asyncawait 语法糖,用同步代码的书写方式来解决异步操作。

运行 app.js,终端打印 start end,浏览器访问显示 you are the best.

至此,你已经实现了 Koa 的全部核心功能 🎉

源码地址


Written by B2D1(包邦东)