天天看點

當心“中間件”!

“給一個小男孩一把錘子,他就會發現他遇到的每件事都需要錘擊。”——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 作業上調用。