65.9K
CodeProject 正在变化。 阅读更多。
Home

CX 第二部分

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.97/5 (27投票s)

2009年8月5日

CPOL

47分钟阅读

viewsIcon

56806

downloadIcon

371

为 CX 动态组合框架构建元数据设计器。

关于下载

如果您对试用此框架感兴趣,下载代码后,请将 Cx.Designer.App 设置为启动项目。

特别鸣谢

我要感谢 Bill Woodruff,感谢他在这篇文章的校对工作中付出的巨大努力,并提供了许多改进和澄清内容的建议。谢谢您,Bill!

引言

此系列文章关于 CX 框架的第二部分,实际上是对如何使用 CX 本身构建一个简单的连接工具(关于“连接”,请参阅 第一部分)的探讨,以及我在此过程中发现的关于控制反转 (IoC) 或依赖注入 (DI) 框架的一些体会。因此,这实际上是关于架构和应用程序设计,我在此讨论的问题适用于我在第一部分提到的任何流行 IoC 框架。我只是将 CX 用作探索 IoC/DI 架构问题的一种方式。

我为什么不直接为 CAB 或 Spring.NET 编写一个设计器,然后出名呢?嗯,等我完成这个之后,也许用 CX 来为其他框架编写设计器会更容易一些!

必备组件

读者应该对控制反转模式(见下文)、依赖注入、XAML、反射、用于在组件之间创建清晰分离的各种设计模式有一定的了解,并且有企业级应用程序开发经验(或考虑过),(这并非为了抛出流行语,而是)这正是考虑使用 IoC 框架的原因。

什么是控制反转?

来自 维基百科

在传统编程中,程序的流程由中心代码控制。使用控制反转时,这种中心控制作为一种设计原则被抛弃了。虽然 调用者 最终会得到答案,但何时以及如何得到答案已超出调用者的控制范围。由被调用者决定何时以及如何回答。控制反转原则也称为 好莱坞原则。控制反转作为一种设计指南,有以下目的:

  • 执行某个任务与实现之间实现了解耦。
  • 每个系统都可以专注于其设计目标。
  • 系统不会对其他系统做什么或应该做什么做出假设。
  • 替换一个系统不会对其他系统产生副作用。

什么是依赖注入?

来自 维基百科

传统上,如果一个对象为了完成某项任务需要特定的 服务,它还将负责实例化和释放(内存移除、关闭流等)该服务,从而使其更复杂且难以维护。理想情况下,该对象不需要管理其服务的生命周期,只需拥有对 实现 的引用,并调用其相关行为。依赖注入是一种 设计模式,可用于向对象提供其依赖项,并将与服务生命周期相关的代码移至更合适的位置。

为什么写这篇文章?

本文旨在探讨在编写了一个动态组合框架后,使用该框架创建了一个简陋的设计器来创建元数据时所演变出的控制反转 (IoC) 和依赖注入 (DI) 的最佳实践。

在与 Microsoft CABSpring.NET 合作后,我坦白地说我看不出有什么意义

  • 这似乎只是在重复发明服务轮子,
  • 并且使应用程序更难调试,因为运行时组合,
  • 并且要求您使用 XML 而不是提供一个工具来定义元数据。

我想知道为什么这些框架比使用更传统的服务方法要好。当然,维基百科在 IoC/DI 上说的“向对象提供其依赖项,并将与服务生命周期相关的代码移至更合适的位置”,可以轻松地纠正,而无需创建一个庞大的框架。

我还想学习如何使用这些框架的最佳实践——仅仅因为你有一个时髦的框架,并不意味着你的应用程序就会更好。所以,对于我的一些问题,我决定编写 Cx,一个动态组合框架,并从头开始学习 IoC 和 DI,而不是自上而下地使用别人的架构。而且,我想用我的框架来解决我对现有 IoC 框架最大的抱怨:它们没有任何设计工具!

最佳实践

我把这一节放在最前面,供想直奔主题的人阅读。这总结了使用 IoC 框架时的最佳实践,这是我花了许多小时使用 Cx 并编写 Cx Designer 的结果。如果您想阅读更多关于我是如何以及为何得出这些最佳实践的内容,那么,剩下的文章就是为此而准备的。

其中一些子章节只有一两个要点。目前没关系,因为我预计随着我继续使用 Cx 和其他 IoC 框架,这些章节会得到充实。

编码前的实践

  1. 决定是使用接口(方法调用)来启动活动还是命令模式,并尽量坚持其中一种以保持一致性。决定因素可能取决于应用程序的预期复杂性以及参与项目的开发人员和开发团队的数量。
  2. 如果您必须直接与某个组件交互,请始终使用接口。这会使一切变得更容易。假设您想模拟该组件,或者您需要根据上下文实例化几个不同的实现。如果您不使用接口,这是不可能的,所以在编写类时请考虑未来。
  3. 看看您的应用程序与 IoC/DI 框架的耦合程度。它是否如此耦合以至于您的代码在框架上下文之外完全不可重用?如果是这样,您可能需要考虑该选择的后果。
  4. 进行一些规划和故事板绘制,以便及早而不是晚期识别接口方法、命令、生产者和消费者等。
  5. 认识到通过依赖注入组件实例,您就隐含地接受了某种架构范式(参见下文 依赖注入)。有意识地选择何时使用组件实例注入以及何时通过命令模式解耦组件。

项目布局实践

  1. 将您的接口放入单独的程序集中。这对于表示“插件”或我所说的组件的接口很重要。将接口与实现类放在同一个项目中,这在某种程度上会破坏拥有接口并将其与组件分离的意义。
  2. 尽可能保持您的组件实现轻量级,并仔细考虑如何将它们组织到不同的程序集中。考虑将您的 UI 组件放入一个或多个程序集中,并将您的视图模型放入不同的程序集中。考虑您想如何组织您的模型。

性能实践

  1. 仔细查看那些“让开发者更轻松”的小助手。这是否意味着框架只是为了让您更方便而使用了反射?
  2. 尽早识别 IoC/DI 框架的性能问题:它在哪些方面明显影响性能,又在哪些方面仅仅起到了促进作用。
  3. 将您的 IoC/DI 框架视为一名管理者——糟糕的管理者会让所有人的工作变得更难、更慢,同时声称提高了效率;而优秀的管理者则能起到促进作用,让人们工作得更快、更容易。

易用性实践

  1. 查看粘合在一起的元数据的复杂性。它是否做了太多事情,以至于隐藏了应用程序的意图?如果是这样,这意味着新程序员的学习曲线会更陡峭,最终,即使是老手也会忘记为什么以某种方式完成某事。

泛化实践

  1. 决定您的泛化深度。高度泛化的应用程序更难理解其意图,但组件的生命周期可能超出应用程序的生命周期。浅泛化更容易进入和支持,但并不真正适用于特定应用程序之外的重用。

安全实践

  1. 尽早处理安全等问题。所有命令是否在所有安全上下文中都可执行?

生命周期实践

  1. 考虑对象的生命周期,以及您的数据生产者组件是否需要在将其发送到命令负载之前克隆该对象。消费者不应担心他们的集合或数据值是否会在下一个生产者组件生成对象或修改对象时被破坏。
  2. 注意任何组件初始化要求。如果一个组件被缓存,那么在构造函数中发生的任何初始化都只会发生一次。

术语

生产者/消费者

我使用“生产者”和“消费者”这两个术语,即使不正确,也相当随意。这不是生产者-消费者问题(移动数据通过共享缓冲区)。这更接近发布者-订阅者模式,但也不是完全匹配,因为消息不是异步的,而且很多“消息”实际上是命令——“消息”没有实际内容。

所以,我的“生产者”定义是一个类,至少在这个实现中,它触发一个事件,可选地带有数据负载。我的“消费者”定义是一个方法,其参数签名与事件委托匹配,因此可以连接起来“消费”该事件。

动态组合框架

