工厂方法模式与策略模式组合

Published on 2016 - 09 - 11

迷你版的交易系统

大家可能对银行的交易系统充满敬畏之情,一听说是银行的IT人员,立马想当然地认为这是个很厉害的人物,那我们今天就来对银行的交易系统做一个初步探讨。国内一家大型集团(全球500强之一)计划建立全国“一卡通”计划,每个员工配备一张IC卡,该卡基本上就是万能的,门禁系统用它,办公系统用它,你想打开自己的邮箱,没有它就甭想了,它还可以用来进行消费,比如到食堂吃饭,到园区内的商店消费,甚至洗澡、理发、借书、买书等都可以用它,只要这张卡内有余额,在集团内部就是一张借记卡(当然还有一些内部的补助通过该卡发放)。我们要讲解的就是“一卡通”项目联机交易子系统,类似于银行的交易系统,可以说它是交易系统的mini版吧。

该项目具有一定的挑战性,集团公司的架构分为三层:总部、省级分部、市级机构,业务要求是“一卡通”推广到全国,一名员工从北京出差到了上海,凭一卡通能在北京做的事情在上海同样能完成。对于联机交易子项目,异地分支机构与总部之间的通信采用了MQ(Message Queue,消息队列)传递消息,也就是我们观察者模式的BOSS版,与目前的通过POS机刷信用卡基本上是一个道理。

联机交易子系统有一个非常重要的子模块(Module)——扣款子模块。这个模块太重要了!从业务上来说,扣款失败就代表着所有的商业交易关闭,这是不允许发生的;从技术上来说,扣款的异常处理、事务处理、鲁棒性都是不容忽视的,特别是饭点时间,并发量是很恐怖的,这对架构师提出了很高的要求。

我们详细分析一下扣款子模块,每个员工都有一张IC卡,他的IC卡上有以下两种金额。

  • 固定金额

固定金额是指员工不能提现的金额,这部分金额只能用来特定消费,即员工日常必需的消费,例如食堂内吃饭、理发、健身等活动。

  • 自由金额

自由金额是可以提现的,当然也可以用于消费。每个月初,总部都会为每个员工的IC卡中打入固定数量的金额,然后提倡大家在集团内的商店消费。

在实际的系统开发中,架构设计采用的是一张IC卡绑定两个账户:固定账户和自由账号,本书为了简化描述,还是使用固定金额和自由金额的概念。既然有消费,系统肯定有扣款处理,系统内有两套扣款规则。

  • 扣款策略一

该类型的扣款会对IC卡上的两个金额产生影响,计算公式如下:

IC卡固定余额=IC卡现有固定余额-交易金额/2
IC卡自由余额=IC卡现有自由金额-交易金额/2

也就是说,该类型的消费分别在固定金额和自由金额上各扣除一半。它适用于固定消费场景例如吃饭、理发等情况下的扣款,这么做是为了防止乱请客,你请别人吃饭时自己也要出一半。

  • 扣款策略二

全部从自由金额上扣除,由于集团内的各种消费、服务非常齐全,而且比市面价格稍低,员工还是很乐意到这里消费的,而且很多员工本身就住在集团附近,基本上就是“公司即家,家即公司”。

今天要讲的重点就是这两种消费的扣款策略该怎样设计?要知道这种联机交易,日后允许大规模变更的可能性基本上是零,所以系统设计的时候要做到可拆卸(Pluggable),避免日后维护的大量开支。

很明显,这是一个策略模式的实际应用,但是你还记得策略模式是有缺陷的吗?它的具体策略必须暴露出去,而且还要由上层模块初始化,这不合适,与迪米特法则有冲突,高层次模块对低层次的模块应该仅仅处在“接触”的层次上,而不应该是“耦合”的关系,否则,维护的工作量就会非常大。问题提出了,那我们就应该想办法来修改这个缺陷,正好工厂方法模式可以帮我们产生指定的对象,但是问题又来了,工厂方法模式要指定一个类,它才能产生对象,怎么办?引入一个配置文件进行映射,避免系统僵化情况的发生,我们以枚举类完成该任务。

