“給一個小男孩一把錘子,他就會發現他遇到的每件事都需要錘擊。”——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 請求是無處不在的!
如果我們的中間件描繪簡單、獨立的操作,我仍然認為它是一種糟糕的表達方式,但這在大多數情況下還是好的。當操作變得複雜且互相依賴時,麻煩就開始了。
例如,如下這些操作可以稱為簡單操作:
- 速率限制為每個 IP 每分鐘 100 個請求。
- 如果請求缺少有效的授權 header,則傳回 401
- 所有傳入請求的 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 “翻譯”成這些操作的更受限制的語言(并執行它們)。我相信,它可以類比為“瘦控制器”。
在這個簡單的例子中,我的方法并沒有明顯的優勢。是以讓我們來引入一些複雜的情況吧。
假設有一些新的需求:
- 有些請求來自“管理者”。
- 來自管理者的請求 100% 都應該被記錄下來(這樣調試就更容易了)
- 管理請求也不應該受到速率限制。
最簡單的方法是在記錄日志時進行查找和檢查,并限制中間件的速率。
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 作業上調用。