Koa 做爲搭建 NodeJS 服務時最經常使用的 web 框架,其源碼並不複雜,但卻實現了兩個核心要點:node
不管你在準備面試,或想提高編碼能力,那麼理解 Koa 源碼是一個不錯的選擇。因此,咱們的目標是:git
只關注核心功能點,最大程度地精簡代碼,親自實現一個 Koa。github
首先,確保你的工做目錄以下:web
├── 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);
複製代碼
固然這個服務暫時沒法運行。json
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)中。bash
// request.js
module.exports = {
get url() {
return this.req.url;
},
get method() {
return this.req.method;
},
};
複製代碼
// response.js
module.exports = {
get body() {
return this._body;
},
set body(val) {
this._body = val;
},
};
複製代碼
context 對象爲被訪問的屬性設置 getter
和 setter
,並委託給 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 的指導思想:利用 async
、await
語法糖,用同步代碼的書寫方式來解決異步操做。
運行 app.js
,終端打印 start end
,瀏覽器訪問顯示 you are the best.
至此,你已經實現了 Koa 的所有核心功能 🎉