分布式“消息发布者-订阅者”模型的实现——WCF双工通讯特性的应用

 

 

 

  近日在研究WCF时觉得WCF的双工通讯特性是一个很棒的功能,其应用值得好好研究一番,因此选择“消息发布者-订阅者”这一典型场景作为应用主题,探讨一种利用WCF双工通讯特性实现分布式的“消息发布者-订阅者”模型的方法。

  “消息发布者-订阅者”模型也就是一个事件监听和广播模型,消息的订阅者在需要的时候向发布者注册一个监听器,当发布者有消息需要发布时,通过监听器触发事件通知到订阅者。

  在一个单机单应用程序域的.NET程序中,.NET的事件委托机制是该模型的天然实现,或者采用观察者模式实现也是相当简单的事情。但是当我们需要一个跨域、跨进程、跨主机甚至跨网络的事件监听和事件广播系统时,就需要考虑跨越进程或网络注册事件以及事件触发时通知的问题。基于WCF实现方法上,最直接的解决方案就是在各个节点上都建立一个服务主机,事件注册也就是订阅者调用发布者的服务,将订阅者的服务地址发送给发布者保存。当要触发事件时,发布者则调用订阅者的服务地址发送事件通知。这种方式虽不优雅,但有其可取之处,在介绍了通过WCF双工通讯特性的实现之后再比较这两种实现方式的异同。

  本示例的服务端和客户端都是控制台程序。服务端启动后可以接受客户端的事件监听注册,通过控制台输入文本按回车后广播给所有已注册的客户端。客户端启动后立即向服务端注册,并在收到消息之后显示到控制台。

  • 发布者-服务端

  WCF双工通讯方式要求定义服务契约的同时指定一个用于服务端回调客户端的回调(Callback)契约,如下: 

定义服务契约
/// <summary>
/// 消息发布服务;
/// </summary>
[ServiceContract(CallbackContract=typeof(IMessageListener))]
public interface IMessagePublishService
{
/// <summary>
/// 注册消息监听器;
/// </summary>
[OperationContract]
void Regist();

/// <summary>
/// 注销消息监听器;
/// </summary>
[OperationContract]
void Unregist();
}

/// <summary>
/// 消息监听器;
/// 作为消息发布服务的回调契约;
/// </summary>
public interface IMessageListener
{
[OperationContract(IsOneWay
=true)]
void OnPublish(string message);
}

   然后实现服务,如下:

实现服务
/// <summary>
/// 实现消息发布服务;
/// </summary>
[ServiceBehavior(InstanceContextMode = InstanceContextMode.Single,
ConcurrencyMode
= ConcurrencyMode.Multiple)]
class MessagePublishServiceImpl : IMessagePublishService
{
#region IMessagePublishService 成员
/// <summary>
/// 注册;
/// </summary>
public void Regist()
{
RemoteEndpointMessageProperty remoteEndpointProp
= OperationContext.Current.IncomingMessageProperties[RemoteEndpointMessageProperty.Name] as RemoteEndpointMessageProperty;
IMessageListener callback
= OperationContext.Current.GetCallbackChannel<IMessageListener>();
MessageCenter.Instance.AddListener(
new MessageListener(remoteEndpointProp.Address, remoteEndpointProp.Port, callback));
}

/// <summary>
/// 注销;
/// </summary>
public void Unregist()
{
RemoteEndpointMessageProperty remoteEndpointProp
= OperationContext.Current.IncomingMessageProperties[RemoteEndpointMessageProperty.Name] as RemoteEndpointMessageProperty;
IMessageListener callback
= OperationContext.Current.GetCallbackChannel<IMessageListener>();
MessageCenter.Instance.RemoveListener(
new MessageListener(remoteEndpointProp.Address, remoteEndpointProp.Port, callback));
}

#endregion
}

  服务实现中通过 IMessageListener callback = OperationContext.Current.GetCallbackChannel<IMessageListener>() 操作获得客户端的回调,并保存该实例,在事件触发的时候通过此 callback 实例的OnPublish 方法通知订阅者客户端。代码中的 MessageCenter 是一个用于消息缓存和发送的单例类,MessageListener 是对 IMessageListener 的简单包装,具体参看附件源码。

  接下来进行服务寄宿。本示例通过控制台程序承载 IMessagePublishService 服务,程序启动后根据参数获取服务监听的IP地址和端口,服务启动后在控制台中输入字符串按回车将文字发送给所有注册进来的订阅者客户端。具体代码如下:

