当心“中间件”

阅读数:1698 2019 年 12 月 16 日 14:53

当心“中间件”

“给一个小男孩一把锤子,他就会发现他遇到的每件事都需要锤击。” 对于“中间件”,我们从来没有真正停下来思考过它的利弊。这似乎是一件“正确”的事情:框架希望我做的事情,我就照做了。本文通过 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

评论

发布