探索 CAL






4.50/5 (5投票s)
评估 Microsoft 的复合应用程序指南和库。
引言
抱歉,这是我能想到的最好的标题了。一个更好的标题可能是——“探讨 CAL 中提供的概念,并将其与我过去的工作方式进行比较,以及 CAL 如何使我的生活更轻松。” 但我想你也会同意,这标题有点太‘啰嗦’了。不过,这确实是本文的意图。我开始阅读 Composite Application Guidance 是因为我对它在促进多平台开发方面的能力感到好奇。嗯,它远不止于此。我将继续称之为‘指南’的,是一系列用于构建复杂 WPF 应用程序的建议实践。它包括示例代码、一个可重用的库(Composite Application Library - CAL)以促进指南的实现,以及一个指南的参考实现应用程序。
发布该指南的 Patterns & Practices 团队在评估该指南时提出了以下建议:
- 进行适合性分析,以确定库的任何部分是否可能满足您的需求,
- 通过审查部分示例代码来初步评估,以理解各种概念,以及
- 通过使用指南附带的示例应用程序或开发概念验证应用程序来进行深入评估。
这篇文章对我来说是第 3 步。跟随一个现成的应用程序可以让你深入了解别人可能如何做某事。这本身就是一个强大的学习工具。但我喜欢亲自动手。而且,日常工作的需求通常与参考应用程序的需求不同。学习最简单的方法之一就是进行比较。因此,我决定使用我过去开发的‘可组合’应用程序框架作为指南的参考。这样,我就可以比较我过去做的事情,看看指南中的每个概念是如何让我的工作变得更容易,或者让我能够开发出更好的解决方案。
为了让你能够理解其中一些内容,你可能需要做一些功课。我猜测,你最好能拿到这个库,并在某个时候阅读指南。而且,最终你很可能想要开发自己的概念验证应用程序。
薪火相传
我从阅读别人的文章中获益匪浅。我也知道他们写文章花费的时间比我阅读的时间要多得多。所以,这是我对我所获得的一切的贡献,或者说是报答。而且,我知道我获得的远远超过我付出的。我还有第二个,部分是自私的动机。当我写 SWAT 的时候,我曾以为我永远不会完成。我确信我挖了一个我爬不出来的坑。但最终,我看到了它的价值,因为我学到了很多东西。所以写文章,就像教学一样,有助于我们的学习过程。组织文章的格式会迫使你形成更广阔的视角。我还发现它迫使我深入研究那些我否则不会深入的细节。最后,收到有人受益于文章的评论,也带来了极大的满足感。这是无法衡量的。因此,如果你在文章中发现任何可以做得更好的地方,请发表评论。这样,大家都能受益,我随时准备学习。好了,闲话少说……
初印象
当我第一次看到这个指南时,我说:“哇,真是个‘了不起’的‘Hello World!’”。这里没有‘拖放、点击、点击、F5’就能完成的事情。要将那两个小小的词显示在屏幕上需要付出一些努力。所以,我的第一反应是,需要花点时间去消化它。而且,老实说,这正是指南所推荐的,如上所述。我也在上面提到过,该指南是用于构建‘复杂’应用程序的。我的意思是,应用程序的复杂性有一个最低阈值,低于这个阈值使用该库就没有意义了。也就是说,它不适用于简单的基于对话框的应用程序。即便如此,也不必将其作为‘全或无’的解决方案。你可以‘按需’使用该库。只需选择可能符合需求的任何功能即可。你甚至可以修改代码来调整功能以满足你的具体要求。
还有一件事,这件事只在我们之间说,因为我真的没有权威这么说。但是,我确实觉得该库的某些部分本应包含在 WPF 中。这只是我的直觉。也许它们是为发布准备的,但 WPF 中其他更‘花哨’的功能获得了优先权。我对WCF印象非常深刻,因为它对我来说似乎是一个如此完整的解决方案。当然,WPF 可能是一个更大的挑战,但……无论如何,一旦我们深入探讨实质内容,我还没有到那里。
模块化设计
该指南的一个主要重点是模块化。模块化设计是好的,对吧?我们都知道它的好处。
- 它允许我们并行开发和测试,
- 它有助于独立修复和升级,
- 它促进了重用,并且
- 它有助于我们使复杂应用程序更易于管理和理解(设计)。
然而,一旦你将一个应用程序分解成几个部分,你就打开了潘多拉的盒子。这些应用程序部分仍然需要像一个整体一样进行通信和交互。从用户的角度来看,它仍然是一个应用程序。而且,所有部分都需要像管弦乐队一样和谐地演奏。这种通信和交互是一个棘手的难题,而这正是指南所要解决的一部分问题。
除了上述好处之外,有时设计要求决定了模块化设计。在下面描述的场景中,该应用程序是一个分布式应用程序,按定义它在构成上必须是模块化的。该应用程序的一些额外要求包括:
- 允许在不重新编译整个应用程序的情况下进行更新
- 在不重新编译应用程序的情况下添加新功能
- 允许对应用程序进行动态更新
但我们不是一直都在构建模块化应用程序吗?20 世纪 90 年代初,VB 通过轻松构建可插入组件而流行起来。OLE 和 COM 的创建是为了促进基于组件的设计。什么是模块化的组件设计?我认为这里存在术语问题。而且,我将尝试解释一下,但请记住,这只是我的看法。我认为模块化仍然意味着同样的事情。在我看来,一个模块是一个功能实体。它可能由不同的部分组成,但它们都旨在做一件事或提供一组相关服务。而且,你可以轻松地定义或识别它,例如‘安全模块’。也许区别仅仅在于我们一直处理的是大块,而现在我们可以处理小块了。所以,这是我的总结。WPF 使我们能够构建更模块化的应用程序,或者用更地道的英语来说,构建具有更精细粒度的应用程序。而 CAL 为我们提供了一些‘组合’这些小块的‘管道’,以及促进这些小块之间通信和交互的功能。这是我的 10,000 英尺的视角。
WidgeNet, Inc.
示例代码是一个‘虚构’的应用程序(完全是虚构的,任何对真实公司的提及都绝对是巧合)。它主要旨在展示我过去用于构建‘可组合’应用程序的机制。如前所述,它将提供一个平台来比较 CAL 提供的概念。我应该提到,该应用程序的一些功能是基于系统需求设定的,因此并非所有功能都普遍适用。它是一个生产自动化应用程序,这意味着它将有一些独特的需求。例如,这些类型的应用程序能够‘轻松’从异常情况中恢复,保持其状态非常重要。它们也是长时间运行的应用程序,这意味着它们不会关闭。而且,由于它们不会关闭,因此在运行时更新应用程序成为一项重要功能。这些应用程序大多数都是分布式的,这意味着应用程序的模块将安装在不同的机器上,并且需要协调它们的活动。但是,即使这些需求乍一看似乎是独特的,但考虑到当前应用程序开发趋势(SOA、云计算),我认为它们正变得越来越适用。
因此,为了给应用程序提供一些背景信息,我将介绍一个虚构的场景。WidgeNet 是一家制造 Weebles 的公司。Weebles 有各种配置,并且由 Weebits 组成。换句话说,Weebits 是 WidgeNet 用于生产 Weebles 的原材料。到目前为止,WidgeNet 一直是手动组装 Weebles。然而,他们对产品的需求量急剧增加。因此,他们已与 Production Automation Partners, Inc.(简称 PAPI)签约,以实现其生产设施的自动化。图 1 显示了将要安装的工厂布局。
该系统由三个机器人(R)组成,它们将 Weebits 组装成 Weebles。当一个机器人完成一个 Weeble 的组装时,一辆自动叉车(A)会从机器人组装位置拾起 Weeble 并将其运送到装货传送带。Weebits 会根据需要被送到机器人处。当机器人需要更多某种 Weebit 时,系统会向在仓库工作的有人的叉车(F)的终端发送一条消息。然后,操作员会将一叠所需的 Weebit 送到用于将产品运入系统的传送带上。当 Weebit 堆栈到达传送带的另一端时,一辆自动叉车会拾起堆栈并将其运送到相应的机器人处。所有系统操作都由系统自动化控制。只有两项手动操作是将原材料运入系统和装载成品以供发货。而且,WidgeNet 计划自动化这些操作,以便每个人都能拥有一个 Weeble!
DCAF
继续我们的场景,PAPI 开发了一个框架,允许他们为客户提供基于通用可扩展框架的定制化解决方案。该框架由一个特定行业的对象模型库组成,该库允许他们在任何客户的特定需求下提供相同的代码库。在此对象模型库之上,他们还整合了一个动态‘可组合’应用程序框架(DCAF),该框架为他们提供了在解决方案部署和管理方面的额外灵活性。(注意:我在缩写上稍作发挥。‘框架’可能有点夸张,但它是一个很好的缩写。)
图 2 显示了 DCAF 的概述。一般来说,DCAF 由一个或多个 UI 容器模块和一个或多个服务托管模块组成。UI 可以根据需求‘分布’在不同的机器上。这里的‘分布式’是指功能可以部署在一台以上的机器上,而不是它们之间互相通信。同样,服务托管也可以部署在多台机器上。WCF 是将所有这些‘松散地’连接在一起的‘胶水’。
UI 容器和服务托管都是通用的,并且对它们所托管的模块是不可知的。因此,可以部署一个或多个实例,并且每个实例都会呈现不同的‘个性’。
DCAF UI 容器
上面图 1 显示了一个 DCAF 容器的版本。演示代码中包含的版本使用列表框作为由容器托管的各种视图的选择机制。稍加修改,就可以使用树形视图控件代替列表框。树形视图允许对视图进行分类,以便进行逻辑分组。另一种变体可以使用选项卡控件,其中只有少数几个视图需要渲染,或者在部署多个容器时使用。
视图本身实现为用户控件,并且可以独立开发和测试。容器从配置文件中‘发现’它应该加载哪些视图。这样,视图就可以在不重新编译应用程序的情况下被添加、删除或替换。如上所述,这也允许多个容器部署以逻辑分离功能。例如,假设上面图 1 中描绘的容器已部署到客户端。稍后,决定将某些功能部署到另一台机器上可能会更有效率。也许,报表和订单处理功能将由不同的人员执行。所有需要做的就是将容器复制到另一台机器,将相应的用户控件移动到第二台机器,并根据需要复制/修改配置文件。对客户来说,这就像魔法一样,他们现在花一份钱得到了两份。
让我们先来看看 UI 容器的代码。配置文件是识别视图定义的地方。定义了一个新节,用于标识构成应用程序的模块列表。
<configSections>
<section name="WidgeNetModules" type="WidgeNet.WidgeNetUI.ModulesSection, WidgeNetUI" />
</configSections>
<WidgeNetModules>
<modules>
<module name="Weeble Editor"
ctlmod="WidgeNet.Weeble.WeebleEditor" modassy="WeebleEditorCtl.dll"/>
<module name="Weebit Editor"
ctlmod="WidgeNet.Weebit.WeebitEditor" modassy="WeebitEditorCtl.dll"/>
<module name="System Viewer"
ctlmod="WidgeNet.SystemViewer.SystemViewer"
modassy="SystemViewerCtl.dll"/>
<module name="Weeble Orders"
ctlmod="WidgeNet.WeebleOrders.OrdersViewer"
modassy="WeebleOrdersCtl.dll"/>
<module name="Production Control"
ctlmod="WidgeNet.WeebleProduction.WeebleProduction"
modassy="WeebleProductionCtl.dll"/>
<module name="Robot Control"
ctlmod="WidgeNet.Robot.RobotControl" modassy="RobotCtl.dll"/>
<module name="Weebit Inventory"
ctlmod="WidgeNet.WeebitInventory.WeebitInventory"
modassy="WeebitInventoryCtl.dll"/>
<module name="Status Viewer"
ctlmod="WidgeNet.StatusViewer.StatusViewer"
modassy="StatusViewerCtl.dll"/>
<module name="Report Viewer"
ctlmod="WidgeNet.Reports.ReportViewer"
modassy="ReportViewerCtl.dll"/>
</modules>
</WidgeNetModules>
当窗体(容器)加载时,它会在配置文件中查找要加载的模块,并加载它们。为了便于处理配置文件节,定义了一个辅助类来处理这项工作。但实际上没什么难的,所以你可以看看演示中的代码。这是窗体加载时执行的代码:
private void Form1_Load(object sender, EventArgs e)
{
//Load the configured modules
try
{
ModulesSection modSection =
ConfigurationManager.GetSection("WidgeNetModules") as ModulesSection;
ModulesCollection modCollection = modSection.Modules;
AppDomainSetup setup = new AppDomainSetup();
setup.ApplicationBase = AppDomain.CurrentDomain.BaseDirectory;
foreach (ModuleElement modElement in modCollection)
{
if (LoadAssembly(modElement.ControlModule, modElement.Assembly))
listModules.Items.Add(new ModuleItem(modElement.Name,
modElement.ControlModule));
}
}
catch (System.Exception ex)
{
MessageBox.Show("Failed reading config! " + ex.Message);
}
}
private bool LoadAssembly(string ctl, string assy)
{
bool returnVal = false;
try
{
Assembly assembly = Assembly.LoadFrom(assy);
Type t = assembly.GetType(ctl);
Control c = (Control)Activator.CreateInstance(t);
splitContainer1.Panel2.Controls.Add(c);
c.Parent = splitContainer1.Panel2;
c.Visible = false;
returnVal = true;
}
catch(Exception ex)
{
MessageBox.Show("Failed loading module-" + ctl +" Error:"+ex.Message);
}
return returnVal;
}
非常直接;我们使用配置文件辅助类,遍历每个项目,加载程序集,创建模块实例,并将其分配给右窗格作为子控件。在示例代码中,所有视图都一起加载,并设置为不可见。用户的选择决定了每个视图的显示/隐藏。视图也可以在首次使用时加载,但这可能会使事情变得有点复杂。在此场景中,所有视图都预计一直可用。这是控制视图可见性的列表框的事件处理程序:
private void listModules_SelectedIndexChanged(object sender, EventArgs e)
{
foreach (UserControl c in splitContainer1.Panel2.Controls)
{
if (c.GetType().ToString() ==
((ModuleItem)(listModules.SelectedItem)).ctlName)
{
//If the control is larger than panel...
if (c.Width > splitContainer1.Panel2.Width)
this.Width += c.Width - splitContainer1.Panel2.Width;
else
c.Width = splitContainer1.Panel2.Width;
if (c.Height > splitContainer1.Panel2.Height)
this.Height += c.Height - splitContainer1.Panel2.Height;
else
c.Height = splitContainer1.Panel2.Height;
c.Visible = true;
}
else
c.Visible = false;
}
}
唯一的复杂性是如何处理容器的大小,因为视图的大小不同。我认为没有一个干净的解决方案。如果容器的大小调整为控件的大小,那对用户来说有点分散注意力。所以演示代码只是将容器的大小调整为最大视图的大小。这算不上最好,所以我们将不得不等待 WPF 提供更好的解决方案。还有一件事,演示代码期望控件与容器在同一位置,但当然,这并非强制。你可以在配置文件中指定位置并相应地修改代码。
如前所述,也可以实现其他 UI 风格。如果使用选项卡控件,那么你将在加载过程中确定所需的选项卡数量,然后将每个控件分配到自己的选项卡中。代码会更简单一些,因为选项卡控件提供了选择和显示/隐藏功能。如果使用树形控件,则需要在配置文件中包含一些额外信息,以指示视图的分类方案。
DCAF 服务托管
DCAF 服务托管扮演的角色与 DCAF UI 容器类似。它允许动态加载服务模块,形式为数据模块、业务逻辑模块和对象库模块。模块的识别方式相同,使用配置文件。DCAF 服务托管可以实现为任何类型的可执行文件,但更可能的是实现为 Windows 服务(以前称为 NT 服务)。这些服务非常适合由操作系统管理的长时间运行的应用程序。演示代码将托管实现为控制台应用程序,但这主要是为了演示的方便。
由于托管通常会实现为 Windows 服务,因此服务托管还提供一项附加功能。那就是它允许在运行时动态卸载模块。也就是说,无需关闭应用程序。虽然这很棒,但对大多数应用程序来说可能不是必需的。它适用于无法关闭的系统环境,因为它们是 24/7 运行的,并且仅在一年中的特定时间关闭。
为了实现此功能,托管会公开一个接口,外部应用程序可以通过该接口控制模块的加载和卸载。这通常用于升级或更新模块,甚至为应用程序添加新功能。当然,这个功能不能随意使用,而要不顾应用程序的状态。就像在不知道谁在使用 DLL 的情况下就删除它一样不明智。使用此功能需要了解系统的状态以及模块提供的功能。模块也需要考虑到此功能进行设计,因为它们必须将自己置于‘可卸载’模式。模块创建的任何线程都必须退出,以便释放模块。但是,此功能确实提供了在不关闭服务的情况下进行更新的功能,并且可以远程完成。
演示代码中有一个控制应用程序来演示此功能。确保在启动 DCAF 容器 UI 之前先启动 DCAF 服务托管。DCAF 服务托管加载的模块之一是 WidgeNetProductionSvc 模块。该模块公开一个接口,该接口由 Production Control 视图从 UI 进行控制。如果选择 Production Control 视图,它将显示一个“开始”和“停止”按钮来模拟生产。如果单击“开始”按钮,生产模块将通过向状态查看器屏幕发送状态消息来响应。单击“开始”按钮后切换到状态查看器屏幕,你将看到生产模块发送的消息。在 Production Control 屏幕上按“停止”按钮以停止生产。你会注意到消息不再发送。现在,启动 WidgeNetControl 应用程序(如图 3 所示)。应用程序启动时,它会自动与 DCAF 服务托管通信,并显示已配置的所有模块以及当前加载到内存中的模块。从“已加载模块”列表中选择生产服务模块。然后,单击“卸载”按钮。你会看到该项已从列表中删除。这证实了该模块已被卸载,因为加载列表是从服务托管中检索的,并且列表框已更新。现在,使用 Visual Studio 打开 WidgeNetProductionSvc 项目,并修改服务发送的消息。它位于辅助线程类中。重新编译模块,并确保将其保存在同一个位置,以便服务托管找到它。现在,在 WidgeNetControl 窗口中从“已配置模块”列表中选择生产服务模块,然后按“重新加载”按钮。DCAF 服务托管将重新加载生产模块。如果你现在单击 Production Control 屏幕上的“开始”按钮,你应该会看到来自生产服务模块的修改后的消息。我们刚刚动态更新了应用程序的新代码!
提供卸载功能的一个后果是,每个模块都必须加载到自己的 AppDomain 中。没有 AppDomain 的‘卸载’功能,唯一的办法就是将其丢弃。以下是用于服务托管加载模块的代码:
private void LoadAllModules()
{
try
{
ModulesSection modSection =
ConfigurationManager.GetSection("WidgeNetModules") as ModulesSection;
ModulesCollection modCollection = modSection.Modules;
AppDomainSetup setup = new AppDomainSetup();
setup.ApplicationBase = AppDomain.CurrentDomain.BaseDirectory;
foreach (ModuleElement modElement in modCollection)
{
AppDomain appDomain = AppDomain.CreateDomain(modElement.Name);
WidgeNetLoader remoteLoader = (WidgeNetLoader)
appDomain.CreateInstanceFromAndUnwrap(setup.ApplicationBase +
"WidgeNetHost.exe",
"WidgeNet.WidgeNetHost.WidgeNetLoader");
remoteLoader.LoadAssembly(modElement.Name, modElement.Host);
modules.Add(new WidgeNetModule(appDomain,
remoteLoader,
modElement.Name));
}
}
catch (System.Exception ex)
{
EventLog.WriteEntry("WidgeNet",
"WidgeNetHost-Error:" + ex.Message);
}
}
你可以看到与 UI 容器的相似之处。当托管启动时,它将加载配置文件中标识的所有模块。因此,有一个类似的辅助类来处理配置文件节。而且,如上所述,我们还需要一个辅助类将模块加载到其自己的 AppDomain 中。这是该类的代码:
public class WidgeNetLoader : MarshalByRefObject, IDisposable
{
public object host = null;
public void LoadAssembly(string modName, string hostName)
{
try
{
Assembly assembly = AppDomain.CurrentDomain.Load(modName);
host = assembly.CreateInstance(hostName);
}
catch (System.Exception ex)
{
EventLog.WriteEntry("WidgeNet",
"WidgeNetHostSvc:Failed to load module-" + modName +
". Error:" + ex.Message);
}
}
public void Dispose()
{
if (host != null)
((IDisposable)host).Dispose();
}
public override object InitializeLifetimeService()
{
//We don't want our lifetime to expire and the dll to be unloaded.
return null;
}
}
要构建和部署模块化、分布式、动态‘可组合’的应用程序,就只需要这些。当然,一切并非‘尽善尽美’。容器(UI 和服务托管)不为它们托管的模块提供任何额外功能。如果 UI 容器能提供一些视图可以依赖的通用 UI 功能,那就好了。另一个缺点是,视图之间无法通信,因此它们需要自给自足。而且,即使它是模块化的,‘块’仍然相当大。如果能更细粒度一点会更好。换句话说,视图仍然更像是迷你应用程序,如果它们可以被分解成更多半独立的部件会更好。而且,如果我们想让一个‘共享’给多个功能模块的视图,没有一些代码重复就无法做到。最后,即使每个控件都可以通过简单地将其放入对话框测试工具中进行独立测试,但它仍然需要作为一个整体进行测试。
在此方面,DCAF 为测试环境提供了相当好的支持。任何服务模块都可以替换为设备服务的模拟版本,或数据服务的存根版本。这在开发过程中很有用,以后在调试和故障排除时也很有用。在开发过程中,模拟模块可以加快开发速度,原因有两个。首先,真实模块可以并行开发,其次,模拟版本可以比真实版本响应得更快。例如,模拟机器人模块可以在几毫秒内模拟一个操作,而真实机器人需要几秒钟才能执行一个请求。这使得整个应用程序可以在模拟模式下进行测试,并在几分钟而不是几天内运行生产周期,如果必须使用真实硬件测试整个系统,则可能需要这么长时间。而且,在模拟环境中测试新代码比在真实硬件上测试更安全。
发现 bug 的最简单方法之一就是能够重现它。设置一个可以重复序列的模拟环境是一个宝贵的资源。当然,并非所有 bug 都能在模拟环境中产生。但是,即使在那里,它也证明是有帮助的,因为你可以使用模拟来缩小焦点范围。如果它是可重现的,那么找到它只是时间问题。
现在,让我们将注意力转移到 WPF 和 CAL 正在解决的一些典型的应用程序‘改进机会’上。我确信还有很多其他的,所以我们只举出其中几个。我们将首先展示它们在 WidgeNet 中是如何解决的,以便稍后进行比较。我们将在下一期中,看看 CAL 是否提供了更好的方法来解决这些问题。我们还突出了一些我们可以作为检查焦点的应用程序‘改进机会’。所以,下次我们将开始剖析 Composite Application Library,看看它如何能够改进设计。在此期间,你可以看看指南,看看你的想法。
多视图数据一致性
一种常见的情况是,一个以上的视图显示一些公共数据。通常,有一个主视图或控制视图创建并维护某个数据对象或实体,并且可能有一个或多个依赖视图显示该数据对象信息的部分或全部。WidgeNet 就有这种情况,所以让我们继续我们的场景。
WidgeNet 计划将他们的 Weeble 设计过程升级为自动化程序。但在此期间,Weebles 必须使用操作员控制台提供的编辑器手动设计。设计一个 Weeble 只是选择一个或多个 Weebits,并在编辑器中定位和定向它们(但重点不是这里)。图 4 显示了 Weeble 编辑器窗口。
编辑器窗口列出了当前定义的 Weebits,它们可以用来定义新的 Weebles。如果所需的 Weebit 不可用,则必须使用 Weebit 编辑器屏幕进行定义。这两个编辑器窗口在控件最初加载时都会初始化可用 Weebits 的列表。问题出现在当在 Webit 编辑器中定义了一个新的 Weebit 时,直到控件重新加载之前,它在 Weeble 编辑器列表中都不可用。这意味着你必须关闭应用程序。这不会让用户非常满意。一种解决方案是让 Weeble 编辑器定期轮询 Weebit 服务以刷新其列表,或提供一个用户可以使用的刷新按钮。是的,我知道这不是一个非常值得称赞的解决方案,但它确实解决了问题,你在到处都能看到刷新按钮。事实上,你现在使用的应用程序上可能有一个刷新按钮来查看这个。Weeble 编辑器的另一种方法是在 `VisibleChanged` 事件中添加代码来重新加载当前的 Weebits 列表。这样,每次显示 Weeble 编辑器时,它都会获取当前列表。但是,如果你允许用户同时查看这两个编辑器呢?DCAF 容器的一个特性是,如果双击一个项目,它将在自己的窗口中显示。图 5 显示了这种情况,每个编辑器都在自己的独立框架中打开。
在这种情况下,`VisibleChanged` 事件将无法提供解决方案。真正的解决方案是让 Weebit 数据服务告诉你数据何时已更改。这样,你就能直接从‘马嘴’里获得信息。而这可以通过利用 Weebit 数据服务的发布者/订阅者模式来实现。演示中的 Weebit 服务实现了一个客户端可以用来订阅/取消订阅数据更改通知的订阅接口。客户端(Weeble 编辑器)必须实现一个回调接口,服务将使用该接口发送通知。以下代码显示了接口定义:
[ServiceContract(Namespace="http://WidgeNet",
CallbackContract = typeof(IWeebitChangedHandler),
SessionMode=SessionMode.Required)]
public interface IWeebitDataPub
{
[OperationContract(IsInitiating=true)]
void Subscribe();
[OperationContract(IsTerminating=true)]
void Unsubscribe();
}
//The interface that clients must implement...
public interface IWeebitChangedHandler
{
[OperationContract(IsOneWay=true)]
void DataChanged();
}
Weebit 服务实现了 `IWeebitDataPub`,而客户端必须实现 `IWeebitChangedHandler`。当加载 Weeble 编辑器时,它会像下面这样订阅 Weebit 数据服务以获取数据更改的发布:
private void WeebleEditor_Load(object sender, EventArgs e)
{
...
//Subscribe so we know when there has been a change
pubClient = new WeebitDataPubProxy(dataChangedHandler);
pubClient.Subscribe();
...
}
这里有两点需要注意。首先,为了让通知能够返回,必须保持与服务的连接。代理(`pubClient`)是一个类变量,在此初始化,并在控件销毁时释放。而‘dataChangedHandler
’是传递给服务的回调接口。这是实现回调接口的类的代码:
[ServiceBehavior(InstanceContextMode = InstanceContextMode.Single,
Namespace = "http://WidgeNet")]
public class DataChangedCallbackHandler : IWeebitChangedHandler
{
WeebleEditor parent;
public DataChangedCallbackHandler(WeebleEditor parent)
{
this.parent = parent;
}
public void DataChanged()
{
parent.context.Send(parent.dataChanged, null);
}
}
你可能会注意到,该类与如何声明服务非常相似。事实上,如果你愿意这样想,上面显示的回调处理程序实际上是 Weebit 数据服务的‘服务’。如果是这样,那么我们就需要一些东西来‘托管’回调服务,就像 ServiceHost 为服务提供的那样。而这正是 `InstanceContext` 类提供的。我们创建一个 `InstanceContext` 类的实例,并将其传递一个实现回调接口的类的实例。以下是这部分代码,它在构造函数中:
public WeebleEditor()
{
InitializeComponent();
context = SynchronizationContext.Current;
dataChanged = new SendOrPostCallback(DataChanged);
handler = new DataChangedCallbackHandler(this);
dataChangedHandler = new InstanceContext(handler);
}
...
void DataChanged(object o)
{
using (WeebitDataClient client = new WeebitDataClient())
{
listWeebits.Items.Clear();
listWeebits.Items.AddRange(client.GetWeebitNames());
}
}
而且,由于通知将在另一个线程上返回,我们设置了一个 `SynchronizationContext` 和一个事件处理程序,回调类将调用它。现在,每次 Weebit 数据更改时,列表框将同时更新。我必须说这是一个‘捷径’解决方案。更好的解决方案是将更新后的数据作为通知的一部分发送。这需要多一点工作,但效率要高得多。如果编辑器每次收到通知都要检索长长的项目列表,这绝对不是一个好方法。对于那些情况,回调接口可以包含更新后的对象以及一个标志来指示它是更新还是创建(以简化客户端的处理)。现在,让我们看看服务模块需要实现什么。Weebit 服务实现了客户端用于订阅/取消订阅通知的订阅接口。服务所要做的就是维护一个列表,其中包含谁有兴趣收到通知。以下是服务实现的实现代码:
public void Subscribe()
{
lock (subscribers)
{
IWeebitChangedHandler subCallback =
OperationContext.Current.GetCallbackChannel<iweebitchangedhandler>();
if (subscribers.Contains(subCallback) == false)
subscribers.Add(subCallback);
}
}
public void Unsubscribe()
{
lock (subscribers)
{
IWeebitChangedHandler subCallback =
OperationContext.Current.GetCallbackChannel<iweebitchangedhandler>();
foreach (IWeebitChangedHandler handler in subscribers)
{
if (handler == subCallback)
{
subscribers.Remove(subCallback);
break;
}
}
}
}
正如我们之前提到的,可以通过发送更改的数据来增强回调,`Subscribe()` 方法也可以通过包含参数来增强。例如,客户端可能只想在添加新 Weebits 时收到通知,而不是在进行编辑时收到通知。这在服务方面需要多做一些工作,但一切皆有可能。这完全取决于需求;对于演示来说,它不会造成太大的损害。
由于发送通知可能存在延迟(以及可能的异常),并且我们还希望断开启动更新的服务调用的连接,因此我们将通知实现为单独的线程。这是代码:
internal class PublishingThread
{
WeebitDataSvc parent;
public PublishingThread(WeebitDataSvc parent)
{
this.parent = parent;
//Start a thread
Thread thread = new Thread(new ThreadStart(Publish));
thread.Start();
}
void Publish()
{
List<iweebitchangedhandler> bogusSubs = new List<iweebitchangedhandler>();
lock (parent.subscribers)
{
foreach (IWeebitChangedHandler handler in parent.subscribers)
{
try
{
handler.DataChanged();
}
catch (System.Exception ex)
{
//Remove any subscribers that are troublemakers...
bogusSubs.Add(handler);
}
}
foreach (IWeebitChangedHandler wh in bogusSubs)
{
parent.subscribers.Remove(wh);
}
}
}
}
就是这样。每次 Weebit 数据被编辑或创建一个新的 Weebit 时,都会创建一个 `PublishingThread` 实例,它将向所有订阅者发送通知。如果数据对象与通知一起发送,那么只需在构造函数中传递它即可。当然,订阅者列表也需要指示 `Subscribe` 方法可能传入的任何偏好(如果它那样设计的话)。
另一种解决方案是提供一个通用的发布服务作为应用程序的一部分,所有模块都将使用该服务。客户端将订阅一个特定的‘发布类型’,而服务将它们的通知发布到发布服务,然后该服务会将通知广播给所有订阅者。在上面的例子中,可以为系统定义一个‘WeebitDataChange
’发布类型。客户端将使用此类型与发布服务进行订阅。而且,Weebit 数据服务将发布到发布服务,并将此类型作为发布消息的参数。这样做的好处是为功能提供了一个中心位置。对于简单的事件通知,发布服务将非常简单,甚至可以提供从配置文件自动配置通知的功能。如果事件通知很复杂并且传递了数据对象,那么发布服务就会变得更复杂。
UI 可视状态
UI 设计中的一个常见问题是控制用户可以做什么和/或看到什么。你知道用户非常不耐烦,会随意点击。你按电梯的‘关门’按钮多少次,希望你的不耐烦能让它更快地关门?嗯,它没在听!它会按照自己的方式运行,无论如何。对于 UI,我们也必须保护应用程序免受用户的侵害。一种方法是允许用户随意按按钮,但什么都不会发生,因为应用程序已经接受了之前的输入。显然,应用程序不能像电梯那样运作。UI 需要在任何时候向用户指示他们有哪些选项。这是通过适当地启用和显示/隐藏控件来实现的。可能有许多方法可以‘构思’一个解决方案,但我认为没有一个干净的解决方案(至少,在本文的这一点上)。在上面图 4 和图 5 中,你可以看到前面描述的两个编辑器。你可以看到屏幕上有许多控件,有些已启用,有些已禁用。演示代码使用伪状态机来控制 UI 的‘状态’。根据用户当前正在做什么,相应的控件将被启用和/或显示。以下是 Weeble 编辑器代码的一部分:
...
if (currentState == ViewStates.EditingExisting ||
currentState == ViewStates.EditingNew)
{
textDescription.Enabled = true;
buttonSave.Enabled = true;
buttonCancel.Enabled = true;
listWeebles.Enabled = false;
listWeebits.Enabled = true;
buttonAdd.Enabled = true;
weebleDesignerCtl1.Enabled = true;
}
if (currentState == ViewStates.EditingNew)
{
textName.Visible = true;
labelName.Visible = true;
}
if (currentState == ViewStates.NoSelect)
{
buttonNew.Enabled = true;
}
if (currentState == ViewStates.Viewing)
{
buttonNew.Enabled = true;
buttonEdit.Enabled = true;
}
if (currentState == ViewStates.NoSelect ||
currentState == ViewStates.EditingNew)
{
textName.Text = "";
textDescription.Text = "";
weebleDesignerCtl1.Clear();
}
...
这个问题的一个困难在于逻辑和 UI 紧密耦合。任何 UI 的更改都需要代码的更改。有些更改可能很简单,其他更改可能会对支持它的代码产生重大影响。这个问题的变体是,一个 UI 控件依赖于多个视图的状态。
在图 6 所示的 Robot Control 视图中,“开始”按钮依赖于每个机器人是否已启用。在演示代码中,选项卡是简单的容器,并且事件处理程序都是本地处理的。但是,如果每个选项卡实际上都托管了一个用户控件(更好的方法),那么聚合逻辑就会变得更加复杂。每个用户控件将处理按钮点击事件,但然后必须将其传达给选项卡。无论如何,控制 UI 的状态可能会变成一团乱麻。
事件隐藏
我敢肯定你以前遇到过这种情况。你想捕获一个事件,让 UI 更直观或代码更简单,但它就是不可用。并非所有控件都一样。并非所有控件都‘可聚焦’,因此可用的操作各不相同。这意味着,要获得期望的事件可能不可行,或者你必须费尽周折才能获得期望的结果。其他时候,事件由父类处理,因此子类无法处理它们。在某些情况下,你必须在一个类中处理事件,然后通过方法调用将其传递给另一个类以模拟事件处理程序。而且,这实际上可能会使 UI 显得不太直观。
演示代码中至少有一个例子。在 Weeble 编辑器视图中,有一个面板,Weebles 在其中设计。用户可以拖放和旋转每个 Weebit 到所需的位置和方向。为了使定位更精确,用户可以‘缩放’面板的内容。面板下方的滑块控制着面板的放大倍数。更直观的机制是使用鼠标滚轮作为‘缩放’操作的控制机制。这对用户来说会更方便,显示也会更干净。不幸的是,`MouseWheel` 事件对于该控件不可用,因此不得不实现替代解决方案。
我想底线是,在我们的日常活动中,我们必须与基础设施的局限性作斗争。有时,这会变成比要解决的业务问题更大的挑战。而且,我们甚至可能不得不接受不完美。这里没有任何批评的意思。这只是我们行业的性质,不断增长的复杂性,以及持续的变化。
深度嵌套依赖
你见过类似下面的情况吗?
...
parent.parent.parent.DoSomething();
...
来吧,承认吧,是的,你见过。OOP 的基石之一是组合,它允许我们将复杂的对象构建成更简单的对象。这种组合有时会导致深层层次结构。当然,你会尽量最小化或消除可能导致层次结构产生的任何垂直依赖。但有时,你就是不得不‘顶撞’父母。即使自从我们很小的时候就被教导不要这样做。发生的情况是,有时别无选择,只能让父级创建资源并使其可供子级使用。我认为这比其他情况更像是 UI 的问题,因为在其他情况下可能有更多的选择。但无论如何,这是一个常见的问题。
上面图 6 显示了 WidgeNet 的库存屏幕。每个按钮代表其各自‘子系统’中的位置,Weebit 库存可以存在于这些位置。每个‘子系统’都实现为一个用户控件,该控件又创建了适当数量的位置按钮。当用户将鼠标悬停在其中一个按钮位置上时,该位置的 Weebit 数量将显示在屏幕上。问题是,InventoryPosition(按钮)处理鼠标事件,并拥有数据,或者知道如何获取数据,但数据需要显示在比它高两个级别的控件上。在这种情况下,这只是一个简单的数字,可以轻松地显示在按钮本身上。但更常见的情况是,会为每个项目显示更多信息。在本例中,上面的结构可行,因为所有级别都是具有‘parent’成员的控件。但是,子级必须将父级转换为适当的类型。现在,如果类不是控件,那么你必须将引用传递给所有级别,直到需要它的地方。这意味着每个需要与顶层通信的对象都必须维护对顶层的引用。而且,顶层必须定义一个数据通信的方法,或者公开资源。这两种方法都不好,因为控件之间存在紧密的连接。另一种解决方案是定义几个事件,由位置按钮在处理已经存在的鼠标进入和鼠标离开事件时触发。这消除了顶部和底部之间的耦合,因为较低级别不需要了解父级。然而,这需要触发多个事件,每个事件在层次结构中的每个级别触发一次,以便将信息向上级联。而且,WidgeNet 示例也是一个非常简单的情况,有时设计需要更复杂的组合。
再见了,宝贝
好了,就到这里了。现在我们有了一个可以用来分析指南概念的应用程序。我们还研究了虚构场景中是如何解决问题的。下次,我们将看看 CAL 是否提供了更好的方法来解决这些问题。我们还重点介绍了可以作为检查焦点的应用程序‘改进机会’。所以,下次我们将开始剖析 Composite Application Library,看看它如何能够改进设计。在此期间,你可以看看指南,看看你的想法。