Koa 2.x 版本是當下最流行的 NodeJS 框架,同時社群湧現出一大批圍繞 Koa 2.x 的中介軟體以及基於 Koa 2.x 封裝的企業級框架,如 egg.js,然而 Koa 本身的程式碼卻非常精簡,精簡到所有檔案的程式碼去掉註釋後還不足 2000 行,本篇就圍繞著這 2000 行不到的程式碼抽出核心邏輯進行分析,並壓縮成一版只有 200 行不到的簡易版 Koa。
Koa 分析過程
在下面的內容中,我們將對 Koa 所使用的功能由簡入深的分析,首先會給出使用案例,然後根據使用方式,分析實現原理,最後對分析的功能進行封裝,封裝過程會從零開始並一步一步完善,程式碼也是從少到多,會完整的看到一個簡版 Koa 誕生的過程,在此之前我們開啟 Koa 原始碼地址。
Koa 檔案目錄
通過上面對 Koa 原始碼目錄的截圖,發現只有 4 個核心檔案,為了方便理解,封裝簡版 Koa 的檔案目錄結構也將嚴格與原始碼同步。
搭建基本服務
在引入 Koa 時我們需要建立一個 Koa 的例項,而啟動服務是通過 listen 監聽一個埠號實現的,程式碼如下。
const Koa = require('koa');const app = new Koa();app.listen(3000, () => { console.log('server start 3000');});通過使用我們可以分析出 Koa 匯出的應該是一個類,或者建構函式,鑑於 Koa 誕生的時間以及基於 node v7.6.0 以上版本的情況來分析,正是 ES6 開始 “橫行霸道” 的時候,所以推測 Koa 匯出的應該是一個類,開啟原始碼一看,果然如此,所以我們也通過 class 的方式來實現。
而從啟動服務的方式上看,app.listen 的呼叫方式與原生 http 模組提供的 server.listen 幾乎相同,我們分析,listen 方法應該是對原生 http 模組的一個封裝,啟動服務的本質還是靠 http 模組來實現的。
檔案路徑:~koa/application.js
const http = require('http');class Koa { handleRequest(req, res) { // 請求回撥 } listen(...args) { // 建立服務 let server = http.createServer(this.handleRequest.bind(this)); // 啟動服務 server.listen(...args); }}module.exports = Koa;上面的程式碼初步實現了我們上面分析出的需求,為了防止程式碼冗餘,我們將建立服務的回撥抽取成一個 handleRequest 的例項方法,內部的邏輯在後面完善,現在可以建立這個 Koa 類的例項,通過呼叫例項的 listen 方法啟動一個伺服器。
上下文物件 ctx 的封裝
基本使用
Koa 還有一個很重要的特性,就是它的 ctx 上下文物件,我們可以呼叫 ctx 的 request 和 response 屬性獲取原 req 和 res 的屬性和方法,也在 ctx 上增加了一些原生沒有的屬性和方法,總之 ctx 給我們要操作的屬性和方法提供了多種呼叫方式,使用案例如下。
const Koa = require('koa');const app = new Koa();app.use((ctx, next) => { // 原生的 req 物件的 url 屬性 console.log(ctx.req.url); console.log(ctx.request.req.url); console.log(ctx.response.req.url); // Koa 擴充套件的 url console.log(ctx.url); console.log(ctx.request.req.url); // 設定狀態碼和響應內容 ctx.response.status = 200; ctx.body = 'Hello World';});app.listen(3000, () => { console.log('server start 3000');});建立 ctx 的引用關係
從上面我們可以看出,ctx 為 use 方法的第一個引數,request 和 response 是 ctx 新增的,而通過這兩個屬性又都可以獲取原生的 req 和 res 屬性,ctx 本身也可以獲取到原生的 req 和 res,我們可以分析出,ctx 是對這些屬性做了一個整合,或者說特殊處理。
原始碼的檔案目錄中正好有與 request、response 名字相對應的檔案,並且還有 context 名字的檔案,我們其實可以分析出這三個檔案就是用於封裝 ctx 上下文物件使用的,而封裝 ctx 中也會用到 req 和 res,所以核心邏輯應該在 handleRequest 中實現。
在使用案例中 ctx 是作為 use 方法中回撥函式的引數,所以我們分析應該有一個數組統一管理呼叫 use 後傳入的函式,Koa 應該有一個屬性,值為陣列,用來儲存這些函式,下面是實現程式碼。
檔案路徑:~koa/application.js
const http = require('http');// ***************************** 以下為新增程式碼 *****************************const context = require('./context');const request = require('./request');const response = require('./response');// ***************************** 以上為新增程式碼 *****************************class Koa {// ***************************** 以下為新增程式碼 ***************************** contructor() { // 儲存中介軟體 this.middlewares = []; // 為了防止通過 this 修改屬性而導致影響原引入檔案的匯出物件,做一個繼承 this.context = Object.create(context); this.request = Object.create(request); this.response = Object.create(response); } use(fn) { // 將傳給 use 的函式存入陣列中 this.middlewares.push(fn); } createContext(req, res) { // 或取定義的上下文 let ctx = this.context; // 增加 request 和 response ctx.request = this.request; ctx.response = this.response; // 讓 ctx、request、response 都具有原生的 req 和 res ctx.req = ctx.request.req = ctx.response.req = req; ctx.res = ctx.response.res = ctx.request.res = res; // 返回上下文物件 return ctx; }// ***************************** 以上為新增程式碼 ***************************** handleRequest(req, res) { // 建立 ctx 上下文物件 let ctx = this.createContext(req, res); } listen(...args) { // 建立服務 let server = http.createServer(this.handleRequest.bind(this)); // 啟動服務 server.listen(...args); }}module.exports = Koa;首先,給例項建立了三個屬性 context、request 和 response 分別繼承了 context.js、request.js 和 response.js 匯出的物件,之所以這麼做而不是直接賦值是防止操作例項屬性時 “汙染” 原物件,而獲取原模組匯出物件的屬性可以通過原型鏈進行查詢,並不影響取值。
其次,給例項掛載了 middlewares 屬性,值為陣列,為了儲存 use 方法呼叫時傳入的函式,在 handleRequest 把建立 ctx 屬性及引用的過程單獨抽取成了 createContext 方法,並在 handleRequest 中呼叫,返回值為建立好的 ctx 物件,而在 createContext 中我們根據案例中的規則構建了 ctx 的屬性相關的各種引用關係。
實現 request 取值
上面構建的屬性中,所有通過訪問原生 req 或 res 的屬性都能獲取到,反之則是 undefined,這就需要我們去構建 request.js。
檔案路徑:~koa/request.js
const url = require('url');// 給 url 和 path 新增 getterconst request = { get url() { return this.req.url; }, get path() { return url.parse(this.req.url).pathname; }};module.exports = request;上面我們只構造了兩個屬性 url 和 path,我們知道 url 是原生所自帶的屬性,我們在使用 ctx.request.url 獲取是通過 request 物件設定的 getter,將 ctx.request.req.url 的值返回了。
path 是原生 req 所沒有的屬性,但卻是通過原生 req 的 url 屬性和 url 模組共同構建出來的,所以我們同樣用了給 request 物件設定 getter 的方式獲取 req 的 url 屬性,並使用 url 模組將轉換物件中的 pathname 返回,此時就可以通過 ctx.request.path 來獲取訪問路徑,至於原始碼中我們沒有處理的 req 屬性都是通過這樣的方式建立的引用關係。
實現 response 的取值和賦值
Koa 中 response 物件的真正作用是給客戶端進行響應,使用時是通過訪問屬性獲取,並通過重新賦值實現響應,但是現在 response 獲取的屬性都是 undefined,我們這裡先不管響應給瀏覽器的問題,首先要讓 response 下的某個屬性有值才行,下面我們來實現 response.js。
檔案路徑:~koa/response.js
// 給 body 和 status 新增 getter 和 setterconst response = { get body() { return this._body; }, set body(val) { // 只要給 body 賦值就代表響應成功 this.status = 200; this._body = val; }, get status() { return this.res.statusCode; }, set status(val) { this.res.statusCode = val; }};module.exports = response;這裡選擇了 Koa 在使用時,response 物件上比較重要的兩個屬性進行處理,因為這兩個屬性是伺服器響應客戶端所必須的,並模仿了 request.js 的方式給 body 和 status 設定了 getter,不同的是響應瀏覽器所做的其實是賦值操作,所以又給這兩個屬性添加了 setter,對於 status 來說,直接操作原生 res 物件的 statusCode 屬性即可,因為同為賦值操作。
還有一點,響應是通過給 body 賦值實現,我們認為只要觸發了 body 的 setter 就成功響應,所以在 body 的 getter 中將響應狀態碼設定為 200,至於 body 賦值是如何實現響應的,放在後面再說。
ctx 代理 request、response 的屬性
上面實現了通過 request 和 response 對屬性的操作,Koa 雖然給我們提供了多樣的屬性操作方式,但由於我們程式猿(媛)們都很 “懶”,幾乎沒有人會在開發的時候願意多寫程式碼,大部分情況都是通過 ctx 直接操作 request 和 response 上的屬性,這就是我們現在的問題所在,這些屬性通過 ctx 訪問不到。
我們需要給 ctx 物件做一個代理,讓 ctx 可以訪問到 request 和 response 上的屬性,這個場景何曾相識,不正是 Vue 建立例項時,將傳入引數物件 options 的 data 屬性代理給例項本身的場景嗎,既然如此,我們也通過相似的方式實現,還記得上面引入的 context 模組作為例項的 context 屬性所繼承的物件,而剩下的最後一個核心檔案 context.js 正是用來做這件事的,程式碼如下。
檔案路徑:~koa/context.js
const proto = {};// 將傳入物件屬性代理給 ctxfunction defineGetter(property, key) { proto.__defineGetter__(key, function () { return this[property][key]; });}// 設定 ctx 值時直接操作傳入物件的屬性function defineSetter(property, key) { proto.__defineSetter__(key, function (val) { this[property][key] = val; });}// 將 request 的 url 和 path 代理給 ctxdefineGetter('request', 'url');defineGetter('request', 'path');// 將 response 的 body 和 status 代理給 ctxdefineGetter('response', 'body');defineSetter('response', 'body');defineGetter('response', 'status');defineSetter('response', 'status');module.exports = proto;在 Vue 中是使用 Object.defineProperty 來時實現的代理,而在 Koa 原始碼中藉助了 delegate 第三方模組來實現的,並在新增代理時鏈式呼叫了 delegate 封裝的方法,我們並沒有直接使用 delegate 模組,而是將 delegate 內部的核心邏輯抽取出來在 context.js 中直接編寫,這樣方便大家理解原理,也可以清楚的知道是如何實現代理的。
我們封裝了兩個方法 defineGetter 和 defineSetter 分別來實現取值和設定值時,將傳入的屬性(第二個引數)代理給傳入的物件(第一個引數),函式內是通過 Object.prototype.__defineGetter__ 和 Object.prototype.__defineSetter__ 實現的,點選方法名可檢視官方 API。
洋蔥模型 —— 實現中介軟體的序列
現在已經實現了 ctx 上下文物件的建立,但是會發現我們封裝 ctx 之前所寫的案例 use 回撥中的程式碼並不能執行,也不會報錯,根本原因是 use 方法內傳入的函式沒有呼叫,在使用 Koa 的過程中會發現,我們往往使用多個 use,並且傳入 use 的回撥函式除了 ctx 還有第二個引數 next,而這個 next 也是一個函式,呼叫 next 則執行下一個 use 中的回撥函式,否則就會 “卡住”,這種執行機制被取名為 “洋蔥模型”,而這些被執行的函式被稱為 “中介軟體”,下面我們就來分析這個 “洋蔥模型” 並實現中介軟體的序列。
洋蔥模型執行過程
洋蔥模型分析
下面來看看錶述洋蔥模型的一個經典案例,結果似乎讓人匪夷所思,一時很難想到原因,不著急先看了再說。
const Koa = require('koa');const app = new Koa();app.use((ctx, next) => { console.log(1); next(); console.log(2);});app.use((ctx, next) => { console.log(3); next(); console.log(4);});app.use((ctx, next) => { console.log(5); next(); console.log(6);});app.listen(3000, () => { console.log('server start 3000');});
根據上面的執行特性我們不妨來分析以下,我們知道 use 方法執行時其實是把傳入的回撥函式放入了例項的 middlewares 陣列中,而執行結果列印了 1 說明第一個回撥函式被執行了,接著又列印了 2 說明第二個回撥函式被執行了,根據上面的程式碼我們可以大膽的猜想,第一個回撥函式呼叫的 next 肯定是一個函式,可能就是下一個回撥函式,或者是 next 函式中執行了下一個回撥函式,這樣根據函式呼叫棧先進後出的原則,會在 next 執行完畢,即出棧後,繼續執行上一個回撥函式的程式碼。
支援非同步的中介軟體序列
在實現中介軟體序列之前需要補充一點,中介軟體函式內呼叫 next 時,前面的程式碼出現非同步,則會繼續向下執行,等到非同步執行結束後要執行的程式碼插入到同步程式碼中,這會導致執行順序錯亂,所以在官方推薦中告訴我們任何遇到非同步的操作前都需要使用 await 進行等待(包括 next,因為下一個中介軟體中可能包含非同步操作),這也間接的說明了傳入 use 的回撥函式只要有非同步程式碼需要 await,所以應該是 async 函式,而了解 ES7 特性 async/await 的我們來說,一定能分析出 next 返回的應該是一個 Promise 例項,下面是我們在之前 application.js 基礎上的實現。
檔案路徑:~koa/application.js
const http = require('http');const context = require('./context');const request = require('./request');const response = require('./response');class Koa { contructor() { // 儲存中介軟體 this.middlewares = []; // 為了防止通過 this 修改屬性而導致影響原引入檔案的匯出物件,做一個繼承 this.context = Object.create(context); this.request = Object.create(request); this.response = Object.create(response); } use(fn) { // 將傳給 use 的函式存入陣列中 this.middlewares.push(fn); } createContext(req, res) { // 或取定義的上下文 let ctx = this.context; // 增加 request 和 response ctx.request = this.request; ctx.response = this.response; // 讓 ctx、request、response 都具有原生的 req 和 res ctx.req = ctx.request.req = ctx.response.req = req; ctx.res = ctx.response.res = ctx.request.res = res; // 返回上下文物件 return ctx; }// ***************************** 以下為新增程式碼 ***************************** compose(ctx, middles) { // 建立一個遞迴函式,引數為儲存中介軟體的索引,從 0 開始 function dispatch(index) { // 在所有中介軟體執行之後給 compose 返回一個 Promise(相容一箇中間件都沒寫的情況) if (index === middles.length) return Promise.resolve(); // 取出第 index 箇中間件函式 const route = middles[index]; // 為了相容中介軟體傳入的函式不是 async,一定要包裝成一個 Promise return Promise.resolve(route(ctx, () => dispatch(++index))); } return dispatch(0); // 預設執行一次 }// ***************************** 以上為新增程式碼 ***************************** handleRequest(req, res) { // 建立 ctx 上下文物件 let ctx = this.createContext(req, res);// ***************************** 以下為新增程式碼 ***************************** // 執行 compose 將中介軟體組合在一起 this.compose(ctx, this.middlewares);// ***************************** 以上為新增程式碼 ***************************** } listen(...args) { // 建立服務 let server = http.createServer(this.handleRequest.bind(this)); // 啟動服務 server.listen(...args); }}module.exports = Koa;
仔細想想我們其實在利用迴圈執行每一個 middlewares 中的函式,而且需要把下一個中介軟體函式的執行作為函式體的程式碼包裝一層成為新的函式,並作為引數 next 傳入,那麼在上一個中介軟體函式內部呼叫 next 就相當於先執行了下一個中介軟體函式,而下一個中介軟體函式內部呼叫 next,又先執行了下一個的下一個中介軟體函式,依次類推。
直到執行到最後一箇中間件函式,呼叫了 next,但是 middlewares 中已經沒有下一個中介軟體函數了,這也是為什麼我們要給下一個中介軟體函式外包了一層函式而不是直接將中介軟體函式傳入的原因之一(另一個原因是解決傳參問題,因為在執行時還要傳入下一個中介軟體函式),但是防止遞迴 “死迴圈”,要配合一個終止條件,即指向 middlewares 索引的變數等於了 middlewares 的長度,最後只是相當於執行了一個只有一條判斷語句的函式就 return 的函式,而並沒有報錯。
在這整個過程中如果有任意一個 next 沒有被呼叫,就不會向下執行其他的中介軟體函式,這樣就 “卡住了”,完全符合 Koa 中介軟體的執行規則,而 await 過後也就是下一個中介軟體優先執行完成,則會繼續執行當前中介軟體 next 呼叫下面的程式碼,這也就是 1、3、5、6、4、2 的由來。
為了實現所描述的執行過程,將所有中介軟體序列的邏輯抽出了一個 compose 方法,但是我們沒有使用普通的迴圈,而是使用遞迴實現的,首先在 compose 建立 dispatch 遞迴函式,引數為當前陣列函式的索引,初始值為 0,函式邏輯是先取出第一個函式執行,並傳入一個回撥函式引數,回撥函式引數中遞迴 dispatch,引數 +1,這樣就會將整個中介軟體序列起來了。
但是上面的序列也只是同步序列,如果某個中介軟體內部需要等待非同步,則呼叫的 next 函式必須返回一個 Promise 例項,有些中介軟體沒有執行非同步,則不需要 async 函式,也不會返回 Promise,而 Koa 規定只要遇到 next 就需要等待,則將取出每一箇中間件函式執行後的結果使用 Promise.resolve 強行包裝成一個成功態的 Promise,就對非同步進行了相容。
我們最後也希望 compose 返回一個 Promise 方便執行一些只有在中介軟體都執行後才會執行的邏輯,每次序列最後執行的都是一個只有一條判斷邏輯就 return 了的函式(包含一箇中間件也沒有的情況),此時 compose 返回了 undefined,無法呼叫 then 方法,為了相容這種情況也強行的使用相同的 “招數”,在判斷條件的 return 關鍵字後面加上了 Promise.resolve(),直接返回了一個成功態的 Promise。
注意:官方只是推薦我們在呼叫 next 的時候使用 await 等待,即使執行的 next 真的存在非同步,也不是非 await 不可,我們完全可以使用 return 來代替 await,唯一的區別就是 next 呼叫後,下面的程式碼不會再執行了,類比 “洋蔥模型”,形象地說就是 “下去了就上不來了”,這個完全可以根據我們的使用需要而定,如果 next 後面不再有任何邏輯,完全可以使用 return 替代。
實現真正的響應
在對 ctx 實現屬性代理後,我們通過 ctx.body 重新賦值其實只是改變了 response.js 匯出物件的 _body 屬性,而並沒有實現真正的響應,看下面這個 Koa 的例子。
const Koa = require('koa');const fs = require('fs');const app = new Koa();app.use(async (ctx, next) => { ctx.body = 'hello'; await next();});app.use(async (ctx, next) => { ctx.body = fs.createReadStream('1.txt'); ctx.body = await new Promise((resolve, reject) => { setTimeout(() => resolve('panda'), 3000); });});app.listen(3000, () => { console.log('server start 3000');});
其實最後響應給客戶端的值是 panda,正常在最後一箇中間件執行後,由於非同步定時器的程式碼沒有執行完,ctx.body 最後的值應該是 1.txt 的可讀流,這與客戶端接收到的值相違背,通過這個猜想上的差異我們應該知道,compose 在序列執行中介軟體後為什麼要返回一個 Promise 了,因為最後執行的只有判斷語句的函式會等待我們例子中最後一個 use 傳入的中介軟體函式執行完畢呼叫,也就是說在執行 compose 返回值的 then 時,ctx.body 的值已經是 panda 了。
檔案路徑:~koa/application.js
const http = require('http');// ***************************** 以下為新增程式碼 *****************************const Stream = require('stream');// ***************************** 以上為新增程式碼 *****************************const context = require('./context');const request = require('./request');const response = require('./response');class Koa { contructor() { // 儲存中介軟體 this.middlewares = []; // 為了防止通過 this 修改屬性而導致影響原引入檔案的匯出物件,做一個繼承 this.context = Object.create(context); this.request = Object.create(request); this.response = Object.create(response); } use(fn) { // 將傳給 use 的函式存入陣列中 this.middlewares.push(fn); } createContext(req, res) { // 或取定義的上下文 let ctx = this.context; // 增加 request 和 response ctx.request = this.request; ctx.response = this.response; // 讓 ctx、request、response 都具有原生的 req 和 res ctx.req = ctx.request.req = ctx.response.req = req; ctx.res = ctx.response.res = ctx.request.res = res; // 返回上下文物件 return ctx; } compose(ctx, middles) { // 建立一個遞迴函式,引數為儲存中介軟體的索引,從 0 開始 function dispatch(index) { // 在所有中介軟體執行之後給 compose 返回一個 Promise(相容一箇中間件都沒寫的情況) if (index === middles.length) return Promise.resolve(); // 取出第 index 箇中間件函式 const route = middles[index]; // 為了相容中介軟體傳入的函式不是 async,一定要包裝成一個 Promise return Promise.resolve(route(ctx, () => dispatch(++index))); } return dispatch(0); // 預設執行一次 } handleRequest(req, res) { // 建立 ctx 上下文物件 let ctx = this.createContext(req, res);// ***************************** 以下為修改程式碼 ***************************** // 設定預設狀態碼(Koa 規定),必須在呼叫中介軟體之前 ctx.status = 404; // 執行 compose 將中介軟體組合在一起 this.compose(ctx, this.middlewares).then(() => { // 獲取最後 body 的值 let body = ctx.body; // 檢測 ctx.body 的型別,並使用對應的方式將值響應給瀏覽器 if (Buffer.isBuffer(body) || typeof body === 'string') { // 處理 Buffer 型別的資料 res.setHeader('Content-Type', 'text/plain;charset=utf8'); res.end(body); } else if (typeof body === 'object') { // 處理物件型別 res.setHeader('Content-Type', 'application/json;charset=utf8'); res.end(JSON.stringify(body)); } else if (body instanceof Stream) { // 處理流型別的資料 body.pipe(body); } else { res.end('Not Found'); } });// ***************************** 以上為修改程式碼 ***************************** } listen(...args) { // 建立服務 let server = http.createServer(this.handleRequest.bind(this)); // 啟動服務 server.listen(...args); }}module.exports = Koa;
處理 response 時,在 body 的 setter 中將狀態碼設定為了 200,就是說需要設定 ctx.body 去觸發 setter 讓響應成功,如果沒有給 ctx.body 設定任何值,預設應該是無響應的,在官方文件也有預設狀態碼為 404 的明確說明,所以在 handleRequest 把狀態碼設定為了 404,但必須在 compose 執行之前才叫預設狀態碼,因為中介軟體中可能會操作 ctx.body,重新設定狀態碼。
在 comose 的 then 中,也就是在所有中介軟體執行後,我們取出 ctx.body 的值,即為最後生效的響應值,對該值進行了資料型別驗證,如 Buffer、字串、物件和流,並分別用不同的方式處理了響應,但本質都是呼叫的原生 res 物件的 end 方法。
中介軟體錯誤處理
在上面的邏輯當中我們實現了很多 Koa 的核心邏輯,但是隻考慮了順利執行的情況,並沒有考慮如果中介軟體中程式碼執行出現錯誤的問題,如下面案例。
const Koa = require('koa');const app = new Koa();app.use((ctx, next) => { // 丟擲異常 throw new Error('Error');});// 新增 error 監聽app.on('error', err => { console.log(err);});app.listen(3000, () => { console.log('server start 3000');});
我們之所以讓 compose 方法在執行所有中介軟體後返回一個 Promise 還有一個更重要的意義,因為在 Promise 鏈式呼叫中,只要其中任何一個環節出現程式碼執行錯誤或丟擲異常,都會直接執行出現錯誤的 then 方法中錯誤的回撥或者最後的 catch 方法,對於 Koa 中介軟體的序列而言,最後一個 then 呼叫 catch 方法就是 compose 的返回值呼叫 then 後繼續呼叫的 catch,catch 內可以捕獲到任意一箇中間件執行時出現的錯誤。
檔案路徑:~koa/application.js
const http = require('http');const Stream = require('stream');// ***************************** 以下為新增程式碼 *****************************const EventEmitter = require('events');const httpServer = require('_http_server');// ***************************** 以上為新增程式碼 *****************************const context = require('./context');const request = require('./request');const response = require('./response');// ***************************** 以下為修改程式碼 *****************************// 繼承 EventEmitter 後可以用建立的例項 app 新增 error 監聽,可以通過 emit 觸發監聽class Koa extends EventEmitter { contructor() { supper();// ***************************** 以上為修改程式碼 ***************************** // 儲存中介軟體 this.middlewares = []; // 為了防止通過 this 修改屬性而導致影響原引入檔案的匯出物件,做一個繼承 this.context = Object.create(context); this.request = Object.create(request); this.response = Object.create(response); } use(fn) { // 將傳給 use 的函式存入陣列中 this.middlewares.push(fn); } createContext(req, res) { // 或取定義的上下文 let ctx = this.context; // 增加 request 和 response ctx.request = this.request; ctx.response = this.response; // 讓 ctx、request、response 都具有原生的 req 和 res ctx.req = ctx.request.req = ctx.response.req = req; ctx.res = ctx.response.res = ctx.request.res = res; // 返回上下文物件 return ctx; } compose(ctx, middles) { // 建立一個遞迴函式,引數為儲存中介軟體的索引,從 0 開始 function dispatch(index) { // 在所有中介軟體執行之後給 compose 返回一個 Promise(相容一箇中間件都沒寫的情況) if (index === middles.length) return Promise.resolve(); // 取出第 index 箇中間件函式 const route = middles[index]; // 為了相容中介軟體傳入的函式不是 async,一定要包裝成一個 Promise return Promise.resolve(route(ctx, () => dispatch(++index))); } return dispatch(0); // 預設執行一次 } handleRequest(req, res) { // 建立 ctx 上下文物件 let ctx = this.createContext(req, res); // 設定預設狀態碼(Koa 規定),必須在呼叫中介軟體之前 ctx.status = 404; // 執行 compose 將中介軟體組合在一起 this.compose(ctx, this.middlewares).then(() => { // 獲取最後 body 的值 let body = ctx.body; // 檢測 ctx.body 的型別,並使用對應的方式將值響應給瀏覽器 if (Buffer.isBuffer(body) || typeof body === 'string') { // 處理 Buffer 型別的資料 res.setHeader('Content-Type', 'text/plain;charset=utf8'); res.end(body); } else if (typeof body === 'object') { // 處理物件型別 res.setHeader('Content-Type', 'application/json;charset=utf8'); res.end(JSON.stringify(body)); } else if (body instanceof Stream) { // 處理流型別的資料 body.pipe(body); } else { res.end('Not Found'); }// ***************************** 以下為修改程式碼 ***************************** }).catch(err => { // 執行 error 事件 this.emit('error', err); // 設定 500 狀態碼 ctx.status = 500; // 返回狀態碼對應的資訊響應瀏覽器 res.end(httpServer.STATUS_CODES[ctx.status]); });// ***************************** 以上為修改程式碼 ***************************** } listen(...args) { // 建立服務 let server = http.createServer(this.handleRequest.bind(this)); // 啟動服務 server.listen(...args); }}module.exports = Koa;
在使用的案例當中,使用 app(即 Koa 建立的例項)監聽了一個 error 事件,當中間件執行錯誤時會觸發該監聽的回撥,這讓我們想起了 NodeJS 中一個重要的核心模組 events,這個模組幫我們提供了一個事件機制,通過 on 方法新增監聽,通過 emit 觸發監聽,所以我們引入了 events,並讓 Koa 類繼承了 events 匯入的 EventEmitter 類,此時 Koa 的例項就可以使用 EventEmitter 原型物件上的 on 和 emit 方法。
在 compose 執行後呼叫的 catch 中,通過例項呼叫了 emit,並傳入了事件型別 error 和錯誤物件,這樣就是實現了中介軟體的錯誤監聽,只要中介軟體執行出錯,就會執行案例中錯誤監聽的回撥。
讓引入的 Koa 直接指向 application.js
在上面我們實現了 Koa 大部分常用功能的核心邏輯,但還有一點美中不足,就是我們引入自己的簡易版 Koa 時,預設會查詢 koa 路徑下的 index.js,想要執行我們的 Koa 必須要使用路徑找到 application.js,程式碼如下。
現在的引入方式
1const Koa = require('./koa/application');
希望的引入方式
1const Koa = require('./koa');
我們更希望像直接引入指定 koa 資料夾,就可以找到 application.js 檔案並執行,這就需要我們在 koa 資料夾建立 package.json 檔案,並在動一點小小的 “手腳” 如下。
檔案路徑:~koa/package.js
{ . . . "main": "./application.js", . . .}
Koa 原理圖
在文章最後一節送給大家一張 Koa 執行的原理圖,這張圖片是準備寫這篇文章時在 Google 上發現的,覺得把 Koa 的整個流程表達的非常清楚,所以這裡拿來幫助大家理解 Koa 框架的原理和執行過程。
Koa 原理圖
之所以沒有在文章開篇放上這張圖是因為覺得在完全沒有了解過 Koa 的原理之前,可能有一部分小夥伴看這張圖會懵,會打消學習的積極性,因為本篇的目的就是帶著大家從零到有的,一步一步實現簡易版 Koa,梳理 Koa 的核心邏輯,如果你已經看到了這裡,是不是覺得這張圖出現的不早不晚,剛剛好。