编程应该如何进行






4.90/5 (48投票s)
Cx:一个关于组件松耦合的原型。
引言
这是我一直认为我们现在在软件开发方面应该能够做到的一件事的概念性文章,但似乎我们离它还有好几年。我受到了今天与 Jim Crafton 的一次谈话的启发,重新拾起了这个想法(2009 年 6 月 10 日)。我们谈论的内容并不是特别具体,但它启发了我把这个东西整理出来。
这个想法是让组件彼此完全分离,将它们视为信息的生产者和消费者,并使用元数据来连接它们之间的通信(如果你愿意,这可以成为应用程序域的粘合剂)。这是一个简单且非原创的概念。因此
- 你有组件,例如 UI 元素、业务逻辑、持久化存储接口、服务等等。
- 这些都实现了可通过反射或装饰方法的属性发现的消费者方法(事件处理程序)。
- 同样,组件(生产者)也可以触发事件。UI 事件通常是按钮点击、列表选择或文本框更改。业务组件事件通常是数据值更改。数据层事件通常是事务响应或“这里有一些数据”事件。
- 现在,我们应该能够通过绘制来做到的是,将生产者事件连接到消费者方法,从而连接组件,并实现应用程序特定的功能。
对于这个原型,我不会创建一个可视化编辑器——我将通过向你展示如果你通过元数据连接事件和方法会发生什么来阐述这个概念。听起来熟悉吗?应该如此。我预计(因为在我写这个介绍时还没有写一行代码)我将使用 XML 来进行这种连接(别皱眉,Jim)。
此外,这种方法可以实现在运行时或编译时通过从元数据生成必要的代码来执行连接。我为这个原型选择了运行时。
我面临的最大问题是如何给这个基础设施命名。我认为我会选择一个非常基础的名字。Cx。“C”是因为它是一个用于组件化应用程序的基础设施,“x”是因为任何酷的东西都必须有一个“x”。而且,如果你说得很快,把“C”和“x”连在一起,你就明白了,哈哈。
为什么不用 XML 来做这一切?
使用 MyXaml 和我认为的 XAML 来完成这件事绝对是直接的。区别主要在于 XML 的格式。例如,而不是使用 xmlns
属性列出程序集,我通过指定它们的路径来定义程序集,并且没有其他额外的东西:版本号等。此外,程序集在元数据中被明确定义为“可视化”组件或“业务”组件。我认为这有助于理解这个概念。而且,作为未来的增强功能,我打算支持每个程序集有多个组件,并将它们列在程序集本身之下。我多年来在处理 XML 时发现,选择一个适合任务的模式(以及因此的 XML 格式)很重要。当然,我可以创建对象图的类,并使用 MyXaml 或 MycroXaml 来实例化图,但这对于当前的任务来说是小题大做了。
现有的框架呢?
Microsoft Composite UI Application Block (CAB)、Spring.NET、NInject 是支持诸如控制反转 (IoC)、依赖注入 (DI) 和面向切面编程 (AOP) 之类的概念的框架。它们都涉及创建支持组件之间清晰的关注点分离的架构,所以如果你觉得这篇文章很有趣,我建议你研究一下这些和其他框架。但是,请记住,虽然这些框架声称可以提高可维护性、模块化等,但要实现这一点(假设框架设计良好)的唯一方法是,如果你的应用程序代码以一致、周到的方式利用该框架。
啊,好吧,我并不是说我的原型 Cx 框架也符合这个标准。然而,在与 Spring.NET 和 CAB 合作了一段时间之后,我想把问题(真的有问题吗?)简化到最简单的形式:一个拥有重要信息的组件如何将这些信息传递给另一个组件,而不会在两者之间产生紧密的耦合?而且,能否通过高度可视化的方式进行这些组件的连接,例如从生产者事件画一条线到消费者处理程序?因此,我创建了这个原型,因为从视觉上看,这种连接会非常容易实现。也许 WPF 的大神们会有兴趣帮助我!
案例研究要求
我将选择一个相当简单的东西——一个基本的计算器——作为案例研究。计算器将有四个组件
- 一个数字键盘 UI(0-9 和小数点按钮)
- 一个算术运算符 UI(四个基本运算符、等于按钮和清除按钮)
- 一个显示 UI(用于数字和结果)
- 一个用于处理操作的业务单元
组件通信
首先,让我们看看每个组件之间的通信。
- 数字键盘 UI 上的按钮按下会更新显示 UI,将数字追加到显示 UI 中显示的字符串中。
- 显示 UI 的更改会更新业务单元数据模型中的当前值。
- 运算符 UI 上的“清除”按钮会告诉业务单元清除其状态并将当前值设置为 0。
- 运算符或等于按钮会调用业务单元上的相应运算符方法。
- 业务单元当前值的更新会更新显示 UI。
事件
鉴于上述通信需求,我们可以描述所需的最少事件。所有组件都参与成为信息生产者,因此至少有一个事件。
数字键盘 UI 组件需要
- 每个代表数字 0-9 和小数点的按钮的事件
运算符 UI 组件需要
- 加法运算符事件
- 减法运算符事件
- 乘法运算符事件
- 除法运算符事件
- 等于事件
- 清除事件
显示 UI 组件需要
- 文本值更改事件
业务单元组件需要
- 当前值更改事件
消费者
只有两个组件是消费者:显示 UI 组件和业务 UI 组件。这些是将会消费其他组件事件的组件(没有什么能阻止一个组件消费它自己的事件)。
显示 UI 组件有可以消费的方法
- 设置显示文本
- 追加显示文本
- 设置为显示新文本
业务单元组件
- 当前值已更新
- 加法运算
- 减法运算
- 乘法运算
- 除法运算
- 等于运算
- 清除运算
状态问题
让我们简要看一下显示 UI 和业务单元组件中的状态问题。
当调用“追加显示文本”方法时,显示 UI 组件需要知道是开始新的显示文本还是追加到现有显示文本。这是这样确定的:组件最初处于“显示为新文本”状态。当调用第一个追加显示文本方法时,组件进入“追加”状态。当调用“设置显示文本”方法时,组件返回到“显示为新文本”状态。此状态也可以由运算符事件强制。
业务单元还需要保留有关堆栈处理的一些状态信息。基本上,当调用清除方法时(我们力求非常简单),堆栈将被清除。
连接
现在,让我们看看事件连接的样子。
- 数字键盘组件事件连接到显示组件上的追加显示文本方法。
- 显示组件的文本值更改事件连接到业务单元的当前值更新方法。
- 任何运算符事件(运算符 UI 组件)都连接到显示 UI 组件的“设置显示为新文本”方法。
- 运算符事件(运算符 UI 组件)连接到业务组件中的相应方法。
- 清除和等于事件(运算符 UI 组件)连接到业务组件中的相应方法。
- 业务单元的当前值更改事件连接到显示组件的设置显示文本事件。
数据绑定可以用于显示组件中的文本框和业务模型之间。我选择此时不使用数据绑定,以保持此案例研究的一致性和简单性。
结束案例研究要求
我已经定义了创建基本计算器所需的所有事件、接口、状态和连接。诚然,这似乎有点大材小用,但这里的重点是我们将计算器作为概念验证。我们在这里真正验证的是支持这种编程风格所需的基础设施。
实现
所有 UI 组件都实现为用户控件,基本上是控件集合的容器。应用程序窗体中组件的实际布局在 XML 中描述。熟悉我文章的读者在意识到我没有使用 XML 来定义组件 UI 时会感到震惊!
请注意,没有任何组件知道其他组件的任何信息,组件所需的唯一程序集引用是它们实现的接口(基本上是存根)和专门的 EventArgs 类助手,用于连接生产者和消费者。
另外,我将从上往下描述概念,而不是直接从基础设施开始,先看启动应用程序,然后看可视化组件,接着看业务单元,最后看支持程序集加载和连接的基础设施。
元数据文件
为了在下面的讨论中参考,这里是元数据文件
<?xml version="1.0" encoding="utf-8"?>
<Cx>
<Components>
<VisualComponent Name="Display"
Assembly="..\..\..\TextDisplayComponent\bin\debug\TextDisplayComponent.dll"
Location="10, 10"/>
<VisualComponent Name="Keypad"
Assembly="..\..\..\NumericKeypadComponent\bin\debug\NumericKeypadComponent.dll"
Location="10, 40"/>
<VisualComponent Name="Operators"
Assembly="..\..\..\OperatorComponent\bin\debug\OperatorComponent.dll"
Location="130, 40"/>
<BusinessComponent Name="Calculator"
Assembly="..\..\..\BusinessUnitComponent\bin\debug\BusinessUnitComponent.dll"/>
</Components>
<Wireups>
<WireUp Producer="Keypad.KeypadEvent" Consumer="Display.OnChar"/>
<WireUp Producer="Operators.btnPlus.Click" Consumer="Calculator.Add"/>
<WireUp Producer="Operators.btnMinus.Click" Consumer="Calculator.Subtract"/>
<WireUp Producer="Operators.btnMultiply.Click" Consumer="Calculator.Multiply"/>
<WireUp Producer="Operators.btnDivide.Click" Consumer="Calculator.Divide"/>
<WireUp Producer="Operators.btnEqual.Click" Consumer="Calculator.Equal"/>
<WireUp Producer="Operators.btnPlus.Click" Consumer="Display.StateIsNewText"/>
<WireUp Producer="Operators.btnMinus.Click" Consumer="Display.StateIsNewText"/>
<WireUp Producer="Operators.btnMultiply.Click" Consumer="Display.StateIsNewText"/>
<WireUp Producer="Operators.btnDivide.Click" Consumer="Display.StateIsNewText"/>
<WireUp Producer="Operators.btnEqual.Click" Consumer="Display.StateIsNewText"/>
<WireUp Producer="Operators.btnClear.Click" Consumer="Calculator.Clear"/>
<WireUp Producer="Display.DisplayTextChanged" Consumer="Calculator.SetCurrentValue"/>
<WireUp Producer="Calculator.CurrentValueChanged" Consumer="Display.OnText"/>
</Wireups>
</Cx>
启动应用程序
启动应用程序包括两个部分:初始化基础设施,以及将可视化组件添加到应用程序窗体。
初始化基础设施
static void Main()
{
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
CxApp cx = new CxApp();
cx.LoadXml(Path.GetFullPath("cx.xml"));
cx.LoadVisualComponents();
cx.LoadBusinessComponents();
cx.WireUpComponents();
Application.Run(new Form1(cx.VisualComponents));
}
正如我们所见,基础设施初始化包括加载 XML 元数据,然后加载可视化和业务组件,并连接这些组件。所有组件在连接之前都被实例化(事实上,这是连接事件的先决条件)。
注意:更复杂的框架允许延迟初始化和连接,但这需要通过框架的辅助服务来实例化类,类似于
Foo foo=Framework.CreateObject<Foo>();
或者当组件定义具有必须同时实例化的依赖项时,会自动处理。
Form
对象获取可视化组件的集合,并将它们按 XML 元数据中的描述布置在窗体上。
public Form1(List<ICxComponent> visualComponents)
{
InitializeComponent();
foreach (ICxComponent comp in visualComponents)
{
// Isn't there a Parse method to do this???
string[] loc=comp.Location.Split(',');
Point p = new Point(Convert.ToInt32(loc[0].Trim()),
Convert.ToInt32(loc[1].Trim()));
Control ctrl = (Control)comp.Instance;
ctrl.Location = p;
Controls.Add(ctrl);
}
}
结果是一个由三个可视化组件组成的窗体。
显示组件
显示组件由一个带 TextBox
控件的用户控件组成。
代码实现了我在需求中讨论的所有内容。
public partial class TextDisplay : UserControl, ICxVisualComponent
{
public event CxStringDlgt DisplayTextChanged;
protected enum State
{
NewText,
AddChar,
}
protected State state;
public TextDisplay()
{
InitializeComponent();
tbDisplay.TextChanged += new EventHandler(OnTextChanged);
state = State.NewText;
}
public void OnChar(object sender, CxEventArgs<char> args)
{
switch (state)
{
case State.AddChar:
tbDisplay.Text = tbDisplay.Text + args.Data;
break;
case State.NewText:
tbDisplay.Text = args.Data.ToString();
state = State.AddChar;
break;
}
}
public void OnText(object sender, CxEventArgs<string> args)
{
tbDisplay.Text = args.Data;
state = State.NewText;
}
public void StateIsNewText(object sender, EventArgs args)
{
state = State.NewText;
}
protected void OnTextChanged(object sender, EventArgs e)
{
if (DisplayTextChanged != null)
{
DisplayTextChanged(this, new CxEventArgs<string>(tbDisplay.Text));
}
}
}
除了我不一致的方法命名约定,你还可以看到该组件如何处理
- 它的状态(字符是清除当前显示还是追加到当前显示)
- 能够接收字符和文本消息
- 能够产生文本更改消息
这很简单,而且不与任何其他功能纠缠在一起,很容易进行单元测试。
数字键盘组件
此组件是字符消息的生产者,其中字符对应于按下的按钮。实现非常简单(易于阅读和单元测试)。
public partial class NumericKeypad : UserControl, ICxVisualComponent
{
public event CxCharDlgt KeypadEvent;
public NumericKeypad()
{
InitializeComponent();
}
protected virtual void RaiseKeypadEvent(char c)
{
if (KeypadEvent != null)
{
KeypadEvent(this, new CxEventArgs<char>(c));
}
}
protected void KeypadClick(object sender, EventArgs e)
{
string padItem = (string)((Control)sender).Tag;
RaiseKeypadEvent(padItem[0]);
}
}
再次注意我命名约定的不一致。决定如何称呼一个触发事件的方法与一个处理事件的方法之间存在一些挣扎。
运算符组件
这个组件有点不同。由于按钮是“命令”,除了 Visual Studio 创建的内容之外,没有其他实现。
public Operator()
{
InitializeComponent();
}
相反,作为命令事件,元数据通过直接引用按钮点击事件来描述连接,例如
<WireUp Producer="Operators.btnPlus.Click" Consumer="Display.StateIsNewText"/>
业务单元组件
此组件的代码应该是自解释的。此时,最好查看元数据,了解业务逻辑如何参与 UI。
public class Calculator : ICxBusinessComponent
{
public event CxStringDlgt CurrentValueChanged;
protected enum PendingOperation
{
None,
Add,
Subtract,
Multiply,
Divide,
}
protected PendingOperation pendingOperation;
protected string lastValue;
protected string currentValue;
public string CurrentValue
{
get { return currentValue; }
set
{
if (currentValue != value)
{
currentValue = value;
OnCurrentValueChanged();
}
}
}
public Calculator()
{
pendingOperation = PendingOperation.None;
}
public void SetCurrentValue(object sender, CxEventArgs<string> args)
{
CurrentValue = args.Data;
}
public void Add(object sender, EventArgs e)
{
Calculate();
lastValue = currentValue;
pendingOperation = PendingOperation.Add;
}
public void Subtract(object sender, EventArgs e)
{
Calculate();
lastValue = currentValue;
pendingOperation = PendingOperation.Subtract;
}
public void Multiply(object sender, EventArgs e)
{
Calculate();
lastValue = currentValue;
pendingOperation = PendingOperation.Multiply;
}
public void Divide(object sender, EventArgs e)
{
Calculate();
lastValue = currentValue;
pendingOperation = PendingOperation.Divide;
}
public void Equal(object sender, EventArgs e)
{
Calculate();
pendingOperation = PendingOperation.None;
}
public void Clear(object sender, EventArgs e)
{
CurrentValue = "0";
pendingOperation = PendingOperation.None;
}
protected void Calculate()
{
try
{
switch (pendingOperation)
{
case PendingOperation.Add:
CurrentValue = Convert.ToString(Convert.ToDouble(lastValue) +
Convert.ToDouble(currentValue));
break;
case PendingOperation.Subtract:
CurrentValue = Convert.ToString(Convert.ToDouble(lastValue) -
Convert.ToDouble(currentValue));
break;
case PendingOperation.Multiply:
CurrentValue = Convert.ToString(Convert.ToDouble(lastValue) *
Convert.ToDouble(currentValue));
break;
case PendingOperation.Divide:
CurrentValue = Convert.ToString(Convert.ToDouble(lastValue) /
Convert.ToDouble(currentValue));
break;
}
}
catch
{
CurrentValue = "Error";
}
pendingOperation = PendingOperation.None;
}
protected void OnCurrentValueChanged()
{
if (CurrentValueChanged != null)
{
CurrentValueChanged(this, new CxEventArgs<string>(currentValue));
}
}
}
基础设施
以下描述了实现 Cx 原型最小基础设施的代码。
接口
有几个接口我用来抽象具体的实现,这些接口定义在 Cx.Interfaces 程序集中。这些接口非常轻量级,主要是为了表示组件的结构。
namespace Cx.Interfaces
{
/// <summary>
/// Base interface for a component instance.
/// </summary>
public interface ICxComponent
{
ICxComponentClass Instance { get; }
}
/// <summary>
/// Properties and methods specific to visual components.
/// </summary>
public interface ICxVisualComponent
{
string Location { get; }
}
/// <summary>
/// Properties and methods specific to business components.
/// </summary>
public interface ICxBusinessComponent
{
// None right now.
}
/// <summary>
/// Base class for defining a class as a component. Should not be
/// directly inherited from a component class.
/// </summary>
public interface ICxComponentClass
{
}
/// <summary>
/// Any class inheriting this interface is a visual component.
/// </summary>
public interface ICxVisualComponentClass : ICxComponentClass
{
}
/// <summary>
/// Any class inheriting this interface is a business component.
/// </summary>
public interface ICxBusinessComponentClass : ICxComponentClass
{
}
}
加载组件
有两种加载组件的方法——一种用于可视化组件,一种用于业务组件。它们非常相似,所以我只给你看可视化组件加载器。
public void LoadVisualComponents()
{
foreach (CxVisualComponent comp in from el in xdoc.Root.Elements(
XName.Get("Components")).Elements("VisualComponent")
select new CxVisualComponent()
{
Name = el.Attribute(XName.Get("Name")).Value,
Assembly = el.Attribute(XName.Get("Assembly")).Value,
Location=el.Attribute(XName.Get("Location")).Value,
})
{
ICxComponentClass compInst = AcquireComponent(comp.Assembly,
typeof(ICxVisualComponentClass));
comp.Instance = compInst;
comp.Type = compInst.GetType();
components.Add(comp.Name, comp);
visCompList.Add(comp);
}
}
业务组件和可视化组件都被添加到组件集合中(所有这些实现的一个共同点是它们最终至少有一个包含所有不同部分的容器),并且可视化组件被放入一个单独的列表中,以便应用程序窗体可以轻松地实例化它们。
AcquireComponent 方法
这是一个重要的调用。为什么?因为此调用隐含的意思是,程序集立即加载,并且可视化或业务组件立即被实例化。不支持延迟程序集加载和组件实例化,所以我们必须认识到这会对应用程序启动造成巨大的开销,以及如果组件在初始化时想与可能不可用或引入自身延迟的服务、数据库等通信时产生其他问题。
使用这些框架之一时,了解你的组件初始化过程至关重要,也许将一些工作放到工作线程中,这样你就不会拖慢应用程序启动。同样重要的是要考虑哪些组件必须立即初始化,哪些可以延迟加载,仅在需要时加载。
protected virtual ICxComponentClass AcquireComponent(string assyPath, Type componentType)
{
ICxComponentClass compInst = null;
Assembly assy = Assembly.LoadFrom(assyPath);
Type t = FindImplementor(assy, componentType);
compInst = InstantiateComponent(t);
return compInst;
}
当然,延迟加载和实例化的问题在于,直到你执行了激活组件实例化所必需的特定操作,你才不知道组件是否抛出异常。
对于延迟实例化,彻底单元测试你的组件至关重要,因为你可能(而且很容易)在编写验收测试计划或其他 QA 程序时没有意识到,可能需要特定的步骤序列来完全测试一个延迟加载的组件。当然,临时测试几乎总会遗漏这些延迟加载的组件。
FindImplementor 方法
看到这个方法,你应该意识到我这个小原型又一个缺点——它只允许每个程序集有一个组件。这是一个人为的要求,需要删除,然后它才能成为一个可行的框架。
protected virtual Type FindImplementor(Assembly assy, Type targetInterface)
{
IEnumerable<Type> cxComponents = from classType in assy.GetTypes() where
classType.IsClass && classType.IsPublic
from classInterface in classType.GetInterfaces() where classInterface==targetInterface
select classType;
if (cxComponents.Count<Type>() == 0)
{
throw new CxException("Expected assembly " + assy.FullName +
" to have one class implementing "+targetInterface.Name);
}
if (cxComponents.Count<Type>() > 1)
{
throw new CxException("Expected assembly " + assy.FullName +
" to have one and only one class implementing "+targetInterface.Name);
}
return cxComponents.ElementAt<Type>(0);
}
如果你仔细想想,甚至不需要让可视化组件和业务组件继承自接口。然而,我之所以想采取这种方法,是因为一些专业框架通过字符串(程序集名称、类名称、方法名称、标签等)来识别组件,这让我感到不安。如果你的字符串定义中有拼写错误,直到运行时才知道。最终,我宁愿使用接口和属性来声明“我是一个生产者事件”或“我是一个消费者方法”,而不是依赖于某人每次都正确输入程序集、方法或标签的名称——这就是为什么我们都应该使用可视化设计器来连接组件。当然,元数据可以经过程序集的检查过程,无论我们是否有可视化设计器,都应该这样做,以确保在设计器外部所做的任何更改都不会破坏元数据连接。
InstantiateComponent 方法
这是一个非常小的方法,其要点是,我期望元数据精确描述程序集的查找位置。更专业的方法将包括一个程序集解析器,该解析器可以根据某些应用程序特定的配置信息帮助 .NET 框架定位程序集,以便可以从元数据中删除路径本身。这当然有利于部署。
protected virtual ICxComponentClass InstantiateComponent(Type t)
{
ICxComponentClass inst = null;
inst = (ICxComponentClass)Activator.CreateInstance(t);
return inst;
}
连接组件
真正有趣的是连接组件。我将展示所有涉及此过程的方法。我与之斗争最多的部分是 Delegate.CreateDelegate
调用。最终的问题是我传递了 CxComponent
实例而不是组件实例作为目标参数,当我意识到问题时,我感到非常尴尬。另一个有趣的部分是 DrillInto
方法,它在编写 Operators.btnPlus.Click
这样的元数据时非常有用,因为它允许钻入对象图以获取所需的事件。任何使用 WPF 的人对此应该都很熟悉。
protected void WireUp(string producer, string consumer)
{
object producerTarget = GetProducerTarget(producer);
object consumerTarget = GetConsumerComponent(consumer).Instance;
EventInfo ei = GetEventInfo(producer);
MethodInfo mi = GetMethodInfo(consumer);
Delegate dlgt = Delegate.CreateDelegate(ei.EventHandlerType, consumerTarget, mi);
ei.AddEventHandler(producerTarget, dlgt);
}
protected object GetProducerTarget(string producer)
{
string[] parts = producer.Split('.');
object obj = components[parts[0]].Instance;
obj = DrillInto(obj, parts);
return obj;
}
protected CxComponent GetConsumerComponent(string consumer)
{
string[] consumerParts = consumer.Split('.');
return components[consumerParts[0]];
}
protected EventInfo GetEventInfo(string producer)
{
string[] parts = producer.Split('.');
object obj = components[parts[0]].Instance;
obj = DrillInto(obj, parts);
EventInfo ei = obj.GetType().GetEvent(parts[parts.Length-1]);
return ei;
}
protected MethodInfo GetMethodInfo(string consumer)
{
string[] parts = consumer.Split('.');
MethodInfo mi = components[parts[0]].Type.GetMethod(parts[1],
BindingFlags.Public | BindingFlags.NonPublic |
BindingFlags.Instance | BindingFlags.Static);
return mi;
}
/// <summary>
/// Follow the field chain until we get to the event name.
/// Allows us to do things like [component].[property].[property].[eventname]
/// </summary>
protected object DrillInto(object obj, string[] parts)
{
int n = 1;
while (n < parts.Length - 1)
{
obj = obj.GetType().GetField(parts[n],
BindingFlags.Public | BindingFlags.NonPublic |
BindingFlags.Instance).GetValue(obj);
++n;
}
return obj;
}
几个通用委托
对于命令事件,如加、减等,没有数据,所以我们连接 UI 事件本身(在这种情况下)。对于包含数据的事件,例如,当显示组件的文本更改时,我们最终连接控件的事件,然后用数据重新触发我们自己的事件。这样,数据总是推送到消费者,而不是消费者必须查询生产者来获取数据——例如,将发送者转换为 TextBox
来访问 Text
属性。这使得生活有点烦人,尤其因为有一些事件(通过钻入机制)可以从组件对象访问,还有一些事件是为了帮助 Cx 生产者-消费者连接而存在的。
namespace Cx.EventArgs
{
public delegate void CxCharDlgt(object sender, CxEventArgs<char> args);
public delegate void CxStringDlgt(object sender, CxEventArgs<string> args);
public class CxEventArgs<T> : System.EventArgs
{
public T Data { get; set; }
public CxEventArgs(T val)
{
Data = val;
}
}
}
显然,我们可以在这里添加更多的通用委托,你可以使用 CxEventArgs<T>
类来定义你自己的组件特定参数。
结论
想象一下,如果编程可以这样进行,即你定义由配置文件实例化的小型组件,并且它们之间的所有信息交换和命令都通过某种可视化工具连接,使用生产者“事件”和消费者方法。这才是编程应该的方式,让我想起了科幻电影中的编程概念,基本上是将组件以新的配置连接起来。所以希望这篇文章能激起你对这种概念的兴趣。
我喜欢这种方法的一点是,单元测试变得微不足道。组件之间的依赖关系(纠缠)被消除了,所以你的单元测试由两部分组成:
- 触发数据/命令以测试正常代码路径。
- 触发数据/命令以测试边缘情况。
消除了由于组件依赖性而执行复杂初始化的需要。由于数据/命令与组件生成的事件相关联,而单元测试会模拟这些事件以测试特定的消费者组件,因此许多单元测试最终都可以通过脚本来完成。请记住,也可以为组件的“结果”事件及其预期的值编写脚本。这个好处不应被低估,我认为在使用任何此类框架时都应该追求它。
我是否重新发明了轮子?
我不这么认为。我认为我采取了一种独特而轻量级的方法来实现完全分离组件的“圣杯”(实际上是否存在需要解决的问题?),通过实现一个非常简单的生产者-消费者模式。再次强调,这个原型的目的是为创建可视化设计器奠定基础,并将框架(保持其轻量级)扩展成实际可用的东西。但是,如果有人已经做了这项工作,请告诉我!
生产者-消费者与发布-订阅
我提供的代码是生产者-消费者模式,在这种模式下,组件通过 .NET 的事件功能生成数据或命令,其他组件则使用 Cx 框架连接事件到事件处理程序来消费这些事件。这与发布-订阅模式略有不同,在这种模式下,数据/命令被发布(在事件或某种队列中),订阅者获取数据或命令(通过直接挂钩事件或监视队列)。这两种模式都有其相似之处和细微差别。
递归属性更改事件
如果你好奇的话,递归通知最容易解决的方法是只在属性值实际更改时才触发属性更改事件——这是非常标准的做法。
EventHandler<TEventArgs> 与 CEventArgs<T>
为什么我不使用 EventHandler<TEventArgs>
?没有特别的原因,并且请记住,实际上没有什么可以阻止你自己这样做。在我的特定情况下,我喜欢 CxEventArgs<T>
派生自 EventArgs
,这样你仍然可以保持事件签名的纯洁性,而且,EventHandler<char>
不起作用——你会得到编译器错误There is no boxing conversion from 'char' to System.EventArgs.。
一些我们可以做到的有趣事情
这里有一些关于这个原型还可以做什么的想法。这个方案的好处在于,这些想法可以统一应用于任何使用 Cx 的代码。
添加事件监控
当事件最初连接时,因为它是多播的,我们可以添加一个内部日志处理程序来记录事件何时被触发。
记录事件数据
任何对象都实现 ToString()
,所以我们也可以记录与事件相关联的数据。
异步事件
指定消费者可以在线程中执行会非常简单。连接可以为每个消费者维护信息,说明它是在当前线程上执行,是在新线程上异步执行,还是被分配给线程池。
监控执行时间
我们可以轻松地添加一个秒表功能来监控消费者(或所有消费者)执行需要多长时间。
安全事件执行
如果消费者抛出异常怎么办?其他消费者是否仍应收到通知?事件链是否应该终止?这些问题可以通过修改 Cx 中的事件连接来解决,而不是调用安全事件执行方法。
执行链
我们也可以玩转执行链本身。是否所有消费者都有机会处理事件,还是我们在第一个消费者表示已处理事件时停止?更复杂的事件链(如 WPF 支持的)呢,例如对象图,在其中我们测试父对象或子对象是否可以处理事件?