聊聊设计模式——上篇
0. 前言
对于23种设计模式,对程序员有多么重要我无需多言,它是让你从“搬砖师”到“工程师”的必经之路,只有掌握了模式,你才能做一名合格的“工程师”,而不只是一个没有感情的“代码搬运工”。
模式的学习要经历这样几个阶段:有了解->认得出->能使用
首先,设计模式是前人总结的精华,是解决某些场景下“银弹”,是编程进阶的基础,所以我们要学习了解。当我们了解这些模式之后,在阅读高手些的源码时就应该发现模式无处不在,就可以感悟到模式之美。当然,我们掌握设计模式不是用来欣赏的,他不是高高在上的艺术品,而是我们日常工作的工具箱,我们应该使用工具解决实际问题。
经历了这三个阶段,我们才能真正意义的掌握设计模式。
接下来我会在“了解”阶段介绍这些设计模式,我不会介绍所有的模式,会挑一些常用的介绍。但就算这样内容也会很多,所以我打算分两到三篇文章说完。
和众多先贤的文章相比,我写的内容微不足道,只是我自己的学习、应用模式的一些收获和感悟,拿出来分享给大家。如能作为诸位学习模式前一道开胃小菜,我即不慎欢喜。
1. 策略模式
我们用策略模式开篇足见其重要性,策略模式是模式的基础,如果说“一切皆策略”一点都不夸张。策略模式支撑了依赖倒置原则和开发原则两大设计原则。也是“多用组合、少用继承”原则的实施践行。
1.1 类图:
类图非常简单,就是一个接口和一个实现类,但是可以说明很多东西:
首先,应用依赖接口,不依赖实现,典型的依赖倒置原则应用。
其次,具体的策略由策略实现来实现(绕口令一般),新增策略就是新增实现类,符合开闭原则。
1.2 适用场景
如果只是为了重写父类试用了继承,试着改用策略模式吧,用策略类组成你原先的子类。组合比继承更加灵活。(其实,现实中写crud业务你也很少用继承的)
如果你的代码中有一堆if...else...,也可以试试策略模式,把判断转移出去。
1.3 关键点
策略的额关键点在于抽象策略类,将代码中“变化”的业务逻辑抽象为“固定”的策略接口,应用只依赖“固定”的策略类,无需关心“变化”,“变化”就要交具体子类去实现。这个思路非常关键,我们我们还会借其他模式进一步说明。
2. 观察者模式
意图:定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都得到通知并被自动更新
2.1 类图
其实根据描述,我们就能猜出类图的样子,Subject作为被观察者,也不依赖具体的实现,被观察者和具体的观察者都依赖于观察者抽象接口,又是一个依赖倒置原则的实际体现,观察者的接口当然应该由被观察者定义的,“想知道我的状态变化吗,先实现我的接口吧”
如果不关注那条虚线(虚线决定是推模式还是拉模式),观察者的类图和策略模式几乎一样,其实观察者模式的基础就是策略模式,只不过衍生到一对多关系管理场景。
2.2 适用场景
如果你有一个对象的状态发生变化时,其他对象需要感知它的变换并进行相应操作,可以使用观察者模式。
策略模式在单体应用比较常见。但是在微服务场景下,大多数服务都以消息的方式实现状态变化的时候相互通知的场景。
2.3 关键点
被观察者依赖观察者的接口,但是调用过程还是会产生异常需要处理,或者某一个观察者实现行为耗时,也影响被观察者调用,所以建议采用异步方式执行通知调用。
Java自带观察者模式实现方式,可以按需使用。
3. 模板方法模式
在模板模式中,一个抽象类公开定义了执行它的模板方法,里面包好若干抽象方法组成的固定业务流程。它的子类可以按需要重写抽象方法实现,以实现变化的部分。
这么说还有有点抽象,我们举一个现实中的例子:
(请忽视表格的申请内容,虽然这是我们的梦想)
我们填的表格就是一份模板,他定义了基本不变的内容和填写的顺序,这就相当于模板了的模板方法,空格就是抽象方法,具体人只需要填写空格中内容(子类现抽象方法)即可。
3.1 类图
参照示意图很好理解类图,不再赘述。
2.2 适用场景
当多个类中都实现了这样一些流程方法,有一部分固定且相同,另一部分随情况变化是,我们就可以将相同的方法抽象成模板类中的模板方法,变化部分抽象模板类中抽象方法,供模板方法调用。
模板方法显示中比较好落地,因为我们总能在业务场景中找出大量的相似的执行顺序和方式,方便我们抽象使用。至少在我们的实际工作中,我经常使用这个模式。
2.3 “变化”VS“固定”
这里我们还要掌握一个关键的思路,分清“变化”和“固定”的部分,分而治之。策略模式我们已经说过,在这类我在强调一下。将“变化”的业务抽象成“固定”的接口,客户程序只依赖“固定”的接口,“变化”交由接口实现类去支持。
模板方法就是将“变化”业务中“固定”的流程和方法调用抽象为抽象类的模板方法,而“变化”的流程交由子类去实现。
这种区分“变”与“不变”是我们在软件设计中的关键原则,必须要牢记。
4. 代理模式
在代理模式中,我们用一个类代表另一个类的功能。
为什么我们要用一个类代表另一个类呢?
如果被代理类(委托类)完全满足业务需求,我们也无需代理类存在。
想象一下房产中介的场景,中介就是代理人,你就是委托人,如果你自己很轻松就能把房子卖出去(其实也不是不可以,但是要付出很多时间和经历,如果你真的去做了也就违反了SRP原则),也就没有中介存在的意义了。你与买房人(应用)之间存在某种障碍(信息差,时间成本,沟通交流方式等),代理就是要补充能力、跨域障碍。
4.1 类图
代理类的方法由被代理类的方法实现,实现过程为我们提供了编码空间和逻辑插入点,才能弥补委托类没有的逻辑。
4.2 适用场景
应用程序和委托类(被代理类,下同)之间有障碍需要代理类解决的场景。
通常都是较重使用场景,代理类本身业务逻辑也非常重。
例如:
远程调用代理:解决应用程序和委托类调用过程中网络障碍。
防火墙代理,解决应用程序和委托类调用过程中安全障碍。
Nginx方向代理:解决应用程序和委托类内外网访问障碍
通过代理类为委托类增加功能(这类场景也可以理解为适配器模式,后面会再谈)
通常都是轻量级的使用场景。
例如:
Spring AOP:增加切面编程能力,spring事务就是使用AOP实现。
日志代理:调用委托类方法自动添加日志。
缓存代理:为委托类增加缓存能力。
这里思考一个问题。代理类中存不存在自己处理无需委托类参与的方法呢?
答案是肯定的,还是房产中介的例子,对于客户咨询这个行为,中介销售完全可以应付,无需房主的参与。要是天天询价的电话直接打给你,你也是要烦死的。这也是中介存在的意义,将买房者和买房者解耦。
4.3 两种代理
静态代理
程序员自己编写代理类,程序运行前代理和被代理了的委托关系已经确定。
动态代理
运行期间动态生成代理类,动态制定委托关系。大部分框架使用动态代理模式。也是现实场景更多使用的方式。
5. 适配器模式
适配器作为接口之间的桥梁,将一个接口能力转化为另一个接口能力。
用生活中非常常见的电源适配器举例:
大家一定使用过笔记本电脑的充电器,其实他的官方名称叫电源适配器,它将我们日常使用的220v电源电压适配为15v电源电压。正如示意图所示,笔记本无法直接使用居民供电提供的电源,只能使用经过适配器适配之后的电源。
目光转移到类图,很明显我们又一次使用了策略模式,符合OCP和DIP。我们抽象了一个“供电服务”接口,笔记本并不关心谁给他供电,只要满足“供电服务”接口要求即可,具体的实现可以是电池,可以是居民供电,甚至是手摇发电机。实际情况是“居民供电”不符合“供电服务”接口要求(电压不匹配呀),我们也不可能修改“居民供电”符合可供电单元接口(你不可能说服国家电网,就算是说服了,你影响的是亿万家庭的供电呀,大家和你一起改吗),这时就需要电源适配器角色,他委托“居民供电”为他提供电源,但是在中间做一些适配转换工作(降电压),以符合“供电服务”的要求。是不是很好理解
5.1 类图
有两种适配器实现方式:
类适配器:通过集成实现(有可能违反LSP)
对象适配器:通过依赖实现
本着多用组合,少用继承的原则,建议工作中对象适配方式。我们上面的示例也是使用对象适配器的模式,大家可以参考对照,不再赘述。
6. 适配器模式VS代理模式
让我们再回顾一下前面说的了两种模式。这两个模式常常会被混淆。
6.1 做个对比
先让我们对比一下类图:
共同点:
两种模式都是用了依赖,将一个对象的行为委托给另一个对象。
(这里说对象是因为动态的关系,委托关系不是编译期的确定的,而是运行期注入的)
不同点:
委托类(Adaptee、RealSubject)和依赖类(Adapter、Proxy)是否实现同一个接口。
6.2 你真的理解模式的意图吗?
实际场景中,一定是现有接口和委托类,对于代理模式是先有subject接口和RealSubject实现类。对于适配器模式也是一定是先有Target接口和Adaptee实现类。那么为什么还要Adapter和Proxy类呢?一定是有意图的,否则本着奥卡姆剃刀原则,我们没有必要新增实体。
适配器意图:适配功能,满足接口需求
代理的意图:跨越直接使用的障碍
我们判断一个模式是代理还是适配器模式是,不能生搬硬套,完全按照类图结构判断,需要根据代码意图判断。模式长相并不重要,意图很重要。
6.3 举个栗子
6.3.1 先上点源码
我截取Java JDK中Collections.synchronizedList(List<T> var0)源码。
SynchronizedList的源码
上面是一些关键代码片段。显然SynchronizedList方法都是委托给传入的List接口实现的对象实现的。这个方法很简单,就是将出入list实现变成线程安全的list实现并返回。
如果我们这样调用Collections.synchronizedList(new ArrayList())。SynchronizedList类的实现关系如下图:
6.3.2 这是什么模式?
如果我们根据类图判断,太标准了,和代理模式一模一样呀。当然是代理了。更一步想想,代理模式的意图是什么?是跨越直接调用ArrayList的障碍。而我们这段代码的意图有跨越障碍的意思吗?
我们先有的List接口来规定线性表容器能力,后又用数组实现了这个接口。但是ArrayList是线程不安全的。我们的意图是实现一个线程容器,是因为我们对List有了新的需求,所以我们用SynchronizedList来适配线程安全的需求,但是容器的具体委托给ArrayList实现。所以这个行为更偏向于适配器。适配应用程序对List接口线程安全的需求。
还记得我们在4.2描述代理模式场景了时候说了某一种代理场景是为了增强委托类功能,增强功能就是为了适配新的接口要求,不是说接口不同才需要适配,对接口方法的期望改变也需要适配。所以从真正的意图上说,4.2描述的增强场景也是可以理解为适配器模式。只不过现实中实现的时候我们都是使用动态代理能力落地的。
聊了这么多并不是为了说明分清是什么模式很重要,更重要的是领悟使用模式的意图。我们要解决什么问题,用什么方法解决的,其实这才是模式的本质。
7. 装饰者模式
装饰器模式允许向一个现有的对象添加新的功能,同时又不改变其结构。(这个描述很符合OCP哟)。
(请注意这里我们说的是对象,而不是类。因为装饰者模式不是通过子类继承的方式在编译的时候扩展了类的行为,我们是在运行时决定对象的装饰行为和顺序。)
7.1 类图
这个类图和代理模式很像,唯一不同的地方在于装饰者类依赖的是Subject接口是不是它某个具体的实现类。这样就可以实现一个很神奇的功能,嵌套装饰,而且还是顺序随意的嵌套装饰,装饰者可以装饰任意RealSubject对象和其他Decorator对象。
7.2 嵌套装饰(洋葱模式)
7.2.1 先说嵌套
如果用代码表示类似于以下的结构:
对象功能是由内到外逐层增强,方法调用时时由外到内依次调用。看到这样的结构我首先想到了洋葱,洋葱越靠近外面越大(类比能力增强),但是剥洋葱的时候要先剥开外面才能继续剥里面,是由外向内一层层剥的(类比调用顺序),所以我给装饰者模式起了个形象的昵称——洋葱模式。
7.2.1 再说顺序随意
还是刚才的代码,我们也可以这样写
或者这样
总之就是想怎么嵌套就怎么嵌套。是不是很神奇。
7.3 适用场景
如果你需要在不改变对象结构的情况下为对象新增一些功能,更重要的我们按照功能将这些新增功能分布于各个类中(符合SRP和OCP),且可以被任意顺序嵌套,随意复用。那我建议你使用装饰者模式。
JDK中InputStream和OutputStream都有类似的场景诉求,所以他们都使用了装饰者模式。你可以作为扩展知识进一步了解。在此不再赘述。
8. 本篇小结
本篇中聊了6种设计模式,为什么先挑出这些聊呢,因为这些都是我日常常用的,也是相对比较好理解的。
我们以策略模式开头,因为从某种意义说它是其他一切模式的基础,其他模式多多少少都有一些策略模式的影子。我特意指出了观察者模式和策略模式的结构的相似之处,希望你能通过这个思路在其他模式中看到它的存在。
对于观察者模式和模板模式都是相对简单且场景比较好判断,一旦找到相应场景就可以轻松使用的模式。通过模板模式,又一次强调了区分“变”与“不变”的思路,这是思路应该是我们日常软件设计中的利器,希望你掌握。
最后我们介绍了代理模式、适配器模式和装饰者模式,这些模式多多少少有一些类似,尤其是代理模式和适配器模式更容易混淆,需要通过意图去区分,不过关键并在于区分使用的是什么模式,而是知道某种模式是如何解决某个场景问题的。
9. 后语
就先聊这么多吧,后续的模式我会另文再述,希望文章对你有帮助,如果有问题请给我留言,我们一起讨论。
最后用一句话总结经典知识学习的重要性。
吾生也有涯,而知也无涯。以有涯随无涯,殆已!已而为知者,殆而已矣!
——庄子
望我们不要在追逐无涯的学识上迷失自我,把握重点才能变“无涯”为“有涯”。
版权声明: 本文为 InfoQ 作者【Jerry Tse】的原创文章。
原文链接:【http://xie.infoq.cn/article/8de1537cca9a0ff663f9a3002】。文章转载请联系作者。
评论