前端如何正确使用中间件?

简介: 中间件可以算是一种前端中常用的”设计模式“了,有的时候甚至可以说,整个应用的架构都是使用中间件为基础搭建的。那么中间件有哪些利弊?什么才是中间件正确的使用姿势?本文将分享作者在实际使用中的一些想法,欢迎同学们共同讨论。

image.png

一 先简单讲讲中间件

const compose = (middlewares) => {
  const reduce = (pre, cur) => {
    if (pre) {
      return (ctx) => cur(ctx, pre)
    } else {
      return (ctx) => cur(ctx,  () => ctx)
    }
  }
  return [...middlewares].reverse().reduce(reduce, null);
}

这是一段非常简洁的中间件代码,通过传入的类似这样的函数的列表:

const middlware = async (ctx, next) => {
  /**
   * do something to modify ctx
   */
  if (/* let next run */true) {
    await next(ctx)
  }
  /**
   * do something to modify ctx
   */
}

得到一个新的函数,这个函数的执行,会让这些中间件逐个处理并且每个中间件可以决定:

  • 在下个中间件执行之前做些什么?
  • 是否让下个中间件执行?
  • 在下个中间件执行之后做些什么?

现在的中间件都是使用的洋葱模型,洋葱模型的大致示意图是这样的:

image.png

按照这张图,中间件的执行顺序是:

middleware1 -> middleware2 -> middleware3 -> middleware2 -> middleware1

处理顺序是先从外到内,再从内到外,这就是中间件的洋葱模型。

在中间件的应用上,开发者可以将统一逻辑做成一个中间件,这样就能在其他地方复用这个逻辑。我觉得这其实是中间件这种模式的初心吧,好,那我们先把这个初心放一放。

但实际上这个模式就是一个空壳,通过不同的中间件,就可以实现各种自定义的逻辑。比如:

const handler = compose([(ctx, next) => {
  if (ctx.question === 'hello') {
    ctx.answer = 'hello';
    return
  }
  if (next) [
    next(ctx)
  ]
}, (ctx, next) => {
  if (/age/.test(ctx.question)) {
    ctx.answer = 'i am 5 yours old';
    return
  }
  if (next) [
    next(ctx)
  ]
}])
const ctx = { question: 'hello' };
handler(ctx)
console.log(ctx.answer)  // log hello
ctx.question = 'how about your age?'
handler(ctx)
console.log(ctx.answer) // log i am 5 yours old

这样看起来我们甚至可以去实现一个机器人,把中间件这么拿来用,相当于是把中间件作为一个 if 语句展开了,通过不同的中间件对ctx的劫持来分离逻辑,看起来好像也不错?

得益于中间件的灵活性,每个中间件可以实现:1)实现独立的某个逻辑;2)控制后续的流程是否执行。

二 聊聊几个栗子

今年有参与做个小程序的Bridge,先简单的介绍一下Bridge的功能。

  • 从支付宝小程序的视角来抹平其他小程序的JSAPI。
  • Bridge拥有扩展能力,能够扩展JSAPI。

看到“扩展能力”,熟练的同学应该就知道我可以切入正题了。

Bridge现在的设计采用插件的形式来注入一系列API,每个插件都有插件名、API名、中间件三个属性,注入Bridge后,Bridge会将相同API名的插件整合在一起,让这个API的实现指向这些插件带有的中间件的 compose ,用这种方式来实现自定义API。

image.png

这种方式其实看起来是非常美妙的,因为所有的API都可以通过插件的形式注入到Bridge中,可以很灵活地扩展API。

众所周知,有得必有失。这种模式其实有自己的缺点,具体的缺点我们可以从“面向开发者”和“面向使用者”两方面来整理,面向开发者指的是面向写插件(也就是写中间件)的开发者,面向使用者(用户)指的是最终使用Bridge的开发者。

1 面向开发者

API的不确定性

多个中间件注册在同一个API上面,开发者自己的API是否能够运行正常有的时候是依赖上下文的,而零散的中间件被载入Bridge,对于上下文的修改是未知的,因此会对API的执行带来很多不确定性。

从洋葱模型的图上面,我们可以发现,内层往往会受外部的影响,当然在回流的时候,外部中间件也会受内部中间件的影响,在开发中间件的时候,我们需要考虑自己的依赖,在已知依赖没有问题的情况下去做开发,才会比较稳妥,但是当前Bridge这种散装载入Plugin的方式,让依赖关系没有办法稳定的描述。

API的维护成本高

由于有多个插件注册到单个API上,维护某个API的情况下就会有比较高的成本,就有点像是现在服务端排查问题的情况了,多个插件的情况下最差情况可能要逐个开发者去做排查,最终才能分锅,虽然实际情况可能没有这么糟糕,但还是要考虑一下最差的情况。