这里 是一个专业术语

"动态组合是面向服务计算 (SOC) 的一个关键特性,其中服务在运行时被发现和组合。当前的服务组合标准和方法要求在 BPEL 中静态绑定已发布的 WSDL 接口到组合中。在本文中,我们提出了一个用于企业服务总线 (ESB) 上动态组合的框架的初步结果。我们提出了动态组合处理程序的设计和实现。"

但这基本上就是我使用该术语时的意思:在运行时从彼此并不熟悉的组件构建应用程序。

深度泛化

在深度泛化的应用程序中,您需要某种方式以泛化但结构化的方式在组件之间通信更改。所以,我的意思是几个方面:

  • 构造组件,使其脱离特定应用程序的要求。
  • 构造组件,使其不依赖于任何其他组件。您可能将组件打包到一个程序集中,但没有任何一个组件会要求其他组件的任何内容。对框架(用于属性和辅助函数)的少量依赖是可以接受的。
  • 完全将任何业务逻辑与组件分离。这主要适用于 UI 组件,并允许您在不接触组件渲染的情况下替换业务逻辑。
  • 完全定义数据进出组件的方式以及组件可以启动的活动。否则称为 契约式设计,只是这里我们使用元数据和/或属性向设计工具提供有关契约的信息(类似于您在类中装饰属性以便 Visual Studio 设计器可以使用它们的方式)。

决定,决定

我想通过设计工具实现的目标不是一个漂亮的 UI(见上图),而是创建一个用于创建 CX 元数据的功能性 UI:组件、组件的属性以及生产者/消费者连接。我最终实现了这个目标:“声明的事件” UI 完全在设计器中创建,除了我必须进行的少量手工调整以支持泛型类型;参见 几个缺失的功能

决策点:模块化什么?

我倾向于在相当深的层次上进行模块化(或泛化),所以我最初的想法是 UI 将由三个列表的可视化组成:

  1. 组件本身,可能是一个带有名称、类名和程序集名称作为列的 ListView
  2. 组件中的生产者(事件);
  3. 组件中的消费者(事件处理程序)。

我想到了列表本身可能是 ListBoxListView 控件,并且它们应该有一个描述列表的标签。这似乎很基础。除此之外,我还需要为每个列表提供一些额外功能:

  • 用户需要添加和删除列表中的组件,这需要支持业务组件和可视化组件;
  • 用户可以选择一个生产者;
  • 消费者列表将带有复选框,以便用户可以选中/取消选中连接到所选生产者的消费者。

所以,我想我会创建一个由带有标签的列表组成的通用用户控件。但到某个时候,我意识到,我为什么应该以如此笼统的方式来考虑 UI?创建一个带有所有三个列表的 UI,并预先定制它们的显示,难道不可以接受吗?以下是我对创建高度泛化 UI 的初步想法:

深度泛化的优点

  • UI 布局非常灵活
  • 每个 UI 组件都易于替换
  • 创建一个可重用的模板库(毕竟用户控件就是如此)

缺点

  • UI 的“意图”隐藏在 XML 布局中
  • 可能将事情推向极端:将控件包装在用户控件中带来的价值很小
  • 模板(用户控件)的定制被推到了 XML 布局中
  • 可能过度泛化的集合结构迫使行为被放入列表项类中

“缺点”列表值得进一步探讨。我选择了深度泛化方法,以便能进一步探索这些问题。

UI 的意图

您能从这段 XML 中看出 UI 的意图吗(为便于阅读而缩写,哈哈)

<Components>
  <VisualComponent Name="ComponentList" ComponentName="ListView" 
                   Location="10, 10" Size="300, 180"/>
  <VisualComponent Name="ProducerList" ComponentName="List" 
                   Location="10, 200" Size="200, 200"/>
  <VisualComponent Name="ConsumerList" ComponentName="List" 
                   Location="230, 200" Size="200, 200"/>
  <BusinessComponent Name="CxDesigner" ComponentName="CxDesigner" />
</Components>

勉强可以,如果您有文章开头处的截图作为参考,那就容易多了。

将应用程序上下文与控件分离

我可以创建一个用户控件,并将我的列表都预先配置好,但这样我的用户控件将非常特定于设计器应用程序,并且更改控件的某个方面(例如列表之一)将需要重新实现整个控件。然而,这样做的优点是我不必处理每个列表的定制,包括:

  • 与列表相关的标签
  • ListView 的任何标头配置
  • 默认 ListView 显示模式
  • 列表是否应带有复选框
  • 数据是否可编辑
  • 等等。

但是,如果我实现 UI 的深度泛化模式,其中包含与应用程序上下文解耦的通用列表控件,我现在就需要将应用程序上下文与控件分开指定。这可以通过命令式(代码)或声明式(元数据)完成。例如,我可以在 XML 中为配置 ListView 定义所有这些附加属性值:

<VisualComponent 
           Name="ComponentList" 
           ComponentName="ListView" 
           Assembly="..\..\..\Cx.Designer.Components\bin\debug\Cx.Designer.Components.dll" 
           Location="10, 10" 
           Size="300, 180">
  <Property Name="HeaderText">
    <Item Value="Name"/>
    <Item Value="Component Name"/>
    <Item Value="Assembly"/>
  </Property>
  <Property Name="HeaderWidths">
    <Item value="200"/>
    <Item Value="200"/>
    <Item Value="600"/>
  </Property>
</VisualComponent>

所以,通过创建一个泛化组件,我将应用程序上下文与控件分开了,这需要其他机制将该上下文引入到控件的视觉外观中。我选择元数据(XML)作为这种上下文的载体,通过创建一种自定义格式来声明属性值和处理列表(如标头文本和宽度),从而增加了复杂性。

属性现在是设计器需要处理的另一个东西,所以开发者不必手动编辑 XML。因此,我使我的生活变得更复杂了。

过度泛化的集合结构

如果我创建一个专用于 CX 设计器的自定义用户控件,那么至少可以使用 CX 接口来管理我的集合(组件、生产者、消费者)。但是,如果我使用高度泛化的组件(目的是可能将这些组件用于其他应用程序),那么我的集合管理可能在 IEnumerable(或类似)级别上工作,而显示集合中项相关文本的方式现在落在了集合项上,通常通过重写 ToString 方法来实现。

这正是我最终发生的情况。我的列表组件在 OnData 处理程序中使用 IEnumerable

[CxConsumer]
public void OnData(object sender, CxEventArgs<IEnumerable> args)
{
  lbList.Items.Clear();

  foreach (object obj in args.Data)
  {
    lbList.Items.Add(obj);
  }
}

并且项类现在必须重写 ToString() 实现,以便获得所需的视觉效果(以 CxProducer 类为例)。

public class CxProducer
{
  public ICxComponent Component { get; protected set; }
  public string Name { get; protected set; }

  public CxProducer(ICxComponent comp, string name)
  {
    Component = comp;
    Name = name;
  }

  public override string ToString()
  {
    return Component.Name + "." + Name;
  }
}

因此,我成功地将应用程序特异性问题推到了另一个领域,这可能不是最佳解决方案,因为我在代码完全不同的部分限制了自己,只关注了生产者名称的视觉效果。换句话说,我增加了视觉效果方面的复杂性。

您可能会问,为什么不使用泛型来指定集合类型?我在 一个业务组件 中探讨了这个问题,该组件将列表转换为可排序列表。

决策点:如何进行最终初始化?

在我初始化设计器应用程序时,我编写了这段代码:

CxApp designer = CxApp.Initialize(Path.GetFullPath("cxdesigner.xml"));
ICxDesigner designerComponent = designer.GetComponent<ICxDesigner>();
designerComponent.LoadComponents(metadataFilename);

以 Spring.NET 为例,您可以在定义组件引用的 XML 文件中指定初始化方法。我不热衷于将所有内容都塞到 XML 文件中。

