Главная > Блог > Веб-сокеты в каждый дом

Веб-сокеты в каждый дом

В этой статье речь пойдёт о протоколе WebSocket, немного теории и реализация его в браузере через JavaScript и на сервере на «голом» php.

Веб-сокет

Оглавление

Общая информация о веб-сокетах

Веб-сокеты, это такая технология, которая позволяет браузеру и серверу создать одно постоянное соединение и через него обмениваться данными. Преимущества такого подхода в том что для отслеживания изменения на сайте, браузеру теперь нет необходимости постоянно «сыпать» запросы на сервер. При постоянном соединении сервер теперь может когда ему надо отправить сообщение браузеру, т.е. связь двунаправленная, от браузера к серверу и от сервера к браузеру.

Рассмотрим классическую схему уведомления о сообщениях на сайте. Когда пользователь авторизуется на сайте, браузер каждый 30 секунд (может и чаще) шлёт ajax-запрос на сайт, по определённому урлу. Запрос типа - «Пришли ли мне новые сообщения». Сервер в большинстве случаев будет отвечать «Сообщений новых нет», и только изредка долгожданное «У вас 1 новое сообщение». Когда пользователей не много такая схема устраивает, но когда их много сервер получает до 1000 и более безсмысленных запросов. Такая схема использовалась, потому что http построен по принципу сделал запрос, получил ответ и «давай до свидание». В http нет возможности отправить сообщение от сервера браузеру, если браузер не спросит. При схеме с веб-сокетами браузеру достаточно создать соединение и ждать, сервер сам ответит браузеру, когда нужно. Преимущество на лицо - значительно снижается трафик и нагрузка на сервер, и уведомление приходит моментально. Широта использования веб-сокетов велика: чаты, уведомления, «доставучие» online-консультанты и прочее.

Реализация клиента на Javascript

Протокол веб-сокет создан уже давно (приобрёл статус RFC в 11.12.2011) и поддерживается большинством браузеров. Чтобы узнать поддерживает ли ваш браузер веб-сокеты перейдите по ссылке.

Работа в браузерах с вебсокетам проходит в несколько этапов:

  • Установка соединения или рукопожатие (handshake).
  • Создание обработчиков событий: onopen (соединение создано), onclose(соединение закрыто), onmessage (пришло сообщение от сервера), onerror (ошибка при работе веб-сокетов).
  • Отправка сообщений (фреймов) на сервер.

Тестировать веб-сокеты мы будем на сервере websocket.org «ws://echo.websocket.org», который будет принимать от нас сообщения и отвечать на них повторением сообщением. Этот сайт как раз существует, что лучше понять веб-сокеты, он понимает кросс-доменные запросы, поэтому страницу с JavaScript будем размещать у себя на локальном компьютере.

Этап. Рукопожатие

Чтобы создать соединение по веб-сокету достаточно создать объект WebSocket, в котором указывается урл для подключения.

var ws = new WebSocket("ws://echo.websocket.org");

Используйте протокол «ws://», если нужно не шифрованное соединение или протокол «wss://» для шифрованного соединения.

Этап. Создание обработчиков событий.

После того как мы создали объект WebSocket необходимо повесить функции-обработчики на события.

ws.onopen = function()
{
	console.log("Соединение установлено.");
};

ws.onclose = function(event)
{
	console.log("Соединение закрыто. Код «" + event.code + "». Причина «" + event.reason + "».");
};

ws.onmessage = function(event)
{
	console.log("Пришло сообщение «" + event.data + "».");
};

ws.onerror = function(error) 
{
	console.log("Произошла ошибка: «" + error.message + "».");
};

Если нужно повесить несколько функций на событие используем методы «addEventListener» и «removeEventListener». Пример:

ws.addEventListener("message", function(event)
{
	console.log("Пришло сообщение «" + event.data + "».");
});

ws.addEventListener("message", function(event)
{
	console.log("Обрабатываем сообщение «" + event.data + "».");
});

ws.addEventListener("open", function(){});
ws.addEventListener("close", function(event){});
ws.addEventListener("message", function(event){});
ws.addEventListener("error", function(error){});

Этап. Отправка сообщений на сервер

По веб-сокету сообщения отправляются в виде строки. Пример отправки простого текстового сообщения.

ws.send("test");

Обработка приходящих данных лежит уже на стороне сервера. Чаще для удобства работы по вебсокету отправляют JSON данные серилизованные в строку и обрабатывают приходящие данные как строка в JSON-e. Пример использования:

var query = 
{
	param: 10,
	text: "message"
};
ws.send(JSON.stringify(query));

