2017 年 3 月

第 32 卷,第 3 期

模式 - 有效事件: 可取代数十种设计模式

作者 Thomas Hansen | 2017 年 3 月

编辑寄语

当我让 MSDN 杂志高级特约编辑 James McCaffrey 审阅本文的初稿时,他为本文作者提出的一些观点和想法所震怒,愤愤然地离开了。大多数时候,这预示着文稿不通过。但 McCaffrey 指出,软件工程中的新概念遭到摒弃再常见不过,这完全是因为之前从未有人提出过。虽然 McCaffrey 声称自己仍为本文中的诸多观点而震怒,但我们都认为这篇文章可能会引发很多有关软件设计和方法范例的思考。- Michael Desmond

只要有软件,就会有失败的软件。研究显示,所有项目中有 25% 完全失败,有 50% 可以说是“有缺陷”。 试想想,如果汽车制造商也有相同的统计数据,那就是有 25% 的汽车无法启动或在开灯时爆炸,有 50% 的汽车仅能达到 25 英里每小时或每英里使用 15 加仑的汽油。更重要的是,如果客户在付款前并不知道哪款汽车适合自己,情况又如何?这与当前的软件开发现状颇为相似。

已提出各种解决方案来改善这种状况,但均未涉及代码这一根本原因! 我认为,软件项目失败通常可以归因为同一问题:混乱不堪的代码。代码之所以如此糟糕是因为确实没有办法适应变更。正因为此,我们建议采用极限编程和敏捷软件开发等方法。不过,这些方法其实都无法帮助创建更好的代码。敏捷软件开发并不能神奇地帮助避免混乱不堪的代码。必须能够通过原子构建基块单独处理问题,才能变得敏捷。

本文介绍的设计模式并不能替代敏捷软件开发或其他类似方法。毕竟敏捷软件开发是一种管理框架,而不是体系结构设计模式。相反,我提出的设计方法可以让你更轻松地变得敏捷,因为你可以适应变更。在过去几年里,我开发出了一种设计模式,我将其称为“有效事件”。 借助这种设计模式,可以创建可重用的小型组件,从而能够轻松扩展、修改、重用这些组件,并将其与其他部件进行交换。这种设计模式带来了不同的软件视角,让你可以“安排”代码(几乎就像是各块乐高积木)。你可能会认为,这与使用面向对象的编程 (OOP) 范例编写代码颇为相似。然而,有效事件与传统 OOP 有很大区别。

检查你自己的代码

帮个忙呗: 打开你在 Visual Studio 中生成的最后一个解决方案,然后检查依赖项关系图。可以从你的解决方案中提取多少项目和类? 可以在未来项目中重用多少类? 在不破坏软件的前提下,可以将多少类替换成其他类?

我猜你的项目和类依赖项与其他大多数项目一样。你项目的依赖项关系图很有可能混乱不堪。非常抱歉,我可能太过坦诚,而忽略大家的感受,但这确是我的经验之谈。但请放心,不只你一个人遇到这种问题。虽然没有确凿的数据,但我可以说世界上 98% 的代码都存在同样的问题。此外,我不会强迫大家去面对这个问题,除非我有解决方案。

如果可以理清这些依赖项并创建可重用的组件,这样代码的各个部分就都不会引用其他任何部分,那么你的编程质量将会大大提高。如果你的项目和类不像现在这样混乱,就可以更容易地交换各个部分了。如果理清了你的类,便可以在未来项目中重用它们。如果你的项目最大限度地减少了依赖项,那么你将拥有一组更有可能重用的组件,而不是一个庞然大物。有效事件可推进跨类和项目不使用依赖项。若要解决软件混乱不堪的问题,零依赖项是唯一可接受的数字!

有效事件的工作原理是什么?

有效事件设计模式实际上极其易于理解。与其创建接口来分隔逻辑块的实现和使用者,不如直接使用属性来标记方法。例如:

[ActiveEvent (Name = “foo.bar”)]
protected static void foo_bar(ApplicationContext context, ActiveEventArgs e)
{
  var input = e.Args[“input”].Value as string;
  e.Args.Add(“output”, “Hello there ” + var);
}

此语法取自 Phosphorus Five,这是我创建的一个开放源代码有效事件实现。有关详细信息,请参阅“几乎不存在的软件”。

