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

10,615 阅读11分钟

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

需求

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

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

面条
面条

重构

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

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

抽方法
抽方法

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

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

抽取类
抽取类

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

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

使用Pipeline

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

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

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

pipeline关系图
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
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

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

树与图

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

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

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

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

关于作者

我是Yasin,一个不断精进的菜鸡。

微信公众号:编了个程

个人网站:https://yasinshaw.com

关注我的公众号,和我一起成长~

公众号
公众号

本文使用 mdnice 排版