当心“中间件”

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

  • 发表于:
  • 本文为 InfoQ 中文站特供稿件
  • 首发地址https://www.infoq.cn/article/ChNcKkKoW7dGg4fKtxpE
  • 如有侵权,请联系 cloudcommunity@tencent.com 删除。

扫码关注腾讯云开发者

领取腾讯云代金券