若要调用已定义的方法,使用有效事件范例同样十分简单。通过有效事件名称间接调用方法,而不是直接调用:

/* ... some method in another assembly ... */
var args = new Node ();
args.Add ("input", "John Doe");
/* context is your ApplicationContext instance, which keeps
   track of your Active Events */
var result = context.Raise ("foo.bar", args)["output"].Value as string;

引发调用调用的是 foo.bar 有效事件。请注意,在有效事件调用及其实现之间没有依赖项。此外,它们之间没有共用的公共类。只有数据在事件中传入和传出。此外,还请注意,所有有效事件的签名都完全相同。因此,类、接口和方法略微转向幕后,并且在源代码中的受重视程度降低。

有效事件组件的加载完全是一个动态过程,可以通过应用程序配置文件中引用的程序集列表完成,也可以通过从文件夹自动加载所有 DLL,然后将其注册为有效事件处理程序来完成。在内部,有效事件“内核”会在加载期间遍历 DLL 中的所有类型,并对有效事件方法的 MethodInfo 引用存储到字符串/MethodInfo 字典中。稍后会在调用事件时使用此字典。若要限定有效事件的范围或将其与状态相关联,可以动态添加类实例作为有效事件侦听器,并使用实例方法(而不是静态方法)作为有效事件接收器,这便是(举例来说)Phosphorus Five 中的 plugins/p5.web 项目完成的工作。

这会创建一个环境,其中不同的代码部分都是简单的构建基块,彼此间接调用,以便你可以轻松地将任意一个有效事件程序集与另一个实现进行交换。彻底更改生成的应用程序就像更改配置设置或替换 DLL 一样简单。如果愿意,就像将字符串“foo.bar”更改为“foo.bar-2”一样简单。 同时,解决方案中的每个程序集仍能调用其他程序集中的方法,而不需要像普通旧数据 (POD) 结构一样与另一方进行共享。基本上来说,有固定的“另一抽象层”可供任意使用。换言之,组件已原子化。

让我们将此与需要在传统 OOP 中执行的操作进行比较。首先,假设使用的是按接口设计方法,且有一个接口和至少一个接口实现。可能需要使用抽象工厂方案来创建对象,具体视应用场景而定。因此,需要创建两个类和一个接口,很容易就总共有 30 行以上代码。此外,若要根据实现创建真正的插件,最终会为使用者部分生成一个项目(使用插件),并为实现部分生成一个项目(实现接口的含类项目)。若要采用完全模块化的方法,可以使用第三个项目来包含接口和可能的抽象工厂。然后,查看结果时,你会发现最终在三个项目中生成了至少三个交叉组件引用。此外,只有修改所有三个项目,才能添加或更改接口的输入或输出部件。将这与有效事件示例生成的单行解决方案相比,后者根本就没有引用,向它提供任何输入或输出自变量,都不要紧。传统方法的代码行可能有两倍之多或更多,根据我的经验,会生成更复杂的依赖项关系图、更严格的结果和更复杂的代码。

OOP 不是 OO

我喜欢 OOP。那是一个极好的范例,可方便你轻松封装代码逻辑、方法和数据。不过,也有人指出,OOP 没有完全解决所有编码问题。在某些情况下,使用 OOP 时,必须定义多个类,这些类通常会紧密耦合,并创建依赖项。

在 OOP 中,需要知道方法的签名,需要拥有对接口的访问权限,也需要知道如何创建此接口的实例。此外,通常还需要在接口的使用者部分和实现部分之间共享类型。这将在构建基块中创建依赖项。使用 OOP,仅仅为了创建一个简单的“Hello World”插件,而需要创建多个类再常见不过。如果更改 OOP 接口,除非你是很认真地设计接口,否则可能需要重新编译多个程序集。最终可能会有许多抽象接口和样本代码,或者不得不接受以下事实:更改接口有可能引起多米诺效应,需要额外更改大量代码。只要某事物变得过于复杂,或需要完成大量繁琐重复的工作,你的直觉应该就会认为这是不对的。

问问自己下面这个问题: “如果 OOP 允许创建面向对象的 (OO) 软件,为什么还很难成功地使用它?” 在我看来,OOP 并不等同于 OO。如果等同,就不需要为了充分利用它而了解这么多的设计模式。

