Koa 2.x
版本是當下最流行的 NodeJS 框架,Koa 2.0
的源碼特別精簡,不像 Express
封裝的功能那麼多,因此大部分的功能都是由 Koa
開發團隊(同 Express
是一家出品)和社區貢獻者針對 Koa
對 NodeJS 的封裝特性實現的中間件來提供的,用法很是簡單,就是引入中間件,並調用 Koa
的 use
方法使用在對應的位置,這樣就能夠經過在內部操做 ctx
實現一些功能,咱們接下來就討論經常使用中間件的實現原理以及咱們應該如何開發一個 Koa
中間件供本身和別人使用。html
咱們本次不對洋蔥模型的實現原理進行過多的刨析,主要根據 API 的使用方式及洋蔥模型分析中間件是如何工做的。npm
// 洋蔥模型特色
// 引入 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
複製代碼
咱們知道 Koa
的 use
方法是支持異步的,因此爲了保證正常的按照洋蔥模型的執行順序執行代碼,須要在調用 next
的時候讓代碼等待,等待異步結束後再繼續向下執行,因此咱們在 Koa
中都是建議使用 async/await
的,引入的中間件都是在 use
方法中調用,由此咱們能夠分析出每個 Koa
的中間件都是返回一個 async
函數的。json
想要分析 koa-bodyparser
的原理首先須要知道用法和做用,koa-bodyparser
中間件是將咱們的 post
請求和表單提交的查詢字符串轉換成對象,並掛在 ctx.request.body
上,方便咱們在其餘中間件或接口處取值,使用前需提早安裝。redux
npm install koa koa-bodyparser數組
koa-bodyparser 具體用法以下:promise
// 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 中進行。app
首先是 next
的調用,咱們知道 Koa
的 next
執行,其實就是在執行下一個中間件的函數,即下一個 use
中的 async
函數,爲了保證後面的異步代碼執行完畢後再繼續執行當前的代碼,因此咱們須要使用 await
進行等待,其次就是數據從接收到掛在 ctx.request.body
都在 Promise 中執行,是由於在接收數據的操做是異步的,整個處理數據的過程須要等待異步完成後,再把數據掛在 ctx.request.body
上,能夠保證咱們在下一個 use
的 async
函數中能夠在 ctx.request.body
上拿到數據,因此咱們使用 await
等待一個 Promise 成功後再執行 next
。框架
koa-bodyparser
在處理表單提交時仍是顯得有一點弱,由於不支持文件上傳,而 koa-better-body
則彌補了這個不足,可是 koa-better-body
爲 Koa 1.x
版本的中間件,Koa 1.x
的中間件都是使用 Generator
函數實現的,咱們須要使用 koa-convert
將 koa-better-body
轉化成 Koa 2.x
的中間件。koa
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
。
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
中間件的用法,代碼使用的依賴以下,使用前需安裝。
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
交給其餘中間件處理。
在 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
表明下一個匹配路由的回調。
上面咱們分析和模擬了一些中間件,其實咱們會理解 Koa
和 Express
相比較的優點是沒有那麼繁重,開發使用方便,須要的功能均可以用對應的中間件來實現,使用中間件能夠給咱們帶來一些好處,好比能將咱們處理好的數據和新方法掛載在 ctx
上,方便後面 use
傳入的回調函數中使用,也能夠幫咱們處理一些公共邏輯,不至於在每個 use
的回調中都去處理,大大減小了冗餘代碼,由此看來其實給 Koa
使用中間件的過程就是一個典型的 「裝飾器」 模式,在經過上面的分析以後相信你們也瞭解了 Koa
的 「洋蔥模型」 和異步特色,知道該如何開發本身的中間件了。