Comet — это методика передачи контента с веб-сервера браузеру без явного запроса с использованием долгоживущих AJAX-соединений. Она обеспечивает более высокий уровень интерактивности и занимает меньшую полосу пропускания, чем типичный обмен данными с сервером, инициируемый обратной передачей страницы (page postback) для получения дополнительных данных. Хотя существует множество реализаций Comet, большинство из них основано на Java. В этой статье я сосредоточусь на создании сервиса на C#, опираясь на образец кода cometbox, доступный по ссылке code.google.com/p/cometbox.
Есть и более новые способы реализации того же поведения с применением таких средств HTML5, как WebSockets и события на серверной стороне, но они доступны лишь новейшим версиям браузеров. Если вам нужно поддерживать более старые версии браузеров, Comet — самое совместимое решение. Однако браузер должен поддерживать AJAX, реализуя объект xmlHttpRequest; иначе он не сможет поддерживать взаимодействие в стиле Comet.
Высокоуровневая архитектура
На рис. 1 показано базовое взаимодействие в стиле Comet, а рис. 2 представляет архитектуру моего примера. Comet использует объект xmlHttpRequest браузера, значимый для AJAX-взаимодействия, чтобы установить долгоживущее HTTP-соединение с сервером. Сервер поддерживает это соединение открытым и передает контент браузеру, как только этот контент становится доступным.
Рис. 1. Взаимодействие в стиле Comet
Browser | Браузер |
Proxy | Прокси |
Comet Server | Comet-сервер |
AJAX Request #1 | AJAX-запрос 1 |
Comet Response #1 | Comet-ответ 1 |
Long-Lived HTTP Request Held | Долгоживущий HTTP-запрос |
Message to Broadcast Received | Прием широковещательного сообщения |
Рис. 2. Архитектура Comet-приложения
Web Browser | Веб-браузер |
ASP.NET Page | Страница ASP.NET |
Web User Control | Пользовательский веб-элемент управления |
$.ajax (xmlHttpRequest) | $.ajax (xmlHttpRequest) |
$.ajax success handler | Обработчик успешного выполнения $.ajax |
ASP.NET Proxy | Прокси ASP.NET |
Windows Service | Windows-служба |
TCP Listener (Holds onto Connection) | TCP-слушатель (удерживает соединение открытым) |
TCP Network Stream | Сетевой поток по TCP |
Message Queue | Очередь сообщений |
Между браузером и сервером находится страница прокси, которая размещается по тому же пути веб-приложения, что и веб-страница, содержащая клиентский код, и она ничего не делает, кроме пересылки сообщений от браузера серверу и наоборот. Зачем нужна страница прокси? Я поясню это чуть позже.
Первый шаг — выбор формата сообщений, которыми обмениваются браузер и сервер: 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. В этом примере я задал тайм-аут равным десяти минутам, так как я создаю решение лишь для проверки концепции; вы можете изменить время ожидания (тайм-аут) на любое другое значение.
Теперь откройте соединение с сервером, используя 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, а также предоставляющий средства для выполнения тестов на производительность и масштабируемость. Для своего примера я поместил JavaScript-код getResponse в пользовательский веб-элемент управления (Web user control) и зарегистрировал его в отделенном коде (codebehind), чтобы AJAX-соединение открывалось сразу после загрузки этого элемента в страницу ASP.NET:
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-сообщений с контентом.
Чтобы приступить к работе, создайте проект Windows-службы в своем решении Visual Studio и добавьте компонент установки службы (инструкции см. по ссылке bit.ly/TrHQ8O), чтобы иметь возможность включать и выключать эту службу в Control Panel | Administrative Tools | Services (Панель управления | Администрирование | Службы). После этого вы должны создать два потока: один из них будет выполнять привязку к TCP-порту и осуществлять прием и передачу сообщений, а другой — блокироваться на очереди сообщений, чтобы гарантировать, что контент передается, только когда сообщение принято.
Сначала вы должны создать класс, который прослушивает данный TCP-порт, принимая новые сообщения и передавая ответы. Теперь можно реализовать несколько стилей Comet-взаимодействия, и в реализации должен быть класс Server (см. файл кода Comet_Win_Service HTTP\Server.cs в пакете исходного кода), который абстрагирует все это. Однако простоты ради я сосредоточусь на том, что минимально требуется для приема JSON-сообщения поверх HTTP и для удержания соединения до появления контента, который может быть передан клиенту.
В классе Server я создам ряд защищенных членов, хранящих объекты, к которым мне понадобится доступ из объекта Server. К ним относятся поток, который будет выполнять привязку к TCP-порту и прослушивать его HTTP-соединения, некоторые семафоры и список клиентских объектов, каждый из которых будет представлять одно соединение с сервером. Очень важен член _isListenerShutDown, который будет предоставляться как открытое свойство, поэтому его содержимое можно будет модифицировать в событии Stop сервиса.
Затем в конструкторе я буду создавать экземпляр объекта TCP-слушателя, настраивать его на монопольное использование порта, а потом запускать. Далее я запущу поток для приема и обработки клиентов, подключающихся к TCP-слушателю.
Поток, слушающий клиентские соединения, содержит цикл while, постоянно сбрасывающий флаг, который указывает, было ли сгенерировано событие Stop сервиса (рис. 4). Я присваиваю первую часть этого цикла мьютексу для блокирования всех слушающих потоков, проверяя, было ли сгенерировано событие Stop. Если да, свойство _isListenerShutDown устанавливается в true. Когда проверка завершается, мьютекс освобождается, и, если сервис все еще выполняется, я вызываю TcpListener.AcceptTcpClient, который возвращает объект TcpClient. Дополнительно я проверяю существующие TcpClient, чтобы убедиться, что я не добавляю существующий клиент. Однако в зависимости от количества ожидаемых клиентов вы, возможно, предпочтете заменить это системой, где сервис генерирует уникальный идентификатор и передает его клиенту браузера, который запоминает этот идентификатор и заново отправляет его всякий раз, когда он взаимодействует с сервером; тем самым вы будете уверены, что клиент удерживает только одно соединение. Но это может стать проблематичным, если сервис потерпит неудачу; это приведет к сбросу счетчика идентификаторов, и новые клиенты могут получать уже задействованные идентификаторы.
Рис. 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-слушатель принимает клиентское соединение, но это может отрицательно повлиять на производительность, если количество клиентов выйдет на уровень сотен тысяч. Если вы намерены использовать это в общедоступных веб-приложениях, предлагаю добавить таймер, периодически срабатывающий и выполняющий необходимую очистку.
Когда объект TcpClient возвращается методом Loop класса Server, на его основе создается клиентский объект, который представляет клиент браузера. Поскольку каждый клиентский объект создается в уникальном потоке, конструктор клиентского класса, как и серверный конструктор, должен ожидать на мьютексе, чтобы перед продолжением работы быть уверенным в том, что клиент не был закрыт. Впоследствии я проверяю TCP-поток, начинаю его чтение и инициирую обработчик обратного вызова для разового выполнения по окончании чтения. В этом обработчике я просто читаю байты и разбираю их с помощью метода ParseInput, который вы сможете увидеть в пакете примеров кода, сопутствующем этой статье.
В методе ParseInput класса Client я создаю объект Request с членами, которые соответствуют различным частям типичного HTTP-сообщения, и заполняю их. Сначала я разбираю заголовочную информацию поиском символов лексем, таких как «\r\n», определяя блоки заголовочной информации по формату HTTP-заголовка. Затем вызываю метод ParseRequestContent, чтобы получить тело HTTP-сообщения. Первая операция в ParseInput — определение используемого метода HTTP-взаимодействия и URL, по которому был отправлен запрос. Далее извлекаются HTTP-заголовки сообщения и сохраняются в свойстве Headers объекта Request, которое является словарем типов и значений заголовков. И вновь смотрите пакет скачиваемого кода, чтобы увидеть, как это делается. Наконец, я загружаю содержимое запроса в свойство Body объекта Request, которое является просто строковой переменной, содержащей все байты контента. К этому моменту контент еще предстоит разобрать. На конечном этапе, если есть какие-нибудь проблемы с 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, чтобы при необходимости его можно было десериализовать в объекты различных типов в зависимости от Content-Type запроса, так как некоторые десериализаторы работают только с потоками:
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
{
// Извлекаем сообщение из HTTP
XmlSerializer s = new XmlSerializer(typeof( Derrick.Web.SIServer.SIRequest));
// Загружаем сообщение в объект для обработки
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
}
Однако для веб-приложений я настоятельно рекомендую форматировать сообщения в JSON, так как в отличие от XML у него нет издержек, связанных с некоторыми тегами, и он изначально поддерживается JavaScript. Я просто использую заголовок Content-Type в HTTP-запросе, указывая, было ли сообщение отправлено в формате JSON, и десериализую его содержимое с помощью класса JavaScriptSerializer из пространства имен System.Web.Script.Serialization. Этот класс значительно упрощает десериализацию 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
}
Наконец, для целей тестирования я добавил Content-Type «ping», который просто отвечает текстовым HTTP-сообщением, содержащим единственное слово «PING». Тем самым я могу легко проверить, выполняется ли мой Comet-сервер, отослав ему JSON-сообщение с Content-Type «ping», как показано на рис. 7.
Рис. 7. Content-Type «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-данных требует немного больше усилий, поскольку контент нужно сначала записывать в MemoryStream, а затем десериализовать с помощью класса 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;
}
Класс Response, как и класс Request, содержит методы для сохранения информации HTTP-ответа в форме, легко доступной Windows-службе, написанной на C#. В метод SendResponse я добавил логику для подключения собственных HTTP-заголовков, как это требуется для совместного использования ресурсов разных источников (cross-origin resource sharing, CORS), и загрузил эти заголовки из конфигурационного файла, чтобы их было проще модифицировать. В классе Response также есть методы для вывода сообщений, соответствующих некоторым распространенным кодам состояния HTTP, например 200, 401, 404, 405 и 500.
Член SendResponse класса Response просто пишет сообщение в поток HTTP-ответа, который должен быть по-прежнему активен, так как тайм-аут, заданный клиентом, довольно велик (10 минут):
public void SendResponse(NetworkStream stream, Client client)
{
...
Как показано на рис. 9, в HTTP-ответ добавляются заголовки, соответствующие спецификации W3C для CORS. Простоты ради эти заголовки считываются из конфигурационного файла, чтобы содержимое заголовков можно было легко модифицировать.
Рис. 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();
Далее я добавляю обычные заголовки и контент в HTTP-ответ (рис. 10).
Рис. 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");
Здесь все сообщение, сформированное как объект типа String, записывается в поток HTTP-ответа, переданный в качестве параметра методу SendResponse:
byte[] htext = Encoding.ASCII.GetBytes(r.ToString());
stream.Write(htext, 0, htext.Length);
Передача сообщений
Поток, передающий сообщения, по сути, является не чем иным, как циклом While, который блокируется на очереди сообщений — Microsoft Message Queue (MSMQ). В нем генерируется событие SendMessage, когда нужно извлечь сообщение из очереди. Это событие обрабатывается методом в серверном объекте, который в основном вызывает метод SendResponse каждого клиента, тем самым осуществляя широковещательную доставку сообщения каждому браузеру, подключенному к нему.
Поток ожидает на подходящей очереди сообщений, пока в ней не появится какое-либо сообщение, а это означает, что у сервера появился какой-то контент, который он широковещательно отправляет клиентам:
Message msg = _intranetBannerQueue.Receive();
// Блокируем поток, пока не будет принято сообщение
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-ответа — содержимое сообщения, которое сервер будет широковещательно рассылать всем клиентам. В предыдущем примере содержимое сообщения помещалось в MSMQ и форматировалось как 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, так как иногда возникают трудности в создании экземпляра объекта JavaScriptSerializer:
try
{
jsonSerializer = new JavaScriptSerializer();
}
catch (Exception ex)
{
errorInSendResponse = true;
Trace.WriteLine("Cannot instantiate JSON serializer: " +
ex.ToString());
}
Потом я создаю строковую переменную для хранения сообщения в формате JSON и экземпляр класса Response для отправки JSON-сообщения.
Я сразу же выполняю базовую проверку на ошибки, чтобы убедиться, что работаю с допустимым HTTP-запросом. Поскольку этот Comet-сервис порождает поток для каждого TCP-клиента, а также для серверных объектов, надежнее было включить эти периодические проверки, чтобы упростить отладку.
Удостоверившись в допустимости запроса, я помещаю JSON-сообщение в поток HTTP-ответа. Заметьте, что я просто создаю 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);
}
Чтобы соединить все воедино, я сначала создаю экземпляры объекта цикла обработки сообщений (message loop object) и объекта серверного цикла (server loop object) в событии Start сервиса. Заметьте, что эти объекты должны быть защищенными членами класса сервиса, чтобы их методы можно было вызывать при других событиях сервиса. Теперь событие отправки сообщения цикла (message loop send message event) должно обрабатываться методом BroadcastMessage серверного объекта:
public override void BroadcastMessage(Object sender,
SendMessageEventArgs args)
{
// Генерируем новый 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-сервис, который можно установить в апплет Services из командной строки, введя команду installutil (подробнее на эту тему см. по ссылке bit.ly/OtQCB7). Вы могли бы также создать собственный Windows-установщик для развертывания этого сервиса, так как вы уже добавили компоненты установщика сервиса в проект.
Почему это не работает? Проблема с CORS
Теперь попробуйте задать URL в вызове $.ajax клиента браузера, чтобы указать на URL Comet-сервиса. Запустите Comet-сервис и откройте клиент браузера в Firefox. Убедитесь, что браузере Firefox установлено расширение Firebug. Запустите Firebug и обновите страницу; вы увидите в консоли сообщение «Access denied». Это связано с CORS: по соображениям безопасности JavaScript не может обращаться к ресурсам вне того же веб-приложения и виртуального каталога, в котором размещена его страница (housing page). Например, если страница клиента вашего браузера находится в http://www.somedomain.com/somedir1/somedir2/client.aspx, то любой AJAX-вызов этой страницы может быть адресован только ресурсам в том же виртуальном каталоге или подкаталоге. Это отлично, если вы вызываете другую страницу или HTTP-обработчик в рамках веб-приложения, но вы не захотите, чтобы страницы и обработчики блокировались на очереди сообщений при передаче одного и того же сообщения всем клиентам; поэтому нужно использовать Windows Comet-сервис и требуется какой-то способ, позволяющий обойти ограничения CORS.
Для этого я советую создать страницу прокси в том же виртуальном каталоге, чья единственная функция заключается в перехвате HTTP-сообщения от клиента браузера, извлечении всех релевантных заголовков и контента и формировании другого объекта HTTP-запроса, который подключается к Comet-сервису. Поскольку это соединение выполняется на сервере, ограничения 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 в дважды закрытом singleton-объекте (double locking singleton), чтобы уменьшить нагрузку подключений к Comet-серверу и позволить IIS выполнять балансировку нагрузки подключений за вас.
Затем я заполняю заголовки HttpWebRequest теми значениями, которые я получил от клиента jQuery, а главное — устанавливаю свойство KeepAlive в true, чтобы поддерживать долгоживущее HTTP-соединение, имеющее фундаментальное значение для взаимодействия в стиле Comet.
Здесь я проверяю наличие заголовка Origin, обязательного по спецификации W3C при работе с CORS:
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.");
Далее я извлекаю байты из контента HTTP-запроса от клиента jQuery и записываю их в поток запроса HttpWebRequest, который будет отправлен Comet-серверу, как показано на рис. 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-серверу я вызываю метод GetResponse объекта HttpWebRequest, который предоставляет объект 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];
// Считываем 256 символов за раз
int count = s.Read(read, 0, messageSize);
while (count > 0)
{
// Копируем 256 символов в строку и отображаем ее в консоли
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();
}
Тестирование приложения
Для проверки приложения создайте веб-сайт, который будет содержать страницы приложения-примера. Убедитесь, что URL вашей Windows-службы корректен, что очередь сообщений правильно сконфигурирована и что ею можно пользоваться. Запустите службу и откройте в одном окне браузера страницу Comet-клиента, а в другом — страницу, которая будет посылать сообщения. Наберите сообщение и щелкните кнопку отправки; примерно через 10 мс вы должны увидеть это сообщение в другом окне браузера. Попробуйте то же самое с разными браузерами — особенно с некоторыми из старых браузеров. Если они поддерживают объект xmlHttpRequest, все должно работать. Это обеспечивает веб-взаимодействие почти в реальном времени (en.wikipedia.org/wiki/Realtime_web), где контент практически мгновенно отправляется браузеру безо всяких действий пользователя.
Прежде чем развертывать новое приложение, вы должны выполнить тестирование производительности под нагрузкой. Для этого нужно сначала определиться с показателями, которые вы хотите собирать. Я предлагаю измерять нагрузку как по времени ответа, так и по размеру передаваемых данных. Кроме того, требуется проверить сценарии использования, релевантные для Comet, в частности широковещательную рассылку одного сообщения множеству клиентов без обратной передачи (postback).
Для тестирования я создал утилиту, которая открывает множество потоков, каждый с подключением к Comet-серверу, и ждет, пока сервер не сгенерирует ответ. Эта тестовая утилита позволяет мне задавать несколько параметров, таких как общее количество пользователей, которые будут подключаться к моему Comet-серверу, и сколько раз они будут заново открывать соединение (в настоящее время соединение закрывается после передачи ответа сервером).
Потом я создал утилиту, которая копирует сообщение размером x байтов в очередь и содержит на основном экране текстовые поля для задания числа байтов и числа миллисекунд для ожидания между сообщениями, посылаемыми сервером. Я использую ее для отправки тестового сообщения обратно клиенту. Затем я запустил тестовый клиент, указал количество пользователей и значение, определяющее число повторных открытий клиентом Comet-соединения, после чего потоки открывали соединения с моим сервером. Я выжидал несколько секунд для открытия всех соединений, потом переключался на утилиту отправки сообщений и передавал определенное количество байтов. Эту процедуру я повторял для различных комбинаций общего количества пользователей, повторных соединений и размеров сообщений.
Первая выборка данных была для одного пользователя с возрастающим числом повторений, но размер сообщения ответа сохранялся одинаковым (небольшим) на протяжении всего тестирования. Как видно в табл. 1, количество повторений не оказывает влияния на производительность или надежность системы.
Табл. 1. Варьирование количества пользователей
Пользователи | Повторения | Размер сообщения (байтов) | Время ответа (мс) |
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-сервере, в целом, является отказоустойчивым. На рис. 14 показан график зависимости времени ответа от количества пользователей для сообщения размером 512 байтов. В табл. 2 приведена некоторая статистика для сообщения размером 1024 байтов. Наконец, на рис. 15 данные из табл. 2 представлены в графическом виде. Все эти тесты проводились на одном и том же лэптопе с 8 Гб памяти и процессором Intel Core i3 с тактовой частотой 2,4 ГГц.
Рис. 14. Время ответа для варьируемого количества пользователей при передаче сообщений размером 512 байтов
Табл. 2. Тестирование с передачей сообщений размером 1024 байта
Пользователи | Повторения | Время ответа (мс) |
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 |
Рис. 15. Влияние нагрузки по количеству пользователей на время ответа при сообщении размером 1 Кб
Числа не показывают какого-то специфического тренда, кроме того, что время ответа вполне приемлемое и остается менее секунды для сообщений размером вплоть до 1 Кб. Я не стал отслеживать используемую полосу пропускания, так как на это влияет формат сообщений. А поскольку все тестирование выполнялось на одном компьютере, сетевые задержки были исключены как фактор. Я мог бы опробовать все это в своей домашней сети, но решил, что вряд ли стоит это делать, так как общедоступный Интернет устроен гораздо сложнее, чем моя домашняя сеть, включающая беспроводной маршрутизатор и кабельный модем. Однако, поскольку ключевой момент в методиках взаимодействия в стиле Comet заключается в сокращении количества полных обменов данными с сервером за счет «проталкивания» контента с сервера по мере обновления, теоретически методология Comet должна использовать вполовину меньшую пропускную полосу сети.
Заключение
Надеюсь, теперь вы сможете успешно реализовать собственные приложения в стиле Comet и эффективно использовать их для уменьшения нагрузки на сеть и увеличения производительности веб-сайтов. Конечно, вы наверняка захотите проверить новые технологии, включенные в HTML5, которые могут заменить Comet, например WebSockets (bit.ly/UVMcBg) и Server-Sent Events (SSE) (bit.ly/UVMhoD). Эти технологии обещают значительно упростить передачу контента браузеру, но требуют от пользователя наличия браузера с поддержкой HTML5. Если вы по-прежнему поддерживаете пользователей со старыми браузерами, то взаимодействие в стиле Comet остается лучшим выбором.