Pull to refresh

Вебсокеты: боевое применение

Reading time 6 min
Views 78K
imageВебсокеты — это прогрессивный стандарт полнодуплексной (двусторонней) связи с сервером по TCP-соединению, совместимый с HTTP. Он позволяет организовывать живой обмен сообщениями между браузером и веб-сервером в реальном времени, причем совершенно иным способом, нежели привычная схема «запрос URL — ответ». Когда два года назад я присматривался к этому стандарту, он был еще в зачаточном состоянии. Существовал лишь неутвержденный набросок черновика и экспериментальная поддержка некоторыми браузерами и веб-серверами, причем в Файрфоксе он был по умолчанию отключен из-за проблем с безопасностью. Однако теперь ситуация изменилась. Стандарт приобрел несколько ревизий (в том числе без обратной совместимости), получил статус RFC (6455) и избавился от детских болезней. Во всех современных браузерах, включая IE10, заявлена поддержка одной из версий протокола, и есть вполне готовые к промышленному использованию веб-серверы.

Я решил, что настало время попробовать это на живом проекте. И теперь делюсь, что из этого вышло.

Суть задачи


На моем личном небольшом сайте Клавогонки.ру есть центральная часть — список действующих в данных момент игр-заездов. Список крайне динамичный: новый заезд создается игроками каждые несколько секунд, иногда несколько в секунду. Заезды начинаются с отсчета времени, по завершению перемещаются из раздела открытых игр в раздел активных. После выхода всех игроков заезд убирается со страницы. В один заезд заходит от одного и иногда до ста игроков, что требуется отображать тут же.

image

Как это работало раньше


Изначально, когда появилась необходимость сделать эту часть функциональности сайта, возникло много трудностей с динамическим обновлением списка. На странице списка могут находиться десятки человек одновременно, каждый из которых хочет видеть свежую картину. У многих заездов может быть таймаут отсчета от создания до старта всего 10-20 секунд, и чтобы успеть присоединиться к ним, обновление должно происходить достаточно живо.

Обновление всей страницы по таймауту здесь не подходило вообще никак, и нужно было искать другие варианты (тут надо сделать ремарку, что использовать флеш на сайте не хотелось без очень сильной на то необходимости).

Самым очевидным и простым решением здесь на первый взгляд казался long-polling — висящее подключение к серверу, которое обрывается в момент поступления нового события и переоткрывается заново. Однако после некоторых тестов этот вариант тоже оказался нежизнеспособным: события поступали непрерывным потоком, ведь клиенту нужно сообщать не только о создании новой игры, но и об изменении параметров всех игр (например, старт таймаута, смена статуса, состава игроков), и количество запросов начало вызывать определенную степень недовольства у сервера. Да и оверхед на открытие-закрытие запросов тоже выходил немаленький.

HTTP-streaming не получилось использовать из-за проблем с прокси-серверами у многих пользователей.

Поэтому я остановился на простом варианте обновления содержимого страницы по таймауту раз в 3 секунды через ajax-запросы. На сервере текущие данные кешировались и отдавались клиентам из кэша в json, при этом для экономии трафика отдавался не весь список каждый раз, а лишь измененные данные через систему версионирования (увеличилась версия по сравнению с запрашиваемой — отдаем новую информацию о заезде, иначе отдаем только текущий номер версии).

Система показала себя неплохо и проработала долгое время. Однако был большой минус — очень трудно зайти в заезд с 10-секундным таймаутом до старта. Кроме того, это совсем не соответствовало духу динамичной гоночной онлайн-игры и выглядело не слишком технологично в целом.

Увидеть эту страницу в ее старом варианте вы можете по этой ссылке.

Как это работает сейчас


Если говорить кратко, вебсокеты позволили внести драйв в весь этот процесс.

Для начала был выбран сервер, который должен жить в связке с действующим игровым бэкэндом. По ряду причин я выбрал для этого node.js — событийно-ориентированная модель и хорошо развитые коллбэки в javascript идеально подошли для этой задачи.

Общей средой общения между php-бэкэндом и сервером на node.js стали pubsub-каналы redis. При создании новой игры или любом действии, изменяющем данные, php делает примерно следующее (код здесь и далее сильно упрощен):

$redis = new Redis();
$redis->pconnect('localhost', 6379);
$redis->publish("gamelist", json_encode(array(
		   		"game created", array(
		   			'gameId' => $id))));

Redis работает как отдельный демон на отдельном TCP-порте и принимает/рассылает сообщения от любого количества подключенных клиентов. Это дает возможность хорошо масштабировать систему, невзирая на количество процессов (ну и серверов, если думать оптимистично) php и node.js. Сейчас крутится примерно 50 php-процессов и 2 node.js-процесса.

