Koa2 中間件原理解析 —— 看了就會寫

在這裏插入圖片描述


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


前言

Koa 2.x 版本是當下最流行的 NodeJS 框架,Koa 2.0 的源碼特別精簡,不像 Express 封裝的功能那麼多,所以大部分的功能都是由 Koa 開發團隊(同 Express 是一家出品)和社區貢獻者針對 Koa 對 NodeJS 的封裝特性實現的中間件來提供的,用法非常簡單,就是引入中間件,並調用 Koause 方法使用在對應的位置,這樣就可以通過在內部操作 ctx 實現一些功能,我們接下來就討論常用中間件的實現原理以及我們應該如何開發一個 Koa 中間件供自己和別人使用。


Koa 的洋蔥模型介紹

我們本次不對洋蔥模型的實現原理進行過多的刨析,主要根據 API 的使用方式及洋蔥模型分析中間件是如何工作的。

// 洋蔥模型特點
// 引入 Koa
const Koa = require("koa");

// 創建服務
const app = new Koa();

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

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

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

// 監聽服務
app.listen(3000);

// 1
// 3
// 5
// 6
// 4
// 2

我們知道 Koause 方法是支持異步的,所以爲了保證正常的按照洋蔥模型的執行順序執行代碼,需要在調用 next 的時候讓代碼等待,等待異步結束後再繼續向下執行,所以我們在 Koa 中都是建議使用 async/await 的,引入的中間件都是在 use 方法中調用,由此我們可以分析出每一個 Koa 的中間件都是返回一個 async 函數的。


koa-bodyparser 中間件模擬

想要分析 koa-bodyparser 的原理首先需要知道用法和作用,koa-bodyparser 中間件是將我們的 post 請求和表單提交的查詢字符串轉換成對象,並掛在 ctx.request.body 上,方便我們在其他中間件或接口處取值,使用前需提前安裝。

npm install koa koa-bodyparser

koa-bodyparser 具體用法如下:

// koa-bodyparser 的用法
const Koa = require("koa");
const bodyParser = require("koa-bodyparser");

const app = new Koa();

// 使用中間件
app.use(bodyParser());

app.use(async (ctx, next) => {
    if (ctx.path === "/" && ctx.method === "POST") {
        // 使用中間件後 ctx.request.body 屬性自動加上了 post 請求的數據
        console.log(ctx.request.body);
    }
});

app.listen(3000);

根據用法我們可以看出 koa-bodyparser 中間件引入的其實是一個函數,我們把它放在了 use 中執行,根據 Koa 的特點,我們推斷出 koa-bodyparser 的函數執行後應該給我們返回了一個 async 函數,下面是我們模擬實現的代碼。

// 文件:my-koa-bodyparser.js
const querystring = require("querystring");

module.exports = function bodyParser() {
    return async (ctx, next) => {
        await new Promise((resolve, reject) => {
            // 存儲數據的數組
            let dataArr = [];

            // 接收數據
            ctx.req.on("data", data => dataArr.push(data));

            // 整合數據並使用 Promise 成功
            ctx.req.on("end", () => {
                // 獲取請求數據的類型 json 或表單
                let contentType = ctx.get("Content-Type");

                // 獲取數據 Buffer 格式
                let data = Buffer.concat(dataArr).toString();

                if (contentType === "application/x-www-form-urlencoded") {
                    // 如果是表單提交,則將查詢字符串轉換成對象賦值給 ctx.request.body
                    ctx.request.body = querystring.parse(data);
                } else if (contentType === "applaction/json") {
                    // 如果是 json,則將字符串格式的對象轉換成對象賦值給 ctx.request.body
                    ctx.request.body = JSON.parse(data);
                }

                // 執行成功的回調
                resolve();
            });
        });

        // 繼續向下執行
        await next();
    };
};

在上面代碼中由幾點是需要我們注意的,即 next 的調用以及爲什麼通過流接收數據、處理數據和將數據掛在 ctx.request.body 要在 Promise 中進行。

首先是 next 的調用,我們知道 Koanext 執行,其實就是在執行下一個中間件的函數,即下一個 use 中的 async 函數,爲了保證後面的異步代碼執行完畢後再繼續執行當前的代碼,所以我們需要使用 await 進行等待,其次就是數據從接收到掛在 ctx.request.body 都在 Promise 中執行,是因爲在接收數據的操作是異步的,整個處理數據的過程需要等待異步完成後,再把數據掛在 ctx.request.body 上,可以保證我們在下一個 useasync 函數中可以在 ctx.request.body 上拿到數據,所以我們使用 await 等待一個 Promise 成功後再執行 next


koa-better-body 中間件模擬

