当心“中间件”!

Posted InfoQ

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了当心“中间件”!相关的知识,希望对你有一定的参考价值。

作者丨Richard Marmorstein

译者丨刘雅梦

策划丨Tina

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

所以更广泛的教训是,试着通过你自己的思维意识到这种趋势。使用工具,但别让工具利用你。尤其是如果你是一个更有经验的程序员,并且按照工具“想要”的方式使用它似乎不怎么正确时,那它可能就真的不正确。

使用函数,将它们需要的东西作为参数,并将其结果放在返回值中。如果可以的话,编写编译器之类的应用程序,这也是一个深刻的教训。

原文链接:

http://twitchard.github.io/posts/2019-11-01-beware-middleware.html


点个在看少个 bug

以上是关于当心“中间件”!的主要内容,如果未能解决你的问题,请参考以下文章

NSManagedObject 没有被保存——当心反向关系

当心iOS开发工具遭恶意代码插入,你的信息可能被窃取……[附存安全隐患的App名单]

当心JavaScript奇葩的逗号表达式

[Effective JavaScript 笔记] 第14条:当心命名函数表达式笨拙的作用域

javascript 当心匿名函数

当心即时值