當心“中間件”

“給一個小男孩一把錘子,他就會發現他遇到的每件事都需要錘擊。” 對於“中間件”,我們從來沒有真正停下來思考過它的利弊。這似乎是一件“正確”的事情:框架希望我做的事情,我就照做了。本文通過 HTTP API 探討了“中間件”使用的利弊。

“給一個小男孩一把錘子,他就會發現他遇到的每件事都需要錘擊。”——Abraham Kaplan

當你編寫一個HTTP API 時,通常會描述兩種行爲:

  • 應用於特定路由的行爲。
  • 應用於所有或多個路由的行爲。

一個好主意:控制器和模型

在我所見過的應用程序中,應用於特定路由的行爲通常劃分爲“控制器”和一個或多個“模型”。理想情況下,控制器是“瘦”的,本身不會做太多工作。它的任務是將請求所描述的動作從 HTTP “語言”轉換爲模型“語言”。

爲什麼分成“模型”和“控制器”是一個好主意呢?因爲受約束的數據比不受約束的數據更容易推導。

這相關的一個經典例子是編譯器的階段,所以讓我們稍微探討一下這個類比。簡單編譯器的前兩個階段是詞法分析器(lexer)和解析器(parser),詞法分析器獲取完全不受約束的數據(字節流)併發出已知的標識,如 QUOTATION_MARK 或 LEFT_PAREN 或 LITERAL “foo”,而解析則是獲取這些標識流並生成語法樹。將表示 if 語句的語法樹轉換爲字節碼是很簡單的。但將表示 if 語句的任意字節流直接轉換爲字節碼就不那麼簡單了……

在這個類比中,HTTP 請求就像是不受約束的字節流。它們有一些結構,但是它們的主體可以包含任意字節(對任意的JSON進行編碼 ),它們的header可以是任意字符串。我們不想在任意請求的操作上表達業務邏輯。用“Accounts”、“Posts”或任何領域對象來表示業務邏輯要自然得多。因此,在我看來,控制器的工作類似於詞法分析器/解析器。它的工作是採取一個不受約束的數據結構來描述一個動作,並將其轉換爲一種更受約束的形式(例如,“對 account 對象的 .update 方法的調用,隨之有一條包含了“email address”和“bio”字符串的記錄)。

這種類比的奇怪之處在於,雖然詞法分析器/解析器返回了它們從字節流生成的語法樹,但是HTTP 控制器通常不會返回對應於其輸入 HTTP 請求的模型方法調用的表示(當然它可以實現……但這種想法就是另一篇博客文章了),而是直接執行。不過,這應該對咱們這個類比沒什麼影響。

一個有爭議的想法:中間件

不過,控制器通常只會涉及到應用於單一路由的行爲。根據我的經驗,應用於多個路由的行爲往往被組織成一系列“中間件”或“中間件堆棧”。這是一個壞主意,因爲把控制器放在模型前面是一個好主意。也就是說,中間件操作的是非常不受約束的數據結構(HTTP請求和響應),而不是易於推導和組合的受約束的數據結構。

雖然我假設我們對中間件都比較熟悉,但還是在此做個簡單介紹吧:

  • 將 HTTP 請求和(正在進行的)HTTP 響應作爲參數
  • 沒有有意義的返回值
  • 因此,操作必須通過修改請求或響應對象、修改全局狀態、引發一些副作用或拋出錯誤來進行。

我們需要拋棄在模型/控制器架構中使用的關於嘗試操作受約束數據的知識。對於“中間件”,在路由之前,HTTP請求是無處不在的!

如果我們的中間件描繪簡單、獨立的操作,我仍然認爲它是一種糟糕的表達方式,但這在大多數情況下還是好的。當操作變得複雜且相互依賴時,麻煩就開始了。

例如,如下這些操作可以稱爲簡單操作:

  1. 速率限制爲每個IP每分鐘100個請求。
  2. 如果請求缺少有效的授權header,則返回401
  3. 所有傳入請求的10%記錄日誌

在 Express 中以中間件的形式進行編碼,如下所示(代碼僅用於演示,請不要嘗試運行它)

