.Net Remoting(应用程序域) - Part.1
引言
在互联网日渐普及,网络传输速度不断提高的情况下,分布式的应用程序是软件开发的一个重要方向。在.Net中,我们可以通过Web Service 或者Remoting 技术构建分布式应用程序(除此还有新一代的WCF,Windows Communication Foundation)。本文将简单介绍Remoting的一些基本概念,包括 应用程序域、Remoting构架、传值封送(Marshal by value)、传引用封送(Marshal by reference)、远程方法回调(Callback)、分别在Windows Service和IIS中寄宿宿主程序,最后我们介绍一下远程对象的生存期管理。
理解Remoting
1.应用程序域基本概念
.Net中的很多概念都是环环相扣的,如果一个知识点没有掌握(套用一下数据结构中“前驱节点”这个术语,那么这里就是“前驱知识点”),就想要一下子理解自己当前所直接面临问题,常常会遇到一些障碍而无法深入下去,或者是理解的浅显而不透彻(知道可以这样做,不知道为什么会是这样。如果只是应急,需要快速应用,这样也未尝不可)。为了更好地理解Remoting,我们也最好先了解一下Remoting的前驱知识点 -- 应用程序域。
我们知道所有的.Net 应用程序都运行在托管环境(managed environment)中,但操作系统只提供进程(Process)供程序运行,而进程只是提供了基本的内存管理,它不了解什么是托管代码。所以托管代码,也可以说是我们创建的.Net程序,是无法直接运行在操作系统进程中的。为了使托管代码能够运行在非托管的进程之上,就需要有一个中介者,这个中介者可以运行于非托管的进程之上,同时向托管代码提供运行的环境。这个中介者就是 应用程序域(Application Domain,简写为App Domain)。所以我们的.Net程序,不管是Windows窗体、Web窗体、控制台应用程序,又或者是一个程序集,总是运行在一个App Domain中。
如果只有一个类库程序集(.dll文件),是无法启动一个进程的(它并非可执行文件)。所以,创建进程需要加载一个可执行程序集(Windows 窗体、控制台应用程序等.exe文件)。当可执行程序集加载完毕,.Net会在当前进程中创建一个新的应用程序域,称为 默认应用程序域。一个进程中只会创建一个默认应用程序域,这个应用程序域的名称与程序集名称相同。默认应用程序域不能被卸载,并且与其所在的进程同生共灭。
那么应用程序域是如何提供托管环境的呢?简单来说,应用程序域只是允许它所加载的程序集访问由.Net Runtime所提供的服务。这些服务包括托管堆(Managed Heap),垃圾回收器(Garbage collector),JIT 编译器等.Net底层机制,这些服务本身(它们构成了.Net Runtime)是由非托管C++实现的。
在一个进程中可以包含多个应用程序域,一个应用程序域中可以包含多个程序集。比如说,我们的Asp.Net应用程序都运行在aspnet_wp.exe(IIS5.0)或者w3wp.exe(IIS6.0)进程中,而IIS下通常会创建多个站点,那么是为每个站点都创建一个独立的进程么?不是的,而是为每个站点创建其专属的应用程序域,而这些应用程序域运行在同一个进程(w3wp.exe或aspnet_wp.exe)中。这样做起码有两个好处:1、在一个进程中创建多个App Domain要比创建和运行多个进程需要少得多系统开销;2、实现了错误隔离,一个站点如果出现了致命错误导致崩溃,只会影响其所在的应用程序域,而不会影响到其他站点所在的应用程序域。
2.应用程序域的基本操作
在.Net 中,将应用程序域封装为了AppDomain类,这个类提供了应用程序域的各种操作,包含 加载程序集、创建对象、创建应用程序域 等。通常的编程情况下下,我们几乎从不需要对AppDomain进行操作,这里我们仅看几个本文会用到的、有助于理解和调试Remoting的常见操作:
1.获取当前运行的代码所在的应用程序域,可以使用AppDomain类的静态属性CurrentDoamin,获取当前代码所在的应用程序域;或者使用Thread类的静态方法GetDomain(),得到当前线程所在的应用程序域:
AppDomain currentDomain = AppDomain.CurrentDomain;
AppDomain currentDomain = Thread.GetDomain();
NOTE:一个线程可以访问进程中所包含的所有应用程序域,因为虽然应用程序域是彼此隔离的,但是它们共享一个托管堆(Managed Heap)。
2.获取应用程序域的名称,使用AppDomain的实例只读属性,FriendlyName:
string name = AppDomain.CurrentDomain.FriendlyName;
3.从当前应用程序域中创建新应用程序域,可以使用CreateDomain()静态方法,并传入一个字符串,作为新应用程序域的名称(亦即设置FriendlyName属性):
AppDomain newDomain = AppDomain.CreateDomain("New Domain");
4.在应用程序域中创建对象,可以使用AppDomain的实例方法CreateInstanceAndUnWrap()或者CreateInstance()方法。方法包含两个参数,第一个参数为类型所在的程序集,第二个参数为类型全称(这两个方法后面会详述):
DemoClass obj = (DemoClass)AppDomain.CurrentDomain.CreateInstanceAndUnWrap("ClassLib", "ClassLib.DemoClass");
ObjectHandle objHandle = AppDomain.CurrentDomain.CreateInstance("ClassLib", "ClassLib.DemoClass");
DemoClass obj = (DemoClass)objHandle.UnWrap();
5.判断是否为默认应用程序域:
newDomain.IsDefaultAppDomain()
3.在默认应用程序域中创建对象
开始之前我们先澄清一个概念,请看下面一段代码:
class Program{
static void Main(string[] args) {
MyClass obj = new MyClass();
obj.DoSomething();
}
}
此时我们说obj 是服务对象,Program是客户程序,而不管obj位于什么位置。
接下来我们来看一个简单的范例,我们使用上面提到基于AppDomain的操作,在当前的默认应用程序域中创建一个对象。我们先创建一个类库项目ClassLib,然后在其中创建一个类DemoClass,这个类的实例即为我们将要创建的对象:
namespace ClassLib {
public class DemoClass {
private int count = 0;
public DemoClass() {
Console.WriteLine("\n======= DomoClass Constructor =======");
}
public void ShowCount(string name) {
count++;
Console.WriteLine("{0},the count is {1}.", name, count);
}
// 打印对象所在的应用程序域
public void ShowAppDomain() {
AppDomain currentDomain = AppDomain.CurrentDomain;
Console.WriteLine(currentDomain.FriendlyName);
}
}
}
接下来,我们再创建一个控制台应用程序,将项目命名为ConsoleApp,引用上面创建的类库项目ClassLib,然后添加如下代码:
class Program {
static void Main(string[] args) {
Test1();
}
// 在当前AppDomain中创建一个对象
static void Test1() {
AppDomain currentDomain = AppDomain.CurrentDomain; // 获取当前应用程序域
Console.WriteLine(currentDomain.FriendlyName); // 打印名称
DemoClass obj;
// obj = new DemoClass() // 常规的创建对象的方式
// 在默认应用程序域中创建对象
obj = (DemoClass)currentDomain.CreateInstanceAndUnwrap("ClassLib", "ClassLib.DemoClass");
obj.ShowAppDomain();
obj.ShowCount("Jimmy");
obj.ShowCount("JImmy");
}
}
运行这段代码,得到的运行结果是:
ConsoleApp.exe
======= DomoClass Constructor =======
ConsoleApp.exe
Jimmy,the count is 1.
Jimmy,the count is 2.
现在运转良好,一切都没有什么问题。你可能想问,使用这种方式创建对象有什么意义呢?通过CreateInstanceAndUnwrap()创建对象和使用new DemoClass()创建对象有什么不同呢?回答这个问题之前,我们再来看下面另一种情况:
4.在新建应用程序域中创建对象
我们看看如何 创建一个新的AppDomain,然后在这个新的AppDomain中创建DemoClass对象。你可能会想,这还不简单,把上面的例子稍微改改不就OK了:
// 在新AppDomain中创建一个对象
static void Test2() {
AppDomain currentDomain = AppDomain.CurrentDomain;
Console.WriteLine(currentDomain.FriendlyName);
// 创建一个新的应用程序域 - NewDomain
AppDomain newDomain = AppDomain.CreateDomain("NewDomain");
DemoClass obj;
// 在新的应用程序域中创建对象
obj = (DemoClass)newDomain.CreateInstanceAndUnwrap("ClassLib", "ClassLib.DemoClass");
obj.ShowAppDomain();
obj.ShowCount("Jimmy");
obj.ShowCount("Jimmy");
}
然后我们在Main()方法中运行Test2(),结果却是得到了一个异常:类型“ClassLib.DemoClass”未标记为可序列化。在把ClassLib.DemoClass标记为可序列化(Serializable)之前,我们想一想为什么会发生这个异常。我们看看声明obj类型的这行代码:DemoClass obj,这说明了obj是在当前的默认应用程序域,也就是AppConsole.exe中声明的;然后我们在往下看,类型的实例(对象本身)却是通过 newDomain.CreateInstanceAndUnwrap() 在新创建的应用程序域 -- NewDomain中创建的。这样就出现了一种尴尬的情况:对象的引用(类型声明)位于当前应用程序域(AppConsole.exe)中,而对象本身(类型实例)位于新创建的应用程序域(NewDomain)。而上面我们提到默认情况下AppDomain是彼此隔离的,我们不能直接在一个应用程序中引用另一个应用程序域中的对象,所以这里便会引发异常。
那么如何解决这个问题呢?按照异常提示:"ClassLib.DemoClass"未标记为可序列化。那我们将它标记为可序列化是不是就解决了这个问题呢?我们可以试一下,先将ClassLib.DemoClass标记为可序列化:
[Serializable]
public class DemoClass { /*略*/ }
然后再次运行程序,发现程序果然正常运行,并且和上面的输出完全一致:
ConsoleApp.exe
======= DomoClass Constructor =======
ConsoleApp.exe
Jimmy,the count is 1.
Jimmy,the count is 2.
根据输出,我们发现在应用程序域NewDomain中创建的对象位于ConsoleApp.exe,也就是当前应用程序域中了。这就说明了一个问题:当我们将对象标记为可序列化时,然后进行上面的操作时,对象本身已经由另一应用程序域(远程)传递到了本地应用程序域中。因为其要求将对象标记为可序列化,所以不难想到,具体的方法是 先在远程创建对象,接着将对象序列化,然后传递对象,在本地进行反序列化,最后还原对象。
5.代理(Proxy)和封送(Marshaling)
5.1 代理(Proxy)
现在我们在回到第3小节中 在默认应用程序域中创建对象 的例子,通过上面Test2()的例子,很容易理解为什么Test1()没有抛出异常,因为obj对象本身就位于当前应用程序域ConsoleApp.exe,所以不存在跨应用程序域访问的问题,自然不会抛出异常。那么在当前应用程序域中使用下面两种方式创建对象有什么不同呢?
DemoClass obj = new DemoClass(); // 方式一
DemoClass obj = (DemoClass)newDomain.CreateInstanceAndUnwrap("ClassLib", "ClassLib.DemoClass"); // 方式二
当我们使用第一种方式时,我们在托管堆中创建了一个对象,并且直接引用了这个对象;采用第二种方式时,我们实际上创建了两个对象:我们在newDomain中创建了这个对象,然后将对象的状态进行拷贝、串行化,然后进行封送,接着在ConsoleApp.exe(客户端应用程序域)重新创建这个对象、还原对象状态,创建对象代理。最后,我们通过这个代理访问这个对象,此时,因为代理访问的是在本地重新创建的对象而非远程对象,所以当我们在对象上调用ShowDomain()时,显示的是ConsoleApp.exe。
上面的说明中出现了两个新名称,代理和封送。现在先来解释一下代理,代理(Proxy) 提供了和远程对象(本例中是在NewDomain中创建的DemoClass对象)完全相同的接口(属性和方法)。.Net需要在客户端(本例中是ConsoleApp.exe)基于远程对象的元信息(metadata)创建代理。因此客户端必须包含远程对象的元信息(简单来说就是只包含名称及接口定义,但可以不包含实际的代码实现)。因为代理有着和远程对象完全一样的接口和名称,所以对于客户程序来说,代理就好像是远程对象一样;而代理实际上又并不包含向客户程序提供服务的实际代码(比如说方法体),所以代理仅仅是将自己与某一对象相绑定,然后把客户程序对自己的服务请求发送给对象。对于客户程序来说,远程对象(服务端对象)就好像是在本地;而对远程对象来说,也好像是为其本地程序提供服务。
NOTE:有的书本讲到这里,会提到透明代理、真实代理,以及Message Sink等概念,这些我们留待后面再说。
5.2 传值封送、传引用封送
在上面的例子中,当位于ConsoleApp.exe的obj引用NewDomain中创建的对象时,.Net将NewDomain中对象的状态进行复制、序列化,然后在ConsoleApp.exe中重新创建对象,还原状态,并通过代理来对对象进行访问。这种跨应用程序域的访问方式叫做 传值封送(Marshal by value),有点类似于C#中参数的按值传递:
NOTE:上面这种通过调用CreateInstanceAndUnWrap()方法这种方式进行传值封送是一种特例,仅仅作为示范用。在Remoting通常的情况下,传值封送发生在远程对象的方法向客户端返回数值,或者客户端向远程对象传递方法参数的情况下。后面会详细解释。
由图上可以看出,传值封送时,因为要将整个对象传递到本地,对于大对象来说很显然是低效的。所以还有一种方式就是让对象依然保留在远程(本例为NewDomain中),而在客户端仅创建代理,上面已经说了代理的接口和远程对象完全相同,所以客户端以为仍然访问的是远程对象,当客户端调用代理上的方法时,由代理将对方法的请求发送给远程对象,远程对象执行方法请求,最后再将结果传回。这种方式叫做 传引用封送(Marshal by reference)。
对象或者对象引用在传递的过程中,是以一种包装过的状态(warpper state)进行传递(所以才会称为封送吧,仅为个人猜测)。所以在创建对象时,要解包装,因此在CreateInstanceAndUnWrap()方法后多了一个AndUnWrap后缀,实际上UnWrap还包含一个创建代理的过程。
6.传引用封送范例
上面的例子中我们已经使用了传值封送,那么如何实现传引用封送呢?我们只要让对象继承自MarshalByRefObject基类就可以了,所以修改DemoClass,去掉Serializable标记,然后让它继承自MarshalByRefObject:
public class DemoClass:MarshalByRefObject {/*略*/}
接下来我们再次运行程序:
ConsoleApp.exe
======= DomoClass Constructor =======
NewDomain
Jimmy,the count is 1.
Jimmy,the count is 2.
发现obj.ShowDomain()输出为NewDomain,说明DemoClass的类型实例obj没有传值封送到ConsoleApp.exe中,而是依然保留在了NewDomain中。有的人可能想那我既标记上Serializable,又继承自MarshalByRefObject程序怎么处理呢?当我们让一个类型继承自MarshalByRefObject后,它就一定不会离开自己的应用程序域,所以仍会以传引用封送的方式进行。声明为Serialzable只是说明它可以被串行化。
继续进行之前,我们看看上面的结果还能说明什么问题:对象的状态是保留着的。这句话是什么意思呢?当我们两次调用ShowCount()方法时,第二次运行的值(count的值)是基于第一次的运行结果的。
我们再对上面Test2()的进行一下修改,多创建一个DemoClass的实例,看看会发生什么:
static void Test2() {
AppDomain currentDomain = AppDomain.CurrentDomain;
Console.WriteLine(currentDomain.FriendlyName);
// 创建一个新的应用程序域 - NewDomain
AppDomain newDomain = AppDomain.CreateDomain("NewDomain");
DemoClass obj, obj2;
// 在新的应用程序域中创建对象
obj = (DemoClass)newDomain.CreateInstanceAndUnwrap("ClassLib", "ClassLib.DemoClass");
obj.ShowAppDomain();
obj.ShowCount("Jimmy");
obj.ShowCount("Jimmy");
// 再创建一个obj2
obj2 = (DemoClass)newDomain.CreateInstanceAndUnwrap("ClassLib", "ClassLib.DemoClass");
obj2.ShowAppDomain();
obj2.ShowCount("Zhang");
obj2.ShowCount("Zhang");
}
运行Test2(),可以得到下面的输出:
ConsoleApp.exe
======= DomoClass Constructor =======
NewDomain
Jimmy,the count is 1.
Jimmy,the count is 2.
======= DomoClass Constructor =======
NewDomain
Zhang,the count is 1.
Zhang,the count is 2.
这次我们又发现什么了呢?对于obj和obj2,在NewDomain中分别创建了两个对象为其服务,且这两个对象仅创建了一次(注意到只调用了一次构造函数)。这种方式称为客户端激活对象(Client Activated Object,简称为 CAO)。请大家再次看看上面第二张传引用封送的示意图,是不是可以推出这里的结果?关于客户激活对象,后面我们会再看到,这里大家先留个印象。
7.客户应用程序(域)、服务端程序集、宿主应用程序(域)
看到Remoting这个词,我们通常所理解的可能只是本地客户机与远程服务器之间的交互。而实际上,只要是跨越AppDomain的访问,都属于Remoting。不管这两个AppDomain位于同一进程中,不同进程中,还是不同机器上。对于Remoting,可能大家理解它就包含两个部分,一个Server(服务器端)、一个Client(客户端)。但是如果从AppDomain的角度来看,服务端的AppDomain仅仅是提供了一个实际提供服务的远程对象的运行环境。所以提起Remoting,我们应该将其视为三个部分,这样在以后操作,以及我下面的讲述中,概念都会更加清晰:
- 宿主应用程序(域),服务程序运行的环境(服务对象所在的AppDomain),它可以是控制台应用程序,Windows窗体程序,Windows 服务,或者是IIS的工作者进程等。上例中为 NewDomain。
- 服务程序(对象),响应客户请求的程序(或对象),通常为继承自MarshalByRefObject的类型,表现为一个程序集。上例中为 DemoClass。
- 客户应用程序(域),向宿主应用程序发送请求的程序(或对象)。上例中为 ConsoleApp.exe。
在文中,有时我可能也会用到 客户端(Client Side) 和 服务端(Server Side)这样的词,当提到客户端时,仅指客户应用程序;当提到服务端的时候,指服务程序 和 宿主应用程序。
可以看出,在我们上面的例子中,客户端 与 宿主应用程序 位于同一个进程的不同应用程序域当中,尽管大多数情况下,它们位于不同的进程中。
而我们本章第三节,在当前应用程序域的实例上调用CreateInstanceAndUnwrap()方法创建DemoClass对象时,则是一个极端情况:即 客户程序域、宿主应用程序域 为同一个应用程序域 ConsoleApp.exe 。
NOTE:在应用程序域中底部,还有一个代码执行领域,称为环境(Context)。一个AppDomain中可以包含多个环境,跨越环境的访问也可以理解成Remoting的一个特例。但是本文不涉及这部分内容。