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

前言
Koa 2.x
版本是當下最流行的 NodeJS 框架, Koa 2.0
的原始碼特別精簡,不像 Express
封裝的功能那麼多,所以大部分的功能都是由 Koa
開發團隊(同 Express
是一家出品)和社群貢獻者針對 Koa
對 NodeJS 的封裝特性實現的中介軟體來提供的,用法非常簡單,就是引入中介軟體,並呼叫 Koa
的 use
方法使用在對應的位置,這樣就可以通過在內部操作 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(1); await next(); console.log(2); }); app.use(async (ctx, next) => { console.log(1); await next(); console.log(2); }); // 監聽服務 app.listen(3000); // 1 // 3 // 5 // 6 // 4 // 2 複製程式碼
我們知道 Koa
的 use
方法是支援非同步的,所以為了保證正常的按照洋蔥模型的執行順序執行程式碼,需要在呼叫 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
的呼叫,我們知道 Koa
的 next
執行,其實就是在執行下一個中介軟體的函式,即下一個 use
中的 async
函式,為了保證後面的非同步程式碼執行完畢後再繼續執行當前的程式碼,所以我們需要使用 await
進行等待,其次就是資料從接收到掛在 ctx.request.body
都在 Promise 中執行,是因為在接收資料的操作是非同步的,整個處理資料的過程需要等待非同步完成後,再把資料掛在 ctx.request.body
上,可以保證我們在下一個 use
的 async
函式中可以在 ctx.request.body
上拿到資料,所以我們使用 await
等待一個 Promise 成功後再執行 next
。
koa-better-body 中介軟體模擬
koa-bodyparser
在處理表單提交時還是顯得有一點弱,因為不支援檔案上傳,而 koa-better-body
則彌補了這個不足,但是 koa-better-body
為 Koa 1.x
版本的中介軟體, Koa 1.x
的中介軟體都是使用 Generator
函式實現的,我們需要使用 koa-convert
將 koa-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}.${filename}`), "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
函式,所以我們將 stat
和 access
轉化成了 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
原始碼中用於串聯 redux
的 promise
、 thunk
和 logger
等模組,我們的實現是一個簡版,並沒有相容非同步,主要思想是遞迴 dispatch
函式,每次取出陣列中下一個路由物件的回撥函式執行,直到所有匹配的路由的回撥函式都執行完,執行 Koa
的下一個中介軟體 next
,注意此處的 next
不同於陣列中回撥函式的引數 next
,陣列中路由物件回撥函式的 next
代表下一個匹配路由的回撥。