使用pipeline设计模式实现用户积分任务需求
背景
背景就是产品经理提了一个需求,完成任务赠送积分,如果遇到退款需要回收积分,任务是大概是这样的:
- 每天首次加入购物车赠送 10 积分
- 每天首单可以赠送 100 积分
- 购物累积金额达到 99 元赠送 100 积分
- 购物次数满 10 次赠送 100 积分
- 每日签到送 10 积分
- 还有很多奇奇怪怪的任务…
实现过程
分析
当加入购物车时赠送积分,任务结束,当购买商品时就有可能会同时命中多个条件同时赠送积分,命中的所有条件都赠送后,任务结束。
分析完需求,接下来就想如何实现,最简单的方法也就是 if else
实现:
// 支付成功触发赠送积分
if ("当天首单") { // Reward shopping points }
if ("累积99元") { // Reward shopping points }
if ("买满10次") { // Reward shopping points }
// ...
提需求的时候产品已经想到二期的积分任务需求了,所以随着任务的增多,可维护性一定会降低,所以立马否决了用if else
实现的想法
紧接着想到了之前做支付时用到的「简单工厂」+ 「策略模式」经验,应该是有符合要求的设计模式能解决这类问题。因为整体流程是一条直线的流程,依次执行,就想到责任链模式。通过查询相关资料,责任链模式的变种「管道模式」似乎更适合应用至此。
管道模式
管道模式也称为流水线模式,英文:Pipeline。
看到 Pipeline
这个单词非常熟悉,似乎在那里见过,思来想去,是在 Laravel 里面见过,之前分析 Laravel 依赖注入和控制反转 时见到过。
Laravel 通过 Pipeline
实现 Middleware
: github.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 重写了部分方法。
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();
}
文件看起来似乎挺多的,但条理还是比较清晰的:
- 如有新任务,则新建一个任务类继承
PointTask
实现send
方法,如有可能收回积分则再实现recycle
方法。 - 再在
PointTaskService
对外开放的 Service 中加入到指定位置,即可完成,不会影响到其他的业务逻辑。 - 已有的调用处也不用变动代码。
总结
- 认真分析过的源码可能会忘记,但能在合适的时间回想起来就证明当时是有效的分析阅读。
- 平时缝缝补补的小需求遇到糟心的代码基本也是往上继续堆代码,但如果有机会接手完整的功能点,那就尽可能的写好点吧。
源码
github.com/zxr615/rewrite-pay-modu...
参考
本作品采用《CC 协议》,转载必须注明作者和本文链接
高认可度评论:
可能是理解不一样,我们的用法不太一样,我们使用
pipeline
的目的是解耦合。如果抽象一点讲,我们将
pipeline
分为:入口 节点逻辑封装 和 编排 三部分。入口
入口是管道执行
then()
方法的位置,入口可传入该位置的业务逻辑参数,并通过管道获取返回值比如:节点
节点逻辑封装跟楼主的代码类似,都是抽象成一个类型,提供方法供管道调用。
编排
编排的存在的价值是将以上两部分结合在一起,比如在
config
目录中存在某个名为foo.php
的文件其内容为:解耦过程
入口
位置,不知道有哪些管道节点会被加载;管道节点
封装的逻辑只知道自己将被入口引用,但不知道是否存在其他管道节点;编排
(即便是执行顺序也可进行编排)。这样就得到了一个
管道节点
向入口
单向依赖的解耦的过程。应用场景
就说我们现在的应用,是要根据客户的需求来定制各种模块进行交付,如果我们将代码都写在一起用流控制语句进行判断会产生大量的无效代码,很难进行维护。重新开发也会增加为每个客户维护系统的难度。所以我们将主要功能以外的功能,封装成模块,通过
composer
拉取代码仓库进行安装和维护。大部分模块分包的代码都是同步更新的,如果客户对某个模块有很强的定制化需求,我们也不需要替换所有分包。那么现在就有了问题:主项目都不知道加载了哪些模块,与模块的代码之间如何通讯?分包之间彼此也不知道对方的存在,他们之间如何通讯?在使用微服务或消息订阅等技巧之前,我们选择的则是
pipeline
模式。如果要考虑配置能力,我们甚至可以将编排数据保存到数据库中,来为客户动态关闭或开放某些功能模块。有缺点吗?
以上我只说了使用
pipeline
的优点或者说是特点。但使用过程中确实存在一些问题,我觉得值得跟大家分享,大家若有好的解决方案,还请赐教:入口拆分
使用了
pipeline
进行解耦就意味着,要时刻在心里评估实现一个简单的模块功能要拆分到多少入口进行引用。举个简单的订单查询
的例子,这个功能基本上可以在一个请求的生命周期内解决。但如果要加入一个根据优惠券名称查询并在结果中显示优惠券名称的逻辑,就要求我们可能在请求校验
控制器整理参数
查询构造器操作
视图层呈现
这些部分增加pipeline
入口;有时为了执行效率或资源开销还要考虑在进程中复用管道实例。这无疑增加的节点
逻辑维护的难度。脆弱
就像编排数据中所描述的,节点之间在执行过程中实际上还是环环相扣的,一个脆弱的模块节点将造成整个执行过程都变得脆弱。这种脆弱不仅体现在该节点存在的时候,也体现在将该节点移除的时候。这对工程师的要求很高,既要考虑当前功能实现,又要考虑各种编排下程序是否能获取期望的结果。当节点数量增加时,若要用测试用例进行全方面覆盖,几乎就成了不可完成的任务。
总结
从我们的经验上来看,使用
pipeline
来实现模块解耦开发,虽然道路上是正确的,但需要更多的文档和注释对入口
和节点
登记造册,也需要在开发人员培训方面投入更多的精力以及磨合的时间。个人觉得这里用观察者更直观,更好维护,逻辑也更加清晰,不用引入那么多的类,再从容器中掏服务,也就是 Laravel Event
管道在维护主对象的状态变更时很有用, 每个 task 都有可能改变上个对象的状态, 但是对于订单来说(恒定不变),用它的意义不大。 这是它和观察者最大的区别。
认真看完的举个爪!总结写的很好,受教了,但 对 pipeline 还不是很理解。
nice,对管道又有了一个新的认识。
前面都看懂了, 就后面的
PointTaskService
和调用方法没看懂对于有强迫症人来说,可以改成这样
每日首单任务
买满多少钱赠送积分
每日签到
@zxr615 在数据判断时有使用这种
:+1: 有案例。
可能是理解不一样,我们的用法不太一样,我们使用
pipeline
的目的是解耦合。如果抽象一点讲,我们将
pipeline
分为:入口 节点逻辑封装 和 编排 三部分。入口
入口是管道执行
then()
方法的位置,入口可传入该位置的业务逻辑参数,并通过管道获取返回值比如:节点
节点逻辑封装跟楼主的代码类似,都是抽象成一个类型,提供方法供管道调用。
编排
编排的存在的价值是将以上两部分结合在一起,比如在
config
目录中存在某个名为foo.php
的文件其内容为:解耦过程
入口
位置,不知道有哪些管道节点会被加载;管道节点
封装的逻辑只知道自己将被入口引用,但不知道是否存在其他管道节点;编排
(即便是执行顺序也可进行编排)。这样就得到了一个
管道节点
向入口
单向依赖的解耦的过程。应用场景
就说我们现在的应用,是要根据客户的需求来定制各种模块进行交付,如果我们将代码都写在一起用流控制语句进行判断会产生大量的无效代码,很难进行维护。重新开发也会增加为每个客户维护系统的难度。所以我们将主要功能以外的功能,封装成模块,通过
composer
拉取代码仓库进行安装和维护。大部分模块分包的代码都是同步更新的,如果客户对某个模块有很强的定制化需求,我们也不需要替换所有分包。那么现在就有了问题:主项目都不知道加载了哪些模块,与模块的代码之间如何通讯?分包之间彼此也不知道对方的存在,他们之间如何通讯?在使用微服务或消息订阅等技巧之前,我们选择的则是
pipeline
模式。如果要考虑配置能力,我们甚至可以将编排数据保存到数据库中,来为客户动态关闭或开放某些功能模块。有缺点吗?
以上我只说了使用
pipeline
的优点或者说是特点。但使用过程中确实存在一些问题,我觉得值得跟大家分享,大家若有好的解决方案,还请赐教:入口拆分
使用了
pipeline
进行解耦就意味着,要时刻在心里评估实现一个简单的模块功能要拆分到多少入口进行引用。举个简单的订单查询
的例子,这个功能基本上可以在一个请求的生命周期内解决。但如果要加入一个根据优惠券名称查询并在结果中显示优惠券名称的逻辑,就要求我们可能在请求校验
控制器整理参数
查询构造器操作
视图层呈现
这些部分增加pipeline
入口;有时为了执行效率或资源开销还要考虑在进程中复用管道实例。这无疑增加的节点
逻辑维护的难度。脆弱
就像编排数据中所描述的,节点之间在执行过程中实际上还是环环相扣的,一个脆弱的模块节点将造成整个执行过程都变得脆弱。这种脆弱不仅体现在该节点存在的时候,也体现在将该节点移除的时候。这对工程师的要求很高,既要考虑当前功能实现,又要考虑各种编排下程序是否能获取期望的结果。当节点数量增加时,若要用测试用例进行全方面覆盖,几乎就成了不可完成的任务。
总结
从我们的经验上来看,使用
pipeline
来实现模块解耦开发,虽然道路上是正确的,但需要更多的文档和注释对入口
和节点
登记造册,也需要在开发人员培训方面投入更多的精力以及磨合的时间。用消息来解决不更简单吗
个人觉得这里用观察者更直观,更好维护,逻辑也更加清晰,不用引入那么多的类,再从容器中掏服务,也就是 Laravel Event
管道在维护主对象的状态变更时很有用, 每个 task 都有可能改变上个对象的状态, 但是对于订单来说(恒定不变),用它的意义不大。 这是它和观察者最大的区别。
没看出来有什么优势 感觉十分繁琐 把php简单的直观的代码优势整没了 感慨一下现在的php代码真是越来越看不懂了 代码花里胡哨的绕来绕去的
我在这讲一下 我实现这种活动任务发奖积分奖励的思路
对于积分奖励增减的,直接建一个类单独处理,反而更加清晰.
虽然跟楼主功能不太一样 楼主积分又发放又回收 我的呢只管发放 但是原理都是一样的,同样可以加入回收积分
代码很简单大家看一下类的方法名也可以猜出来写了什么功能
由数据库管理的活动任务类
相信大家都写过
我想你们也曾经被这些任务奖励代码整恐惧了吧
真是简单又繁琐的体力劳动且极易出错
我的思路写出来的 代码简单清晰,测试用例写起来同样简单
仅经验之谈 不引战 谢谢
活动管理的类 领取积分奖励
app/Actions/TaskScript/SignInActivity.php
每日签到任务类TaskScript目录 存放任务活动的积分发放的逻辑
app/Actions/TaskAction.php
TaskAction.php 统一管理这些任务活动类
每日签到活动类 发放奖励
调用奖励发放
任务完成情况列表接口

任务名称 任务奖励 任务进度 任务描述 任务完成状态
我也想说这种场景应该更适合事件
Event
,把listener
丢到队列里,还能异步降低串行压力,而且pipeline
多了串行压力大不说,中间任何一个through
产生异常都将影响整个管道,不能因为某一项积分添加错误影响后续积分规则。另外listener
队列执行还增强了容错机制,即便添加积分时错误还能重试,且各个listener
执行互不影响。另外再说说业务上的问题,积分这一类的添加属于产品运营策略,目前你代码的设计中都是把
积分添加
强绑定在某一个具体的action
中,实际运营中会有多样化需求,在pipeline
设计中例如签到积分发放只会在签到场景中才会触发,但是运营又希望能在后台手动触发,即不需要用户签到,我也能给他赠送签到积分,因此就需要将签到送积分
类视为一个单独的个体,具有调度
功能,这样你就可以任何地方去执行它,事件中listener
完美的支持这一概念。