颠覆C#王权的“魔比斯环” — 实现AOP框架的终极利器
本文曾发表于IT168:http://tech.it168.com/j/n/2007-05-09/200705091133359.shtml
时间要追溯到2005年。那时正在做硕士论文。题目是“AOP framework for .net”。这个AOP框架将使用C#2.0来实现。 这当然没什么令人惊奇的。从理论上说,任何开发语言都可以实现AOP框架。但要按着AOP联盟的规范实现这个AOP框架,大多数的开发语言并不能很容易地完成这项任务。
微软公司在我们心目中是强大的,而出自于微软的C#自然也会被认为是强大的。使用C#几乎可以很容易地完成大多数的应用程序(包括桌面、Web、移动等)。但要用C#来实现标准的AOP框架却不是那么容易,甚至有点强人所难。这到底是为什么呢?
一、 AOP的概念和原理
AOP(Aspect
Oriented Programming)
的中文意思是“面向方面编程”。它是10年前由施乐公司提出的。这种技术并不是一种新的编程技术,和OOP(Object Oriented Programming)并不是平级的。它只是OOP的一种扩展。
一个典型的软件系统由核心功能和系统功能组成。
所谓核心功能就是和这个系统相关的业务功能,如在mail服务器中的核心功能是接收和发送电子邮件。系统功能则可以看成是系统的可选功能,如日志、安全等。如果没有了这些功能,mail服务器仍可以照常工作,只是不太安全了,并且无法查找以往的记录。实现这些系统功能,一般的作法是将系统功能的代码和核心功能的代码混在一起。这样做不仅加大了系统设计和实现的难度,而且使设计系统核心功能的程序员必须要考虑这些系统的功能,分散了他们的注意力。
由于在软件开发中遇到以上问题,人们开始设想是否能将软件中的核心功能和系统功能分开,达到单独设计、实现、维护的目的。于是就有人提出在设计和实现时单独进行,当编译时将单独编写的系统功能的代码织入(weaves)到核心功能的代码中,将代码织入的工具叫织入器(weaver)。这种思想经过长期的实践,就逐渐形成了AOP的核心思想,同时也形成了AOP最早的一个实现:AspectJ。
AOP构建在已有的技术基础之上,同时提供了自己的一套额外机制,也就是Aspect机制,对系统进行描述、设计和实现。AOP要保证这些机制在概念上简洁,执行效率高。其基本思想是通过分别描述系统的不同关注点(属性或者兴趣)及其关系,以一种松耦合的方式实现单个关注点,然后依靠AOP环境的支撑机制,将这些关注点组织或编排成最终的可运行程序。关注点包括普通关注点和系统的贯穿特性。通常可以使用传统的结构化方法和面向对象方法提供的机制,对普通关注点进行处理;使用Aspect机制,对贯穿特性进行处理。系统的贯穿特性范围包括了从高层的关注目标,比如安全和服务质量,到低层的关注目标比如缓存处理等。贯穿特性可以是功能性的,比如事务规则,也可以是非功能的,比如同步和交易管理等。AOP将传统方法学中分散处理的贯穿特性实现为系统的一类元素—Aspect,并将它们从类结构中独立了出来,成为单独的模块。
为了支持上述的系统实现和运行过程,AOP系统首先必须提供一种声明Aspect的机制,包括Aspect如何声明,连接点(Aspect代码与其它代码的交互点)如何定义,Aspect代码如何定义,Aspect的参数化程度和可定制性等。其次,需要提供一种将Aspect代码和基础代码组合编排(Weaving)在一起的机制,包括定义编排语言和规则,解决Aspect之间潜在的冲突,为组装和执行建立外部约束等。最后,必须有生成可运行系统的实现机制,包括系统提供什么组合机制,是编译时刻静态组装,还是运行时动态组装;对程序单元分别进行编译的模块化编译机制;对AOP机制和现有系统兼容性的规约;对组装结果的验证机制等。
二、C#限制颇多,实现AOP框架困难重重
虽然诞生于10多年前的AOP技术在近几年开始逐渐流行起来。有一些应用很广的软件或框架,如JBoss、Spring,都使用了AOP技术。但这些AOP技术大多是基于java的。如AspectJ、JBoss AOP和Spring AOP。但是AOP技术在.net环境下应用得很少,其于.net的AOP 框架也不多。形成这种情况的原因很多。众所周知,实现AOP一般有两种方法。一种是利用动态代理或其它技术在程序运行时对方法等信息进行监控(如JBoss AOP和Spring AOP),即动态AOP。另外一种是直接在编译器中支持,就象AspectJ。但这种做法实现的难度较大,大多数AOP框架的实现都是采用了第一种方法。而动态代理技术在.net中(不管是VB.net和C#都一样)实现是非常困难的,在.net中并不象java提供了动态代理实现机制。在.net中要想实现动态代理必须得直接使用IL(Intermediate Language)写代码,这样就必须对IL有非常深入的了解。
由于要在C#中实现这个AOP框架,因此,我不可能再自己做个编译C#编译器甚至虚拟机。所以只能使用动态代理的方式来实现。但使用动态代理需要解决两个问题。
1. 如何动态生成代理类。
2. 如何拦截构造方法。
1. 如何动态生成代理类
在阐述问题之前,先让我们看一看什么叫代理类。代理类也是普通的类。只是这个类要继承于被代理的类。而且在代理类中的方法要覆盖父类中的方法。如类Class1的代码如下:
{
public virtual void Method1() {}
public virtual String Method2() { return s;}
public Class1(String s){}
}
上面的Class1有一系列的virtual方法,并且有一个构造方法。下面让我们来编写一个代理Class1的代理类ProxyClass:
{
public override void Method1(){ base.Method1(); }
public override String Method2(){ String s = base.Method2(); return s}
public Class2(String s) : base(s) {}
}
从ProxyClass类可以看出,在使用下面代码创建Class1对象后,仍然会得到Class1类中相应方法执行后的结果:
Class1 class1 = new ProxyClass(“hello world”);
虽然上面的代码可能大多数程序员都能理解(就是多态调用),但实际上这种代码对基于动态代理技术的AOP框架是毫无用处的。之所以叫动态代理,就是在程序执行时自动生成代理类。而上面的代码是静态地写到程序中的,在编译后无法更改。也许有人会说,根据Class1可以自动生成ProxyClass类的源码,然后在内存或硬盘上编译再调用不就可以了!这种做法可以是可以,但效率却非常的低。大家可以想象,如果每执行一个方法,就生成一堆C#源代码,再编译,恐怕C#就比脚本语言还慢了。
看到这些,也许那些C#或.net高手会说,直接生成MSIL不就行了。但你要知道,虽然MSIL没有汇编复杂,可对于大多数程序员来说,是可望而不可及的。因此,这个技术问题就成为实现AOP框架的第一个大障碍。
2. 如何拦截构造方法
AOP最重要的特性就是对方法的拦截,这其中也包括构造方法。所谓拦截,就是在执行原方法时,要在两个位置来执行我们织入的代码,如日志代码。这两个位置是before(在原代码执行之前)和after(在原代码执行之后)。当然,还可以对源代码进行around(完全取代原来的代码)。这对于普通的方法来说并不算什么,如上面的Method1方法。要想在ProxyClass类中的Method1方法拦截Class1类中的方法,before、after和around的实现分别如下:
public override void Method1() { base.Method1(); }
after:
public override void Method1() { base.Method1(); }
around:
public override void Method1() { }
但这对于构造方法来说却无法实现。因此,在子类中调用父类中的构造方法只能在方法后面调用,如下面代码所示:
public Class2(String s) : base(s) {...}
从上面的代码可以看出,构造方法只能实现before,而不能实现after和around。也就是在子类中无论写不写base关键字,必须调用父类中的构造方法(如果不写,调用父类中无参数的默认构造方法)。更不会有下面的代码形式:
public Class2(String s) {... base(s);}
因此,如何使构造方法也拥有after和around特性,就成为实现AOP构架的第二个拦路虎。
三、偶遇通往MSIL世界的“魔比斯环”,C#王权土崩瓦解
可能每个人都向往着穿越时空。然而有一群幸运的科学家却做到了。这些科学家在一个深谷中偶然发现了一个可以通往另一个世界的大门,这就是“魔比斯环”。通过“魔比斯环”,不仅能穿越时空,同时也将拥有无穷无尽的力量,尽管这么做会有带来一定的危险。
上面所描述的只是科幻电影中的场景。然而在现实世界也确实存在着很多的“魔比斯环”。如我们使用的C#就是这样。虽然C#是强大的,但我们必须受到C#语法和约定的限制。如一个重要限制是任何子类在创建其对象实例时,在子类的构造方法中必须调用父类的一个构造方法,如果不调用,C#编译器会自动在子类的构造方法加入调用父类构造方法的语句(无参数的默认构造方法)。我们可以先看看下面两个类:
{
public ParentClass()
{
System.Console.WriteLine("ParentClass");
}
}
class ChildClass : ParentClass
{
public ChildClass()
{
System.Console.WriteLine("ChildClass");
}
}
如果在控制台程序中创建ChildClass对象,将输出如下的结果:
ParentClass
ChildClass
让我们用微软提供的反编译工具ildasm.exe(位置:C:"Program Files"Microsoft Visual Studio 8"SDK"v2.0"Bin"ildasm.exe)来反编译exe,看看为什么会这样。用ildasm.exe打开exe后的界面如图1所示:
图1
双击图1所选中的部分,将会显示如下的中间语言代码:
instance void .ctor() cil managed
{
.maxstack 8
IL_0000: ldarg.0
IL_0001: call instance void ConsoleApplication1.ParentClass::.ctor()
IL_0006: nop
IL_0007: nop
IL_0008: ldstr "ChildClass"
IL_000d: call void [mscorlib]System.Console::WriteLine(string)
IL_0012: nop
IL_0013: nop
IL_0014: ret
}
读者可以不必管这些代码是什么意思,只要注意上面黑体字部分。C#编译器已经自动加入了调用ParentClass的构造方法的代码(.ctor()表示构造方法),这一行是去不掉的。找到问题所在,就意味着离成功又近了一步。从中间代码可以看出,如果不让C#编译器加上这行不就行了吗?但我又不能再设计一个C#编译器,因此,只能利用C#原来的编译器。
根据上面所述,现在我只要解决一个问题即可,就是要利用C#编译器来改变C#的规则。这就要求不能使用C#的代码,而构造方法的代码必须直接使用中间语言来完成(并不是象编译器一样直接生成dll或exe)。
这可真给我出了个难题。不过C#和.net framework的强大使用坚信一定有方法解决,只是我暂时没找到而已。
由于大多数使用虚拟机的语言都支持反射功能,因此,我决定碰碰运气,看看是否能通过反射解决这个问题。在.net中有一个命名空间System.Reflection。看了一下其中的类,基本都是用来得到信息的(方法、属性、Assembly的信息),并没有写入信息的(就是写入IL)。当我快要绝望的时候,终于眼前一亮。在System.Reflection中又发现了一个Emit命名空间。Emit的中文含义是“发出”的意思,想到此,我的希望又重新燃起。于是上MSDN查了关于Emit的资料。MSDN上说Emit的主要功能就是通过C#语言绕过C#编译器直接向内存中写入IL,相当于从硬盘上读取IL一样。yeah,这正是我需要的。有了这个,就可以动态生成代理类了。而且速度和静态类是一样的。
在Emit中提高了IL指令的全部映射。如下面的代码将直接用IL生成一个方法及其实现:
private void GenerateMethod()
{
Type[] argsType = { typeof(String) };
DynamicMethod dm = new DynamicMethod("MyMethod1",
typeof(String),
argsType,
typeof(Form1).Module);
ILGenerator il = dm.GetILGenerator();
il.Emit(OpCodes.Ldstr, "hello:");
il.Emit(OpCodes.Ldarg_0);
il.Emit(OpCodes.Call, typeof(String).GetMethod("Concat", new Type[]{typeof(String), typeof(String)}));
il.Emit(OpCodes.Ret);
MyMethod myMethod = (MyMethod)dm.CreateDelegate(typeof(MyMethod));
MessageBox.Show(myMethod("my friends"));
}
上面的GenerateMethod方法动态地生成了一个MyMethod1方法,并显示如图2的对话框:
图2
在上面代码中主要有三个地方需要提一下:
1. OpCodes包含了IL的所有的指令。如OpCodes.Ldstr表示将一个字符串压栈(每个方法都有一个操作栈),OpCodes.Call表示调用C#中的其他方法。
2. DynamicMethod类可以建立动态的普通方法。如果想建立构造方法,可以使用ConstructorInfo。
3. 所有的IL代码都是通过ILGenerator的Emit方法写入内存的。
Emit就象电影中的“魔比斯环”一样,可以很容易地突破C#的限制,从而穿越了C#世界,到达了另外一个MSIL世界。这将为我们实现另人振奋的功能提供了可能性。
四、在MSIL世界建立起强大的AOP帝国
既然能用C#直接写MSIL,那么就可容易编写AOP框架了。虽然这是用C#代码写MSIL,但也要对MSIL有一定的了解,感兴趣的读者可以到微软网站去下载IL Specification。
由于这个AOP框架的代码十分庞大,在这里只给出了一些代码片段。实现AOP框架的核心就是生成动态代理类。因此,使用IL生成代理类的框架是第一步。下面是生成代理类框架的核心代码:
{
string className = GetNewClassName();
TypeAttributes typeAttributes = TypeAttributes.Class | TypeAttributes.Public | TypeAttributes.Sealed;
TypeBuilder typeBuilder = m_EmitClassInfo.Module.DefineType(className,
typeAttributes, m_EmitClassInfo.BaseType);
return typeBuilder;
}
从上面的代码很容易猜到我要生成一个public的sealed类(不可继承)。接下来就是根据父类生成相应的方法(包括普通方法和构造方法),下面是一些代码片段:
{
MethodAttributes methodAttributes = MethodAttributes.Public;
MethodBuilder methodBuilder =
m_TypeBuilder.DefineMethod("__GetMethodInvocation", methodAttributes, typeof(IMethodInvocation), new Type[] { typeof(ICallable), typeof(MethodInfo) });
m_EmitClassInfo.__GetMethodInvocation = methodBuilder;
ILGenerator ilGenerator = methodBuilder.GetILGenerator();
Label execIfLabel = ilGenerator.DefineLabel();
Label endIfLabel = ilGenerator.DefineLabel();
LocalBuilder methodInvocation = ilGenerator.DeclareLocal(typeof(IMethodInvocation));
ilGenerator.Emit(OpCodes.Ldarg_0);
}
private void GenerateConstructor() // 生成构造方法
{
try
{
MethodAttributes methodAttributes = MethodAttributes.Public;
CallingConventions callingConventions = CallingConventions.Standard;
m_BaseConstructorParams = m_EmitClassInfo.ConstructorArgumentsType;
m_ConstructorParams = new Type[m_BaseConstructorParams.Length + 1];
m_BaseConstructorParams.CopyTo(m_ConstructorParams, 0);
m_ConstructorParams[m_BaseConstructorParams.Length] = typeof(IInterceptor);
m_constructorBuilder = m_TypeBuilder.DefineConstructor(methodAttributes, callingConventions, m_ConstructorParams);
m_EmitClassInfo.Constructor = m_constructorBuilder;
m_IlGenerator = m_constructorBuilder.GetILGenerator();
}
catch (Exception e)
{
throw e;
}
}
使用Emit不仅仅可以实现AOP框架,还可以根据用户需要自动生成任何IL代码。这些IL代码不会受到VB.net、C#的限制。因此,用户可以通过Emit来优化生成的中间语言代码,并完成一些C#无法做到的任务。如果读者想了解Emit的详细用法,可以参考MSDN或其他的相关文档。