实现服务寄宿和消息通知
static void Main(string[] args)
{
//参数获取设置的服务地址,如果没有,则保留默认的 127.0.0.1:8888
string ip = "127.0.0.1";
IPAddress ipAddr;
if (args.Length > 0 && IPAddress.TryParse(args[0], out ipAddr))
{
ip
= ipAddr.ToString();
}
int port = 8888;
int tempPort;
if (args.Length > 1 && int.TryParse(args[1], out tempPort))
{
port
= tempPort;
}
string uri = string.Format("net.tcp://{0}:{1}", ip, port);
NetTcpBinding binding
= new NetTcpBinding();
binding.ReceiveTimeout
= TimeSpan.MaxValue;//设置连接自动断开的空闲时长;
ServiceHost host = new ServiceHost(typeof(MessagePublishServiceImpl));
host.AddServiceEndpoint(
typeof(IMessagePublishService), binding, uri);

Console.WriteLine(
"启动消息发布服务……接入地址:{0}", uri);

MessageCenter.Instance.ListenerAdded
+= new EventHandler<MessageListenerEventArgs>(Instance_ListenerAdded);
MessageCenter.Instance.ListenerRemoved
+= new EventHandler<MessageListenerEventArgs>(Instance_ListenerRemoved);
MessageCenter.Instance.NotifyError
+= new EventHandler<MessageNotifyErrorEventArgs>(Instance_NotifyError);
host.Open();

Console.WriteLine(
"服务正在运行");
EnterMessageInputMode();

Console.WriteLine(
"正在关闭服务……");
host.Close();

Console.WriteLine(
"服务已关闭。");
host
= null;
Console.ReadLine();
}