Удобный способ отправки сообщений по веб-сокету служит протокол «JSON-RPC» (ссылка). Это очень простой протокол, который облегчит взаимодействие браузера и сервера. Пример использования JSON RPC:

var query = 
{
	jsonrpc : "2.0", 
	id : "asdafars",
	method : "method_name",
	params : 
	{
		param1 : 1,
		param2 : "Два"
	}
};
ws.send(JSON.stringify(query));

Параметры json-rpc объекта:

  • jsonrpc - версия протокола, может быть «2.0» или «1.0»
  • id - идентификатор запроса. Используется для идентификации ответа от сервера по своем запросу. Т.е. если отправить два запроса, то ответ от сервера по каждому запросу прийдёт в разное время, для этого и нужен id. На сервере необходимо учитывать этот параметр и в ответ прислать именно нужный id.
  • method - наименование метода, любая строка, к примеру «get», «hello», «set» и др.
  • params - параметры связанные с этим методом, тип переменной может быть любой, всё зависит от сервера.

Чтобы закрыть соединение используем метод close().

ws.close();

Протокол WebSocket

Рукопожатие. Запрос браузера

Работа с веб-сокетами идёт в два этапа, сначала браузер отправляет серверу по HTTP протоколу запрос, за соединение (handshake - рукопожатие). Запрос выглядит примерно так:

GET / HTTP/1.1
Host: example.com:3333
User-Agent: Mozilla/5.0 (X11; Linux i686; rv:45.0) Gecko/20100101 Firefox/45.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: ru-RU,ru;q=0.8,en-US;q=0.5,en;q=0.3
Accept-Encoding: gzip, deflate
Sec-WebSocket-Version: 13
Origin: http://example.com
Sec-WebSocket-Extensions: permessage-deflate
Sec-WebSocket-Key: ywIj4HquwZZM5S1v/rKB1Q==
Connection: keep-alive, Upgrade
Pragma: no-cache
Cache-Control: no-cache
Upgrade: websocket

в котором обязательно должы присутствовать эти заголовки:

  • GET, Host - стандартные заголовки
  • Connection, Upgrade - браузер хочет перейти на новый протокол
  • Origin - адрес с которого отправлен запрос. Мы можем это учитывать или нет.
  • Sec-WebSocket-Key - случайный ключ, который генерируется браузером в кодировке Base64, нужен чтобы понять, что ответ от сервера на подключение предназначен именно ему.
  • Sec-WebSocket-Version - версия протокол. Последняя версия 13.

Также есть дополнительные заголовки:

  • Sec-WebSocket-Extensions - расширения протокола, которые поддерживает браузер, можно указать несколько через точку с запятой. Если сервер поддерживает эти расширения, он должен ответить ответным заголовком с этим расширением. Например если указано «permessage-deflate» (сжимать фреймы по алгоритму Deflate), то в ответном сообщении, будет строка «Sec-WebSocket-Extensions: permessage-deflate». Походу только одно расширение. Подробнее тут.
  • Sec-WebSocket-Protocol - протокол по которому будет браузер отсылать данные. Т.к. по websocket-у можно отсылать любые данные в виде строки, указание протокола поможет легче парсить приходящие данные. Увы «JSON RPC» тут нету, зато есть куча других, например: soap, wamp и др., подробнее тут.

Рукопожатие. Ответ сервера

Для начала сообщим браузеру, что сервер понимает WebSocket-протокол. Для это отправим ответное сообщение:

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: XJ5oIwgkPS19PNMxp3UvbKsDl48=
Sec-WebSocket-Version: 13

В сообщении символ новой строки должен быть в Windows-стиле (\r\n), а в конце сообщения должно быть две новые строки (\r\n\r\n). Заголовок «Sec-WebSocket-Accept» вычисляется в зависимости от заголовка «Sec-WebSocket-Key» присланного браузером, порядок получения его таков:

  • Соединяем «Sec-WebSocket-Key» со строкой «258EAFA5-E914-47DA-95CA-C5AB0DC85B11», это строка прописана в RFC 6455 для веб-сокетов.
  • Далее вычисляем бинарный SHA1 по полученной строке.
  • И наконец кодируем строку алгоритмом base64.

В PHP вычисление «Sec-WebSocket-Accept» будет выглядит так:

$guid = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
$sec_websocket_accept = $header['Sec-WebSocket-Key'] . $guid;
$sec_websocket_accept = sha1($sec_websocket_accept, true);
$sec_websocket_accept = base64_encode($sec_websocket_accept);

Отправка сообщений