const rateLimitingMiddleware = async (req, res) => {
  const ip = req.headers['ip']
  db.incrementNRequests(ip)
  if (await db.nRequestsSince(Date.now() - 60000, ip) > 100) {
    return res.send(423)
  }
}

const authorizationMiddleware = async (req, res) => {
  const account = await db.accountByAuthorization(req.headers['authorization'])
  if (!account) { return res.send(401) }
}

const loggingMiddleware = async (req, res) => {
  if (Math.random() <= .1) {
    console.log(`request received ${req.method} ${req.path}\n${req.body}`)
  }
}

app.use([
  rateLimitingMiddleware,
  authorizationMiddleware,
  loggingMiddleware
].map(
  // Not important, quick and dirty plumbing to make express play nice with   
  // async/await
  (f) => (req, res, next) =>
    f(req, res)
      .then(() => next())
      .catch(err => next(err))
))

我所提倡的大致是這樣的:

const shouldRateLimit = async (ip) => {
  return await db.nRequestsSince(Date.now() - 60000, ip) < 100
}

const isAuthorizationValid = async (authorization) => {
  return !!await db.accountByAuthorization(authorization)
}

const emitLog = (method, path, body) => {
  if (Math.random() < .1) {
    console.log(`request received ${method} ${path}\n${body}`)
  }
}

const mw = async (req, res) => {
  const {ip, authorization} = req.headers
  const {method, path, body} = req

  if (await shouldRateLimit(ip)) {
    return res.send(423)
  }

  if (!await isAuthorizationValid(authorization)) {
    return res.send(401)
  }

  emitLog(method, path, body)
}

app.use((req, res, next) => {
  // async/await plumbing 
  mw(req, res).then(() => next()).catch(err => next(err))
})

我沒有將每個操作註冊爲自己的中間件,並依賴 Express 按順序調用它們,傳入不受約束的請求和響應對象,而是將每個操作作爲函數來編寫,將其約束輸入聲明爲參數,並將其結果描述爲返回值。然後我註冊了一箇中間件,負責將 HTTP “翻譯”成這些操作的更受約束的語言(並執行它們)。我相信,它可以類比爲“瘦控制器”。

在這個簡單的例子中,我的方法並沒有明顯的優勢。所以讓我們來引入一些複雜的情況吧。

假設有一些新的需求

  1. 有些請求來自“管理員”。
  2. 來自管理員的請求100%都應該被記錄下來(這樣調試就更容易了)
  3. 管理請求也不應該受到速率限制。

最簡單的方法是在記錄日誌時進行查找和檢查,並限制中間件的速率。

const rateLimitingMiddleware = async (req, res) => {
  const account = await db.accountByAuthorization(req.headers['authorization'])
  if (account.isAdmin()) {
    return
  }
  const ip = req.headers['ip']
  db.incrementNRequests(ip)
  if (await db.nRequestsSince(Date.now() - 60000, ip) > 100) {
    return res.send(423)
  }
}

const loggingMiddleware = async (req, res) => {
  const account = await db.accountByAuthorization(req.headers['authorization'])
  if (account.isAdmin() || Math.random() <= .1) {
    console.log(`request received ${req.method} ${req.path}\n${req.body}`)
  }
}

但這並不能令人滿意。只調用一次 db.accountByAuthorization,避免來來回回訪問三次數據庫,不是更好嗎?中間件不能產生返回值,也不能接受其他中間件產生的參數值,因此必須通過修改請求(或響應)對象來實現,如下所示:

const authorizationMiddleware = async (req, res) => {
  const account = await db.accountByAuthorization(req.headers['authorization'])
  if (!account) { return res.send(401) }
  req.isAdmin = account.isAdmin()
}

const rateLimitingMiddleware = async (req, res) => {
  if (req.isAdmin) return
  const ip = req.headers['ip']
  db.incrementNRequests(ip)
  if (await db.nRequestsSince(Date.now() - 60000, ip) > 100) {
    return res.send(423)
  }
}

const loggingMiddleware = async (req, res) => {
  if (req.isAdmin || Math.random() <= .1) {
    console.log(`request received ${req.method} ${req.path}\n${req.body}`)
  }
}

