本文章是由機器翻譯。

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 擴展。 Start Firebug and refresh the page; you’ll notice you get an error in the console output area stating “Access denied.” This is due to CORS, where for security reasons, JavaScript can’t access resources outside the same Web application and virtual directory its housing page resides in. 例如,如果瀏覽器用戶端頁面位於 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