После того как браузер получает ответ, устанавливается постоянное TCP-соединение и обмен сообщениями между сервером и браузером осуществляется по бинарному протоколу ничего общего с HTTP не имеющего. Бинарные сообщения, которые пересылаются по этому протоколу именуют ещё «фреймами» (frame).

Формат фрейма по 32 бита, как в RFC.

0                   1                   2                   3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len |    Extended payload length    |
|I|S|S|S|  (4)  |A|     (7)     |             (16/64)           |
|N|V|V|V|       |S|             |   (if payload len==126/127)   |
| |1|2|3|       |K|             |                               |
+-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
|     Extended payload length continued, if payload len == 127  |
+ - - - - - - - - - - - - - - - +-------------------------------+
|                               |Masking-key, if MASK set to 1  |
+-------------------------------+-------------------------------+
| Masking-key (continued)       |          Payload Data         |
+-------------------------------- - - - - - - - - - - - - - - - +
:                     Payload Data continued ...                :
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
|                     Payload Data continued ...                |
+---------------------------------------------------------------+

Формат фрейма по 16 бит.

0                   1            
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6
+-+-+-+-+-------+-+-------------+
|F|R|R|R| Опкод |М|    Длина    |
|I|S|S|S|(4бита)|А|  сообщения  | 
|N|V|V|V|       |С|   (7бит)    | 16 бит
| |1|2|3|       |К|             |
| | | | |       |А|             |
+-+-+-+-+-------+-+-------------+
|  Расширенная длина сообщения  | 16 или 64 бита или отсутствует
+-------------------------------+
|           Маска               | 32 бита или отсутствует
+-------------------------------+
|                               |
|                               |
|          Сообщение            | Зависит от длины сообщения
|                               |
|                               |
+-------------------------------+

Разберём первые 16 бит фрейма (далее заголовок фрейма):

  • Флаг FIN - Браузер может посылать сообщение частями, т.е. сообщение будет из несколько фреймов. Если фрейм фрагментированный, у всех фреймов кроме последнего будет 0, а у последнего 1. Если сообщение не фрагмантировано то флаг всегда будет в 1.
  • Флаги RSV1, RSV2, RSV3 почти всегда в 0, предназначены для расширений протокола.
  • Опкод - шестнадцатеричное число, указывает тип фрейма:
    • 0x1 - текстовой фрейм.
    • 0x2 - двоичный фрейм.
    • 0x3 - 0x7 - не используются, зарезервированы.
    • 0x8 - фрейм закрытия соединения
    • 0x9 - фрейм PING
    • 0xA - фрейм PONG.
    • 0xB - 0xF - не используются, зарезервированы.
    • 0x0 - обозначает фрейм-продолжение для фрагментированного сообщения
  • Флаг маски - если 1 то фрейм замаскирован
  • Длина сообщения - предварительная длина сообщения.

Длина сообщения указывается в байтах и вычисляется по схеме (ну и намудрили):

  • Если длина сообщения в заголовке фрейма 125 и ниже, то длина сообщения будет как указано в заголовке
  • Если длина сообщения в заголовке фрейма равна 126, то длиной сообщения будет следующии за заголовком 16 бит
  • Если длина сообщения в заголовке фрейма равна 127, то длиной сообщения будет следующии за заголовком 64 бита

Маска используется для того чтобы замаскировать сообщение. Маска используется для защиты от атаки отравленый кэш.

Фрейм может быть замаскирован, а может быть и нет:

  • Фрейм поступающий от браузера может быть замаскирован, а может быть и нет.
  • Фрейм поступающий от сервера должен быть не замаскирован (хотя некоторые браузеры понимают замаскированные фреймы).

Если фрейм замаскирован, то флаг маски установливается в 1 и следующие 32 бита (4 байта) будет маска, а строка сообщения будет XOR закодировано. Это значит что над каждым байтом в сообщении будет выполнено побитовая операция «исключающее или» с байтом из маски. В PHP для это используется символ «^». Работает это примерно так "z" ^ "m" ^ "m" === "z". Пример на PHP:

/* Сообщение */
$str = 
	"Мороз и солнце; день чудесный!\n" .
	"Еще ты дремлешь, друг прелестный -\n" .
	"Пора, красавица, проснись:\n" .
	"Открой сомкнуты негой взоры\n" .
	"Навстречу северной Авроры,\n" .
	"Звездою севера явись!";
$str_length = strlen($str);

/* Маска */
$mask = "abcd";
$mask_length = strlen($mask);

/* Маскируем */
for ($i = 0; $i < $str_length; $i++)
{
	$byte = $str[$i];
	$byte_mask = $mask[$i % $mask_length];
	$str[$i] = $byte ^ $byte_mask;
}

