Koa 源码剖析 & 实现

May 19, 2020

前言

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

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

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

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

准备工作

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

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

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

js
// app.js
const Koa = require("./lib/application.js");
const app = new Koa();
app.use(async ctx => {
ctx.body = "Hello World";
});
app.listen(3000);
js
// 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 实例。

js
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;
js
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

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

Response

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

Context

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

js
// 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;
},
};
js
// 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;
},
};

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

js
// 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;
js
// 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

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;
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 函数:它是一个高阶函数,能将多个顺序执行的函数组合成一个函数,内层函数的返回值作为外层函数的参数。

举个栗子 🌰:

js
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
js
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 函数来自动实现以上操作,而不是手动。

js
function compose(...funcs) {
return args => funcs.reduceRight((composed, f) => f(composed), args);
}
const fn = compose(padStart, lower, join);
fn(["BANANA", "ORANGE"]);
// apple,banana,orange
js
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 代码:

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);
});
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 中添加以下内容:

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)));
}
};
}
}
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 面板进行调试。

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

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

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"
}
]
}
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() 中:

js
const fn = compose(padStart, lower, join);
fn(["BANANA", "ORANGE"]);
// 一旦调用 fn(),便会递归执行,依次调用 join(), lower(), padStart()
js
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 代码:

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);
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

bash
you are the best.
bash
you are the best.

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

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

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

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

源码地址


B2D1 (包邦东)

Written by B2D1 (包邦东)