KOA2 內部原理及模擬源碼

前言

Koa 2.x 版本是當下最流行的 NodeJS 框架,同時社區涌現出一大批圍繞 Koa 2.x 的中間件以及基於 Koa 2.x 封裝的企業級框架,如 egg.js,然而 Koa 自己的代碼卻很是精簡,精簡到全部文件的代碼去掉註釋後還不足 2000 行,本篇就圍繞着這 2000 行不到的代碼抽出核心邏輯進行分析,並壓縮成一版只有 200 行不到的簡易版 Koanode

Koa 分析過程

在下面的內容中,咱們將對 Koa 所使用的功能由簡入深的分析,首先會給出使用案例,而後根據使用方式,分析實現原理,最後對分析的功能進行封裝,封裝過程會從零開始並一步一步完善,代碼也是從少到多,會完整的看到一個簡版 Koa 誕生的過程,再此以前咱們打開 Koa 源碼地址git


Koa 文件目錄

經過上面對 Koa 源碼目錄的截圖,發現只有 4 個核心文件,爲了方便理解,封裝簡版 Koa 的文件目錄結構也將嚴格與源碼同步。github

搭建基本服務

在引入 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 的方式來實現。json

而從啓動服務的方式上看,app.listen 的調用方式與原生 http 模塊提供的 server.listen 幾乎相同,咱們分析,listen 方法應該是對原生 http 模塊的一個封裝,啓動服務的本質仍是靠 http 模塊來實現的。redux

文件路徑:~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 上下文對象,咱們能夠調用 ctxrequestresponse 屬性獲取原 reqres 的屬性和方法,也在 ctx 上增長了一些原生沒有的屬性和方法,總之 ctx 給咱們要操做的屬性和方法提供了多種調用方式,使用案例以下。promise

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 的引用關係

從上面咱們能夠看出,ctxuse 方法的第一個參數,requestresponsectx 新增的,而經過這兩個屬性又均可以獲取原生的 reqres 屬性,ctx 自己也能夠獲取到原生的 reqres,咱們能夠分析出,ctx 是對這些屬性作了一個集成,或者說特殊處理。瀏覽器

源碼的文件目錄中正好有與 requestresponse 名字相對應的文件,而且還有 context 名字的文件,咱們其實能夠分析出這三個文件就是用於封裝 ctx 上下文對象使用的,而封裝 ctx 中也會用到 reqres,因此核心邏輯應該在 handleRequest 中實現。bash

在使用案例中 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;複製代碼

首先,給實例建立了三個屬性 contextrequestresponse 分別繼承了 context.jsrequest.jsresponse.js 導出的對象,之因此這麼作而不是直接賦值是防止操做實例屬性時 「污染」 原對象,而獲取原模塊導出對象的屬性能夠經過原型鏈進行查找,並不影響取值。

其次,給實例掛載了 middlewares 屬性,值爲數組,爲了存儲 use 方法調用時傳入的函數,在 handleRequest 把建立 ctx 屬性及引用的過程單獨抽取成了 createContext 方法,並在 handleRequest中調用,返回值爲建立好的 ctx 對象,而在 createContext 中咱們根據案例中的規則構建了 ctx 的屬性相關的各類引用關係。

三、實現 request 取值

上面構建的屬性中,全部經過訪問原生 reqres 的屬性都能獲取到,反之則是 undefined,這就須要咱們去構建 request.js

文件路徑:~koa/request.js
const url = require("url");

// 給 url 和 path 添加 getter
const request = {
    get url() {
        return this.req.url;
    },
    get path() {
        return url.parse(this.req.url).pathname;
    }
};

module.exports = request;複製代碼

上面咱們只構造了兩個屬性 urlpath,咱們知道 url 是原生所自帶的屬性,咱們在使用 ctx.request.url 獲取是經過 request 對象設置的 getter,將 ctx.request.req.url 的值返回了。

path 是原生 req 所沒有的屬性,但倒是經過原生 requrl 屬性和 url 模塊共同構建出來的,因此咱們一樣用了給 request 對象設置 getter 的方式獲取 requrl 屬性,並使用 url 模塊將轉換對象中的 pathname 返回,此時就能夠經過 ctx.request.path 來獲取訪問路徑,至於源碼中咱們沒有處理的 req 屬性都是經過這樣的方式創建的引用關係。

四、實現 response 的取值和賦值

Koaresponse 對象的真正做用是給客戶端進行響應,使用時是經過訪問屬性獲取,並經過從新賦值實現響應,可是如今 response 獲取的屬性都是 undefined,咱們這裏先無論響應給瀏覽器的問題,首先要讓 response 下的某個屬性有值才行,下面咱們來實現 response.js

文件路徑:~koa/response.js
// 給 body 和 status 添加 getter 和 setter
const 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 的方式給 bodystatus 設置了 getter,不一樣的是響應瀏覽器所作的實際上是賦值操做,因此又給這兩個屬性添加了 setter,對於 status 來講,直接操做原生 res 對象的 statusCode 屬性便可,由於同爲賦值操做。

還有一點,響應是經過給 body 賦值實現,咱們認爲只要觸發了 bodysetter 就成功響應,因此在 bodygetter 中將響應狀態碼設置爲 200,至於 body 賦值是如何實現響應的,放在後面再說。

五、ctx 代理 request、response 的屬性

上面實現了經過 requestresponse 對屬性的操做,Koa 雖然給咱們提供了多樣的屬性操做方式,但因爲咱們程序猿(媛)們都很 「懶」,幾乎沒有人會在開發的時候願意多寫代碼,大部分狀況都是經過 ctx 直接操做 requestresponse 上的屬性,這就是咱們如今的問題所在,這些屬性經過 ctx 訪問不到。

