從頭實現一個koa框架

歡迎去個人博客觀看: https://github.com/mly-zju/blog/issues/9

koa.js是最流行的node.js後端框架之一,有不少網站都使用koa進行開發,同時社區也涌現出了一大批基於koa封裝的企業級框架。然而,在這些亮眼的成績背後,做爲核心引擎的koa代碼庫自己,卻很是的精簡,不得不讓人驚歎於其巧妙的設計。javascript

在平時的工做開發中,筆者是koa的重度用戶,所以對其背後的原理天然也是很是感興趣,所以在閒暇之餘進行了研究。不過本篇文章,並非源碼分析,而是從相反的角度,向你們展現如何從頭開發實現一個koa框架,在這個過程當中,koa中最重要的幾個概念和原理都會獲得展示。相信你們在看完本文以後,會對koa有一個更深刻的理解,同時在閱讀本文以後再去閱讀koa源碼,思路也將很是的順暢。java

首先放出筆者實現的這個koa框架代碼庫地址:simpleKoanode

須要說明的是,本文實現的koa是koa 2版本,也就是基於async/await的,所以須要node版本在7.6以上。若是讀者的node版本較低,建議升級,或者安裝babel-cli,利用其中的babel-node來運行例子。git

四條主線

筆者認爲,理解koa,主要須要搞懂四條主線,其實也是實現koa的四個步驟,分別是github

  1. 封裝node http Server
  2. 構造resquest, response, context對象
  3. 中間件機制
  4. 錯誤處理

下面就一一進行分析。json

主線一:封裝node http Server: 從hello world提及

首先,不考慮框架,若是使用原生http模塊來實現一個返回hello world的後端app,代碼以下:後端

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');
});

實現koa的第一步,就是對這個原生的過程進行封裝,爲此,咱們首先建立application.js實現一個Application對象:api

// application.js
let http = require('http');

class Application {

    /**
     * 構造函數
     */
    constructor() {
        this.callbackFunc;
    }

    /**
     * 開啓http server並傳入callback
     */
    listen(...args) {
        let server = http.createServer(this.callback());
        server.listen(...args);
    }

    /**
     * 掛載回調函數
     * @param {Function} fn 回調處理函數
     */
    use(fn) {
        this.callbackFunc = fn;
    }

    /**
     * 獲取http server所需的callback函數
     * @return {Function} fn
     */
    callback() {
        return (req, res) => {
            this.callbackFunc(req, res);
        };
    }

}

module.exports = Application;

而後建立example.js:數組

let simpleKoa = require('./application');
let app = new simpleKoa();

app.use((req, res) => {
    res.writeHead(200);
    res.end('hello world');
});

app.listen(3000, () => {
    console.log('listening on 3000');
});

能夠看到,咱們已經初步完成了對於http server的封裝,主要實現了app.use註冊回調函數,app.listen語法糖開啓server並傳入回調函數了,典型的koa風格。promise

可是美中不足的是,咱們傳入的回調函數,參數依然使用的是reqres,也就是node原生的request和response對象,這些原生對象和api提供的方法不夠便捷,不符合一個框架須要提供的易用性。所以,咱們須要進入第二條主線了。

主線二:構造request, response, context對象

若是閱讀koa文檔,會發現koa有三個重要的對象,分別是request, response, context。其中request是對node原生的request的封裝,response是對node原生response對象的封裝,context對象則是回調函數上下文對象,掛載了koa request和response對象。下面咱們一一來講明。

首先要明確的是,對於koa的request和response對象,只是提供了對node原生request和response對象的一些方法的封裝,明確了這一點,咱們的思路是,使用js的getter和setter屬性,基於node的對象req/res對象封裝koa的request/response對象。

規劃一下咱們要封裝哪些易用的方法。這裏在文章中爲了易懂,姑且只實現如下方法:

對於simpleKoa request對象,實現query讀取方法,可以讀取到url中的參數,返回一個對象。

對於simpleKoa response對象,實現status讀寫方法,分別是讀取和設置http response的狀態碼,以及body方法,用於構造返回信息。

而simpleKoa context對象,則掛載了request和response對象,並對一些經常使用方法進行了代理。

首先建立request.js:

// request.js
let url = require('url');