按照目前的做法,我认为 OOP 存在一些根本性的限制,为了充分利用 OOP 而需要了解所有这些设计模式就是体系结构存在以下更为基本的­问题的症状:无法交付 OO 软件。有效事件可以解决这些问题,不是通过实现更好的抽象工厂或新的类构造,而是通过更改方法和函数的调用方式,完全剔除 OOP。若要创建 OO 软件,需要的不是实例化抽象类和接口的新方法,而是不同的功能调用机制,以及调用的每个接口方法能否共用一个签名。

几乎不存在的软件

有关展示了有效事件广泛应用范围的示例,请查看 Phosphorus Five (github.com/polterguy/phosphorusfive)。Phosphorus Five 是一个大型软件项目,而有效事件实现仅用了 1.000 行代码。有关有效事件实现,可以访问 core/p5.core。

在这个项目中,我创造了新的编程语言 Hyperlambda,用于创建有效事件挂钩。这样一来,我就可以根据需要更改 for-each 和 while 语句的实现。我还可以将 else 语句扩展为在另一台服务器上执行。我可以将编程语言轻松扩展为创建我自己的域特定关键字,以执行解决任何域问题所需的操作。

我还创造了一种文件格式,用于以一段文字的形式动态声明节点结构,并将其存储到我的磁盘或我的数据库。此外,我还创造了自己的表达式语法,用于引用树任何部分内的任意节点。这样就生成了非编程语言(我喜欢这样称呼),完全是图灵完备语言。我的非编程语言既不用动态解释,也不用静态编译;有时只需五行代码就能完成其他编程语言要用数百行代码才能完成的操作。

借助有效事件、Hyperlambda 和托管的 AJAX 库(仅包含一个 AJAX 小组件),在未引入“Web OS”等名称的情况下,我成功创造了一些自己不知道如何称呼的事物。 Phosphorus Five 中大约有 30 个项目,不同的插件中没有任何引用。所有项目都只引用有效事件实现 p5.core 和表达式引擎 p5.exp。就是这样。核心文件夹中的主要网站项目 p5.website 只包含一个简单的容器小组件,几乎不含逻辑。应用程序启动期间,所有插件均在我的 Global.asax 中动态加载。尽管如此,所有项目都能动态调用其他项目内的功能。既没有引用、没有依赖项,也没有出现任何问题!

回归基本

解决方案总是蕴含在问题之中。自相矛盾的是,OOP 旨在解决的一些问题(全局函数和数据)反而成为 OOP 无意间引发的问题的解决方案。如果你看看有效事件这种设计模式,首先会发现的是,在某种程度上,它是回归基本的,同时使用全局函数取代方法和类。然而,由于无需在有效事件的使用者和实现之间知道并共享签名或类型,因此与使用 OOP 相比,环境更像是黑盒。这样一来,可以轻松地进行交换,例如,将 SaveToDatabase 与 InvokeWebService 或 SaveToFile 进行交换。没有接口、类型、POD 结构和类,只有一个共用签名。只有优质的普通旧数据。数据只会传入传出!

实现多形性和更改字符串一样简单。下面的示例展示了如何使用有效事件实现多形性:

string myEvent = "some-active-event";
if (usePolymorphism) {
  myEvent = "some-other-active-event";
}
context.Raise (myEvent);

我意识到,经验丰富的构架师一定会认为这种多形性构造幼稚可笑且简单粗糙。然而,这种简单性正是它奏效的原因所在。借助有效事件,可以从数据库、配置文件或通过用户在窗体文本框中提供名称来提取方法或函数的名称。可以将此看作是不需要显式类的多形性变体。这是不含类型的多形性。这是在运行时期间动态确定的多形性。通过剔除所有关于多形性的传统观念,并重构多形性的实质内容,最终会生成实际有效的多形性(封装和多形性),而不含类、类型、接口或设计模式。链接有效事件就像原子结合成分子一样简单。这就是敏捷软件!

唯一需要的图类型 Node.cs

使用有效事件,将在有效事件中仅传入传出数。这样一来,可以松散耦合组件。为此,需要使用一个数据类,这也是在使用有效事件范例时唯一需要的一个图类型。类需要能够封装所有可能类中的全部可能字段和属性。在 Phosphorus Five 中,此类名为 Node.cs,就是包含键/值/子设计的图对象。

