设计模式:Observer(观察器)
 

2009-01-08 来源:microsoft

 
本页内容
上下文
问题
影响因素
解决方案
示例
结果上下文
相关模式

上下文

在面向对象的编程中,对象同时包含数据和行为,这两者一起表示业务域的特定方面。使用对象生成应用程序的优点之一是可以将所有数据操作封装在对象内。这样,就使对象成为独立的单位,并增加了在其他应用程序中重用对象的可能性。但是,对象无法在孤立状态下工作。在除最不重要的应用程序之外的所有应用程序中,对象必须协作才能完成更复杂的任务。当对象协作时,对象可能必须在对象状态发生更改时互相通知对方。例如,Model-View-Controller 模式规定将业务数据(模型)与显示逻辑(视图)分离。当模型发生改变时,系统必须通知视图以便它可以刷新可视显示,并准确地反映模型的状态。换句话说,视图依赖于模型,以便获知模型内部状态的更改。

问题

一个对象如何将状态更改通知其他对象,而又不依赖于这些对象的类?

影响因素

要解决此问题,就必须协调下列影响因素和注意事项:

  • 将状态更改通知依赖对象的最容易的方法是直接调用它们。但是,对象之间的直接协作需要让它们的类建立相互的依赖性。例如,如果模型对象调用视图对象以便将更改通知它,则模型类现在也会依赖于视图类。两个对象之间的这种直接耦合(也称为紧耦合)降低了类的可重用性。例如,每当要重用模型类时,还必须重用视图类,因为模型会调用它。如果有多个视图,问题就更复杂了。
  • 在事件驱动的框架中,经常出现需要耦合类的情况。框架必须能够将事件通知应用程序,但是框架不能依赖于特定应用程序类。
  • 同样,如果对视图类进行了更改,则模型很可能受到影响。包含许多紧耦合类的应用程序往往是脆弱的和难于维护的,因为一个类中的更改可能影响所有的紧耦合类。
  • 如果直接调用依赖对象,则在每次添加新依赖项时,都必须对源对象内的代码进行修改。
  • 在某些情况下,依赖性对象的数目在设计时可能是未知的。例如,如果您允许用户打开某个特定模型的多个窗口(视图),则必须在模型状态改变时更新多个视图。
  • 直接的函数调用仍然是在两个对象之间传递信息时效率最高的方式(仅次于让两个对象的功能同时包含在同一个对象中)。因此,使用其他机制将对象功能分隔开来很可能对性能有负面影响。取决于应用程序的性能要求,您可能必须对此进行权衡。

解决方案

使用 Observer 模式在独立的对象(主体)中维护一个对主体感兴趣的依赖项(观察器)列表。让所有观察器各自实现公共的 Observer 接口,以取消主体和依赖性对象之间的直接依赖关系(见图 1)。

同样,如果对视图类进行了更改,则模型很可能受到影响。包含许多紧耦合类的应用程序往往是脆弱的和难于维护的,因为一个类中的更改可能影响所有的紧耦合类。

图 1:基本的 Observer 结构

在与依赖性对象相关的客户端中发生状态更改时,ConcreteSubject 会调用 Notify() 方法。Subject 超类用于维护所有感兴趣观察器组成的列表,以便让 Notify() 方法能够遍历所有观察器列表,并调用每个已注册的观察器上的 Update() 方法。观察器通过调用 Subject 上的 subscribe() unsubscribe() 方法,来注册到更新和取消注册(见图 2)。ConcreteObserver 的一个或多个实例可能也会访问 ConcreteSubject 以获取详细信息,因此通常依赖于 ConcreteSubject 类。但是,如图 1 所示,ConcreteSubject 类既不直接也不间接依赖于 ConcreteObserver 类。

图 2:基本的 Observer 交互

使用在主体和观察器之间进行通信这种普通方法,可以动态而不是静态地构建协作。由于将通知逻辑与同步逻辑分离,因此可以添加新观察器而不必修改通知逻辑,而且也可以更改通知逻辑而不会影响观察器中的同步逻辑。代码现在的分离程度更高,因此更易于维护和重用。

将更改通知对象而不致于依赖这些对象的类是一项很常见的要求,因此,某些平台提供了语言支持来执行此功能。例如,Microsoft® .NET Framework 定义了委托和事件这两个概念以实现 Observer 角色。因此,很少需要在 .NET 中显式地实现 Observer 模式,而应该使用委托和事件。大多数 .NET 开发人员会将 Observer 模式视为实现事件的复杂方式。