在代码中完成的优点

  • 我认为更容易调试。
  • 初始化调用发生在哪里更加明确。
  • 要调试,您可以设置一个断点,而不是在组件方法内部,而是在初始化方法被调用处;如果框架使用反射进行调用,您将失去此能力。
  • 是否存在初始化顺序问题?例如,所有连接是否都已完成,并且其他组件是否已初始化?最后一点可以强制您在 XML 文件中指定依赖项,这在 Microsoft 的 CAB 元数据中可以看到。
  • 如果初始化引发异常,您的堆栈跟踪会更短。例如,使用 Spring.NET 时,在您的组件构造函数/初始化和应用程序启动整个过程的某个地方之间,堆栈跟踪中约有 50 多个调用。
  • 如果初始化引发异常,您可以自行处理,而不是被重新包装到框架的异常处理机制中。
  • 简单即是最佳。

我曾与 Spring.NET 和 Microsoft CAB 遇到过其中一些问题。这可能令人沮丧。

缺点

  • 除了将所有内容都放入 XML 定义很酷之外,实在想不出其他优点。

例如,在设计器类中,我有以下初始化:

protected void LoadComponents()
{
  LoadVisualComponents();
  LoadBusinessComponents();
  RaiseComponentsLoaded();
  BuildProducerConsumerList();
  RaiseProducerListLoaded();
  RaiseConsumerListLoaded();
}

请注意 Raise... 调用。这会触发事件,此时我们知道监听这些事件的组件已被实例化并且事件已连接。此时。

决策点:是否接受框架的理念?

在高度泛化的应用程序中,您需要某种方式以泛化但结构化的方式在组件之间通信更改。CX 框架使用事件,但您也可以使用其他命令模式。或者,您可以使用 .NET 的数据绑定范式,或者简单地获取不同组件的实例(通常通过依赖注入)来访问实例的属性值。

无论如何,您都需要做一些事情来建立这种依赖。在 CX 框架中,您会在组件中创建事件,例如:

[CxEvent] 
public event CxEnumerableDlgt ProducerListLoaded;
[CxEvent] 
public event CxEnumerableDlgt ConsumerListLoaded;

编写 Raise 方法

protected void RaiseProducerListLoaded()
{
  if (ProducerListLoaded != null)
  {
    ProducerListLoaded(this, new CxEventArgs<IEnumerable>(producers));
  }
}

protected void RaiseConsumerListLoaded()
{
  if (ConsumerListLoaded != null)
  {
    ConsumerListLoaded(this, new CxEventArgs<IEnumerable>(consumers));
  }
}

并连接它们,例如:

<Wireups>
  <WireUp Producer="CxDesigner.ComponentsAdded" Consumer="ComponentList.OnData"/>
  <WireUp Producer="CxDesigner.ProducerListLoaded" Consumer="ProducerList.OnData"/>
  <WireUp Producer="CxDesigner.ConsumerListLoaded" Consumer="ConsumerList.OnData"/>
</Wireups>

这需要大量工作,而且是的,框架可以帮助您——例如,我可以要求框架为我创建一个特定的事件,然后我可以触发它。比如:

producerListLoadedEvent = EventHelpers.CreateEvent<IEnumerable>(this, "ProducerListLoaded");
...
producerListLoadedEvent.Fire(producers);

然后就完成了。我们仍然需要使用属性告诉框架我们通过编程公开的事件:

[CxExplicitEvent("ProducerListLoaded")]
[CxExplicitEvent("ConsumerListLoaded")]
public class CxDesigner : CxApp, ICxBusinessComponentClass, ICxDesigner

这与我在此版本中添加的 事件转换 已经做过的事情类似。

既然这看起来很有用,我实际上实现了对上述代码示例的支持。存在恼人的 switch 语句,因为泛型不像 C++ 模板,所以我们无法创建泛型委托。是的,还有一些我在此代码示例中未展示的常见类型。

public static EventHelper CreateEvent<T>(object component, string eventName)
{
  EventHelper ret = null;

  switch (typeof(T).Name)
  {
    case "String":
      ret = new StringEventHelper(component);
      break;
    case "IEnumerable":
      ret = new EnumerableEventHelper(component);
      break;
  }

  componentEventHelpers[new ComponentKey(component, eventName)] = ret;

  return ret;
}

同样出于这个原因,Fire 方法接受一个 object 作为参数,这意味着我们正在装箱/拆箱基本类型(此示例中未显示,但请注意类型转换)。

public override void Fire(object data)
{
  if (Event != null)
  {
    Event(Component, new CxEventArgs<IEnumerable>((IEnumerable)data));
  }
}

我感觉肯定有一种方法可以使用泛型委托,但答案不是 EventHandler<T> 类,因为它不处理基本类型!

事件转换

关于我上面提到的事件转换,这里有一个 CxExplicitEvent 属性的示例(已缩减),因为我们要求框架将 TextBox.TextChanged 事件(签名是(object, EventArgs))转换为具有签名(object, CxEventArgs<string>)的 CX 事件。

[CxExplicitEvent("DisplayTextChanged")]
public partial class TextDisplay : UserControl, ICxVisualComponentClass
{
  public TextDisplay()
  {
    EventHelpers.Transform(this, tbDisplay, "TextChanged", 
                           "Text").To("DisplayTextChanged");
}

这是一个巧妙的小助手,它连接到 TextBox.TextChanged 事件并触发指定名称的 CX 事件。无需对组件包装器进行进一步处理。

决策点:框架为您带来了哪些便利?

在上面 CxExplicitEvent 属性的代码示例中,我这样做的原因是我想让我的生活更轻松。我不想编写代码来获取通用控件事件并将其重新打包成 CX 事件。虽然这让我的开发者生活更轻松,但缺点是框架必须使用反射来访问属性值,这会降低速度。

protected void CommonHandler(object sender, System.EventArgs e)
{
  string val = PropertyInfo.GetValue(Object, null) as string;

  if (Event != null)
  {
    Event(Component, new CxEventArgs<string>(val));
  }
}

所以,如果您发现自己在使用框架提供的机制来让生活更轻松(例如 AOP 功能),请问问自己您是如何:

  • 影响性能(反射、装箱/拆箱、其他开销)
  • 影响代码的可调试性(C# 3.0 中的自动属性 getter/setter 功能是一个很好的例子——您无法对其设置断点)
  • 影响可读性,例如,属性是如何初始化的,或者事件是如何连接的(我经常因为这个问题在 Spring.NET 中抓耳挠腮)
  • (所谓的)可维护性/灵活性/可扩展性的改进是否真的值得?

例如,看看为了支持我上面描述的那种便捷的事件转换语法,仅仅是为了进行初始化而需要做的事情。

public static EventHelper Transform(object component, object obj, 
              string objectEventName, string objectPropertyName)
{
  Type objType=obj.GetType();
  EventInfo ei = objType.GetEvent(objectEventName);
  PropertyInfo pi = objType.GetProperty(objectPropertyName);
  EventHelper helper = null;

  // TODO: Add more intrinsic type transformations.
  switch (pi.PropertyType.Name)
  {
    case "String":
      helper = new StringEventHelper(component, obj, ei, pi);
      break;

    default:
      throw new CxException("No implementation for transforming the event " + 
                            objectEventName + " to a Cx event of type " + 
                            pi.PropertyType.Name);
  }

  return helper;
}

大量的反射,以及那个丑陋的 switch 语句。好吧,至少我没有被限制只能这样做!但考虑到便利性,有多少开发者真的会考虑便利性的成本?

决策点:您希望与框架的耦合程度有多深?

在 CX 的第一个版本中,框架基本上只是一个连接工具,并帮助实例化组件。除此之外,它真的没有妨碍。现在,我们使用属性来装饰类、事件和方法,以便设计器可以发现它们,并且我们添加了一些辅助函数来让处理 CX 式连接(即事件)的开发者生活更轻松。此外,我们使 XML 更加复杂,因为我们要支持深度泛化组件,这意味着在元数据中初始化属性。

关键是,框架对应用程序的侵入性越强,应用程序在其整个生命周期中对框架的依赖就越深。您真的想要那样吗?是否有其他团队可能想重用您的代码,而不必也采用您选择的 IoC 框架?

到目前为止,CX 可以以一种非常非侵入性的方式使用。您不需要使用属性(您可以自己编写 XML),也不需要使用我在此版本中添加的那些花哨的事件转换和辅助工具。坦白说,您在属性和快捷方式方面使用的框架越少,您的组件在 CX 框架之外的可重用性就越高!如果项目要求在 IoC 框架之外实现可重用性,那会很好,否则就没什么区别。当然,别忘了您也接受了框架的理念——在这种情况下,CX 的事件机制用于通信。

决策点:接口和通信

框架的目的是为您提供适当“组件化”应用程序的工具,因为有些管理人员读到 IoC 和 DI 如何解决编写准时、按预算的企业级应用程序的所有问题。好的,到目前为止都很好。但如果您想做得对:

  • 使用接口(参见下文 接口,了解为什么您不需要使用它们);
  • 您需要非常仔细地规划您的接口暴露什么(持续重构:糟糕,我需要访问那个方法);
  • 您需要非常仔细地规划您在组件之间通信什么(持续重构:糟糕,那会很有用)。

我发现这说起来容易做起来难。如果您正在使用 IoC/DI 框架,那么我假设您实际上正在开发一个企业级应用程序(而不是像我这样瞎折腾),在这种情况下,您可能有很多开发者分布在多个团队中,每个团队开发组件、模块、基础设施代码、数据访问层、表示层等等。您需要考虑一下:

您的组件如何相互通信?

几乎可以以一种如此解耦的方式实现所有功能,以至于您的组件甚至不需要知道它们在与谁通信,事实上,也不知道是否有人在监听。您可以使用命令模式来启动另一个组件中的活动,并可以使用属性更改事件在组件之间移动数据。在这个抽象级别上,您实际上不需要太多接口,因为您的组件是如此自主,它们从不通过标准方法调用直接与其他组件通信。我个人喜欢这种方法。

接口

例如,我为设计器的业务组件使用了一个接口,并通过 CX 框架调用来获取组件:

ICxDesigner designerComponent = designer.GetComponent<ICxDesigner>();
designerComponent.LoadComponents(Path.GetFullPath("cxdesigner.xml"));

但我不必这样做。相反,我可以创建一个命令事件,在我的应用程序中:

static class Program
{
  [STAThread]
  static void Main()
  {
    Application.EnableVisualStyles();
    Application.SetCompatibleTextRenderingDefault(false);

    CxApp designer = CxApp.Initialize(Path.GetFullPath("cxdesigner.xml"));
    App app = designer.GetComponent<App>();
    app.Initialize(Path.GetFullPath("cxdesigner.xml"));
    Application.Run(new Form1(designer.VisualComponents));
  }
}

public class App : ICxBusinessComponentClass
{
  protected EventHelper initialize;

  public App()
  {
    initialize = EventHelpers.CreateEvent<string>(this, "Initialize");
  }

  public void Initialize(string fn)
  {
    initialize.Fire(fn);
  }
}

并在设计器业务组件中,处理程序:

// We call it a Handler to avoid ambiguous match.
[CxConsumer]
public void LoadComponentsHandler(object sender, CxEventArgs<string> args)
{
  LoadComponents(args.Data);
}

现在,让我们看看 XML。我将 App 类初始化为一个业务组件:

<BusinessComponent Name="App" ComponentName="App" Assembly="Cx.Designer.App.exe"/>

并将生产者与消费者连接起来:

<WireUp Producer="App.Initialize" Consumer="CxDesigner.LoadComponentsHandler"/>

所以,通过一点额外的代码,我实现了一个非常有趣的东西:我完全消除了应用程序需要设计器业务组件接口的需求!

我真的想强调这一点:通过将架构从接口调用更改为命令模式,我消除了对接口的需求。通过将架构从使用接口更改为使用命令模式,我消除了对接口的需求,并改进了我的代码。我已:

  • 使我的应用程序初始化更加强大,因为其他组件可以接入我应用程序的初始化命令;
  • 通过消除接口使代码更简单(少了一个程序集引用,少了一段代码需要维护);
  • 使用命令模式来启动活动而不是方法调用,使我的设计器更加健壮;
  • 通过使用事件机制而不是直接方法调用,降低了性能;
  • 使不熟悉情况的开发者更加困惑,他们会想,UI 的数据是怎么来的?

现在,这里有一个非常有趣的最终观点:通过使用事件驱动的命令模式,框架可以自动记录组件之间所有交互的日志。我将在下一部分(将是一篇较短的文章!)中实现这一点。

您真的需要重构吗?

在企业级、多团队环境中,如果您选择接口方法,您很可能大部分时间都处于接口重构的等待状态,因为新功能会添加进来,并且需要重建所有依赖于这些组件的项目。如果您选择命令模式(无论机制如何),您就不需要所有这些接口来启动组件中的活动,而是您的团队只需更新他们的程序集,您就可以使用设计器将新功能连接到您组件的事件上。

啊,但是有一个陷阱!您可能仍然需要修改您的项目以添加一个事件,以便为新命令触发!但是,现在您有了一个选择——如果您不需要在组件中公开该命令,您实际上不必修改您的代码,而且更好的是,您的项目将不需要重新构建。

依赖注入

我之前提到,使组件更通用会将这些组件的初始化推到 XML 中。这并不完全正确——您可以在代码中进行最终初始化。坦白说,我认为这两种选择都有问题。如果您在代码中进行最终初始化:

  • 您是命令式地编写初始化代码,这会降低灵活性(如果您一开始就需要它的话)。
  • 代码需要访问组件,这意味着您可能需要实现接口,只是为了最终初始化。
  • 当组件加载时,执行最终初始化的代码需要运行。该机制可能很晦涩。

如果您在元数据中进行初始化:

  • 嗯,它在元数据中而不是代码中,所以这是另一个需要解析器处理的格式。
  • 设计器需要支持这一点。这意味着需要另一个 UI 来管理属性和框架属性,以发现对设计器可见的属性。
  • 您无法获得智能感知或编译时语法检查。
  • 但随着需求的变化,重新配置应用程序可能更容易。

当然,通过打开元数据中的一个机制来支持属性初始化,我们就打开了我认为是某种“潘多拉魔盒”的东西:依赖注入 (DI),意味着一个属性可以用作其他组件的实例来初始化,而不仅仅是一个简单的内在值。我为什么认为这是一个潘多拉魔盒?因为它很容易破坏架构目标:

  • 注入不实现接口的实例很容易,违反了重要的 IoC 规则,并使得模拟或替换该实例变得不可能;
  • 既然应该(必须)使用接口,那么命令模式带来的完全解耦的好处就被规避了;
  • 既然可能不使用命令模式,那么您就失去了所有组件之间通信的自动日志记录;
  • 并且,由于没有使用命令模式,您就失去了其他组件监听命令的能力。

我们需要依赖注入吗?

您不需要。在一个基于事件的动态组合框架中,您实际上只需要为其他组件触发一个事件来执行某些操作,并通过事件来获取您组件所需的内容。将组件实例注入到其他组件的属性中,会在应用程序上强制实施某种架构,而架构师并没有有意识地做出接受这种范式的决定。另一方面,正如我之前提到的,基于事件的命令模式会产生影响性能的开销,并需要一些额外的编码,所以实际上,任何大型应用程序可能都会以一种智能地考虑每种方法的优缺点的平衡方式来结合组件引用注入和命令模式的使用。

XML 中的属性初始化

我在上一篇文章中收到的一些评论是 XML 干净且易于阅读。所以,我决定将属性初始化放在一个单独的章节,这与文章前面的示例不同。例如,组件列表的最终初始化描述如下:

<Properties Component="ComponentList">
  <Property Name="Label" Value="Components:"/>
  <Property Name="HeaderText">
    <Item Value="Name"/>
    <Item Value="Component Name"/>
    <Item Value="Assembly"/>
  </Property>
  <Property Name="HeaderWidths">
    <Item value="200"/>
    <Item Value="200"/>
    <Item Value="600"/>
  </Property>
 <Property Name="DataPropertyNames">
    <Item Value="Name"/>
    <Item Value="ComponentName"/>
    <Item Value="AssemblyFilename"/>
  </Property>
</Properties>

坦白说,我认为这使得设计器更容易处理 XML,并且消除了组件列表中的这些内容。虽然 XML 在创建复杂对象图方面非常出色,但我发现实际上(对于开发者和底层代码来说),将对象图分离到不同的部分通常更好。

属性在框架中进行初始化。

protected void FinalComponentInitialization()
{
  Verify.IsNotNull(xdoc, "Configuration XML file must be loaded " + 
                   "before loading business components.");

  foreach (ICxComponent comp in components.Values)
  {
    foreach (var property in from el in xdoc.Root.Elements("Properties")
      where el.Attribute("Component").Value == comp.Name
      select new
    {
      Property = el.Elements("Property")
    })
    {
      foreach (var prop in from el2 in property.Property
        select new
      {
        Name=el2.Attribute("Name").Value,
        Value=(el2.Attribute("Value")==null ? null : el2.Attribute("Value").Value),
        Items=el2.Elements("Item"),
      })
      {
        if (!String.IsNullOrEmpty(prop.Value))
        {
          InitializeProperty(comp, prop.Name, prop.Value);
        }
        else
        {
          List<string> itemVals = new List<string>(from el3 in 
                                      prop.Items select el3.Attribute("Value").Value);
          InitializeProperty(comp, prop.Name, itemVals);
        }
      }
    }
  }
}

protected void InitializeProperty(ICxComponent comp, string propName, object propValue)
{
  PropertyInfo pi = comp.Instance.GetType().GetProperty(propName, 
       BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance);
  Verify.IsNotNull(pi, "The component " + comp.ComponentName + 
                   " does not implement the property " + propName);
  pi.SetValue(comp.Instance, propValue, null);
}

哦,我提到通过反射进行所有这些初始化当然有一个性能损失了吗?

模型:开发者想要 vs UI 想要 vs 数据服务想要

在我进行这个练习的过程中,我一直在问自己的一个问题是:如果我们完全解耦组件,那么模型由谁来维护?模型是否存在一个集中的地方?例如,如果数据服务与模型完全解耦(没有接口,只有属性更改通知和事件命令),那么模型最终会存在于两个地方:数据服务中的模型会随着模型的变化而更新,而当从数据存储中获取新内容时,模型也会更新。当然,我本身就厌恶“特定于模型的数据服务”这个概念。是的,您没看错。我总有一天会研究这个问题,但就目前而言,只要说,如果您忽略我的极端架构实践,您很可能希望在您的模型上有一个接口,供数据服务使用。

强行解决:组件列表的持久化

有什么比通过强制执行它来更好地探索这个问题呢?将持久化(目前只是构建列表)从 CX 框架移到一个 CX 组件中。目前,模型来自 XML 文件,但如果我泛化这一点,我们可以将文件组件替换为不同的持久化服务,例如数据库或其他文件格式。这迫使我至少探索在动态组合(借用 WEF 项目 的说法)框架中模型管理的最佳实践。

引导

尝试这种分离会立即产生一个问题:加载器本身作为一个组件,无法初始化,因为现在是马车在前,马在后:框架尚未初始化,因为它需要来自加载器的数据!所以,我们现在需要一个引导过程,该过程执行几项操作:

  • 加载数据服务程序集;
  • 指示它加载元数据,即我们的组件配置信息;
  • 将数据对象交给 CX 框架;
  • 指示 CX 框架进行组件初始化——类实例化、连接和最终属性初始化。

我们必须为数据服务使用接口,因为框架尚未启动。结果实际上出人意料地更好。因为我从 CxApp 类中删除了所有 XML 处理,所以它现在更加纯粹——它处理实例化、连接和最终属性初始化。仅此而已!

从应用程序的角度来看,初始化需要一个额外的参数:数据服务程序集。

// Initialize all components.
CxApp designer = CxApp.Initialize(@"..\..\..\Cx.DataService\bin\debug\Cx.DataService.dll", 
                  Path.GetFullPath("cxdesigner.xml"));

虽然我在这里硬编码了它,但我相信您会意识到这可以被外部化,事实上,可以与元数据文件关联。

在内部,CxApp 在运行时实例化数据服务。

public static CxApp Initialize(string dataServiceAssemblyFilename, string configUri)
{
  CxApp cx = new CxApp();
  cx.InitializeDataService(dataServiceAssemblyFilename, configUri);
  cx.InstantiateComponents();

  return cx;
}

protected void InitializeDataService(string dataServiceAssemblyFilename, string configUri)
{
  Assembly assy = Assembly.LoadFrom(dataServiceAssemblyFilename);
  Type t = FindImplementor(assy, "CxDataService", typeof(ICxBusinessComponentClass));
  dataService = (ICxDataService)Activator.CreateInstance(t);
  dataService.LoadComponents(configUri);
  components = dataService.Components;
  wireups = dataService.Wireups;
  propertyValues = dataService.PropertyValues;
}

然而,一旦框架启动并运行,模型组件就可以使用事件机制与数据服务组件进行交互:这就是我在设计器模型实现中所做的。

<BusinessComponent Name="CxDesigner" 
   ComponentName="CxDesigner" 
   Assembly="..\..\..\Cx.Designer\bin\debug\Cx.Designer.dll"/>
<BusinessComponent Name="DataService" 
   ComponentName="CxDataService" 
   Assembly="..\..\..\Cx.DataService\bin\debug\Cx.DataService.dll"/>
...
<WireUp Producer="CxDesigner.RequestLoadComponents" Consumer="DataService.OnLoadComponents"/>
<WireUp Producer="DataService.ComponentsLoaded" Consumer="CxDesigner.OnComponentsLoaded"/>

模型 - 存储库的张力

从上面的代码示例可以看出,数据服务创建了三个集合(组件、连接和属性值),模型实例引用了这些集合。我相信一个更好的模型将基本上颠倒上面的例子:模型成为数据存储库更有意义。如果我们想从一个数据服务读取数据并写入另一个数据服务呢?如果我们想在 CX 更高级的版本中,将数据服务加载到其自己的应用程序域中,以便框架可以卸载它呢?

我们需要考虑,在像 CX 这样的动态组合应用程序框架中,组件之间通信数据的性质:

  • 如果数据被推送到应用程序
    • 谁会收到通知——模型还是数据服务?
    • 如果通知了数据服务,它如何更新模型?
  • 我们是公开加载和保存方法还是命令
    • 在模型中,然后通过接口与数据服务通信?
    • 在数据服务中,然后与模型通信以访问数据?
    • 在一个单独的控制器中?
  • 假设我们已经运行了框架,并且有一个模型和数据服务,它们是完全解耦的组件,现在我们可以利用框架的能力来连接模型和数据服务之间的交互,而不是使用接口。
    • 避免在数据服务和模型之间重新创建集合似乎是个好主意,但这真的最好吗?我们不知道组件将来可能如何使用,并且数据服务为某个组件维护的集合可能最终会被具有不同标准的另一个组件修改。
      • 这实际上要求模型维护一个独立于数据服务的集合!
  • 对象映射器在这种环境中如何工作?
  • 在分页数据检索环境中,这如何工作?
  • 在异步数据检索环境中,这如何工作?

我认为(管理数据集合的)这些问题要求模型是与其实例相关的集合的“主导者”。数据服务为通信目的创建的任何集合都可以在该通信后被丢弃。然而,我们真的不希望消费者担心这个问题,所以我们复制了数据来源组件中的集合。

protected void LoadConfigurationMetadata()
{
  LoadVisualComponents();
  LoadBusinessComponents();
  LoadWireups();
  LoadProperties();
  componentsLoaded.Fire(new Dictionary<string, ICxComponent>(Components));
}

UI - 模型张力

UI-模型张力的通常架构解决方案是数据绑定。是的,UI 和模型之间存在张力,因为模型可能想要不同类型的数据(一个整数属性连接到一个 TextBox 控件),当然还有货币管理。自定义类型转换器经常用于处理更复杂的数据类型不匹配,另一个选项是 视图-模型-模型-视图 模式(不仅仅用于 WPF),特别是用于管理 UI 状态。.NET 框架提供了丰富的基础设施,可以将几乎任何东西绑定到控件,这提供了一种将数据在 UI 中的表示映射到模型表示的机制。几乎任何离散值都可以绑定到控件的属性,并且任何实现 IListIListSource 的集合。

我将从 CX 设计器业务组件中提取一行代码来说明一些问题。

componentListLoadedEvent.Fire(
    new SortableBindingList<ICxComponent>(
      new List<ICxComponent>(components.Values)));

这真的很糟糕。

  1. 组件集合是对数据服务管理的集合的引用;
  2. 此字典的值是:组件本身;
  3. 但是,为了能够绑定,我们必须将 ValueCollection 转换为实现 IList 的内容。ValueCollection 类不实现 IList
  4. 并且,为了可排序,我们使用我在这里找到的 SortableBindingList 类创建了一个新列表,这个类非常有用的;
  5. 我们现在创建了两个新集合:一个来自 ValueCollectionIList 集合,以及一个来自 IList 集合的 SortableBindingList 集合!
  6. 我们正在设计器业务组件(它管理正在设计的元数据模型)中执行此操作,而不是在 UI 组件级别执行此操作;
  7. 我们在这里不使用数据绑定:事实上,我们无法做到,因为字典的值集合是不可绑定的;
  8. 接收此消息的接收者是否真的关心派生自 IList 的集合的排序?ValueCollection 类实现 IEnumerable,那么这是否足够?问题在于,如果集合不派生自可排序列表,DataGrid 控件将不会提供可点击的标头供用户按字段排序。

这一行代码存在如此多的问题,简直令人难以置信。

让我分别总结一下这些缺点,对应上面的编号点:

  1. 好吧,我们以后再担心;
  2. 设计器实际上不需要字典(CX 框架需要,但设计器业务组件不需要)。这与第一个问题相吻合——我们需要以不同的方式处理数据,所以数据服务不能对我们希望数据的方式做出假设。因此,为避免复制数据,专门化数据服务以适应我们期望的数据方式是有意义的;
  3. 同样,我们没有以正确的方式从数据服务获取数据;
  4. 现在我们做了一些更特定于 UI 组件数据需求的事情,但如果我们给组件一个不可排序的集合,它仍然需要创建一个新的可排序集合;
  5. 是否有可能至少消除其中一个副本?
  6. 事实上,UI 组件实际上不应该关心数据格式。一种非常巧妙的解决方法是注入一个辅助业务流程,将 List 转换为可排序列表。这很容易,因为我们可以将这个过程注入到“我有数据”的生产者事件和消费者处理程序之间。目前最简单的方法是修改连接并引入此过程。我想立即实现的最后一件事是 AOP 风格的事件注入功能;
  7. 坦白说,数据绑定可能不是动态组合应用程序的最佳机制。多个对象属性不能绑定到同一个控件属性,并且数据绑定是“静默的”——我们无法附加额外的侦听器。另一方面,数据绑定非常方便,我们可能应该在 CX 框架中创建一些辅助方法来复制数据绑定的功能。
  8. 我的结论是,我们不应该在生产者中操作数据,仅仅因为我们碰巧知道消费者想要什么,因为我们可能会浪费时间并以某种其他消费者不想要的数据格式创建数据。

这些问题中的大多数都可以通过实现一个辅助类并遵循最佳实践规则来解决,即,是的,在将集合(在某些情况下,对象本身)从一个组件发送到另一个组件时应该进行克隆。所以,数据服务会创建一个副本:

componentListLoaded.Fire(new List<ICxComponent>(ComponentList));

接下来,我们创建一个转换器,将数据从 List 复制到 SortableList

转换器组件

组件使用泛型类型声明进行实例化,以转换类型。

<BusinessComponent Name="ComponentBindingConverter"
       ComponentName="Cx.Converters.CxBindingConverter`1
       [[[Cx.Interfaces.ICxComponent, Cx.Interfaces, 
       Version=1.0.0.0, Culture=neutral, PublicKeyToken=null]]" 
       Assembly="..\..\..\Cx.Converters\bin\debug\Cx.Converters.dll" />

该组件将列表转换为 SortableBindingList,并且实例在数据服务和 UI 组件之间连接。

<WireUp Producer="CxDesigner.ComponentListLoaded" 
        Consumer="ComponentBindingConverter.OnConvertToSortedBindingList" />
<WireUp Producer="ComponentBindingConverter.Converted" Consumer="ComponentList.OnData" />

转换器的实现非常简单:

namespace Cx.Converters
{ 
  [CxComponentName("Cx.Converters.CxBindingConverter")]
  [CxExplicitEvent("Converted")]
  public class CxBindingConverter<T> : ICxBusinessComponentClass
  {
    protected EventHelper converted;

    public CxBindingConverter()
    {
      converted=EventHelpers.CreateEvent<IEnumerable>(this, "Converted");
    }

    [CxConsumer]
    public void OnConvertToSortedBindingList(object sender, 
                CxEventArgs<IEnumerable> args)
    {
      SortableBindingList<T> sbl = new SortableBindingList<T>((List<T>)args.Data);
      converted.Fire(sbl);
    }
  }
}

唯一的真正技巧是在实例声明中指定类型 T:整个

`1[[Cx.Interfaces.ICxComponent, Cx.Interfaces, 
       Version=1.0.0.0, Culture=neutral, PublicKeyToken=null]]

类定义的这一部分。可能,随着 C# 4.0 的协变/逆变能力,这将不再是必需的。

此外,我将借此机会感谢 这篇文章,我从那里获得了 SortableBindingList 的实现。

声明式困境

一旦我们走上了声明式之路,就很难甚至不可能停止。例如,以菜单(或更普遍的工具栏)为例。菜单本身就是一种难题:

  • 它们是分层的;
  • 它们可以包含复选标记甚至其他控件等选项;
  • 单击菜单项会触发一个事件。

在组件化应用程序中,菜单会带来很多问题:

  • 您是否在 XML 中表示它们,使用另一种特定于菜单的模式?
  • 您是否允许开发者使用 VS 设计器创建菜单?
  • 如果您在 XML 中创建菜单,您可以命名单击事件,CX 设计器可以找到它们。这是一件好事,但现在 CX 设计器还必须支持设计菜单,这增加了复杂性;
  • 如果您允许开发者使用 VS 设计器创建菜单,CX 设计器如何在生产者列表中显示菜单单击事件?在某个地方创建显式事件,并将其与某个菜单可视化组件关联,这似乎很烦人;
  • 说起来,菜单栏(或工具栏)是否实现为 CX 可视化组件?

目前,我对此没有好的答案。我决定使用 Visual Studio 来创建 CX 设计器的菜单,以便至少探索一部分问题。为此,我创建了一个可视化组件:

<VisualComponent Name="Menu" 
    ComponentName="CxMenu" 
    Assembly="..\..\..\Cx.Designer.Components\bin\debug\Cx.Designer.Components.dll" />

其唯一目的是递归遍历 MenuStrip 并实例化事件辅助程序,通过追加“Click”来修改菜单名称。

protected void RegisterEvent(ToolStripMenuItem tsmi)
{
  EventHelpers.Transform(this, tsmi, "Click").To(tsmi.Name + "Click");
}

最初,我手动连接了事件,因为 CX 设计器在设计时对菜单事件一无所知。

<WireUp Producer="Menu.mnuSaveClick" Consumer="CxDesigner.OnSave" />
<WireUp Producer="Menu.mnuExitClick" Consumer="App.CloseDialog" />
<WireUp Producer="Menu.mnuOpenClick" Consumer="CxDesigner.OnOpenMetadata" />
<WireUp Producer="Menu.mnuNewClick" Consumer="CxDesigner.OnNewMetadata" />
<WireUp Producer="Menu.mnuSaveAsClick" Consumer="CxDesigner.OnSaveAs" />

但既然不行,我还是屈服了,并在 XML 中添加了一个 ExplicitEvents 部分。

<ExplicitEvents Producer="Menu">
  <Event Name="mnuSaveClick"/>
  <Event Name="mnuExitClick"/>
  <Event Name="mnuOpenClick"/>
  <Event Name="mnuNewClick"/>
  <Event Name="mnuSaveAsClick"/>
</ExplicitEvents>

我认为这与使用 ExplicitEventAttribute 是一个替代方案(替代方案是好的),所以它通常很有用。

推送 vs. 拉取:数据

我不得不为设计器解决的另一个有趣(如果可以这么说)的问题是关于将数据从一个组件拉取到另一个组件。同样,UI 提供了一个问题示例:用户选择一个组件,然后单击“属性”或“事件”按钮。此时,一个子对话框会弹出,并需要显示一些属性或事件列表,这些列表基于在父对话框中选择的组件——在此例中是设计器的主窗体那么,此时子对话框如何获取它所需的信息?这在很多方面都是经典的初学者问题,即如何将信息传递给子对话框。通常的答案是,在显示对话框之前使用属性设置器,但这在动态组合环境中不起作用,因为组件的解耦程度很高。在像 CAB 和 Spring.Net 这样的依赖注入框架中,答案相当直接:您可以在元数据中指定属性初始化,并引用在某个容器中管理的对象的引用。

命令式 / 声明式张力

在我对使用我更有限的 CX 框架的 IoC/DI 概念的探索中,我仍然不愿意完全进入 DI 的世界,因为(尽管可能显得讽刺)我试图限制元数据中存储的信息量。这使得编写设计器更加复杂,我正试图达到初始版本,而不需要为框架添加太多设计器需要支持的新功能。正如我们在菜单中所见的,命令式操作和如何将这些信息输入设计器以进行声明式部分之间存在脱节。我们根本不知道在命令式地将哪些对象放入哪些容器,以便在设计时,它们可以被注入到属性中或参与声明式代码中的构造函数参数。答案是“所有需要注入或参与构造函数参数的对象也应该以声明式方式进行初始化”,这对我来说不可行,因为它将开发者和应用程序与框架过于紧密地联系在一起。并非说这在任何实际意义上都是可以避免的,但至少应该予以考虑。

一个解决方案

<BusinessComponent 
    Name="AppProperties" 
    ComponentName="App" 
    Assembly="C:\projects2008\Cx\Cx.Designer.App\bin\Debug\Cx.Designer.App.exe" />

现在,我们可以将窗体的实例化与此特定应用程序实例连接起来(请注意,此连接位于主窗体的元数据中)。

<WireUp Producer="CxDesigner.EditProperties" Consumer="AppProperties.ShowModalForm" />

现在模型可以消费初始化事件(注意,此连接位于子窗体的元数据中)。

<WireUp Producer="AppProperties.FormInitialized" Consumer="EditProperties.OnInitialize" />

并从中进行任何需要的初始化,并在初始化完成后触发事件,这些事件可以连接到其他组件,包括可视化组件。

移除连接

当我开始使用设计器时,我注意到与对话框关联的业务组件中存在一个恼人的行为:组件之间的连接没有被移除。换句话说,即使没有对组件的引用,第二次使用相同的对话框时,连接到旧业务组件的事件也会被触发,连接到新业务组件的事件也会被触发。我首先在全局业务生产者到本地业务消费者事件中发现了这一点。例如,设计器组件(它是单例,因此是全局的)触发一个带有选定组件属性信息的事件,该事件被“编辑属性”业务组件(对话框特有的本地组件)所消费。

实现 Dispose

第一个问题是我没有将业务组件从全局缓存中移除,所以即使是引用计数为 0 的组件也被重复使用。似乎最好的机制是让 CX 类实现 IDisposable,并在不再需要 CXApp 实例时调用 Dispose 方法,或者将其包装在“using”块中。

[CxConsumer] 
public void ShowModalForm(object sender, CxEventArgs<string> args)
{
  using (CxApp dlg = CxApp.Initialize(@"..\..\..\Cx.DataService\" + 
                     @"bin\debug\Cx.DataService.dll", 
                     Path.GetFullPath(args.Data)))
  {
    dlg.WireUpComponents();
    Form form = new Form();
    Size extents = new Size(0, 0);

    foreach (ICxVisualComponent comp in dlg.VisualComponents)
    {
      ((ICxVisualComponentClass)comp.Instance).Register(form, comp);
      extents = Program.UpdateExtents(comp, extents);
    }

    form.Size = extents + new Size(25, 40);
    form.Activated += new EventHandler(OnActivated);
    formInitialized.Fire();
    form.ShowDialog();
    // The dispose method call is critical to remove
    // wireups to components used in this dialog.
  }
}

我对这种方法不太满意,因为开发者需要记住 Dispose CxApp 实例——等待垃圾回收器来调用 Dispose 方法,同时事件仍然连接,可能会导致一些非常意外的副作用。

Dispose 方法本身中,组件引用从全局缓存中移除。

protected void Dispose(bool disposing)
{
  if (!disposed)
  {
    if (disposing)
    {
      List<string> markedForRemoval = new List<string>();

      // Remove business component reference counts.
      // If 0, we can remove the entry in the dictionary.
      foreach (ICxBusinessComponent comp in busCompList)
      {
        ComponentReference compRef=null;

        if (businessComponentCountMap.TryGetValue(comp.Name, out compRef))
        {
          --compRef.Count;

          if (compRef.Count == 0)
          {
            markedForRemoval.Add(comp.Name);
          }
        }
      }

      // Remove all component references marked for removal.
      foreach (string name in markedForRemoval)
      {
        components.Remove(name);
        businessComponentCountMap.Remove(name);
      }

      RemoveWireups();
      visCompList.Clear();
      busCompList.Clear();
    }

  disposed = true;
  }
}

组件引用和初始化

这引出了一个有趣的问题:当引用计数变为 0 时,我们是否应该实际从缓存中移除组件,这会在下次需要组件时强制重新实例化,还是应该将其留在缓存中以便重复使用?虽然这仅适用于业务组件,但这个决定对组件的构造函数初始化有严重影响:重复使用的组件的构造函数永远不会只调用一次,而是每次实例化组件都会调用。

移除连接

移除连接的最简单方法是将连接添加到集合中,当 CxApp 实例执行连接时。

wireupInfo.Add(new WireupInfo(ei, producerTarget, dlgt));

并且当 CxApp 实例被释放时,取消生产者-消费者连接。

protected void RemoveWireups()
{
  foreach (WireupInfo wi in wireupInfo)
  {
    wi.Remove();
  }

  wireupInfo.Clear();
}

业务组件 GUID

这解决了我的事件触发到“旧”业务组件实例的问题。并非易于理解。有时我真希望我能看到对象的实际内存地址,这样我就可以轻松地分辨事件是触发到不同的组件实例还是同一个实例。这实际上为业务组件分配 GUID 提供了一个论据。

public class CxBusinessComponent : CxComponent, ICxBusinessComponent
{
  /// <summary>
  /// The Guid is useful for determining different instances
  /// of the same named business component.
  /// </summary>
  public Guid Guid { get; protected set; }

  public CxBusinessComponent()
  {
    Guid = Guid.NewGuid();
  }
}

现在我们可以通过检查 GUID 来判断实例是否不同。

我们玩得开心吗?

我必须承认,这以一种病态和扭曲的方式很有趣。我发现:

  • 我写了很多生产者/消费者事件;
  • 我进行了大量的属性编程;
  • 随着我实现功能的增多,我越来越多地使用 CX 设计器;
  • 它确实强制了组件的分离(没有接口,没有直接引用),以及组件之间非常清晰、明确的合同(事件生产者和方法消费者)。虽然我喜欢这一点,但它也可以被视为一种痛苦;
  • 很多我通常会采取的快捷方式实际上是无法采取的。例如,如果我有一个应该打开对话框的按钮,我通常会子类化按钮,添加一个属性来指定它应该打开的对话框,然后将 Click 事件连接到子类化的按钮中以加载该对话框(因为这很可能是许多应用程序的常见 UI 模式)。我不能在 CxButtCxButtonon 组件中这样做,因为它无法访问应用程序的“ShowModalForm”方法。作为替代,我可以触发一个事件,但这感觉很糟糕——我将业务逻辑放入 UI 组件中,而不是保持更纯粹的实现:“按钮被点击了”,然后让设计器业务组件决定做什么。我也更喜欢这种方法,因为它明确说明“设计器组件有一个消费者将打开此对话框”,因此任何东西(不仅仅是按钮)都可以源这个事件。我真的认为这是一件好事,当然,它也可以被推向极端,所以像任何事情一样,关键不是要做极端主义者,而是要意识到我们否则就处于隐性编程决策的之中;
  • 连接组件之间的离散行为并观看它们协同工作,实际上非常有趣;
  • 我正在以离散的功能包进行编码,而不怎么考虑代码将如何用于特定需求,而是保持通用性;
  • 我还注意到,真正地模块化某些东西(特别是 UI 功能)并不容易,而不进行一些返工/重新思考。例如,为了处理 VS 创建的菜单,我不得不修改所有当前的 UI 组件,添加一个 Register 方法,该方法在 ICxVisualComponentClass 接口中声明。这(我认为是积极的)效果是将将控件添加到窗体集合的代码移到了控件本身,但我最终使用了扩展方法来扩展 Control 类的行为;
  • 我放弃了数据绑定。这是一件好事吗?有没有一种好的方法可以将数据绑定强制用于 CX 消息机制,以便我们可以像跟踪任何其他 CX 消息一样跟踪数据绑定消息?
  • 组件命名——目前,如果“子级”(通常适用于子对话框)中使用相同的名称,则会重用组件实例。这不能处理相同事物的不同上下文,例如 MDI 场景!
  • 某种文档生成会非常有帮助,因为您不知道消费者参数签名应该是什么,而无需查看事件生产者签名。而且,这也将是一个很棒的功能添加到设计器,以便它只显示签名与生产者匹配的消费者。这将消除许多混淆,更不用说避免运行时事件连接异常。

例如,我确实觉得能够获取菜单的 Save As 事件,并将其连接到设计器的消费者,这真是太酷了。

<WireUp Producer="Menu.mnuSaveAsClick" Consumer="CxDesigner.OnSaveAs" />

该消费者是“模型”,它设置一个过滤器并触发自己的事件。

[CxConsumer]
public void OnSaveAs(object sender, System.EventArgs args)
{
  saveAs.Fire("xml files (*.xml)|*.xml|all files (*.*)|*.*");
}

然后该事件连接到 App 消费者。

<WireUp Producer="CxDesigner.SaveAs" Consumer="App.ShowSaveAsDialog" />

其实现会弹出 Save As 对话框,如果用户不取消,则会简单地触发一个带有文件名的事件。

[CxConsumer]
public void ShowSaveAsDialog(object sender, CxEventArgs<string> args)
{
  SaveFileDialog sfd = new SaveFileDialog();
  sfd.Filter = args.Data;
  DialogResult ret = sfd.ShowDialog();

  if (ret == DialogResult.OK)
  {
    save.Fire(sfd.FileName);
  }
}

该事件又连接回设计器组件。

<WireUp Producer="App.Save" Consumer="CxDesigner.OnSaveFilename" />

该组件又设置其文件名,触发一个事件表示文件名已设置,并最终触发另一个事件到数据服务以保存模型。

[CxConsumer]
public void OnSaveFilename(object sender, CxEventArgs<string> args)
{
  metadataFilename=args.Data;
  filenameSet.Fire(metadataFilename);
  SaveModel();
}

顺便说一句,文件名设置事件连接到一个 App 消费者。

<WireUp Producer="CxDesigner.FilenameSet" Consumer="App.SetCaption" />

该消费者更新窗体的标题。

[CxConsumer]
public void SetCaption(object sender, CxEventArgs<string> args)
{
  Program.mainForm.Text = "Cx Designer - "+Path.GetFileName(args.Data);
}

这种模式的优点在于从通用到具体的通信模式:生产者通常是一个通用的“我在说什么”,而消费者是“我将对你说的话做一些具体的事情”。生产者不知道也不关心是否有人在听。

所以是的,我玩得很开心,但实现这些事件很繁琐。应该有一种更好的方法,而不是为每个事件创建一个 EventHelper 字段和一个类的 ExplicitEvent 属性。仅仅通过一个属性来创建事件就应该足够了!

而且,当然,我还没有做但会做的一件事是编写一个事件监视器,这样您就可以获得一个很好的审计日志,记录所有消息和组件之间发送的数据事件。

几个缺失的功能

设计器缺少两样东西(除了更好的 UI):

  • 编辑组件定义
  • 定义泛型类型
  • 管理工具栏/菜单并不好看
  • MDI 支持

结论

在编写这个并不性感的“设计师”的过程中,我终于有机会研究了 IoC 框架的优缺点,并总结出了一些最佳实践。我相信还有很多工作要做,但无论这个“设计师”是否性感都无关紧要——无论 UI 看上去多么酷炫,这些问题都是一样的。例如,有没有一个 WPF UI 可以让你绘制事件生产者和方法使用者之间的连接呢?

现在,写完这些之后,我突然意识到,事件的整个结构和使用方式只是一种消息传递,因此使用事件的细节不应该暴露给组件。相反,如果组件能够与一个抽象的消息系统协同工作,而这个系统又使用事件(例如,在 WCF 中,很容易将 TCP 通信管道替换为 HTTP 管道),那会更好。这当然会大大提高处理跨网络等通信的灵活性。

另一个问题是,对于一个大型应用程序,生产者和使用者组件的列表会变得相当大,而每个生产者/使用者对应的生产者事件和使用者方法数量可能也会很多。事实上,即使是 Cx Designer,列表的大小也已经接近难以管理了。因此,任何方便将生产者与使用者连接起来的设计师 UI 都必须经过精心设计,以便于开发者使用,而不是成为障碍。

此外,指定哪些事件可以异步运行也变得异常简单。例如,可以在数据服务中异步加载组件、生产者和使用者列表,填充 UI 组件也可以异步进行。如果我为加载 XML 的每个部分创建了特定的使用者,而不是拥有一个“加载”使用者,事件连接就可以指定“异步执行此操作”。当然,这会产生一些有趣的后续问题,因为所有这些事件都在空中飞舞——如果你处理的是一个异步事件,如何协调返回到主应用程序线程来更新 UI,以及如何管理工作线程?

如我上文所述,我认为一个非常有用的功能是“记录”生产者的事件签名,这样设计师就只会显示能够消费该签名的使用者。在我想用这个框架做的所有事情中,这可能和事件日志记录器/查看器一样,都排在首位。

所以,未来的系列内容有很多值得思考的有趣之处。

© . All rights reserved.