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.jsconst Koa = require("./lib/application.js");const app = new Koa();app.use(async ctx => {ctx.body = "Hello World";});app.listen(3000);
js
// app.jsconst 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.jsmodule.exports = {get url() {return this.req.url;},get method() {return this.req.method;},};
js
// request.jsmodule.exports = {get url() {return this.req.url;},get method() {return this.req.method;},};
Response
js
// response.jsmodule.exports = {get body() {return this._body;},set body(val) {this._body = val;},};
js
// response.jsmodule.exports = {get body() {return this._body;},set body(val) {this._body = val;},};
Context
context 对象为被访问的属性设置 getter
和 setter
,并委托给 request 对象和 response 对象执行。
js
// context.jsmodule.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.jsmodule.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.jsconst 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.jsconst 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 的指导思想:利用 async
、await
语法糖,用同步代码的书写方式来解决异步操作。
运行 app.js
,终端打印 start end
,浏览器访问显示 you are the best.
至此,你已经实现了 Koa 的全部核心功能 🎉