module.exports = {

    get query() {
        return url.parse(this.req.url, true).query;
    }

};

很簡單,就是導出了一個對象,其中包含了一個query的讀取方法,經過url.parse方法解析url中的參數,並以對象的形式返回。須要注意的是,代碼中的this.req表明的是node的原生request對象,this.req.url就是node原生request中獲取url的方法。稍後咱們修改application.js的時候,會爲koa的request對象掛載這個req。

而後建立response.js:

// response.js
module.exports = {

    get body() {
        return this._body;
    },

    /**
     * 設置返回給客戶端的body內容
     *
     * @param {mixed} data body內容
     */
    set body(data) {
        this._body = data;
    },

    get status() {
        return this.res.statusCode;
    },

    /**
     * 設置返回給客戶端的stausCode
     *
     * @param {number} statusCode 狀態碼
     */
    set status(statusCode) {
        if (typeof statusCode !== 'number') {
            throw new Error('statusCode must be a number!');
        }
        this.res.statusCode = statusCode;
    }

};

也很簡單。status讀寫方法分別設置或讀取this.res.statusCode。一樣的,這個this.res是掛載的node原生response對象。而body讀寫方法分別設置、讀取一個名爲this._body的屬性。這裏設置body的時候並無直接調用this.res.end來返回信息,這是考慮到koa當中咱們可能會屢次調用response的body方法覆蓋性設置數據。真正的返回消息操做會在application.js中存在。

而後咱們建立context.js文件,構造context對象的原型:

// context.js
module.exports = {

    get query() {
        return this.request.query;
    },

    get body() {
        return this.response.body;
    },

    set body(data) {
        this.response.body = data;
    },

    get status() {
        return this.response.status;
    },

    set status(statusCode) {
        this.response.status = statusCode;
    }

};

能夠看到主要是作一些經常使用方法的代理,經過context.query直接代理了context.request.querycontext.bodycontext.status代理了context.response.bodycontext.response.status。而context.requestcontext.response則會在application.js中掛載。

因爲context對象定義比較簡單而且規範,當實現更多代理方法時候,這樣一個一個經過聲明的方式顯然有點笨,js中,設置setter/getter,能夠經過對象的__defineSetter____defineSetter__來實現。爲此,咱們精簡了上面的context.js實現方法,精簡版本以下:

let proto = {};

// 爲proto名爲property的屬性設置setter
function delegateSet(property, name) {
    proto.__defineSetter__(name, function (val) {
        this[property][name] = val;
    });
}

// 爲proto名爲property的屬性設置getter
function delegateGet(property, name) {
    proto.__defineGetter__(name, function () {
        return this[property][name];
    });
}

// 定義request中要代理的setter和getter
let requestSet = [];
let requestGet = ['query'];

// 定義response中要代理的setter和getter
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;

這樣,當咱們但願代理更多request和response方法的時候,能夠直接向requestGet/requestSet/responseGet/responseSet數組中添加method的名稱便可(前提是在request和response中實現了)。

最後讓咱們來修改application.js,基於剛纔的3個對象原型來建立request, response, context對象:

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

class Application {

    /**
     * 構造函數
     */
    constructor() {
        this.callbackFunc;
        this.context = context;
        this.request = request;
        this.response = response;
    }

    /**
     * 開啓http server並傳入callback
     */
    listen(...args) {
        let server = http.createServer(this.callback());
        server.listen(...args);
    }

    /**
     * 掛載回調函數
     * @param {Function} fn 回調處理函數
     */
    use(fn) {
        this.callbackFunc = fn;
    }

    /**
     * 獲取http server所需的callback函數
     * @return {Function} fn
     */
    callback() {
        return (req, res) => {
            let ctx = this.createContext(req, res);
            let respond = () => this.responseBody(ctx);
            this.callbackFunc(ctx).then(respond);
        };
    }

    /**
     * 構造ctx
     * @param {Object} req node req實例
     * @param {Object} res node res實例
     * @return {Object} ctx實例
     */
    createContext(req, res) {
        // 針對每一個請求,都要建立ctx對象
        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;
    }

    /**
     * 對客戶端消息進行回覆
     * @param {Object} ctx ctx實例
     */
    responseBody(ctx) {
        let content = ctx.body;
        if (typeof content === 'string') {
            ctx.res.end(content);
        }
        else if (typeof content === 'object') {
            ctx.res.end(JSON.stringify(content));
        }
    }

}

