前言

我常常觉得人们低估了设计模式的作用和意义。它们不仅是简历上的金边、程序员的黑话,也不仅是常见业务的常用处理方式或经验总结。

设计模式不仅是这些,它们更是面向对象思想理论结合实践的切入点。我们前面聊过抽象、高内聚低耦合、封装继承多态、SOLID设计原则。它们更偏理论指导,离编码实践还有一段距离。而这里要聊的设计模式,不仅有扎实的理论基础,而且实实在在地俯下身子、扎根到了实践当中。

从编码实践的角度来讲设计模式,这类文章没有一万也有八千。细抠几种设计模式之间的区别,这类文章写再多也没太大意义。这里就不凑这些热闹了。

这里,我会简单聊聊几个主要设计模式的编程应用,然后把主要精力放在它们与面向对象思想的关联上。另外,在聊过几种设计模式之后,计划提供一种复合模式,作为我使用设计模式的“最佳实践”,供读者参考。

工厂模式

是什么

工厂模式的定义其实很简单:提供一个独立组件,用以根据不同条件选择并构建不同实例。这个组件就是“工厂”。

Java世界中最富盛名的“工厂”,恐怕非SpringIoC莫属了。它的核心组件BeanFactory和ApplicationContext,接管了创建Bean实例、按配置注入属性、执行初始化操作等工作。业务代码只需要从中获取实例、拿来处理业务逻辑即可。

SpringIoC中的BeanFactory

我个人认为,除了负责构建实例之外,一个好的“工厂”还应当具备一项能力:判断在什么场景、条件下构建什么实例。例如,夏天要穿短袖,冬天要穿棉衣;有人喜欢新潮,有人讲究传统。会做各种衣服,固然称得上合格的服装厂;但必须有量体裁衣的能力,才能成为高端定制品牌。

按这个观点来看,SpringIoC就属于服装厂而非高定品牌。它虽然能让我们轻松获取实例,却并不能根据业务场景、条件来获取实例。例如,如果用户填写了手机号,就调用PhoneNoLoginService;如果填写了Email,就调用EmailLoginService;如果……这段逻辑用SpringIoC要怎样处理呢?

很遗憾,单凭SpringIoC处理不了这种逻辑。SpringIoC仅仅将bean与beanName关联了起来,没有把beanName与业务关联起来。我们需要自己这种关联关系,然后才能借助它来判断哪种业务场景使用哪种实例。就相当于我们即使买了“楼座11排142号”的座位,也还需要领座员帮助,才能找到位置一样。

光知道座位号,你能找到座位吗

“根据不同条件选择并构建不同实例”,这是一种很常见的业务模式。作为处理这种业务模式的设计模式,工厂模式应该具备同时“选择”和“构建”两种能力,而不是瘸子走路——一条腿蹦跶。

怎么做

工厂模式通常包括简单工厂、工厂方法和抽象工厂这三种。每种工厂的定义和实现都略有不同。我个人理解,它们的主要区别在于:简单工厂是用“一个”工厂来生产“一套”产品,简单工厂是用“一套”工厂来生产“一套”产品,抽象工厂则是用“一套”工厂来生产“多套”产品。

简单工厂

大多数情况下,我们自己写工厂时,都只会写一个静态方法,在方法中用if-else来选择和构建不同实例。这就是简单工厂:这个静态方法及其所在的类就是仅有的那“一个”工厂;它创建的多种实例都继承自同一父类,这就是所谓“一套”产品。

/** 只需要一个工厂,就可以生产ProductA、ProductB..等一套产品。 */
public class Factory{
    
    public static Product produce(ProductType type){
        if(type == ProductType.A){
            // ProductA extends Product
            ProductA product = new ProductA();
            // 设值、初始化
            return product;
        }else if(type == ProductType.B){
            // 如法炮制,略        
        }    
        // 其它略
    }    
}

简单工厂最大的问题在于把所有产品的生产逻辑都放在一个工厂中。当产品越来越多、生产逻辑越来越复杂时,简单工厂内的if-else势必会随之增长,整个工厂也会越来越臃肿。

工厂方法

面对这种bad smell,很容易想到可以用策略模式来做重构简单工厂。经过重构之后,我们就有了多个工厂,每个工厂生产一种产品。把它们汇总起来,就得到了“一套”工厂生产“一套”产品的工厂方法模式:

工厂方法:一套工厂、一套产品

抽象工厂

工厂方法模式几乎可以包打天下了。除非我们想为更复杂的业务定制框架。

