组织演示逻辑
有几种方法可以拆分演示逻辑。
2006 年 7 月 11 日
这是我在 2000 年代中期进行的 企业应用程序架构进一步开发 写作的一部分。不幸的是,太多其他事情吸引了我的注意力,因此我没有时间进一步研究它们,而且在可预见的未来我也没有看到太多时间。因此,这些材料非常草稿形式,我不会进行任何更正或更新,直到我有时间再次处理它。
在设计任何演示层时,您可以做到的最有用的事情之一是强制执行 分离演示。完成此操作后,下一步是考虑演示逻辑本身将如何组织。对于一个简单的窗口,一个类可能就足够了。但是,更复杂的逻辑会导致更广泛的分解。
最常见的方法是为应用程序中的每个窗口设计一个类。此类通常继承自 GUI 库的窗口类,并包含处理该窗口所需的所有代码。如果窗口包含一个复杂的面板,您可能需要为该面板创建一个单独的类,从而形成一个复合结构。我不会深入讨论这种复合结构,因为这很简单,相反,我将更多地关注在单个窗口内组织基本行为的方法。
专辑列表运行示例
在本文的大部分讨论和示例中,我将使用一个示例屏幕来讨论出现的问题( 图 1)。该窗口显示有关音乐录音的信息。对于每张专辑,它都会显示艺术家、标题、是否为古典录音,以及如果是古典录音:作曲家。我选择这个例子是为了包含一些演示逻辑元素。
- 列表选择中的选择决定了在字段中显示哪张专辑的数据。
- 窗口标题来自当前显示的专辑的标题。
- 只有在选中古典复选框时,作曲家字段才启用。
- 只有在编辑数据时,应用和取消按钮才启用。
图 1:一个简单的专辑信息窗口
将演示逻辑与视图分离
虽然将所有演示逻辑放在一个 自主视图 中既常见又可行,但它确实有一些缺点。如今,人们谈论 自主视图 最常见的缺点是与测试有关。通过 GUI 窗口测试演示通常很尴尬,在某些情况下甚至不可能。您必须构建某种 UI 驱动程序来驱动 GUI。有些人使用模拟原始鼠标和键盘事件的 GUI 工具,但这些工具通常会创建脆弱的测试,每当对演示进行微小更改时,这些测试都会给出误报。更详细的工具更直接地处理 UI 控件,这些工具不太脆弱,但仍然很麻烦。它们还依赖于 GUI 框架是否通过 API 提供足够的支持来进行直接控制访问,并非所有框架都支持。
因此,许多基于程序员的测试倡导者主张使用 谦虚的对话框(也称为 超薄 GUI)。这里的主要思想是通过将所有逻辑移到其他演示层类中,使包含 UI 控件的类尽可能小且愚蠢。此 GUI 控件类通常称为 视图,原因我将在稍后解释。然后,您可以在不需要使用任何 GUI 控件的情况下,对智能类运行测试,如果需要,可以对谦虚的视图进行存根。由于视图非常愚蠢,因此几乎不会出错,您可以通过处理智能类来找到大多数错误。我喜欢用的一种术语来描述这种测试风格是皮下测试,因为测试是在应用程序的皮肤下进行的。
虽然皮下测试是如今拆分演示类的主要原因,但还有其他几个原因值得考虑这种拆分。智能类可以独立于视图的几个方面,例如控件的选择、控件的布局,甚至可能是精确的 UI 框架本身。这使您可以使用相同的逻辑行为支持多个不同的视图。虽然这很有用,但通过仅替换谦虚的视图,您只能对应用程序的多个“皮肤”进行有限的变体。
在某些方面,分离演示逻辑可以使演示编程更容易。它允许您在编写行为时忽略视图布局的细节,有效地为您提供了一个更舒适的视图控件 API。然而,与之相对的是,分离演示逻辑确实会导致额外的机制来支持分离(其性质取决于您使用的模式)。因此,在两个方向上都有合理的论据,说分离要么简化了演示,要么增加了演示的复杂性。
这样做拆分有一个历史先例,它是模型视图控制器 (MVC) 的一部分。正如我在 [P of EAA] 中讨论的那样,MVC 方法进行了两次分离。最重要的分离是 分离演示,即分离模型和视图/控制器。视图和控制器的另一种分离在富客户端 GUI 框架中并不流行,尽管它确实在基于 Web 的用户界面中复兴了。在 MVC 中,视图是对模型中信息的简单显示,而控制器处理各种用户输入事件。这在大多数 GUI 框架中并不适用,因为它们的设计使得 UI 控件既显示又接收用户输入事件。
要创建谦虚的视图,设计必须将所有行为从视图中移出,包括处理用户事件和任何域信息的显示逻辑。有两种主要方法可以做到这一点。第一种是模型-视图-演示者风格,其中行为被移到演示者中,您可以将其视为一种控制器形式。演示者处理用户事件,并在更新视图方面也扮演着一定的角色。 监督控制器 和 被动视图 是这种方法的两种风格。 监督控制器 将简单的视图逻辑放在视图中,而 被动视图 将所有视图逻辑放在控制器中。另一种风格是 演示模型,它创建了一种模型形式,该模型捕获所有数据和行为,以便视图只需要简单的同步。
在这两种风格中,视图都是用户事件的初始处理程序,但随后立即将控制权交给控制器。
所有这三种模式,通过引入一个额外的类,都产生了可能更复杂的设计。将一个做太多事情的类拆分成单独的类以满足每个职责是一个好习惯,但问题是 自主视图 是否过于复杂。当然,其他模式提供了其他测试选项以及支持多个视图的能力。如果您不需要多个视图,并且对通过视图进行测试感到满意,那么 自主视图 可能很好,特别是如果窗口不太复杂。
在 演示模型、监督控制器 和 被动视图 之间进行选择更多是任意的,它实际上取决于在您的 GUI 环境中以及根据您自己的个人喜好执行模式的难易程度。
每当我们使用 MVC 中的术语时,不可避免地会问什么是模型。在经典的 Smalltalk MVC 中,模型是一个 领域模型。通常,如今在 MVC 讨论中,模型指的是域层的接口;它可以是经典的领域模型,也可以是服务层、事务脚本、表模块或域的任何其他表示。实际上,如果没有单独的域层,模型很可能是数据库的接口。
屏幕、层和数据
大多数企业应用程序都涉及编辑数据。这些数据通常在应用程序的多个层之间复制,并且可能在使用同一系统的多个用户之间复制。企业应用程序的大部分行为取决于如何协调这些数据的更改以及如何在层之间同步数据。对于如何考虑这一点,没有普遍接受的术语,为了本书的目的,我将强加以下内容。
数据层
首先要考虑的是不同层中数据的不同副本。从物理角度考虑,通常存在内存中的数据和数据库或文件中的数据之间的差异。您可以将其视为瞬态数据和持久数据之间的差异。但是,我发现通常不仅仅是这些。即使是内存中的数据也经常出现在两个地方。您通常会发现屏幕上的数据和支持屏幕的某种内存存储中的数据之间存在差异。这可能是从数据库中检索到的记录集(但尚未提交回数据库),或者可能是领域模型。
考虑在文字处理器中处理文档。该文档位于磁盘上,您已在文字处理器中打开该文档并编辑了文档的文本。这会导致内存中的文本与磁盘上的文本不同。现在打开一个对话框来更改某些文本的格式。通常,您可以在对话框中更改格式,但它不会更改底层文本,直到您点击应用按钮。对话框中的格式数据是内存中的数据,但它与主内存中的文档中的数据不同。
我将在本书中使用的术语是屏幕状态、会话状态和记录状态。屏幕状态 是在用户界面上显示的数据。会话状态 是用户当前正在处理的数据。会话状态被用户视为某种程度上的临时状态,他们通常有能力保存或放弃他们的工作。记录状态 指的是更持久的数据,预计在会话之间存在的数据。
会话状态主要在内存中进行操作,但通常存储在磁盘上。现代文字处理器通常会保存恢复文件,以避免因电源丢失或系统崩溃而导致工作丢失。企业应用程序可以将会话状态保存在本地检查点文件中,或者服务器可以在请求之间将状态保存在磁盘上。
在企业应用程序中,会话状态和记录状态之间的特殊区别在于,记录状态在系统的多个用户之间共享,而会话状态是私有状态,仅对正在处理它的用户可见。因此,用户不仅决定将更改保存到更持久的形式,而且还决定与同事共享它。会话状态通常与单个业务事务相关联,尽管它通常跨越多个系统事务,这种情况通常需要 离线并发。
并非所有应用程序都具有会话状态。有些应用程序只有屏幕状态和记录状态 - 任何对数据的更改在保存时会直接写入记录状态。在这些情况下,您可能根本没有会话状态,或者任何更改都会立即写入记录状态,因此会话状态始终与记录状态保持一致。没有会话状态可以极大地简化应用程序,因为您无需担心管理会话状态。在许多应用程序中,用户甚至更喜欢这种方式,因为用户永远不必担心丢失工作。但是,省略会话状态并非全是好事。用户将失去在工作状态下尝试场景并根据需要丢弃场景的能力。它还会阻止人们在多用户应用程序中独立工作。
您还可以获得额外的状态层。例如,会话状态同时存储在客户端层和服务器层。这些状态可以独立更改,尽管通常存在关于它们如何同步的相当严格的规则,这使得管理它们变得更加简单。
额外层的示例是开发人员如何在团队中工作。在这种情况下,记录状态是共享源代码存储库的状态。开发人员本地机器上的工作副本是一种会话状态,存储在磁盘上。在这种情况下,它本身是短暂的。然后在 IDE 中还有其他表示形式。在保存并不断更新内存中语法树的现代 IDE 中,您拥有该语法树,以及屏幕上显示的文本。在这种情况下,存在超过三层,但仍然有用的是考虑开发人员在屏幕上看到的内容、他的私有会话数据以及共享的持久数据。为了有效地推理,我会为每组数据命名,并将它们视为独立的层。特定的应用程序将始终拥有自己的集合,在我的讨论中,我将重点关注屏幕、会话和记录状态这三种常见状态。
大多数情况下,用户一次在一个会话中工作。有时用户会同时在多个会话中操作。这通常会导致混淆,因为一个会话中的更改不会显示在另一个会话中,直到两者都与记录状态同步。您可以通过同步两个会话来解决此问题,但这通常很混乱。
这些多个状态通常对应于企业应用程序的各个层。在一个理想的应用程序中,该应用程序使用表示层、域层和数据源层,您将只有域逻辑在会话状态上运行。在实践中,这种区别变得模糊,通常是由于不好的原因,但有时是由于好的原因。在一个应用程序中,域层与表示层位于不同的进程中,您可能希望在表示层进程中运行一些域逻辑,以使应用程序具有适当的响应能力。这种逻辑可能涉及从主域进程复制一些会话状态,或者您可能需要针对表示层控件中的数据运行域逻辑。同样,如果您需要对大量数据进行操作,您可能需要通过类似存储过程的方式将域逻辑嵌入到数据库中。这种逻辑在记录状态上运行。但是,大多数情况下,您希望域逻辑在会话状态上运行。
层之间同步
在这些不同上下文之间同步数据是构建应用程序的重要部分。当您在用户界面上工作时,您可以将屏幕状态同步到两种不同的深度:会话状态或记录状态。如果您同步到会话状态,您将需要一些控件,允许用户将会话状态保存到记录状态。
同步可以以不同的频率发生,我发现以下三种很有用。**按键同步**意味着您在每次按键或鼠标点击时同步。**字段同步**意味着您在完成字段编辑时同步。**屏幕同步**意味着您在完成一屏信息后,同步一些 UI 中的特殊按钮(通常标记为“应用”、“确定”、“取消”或“提交”)。
一旦您需要同步,下一个问题是您同步多少。当您查看将屏幕数据与会话数据同步时,我看到两种主要方案。**粗粒度同步**意味着无论何时您在 UI 上进行更改,整个 UI 都会同步;因此,更改艺术家字段意味着同步整个窗口,即使没有其他内容需要更改。**细粒度同步**意味着只更改真正需要更新的字段。因此,更改标题字段将涉及同步标题字段、窗口标题和列表框 - 但没有其他内容。
会话数据和记录数据之间的同步通常使用不同的方法。会话数据通常不会被多个人同时使用,因此您不必担心并发问题。会话数据和记录数据之间的同步通常发生在屏幕同步时,并且通常需要更长的时间。因此,您会执行诸如将数据元素标记为脏或使用工作单元之类的操作。
所有这些方面在 UI 的内部设计和交互设计中相互权衡。最明显的权衡发生在同步的频率和深度之间。将按键同步到记录状态会导致不可接受的性能,以及其他弊病。因此,大多数情况下,我看到屏幕同步在该深度使用。实际上,屏幕同步也是会话深度中最常见的。它通常是最容易做到的,并且许多应用程序都是这样工作的,因此用户已经习惯了它。但是,交互设计确实需要字段同步也很常见。如果域逻辑与表示层位于同一个进程中,则字段同步非常容易,如果它位于不同的进程中,则获得良好的性能会更加困难。因此,对于域层,屏幕同步是一个合理的默认值,但预计会相当频繁地进行字段同步。
按键同步似乎比较少见,但如果域位于同一个进程中,则很容易实现。
虽然时间选择会随着深度和应用程序设计的不同而有所不同,但我几乎总是赞成屏幕和会话状态之间的粗粒度同步。许多人回避粗粒度同步,因为他们担心性能影响。但是,细粒度同步很难维护,因为有很多代码重复出现。所有这些代码中的错误都很难发现,因此也很难修复。大多数情况下,粗粒度同步的性能足够好,因此我的建议是始终先使用它。如果您确实遇到了性能问题,并且您已经通过分析来检查它确实是同步问题,那么您必须引入一些细粒度同步来解决它。此时,只需执行解决性能问题的最小操作即可。
这种同步需求非常普遍,因此人们不可避免地会开发框架来尝试处理它。一个备受关注的框架是 .NET 中的数据绑定框架,它会自动同步屏幕和会话状态。数据绑定具有许多优点,理论上应该能够很好地处理同步。到目前为止(截至 1.0 版),我发现它在简单情况下运行良好,但在中等复杂的情况下会失效。我与之交谈过的开始使用数据绑定的项目最终都放弃了,因为没有足够的方法来控制绑定的工作方式。因此,我建议谨慎使用它,除非您的需求非常简单。但是,在更高版本中重新评估它 - 我很容易看到它会变成一个非常有效的解决方案,可以解决同步问题。
同步和多个屏幕
同步的一部分是关于在状态层之间同步,另一部分是处理在同一层的多个线程之间同步。您经常会在单个记录状态之上找到多个会话,以及每个会话之上的多个屏幕。每个都是一个独立的上下文,您必须考虑一个上下文中的更改如何传播到其他上下文。
由于我在这里谈论的是演示,因此我不会过多地讨论同步多个会话。无论如何,这是一个更容易理解且相对简单的主题。大多数情况下,会话彼此隔离,并且只与记录状态同步。它们使用事务或某种形式的离线并发控制来执行此操作。
演示更复杂,因为用户期望更少的隔离和更快的同步。
如何最好地同步多个屏幕在很大程度上取决于屏幕的组织方式以及屏幕之间流动的结构。从两个极端来看,我们可以考虑对比向导与完全非模态的界面(例如文件系统资源管理器)。
使用向导用户界面,系统会引导用户完成一个非常受控的屏幕流程。在任何时候,只有一个屏幕可见,并且通常用户只能从每个屏幕向前或向后移动。在这种情况下,屏幕的设计者确切地知道显示了哪些数据,以及何时打开和关闭屏幕。
使用文件系统资源管理器,用户可以随意在屏幕之间移动。更重要的是,用户可以打开多个资源管理器窗口,显示相同的文件。如果用户在一个窗口中更改了文件夹的名称,其他窗口也应该更新。UI 的程序员永远不会真正确定何时会打开窗口,以及是否在多个窗口中显示了相同的数据。
这两个极端表明了在屏幕之间协调信息的两种不同方法。使用流程同步,每个屏幕都会根据应用程序的流程决定何时将其屏幕状态与任何底层会话状态同步。因此,对于向导,屏幕通常会在从一个屏幕移动到另一个屏幕时同步;写入旧屏幕并读取新屏幕的数据。流程同步在屏幕之间的流程很简单,并且存在明确的点可以将数据从屏幕状态保存到会话状态并加载到会话状态时效果最佳。
对于文件资源管理器,流程同步将很困难。一个屏幕永远无法真正确定另一个屏幕是否已更改底层数据。在这种情况下,屏幕需要彼此不知情,并在底层数据更改时同步。使用观察者同步,底层屏幕状态充当数据的母版源。每当它发生更改时,显示它的屏幕都会收到通知,并可以更新其屏幕状态,通常使用观察者模式。在这种形式下,观察者同步是模型视图控制器样式的基本组成部分。
关于观察者同步的好处是,所有屏幕始终彼此完全独立,既不需要彼此了解以进行同步,也不需要彼此告知同步事件。这使得在应用程序中拥有非常临时和复杂的流程变得容易。观察者同步的缺点是它依赖于使用观察者,并且会引入一些隐式行为,如果您让它失控,可能会变得非常棘手。
然而,总的来说,观察者同步是复杂 UI 的主要选择。流程同步实际上只有在应用程序流程非常简单时才可以使用:通常一次只有一个活动屏幕,并且屏幕之间的流程很简单。即使这样,一旦您习惯了观察者同步,您可能更愿意即使对于这些简单情况也使用它。
观察者陷阱
富客户端演示中的许多交互都利用了观察者模式。观察者是一个有用的模式,但它带来了一些重要的问题,您需要了解这些问题。
观察者模式的强大之处,也是其弱点,在于控制权从主题对象隐式地传递给观察者。你无法通过阅读代码来判断观察者何时会触发,唯一的方法是使用调试器。因此,当存在复杂的观察者链时,要弄清楚、修改或调试它们将是一场噩梦,因为操作会触发其他操作,而原因却难以追踪。因此,我强烈建议你只在非常简单的情况下使用观察者模式。
- 不要让对象之间形成观察者链,比如一个对象观察另一个对象,而另一个对象又观察另一个对象。最好只使用一层观察者关系(除非你使用事件聚合器)。
- 不要在同一层级的对象之间建立观察者关系。领域对象不应该观察其他领域对象,表示层也不应该观察其他表示层。观察者模式最适合跨层边界使用,经典的用法是表示层观察领域层。
观察者模式在内存管理方面也存在问题。假设我们有一些屏幕观察着一些领域对象。当我们关闭一个屏幕时,我们希望它被删除,但领域对象实际上通过观察者关系持有对屏幕的引用。在内存管理环境中,长生命周期的领域对象可能会保留大量僵尸屏幕,导致严重的内存泄漏。因此,当你想删除观察者时,让它们从主题对象中解除注册非常重要。
当你想删除领域对象时,也会出现类似的问题。如果你依赖于断开所有领域对象之间的链接,这可能还不够,因为屏幕可能正在观察领域对象。在实践中,这个问题出现的频率较低,因为屏幕的离开和领域对象的生存期通常由数据源层控制。但总的来说,值得记住的是,观察者关系经常被遗忘,是导致僵尸对象的一个常见原因。使用事件聚合器通常可以简化这些关系,虽然不是万能药,但可以使生活更轻松。
我特别感谢我的同事郭晓,他通过分析他在窗口导航和数据同步方面的经验,促进了本章中许多思考的形成。帕特里克·诺德瓦尔指出了观察者模式和内存泄漏的问题。
重大修订
2006年7月11日:更新以处理MVP风格的拆分。
2004年11月20日:添加了流同步讨论。
2004年8月4日:添加了关于屏幕、层和数据的材料,这些材料来自与郭晓的对话。
2004年7月19日:首次公开发布。主要内容是关于表示模型和MVP的比较。
2004年5月15日:内部发布到TW。