2025上半年,最新 AI实践都在这!20+ 应用案例,任听一场议题就值回票价 了解详情
写点什么

当心“中间件”

  • 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:532768

评论

发布
暂无评论
发现更多内容

化妆品行业需要使用堡垒机情形分析以及品牌推荐

行云管家

网络安全 信息安全 IT运维

Svelte 最新中文文档教程(15)—— Stores

冴羽

vue.js 前端 React Svelte SvelteKit

行云堡垒-助力深圳企业快速过等保

行云管家

等保 深圳 等保测评 过等保

2600 万表流计算分析如何做到? 时序数据库 TDengine 助力数百家超市智能化转型

TDengine

数据库 时序数据库

Android APP性能优化

北京木奇移动技术有限公司

Android开发 软件外包公司 APP外包公司

挑战数据传输路由规划,与DeepSeek共探大模型算法优化

华为云开发者联盟

人工智能 动态内存 大模型 LLM 路由规划

合合信息2025届春季校园招聘全面启动!

合合技术团队

人工智能 AI 招聘

精选案例展 | 智己汽车—全栈可观测驱动智能化运营与成本优化

博睿数据

CRM系统(源码+文档+讲解+演示)

深圳亥时科技

如何利用MES系统进行产能分析呢?

万界星空科技

制造业 mes 万界星空科技mes 制造业转型 产能分析

时序数据库 TDengine 化工新签约:存储降本一半,查询提速十倍

TDengine

数据库 大数据 时序数据库

政策利好!数造科技抢滩可信数据空间,加入开放数据空间联盟

数造万象

数据资产 政策 数据流通 数据要素 可信数据

动效资源交付的突破:Vision平台准入准出方案

快手技术

Java 大前端 动效

Deepseek官网太卡,教你白嫖阿里云的Deepseek-R1满血版

卷福同学

阿里云 DeepSeek DeepSeek-R1、

【GreatSQL优化器-15】index merge

GreatSQL

国内十大专业SEO公司评测

科技汇

智能制造与精益生产的深度融合

积木链小链

数字化转型 智能制造 精益生产

当心“中间件”_架构_Richard Marmorstein_InfoQ精选文章