На стороне node.js при старте идет подключение к прослушке redis-канала под названием gamelist:

var redis = require('redis').createClient(6379, 'localhost'),
redis.subscribe('gamelist');	

Для работы с клиентами используется обвязочная библиотека Socket.IO (upd: товарищи Voenniy и Joes в комментах говорят, что есть более качественные альтернативы вроде SockJS и Beseda, что вполне может быть правдой). Она позволяет использовать вебсокеты как основной транспорт, откатываясь при этом на другие транспорты вроде флеша или xhr-polling если браузер не поддерживает вебсокеты. Вдобавок, она упрощает работу с клиентами в целом, например дает API для мультиплексирования и разделения подключенных клиентов по разным псевдокаталогам (каналам), позволяет именовать события и некоторые другие плюшки.

var io = require('socket.io').listen(80);
var gamelistSockets = io.of('/gamelist');	

При подключении браузера клиента к ws://ws.klavogonki.ru/gamelist он распознается как подключенный к socketio-каналу gamelist. Браузер для этого делает следующее:

<script src="http://ws.klavogonki.ru/socket.io/socket.io.js" type="text/javascript"></script>
...
<script type="text/javascript">
var socket = io.connect('ws.klavogonki.ru/gamelist');
</script>


При поступлении по redis-каналу события из бэкэнда оно всячески предварительно анализируется и потом отсылается всем подключенным клиентам в gamelistSockets:

redis.on('message', function(channel, rawMsgData) {		
		
	if(channel == 'gamelist') {	
			
		var msgData = JSON.parse(rawMsgData);		
		var msgName = msgData[0];		
		var msgArgs = msgData[1];  
			
		switch(msgName) {
			
			case 'game created': {
				...
				gamelistSockets.emit('game created', info);	
				break;
			}		
				
			case 'game updated': {
				...
				gamelistSockets.emit('game updated', info);					
				break;
			}
				
			case 'player updated': {
				...
				gamelistSockets.emit('player updated', info);					
				break;
			}
		}			
	}			
});

Браузер получает событие ровно таким же образом и рендерит необходимые изменения на странице.

socket.on('game created', function(data) {
	insertGame(data);		
});
				
socket.on('game updated', function(data) {
	updateGame(data);	
});
		
socket.on('player updated', function(data) {
	updatePlayer(data);			
});


Принцип совершенно прост и ясен. Продвинутые технологии в основе этой схемы позволяют сильно упростить процесс и сосредоточиться на логике самой работы. Хотя пришлось несколько повозиться с переделкой некоторых частей php-кода для работы в идеологии «сообщаем об изменении, а не о состоянии», а также вынести домен вебсокетов на отдельную от основной машину (чтобы не мучиться с разделяющим прокси на 80 порту), но в сухом остатке плюсы вышли очень существенными:

  • Высочайшая динамичность интерфейса, обновление происходит в реальном времени, можно отслеживать единичные изменения и чувствовать себя в онлайн-игре, а не на страничке чата 90-х годов.
  • Практически полное отсутствие необходимости в кэшировании, ведь данные идут транзитом от бэкэнда прямо в браузер.
  • Органичная экономия трафика на отсылке только необходимых изменений состояния (если постараться прикрутить компрессию, то будет еще интересней).
  • Роста сетевой нагрузки практически незаметно, так как node.js разрабатывался как раз с целью держать и обрабатывать любое мыслимое число одновременных подключений; а рост нагрузки на цпу даже упал, ведь просчет изменения состояния делается один раз на бэкэнде и всем клиентам рассылается уже в готовом виде.
  • Событийно-ориентированная схема дает возможность знать о всех моментах изменений данных и, например, делать анимация всплывания и уплывания при этом.

Сплошной профит, короче.

Посмотреть на то, что получилось в итоге, вы можете здесь. Разница видна невооруженным взглядом.

В качестве бонуса две таблички, небольшая статистика по аудитории Клавогонок, браузеры и используемые в Socket.IO транспорты:
Браузер Доля Транспорт Доля
Chrome 51% websocket 90%
Firefox 20% xhr-polling 5%
Opera 15% flashsocket 4%
IE (примерно пополам 8 и 9) 6% jsonppolling 1%
Как видно, вполне готово к употреблению.

Итог


Тут могла бы быть заключительная резюмирующая часть с итогами, библиографией и моралью. Но я сэкономлю ваше время и скажу просто: вебсокеты — это очень круто!

P.S. Во вновь разрабатываемых частях проекта (включая описанное выше) используются также такие интересные слова, как mongodb и angular.js. Если есть интерес, то следующие топики будут на эту тему.
Tags:
Hubs:
+86
Comments 137
Comments Comments 137

Articles