还有一个问题,一个交易的扣款模式是固定的,根据其交易编号而定,那我们怎样把交易编号与扣款策略对应起来呢?采用状态模式或责任链模式都可以,如果采用状态则认为交易编号就是一个交易对象的状态,对于一笔确定的交易(一个已经生成了的对象),它的状态不会从一个状态过渡到另一个状态,也就是说它的状态只有一个,执行完毕后即结束,不存在多状态的问题;如果采用责任链模式,则可以用交易编码作为链中的判断依据,由每个执行节点进行判断,返回相应的扣款模式。但是在实际中,采用了关系型数据库存储扣款规则与交易编码的对应关系,为了简化该部分的讲义,我们在下面的设计中使用了条件判断语句来代替。

还有,这么复杂的扣款模块总要进行一个封装吧,不能让上层的业务模块直接深入到模块的内部,于是门面模式又摆在了眼前。

分析完毕,我们要先画出类图,做设计要遵循这样一个原则:先选最简单的业务,然后画出类图。那我们先定义交易中用到的两个类:IC卡类和交易类,如图1所示。

每个IC卡有三个属性,分别是IC卡号码、固定金额、自由金额,然后通过getter/setter方法来访问,如代码清单1所示。

public class Card {
     //IC卡号码
     private String cardNo="";
     //卡内的固定交易金额
     private int steadyMoney =0;
     //卡内自由交易金额
     private int freeMoney =0;
     //getter/setter方法     
     public String getCardNo() {
             return cardNo;
     }
     public void setCardNo(String cardNo) {
             this.cardNo = cardNo;
     }
     public int getSteadyMoney() {
             return steadyMoney;
     }
     public void setSteadyMoney(int steadyMoney) {
             this.steadyMoney = steadyMoney;
     }
     public int getFreeMoney() {
             return freeMoney;
     }
     public void setFreeMoney(int freeMoney) {
             this.freeMoney = freeMoney;
     }
}

细心的读者可能注意到,金额怎么都是整数类型呀,应该是double类型或者BigDecimal类型呀。是,一般非银行的交易系统,比如超市的收银系统,系统内都是存放的int类型,在显示的时候才转换为货币类型。

交易信息Trade类,负责记录每一笔交易,它是由监听程序监听MQ队列而产生的,有两个属性:交易编号和交易金额,其中的交易编号对整个交易非常重要,18位字符(在银行的交易系统中,这里可不是字符串,一般是十进制数字或二进制数字,要考虑系统的性能,数字运算可比字符运算快得多),包括POS机编号、商户编号、校验码等,我们这里暂时用不到,就不多做介绍,我们只要知道它是一个非常有用的编码就成。交易金额为整数类型,实际金额放大100倍即可。如代码清单2所示。

public class Trade {
     //交易编号
     private String tradeNo = "";
     //交易金额
     private int amount = 0;
     //getter/setter方法
     public String getTradeNo() {
             return tradeNo;
     }
     public void setTradeNo(String postNo) {
             this.tradeNo = postNo;
     }
     public int getAmount() {
             return amount;
     }
     public void setAmount(int amount) {
             this.amount = amount;
     }
}

两个最简单也是在应用中最常使用的对象定义完毕,下面就需要来定义策略了,非常明显的策略模式,类图如图2所示。

典型的策略模式,扣款有两种策略:固定扣款和自由扣款。下面我们来看代码,先看抽象策略,也就是扣款接口,如代码清单3所示。

public interface IDeduction {
     //扣款,提供交易和卡信息,进行扣款,并返回扣款是否成功
     public boolean exec(Card card,Trade trade);
}

固定扣款的规则是固定金额和自由金额各扣除交易金额的一半,如代码清单4所示。