能夠看到,最主要的是增長了createContext方法,基於咱們以前建立的context 爲原型,使用Object.create(this.context)方法建立了ctx,並一樣經過Object.create(this.request)Object.create(this.response)建立了request/response對象並掛在到了ctx對象上面。此外,還將原生node的req/res對象掛載到了ctx.request.req/ctx.reqctx.response.res/ctx.res對象上。

回過頭去看咱們以前的context/request/response.js文件,就能知道當時使用的this.res或者this.response之類的是從哪裏來的了,原來是在這個createContext方法中掛載到了對應的實例上。一張圖來講明其中的關係:

構建了運行時上下文ctx以後,咱們的app.use回調函數參數就都基於ctx了。

下面一張圖描述了ctx對象的結構和繼承關係:

clipboard.png

最後回憶咱們的ctx.body方法,並無直接返回消息體,而是將消息存儲在了一個變量屬性中。爲了每次回調函數處理結束以後返回消息,咱們建立了responseBody方法,主要做用就是經過ctx.body讀取存儲的消息,而後調用ctx.res.end返回消息並關閉鏈接。從方法中知道,咱們的body消息體能夠是字符串,也能夠是對象(會序列化爲字符串返回)。注意這個方法的調用是在回調函數結束以後調用的,而咱們的回調函數是一個async函數,其執行結束後會返回一個Promise對象,所以咱們只須要在其後經過.then方法調用咱們的responseBody便可,這就是this.callbackFunc(ctx).then(respond)的意義。

而後咱們來測試一下目前爲止的框架。修改example.js以下:

let simpleKoa = require('./application');
let app = new simpleKoa();

app.use(async ctx => {
    ctx.body = 'hello ' + ctx.query.name;
});

app.listen(3000, () => {
    console.log('listening on 3000');
});

能夠看到這個時候咱們經過app.use傳入的已經再也不是原生的function (req, res)回調函數,而是koa2中的async函數,接收ctx做爲參數。爲了測試,在瀏覽器訪問localhost:3000?name=tom,能夠看到返回了'hello tom',符合預期。

這裏再插入分析一個知識概念。從剛纔的實現中,咱們知道了this.context是咱們的中間件中上下文ctx對象的原型。所以在實際開發中,咱們能夠將一些經常使用的方法掛載到this.context上面,這樣,在中間件ctx中,咱們也能夠方便的使用這些方法了,這個概念就叫作ctx的擴展,一個例子是阿里的egg.js框架已經把這個擴展機制做爲一部分,融入到了框架開發中。

下面就展現一個例子,咱們寫一個echoData的方法做爲擴展,傳入errno, data, errmsg,可以給客戶端返回結構化的消息結果:

let SimpleKoa = require('./application');
let app = new SimpleKoa();

// 對ctx進行擴展
app.context.echoData = function (errno = 0, data = null, errmsg = '') {
    this.res.setHeader('Content-Type', 'application/json;charset=utf-8');
    this.body = {
        errno: errno,
        data: data,
        errmsg: errmsg
    };
};

app.use(async ctx => {
    let data = {
        name: 'tom',
        age: 16,
        sex: 'male'
    }
    // 這裏使用擴展,方便的返回utf-8格式編碼,帶有errno和errmsg的消息體
    ctx.echoData(0, data, 'success');
});

app.listen(3000, () => {
    console.log('listenning on 3000');
});

主線三:中間件機制

到目前爲止,咱們成功封裝了http server,並構造了context, request, response對象。但最重要的一條主線卻尚未實現,那就是koa的中間件機制。

關於koa的中間件洋蔥執行模型,koa 1中使用的是generator + co.js執行的方式,koa 2中則使用了async/await。關於koa 1中的中間件原理,我曾寫過一篇文章進行解釋,請移步:深刻探析koa之中間件流程控制篇

這裏咱們實現的是基於koa 2的,所以再描述一下原理。爲了便於理解,假設咱們有3個async函數:

async function m1(next) {
    console.log('m1');
    await next();
}

async function m2(next) {
    console.log('m2');
    await next();
}

async function m3() {
    console.log('m3');
}

