ASP.NET

在 Microsoft .NET Framework 中构建简单 Comet 应用程序

Derrick Lau

下载代码示例

 

Comet 是一种无需显式请求即可使用长期 AJAX 连接将 Web 服务器的内容推送至浏览器的方法。 这种方法可实现交互性更强的用户体验,与由页面回发触发检索更多数据的典型服务器往返相比,使用的带宽更小。 尽管可用的 Comet 实现方式非常多,大多数还是基于 Java。 本文重点介绍基于 code.google.com/p/cometbox 提供的 cometbox 代码示例构建 C# 服务。

使用 HTML5 功能(如 WebSocket 和服务器端事件)实现相同行为是较新的方法,但是只有最新版浏览器才提供这些功能。 如果必须支持版本较旧的浏览器,Comet 是最适合的解决方案。 不过,浏览器必须通过实现 xmlHttpRequest 对象支持 AJAX;否则无法支持 Comet 样式通信。

高级体系结构

图 1 显示基本的 Comet 样式通信,图 2 是我的示例的体系结构。 Comet 使用浏览器的 xmlHttpRequest 对象建立到服务器的长期 HTTP 连接,该对象对 AJAX 通信至关重要。 服务器保持该连接打开,将可用内容推送至浏览器。

Comet-Style Communication
图 1 Comet 样式通信

Architecture of the Comet Application
图 2 Comet 应用程序的体系结构

浏览器和服务器之间是一个代理页面,它作为包含客户端代码的网页驻留在相应 Web 应用程序路径中,除了在浏览器和服务器之间转发消息外,不执行任何操作。 为何需要代理页面? 稍后我会对此进行说明。

首先,为在浏览器和服务器之间交换的消息选择格式 ― JSON、XML 或自定义格式。 为简单起见,我选择了 JSON,因为它在 JavaScript、jQuery 和 Microsoft .NET Framework 中自然地得到支持,较之以 XML 格式传输数量相同的数据,使用的字节更少,因而所用的带宽更小。

若要设置 Comet 样式通信,需要打开一个到服务器的 AJAX 连接。 为此,最方便的方法是使用 jQuery,因为它支持多种浏览器,并提供了一些非常好的包装函数,如 $.ajax。 这个函数实质上是用于每个浏览器的 xmlHttpRequest 对象的包装,它恰到好处地提供了一些可实现来处理来自服务器的传入消息的事件处理程序。

开始连接前,需要实例化要传输的消息。 为此,需要声明一个变量,并使用 JSON.stringify 将数据格式设置为 JSON 消息,如图 3 所示。

图 3 将数据的格式设置为 JSON 消息