koa-bodyparser 在處理表單提交時還是顯得有一點弱,因爲不支持文件上傳,而 koa-better-body 則彌補了這個不足,但是 koa-better-bodyKoa 1.x 版本的中間件,Koa 1.x 的中間件都是使用 Generator 函數實現的,我們需要使用 koa-convertkoa-better-body 轉化成 Koa 2.x 的中間件。

npm install koa koa-better-body koa-convert path uuid

koa-better-body 具體用法如下:

// koa-better-body 的用法
const Koa = require("koa");
const betterBody = require("koa-better-body");
const convert = require("koa-convert"); // 將  koa 1.0 中間轉化成 koa 2.0 中間件
const path = require("path");
const fs = require("fs");
const uuid = require("uuid/v1"); // 生成隨機串

const app = new Koa();

// 將 koa-better-body 中間件從 koa 1.0 轉化成 koa 2.0,並使用中間件
app.use(convert(betterBody({
    uploadDir: path.resolve(__dirname, "upload")
})));

app.use(async (ctx, next) => {
    if (ctx.path === "/" && ctx.method === "POST") {
        // 使用中間件後 ctx.request.fields 屬性自動加上了 post 請求的文件數據
        console.log(ctx.request.fields);

        // 將文件重命名
        let imgPath = ctx.request.fields.avatar[0].path;
        let newPath = path.resolve(__dirname, uuid());
        fs.rename(imgPath, newPath);
    }
});

app.listen(3000);

上面代碼中 koa-better-body 的主要功能就是將表單上傳的文件存入本地指定的文件夾下,並將文件流對象掛在了 ctx.request.fields 屬性上,我們接下來就模擬 koa-better-body 的功能實現一版基於 Koa 2.x 處理文件上傳的中間件。

// 文件:my-koa-better-body.js
const fs = require("fs");
const uuid = require("uuid/v1");
const path = require("path");

// 給 Buffer 擴展 split 方法預備後面使用
Buffer.prototype.split = function (sep) {
    let len = Buffer.from(sep).length; // 分隔符所佔的字節數
    let result = []; // 返回的數組
    let start = 0; // 查找 Buffer 的起始位置
    let offset = 0; // 偏移量

    // 循環查找分隔符
    while ((offset = this.indexOf(sep, start)) !== -1) {
        // 將分隔符之前的部分截取出來存入
        result.push(this.slice(start, offset));
        start = offset + len;
    }

    // 處理剩下的部分
    result.push(this.slice(start));

    // 返回結果
    return result;
}

module.exports = function (options) {
    return async (ctx, next) => {
        await new Promise((resolve, reject) => {
            let dataArr = []; // 存儲讀取的數據

            // 讀取數據
            ctx.req.on("data", data => dataArr.push(data));

            ctx.req.on("end", () => {
                // 取到請求體每段的分割線字符串
                let bondery = `--${ctx.get("content-Type").split("=")[1]}`;

                // 獲取不同系統的換行符
                let lineBreak = process.platform === "win32" ? "\r\n" : "\n";

                // 非文件類型數據的最終返回結果
                let fields = {};

                // 分隔的 buffer 去掉沒用的頭和尾即開頭的 '' 和末尾的 '--'
                dataArr = dataArr.split(bondery).slice(1, -1);

                // 循環處理 dataArr 中每一段 Buffer 的內容
                dataArr.forEach(lines => {
                    // 對於普通值,信息由包含鍵名的行 + 兩個換行 + 數據值 + 換行組成
                    // 對於文件,信息由包含 filename 的行 + 兩個換行 + 文件內容 + 換行組成
                    let [head, tail] = lines.split(`${lineBreak}${lineBreak}`);

                    // 判斷是否是文件,如果是文件則創建文件並寫入,如果是普通值則存入 fields 對象中
                    if (head.includes("filename")) {
                        // 防止文件內容含有換行而被分割,應重新截取內容並去掉最後的換行
                        let tail = lines.slice(head.length + 2 * lineBreak.length, -lineBreak.length);

                        // 創建可寫流並指定寫入的路徑:絕對路徑 + 指定文件夾 + 隨機文件名,最後寫入文件
                        fs.createWriteStream(path.join(__dirname, options.uploadDir, uuid())).end(tail);
                    } else {
                        // 是普通值取出鍵名
                        let key = head.match(/name="(\w+)"/)[1];

                        // 將 key 設置給 fields tail 去掉末尾換行後的內容
                        fields[key] = tail.toString("utf8").slice(0, -lineBreak.length);
                    }
                });

                // 將處理好的 fields 對象掛在 ctx.request.fields 上,並完成 Promise
                ctx.request.fields = fields;
                resolve();
            });
        });

        // 向下執行
        await next();
    }
}

