.NET Remoting 事件详解
解释了.NET Remoting 事件的产生和消费,以及其缺点和优点。
引言
.NET Remoting 第一次接触时既是一项艰巨的任务,也能让生活变得更加轻松。.NET 的 Remoting 框架目标是尽可能简化应用程序和计算机边界之间的数据序列化和反序列化,同时提供 .NET 框架的所有灵活性和强大功能。在这里,我们将探讨在 Remoting 环境中使用事件以及设计应用程序以使用事件的正确方法。
背景
事件让下游应用程序的生活变得更加轻松,在客户端/服务器环境中使用事件也同样如此。当服务器上发生某些事情或某个事件发生时通知客户端,而无需客户端轮询服务器,这意味着客户端方面的实现更加简单。
.NET 的问题在于,触发事件的服务器端需要了解事件在消费端上的实际实现。我看到太多 .NET Remoting 事件的示例,它们要求服务器引用客户端应用程序(有时甚至是 .EXE 本身,糟糕!)和/或客户端需要引用服务器的完整实现。
在服务器端和客户端都遵循良好的编程实践是实现分离,这样服务器就不需要了解客户端是如何实现的,客户端也不需要了解服务器是如何实现的。
应用程序设计
在应用程序设计初期花一些时间,确实可以省去日后的许多麻烦。我看到太多开发者直接投入实现,然后在编程过程中途发现某些基本编程方面存在问题,不得不花大量时间重构或调整,而前期稍作规划就能省去所有这些工作。
在我们的示例应用程序中,我们将有一个服务器和多个客户端。客户端将位于不同的计算机上,但位于同一个内部网络。客户端将与服务器松散耦合;也就是说,客户端的连接状态可能随时因任何原因而改变。
客户端将向服务器发送消息,服务器必须通知所有已连接的客户端新消息已到达以及消息内容。客户端将在收到通知时显示消息。根据以上几点,我们可以确定:
- 服务器必须控制自身的生命周期
- 我们将使用 TCP 协议(IPC 不适合跨计算机通信)
- 我们将使用 .NET 事件
- 客户端和服务器不能了解彼此的实现细节
公共库
因此,分离实现,我们将需要某种公共库来存储客户端和服务器之间共享的数据。我们的公共库将包含以下内容:
- 事件声明
- 事件代理
- 服务器接口
让我们从事件声明开始(EventDeclarations.cs)
namespace RemotingEvents.Common
{
public delegate void MessageArrivedEvent(string Message);
}
非常简单。我们只需要声明一个名为 MessageArrivedEvent
的委托,它标识我们将用作事件的函数。现在,我们将跳到服务器接口(稍后回来处理 EventProxy
)。
namespace RemotingEvents.Common
{
public interface IServerObject
{
#region Events
event MessageArrivedEvent MessageArrived;
#endregion
#region Methods
void PublishMessage(string Message);
#endregion
}
}
这也非常简单。我们在这里声明服务器对象的接口,而不是实现。客户端将不知道这些函数在服务器端的实现方式,只知道与之交互或接收事件通知的接口。服务器(正如我们在下一节中将看到的)会为该接口添加很多内容,但其中没有一项可供客户端使用。
现在,让我们看一下 EventProxy
类。首先是代码:
namespace RemotingEvents.Common
{
public class EventProxy : MarshalByRefObject
{
#region Event Declarations
public event MessageArrivedEvent MessageArrived;
#endregion
#region Lifetime Services
public override object InitializeLifetimeService()
{
return null;
//Returning null holds the object alive
//until it is explicitly destroyed
}
#endregion
#region Local Handlers
public void LocallyHandleMessageArrived(string Message)
{
if (MessageArrived != null)
MessageArrived(Message);
}
#endregion
}
}
这个类并不复杂,但让我们关注一些细节。首先,该类继承自 MarshalByRefObject
。这是因为 EventProxy
会被序列化到客户端并从客户端反序列化回来,因此 Remoting 框架需要知道如何对该对象进行封送。在这里使用 MarshalByRefObject
意味着对象是通过引用跨边界进行封送的,而不是通过值(通过副本)进行封送的。
InitializeLifetimeService()
函数是从 MarshalByRefObject
类重写的。从此类返回 null
意味着我们希望 .NET 环境保持代理处于活动状态,直到应用程序显式销毁它。我们也可以在这里返回一个新的 ILease
,并将超时设置为 TimeSpan.Zero
来实现相同的功能。
我们拥有这个代理类的原因是因为服务器端需要了解客户端事件消费者的实现。如果我们不使用代理类,服务器将不得不引用客户端实现,以便知道如何以及在哪里调用该函数。我们将在客户端实现部分介绍如何使用这个代理类。
服务器实现
现在,让我们进入服务器实现。服务器在名为(在我们的示例中)RemotingEvents.Server 的单独项目中实现。该项目引用 RemotingEvents.Common 项目,以便我们可以使用接口、事件声明和事件代理(间接)。以下是完整代码:
namespace RemotingEvents.Server
{
public class RemotingServer : MarshalByRefObject, IServerObject
{
#region Fields
private TcpServerChannel serverChannel;
private int tcpPort;
private ObjRef internalRef;
private bool serverActive = false;
private static string serverURI = "serverExample.Rem";
#endregion
#region IServerObject Members
public event MessageArrivedEvent MessageArrived;
public void PublishMessage(string Message)
{
SafeInvokeMessageArrived(Message);
}
#endregion
public void StartServer(int port)
{
if (serverActive)
return;
Hashtable props = new Hashtable();
props["port"] = port;
props["name"] = serverURI;
//Set up for remoting events properly
BinaryServerFormatterSinkProvider serverProv =
new BinaryServerFormatterSinkProvider();
serverProv.TypeFilterLevel =
System.Runtime.Serialization.Formatters.TypeFilterLevel.Full;
serverChannel = new TcpServerChannel(props, serverProv);
try
{
ChannelServices.RegisterChannel(serverChannel, false);
internalRef = RemotingServices.Marshal(this,
props["name"].ToString());
serverActive = true;
}
catch (RemotingException re)
{
//Could not start the server because of a remoting exception
}
catch (Exception ex)
{
//Could not start the server because of some other exception
}
}
public void StopServer()
{
if (!serverActive)
return;
RemotingServices.Unmarshal(internalRef);
try
{
ChannelServices.UnregisterChannel(serverChannel);
}
catch (Exception ex)
{
}
}
private void SafeInvokeMessageArrived(string Message)
{
if (!serverActive)
return;
if (MessageArrived == null)
return; //No Listeners
MessageArrivedEvent listener = null;
Delegate[] dels = MessageArrived.GetInvocationList();
foreach (Delegate del in dels)
{
try
{
listener = (MessageArrivedEvent)del;
listener.Invoke(Message);
}
catch (Exception ex)
{
//Could not reach the destination, so remove it
//from the list
MessageArrived -= listener;
}
}
}
}
}
内容很多,让我们一点一点地分解。
public class RemotingServer : MarshalByRefObject, IServerObject
我们的类继承自 MarshalByRefObject
和 IServerObject
。MarshalByRefObject
是因为我们希望服务器通过对服务器对象的引用在边界之间进行封送,而 IServerObject
意味着我们正在实现客户端已知的服务器接口。
#region Fields
private TcpServerChannel serverChannel;
private int tcpPort;
private ObjRef internalRef;
private bool serverActive = false;
private static string serverURI = "serverExample.Rem";
#endregion
#region IServerObject Members
public event MessageArrivedEvent MessageArrived;
public void PublishMessage(string Message)
{
SafeInvokeMessageArrived(Message);
}
#endregion
这里是私有工作变量集以及 IServerObject
成员的实现。TcpServerChannel
是我们用于服务器的 TCP Remoting 通道的引用。tcpPort
和 serverActive
不言而喻。ObjRef
保持对被呈现(封送)用于 Remoting 的对象的内部引用。我们不一定需要封送我们自己的类,我们可以封送其他类;我只是喜欢将服务代码放在被封送的对象内部。
我们稍后将介绍 SafeInvokeMessageArrived
。首先,让我们看一下启动和停止服务器服务。
public void StartServer(int port)
{
if (serverActive)
return;
Hashtable props = new Hashtable();
props["port"] = port;
props["name"] = serverURI;
//Set up for remoting events properly
BinaryServerFormatterSinkProvider serverProv =
new BinaryServerFormatterSinkProvider();
serverProv.TypeFilterLevel =
System.Runtime.Serialization.Formatters.TypeFilterLevel.Full;
serverChannel = new TcpServerChannel(props, serverProv);
try
{
ChannelServices.RegisterChannel(serverChannel, false);
internalRef = RemotingServices.Marshal(this, props["name"].ToString());
serverActive = true;
}
catch (RemotingException re)
{
//Could not start the server because of a remoting exception
}
catch (Exception ex)
{
//Could not start the server because of some other exception
}
}
public void StopServer()
{
if (!serverActive)
return;
RemotingServices.Unmarshal(internalRef);
try
{
ChannelServices.UnregisterChannel(serverChannel);
}
catch (Exception ex)
{
}
}
我不会详细介绍所有内容,但让我们看一下对 Remoting 事件非常重要的部分。
BinaryServerFormatterSinkProvider serverProv = new BinaryServerFormatterSinkProvider();
serverProv.TypeFilterLevel =
System.Runtime.Serialization.Formatters.TypeFilterLevel.Full;
serverChannel = new TcpServerChannel(props, serverProv);
在这里,我们设置了 BinaryServerFormatterSinkProvider
。我们将在客户端쪽에进行类似的匹配设置(我们将在下一节中看到)。这识别了我们如何跨 Remoting 边界提供事件(在这种情况下,我们选择了二进制实现而不是 XML)。为了使事件正常工作,我们需要将 TypeFilterLevel
设置为 Full
。
由于向 TcpServerChannel
的构造函数提供 sink provider 的唯一方法是使用 Hashtable
,因此我们需要使用我们构造的哈希表来保存服务器的名称(在 URI 或“统一资源标识符”中使用)以及我们 Remoting 的端口。
对于我的机器,生成的 URI 是 *tcp://192.168.1.68:15000/serverExample.Rem*。这在客户端쪽에稍后使用,并且一开始很难理解(和确定)。您应该注意到,使用内部引用对象的函数获取 URI 会生成一个非常奇怪的字符串,而且它们都没有表示您可以用来连接到服务器的字符串。
现在,让我们看看 SafeInvokeMessageArrived
函数。
private void SafeInvokeMessageArrived(string Message)
{
if (!serverActive)
return;
if (MessageArrived == null)
return; //No Listeners
MessageArrivedEvent listener = null;
Delegate[] dels = MessageArrived.GetInvocationList();
foreach (Delegate del in dels)
{
try
{
listener = (MessageArrivedEvent)del;
listener.Invoke(Message);
}
catch (Exception ex)
{
//Could not reach the destination, so remove it
//from the list
MessageArrived -= listener;
}
}
}
这就是您应该实现 **所有** 事件调用代码的方式,而不仅仅是那些涉及 Remoting 的代码。虽然我解释了为什么这与 Remoting 相关,但对于任何应用程序来说,这都是同样适用的,这只是一个良好的实践。
在这里,我们首先检查服务器是否处于活动状态。如果服务器未处于活动状态,则我们不会尝试引发任何事件。这只是一个健全性检查。接下来,我们检查是否有任何已附加的侦听器,这意味着 MessageArrived
委托(事件)将为 null
。如果是,我们就返回。
接下来的两行很重要。我们为 listener
创建了一个临时委托,然后存储了我们的事件当前持有的调用列表。我们这样做是因为在我们遍历调用列表时,客户端可能会(有意地)从调用列表中移除自身,我们可能会进入一个非线程安全的情况。
接下来,我们遍历所有委托并尝试使用消息调用它们。如果调用引发了异常,我们就会从调用列表中移除它,从而有效地移除该客户端接收通知。
这里有几点需要记住。首先,您 **不** 希望使用 [OneWay]
属性声明您的事件。这样做会使整个练习无效,因为服务器不会等待检查结果,并且总是会调用调用列表中的每个项目,而不管它是否已连接。对于生命周期较短的服务器应用程序来说,这不是一个大问题,但如果您的服务器运行数月或数年,您的调用列表可能会增长到足以导致服务器崩溃,而这是一个很难找到的 bug。
您还需要意识到事件是同步的(稍后会详细介绍),因此服务器 **将** 等待客户端从函数调用返回,然后才会调用下一个侦听器。稍后将详细介绍。
客户端实现
让我们快速看一下客户端。
namespace RemotingEvents.Client
{
public partial class Form1 : Form
{
IServerObject remoteServer;
EventProxy eventProxy;
TcpChannel tcpChan;
BinaryClientFormatterSinkProvider clientProv;
BinaryServerFormatterSinkProvider serverProv;
//Replace with your IP
private string serverURI =
"tcp://192.168.1.100:15000/serverExample.Rem";
private bool connected = false;
private delegate void SetBoxText(string Message);
public Form1()
{
InitializeComponent();
clientProv = new BinaryClientFormatterSinkProvider();
serverProv = new BinaryServerFormatterSinkProvider();
serverProv.TypeFilterLevel =
System.Runtime.Serialization.Formatters.TypeFilterLevel.Full;
eventProxy = new EventProxy();
eventProxy.MessageArrived +=
new MessageArrivedEvent(eventProxy_MessageArrived);
Hashtable props = new Hashtable();
props["name"] = "remotingClient";
props["port"] = 0; //First available port
tcpChan = new TcpChannel(props, clientProv, serverProv);
ChannelServices.RegisterChannel(tcpChan);
RemotingConfiguration.RegisterWellKnownClientType(
new WellKnownClientTypeEntry(typeof(IServerObject), serverURI));
}
void eventProxy_MessageArrived(string Message)
{
SetTextBox(Message);
}
private void bttn_Connect_Click(object sender, EventArgs e)
{
if (connected)
return;
try
{
remoteServer = (IServerObject)Activator.GetObject(
typeof(IServerObject), serverURI);
remoteServer.PublishMessage("Client Connected");
//This is where it will break if we didn't connect
//Now we have to attach the events...
remoteServer.MessageArrived +=
new MessageArrivedEvent(eventProxy.LocallyHandleMessageArrived);
connected = true;
}
catch (Exception ex)
{
connected = false;
SetTextBox("Could not connect: " + ex.Message);
}
}
private void bttn_Disconnect_Click(object sender, EventArgs e)
{
if (!connected)
return;
//First remove the event
remoteServer.MessageArrived -= eventProxy.LocallyHandleMessageArrived;
//Now we can close it out
ChannelServices.UnregisterChannel(tcpChan);
}
private void bttn_Send_Click(object sender, EventArgs e)
{
if (!connected)
return;
remoteServer.PublishMessage(tbx_Input.Text);
tbx_Input.Text = "";
}
private void SetTextBox(string Message)
{
if (tbx_Messages.InvokeRequired)
{
this.BeginInvoke(new SetBoxText(SetTextBox), new object[] { Message });
return;
}
else
tbx_Messages.AppendText(Message + "\r\n");
}
}
}
我们的客户端是一个 Windows 窗体,它引用了 RemotingEvents.Common 库,并且如您所见,它持有了 IServerObject
和 EventProxy
类的引用。即使 IServerObject
是一个接口,我们也可以像使用类一样调用它。 **如果您运行此示例,您需要在代码中更改 URI 以匹配您的服务器 IP!**
public Form1()
{
InitializeComponent();
clientProv = new BinaryClientFormatterSinkProvider();
serverProv = new BinaryServerFormatterSinkProvider();
serverProv.TypeFilterLevel =
System.Runtime.Serialization.Formatters.TypeFilterLevel.Full;
eventProxy = new EventProxy();
eventProxy.MessageArrived +=
new MessageArrivedEvent(eventProxy_MessageArrived);
Hashtable props = new Hashtable();
props["name"] = "remotingClient";
props["port"] = 0; //First available port
tcpChan = new TcpChannel(props, clientProv, serverProv);
ChannelServices.RegisterChannel(tcpChan);
RemotingConfiguration.RegisterWellKnownClientType(
new WellKnownClientTypeEntry(typeof(IServerObject), serverURI));
}
在窗体的构造函数中,我们设置了 Remoting 通道的信息。您会看到,我们创建了两个 sink provider,一个用于客户端,一个用于服务器。只有服务器需要将 TypeFilterLevel
设置为 Full
;客户端只需要一个 sink provider 的引用。
我们还在这里创建了 EventProxy
并注册了本地事件处理程序。当我们连接到服务器时,我们会将服务器连接到代理。所有剩余的工作是使用我们的哈希表和 sink provider 创建 TcpChannel
对象,注册通道,然后注册一个 WellKnownClientTypeEntry
。
private void bttn_Connect_Click(object sender, EventArgs e)
{
if (connected)
return;
try
{
remoteServer = (IServerObject)Activator.GetObject(
typeof(IServerObject), serverURI);
remoteServer.PublishMessage("Client Connected");
//This is where it will break if we didn't connect
//Now we have to attach the events...
remoteServer.MessageArrived +=
new MessageArrivedEvent(eventProxy.LocallyHandleMessageArrived);
connected = true;
}
catch (Exception ex)
{
connected = false;
SetTextBox("Could not connect: " + ex.Message);
}
}
private void bttn_Disconnect_Click(object sender, EventArgs e)
{
if (!connected)
return;
//First remove the event
remoteServer.MessageArrived -= eventProxy.LocallyHandleMessageArrived;
//Now we can close it out
ChannelServices.UnregisterChannel(tcpChan);
}
这是连接和断开连接的代码。我只想强调一点,当我们为 remoteServer
注册事件时,实际上是指向我们的 eventProxy.LocallyHandleMessageArrived
,它只是将事件传递给我们的应用程序。
您还应该注意到,由于我对客户端的仓促实现,如果您单击“断开连接”按钮,除非重新启动应用程序,否则您将无法重新连接。这是因为我在断开连接时取消注册了通道,但我在连接函数中没有注册它。
关于跨线程调用的快速说明
快速地,我想谈谈跨线程调用,因为您会在 Remoting 和 UI 应用程序中遇到这个问题。事件处理程序在与服务用户界面的线程不同的线程上运行,因此调用您的 TextBox.Text=
属性将引发那个精彩的 IllegalCrossThreadCallException
。如果您调用 Control.CheckForIllegalCrossThreadCalls = false
,可以禁用此异常,但这并不能解决问题。
发生的情况是,您将创建一个死锁,其中一个线程等待另一个线程,而另一个线程等待第一个线程。这将导致您的客户端和服务器都挂起(请参阅“事件是同步的?”部分),并阻止您的其他客户端接收事件。
您会在客户端代码中看到,我有以下代码:
private delegate void SetBoxText(string Message);
private void SetTextBox(string Message)
{
if (tbx_Messages.InvokeRequired)
{
this.BeginInvoke(new SetBoxText(SetTextBox),
new object[] { Message });
return;
}
else
tbx_Messages.AppendText(Message + "\r\n");
}
它使用 this.BeginInvoke
来服务于使用创建代码的 UI 线程来设置文本框。这可以扩展为接受一个文本框参数,这样您就不必为每个文本框创建此函数。要记住的重要一点是,不要禁用跨线程调用检查,并思考多线程。
运行应用程序
从 VS2008 IDE 下载应用程序将会在同一台机器上启动服务器和客户端项目。单击“启动服务器”将启动 Remoting 服务器。通过单击客户端屏幕上的“连接”将客户端连接到 Remoting 服务器,然后在框中键入任何内容并单击“发送”。这将使消息同时显示在客户端和服务器上。客户端通过来自服务器的事件接收消息,而不是直接从文本框接收。
您可以启动任意数量的客户端实例,即使是在同一台机器上,并发送消息,所有消息都应该显示在每个已连接的客户端上。尝试杀死一个客户端(通过任务管理器)并发送消息。您应该会注意到一些客户端接收事件时有轻微延迟。这是因为服务器必须等待 TCP 套接字确定客户端无法访问,这可能需要大约 1.5 秒(在我的机器上)。
事件是同步的?
让我们在客户端代码中做一个实验。将以下函数更改为如下所示:
void eventProxy_MessageArrived(string Message)
{
MessageBox.Show(Message);
//SetTextBox(Message);
}
再次运行项目,启动几个客户端。您会注意到,当第一个客户端启动消息框时,您必须在消息框中单击“确定”才能让下一个客户端接收它。这是因为事件是同步的!服务器在继续之前会等待客户端的响应。
这里的教训是 **处理您的事件然后退出!** 不要对事件处理程序代码执行任何长时间操作,您的其他客户端将在它完成之前无法接收事件,并且事件可能会在服务器端堆积。
您可以通过使用 Delegate.BeginInvoke
函数使事件异步,但这需要先考虑一些重要事项。
首先,使用 BeginInvoke
会消耗线程池中的一个线程。.NET 每个处理器只提供 25 个线程供您消耗,因此如果您有很多客户端,您可能会很快耗尽线程池。
其次,当您使用 BeginInvoke
时,您必须使用 EndInvoke
。如果您的客户端应用程序尚未准备好结束,您可以强制它结束,或者您可以让您的服务器线程等待(不推荐)它完成,使用 IAsyncResult.WaitOne
函数。
最后,使用异步事件很难确定(并非不可能)客户端是否可访问。
关于事件要记住什么
事件应仅在以下情况下使用:
- 事件消费者与服务器位于同一网络上
- 事件数量很少
- 客户端快速处理事件并返回
另外,请记住:
- 事件是同步的!
- 事件委托可能会变得不可达
- 事件会使您的应用程序变为多线程
- **切勿** 使用
[OneWay]
属性
Remoting 事件的替代方案
尽可能避免使用 .NET Remoting 事件。一些可以帮助您进行通知的技术包括:
- UDP 消息广播
- 消息队列服务
- IP 多播
参考文献
图书:《高级 .NET Remoting》(Ingo Rammer / Mario Szpuszta)ISBN: 978-1-59509-417-9。