在编译期,我们很容易满足里氏替换原则的要求,即用子类安全地替换父类。在java中,只要使用了extends或者implements关键字,就可以做到这一点:
public interface PayService{
public void pay(Account from, Account to, Money amt);
}
public class ChatPayService implements PayService{
public void pay(Account from, Account to, Money amt){
// 略
}
}
public class AliPayService implements PayService{
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("不支持此种支付方式");
}
}
}
这样一来,客户端就可以这样调用服务,而不必担心出现“扫了微信的码、调了阿里的服务”了:
娜娜项目网每日更新创业和副业项目
网址:nanaxm.cn 点击前往娜娜项目网
站 长 微 信: nanadh666
// 从工厂获取一个支付服务的实例
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。这样,不仅系统简单、清晰,还不用费心费力写工厂代码”,这个设计可以用下面这张图来说明:
Controller-Service,加上更底层的Dao,就是“1:1:1”了。
这个设计存在什么问题呢?
“用哪个实现类来处理当前请求”,我们可以把它看做一个“知识点”。只要相关的业务和代码还在,这个“知识点”就会一直存在,只是会以不同形式出现在放在不同的代码中。有时,它会以if-else形式出现在服务实现类的内部;有时它会表现为类或模块间的依赖,出现在某些聚合调用类中。
上面这种设计方案中,这个“知识点”被放在了什么地方呢?似乎无论是Controller还是服务实现类,都没有出现这一知识啊?
没错。在这个设计中,服务实现类和Controller类都没有掌握这一知识点:因为它已进一步的“泄密”,泄露到了Controller的调用方里。无论是谁要调用这些Controller接口,都必然要考虑一个问题:我应该调用哪个Controller接口呢?也许Controller的调用方自己也无法决定,还会把它丢给再下一级调用方、再下下级调用方……甚至交到最终用户手中。
最终,谁来回答这一问题,谁就掌握了这个知识点。掌握这个知识点的“人”离服务实现类越“远”,这个知识点的影响范围就越接近失控,调用方的处理逻辑就越复杂和冗余工厂模式,服务实现类的维护和扩展也就越困难。这就是为什么上一节结尾时说“事情没有这么简单”的原因。
我是用户我也懵。
“用哪个实现类来处理当前请求”,我们可以把它看做一个“知识点”。只要相关的业务和代码还在,这个“知识点”就会一直存在,只是会以不同形式出现在放在不同的代码中。有时,它会以if-else形式出现在服务实现类的内部;有时它会表现为类或模块间的依赖,出现在某些聚合调用类中。
让我们换个角度来思考:这个知识点是服务调用方必须掌握的吗?这个问题可以见仁见智,我个人的答案是否定的。我去饭馆吃饭,难道还必须知道“让哪个厨师来给我做饭”才行吗?我去银行存钱,难道还必须知道“用哪个保险柜来给我放钱”吗?同理,我调用一个服务接口,难道还必须知道接口低层是怎么实现的吗?
你知道网线那头是人是狗还是GPT吗?
“用哪个实现类来处理当前请求”不是服务调用方必须掌握的知识点,换一种表达方式就是:“用哪个实现类来处理当前请求”不属于服务调用方所需的“最小知识”。既然如此,根据“最小知识法则”,也就是“迪米特法则”,“用哪个实现类来处理当前请求”相关逻辑就不应当放到服务调用方中。
不应该放到服务调用方,那应该放到哪儿呢?显然,工厂就是一个不错的选择。把它放到工厂中,可以保证调用方的“最小知识”集不被污染,从而调用方与服务方之间的关系更符合最小知识法则的要求。这就是工厂模式与迪米特法则之间的关系。
同时,作为服务实现类生命周期的一部分,工厂距离服务实现类非常“近”。用工厂模式来处理“用哪个实现类来处理当前请求”,这个知识点就变得非常可控,调用方处理逻辑也就非常简单,服务实现类的维护也扩展也非常轻松。这也是工厂模式应该具备“选择”能力的又一个原因。
数据、信息和知识
最后,不知道有没有人也有这样的疑惑:服务调用方怎么可能完全不知道“用那个实现类来处理当前请求”呢?绝大多数时候,调用参数里都会有产品代码、业务类型之类的数据。看到这些数据,“用那个实现类来处理当前请求”不就一目了然了吗?
回答这个问题既容易工厂模式,也不容易。说容易,是因为我们只需要梳理一遍调用方代码,看能不能从代码中“推演”它将调用哪个服务实现类即可。梳理代码嘛,哪个开发没干过。说不容易,是因为我们在梳理代码时,必须泾渭分明地区分哪些是由代码推演得出的结论、哪些又是由我们头脑中的知识推理得出的结论。这种边界感可不是谁都能清楚把握好的。
只有当仅凭调用方代码就能推知底层实现类时,我们才能说调用方掌握了底层实现类相关的知识。一般来说,光看参数是看不出这一点的。
如果要深入思考这个问题,我们还需要搞清楚数据、信息和知识的范畴。
数据、信息、知识是什么?我没有找到信服的定义。不过,可以从下面这张图中一窥门径:
数据-信息-知识-洞察力-智慧-冲击
我们都做过这样的数学题:已知三角形一个角为90°,这个角的两条邻边长度分别为3cm、4cm;则根据勾股定理可知,该三角形第三条边长度为5cm。在这道题目中,90°、3cm、4cm是数据;它们都是同一个三角形的组成部分,这是信息。勾股定理则是知识。知识以命题的形式,向我们揭示了信息之间的关联关系。
结合我们的问题,则可以这么理解。调用方传入的产品代码、业务类型,这是数据;这个产品归属哪个业务,这是信息。这项业务应该由哪个服务实现类来处理,这是知识。
服务调用方必然要掌握数据和信息,也许还会了解其中包含的其它知识;但它不应该掌握与服务实现类有关的知识,应该排除在服务调用方所必须的“最小知识”集之外。
工厂模式与其它设计模式工厂模式与策略模式
此前讨论策略模式时,我就一直在吐槽它把服务内部实现暴露给了调用方。如果不能解决这一问题,使用策略模式无异于饮鸩止渴。
工厂模式恰恰可以解决这一问题:它可以把策略模式暴露出去的知识封装起来。它不仅实现了调用方与服务实现之间的解耦合,也让策略模式放下泄密之忧、放心地大展拳脚。
不仅如此。我们还可以把策略类生命周期前期的构建逻辑也交由工厂模式来处理。这样一来,策略模式就可以专注于自己最重要的工作,而无需从开天辟地开始操心了。
总之,只有使用了工厂模式,策略模式才能摆脱“顾头不顾腚”的困窘,才能放心地去往星辰大海。
强如火影,也不能顾头不顾腚。
对工厂模式来说,策略模式的重要性也不遑多让:只有用到了策略模式,工厂模式才有价值。前面讨论过,“只有产品是多态的,才有使用工厂模式的必要”。多态的最常见形式就是一个接口下存在多个实现类。而“一个接口下存在多个实现类”,这不正是策略模式吗?
四舍五入来说就是:只有用到了策略模式,才有使用工厂模式的必要。
可见,工厂模式和策略模式,简直是“全天候全方位战略合作伙伴”关系了。
复合模式
很容易想到:把这两位“全天候全方位战略合作伙伴”组合起来,我们就能得到一个复合模式,如下图所示。这里用到了在“工厂模式与单一职责原则”一节中提出的思路:工厂选择器不负责构建,只负责选择;其它工厂实现类不负责选择,只负责构建。
简单工厂模式+策略模式
实践比构想还更很容易些:构建职责可以交给SpringIoC,自定义工厂只需负责选择即可。如果选择逻辑比较简单,我们还可以将简单工厂模式进一步简化为工厂方法模式:
简单工厂模式+SpringIoC+策略模式
这个方案非常简单实用,但问题也很明显:“在维护产品抽象的同时,工厂模式也增加了一套新的抽象,也就是这个工厂自己。这使得调用方必须先获得工厂的实例,然后才能从工厂中得到产品实例”。至于解决方法,也在前文中提到过:“在很多情况下,我们可以把工厂放到产品的抽象之内”。
实践起来要怎么做呢?首先,我们要把工厂方法和服务接口的调用参数整合成一套。然后,让工厂实现服务接口,从而让它下沉到服务抽象内部。最后,确保所有调用方获取到的服务实现类都是工厂实例,就实现了这一“复合模式”。就像下图这样:
工厂下沉到服务接口内部
这个复合模式不仅融合了策略模式和工厂模式的优点,而且用工厂模式弥补了策略模式的缺点。更进一步的,它还避免了工厂模式对服务抽象的破坏,保持了服务抽象的唯一和统一。真是居家旅行……必备良药。
那么……哪里可以买到呢?
彩虹屁到此为止,这个模式有什么缺点吗?有。
最常见的问题是:当我们扩展新业务时,除了要增加一个服务实现类之外,势必还要修改工厂类的代码实现。这不仅不符合开闭原则,而且可能出现增加了实现类却忘记修改工厂类的问题。毕竟,从我们copy的服务实现类当中,完全看不到工厂类的蛛丝马迹。
还有一个特殊情况:如果工厂的代码不在业务项目中,我们甚至无法修改它的代码,更不能向其中新增实现类。这是我在写业务框架时真实遇到过的问题,虽然情况特殊,然而一旦遇上就让人焦头烂额。
正如邓公所言,“发展的问题要靠发展来解决”。这个设计模式中遇到的问题,也可以靠另一个设计模式来解决。至于是哪一个设计模式,我们下回分解。
往期索引
从具体的语言和实现中抽离出来,面向对象思想究竟是什么?
娜娜项目网每日更新创业和副业项目
网址:nanaxm.cn 点击前往娜娜项目网
站 长 微 信: nanadh666