咱們須要給 ctx 對象作一個代理,讓 ctx 能夠訪問到 requestresponse 上的屬性,這個場景何曾相識,不正是 Vue 建立實例時,將傳入參數對象 optionsdata 屬性代理給實例自己的場景嗎,既然如此,咱們也經過類似的方式實現,還記得上面引入的 context 模塊做爲實例的 context 屬性所繼承的對象,而剩下的最後一個核心文件 context.js 正是用來作這件事的,代碼以下。

文件路徑:~koa/context.js
const proto = {};

// 將傳入對象屬性代理給 ctx
function 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 代理給 ctx
defineGetter("request", "url");
defineGetter("request", "path");

// 將 response 的 body 和 status 代理給 ctx
defineGetter("response", "body");
defineSetter("response", "body");
defineGetter("response", "status");
defineSetter("response", "status");

module.exports = proto;複製代碼

Vue 中是使用 Object.defineProperty 來時實現的代理,而在 Koa 源碼中藉助了 delegate 第三方模塊來實現的,並在添加代理時鏈式調用了 delegate 封裝的方法,咱們並無直接使用 delegate 模塊,而是將 delegate 內部的核心邏輯抽取出來在 context.js 中直接編寫,這樣方便你們理解原理,也能夠清楚的知道是如何實現代理的。

咱們封裝了兩個方法 defineGetterdefineSetter 分別來實現取值和設置值時,將傳入的屬性(第二個參數)代理給傳入的對象(第一個參數),函數內是經過 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");
});

// 1
// 3
// 5
// 6
// 4 複製代碼

// 2根據上面的執行特性咱們不妨來分析如下,咱們知道 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 + 1)));
        }
        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 調用下面的代碼,這也就是 一、三、五、六、四、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 + 1)));
        }
        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(res);
            } else {
                res.end("Not Found");
            }
        });
// ***************************** 以上爲修改代碼 *****************************
    }
    listen(...args) {
        // 建立服務
        let server = http.createServer(this.handleRequest.bind(this));

        // 啓動服務
        server.listen(...args);
    }
}

module.exports = Koa;複製代碼

處理 response 時,在 bodysetter 中將狀態碼設置爲了 200,就是說須要設置 ctx.body 去觸發 setter 讓響應成功,若是沒有給 ctx.body 設置任何值,默認應該是無響應的,在官方文檔也有默認狀態碼爲 404 的明確說明,因此在 handleRequest 把狀態碼設置爲了 404,但必須在 compose 執行以前才叫默認狀態碼,由於中間件中可能會操做 ctx.body,從新設置狀態碼。

comosethen 中,也就是在全部中間件執行後,咱們取出 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 後繼續調用的 catchcatch 內能夠捕獲到任意一箇中間件執行時出現的錯誤。

文件路徑:~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 + 1)));
        }
        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(res);
            } 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 原型對象上的 onemit 方法。

compose 執行後調用的 catch 中,經過實例調用了 emit,並傳入了事件類型 error 和錯誤對象,這樣就是實現了中間件的錯誤監聽,只要中間件執行出錯,就會執行案例中錯誤監聽的回調。

讓引入的 Koa 直接指向 application.js

在上面咱們實現了 Koa 大部分經常使用功能的核心邏輯,但還有一點美中不足,就是咱們引入本身的簡易版 Koa時,默認會查找 koa 路徑下的 index.js,想要執行咱們的 Koa 必需要使用路徑找到 application.js,代碼以下。

如今的引入方式
const Koa = require("./koa/application");複製代碼
但願的引入方式
const Koa = require("./koa");複製代碼

咱們更但願像直接引入指定 koa 文件夾,就能夠找到 application.js 文件並執行,這就須要咱們在 koa 文件夾建立 package.json 文件,並在動一點小小的 「手腳」 以下。

文件路徑:~koa/package.js
{
    .
    .
    .
    "main": "./application.js",
    .
    .
    .
}複製代碼

Koa 原理圖

在文章最後一節送給你們一張 Koa 執行的原理圖,這張圖片是準備寫這篇文章時在 Google 上發現的,以爲把 Koa 的整個流程表達的很是清楚,因此這裏拿來幫助你們理解 Koa 框架的原理和執行過程。


Koa 原理圖

之因此沒有在文章開篇放上這張圖是由於以爲在徹底沒有了解過 Koa 的原理以前,可能有一部分小夥伴看這張圖會懵,會打消學習的積極性,由於本篇的目的就是帶着你們從零到有的,一步一步實現簡易版 Koa,梳理 Koa的核心邏輯,若是你已經看到了這裏,是否是以爲這張圖出現的不早不晚,剛恰好。

總結

最後仍是在這裏作一個總結,在 Koa 中主要的部分有 listen 建立服務器、封裝上下文對象 ctx 並代理屬性、use 方法添加中間件、compose 串行執行中間、讓 Koa 繼承 EventEmitter 實現錯誤監聽,而我我的以爲最重要的就是 compose,它是一個事件串行機制,也是實現 「洋蔥模型」 的核心,現在 compose 已經再也不只是一個方法名,而是一種編程思想,用於將多個程序串行在一塊兒,或同步,或異步,在 Koa 中自沒必要多說,由於你們已經見識過了,composeReact 中也起着串聯中間件的做用,如串聯 promiseredux-thunklogger 等,在 Webpack 源碼依賴的核心模塊 tapable 中也有所應用,在咱們的學習過程當中,這樣優秀的編程思想是應該重點吸取的。

原文出自:https://www.pandashen.com

相關文章
相關標籤/搜索