图 1 所示的解决方案显示从 Subject 类继承的 ConcreteSubject 类。在 Subject 类中,实现了添加或删除观察器以及遍历观察器列表的方法。ConcreteSubject 必须做的全部工作是继承 Subject,并在发生状态更改时调用 Notify()。在仅支持单一继承的语言(如 Java 或 C#)中,一个类如果继承了 Subject 就不能再继承任何其他类。这会是一个问题,因为在许多情况下 ConcreteSubject 是一个可能继承域对象基类的域对象。因此,将 Subject 类替换为 Subject 接口并为实现提供一个帮助器类是一个更好的主意(见图 3)。这样,您不担忧与 Subject 类的单一超类关系,而可以在另一个继承层次结构中使用 ConcreteSubject。一些语言(例如 Smalltalk)甚至将 Subject 接口实现为 Object 类的一部分,让每个类隐式继承 Subject 接口。

图 3:使用帮助器类避免继承 Subject 类

可惜的是,现在您必须将代码添加到从 Subject 接口继承的每个类中,才能实现在该接口中定义的方法。此任务可能是非常重复单调的。此外,因为域对象与 ConcreteSubject 重合,所以,它无法区分与不同主体关联的各种状态更改类型。这仅允许观察器订阅 ConcreteSubject 的所有状态更改,即使您可能希望选择某些更改(例如,如果源对象包含一个列表,那么,您可能希望得到更新通知,而不是插入通知)。您可能让观察器筛掉不相关的通知,但是,这样做会降低解决方案的效率,因为 ConcreteSubject 调用所有观察器只是为了确定它们是否真的不感兴趣。

通过将主体与源类完全分离,可以解决这些问题(见图 4)。这样做的结果是:ConcreteSubject 仅实现 Subject 接口;它没有任何其他职责。这样,DomainObject 就可以与多个 ConcreteSubject 关联,以便您可以区分单个域类的不同事件类型。

图 4: DomainObject 与 Subject 分离

.NET Framework 中的事件和委托功能以语言构造的形式实现了此方法,以便您甚至不必再实现自己的 ConcreteSubject 类。简单地说,事件替代了 ConcreteSubject 类,委托实现了 Observer 接口的角色。

传播状态信息

到此为止,此解决方案描述了客户端对象如何在发生状态更改时通知观察器,但还没有讨论观察器如何确定客户端对象所处的状态。有两种机制可以将此信息传递给观察器。

  • 推模型。在推模型中,由客户端发送有关主体状态更改的所有相关信息,再由主体将信息传递给每个观察器。如果信息是以中性格式(例如 XML)传递的,此模型就会使依赖性观察器不必直接访问客户端即可获取详细信息。另一方面,主体必须作出一些有关哪些信息与观察器相关的假定。如果添加了新观察器,则主体可能必须发布该观察器所需的其他信息。这将使主体和客户端再次依赖于观察器,而这是您在前面试图解决的问题。因此,如果使用推模型,则在确定要传递给观察器的信息量时,应该宁可错误地包括信息。在许多情况下,您将在对观察器的调用中包括对主体的引用。观察器可以使用该引用获得状态信息。
  • 拉模型。在拉模型中,客户端将状态更改通知主体。观察器收到通知之后,它们使用 getState() 方法访问主体或客户端,以获取其他数据(见图 5)。此模型不要求主体将任何信息与 update() 方法一起进行传递,但是它可能要求观察器只是为了确定状态更改是否不相关而调用 getState()。因此,此模型的效率可能低一点。当观察器和主体在不同的线程中运行时(例如,如果使用 RMI 通知观察器),可能会出现另一个问题。在此情况下,在观察器通过回调获得状态信息之前,主体的内部状态可能已经再次更改。这可能导致观察器跳过操作。

图 5:使用接收模型的状态传播

何时触发更新

在实现 Observer 模式时,您可以选择两种方式之一来管理更新的触发。第一种方式是:在每次影响内部状态更改的 Subject 调用之后,在客户端中插入 Notify() 调用。这样,客户端就可以完全控制通知的频率;但同时也使客户端具有额外的职责,在开发人员忘记调用 Notify() 时这会引起错误。另一种方式是:在 Subject 的每个更改状态操作内封装 Notify() 调用。这样,状态更改始终会导致调用 Notify(),而客户端无需执行其他操作。不利方面是,几个嵌套的操作可能导致多个通知。图 6 显示这种情况的一个示例,其中操作 A 调用子操作 B,观察器可能收到两个对其 Update 方法的调用。

图 6:额外的通知

为单个但嵌套的操作而调用多个更新,会导致效率有一定程度的下降,而且产生更严重的副作用:如果在操作 B 结束时调用嵌套的 Notify 方法(见图 6),主体可能处于无效状态,因为仅仅处理了操作 A 的一部分。在这种情况下,应该避免嵌套的通知。例如,可以将操作 B 提取到一个没有通知逻辑的方法中,并且可以依赖操作 A 内对 Notify() 的调用。Template Method [Gamma95] 是一种确保仅通知一次观察器的有用构造。

影响状态更改的观察器

在一些情况下,观察器在处理 update() 调用的同时可能更改主体的状态。如果主体在每次状态更改之后都自动调用 Notify(),则可能引起问题。图 7 说明引起问题的原因。

图 7:从更新内修改对象状态导致无限循环

在此示例中,作为对状态更改通知的响应,观察器执行了操作 A。如果操作 A 更改 DomainObject 的状态,此后它将触发对 Notify() 的另一个调用,这又会再次调用观察器的 Update 方法。这将导致无限循环。在此简单示例中无限循环是很容易识别的,但是如果关系是更复杂的,则可能很难确定依赖链。降低无限循环可能性的一种方法是使通知与特定兴趣有关。例如,在 C# 中,将下列接口用于主体,其中 Interest 可能是所有类型的兴趣的枚举:

  interface Subject  
  { 
  public void addObserver(Observer o, Interest a); 
  public void notify(Interest a); 
  ... 
  } 
  interface Observer 
  { 
     public void update(Subject s, Interest a); 
  } 

仅当与观察器的特定兴趣有关的事件发生时才允许通知观察器,这样可以减小依赖链和帮助避免无限循环。这与在 .NET 中定义多个、所定义范围更狭窄的事件类型是等效的。避免循环的另一种方法是引入锁定机制,使主体在仍然处于原始 Notify() 循环中时无法发布新通知。

示例

请参阅在 .NET 中实现 Observer

结果上下文

因为 Observer 支持松耦合和减少依赖性,是否应该让互相依赖的每对对象建立松耦合?当然不是。对于大多数模式,一种解决方案很少解决所有问题。在利用 Observer 模式时,您需要权衡下列因素。

优点

  • 支持松耦合和减少依赖性。客户端不再依赖于观察器,因为通过使用主体和 Observer 接口对客户端进行了隔离。许多框架具有此优点,在这些框架中的应用程序组件可以注册为当(低级)框架事件发生时得到通知。结果,框架将调用应用程序组件,但不会依赖于它。
  • 观察器数目可变。观察器可以在运行时附加和分离,因为主体对于观察器数目没有任何假定。此功能在这样的情况下是很有用的:观察器数在设计时是未知的。例如,如果用户在应用程序中打开的每个窗口都需要一个观察器。

缺点

  • 性能降低。在许多实现中,观察器的 update() 方法可能与主体在同一线程中执行。如果观察器列表很长,则执行 Notify() 方法可能需要很长时间。抽取对象依赖性并不意味着添加观察器对应用程序没有任何影响。
  • 内存泄漏。 Observer 中使用的回调机制(当对象注册为以后调用时)会产生一个常见的错误,从而导致内存泄漏,甚至是在托管的 C# 代码中。假定观察器超出作用范围,但忘记取消对主体的订阅,那么主体仍然保留对观察器的引用。此引用防止垃圾收集在主体对象也被破坏之前重新分配与观察器关联的内存。如果观察器的生存期比主体的生存期短得多(通常是这种情况),则会导致严重的内存泄漏。
  • 隐藏的依赖项。观察器的使用将显式依赖性(通过方法调用)转变为隐式依赖性(通过观察器)。如果在整个应用程序中广泛地使用观察器,则开发人员几乎不可能通过查看源代码来了解所发生的事情。这样,就使得了解代码更改的含意非常困难。此问题随传播级别急剧增大(例如,充当 Subject 的观察器)。因此,应该仅在少数定义良好的交互(如 Model-View-Controller 模式中模型和视图之间的交互)中使用观察器。最好不要在域对象之间使用观察器。
  • 测试 / 调试困难。尽管松耦合是一项重大的体系结构功能,但是它可以使开发更困难。将两个对象去耦的情况越多,在查看源代码或类的关系图时了解它们之间的依赖性就越难因此,仅当可以安全地忽略两个对象之间的关联时才应该将它们松耦合(例如,如果观察器没有副作用)。

相关模式

有关详细信息,请参阅在 .NET 中实现 Observer


火龙果软件/UML软件工程组织致力于提高您的软件工程实践能力,我们不断地吸取业界的宝贵经验,向您提供经过数百家企业验证的有效的工程技术实践经验,同时关注最新的理论进展,帮助您“领跑您所在行业的软件世界”。
资源网站: UML软件工程组织