以上图为例。围绕消息体,通常会有发送消息和接收消息两种业务。不同的消息需要用不同的服务类来实现这两种业务。此时,我们的工厂除了要生产消息体,还要生产发送消息和接收消息的服务对象,也就是用“一套”工厂生产“三套”产品。这就是抽象工厂模式。

抽象工厂:一套工厂、多套产品

细心的朋友可能已经发现了:在上面的例子中,只有简单工厂同时实现了“选择”和“构建”这两项功能。其它两类工厂都没有做到这一点。

在上面的例子中,的确存在这个问题。但工厂方法和抽象工厂可以解决它。不过,我要在这里卖个关子,留到后面再讨论怎样解决这个问题。

为什么

借助工厂模式,业务代码只需要从“工厂”中获取构建好的实例就行了。套用钱钟书先生的话来说,就是把下蛋的事情交给老母鸡。我们只要专心煎荷包蛋就好了。

可以说,“构建”是工厂模式的基本功能。使用工厂来构建实例,而不是在业务代码中直接调用构造方法,可以降低业务代码与实例类型之间的耦合度。这是因为,构造器只能返回当前类型的实例,而无法替换为某个子类。工厂模式则可以做到这一点。

例如,我们的系统中有一个奖券实体类:

public class Coupon{
    private Type type;
    private String name;
    private BigDecimal amt;
}

用户使用这张奖券,就可以在我们的系统内兑换面值amt的一笔权益。例如,在我们的商城内享受满减抵扣。

随着业务发展,奖券实体扩展出了一个子类:合作方奖券。

public class CouponThird extends coupon{
    private Provider provider;
    private String exchangeCode;
}

用户获得奖券后,需要登录合作方平台,凭兑换码去合作方兑换面值amt的一笔权益。例如,用户可以登录中国移动网站,凭兑换码给自己充一笔话费。

在合作方奖券出现之前,业务代码都是直接使用构造器来创建实例。粗略统计发现,这样的代码出现了将近20次:

Coupon coupon = new Coupon();
coupon.setType(type);
// 其它set方法,后略

而合作方的奖券,构建起来相当麻烦:

// 查询合作方供应商
CouopnThird third = new CouopnThird();
Provider provider = queryProvider(type);
third.setType(type);
third.setProvider(provider);  
// 从合作方处获取兑换码
String exchangeCode = provider.exchangeCode();
third.setExchangeCode(exchangeCode);

自然,我们也要根据type来选择构建哪种奖券:

if(isThirdParty(type)){
    // 构建合作方奖券,如上
} else{
    // 构建普通奖券,如上。
}

显然,无论构建还是选择,都不应该再重复二十来遍。最好的办法,就是把它们汇总到一个工厂中,然后,用工厂方法替代原有的构造器方法:

public class CouponFactory{
    public Coupon produce(Type type){
        if(isThirdParty(type)){
            // 构建合作方奖券
            // 查询合作方供应商
            CouopnThird third = new CouopnThird();
            Provider provider = queryProvider(type);
            third.setType(type);
            third.setProvider(provider);  
            // 从合作方处获取兑换码
            String exchangeCode = provider.exchangeCode();
            third.setExchangeCode(exchangeCode);
            // 设置其它字段,略
            return third;
        } else{
            // 构建普通奖券
            Coupon coupon = new Coupon();
            coupon.setType(type);
            // 其它set方法,后略
        }    
    }
}

我想,这个例子应该可以说明为什么要使用工厂模式,以及为什么要让工厂模式同时承担“构建”和“选择”这两项功能了吧。

工厂模式与面向对象思想

工厂模式中,我们需要考虑三方关系:工厂、产品、以及调用方。

抽象

往常谈论抽象时,大多都是在已经获得了服务方的实例之后,在调用方调用服务时这个场景下,探讨服务方如何隐藏实现细节。至于调用方如何获得服务方的实例,此前几乎从未涉及,仿佛服务方的这套东西是从石头里蹦出来的一般。

服务方实例是从石头缝里蹦出来的吗?显然不是。它也有生命周期,也有生老病死。如果处置不当,这里也会暴露服务方的实现细节。杨修见到曹丕“以车载废簏”,就知道车里藏了吴质;林帅读战报发现缴获的手枪多于步枪,就知道敌军司令部在哪里。可见蚁穴虽细,却足以溃堤。

工厂模式把服务方打包成了自己的产品,帮助它隐藏了自己生命周期前期的细节——也就是构建和选择的细节。从这一点上来说,工厂模式对构建一套完整的服务抽象大有裨益:它让抽象真正获得了“召之即来、来之能战”的能力。否则,就像老电影《甲午海战》中表现的那样,炮兵拿到炮弹后,还要自己加工一遍,怎么能有战斗力、怎么能打赢?

