1. 程式人生 > >KOA2框架原理解析和實現

KOA2框架原理解析和實現

什麼是koa框架?

koa是一個基於node實現的一個新的web框架,它是由express框架的原班人馬打造的。它的特點是優雅、簡潔、表達力強、自由度高。它更express相比,它是一個更輕量的node框架,因為它所有功能都通過外掛實現,這種插拔式的架構設計模式,很符合unix哲學。

koa框架現在更新到了2.x版本,本文從零開始,循序漸進,講解koa2的框架原始碼結構和實現原理,展示和詳解koa2框架原始碼中的幾個最重要的概念,然後手把手教大家親自實現一個簡易的koa2框架,幫助大家學習和更深層次的理解koa2,看完本文以後,再去對照koa2的原始碼進行檢視,相信你的思路將會非常的順暢。

本文所用的框架是koa2,它跟koa1不同,koa1使用的是generator+co.js的執行方式,而koa2中使用了async/await,因此本文的程式碼和demo需要執行在node 8版本及其以上,如果讀者的node版本較低,建議升級或者安裝babel-cli,用其中的babel-node來執行本文涉及到的程式碼。

koa原始碼結構

上圖是koa2的原始碼目錄結構的lib資料夾,lib資料夾下放著四個koa2的核心檔案:application.js、context.js、request.js、response.js。

application.js

application.js是koa的入口檔案,它向外匯出了建立class例項的建構函式,它繼承了events,這樣就會賦予框架事件監聽和事件觸發的能力。application還暴露了一些常用的api,比如toJSON、listen、use等等。

listen的實現原理其實就是對http.createServer進行了一個封裝,重點是這個函式中傳入的callback,它裡面包含了中介軟體的合併,上下文的處理,對res的特殊處理。

use是收集中介軟體,將多箇中間件放入一個快取佇列中,然後通過koa-compose這個外掛進行遞迴組合呼叫這一些列的中介軟體。

context.js

這部分就是koa的應用上下文ctx,其實就一個簡單的物件暴露,裡面的重點在delegate,這個就是代理,這個就是為了開發者方便而設計的,比如我們要訪問ctx.repsponse.status但是我們通過delegate,可以直接訪問ctx.status訪問到它。

request.js、response.js

這兩部分就是對原生的res、req的一些操作了,大量使用es6的get和set的一些語法,去取headers或者設定headers、還有設定body等等,這些就不詳細介紹了,有興趣的讀者可以自行看原始碼。

實現koa2的四大模組

上文簡述了koa2原始碼的大體框架結構,接下來我們來實現一個koa2的框架,筆者認為理解和實現一個koa框架需要實現四個大模組,分別是:

  • 封裝node http server、建立Koa類建構函式

  • 構造request、response、context物件

  • 中介軟體機制和剝洋蔥模型的實現

  • 錯誤捕獲和錯誤處理

下面我們就逐一分析和實現。

模組一:封裝node http server和建立Koa類建構函式

閱讀koa2的原始碼得知,實現koa的伺服器應用和埠監聽,其實就是基於node的原生程式碼進行了封裝,如下圖的程式碼就是通過node原生程式碼實現的伺服器監聽。

let http = require('http');
let server = http.createServer((req, res) => {
    res.writeHead(200);
    res.end('hello world');
});
server.listen(3000, () => {    
    console.log('listenning on 3000');
});
複製程式碼

我們需要將上面的node原生程式碼封裝實現成koa的模式:

const http = require('http');
const Koa = require('koa');
const app = new Koa();
app.listen(3000);
複製程式碼

實現koa的第一步就是對以上的這個過程進行封裝,為此我們需要建立application.js實現一個Application類的建構函式:

let http = require('http');
class Application {    
    constructor() {        
        this.callbackFunc;
    }
    listen(port) {        
        let server = http.createServer(this.callback());
        server.listen(port);
    }
    use(fn) {
        this.callbackFunc = fn;
    }
    callback() {
        return (req, res) => {
            this.callbackFunc(req, res);
        };
    }
}
module.exports = Application;
複製程式碼

然後建立example.js,引入application.js,執行伺服器例項啟動監聽程式碼:

let Koa = require('./application');
let app = new Koa();
app.use((req, res) => {
    res.writeHead(200);
    res.end('hello world');
});
app.listen(3000, () => {
    console.log('listening on 3000');
});
複製程式碼

