Протокол WebSocket предназначен для поддержки двухсторонних коммуникаций в мире Web, где доминируют клиенты, отвечающие за установление соединений и инициацию пар «запрос-ответ». Он наконец позволил использовать в приложениях гораздо больше преимуществ TCP, причем таким способом, который лучше подходит для Web. Учитывая, что протокол WebSocket был стандартизован комитетом Internet Engineering Task Force (IETF) в декабре 2011 г. и что на момент написания этой статьи он все еще находится на рассмотрении World Wide Web Consortium, просто удивительно, насколько обширно применяется в Windows 8 эта новая технология Интернета.
В данной статье я впервые покажу, как работает протокол WebSocket и поясню его связь с более крупным стеком протоколов TCP/IP. Далее мы исследуем различные способы, с помощью которых программисты в Windows 8 могут легко задействовать эту новую технологию в своих приложениях.
Почему WebSocket?
Основная цель этого протокола — предоставить стандартный и эффективный способ приложениям на основе браузеров свободно взаимодействовать с серверами без использования пар «запрос-ответ». Несколько лет назад все веб-разработчики благоговейно говорили об Asynchronous JavaScript and XML (AJAX) и о том, как он позволяет реализовать динамические и интерактивные сценарии. Безусловно, AJAX это позволил, но объект XMLHttpRequest, с которого все и началось, давал возможность браузеру выдавать лишь HTTP-запросы. Как быть, если серверу нужно отправить сообщение клиенту по вспомогательному соединению (out-of-band)? Вот здесь в игру и вступает протокол WebSocket. Он не только позволяет серверу посылать сообщения клиенту, но и делать это без HTTP, обеспечивая двухстороннюю связь, которая по скорости близка к таковой через TCP-соединение. Без протокола WebSocket веб-разработчикам приходилось злоупотреблять HTTP для опроса сервера на предмет обновлений, применяя методы программирования в стиле Comet и используя множество HTTP-соединений с большими издержками только для того, чтобы их приложения вовремя обновлялись. Это приводило к перегруженности серверов, пустой трате полосы пропускания и чрезмерному усложнению веб-приложений. Протокол WebSocket устраняет эти проблемы удивительно простым и эффективным способом, но, прежде чем описывать, как он работает, нам нужно вспомнить некоторые фундаментальные вещи в историческом контексте.
Стек TCP/IP
TCP/IP — это стек протоколов, или набор взаимосвязанных протоколов, которые реализуют архитектуру Интернета. Он развивался многие годы. Мир радикально изменился со времен 1960-х, когда впервые разработали концепцию сетей с коммутацией пакетов. Компьютеры стали куда быстрее, программное обеспечение стало требовательнее, а Интернет быстро превратился во всеобъемлющую паутину информации, коммуникаций и взаимодействия и до сих пор является опорой многих программ, популярных и сегодня.
Стек TCP/IP состоит из набора уровней, свободно моделируемых на основе многоуровневой модели Open System Interconnection (OSI). Хотя протоколы на различных уровнях не особо четко разграничены, TCP/IP давно доказал свою эффективность, а проблемы, связанные с уровнями, были преодолены за счет продуманной комбинации аппаратных и программных архитектур. Однако разделение TCP/IP на уровни, какими бы туманными они ни были, помогло ему в развитии по мере изменения аппаратного обеспечения и технологий, а также позволило программистам разной квалификации успешно работать с разными уровнями абстракции.
На самых низких уровнях находятся физические протоколы, в том числе такие, как управляющие доступом по проводным носителям и Wi-Fi, обеспечивающие физические соединения, а также локальную адресацию и обнаружение ошибок. Большинство программистов не особо задумывается об этих протоколах.
Выше по стеку на сетевом уровне размещается сам протокол IP (Internet Protocol); он позволяет TCP/IP взаимодействовать через различные физические уровни. Этот протокол отвечает за сопоставление адресов компьютеров с физическими адресами и маршрутизацию пакетов от компьютера к компьютеру.
Также существуют вспомогательные протоколы, и мы могли бы долго обсуждать, на каком уровне они располагаются, но на самом деле они обеспечивают такие необходимые вещи, как автоматическое конфигурирование, разрешение имен, обнаружение, оптимизация маршрутизации и диагностика.
Поднимаясь еще выше по стеку, вы обнаружите транспортные и прикладные протоколы. Транспортные протоколы отвечают за мультиплексирование и демультиплексирование пакетов с более низких уровней, так чтобы — даже при наличии всего одного физического и сетевого уровня — множество разных приложений могло совместно использовать коммуникационный канал. Транспортный уровень также обычно предоставляет дополнительную поддержку распознавания ошибок, надежную доставку и даже такие функции, относящиеся к производительности, как поглощение пакетов и управление потоками. Прикладной уровень традиционно содержит протоколы наподобие HTTP (реализуемый веб-браузерами и серверами) и SMTP (реализуемый почтовыми клиентами и серверами). По мере того как во всем мире стали активнее пользоваться такими протоколами, как HTTP, их реализации были перемещены в глубины операционных систем как для повышения производительности, так и для совместного использования различными приложениями.
TCP и HTTP
Из всего стека TCP/IP протоколы TCP и User Datagram Protocol (UDP), находящиеся на транспортном уровне, по-видимому, наиболее известны среднему программисту. Оба определяют абстракцию порта, которую эти протоколы используют в сочетании с IP-адресами для мультиплексирования и демультиплексирования пакетов по мере их приема и передачи.
Хотя UDP интенсивно используется другими TCP/IP-протоколами, например Dynamic Host Configuration Protocol (DHCP) и DNS, и широко применяется в частных сетях, его распространенность на уровне Интернета гораздо меньше, чем TCP. С другой стороны, TCP столь популярен по большей части благодаря HTTP. TCP намного сложнее UDP, но основная часть этой сложности скрыта от прикладного уровня, где приложения используют преимущества TCP, ничего не зная о его внутренней сложности.
TCP обеспечивает надежную передачу данных между компьютерами, из-за чего его реализация крайне сложна. Он отвечает за упорядочение пакетов и реконструкцию данных, обнаружение ошибок и восстановление после них, управление поглощением пакетов и производительностью, обеспечение тайм-аутов, повторных передач и многое другое. Однако приложение видит только двухстороннее соединение между портами и предполагает, что отправляемые и принимаемые данные будут передаваться корректно и по порядку.
Современный HTTP полностью полагается на надежный протокол, ориентированный на соединения, и здесь TCP является очевидным и повсеместным выбором. В этой модели HTTP функционирует как клиент-серверный протокол. Клиент открывает TCP-соединение с сервером. Затем посылает запрос, который сервер оценивает и на который отвечает. И это ежесекундно повторяется тысячи и тысячи раз по всему миру.
Конечно, это упрощение или ограничение функциональности, предоставляемой TCP. Он позволяет обеим сторонам одновременно посылать данные. При этом любая из сторон не должна ждать перед ответом, когда другая отправит соответствующий запрос. Однако это упрощение обеспечивает кеширование ответов на серверной стороне, что оказало сильнейшее влияние на способность Web к масштабированию. Но популярность HTTP, несомненно, обусловлена его изначальной простотой. Если TCP предоставляет двухсторонний канал для двоичных данных (пару потоков, если хотите), то в HTTP сообщение запроса предшествует сообщению ответа, и оба состоят из ASCII-символов, хотя тела сообщений, если таковые есть, могут кодироваться каким-либо иным образом. Простой запрос может выглядеть так:
GET /resource HTTP/1.1\r\n
host: example.com\r\n
\r\n
Каждая строка завершается символом возврата каретки (\r) и перевода строки (\n). Первая строка, называемая строкой запроса, указывает метод доступа к ресурсу (в данном случае GET), путь к ресурсу и, наконец, нужную версию HTTP. Аналогично протоколам более низкого уровня через этот путь к ресурсу HTTP обеспечивает мультиплексирование и демультиплексирование. За строкой запроса следует одна или более строк заголовков. Заголовки состоят из имени и значения, как показано выше. Некоторые заголовки обязательны, например host, тогда как большинство других не являются обязательными и просто помогают браузерам и серверам более эффективно взаимодействовать друг с другом или согласовывать использование той или иной функциональности.
Ответ может выглядеть следующим образом:
HTTP/1.1 200 OK\r\n
content-type: text/html\r\n
content-length: 1307\r\n
\r\n
<!DOCTYPE HTML><html> ... </html>
Формат в основном тот же, но вместо строки запроса присутствует строка ответа, подтверждающая версию HTTP, которая будет использоваться, код состояния (200) и описание этого кода. Код состояния 200 указывает клиенту, что запрос успешно обработан и сразу за строками заголовков включен некий результат. Сервер мог бы, например, сообщить, что запрошенный ресурс не существует, вернув код состояния 404. Заголовки имеют тот же формат, что и в запросе. В данном случае заголовок content-type информирует браузер о том, что запрошенный ресурс в теле сообщения следует интерпретировать как HTML, а заголовок content-length сообщает браузеру, сколько байтов содержится в теле сообщения. Это важно, поскольку, как вы помните, HTTP-сообщения передаются через TCP, который не разграничивает сообщения. Без этого заголовка HTTP-приложениям пришлось бы использовать различные эвристические методы, чтобы определить длину тела сообщения.
Все это довольно просто, подтверждение прямолинейной архитектуры HTTP. Но теперь HTTP больше не является простым. Современные веб-браузеры и серверы — программы с тысячами взаимосвязанных возможностей, а HTTP служит рабочей лошадкой, которой нужно идти в ногу с ними всеми. Большая часть сложность возникла из-за потребности в высоких скоростях. Теперь есть заголовки для согласования сжатия тела сообщения, заголовки кеширования и истечения срока действия, чтобы вообще не передавать тело сообщения, и многие другие. Параллельно шло развитие методов, уменьшающих количество HTTP-запросов комбинированием различных ресурсов. По миру даже распространились сети доставки контента (content delivery networks, CDN) в попытке хостинга часто используемых ресурсов поближе к веб-браузерам, которые к ним обращаются.
Несмотря на все эти достижения, многие веб-приложения можно было бы масштабировать еще больше и даже упростить, если бы был какой-то способ при необходимости выходить за рамки HTTP и возвращаться к потоковой модели TCP. Это и есть то, что дает протокол WebSocket.
Установление соединения по WebSocket
WebSocket занимает свое место в стеке TCP/IP над TCP и рядом с HTTP. Одна из проблем с введением нового протокола в Интернет — нужно каким-то образом заставить бесчисленные маршрутизаторы, прокси и брандмауэры поверить в то, что в нашем мире ничего не изменилось. Эта цель достигается протоколом WebSocket маскировкой под HTTP до переключения на собственный механизм передачи данных по тому же нижележащему TCP-соединению. Благодаря этому многие промежуточные аппаратно-программные средства не требуют обновления для поддержки обмена данными по WebSocket через их сетевые соединения. На практике не все так гладко, потому что некоторые чрезмерно рьяные маршрутизаторы вмешиваются в HTTP-запросы и ответы, пытаясь переписывать их так, чтобы они подходили для их целей, например кеширования прокси либо трансляции адресов или ресурсов. Эффективное решение в краткосрочной перспективе — использование протокола WebSocket по защищенному каналу — Transport Layer Security (TLS), поскольку это сводит к минимуму вероятность какого-либо вмешательства.
WebSocket заимствует идеи из разнообразных источников, в том числе IP, UDP, TCP и HTTP, и делает эти концепции доступными веб-браузерам и другим приложениям в упрощенной форме. Все начинается с установления соединения (handshake), которое работает и выглядит подобно процедуре с использованием пары «запрос-ответ» из HTTP. Это делается не для того, чтобы клиенты или серверы могли обманом заставлять друг друга использовать WebSocket. Весь смысл — обмануть промежуточные аппаратно-программные средства и заставить их считать, что это просто еще одно TCP-соединение, обслуживающее HTTP. По сути, протокол WebSocket специально спроектирован так, чтобы предотвратить ошибочное принятие любой из сторон такого соединения. Установление соединения начинается с отправки клиентом соответствующих данных, которые действительно являются HTTP-запросом и могут выглядеть следующим образом:
GET /resource HTTP/1.1\r\n
host: example.com\r\n
upgrade: websocket\r\n
connection: upgrade\r\n
sec-websocket-version: 13\r\n
sec-websocket-key: E4WSEcseoWr4csPLS2QJHA==\r\n
\r\n
Как видите, ничто не мешает считать это совершенно корректным HTTP-запросом. Неподозревающий посредник должен просто пропустить этот запрос на сервер, который может быть даже HTTP-сервером, параллельно исполняющим роль сервера WebSocket. Строка запроса в этом примере указывает стандартный GET-запрос. Это также означает, что сервер WebSocket мог бы разрешить обслуживание множества конечных точек одним сервером точно так же, как это делает большинство HTTP-серверов. Заголовок host требуется HTTP 1.1 и служит той же цели: обеспечить согласие обеих сторон по домену хостинга в сценариях с общим хостингом (shared hosting). Заголовки upgrade и connection тоже являются стандартными HTTP-заголовками, используемыми клиентами для запроса обновления протокола, который применяется в рамках данного соединения. Этот метод иногда используется HTTP-клиентами для переключения на защищенное TLS-соединение, хотя встречается это редко. Однако эти заголовки обязательны для протокола WebSocket. В частности, заголовок upgrade указывает, что соединение должно быть обновлено до протокола WebSocket, а заголовок connection — что этот заголовок upgrade специфичен для данного соединения, а это означает, что прокси не должны передавать его по последующим соединениям.
Заголовок sec-websocket-version обязателен, и его значение должно быть 13. Если сервер WebSocket не поддерживает эту версию, он отклонит запрос на установление соединения, вернув соответствующий HTTP-код состояния. Как вы вскоре увидите, даже если сервер ничего не знает о протоколе WebSocket и возвращает ответ об успешном принятии соединения, клиент отклоняет такое соединение.
Заголовок sec-websocket-key является по-настоящему ключевым в WebSocket-запросе на установление соединения. Проектировщики протокола WebSocket хотели гарантировать, что сервер не сможет принять соединение от клиента, который на самом деле не является клиентом WebSocket. Они стремились предотвратить вероятность того, что в злонамеренном скрипте будет сконструирована передача формы или использован объект XMLHttpRequest для подделки WebSocket-соединения добавлением заголовков sec-*. Чтобы подтвердить обеим сторонам факт установления легитимного соединения, заголовок sec-websocket-key должен также присутствовать в запросе клиента на установление соединения. Его значение должно выбираться случайным образом (в идеале, требуется генератор случайных чисел криптографического уровня) и быть 16-байтовым числом (на жаргоне специалистов в области безопасности его называют словом, образованным только для данного случая [nonce]), которое потом кодируется по основанию base64.
Отправив запрос на установление соединения, клиент ждет ответа, подтверждающего, что сервер действительно хочет и может установить WebSocket-соединение. Если сервер не является объектом, он мог бы посылать следующее подтверждение как HTTP-ответ:
HTTP/1.1 101 OK
upgrade: websocket\r\n
connection: upgrade\r\n
sec-websocket-accept: 7eQChgCtQMnVILefJAO6dK5JwPc=\r\n
\r\n
И вновь это совершенно корректный HTTP-ответ. Строка ответа включает версию HTTP, за ней идет код состояния, но вместо обычного кода 200, указывающего на успех, сервер должен отвечать стандартным кодом 101, сообщающим, что он понимает запрос обновления и готов переключить протоколы. Описание кода состояния на английском здесь не имеет ни малейшего значения. Сервер может указать «OK», «Switching to WebSocket» или даже случайную цитату из Марка Твена. Важен лишь сам код состояния, и клиент должен убедиться, что он равен 101. Сервер мог бы, например, отклонить запрос и попросить клиент аутентифицироваться, используя код состояния 401 перед тем, как принять клиентский запрос на установление WebSocket-соединения. Однако ответ об успешном завершении операции должен включать заголовки upgrade и connection, подтверждающие, что код состояния 101 действительно указывает на переключение на протокол WebSocket; это еще одна мера защиты от попыток обмана.
Наконец, для проверки установления связи клиент должен убедиться в том, что в ответе присутствует заголовок sec-websocket-accept и что его значение правильное. Серверу не нужно декодировать отправленное клиентом значение, закодированное по основанию base64. Он просто принимает эту строку, соединяет ее со строковым представлением общеизвестного GUID и хеширует эту комбинацию по алгоритму SHA-1, чтобы получить 20-байтовое значение, которое потом кодируется по основанию base64 и используется как значение для заголовка sec-websocket-accept. Затем клиент может легко проверить, что сервер действительно сделал все, как нужно, и тогда уже не останется сомнений, что обе стороны соглашаются установить WebSocket-соединение.
Если все прошло успешно, к этому моменту устанавливается допустимое WebSocket-соединение и обе стороны могут свободно взаимодействовать и одновременно посылать кадры данных по протоколу в обоих направлениях. При изучении протокола WebSocket становится очевидным, что он проектировался после апокалипсиса ненадежности Web. В отличие от большинства своих предшественников протокол WebSocket разработан с учетом безопасности. Этот протокол также требует, чтобы клиент включал заголовок origin, если он на самом деле является веб-браузером. Это позволяет браузерам обеспечивать защиту от атак с подделкой между источниками (cross-origin attacks). Конечно, это имеет смысл только в контексте доверяемой среды хостинга, например в браузере.
Передача данных по WebSocket
Суть протокола WebSocket сводится к тому, чтобы вернуть Web обратно к сравнительно высоко производительной модели коммуникаций с малыми издержками, предоставляемой IP и TCP, не добавляя дополнительные уровни сложности и издержек. По этой причине сразу после согласования соединения издержки WebSocket поддерживаются на минимальном уровне. Этот протокол предоставляет механизм разбиения пакетов на кадры поверх механизма IP разбиения на пакеты, на который опирается сам TCP и из-за которого столь популярен UDP, но без ограничений на размеры пакетов, обременяющих эти протоколы. Если TCP предоставляет приложениям абстракцию на основе потоков, то WebSocket — абстракцию на основе сообщений. И хотя TCP-потоки передаются через сегменты, WebSocket-сообщения транспортируются как последовательность кадров. Эти кадры передаются по тому же TCP-соединению, и благодаря этому естественным образом обеспечивается их надежная и последовательная доставка. Протокол кадровой синхронизации весьма сложен, но специально разработан так, чтобы добавлять крайне малый объем данных — во многих случаях кадр дополняется всего несколькими байтами. Кадры данных могут передаваться либо клиентом, либо сервером в любое время после завершения операции установления соединения.
Каждый кадр включает операционный код (opcode), описывающий тип кадра и размер полезной нагрузки. Эта полезная нагрузка представляет собственно данные, которые отправляются приложением наряду с любыми, заранее подготовленными данными расширения. Любопытно, что этот протокол разрешает фрагментацию сообщений. Если у вас есть серьезный опыт работы с сетями, то, возможно, вы помните о том, насколько отрицательно влияет на производительность фрагментация на уровне IP и какие ухищрения предпринимаются TCP, чтобы избежать ее. Но концепция фрагментации в WebSocket совершенно другая. Идея в том, чтобы протокол WebSocket обеспечивал все преимущества использования сетевых пакетов, но без ограничений на их размер. Если отправитель не знает точной длины посылаемого сообщения, оно может быть фрагментировано, и каждый кадр указывает, сколько в нем данных и является ли он последним фрагментом. Помимо этого, кадр просто сообщает, содержит он двоичные данные или текст в кодировке UTF-8.
Также определяются управляющие кадры, которые в основном применяются для закрытия соединения, но могут использоваться и как контрольный сигнал для проверки связи с другими конечными точками, чтобы убедиться в том, что они еще отвечают, или чтобы сохранить TCP-соединение активным. Наконец, я должен подчеркнуть, что, если вам доводилось заглядывать в посылаемый клиентом WebSocket-кадр с помощью анализатора сетевых протоколов, например Wireshark, то, вероятно, вы замечали, что кадры данных содержат кодированную информацию. Протокол WebSocket требует, чтобы все данные, посылаемые клиентом серверу маскировались. Маскирование включает простой алгоритм: логическую операцию XOR над байтами данных с помощью ключа маскирования. Этот ключ содержится в кадре, так что его не следует рассматривать как некую смехотворную функцию защиты, хотя он все же имеет некоторое отношение к защите. Как упоминалось, проектировщики протокола WebSocket потратили много усилий, прорабатывая различные сценарии, связанные с безопасностью, в попытке предусмотреть различные способы потенциально возможных атак на этот протокол. Один из проанализированных векторов таких атак включает попытку непрямого взлома WebSocket за счет компрометации других частей инфраструктуры Интернета, в данном случае — прокси-серверов. Неподозревающие прокси-серверы, ничего не знающие о сходстве запроса на установление связи по WebSocket с GET-запросом, можно было бы обманным путем заставить кешировать данные для фальшивого GET-запроса, инициированного атакующим, что в конечном счете «отравило» бы кеш для некоторых пользователей. Маскирование каждого кадра новым ключом ослабляет именно эту угрозу, потому что кадры становятся непредсказуемыми. Эта атака далеко не исчерпывается сказанным, и, несомненно, исследователи откроют со временем другие возможные бреши. Тем не менее, впечатляет, насколько далеко зашли проектировщики в стремлении предусмотреть многие виды атак.
Windows 8 и протокол WebSocket
Как ни полезно глубокое понимание протокола WebSocket, не менее полезно поработать с ним на платформе с его поддержкой, и Windows 8 определенно позволяет сделать это. Давайте рассмотрим некоторые способы, которыми вы можете использовать протокол WebSocket без необходимости реализовать этот протокол самостоятельно.
Windows 8 предоставляет Microsoft .NET Framework, поддерживает как управляемые, так и неуправляемые клиенты через Windows Runtime и позволяет создавать WebSocket-клиенты на C++ с применением Windows HTTP Services (WinHTTP) API. Наконец, IIS 8 предоставляет неуправляемый модуль WebSocket, а Internet Explorer, конечно же, поддерживает протокол WebSocket. Это весьма разнородная смесь, но еще удивительнее то, что Windows 8 включает только одну реализацию WebSocket, которая совместно используется всеми этими компонентами. WebSocket Protocol Component API реализует все правила протокола для установления связи и синхронизации кадров без создания реального сетевого соединения любого типа. Эту общую реализацию могут использовать различные платформы и исполняющие среды и подключать ее в свой сетевой стек.
.NET-клиенты и серверы
.NET Framework предоставляет расширения ASP.NET и предлагает HttpListener (который сам базируется на неуправляемом HTTP Server API, используемом IIS) для серверной поддержки протокола WebSocket. В случае ASP.NET вы можете просто написать HTTP-обработчик, вызывающий новый метод HttpContext.AcceptWebSocketRequest, который принимает WebSocket-запрос в конкретной конечной точке. Вы можете проверить, действительно ли это запрос от WebSocket-клиента, используя свойство HttpContext.IsWebSocketRequest. Вне ASP.NET сервер WebSocket можно разместить с помощью класса HttpListener. Эта реализация тоже по большей части используется совместно обоими компонентами. Несложный пример такого сервера показан на рис. 1.
Рис. 1. Сервер WebSocket, использующий HttpListener
static async Task Run()
{
HttpListener s = new HttpListener();
s.Prefixes.Add("http://localhost:8000/ws/");
s.Start();
var hc = await s.GetContextAsync();
if (!hc.Request.IsWebSocketRequest)
{
hc.Response.StatusCode = 400;
hc.Response.Close();
return;
}
var wsc = await hc.AcceptWebSocketAsync(null);
var ws = wsc.WebSocket;
for (int i = 0; i != 10; ++i)
{
await Task.Delay(2000);
var time = DateTime.Now.ToLongTimeString();
var buffer = Encoding.UTF8.GetBytes(time);
var segment = new ArraySegment<byte>(buffer);
await ws.SendAsync(segment, WebSocketMessageType.Text,
true, CancellationToken.None);
}
await ws.CloseAsync(WebSocketCloseStatus.NormalClosure,
"Done", CancellationToken.None);
}
Здесь я использую C#-метод async, чтобы код выглядел последовательным, но на деле он весь асинхронный. Я начинаю с регистрации конечной точки и ожидаю входящего запроса. Затем проверяю, действительно ли это запрос на установление соединения по WebSocket, и, если это не так, возвращаю код состояния 400 («неправильный запрос»). Потом вызываю AcceptWebSocketAsync, чтобы принять клиентский запрос на установление связи и жду, когда закончится согласование соединения. В этот момент я могу свободно осуществлять коммуникации с помощью объекта WebSocket. В этом примере сервер с короткой задержкой посылает 10 кадров UTF-8, каждый из которых содержит метку времени. Кадры посылаются асинхронно методом SendAsync. Это довольно эффективный метод, и он может посылать кадры (UTF-8 или двоичные) как целиком, так и разбитыми на фрагменты. Третий параметр (в данном случае true) указывает, представляет ли данный вызов SendAsync конец сообщения. Таким образом, вы можете многократно вызывать этот метод для отправки длинных сообщений, которые будут фрагментироваться автоматически. Наконец, метод CloseAsync выполняет корректное закрытие WebSocket-соединения, посылая управляющий кадр закрытия и ожидая подтверждения от клиента в виде его кадра закрытия.
На клиентской стороне новый класс ClientWebSocket, используя объект HttpWebRequest на внутреннем уровне, обеспечивает возможность подключения к серверу WebSocket. На рис. 2 показан простой пример клиента, который можно использовать для подключения к серверу с рис. 1.
Рис. 2. Клиент WebSocket, использующий ClientWebSocket
static async Task Client()
{
ClientWebSocket ws = new ClientWebSocket();
var uri = new Uri("ws://localhost:8000/ws/");
await ws.ConnectAsync(uri, CancellationToken.None);
var buffer = new byte[1024];
while (true)
{
var segment = new ArraySegment<byte>(buffer);
var result =
await ws.ReceiveAsync(segment, CancellationToken.None);
if (result.MessageType == WebSocketMessageType.Close)
{
await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "OK",
CancellationToken.None);
return;
}
if (result.MessageType == WebSocketMessageType.Binary)
{
await ws.CloseAsync(WebSocketCloseStatus.InvalidMessageType,
"I don't do binary", CancellationToken.None);
return;
}
int count = result.Count;
while (!result.EndOfMessage)
{
if (count >= buffer.Length)
{
await ws.CloseAsync(WebSocketCloseStatus.InvalidPayloadData,
"That's too long", CancellationToken.None);
return;
}
segment =
new ArraySegment<byte>(buffer, count, buffer.Length - count);
result = await ws.ReceiveAsync(segment, CancellationToken.None);
count += result.Count;
}
var message = Encoding.UTF8.GetString(buffer, 0, count);
Console.WriteLine("> " + message);
}
}
Здесь я использую метод ConnectAsync для установления соединения и подготовки к переключению на WebSocket. Заметьте, что в URL применяется новая URI-схема «ws» для идентификации конечной точки WebSocket. Как и в случае HTTP, порт по умолчанию для ws — 80. Также определена схема «wss», представляющая защищенное TLS-соединение и использующая соответствующий порт 443. Затем клиент вызывает ReceiveAsync в цикле для приема такого количества кадров, какое сервер отправляет клиенту. После приема кадр сначала проверяется на то, не представляет ли он управляющий кадр закрытия. В этом случае клиент отвечает отправкой собственного кадра закрытия, позволяя серверу корректно закрыть соединение. Далее клиент проверяет, содержит ли кадр двоичные данные, и, если да, закрывает соединение с ошибкой, указывающей на то, что этот тип кадров не поддерживается. Наконец, данные кадра могут быть считаны. Для подстройки под фрагментированные сообщения цикл while ожидает, пока не будет принят последний фрагмент. Новая структура ArraySegment позволяет управлять смещением в буфере для корректной сборки фрагментов.
WinRT-клиент
Windows Runtime более ограниченно поддерживает протокол WebSocket. Поддерживаются только клиенты, а фрагментированные сообщения UTF-8 должны быть полностью буферизованы до того, как их можно будет считать. С помощью этого API возможна потоковая передача только двоичных сообщений. На рис. 3 дан простой пример клиента, который тоже позволяет подключаться к серверу с рис. 1.
Рис. 3. WebSocket-клиент, использующий Windows Runtime
static async Task Client()
{
MessageWebSocket ws = new MessageWebSocket();
ws.Control.MessageType = SocketMessageType.Utf8;
ws.MessageReceived += (sender, args) =>
{
var reader = args.GetDataReader();
var message = reader.ReadString(reader.UnconsumedBufferLength);
Debug.WriteLine(message);
};
ws.Closed += (sender, args) =>
{
ws.Dispose();
};
var uri = new Uri("ws://localhost:8000/ws/");
await ws.ConnectAsync(uri);
}
Этот пример, хоть и написан на C#, по большей части опирается на обработчики событий, а C#-метод async используется минимально — только для того, чтобы объект MessageWebSocket мог подключаться асинхронно. Этот код сравнительно прост, но несколько причудлив. Обработчик события MessageReceived вызывается после приема всего (возможно, фрагментированного) сообщения и готовности к его чтению. Несмотря на то, что принято все сообщение и что оно может быть только строкой UTF-8, сообщение хранится в потоке данных, и для чтения его содержимого нужно использовать объект DataReader, возвращающий строку. Наконец, обработчик события Closed позволяет узнать, что сервер отправил управляющий кадр закрытия, но, как и в случае с .NET-классом ClientWebSocket, за передачу своего управляющего кадра закрытия на сервер по-прежнему отвечаете вы. Однако класс MessageWebSocket посылает этот кадр только перед тем, как уничтожается этот объект. Поэтому для корректного закрытия соединения мне приходится использовать в C# метод Dispose.
Прототип JavaScript-клиента
Несомненно, JavaScript — та среда, где протокол WebSocket будет иметь наибольшее значение, и его API впечатляюще прост. Вот что нужно для подключения к серверу с рис. 1:
var ws = new WebSocket("ws://localhost:8000/ws/");
ws.onmessage = function (args)
{
var time = args.data;
...
};
В отличие от других API в Windows браузер автоматически закрывает WebSocket-соединение, когда принимает управляющий кадр закрытия. Конечно, вы можете явным образом закрывать соединение или обрабатывать событие onclose, но для завершения процедуры закрытия больше никаких действий от вас не требуется.
WinHTTP-клиент для C++
Конечно, WinRT WebSocket Client API можно использовать и из неуправляемого C++, но, если вам нужен больший контроль, тогда WinHTTP — как раз для вас. На рис. 4 дан простой пример использования WinHTTP для подключения к серверу с рис. 1. В этом примере WinHTTP API используется в синхронном режиме для большей ясности, но этот API прекрасно работает и в асинхронном режиме.
Рис. 4. WebSocket-клиент, использующий WinHTTP
auto s = WinHttpOpen( ... );
auto c = WinHttpConnect(s, L"localhost", 8000, 0);
auto r = WinHttpOpenRequest(c, nullptr, L"/ws/", ... );
WinHttpSetOption(r, WINHTTP_OPTION_UPGRADE_TO_WEB_SOCKET, nullptr, 0);
WinHttpSendRequest(r, ... );
VERIFY(WinHttpReceiveResponse(r, nullptr));
DWORD status;
DWORD size = sizeof(DWORD);
WinHttpQueryHeaders(r,
WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER,
WINHTTP_HEADER_NAME_BY_INDEX,
&status,
&size,
WINHTTP_NO_HEADER_INDEX);
ASSERT(HTTP_STATUS_SWITCH_PROTOCOLS == status);
auto ws = WinHttpWebSocketCompleteUpgrade(r, 0);
char buffer[1024];
DWORD count;
WINHTTP_WEB_SOCKET_BUFFER_TYPE type;
while (NO_ERROR ==
WinHttpWebSocketReceive(ws, buffer, sizeof(buffer), &count, &type))
{
if (WINHTTP_WEB_SOCKET_CLOSE_BUFFER_TYPE == type)
{
WinHttpWebSocketClose(
ws, WINHTTP_WEB_SOCKET_SUCCESS_CLOSE_STATUS, nullptr, 0);
break;
}
if (WINHTTP_WEB_SOCKET_BINARY_MESSAGE_BUFFER_TYPE == type ||
WINHTTP_WEB_SOCKET_BINARY_FRAGMENT_BUFFER_TYPE == type)
{
WinHttpWebSocketClose(
ws, WINHTTP_WEB_SOCKET_INVALID_DATA_TYPE_CLOSE_STATUS, nullptr, 0);
break;
}
std::string message(buffer, count);
while (WINHTTP_WEB_SOCKET_UTF8_FRAGMENT_BUFFER_TYPE == type)
{
WinHttpWebSocketReceive(ws, buffer, sizeof(buffer), &count, &type);
message.append(buffer, count);
}
printf("> %s\n", message.c_str());
}
Как и в случае всех WinHTTP-клиентов, вы должны создать сеанс WinHTTP, соединение и объект запроса. Здесь нет ничего нового, поэтому ряд деталей я опустил. Перед самой отправкой запроса вы должны установить новый параметр WINHTTP_OPTION_UPGRADE_TO_WEB_SOCKET в запросе, чтобы указать WinHTTP, что вам нужен WebSocket. После этого запрос можно отправить вызовом функции WinHttpSendRequest. Далее для ожидания ответа используется функция WinHttpReceiveResponse, которая в данном случае включит в возвращаемые данные результат запроса на установление связи по WebSocket. Как всегда, чтобы определить результат запроса, вызывается функция WinHttpQueryHeaders, которая читает код состояния, возвращенный сервером. К этому моменту WebSocket-соединение уже установлено, и вы можете начать напрямую пользоваться им. WinHTTP API обрабатывает разбиение на кадры и их синхронизацию за вас, и эта функциональность предоставляется через новый WinHTTP-объект WebSocket, получаемый вызовом функции WinHttpWebSocketCompleteUpgrade объекта запроса.
Прием сообщений от сервера осуществляется, по крайней мере на концептуальном уровне, во многом так же, как и в примере на рис. 2. Функция WinHttpWebSocketReceive ожидает приема следующего кадра данных. Это также позволяет вам читать фрагменты WebSocket-сообщения, и в примере на рис. 4 продемонстрировано, как это можно сделать в цикле. Если принимается управляющий кадр закрытия, посылается соответствующий кадр закрытия серверу через функцию WinHttpWebSocketClose. Если принимается кадр двоичных данных, соединение закрывается аналогичным образом. Учтите, что при этом закрывается только WebSocket-соединение. Для освобождения WinHTTP-объекта WebSocket вам по-прежнему нужно вызвать WinHttpCloseHandle; то же самое относится ко всем остальным WinHTTP-объектам в вашем распоряжении. Здесь пригодится класс-оболочка описателя наподобие того, который я описывал в своей рубрике за июль 2011 г. (msdn.microsoft.com/magazine/hh288076).
Протокол WebSocket — крупное новшество в мире веб-приложений и, несмотря на его относительную простоту, желанное дополнение в семействе протоколов TCP/IP. Я почти не сомневаюсь, что протокол WebSocket вскоре станет таким же распространенным, как и сам HTTP, упрощая и делая более эффективным коммуникационное взаимодействие приложений и подключенных систем всех видов. Свою роль в этом сыграла и Windows 8, которая предоставляет внушительный набор API для создания как клиентов, так и серверов WebSocket.