Koa2 中介軟體原理解析 —— 看了就會寫

PandaShen發表於2019-03-02

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 的 “洋蔥模型” 和非同步特點,知道該如何開發自己的中介軟體了。


相關文章