/// <summary>
/// 消息发送模式;
/// </summary>
static void EnterMessageInputMode()
{
Console.WriteLine(
"请输入要发送的消息,按回车键发送。输入 @exit 回车结束操作并关闭服务。");
string line;
do
{
Console.Write(
">>");
line
= Console.ReadLine();
if ("@exit" == line.Trim())
{
break;
}
if (string.Empty == line.Trim())
{
Console.WriteLine(
"[{0}]不能发送空消息!", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"));
continue;
}
MessageCenter.Instance.NotifyMessage(line);
Console.WriteLine(
"[{0}]发送成功!", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"));

}
while (true);
}
  • 订阅者-客户端

  消息订阅者客户端也实现为控制台程序,在启动程序后注册事件,得到事件通知后显示消息。代码如下:

实现订阅
/// <summary>
/// 订阅者;
/// </summary>
class Subscriber : IDisposable
{
private string _serviceUri;
private MessageListener _listener = new MessageListener();
private IMessagePublishService _serviceProxy;

public Subscriber(string serviceUri)
{
_serviceUri
= serviceUri;
}

public void Subscribe()
{
NetTcpBinding binding
= new NetTcpBinding();
_serviceProxy
= DuplexChannelFactory<IMessagePublishService>.CreateChannel(_listener, binding, new EndpointAddress(_serviceUri));
_serviceProxy.Regist();
}

#region IDisposable 成员

public void Dispose()
{
_serviceProxy.Unregist();
(_serviceProxy
as IDisposable).Dispose();
_listener
= null;
_serviceProxy
= null;
}

#endregion
}


/// <summary>
/// 客户端入口;
/// </summary>
/// <param name="args"></param>
static void Main(string[] args)
{
string serviceUri = "net.tcp://127.0.0.1:8888";
if (args.Length > 0)
{
serviceUri
= args[0];
}
Console.WriteLine(
"[{0}]连接服务…… {1}", DateTime.Now.ToString("yy-MM-dd HH:mm:ss"), serviceUri);
try
{
using (Subscriber sub = new Subscriber(serviceUri))
{
sub.Subscribe();
Console.WriteLine(
"[{0}]连接成功!", DateTime.Now.ToString("yy-MM-dd HH:mm:ss"));
Console.WriteLine(
"输入 @exit 断开连接。");
string line;
while (true)
{
Console.Write(
">>");
line
= Console.ReadLine();
if ("@exit" == line.Trim())
{
Console.WriteLine(
"[{0}]准备断开连接……", DateTime.Now.ToString("yy-MM-dd HH:mm:ss"));
break;
}
}
}
}
catch (Exception ex)
{
Console.WriteLine(
"[{0}]发生错误!--{1}", DateTime.Now.ToString("yy-MM-dd HH:mm:ss"), ex.Message);
}
Console.WriteLine(
"[{0}]断开连接!", DateTime.Now.ToString("yy-MM-dd HH:mm:ss"));
Console.ReadLine();
}

  回调契约 IMessageListener 的实现由订阅者客户端提供,客户端的IMessageListener 的实现为 MessageListener 类如下:

实现消息监听器
/// <summary>
/// 消息监听器;
/// </summary>
class MessageListener : IMessageListener
{
#region IMessageListener 成员

public void OnPublish(string message)
{
Console.WriteLine(
"[{0}]收到消息:{1}", DateTime.Now.ToString("yy-MM-dd HH:mm:ss"), message);
}

#endregion
}

  程序运行结果如下:

 

  •  实现总结

  有过Socket开发经验的朋友一定会问,客户端与服务端之间的建立的连接是长连接吗?当服务端长时间空闲之后再去调用客户端订阅的回调委托时还能调用成功吗?

  本示例中采用的 NetTcpBinding 使用的是 TCP 连接,的确是需要考虑长连接的问题。我们通过 TcpViewer 工具查看我们 MessagePublisher.exe 和 MessageSubscriber.exe 程序的网络连接情况,如下图:

  

   从图中可以看到,服务端和客户端之间建立的连接。通过 TCPView 我们发现这些连接的确是长连接。但是默认情况下,这些连接并不会一直保持。默认情况下,当连接空闲达 10 分钟时WCF将自动断开连接。细心的朋友可能会发现上面的服务端寄宿的代码中,在创建 ServiceHost 时指定的 NetTcpBinding 设置了 ReceiveTimeout 属性: binding.ReceiveTimeout = TimeSpan.MaxValue。如果忽略这一行代码,默认情况下,当客户端订阅了事件之后,服务端空闲了10分钟后再去调用回调(Callback)进行事件通知会抛出异常。尽管从名称上看这是数据接收超时,且MSDN对此的描述是“获取或设置在传输引发异常之前可用于完成读取操作的时间间隔。”,但是,正是ReceiveTimeout 属性控制着连接空闲自动断开的时长,默认为10分钟。我一直也没搞清楚为什么是这个属性,希望知道的朋友不吝回帖解答。此外,对于 ReceiveTimeout 的设置只需要在服务端的 Binding 设置即可,客户端的 Binding 的这一属性并不产生影响。

  基于NetTcpBinding的连接是个长连接,并且连接会在空闲时自动断开的事实使我们不得不考虑该在什么情形下使用WCF的回调机制实现“消息发布者-订阅者”模型。由于每一个客户端连接之后都会长期保持着连接不断开,这会减少服务器可接收的服务请求。开篇时提到的每个订阅者都独立建立一个消息通知服务,事件注册时仅传递订阅者的服务地址的方案实质是一个短连接的方案,短连接则使得服务端可以容纳更多的订阅者注册消息事件。这两个方案的对比实际上是长连接和短连接的对比,这又一次印证了“软件是一门权衡的艺术”这句话。如果使用场景并不假设存在大量的订阅者,那么采用WCF的回调机制会令你的实现更加简单和优雅。

  • WCF双工通讯特性的其它应用场景

  1、服务操作执行进度反馈

  对于某些由客户端发起的后台服务操作需要长时间执行,并且需要将后台的操作进度反馈给客户端的场景下,使用双工通讯特性通过回调反馈操作进度。需要注意的是建立服务时需要定义足够长的SendTimeout 超时时长,以避免在操作执行时客户端等待超时,同时应该为服务设置恰当的并发模式 ConcurrencyMode = ConcurrencyMode.Multiple 或是将回调操作设置为单向操作(IsOneWay=true)以避免死锁。

  2、大文件上传

  客户端调用服务方法请求上传文件,并附上文件的相关信息,服务端如果允许上传则通过回调(Callback)采用拉(Pull)的方式分多次获取文件。此场景下的回调委托的方法由于需要返回数据而不能定义为单向操作(OneWay)。这种方法可以作为WCF流传输方式上传文件方案的替代方式。

 

 

 

注:关于WCF双工通讯机制的详细分析参看 Artech 的博客文章《在WCF中实现双工通信》

 

附件:示例源码

 

posted @ 2010-09-14 16:12  haiq  阅读(6194)  评论(5编辑  收藏  举报