深入理解洋蔥模型中介軟體機制

蘇里發表於2019-12-22

前言

本文來由,希望可以剖析中介軟體的組合原理,從而幫助大家更加理解洋蔥模型。

深入理解洋蔥模型中介軟體機制

話不多說,正文如下。

這一段程式碼來源於 redux 裡匯出的 compose 函式。我做了一些修改。主要是給匿名函式新增了名稱,比如 reducer 和 nextWrapper,主要原因是匿名函式(anonymous)不便於除錯。所以 《You-Dont-Know-JS》 的作者 Kyle Simpson 大叔就對箭頭函式持保留意見,認為不該亂用,不過跑題了,扯回。

先貼程式碼如下。

function compose(...funcs) {
  if (funcs.length === 0) {
    return arg => arg;
  }

  if (funcs.length === 1) {
    return funcs[0];
  }

  return funcs.reduce(function reducer(a, b) {
    return function nextWrapper(...args) {
      return a(b(...args));
    };
  });
}
複製程式碼

接下來全文將基於此函式剖析。

接下來將提供幾個簡單 redux 中介軟體,同樣,我避免了箭頭函式的使用,理由同上。程式碼如下:

function next(action) {
  console.log("[next]", action);
}

function fooMiddleware(next) {
  console.log("[fooMiddleware] trigger");
  return function next_from_foo(action) {
    console.log("[fooMiddleware] before next");
    next(action);
    console.log("[fooMiddleware] after next");
  };
}

function barMiddleware(next) {
  console.log("[barMiddleware] trigger");
  return function next_from_bar(action) {
    console.log("[barMiddleware] before next");
    next(action);
    console.log("[barMiddleware] after next");
  };
}

function bazMiddleware(next) {
  console.log("[bazMiddleware] trigger");
  return function next_from_baz(action) {
    console.log("[bazMiddleware] before next");
    next(action);
    console.log("[bazMiddleware] after next");
  };
}
複製程式碼

此時如果將以上 foo bar baz 三個中介軟體組合執行如下:

const chain = compose(fooMiddleware, barMiddleware, bazMiddleware);
const nextChain = chain(next);
nextChain("{data}");
複製程式碼

以上將會在控制檯輸出什麼?

大家可以思考一下。

...

熟悉中介軟體執行順序的同學可能很快得出答案:

[bazMiddleware] trigger
[barMiddleware] trigger
[fooMiddleware] trigger
[fooMiddleware] before next
[barMiddleware] before next
[bazMiddleware] before next
[next] {data}
[bazMiddleware] after next
[barMiddleware] after next
[fooMiddleware] after next
複製程式碼

寫不出正確答案的同學也無須灰心。這篇文章的目的,正是幫助大家更好理解這一套機制原理。

這種洋蔥模型,也即是中介軟體的能力之強大眾所周知,現在在 Web 社群發揮極大作用的 Redux、Express、Koa,開發者利用其中的洋蔥模型,構建無數強大又有趣的 Web 應用和 Node 應用。更不用提基於這三個衍生出來的 Dva、Egg 等。 所以其實需要理解的是這套實現機制原理,如果光是記住中介軟體執行順序,未免太過無趣了,現在讓我們逐層逐層解構以上程式碼來探索洋蔥模型吧。

到這裡,正文正式開始!

以上程式碼的靈魂之處在於 Array.prototype.reduce(),不瞭解此函式的同學強烈建議去 MDN 遛躂一圈 MDN | Array.prototype.reduce()

reduce 函式是函數語言程式設計的一個重要概念,可用於實現函式組合(compose)

組合中介軟體機制

const chain = compose(fooMiddleware, barMiddleware, bazMiddleware);
複製程式碼

以上 compose 傳入了 fooMiddleware、barMiddleware、bazMiddleware 三個中介軟體進行組合,內部執行步驟可以分解為以下兩步。

  1. 第一步輸入引數:a -> fooMiddleware,b -> barMiddleware

執行 reduce 第一次組合,得到返回輸出:function nextWrapper#1(...args) { return fooMiddleware(barMiddleware(...args)) }

  1. 第二步輸入引數:a -> function nextWrapper#1(...args) { return fooMiddleware(barMiddleware(...args)) },b -> bazMiddleware

執行 reduce 第二次組合,得到返回輸出:function nextWrapper#2(...args) { return nextWrapper#1(bazMiddleware(...args)) }

所以 chain 就等於最終返回出來的 nextWrapper。

