使用pipeline设计模式实现用户积分任务需求

背景

背景就是产品经理提了一个需求,完成任务赠送积分,如果遇到退款需要回收积分,任务是大概是这样的:

  1. 每天首次加入购物车赠送 10 积分
  2. 每天首单可以赠送 100 积分
  3. 购物累积金额达到 99 元赠送 100 积分
  4. 购物次数满 10 次赠送 100 积分
  5. 每日签到送 10 积分
  6. 还有很多奇奇怪怪的任务…

实现过程

分析

当加入购物车时赠送积分,任务结束,当购买商品时就有可能会同时命中多个条件同时赠送积分,命中的所有条件都赠送后,任务结束。

分析完需求,接下来就想如何实现,最简单的方法也就是 if else 实现:

// 支付成功触发赠送积分
if ("当天首单") { // Reward shopping points }
if ("累积99元") { // Reward shopping points }
if ("买满10次") { // Reward shopping points }
// ...

提需求的时候产品已经想到二期的积分任务需求了,所以随着任务的增多,可维护性一定会降低,所以立马否决了用if else 实现的想法

紧接着想到了之前做支付时用到的「简单工厂」+ 「策略模式」经验,应该是有符合要求的设计模式能解决这类问题。因为整体流程是一条直线的流程,依次执行,就想到责任链模式。通过查询相关资料,责任链模式的变种「管道模式」似乎更适合应用至此。

管道模式

管道模式也称为流水线模式,英文:Pipeline。

看到 Pipeline 这个单词非常熟悉,似乎在那里见过,思来想去,是在 Laravel 里面见过,之前分析 Laravel 依赖注入和控制反转 时见到过。

Laravel 通过 Pipeline 实现 Middlewaregithub.com/laravel/framework/blob/...

use Illuminate\Routing\Pipeline;

protected function sendRequestThroughRouter($request)
{
    $this->app->instance('request', $request);

    Facade::clearResolvedInstance('request');

    $this->bootstrap();

    return (new Pipeline($this->app))
                ->send($request)
                ->through($this->app->shouldSkipMiddleware() ? [] : $this->middleware)
                ->then($this->dispatchToRouter());
}

继续往上追 Pipeline 的实现,发现 Laravel 是实现了一个 Pipleline 契约接口,实现了两个管道分别是公用的Pipleline和一个 Routing 相关的 Pipleline,其中Routing Pipleline是继承了公用的 Pipleline 重写了部分方法。

Pipleline 契约接口

image-20220802181021973

  • send() 需要传递的数据。
  • through() 需要处理的任务
  • via() 调用的方法名,默认为 handel()
  • then() 对于返回数据的处理

看到这里,既然Laravel 已经实现了 Pipleline 的公用方法,那就可以直接拿来用了,刚开始还想着要实现的话还得加加班呢,现在不用了。真优雅~

编码

整体构建目录

├── PointTask
│   ├── OverRmb.php          // 满 N 元任务
│   ├── SignIn.php           // 签到任务
│   ├── TodayFirst.php       // 每日首单任务
│   ├──
│   ├── PointTask.php        // abstract 约束
│   └── PointTaskService.php // 对外调用方法

既然要考虑到以后的修改以及通用性,那就要抽象出公用方法,统一继承实现。

经过分析主要方法有两个:分别是发送积分和回收积分,所以先抽象这两个方法。

abstract class PointTask
{
    // 发送积分
    abstract function send($next, $orderInfo);

    // 回收积分
    public function recycle($next, $orderInfo)
    {
        return $next($orderInfo);
    }
}

因为有些任务是只有赠送,没有回收的情况,所以定义了 abstract 抽象方法,而不是 interface ,这样在具体任务的实现时可以不去实现 recycle 方法。

  • 每日首单任务

    class TodayFirst extends PointTask
    {
        function send($next, $orderInfo) {
            // 有订单直接执行下一个任务
            if (!app(PayOrderService::class)->isTodayFirst($orderInfo['orderSn'])) {
                return $next($orderInfo);
            }
            // 赠送积分
            app(PayOrderService::class)->sendPoint(100);
            return $next($orderInfo);
        }
    
        function recycle($next, $orderInfo) {
            // 回收积分, code...
            $next($orderInfo);
        }
    }
  • 买满多少钱赠送积分

    class OverRmb extends PointTask
    {
        function send($next, $orderInfo) {
            // 小于 100 元直接执行下一个任务
            if ($orderInfo['price'] < 100) {
                return $next($orderInfo);
            }
    
            // 赠送积分, code...
            return $next($orderInfo);
        }
    
        function recycle($next, $orderInfo) {
            // 回收积分, code...
            $next($orderInfo);
        }
    }
  • 每日签到

    class SignIn extends PointTask
    {
        function send($next, $orderInfo)
        {
            // 已签到直接执行下一个任务
            if (app(UserService::class)->todayIsSinIn()) {
                return $next($orderInfo);
            }
    
            // 赠送积分, code...
            app(PayOrderService::class)->sendPoint(10);
            return $next($orderInfo);
        }
    }

案例已经完成了方法的抽象,实现了 3 个具体积分任务,接下来编写 PointTaskService 实现 Pipeline 的组织。对 Laravel 提供的 Pipeline 不太明白的朋友,可以参考下方的参考文章。

PointTaskService

class PointTaskService
{
    // 定义了可能同时触发的任务
    public $shopping = [TodayFirst::class, OverRmb::class];

    // 购物赠送积分
    public function shoppingSend($orderSn) {
        $orderInfo = app(PayOrderService::class)->getOrderInfoByOrderNo($orderSn);
        return (new Pipeline(app()))
            ->send($orderInfo)
            ->via('send')
            ->through($this->shopping)
            ->thenReturn();
    }

    // 购物退款回收积分
    public function shoppingRecycle($orderSn) {
        $orderInfo = app(PayOrderService::class)->getOrderInfoByOrderNo($orderSn);
        return (new Pipeline(app()))
            ->send($orderInfo)
            ->via('recycle')
            ->through($this->shopping)
            ->thenReturn();
    }

    // 每日签到
    public function signIn() {
        return (new Pipeline(app()))
            ->via('send')
            ->through(SignIn::class)
            ->thenReturn();
    }
}

thenReturn() 方法

thenReturn() 方法是对Pipleline 契约接口的 then() 方法的包装,默认的返回值是调用 send() 时传入的参数,如果对返回值需要再进行处理,则可调用 then(), 传入一个匿名函数进行处理。

支付成功后调用:

if ($isPaid) {
   // 赠送积分实效可以不用那么及时,可推到队列异步执行。
   app(PointTaskService::class)->shoppingSend("0722621373");
}

退款成功后调用:

if ($isRefund) {
   app(PointTaskService::class)->shoppingRecycle("0722621373");
}

每日签到调用:

if ($signIn) {
   app(PointTaskService::class)->signIn();
}

文件看起来似乎挺多的,但条理还是比较清晰的:

  1. 如有新任务,则新建一个任务类继承 PointTask 实现 send 方法,如有可能收回积分则再实现 recycle 方法。
  2. 再在 PointTaskService 对外开放的 Service 中加入到指定位置,即可完成,不会影响到其他的业务逻辑。
  3. 已有的调用处也不用变动代码。

总结

  1. 认真分析过的源码可能会忘记,但能在合适的时间回想起来就证明当时是有效的分析阅读。
  2. 平时缝缝补补的小需求遇到糟心的代码基本也是往上继续堆代码,但如果有机会接手完整的功能点,那就尽可能的写好点吧。

源码

github.com/zxr615/rewrite-pay-modu...

参考

Laravel 中的 Pipeline — 管道设计范式

Pipeline 管道操作实现请求中间件过滤

Laravel Pipeline 组件的实现原理

本作品采用《CC 协议》,转载必须注明作者和本文链接
本帖由系统于 1年前 自动加精
《L04 微信小程序从零到发布》
从小程序个人账户申请开始,带你一步步进行开发一个微信小程序,直到提交微信控制台上线发布。
《L02 从零构建论坛系统》
以构建论坛项目 LaraBBS 为线索,展开对 Laravel 框架的全面学习。应用程序架构思路贴近 Laravel 框架的设计哲学。
讨论数量: 19
sanders

可能是理解不一样,我们的用法不太一样,我们使用 pipeline 的目的是解耦合。

如果抽象一点讲,我们将 pipeline 分为:入口 节点逻辑封装 和 编排 三部分。

入口

入口是管道执行 then() 方法的位置,入口可传入该位置的业务逻辑参数,并通过管道获取返回值比如:

# 若是集合对象参数也可从管道中使用PHP的对象引用传值特性带出数据
$params = collect(['foo'=>'bar']);
# 获取配置中的编排数据
$compose = config('pipes.foo.bar',[]);
(new Pipeline)->send($params)->through($compose)->then(function (Collection $passable) {
    # 执行入口部分作为管道中心点的逻辑,也可以什么都不执行直接返回
    return data_get($passable,'foo');
})

节点

节点逻辑封装跟楼主的代码类似,都是抽象成一个类型,提供方法供管道调用。

class Foo
{
    function handle (Collection $passable, \Closure $next)
    {
        # 节点自身的逻辑,但若不想中断当前管道逻辑传递,就必须调用 $next 闭包
        # 这里可以执行前置逻辑
        $value = $next();
        # 这里可以执行后置逻辑
        return $value;
    }
}

编排

编排的存在的价值是将以上两部分结合在一起,比如在 config 目录中存在某个名为 foo.php 的文件其内容为:

return [
    'bar' => Foo::class
];

解耦过程

  1. 现在我们假设在 入口 位置,不知道有哪些管道节点会被加载;
  2. 假设 管道节点 封装的逻辑只知道自己将被入口引用,但不知道是否存在其他管道节点;
  3. 所有的管道节点均使用配置数据进行 编排 (即便是执行顺序也可进行编排)。

这样就得到了一个 管道节点入口 单向依赖的解耦的过程。

应用场景

就说我们现在的应用,是要根据客户的需求来定制各种模块进行交付,如果我们将代码都写在一起用流控制语句进行判断会产生大量的无效代码,很难进行维护。重新开发也会增加为每个客户维护系统的难度。所以我们将主要功能以外的功能,封装成模块,通过 composer 拉取代码仓库进行安装和维护。大部分模块分包的代码都是同步更新的,如果客户对某个模块有很强的定制化需求,我们也不需要替换所有分包。那么现在就有了问题:主项目都不知道加载了哪些模块,与模块的代码之间如何通讯?分包之间彼此也不知道对方的存在,他们之间如何通讯?

在使用微服务或消息订阅等技巧之前,我们选择的则是 pipeline 模式。如果要考虑配置能力,我们甚至可以将编排数据保存到数据库中,来为客户动态关闭或开放某些功能模块。

有缺点吗?

以上我只说了使用 pipeline 的优点或者说是特点。但使用过程中确实存在一些问题,我觉得值得跟大家分享,大家若有好的解决方案,还请赐教:

入口拆分

使用了 pipeline 进行解耦就意味着,要时刻在心里评估实现一个简单的模块功能要拆分到多少入口进行引用。举个简单的 订单查询 的例子,这个功能基本上可以在一个请求的生命周期内解决。但如果要加入一个根据优惠券名称查询并在结果中显示优惠券名称的逻辑,就要求我们可能在 请求校验 控制器整理参数 查询构造器操作 视图层呈现 这些部分增加 pipeline 入口;有时为了执行效率或资源开销还要考虑在进程中复用管道实例。这无疑增加的 节点 逻辑维护的难度。

脆弱

就像编排数据中所描述的,节点之间在执行过程中实际上还是环环相扣的,一个脆弱的模块节点将造成整个执行过程都变得脆弱。这种脆弱不仅体现在该节点存在的时候,也体现在将该节点移除的时候。这对工程师的要求很高,既要考虑当前功能实现,又要考虑各种编排下程序是否能获取期望的结果。当节点数量增加时,若要用测试用例进行全方面覆盖,几乎就成了不可完成的任务。

总结

从我们的经验上来看,使用 pipeline 来实现模块解耦开发,虽然道路上是正确的,但需要更多的文档和注释对 入口节点 登记造册,也需要在开发人员培训方面投入更多的精力以及磨合的时间。

1年前 评论

个人觉得这里用观察者更直观,更好维护,逻辑也更加清晰,不用引入那么多的类,再从容器中掏服务,也就是 Laravel Event

管道在维护主对象的状态变更时很有用, 每个 task 都有可能改变上个对象的状态, 但是对于订单来说(恒定不变),用它的意义不大。 这是它和观察者最大的区别。

1年前 评论
lyxxxh 1年前

认真看完的举个爪!总结写的很好,受教了,但 对 pipeline 还不是很理解。

1年前 评论
zxr615 (楼主) 1年前

nice,对管道又有了一个新的认识。

1年前 评论

前面都看懂了, 就后面的 PointTaskService 和调用方法没看懂

1年前 评论
zxr615 (楼主) 1年前

对于有强迫症人来说,可以改成这样

每日首单任务

class TodayFirst extends PointTask
{
    function send($next, $orderInfo) {
        // 有订单直接执行下一个任务
        if (app(PayOrderService::class)->isTodayFirst($orderInfo['orderSn'])) {
             // 赠送积分
             app(PayOrderService::class)->sendPoint(100);
        }
        return $next($orderInfo);
    }
}

买满多少钱赠送积分

class OverRmb extends PointTask
{
    function send($next, $orderInfo) {
        if ($orderInfo['price'] >= 100) {
            // 赠送积分, code...
        }
        return $next($orderInfo);
    }
}

每日签到

class SignIn extends PointTask
{
    function send($next, $orderInfo)
    {
        if (!app(UserService::class)->todayIsSinIn()) {
            // 赠送积分, code...
            app(PayOrderService::class)->sendPoint(10);
        }        
        // 已签到直接执行下一个任务
        return $next($orderInfo);
    }
}
1年前 评论
zxr615 (楼主) 1年前
sanders

可能是理解不一样,我们的用法不太一样,我们使用 pipeline 的目的是解耦合。

如果抽象一点讲,我们将 pipeline 分为:入口 节点逻辑封装 和 编排 三部分。

入口

入口是管道执行 then() 方法的位置,入口可传入该位置的业务逻辑参数,并通过管道获取返回值比如:

# 若是集合对象参数也可从管道中使用PHP的对象引用传值特性带出数据
$params = collect(['foo'=>'bar']);
# 获取配置中的编排数据
$compose = config('pipes.foo.bar',[]);
(new Pipeline)->send($params)->through($compose)->then(function (Collection $passable) {
    # 执行入口部分作为管道中心点的逻辑,也可以什么都不执行直接返回
    return data_get($passable,'foo');
})

节点

节点逻辑封装跟楼主的代码类似,都是抽象成一个类型,提供方法供管道调用。

class Foo
{
    function handle (Collection $passable, \Closure $next)
    {
        # 节点自身的逻辑,但若不想中断当前管道逻辑传递,就必须调用 $next 闭包
        # 这里可以执行前置逻辑
        $value = $next();
        # 这里可以执行后置逻辑
        return $value;
    }
}

编排

编排的存在的价值是将以上两部分结合在一起,比如在 config 目录中存在某个名为 foo.php 的文件其内容为:

return [
    'bar' => Foo::class
];

解耦过程

  1. 现在我们假设在 入口 位置,不知道有哪些管道节点会被加载;
  2. 假设 管道节点 封装的逻辑只知道自己将被入口引用,但不知道是否存在其他管道节点;
  3. 所有的管道节点均使用配置数据进行 编排 (即便是执行顺序也可进行编排)。

这样就得到了一个 管道节点入口 单向依赖的解耦的过程。

应用场景

就说我们现在的应用,是要根据客户的需求来定制各种模块进行交付,如果我们将代码都写在一起用流控制语句进行判断会产生大量的无效代码,很难进行维护。重新开发也会增加为每个客户维护系统的难度。所以我们将主要功能以外的功能,封装成模块,通过 composer 拉取代码仓库进行安装和维护。大部分模块分包的代码都是同步更新的,如果客户对某个模块有很强的定制化需求,我们也不需要替换所有分包。那么现在就有了问题:主项目都不知道加载了哪些模块,与模块的代码之间如何通讯?分包之间彼此也不知道对方的存在,他们之间如何通讯?

在使用微服务或消息订阅等技巧之前,我们选择的则是 pipeline 模式。如果要考虑配置能力,我们甚至可以将编排数据保存到数据库中,来为客户动态关闭或开放某些功能模块。

有缺点吗?

以上我只说了使用 pipeline 的优点或者说是特点。但使用过程中确实存在一些问题,我觉得值得跟大家分享,大家若有好的解决方案,还请赐教:

入口拆分

使用了 pipeline 进行解耦就意味着,要时刻在心里评估实现一个简单的模块功能要拆分到多少入口进行引用。举个简单的 订单查询 的例子,这个功能基本上可以在一个请求的生命周期内解决。但如果要加入一个根据优惠券名称查询并在结果中显示优惠券名称的逻辑,就要求我们可能在 请求校验 控制器整理参数 查询构造器操作 视图层呈现 这些部分增加 pipeline 入口;有时为了执行效率或资源开销还要考虑在进程中复用管道实例。这无疑增加的 节点 逻辑维护的难度。

脆弱

就像编排数据中所描述的,节点之间在执行过程中实际上还是环环相扣的,一个脆弱的模块节点将造成整个执行过程都变得脆弱。这种脆弱不仅体现在该节点存在的时候,也体现在将该节点移除的时候。这对工程师的要求很高,既要考虑当前功能实现,又要考虑各种编排下程序是否能获取期望的结果。当节点数量增加时,若要用测试用例进行全方面覆盖,几乎就成了不可完成的任务。

总结

从我们的经验上来看,使用 pipeline 来实现模块解耦开发,虽然道路上是正确的,但需要更多的文档和注释对 入口节点 登记造册,也需要在开发人员培训方面投入更多的精力以及磨合的时间。

1年前 评论

用消息来解决不更简单吗

1年前 评论

个人觉得这里用观察者更直观,更好维护,逻辑也更加清晰,不用引入那么多的类,再从容器中掏服务,也就是 Laravel Event

管道在维护主对象的状态变更时很有用, 每个 task 都有可能改变上个对象的状态, 但是对于订单来说(恒定不变),用它的意义不大。 这是它和观察者最大的区别。

1年前 评论
lyxxxh 1年前

没看出来有什么优势 感觉十分繁琐 把php简单的直观的代码优势整没了 感慨一下现在的php代码真是越来越看不懂了 代码花里胡哨的绕来绕去的

我在这讲一下 我实现这种活动任务发奖积分奖励的思路

对于积分奖励增减的,直接建一个类单独处理,反而更加清晰.

虽然跟楼主功能不太一样 楼主积分又发放又回收 我的呢只管发放 但是原理都是一样的,同样可以加入回收积分

代码很简单大家看一下类的方法名也可以猜出来写了什么功能

由数据库管理的活动任务类
相信大家都写过

  • 每日签到
  • 每周签到奖励
  • 上传头像奖励
  • 首次进入直播间奖励
  • 观看直播间5,10,20分钟奖励
  • 邀请用户奖励
  • 邀请满10人 20 人奖励
  • 每日评论奖励
  • 新用户首单购买奖励
  • 每日首单购买奖励

我想你们也曾经被这些任务奖励代码整恐惧了吧
真是简单又繁琐的体力劳动且极易出错

我的思路写出来的 代码简单清晰,测试用例写起来同样简单

仅经验之谈 不引战 谢谢

活动管理的类 领取积分奖励

app/Actions/TaskScript/SignInActivity.php 每日签到任务类

TaskScript目录 存放任务活动的积分发放的逻辑

app/Actions/TaskAction.php

TaskAction.php 统一管理这些任务活动类

file

file

每日签到活动类 发放奖励

file

调用奖励发放

Laravel

任务完成情况列表接口
任务名称 任务奖励 任务进度 任务描述 任务完成状态
Laravel

1年前 评论
oneCandy55 1年前
Chenhappy (作者) 1年前
Chenhappy (作者) 1年前

我也想说这种场景应该更适合事件Event,把listener丢到队列里,还能异步降低串行压力,而且pipeline多了串行压力大不说,中间任何一个through产生异常都将影响整个管道,不能因为某一项积分添加错误影响后续积分规则。另外listener队列执行还增强了容错机制,即便添加积分时错误还能重试,且各个listener执行互不影响。

另外再说说业务上的问题,积分这一类的添加属于产品运营策略,目前你代码的设计中都是把积分添加强绑定在某一个具体的action中,实际运营中会有多样化需求,在pipeline设计中例如签到积分发放只会在签到场景中才会触发,但是运营又希望能在后台手动触发,即不需要用户签到,我也能给他赠送签到积分,因此就需要将签到送积分类视为一个单独的个体,具有调度功能,这样你就可以任何地方去执行它,事件中listener完美的支持这一概念。

1年前 评论
Neilyozの鱼不浪 1年前

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!