那么为什么服务端这种架构是合理的呢,因为服务端的微服务架构确实能够将多个业务逻辑拆分来解耦比较复杂的逻辑,但是Bridge这里只是想要实现某个API的实现,也很明显的发现实际在使用过程中,基本都采用了单插件的注册方式。所以感觉用中间件来实现某个API,有点过渡设计了,反而造成了维护成本的提高。

2 面向使用者

面向使用者其实要分为两种不同的场景:直接使用插件和通过preset来使用插件的集成。

3 直接使用插件

image.png

这种模式下,使用者要自己去引用插件,通过引用一系列插件来获得一个可以正常使用的API,可是使用者往往期望的是能够开箱即用,也就是说拿到这个Bridge,看一下文档,就能够调用某个API了,如今需要Bridge的使用者通过自己注册一个Plugin这样的东西来获得一个可用的API,显然是不合理的,不合理的地方主要体现在:

API难理解

Bridge使用者原本只需要理解一下Bridge的文档就能够轻松使用API,现在需要理解plugin的运作机制以及如果有若干个插件的话,还要理解插件单独的运作和相互运作的实现。这些都很难让一个Bridge使用者接受,对于业务开发来讲,成本变高了。

问题排查难度上升

这点和之前提到的使用中间件这种方式会造成API的逻辑不连贯的情况是类似的,Bridge在使用API的时候如果发现有问题,那么排查问题的时候就会因为有多个Plugin实现而增加难度,总的来说他还是需要简单的去理解每个插件基本实现和插件间的运作机制,对于业务开发来讲,成本较高。

4 通过Preset来使用插件的集成

由于上述Bridge使用者直接使用Bridge的问题,其实通过preset的封装可以解决一部分的痛点,而Bridge的preset的概念就是,通过编写一个preset,这个preset去维护一个API和多个插件的关系,然后给到用户的是一个集成好的Bridge,上述的两个问题都可以被解决。

image.png

这个模式看起来形式上就是之前的Bridge用户选了一个“最懂插件的人”来做他们的替身,做了之前的那个User的角色,让这个人来理解所有的Plugin,并维护这些API,这个"最懂"趋向极限,基本就等于开发Plugin的人了,那么饶了这么大一圈,做的这么灵活,最后维护插件的人是同一个人,也是这个人对外输出API,那么这个东西真的有复杂到要这么拆分么。就我个人来讲觉得还是直接简单明了的的实现一个API来的方便。那是中间件这种模式辣鸡吗?

5 抬走,我们来看下一个

除了Bridge,老生常谈的还有类似Fetch这样的基础库,Fetch是另一波同学做的了,但是我也是小撇了几眼代码,发现居然也用了中间件来做,正好可以看看他们在设计API的时候使用中间件的合理性。先说说Fetch为啥走了这条路吧,看看诉求:

因为实在是有太多种不同的请求类型了,因此想实现在相同的入参下,通过adaptor参数来区分最终走怎样的请求逻辑。

因此Fetch在设计的时候,是这么使用中间件的:

fetch.use(commonMiddleware)
fetch.use('adaptor-xxx', [middleware]) // 比如adaptor-json
fetch({ ...requestConfig, adaotpr: 'adaptor-xxx' })

image.png

Fetch的中间件使用会相对合理一点,通过利用中间件的特性,对外输出了相同的出入参,再借助不同的中间件对请求的过程做流式处理。

但实际的使用过程中,也要很多同学反馈,有类似Bridge的使用问题。

6 调用过程排查困难

和Bridge类似,业务在使用过程中如果遇到问题,排查难度会比较高,首先业务开发同学的理解能力就很难了,因为要同时理解这套中间件+每个中间件的实现原理,而adaptor开发同学也比较难排查问题,首先他需要知道业务开发同学本地是如何使用这些适配器的,在知道了之后再零散的逐个插件去排查,相比于直接看某个类型的请求的实现,难度会较高。

三 引出观点

那么回头看看这两个Bridge和Fetch究竟有必要使用中间件么,有没有更好的选择。

先考虑假如我们不使用中间件来做,是不是现在的困境都会不存在了,就比如:

fetch.rpc = () => {}
fetch.mtop = () => {}
fetch.json = () => {}

这样实现不同类型的请求,每个请求的实现就会比较直观的收敛在具体的函数中,随之带来的应该有如下的问题:

不同请求实现之间的共享逻辑会不那么直观,说白了就是将中间件前置后置那堆东西拿放到各自的实现中,哪怕是抽了公共函数然后再放到各自函数的实现中,这些共享逻辑都不直观,而中间件那种共享逻辑的处理,可以减少一定的维护成本。

那么会杠的同学就要开始问了:刚才你说多个中间件会加大维护的成本,现在又说共享的逻辑做成中间件能够减少维护成本,你这前后矛盾啊!

这波流程Q的不错。

那终于,要在这里抛一个观点:

中间件的这种模式,应该作为某个函数的装饰者模式来使用。

那么既然提到装饰者模式,我们可以引用一本《维基百科》中的描述:

the decorator pattern is a design pattern) that allows behavior to be added to an individual object), dynamically, without affecting the behavior of other objects from the same class).

装饰者模式是一个可以在不影响其他相同类的对象的情况下,动态修改某个对象行为的设计模式。

其实这段描述的体感不是很强,因为其实中间件本身已经不是一个对象了,而维基百科中的设计模式针对面向对象的语言做了描述。

为了更有体感一点,附上一张《Head First设计模式》中的一图:

image.png

可以发现几点:

  • 装饰器和我们需要扩展的Class都是实现了同一个接口。
  • 装饰器是通过接收一个Component对象来运作的。

看到上面这两点就会发现其实装饰器模式和中间件的概念是大致相同的,只不过在Javascript中,通过一个compose的函数将几个毫不相干的函数串了起来,但最终的模式是和这个装饰者模式基本一致的。

另外《Head First设计模式》中还有一张图:

image.png

这是他举的咖啡计算价格的例子,看到这张图不是特别眼熟么,这和我们最开始说的洋葱模型非常相近,这也再一次证明了其实我们用的“中间件设计模式”其实就是“装饰者模式”。

那么聊了一下装饰者模式,其实是为了说明我之前阐述的“中间件的这种模式,应该作为某个函数的装饰者模式来使用”的观点,因为装饰器本身是为了解决继承带来的类的数量爆炸的问题的,而使用场景正如同它的名字一般,是有装饰者和被装饰者的区分的,尽管装饰者最终也能成为一个被装饰者,就如同例子中,计算咖啡的价格,装饰者可以根据加奶或者加奶泡等等来计算收费,但是其实着这个场景下,去做对加奶的装饰,就没什么意义了,也很难懂。反推我觉得中间件这种模式,亦是如此。

四 回应

通过如上的分析,我们得知,我们在运用中间件的时候,起码要有一个主要的函数,而其他的中间件,都是用于装饰使用。

就比如我们在使用Koa做Node开发的时候,常常把业务逻辑放到某个中间件中,其他的都是一些拦截或者预处理的中间件,在egg中主要的业务逻辑被做成了一个controller,当然他最后肯定还是一个中间件,这是一种API的美化,非常科学。

再比如我们在使用redux的时候,中间件往往都是做一些简单的预处理或者action监听等等,当然也有另类的做法,比如redux-saga整个将逻辑接管掉的,这块另说,我们这次先只聊常规用法。

那回过头来,想比如Bridge这类如何做修改呢?

我觉得Bridge底层使用中间件来做API的处理流完全没有问题,但造成现在这样的问题主要是他的API,就如同egg做了koa的API的美化一般,Bridge也应该在API的设计上美化一下,限制二次开发者的脑洞,API不是越自由就越好,有句话说的好“你在召唤多强大的自由,就是在召唤多强大的奴役”。

那么我们应该如何限制API呢?

依照之前阐述过的说法“中间件的这种模式,应该作为某个函数的装饰者模式来使用”,因此,首先要有一个显式申明的主函数,这块我们的API应该如下设计:

bridge.API('APINAME', handler)
// 或者更加直接的
bridge.APINAME = handler

这样一来,开发者在查找API实现的时候,就能够比较明确的找到这块的实现,而最底层Bridge还是会吧这个handler丢到一个中间件中去做处理,这样就能做到对这个handler的装饰。

在这个的基础上,再设计一个能够支持中间件的API:

bridge.use(middleware) // 对所有的API生效
bridge.use('APINAME', middleware) // 对某个API生效

再回顾一下之前列出来的问题:

API的不确定性

API的实现都会放到handler中,且仅有这个handler会做主要逻辑处理,开发者明确的知道这里写的就是主逻辑。

API的维护成本高

API的主要实现就在handler中,只需要维护handler就行,有特殊的问题,再去看使用的中间件。

API难理解

用户明确的知道只需要理解handler的实现就行,中间件的逻辑大部分是用于公共使用,只要统一理解就行。

到这里,会杠的同学还是会问,其实你这好像问题也没有完全解决,只要开发者想搞你,还是会出现之前的问题,比如就会有骚的人把逻辑写到中间件里面,不写到handler里面,你这种设计不还是一样。

这说的一点都没错,因为设计这个API难免的就是要开放给开发者这样的能力,也就是:1)自定义API;2)对若干API做一些个性化的统一逻辑。API的设计者能够做到的就是在API上传达给开发者一种规范,就比如 bridge.plugin() 这种开放性的API,就没有 bridge.API() 这种好,因为后者很明确的让开发者申明一个API,而前者不明确,前者让开发者觉得中间件就是API的实现。

五 结语

本篇我们从中间件聊到中间件的使用实例,再聊到了装饰器模式,最后聊到了使用中间件的API的设计。在日常API设计中,我不仅会面对底层设计的选型,还会面对对外开放API的设计,两者都同样重要。不过本篇仅代表个人观点,欢迎在评论区指教、讨论。

你可能感兴趣的:(前端,中间件)