上面的內容邏輯可以通過代碼註釋來理解,就是模擬 koa-better-body 的功能邏輯,我們主要的關心點在於中間件實現的方式,上面功能實現的異步操作依然是讀取數據,爲了等待數據處理結束仍然在 Promise 中執行,並使用 await 等待,Promise 執行成功調用 next


koa-views 中間件模擬

Node 模板是我們經常使用的工具用來在服務端幫我們渲染頁面,模板的種類繁多,因此出現了 koa-view 中間件,幫我們來兼容這些模板,先安裝依賴的模塊。

npm install koa koa-views ejs

下面是一個 ejs 的模板文件:

<!-- 文件:index.ejs -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>ejs</title>
</head>
<body>
    <%=name%>
    <%=age%>

    <%if (name=="panda") {%>
        panda
    <%} else {%>
        shen
    <%}%>

    <%arr.forEach(item => {%>
        <li><%=item%></li>
    <%})%>
</body>
</html>

koa-views 具體用法如下:

// koa-views 的用法
const Koa = require("koa");
const views = require("koa-views");
const path = require("path");

const app = new Koa();

// 使用中間件
app.use(views(path.resolve(__dirname, "views"), {
    extension: "ejs"
}));

app.use(async (ctx, next) => {
    await ctx.render("index", { name: "panda", age: 20, arr: [1, 2, 3] });
});

app.listen(3000);

可以看出我們使用了 koa-views 中間件後,讓 ctx 上多了 render 方法幫助我們實現對模板的渲染和響應頁面,就和直接使用 ejs 自帶的 render 方法一樣,並且從用法可以看出 render 方法是異步執行的,所以需要使用 await 進行等待,接下來我們就來模擬實現一版簡單的 koa-views 中間件。

// 文件:my-koa-views.js
const fs = require("fs");
const path = require("path");
const { promisify } = require("util");

// 將讀取文件方法轉換成 Promise
const readFile = promisify(fs.radFile);

// 到處中間件
module.exports = function (dir, options) {
    return async (ctx, next) => {
        // 動態引入模板依賴模塊
        const view = require(options.extension);

        ctx.render = async (filename, data) => {
            // 異步讀取文件內容
            let tmpl = await readFile(path.join(dir, `${filename}.${options.extension}`), "utf8");

            // 將模板渲染並返回頁面字符串
            let pageStr = view.render(tmpl, data);

            // 設置響應類型並響應頁面
            ctx.set("Content-Type", "text/html;charset=utf8");
            ctx.body = pageStr;
        }

        // 繼續向下執行
        await next();
    }
}

掛在 ctx 上的 render 方法之所以是異步執行的是因爲內部讀取模板文件是異步執行的,需要等待,所以 render 方法爲 async 函數,在中間件內部動態引入了我們使的用模板,如 ejs,並在 ctx.render 內部使用對應的 render 方法獲取替換數據後的頁面字符串,並以 html 的類型響應。


koa-static 中間件模擬

下面是 koa-static 中間件的用法,代碼使用的依賴如下,使用前需安裝。

npm install koa koa-static mime

koa-static 具體用法如下:

// koa-static 的用法
const Koa = require("koa");
const static = require("koa-static");
const path = require("path");

const app = new Koa();

app.use(static(path.resolve(__dirname, "public")));

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

app.listen(3000);

通過使用和分析,我們知道了 koa-static 中間件的作用是在服務器接到請求時,幫我們處理靜態文件,如果我們直接訪問文件名的時候,會查找這個文件並直接響應,如果沒有這個文件路徑會當作文件夾,並查找文件夾下的 index.html,如果存在則直接響應,如果不存在則交給其他中間件處理。

// 文件:my-koa-static.js
const fs = require("fs");
const path = require("path");
const mime = require("mime");
const { promisify } = require("util");

// 將 stat 和 access 轉換成 Promise
const stat = promisify(fs.stat);
const access = promisify(fs.access)

module.exports = function (dir) {
    return async (ctx, next) => {
        // 將訪問的路由處理成絕對路徑,這裏要使用 join 因爲有可能是 /
        let realPath = path.join(dir, ctx.path);

        try {
            // 獲取 stat 對象
            let statObj = await stat(realPath);

            // 如果是文件,則設置文件類型並直接響應內容,否則當作文件夾尋找 index.html
            if (statObj.isFile()) {
                ctx.set("Content-Type", `${mime.getType()};charset=utf8`);
                ctx.body = fs.createReadStream(realPath);
            } else {
                let filename = path.join(realPath, "index.html");

                // 如果不存在該文件則執行 catch 中的 next 交給其他中間件處理
                await access(filename);

                // 存在設置文件類型並響應內容
                ctx.set("Content-Type", "text/html;charset=utf8");
                ctx.body = fs.createReadStream(filename);
            }
        } catch (e) {
            await next();
        }
    }
}