甲午海战,气得牙痒痒

虽有帮助,也有代价。在维护产品抽象的同时,工厂模式也增加了一套新的抽象,也就是这个工厂自己。这使得调用方必须先获得工厂的实例,然后才能从工厂中得到产品实例。看起来就像“脱了裤子放屁”……

当然,这并不是脱了裤子放屁。虽然付出了一点代价,但毕竟解决了一部分问题。何况这点代价也不是无解的问题。在很多情况下,我们可以把工厂放到抽象之内。这是后话,按下不表。

高内聚低耦合

增加一套抽象导致工厂模式的内聚性不是很高。当工厂生产的不仅是简单的数据容器,还是一组服务对象时,“脱了裤子放屁”这个问题就比较显著了。

例如,我们有一套通知服务:

public interface NoticeService<T extends Message>{
    void send(T message);
}

public class NoticeBySmsService implements NoticeService<SmsMessage>{
    void send(SmsMessage message){
        // 短信通知,略    
    }
}

public class NoticeByAppPushService implements NoticeService<AppPushMessage>{
    void send(AppPushMessage message){
        // APP push消息通知,略    
    }
}

配合上这个工厂:

/** 工厂代码 */
public class NoticeFactory{
    public Message buildMessage(MessageType type){
        // 根据不同消息类型,构建不同的消息体
    }
    
    public NoticeService buildService(MessageType type){
        // 根据不同消息类型,构建不同的通知服务
    }
}

调用方恐怕得这样写,才能获得最终产品、并调用其服务:

/** 调用代码 */
// 首先,获取工厂
NoticeFactory factory = ...;
// 然后,借助工厂构建消息体
Message message = factory.buildMessage(type);
// 接着,借助工厂构建消息服务
NoticeService noticeService = factory.buildService(type);
// 最后,使用服务发送消息
noticeService.send(message);

从获取工厂到发送消息这四个步骤,前面三步都是“准备工作”,只有最后一步是真正的业务操作。3:1,就是内聚性不够的代价。

内聚不够高,耦合也就低不了。工厂模式隐藏了对象生命周期前期的细节,这确实降低了一些耦合程度。可惜的是,它还是把对象的生命周期暴露了出去,这又抬高了抽象与调用方的耦合度。上面的代码就是很好的例子,这里不再啰嗦。

封装继承多态

封装

抽象性不足、内聚不够高、耦合不够低,这类问题都可以归结到“封装不够严”上。策略模式如是,工厂模式亦如是。

策略模式没有把“抽象内部的实现类”封装好,相当于饭馆没把后厨锁好,顾客上门了得自己指定由哪位厨师给自己做饭。

工厂模式没有把“抽象实例的选择和创建”封装好,类似于顾客到了饭馆,得从厨师的招聘开始抓起……

这是顾客要操心的事儿吗

说几句题外话。在某些行当里,比如装修,这种情况非常普遍。我一个顾客,出钱提需求的甲方,被逼着把乙方的弯弯绕摸了个门儿清,什么建材家具,什么水电走线,什么腻子打胶……简直了。真不知道这种风气是怎么起来的。

继承

工厂模式与继承的关系,可以和多态一起讨论。

多态

工厂模式自身虽然与继承和多态关系不深,可它生产的产品却与之交情匪浅。

就自身而论,只有在工厂方法和抽象工厂中,工厂模式才会用到继承和多态。这里的继承和多态非常简单,简单得都没啥好写的。当然,产品自身的继承与多态,其实也很简单,同样没啥好写的。

不过,考虑产品与工厂之间的关系,就不那么简单了:一般来说,只有产品是多态的,才有使用工厂模式的必要。

至少,从“选择”的角度来说是这样:只有产品是多态的,才有使用工厂模式来做选择的必要。产品是多态的,才能称之为“一套”。前文总结过,无论工厂方法、简单工厂还是抽象工厂,它们都至少要生产“一套”产品。如果不是“一套”,而是“一种”甚至“一个”,那压根就没得选,也就没有使用工厂模式的必要了。

我以前没得选择,现在我想用工厂

