.NET Remoting 事件详解 - VB.NET 版
这是 ".NET Remoting 事件详解" 的 VB.NET 版本。
引言
作为一名中级开发人员,我花了大量时间寻找 VB.NET 的 .NET Remoting 示例。我最终不得不重写 Ron Beyer 的文章《.NET Remoting 事件详解》。这篇文章的大部分功劳归于他,我也想借此机会感谢他允许我制作这篇 VB.NET 版本。我将尝试以相同的布局呈现 VB.NET 代码,以便可以很好地对照 VB.NET 和 C# 进行比较。这段代码是为了与 VB.NET 2013 及更高版本中的 .NET4.0 兼容而编写的。我目前正在 VB.NET 2017 中使用它。
使用 .NET 进行远程处理(Remoting)一开始可能是一项艰巨的任务,但它也能让生活变得更轻松。.NET 的远程处理框架的目标是尽可能简化跨应用程序和计算机边界的数据序列化和反序列化,同时提供 .NET 框架的所有灵活性和强大功能。在这里,我们将探讨在远程处理环境中使用事件以及设计一个用于事件的应用程序的正确方法。
背景
事件让下游应用程序的生活变得更简单,在客户端/服务器环境中使用事件也一样。当服务器上的某些内容发生变化或发生某个事件时通知客户端,而无需客户端轮询服务器,这意味着客户端的实现要简单得多。
.NET 的问题在于,触发事件的服务器端需要了解事件在消费端的实际实现。我经常看到 .NET Remoting 示例中的事件需要服务器引用客户端应用程序(有时甚至是.EXE 本身,太糟糕了!)和/或客户端需要对服务器的完整实现进行引用。
服务器端和客户端的良好编程实践是相互分离实现,这样服务器就不需要知道客户端是如何实现的,客户端也不需要知道服务器是如何实现的。
应用程序设计
在我们的示例应用程序中,我们将有一个服务器和多个客户端。客户端将位于不同的计算机上,但位于同一内部网络中。客户端与服务器是松散耦合的;也就是说,客户端的连接状态可以随时因任何原因而改变。
客户端将向服务器发送一条消息,服务器必须通知所有已连接的客户端一条新消息已到达以及消息的内容。客户端将在收到通知时显示该消息。根据以上信息,我们可以确定:
- 服务器必须控制自己的生命周期。
- 我们将使用 TCP 协议(IPC 不适用于跨计算机通信)。
- 我们将使用 .NET 事件。
- 客户端和服务器不能知道彼此的实现细节。
通用库
因此,将实现分离,我们将需要某种通用库来保存客户端和服务器之间共享的数据。我们的通用库将包含以下内容:
- 事件声明
- 事件代理
- 服务器接口
让我们从事件声明开始(EventDeclarations.vb)
Namespace RemotingEvents.Common
Public Delegate Sub MessageArrivedEvent(Message As String)
End Namespace
很简单。我们只是声明一个名为 MessageArrivedEvent
的委托,它标识我们将用作事件的函数。现在,我们将跳过服务器接口(稍后回到 EventProxy
)。
Namespace RemotingEvents.Common
Public Interface IServerObject
Event MessageArrived As MessageArrivedEvent
Sub PublishMessage(Message As String)
End Interface
End Namespace
这个也很简单。我们在这里声明了我们的服务器对象的接口,但没有实现。客户端不会知道这些函数在服务器端是如何实现的,只会知道调用它们或获取事件通知的接口。服务器(正如我们将在下一节中看到的)为该接口添加了许多内容,但其中没有一个可以从客户端使用。
现在,让我们看一下 EventProxy
类。首先是代码:
Imports System.Runtime.Remoting.Lifetime
Namespace RemotingEvents.Common
Public Class EventProxy
Inherits MarshalByRefObject
Public Event MessageArrived As MessageArrivedEvent
Public Overrides Function InitializeLifetimeService()
Dim baseLease As ILease = MyBase.InitializeLifetimeService()
If (baseLease.CurrentState = LeaseState.Initial) Then
' Make Lease Infinite
baseLease.RenewOnCallTime = TimeSpan.Zero
baseLease.SponsorshipTimeout = TimeSpan.Zero
End If
Return baseLease
'Returning null holds the object alive
'until it is explicitly destroyed
'Return Nothing
End Function
'Public Sub LocallyHandleMessageArrived(Message As String) Handles Me.MessageArrived
Public Sub LocallyHandleMessageArrived(Message As String)
Try
If Not IsNothing(MessageArrivedEvent) Then
RaiseEvent MessageArrived(Message)
End If
Catch ex As Exception
MsgBox(ex.InnerException)
End Try
End Sub
End Class
End Namespace
这个类并不复杂,但让我们注意一些细节。首先,该类继承自 MarshalByRefObject
。这是因为 EventProxy
被序列化并反序列化到客户端,所以远程处理框架需要知道如何进行对象封送。在这里使用 MarshalByRefObject
意味着对象是通过引用而不是通过值(通过副本)进行跨边界封送的。
函数 InitializeLifetimeService()
是从 MarshalByRefObject
类中重写的。从此类返回 Nothing
意味着我们希望 .NET 环境保持代理的活动状态,直到被应用程序显式销毁。我们也可以在这里返回一个新的 ILease
,并将超时设置为 TimeSpan.Zero
来实现相同的功能。
我们之所以需要这个代理类,是因为服务器端需要了解客户端上事件消费者的实现。如果我们不使用代理类,服务器将不得不引用客户端实现,以便知道如何以及在哪里调用该函数。我们将在客户端实现部分看到如何使用这个代理类。
服务器实现
现在,让我们继续服务器实现。服务器在一个名为(在我们的示例中)RemotingEvents.Server
的单独项目中实现。该项目创建了对 RemotingEvents.Common
项目的引用,以便我们可以(间接地)使用接口、事件声明和事件代理。这是完整的代码:
Imports System.Runtime.Remoting
Imports System.Runtime.Remoting.Channels
Imports System.Runtime.Remoting.Channels.Tcp
Imports System
Imports System.ComponentModel
Imports System.Reflection
Imports RemotingEvents.Common.RemotingEvents.Common
Namespace RemotingEvents.Server
Public Class RemotingServer
Inherits MarshalByRefObject
Implements IServerObject
Private serverChannel As TcpServerChannel
Private tcpPort As Integer
Private internalRef As ObjRef
Private serverActive As Boolean = False
Private Const serverURI As String = "serverExample.Rem"
Public Event MessageArrived As MessageArrivedEvent Implements IServerObject.MessageArrived
Public Sub PublishMessage(Message As String) Implements IServerObject.PublishMessage
SafeInvokeMessageArrived(Message)
End Sub
Public Sub StartServer(port As Integer)
If (serverActive) Then
Return
End If
Dim props As Hashtable = New Hashtable()
props("port") = port
props("name") = serverURI
'Set up for remoting events properly
Dim serverProv As BinaryServerFormatterSinkProvider = _
New BinaryServerFormatterSinkProvider()
serverProv.TypeFilterLevel = System.Runtime.Serialization.Formatters.TypeFilterLevel.Full
serverChannel = New TcpServerChannel(props, serverProv)
Try
ChannelServices.RegisterChannel(serverChannel, False)
internalRef = RemotingServices.Marshal(Me, props("name").ToString())
serverActive = True
Catch re As RemotingException
'Could not start the server because of a remoting exception
Catch ex As Exception
'Could not start the server because of some other exception
End Try
End Sub
Public Sub StopServer()
If Not serverActive Then
Return
End If
RemotingServices.Unmarshal(internalRef)
Try
ChannelServices.UnregisterChannel(serverChannel)
Catch ex As Exception
End Try
End Sub
Private Sub SafeInvokeMessageArrived(Message As String)
If (Not serverActive) Then
Return
End If
If MessageArrivedEvent = Nothing Then
Return 'No Listeners
End If
Dim listener As MessageArrivedEvent = Nothing
Dim dels() As [Delegate] = MessageArrivedEvent.GetInvocationList()
For Each del As [Delegate] In dels
Try
listener = CType(del, MessageArrivedEvent)
listener.Invoke(Message)
Catch ex As Exception
'Could not reach the destination, so remove it
'from the list
RemoveHandler MessageArrived, listener
End Try
Next
End Sub
End Class
End Namespace
这有很多需要吸收的内容,所以我们把它分解一下:
Public Class RemotingServer
Inherits MarshalByRefObject
Implements IServerObject
我们的类继承自 MarshalByRefObject
并实现了 IServerObject
。MarshalByRefObject
是因为我们希望我们的服务器通过对服务器对象的引用进行跨边界封送,而 IServerObject
意味着我们实现了客户端已知的服务器接口。
Private serverChannel As TcpServerChannel
Private tcpPort As Integer
Private internalRef As ObjRef
Private serverActive As Boolean = False
Private Const serverURI As String = "serverExample.Rem"
Public Event MessageArrived As MessageArrivedEvent Implements IServerObject.MessageArrived
Public Sub PublishMessage(Message As String) Implements IServerObject.PublishMessage
SafeInvokeMessageArrived(Message)
End Sub
这是私有的工作变量设置和 IServerObject
成员的实现。TcpServerChannel
是我们用于服务器的 TCP 远程处理通道的引用。tcpPort
和 serverActive
很容易理解。ObjRef
保存了正在呈现(封送)进行远程处理的对象的内部引用。我们不一定需要封送我们自己的类,我们可以封送其他类;我只是倾向于将服务代码放在正在被封送的对象内部。
我们稍后将查看 SafeInvokeMessageArrived
。首先,让我们看一下启动和停止服务器服务:
Public Sub StartServer(port As Integer)
If (serverActive) Then
Return
End If
Dim props As Hashtable = New Hashtable()
props("port") = port
props("name") = serverURI
'Set up for remoting events properly
Dim serverProv As BinaryServerFormatterSinkProvider = _
New BinaryServerFormatterSinkProvider()
serverProv.TypeFilterLevel = _
System.Runtime.Serialization.Formatters.TypeFilterLevel.Full
serverChannel = New TcpServerChannel(props, serverProv)
Try
ChannelServices.RegisterChannel(serverChannel, False)
internalRef = RemotingServices.Marshal(Me, props("name").ToString())
serverActive = True
Catch re As RemotingException
'Could not start the server because of a remoting exception
Catch ex As Exception
'Could not start the server because of some other exception
End Try
End Sub
Public Sub StopServer()
If Not serverActive Then
Return
End If
RemotingServices.Unmarshal(internalRef)
Try
ChannelServices.UnregisterChannel(serverChannel)
Catch ex As Exception
End Try
End Sub
我不会详细介绍所有内容,但让我们看一下对远程处理事件非常重要的内容:
Dim serverProv As BinaryServerFormatterSinkProvider = New BinaryServerFormatterSinkProvider()
serverProv.TypeFilterLevel = System.Runtime.Serialization.Formatters.TypeFilterLevel.Full
serverChannel = New TcpServerChannel(props, serverProv)
在这里,我们设置了 BinaryServerFormatterSinkProvider
。我们的客户端也需要类似的匹配设置(我们将在下一节中看到)。这标识了我们如何在远程处理边界上传递事件(在这种情况下,我们选择了二进制实现而不是 XML)。为了让事件正常工作,我们需要将 TypeFilterLevel
设置为 Full
。
由于向 TcpServerChannel
构造函数提供通道提供程序的唯一方法是使用 Hashtable
,因此我们需要使用我们构造的哈希表来保存服务器的名称(在 URI 或“统一资源标识符”中使用)以及远程处理的端口。
对于我的机器,生成的 URI 是 tcp://192.168.1.68:15000/serverExample.Rem。这在客户端以后会用到,并且一开始可能难以理解(和确定)。您应该注意,使用内部引用对象的函数来获取 URI 会得到一个看起来很奇怪的字符串,而且它们都不能用于连接到您的服务器。
现在,让我们看一下 SafeInvokeMessageArrived
函数:
Private Sub SafeInvokeMessageArrived(Message As String)
If (Not serverActive) Then
Return
End If
If MessageArrivedEvent = Nothing Then
Return 'No Listeners
End If
Dim listener As MessageArrivedEvent = Nothing
Dim dels() As [Delegate] = MessageArrivedEvent.GetInvocationList()
For Each del As [Delegate] In dels
Try
listener = CType(del, MessageArrivedEvent)
listener.Invoke(Message)
Catch ex As Exception
'Could not reach the destination, so remove it
'from the list
RemoveHandler MessageArrived, listener
End Try
Next
End Sub
你应该用这种方式实现所有你的事件调用代码,而不仅仅是那些涉及远程处理的。虽然我在这里解释了关于远程处理的原因,但同样适用于任何应用程序,这只是一个好的做法。
在这里,我们首先检查服务器是否处于活动状态。如果服务器不处于活动状态,则不尝试引发任何事件。这只是一个健全性检查。接下来,我们检查是否有任何附加的监听器,这意味着 MessageArrived
委托(事件)将为 null
。如果是,我们直接返回。
接下来的两行很重要。我们为 listener
创建一个临时委托,然后存储我们事件当前持有的调用列表。我们这样做是因为在我们迭代调用列表时,一个客户端可能会(故意)从调用列表中移除自己,这可能导致线程不安全的情况。
接下来,我们遍历所有委托并尝试使用消息调用它们。如果调用引发了异常,我们将它从调用列表中移除,从而有效地将该客户端从接收通知的列表中移除。
这里有几点需要记住。首先是您不希望使用 [OneWay]
属性声明事件。这样做会使整个练习无效,因为服务器不会等待检查结果,并且无论是否连接,都会调用调用列表中的每个项目。对于短期运行的服务器应用程序来说,这不是一个大问题,但如果您的服务器运行数月或数年,您的调用列表可能会增长到足以导致服务器崩溃,而这是一个很难找到的 bug。
您还需要意识到事件是同步的(稍后将详细介绍),因此服务器在调用下一个监听程序之前将等待客户端从函数调用返回。稍后将详细介绍。
客户端实现
让我们快速看一下客户端:
Imports System.Runtime.Remoting
Imports System.Runtime.Remoting.Channels
Imports System.Runtime.Remoting.Channels.Tcp
Imports RemotingEvents.Common.RemotingEvents.Common
Public Class Form1
Public remoteServer As IServerObject
Public eventProxy As EventProxy
Private tcpChan As TcpChannel
Private clientProv As BinaryClientFormatterSinkProvider
Private serverProv As BinaryServerFormatterSinkProvider
'Replace with your IP
Private serverURI As String = "tcp://127.0.0.1:15000/serverExample.Rem"
Private connected As Boolean = False
Private Delegate Sub SetBoxText(Message As String)
Public Sub New()
InitializeComponent()
clientProv = New BinaryClientFormatterSinkProvider()
serverProv = New BinaryServerFormatterSinkProvider()
serverProv.TypeFilterLevel = System.Runtime.Serialization.Formatters.TypeFilterLevel.Full
eventProxy = New EventProxy
AddHandler eventProxy.MessageArrived, _
New MessageArrivedEvent(AddressOf eventProxy_MessageArrived)
Dim props As Hashtable = New Hashtable()
props("name") = "remotingClient"
props("port") = 0 'First available port
tcpChan = New TcpChannel(props, clientProv, serverProv)
ChannelServices.RegisterChannel(tcpChan, False)
RemotingConfiguration.RegisterWellKnownClientType(
New WellKnownClientTypeEntry(GetType(IServerObject), serverURI))
End Sub
Sub eventProxy_MessageArrived(Message As String)
SetTextBox(Message)
End Sub
Private Sub bttn_Connect_Click(sender As Object, e As EventArgs) Handles bttn_Connect.Click
If (connected) Then
Return
End If
Try
remoteServer = CType(Activator.GetObject_
(GetType(IServerObject), serverURI), IServerObject)
remoteServer.PublishMessage("Client Connected")
'This is where it will break if we didn't connect
'Now we have to attach the events...
AddHandler remoteServer.MessageArrived, _
AddressOf eventProxy.LocallyHandleMessageArrived
connected = True
Catch ex As Exception
connected = False
SetTextBox("Could not connect: " + ex.Message)
End Try
End Sub
Private Sub bttn_Disconnect_Click(sender As Object, e As EventArgs) _
Handles bttn_Disconnect.Click
If (Not connected) Then
Return
End If
'First remove the event
RemoveHandler remoteServer.MessageArrived, _
(AddressOf eventProxy.LocallyHandleMessageArrived)
'Now we can close it out
ChannelServices.UnregisterChannel(tcpChan)
End Sub
Private Sub bttn_Send_Click(sender As Object, e As EventArgs) Handles bttn_Send.Click
If (Not connected) Then
Return
End If
remoteServer.PublishMessage(tbx_Input.Text)
tbx_Input.Text = ""
End Sub
Private Sub SetTextBox(Message As String)
If (tbx_Messages.InvokeRequired) Then
Me.BeginInvoke(New SetBoxText(AddressOf SetTextBox), New Object() {Message})
Return
Else
tbx_Messages.AppendText(Message & vbNewLine)
End If
End Sub
End Class
我们的客户端是一个 Windows 窗体,它引用了 RemotingEvents.Common
库,并且如您所见,它持有了 IServerObject
和 EventProxy
类的引用。尽管 IServerObject
是一个接口,但我们可以像调用类一样调用它。如果您运行此示例,您将需要更改代码中的 URI 以匹配您的服务器 IP!
Public Class Form1
Public remoteServer As IServerObject
Public eventProxy As EventProxy
Private tcpChan As TcpChannel
Private clientProv As BinaryClientFormatterSinkProvider
Private serverProv As BinaryServerFormatterSinkProvider
'Replace with your IP
Private serverURI As String = "tcp://127.0.0.1:15000/serverExample.Rem"
Private connected As Boolean = False
Private Delegate Sub SetBoxText(Message As String)
Public Sub New()
InitializeComponent()
clientProv = New BinaryClientFormatterSinkProvider()
serverProv = New BinaryServerFormatterSinkProvider()
serverProv.TypeFilterLevel = System.Runtime.Serialization.Formatters.TypeFilterLevel.Full
eventProxy = New EventProxy()
AddHandler eventProxy.MessageArrived, _
New MessageArrivedEvent(AddressOf eventProxy_MessageArrived)
Dim props As Hashtable = New Hashtable()
props("name") = "remotingClient"
props("port") = 0 'First available port
tcpChan = New TcpChannel(props, clientProv, serverProv)
ChannelServices.RegisterChannel(tcpChan, False)
RemotingConfiguration.RegisterWellKnownClientType(
New WellKnownClientTypeEntry(GetType(IServerObject), serverURI))
End Sub
在窗体的构造函数中,我们设置了远程处理通道的信息。您会看到,我们创建了两个通道提供程序,一个用于客户端,一个用于服务器。只有服务器需要将 TypeFilterLevel
设置为 Full
;客户端只需要一个通道提供程序的引用。
我们还在这里创建了 EventProxy
并注册了本地事件处理程序。我们将在连接到服务器时将服务器连接到代理。剩下的是使用我们的哈希表和通道提供程序创建 TcpChannel
对象,注册该通道,然后注册一个 WellKnownClientTypeEntry
。
Private Sub bttn_Connect_Click(sender As Object, e As EventArgs) Handles bttn_Connect.Click
If (connected) Then
Return
End If
Try
remoteServer = CType(Activator.GetObject_
(GetType(IServerObject), serverURI), IServerObject)
remoteServer.PublishMessage("Client Connected")
'This is where it will break if we didn't connect
'Now we have to attach the events...
AddHandler remoteServer.MessageArrived, _
AddressOf eventProxy.LocallyHandleMessageArrived
connected = True
Catch ex As Exception
connected = False
SetTextBox("Could not connect: " + ex.Message)
End Try
End Sub
Private Sub bttn_Disconnect_Click(sender As Object, e As EventArgs) _
Handles bttn_Disconnect.Click
If (Not connected) Then
Return
End If
'First remove the event
RemoveHandler remoteServer.MessageArrived, _
(AddressOf eventProxy.LocallyHandleMessageArrived)
'Now we can close it out
ChannelServices.UnregisterChannel(tcpChan)
End Sub
这是连接和断开连接的代码。我只想强调一点,当我们为 remoteServer
注册事件时,我们实际上将其指向了我们的 eventProxy.LocallyHandleMessageArrived
,它只是将事件传递给我们的应用程序。
您还应该注意,由于我急于实现客户端,如果您单击“断开连接”按钮,您将无法重新连接,除非您重新启动应用程序。这是因为我在断开连接中注销了通道,但在连接函数中没有注册它。
关于跨线程调用的快速说明
快速提一下跨线程调用,因为您在远程处理和 UI 应用程序中会遇到这种情况。事件处理程序运行在与服务用户界面的线程不同的线程上,因此调用您的 TextBox.Text=
属性将引发那个很棒的 IllegalCrossThreadCallException
。如果您调用 Control.CheckForIllegalCrossThreadCalls = false
,可以禁用此异常,但这不会解决问题。
会发生的是,您将创建一个死锁,一个线程等待另一个线程,而另一个线程等待第一个线程。这将导致您的客户端和服务器都挂起(请参阅“事件是同步的?”部分),并阻止您的其他客户端接收事件。
您会在客户端代码中看到,我使用了以下代码:
Private Sub SetTextBox(Message As String)
If (tbx_Messages.InvokeRequired) Then
Me.BeginInvoke(New SetBoxText(AddressOf SetTextBox), New Object() {Message})
Return
Else
tbx_Messages.AppendText(Message & vbNewLine)
End If
End Sub
它使用 this.BeginInvoke
来处理使用创建代码的 UI 线程设置 textbox
。这可以扩展以接受 textbox
参数,这样您就不必为每个 textbox
创建此函数。重要的是要记住不要禁用跨线程调用检查,并考虑多线程。
运行应用程序
在 VS2013 IDE 中下载并运行应用程序将会在同一台机器上同时启动服务器和客户端项目。单击“启动服务器”将启动远程处理服务器。通过在客户端屏幕上单击“连接”将客户端连接到远程处理服务器,然后输入任何内容到框中并单击“发送”。这将使消息同时显示在客户端和服务器上。客户端通过服务器的事件接收消息,而不是直接从文本框接收。
您可以根据需要启动任意数量的客户端实例,即使在同一台机器上,并发送消息,所有消息都应该出现在每个已连接的客户端上。尝试通过任务管理器终止一个客户端,然后发送消息。您应该会注意到一些客户端接收事件的延迟很小。这是因为服务器必须等待 TCP 套接字确定客户端不可达,这可能需要大约 1.5 秒(在我的机器上)。
事件是同步的?
不要在事件处理程序代码中执行任何长时间运行的操作,否则您的其他客户端将不会收到事件,直到它完成为止,并且事件可能会在服务器端堆积。
您可以使用 Delegate.BeginInvoke
函数使事件异步,但首先需要考虑一些重要事项:
首先,使用 BeginInvoke
会消耗线程池中的一个线程。.NET 只为每个处理器提供 25 个线程供您消耗,因此如果您有很多客户端,您可能会很快耗尽线程池。
第二,当您使用 BeginInvoke
时,您必须使用 EndInvoke
。如果您的客户端应用程序尚未准备好结束,您可以强制它结束,或者让您的服务器线程等待(这是一个糟糕的主意)它完成,使用 IAsyncResult.WaitOne
函数。
最后,使用异步事件很难(并非不可能)确定客户端是否可达。
关于事件要记住什么
事件仅应在以下情况中使用:
- 事件消费者与服务器位于同一网络中。
- 事件数量较少。
- 客户端快速服务事件并返回。
另外,请记住:
- 事件是同步的!
- 事件委托可能会变得不可达。
- 事件使您的应用程序成为多线程的。
- 切勿使用
[OneWay]
属性。
替代 .NET Remoting 事件
尽量避免使用 .NET Remoting 事件。一些可以帮助您进行通知的技术包括:
- UDP 消息广播
- 消息队列服务
- IP 组播
参考文献
- 书籍:《Advanced .NET Remoting》(Ingo Rammer / Mario Szpuszta)ISBN:978-1-59509-417-9。
- 文章:.NET Remoting 事件详解(Ron Beyer)