我想分享一个我最喜欢的、屡试不爽的设计模式——“管道模式”。所谓的“管道模式”,可以将其想象成一个“传输带”,或者“生产线”,它们传递一个东西,每一步进行相应操作,然后再传给下一环。在编程里,就是拿起一个实例对象,进行必要操作或修改,然后再传给下一个类,如此传下去。

可以想象这么一个订单处理的电商逻辑:

  1. 用户下单了
  2. 支付逻辑处理了支付
  3. 订单记录,或者发货单生成了,并发送给了用户
  4. 订单发送到了你的ERP系统中了,交付生产了
  5. 订单物品打包后发货了
  6. 用户收到了一封感谢信

虽然借助于某些状态机(state-machine,可大致理解成管理状态的机器系统),这整个流程会更好处理些,但是这期间,很显然展示出了一个“管道”、“流程”或“步骤”概念。

在这些所有的步骤或管道中,有一个基本的常量——这个订单,它被传递到了这个过程的每一步,直到最后处理完。

如果你之前搞过类似的逻辑,那么十有八九你会在你的比如说Order.php里,搞类似的这么一堆乱糟糟的代码:

if ($order->getStatus() === 'success') {
    $this->getErpAdapter()->sendOrder($order);
}
// 此处省略了一百个if判断

这样的代码,要理解它,你就得一行行的紧盯着看,又乱又费时间,让人没心情看完。

Any fool can write code that a computer can understand. Good programmers write code that humans can understand.

任何一个傻子,都可以写出能让计算机执行的代码;但是只有好的程序员,才能写出让人看得懂的代码。

——Martin Fowler

这是我喜欢的一个引用。那么现在,看看下面的代码:

$pipeline = (new Pipeline)
    ->pipe(new createOrder)
    ->pipe(new processPayment)
    ->pipe(new sendInvoice)
    ->pipe(new exportOrder);
$pipeline->process($order);

现在还会有人抱怨“可读性”吗?不会了吧。

这样来写,同样也让你在“可测试性”上,以及单一职责原则上,获得巨大胜利——因为你现在每一块代码只做一件事儿,或者相关的两件也行,然后就传给下一个类了,职责非常简单清晰。这样整个流程的每一步都是可测试的,都可以轻易mock,甚至整个的管道流程也是可测试的。

管道模式的定义

如果你仍然对管道模式感到模糊,下面是一个相对学术的定义:

Each of the sequence of calculations is performed by having the first stage of the pipeline perform the first step, and then the second stage the second step, and so on.

每一系列的运算,都是先执行第一个阶段的第一步,然后是第二阶段第二步,这样继续下去

As each stage completes a step of a calculation, it passes the calculation-in-progress to the next stage and begins work on the next calculation.

每个阶段完成计算以后,它就将“结果”或“到了这一步的计算”,传递到下一步,以进行下一步的计算。

管道模式在php里的实现

这里使用php的例子来演示管道模式的应用,当然类似的完全可以用在任何的语言上。有很多实现了管道模式的组件,最有名的可以说是PHP League PipelineLaravel Pipeline

接下来的例子要使用的是PHP League Pipeline,关于Laravel Pipeline的介绍和用例,我们在之前的文章《轻松理解laravel的Pipeline("管道"模式)》已经提了。具体哪个组件都是大同小异的。

PHP League Pipeline组件网址://pipeline.thephpleague.com/

组件安装

composer require league/pipeline

先看总体的“管道”或生产线

先来看总体的“管道”或生产线,然后再看具体每一步的操作。

创建一个名为RunAllTheThings的类,然后我们要做的只是调用其doIt方法,也即RunAllTheThings->doIt,这样来执行整个管道流程,然后返回结果。

class RunAllTheThings
{
    
    public function doIt()
    {
        // 定义管道的步骤或阶段(铺好管子)
        $pipeline = (new Pipeline)
         ->pipe(new Segment\DoStage1))
         ->pipe(new Segment\DoStage2))
         ->pipe(new Segment\DoStage3));
        
        // 这个payload是在不同的管道状态之间,持续传递的实例(管子里要放什么,是水,还是汽油?)
        $payload = new Payload();
				
        // 处理管道逻辑(开闸放水,或者放油)
        $pipeline->process($payload);
				
        return $payload;
    }
}