public class SteadyDeduction implements IDeduction {
     //固定性交易扣款
     public boolean exec(Card card, Trade trade) {
             //固定金额和自由金额各扣除50%
             int halfMoney = (int)Math.rint(trade.getAmount() / 2.0);
             card.setFreeMoney(card.getFreeMoney() - halfMoney);
             card.setSteadyMoney(card.getSteadyMoney() - halfMoney);
             return true;
     }
}

这个具体策略也非常简单,就是两个金额各自减去交易额的一半(注意除数是2.0,可不是2),然后再四舍五入,算法确实简单。该逻辑没有考虑账户余额不足的情况,也没有考虑异常情况,比如并发情况,读者可以想想看,一张卡有两笔消费同时发生时,是不是就发生错误了?一张卡同时有两笔消费会出现这种情况吗?会的,网络阻塞的情况,MQ多通道发送,在网络繁忙的情况下是有可能出现该问题,这里就不多介绍,有兴趣的读者可以看看MQ的资料。我们在这里的讲解实现的是一个快乐路径,认为所有的交易都是在安全可靠的环境中发生的,并且所有的系统环境都满足我们的要求。我们再来看另一个策略,这个策略更简单,如代码清单5所示。

public class FreeDeduction implements IDeduction {
     //自由扣款
     public boolean exec(Card card, Trade trade) {
             //直接从自由余额中扣除
             card.setFreeMoney(card.getFreeMoney() - trade.getAmount());
             return true;
     }
}

卡内的自由金额减去交易金额再修改卡内自由金额就完事了,异常情况不考虑。这两个具体的策略与我们的交易类型没有任何关系,也不应该有关系,策略模式就是提供两个可以相互替换的策略,至于在什么时候使用什么策略,则不是由策略模式来决定的。策略模式还有一个角色没出场,即封装角色,如代码清单6所示。

public class DeductionContext {
     //扣款策略
     private IDeduction deduction = null;
     //构造函数传递策略
     public DeductionContext(IDeduction _deduction){
             this.deduction = _deduction;
     }
     //执行扣款
     public boolean exec(Card card,Trade trade){
             return this.deduction.exec(card, trade);
     }
}

典型的策略上下文角色。扣款模块的策略已经定义完毕了,然后需要想办法解决策略模式的缺陷:它把所有的策略类都暴露出去,暴露得越多以后的修改风险也就越大。怎么修改呢?增加一个映射配置文件,实现策略类的隐藏。我们使用枚举担当此任,对策略类进行映射处理,避免高层模块直接访问策略类,同时由工厂方法模式根据映射产生策略对象,类图如图3所示。

又是一个简单得不能再简单的模式——工厂方法模式,通过StrategyMan负责对具体策略的映射,如代码清单7所示。

public enum StrategyMan {
     SteadyDeduction("com.cbf4life.common.SteadyDeduction"),
     FreeDeduction("com.cbf4life.common.FreeDeduction");
     String value = "";
     private StrategyMan(String _value){
             this.value = _value;
     }
     public String getValue(){
             return this.value;
     }
}

类似的代码解释过很多遍了,不再多说,它就是一个登记容器,所有的具体策略都在这里登记,然后提供给工厂方法模式。策略工厂如代码清单8所示。

public class StrategyFactory {
     //策略工厂
     public static IDeduction getDeduction(StrategyMan strategy){
             IDeduction deduction = null;
             try {
                  deduction = (IDeduction)Class.forName(strategy.getValue()).newInstance();
             }  catch (Exception e) {
                     // 异常处理
             }
             return deduction;
     }
}

一个简单的工厂,根据策略管理类的枚举项创建一个策略对象,简单而实用,策略模式的缺陷也弥补成功。那这么复杂的系统怎么让高层模块访问?(你看不出复杂?那是因为我们写的都是快乐路径,太多情况都没有考虑,在实际项目中仅就并发处理和事务管理这两部分就够你头疼了。)既然系统很复杂,是不是需要封装一下。我们请出门面模式进行封装,如代码清单9所示。

