這篇文章會(huì)講些什么?
- 如何從零開始完成一個(gè)涵蓋Koa核心功能的Node.js類庫
- 從代碼層面解釋Koa一些代碼寫法的原因:如中間件為什么必須調(diào)用next函數(shù)、ctx是怎么來的和一個(gè)請求是什么關(guān)系
我們知道Koa類庫主要有以下幾個(gè)重要特性:
- 支持洋蔥圈模型的中間件機(jī)制
- 封裝request、response提供context對象,方便http操作
- 異步函數(shù)、中間件的錯(cuò)誤處理機(jī)制
第一步:基礎(chǔ)Server運(yùn)行
目標(biāo):完成基礎(chǔ)可行新的Koa Server
- 支持app.listen監(jiān)聽端口啟動(dòng)Server
- 支持app.use添加類middleware處理函數(shù)
核心代碼如下:
class Koa {
private middleware: middlewareFn = () => {};
constructor() {}
listen(port: number, cb: noop) {
const server = http.createServer((req, res) => {
this.middleware(req, res);
});
return server.listen(port, cb);
}
use(middlewareFn: middlewareFn) {
this.middleware = middlewareFn;
return this;
}
}
const app = new Koa();
app.use((req, res) => {
res.writeHead(200);
res.end("A request come in");
});
app.listen(3000, () => {
console.log("Server listen on port 3000");
});
第二步:洋蔥圈中間件機(jī)制實(shí)現(xiàn)
目標(biāo):接下來我們要完善listen和use方法,實(shí)現(xiàn)洋蔥圈中間件模型
如下面代碼所示,在這一步中我們希望app.use能夠支持添加多個(gè)中間件,并且中間件是按照洋蔥圈(類似深度遞歸調(diào)用)的方式順序執(zhí)行
app.use(async (req, res, next) => {
console.log("middleware 1 start");
// 具體原因我們會(huì)在下面代碼實(shí)現(xiàn)詳細(xì)講解
await next();
console.log("middleware 1 end");
});
app.use(async (req, res, next) => {
console.log("middleware 2 start");
await next();
console.log("middleware 2 end");
});
app.use(async (req, res, next) => {
res.writeHead(200);
res.end("An request come in");
await next();
});
app.listen(3000, () => {
console.log("Server listen on port 3000");
});
上述Demo有三個(gè)需要我們注意的點(diǎn):
- 在中間件中next()函數(shù)必須且只能調(diào)用一次
- 調(diào)用next函數(shù)時(shí)必須使用await
我們會(huì)在接下來的代碼中逐個(gè)分析這些使用方法的原因,下面我們來看一看具體怎么實(shí)現(xiàn)這種洋蔥圈機(jī)制:
class Koa {
...
use(middlewareFn: middlewareFn) {
// 1、調(diào)用use時(shí),使用數(shù)組存貯所有的middleware
this.middlewares.push(middlewareFn);
return this;
}
listen(port: number, cb: noop) {
// 2、 通過composeMiddleware將中間件數(shù)組轉(zhuǎn)換為串行[洋蔥圈]調(diào)用的函數(shù),在createServer中回調(diào)函數(shù)中調(diào)用
// 所以真正的重點(diǎn)就是 composeMiddleware,如果做到的,我們接下來看該函數(shù)的實(shí)現(xiàn)
// BTW: 從這里可以看到 fn 是在listen函數(shù)被調(diào)用之后就生成了,這就意味著我們不能在運(yùn)行時(shí)動(dòng)態(tài)的添加middleware
const fn = composeMiddleware(this.middlewares);
const server = http.createServer(async (req, res) => {
await fn(req, res);
});
return server.listen(port, cb);
}
}
// 3、洋蔥圈模型的核心:
// 入?yún)ⅲ核惺占闹虚g件
// 返回:串行調(diào)用中間件數(shù)組的函數(shù)
function composeMiddleware(middlewares: middlewareFn[]) {
return (req: IncomingMessage, res: ServerResponse) => {
let start = -1;
// dispatch:觸發(fā)第i個(gè)中間件執(zhí)行
function dispatch(i: number) {
// 剛開始可能不理解這里為什么這么判斷,可以看完整個(gè)函數(shù)在來思考這個(gè)問題
// 正常情況下每次調(diào)用前 start < i,調(diào)用完next() 應(yīng)該 start === i
// 如果調(diào)用多次next(),第二次及以后調(diào)用因?yàn)橹耙淹瓿蓅tart === i賦值,所以會(huì)導(dǎo)致 start >= i
if (i <= start) {
return Promise.reject(new Error("next() call more than once!"));
}
if (i >= middlewares.length) {
return Promise.resolve();
}
start = i;
const middleware = middlewares[i];
// 重點(diǎn)來了!!!
// 取出第i個(gè)中間件執(zhí)行,并將dispatch(i+1)作為next函數(shù)傳給各下一個(gè)中間件
return middleware(req, res, () => {
return dispatch(i + 1);
});
}
return dispatch(0);
};
}
主要涉及到Promise幾個(gè)知識(shí)點(diǎn):
- async 函數(shù)返回的是一個(gè)Promise對象【所以的中間件都會(huì)返回一個(gè)promise對象】
- async 函數(shù)內(nèi)部遇到 await 調(diào)用時(shí)會(huì)暫停執(zhí)行await函數(shù),等待返回結(jié)果后繼續(xù)向下執(zhí)行
- async 函數(shù)內(nèi)部發(fā)生錯(cuò)誤會(huì)導(dǎo)致返回的Promise變?yōu)閞eject狀態(tài)
現(xiàn)在我們在回顧之前提出的幾個(gè)問題:
-
koa中間件中為什么必須且只能調(diào)用一次next函數(shù)
可以看到如果不調(diào)用next,就不會(huì)觸發(fā)dispatch(i+1),下一個(gè)中間件就沒辦法觸發(fā),造成假死狀態(tài)最終請求超時(shí) 調(diào)用多次next則會(huì)導(dǎo)致下一個(gè)中間件執(zhí)行多次
-
next() 調(diào)用為什么需要加 await
這也是洋蔥圈調(diào)用機(jī)制的核心,當(dāng)執(zhí)行到 await next(),會(huì)執(zhí)行next()【調(diào)用下一個(gè)中間件】等待返回結(jié)果,在接著向下執(zhí)行
第三步:Context提供
目標(biāo):封裝Context,提供request、response的便捷操作方式
// 1、 定義KoaRequest、KoaResponse、KoaContext
interface KoaContext {
request?: KoaRequest;
response?: KoaResponse;
body: String | null;
}
const context: KoaContext = {
get body() {
return this.response!.body;
},
set body(body) {
this.response!.body = body;
}
};
function composeMiddleware(middlewares: middlewareFn[]) {
return (context: KoaContext) => {
let start = -1;
function dispatch(i: number) {
// ..省略其他代碼..
// 2、所有的中間件接受context參數(shù)
middleware(context, () => {
return dispatch(i + 1);
});
}
return dispatch(0);
};
}
class Koa {
private context: KoaContext = Object.create(context);
listen(port: number, cb: noop) {
const fn = composeMiddleware(this.middlewares);
const server = http.createServer(async (req, res) => {
// 3、利用req、res創(chuàng)建context對象
// 這里需要注意:context是創(chuàng)建一個(gè)新的對象,而不是直接賦值給this.context
// 因?yàn)閏ontext適合請求相關(guān)聯(lián)的,這里也保證了每一個(gè)請求都是一個(gè)新的context對象
const context = this.createContext(req, res);
await fn(context);
if (context.response && context.response.res) {
context.response.res.writeHead(200);
context.response.res.end(context.body);
}
});
return server.listen(port, cb);
}
// 4、創(chuàng)建context對象
createContext(req: IncomingMessage, res: ServerResponse): KoaContext {
// 為什么要使用Object.create而不是直接賦值?
// 原因同上需要保證每一次請求request、response、context都是全新的
const request = Object.create(this.request);
const response = Object.create(this.response);
const context = Object.create(this.context);
request.req = req;
response.res = res;
context.request = request;
context.response = response;
return context;
}
}
第四步:異步函數(shù)錯(cuò)誤處理機(jī)制
目標(biāo):支持通過 app.on("error"),監(jiān)聽錯(cuò)誤事件處理異常
我們回憶下在Koa中如何處理異常,代碼可能類似如下:
app.use(async (context, next) => {
console.log("middleware 2 start");
// throw new Error("出錯(cuò)了");
await next();
console.log("middleware 2 end");
});
// koa統(tǒng)一錯(cuò)誤處理:監(jiān)聽error事件
app.on("error", (error, context) => {
console.error(`請求${context.url}發(fā)生了錯(cuò)誤`);
});
從上面的代碼可以看到核心在于:
- Koa實(shí)例app需要支持事件觸發(fā)、事件監(jiān)聽能力
- 需要我們捕獲異步函數(shù)異常,并觸發(fā)error事件
下面我們看具體代碼如何實(shí)現(xiàn):
// 1、繼承EventEmitter,增加事件觸發(fā)、監(jiān)聽能力
class Koa extends EventEmitter {
listen(port: number, cb: noop) {
const fn = composeMiddleware(this.middlewares);
const server = http.createServer(async (req, res) => {
const context = this.createContext(req, res);
// 2、await調(diào)用fn,可以使用try catch捕獲異常,觸發(fā)異常事件
try {
await fn(context);
if (context.response && context.response.res) {
context.response.res.writeHead(200);
context.response.res.end(context.body);
}
} catch (error) {
console.error("Server Error");
// 3、觸發(fā)error時(shí)提供context更多信息,方面日志記錄,定位問題
this.emit("error", error, context);
}
});
return server.listen(port, cb);
}
}
總結(jié)
至此我們已經(jīng)使用TypeScript完成簡版Koa類庫,支持了
- 洋蔥圈中間件機(jī)制
- Context封裝request、response
- 異步異常錯(cuò)誤處理機(jī)制
完整Demo代碼可以參考koa2-reference
更多精彩文章,歡迎大家Star我們的倉庫,我們每周都會(huì)推出幾篇高質(zhì)量的大前端領(lǐng)域相關(guān)文章。