咱們但願可以構造出一個函數,實現的效果是讓三個函數依次執行。首先考慮想讓m2執行完畢後,await next()去執行m3函數,那麼顯然,須要構造一個next函數,做用是調用m3,而後做爲參數傳給m2

let next1 = async function () {
    await m3();
}

m2(next1);

// 輸出:m2,m3

進一步,考慮從m1開始執行,那麼,m1的next參數須要是一個執行m2的函數,而且給m2傳入的參數是m3,下面來模擬:

let next1 = async function () {
    await m3();
}

let next2 = async function () {
    await m2(next1);
}

m1(next2);

// 輸出:m1,m2,m3

那麼對於n個async函數,但願他們按順序依次執行呢?能夠看到,產生nextn的過程可以抽象爲一個函數:

function createNext(middleware, oldNext) {
    return async function () {
        await middleware(oldNext);
    }
}

let next1 = createNext(m3, null);
let next2 = createNext(m2, next1);
let next3 = createNext(m1, next2);

next3();

// 輸出m1, m2, m3

進一步精簡:

let middlewares = [m1, m2, m3];
let len = middlewares.length;

// 最後一箇中間件的next設置爲一個當即resolve的promise函數
let next = async function () {
    return Promise.resolve();
}
for (let i = len - 1; i >= 0; i--) {
    next = createNext(middlewares[i], next);
}

next();

// 輸出m1, m2, m3

至此,咱們也有了koa中間件機制實現的思路,新的application.js以下:

/**
 * @file simpleKoa application對象
 */
let http = require('http');
let context = require('./context');
let request = require('./request');
let response = require('.//response');

class Application {

    /**
     * 構造函數
     */
    constructor() {
        this.middlewares = [];
        this.context = context;
        this.request = request;
        this.response = response;
    }

    // ...省略中間 

    /**
     * 中間件掛載
     * @param {Function} middleware 中間件函數
     */
    use(middleware) {
        this.middlewares.push(middleware);
    }

    /**
     * 中間件合併方法,將中間件數組合併爲一箇中間件
     * @return {Function}
     */
    compose() {
        // 將middlewares合併爲一個函數,該函數接收一個ctx對象
        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();
        };
    }

    /**
     * 獲取http server所需的callback函數
     * @return {Function} fn
     */
    callback() {
        return (req, res) => {
            let ctx = this.createContext(req, res);
            let respond = () => this.responseBody(ctx);
            let fn = this.compose();
            return fn(ctx).then(respond);
        };
    }

    // ...省略後面 

}

module.exports = Application;

能夠看到,首先對app.use進行改造了,每次調用app.use,就向this.middlewares中push一個回調函數。而後增長了一個compose()方法,利用咱們前文分析的原理,對middlewares數組中的函數進行組裝,返回一個最終的函數。最後,在callback()方法中,調用compose()獲得最終回調函數,並執行。

改寫example.js驗證一下中間件機制:

let simpleKoa = require('./application');
let app = new simpleKoa();

let responseData = {};

app.use(async (ctx, next) => {
    responseData.name = 'tom';
    await next();
    ctx.body = responseData;
});

app.use(async (ctx, next) => {
    responseData.age = 16;
    await next();
});

app.use(async ctx => {
    responseData.sex = 'male';
});

app.listen(3000, () => {
    console.log('listening on 3000');
});

// 返回{ name: "tom", age: 16, sex: "male"}

例子中一共三個中間件,分別對responseData增長了name, age, sex屬性,最後返回該數據。

至此,一個koa框架基本已經浮出水面了,不過咱們還須要進行最後一個主線的分析:錯誤處理。

主線四:錯誤處理

一個健壯的框架,必須保證在發生錯誤的時候,可以捕獲錯誤並有降級方案返回給客戶端。但顯然如今咱們的框架還作不到這一點,假設咱們修改一下例子,咱們的中間件中,有一個發生錯誤拋出了異常:

let simpleKoa = require('./application');
let app = new simpleKoa();

let responseData = {};
app.use(async (ctx, next) => {
    responseData.name = 'tom';
    await next();
    ctx.body = responseData;
});

app.use(async (ctx, next) => {
    responseData.age = 16;
    await next();
});