function getResponse() {
  var currentDate = new Date();
  var sendMessage = JSON.stringify({
    SendTimestamp: currentDate,
    Message: "Message 1"
  });
  $.ajaxSetup({
    url: "CometProxy.aspx",
    type: "POST",
    async: true,
    global: true,
    timeout: 600000
  });

接下来,使用要连接到的 URL、要使用的 HTTP 通信方法、通信样式和连接超时参数初始化该函数。 JQuery 在一个名为 ajaxSetup 的库调用中提供此功能。 在本示例中,我将超时设置为 10 分钟,因为我只是要构建一个概念证明解决方案;您可以将超时设置更改为任何所需时间。

现在,以成功事件处理程序的定义作为唯一参数,使用 jQuery $.ajax 方法打开到服务器的连接:

$.ajax({
  success: function (msg) {
    // Alert("ajax.success().");
    if (msg == null || msg.Message == null) {
      getResponse();
      return;
    }

该处理程序对返回的消息对象进行测试,以确保消息在分析之前包含有效信息;这是必要的,因为如果返回错误代码,则 jQuery 会失败,并向用户显示未定义的消息。 出现空消息时,处理程序会再次递归调用该 AJAX 函数并返回;我发现,添加 return 会组织代码继续执行。 如果消息正常,则只需读取消息,将内容写到页面:

$("#_receivedMsgLabel").append(msg.Message + "<br/>");
getResponse();
return;
    }
  });

这创建了一个简单客户端,说明了 Comet 样式通信的工作原理,也提供了一种执行性能测试和可伸缩性测试的方法。 在我的示例中,我将 getResponse JavaScript 代码放在一个 Web 用户控件中,在代码隐藏文件中将其注册,以便在该控件加载到 ASP.NET 页面上时,AJAX 连接会立即打开:

public partial class JqueryJsonCometClientControl :
  System.Web.UI.UserControl
{
  protected void Page_Load(object sender, EventArgs e)
  {
    string getResponseScript =
      @"<script type=text/javascript>getResponse();</script>";
    Page.ClientScript.RegisterStartupScript(GetType(),
      "GetResponseKey", getResponseScript);
  }
}

服务器

我现在有了一个能够发送和接收消息的客户端,我将构建一个能够接收并响应消息的服务。

我曾尝试实现几种不同的 Comet 样式通信方法,包括使用 ASP.NET 页面和 HTTP 处理程序,但都没有成功。 我未能实现的是将单个消息广播至多个客户端。 很幸运,经过大量研究,我偶然碰到了 Cometbox 项目,发现这是最方便的方法。 我进行了一些修改补充,使它作为一项 Windows 服务运行,这样更易于使用;然后,我为它增加了保持长期连接并将内容推送至浏览器的功能。 (很遗憾,在这个过程中,我损失了一定的跨平台兼容性。) 最后,我添加了对 JSON 的支持以及我自己的 HTTP 内容消息类型。

首先,在 Visual Studio 解决方案中创建一个 Windows 服务项目,然后添加一个服务安装程序组件(有关说明,请参见 bit.ly/TrHQ8O),这样,就可以在控制面板中的“管理工具”的“服务”小程序中开启和关闭该服务。 然后,需要创建两个线程: 一个线程绑定到 TCP 端口,负责接收和发送消息;另一个线程阻止消息队列以确保仅在接收到消息时传送内容。

首先,必须创建一个在 TCP 端口上侦听新消息并传送响应的类。 现在,有几种可以实现的 Comet 通信样式,在实现中,有一个用于对这些样式进行抽象的 Server 类(请参见示例代码中的代码文件 Comet_Win_Service HTTP\Server.cs)。 但为简单起见,我将重点介绍如何实现通过 HTTP 执行 JSON 消息的基本接收功能以及在有要推送回的内容之前保持连接。

在 Server 类中,我将创建一些受保护成员以保存从 Server 对象进行访问所需的对象。 其中包括要绑定到 TCP 端口并对该端口进行侦听以实现 HTTP 连接的线程、某些信号以及客户端对象列表,每一项都代表与服务器间的一个连接。 _isListenerShutDown 十分重要,它是作为公共属性公开的,以便可以在服务 Stop 事件中进行修改。

接下来,在构造函数中,我针对该端口实例化 TCP 侦听器对象,对其进行设置以仅使用该端口,然后将其启动。 然后,我启动一个线程以接收并处理连接至 TCP 侦听器的客户端。

侦听客户端连接的线程包含一个 while 循环,此循环连续重置一个用于指示是否引发了服务 Stop 事件的标志(请参见图 4)。 将此循环的第一部分设置为可阻止所有侦听线程的互斥,以检查是否引发了服务 Stop 事件。 如果引发了该事件,则 _isListenerShutDown 属性为 true。 检查完成时,互斥解除,如果服务仍在运行,则调用 TcpListener.Accept­TcpClient,它返回一个 TcpClient 对象。 我还可以检查现有 TcpClient 以确保我没有添加现有客户端。 但是,根据所预期的客户端数量,可能需要用某个系统来替换此系统;在该系统中,服务生成一个唯一 ID 并将其发送至浏览器客户端,后者会记住该 ID,每次与服务器通信时会重新发送该 ID,以确保仅保持一个连接。 不过,这样做可能会在服务失败的情况下产生问题;它会将 ID 计数器重置,可能为新客户端提供已使用的 ID。

图 4 侦听客户端连接

private void Loop()
{
  try
  {
    while (true)
    {
      TcpClient client = null;
      bool isServerStopped = false;
      _listenerMutex.WaitOne();
      isServerStopped = _isListenerShutDown;
      _listenerMutex.ReleaseMutex();
      if (!isServerStopped)
      {
        client = listener.AcceptTcpClient();
      }
    else
    {
      continue;
    }
    Trace.WriteLineIf(_traceSwitch.TraceInfo, "TCP client accepted.",
      "COMET Server");
    bool addClientFlag = true;
    Client dc = new Client(client, this, authconfig, _currentClientId);
    _currentClientId++;
    foreach (Client currentClient in clients)
    {
      if (dc.TCPClient == currentClient.TCPClient)
      {
        lock (_lockObj)
        {
          addClientFlag = false;
        }
      }
    }
    if (addClientFlag)
    {
      lock (_lockObj)
      {
        clients.Add(dc);
      }
    }

最后,该线程检查客户端列表,移除不再处于活动状态的所有客户端。 为简单起见,我将这部分代码放在 TCP 侦听器接受客户端连接时所调用的方法中,但如果有成千上万个客户端,这可能会影响性能。 如果打算在面向公众的 Web 应用程序中使用此方法,建议添加一个经常触发的计时器并在其中执行清理。

在 Server 类循环方法中返回 TcpClient 对象时,该对象用于创建表示浏览器客户端的客户端对象。 因为每个客户端对象与服务器构造函数一样都是在唯一线程中创建的,所以客户端类构造函数必须在互斥时等待,以确保在继续执行之前客户端没有关闭。 之后,检查 TCP 流并开始读取,并启动一个在读取完成时执行的回调处理程序。 在该回调处理程序中,我只需读取字节并使用 ParseInput 方法对它们进行分析,这可在本文提供的示例代码中看到。

在 Client 类的 ParseInput 方法中,我用对应于典型 HTTP 消息的不同部分的成员构建了一个 Request 对象,并相应地填充这些成员。 首先,我通过搜索令牌字符(如“\r\n”)分析标头信息,根据 HTTP 标头的格式确定标头信息的各个部分。 然后,调用 ParseRequestContent 方法以获取 HTTP 消息的正文。 ParseInput 的第一步是确定所使用的 HTTP 通信方法和要将该请求发送到的目标 URL。 接下来,提取 HTTP 消息标头并将其存储在 Request 对象的 Headers 属性中,此属性是一个具有标头类型和值的字典。 再看一下可下载的示例代码,了解这是如何完成的。 最后,将请求的内容加载到 Request 对象的 Body 属性中,该属性只是一个包含内容的所有字节的字符串变量。 此时,还需要对内容进行分析。 结束时,如果从客户端接收的 HTTP 请求有任何问题,会发送相应的错误响应消息。

我将用于分析该 HTTP 请求的内容的方法进行了分离,这样,可以添加对不同消息类型(如纯文本、XML、JSON 等)的支持:

public void ParseRequestContent()
{
  if (String.IsNullOrEmpty(request.Body))
  {
    Trace.WriteLineIf(_traceSwitch.TraceVerbose,
      "No content in the body of the request!");
    return;
  }
  try
  {

首先将内容写入 MemoryStream,这样在必要时,可根据请求的内容类型将它们反序列化为对象类型,因为某些反序列化程序仅使用流:

MemoryStream mem = new MemoryStream();
mem.Write(System.Text.Encoding.ASCII.GetBytes(request.Body), 0,
  request.Body.Length);
mem.Seek(0, 0);
if (!request.Headers.ContainsKey("Content-Type"))
{
  _lastUpdate = DateTime.Now;
  _messageFormat = MessageFormat.json;
}
else
{

图 5 所示,我保留了处理 XML 格式消息的默认操作,因为 XML 仍是一种常用格式。

图 5 默认的 XML 消息处理程序

if (request.Headers["Content-Type"].Contains("xml"))
{
  Trace.WriteLineIf(_traceSwitch.TraceVerbose, 
    "Received XML content from client.");
  _messageFormat = MessageFormat.xml;
  #region Process HTTP message as XML
  try
  {
    // Picks up message from HTTP
    XmlSerializer s = new XmlSerializer(typeof(Derrick.Web.SIServer.SIRequest));
    // Loads message into object for processing
    Derrick.Web.SIServer.SIRequest data =
      (Derrick.Web.SIServer.SIRequest)s.Deserialize(mem);
  }
  catch (Exception ex)
  {
    Trace.WriteLineIf(_traceSwitch.TraceVerbose,
      "During parse of client XML request got this exception: " + 
        ex.ToString());
  }
  #endregion Process HTTP message as XML
}

但是,对于 Web 应用程序,强烈建议对 JSON 中的消息进行格式设置,因为与 XML 不同的是,它没有开始和取消标记的开销,并且它在 JavaScript 中得到本机支持。 我只是使用 HTTP 请求的 Content-Type 标头来指示是否在 JSON 中发送了消息,并使用 System.Web.Script.Serialization 命名空间 JavaScriptSerializer 类将内容进行反序列化。 使用此类,可以非常方便地将 JSON 消息反序列化为 C# 对象,如图 6 所示。

图 6 反序列化 JSON 消息

else if (request.Headers["Content-Type"].Contains("json"))
{
  Trace.WriteLineIf(_traceSwitch.TraceVerbose,
    "Received json content from client.");
  _messageFormat = MessageFormat.json;
  #region Process HTTP message as JSON
  try
  {
    JavaScriptSerializer jsonSerializer = new JavaScriptSerializer();
    ClientMessage3 clientMessage =
      jsonSerializer.Deserialize<ClientMessage3>(request.Body);
    _lastUpdate = clientMessage.SendTimestamp;
    Trace.WriteLineIf(_traceSwitch.TraceVerbose,
      "Received the following message: ");
    Trace.WriteLineIf(_traceSwitch.TraceVerbose, "SendTimestamp: " +
      clientMessage.SendTimestamp.ToString());
    Trace.WriteLineIf(_traceSwitch.TraceVerbose, "Browser: " +
      clientMessage.Browser);
    Trace.WriteLineIf(_traceSwitch.TraceVerbose, "Message: " +
      clientMessage.Message);
  }
  catch (Exception ex)
  {
    Trace.WriteLineIf(_traceSwitch.TraceVerbose,
      "Error deserializing JSON message: " + ex.ToString());
  }
  #endregion Process HTTP message as JSON
}

最后,为了进行测试,我添加了一个 ping 内容类型,它简单地以只包含“PING”一词的文本 HTTP 响应做出响应。 这样,可以方便地查看我的 Comet 服务器是否正在运行,方法是向其发送一个内容类型为“ping”的 JSON 消息,如图 7 所示。

图 7 内容类型“Ping”

else if (request.Headers["Content-Type"].Contains("ping"))
{
  string msg = request.Body;
  Trace.WriteLineIf(_traceSwitch.TraceVerbose, "Ping received.");
  if (msg.Equals("PING"))
  {
    SendMessageEventArgs args = new SendMessageEventArgs();
    args.Client = this;
    args.Message = "PING";
    args.Request = request;
    args.Timestamp = DateTime.Now;
    SendResponse(args);
  }
}

ParseRequestContent 最终就是一个字符串分析方法 ― 功能就是这样,不多不少。 可以看到,涉及更多的是分析 XML 数据,因为必须先将内容写入 Memory­Stream,再使用 XmlSerializer 类将其反序列化为一个已创建的类,以表示来自客户端的消息。

为了更好地组织源代码,我创建一个 Request 类(如图 8 所示),该类仅包含用于保存在 HTTP 请求中发送的标头和其他信息的成员,使其在服务中易于访问。 如果需要,可以添加帮助程序方法来确定请求是否包含内容以及身份验证检查等。 不过,为了保持服务简单和易于实现,我在这里没有这样做。

图 8 Request 类

public class Request
{
  public string Method;
  public string Url;
  public string Version;
  public string Body;
  public int ContentLength;
  public Dictionary<string, string> Headers = 
    new Dictionary<string, string>();
  public bool HasContent()
  {
    if (Headers.ContainsKey("Content-Length"))
    {
      ContentLength = int.Parse(Headers["Content-Length"]);
      return true;
    }
    return false;
  }

与 Request 类一样,Response 类包含用于存储 HTTP 响应信息的方法,以便 C# Windows 服务进行访问。 在 SendResponse 方法中,我添加了根据跨来源资源共享 (CORS) 的需要附加自定义 HTTP 标头的逻辑,并从一个配置文件加载这些标头以便对它们进行修改。 Response 类还包含用于输出某些常见 HTTP 状态(如 200、401、404、405 和 500)的消息的方法。

Response 类的 SendResponse 成员只是向仍应处于活动状态的 HTTP 响应流写入消息,因为客户端设置的超时较长(10 分钟):

public void SendResponse(NetworkStream stream, Client client)
{

图 9 所示,为了符合 CORS 的 W3C 规范,HTTP 响应添加了相应的标头。 为简单起见,是从配置文件读取标头,这样可方便地修改标头内容。

现在添加常规 HTTP 响应标头和内容,如图 10 所示。

图 9 添加 CORS 标头

if (client.Request.Headers.ContainsKey("Origin"))
{
  AddHeader("Access-Control-Allow-Origin", client.Request.Headers["Origin"]);
  Trace.WriteLineIf(_traceSwitch.TraceVerbose,
    "Access-Control-Allow-Origin from client: " +
    client.Request.Headers["Origin"]);
}
else
{
  AddHeader("Access-Control-Allow-Origin",
    ConfigurationManager.AppSettings["RequestOriginUrl"]);
  Trace.WriteLineIf(_traceSwitch.TraceVerbose,
    "Access-Control-Allow-Origin from config: " +
    ConfigurationManager.AppSettings["RequestOriginUrl"]);
}
AddHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS");
AddHeader("Access-Control-Max-Age", "1000");
// AddHeader("Access-Control-Allow-Headers", "Content-Type");
string allowHeaders = ConfigurationManager.AppSettings["AllowHeaders"];
// AddHeader("Access-Control-Allow-Headers", "Content-Type, x-requested-with");
AddHeader("Access-Control-Allow-Headers", allowHeaders);
StringBuilder r = new StringBuilder();

图 10 添加常规 HTTP 响应标头

r.Append("HTTP/1.1 " + GetStatusString(Status) + "\r\n");
r.Append("Server: Derrick Comet\r\n");
r.Append("Date: " + DateTime.Now.ToUniversalTime().ToString(
  "ddd, dd MMM yyyy HH':'mm':'ss 'GMT'") + "\r\n");
r.Append("Accept-Ranges: none\r\n");
foreach (KeyValuePair<string, string> header in Headers)
{
  r.Append(header.Key + ": " + header.Value + "\r\n");
}
if (File != null)
{
  r.Append("Content-Type: " + Mime + "\r\n");
  r.Append("Content-Length: " + File.Length + "\r\n");
}
else if (Body.Length > 0)
{
  r.Append("Content-Type: " + Mime + "\r\n");
  r.Append("Content-Length: " + Body.Length + "\r\n");
}
r.Append("\r\n");

在这里,构建为字符串的整个 HTTP 响应消息写入作为参数传递给 SendResponse 方法的 HTTP 响应流:

byte[] htext = Encoding.ASCII.GetBytes(r.ToString());
stream.Write(htext, 0, htext.Length);

传送消息

用于传送消息的线程实质上是一个由 Microsoft 消息队列阻止的 While 循环。 它有一个在该线程从队列获取到消息时会引发的 SendMessage 事件。 该事件由服务器对象中基本上负责调用每个客户端的 SendResponse 方法的一个方法进行处理,从而将消息广播至与该客户端相连的每个浏览器。

该线程在相应的消息队列上等待,直到队列中放入了消息,表明服务器有一些它希望广播至客户端的内容:

Message msg = _intranetBannerQueue.Receive(); 
// Holds thread until message received
Trace.WriteLineIf(_traceSwitch.TraceInfo,
  "Message retrieved from the message queue.");
SendMessageEventArgs args = new SendMessageEventArgs();
args.Timestamp = DateTime.Now.ToUniversalTime();

消息接收后,转换为所需的对象类型:

msg.Formatter = new XmlMessageFormatter(new Type[] { typeof(string) });
string cometMsg = msg.Body.ToString();
args.Message = cometMsg;

在确定要发送至客户端的内容后,在服务器上引发了一个 Windows 事件,指示存在要广播的消息:

if (SendMessageEvent != null)
{
  SendMessageEvent(this, args);
  Trace.WriteLineIf(_traceSwitch.TraceVerbose,
    "Message loop raised SendMessage event.");
}

接下来,需要一个用来构建实际 HTTP 响应正文(服务器将向所有客户端广播的消息内容)的方法。 之前的消息获取转储到 Microsoft 消息队列中的消息内容,将其设置为 JSON 对象格式,以便通过 HTTP 响应消息传输至客户端,如图 11 所示。

图 11 构建 HTTP 响应正文

public void SendResponse(SendMessageEventArgs args)
{
  Trace.WriteLineIf(_traceSwitch.TraceVerbose,
    "Client.SendResponse(args) called...");
  if (args == null || args.Timestamp == null)
  {
    return;
  }
  if (_lastUpdate > args.Timestamp)
  {
    return;
  }
  bool errorInSendResponse = false;
  JavaScriptSerializer jsonSerializer = null;

接下来,需要实例化 JavaScriptSerializer 对象的一个实例,以便将消息内容转为 JSON 格式。 添加以下 try/catch 错误处理,因为有时在实例化 avaScriptSerializer 对象的实例时可能会存在困难:

try
{
  jsonSerializer = new JavaScriptSerializer();
}
catch (Exception ex)
{
  errorInSendResponse = true;
  Trace.WriteLine("Cannot instantiate JSON serializer: " + 
    ex.ToString());
}

然后,创建一个字符串变量用于保存 JSON 格式的消息,并创建一个 Response 类的实例用于发送 JSON 消息。

我立即执行一些基本错误检查以确保使用有效的 HTTP 请求。 因为此 Comet 服务为每个 TCP 客户端以及服务器对象都生成一个线程,我认为最安全的方法是经常进行这些安全检查,这会使调试更加容易。

在验证是有效请求之后,创建一条要发送到 HTTP 响应流的 JSON 消息。 请注意,我只是创建 JSON 消息,将它序列化,并用它来创建 HTML 响应消息:

if (request.HasContent())
{
  if (_messageFormat == MessageFormat.json)
  {
    ClientMessage3 jsonObjectToSend = new ClientMessage3();
    jsonObjectToSend.SendTimestamp = args.Timestamp;
    jsonObjectToSend.Message = args.Message;
    jsonMessageToSend = jsonSerializer.Serialize(jsonObjectToSend);
    response = Response.GetHtmlResponse(jsonMessageToSend,
      args.Timestamp, _messageFormat);
    response.SendResponse(stream, this);
  }

为了将所有内容挂接在一起,我首先在服务 Start 事件期间创建消息循环对象和服务器循环对象的实例。 请注意,这些对象应该是该服务类的受保护成员,这样可以在其他服务事件期间调用它们的方法。 现在,消息循环发送消息事件应由服务器对象 BroadcastMessage 方法处理:

public override void BroadcastMessage(Object sender, 
  SendMessageEventArgs args)
{
  // Throw new NotImplementedException();
  Trace.WriteLineIf(_traceSwitch.TraceVerbose,
    "Broadcasting message [" + args.Message + "] to all clients.");
  int numOfClients = clients.Count;
  for (int i = 0; i < numOfClients; i++)
  {
    clients[i].SendResponse(args);
  }
}

BroadcastMessage 将同一消息发送至所有客户端。 如果需要,可以将它修改为只将消息发送至所需的客户端;举例来说,通过这种方式,可以使用此服务处理多个在线聊天室。

服务停止时,调用 OnStop 方法。 该方法随后调用服务器对象的 Shutdown 方法,用于检查仍有效的客户端对象的列表,然后关闭这些对象。

现在,我有了一个比较像样的 Comet 服务,我可以使用 installutil 命令,从命令提示符将该服务安装到服务小程序中(有关详细信息,请参阅 bit.ly/OtQCB7)。 因为已将服务安装程序组件添加到服务项目中,您还可以创建自己的 Windows 安装程序以进行部署。

为什么不能正常工作? 问题在于 CORS

现在,请尝试在浏览器客户端的 $.ajax 调用中设置 URL 以指向 Comet 服务 URL。 启动 Comet 服务,在 Firefox 中打开浏览器客户端。 确保在 Firefox 浏览器中安装了 Firebug 扩展。 启动 Firebug 并刷新页面;可以看到控制台输出区域中有“拒绝访问”错误。这是 CORS 的缘故,因为出于安全原因,JavaScript 无法访问该 Web 应用程序外部的资源及其客户端页面所在的虚拟目录。 例如,如果浏览器客户端页面位于 http://www.somedomain.com/somedir1/somedir2/client.aspx,则对该页面进行的任何 AJAX 调用都只能转到同一虚拟目录或其子目录中的资源。 如果要调用该 Web 应用程序内的其他页面或 HTTP 处理程序,这种方法很好;但是如果不希望在将同一消息发送至所有客户端时使页面和处理程序在消息队列上受阻,则需要使用 Windows Comet 服务,并且需要一种不受 CORS 限制的解决方法。

为此,我建议在同一虚拟目录中构建一个代理页面,其作用只是解释来自浏览器客户端的 HTTP 消息,提取所有相关标头和内容,并构建另一个连接至 Comet 服务的 HTTP 请求对象。 因为此连接是在服务器上进行的,所以它不受 CORS 的影响。 这样,您可以通过代理在浏览器客户端和 Comet 服务之间保持长期连接。 并且,您现在还可以在一条消息到达消息队列时将其同时发送至所有连接的浏览器客户端。

首先,获取 HTTP 请求,将其流式处理到字节数组中,这样可以将它传递给一个新的 HTTP 请求对象,该对象将很快进行实例化:

byte[] bytes;
using (Stream reader = Request.GetBufferlessInputStream())
{
  bytes = new byte[reader.Length];
  reader.Read(bytes, 0, (int)reader.Length);
}

接下来,创建一个新的 HttpWebRequest 对象,使它指向 Comet 服务器,我已将该服务器的 URL 置于 web.config 文件中,以便于以后修改:

string newUrl = ConfigurationManager.AppSettings["CometServer"];
HttpWebRequest cometRequest = (HttpWebRequest)HttpWebRequest.Create(newUrl);

这会为每个用户创建与 Comet 服务器的连接,但是,因为将同一消息广播至每个用户,所以只能将 cometRequest 对象封装在一个双重锁定单一实例中,以减小 Comet 服务器上的连接负载,并让 IIS 来执行连接负载平衡。

然后,使用从 jQuery 客户端接收的值填充 HttpWebRequest 标头,尤其是将 KeepAlive 属性设置为 true,这样可以保持长期 HTTP 连接,这是 Comet 样式通信所依赖的基本方法。

在这里,检查在处理与 CORS 相关的问题时 W3C 规范要求使用的 Origin 标头:

for (int i = 0; i < Request.Headers.Count; i++)
{
  if (Request.Headers.GetKey(i).Equals("Origin"))
  {
    containsOriginHeader = true;
    break;
  }
}

然后将 Origin 标头传递给 HttpWebRequest,以便 Comet 服务器将其接收:

if (containsOriginHeader)
{
  // cometRequest.Headers["Origin"] = Request.Headers["Origin"];
  cometRequest.Headers.Set("Origin", Request.Headers["Origin"]);
}
else
{
  cometRequest.Headers.Add("Origin", Request.Url.AbsoluteUri);
}
System.Diagnostics.Trace.WriteLineIf(_proxySwitch.TraceVerbose,
  "Adding Origin header.");

接下来,从 jQuery 客户端获取 HTTP 请求内容中的字节,将它们写入将发送至 Comet 服务器的 HttpWebRequest 请求流,如图 12 所示。

图 12 写入 HttpWebRequest 流

Stream stream = null;
if (cometRequest.ContentLength > 0 && 
  !cometRequest.Method.Equals("OPTIONS"))
{
  stream = cometRequest.GetRequestStream();
  stream.Write(bytes, 0, bytes.Length);
}
if (stream != null)
{
  stream.Close();
}
// Console.WriteLine(System.Text.Encoding.ASCII.GetString(bytes));
System.Diagnostics.Trace.WriteLineIf(_proxySwitch.TraceVerbose, 
  "Forwarding message: " 
  + System.Text.Encoding.ASCII.GetString(bytes));

将消息转发给 Comet 服务器之后,调用 HttpWebRequest 对象的 GetResponse 方法,该方法提供一个 HttpWebResponse 对象,可用来处理服务器的响应。 另外添加随消息发送回客户端所需的 HTTP 标头:

try
{
  Response.ClearHeaders();
  HttpWebResponse res = (HttpWebResponse)cometRequest.GetResponse();
  for (int i = 0; i < res.Headers.Count; i++)
  {
    string headerName = res.Headers.GetKey(i);
    // Response.Headers.Set(headerName, res.Headers[headerName]);
    Response.AddHeader(headerName, res.Headers[headerName]);
  }
  System.Diagnostics.Trace.WriteLineIf(_proxySwitch.TraceVerbose,
    "Added headers.");

然后,等待服务器的响应:

Stream s = res.GetResponseStream();

接收到 Comet 服务器的消息时,将其写入原始 HTTP 请求的响应流,以便客户端接收,如图 13 所示。

图 13 将服务器消息写入 HTTP 响应流

string msgSizeStr = ConfigurationManager.AppSettings["MessageSize"];
int messageSize = Convert.ToInt32(msgSizeStr);
byte[] read = new byte[messageSize];
// Reads 256 characters at a time
int count = s.Read(read, 0, messageSize);
while (count > 0)
{
  // Dumps the 256 characters on a string and displays the string to the console
  byte[] actualBytes = new byte[count];
  Array.Copy(read, actualBytes, count);
  string cometResponseStream = Encoding.ASCII.GetString(actualBytes);
  Response.Write(cometResponseStream);
  count = s.Read(read, 0, messageSize);
}
Response.End();
System.Diagnostics.Trace.WriteLineIf(_proxySwitch.TraceVerbose, 
  "Sent Message.");
s.Close();
}

测试应用程序

若要测试应用程序,请创建一个网站来存储示例应用程序页面。 确保 Windows 服务的 URL 正确,并且消息队列已正确配置,可以使用。 启动服务,在一个浏览器中打开 Comet 客户端页面,在另一个浏览器中打开要发送消息的页面。 键入一条消息并按发送按钮;大约 10 毫秒后,另一个浏览器窗口中可以看到这条消息。 对各种浏览器尝试此操作,尤其是对一些旧版本浏览器。 只要浏览器支持 xmlHttpRequest 对象,就应该可以正常工作。 这实现了几乎实时的 Web 行为 (en.wikipedia.org/wiki/Real-time_web),用户无需操作,几乎是瞬时将内容推送至浏览器。

在部署任何新应用程序之前,必须执行性能和负载测试。 为此,应首先确定要收集的指标。 我建议根据响应时间和数据传输大小来测量使用负载。 另外,还应测试与 Comet 有关的使用方案,尤其应对将单条消息广播至多个客户端而不回发的情况进行测试。

为了执行测试,我构造了一个实用工具,它打开多个线程(每个线程都连接至 Comet 服务器),并等待服务器触发响应。 通过这个测试实用工具,可以设置一些参数,例如,将连接到 Comet 服务器的用户总数以及用户重新打开连接的次数(目前,在发送服务器响应之后,连接会关闭)。

然后,我创建了一个用于将含有 x 个字节的消息转储到消息队列的实用工具(字节数通过主屏幕上的一个文本字段设置)以及一个用于设置从服务器发送的两条消息之间所需等待的毫秒数的文本字段。 我将使用此实用工具将测试消息发送回客户端。 然后,我启动测试客户端,指定用户数以及客户端重新打开 Comet 连接的次数,线程针对我的服务器打开连接。 等待几秒钟以便打开所有连接,然后转到用于发送消息的实用工具,提交了一定数量的字节。 我以不同的总用户数、总重复次数和消息大小重复这一过程。

我的首次数据采样是针对单个重复次数不断增加、但响应消息的大小(较小)在整个测试过程中保持一致的用户。 在图 14 中可以看到,重复次数对系统性能或可靠性没有什么影响。

图 14 改变用户数

用户 重复次数 消息大小(字节) 响应时间(毫秒)
1,000 10 512 2.56
5,000 10 512 4.404
10,000 10 512 18.406
15,000 10 512 26.368
20,000 10 512 36.612
25,000 10 512 48.674
30,000 10 512 64.016
35,000 10 512 79.972
40,000 10 512 99.49
45,000 10 512 122.777
50,000 10 512 137.434

时间以线性/恒定方式逐渐增加,这意味着 Comet 服务器上的代码大体是可靠的。 图 15 是用户数与 512 字节消息响应时间的关系曲线。 图 16 显示大小为 1,024 字节的消息的一些统计数据。 最后,图 17 以图形方式显示图 16 中的表格数据。所有这些测试都是在一台带有 8GB RAM 和 2.4 GHz Intel Core i3 CPU 的便携式计算机上进行的。

Response Times for Varying Numbers of Users for a 512-Byte Message
图 15 处理 512 字节消息时不同用户数的响应时间

图 16 对 1,024 字节的消息大小的测试

用户 重复次数 响应时间(毫秒)
1,000 10 144.227
5,000 10 169.648
10,000 10 233.031
15,000 10 272.919
20,000 10 279.701
25,000 10 220.209
30,000 10 271.799
35,000 10 230.114
40,000 10 381.29
45,000 10 344.129
50,000 10 342.452

User Load vs Response Time for a 1KB Message
图 17 1KB 消息的用户负载与响应时间

这些数字没有显示任何特别趋势,但响应时间是合理的,对于高达 1KB 的消息大小,都保持在 1 秒以下。 我没有对带宽使用情况进行跟踪,因为带宽的使用受消息格式影响。 另外,所有测试都是在一台计算机上进行的,因此消除了网络延迟这一因素的影响。 我没有尝试在家庭网络中进行测试,不过我认为那不值得,因为与家中的无线路由器和有线调制解调器设置相比,公共 Internet 要复杂得多。 但是,因为 Comet 通信方法的要点是,在内容更新时从服务器推送内容,从而减少服务器往返流量,因此,通过 Comet 方法在理论上可以少用一半网络带宽。

总结

我希望您现在已能够成功实现自己的 Comet 样式应用程序并有效加以利用,从而降低网络带宽使用和提高网站应用程序性能。 当然,您需要查阅 HTML5 附带的、可以取代 Comet 的新技术,例如,WebSocket (bit.ly/UVMcBg) 和服务器发送事件 (SSE) (bit.ly/UVMhoD)。 这些技术有望提供一种更简单的向浏览器推送内容的方法,但是它们要求用户使用支持 HTML5 的浏览器。 如果您仍必须为使用旧版浏览器的用户提供支持,Comet 样式通信仍是最佳选择。

Derrick Lau 是一位经验丰富的软件开发团队主管,拥有大约 15 年相关经验。 他曾在金融企业和政府机构的 IT 部门工作,也曾在技术型公司的软件开发部门任职。 他在 2010 年的 EMC 开发竞赛中获得大奖,并在 2011 年入围最后的决赛。他还通过了 MCSD 和 EMC 内容管理开发人员认证。

衷心感谢以下技术专家对本文的审阅: Francis Cheung