(這裡用了 #1,#2 用來指代不同組合的 nextWrapper,實際上並沒有這樣的語法,須知)

深入理解洋蔥模型中介軟體機制

應用中介軟體機制

然而此時請留意,所有中介軟體並沒有執行,到目前為止最終通過高階函式 nextWrapper 返回了出來而已。

因為直到以下這一句,傳入 next 函式作為引數,才開始真正的觸發了 nextWrapper,開始迭代執行所有組合過的中介軟體。

const nextChain = chain(next);
複製程式碼

我們在上面得知了 chain 最終是形如(...args) => fooMiddleware(barMiddleware(bazMiddleware(...args)))的函式。因此當傳入 next 函式時,內部執行步驟可以分為下述幾步:

  1. 第一步,執行 chain 函式(也即是 nextWrapper#2),從 compose 的函式組合從內至外,next 引數首先交由 bazMiddleware 函式執行,列印日誌後,返回了函式 next_from_baz。

  2. 第二步,next_from_baz 立即傳入 nextWrapper#1,返回了 fooMiddleware(barMiddleware(...args))。 因此,barMiddleware 函式接收的期望 next 引數,其實並不是我們一開始的 next 函式了,而是 bazMiddleware 函式執行後返回的 next_from_baz。barMiddleware 收到 next 引數開始執行,列印日誌後,返回了 next_from_bar 函式。

  3. 第三步,同理,fooMiddleware 函式接收的期望 next 引數是 barMiddleware 函式執行後返回的 next_from_bar。fooMiddleware 收到 next 引數開始執行,列印日誌並返回了 next_from_foo 函式。

所以此時我們此時可知,執行完 chain 函式後,實際上 nextChain 函式就是 next_from_foo 函式。

再用示意圖詳細描述即為:

深入理解洋蔥模型中介軟體機制

此時經過以上步驟,控制檯輸出了下述日誌:

[bazMiddleware] trigger
[barMiddleware] trigger
[fooMiddleware] trigger
複製程式碼

這裡的 next_from_baz,next_from_bar,next_from_foo 其實就是一層層的對傳入的引數函式 next 包裹。官方說法稱之為 Monkeypatching。

我們很清晰的知道,next_from_foo 包裹了 next_from_bar,next_from_bar 又包裹了 next_from_baz,next_from_baz 則包裹了 next。

如果直接寫 Monkeypatching 如下

const prevNext = next;
next = (...args) => {
  // @todo
  prevNext(...args);
  // @todo
};
複製程式碼

但這樣如果需要 patch 很多功能,我們需要將上述程式碼重複許多遍。的確不是很 DRY。

Monkeypatching 本質上是一種 hack。“將任意的方法替換成你想要的”。

關於 Monkeypatching 和 redux 中介軟體的介紹,十分推薦閱讀官網文件 Redux Docs | Middleware

到這裡我想出個考題,如下:

function add5(x) {
  return x + 5;
}

function div2(x) {
  return x / 2;
}

function sub3(x) {
  return x - 3;
}

const chain = [add5, div2, sub3].reduce((a, b) => (...args) => a(b(...args)));
複製程式碼

請問,chain(1) 輸出值?

執行順序為 sub3 -> div2 -> add5。 (1 - 3) / 2 + 5 = 4。答案是 4。

那麼再問:

const chain = [add5, div2, sub3].reduceRight((a, b) => (...args) => b(a(...args)));
複製程式碼

此時 chain(1) 輸出值?還是 4。

再看如下程式碼:

const chain = [add5, div2, sub3].reverse().reduce((a, b) => (...args) => b(a(...args)));
複製程式碼

此時 chain(1) 輸出值?仍然是 4。

如果你對上述示例都能很清晰的運算出答案,那麼你應該對上文中 chain(next)的理解 ok,那麼請繼續往下看。

洋蔥模型機制

nextChain("{data}");
複製程式碼

終於重頭戲來了,nextChain 函式來之不易,但毫無疑問,它的能力是十分強大的。(你看,其實在 redux 中,這個 nextChain 函式其實就是 redux 中的 dispatch 函式。)

文章截止目前為止,我們得知了 nextChain 函式即為 next_from_foo 函式。

因此下述的執行順序我將用函式堆疊圖給大家示意。

深入理解洋蔥模型中介軟體機制

依次執行,每當執行到 next 函式時,新的 next 函式入棧,迴圈往復,直到 next_from_baz 為止。函式入棧的過程,就相當於進行完了洋蔥模型從外到裡的進入過程。

控制檯輸出日誌:

[fooMiddleware] before next
[barMiddleware] before next
[bazMiddleware] before next
複製程式碼

函式入棧直到最終的 next 函式,我們知道,next 函式並沒有任何函式了,也就是說到達了終點。

接下來就是逐層出棧。示意圖如下

深入理解洋蔥模型中介軟體機制

控制檯輸出日誌:

[next] {data}
[bazMiddleware] after next
[barMiddleware] after next
[fooMiddleware] after next
複製程式碼

函式出棧的過程,就相當於洋蔥模型從裡到外的出去過程。

上述是函式堆疊的執行順序。而下述示意圖是我整理後幫助大家理解的線性執行順序。每當執行到 next(action)的時候函式入棧,原 next 函式暫時停止執行,執行新的 next 函式,正如下圖彎曲箭頭所指。

深入理解洋蔥模型中介軟體機制

上圖,程式碼從上至下執行,實際上就是呼叫棧的一個程式控制流程。所以理論上無論有多少個函式巢狀,都可以等同理解。

我們修改一開始的洋蔥模型,示例如下:

深入理解洋蔥模型中介軟體機制

小結

redux 的中介軟體也就是比上述示例的中介軟體多了一層高階函式用以獲取框架內部的 store。

const reduxMiddleware = store => next => action => {
  // ...
  next(action);
  // ...
};
複製程式碼

而 koa 的中介軟體多了 ctx 上下文引數,和支援非同步。

app.use(async (ctx, next) => {
  // ...
  await next();
  // ...
});
複製程式碼

你能想到大致如何實現了麼?是不是有點撥開雲霧見太陽的感覺了?

如果有,本文發揮了它的作用和價值,筆者將會不甚榮幸。如果沒有,那筆者的表達能力還是有待加強。

相關文章