有人可能不同意:我用SpringIoC的时候,可没有给每个Service都写好几个子类啊!这话没错,使用SpringIoC并不要求业务类必须是多态的。这是因为SpringIoC眼中的产品,并不是我们的业务类或者其接口和父类,而是Object类。你看ApplicationContext中的getBean方法的返回值就是Object——即使用了泛型,擦除后也还是Object。Java中的所有类,包括我们的业务类,都继承自Object类。可见,SpringIoC也是生产“一套”产品,我说的也没错。

通常意义上的多态,是指一个父类有多个子类。在工厂模式中,多态还有一种可能性:一个产品类有多个实例。

我曾经写过一段读写分离的代码,用以实现读从库、写主库的功能。显然,这里需要两个DataSource实例。这两个实例就是同一个类而有多个实例的一种多态。这段代码大概长这样:

public class ReadWriteDao{
    /** 当然,实际代码要复杂些。这里是个示例,会意就好。 */
    private DataSourceFactory dataSourceFactory;
    
    public Data readData(){
        // 获取从库数据源
        DataSource dataSource = dataSourceFactory.getDataSource(DataSource.SLAVE);
        // 操作数据源,读取数据。略
    }
    
    public void writeData(Data data){
        // 获取主库数据源
        DataSource dataSource = dataSourceFactory.getDataSource(DataSource.MASTER);
        // 操作数据源,写入数据。略
    }
}

如果与这两类多态都不沾边,而仍要使用工厂模式,我只能想到一种情况:构建过程非常复杂,复杂到连init方法或newInstance方法都不够用的程度。不过,似乎也很少有人把这种情况下新增的类称为“工厂模式”吧?

往期索引

《面向对象是什么》

从具体的语言和实现中抽离出来,面向对象思想究竟是什么?

《抽象》

抽象这个东西,说起来很抽象,其实很简单。

《高内聚与低耦合》

《细说几种内聚》

《细说几种耦合》

"高内聚"与"低耦合"是软件设计和开发中经常出现的一对概念。它们既是做好设计的途径,也是评价设计好坏的标准。

《封装》

《继承》

《多态》

——“面向对象的三大特性是什么?”
——“封装、继承、多态。”

《[5+1]单一职责原则》

单一职责原则非常好理解:一个类应当只承担一种职责。因为只承担一种职责,所以,一个类应该只有一个发生变化的原因。

《[5+1]开闭原则(一)》

《[5+1]开闭原则(二)》

什么是扩展?就Java而言,实现接口(implements SomeInterface)、继承父类(extends SuperClass),甚至重载方法(Overload),都可以称作是“扩展”。
什么是修改?在Java中,严格来说,凡是会导致一个类重新编译、生成不同的class文件的操作,都是对这个类做的修改。实践中我们会放宽一点,只有改变了业务逻辑的修改,才会归入开闭原则所说的“修改”之中。

《[5+1]里氏替换原则(一)》

《[5+1]里氏替换原则(二)》

里氏替换原则(Liskov Substitution principle)是一条针对对象继承关系提出的设计原则。它以芭芭拉·利斯科夫(Barbara Liskov)的名字命名。1987年,芭芭拉在一次名为“数据的抽象与层次”的演讲中首次提出这条原则;1994年,芭芭拉与另一位女性计算机科学家周以真(Jeannette Marie Wing)合作发表论文,正式提出了这条面向对象设计原则

《[5+1]接口隔离原则(一)》

《[5+1]接口隔离原则(二)》

一般我们会说,接口隔离原则是指:把庞大而臃肿的接口拆分成更 小、更具体的接口。
不过,这并不是接口隔离原则的定义。实际上,接口隔离原则的定义其实是这样的……客户端不应被迫依赖它们压根用不上的接口;或者反过来说,客户端应该只依赖它们要用的接口。

《[5+1]依赖倒置原则(一)》

《[5+1]依赖倒置原则(二)》

在Java世界里谈到依赖倒置原则,相信90%的人都会立即想起SpringIOC;还有9%的人会想起“面向接口编程”。最多只有1%的人能想起依赖倒置原则的真正定义。

《[5+1]迪米特法则(一)》

《[5+1]迪米特法则(二)》

迪米特法则可以用一句话概括:Only talk to your friends。
“只和你的朋友说话”,这是1987年的表述。2003/2004年左右,Karl Liebertherr对迪米特法则做了一次升级:由“Only talk to your friends”升级为了“Only talk to your friends who share your concerns”——“只和与你同忧同乐的朋友说话”。

《常用设计模式-策略模式》

策略模式把属于同一类别的不同行为封装为某种“策略抽象”,而把这些行为统一为这个抽象下的某个“策略实现”。这样,我们就可以很灵活地决定在哪种场景下使用哪种“策略”了。