HarmonyOS开发者限时福利来啦!最高10w+现金激励等你拿~ 了解详情
写点什么

当心“中间件”

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

评论

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

我与游戏相伴【自我访谈】

叶阳夏烟

系列 游戏 游戏观 访谈录

Docker从入门到放弃---基础篇

书旅

Docker 容器

MySQL容器化详细教程

Simon

MySQL Docker 容器

带着面试题学习红黑树操作原理,解析什么时候染色、怎么进行旋转、与2-3树有什么关联

小傅哥

Java 数据结构 小傅哥 红黑树 2-3树

不说再见

太以

疫情 毕业季 遗憾 大学

ARTS打卡第一周(200511-200517)

老胡爱分享

ARTS 打卡计划 ARTS活动

TypeScript 设计模式之发布-订阅模式

Geek_z9ygea

typescript 大前端 设计模式

领导发表的报告,你真的读懂了吗?

罗小龙

工作经验

疫情按下快进键,电商抢占市场红利需可靠的助力

华为云开发者联盟

高并发 电商 华为云 流量 云服务器

安全系列之——RSA的公钥私钥有多少人能分的清楚?RSA的签名验签与加密解密如何使用公私钥?

诸葛小猿

加密解密 非对称加密 rsa 签名验签

Flink任务执行-3

小知识点

大数据 flink

MySQL中几种常见的日志

Simon

MySQL 日志

图解JavaScript——进阶篇(执行上下文、变量对象、作用域、作用域链、闭包、this、原型及原型链、事件循环等一把梭)

执鸢者

Java 大前端 函数执行 事件循环

[High Performance TIDB] Leeson 01:TIDB整体架构---作业

远鹏

高性能 #TiDB

学习笔记2

Qx

学习

学习源码的第八个月,我成了Spring的开源贡献者

程序员DMZ

spring 开源

LeetCode题解:11. 盛最多水的容器,while循环双指针,JavaScript,详细注释

Lee Chen

大前端 LeetCode

MySQL下的DB Link

Simon

MySQL

CDN百科11 | 担心纸质病例被洪水淹没?ECS+CDN+OSS如何助力医疗上云

阿里云Edge Plus

CDN 存储 云服务器

Apache Pulsar 在 BIGO 的性能调优实战(上)

Apache Pulsar

Docker入门与简单使用

Simon

Docker Linux

常用SQL语句分享

Simon

MySQL sql

CSS属性整理

kidd

不完满的选择,要不要放弃

zhoo299

随笔杂谈

糖果不需要意义

zhoo299

随笔杂谈 电影

SecureBoost算法

soolaugust

学习 同态加密 secureboost

GrowingIO 数据采集 iOS SDK 测试实践

GrowingIO技术专栏

ios 数据采集 sdk

云上攻击太多怎么办?不妨试试这些工具

华为云开发者联盟

黑客 云服务 数据安全 华为云 企业上云

力扣题 - 29 解析大佬题解

Geek_663541

Java算法

LeetCode152-乘积最大子数组-medium

书旅

LeetCode 动态规划

Docker 架构及工作原理

哈喽沃德先生

Docker 容器 微服务

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