QCon北京「鸿蒙专场」火热来袭!即刻报名,与创新同行~ 了解详情
写点什么

当心“中间件”

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

评论

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

干货 | IDaaS 身份即服务背后的基石

Authing

Focus Matrix for Mac(智能任务管理器) v1.6.1激活版

Rose

Focus Matrix Focus Matrix破解 focus matrix mac激活版 智能任务管理器

怎么看阿里拆中台这件事

agnostic

中台架构

开源赋能 普惠未来|腾讯寄语2023开放原子全球开源峰会

开放原子开源基金会

开源 开放原子全球开源峰会 开放原子

深度解析Seata AT 模式中性能优化与隔离保障的平衡之道

Java你猿哥

Java 数据 ssm 脏读

synchronized和Lock有什么区别?

javacn.site

Xcode for Mac(开发工具)v14.3.1正式版

Rose

Xcode Mac版 Xcode中文版 Xcode破解版

【2023华为云CodeArts Build 实战训练营】云端实战-玩转编译构建

华为云PaaS服务小智

云计算 华为云 华为开发者大会2023

设计模式之不一样的责任链模式

越长大越悲伤

Java 设计模式

开源赋能 普惠未来|OpenHarmony诚邀您参与2023开放原子全球开源峰会

开放原子开源基金会

开源 OpenHarmony 开放原子

国产自研数据库是更新换代首选

YG科技

C语言编程-typedef

芯动大师

MongoDB源码学习:原子操作WriteUnitOfWork

云里有只猫

mongodb 源码刨析

Go 语言流行 ORM 框架 GORM 使用介绍

江湖十年

后端 ORM框架 ORM Go 语言 gorm

如何在企业中培养平台工程文化?

SEAL安全

平台工程 平台工程文化

火山引擎DataLeap的Catalog系统搜索实践(三):Learning to rank与后续工作

字节跳动数据平台

数据湖 数据化 数据平台 大数据分析 DataLeap

宝兰德应用服务器软件与华为云GaussDB完成兼容互认证

YG科技

来聊聊才离职就被拉黑禁用的这些事

HoneyMoose

Microsoft Remote Desktop下载,微软远程连接工具

Rose

microsoft remote desktop 微软远程桌面连接工具 mac远程链接

Java上进了,JDK21 要来了,并发编程再也不是噩梦了

Java你猿哥

Java jdk ssm

如何从零实现一个简单的Spring Bean容器

Java你猿哥

Java spring ssm Spring Bean Java web

硬核!力扣官方首发了这套1568页LeetCode算法刷题笔记(彩页版)

Java你猿哥

面试 算法 LeetCode ssm

写给程序员的可逆计算理论辨析补遗

canonical

低代码 可逆计算 范畴论

爱了,Spring Cloud Alibaba内部微服务架构笔记真的太牛了

Java你猿哥

Java 微服务 微服务架构 Spring Cloud ssm

简单好用的便利贴工具:Sticky 激活版

真大的脸盆

Mac Mac 软件 便利贴工具 便利贴软件

技术驱动,数据赋能,华为云GaussDB给世界一个更优选择

YG科技

强渡大渡河!华为云GaussDB支撑华为MetaERP系统全面替换

YG科技

Nautilus Chain上首个DEX PoseiSwap即将开启IDO,潜力几何?

西柚子

mac高质量图像浏览处理软件 GraphicConverter 12 v12.0.3(6140)中文直装版

Rose

GraphicConverter 12中文 GraphicConverter破解 mac图像浏览器 GraphicConverter下载

App Cleaner & Uninstaller:mac专业的系统清理优化工具

Rose

App Cleaner 系统清理工具 苹果mac系统优化 App Cleaner 破解

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