app.use(async ctx => {
    responseData.sex = 'male';
    // 這裏發生了錯誤,拋出了異常
    throw new Error('oooops');
});

app.listen(3000, () => {
    console.log('listening on 3000');
});

這個時候訪問瀏覽器,是得不到任何響應的,這是由於異常並無被咱們的框架捕獲並進行降級處理。回顧咱們application.js中的中間件執行代碼:

// application.js
// ...
    callback() {
        return (req, res) => {
            let ctx = this.createContext(req, res);
            let respond = () => this.responseBody(ctx);
            let fn = this.compose();
            return fn(ctx).then(respond);
        };
    }
// ...

其中咱們知道,fn是一個async函數,執行後返回一個promise,回想promise的錯誤處理是怎樣的?沒錯,咱們只須要定義一個onerror函數,裏面進行錯誤發生時候的降級處理,而後在promise的catch方法中引用這個函數便可。

於此同時,回顧koa框架,咱們知道在錯誤發生的時候,app對象能夠經過app.on('error', callback)訂閱錯誤事件,這有助於咱們幾種處理錯誤,好比打印日誌之類的操做。爲此,咱們也要對Application對象進行改造,讓其繼承nodejs中的events對象,而後在onerror方法中emit錯誤事件。改造後的application.js以下:

/**
 * @file simpleKoa application對象
 */

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

class Application extends EventEmitter {

    /**
     * 構造函數
     */
    constructor() {
        super();
        this.middlewares = [];
        this.context = context;
        this.request = request;
        this.response = response;
    }

    // ...

    /**
     * 獲取http server所需的callback函數
     * @return {Function} fn
     */
    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();
            // 在這裏catch異常,調用onerror方法處理異常
            return fn(ctx).then(respond).catch(onerror);
        };
    }

    // ... 

    /**
     * 錯誤處理
     * @param {Object} err Error對象
     * @param {Object} ctx ctx實例
     */
    onerror(err, ctx) {
        if (err.code === 'ENOENT') {
            ctx.status = 404;
        }
        else {
            ctx.status = 500;
        }
        let msg = err.message || 'Internal error';
        ctx.res.end(msg);
        // 觸發error事件
        this.emit('error', err);
    }

}

module.exports = Application;

能夠看到,onerror方法的對異常的處理主要是獲取異常狀態碼,當err.code爲'ENOENT'的時候,返回的消息頭設置爲404,不然默認設置爲500,而後消息體設置爲err.message,若是異常中message屬性爲空,則默認消息體設置爲'Internal error'。此後調用ctx.res.end返回消息,這樣就能保證即便異常狀況下,客戶端也能收到返回值。最後經過this.emit出發error事件。

而後咱們寫一個example來驗證錯誤處理:

let simpleKoa = require('./application');
let app = new simpleKoa();

app.use(async ctx => {
    throw new Error('ooops');
});

app.on('error', (err) => {
    console.log(err.stack);
});

app.listen(3000, () => {
    console.log('listening on 3000');
});

瀏覽器訪問'localhost:3000'的時候,獲得返回'ooops',同時http狀態碼爲500 。同時app.on('error')訂閱到了異常事件,在回調函數中打印出了錯誤棧信息。

關於錯誤處理,這裏多說一點。雖然koa中內置了錯誤處理機制,可是實際業務開發中,咱們每每但願可以自定義錯誤處理方式,這個時候,比較好的辦法是在最開頭增長一個錯誤捕獲中間件,而後根據錯誤進行定製化的處理,好比:

// 錯誤處理中間件
app.use(async (ctx, next) => {
    try {
        await next();
    }
    catch (err) {
        // 在這裏進行定製化的錯誤處理
    }
});
// ...其餘中間件

至此,咱們就完整實現了一個輕量版的koa框架。

結語

完整的simpleKoa代碼庫地址爲:simpleKoa,裏面還附帶了一些example。

理解了這個輕量版koa的實現原理,讀者還能夠去看看koa的源碼,會發現機制和咱們實現的框架是很是相似的,無非是多了一些細節,好比說,完整koa的context/request/response方法上面掛載了更多好用的method,或者不少方法中容錯處理更好等等。具體在本文中就不展開講了,留給感興趣的讀者去探索吧~。

相關文章
相關標籤/搜索