当前位置: 首页 > 科技观察

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

时间:2023-03-16 15:48:37 科技观察

中间件可以看作是前端常用的“设计模式”。有时甚至可以说整个应用架构都是建立在中间件的基础之上的。那么中间件的优缺点是什么?中间件的正确使用姿势是什么?本文将分享笔者在实际使用中的一些想法。欢迎同学们一起讨论。首先简单说一下中间件constcompose=(middlewares)=>{constreduce=(pre,cur)=>{if(pre){return(ctx)=>cur(ctx,pre)}else{return(ctx)=>cur(ctx,()=>ctx)}}return[...middlewares].reverse().reduce(reduce,null);}这是一个非常简洁的中间件代码,像这样传入函数列表:constmiddlware=async(ctx,next)=>{/***dosomethingtomodifyctx*/if(/*letnextrun*/true){awaitnext(ctx)}/***dosomethingtomodifyctx*/}得到一个新函数,执行这个函数将导致这些中间件被一个一个地处理,每个中间件可以决定:在下一个中间件执行之前做什么?是否让下一个中间件执行?下一个中间件执行完之后怎么办?现在中间件中使用了洋葱模型。洋葱模型的大致示意图如下:根据这张图,中间件的执行顺序是:middleware1->middleware2->middleware3->middleware2->middleware1处理顺序是先从外到内,然后从里到外,这是中间件的洋葱模型。在中间件的应用中,开发者可以将统一的逻辑做成一个中间件,让这个逻辑可以在其他地方复用。我想这其实就是中间件模型的初衷。好吧,让我们把这个初衷放在一边。但实际上这种模式是一个空壳,可以通过不同的中间件实现各种自定义逻辑。例如:consthandler=compose([(ctx,next)=>{if(ctx.question==='hello'){ctx.answer='hello';return}if(next)[next(ctx)]},(ctx,next)=>{if(/age/.test(ctx.question)){ctx.answer='iam5yoursold';return}if(next)[next(ctx)]}])constctx={question:'hello'};handler(ctx)console.log(ctx.answer)//loghelloctx.question='howaboutyourage?'handler(ctx)console.log(ctx.answer)//logiam5yoursold貌似连我们都可以实现一个机器人,这样使用中间件,相当于把中间件扩展成一个if语句,通过不同中间件对ctx的劫持来分离逻辑。好像不错?由于中间件的灵活性,每个中间件可以实现:1)实现一个独立的逻辑;2)控制后续流程是否执行。2.说几个栗子,今年参与制作小程序的Bridge。首先简单介绍一下Bridge的功能。从支付宝小程序的角度,把其他小程序的JSAPI打通。Bridge具有扩展能力,可以扩展JSAPI。看到“可扩展性”,熟练的同学应该知道,我可以说到点子上了。Bridge目前的设计采用插件的形式注入了一系列的API。每个插件都有三个属性:插件名称、API名称和中间件。实现指向这些插件自带的中间件的组合,通过这种方式来实现自定义的API。这个方法其实看起来很奇妙,因为所有的API都可以以插件的形式注入到Bridge中,API可以灵活扩展。众所周知,有得必有失。这种模式实际上有其自身的缺点。我们可以从“面向开发者”和“面向用户”两个方面来梳理具体的不足。面向开发者是指写插件(也就是写中间件)的开发者,面向用户(user)是指最终使用Bridge的开发者。1面向开发者API的不确定性多个中间件注册在同一个API上。开发者自己的API能否正常运行取决于上下文,分散的中间件加载到Bridge中。对于上下文修改是未知的,因此给API的执行带来了很多不确定性。从洋葱模型的图中,我们可以发现内层经常受到外部的影响。当然在回流的时候,外部中间件也会受到内部中间件的影响。在开发中间件的时候,我们需要考虑自己的依赖,在已知依赖没有问题的情况下开发比较安全。但是目前Bridge这种批量加载Plugin的方式,无法稳定的描述依赖关系。API维护成本高。由于同一个API注册了多个插件,维护某个API的成本会比较高。有点像现在服务器端排查问题的情况。在多个插件的情况下,最坏的情况下,可能需要对每个开发者进行一一调查,最后共同承担责任。虽然实际情况可能没有那么糟糕,但还是要考虑最坏的情况。那么为什么这种服务端的架构是合理的,因为服务端的微服务架构确实可以拆分多个业务逻辑来解耦更复杂的逻辑,但是这里的Bridge只是想实现某个API,难度也很大。可见在实际使用过程中,基本都是采用单一插件注册的方式。所以我觉得用中间件来实现某个API,有点过渡性的设计,反而造成了维护成本的增加。2面向用户面向用户分为两种不同的场景:直接使用插件和通过预设集成插件。3直接使用插件这种模式,用户需要自己去引用插件,通过引用一系列的插件来获取一个正常的API,但是用户往往期望能够开箱即用,也就是说,要得到这个Bridge,看文档,可以调用某个API,而现在Bridge用户需要注册一个Plugin,才能获得一个可用的API,这显然是不合理的。不合理的地方主要体现在:API难以理解Bridge用户原本只需要理解Bridge文档就可以轻松使用API。现在他们需要了解插件的运行机制,如果有多个插件,还需要了解插件单独运行和相互运行的实现。这些对于一个Bridge用户来说是很难接受的,对于业务发展来说,成本也变得更高。增加的排错难度类似于使用中间件导致的API逻辑不连贯。多个插件实现增加了难度。总的来说,他还是需要简单了解各个插件的基本实现,以及插件之间的运行机制。对于业务发展来说,成本是比较高的。4通过Preset集成插件由于Bridge用户直接使用Bridge的问题,其实可以通过封装preset来解决一些痛点。Bridge的preset的概念是写一个preset来维护一个API和多个插件之间的关系,然后给用户一个集成的Bridge,就可以解决以上两个问题。这种模式形式上看是之前的Bridge用户选择了一个“最懂插件的人”作为他们的替身,代替之前的User,让这个人了解所有的Plugins,维护这些API。这个“knowsbest”趋向于极限,基本等于开发Plugin的人,所以这么庞大灵活,和维护plugin的人到头来是同一个人,这个人输出对外的API,所以这个东西真的复杂到可以拆分成这样吗?个人觉得直接清晰的实现一个API比较方便。那是中间件模型吗?5拿走,我们看下一个。除了Bridge,还有Fetch这样的基础库。fetch是另一组同学做的,不过我也有点吝啬。看了几眼代码,发现也是用中间件来做的,只是想看看设计API时使用中间件的合理性。先说说Fetch为什么走这条路,再看诉求:因为不同的请求类型太多了,所以想在相同的入参逻辑下通过adapter参数实现最终的请求。所以在设计Fetch的时候,是这样使用中间件的:fetch.use(commonMiddleware)fetch.use('adaptor-xxx',[middleware])//比如adapter-jsonfetch({...requestConfig,adaotpr:'adapter-xxx'})Fetch的中间件比较合理。利用中间件的特性,对外输出相同的输入输出参数,然后借助不同的中间件对请求过程进行流式处理。但是在实际使用过程中,也有不少同学反馈,出现了类似使用Bridge的问题。6故障排除呼叫过程的难点与Bridge类似。如果在业务使用过程中遇到问题,排查难度会比较高。首先,业务开发的同学理解起来会比较困难,因为需要同时理解这套中间件+各个中间件,但是adapter开发者很难排查问题。首先,他们需要了解业务开发人员在本地如何使用这些适配器。请求的执行将更加困难。3.引出的观点然后回头看看Bridge和Fetch这两个是否有必要使用中间件,有没有更好的选择。首先考虑如果我们不使用中间件,现在的困境会不会消失,例如:fetch.rpc=()=>{}fetch.mtop=()=>{}fetch.json=()=>{}中这样实现了不同类型的请求,每个请求的实现会更直观的汇聚在一个具体的功能上,这应该会带来以下问题:不同请求实现之间的共享逻辑不会那么直观,说白了,就是把中间件放在各自的实现中,放在前面和后面,即使他们抽取共同的功能,然后放在各自功能的实现中,这些共享的逻辑也不直观,中间件这种共享逻辑处理可以减少一定的维修费用。那么擅长的同学就会开始问了:刚才你说多个中间件会增加维护成本,现在你说共享逻辑可以做成中间件来降低维护成本。你不一致!这波过程Q还不错。最后说一个观点:这种中间件模式应该作为某个功能的装饰器模式。既然提到了装饰者模式,我们就可以引用一本书上的描述《维基百科》:装饰者模式是一种设计模式)允许将行为添加到单个对象),动态地,不影响来自其他对象的行为同一个班)。装饰者模式是一种设计模式,可以动态地修改一个对象的行为,而不影响同一类的其他对象。其实这个描述的体感不是很强,因为中间件本身已经不是对象了,维基百科中的设计模式描述的是面向对象的语言。为了有更好的体感,附上《Head First设计模式》的图:可以发现几点:装饰器和我们需要扩展的Class都实现了相同的接口。装饰器通过接收一个组件对象来工作。看到以上两点,你会发现装饰器模式和中间件的概念大致相同,只是在Javascript中,通过一个compose函数将几个不相关的函数连接起来,但最终的模式与这种装饰器模式基本一致。另外,《Head First设计模式》里有一张图:这是他给出的计算咖啡价格的例子。这张图是不是很眼熟?和我们一开始讲的洋葱模型非常相似,再次证明了一个事实,我们使用的“中间件设计模式”其实就是“装饰器模式”。然后说到装饰器模式,其实就是为了解释之前解释的“中间件这种模式应该作为某个功能的装饰器模式”的观点,因为装饰器本身就是为了解决带来的问题关于继承。类的数量激增,使用场景正如它的名字一样,有装饰者和被装饰者的区别,虽然装饰者最终也可以变成被装饰者,就像例子中咖啡的计算价格,装饰者可以根据加牛奶或加奶泡等计算费用,但实际上在这个场景中,用牛奶装饰是没有意义的,也很难理解。反过来,我认为中间件模型是一样的。四回应通过上面的分析我们知道,我们在使用中间件的时候,至少要有一个主要的功能,而其他的中间件都是用来装饰的。比如我们在使用Koa进行Node开发的时候,往往会把业务逻辑放在一些中间件中,其他的都是中间件做拦截或者预处理。egg中主要的业务逻辑做成了一个controller,当然他最后肯定是一个中间件,是API的一种美化,很科学。再比如,我们在使用redux的时候,中间件往往会做一些简单的预处理或者action监听等,当然也有替代的方法,比如redux-saga来接管整个逻辑。先说常规用法吧。回过头来看,如何做出像Bridge这样的改变?我觉得在Bridge底层使用中间件让API处理流程化是完全没有问题的,但是现在的问题主要是他的API造成的,就像egg对koaAPI的美化一般。Bridge也应该美化API的设计,限制二次开发者的想象力。召唤一个多么强大的奴隶。”那么我们应该如何限制API呢?根据前面的说法“这种中间件模式应该作为函数的装饰器模式”,因此,首先,必须有一个显式的声明main函数,我们的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的主要实现在处理程序中。您只需要维护处理程序。如果有特殊问题,再去使用的中间件。API很难理解。用户清楚地知道他们只需要了解处理程序的实现。中间件的大部分逻辑都是公用的,大家统一理解即可。这时候擅长的同学还是会问,其实好像你的问题还没有完全解决。只要开发者想惹你,以前的问题还是会出现。比如有人会把逻辑写到中间件里面。如果你不把它写到处理程序中,你的设计仍然是一样的。这是千真万确的,因为设计这个API不可避免地要向开发者开放这样的能力,即:1)自定义API;2)对几个API做一些个性化的统一逻辑。API设计者能做的就是在API上向开发者传达一个规范,比如bridge.plugin()这种开放的API不如bridge.API(),因为后者很明确前者让开发者可以声明API,前者不明确,前者让开发者觉得中间件就是API的实现。5结束语在本文中,我们从中间件讲到中间件的使用示例,再到装饰器模式,最后讲到使用中间件的API设计。在日常的API设计中,我不仅会面临底层设计的选择,还会面临开放API的设计,两者同等重要。不过本文仅代表个人观点,欢迎评论区指教和讨论。【本文为专栏作者《阿里巴巴官方技术》原创稿件,转载请联系原作者】点此查看作者更多好文