$str;	/* Здесь теперь замаскированное сообщение, некая бинарная каша */

/* Размаскируем, т.е. делаем тоже самое что и при маскировке */
for ($i = 0; $i < $str_length; $i++)
{
	$byte = $str[$i];
	$byte_mask = $mask[$i % $mask_length];
	$str[$i] = $byte ^ $byte_mask;
}

/* Сообщение востановлено */
echo $str;

Текст сообщения должен быть в кодировке UTF-8.

Примеры сообщений.

"Hello World"
81 0B 48 65 6C 6C 6F 20 57 6F 72 6C 64

"Hello World" (маскированное сообщение)
81 8B 31 32 33 31 79 57 5F 5D 5E 12 64 5E 43 5E 57

"Однажды, в студеную зимнюю пору,\nЯ из лесу вышел; был сильный мороз."
81 79 D0 9E D0 B4 D0 BD D0 B0 D0 B6 D0 B4 D1 8B	
2C 20 D0 B2 20 D1 81 D1 82 D1 83 D0 B4 D0 B5 D0 
BD D1 83 D1 8E 20 D0 B7 D0 B8 D0 BC D0 BD D1 8E 
D1 8E 20 D0 BF D0 BE D1 80 D1 83 2C 0A D0 AF 20 
D0 B8 D0 B7 20 D0 BB D0 B5 D1 81 D1 83 20 D0 B2 
D1 8B D1 88 D0 B5 D0 BB 3B 20 D0 B1 D1 8B D0 BB 
20 D1 81 D0 B8 D0 BB D1 8C D0 BD D1 8B D0 B9 20 
D0 BC D0 BE D1 80 D0 BE D0 B7 2E 00

"Однажды, в студеную зимнюю пору,\nЯ из лесу вышел; был сильный мороз." (маскированное сообщение)
81 F9 36 31 30 64 E6 AF E0 D0 E6 8C E0 D4 E6 87	
E0 D0 E7 BA 1C 44 E6 83 10 B5 B7 E0 B2 B5 B5 E1 
84 B4 83 E1 8D B5 B5 E0 BE 44 E6 86 E0 DC E6 8D 
E0 D9 E7 BF E1 EA 16 E1 8F B4 88 E0 B0 B5 B5 1D 
3A B4 99 11 E0 DC E6 86 10 B4 8D E1 85 B5 B7 E0 
B3 44 E6 83 E1 EF E7 B9 E0 D1 E6 8A 0B 44 E6 80 
E1 EF E6 8A 10 B5 B7 E1 88 B4 8D E0 BC B4 8B E0 
BB B4 8F 11 E0 D8 E6 8F E1 E4 E6 8F E0 D3 18 00

Реализация сервера на PHP

Исходники простого WebSocket echo-сервера выложил сюда. Код хорошо документирован, но я всё же опишу некоторые тонкости реализации. Чтобы «поднять» WebSocket сервер нужно создать обычный TCP-сервер. В PHP TCP-сервер реализуется через «stream_socket» или через PHP расширение «sockets». Различия между ними в том, что «stream_socket» работает на встроенных функциях PHP для работы с потоками, «sockets» же работает через модуль PHP и повторяет функции для работы с сокетами в языке «C». Я выбрал «sockets».

Процесс реализован через «while» с задержкой 0.2 секунды. Процесс не форкается и сообщения выбрасывает в консоль, поэтому запускать необходимо только через консоль. Для того, чтобы обслуживать несколько клиентов одновременно, сокет делаю неблокирующим и через «socket_select» каждые 0.2 секунды прослушиваю сокет. При рукопожатии проверяю только наличие заголовков.

Фреймы парсю через «pack/unpack». Сервер не понимает фрагментированных фреймов. Сервер выдаёт только незамаскированные сообщения, т.к. некоторые браузеры не понимают замаскированных сообщений. Сервер реагирует только на текстовые фреймы и фрейм закрытия соединения, бинарные фреймы не понимает.

Ну собственно всё, удачи в исследовании этого не простого протокола.

Источники:

вебсокет, websocket, php, javascript, вебсокет фреймы, маскировка сообщения

Комментарии:

Константин
от
07.06.2016 - 15:57:51
Очень хорошая статья, наглядно показано, как работает эта технология
Комментировать
link26
от
21.06.2016 - 18:21:22
Отличный текст, спасибо за подробный разбор =)
Комментировать
Валерий
от
07.03.2017 - 16:07:54
Ну наконец-то! Нормальная статья, в которой доходчиво всё разжёвано.
Комментировать

Добавить комментарий

x

Добавить