前言
我常常觉得人们低估了设计模式的作用和意义。它们不仅是简历上的金边、程序员的黑话,也不仅是常见业务的常用处理方式或经验总结。
设计模式不仅是这些,它们更是面向对象思想理论结合实践的切入点。我们前面聊过抽象、高内聚低耦合、封装继承多态、SOLID设计原则。它们更偏理论指导,离编码实践还有一段距离。而这里要聊的设计模式,不仅有扎实的理论基础,而且实实在在地俯下身子、扎根到了实践当中。
从编码实践的角度来讲设计模式,这类文章没有一万也有八千。细抠几种设计模式之间的区别,这类文章写再多也没太大意义。这里就不凑这些热闹了。
这里,我会简单聊聊几个主要设计模式的编程应用,然后把主要精力放在它们与面向对象思想的关联上。另外,在聊过几种设计模式之后,计划提供一种复合模式,作为我使用设计模式的“最佳实践”,供读者参考。
工厂模式
上一篇参见:《常用设计模式-工厂模式(一)》。
工厂模式与5+1设计原则
工厂模式与单一职责原则
工厂模式堪称单一职责原则的典范。
在工厂模式中,产品的生产、实现和调用这三类职责,由工厂、产品和调用方各取其一。工厂专注于生产产品,不必关注产品的功能逻辑;产品专注于实现自身功能,不必用长长的构造器来构建自己;调用方更是坐享其成,无需关心产品的内部细节,只需要按说明书使用产品即可。三方合作无间,简直其乐融融。
在工厂方法和抽象工厂中,工厂内部更是精细分工,每一个工厂生产一种产品。我们还可以再进一步拆分职责,把“选择”和“构建”也放到不同的工厂中去:
上图中,浅黄色底色的工厂,就可以只负责选择、不负责构建。而构建职责由其它工厂分别承担。至此,工厂的“单一职责”就算得上“功德圆满”了。
之前提到“只有简单工厂同时实现了‘选择’和‘构建’这两项功能。其它两类工厂都没有做到这一点”,这个问题也在这里一并解决了。
工厂模式与开闭原则
“对新增开放、对修改关闭”,显然,简单工厂并不满足这一要求。简单工厂用“一个”工厂来生产“一套”产品,也就把“选择”和“构建”职责都放到了这“一个”工厂之中。因此,无论是增加新产品还是修改老产品,都必然要修改这仅有的一个工厂。
大多数情况下,简单工厂只生产简单的产品,选择和构建逻辑也都非常简单,改一改也没什么大问题。不过,“常在河边走哪有不湿鞋”,改的次数多了,难免会马失前蹄。
在《code review的作用》一文中,我例举了这样一段问题代码:
private List<BankCardListVO> queryBankCardListVOs(
List<BankListDTO> bankListDTOs){
List<BankCardListVO> bankCardListVOs = new ArrayList<>();
BankCardListVO info = new BankCardListVO();
for (BankListDTO bankListDTO : bankListDTOs){
info.setBankId(bankListDTO.getCode());
info.setBankLogo(bankListDTO.getIcon());
info.setBankName(bankListDTO.getName());
bankCardListVOs.add(info);
}
return bankCardListVOs;
}
这段代码就出现在我们的一个简单工厂中。代码中的问题,正是我们在对这个工厂进行修改时引入的。也许就是因为简单工厂过于简单,所以操刀者才会大意失荆州。
相比简单工厂,工厂方法和抽象工厂都更符合开闭原则:通过新增对应的工厂类就可以应对新的产品了。
不过,只要工厂承担了选择功能,那么每当新增一个产品及其对应的工厂时,就难免要修改这个用于“选择”的工厂了。这不就“对修改开放”了吗?
确实如此。好在也有法子。绝大多数情况下,我们可以用一个注册器来帮助工厂“对修改关闭”。这里的“注册器”又是另一种设计模式,到时候再说。
工厂模式与里氏替换原则
在 《常用设计模式-策略模式》一文中提到:
只靠策略模式,我们可以在编译期满足里氏替换原则的要求,但很难在运行期做到这一点。
在编译期,我们很容易满足里氏替换原则的要求,即用子类安全地替换父类。在java中,只要使用了extends或者implements关键字,就可以做到这一点:
public interface PayService{
public void pay(Account from, Account to, Money amt);
}
public class ChatPayService implements PayService{
@Override
public void pay(Account from, Account to, Money amt){
// 略
}
}
public class AliPayService implements PayService{
@Override
public void pay(Account from, Account to, Money amt){
// 略
}
}
// 调用方:可以安全地用子类替换父类,而不会引发编译报错
PayService payService = new WeChatPayService();
payService = new AliPayService();
在运行时,就没那么容易了。我们必须要有一段“选择”逻辑,用以根据运行时上下文判断“用哪个实现类来处理当前请求”,才能保证子类可以安全地替换父类。否则,扫了微信的收/付款码,却调用AliPayService来处理,不出问题才怪。
工厂模式的“选择”功能就可以解决这一问题。以简单工厂为例,我们只需要提供一个这样的工厂:
public class PayServiceFactory{
private AliPayService aliPayService;
private WeChatPayService weChatPayService;
public static PayService payWith(PayType payType){
swith(payType){
case WeChat:
return weChatPayService;
case AliPay:
return aliPayService;
default:
throw new UnsupportOperationException("不支持此种支付方式");
}
}
}
这样一来,客户端就可以这样调用服务,而不必担心出现“扫了微信的码、调了阿里的服务”了:
// 从工厂获取一个支付服务的实例
PayService payService = PayServiceFactory.payWith(payType);
// 使用这个实例进行支付
payService.pay(from, to, amount);
不难看出,里氏替换原则必须借助这样的“选择”能力,才能超越编译期、在运行期大放异彩。推而广之,多态也需要这样的选择能力,才能在运行期实现动态链接。
可以说,在运行期间,选择不同实例以应对不同上下文的需求,这才是里氏替换原则、多态乃至面向对象设计最终的目标、最大的潜力所在。
如果再鸡汤一点,这种随机应变、“到什么山上唱什么歌”的能力,不就是人们常说的“情商”吗?
回到技术上来。“选择”能力在运行期间的作用,也正是我认为工厂模式在“构建”能力之外,应该同时具备这一能力的主要原因。
工厂模式与接口隔离原则
工厂方法和抽象工厂模式把“构建”实例的功能与实例自身的接口隔离开,这是正中接口隔离原则下怀。
不过,抽象工厂的接口往往需要定义多个方法,每个方法生产一类产品,以此来生产“一套”产品。这个接口似乎有不符合接口隔离原则之嫌。严格遵守这一原则的话,抽象工厂的接口就应该拆分为一堆小接口了。
这里,就需要一点取舍了。
从概念上讲,这样拆分之后,抽象工厂模式就退化为简单工厂模式了。如果抽象工厂的接口就“应该”拆分为一堆小接口的话,那抽象工厂模式就“不应该”作为一个独立的设计理念出现。既然抽象工厂模式堂而皇之地存在至今,由反证法可得:抽象工厂的接口必定有“不应该”拆分为一堆小接口的理由。
实践中,“不应该拆分”的理由首先是“类爆炸”问题。参考前文中抽象工厂的类图。图中有一个抽象工厂的接口,用三个工厂实现类来生产三套产品。如果我们把这个抽象工厂接口拆分为简单工厂的接口,会有几个实现类呢?
显然,需要3个抽象工厂实现类 × 3套产品 = 9个简单工厂实现类
。如果还要增加新的工厂或者新的产品,简单工厂实现类的数量还要继续暴涨。这种宇宙大爆炸式的增长,谁能承受得了呢?
除了可以避免类爆炸之外,使用一个抽象工厂接口、而非多个简单工厂接口,还有一个优点:在增加新的工厂实现类时,抽象工厂接口可以保证我们不会漏掉任何一个产品。
我这里有个反例。我们的系统为用户的三方账号(如微信、支付宝、微博账号等)提供了四项服务:绑定、解绑,以及登录、登出。为了给不同来源的三方账号提供服务,我们在这里引入了简单工厂模式:
这套简单工厂满足了接口隔离的要求,却给我们后续扩展带来了一个不大不小的问题。
当时我们接入了一个新的第三方。按这套工厂的设计,自然也需要增加四个工厂类以及对应的业务服务。不料,接手这个需求的同事“只见一叶,不见泰山”:他并不了解完整业务和类结构,因而只写了一套绑定和解绑的类,而漏掉了登录登出的功能。好在测试同事及时发现了功能缺失,否则这个疏忽大意就要酿成线上事故了。
设想一下,如果我们为三方账号服务提供的不是四个简单工厂接口,而是一个抽象工厂接口,还会出现这样的问题吗?
public interafce Factory{
BindService bindBy(Account account);
UnBindService unBindBy(Account account);
LoginService loginBy(Account account);
LogoutService logoutBy(Account account);
}
在看到这个接口之后,相信稍有经验的人都能明白自己应该提供四个新的服务实现类,也不会写完两个就草草了事。这就是抽象工厂模式带来的一个意外之喜:它构成了一项业务所需服务的完整集合。
工厂模式与依赖倒置原则
依赖导致原则有两项要求。第一,高层不能直接依赖低层,二者应该构建于某种抽象层之上;第二,应该是实现细节依赖于抽象,而非抽象依赖于实现细节。
工厂模式正是第一个要求中所说的“抽象层”。依靠它,高层无需知道低层到底有哪些实现,只需要依赖工厂和低层接口即可。也是依靠它,依赖倒置原则才能从“原则”走入“实践”。我们常用的SpringIoC就是最典型的例子,这里就不多啰嗦了。
可惜的是,即使高层不知道低层实现,工厂还是需要知道的。可见,即使使用了工厂模式,对低层实现的依赖其实还在,只是从调用方转移到了工厂中;而调用方则从依赖底层实现转为了依赖工厂——这套对工厂的依赖是原先系统中所没有的。这样算下来,其实系统的总体复杂度其实不一定不会降低,有时反而会提高。
这一点,可以用下面这张图来说明。图中的虚线(依赖关系和继承关系)可以用作系统结构复杂度的一个度量标准:虚线越多,说明依赖关系越复杂,系统内的结构复杂度也就越高。
从上图中可以看出,如果系统内的依赖全是左上角这种形式,即一个调用方只固定调用某一个服务实现类,那么使用工厂模式就可能不合时宜了。而如果系统内的依赖是左下角这种形式,即调用方可能需要依赖多个服务实现类、并从中选择一个进行调用的话,那么,工厂模式——尤其是带“选择”功能的工厂模式——就可以大显身手了。
这么说的话,只要能把系统维持在左上角的状态,不就可以绕开工厂模式了吗?以前面的支付服务为例,我们可以为每一个PayService的实现类写一个特定的Controller。这样,不仅系统简单、清晰,还不用费心费力写工厂代码,岂不快哉?
遗憾的是,事情没有这么简单。这一点,放到下一节讨论。
工厂模式与迪米特法则
在上一节中提到:“以前面的支付服务为例,我们可以为每一个PayService的实现类写一个特定的Controller。这样,不仅系统简单、清晰,还不用费心费力写工厂代码”,这个设计可以用下面这张图来说明:
这个设计存在什么问题呢?
“用哪个实现类来处理当前请求”,我们可以把它看做一个“知识点”。只要相关的业务和代码还在,这个“知识点”就会一直存在,只是会以不同形式出现在放在不同的代码中。有时,它会以if-else形式出现在服务实现类的内部;有时它会表现为类或模块间的依赖,出现在某些聚合调用类中。
上面这种设计方案中,这个“知识点”被放在了什么地方呢?似乎无论是Controller还是服务实现类,都没有出现这一知识啊?
没错。在这个设计中,服务实现类和Controller类都没有掌握这一知识点:因为它已进一步的“泄密”,泄露到了Controller的调用方里。无论是谁要调用这些Controller接口,都必然要考虑一个问题:我应该调用哪个Controller接口呢?也许Controller的调用方自己也无法决定,还会把它丢给再下一级调用方、再下下级调用方……甚至交到最终用户手中。
最终,谁来回答这一问题,谁就掌握了这个知识点。掌握这个知识点的“人”离服务实现类越“远”,这个知识点的影响范围就越接近失控,调用方的处理逻辑就越复杂和冗余,服务实现类的维护和扩展也就越困难。这就是为什么上一节结尾时说“事情没有这么简单”的原因。
“用哪个实现类来处理当前请求”,我们可以把它看做一个“知识点”。只要相关的业务和代码还在,这个“知识点”就会一直存在,只是会以不同形式出现在放在不同的代码中。有时,它会以if-else形式出现在服务实现类的内部;有时它会表现为类或模块间的依赖,出现在某些聚合调用类中。
让我们换个角度来思考:这个知识点是服务调用方必须掌握的吗?这个问题可以见仁见智,我个人的答案是否定的。我去饭馆吃饭,难道还必须知道“让哪个厨师来给我做饭”才行吗?我去银行存钱,难道还必须知道“用哪个保险柜来给我放钱”吗?同理,我调用一个服务接口,难道还必须知道接口低层是怎么实现的吗?
“用哪个实现类来处理当前请求”不是服务调用方必须掌握的知识点,换一种表达方式就是:“用哪个实现类来处理当前请求”不属于服务调用方所需的“最小知识”。既然如此,根据“最小知识法则”,也就是“迪米特法则”,“用哪个实现类来处理当前请求”相关逻辑就不应当放到服务调用方中。
不应该放到服务调用方,那应该放到哪儿呢?显然,工厂就是一个不错的选择。把它放到工厂中,可以保证调用方的“最小知识”集不被污染,从而调用方与服务方之间的关系更符合最小知识法则的要求。这就是工厂模式与迪米特法则之间的关系。
同时,作为服务实现类生命周期的一部分,工厂距离服务实现类非常“近”。用工厂模式来处理“用哪个实现类来处理当前请求”,这个知识点就变得非常可控,调用方处理逻辑也就非常简单,服务实现类的维护也扩展也非常轻松。这也是工厂模式应该具备“选择”能力的又一个原因。
数据、信息和知识
最后,不知道有没有人也有这样的疑惑:服务调用方怎么可能完全不知道“用那个实现类来处理当前请求”呢?绝大多数时候,调用参数里都会有产品代码、业务类型之类的数据。看到这些数据,“用那个实现类来处理当前请求”不就一目了然了吗?
回答这个问题既容易,也不容易。说容易,是因为我们只需要梳理一遍调用方代码,看能不能从代码中“推演”它将调用哪个服务实现类即可。梳理代码嘛,哪个开发没干过。说不容易,是因为我们在梳理代码时,必须泾渭分明地区分哪些是由代码推演得出的结论、哪些又是由我们头脑中的知识推理得出的结论。这种边界感可不是谁都能清楚把握好的。
只有当仅凭调用方代码就能推知底层实现类时,我们才能说调用方掌握了底层实现类相关的知识。一般来说,光看参数是看不出这一点的。
如果要深入思考这个问题,我们还需要搞清楚数据、信息和知识的范畴。
数据、信息、知识是什么?我没有找到信服的定义。不过,可以从下面这张图中一窥门径:
我们都做过这样的数学题:已知三角形一个角为90°,这个角的两条邻边长度分别为3cm、4cm;则根据勾股定理可知,该三角形第三条边长度为5cm。在这道题目中,90°、3cm、4cm是数据;它们都是同一个三角形的组成部分,这是信息。勾股定理则是知识。知识以命题的形式,向我们揭示了信息之间的关联关系。
结合我们的问题,则可以这么理解。调用方传入的产品代码、业务类型,这是数据;这个产品归属哪个业务,这是信息。这项业务应该由哪个服务实现类来处理,这是知识。
服务调用方必然要掌握数据和信息,也许还会了解其中包含的其它知识;但它不应该掌握与服务实现类有关的知识,应该排除在服务调用方所必须的“最小知识”集之外。
工厂模式与其它设计模式
工厂模式与策略模式
此前讨论策略模式时,我就一直在吐槽它把服务内部实现暴露给了调用方。如果不能解决这一问题,使用策略模式无异于饮鸩止渴。
工厂模式恰恰可以解决这一问题:它可以把策略模式暴露出去的知识封装起来。它不仅实现了调用方与服务实现之间的解耦合,也让策略模式放下泄密之忧、放心地大展拳脚。
不仅如此。我们还可以把策略类生命周期前期的构建逻辑也交由工厂模式来处理。这样一来,策略模式就可以专注于自己最重要的工作,而无需从开天辟地开始操心了。
总之,只有使用了工厂模式,策略模式才能摆脱“顾头不顾腚”的困窘,才能放心地去往星辰大海。
对工厂模式来说,策略模式的重要性也不遑多让:只有用到了策略模式,工厂模式才有价值。前面讨论过,“只有产品是多态的,才有使用工厂模式的必要”。多态的最常见形式就是一个接口下存在多个实现类。而“一个接口下存在多个实现类”,这不正是策略模式吗?
四舍五入来说就是:只有用到了策略模式,才有使用工厂模式的必要。
可见,工厂模式和策略模式,简直是“全天候全方位战略合作伙伴”关系了。
复合模式
很容易想到:把这两位“全天候全方位战略合作伙伴”组合起来,我们就能得到一个复合模式,如下图所示。这里用到了在“工厂模式与单一职责原则”一节中提出的思路:工厂选择器不负责构建,只负责选择;其它工厂实现类不负责选择,只负责构建。
实践比构想还更很容易些:构建职责可以交给SpringIoC,自定义工厂只需负责选择即可。如果选择逻辑比较简单,我们还可以将简单工厂模式进一步简化为工厂方法模式:
这个方案非常简单实用,但问题也很明显:“在维护产品抽象的同时,工厂模式也增加了一套新的抽象,也就是这个工厂自己。这使得调用方必须先获得工厂的实例,然后才能从工厂中得到产品实例”。至于解决方法,也在前文中提到过:“在很多情况下,我们可以把工厂放到产品的抽象之内”。
实践起来要怎么做呢?首先,我们要把工厂方法和服务接口的调用参数整合成一套。然后,让工厂实现服务接口,从而让它下沉到服务抽象内部。最后,确保所有调用方获取到的服务实现类都是工厂实例,就实现了这一“复合模式”。就像下图这样:
这个复合模式不仅融合了策略模式和工厂模式的优点,而且用工厂模式弥补了策略模式的缺点。更进一步的,它还避免了工厂模式对服务抽象的破坏,保持了服务抽象的唯一和统一。真是居家旅行……必备良药。
彩虹屁到此为止,这个模式有什么缺点吗?有。
最常见的问题是:当我们扩展新业务时,除了要增加一个服务实现类之外,势必还要修改工厂类的代码实现。这不仅不符合开闭原则,而且可能出现增加了实现类却忘记修改工厂类的问题。毕竟,从我们copy的服务实现类当中,完全看不到工厂类的蛛丝马迹。
还有一个特殊情况:如果工厂的代码不在业务项目中,我们甚至无法修改它的代码,更不能向其中新增实现类。这是我在写业务框架时真实遇到过的问题,虽然情况特殊,然而一旦遇上就让人焦头烂额。
正如邓公所言,“发展的问题要靠发展来解决”。这个设计模式中遇到的问题,也可以靠另一个设计模式来解决。至于是哪一个设计模式,我们下回分解。
往期索引
《面向对象是什么》
从具体的语言和实现中抽离出来,面向对象思想究竟是什么?
《抽象》
抽象这个东西,说起来很抽象,其实很简单。
《高内聚与低耦合》
《细说几种内聚》
《细说几种耦合》
"高内聚"与"低耦合"是软件设计和开发中经常出现的一对概念。它们既是做好设计的途径,也是评价设计好坏的标准。
《封装》
《继承》
《多态》
——“面向对象的三大特性是什么?”
——“封装、继承、多态。”
《[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”——“只和与你同忧同乐的朋友说话”。
《常用设计模式-策略模式》
策略模式把属于同一类别的不同行为封装为某种“策略抽象”,而把这些行为统一为这个抽象下的某个“策略实现”。这样,我们就可以很灵活地决定在哪种场景下使用哪种“策略”了。
《常用设计模式-工厂模式(一)》
工厂模式的定义其实很简单:提供一个独立组件,用以根据不同条件选择并构建不同实例。这个组件就是“工厂”。