很简单的吧,接下来看看我们管道里的流通物,也即这个payload

payload(管道里的流通物,持续传递的实例)

在这个例子里,这个payload,也即要在管道里传递的流通物,或者说持续传递的实例,只有一个protected属性,几个getter和setter。它的作用是让我们有一种简洁的方式来更新结果,并且保证最后的返回格式。

因为最终doIt方法会返回这个实例,我们就可以类似$result->getResult()这样来获取我们需要的数据。

class Payload
{
   protected $result = null;
    
    public function getResult()
    {
        return $this->result;
    }
    
    public function setResult($result)
    {
        $this->result = $result;
        return $this;
    }
   
    public function addResult($result)
    {
        $this->result .= $result;
        return $this;
    }
}

阶段一(或者第一步)

具体的每一步得是能callable,这意味着它们得是一个closure,或者callback,或者有一个__invoke方法。这里呢,我们使用__invoke方法这种形式。

想了解关于callable类型的更多知识,可以查看官方文档: //php.net/manual/en/language.types.callable.php

class Stage1
{
    public function __invoke(Payload $payload)
    {
        $payload->addResult('从你的');
        return $payload;
    }
}

这一步里,我们只是向$payload实例上添加字符“从你的”。这样就修改了$payload实例,然后又将其返回,交给下一个阶段进一步处理。

阶段二(第二步)

class Stage2
{
    public function __invoke(Payload $payload)
    {
        $payload->addResult('世界');
        return $payload;
    }
}

至此你可能开始逐步感觉到这个模式的魅力了。这一步,接收了$payload实例,然后给它加上“世界”字符,然后继续下一步。

阶段三(第三步)

class Stage3
{
    public function __invoke(Payload $payload)
    {
        $payload->addResult('路过');
        return $payload;
    }
}

知道这是要干啥了吧?

整体执行,返回结果

<?php
$things = new RunAllTheThings();
$result = $things->doIt();
var_dump($result->getResult());

这样来调用,然后会输出:

string "从你的世界路过"

“短路情况”

特定情况下,你不想让管道继续执行,比方说一个订单经校验是无效的,这就没必要进一步处理了,那么这种情况怎么办?

当然最简单的是try/catch,比如在某一步里相应地方抛出一个LogicException 异常来。

try {
    $pipeline->process($payload);
catch (LogicException $e) {
    // 做些其它的逻辑
}

动态管道

League\Pipeline另一个有用的地方是它的PipeBuilder ,它能允许你基于特定条件,来向管道上添加步骤。

use League\Pipeline\PipelineBuilder;

// 实例化PipelineBuilder
$builder = (new PipelineBuilder)
    ->add(new CreateOrder);
		
// 特定条件的状态
if ($order->getOrigin() === 'New Zealand') {
    $builder->add(new PreBookCarrier);
}
// 继续添加更多步骤
$builder->add(new processPayment)
    ->add(new sendInvoice)
    ->add(new exportOrder);
		
// 组装管道
$pipeline = $builder->build();

// 处理
$pipeline->process($order);

管道的复用

有时候,如果能在一个管道里,复用另一个管道里的逻辑,会非常有用。很简单,因为pipe可以接收一个callable或者另一个管道。

$createOrder = (new Pipeline)
    ->pipe(new CreateOrder)
    ->pipe(new GenerateInvoice);
		
$pipeline = (new Pipeline)
    ->pipe($createOrder)
    ->pipe(new ProcessPayment)
    ->pipe(new SendInvoice)
    ->pipe(new ExportOrder);
		
$pipeline->process($order);

结束语

尽管管道模式不是万能的,但是绝对有大量的杂乱代码,可以因此而变得更简洁、更具有可读性。

下次,当你需要构建一个多状态、多步骤的逻辑时,试试这个管道模式,看看会怎样吧~

该篇是我们课程《Laravel底层实战兼核心源码解析》这一课程的扩展阅读,在该课程里,我们提供了laravel Pipeline("管道"模式)的介绍,但可能很多同学还没有重视,没有真正利用起来,加之市面上很多新手教程总是兜售一些乱糟糟的代码,很容易影响大家的成长,故有此文。