這應該會讓我們在道德上感到不安。首先,修改是不好的,或者至少在最近它已經過時了(在我看來,這是正確的)。其次,isAdmin 與 HTTP 請求沒有任何關係,因此將它偷放到一個聲稱代表 HTTP 請求的對象上似乎也不太合適。

此外,還有一個實際問題。代碼被破壞了。rateLimitingMiddleware 現在隱式地依賴於authorizationMiddleware,在authorizationMiddleware運行之後它就會運行。在我修復該問題並將 authorizationMiddleware 放在第一位之前,將不能正確地免除對管理員的速率限制。

如果沒有中間件,那會是什麼樣子的呢?(好吧,只有一個……)

const shouldRateLimit = async (ip, account) => {
  return !account.isAdmin() &&
    await db.nRequestsSince(Date.now() - 60000, ip) < 100
}

const authorizedAccount = async (authorization) => {
  return await db.accountByAuthorization(authorization)
}

const emitLog = (method, path, body, account) => {
  if (account.isAdmin()) { return }
  if (Math.random() < .1) {
    console.log(`request received ${method} ${path}\n${body}`)
  }
}

const mw = async (req, res) => {
  const {ip, authorization} = req.headers
  const {method, path, body} = req

  const account = authorizedAccount(authorization)
  if (!account) { return res.send(401) }

  if (await shouldRateLimit(ip, account)) {
    return res.send(423)
  }

  emitLog(method, path, body, account)
}

這裏,如下寫法包含有類似的bug:

if (await shouldRateLimit(ip, account)) {
  ...
}
const account = authorizedAccount(authorization)

bug 在哪呢?account變量在使用之前需要先定義,這樣可以避免異常拋出。如果我們不這樣做,ESLint 將捕獲異常。同樣地,這也可以通過定義具有約束參數和返回值的函數來實現。在無約束的“請求”對象(任意屬性的“抓包”)方面,靜態分析幫不上你多大的忙。

我希望這個例子能夠說服你,或者與你使用中間件的經驗產生共鳴,儘管我例子中的問題仍然非常輕微。但在實際應用程序中,情況會變得更糟,尤其是當你將更多的複雜性添加到組合中時,如管理員能夠充當其他帳戶、資源級別的速率限制和IP限制、功能標誌等等。

黑暗從何而來?

希望我已經讓你相信中間件是糟糕的了,或者至少認識到它很容易被誤用。但如果它們是如此糟糕,它們又怎麼會如此受歡迎呢?

我曾寫過一些欠考慮的中間件,對我來說,我認爲它歸根結底是“錘子定律”。正如開篇所述:“給一個小男孩一把錘子,他就會發現他遇到的每件事都需要錘擊。”中間件就是錘子,而我就是那個小男孩。

這些Web框架(Express、Rack、Laravel等)強調了“中間件”的概念。我知道在請求到達路由器之前,我需要對它們執行一系列操作。我看到“中間件”似乎是爲了這個目的。我從來沒有真正停下來思考過它的利弊。這似乎是一件“正確”的事情:框架希望我去做什麼,我就做了什麼。

我認爲還有一種模糊的感覺,那就是用框架希望的方式能解決問題也是好的,因爲如果你這樣做了,也許就可以更好地利用框架提供的其他特性。根據我的經驗,這種希望很少能實現。

在其他情況下,我也會陷入這種思維。例如,當我想跨多個CI作業重用代碼時,我使用了Jenkins共享庫。我寫了 }[&%ing Groovy(一種我討厭的語言) 來做這個。如果我不知道“Jenkins共享庫”存在的話,我應該做些什麼,我應該怎麼辦。僅僅是用我想用的任何編程語言來編寫操作(在這種情況下,可能是用Bash進行編程),並使它們可以通過shell在CI作業上調用。

所以更廣泛的教訓是,試着通過你自己的思維意識到這種趨勢。使用工具,但別讓工具利用你。尤其是如果你是一個更有經驗的程序員,並且按照工具“想要”的方式使用它似乎不怎麼正確時,那它可能就真的不正確。

使用函數,將它們需要的東西作爲參數,並將其結果放在返回值中。如果可以的話,編寫編譯器之類的應用程序,這也是一個深刻的教訓。

原文鏈接:

Beware Middleware

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章