装饰器模式(通俗易懂)






4.89/5 (70投票s)
本文采用基于场景的方法,让读者理解装饰器模式
编辑说明:内容未编辑,仅修复了链接。
引言
我的一个朋友曾经说过,学习任何编程学科最难的部分就是运行 “Hello World”。之后,一切就都简单了。多年后,我才意识到他说得多么正确。这是本系列设计模式文章的基本目标,旨在帮助初学者入门。它特别针对那些拥有扎实的 OOP 概念但仍在与设计模式作斗争,并且无法弄清楚如何在设计中应用它们的人。我不敢声称写了一篇关于不同设计模式的综合性参考文章,但我确实希望本系列能帮助您入门。设计模式不能与特定语言绑定。尽管我提供了大部分 C# 代码示例,但我会尽量避免使用 C# 特有的结构,从而面向尽可能多的读者,特别是那些从事受 C++ 对象模型影响的语言的读者。
装饰器模式允许我们动态地为对象添加额外的行为。就像在我之前的文章 策略模式入门 中一样,我们将首先经历一个场景。我们还将审视替代方法,这将有助于我们认识到这种设计模式的真正力量及其提供的灵活性。
思路过程
我们今天将要遵循的场景可能不太现实,有时甚至很奇怪,但它将帮助我们理解这种模式的概念和底层力量。目标是以文本文件的形式将来自不同来源的消息存储到硬盘上。所以,首先我们定义了意图(接口),然后实现了它。IMessageWriter
和 IMessageReader
接口分别用于写入和读取消息,它们的实现如下所示。
interface IMessageWriter
{
string Message { set; }
void WriteMessage(string filePath);
}
class MessageWriter : IMessageWriter
{
private string message;
#region IMessageWriter Members
public string Message
{
set { this.message = value; }
}
public virtual void WriteMessage(string filePath)
{
File.WriteAllText(filePath, this.message);
}
#endregion
}
interface IMessageReader
{
string ReadMessage(string filePath);
}
class MessageReader : IMessageReader
{
#region IMessageReader Members
public virtual string ReadMessage(string filePath)
{
if (File.Exists(filePath))
return File.ReadAllText(filePath);
else return null;
}
#endregion
}
消息将存储在 Message
属性中,而 MessageWriter
的 WriteMessage
只是将消息写入传递给它的文件路径。同样,MessageReader
的 ReadMessage()
函数将从文件中读取消息并将其作为文本 string
返回。假设您收到客户的新需求。
- 对于某些消息,我们需要在读取和写入文件之前验证用户。
- 对于某些消息,我们希望将它们加密保存,这样没有人能够从文件中读取它们。但我们需要将此加密消息保存为 base4 编码字符串。
- 对于某些消息,我们需要这两种功能。
很奇怪,对吧?首先,我们将分析不使用装饰器的不同解决方案。这将让我们认识到这种简单的设计模式有多强大。
经典解决方案
您决定使用继承,因为它允许您在基本行为的基础上进行构建。您选择在 MessageWriter
的派生类 EncryptedMessageWriter
中实现加密行为。
同样,您从 EncryptedMessageWriter
派生了 SecureMessageWriter
(用于用户验证的类)。
现在我们可以编写加密消息以及带用户验证的加密消息。那么,如何在不加密的情况下编写简单的文本消息,但带有用户验证呢?您可以使用一些技巧,在 EncryptedMessageWriter
中放置一些决策代码,使其在不需要时跳过加密。假设您仍然选择此选项。另一种操作顺序怎么样?也就是说,我们想先加密,然后验证,如果未验证,我们还会对 base64 编码的加密消息执行其他操作。显然,上述层次结构无法处理这种情况。谁能阻止客户提出更多功能呢?例如,某些消息应该进行数字签名,较大的消息应该根据需要进行压缩(是否加密),对于某些消息,在将它们写入磁盘后,您必须在 MessageQueue
中记录文件路径和时间戳,供其他应用程序读取,或者甚至写入数据库。等等,等等。
为了评估您所处情况的严重性和复杂性,让我们只关注验证,而忘记层次结构的细节。目前,我们已经实现了加密消息情况下的验证。现在,我们需要在许多其他情况下拥有相同的功能,例如,对于 CompressedMessageWriter
、DigitallySignedMessageWriter
等。您唯一的选择是实现 SecureCompressedMessageWriter
、SecureDigitallySignedMessageWriter
等。同样,对于大量其他组合,例如加密消息压缩、简单消息压缩等等。该死,你真的陷入了子类地狱。
第二种解决方案是编写一个相当庞大的 MessageReader
,并随着需求的到来不断添加所有功能。随着时间的推移,增加了其复杂性,使其难以维护。最不推荐的方法。
第三种解决方案可能是上述两种的组合,这可能会缓解问题,但不会完全消除它。
装饰器模式登场
这正是装饰器模式所要解决的问题。如果您仔细观察上面涉及继承的解决方案,您会意识到问题的根源是继承引入的静态关系。这种关系嵌入到无法在运行时更改的类中。装饰器用包含(聚合)替换了这种关系,这是一种更灵活的、可以在运行时更新的对象关系。
首先,让我们看看装饰器模式到底是什么。下面是装饰器模式的类图。
四个参与者是
- Component:对象接口,可以动态地为其添加职责。在我们的例子中是
IMessageWriter
和IMessageReader
。 - ConcreteComponent:定义实现 Component 接口的对象。这是将被装饰的对象,但它对装饰一无所知。实现装饰器的人甚至可能无法访问源代码。在我们的例子中是
MessageWriter
和MessageReader
。 - Decorator:维护对
Component
对象的引用,并定义一个符合Component
接口的接口。因此,它包含对基本行为的引用,并且还实现了相同的接口,因此可以被视为Component
本身。期望Component
的客户端代码将与 Decorator 打交道,而不会注意到区别。 - ConcreteDecorator:这实际上为组件添加了职责。从 Decorator 继承的类,并可能以新的
public
方法的形式添加一些额外的特定功能。
到目前为止,我们有了两个东西:Component,即基本行为,在我们的例子中是 IMessageWriter
和用于读取的 IMessageReader
,以及 ConcreteComponent,即我们的写入和读取行为的实现,分别是 MessageWriter
和 MessageReader
。
现在,这是我们对 SecureMessageWriter
和 EncryptedMessageWriter
的实现。
您的 Decorator 到底在哪里???
我刚才说过这个模式有四个参与者,我已经向您展示了Component(IMessageReader
、IMessageWriter
)、ConcreteComponent(MessageReader
、MessageWriter
)和ConcreteDecorators(SecureMessageWriter
、EncryptedMessageWriter
)。但Decorator在哪里?在我们的例子中,我们只是在增加现有行为,并没有引入新行为。我们也没有改变层次结构。在这种情况下,我们可以忽略实现Decorator,遵循主要层次结构。我没有展示Reader 类,因为它们只是反向过程。
我们取得了什么成就
现在,当我需要一个简单的消息写入并带有用户验证时,我会这样做。
IMessageWriter msgWriter = new SecureMessageWriter(new MessageWriter());
我正在用 SecureMessageWriter
装饰 MessageWriter
,它现在将在将消息写入磁盘之前验证用户。如果需要带验证的加密消息写入,我会这样做
IMessageWriter msgWriter =
new SecureMessageWriter(new EncryptionMessageWriter(new MessageWriter()));
- 装饰器避免了我们创建一个复杂的基类,其中包含大量代码,而这些代码在大多数情况下并不需要。
- 它们允许我们创建不同组合和序列的不同行为,这否则并非易事。
- 我们没有为每种不同的行为和组合实现不同的子类,而是单独实现了每种所需行为,可以根据需要添加。
回到现实世界
在本节中,我们将看到装饰器模式的一些实际应用示例。
同步包装器
使用过 .NET 中经典的集合类(如 Queue
、ArrayList
等)的人可能仍然记得这些类公开的 synchronized(collection) 函数。它以集合实例本身作为参数,并返回一个同步的集合。集合类本身不是同步的,但此方法实际返回的是派生自集合类本身的装饰器。例如,在下面的代码中,当我们调用 Syncrhonized(al)
时,我们将收到一个 SyncArrayList
实例,这是一个派生自 ArrayList
的类。
ArrayList al = new ArrayList();
al = ArrayList.Synchronized(al);
//Now al can be treated just as ArrayList
//but actually it's an instance of SyncArrayList
//which is derived from ArrayList
SyncArrayList
将在 _list
属性中存储传入的 ArrayList
,并将覆盖 ArrayList
的不同实例方法。
public override int Add(object value)
{
lock (this._root)
{
return this._list.Add(value);
}
}
注意事项
以这种方式创建自己的同步包装器时,请注意所谓的自死锁现象,即已经拥有锁的线程将进入另一个方法(或在递归的情况下进入同一方法),在那里它将尝试再次获取同一对象的锁。在 Windows 中,无论您是使用 .NET 的 Monitors 实现,还是内核级别的命名或匿名互斥体,所有这些都是可重入的(即递归的)。因此,您不会遇到此问题,但在 Linux 等其他环境中编程时,其中默认的互斥体类型是 Fast mutex(非递归互斥体),您的代码可能会成为自死锁的受害者。在使用信号量(即使在 Windows 上)时,它也没有所有权的概念,如果您不小心,它也会给您带来这个问题。当然,对于二元信号量,即 n=1,只有在第二次尝试时您才会陷入自死锁。
同样,您可以为集合类实现一个只读包装器。与我们到目前为止所见的不同,它们不是在类中添加功能,而是移除一些功能。例如,在重写的 Add()
方法中,可能会抛出 OperationNotSupportedException
。.NET 提供了 ReadonlyCollection<T>
,它是通用列表的包装器。Java 提供了不可修改的包装器,例如 unmodifiableCollection
、unmodifiableSet
等。
在 Java 中,您还可以为许多集合类型获取同步包装器,例如
List<t> list =
Collections.synchronizedList(new ArrayList<t>());
Java 和 .NET 中的 IO
Java 的 java.io
包和 .NET 的 Stream
类都采用了这种模式。我将不详细介绍它们的底层实现。在 .NET 中,Stream
是一个 abstract
类,它提供了基本行为(我们的 Component),而像 FileStream
、MemoryStream
这样的类是 ConcreteCompents
,而像 BufferedStream
、CryptoStream
等类是 Concrete Decorators,它们像 Filestream
和 MemoryStream
一样派生自抽象的 Stream
类。您可以清楚地注意到,它们的实现也缺少 Decorator。
同样,在 Java 中,BufferedReader
和 FilterReader
是 Reader
的装饰器,而 BufferedReader
进一步被 LineNumberReader
装饰,FilterReader
被 PushbackReader
装饰。
有什么问题
这种模式允许我们在实现中提供可扩展性点。正如您可能注意到的,在实现我的装饰器时,我甚至从未触及过我的组件类。因此,即使一个人不拥有一个类,也可以对其进行装饰以动态甚至递归地添加新行为。这种附加行为可能在基本行为之前或之后添加,或者这种行为可能阻止它。装饰器可以提供新的方法,甚至新的属性,正如您在类图中看到的。但是,装饰器存在一些问题。
- 将要使用装饰器的程序员必须理解其意图;否则,他可能会最终使用一些无意义的组合或序列。例如,在我们的场景中,如果程序员这样定义顺序:消息将被压缩,然后加密,然后验证,这将有点无意义。在实际场景中,对于某些组合或序列,结果可能会是灾难性的。
- 测试装饰器类需要提供一个被装饰类的模拟。由于我们还没有介绍测试,所以我不会讨论它
- 此外,此模式对基本行为设计者施加了一些责任。虽然在装饰器中添加新属性或
public
方法是完全合法的,但这样它将失去通过基本引用处理的灵活性,并且您将需要使用具体实例
现在一些澄清
我感谢所有在我上一篇关于策略模式的文章中提供反馈的人。这指导了我写这篇。 很多人以这样或那样的方式指责我过于简化。嗯,我断言我并没有过度简化任何东西。 这些词可能有点误导。我想说的是,他们的反馈是正确的,因为简化是我的目标。但过度简化有点苛刻。那些人并非完全错误,因为我避免将设计模式与编程习语混淆。我在这系列文章中展示的是基本原理以及在何处应用模式的指南。基本原理很简单;是问题让你头疼。识别适合设计模式的问题部分可能很棘手,需要经验。有时,直到经过多个重构周期,您才会意识到某段代码是应用某些设计模式的好候选。这为我们提供了分析问题并识别不同设计模式的另一个理由,以避免将来进行大量重构,因为它们已经是大量重构的结果。然而,该反馈对我帮助很大,将来我也会尝试涵盖特定于语言的实现(前提是我有足够的时间)。下一篇文章将介绍创建型模式,涵盖简单工厂、工厂方法、抽象工厂和生成器。再次,您的反馈非常受欢迎,请指出我未能清晰解释的领域,或改进的要点,谢谢。
关于源代码
当我开始写这篇文章时,我计划为 Java 和 .Net 发布代码。但由于最近日程很紧,我只能提供 .Net 的代码。这是一个 Visual Studio 2008 解决方案文件。我忘记了在解决方案中包含自述文件,我现在将其附加。