Pipeline设计模式,不了解一下?

2020-09-26 09:46:11

阅读次数 - 791

架构 Pipeline 设计模式 流程

荒腔走板

咖啡与果汁

这周六趁着凉快,出去玩了一天。下午去老婆公司楼下的咖啡厅睡了一下午,醒来后再来一杯老婆亲手做的咖啡和鲜榨的不掺一滴水的果汁。那味道,别提多香了,上头!

虽然拉花不完美,但也有模有样。

我发了一个朋友圈,说希望有一天,可以回成都,开一家书店。她做咖啡,我写代码。有朋友评论说,这都不忘写代码,这么敬业的吗!我说,写代码不是工作,是我的兴趣爱好。

其实还有句话没说完,我想写自己想写的代码,做自己想做的产品。还想写博客,写小说,还想学画画。当然了,现在这都只是梦想,但是我希望在三十几岁的时候或者之前,能够实现它。

下面开始今天的文章。Pipeline设计模式是我在来新公司才接触到的,也是在项目上广泛使用的一种东西。觉得挺有意思的,所以写一篇文章向大家介绍一下,希望大家喜欢。

需求

前几天小明接到一个需求,要开发一个“简单”的支付处理流程,用来处理用户下单后的一系列处理流程。这个处理流程有很多环节,包括:订单计算(包括折扣计算),金额校验,库存校验,优惠券校验,执行支付,扣减优惠券,扣减库存,通知第三方物流,通知用户付款成功,通知商家发货等等。

小明接到这个需求后,心想这个需求不难,就是简单的计算、校验、调接口、发消息之类的。写if-else谁还不会?于是开始刷刷刷写下了三百行代码,就跟下面条一样,一气呵成。

面条

重构

代码是写完了。不过在Code Review的时候,被老大劈头盖脸地骂了一顿:“你这代码全挤在一堆,叫别人以后怎么维护?你自己过两个月看得懂吗?”

小明懂老大的意思,代码的可读性很重要,这样以后维护起来才方便。所以抽了一些方法出来,主入口只保留最核心的流程。顺便还加了单元测试来保证内部逻辑的正确性。

抽方法

改完后给老大一看,老大语重心长地说道“小明啊,代码可读性很重要,可维护性也很重要。你看,有些环节在其它场景说不定也能用到,比如库存校验、执行支付、发通知等等。”

“我懂的,这样,我把这些流程抽成单独的类,这样以后就可以复用了”。

抽取类

写完后再次给老大过目。老大看后点点头:“不错,现在看起来好多了。不过你也知道,我们现在xx商城业务发展很快,这个支付的场景其实变化很大的。比如说我们打算最近上架一批虚拟产品,就像会员或者游戏皮肤什么的,它是没有库存的,针对这种产品我们就不需要库存校验和扣减的环节。还有我们打算发展外卖行业,那在最后的通知环节就有些不同了,可能要通知外卖小哥。另外我们还打算最近搞个运营活动,有些产品是可以有推荐奖励的,用户付款成功后我们要返利给推荐人,而且这个运营活动一过,这个环节就得去掉。你看看能不能想办法支持一下,把这个流程搞得灵活一点,以后支持新业务尽量成本低一点。”

小明听完后头都大了。这咋搞啊!这流程现在都是写死在代码里的,虽然是抽成一个个类了,但是调用的地方还是一行行代码写死的啊。

使用Pipeline

于是小明上网查资料,了解到有一种叫做pipeline设计模式的东西,感觉跟自己的需求非常契合!

Pipeline翻译过来就是水管的意思,Pipeline设计模式其实很简单,就像是我们常用的CI/CD的Pipeline一样,一个环节做一件事情,最终串联成一个完整的Pipeline。

Pipeline设计模式有三个概念:Pipeline、Valve、Context。它们的关系大概是这样:

pipeline关系图

一条Pipeline有一个Context,多个Valve。这些Valve是很小的、单元化的,一个Valve只做一件简单的事。前后Valve之间的通信由Context来承载。Context是一个简单的POJO类,存放这条Pipeline里面的数据。