public class DeductionFacade {
     //对外公布的扣款信息
     public static Card deduct(Card card,Trade trade){
             //获得消费策略
             StrategyMan reg = getDeductionType(trade);
             //初始化一个消费策略对象
             IDeduction deduction = StrategyFactory.getDeduction(reg);
             //产生一个策略上下文
             DeductionContext context = new DeductionContext(deduction);
             //进行扣款处理
             context.exec(card, trade);
             //返回扣款处理完毕后的数据
             return card;
     }
     //获得对应的商户消费策略
     private static StrategyMan getDeductionType(Trade trade){
             //模拟操作
             if(trade.getTradeNo().contains("abc")){
                     return StrategyMan.FreeDeduction;
             }else{
                     return StrategyMan.SteadyDeduction;
             }
     }
}

这次为什么要先展示代码而后写类图呢?那是因为这段代码比写类图更能让你理解。读者注意一下getDeductionType方法,这个方法在实际项目中是存在的,但是与上面的写法有天壤之别,因为在实际项目中,数据库中保存了策略代码与交易编码的对应关系,直接通过数据库的SQL语句就可以返回对应的扣款策略。这里我们采用大家最熟悉的条件转移来实现,也是比较清晰和容易理解的。

可能读者要问了,在门面模式中已经明确地说明,门面类中不允许有业务逻辑存在,但是你这里还是有了一个getDeductionType方法,它可代表的是一个判断逻辑呀,这是为什么呢?是的,该方法完全可以移到其他Hepler类中,由于我们是示例代码,暂没有明确的业务含义,故编写在此处,读者在实际应用中,请把该方法放置到其他类中。

好,所有用到的模式都介绍完毕了,我们把完整的类图整理一下,如图4所示。

真实系统比这复杂得多,有了我们之前的分析,这个图还是比较容易看懂的。我们所有的开发都完成了,是不是应该写一个测试类来展示一下我们的成果,如代码清单10所示。

public class Client {
     //模拟交易
     public static void main(String[] args) {
             //初始化一张IC卡
             Card card = initIC();
             //显示一下卡内信息
             System.out.println("========初始卡信息:=========");
             showCard(card);     
             //是否停止运行标志
             boolean flag = true;
             while(flag){
                     Trade trade = createTrade();
                     DeductionFacade.deduct(card, trade);
                     //交易成功,打印出成功处理消息
                     System.out.println("\n======交易凭证========");
                     System.out.println(trade.getTradeNo()+" 交易成功!");
                     System.out.println("本次发生的交易金额为:"+ 
                        trade.getAmount()/100.0+"元");
                     //展示一下卡内信息
                    showCard(card);
                     System.out.print("\n是否需要退出?(Y/N)");
                     if(getInput().equalsIgnoreCase("y")){
                             flag = false;  //退出
                     }
             }
     }
     //初始化一个IC卡
     private static Card initIC(){
             Card card = new Card();
             card.setCardNo("1100010001000");
             card.setFreeMoney(100000);  //1000元
             card.setSteadyMoney(80000); //800元
             return card;
     }
     //产生一条交易
     private static Trade createTrade(){
             Trade trade = new Trade();
             System.out.print("请输入交易编号:");
             trade.setTradeNo(getInput());
             System.out.print("请输入交易金额:");
             trade.setAmount(Integer.parseInt(getInput()));
             //返回交易
             return trade;
     }
     //打印出当前卡内交易余额
     public static void showCard(Card card){
             System.out.println("IC卡编号:" + card.getCardNo());
             System.out.println("固定类型余额:"+ card.getSteadyMoney()/100.0 + " 元");
             System.out.println("自由类型余额:"+ card.getFreeMoney()/100.0 + " 元");
     }
     //获得键盘输入
     public static String getInput(){
             String str ="";
             try {
                   str=(new BufferedReader(new InputStreamReader(System.in))).readLine();
             } catch (IOException e) {
                  //异常处理
             }
       return str;
     }
}

