我们能构建需要拦截支持的功能。
应该注意,按照POJO/POCO的严格定义,我们的领域模型类不应该继承基础架构基类。不过,我们可以很容易改过来,只要将所有的逻辑从公共基类移到代理子类中去,就可以得到一个完全的POJO/POCO领域模型。如果一定要满足严格的POJO/POCO,我们的方法很容易满足这项要求,只是举手之劳。如果不强求,我们可以使用基类,此时领域模型类的代码仍然完全不包含任何实际的基础架构代码。
因此到目前为止我们已经整理出了一个架构,能让我们把完全POJO/POCO的领域模型类和将基础架构代码分布到领域模型中去的目标结合在一起。如果我们的目标仅仅是避免肥领域模型,而不强求符合POJO/POCO定义,那么我们就能走“半POJO/POCO”的路线,用一个公共基类来节省一些工作。
使用抽象工厂模式
这听起来很棒,对不对?你可能在想,用它是不是没有任何问题呢。软件业的人都有些吹毛求疵,你大概已经在疑心有那么一两个棘手问题该冒头了吧。
你是对的。有两件值得关注的事情马上就自己显现出来了。第一个相当轻微:为了让子类能够重写领域模型类的成员,并提供拦截,所有的领域模型成员(或至少打算拦截的那些成员)必须是虚拟的。
第二个问题好像更为糟糕一些:既然你想让你的程序使用代理子类的实例,你必须在应用代码中查找所有创建领域模型类实例的地方,将它们改成创建相应的代理子类的实例。
要修改已存在的应用,听起来像个噩梦吧?没错。要想避免这种大规模的查找和替换操作,只有一条出路,就是从一开始就避免使用“new”关键字来创建领域模型类的实例。
避免"new"的传统方法就是使用抽象工厂模式[GoF设计模式]。调用一个工厂类的Create()方法,而不是任由客户代码使用“new”来创建实例。Create()方法负责调用“new”,并且在返回新的实例之前会对其做一些额外的相关设置操作。
如果你在调用领域模型的时候全都使用抽象工厂模式,那就最好不过了,接下来你只要在代码中改一个地方——工厂类——将返回领域类实例改为返回代理子类(或者是基于接口的代理)的实例。
这是使用抽象工厂模式来实例化所有的领域对象的一个重要理由——至少使用像Java和C#这样的语言时是这样,这些语言不允许奇异特性,比如不能像C++一样重载(overload)成员取用运算子,不能像ObjectiveC一样改变“new”关键字的行为,也不能像Ruby一样在运行时修改类。
继承反射
对于代理子类的方法,还有一个问题值得一提。虽然只是一种极端情况,但是它相当隐蔽,如果你陷入这个问题却不知道是什么引起的,会被它狠狠咬上一口。
如果你看一下图9,你会发现Employee继承Person类,这是应该的:无论什么时候,如果一个方法期望Person对象,那么传递给它
Employee对象应该也能工作。此外,PersonProxy类继承Person类。这也很好,因为这意味着将一个PersonProxy对象作为参数传递给期望Person对象的方法也是“合法的”。
图 9
以同样的方式,EmployeeProxy继承Employee,意味着你能把一个EmployeeProxy对象作为参数传递给任何期望Employee
对象的方法。最后,由于EmployeeProxy继承Employee,Employee又继承Person,这表明EmployeeProxy继承
Person。因此,任何期望Person对象的方法也可以接受EmployeeProxy对象。
所有一切都符合我们的期望。当我们让抽象工厂开始返回代理对象而不是领域模型类的简单实例时,有一点是很重要的,我们希望客户端代码仍然继续正常运行,而无需重新考虑如何处理我们的类型层次。
换句话说,如果我们原来向一个期望Person对象的方法传递Employee对象给,当我们忽然换成给它传递EmployeeProxy对象的时候,我们的代码必须还能正常编译(并且工作)。幸运的是,由于EmployeeProxy继承Employee,并且Employee继承Person,所以没有问题。
事实上,当开始使用代理来代替领域模型对象时,一切都将如常继续运转,正如我们希望的。只不过,有一个个非常微小的例外。
图9中的红色继承线表示EmployeeProxy不继承PersonProxy。这什么时候会带来问题呢?
好,考虑一下这样一个方法,它接受两个对象,并用反射来确定其中一种对象是否是另一种对象的子类。当我们把一个Person对象和一个Employee对象传递给该方法时,它会返回true。但是当我们把一个PersonProxy对象和一个EmployeeProxy对象传递给该方法时,突然它就返回
false了。
除非你有基于反射的、检查继承层次的代码,不然应该是安全的。但是万一你真的有这样的代码,这里先警告一下也没坏处(你可以通过修改反射代码来避开这个问题,让它检测代理类型,并一路向上直到找到一个非代理类型)。
使用混入(Mixin)和拦截器类
我们已经向着可维护的领域模型架构努力了很长时间,让它能容下一个有效率的、可访问的基础架构来实行运行时领域模型管理。
我们是否可以做得更多呢?
让我们开始看一下那些只针对某些领域类、而不是全部领域类的功能——对于这些功能,我们需要将所需的基础架构成员放在代理子类中,而不是在公用的基类中。在我们的例子中,为懒加载功能设置的加载标记将必须添加到需要支持懒加载的每个类的子类中。
由于我们可能需要在多个子类中放相同的代码,所以我们明显会导致代码重复。只有一个标记的时候情况还好,但是对于需要多个属性、甚至需要多个有复杂实现的方法的功能呢?
在我们的例子中,加载标记是一次写入(write-once),所以在变成true之后不能再切换回来。因此,在setter方法中,我们有一些代码来实施这个规则——在需要懒加载的每个领域模型类的子类中,我们都要在setter方法中重复的那些代码。
我们会希望创建一个可重用的类来包含这种逻辑。但是,我们已经用了继承(仍然假定我们工作在单继承平台上),那么这个懒加载类该怎样被重用呢?
一个很好的答案就是使用组合模式[GoF Design Patterns],而不是为此采用继承。使用这种模式,EmployeeProxy子类将包含一个内部属性来引用可重用的懒加载类的实例(图10)。
图 10
这种可重用的类常常被称为混入(mixin),它反映了这样一个事实,即实现被加入到类型中,而不是成为继承层次的一部分。使用混入的作用是,让单继承语言(甚至是在只支持接口继承的平台上,比如COM+)也能够获得类似多重继承的效果。
有一点可以补充说明一下,使用我们这种组合方式,以混入的形式存在的行为和状态能动态地被加到目标类中(使用依赖注入),而不是静态地绑定到目标上去(如果行为是继承下来的就会如此)。尽管在这篇文章中我们不会更多关注于如何利用它的潜能,但是它为支持一些非常灵活和动态的场景提供了非常大的空间。
通过将子类引入的成员分离到一个可重用的混入类,我们向一个真正模块化、一致的、内聚的架构迈进了一大步,这个架构还是低耦合的、高度代码可重用的。我们能向这个方向再进一步吗?
那么,接下来要做的事就是把拦截代码从子类中分离出来,并放到可重用的类里面。这些拦截器类会像混入一样包含在代理子类里面,如图11.
图 11
Person、 Employee和DomainBase与代码的上一个版本保持不变,但是PersonProxy和EmployeeProxy发生了变化,并且我们引入了两个新的拦截器类和一个新的混入类。清单5显示了我们进行这些重构(不包括未修改的类)之后代码的样子。
//These proxy subclasses contain only boilerplate
//code now. All actual logic has been refactored
//out into mixin and interceptor classes.
public class PersonProxy : Person
{
private DirtyInterceptor dirtyInterceptor = new DirtyInterceptor();
public override string Name
{
get { return base.Name; }
set
{
dirtyInterceptor.OnPropertySet(this, this.name, value);
base.Name = value;
}
}
}
public class EmployeeProxy : Employee, ILazy
{
//This mixin contains the implementation
//of the ILazy interface.
private ILazy lazyMixin = new LazyMixin();
private LazyInterceptor lazyInterceptor = new LazyInterceptor();
private DirtyInterceptor dirtyInterceptor = new DirtyInterceptor();
public override string Name
{
get
{
lazyInterceptor.OnPropertyGetSet(this, "Name");
return base.Name;
}
set
{
lazyInterceptor.OnPropertyGetSet(this, "Name");
dirtyInterceptor.OnPropertySet(this, this.name, value);
base.Name = value;
}
}
public override decimal Salary
{
get
{
lazyInterceptor.OnPropertyGetSet(this, "Salary");
return base.Salary;
}
set
{
lazyInterceptor.OnPropertyGetSet(this, "Salary");
dirtyInterceptor.OnPropertySet(this, this.name, value);
base.Salary = value;
}
}
//The ILazy interface is implemented
//by forwarding the calls to the mixin,
//which contains the actual implementation.
bool ILazy.Loaded
{
get { return lazyMixin.Loaded; }
set { lazyMixin.Loaded = value; }
}
}
//The following mixin and interceptor classes
//contain all the actual infrastructural logic
//associated with the dirty tracking and
//the lazy loading features.
public class LazyMixin : ILazy
{
private bool loaded;
///
/// The Loaded property is "write-once" -
/// after you have set it to true you can not set
/// it to false again
///
bool ILazy.Loaded
{
get { return loaded; }
set
{
if (loaded)
return;
loaded = value;
}
}
}
public class DirtyInterceptor
{
public void OnPropertySet(
object obj,
object oldValue,
object newValue)
{
if (!oldValue.Equals(newValue))
{
IDirty dirty = obj as IDirty;
if (dirty != null)
dirty.Dirty = true;
}
}
}
public class LazyInterceptor
{
public void OnPropertyGetSet(object obj)
{
ILazy lazy = obj as ILazy;
if (lazy != null)
{
if (!lazy.Loaded)
{
lazy.Loaded = true;
//perform lazy loading...
//(omitted)
}
}
}
}
清单 5
通过重构,最终所有实际的基础架构逻辑都被放进了混入和拦截器类,代理子类变得很苗条,变成专注于转发请求到拦截器和混入的轻量级类。事实上,在这一点上,代理子类里面除了由很容易被代码生成工具生成的样板代码之外,再没其它什么内容。
一种选择是在设计时生成代理子类的代码,但是像C#和Java这样的语言能让你在运行时生成、编译、执行代码。因此,我们也可以选择在运行时生成代理子类,这种做法就是俗称的运行时子类化(runtime
subclassing)。
花一点儿时间回头想想我们走了多远。在第一步,我们将肥领域模型重构为带有代理子类的POJO/POCO领域模型(还可选择增加一个基类),同时基础架构代码仍然分布在领域模型中。在重构的第二步,所有实际的基础架构逻辑从代理子类分离了出来,并加入到可重用的混入和拦截器类,在代理子类中只留下了样板代码。
在最后一步,我们转向使用运行时代码生成器来为样板代理子类生成全部代码。就这样我们一路跋涉到了面向方面编程的疆界。
使用面向方面编程
很长一段时间,我对AOP的大肆宣传都感到很新奇,每次我尝试研究它是怎么一回事的时候,就会完全迷惑——这主要因为AOP社区坚持使用他们哪种奇怪的术语。而且常常有一些(不是全部!)AOP倡导者似乎在故意让这个领域看起来那么的不同,让人兴奋并觉得胆怯。
因此,如果说读到这里,实际上你已经掌握了面向方面编程的所有主要概念,你可能会感到惊讶——不但如此,而且你已经看到了AOP框架在背后是如何运作的,从头到脚。
面向方面编程使用了“方面”这个核心概念,方面简单来说是一组引介(introductions,即混入)和通知(advice,即拦截器)。方面(拦截器和混入)可以通过运行时子类化(runtime
subclassing)的方式(以及其他技术)在运行时应用于现有的类。
正如你看到的,你已经理解了大部分怪异但重要的AOP术语。被大量用来描述方面对什么有益的术语横切(crosscutting)关注点,简单意思就是你有一个能应用于大多数(不一定属于同一个继承树的)类的功能——例如我们的脏跟踪和懒加载功能,这些功能就是横切跨领域模型的关注点。
我们至今还未真正涵盖、唯一真正重要的AOP术语是连接点(join point)和切点(pointcutting)。连接点是目标类中的一个地方,你可以在那里应用拦截器或混入。通常,拦截器所关心的连接点是方法,而混入所关心的连接点则是类本身。简单地说,连接点就是你能加入方面的点。
为了确定哪个方面应该被应用到哪个连接点,你可以使用切点语言。这个语言可以很简单,可以是描述性的语言,比如你可能只是在一个xml文件中命名方面、命名连接点组合;但是也有复杂的可以使用正则表达式、甚至完整的领域特定语言(Domain
Specific Languages)来进行更高级的“切点“。
到目前为止,我们还尚未关注切点,因为我们已经在我们需要的地方手工应用了我们的方面(我们的混入和拦截器)。但是,如果我们要想走完最后的一步,为应用我们的方面而使用AOP框架,我们还是需要考虑这个问题。
但在我们做之前,先让我们停下来思索一下这个计划。在我们前面已经完成那么多重构之后,现在加入AOP框架步子其实并不大。它仅仅是很小的一步,因为我们已经有效地重构了我们的应用,使其使用拦截器(通知)和混入(介绍)形式的方面。
事实上,我会认为我们实质上已经在利用AOP了——我们只是还没有用AOP框架来为我们自动化那些例行公事的部分。但是我们利用着来自AOP的所有基本的概念(拦截和混入),并且我们用一种可重用的方式处理横切关注点。这听起来像极了AOP的定义,所以AOP框架能帮助我们自动化完成之前的一些手工工作是一点儿也不会令人感到意外的。
目前拦截器和混入类中的代码只要轻微地修改一下就是方面了——它已经被重构为一个泛化的、可重用的形式,这正是方面应该具有的形式。我们需要AOP框架做的只是以某种方式帮助我们将方面应用到领域模型类中。如前所述,AOP框架有许多方式可以做到这一点,其中一种方式是在运行时生成代理子类。
如果用的是运行时子类化框架,生成的代理子类代码看起来会和我们例子中代理子类的样板代码非常近似。换句话说,它很容易生成。如果你认为你可以写一个代码生成器来完成任务,那么你就在实现你自己的AOP框架引擎的半路上了。
重构到方面
在我们最后的重构中,我们将看一下,如果我们使用一个AOP框架来应用我们的拦截器和混入,那么我们的例子代码会怎样。在这个例子中,我将使用
NAspect,这是Roger Johansson和我合作完成的一个针对.NET的开源AOP框架。NAspect使用运行时子类来运用方面。
读到这里,至今你应该不难理解NAspect执行的“花招”——它简单地生成包含样板代码的代理子类,样板代码需要转发请求到混入和拦截器,并且它使用标准的、面向对象的、领域类成员的重写,来提供拦截。
很多人把AOP和最糟糕的巫术联系在一起。奇怪的术语已经令人怯步,开发人员在领域类代码中预见不到的事情忽然并“毫无察觉”地发生在拦截器中,令很多人遭受打击,深深地感到不安。
当然,具体了解这些术语是什么意思会有帮助。能理解至少一种运用方面的方法也是有帮助的,至少能让你看到这不是什么魔术——只是几个良好的、成熟的、众所周知的、面向对象的设计模式在发挥作用。
AOP就是用我们熟知的面向对象来对付横切关注点。AOP框架只是让你能够更舒服地运用方面,不用自己手写许多代理子类。
所以,现在我们不会再被AOP给吓到了,让我们看看我们如何使用它来使我们的应用向终点线迈出最后一步。
在这个例子里,我们要做的第一件事情是包含一个对NAspect框架程序集的引用。准备好之后,我们就可以开始创建我们的方面、确定我们的切点。我们也可以直接删除PersonProxy和EmployeeProxy类——从现在开始,NAspect会为我们生成这些类。
如果我们愿意,我们甚至可以删除DomainBase基类——没有必要使用基类来应用公用于所有基类的混入,我们也可以使用AOP框架来做这些。这意味着,通过使用AOP框架,我们甚至可以无需任何额外工作就能满足POJO/POCO的严格要求。
为了除去基类,我们只需要把功能移到混入中去。就我们的情况而言,我们仅需要再创建一个混入——DirtyMixin,它持有先前放在基类中的脏标记。
LazyMixin类中的代码完全保持不变,所以这里不再列出。实际上,我们真正需要去做的是创建新的DirtyMixin类,并稍微修改两个拦截器中的代码,让它们使用NAspect传进来的描述被拦截方法的数据结构。
我们还需要修改我们的工厂类,以便它使用NAspect来为我们创建代理子类。这假定我们已经在应用中的所有地方都转为使用抽象工厂模式。如果我们还没有这样做,现在我们一定要汲取教训,因为没有抽象工厂模式,我们将不得不进行另一项庞大的搜索、替换操作,仔细检查代码中每一个代理子类被实例化的地方,改为调用NAspect运行时子类化引擎。
重构后的代码如清单6所示,准备好使用NAspect。
public class DirtyMixin : IDirty
{
private bool dirty;
bool IDirty.Dirty
{
get { return dirty; }
set { dirty = value; }
}
}
public class DirtyInterceptor : IAroundInterceptor
{
public object HandleCall(MethodInvocation call)
{
IDirty dirty = call.Target as IDirty;
if (dirty != null)
{
//Extract the new value from the call object
object newValue = call.Parameters[0].Value;
//Extract the current value using reflection
object value = call.Target.GetType().GetProperty(
call.Method.Name.Substring(4)).GetValue(
call.Target, null);
//Mark as dirty if the new value is
//different from the old value
if (!value.Equals(newValue))
dirty.Dirty = true;
}
return call.Proceed();
}
}
public class LazyInterceptor : IAroundInterceptor
{
public object HandleCall(MethodInvocation call)
{
ILazy lazy = call.Target as ILazy;
if (lazy != null)
{
if (!lazy.Loaded)
{
lazy.Loaded = true;
//perform lazy loading...
//(omitted)
}
}
return call.Proceed();
}
}
public class Factory
{
public static IEngine engine = ApplicationContext.Configure();
public static Domain.Person CreatePerson()
{ return engine.CreateProxyPerson>();
}
public static Domain.Employee CreateEmployee()
{
return engine.CreateProxyEmployee>();
}
}
清单 6
为了知道你的方面要应用到哪个类,NAspect在程序的配置文件中使用一个xml配置段(也有其它选择)。现在我们可以在xml文件中定义方面,指出混入和拦截器类的类型,以及我们想应用的类型。大体上,我们准备好了尝试一下我们的AOP应用。
不过,仍然有一点让许多开发人员对使用AOP犹豫不决,甚至当他们理解了AOP的术语和理论,那就是由于切点体系定义得不完善而带来的脆弱性。
虽然我们通过一些巧妙的正则表达式来筛选出打算应用方面的类和成员,但风险是当领域模型变化的时候,你可能会忘了更新切点定义,以致正则表达式忽然筛选到了不应该应用方面的类。就是这类问题给AOP带来了巫术的坏名声。
处理切点不明确的一个方式是创建自定义的.NET属性(Attribute)(Java中的注释(annotations))。用自定义属性装饰领域模型,然后使用属性做为切点目标,这样就可以避免使用正则表达式之类的小花招。方面只应用于我们决定该应用的地方,通过在那里放置属性。
因此根据我们的情况,我们继续创建两个自定义属性——LazyAttribute和DirtyAttribute——接下来我们使用它们来装饰我们的类(清单7)。
public class DirtyAttribute : Attribute
{
}
public class LazyAttribute : Attribute
{
}
[Dirty]
public class Person : DomainBase
{
protected string name;
public virtual string Name
{
get { return name; }
[Dirty]
set { name = value; }
}
}
[Dirty]
[Lazy]
public class Employee : Person
{
protected decimal salary;
public virtual decimal Salary
{
[Lazy]
get { return salary; }
[Dirty]
[Lazy]
set { salary = value; }
}
public override string Name
{
[Lazy]
get { return name; }
[Dirty]
[Lazy]
set { name = value; }
}
}
清单 7
我们接着在应用的配置文件中定义我们的切点(清单8),让方面以我们自定义的属性为目标。
name="naspect"
type="Puzzle.NAspect.Framework.Configuration.NAspectConfigurationHandler, Puzzle.NAspect.Framework.NET2"/>
name="DirtyAspect"
target-attribute="InfoQ.AspectsOfDMM.Attributes.DirtyAttribute, InfoQ.AspectsOfDMM" >
target-attribute="InfoQ.AspectsOfDMM.Attributes.DirtyAttribute, InfoQ.AspectsOfDMM" >
type="InfoQ.AspectsOfDMM.Aspects.DirtyInterceptor, InfoQ.AspectsOfDMM" />
name="LazyAspect"
target-attribute="InfoQ.AspectsOfDMM.Attributes.LazyAttribute, InfoQ.AspectsOfDMM" >
target-attribute="InfoQ.AspectsOfDMM.Attributes.LazyAttribute, InfoQ.AspectsOfDMM" >
type="InfoQ.AspectsOfDMM.Aspects.LazyInterceptor, InfoQ.AspectsOfDMM" />
现在,我们最后测试一下我们的AOP应用。当我们运行应用时,NAspect会在运行时为我们生成代理子类,并在子类中插入样板代码来将所有的请求转发到拦截器和混入类。
程序最后的架构可参看图12。注意,这个版本和先前非AOP版本之间的唯一实际差别是,两个代理类(使用虚线边框标识)将由AOP框架生成,而之前我们必须手工编码。
与图11比较,我们多了一个DirtyTrackerMixin类和一个新的IDirtyTracker接口,来代替DomainBase基类,但这些东西哪怕我们不使用AOP,只要决定不使用公共基础架构基类(以满足更严格的POJO/POCO需求)就是要有的。换句话说,如果我们不想使用一个公共基类,不管我们是否使用AOP框架,我们最终都会得到一样的架构(图12所示)。
图 12
切点选项
当你添加新的领域模型类时,要想使它们能够懒加载和脏跟踪,只需要用自定义属性装饰它们,就会”变魔术一样地“获得相应功能了。
用属性/注释来作为切点目标,很快你就会注意到,由于每项功能对应一个属性,很快领域模型中每个成员的属性就太多了。因此,为了减少属性的数量,你可能想找到它们之间的抽象性。
如果你适应基于正则表达式的切点方法,并且你觉得属性令领域模型变得杂乱,使得保持领域模型与基础架构关注点完全无关的目标受到了损害,那么另一种选择是,你可以只配置你的xml配置文件来匹配相关的目标类,并且不需要用自定义属性装饰你的领域类。
另外,NAspect的xml切点语言(像许多其它的AOP框架一样)允许你简单地提供一个完整的类型清单,方面会运用于列出的类型,让你不用去找到一个刚好只匹配正确类的正则表达式。这使配置文件变得更长,但是能让你的领域模型保持完全干净。
在使用切点来动态运用方面的地方使用AOP框架,还有一个好处就是,当方面被用在不同的用例中时,把不同的方面应用于相同的类中会变得很容易。如果一个用例要求某个领域类懒加载,而另一个用例不需要,那么只有第一个用例需要使用应用懒加载方面的配置文件。
这个动态的方面应用可以非常强大,比如在测试场景可以额外应用一些测试用的方面,提供mocking之类的功能。
使用面向方面编程的结论
我们是否可以在领域模型中放置任何基础架构代码,这是我们开始时的问题。我们看到,这绝对可取,因为它令许多功能得以更有效率地实现,但是可能使我们的领域模型变得“肥胖”。
为了解决这个问题,我们重构了我们的肥领域模型——首先是把基础架构代码移到代理子类和一个基类中,然后将实际的逻辑从代理子类中移出、移到混入和拦截器类中。这使我们的领域模型变得更漂亮,但是在代理子类中最终却有很多样板代码需要写。为了解决这个问题,我们转向了面向方面编程。
结果发现迈向AOP框架的步子原来是如此之小——事实上应用的架构并没有任何改变——以至它大概使一些读者感到惊奇,这些读者甚至有熟悉的感觉,这种感觉就像当你第一次理解一个设计模式时,你意识到它就是你已经用了很多次的东西,只是不知道它有一个这样的名字。
你很可能在许多应用中有效地使用过AOP,但并没认识到它是AOP,并且好像没有使用过AOP框架来帮助你自动化地生成部分样板代码。
我们现在在这篇文章的结尾处,很希望在这个地方你会同意我的看法: