远程方法回调通常有两种方式:
当服务端调用客户端的方法时,它们的角色就互换了。此时,需要注意这样几个问题:
根据这三点的变化,我们可以看出:客户端含有客户端对象,但它还需要远程服务对象的元数据来构建代理;服务端含有服务对象,但它还需要客户端对象的元数据来构建代理。因此,客户端服务端均需要服务对象、客户对象的类型元数据,简单起见,我们将它们写在同一个程序集中,命名为ShareAssembly,供客户端、服务端引用。此时,运行时的状态图如下所示:
其中ShareAssembly.dll包含服务对象和客户端对象的代码。接下来一节我们来看一下它们的代码。
由于本文讨论的主要是回调,所以我们创建新的服务对象和客户对象来进行演示。下面是ShareAssembly程序集包含的代码,我们先看一下服务端对象和委托的定义:
public delegate void NumberChangedEventHandler(string name, int count);
public class Server :MarshalByRefObject {
private int count = 0;
private string serverName = "SimpleServer";
public event NumberChangedEventHandler NumberChanged;
// 触发事件,调用客户端方法
[MethodImpl(MethodImplOptions.Synchronized)]
public void DoSomething() {
// 做某些额外方法
count++;
if (NumberChanged != null) {
Delegate[] delArray = NumberChanged.GetInvocationList();
foreach (Delegate del in delArray) {
NumberChangedEventHandler method = (NumberChangedEventHandler)
try {
method(serverName, count);
} catch {
Delegate.Remove(NumberChanged, del);//取消某一客户端的订阅
}
}
}
}
// 直接调用客户端方法
public void InvokeClient(Client remoteClient, int x, int y) {
int total = remoteClient.Add(x, y); //方法回调
Console.WriteLine(
"Invoke client method: x={0}, y={1}, total={2}",x, y, total);
}
// 调用客户端属性
public void GetCount(Client remoteClient) {
Console.WriteLine("Count value from client: {0}", remoteClient.Count);
}
}
在这段代码中首先定义了一个委托,并在服务对象Server中声明了一个该委托类型的事件,它可以用于客户对象注册。它主要包含三个方法:DoSomething()、InvokeClient()和GetCount()。需要注意的是DoSomething()方法,因为我后面将服务端实现为了Singleton模式,所以需要处理并发访问,我使用了一种简便的方法,向方法添加MethodImp特性,它会自动实施方法的线程安全。其次就是在方法中触发事件时,我采用了遍历委托链表的方式,并放在了try/catch块中,因为触发事件时客户端有可能已经不存在了。另外,如果发生异常,我将它从订阅的委托列表中删除掉,这样下次触发时就不会再次调用它了。这里也可以采用BeginInvoke()进行异步调用,具体可以参见C#中的委托和事件(续)一文。
InvokeClient()方法调用了客户端的Add()方法,并向控制台输出了提示性的说明;GetCount()方法获取了客户端Count的值,并产生了输出。注意这三个方法均由客户端调用,但是方法内部又回调了调用它们的客户对象。
接下来我们看下客户端的代码,它没有什么特别,OnNumberChanged()方法在事件触发时自动调用,而其余两个方法由服务对象进行回调,并在调用它时,在客户端控制台输出相应的提示:
public class Client : MarshalByRefObject {
private int count = 0;
// 方式1:供远程对象调用
public int Add(int x, int y) {
// 当有服务端调用时,打印下面一行
Console.WriteLine("Add callback: x={0}, y={1}.", x, y);
return x + y;
}
// 方式1:供远程对象调用
public int Count {
get {
count++;
return count;
}
}
// 方式2:订阅事件,供远程对象调用
public void OnNumberChanged(string serverName, int count){
Console.WriteLine("OnNumberChanged callback:");
Console.WriteLine("ServerName={0}, Server.Count={1}", serverName, count);
}
}
注意一下Count属性,它在输出前进行了一次自增,等下运行时我们会重新看这里。
当客户对象调用服务对象方法时,服务端已经注册了通道、开放了端口,对请求进行监听。同理,当服务端回调客户端对象时,客户端也需要注册通道、打开端口。但现在问题是:服务端如何知道客户端使用了哪个端口?我们在Part.1中提到过,当对象进行传引用封送时,会包含对象的位置,而有了这个位置,再加上类型的元数据便可以创建代理,代理总是知道远程对象的地址,并将请求发送给远程对象。这种会话模型可以用下面的图来表述:
从上面这幅图可以很清楚地看到服务端代理的创建过程:首先在第1阶段,客户端服务端谁也不知道谁在哪儿;因此,在第2阶段,我们首先要为客户端提供服务端对象的地址和类型元数据,有了这两样东西,客户端便可以创建服务端的代理,然后通过代理就访问到服务端对象;第3阶段是最关键的一步,在客户端通过代理调用InvokeClient()时,将client对象以传引用封送的方式传递了过去,我们前面说过,在传引用封送时,它还包括了这个对象的位置,也就是client对象的位置和端口号;第4步时,服务端根据客户端位置和类型元数据创建了客户端对象的代理,并通过代理调用了客户端的Add()方法。
NOTE:图中的代理实际应该分别指向client或者server,由于绘图的空间问题,我就直接指在框框上了。
因此,客户端应用程序与之前相比一个最大的区别就是需要注册通道,除此以外,它并不需要明确地指定一个端口号,可以由.NET自动选择一个端口号,而服务端则会通过客户端代理知道其使用的是哪个端口号。
现在我们来看一下服务端宿主应用程序的实现。简单起见,我们依然创建一个控制台应用程序ServerConsole,然后在解决方案下添加前面创建的ShareAssembly项目,然后在ServerConsole中引用ShareAssembly。
NOTE:在这里我喜欢将解决方案和项目起不同的名称,比如解决方案我起名为ServerSide(服务端),服务端控制台应用程序则叫ServerConsole。这样感觉更清晰一些。
服务端控制台应用程序的代码和前面的类似,还是老一套的注册通道,注册对象,需要注意的是这里采用了自定义formatter的方式,并设置了它的TypeFilterLevel属性为TypeFilterLevel.Full,它默认为Low,但是当设为Low时一些复杂的类型将无法进行Remoting(主要是出于安全性的考虑)。
// using... 略
class Program {
static void
// 设置Remoting应用程序名
RemotingConfiguration.ApplicationName = "CallbackRemoting";
// 设置formatter
BinaryServerFormatterSinkProvider formatter;
formatter = new BinaryServerFormatterSinkProvider();
formatter.TypeFilterLevel = TypeFilterLevel.Full;
// 设置通道名称和端口
IDictionary propertyDic = new Hashtable();
propertyDic["name"] = "CustomTcpChannel";
propertyDic["port"] = 8502;
// 注册通道
IChannel tcpChnl = new TcpChannel(propertyDic, null, formatter);
ChannelServices.RegisterChannel(tcpChnl, false);
// 注册类型
Type t = typeof(Server);
RemotingConfiguration.RegisterWellKnownServiceType(
t, "ServerActivated", WellKnownObjectMode.Singleton);
Console.WriteLine("Server running, model: Singleton ");
Console.ReadKey();
}
}
与服务端类似,我们创建解决方案ClientSide,在其下添加ClientConsole控制台项目,添加现有的ShareAssembly项目,并在ClientConsole项目下添加对ShareAssembly的引用。
//using... 略
class Program {
static void
// 注册通道
IChannel chnl = new TcpChannel(0);
ChannelServices.RegisterChannel(chnl, false);
// 注册类型
Type t = typeof(Server);
string url = "tcp://127.0.0.1:8502/CallbackRemoting/ServerActivated";
RemotingConfiguration.RegisterWellKnownClientType(t, url);
Server remoteServer = new Server(); // 创建远程对象
Client localClient = new Client(); // 创建本地对象
// 注册远程对象事件
remoteServer.NumberChanged +=
new NumberChangedEventHandler(localClient.OnNumberChanged);
remoteServer.DoSomething(); // 触发事件
remoteServer.GetCount(localClient); // 调用GetCount()
remoteServer.InvokeClient(localClient, 2, 5);// 调用InvokeClient()
Console.ReadKey(); // 暂停客户端
}
}
我们看一下上面的代码,它仅仅是多了一个通道注册,注意我们将端口号设置为0,意思是由.NET选择一个可用端口。由于注册了远程类型,所以我们直接使用new操作创建了一个Server对象。然后,我们创建了一个本地的Client对象,注册了NumberChanged事件、触发事件、调用了GetCount()方法和InvokeClient()方法。最后,我们暂停了客户端,为什么这里暂停,而不是直接结束,我们下面运行时再解释。
我们运行先服务端,接着运行一个客户端,此时产生的输出如下:
上面是服务端,下面是客户端。我们在调用server.DoSomething()方法时,触发了事件,所以调用了客户端的OnNumberChanged,产生了客户端的前两行输出;调用GetCount()时,客户端没有产生输出,服务端输出了“Count value from client:
接下来,我们不要关闭上面的窗口,再次打开一个客户端。此时程序的运行结果如下所示,其中第1幅图是服务端、第2幅图是第一个客户端、第3幅图是新开启的客户端:
这里可以发现两点:由于第二个客户端再次调用了DoSomething()方法,所以它再次触发了事件,因此在第一个客户端再次产生了输出“OnNumberChanged Callback...”;再次调用GetCount()方法时,对于服务端来说,是一个新建的客户端localClient对象,所以count值继续输出为1,也就是说两个客户端对象是独立的,对服务器来说,可以将客户端视为客户激活方式(Client-Actived Model)。
这种情况主要用来测试当服务端触发事件时,之前订阅了事件的客户端已经不存在了的情况。由于我们已经在服务端对象中进行了异常处理,可以看到不会出现任何错误,程序会按照预期的执行。
这里还有另外一种方式,就是将客户端的回调方法使用OneWay特性进行标记,然后服务端对象触发事件时直接使用NumberChanged委托变量。当客户端方法用OneWay标记后,.NET会自动实施异步调用,并且在客户端产生异常时也不会影响到服务端的运行。
5.3的例子就不演示了,感兴趣可以自己试一下。