观察者同步

通过让所有屏幕都观察共享的领域数据区域来同步多个屏幕。

2004 年 9 月 8 日

这是我在 2000 年代中期进行的 企业应用程序架构进一步开发 写作的一部分。不幸的是,太多其他事情吸引了我的注意力,所以我没有时间进一步研究它们,而且在可预见的未来我也没有看到太多时间。因此,这些材料非常草稿形式,我不会进行任何更正或更新,直到我能找到时间再次处理它。

在某些应用程序中,您有多个屏幕可用,这些屏幕显示共同数据区域的演示文稿。如果通过其中一个屏幕对数据进行更改,则希望所有其他屏幕都正确更新。但是,您不希望每个屏幕都知道其他屏幕,因为这会增加屏幕的复杂性,并使添加新屏幕变得更加困难。

观察者同步 使用单个面向域的数据区域,并让每个屏幕都成为该数据的观察者。任何一个屏幕的更改都会传播到面向域的数据,然后传播到其他屏幕。这种方法是模型视图控制器方法的重要组成部分。

工作原理

这种方法的本质是,每个屏幕及其关联的屏幕状态都充当 观察者,观察会话数据的公共区域。对会话数据的任何更改都会导致事件,屏幕会监听这些事件并通过从会话数据中重新加载来响应。一旦您设置了这种机制,您就可以通过对每个屏幕进行编码以更新会话数据来确保同步。即使进行更改的屏幕也不需要显式刷新自身,因为观察者机制将以与另一个屏幕进行更改相同的方式触发刷新。

也许这种设计中最大的问题是决定使用什么粒度的事件以及如何设置传播和观察者关系。在最细粒度的级别,每个域数据位都可以有单独的事件来指示确切发生了什么变化。每个屏幕仅注册可能使该屏幕数据失效的事件。最粗粒度的替代方法是使用 事件聚合器 将所有事件汇集到一个通道中。这样,如果任何域数据发生变化,每个屏幕都会重新加载,无论它是否会影响屏幕。

像往常一样,权衡的是复杂性和性能。粗粒度方法设置起来简单得多,并且不太可能产生错误。但是,粗粒度方法会导致屏幕进行大量不必要的刷新,这可能会影响性能。像往常一样,我的建议是从粗粒度开始,只有在测量到实际性能问题后才引入适当的细粒度机制。

事件往往难以调试,因为您无法通过查看代码来查看调用链。因此,保持事件传播机制尽可能简单非常重要,这就是我更喜欢粗粒度机制的原因。一个好的经验法则是将您的对象视为分层,并且只允许层之间存在观察者关系。因此,一个域对象不应该观察另一个域对象,只有表示对象应该观察域对象。

另一个需要注意的是事件链,其中一个事件会导致另一个事件触发。这种事件链很快就会变得非常难以跟踪,因为您无法通过查看代码来理解行为。因此,我倾向于不鼓励层内事件,而是更喜欢单行事件,或者通过 事件聚合器 进行。

当观察者注册事件时,您会从主体获得对观察者的引用。如果在您删除屏幕时观察者没有从主体中删除自身,那么您将有一个僵尸引用和内存泄漏。如果您在每个会话中销毁和创建域数据,并且您的会话很短,这可能不会导致问题。但是,长期存在的域对象会导致严重的泄漏。

何时使用它

观察者同步 当您有多个共享公共数据的活动窗口时,它是一个特别重要的模式。在这种情况下,另一种方法是让窗口相互发出信号以刷新,这会变得相当复杂,因为每个窗口都需要了解其他窗口以及它们何时可能需要刷新。添加新窗口意味着更新信息。使用这种方法,添加新窗口非常简单,并且每个窗口都可以设置为维护其与公共域数据的自身关系。

观察者同步 的主要缺点是事件触发的隐式行为,这很难从代码中可视化。因此,事件传播中的错误可能非常难以找到和修复。

观察者同步 也可以与更简单的导航样式一起使用,尽管在这些情况下,流同步 是一个合理的替代方案。使用事件进行更新引入的复杂性可能超过 观察者同步 的价值。

进一步阅读

这种模式显然与 观察者 非常相似,并且是模型视图控制器的核心部分。我认为这与观察者之间的主要区别在于,这是一种特定的观察者使用方式 - 尽管是最常见的。我认为这是构成模型视图控制器的几种模式的一部分。

致谢

Patrik Nordwall 指出了观察者和内存泄漏的问题。

示例:专辑和表演者 (C#)

图 1:显示专辑和表演者的屏幕。

考虑一个类似于 图 1 的应用程序。我们有活动屏幕来编辑表演者的姓名以及他们出现的专辑。如果我编辑专辑“Kind of Blue”的标题,我希望我的编辑不仅出现在专辑屏幕的文本框和表单标题中,而且还出现在 Miles Davis 和 John Coltrane 的列表条目中。

图 2:专辑和表演者的域对象。

在这种情况下,我在几个简单的域对象中保存了域数据,用于表演者和专辑 图 2。当我更改这些类中的一个中的数据时,我需要传播一个事件。

class Album : DomainObject

  public string Title {
    get { return _title; }
    set {
      _title = value;
      SignalChanged();
    }
  }
  string _title;

class DomainObject...

  public void SignalChanged() {
    if (Changed != null) Changed (this, null);      
  }
  public event DomainChangeHandler Changed;
public delegate void DomainChangeHandler (DomainObject source, EventArgs e);

这里,我只是在 层超类型 中定义了一个简单的更改事件。此事件不会提供有关更改的任何信息,只是发生了一些更改 - 适合来自客户端的粗粒度同步。

在表单中,我通过传入一个专辑来创建一个新的专辑表单。构造函数执行其通常的 GUI 操作,设置专辑引用,连接事件监听器,最后从专辑加载数据。

class FrmAlbum...

  public FrmAlbum(Album album)    {
    InitializeComponent();
    this._album = album;
    observeDomain();
    load();
  }
  private Album _album;
  private void observeDomain() {
    _album.Changed += new DomainChangeHandler(Subject_Changed);
    foreach (Performer p in _album.Performers) 
      p.Changed +=new DomainChangeHandler(Subject_Changed);
  }
  private void Subject_Changed(DomainObject source, EventArgs e)  {
    load();
  }

事件监听器连接起来,以便任何依赖域对象的更改都会导致表单重新加载其数据。

class FrmAlbum...

  private void load() {
    txtTitle.Text = _album.Title;
    this.Text = _album.Title;
    lstPerformers.DataSource = performerNames();
  }
  private string[] performerNames() {
    ArrayList result = new ArrayList();
    foreach (Performer p in _album.Performers) 
      result.Add(p.Name);
    return (string[]) result.ToArray(typeof (string));
  }

如果我更改专辑中的标题,我会直接在底层域对象上进行更改。

class FrmAlbum...

  private void txtTitle_TextChanged(object sender, EventArgs e) {
    this._album.Title = txtTitle.Text;
  }

只要每个表单都可以参与与共享域对象的绑定,您就可以使用数据绑定来实现大部分功能。另一种变体是使用 事件聚合器 - 这将允许每个表单仅向聚合器注册,而不必向每个域对象注册。在没有性能问题的情况下,我会这样做 - 我在这里没有这样做,因为我更喜欢保持示例尽可能相互独立。

如果您使用的是 监督控制器被动视图,则控制器将充当域事件的观察者。如果您使用的是 演示模型,则 演示模型 将充当观察者。