上面的邏輯中需要檢測路徑是否存在,由於我們導出的函數都是 async 函數,所以我們將 stataccess 轉化成了 Promise,並用 try...catch 進行捕獲,在路徑不合法時調用 next 交給其他中間件處理。


koa-router 中間件模擬

Express 框架中,路由是被內置在了框架內部,而 Koa 中沒有內置,是使用 koa-router 中間件來實現的,使用前需要安裝。

npm install koa koa-router

koa-router 功能非常強大,下面我們只是簡單的使用,並且根據使用的功能進行模擬。

// koa-router 的簡單用法
const Koa = require("Koa");
const Router = require("koa-router");

const app = new Koa();
const router = new Router();

router.get("/panda", (ctx, next) => {
    ctx.body = "panda";
});

router.get("/panda", (ctx, next) => {
    ctx.body = "pandashen";
});

router.get("/shen", (ctx, next) => {
    ctx.body = "shen";
})

// 調用路由中間件
app.use(router.routes());

app.listen(3000);

從上面看出 koa-router 導出的是一個類,使用時需要創建一個實例,並且調用實例的 routes 方法將該方法返回的 async 函數進行連接,但是在匹配路由的時候,會根據路由 get 方法中的路徑進行匹配,並串行執行內部的回調函數,當所有回調函數執行完畢之後會執行整個 Koa 串行的 next,原理同其他中間件,我下面來針對上面使用的功能簡易實現。

// 文件:my-koa-router.js
// 控制每一個路由層的類
class Layer {
    constructor(path, cb) {
        this.path = path;
        this.cb = cb;
    }
    match(path) {
        // 地址的路由和當前配置路由相等返回 true,否則返回 false
        return path === this.path;
    }
}

// 路由的類
class Router {
    constructor() {
        // 存放每個路由對象的數組,{ path: /xxx, fn: cb }
        this.layers = [];
    }
    get(path, cb) {
        // 將路由對象存入數組中
        this.layers.push(new Layer(path, cb));
    }
    compose(ctx, next, handlers) {
        // 將匹配的路由函數串聯執行
        function dispatch(index) {
            // 如果當前 index 個數大於了存儲路由對象的長度,則執行 Koa 的 next 方法
            if(index >= handlers.length) return next();

            // 否則調用取出的路由對象的回調執行,並傳入一個函數,在傳入的函數中遞歸 dispatch(index + 1)
            // 目的是爲了執行下一個路由對象上的回調函數
            handlers[index].cb(ctx, () => dispatch(index + 1));
        }

        // 第一次執行路由對象的回調函數
        dispatch(0);
    }
    routes() {
        return async (ctx, next) { // 當前 next 是 Koa 自己的 next,即 Koa 其他的中間件
            // 篩選出路徑相同的路由
            let handlers = this.layers.filter(layer => layer.match(ctx.path));
            this.compose(ctx, next, handlers);
        }
    }
}

在上面我們創建了一個 Router 類,定義了 get 方法,當然還有 post 等,我們只實現 get 意思一下,get 內爲邏輯爲將調用 get 方法的參數函數和路由字符串共同構建成對象存入了數組 layers,所以我們創建了專門構造路由對象的類 Layer,方便擴展,在路由匹配時我們可以根據 ctx.path 拿到路由字符串,並通過該路由過濾調數組中與路由不匹配的路由對象,調用 compose 方法將過濾後的數組作爲參數 handlers 傳入,串行執行路由對象上的回調函數。

compose 這個方法的實現思想非常的重要,在 Koa 源碼中用於串聯中間件,在 React 源碼中用於串聯 reduxpromisethunklogger 等模塊,我們的實現是一個簡版,並沒有兼容異步,主要思想是遞歸 dispatch 函數,每次取出數組中下一個路由對象的回調函數執行,直到所有匹配的路由的回調函數都執行完,執行 Koa 的下一個中間件 next,注意此處的 next 不同於數組中回調函數的參數 next,數組中路由對象回調函數的 next 代表下一個匹配路由的回調。


總結

上面我們分析和模擬了一些中間件,其實我們會理解 KoaExpress 相比較的優勢是沒有那麼繁重,開發使用方便,需要的功能都可以用對應的中間件來實現,使用中間件可以給我們帶來一些好處,比如能將我們處理好的數據和新方法掛載在 ctx 上,方便後面 use 傳入的回調函數中使用,也可以幫我們處理一些公共邏輯,不至於在每一個 use 的回調中都去處理,大大減少了冗餘代碼,由此看來其實給 Koa 使用中間件的過程就是一個典型的 “裝飾器” 模式,在通過上面的分析之後相信大家也瞭解了 Koa 的 “洋蔥模型” 和異步特點,知道該如何開發自己的中間件了。


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章