現在在瀏覽器輸入localhost:3000即可看到瀏覽器裡顯示“hello world”。現在第一步我們已經完成了,對http server進行了簡單的封裝和建立了一個可以生成koa例項的類class,這個類裡還實現了app.use用來註冊中介軟體和註冊回撥函式,app.listen用來開啟伺服器例項並傳入callback回撥函式,第一模組主要是實現典型的koa風格和搭好了一個koa的簡單的架子。接下來我們開始編寫和講解第二模組。

模組二:構造request、response、context物件

閱讀koa2的原始碼得知,其中context.js、request.js、response.js三個檔案分別是request、response、context三個模組的程式碼檔案。context就是我們平時寫koa程式碼時的ctx,它相當於一個全域性的koa例項上下文this,它連線了request、response兩個功能模組,並且暴露給koa的例項和中介軟體等回撥函式的引數中,起到承上啟下的作用。

request、response兩個功能模組分別對node的原生request、response進行了一個功能的封裝,使用了getter和setter屬性,基於node的物件req/res物件封裝koa的request/response物件。我們基於這個原理簡單實現一下request.js、response.js,首先建立request.js檔案,然後寫入以下程式碼:

let url = require('url');
module.exports = {
    get query() {
        return url.parse(this.req.url, true).query;
    }
};
複製程式碼

這樣當你在koa例項裡使用ctx.query的時候,就會返回url.parse(this.req.url, true).query的值。看原始碼可知,基於getter和setter,在request.js裡還封裝了header、url、origin、path等方法,都是對原生的request上用getter和setter進行了封裝,筆者不再這裡一一實現。

接下來我們實現response.js檔案程式碼模組,它和request原理一樣,也是基於getter和setter對原生response進行了封裝,那我們接下來通過對常用的ctx.body和ctx.status這個兩個語句當做例子簡述一下如果實現koa的response的模組,我們首先建立好response.js檔案,然後輸入下面的程式碼:

module.exports = {
    get body() {
        return this._body;
    },
    set body(data) {
        this._body = data;
    },
    get status() {
        return this.res.statusCode;
    },
    set status(statusCode) {
        if (typeof statusCode !== 'number') {
            throw new Error('something wrong!');
        }
        this.res.statusCode = statusCode;
    }
};
複製程式碼

以上程式碼實現了對koa的status的讀取和設定,讀取的時候返回的是基於原生的response物件的statusCode屬性,而body的讀取則是對this._body進行讀寫和操作。這裡對body進行操作並沒有使用原生的this.res.end,因為在我們編寫koa程式碼的時候,會對body進行多次的讀取和修改,所以真正返回瀏覽器資訊的操作是在application.js裡進行封裝和操作。

現在我們已經實現了request.js、response.js,獲取到了request、response物件和他們的封裝的方法,然後我們開始實現context.js,context的作用就是將request、response物件掛載到ctx的上面,讓koa例項和程式碼能方便的使用到request、response物件中的方法。現在我們建立context.js檔案,輸入如下程式碼:

let proto = {};

function delegateSet(property, name) {
    proto.__defineSetter__(name, function (val) {
        this[property][name] = val;
    });
}

function delegateGet(property, name) {
    proto.__defineGetter__(name, function () {
        return this[property][name];
    });
}

let requestSet = [];
let requestGet = ['query'];

let responseSet = ['body', 'status'];
let responseGet = responseSet;

requestSet.forEach(ele => {
    delegateSet('request', ele);
});

requestGet.forEach(ele => {
    delegateGet('request', ele);
});

responseSet.forEach(ele => {
    delegateSet('response', ele);
});

responseGet.forEach(ele => {
    delegateGet('response', ele);
});

module.exports = proto;
複製程式碼

context.js檔案主要是對常用的request和response方法進行掛載和代理,通過context.query直接代理了context.request.query,context.body和context.status代理了context.response.body與context.response.status。而context.request,context.response則會在application.js中掛載

本來可以用簡單的setter和getter去設定每一個方法,但是由於context物件定義方法比較簡單和規範,在koa原始碼裡可以看到,koa原始碼用的是__defineSetter__和__defineSetter__來代替setter/getter每一個屬性的讀取設定,這樣做主要是方便拓展和精簡了寫法,當我們需要代理更多的res和req的方法的時候,可以向context.js檔案裡面的陣列物件裡面新增對應的方法名和屬性名即可。

目前為止,我們已經得到了request、response、context三個模組物件了,接下來就是將request、response所有方法掛載到context下,讓context實現它的承上啟下的作用,修改application.js檔案,新增如下程式碼:

let http = require('http');
let context = require('./context');
let request = require('./request');
let response = require('./response');