成功实现有效事件的关键是,节点类是允许有效事件作为输入接收并作为输出返回的唯一自变量。事情就是这么凑巧,几乎所有类都可以有效地缩减成键/值/子图 POD 对象。与有效事件程序集的动态加载相结合,这可以大大减少项目之间的依赖项数。

若要实现 Node.cs,必须能够保留键或名称、可以是任意对象的值和节点的“子”集合。如果节点类符合这些约束,可以将几乎所有可能的对象轻松转换成节点实例。对于熟悉 JSON 或 XML 的人来说,这一点的相似性可能就非常明显。下面的简化伪代码展示了节点类结构:

class Node
{
  public string Name;
  public object Value;
  public List<Node> Children;
}

在内部,可以在组件内根据需要使用尽可能多的 OOP。然而,当一个组件需要调用另一个组件中的逻辑时,必须以某种方式将所有输入数据都转换成节点实例。从有效事件返回信息时,也需要完成同样的操作。不过,在组件内,可以根据需要使用任意类、接口、抽象工厂、外观组件、单一实例和设计模式。而外部只有节点,并且有效事件是组件之间的桥梁。将有效事件看作是协议,将节点看作是它的数据,如果这样有助于你在脑海中勾勒两者之间的关系的话。

总结

虽然旨在取代当今世界上的其他大多数设计模式,但有效事件并不是灵丹妙药。除其他事项外,这项技术还会带来一定开销。例如,需要进行字典查找,而不是直接调用方法。此外,方法还使用一些反射。这些间接方法调用可能是多个数量级的,相比传统的虚拟方法调用更加昂贵。此外,只要与其他组件有接口,就需要将对象转换成节点和数据。

然而,有效事件不应替代你的所有工作。我们的想法是在组件之间提供更好的接口。鉴于此,性能开销就不是主要问题。如果实际实现需要 5,000,000 个 CPU 周期,那么不论是需要 5 个 CPU 周期,还是需要 500 个 CPU 周期才能调用 SaveToDatabase 方法,都是风马牛不相及! Donald Knuth 曾经说过,“过早的优化是万恶之源。”

每当考虑编写接口时,应该问问自己,改为创建有效事件是否会更好。积累了一些有效事件经验后,你对这个问题的回答往往是“可能会”。 借助有效事件,大多数时候,接口和抽象类的受重视程度降低。

我知道这听起来像是荒谬大胆的立场,但看看“几乎不存在的软件”中介绍的 Phosphorus Five 项目。 在 Hyperlambda(我为有效事件创造的“语言”)中,对象可以是文件、文件夹、lambda 回叫、图节点树的子部分、取自数据库的一段文字,或通过 HTTP 发送的一段数据。所有对象都可以执行,就像是计算机可理解的执行树。在 Hyperlambda 中,理论上可以执行数字 42。

我第一次想到有效事件是在七年多前,直觉告诉我有效事件有一种内在的美。问题在于,有效事件挑战了 60 年来的传统编程智慧。有效事件甚至成为必须重构最佳做法的促使因素。我花了七年时间才忘掉以前所学,跟着自己的直觉走。有效事件存在的最大问题实际上并不是它的实现,而是在于你的想法。事实上,我完全重构了 Phosphorus Five 五次,这就证明了这一点。

在本文的初稿中,我创造了几十个类比。通过关联物理和生物等其他学科的既有知识,我试图让大家相信有效事件的优势。在第二份草稿中,我试图激怒大家。我的想法是刺激你来证明我是不对的,这样你就会来找我立场的缺陷,当然你是发现不了任何缺陷的。然而,在我的第三份也是最后一份草稿中,我决定直接介绍有效事件。如果你顺着思路走下去,那么有效事件将是成为你唯一需要学习的设计模式。


Thomas Hansen 自 8 岁起便一直在开发软件,他于 1982 年就开始使用 Oric-1 计算机编写代码。编写的代码偶尔确实是利大于弊。他对 Web、Ajax、敏捷方法和软件体系结构充满热情。

衷心感谢以下 Microsoft 技术专家对本文的审阅: James McCaffrey