使用 Comet 方法在 RESTful WCF Web 应用程序中推送消息






4.91/5 (37投票s)
本文讨论了一种基于 RESTful WCF 的机制,用于在 Web 应用程序中实现服务器到客户端的异步(推送)通知。WCF 服务通知方法的执行将一直挂起,直到发生服务器事件或超时。
引言
RESTful WCF 服务能够接受和处理普通的 HTTP 请求,包括来自浏览器的请求,从而有效地充当轻量级 Web 服务器。在 Web 应用程序中,通常希望将服务器上发生的更改和事件通知给浏览器。通常,这不是一项微不足道的工作,因为 HTTP 协议不支持回调。有三种常用的技术可以提供回调通知,即轮询、服务器与客户端之间永久打开的套接字,以及长 HTTP 请求(也称为 AJAX Push 或 Comet)。所有这些技术都有许多变体,并且它们之间的界限模糊。当对可扩展性和吞吐量的要求很高时,首选轮询。这也是最简单的技术。但是,当服务器端的事件频率发生很大变化时,轮询会导致不必要的网络过载或用户更新不及时。永久打开的套接字技术需要大量的自定义代码来实现和处理通过套接字传输的数据。更重要的是,该技术需要在客户端打开一个“非标准”通信端口。这需要重新配置客户端防火墙,这可能会有问题。
与永久打开的套接字方法相比,Comet 技术似乎不那么费力,因为它允许更自然地使用标准机制(在本例中是 RESTful WCF)。Comet 比轮询更复杂,可扩展性也更差。但是,异步回调通知的整个思想在于提高服务器事件频率不可预测且在广泛范围内变化的情况下的效率。否则,应优先考虑轮询,因为它简单且可扩展。因此,Comet 可能是合适的选择,例如,用于具有随机事件和有限用户数量的控制和管理系统。
背景
维基百科对 Comet 技术进行了简要介绍:
Comet 是一种 Web 应用程序模型,其中一个长时间保持的 HTTP 请求允许 Web 服务器将数据推送到浏览器,而无需浏览器显式请求。Comet 是一个总称,涵盖了实现此交互的多种技术。所有这些方法都依赖于浏览器默认包含的功能,例如 JavaScript,而不是非默认的插件。Comet 方法不同于 Web 的原始模型,在原始模型中,浏览器一次请求一个完整的网页。
在 Comet 作为这些技术的统称出现之前,Comet 技术就已经在 Web 开发中得到了使用。Comet 也被称为其他几种名称,包括 AJAX Push、Reverse AJAX、Two-way-web、HTTP streaming 和 HTTP server push 等。
本文旨在提供简单的可重用 .NET 类和 JavaScript 函数,以实现 Comet 技术进行异步 Web 客户端通知。
设计和代码
通过结合 RESTful WCF 服务和嵌入在浏览器 HTML 表单中的 XMLHttpRequest
JavaScript 对象,实现了 Comet 方法的异步通知效果。
建议的 Comet 机制的服务器部分由一个 RESTful WCF 服务类 CometSvc
组成,该类实现了接口 ICometSvc
和类 Comet
。它们共同构成了 CometSvcLib
程序集。下面给出 ICometSvc
、CometSvc
和 Comet
类型的代码。
namespace CometSvcLib
{
[ServiceContract(SessionMode = SessionMode.NotAllowed)]
public interface ICometSvc
{
[OperationContract]
[WebInvoke(Method = "GET", UriTemplate = CometSvc.NOTIFICATION +
"/{clientId}/{dummy}", BodyStyle = WebMessageBodyStyle.Bare)]
Message Notification(string clientId, string dummy);
}
[ServiceBehavior(ConcurrencyMode = ConcurrencyMode.Multiple,
InstanceContextMode = InstanceContextMode.PerCall)]
public class CometSvc : ICometSvc
{
public const string NOTIFICATION = "/Notification";
private AutoResetEvent ev = new AutoResetEvent(false);
public Message Notification(string clientId, string dummy)
{
string arg;
HttpStatusCode statusCode = HttpStatusCode.OK;
if (Comet.dlgtGetResponseString != null)
{
Comet.RegisterCometInstance(clientId, this);
if (ev.WaitOne(Comet.timeout))
{
lock (typeof(Comet))
{
arg = Comet.dlgtGetResponseString(clientId);
}
}
else
arg = string.Format("Timeout Elapsed {0}", clientId);
Comet.UnregisterCometInstance(clientId);
}
else
{
arg = "Error: Response generation is not implemented";
Console.WriteLine(arg);
statusCode = HttpStatusCode.InternalServerError;
}
return Comet.GenerateResponseMessage(arg, statusCode);
}
internal void SetEvent()
{
ev.Set();
}
}
}
namespace CometSvcLib
{
public class Comet : IDisposable
{
private WebServiceHost hostComet = null;
private static Dictionary<string, CometSvc> dctSvc =
new Dictionary<string, CometSvc>();
public delegate string GetResponseStringDelegate(string clientId);
public static GetResponseStringDelegate dlgtGetResponseString = null;
public static TimeSpan timeout = new TimeSpan(0, 0, 55);
public Comet()
{
hostComet = new WebServiceHost(typeof(CometSvc));
hostComet.Open();
}
public string CometSvcAddress
{
get
{
return string.Format("{0}{1}",
hostComet.BaseAddresses[0], CometSvc.NOTIFICATION);
}
}
public void Close()
{
Dispose();
}
public void Dispose()
{
if (hostComet != null && hostComet.State ==
CommunicationState.Opened)
hostComet.Close();
}
internal static void RegisterCometInstance(string clientId,
CometSvc cometSvc)
{
lock (typeof(Comet))
{
Comet.dctSvc[clientId] = cometSvc;
}
}
internal static void UnregisterCometInstance(string clientId)
{
lock (typeof(Comet))
{
if (dctSvc.ContainsKey(clientId))
Comet.dctSvc.Remove(clientId);
}
}
public static void SetEvent(string clientId)
{
CometSvc cometSvc;
lock (typeof(Comet))
{
dctSvc.TryGetValue(clientId, out cometSvc);
}
if (cometSvc != null)
cometSvc.SetEvent();
}
public static Message GenerateResponseMessage(string strResponse,
HttpStatusCode statusCode)
{
XmlReader xr;
try
{
xr = XmlReader.Create(new StringReader("<i>" + strResponse + "</i>"));
}
catch
{
xr = XmlReader.Create(new StringReader("<i>Wrong Response.</i>"));
}
Message response =
Message.CreateMessage(MessageVersion.None, string.Empty, xr);
HttpResponseMessageProperty responseProperty =
new HttpResponseMessageProperty();
responseProperty.Headers.Add("Content-Type", "text/html");
response.Properties.Add(HttpResponseMessageProperty.Name,
responseProperty);
(response.Properties.Values.ElementAt(0) as
HttpResponseMessageProperty).StatusCode = statusCode;
return response;
}
}
}
需要注意的是,ICometSvc
的服务协定具有 SessionMode.NotAllowed
,而 CometSvc
的服务行为则定义为 ConcurrencyMode.Multiple
和 InstanceContextMode.PerCall
。这些定义确保了每次调用服务时,都会创建一个 CometSvc
类型的新实例,并且其 CometSvc.Notification()
方法将在被调用后立即执行。
本文示例的流程如下。当用户浏览 http://[host_URI]:[port]/FormSvc 时,将调用 FormSvc
RESTful WCF 服务的 FormSvc.Init()
方法(上图中编号为 1 的操作)。FormSvc.Init()
方法启动一个新线程,并在该线程中执行一些有用的处理。然后,FormSvc.Init()
生成一个 HTTP 响应消息(通过调用静态方法 Comet.GenerateResponseMessage()
)并将其返回给浏览器(编号为 2 的操作)。响应消息包含一个 HTML 页面(FormTemplate.html 文件的内容,其中 {URI} 和 {ID} 占位符被替换为 CometSvc
服务 URI 和客户端 ID)。浏览器显示 HTML 页面。该页面包含一个表单和 JavaScript 部分。JavaScript 代码创建 XMLHttpRequest
对象。该对象使用其 open()
和 send()
方法创建并向服务器发送 HTTP 请求,并使用其 onreadystatechange
属性为服务器响应提供回调方法。为了提供通知,XMLHttpRequest
对象向 http://[host_URI]:[port]/CometSvc/Notification/{ID}/[dummy_count] 发送 HTTP 请求(图中的操作 3)。请求 URI 的最后一个元素是一些可变的整数,用于区分请求。这是必需的,因为浏览器可能不会发送两个相邻的相同请求。此请求由 CometSvc
RESTful WCF 服务的 CometSvc.Notification()
方法接收。CometSvc
服务是在 Comet
类的构造函数中创建和打开的,而 Comet
类的静态实例又是在 FormSvc
的静态构造函数中创建的。
该方法通过将当前 CometSvc
实例放置在某个查找表(我们示例中的 Dictionary<>
)中,并将客户端 ID 作为键来注册。然后,通过 ev.
WaitOne(Comet.timeout)
挂起方法的执行,其中 ev
是 AutoResetEvent
类型的对象。CometSvc.Notification()
将在 ev.Set()
之后或 WaitOne()
方法的超时时间过去后恢复。在任何一种情况下,此 CometSvc
类型实例的条目都将从查找表中删除。等待事件对象的超时时间不应超过配置文件中提供的服务的 sendTimeout
。与此同时,上述在 FormSvc
对象中独立线程中运行的“一些有用的处理”会获得一个客户端应被通知的事件。为了通知客户端,由“有用的处理”设置给定客户端的等待事件对象,从而恢复 CometSvc.Notification()
方法的执行。需要设置的等待事件对象通过客户端 ID 从查找表中找到。
在 CometSvc.Notification()
方法执行恢复后,将调用委托 Comet.dlgtGetResponseString(clientId)
。此委托在 FormSvc
类中分配(在当前实现中,该委托是静态的,并在 FormSvc
类的静态构造函数中分配)。委托方法通过后续调用 Comet.GenerateResponseMessage()
方法生成 HTTP 响应消息的响应字符串。CometSvc.Notification()
将这样的响应发送回浏览器(图中的操作 4)并返回。当回调(分配给 XMLHttpRequest
对象 onreadystatechange
属性的函数)被调用时,HTTP 连接将被浏览器关闭。为了提供连续通知,回调会创建一个新的 XMLHttpRequest
对象,打开一个 HTTP 连接,并通过递归调用 doXhr()
JavaScript 函数发送一个 HTTP 请求。
示例
在代码示例中,通过使用具有随机超时时间的 AutoResetEvent
对象进行重复调用来模拟有用的处理。超时范围“包含”通知 Comet.timeout
的值。因此,在示例工作期间,在某些情况下,通知事件发生在 Comet.timeout
之前,并将通知发送到浏览器。在其他情况下,通知事件发生在 Comet.timeout
之后,一些无信息响应被发送到浏览器。通知消息(在本例中是服务器上的时间)显示在 HTML 表单的文本框中,而无信息响应则被简单地忽略。
该代码示例非常简单,代码量很少。可以通过运行 Host.exe 应用程序,然后将本地浏览器导航到 https://:8700/FormSvc URI 来本地测试(在这种情况下,在上述定义中,[host_URI] = localhost 且 [port] = 8700)。要从远程计算机测试示例,在配置文件 Host.exe.config 中,应将 localhost 替换为主机 IP 地址;应重新配置主机上的防火墙以允许到端口 8700 的入站呼叫;并且应将主机 IP 地址定义为客户端浏览器的受信任站点。
讨论
所提出技术的核心要素是 WCF 服务方法执行的挂起。这意味着挂起了相当多的服务线程。因此,在实现此方法时,我们必须记住,可用的 WCF 服务线程数量通常是有限的。可以通过配置服务节流参数来调整此数量。
在 CometSvcLib
的当前实现中,同步锁在几个地方使用。这主要是为了保护查找表,因为该表是从不同线程访问的。众所周知,为了性能,服务器上的线程同步应尽可能减少。因此,在客户端数量很多且性能至关重要的情况下,可以采用一些技巧来避免这种同步。例如,当最大客户端数量可以预先估计时,查找表可以实现为简单的数组,客户端 ID 可以作为数组相应元素的索引。在这种情况下,无需进行多线程访问同步。
如上所述,在示例中,超时持续时间被选择为使信息性通知和非信息性通知的比例在统计学上相等。在实际应用中,应尽量减少非信息性通知的数量。因此,超时时间应尽可能长,接近(但略小于)配置的 sendTimeout
参数。但是,如果超时时间非常长,则浏览器在超时期间关闭的可能性会增加,因此该客户端的进一步处理和通知挂起将变得不必要。因此,应为超时持续时间假定一个合理的折衷。
结论
演示了一种基于 RESTful WCF 的 Web 应用程序的服务器到客户端通知技术的简单实现。使用 RESTful WCF 可显著简化实现并减少所需代码量。
谢谢
我想对我的一位朋友和同事 Michael Molotsky 表示深深的感谢,感谢他在本文以及许多其他方面的宝贵建议和有益讨论。:)