createContext(req, res) {       
   let ctx = Object.create(this.context);
   ctx.request = Object.create(this.request);
   ctx.response = Object.create(this.response);
   ctx.req = ctx.request.req = req;
   ctx.res = ctx.response.res = res; 
   return ctx;
}
複製程式碼

可以看到,我們添加了createContext這個方法,這個方法是關鍵,它通過Object.create建立了ctx,並將request和response掛載到了ctx上面,將原生的req和res掛載到了ctx的子屬性上,往回看一下context/request/response.js檔案,就能知道當時使用的this.res或者this.response之類的是從哪裡來的了,原來是在這個createContext方法中掛載到了對應的例項上,構建了執行時上下文ctx之後,我們的app.use回撥函式引數就都基於ctx了。

模組三:中介軟體機制和剝洋蔥模型的實現

目前為止我們已經成功實現了上下文context物件、 請求request物件和響應response物件模組,還差一個最重要的模組,就是koa的中介軟體模組,koa的中介軟體機制是一個剝洋蔥式的模型,多箇中間件通過use放進一個數組佇列然後從外層開始執行,遇到next後進入佇列中的下一個中介軟體,所有中介軟體執行完後開始回幀,執行佇列中之前中介軟體中未執行的程式碼部分,這就是剝洋蔥模型,koa的中介軟體機制。

koa的剝洋蔥模型在koa1中使用的是generator + co.js去實現的,koa2則使用了async/await + Promise去實現的,接下來我們基於async/await + Promise去實現koa2中的中介軟體機制。首先,假設當koa的中介軟體機制已經做好了,那麼它是能成功執行下面程式碼的:

let Koa = require('../src/application');

let app = new Koa();

app.use(async (ctx, next) => {
    console.log(1);
    await next();
    console.log(6);
});

app.use(async (ctx, next) => {
    console.log(2);
    await next();
    console.log(5);
});

app.use(async (ctx, next) => {
    console.log(3);
    ctx.body = "hello world";
    console.log(4);
});

app.listen(3000, () => {
    console.log('listenning on 3000');
});
複製程式碼

執行成功後會在終端輸出123456,那就能驗證我們的koa的剝洋蔥模型是正確的。接下來我們開始實現,修改application.js檔案,新增如下程式碼:

    compose() {
        return async ctx => {
            function createNext(middleware, oldNext) {
                return async () => {
                    await middleware(ctx, oldNext);
                }
            }
            let len = this.middlewares.length;
            let next = async () => {
                return Promise.resolve();
            };
            for (let i = len - 1; i >= 0; i--) {
                let currentMiddleware = this.middlewares[i];
                next = createNext(currentMiddleware, next);
            }
            await next();
        };
    }

    callback() {
        return (req, res) => {
            let ctx = this.createContext(req, res);
            let respond = () => this.responseBody(ctx);
            let onerror = (err) => this.onerror(err, ctx);
            let fn = this.compose();
            return fn(ctx);
        };
    }
複製程式碼

koa通過use函式,把所有的中介軟體push到一個內部陣列佇列this.middlewares中,剝洋蔥模型能讓所有的中介軟體依次執行,每次執行完一箇中間件,遇到next()就會將控制權傳遞到下一個中介軟體,下一個中介軟體的next引數,剝洋蔥模型的最關鍵程式碼是compose這個函式:

compose() {
        return async ctx => {
            function createNext(middleware, oldNext) {
                return async () => {
                    await middleware(ctx, oldNext);
                }
            }
            let len = this.middlewares.length;
            let next = async () => {
                return Promise.resolve();
            };
            for (let i = len - 1; i >= 0; i--) {
                let currentMiddleware = this.middlewares[i];
                next = createNext(currentMiddleware, next);
            }
            await next();
        };
    }
複製程式碼

createNext函式的作用就是將上一個中介軟體的next當做引數傳給下一個中介軟體,並且將上下文ctx綁定當前中介軟體,當中間件執行完,呼叫next()的時候,其實就是去執行下一個中介軟體。

for (let i = len - 1; i >= 0; i--) {
        let currentMiddleware = this.middlewares[i];
        next = createNext(currentMiddleware, next);
 }
複製程式碼

上面這段程式碼其實就是一個鏈式反向遞迴模型的實現,i是從最大數開始迴圈的,將中介軟體從最後一個開始封裝,每一次都是將自己的執行函式封裝成next當做上一個中介軟體的next引數,這樣當迴圈到第一個中介軟體的時候,只需要執行一次next(),就能鏈式的遞迴呼叫所有中介軟體,這個就是koa剝洋蔥的核心程式碼機制。

