相信用過 Koa、Redux 或 Express 的小夥伴對中介軟體都不會陌生,特別是在學習 Koa 的過程中,還會接觸到 “洋蔥模型”。
本文阿寶哥將跟大家一起來學習 Koa 的中介軟體,不過這裡阿寶哥不打算一開始就亮出廣為人知的 “洋蔥模型圖”,而是先來介紹一下 Koa 中的中介軟體是什麼?
學習更多知識,可以訪問 ? 阿寶哥 Github 個人主頁
一、Koa 中介軟體
在 @types/koa-compose
包下的 index.d.ts
標頭檔案中我們找到了中介軟體型別的定義:
// @types/koa-compose/index.d.ts
declare namespace compose {
type Middleware<T> = (context: T, next: Koa.Next) => any;
type ComposedMiddleware<T> = (context: T, next?: Koa.Next) => Promise<void>;
}
// @types/koa/index.d.ts => Koa.Next
type Next = () => Promise<any>;
通過觀察 Middleware
型別的定義,我們可以知道在 Koa 中,中介軟體就是普通的函式,該函式接收兩個引數:context
和 next
。其中 context
表示上下文物件,而 next
表示一個呼叫後返回 Promise 物件的函式物件。
瞭解完 Koa 的中介軟體是什麼之後,我們來介紹 Koa 中介軟體的核心,即 compose
函式:
function wait(ms) {
return new Promise((resolve) => setTimeout(resolve, ms || 1));
}
const arr = [];
const stack = [];
// type Middleware<T> = (context: T, next: Koa.Next) => any;
stack.push(async (context, next) => {
arr.push(1);
await wait(1);
await next();
await wait(1);
arr.push(6);
});
stack.push(async (context, next) => {
arr.push(2);
await wait(1);
await next();
await wait(1);
arr.push(5);
});
stack.push(async (context, next) => {
arr.push(3);
await wait(1);
await next();
await wait(1);
arr.push(4);
});
await compose(stack)({});
以上程式碼來源:https://github.com/koajs/comp...
對於以上的程式碼,我們希望執行完 compose(stack)({})
語句之後,陣列 arr
的值為 [1, 2, 3, 4, 5, 6]
。這裡我們先不關心 compose
函式是如何實現的。我們來分析一下,如果要求陣列 arr
輸出期望的結果,上述 3 箇中介軟體的執行流程:
1.開始執行第 1 箇中介軟體,往 arr 陣列壓入 1,此時 arr 陣列的值為 [1]
,接下去等待 1 毫秒。為了保證 arr 陣列的第 1 項為 2
,我們需要在呼叫 next
函式之後,開始執行第 2 箇中介軟體。
2.開始執行第 2 箇中介軟體,往 arr 陣列壓入 2,此時 arr 陣列的值為 [1, 2]
,繼續等待 1 毫秒。為了保證 arr 陣列的第 2 項為 3
,我們也需要在呼叫 next
函式之後,開始執行第 3 箇中介軟體。
3.開始執行第 3 箇中介軟體,往 arr 陣列壓入 3,此時 arr 陣列的值為 [1, 2, 3]
,繼續等待 1 毫秒。為了保證 arr 陣列的第 3 項為 4
,我們要求在呼叫第 3 箇中間的 next
函式之後,要能夠繼續往下執行。
4.當第 3 箇中介軟體執行完成後,此時 arr 陣列的值為 [1, 2, 3, 4]
。因此為了保證 arr 陣列的第 4 項為 5,我們就需要在第 3 箇中介軟體執行完成後,返回第 2 箇中介軟體 next
函式之後語句開始執行。
5.當第 2 箇中介軟體執行完成後,此時 arr 陣列的值為 [1, 2, 3, 4, 5]
。同樣,為了保證 arr 陣列的第 5 項為 6,我們就需要在第 2 箇中介軟體執行完成後,返回第 1 箇中介軟體 next
函式之後語句開始執行。
6.當第 1 箇中介軟體執行完成後,此時 arr 陣列的值為 [1, 2, 3, 4, 5, 6]
。
為了更直觀地理解上述的執行流程,我們可以把每個中介軟體當做 1 個大任務,然後在以 next
函式為分界點,在把每個大任務拆解為 3 個 beforeNext
、next
和 afterNext
3 個小任務。
在上圖中,我們從中介軟體一的 beforeNext
任務開始執行,然後按照紫色箭頭的執行步驟完成中介軟體的任務排程。在 77.9K 的 Axios 專案有哪些值得借鑑的地方 這篇文章中,阿寶哥從 任務註冊、任務編排和任務排程 3 個方面去分析 Axios 攔截器的實現。同樣,阿寶哥將從上述 3 個方面來分析 Koa 中介軟體機制。
1.1 任務註冊
在 Koa 中,我們建立 Koa 應用程式物件之後,就可以通過呼叫該物件的 use
方法來註冊中介軟體:
const Koa = require('koa');
const app = new Koa();
app.use(async (ctx, next) => {
const start = Date.now();
await next();
const ms = Date.now() - start;
console.log(`${ctx.method} ${ctx.url} - ${ms}ms`);
});
其實 use
方法的實現很簡單,在 lib/application.js 檔案中,我們找到了它的定義:
// lib/application.js
module.exports = class Application extends Emitter {
constructor(options) {
super();
// 省略部分程式碼
this.middleware = [];
}
use(fn) {
if (typeof fn !== 'function') throw new TypeError('middleware must be a function!');
// 省略部分程式碼
this.middleware.push(fn);
return this;
}
}
由以上程式碼可知,在 use
方法內部會對 fn
引數進行型別校驗,當校驗通過時,會把 fn
指向的中介軟體儲存到 middleware
陣列中,同時還會返回 this
物件,從而支援鏈式呼叫。
1.2 任務編排
在 77.9K 的 Axios 專案有哪些值得借鑑的地方 這篇文章中,阿寶哥參考 Axios 攔截器的設計模型,抽出以下通用的任務處理模型:
在該通用模型中,阿寶哥是通過把前置處理器和後置處理器分別放到 CoreWork 核心任務的前後來完成任務編排。而對於 Koa 的中介軟體機制來說,它是通過把前置處理器和後置處理器分別放到 await next()
語句的前後來完成任務編排。
// 統計請求處理時長的中介軟體
app.use(async (ctx, next) => {
const start = Date.now();
await next();
const ms = Date.now() - start;
console.log(`${ctx.method} ${ctx.url} - ${ms}ms`);
});
1.3 任務排程
通過前面的分析,我們已經知道了,使用 app.use
方法註冊的中介軟體會被儲存到內部的 middleware
陣列中。要完成任務排程,我們就需要不斷地從 middleware
陣列中取出中介軟體來執行。中介軟體的排程演算法被封裝到 koa-compose 包下的 compose
函式中,該函式的具體實現如下:
/**
* Compose `middleware` returning
* a fully valid middleware comprised
* of all those which are passed.
*
* @param {Array} middleware
* @return {Function}
* @api public
*/
function compose(middleware) {
// 省略部分程式碼
return function (context, next) {
// last called middleware #
let index = -1;
return dispatch(0);
function dispatch(i) {
if (i <= index)
return Promise.reject(new Error("next() called multiple times"));
index = i;
let fn = middleware[i];
if (i === middleware.length) fn = next;
if (!fn) return Promise.resolve();
try {
return Promise.resolve(fn(context, dispatch.bind(null, i + 1)));
} catch (err) {
return Promise.reject(err);
}
}
};
}
compose
函式接收一個引數,該引數的型別是陣列,呼叫該函式之後會返回一個新的函式。接下來我們將以前面的例子為例,來分析一下 await compose(stack)({});
語句的執行過程。
1.3.1 dispatch(0)
由上圖可知,當在第一個中介軟體內部呼叫 next
函式,其實就是繼續呼叫 dispatch
函式,此時引數 i
的值為 1
。
1.3.2 dispatch(1)
由上圖可知,當在第二個中介軟體內部呼叫 next
函式,仍然是呼叫 dispatch
函式,此時引數 i
的值為 2
。
1.3.3 dispatch(2)
由上圖可知,當在第三個中介軟體內部呼叫 next
函式,仍然是呼叫 dispatch
函式,此時引數 i
的值為 3
。
1.3.4 dispatch(3)
由上圖可知,當 middleware
陣列中的中介軟體都開始執行之後,如果排程時未顯式地設定 next
引數的值,則會開始返回 next
函式之後的語句繼續往下執行。當第三個中介軟體執行完成後,就會返回第二中介軟體 next
函式之後的語句繼續往下執行,直到所有中介軟體中定義的語句都執行完成。
分析完 compose
函式的實現程式碼,我們來看一下 Koa 內部如何利用 compose
函式來處理已註冊的中介軟體。
const Koa = require('koa');
const app = new Koa();
// 響應
app.use(ctx => {
ctx.body = '大家好,我是阿寶哥';
});
app.listen(3000);
利用以上的程式碼,我就可以快速啟動一個伺服器。其中 use
方法我們前面已經分析過了,所以接下來我們來分析 listen
方法,該方法的實現如下所示:
// lib/application.js
module.exports = class Application extends Emitter {
listen(...args) {
debug('listen');
const server = http.createServer(this.callback());
return server.listen(...args);
}
}
很明顯在 listen
方法內部,會先通過呼叫 Node.js 內建 HTTP 模組的 createServer
方法來建立伺服器,然後開始監聽指定的埠,即開始等待客戶端的連線。另外,在呼叫 http.createServer
方法建立 HTTP 伺服器時,我們傳入的引數是 this.callback()
,該方法的具體實現如下所示:
// lib/application.js
const compose = require('koa-compose');
module.exports = class Application extends Emitter {
callback() {
const fn = compose(this.middleware);
if (!this.listenerCount('error')) this.on('error', this.onerror);
const handleRequest = (req, res) => {
const ctx = this.createContext(req, res);
return this.handleRequest(ctx, fn);
};
return handleRequest;
}
}
在 callback
方法內部,我們終於見到了久違的 compose
方法。當呼叫 callback
方法之後,會返回 handleRequest
函式物件用來處理 HTTP 請求。每當 Koa 伺服器接收到一個客戶端請求時,都會呼叫 handleRequest
方法,在該方法會先建立新的 Context 物件,然後在執行已註冊的中介軟體來處理已接收的 HTTP 請求:
module.exports = class Application extends Emitter {
handleRequest(ctx, fnMiddleware) {
const res = ctx.res;
res.statusCode = 404;
const onerror = err => ctx.onerror(err);
const handleResponse = () => respond(ctx);
onFinished(res, onerror);
return fnMiddleware(ctx).then(handleResponse).catch(onerror);
}
}
好的,Koa 中介軟體的內容已經基本介紹完了,對 Koa 核心感興趣的小夥伴,可以自行研究一下。接下來我們來介紹洋蔥模型及其應用。
二、洋蔥模型
2.1 洋蔥模型簡介
(圖片來源:https://eggjs.org/en/intro/eg...)
在上圖中,洋蔥內的每一層都表示一個獨立的中介軟體,用於實現不同的功能,比如異常處理、快取處理等。每次請求都會從左側開始一層層地經過每層的中介軟體,當進入到最裡層的中介軟體之後,就會從最裡層的中介軟體開始逐層返回。因此對於每層的中介軟體來說,在一個 請求和響應 週期中,都有兩個時機點來新增不同的處理邏輯。
2.2 洋蔥模型應用
除了在 Koa 中應用了洋蔥模型之外,該模型還被廣泛地應用在 Github 上一些不錯的專案中,比如 koa-router 和阿里巴巴的 midway、umi-request 等專案中。
介紹完 Koa 的中介軟體和洋蔥模型,阿寶哥根據自己的理解,抽出以下通用的任務處理模型:
上圖中所述的中介軟體,一般是與業務無關的通用功能程式碼,比如用於設定響應時間的中介軟體:
// x-response-time
async function responseTime(ctx, next) {
const start = new Date();
await next();
const ms = new Date() - start;
ctx.set("X-Response-Time", ms + "ms");
}
對於每個中介軟體來說,前置處理器和後置處理器都是可選的。比如以下中介軟體用於設定統一的響應內容:
// response
async function respond(ctx, next) {
await next();
if ("/" != ctx.url) return;
ctx.body = "Hello World";
}
儘管以上介紹的兩個中介軟體都比較簡單,但你也可以根據自己的需求來實現複雜的邏輯。Koa 的核心很輕量,麻雀雖小五臟俱全。它通過提供了優雅的中介軟體機制,讓開發者可以靈活地擴充套件 Web 伺服器的功能,這種設計思想值得我們學習與借鑑。
好的,這次就先介紹到這裡,後面有機會的話,阿寶哥在單獨介紹一下 Redux 或 Express 的中介軟體機制。