NVIDIA 初创加速计划,免费加速您的创业启动 了解详情
写点什么

当心“中间件”

  • 2019-12-16
  • 本文字数:4811 字

    阅读完需:约 16 分钟

当心“中间件”

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


2019-12-16 14:532490

评论

发布
暂无评论
发现更多内容
当心“中间件”_架构_Richard Marmorstein_InfoQ精选文章