类比较长,耐心看还是非常简单的,对其中Client类的方法说明如下:

  • initIC方法

初始化一张IC卡,方便进行测试。

  • createTrade方法

创建一笔交易,完成测试任务。

  • showCard方法

显示IC卡内的信息。

  • getInput方法

获得从键盘输入的字符,以回车符作为终结标志。

方法介绍完毕了,我们运行一下看看,结果如下所示:

========初始卡信息:=========
IC卡编号:1100010001000
固定类型余额:800.0 元
自由类型余额:1000.0 元
请输入交易编号:abcdef
请输入交易金额:10000
======交易凭证========
abcdef 交易成功!
本次发生的交易金额为:100.0 元
IC卡编号:1100010001000
固定类型余额:800.0 元
自由类型余额:900.0 元
是否需要退出?(Y/N)

我们模拟了一笔自由消费,直接从自由类型金额中扣除了。我们再模拟一笔固定类型的消费,运行结果如下所示:

========初始卡信息:=========
IC卡编号:1100010001000
固定类型余额:800.0 元
自由类型余额:1000.0 元
请输入交易编号:abcdef
请输入交易金额:10000
======交易凭证========
abcdef 交易成功!
本次发生的交易金额为:100.0 元
IC卡编号:1100010001000
固定类型余额:800.0 元
自由类型余额:900.0 元
是否需要退出?(Y/N)n
请输入交易编号:1001
请输入交易金额:1234
======交易凭证========
1001 交易成功!
本次发生的交易金额为:12.34 元
IC卡编号:1100010001000
固定类型余额:793.83 元
自由类型余额:893.83 元
是否需要退出?(Y/N)

交易成功!到这里为止,联机交易中的扣款子模块开发完毕了!是不是很简单,银行业的交易系统也就是这么回事!

混编小结

回顾一下我们在该案例中使用了几个模式。

  • 策略模式

负责对扣款策略进行封装,保证两个策略可以自由切换,而且日后增加扣款策略也非常简单容易。

  • 工厂方法模式

修正策略模式必须对外暴露具体策略的问题,由工厂方法模式直接产生一个具体策略对象,而其他模块则不需要依赖具体的策略。

  • 门面模式

负责对复杂的扣款系统进行封装,封装的结果就是避免高层模块深入子系统内部,同时提供系统的高内聚、低耦合的特性。

我们主要使用了这三个模式,它们的好处是灵活、稳定,我们可以设想一下可能有哪些业务变化。

  • 扣款策略变更

“增加一个新扣款策略,三步就可以完成:实现IDeduction接口,增加StrategyMan配置项,扩展扣款策略的利用(也就是门面模式的getDeductionType方法,在实际项目中这里只需要增加数据库的配置项)。减少一个策略很简单,修改扣款策略的利用即可。变更一个扣款策略也很简单,扩展一个实现类口就可以了。

  • 变更扣款策略的利用规则

我们的系统不想大修改,还记得我们提出的状态模式吗?这个就是为策略的利用服务的,变更它就能满足要求。想把IC卡也纳入策略利用的规则也不复杂。其实这个变更还真发生了,系统投产后,业务提出考虑退休人员的情况,退休人员的IC卡与普通在职员工一样,但是它的扣款不仅仅是根据交易编码,还要根据IC卡对象,系统的变更做法是增加一个扣款策略,同时扩展扣款利用策略,也就是数据库的配置项,在getDeductionType中新扩展了一个功能:根据IC卡号,确认是否是退休人员,是退休人员,则使用新的扣款策略,这是一个非常简单的扩展。

这就是一个mini版的金融交易系统,没啥复杂的,剩下的问题就是开始考虑系统的鲁棒性,这才是难点。

参考文档