到這裡我們總結一下上面所有剝洋蔥模型程式碼的流程,通過use傳進來的中介軟體是一個回撥函式,回撥函式的引數是ctx上下文和next,next其實就是控制權的交接棒,next的作用是停止運行當前中介軟體,將控制權交給下一個中介軟體,執行下一個中介軟體的next()之前的程式碼,當下一箇中間件執行的程式碼遇到了next(),又會將程式碼執行權交給下下箇中間件,當執行到最後一箇中間件的時候,控制權發生反轉,開始回頭去執行之前所有中介軟體中剩下未執行的程式碼,這整個流程有點像一個偽遞迴,當最終所有中介軟體全部執行完後,會返回一個Promise物件,因為我們的compose函式返回的是一個async的函式,async函式執行完後會返回一個Promise,這樣我們就能將所有的中介軟體非同步執行同步化,通過then就可以執行響應函式和錯誤處理函式。

當中間件機制程式碼寫好了以後,執行我們的上面的例子,已經能輸出123456了,至此,我們的koa的基本框架已經基本做好了,不過一個框架不能只實現功能,為了框架和伺服器例項的健壯,還需要加上錯誤處理機制。

模組四:錯誤捕獲和錯誤處理

要實現一個基礎框架,錯誤處理和捕獲必不可少,一個健壯的框架,必須保證在發生錯誤的時候,能夠捕獲到錯誤和丟擲的異常,並反饋出來,將錯誤資訊傳送到監控系統上進行反饋,目前我們實現的簡易koa框架還沒有能實現這一點,我們接下加上錯誤處理和捕獲的機制。

throw new Error('oooops');
複製程式碼

基於現在的框架,如果中介軟體程式碼中出現如上錯誤異常丟擲,是捕獲不到錯誤的,這時候我們看一下application.js中的callback函式的return返回程式碼,如下:

return fn(ctx).then(respond);
複製程式碼

可以看到,fn是中介軟體的執行函式,每一箇中間件程式碼都是由async包裹著的,而且中介軟體的執行函式compose返回的也是一個async函式,我們根據es7的規範知道,async返回的是一個promise的物件例項,我們如果想要捕獲promise的錯誤,只需要使用promise的catch方法,就可以把所有的中介軟體的異常全部捕獲到,修改後callback的返回程式碼如下:

return fn(ctx).then(respond).catch(onerror);
複製程式碼

現在我們已經實現了中介軟體的錯誤異常捕獲,但是我們還缺少框架層發生錯誤的捕獲機制,我們希望我們的伺服器例項能有錯誤事件的監聽機制,通過on的監聽函式就能訂閱和監聽框架層面上的錯誤,實現這個機制不難,使用nodejs原生events模組即可,events模組給我們提供了事件監聽on函式和事件觸發emit行為函式,一個發射事件,一個負責接收事件,我們只需要將koa的建構函式繼承events模組即可,構造後的虛擬碼如下:

let EventEmitter = require('events');
class Application extends EventEmitter {}
複製程式碼

繼承了events模組後,當我們建立koa例項的時候,加上on監聽函式,程式碼如下:

let app = new Koa();

app.on('error', err => {
    console.log('error happends: ', err.stack);
});
複製程式碼

這樣我們就實現了框架層面上的錯誤的捕獲和監聽機制了。總結一下,錯誤處理和捕獲,分中介軟體的錯誤處理捕獲和框架層的錯誤處理捕獲,中介軟體的錯誤處理用promise的catch,框架層面的錯誤處理用nodejs的原生模組events,這樣我們就可以把一個伺服器例項上的所有的錯誤異常全部捕獲到了。至此,我們就完整實現了一個輕量版的koa框架了。

結尾

前為止,我們已經實現了一個輕量版的koa框架了,我們實現了封裝node http server、建立Koa類建構函式、構造request、response、context物件、中介軟體機制和剝洋蔥模型的實現、錯誤捕獲和錯誤處理這四個大模組,理解了這個輕量版koa的實現原理,再去看koa2的原始碼,你就會發現一切都豁然開朗,koa2的原始碼無非就是在這個輕量版基礎上加了很多工具函式和細節的處理,限於篇幅筆者就不再一一介紹了。


《IVWEB 技術週刊》 震撼上線了,關注公眾號:IVWEB社群,每週定時推送優質文章。

週刊文章集合: weekly