public interface Pipeline { void init(PipelineConfig config); void start(); Context getContext(); } public class Context { } public interface Valve { void invoke(Context context); void invokeNext(Context context); String getValveName(); }

配置化

Pipeline设计模式的精髓在于它的可配置化。使用Pipeline,如果你想调换Valve的顺序,或者某些业务是不是用某个Valve,都是可以在外部配置的。这样就可以很灵活地适配多样化的业务,针对不同的业务配置不同的处理流程。

仔细看Pipeline,是不是发现它像极了我们Web请求的Filter?我们在web.xml通过配置的方式定义使用哪些Filter,最终形成了一个Filter链。它的Context,就是request和response。而是否执行、什么时候执行下一个Filter,是显式调用的:

filterChain.doFilter(request, response);

Tomcat也广泛使用了Pipeline设计模式。

tomcat

事实上我们可以有很多种方式来实现配置化。你可以放在xml或者yml文件里,可以用Json的形式,放在统一的配置中心或者数据库里。甚至可以像Jenkins一样,写一个groovy的代码来跑Pipeline,这取决于你的实现。

大概长这样:

{ "scene_a": { "valves": [ "checkOrder", "checkPayment", "checkDiscount", "computeMount", "payment", "DeductInventory" ], "config": { "sendEmail": true, "supportAlipay": true } } }

如果你的业务相对稳定,业务线多,但变化相对较小。也可以使用Pipeline设计模式,但如果不想做成配置化,也可以直接在代码里写死,显式调用。

Pipeline变种与演化

Pipeline不是一成不变的,根据你的需要,它可以有很多变种和演化。

设计模式

Pipeline其实是使用了责任链模式的思想。但它也可以和其它设计模式很好地结合起来。

策略模式

我们在某个Valve,可能会需要根据不同的业务线,有不同的逻辑。比如同一个文本,有些是发邮件,有些是发短信,有些是发钉钉。那这个时候就可以在配置里面写上当前这个业务线要发送的渠道,然后在Valve里面通过策略模式去决定使用什么渠道发送,这样就不用在Valve里面写死很多if-else,以后很好扩展。

模板方法模式

有时候可能一些Valve具有共同的逻辑。比如下面这段伪代码的逻辑:

这个时候就可以使用模板方法模式,定义一个抽象的Valve,把公共逻辑抽取出来,把每个Valve差异的逻辑做成抽象方法,由Valve自己去实现。

工厂方法模式

Pipeline的优势在于通过配置化来灵活地实现不同的业务走不同的流程。实现统一化和差异化的完美结合。我们通过读取配置,生成一条Pipeline的时候,用工厂模式来实现比较好。

先读取当前的业务线,然后通过这个业务线和配置,完成整条Pipeline的实例化和装配,可以通过调用一个Pipeline Factory来实现。

Pipeline pipeline = PipelineFactory.create(pipelineConfig); pipeline.start();

组合

虽然我们说一个Valve只做一件简单的事。但这是相对于整个流程来说的。有时候太过细化也不好,不方便管理。正确的做法应该是做好抽象和分组。比如我们会有一个“校验”阶段,就不用把具体每个字段的校验都单独抽成Valve放进主流程。我们可以就在主流程放一个“校验”的Valve,然后在这个“校验”的Valve里面专门生成一条“校验Pipeline”。这样主流程也比较清晰明了,每个Valve的职责也比较清晰。

多个pipeline

注意,子Pipeline应该有它单独的Context,但是它同时也应该具有主Pipeline的Context,是不是应该通过继承来实现?

树与图

上面我们介绍的Pipeline,本质上是一个链。但如果往更通用(同时也更复杂)的方向去设计,它还可以做成一个图或者树。

假设我们在某个环节有一个条件分支,通过当时的context里面的数据状态,来判断下一步要走哪个Valve,形成一个树。最后可能又归拢到一个Valve,那就形成了一个图。

