22.3 模 式 讲 解
22.3.1 认识装饰模式
1.装饰模式的功能
装饰模式能够实现动态地为对象添加功能,是从一个对象外部来给对象增加功能,相当于是改变了对象的外观。当装饰过后,从外部使用系统的角度看,就不再是使用原始的那个对象了,而是使用被一系列的装饰器装饰过后的对象。
这样就能够灵活地改变一个对象的功能,只要动态组合的装饰器发生了改变,那么最终所得到的对象的功能也就发生了改变。
变相地还得到了另外一个好处,那就是装饰器功能的复用,可以给一个对象多次增加同一个装饰器,也可以用同一个装饰器装饰不同的对象。
2.对象组合
前面已经讲到了,一个类的功能的扩展方式,可以是继承,也可以是功能更强大、更灵活的对象组合的方式。
其实,现在在面向对象的设计中,有一条基本的规则就是“尽量使用对象组合,而不是对象继承”来扩展和复用功能。装饰模式的思考起点就是这个规则。
可能有些朋友还不太熟悉什么是“对象组合”,下面介绍一下“对象组合”。
什么是对象组合?
直接举例来说吧,假若有一个对象A,实现了一个al的方法,而C1对象想要来扩展A的功能,给它增加一个cll的方法,那么一个方案是继承,A对象示例代码如下:
C1对象示例代码如下:
另外一个方案就是使用对象组合,怎么组合呢?就是在C1对象中不再继承A对象了,而是去组合使用A对象的实例,通过转调A对象的功能来实现A对象已有的功能。写个新的对象C2来示范,示例代码如下:
对象组合是不是也很简单,而且更灵活了。
■ 首先可以有选择地复用功能,不是所有A的功能都会被复用,在C2中少调用几个A定义的功能就可以了;
■ 其次在转调前后,可以实现一些功能处理,而且对于A对象是透明的,也就是A对象并不知道在al方法处理的时候被追加了功能;
■ 还有一个额外的好处,就是可以组合拥有多个对象的功能,假如还有一个对象B,而C2也想拥有B对象的功能,那很简单,再增加一个方法,然后转调B对象就可以了。B对象示例代码如下:
同时拥有A对象的功能,B对象的功能,还有自己实现的功能的C3对象示例代码如下:
最后再说一点,就是关于对象组合中,何时创建被组合对象的实例。
■ 一种方案是在属性上直接定义并创建需要组合的对象实例。
■ 另外一种方案是在属性上定义一个变量,来表示持有被组合对象的实例,具体实例从外部传入,也可以通过IoC/DI容器来注入。
示例代码如下:
3.装饰器
装饰器实现了对被装饰对象的某些装饰功能,可以在装饰器中调用被装饰对象的功能,获取相应的值,这其实是一种递归调用。
在装饰器中不仅仅是可以给被装饰对象增加功能,还可以根据需要选择是否调用被装饰对象的功能,如果不调用被装饰对象的功能,那就变成完全重新实现了,相当于动态修改了被装饰对象的功能。
延伸
另外一点,各个装饰器之间最好是完全独立的功能,不要有依赖,这样在进行装饰组合的时候,才没有先后顺序的限制,也就是先装饰谁和后装饰谁都应该是一样的,否则会大大降低装饰器组合的灵活性。
4.装饰器和组件类的关系
装饰器是用来装饰组件的,装饰器一定要实现和组件类一致的接口,保证它们是同一个类型,并具有同一个外观,这样组合完成的装饰才能够递归调用下去。
组件类是不知道装饰器的存在的,装饰器为组件添加功能是一种透明的包装,组件类毫不知情。需要改变的是外部使用组件类的地方,现在需要使用包装后的类,接口是一样的,但是具体的实现类发生了改变。
5.退化形式
如果仅仅只是想要添加一个功能,就没有必要再设计装饰器的抽象类了,直接在装饰器中实现跟组件一样的接口,然后实现相应的装饰功能就可以了。但是建议最好还是设计上装饰器的抽象类,这样有利于程序的扩展。
22.3.2 Java中的装饰模式应用
1.Java中典型的装饰模式应用——I/O流
装饰模式在Java中最典型的应用,就是I/O流,简单回忆一下,如果使用流式操作读取文件内容,会怎样实现呢?简单的代码示例如下:
仔细观察上面的代码,会发现最里层是一个FileInputStream对象,然后把它传递给一个BufferedInputStream对象,经过BufferedInputStream处理,再把处理后的对象传递给了DataInputStream对象进行处理,这个过程其实就是装饰器的组装过程,FileInputStream对象相当于原始的被装饰的对象,而BufferedInputStream对象和DataInputStream对象则相当于装饰器。
可能有朋友会问,装饰器和具体的组件类是要实现同样的接口的,上面这些类是这样吗?看看Java的I/O对象层次图吧。由于Java的I/O对象众多,因此只是画出了InputStream的部分,而且由于图的大小关系,也只是表现出了部分的流,具体如图22.4所示。
图22.4 Java的I/O的InputStream部分对象层次图
查看图22.4会发现,它的结构和装饰模式的结构几乎是一样的。
■ InputStream就相当于装饰模式中的Component。
■ 其实FileInputStream、ObjectInputStream、StringBufferInputStream这几个对象是直接继承了InputSream,还有几个直接继承InputStream的对象,比如ByteArrayInputStream、PipedInputStream等。这些对象相当于装饰模式中的ConcreteComponent,是可以被装饰器装饰的对象。
■ FilterInputStream就相当于装饰模式中的Decorator,而它的子类DataInputStream、BufferedInputStream、LineNumberInputStream和PushbackInputStream就相当于装饰模式中的ConcreteDecorator了。另外FilterInputStream和它的子类对象的构造器,都是传入组件InputStream类型,这样就完全符合前面讲述的装饰器的结构了。
同样的,输出流部分也类似,就不再赘述。
既然I/O流部分是采用装饰模式实现的,如果我们想要添加新的功能,只需要实现新的装饰器,然后在使用的时候,组合进去就可以了。也就是说,我们可以自定义一个装饰器,然后和JDK中已有的流的装饰器一起使用。能行吗?试试看吧,前面是按照输入流来讲述的,下面的示例按照输出流来做,顺便体会一下Java的输入流和输出流在结构上的相似性。
2.自己实现的I/O流的装饰器——第一版
来个功能简单点的,实现把英文加密存放吧,也谈不上什么加密算法,就是把英文字母向后移动两个位置,比如,a变成c,b变成d,依次类推,最后的y变成a,z就变成b,而且为了简单,只处理小写的,够简单的吧。
好了,还是看看实现简单的加密的代码实现吧。示例代码如下:
测试一下看看,好用吗?客户端使用代码示例如下:
运行一下,打开生成的文件,看看结果。结果示例如下:
很好,是不是被加密了,虽然是明文的,但已经不是最初存放的内容了,一切显得非常的完美。
再试试看,不是说装饰器可以随意组合吗,换一个组合方式看看,比如把BufferedOutputStream和我们自己的装饰器在组合的时候换个位,示例代码如下:
再次运行,看看结果。坏了,出大问题了,这个时候输出的文件一片空白,什么都没有。这是哪里出了问题呢?
要想把这个问题搞清楚,就需要把上面I/O流的内部运行和基本实现搞明白。分开来看看具体的运行过程吧。
(1)先看看成功输出流中内容的写法的运行过程。
■ 当执行到“dout.write("abcdxyz".getBytes());”这句话的时候,会调用DataOutputStream的write方法,把数据输出到BufferedOutputStream中;由于BufferedOutputStream流是一个带缓存的流,它默认缓存8192字节,也就是默认流中的缓存数据到了8192字节,它才会自动输出缓存中的数据;而目前要输出的字节肯定不到8192字节,因此数据就被缓存在BufferedOutputStream流中了,而不会被自动输出。
■ 当执行到“dout.close();”这句话的时候,会调用关闭DataOutputStream流,这会转调到传入DataOutputStream中流的close方法,也就是BufferedOutputStream的close方法,而BufferedOutputStream的close方法继承自FilterOutputStream,在FilterOutputStream的close方法实现里面,会先调用输出流的方法flush,然后关闭流。也就是此时BufferedOutputStream流中缓存的数据会被强制输出;BufferedOutputStream流中缓存的数据被强制输出到EncryptOutputStream流,也就是我们自己实现的流,没有缓存,经过处理后继续输出;EncryptOutputStream流会把数据输出到FileOutputStream中,FileOutputStream会直接把数据输出到文件中,因此,这种实现方式会输出文件的内容。
(2)再来看看不能输出流中内容的写法的运行过程。
■ 当执行到“dout.write("abcdxyz".getBytes());”这句话的时候,会调用DataOutputStream的write方法,把数据输出到EncryptOutputStream中;EncryptOutputStream流,也就是我们自己实现的流,没有缓存,经过处理后继续输出,把数据输出到BufferedOutputStream中;由于BufferedOutputStream流是一个带缓存的流,它默认缓存8192字节,也就是默认流中的缓存数据到了8192字节,它才会自动输出缓存中的数据;而目前要输出的字节肯定不到8192字节,因此数据就被缓存在BufferedOutputStream流中了,而不会被自动输出。
■ 当执行到“dout.close();”这句话的时候:会调用关闭DataOutputStream流,这会转调到传入DataOutputStream流中的close方法,也就是EncryptOutputStream的close方法,而EncryptOutputStream的close方法继承自OutputStream,在OutputStream的close方法实现中,是个空方法,什么都没有做。因此,这种实现方式没有flush流的数据,也就不会输出文件的内容,自然是一片空白了。
3.自己实现的I/O流的装饰器——第二版
要让我们写的装饰器和其他Java中的装饰器一样使用,最合理的方案就是:让我们的装饰器继承装饰器的父类,也就是FilterOutputStream类,然后使用父类提供的功能来协助完成想要装饰的功能。示例代码如下:
22.3.3 装饰模式和AOP
装饰模式和AOP在思想上有共同之处。可能有些朋友还不太了解AOP,下面先简单介绍一下AOP的基础知识。
1.什么是AOP——面向方面编程
AOP是一种编程范式,提供从另一个角度来考虑程序结构以完善面向对象编程(OOP)。
在面向对象开发中,考虑系统的角度通常是纵向的,比如,我们经常画出的如下系统架构图,默认都是从上到下,上层依赖于下层,如图22.5所示。
图22.5 系统架构图示例图
而在每个模块内部呢?就拿大家都熟悉的三层架构来说,也是从上到下来考虑的,通常是表现层调用逻辑层,逻辑层调用数据层,如图22.6所示。
图22.6 三层架构示意图
慢慢地,越来越多的人发现,在各个模块之中,存在一些共性的功能,比如日志管理、事务管理等,如图22.7所示。
图22.7 共性功能示意图
这个时候,在思考这些共性功能的时候,是从横向来思考问题,与通常面向对象的纵向思考角度不同,很明显,需要有新的解决方案,这个时候AOP站出来了。
AOP为开发者提供了一种描述横切关注点的机制,并能够自动将横切关注点织入到面向对象的软件系统中,从而实现了横切关注点的模块化。
AOP能够将那些与业务无关,却为业务模块所共同调用的逻辑或责任,比如,事务处理、日志管理、权限控制等,封装起来,便于减少系统的重复代码,降低模块间的耦合度,并有利于未来的可操作性和可维护性。
AOP之所以强大,就是因为它能够自动把横切关注点的功能模块,自动织入回到软件系统中,这是什么意思呢?
先看看没有AOP,在常规的面向对象系统中,对这种共性的功能如何处理?大都是把这些功能提炼出来,然后在需要用到的地方进行调用如图22.8所示,只绘制调用通用日志的公共模块,其他的类似,就不去画了。
图22.8 调用公共功能示意图
看清楚,是从应用模块中主动去调用公共模块,也就是应用模块要很清楚公共模块的功能以及具体的调用方法才行,应用模块是依赖于公共模块的,是耦合的,这样一来,要想修改公共模块就会很困难了,牵一而发百。
看看有了AOP会怎样?还是画个图来说明,如图22.9所示。
图22.9 AOP的调用示意图
乍一看,和上面不用AOP没有什么区别嘛,真的吗?看得仔细点,有一个非常非常大的改变,就是所有的箭头方向反过来了,原来是应用系统主动去调用各个公共模块的,现在变成了各个公共模块主动织入回到应用系统。
不要小看这一点变化,这样一来应用系统就不需要知道公共功能模块,也就是应用系统和公共模块解耦了。公共功能模块会在合适的时候,由外部织入回到应用系统中,至于谁来实现这样的模块,以及如何实现不在我们的讨论之列,我们更关注其思想。
如果按照装饰模式来对比上述过程,业务功能对象就可以被看做是被装饰的对象,而各个公共的模块就好比是装饰器,可以透明地来给业务功能对象增加功能。
延伸
所以从某个侧面来说,装饰模式和AOP要实现的功能是类似的,只不过AOP的实现方法不同,会更加灵活,更加可配置;另外AOP一个更重要的变化是思想上的变化——“主从换位",让原本主动调用的功能模块变成了被动等待,甚至在毫不知情的情况下被织入了很多新的功能。
2.使用装饰模式做出类似AOP的效果
下面来演示一下使用装饰模式,把一些公共的功能,比如权限控制、日志记录等透明地添加回到业务功能模块中去,做出类似AOP的效果。
(1)首先定义业务接口。
这个接口相当于装饰模式的Component。注意这里使用的是接口,而不像前面一样使用的是抽象类,虽然使用抽象类的方式来定义组件是装饰模式的标准实现方式,但是如果不需要为子类提供公共功能的话,也是可以实现成接口的,这点要先说明一下,免得有些朋友会认为这就不是装饰模式了。示例代码如下:
顺便把封装业务数据的对象也定义出来。很简单,示例代码如下:
(2)定义基本的业务实现对象。示例代码如下:
(3)接下来该来实现公共功能了。把这些公共功能实现成为装饰器,则需要给它们定义一个抽象的父类。示例代码如下:
(4)实现权限控制的装饰器。
先检查是否有运行的权限,如果有就继续调用,如果没有,就不递归调用了,而是输出没有权限的提示。示例代码如下:
(5)实现日志记录的装饰器,就是在功能执行完成后记录日志即可。示例代码如下:
(6)组合使用这些装饰器。
在组合的时候,权限控制应该是最先被执行的,所以把它组合在最外面,日志记录的装饰器会先调用原始的业务对象,所以把日志记录的装饰器组合在中间。
前面讲过,装饰器之间最好不要有顺序限制,但是在实际应用中,可以根据具体的功能要求而有顺序的限制,但应该尽量避免这种情况。
此时客户端测试代码示例如下。
运行结果如下:
好好体会一下,是不是也在没有惊动原始业务对象的情况下,给它织入了新的功能呢?也就是说是在原始业务不知情的情况下,给原始业务对象透明地增加了新功能,从而模拟实现了AOP的功能。
事实上,这种做法完全可以应用在项目开发上,在后期为项目的业务对象添加数据检查、权限控制、日志记录等功能,而不需要在业务对象上去处理这些功能了,业务对象可以更专注于具体业务的处理。
22.3.4 装饰模式的优缺点
装饰模式有以下优点。
■ 比继承更灵活
从为对象添加功能的角度来看,装饰模式比继承更灵活。继承是静态的,而且一旦继承所有子类都有一样的功能。而装饰模式采用把功能分离到每个装饰器当中,然后通过对象组合的方式,在运行时动态地组合功能,每个被装饰的对象最终有哪些功能,是由运行期动态组合的功能来决定的。
■ 更容易复用功能
装饰模式把一系列复杂的功能分散到每个装饰器当中,一般一个装饰器只实现一个功能,使实现装饰器变得简单,更重要的是这样有利于装饰器功能的复用,可以给一个对象增加多个同样的装饰器,也可以把一个装饰器用来装饰不同的对象,从而实现复用装饰器的功能。
■ 简化高层定义
装饰模式可以通过组合装饰器的方式,为对象增添任意多的功能。因此在进行高层定义的时候,不用把所有的功能都定义出来,而是定义最基本的就可以了,可以在需要使用的时候,组合相应的装饰器来完成所需的功能。
装饰模式的缺点是:会产生很多细粒度对象。
前面说了,装饰模式是把一系列复杂的功能,分散到每个装饰器当中,一般一个装饰器只实现一个功能,这样会产生很多细粒度的对象,而且功能越复杂,需要的细粒度对象越多。
22.3.5 思考装饰模式
1.装饰模式的本质
装饰模式的本质:动态组合。
动态是手段,组合才是目的。这里的组合有两个意思,一个是动态功能的组合,也就是动态进行装饰器的组合;另外一个是指对象组合,通过对象组合来实现为被装饰对象透明地增加功能。
但是要注意,装饰模式不仅可以增加功能,而且也可以控制功能的访问,完全实现新的功能,还可以控制装饰的功能是在被装饰功能之前还是之后来运行等。
总之,装饰模式是通过把复杂功能简单化、分散化,然后在运行期间,根据需要来动态组合的这样一个模式。
2.何时选用装饰模式
建议在以下情况中选用装饰模式。
■ 如果需要在不影响其他对象的情况下,以动态、透明的方式给对象添加职责,可以使用装饰模式,这几乎就是装饰模式的主要功能。
■ 如果不适合使用子类来进行扩展的时候,可以考虑使用装饰模式。因为装饰模式是使用的“对象组合”的方式。所谓不适合用子类扩展的方式,比如,扩展功能需要的子类太多,造成子类数目呈爆炸性增长。
22.3.6 相关模式
■ 装饰模式与适配器模式
这是两个没有什么关联的模式,放到一起来说,是因为它们有一个共同的别名:Wrapper。
这两个模式功能上是不一样的,适配器模式是用来改变接口的,而装饰模式是用来改变对象功能的。
■ 装饰模式与组合模式
这两个模式有相似之处,都涉及到对象的递归调用,从某个角度来说,可以把装饰看做是只有一个组件的组合。
但是它们的目的完全不一样,装饰模式是要动态地给对象增加功能;而组合模式是想要管理组合对象和叶子对象,为它们提供一个一致的操作接口给客户端,方便客户端的使用。
■ 装饰模式与策略模式
这两个模式可以组合使用。
策略模式也可以实现动态地改变对象的功能,但是策略模式只是一层选择,也就是根据策略选择一下具体的实现类而已。而装饰模式不是一层,而是递归调用,无数层都可以,只要组合好装饰器的对象组合,那就可以依次调用下去。所以装饰模式更灵活。
而且策略模式改变的是原始对象的功能,不像装饰模式,后面一个装饰器,改变的是经过前一个装饰器装饰后的对象。也就是策略模式改变的是对象的内核,而装饰模式改变的是对象的外壳。
这两个模式可以组合使用,可以在一个具体的装饰器中使用策略模式来选择更具体的实现方式。比如前面计算奖金的另外一个问题就是参与计算的基数不同,奖金的计算方式也是不同的。举例来说:假设张三和李四参与同一个奖金的计算,张三的销售总额是2万元,而李四的销售总额是8万元,它们的计算公式是不一样的,假设奖金的计算规则是,销售额在5万以下,统一3%,而5万以上,5万内是4%,超过部分是6%。
参与同一个奖金的计算,这就意味着可以使用同一个装饰器,但是在装饰器的内部,不同条件下计算公式不一样,那么怎么选择具体的实现策略呢?自然使用策略模式就可以了,也就是装饰模式和策略模式组合来使用。
■ 装饰模式与模板方法模式
这是两个功能上有相似点的模式。
模板方法模式主要应用在算法骨架固定的情况,那么要是算法步骤不固定呢,也就是一个相对动态的算法步骤,就可以使用装饰模式了,因为在使用装饰模式的时候,进行装饰器的组装,其实也相当于是一个调用算法步骤的组装,相当于是一个动态的算法骨架。
既然装饰模式可以实现动态的算法步骤的组装和调用,那么把这些算法步骤固定下来,那就是模板方法模式实现的功能了,因此装饰模式可以模拟实现模板方法模式的功能。
注意
但是请注意,仅仅只是可以模拟功能而已,两个模式的设计目的、原本的功能、本质思想等都是不一样的。