简介
软件开发项目正在变得日趋庞大与复杂。越是复杂的项目,其软件开发与维护的成本越有可能远远超过花费在硬件上的成本。
软件的规模与其开发和维护的成本之间存在着一种超线性的关系。说到底,庞大且复杂的软件需要优秀的工程师进行开发与维护,而优秀的工程师总是难以吸引的,留住他们的代价也更高昂。
尽管维护每行代码的成本如此高昂,但我们依然编写了大量的样板代码,而这其中有很大一部分可以由更智能的编译器来替代完成。实际上,多数模板代码只是重复地实现设计模式,而其中一部分模式已被理解得十分透彻,只要我们教会编译器一些技巧,它们完全是可以自动实现的。
实现观察者模式
以观察者模式作为例子。这个模式在 1995 年就已被早早地提出了,并且成为了 Model-View-Controller 架构成功实现的基础。组成这个模式的各元素在首个版本的 Java(1995,Observable 接口)和.NET(2001,INotifyPropertyChanged 接口)中都得到了实现。虽然这些接口都是框架中的一部分,但还是需要开发者的手动实现。
INotifyPropertyChanged 接口仅包含一个名叫 _PropertyChanged_ 的事件,当对象的任何一个属性值发生变化时,都需要触发该事件。
让我们来看一看一个简单的.NET 示例:
public Person : INotifyPropertyChanged { string firstName, lastName; public event NotifyPropertyChangedEventHandler PropertyChanged; protected void OnPropertyChanged(string propertyName) { if ( this.PropertyChanged != null ) { this.PropertyChanged(this, new PropertyChangedEventArgs(propertyName)); } } public string FirstName { get { return this.firstName; } set { this.firstName = value; this.OnPropertyChanged(“FirstName”); this.OnPropertyChanged(“FullName”); } public string LastName { get { return this.lastName; } set { this.lastName = value; this.OnPropertyChanged(“LastName”); this.OnPropertyChanged(“FullName”); } public string FullName { get { return string.Format( “{0} {1}“, this.firstName, this.lastName); }}}
属性最终依赖于一组字段,一旦我们改变一个字段,那我们就要为一个相关联的属性触发 PropertyChanged 事件。
难道编译器不能为我们自动完成这一工作吗?完整的答案是:如果我们考虑到所有可能发生的边界情况,那么要检测字段与属性之间的依赖确实是一项令人望而生畏的任务。因为属性所依赖的字段有可能指向其它对象,这些对象可以调用其它方法。更糟的是,它们还可能调用虚方法或 delegate,而编译器却无法确定具体的类型。因此对这个问题来说,如果我们不希望编译时间达到几小时或几天,而是可以在几秒或几分钟内就完成的话,那确实不存在一个通用的解决方案。不过,在真实场景中,编译器是可以理解大多数简单的属性的。因此简短的回答是:是的,在典型的应用中,编译器可以为超过 90% 的属性生成通知代码。
在实践中,同样的类可以由以下方式实现:
[NotifyPropertyChanged] public Person { public string FirstName { get; set; } public string LastName { get; set; } public string FullName { get { return string.Format( “{0} {1}“, this.FirstName, this.LastName); }} }
这段代码告诉了编译器要做 _ 什么 _(实现 INotifyPropertyChanged),而不是该 _ 怎样 _ 做。
样板代码是一种反模式
观察者(INotifyPropertyChanged)模式仅是在大型应用程序中产生大量样板代码的一个例子,而在典型的代码库中经常充斥着实现各种模式的大量样板代码。即使它们并不总是被认可为“官方的”设计模式,但它们依然是模式,因为它们在代码库中经常重复不断地出现。最常见的代码重复的原因包括:
- 追踪、日志
- 前置条件与不变式的检测
- 授权与审计
- 锁定与线程分配
- 缓存
- 跟踪变化(以实现撤消 / 重做)
- 事务处理
- 异常处理
这些特性难以用寻常的面向对象技术进行封装,这也是造成了它们经常用样板式代码实现的原因。这真的是一件那么糟糕的事吗?
确实是。
使用样板代码解决横切关注点(cross-cutting concerns),会最终导致其违反优秀软件工程应遵守的基本原则:
- 当一个单一属性的 setter 方法中的实现囊括了多个关注点的内容,如验证、安全、INotifyPropertyChanged 及撤消 / 重做时,单一职责原则即被违反。
- 能够在不修改现有代码的情况下加入新的特性,才是最好地实现了开闭原则,该原则指出,软件实体应该对扩展开放,而对修改关闭。
- 不要重复你自己(DRY)原则不能容忍因手工实现设计模式所带来的代码重复。
- 当由于某个模式的实现难以更改,而不得不手动实现时,就违反了松耦合原则。请注意,耦合不仅仅产生于两个组件之间,也会产生在组件和概念设计之间。将一个类库替换为另一个实现了相同概念设计的库通常是比较容易的,但要切换为一个不同的设计则需要多得多的源代码改动。
除此之外,样板会使你的代码:
- 在试图理解代码如何实现功能需求时,你会发现它难以阅读并理解其原因。考虑到阅读代码在软件维护中占用了 75% 的时间,这一层无谓的复杂性对于软件维护是个巨大成本消耗。
- 代码更庞大,这不仅意味着生产力的降低,也意味着开发与维护软件的成本提高,更不用说产生 bug 的风险也增加了。
- 难以重构及修改。修改一段样板代码(通常是为了修复某个 bug)意味着所有应用该样板的地方都需要一起改变。当某个样板库可能横跨多个解决方案或者是源代码库时,你又如何准确地指出该样板到底在你的整个代码中的哪些地方被用到呢?莫非你打算进行查找与替换吗?
如果你将那些样板代码置之不理,那它们就会像杂草一样爬满你的代码,每次应用到某个新方面时,都会占用更多的空间。直至某日,你的代码库将会被样板代码所占满。在我之间待过的某个团队中,一个简单的数据访问层的类就有超过 1000 行的代码,而其中 90% 的代码是处理各种 SQL 异常及重试的样板代码。
我希望你现在已经理解了为什么使用样板代码实现模式是一种糟糕的方式。它实际上是一种反模式,因为它会导致不必要的复杂性、bug、高昂的维护代价、生产力的缺失并最终导致更高的软件成本。
设计模式自动化与编译器扩展
很多时候,我们纠结于创建可重用的样板代码的原因,是由于 C#和 Java 这样的主流的静态类型语言缺乏对于元数据编程的原生支持。
编译器能够获取许多我们在编码时无法得知的信息,如果我们可以从这些信息中受益,并且通过编写编译器扩展以帮助我们实现设计模式,那不是很好吗?
一个更智能的编译器能允许我们实现以下几点:
- 编译时程序转换: 能够使我们在维持现有代码的语义、复杂性以及代码行数的前提下加入新特性,从而使我们能够自动实现某个设计模式中可自动化的一部分。
- 静态代码检验:为保证编译时安全,它将确保我们正确地使用了设计模式,或是检查某个模式中不能实现自动化的那些部分是否已按照一系列预定义的规则正确地实现了。
示例:C#中的“using”和“lock”关键字
如果你需要证据表明编译器能够直接支持设计模式,只需看看 _using_ 和 _lock_ 关键字就可以了。乍一看,这些关键字在 C#语言中似乎是多余的,但 C#的设计者们意识到了它们的重要性,并专门为它们创建了特有的关键字。
让我们看一看 using 关键字,它实际上是整个 Disposable 模式的一部分,由以下参与者所组成:
- 资源对象:占有任何外部资源的对象,例如一个数据库连接。
- 资源占用者:在某个特定的生命周期中占有资源对象的指令块或者是对象。
Disposable 模式的规则由下列原则构成:
- 资源对象必须实现 IDisposable 接口。
- IDisposable.Dispose 方法的实现必须是冥等的(无副作用的),例如,它可以被安全地调用任意多次。
- 资源对象必须包括一个终结器(在 C++ 中叫做析构器)。
- IDisposable.Dispose 方法的实现必须调用 GC.SuppressFinalize 方法。
- 通常来说,如果某个对象的字段指向一个资源对象,那么该对象本身也成为资源对象。子资源对象应该由它的父对象来清除。
- 分配及占用一个资源对象的指令块必须由 using 关键字进行修饰(除非对资源的引用是保存在对象本身的状态中,请参见上一点)
如你所见,Disposable 模式实际上比它第一眼看上去要复杂得多。这个模式是怎样自动化并强制地实现的呢?
- NET 核心类库提供了 _IDisposable_ 接口。
- C#编译器提供了 _using_ 关键字,它会在编译时自动生成某些代码(即一个 try/finally 语句块)。
- 可以使用 FxCop 定义一个强制的规则,要求所有 disposable 的类必须实现终结器,并且 Dispose 方法必须调用 GC.SuppressFinalize。
因此,Disposable 模式完美地表现了.NET 平台可以直接支持设计模式的实现。
那么那些没有原生支持的模式呢?它们可以通过组合使用类库及编译器扩展来实现。我们的下一个示例同样来自 Microsoft。
示例:代码契约
一直以来,对前置条件进行检验(也可选择性地检验后置条件及不变式)被认为是一种能够避免某个组件中的缺陷造成另一个组件出错的最佳实践。具体思路是这样的:
- 每个组件(一般来说是指每个类)应该被设计为一个“单元”;
- 每个单元为它自己的健壮性负责;
- 每个单元都要检查任何一个来自其它单元的输入;
检验前置条件可以被认为是一种设计模式,因为它是对一个不断发生的问题的可重复的解决方案。
Microsoft 的代码契约就是设计模式自动化的一个完美的例子。它基于原生 C#或 Visual Basic,为你提供一组 API 以表达检验规则,规则的具体形式包括前置条件、后置条件和对象不变式。不过,该 API 不仅仅是一个类库,它还会为你的程序进行编译时转换及检验。
我不打算深入讲解代码契约过于细节的部分,简单地说,它允许你在代码中指定检验规则,并能够在编译时及运行时进行检查。举例来说:
public Book GetBookById(Guid id) { Contract.Requires(id != Guid.Empty); return Dal.Get<Book>(id); } public Author GetAuthorById(Guid id) { Contract.Requires(id != Guid.Empty); return Dal.Get<Author>(id); }
它的二进制重写工具能够(基于你的设置)重写你编译出的程序集,并注入额外的代码以检查你所设定的各种条件。如果检查一下由二进制重写工具所转换后的代码,你将会看到类似如下代码:
public Book GetBookById(Guid id) { if (__ContractsRuntime.insideContractEvaluation <= 4) { try { ++__ContractsRuntime.insideContractEvaluation; __ContractsRuntime.Requires(id != Guid.Empty, (string)null, "id != Guid.Empty"); } finally { --__ContractsRuntime.insideContractEvaluation; } } public Author GetAuthorById(Guid id)< { if (__ContractsRuntime.insideContractEvaluation <= 4) { try { ++__ContractsRuntime.insideContractEvaluation; __ContractsRuntime.Requires(id != Guid.Empty, (string)null, "id != Guid.Empty"); } finally { --__ContractsRuntime.insideContractEvaluation; } } return Dal.Get<program.author>(id); }</program.author>
关于 Microsoft 代码契约的更多信息,请在这里阅读 Jon Skeet 在 InfoQ 上的优秀文章。
像代码契约这样的编译期扩展固然很好,但官方推出的扩展往往要花费数年的时间进行开发,直至成熟与稳定。由于存在着这么多不同的领域,每个领域又有着它自身的问题,官方的扩展是不可能覆盖所有这些问题的。
我们所需要的是一个通用框架,它能以一种纪律性的方式自动化并强制实施设计模式,使得我们自己能够更有效地解决特定于领域的问题。
自动化并强制实施设计模式的通用框架
人们可能会想到动态语言、开放式编译器(如 Roslyn)或重编译器(如 Cecil)等解决方案,因为它们都暴露了抽象语法树的深度细节。但是这些技术是高度抽象层面的操作,导致使用它们实现任何转换都非常复杂,只能用于最简单的一部分。
我们所需要的是一个编译器扩展的高层次的框架,它基于以下原则:
- 提供一系列转换基元,例如:
- 注入方法调用;
- 在方法执行之前及之后运行代码;
- 注入对字段、属性或事件的访问;
- 为某个现有类加入接口实现、方法、属性或事件。
- 提供某种方式,以表达基元应该应用到何处:告诉编译扩展你需要注入一些代码固然是好事,但更好的是我们能得知哪些方法应该被注入!
- 基元必须能够被安全地组合在代码中的相同位置应用多种转换是很自然的需求,因此这个框架应该给我们一种组合这些转换的能力。
当你能够同时应用多种转换时,某些转换也许需要按照特定的顺序进行。因此转换的顺序需要遵循一个定义良好的约定,并且允许我们在适当时重写默认的顺序。
4. 扩展代码的语义应该不受影响转换机制应该保持低调,并尽量减少对原始代码的改动,同时提供对转换进行静态检验的功能。这个框架应该让源代码的意图不要被轻易地“破坏”。
5. 高级的反射与检验功能 按照定义,设计模式应该包含如何实现它的规则。例如,锁定设计模式应该规定实例字段只能被同一对象的实例方法所访问。这个框架必须提供一种机制以查询方法对某一给定字段的访问,并提供一种方式以产生整洁的编译时错误。
面向方面编程(AOP)
面向方面编程是一种编程范式,它旨在于通过允许关注分离以提高模块化。
“方面”(Aspect)是一种特殊的类,它包括了代码转换(称为通知(Advice))、代码匹配规则(粗略地称为切入点(Pointcut))以及代码检验规则。设计模式通常由一到多个方面实现。将方面应用到代码有多种方式,这主要取决于 AOP 框架的实现。定制特性(Java 中的注解(Annotation))是一种为所选的代码元素加入方面的便利方式,而更复杂的切入点可以由 XML 声明式地表达(例如 Microsoft Policy Injection Application Block)、或一门领域特定语言(例如 AspectJ 或 Spring)进行表述、或使用反射(例如由 LINQ 配合 PostSharp 调用 System.Reflection)编程实现。
编织(Weaving)过程将通知与初始源代码在特定的位置(一样粗略地称为连接点)组合在一起,它能够访问初始源代码的元数据,因此对于 C#或 Java 这样的编译语言来说,它就为静态编织者提供了一个执行静态分析的机会,以确保通知与它所应用之处的切入点两者之间关联的有效性。
虽然面向方面编程与设计模式是各自独立的概念,但 AOP 对于那些致力于实现设计模式自动化或强制实施设计规则的人来说是个很好的解决方案。与低层次的元数据编程不同,AOP 是按照以上介绍的原则设计的,因此不仅仅是编程器专家,任何人都可以通过它实现设计模式。
AOP 是一种编程范式而不是一门技术,也因此它可以通过不同方式实现。在 Java 阵营中领先的 AOP 框架 AspectJ,现在已经由 Eclipse Java 编译器直接实现了。而在.NET 阵营中,由于编译器未开源的缘故,实现 AOP 最好的方式是重编译器,将 C#或 Visual Basic 编译器的生成结果进行转换。在.NET 中领先的工具是 PostSharp(见下)。作为替代方式,某些 AOP 的子集可以通过动态代理及服务容器(service container)实现,并且多数依赖注入框架都至少能够提供方法注入的实现。
示例:使用 PostSharp 定制设计模式
PostSharp 是在 Microsoft .NET 中自动化并强制实施设计模式的一项开发工具,并以.NET 平台下最完整的 AOP 框架而闻名。
为了避免把这篇文章变成 PostSharp 的入门指导,还是让我们来看一个非常简单的模式吧:在一个前台(UI)线程和后台线程中反复地分配某个方法调用。该模式可以由两个简单的方面实现:一个方面将方法调用发送至后台线程,而另一个将方法调用发送至前台线程。这两个方面都可以由免费的 PostSharp Express 编译。首先来看一下第一个方面:BackgroundThreadAttribute。
该模式的生成部分非常简单:我们只需创建一个 Task 以执行方法体,并调度这个 Task 的执行。
[Serializable] public sealed class BackgroundThreadAttribute : MethodInterceptionAspect { public override void OnInvoke(MethodInterceptionArgs args) { Task.Run( args.Proceed ); } }
_MethodInterceptionArgs_ 类包含了方法调用的上下文信息,例如参数及返回值。你可以利用这些信息调用原始方法,缓存它的返回值,记录它的输入参数,或者你的用例所要求的任何部分。
对于该模式的检验部分,我们希望避免将这个定制特性应用到那些具有返回值或是具有某个引用传递的参数的方法上。如果这种情况发生,我们将希望生成一个编译时错误。因此,我们必须在我们的 _BackgroundThreadAttribute_ 类中实现 _CompileTimeValidate_ 方法:
// Check that the method returns 'void', has no out/ref argument. public override bool CompileTimeValidate( MethodBase method ) { MethodInfo methodInfo = (MethodInfo) method; if ( methodInfo.ReturnType != typeof(void) || methodInfo.GetParameters().Any( p => p.ParameterType.IsByRef ) ) { ThreadingMessageSource.Instance.Write( method, SeverityType.Error, "THR006", method.DeclaringType.Name, method.Name ); return false; } return true; }
_ForegroundThreadAttribute_ 看上去也差不多,它使用 WPF 中的 Dispatcher 对象,或是调用 WinForms 中的 BeginInvoke 方法。
以上两个方面可以像其它的 attribute 一样应用,例如:
[BackgroundThread] private static void ReadFile(string fileName) { DisplayText( File.ReadAll(fileName) ); } [ForegroundThread] private void DisplayText( string content ) { this.textBox.Text = content; }
最终源代码会比我们直接调用 Task 或 Dispatcher 的方式简洁许多。
有人可能会争辩道,C# 5.0 已经用 async 和 await 关键字更好地解决了这个问题。没错,这也是很好的例子,表现了 C#团队如何找到一个重复发生的问题,并决定通过在编译器和核心代码库中直接实现某个设计模式以解决该问题。只是.NET 的开发者社区必须等到 2012 才能得到这个方案,而 PostSharp 早在 2006 年就提供这个功能了。
.NET 社区还需要为其它通用设计模式的方案等待多久呢?例如 INotifyPropertyChanged?那些特定于你的公司的应用框架的设计模式又怎样呢?
更智能的编译器能允许你实现你自己的设计模式,提高你的团队的生产力也不再依赖于编译器提供商了。
AOP 的不足之处
我希望我已经说服了你,AOP 是自动化设计模式与强制良好设计的一种解决方案,不过,最好能了解到它也存在着一些不足:
-
缺乏人员储备
作为一种范式,AOP 并不是一门本科课程的内容,即使在硕士课程中也极少触及。这方面教育的缺乏也一定程度导致了开发者社区内对 AOP 缺乏一般性的认识。
尽管 AOP 已经出现了 20 年,它依然被误解为一门“新的”范式,这一点经常被证明为许多开发团队不敢采用它的最大障碍,只有最敢冒险的开发团队才敢于应用它。
设计模式存在的年限也差不多,但设计模式可以被自动实现及检验的想法是近期才出现的。我们在本文中举例说明了一些有意义的先进概念,包括 C#编译器、.NET 类库以及 Visual Studio Code Analysis(FxCop)等等,但这些先进概念还未被归纳为设计模式自动化的一种通用实现。
2. ### 惊讶的事实
由于人员和学生缺乏足够的准备,当他们应用 AOP 时也许会遇到各种 Surprise,因为应用程序中包含了一些附加的行为,而这些行为从源代码中不能直接观察到。注意:所谓令人惊讶的部分,是 AOP 所期望的效果,这是由于编译器做了些比通常更多的事,而不是指它产生了任何副作用。
也有某些惊讶是来自于 _ 未预计到 _ 的效果,某个方面(或某个切入点中)包含的 bug 可能会导致转换被应用到预计之外的类与方法上。调试这种错误可能会十分微妙,尤其在开发者未意识到某个方面被应用到这个项目中的情况下。
这些惊讶的事实可以由这些方法解决:
- IDE 集成,这有助于以可视化的方式(a)在编辑器中显示哪些附加特性被应用到代码中(b)显示某个指定的方面被应用到哪些代码元素中。在编写本文的时候,还只有两个 AOP 框架提供对 IDE 良好的集成:AspectJ(配合 AJDT plug-in 使用于 Eclipse 中)与 PostSharp(使用于 Visual Studio 中).
- 开发者的单元测试。方面本身以及它是否被正确地应用,必须和其它源代码一样进行单元测试。
- 在为代码应用方面时,不要依赖于命名约定,而是依赖于代码的组织特性,例如类继承或 custom attribute。注意,这一讨论并不仅限于 AOP,基于约定的编程在近期获得了广泛关注,虽然它的应用也伴随着许多 Surprise 的产生。
-
政策
使用设计模式自动化一般来说是一种敏感的政策问题,因为它也在一个团队中强调了关注分离的方式。通常情况下,高级开发者会选择设计模式并实现为方面,而初级开发者仅仅是应用它。高级开发者还会编写检验规则,以确保手写的代码符合架构规范。初级开发者不需要了解整个代码结构的这一事实,其实也是所预期的效果。
处理这一争论通常是比较微妙的,因为它是从一个高级管理者的角度出发,而往往会伤害到初级开发者的自尊心。
PostSharp 模式库中现成的设计模式实现
如同我们从 Disposable 模式中所看到的,即使是看上去很简单的设计模式实际上也可能需要复杂的代码转换或验证。某些转换和验证虽然复杂,但还是有可能自动实现的。而其它部分可能对于自动处理来说过于复杂,而不得不手动完成。
幸运的是,通过使用 AOP 框架,还是有些简单的设计模式(异常处理、事务处理及安全等等)是每个人都可以轻易地实现为自动化的。
经过多年的市场经验,PostSharp 团队意识到多数客户都在重复地实现相同的方面,于是他们开始为大多数通用的设计模式提供了高精度并且优化的现成实现。
PostSharp 目前已为以下设计模式提供了现成的实现:
- 多线程:读写同步(reader-writer-synchronized)线程模型,角色(Actor)线程模型,线程独占模型,线程调度;
- 诊断:为各种后台类型,如 NLog 及 Log4Net 等提供高性能并且详细的日志记录功能;
- INotifyPropertyChanged:包括对组合属性的支持以及对其它对象的依赖的支持;
- 契约:参数、字段及属性的检验。
现在,使用这些现成的设计模式的实现,开发团队在不必学习 AOP 的情况下就可以开始享受 AOP 所带来的好处了。
总结
像 Java 和 C#这样所谓的高级语言,它依然强制要求开发者在一个不恰当的抽象层面编写代码。由于主流编译器的限制,开发者被迫编写许多样板式代码,这给应用程序的开发和维护都加重了负担。样板源自于对模式的各种混乱的手工实现,这也许是代码复制 - 粘贴在此行业中延续至今的使用最多的情况。
未能实现设计模式的自动化或许使得软件行业平白消耗了数十亿美元,也使得那些软件工程师花费了大量的时间在处理结构体系上的问题,而不是将时间花在增加商业价值上。
不过,如果有更智能的编译器允许我们自动实现大多数的通用模式,那大量的样板代码就可以被消灭了。希望未来的语言设计者能够领会到:设计模式是现代化软件开发的一等公民,并且应该在编译器中得到适当的支持。
但实际上并不需要等待新编译器的出现,它们不仅存在,并且已经很成熟了。面向方面编程方法就是为解决样板代码的问题而特别设计的。AspectJ 和 PostSharp 都是这些理念成熟的实现,并且它们已使用在世界上几个最大的公司里了。并且 PostSharp 和 Spring Roo 都提供了大多数通用模式的现成的实现。一如既往,先行者能比其它追随者提早好几年获得生产力的提升。
在四人组的设计模式一书面市 18 年之后,设计模式也该成年了吧?
关于作者
Gael Fraiteur从小就热衷于编程,在他 12 岁的时候就创建并卖出了他的第一份商业软件。他是 PostSharp Technologies 公司的创建者兼首席工程师,公司坐落在捷克的布拉格。Gael 是一位在面向方面编程领域受到广泛认可的专家,他也常在欧洲及美国的各种开发者会议上进行演讲。
Yan Cui是 iwl 的一位 C#及 F#开发者,iwl 是 GameSys 公司在伦敦设立的负责社交游戏的部门,专注于为社交游戏创建高分布式及高伸缩性的服务端解决方案,这些游戏运行在 Facebook 和 Hi5 等平台上。他经常在英国的本地用户组和技术会议上演讲 C#和 F#方面的主题,并且也是个活跃的博主。
查看英文原文: Design Pattern Automation
感谢杨赛对本文的审校。
给InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家通过新浪微博( @InfoQ )或者腾讯微博( @InfoQ )关注我们,并与我们的编辑和其他读者朋友交流。
评论