图状Pipeline

树和图会显著增加Pipeline的复杂度,需要配合上可视化配置,才能发挥出它的威力,但成本相对较高,除非真的业务非常多且复杂,不然不是很推荐。

树和图的Pipeline也需要结合数据结构专门设计Pipeline,节点如何往下走。

并行执行

我们在前面看到Valve都是链式一个一个执行的。但有时候可能多个Valve彼此之间并不依赖,可以同时并行地去跑。比如发消息,可能多个Valve并行地去发。

这个时候我们可以把Pipeline改造一下,就像Jenkins设计Pipeline那样,把一个完整的Pipeline分成Phase、Stage、Step等,我们可以对某个Phase或者某个Step设置成可以并行执行的。这需要另外写一个并行执行的Pipeline,用CountDownLatch等工具来等待所有Valve执行完,往下走。

日志和可视化

日志和可视化是有必要的。对于一条Pipeline来说,推荐在Context里面生成一个traceId,然后用AOP等技术打印日志或者落库,最后通过可视化的方式在界面展现每次调用经过了哪些Valve,时间,在每个Valve执行前和执行后的Context等等信息。

异常也很重要。如果使用Pipeline设计模式,推荐专门定义一套异常,可以区分为“可中断Pipeline异常”和“不可中断Pipeline”异常。这个根据实际的业务需求,来决定是否需要中断Pipeline。以我们前面的例子来说,我们在校验阶段如果不通过,就应该抛出一个可以中断Pipeline的异常,让它不往下走。但如果在发送邮件的时候发生了异常,只需要catch住异常,打印一下warn日志,继续往下走。中不中断Pipeline,是业务来决定的。

使用ThreadLocal

理论上来说,我们在任何地方,都应该使用Context来在整个Pipeline中传递数据。但Context有时候使用起来相对比较麻烦。比如我们在Valve内部抽私有方法的时候,可能要经常把Context作为方法参数传进去,用起来并不是特别方便。而Valve应该是无状态的,不适合把Context放在Valve里面作为属性。

这个时候我们可以借助ThreadLocal来代替Context的作用,Valve通过使用ThreadLocal来存取数据。但使用ThreadLocal有三个需要注意的点。

如果你的Pipeline是要支持并行的,在并行Valve里面就不适合使用ThreadLocal。

使用ThreadLocal要记得在最后阶段Clear,避免影响当前线程下次执行Pipeline。

不要把零散的一个个属性放进ThreadLocal,因为同一种类型,一个线程只能在一个ThreadLocal里面放一个值。而我们的上下文可能会有多个String、boolean等值。如果使用ThreadLocal,可以把所有属性都包成一个Context类,放进ThreadLocal。

Pipeline的缺点

Pipeline设计模式很强大,但它也有很明显的缺点。

第一个缺点是可读性不强。因为它是可配置化的,且配置经常在外部(比如数据库里的一个JSON)。所以可读性不好。尤其是我们在读Valve代码的时候,如果不对照配置,其实是不知道它的前后调用关系的

第二个缺点是Pipeline之间传递数据是通过Context,而不是简单的函数调用。所以一条Pipeline是有状态的,而且方法调用内部修改Context,而不是通过返回值,是有副作用的。

Pipeline模式适用场景

Pipeline设计模式适合于流程复杂、链路长的业务场景。尤其适用于业务线比较多,而不同的业务线有一些共性又有一些差异化的场景。可以通过一个个单元化的Valve来实现复用逻辑,通过配置化来实现不同业务线的差异化。

Pipeline的本质

Pipeline的本质是数据结构和设计模式的灵活应用,来应对流程复杂多变的业务场景。它并不是一个新的东西,也不是一个固定的设计模式,而应该是一种灵活的设计思想。

当然了,它也不是银弹,不能解决所有问题。还是要合适才行。

感谢阅读


觉得文章还不错?点击下方按钮分享吧!

评论

快来占个沙发吧~


TO: