Вебсокеты — это прогрессивный стандарт полнодуплексной (двусторонней) связи с сервером по TCP-соединению, совместимый с HTTP. Он позволяет организовывать живой обмен сообщениями между браузером и веб-сервером в реальном времени, причем совершенно иным способом, нежели привычная схема «запрос URL — ответ». Когда два года назад я присматривался к этому стандарту, он был еще в зачаточном состоянии. Существовал лишь неутвержденный набросок черновика и экспериментальная поддержка некоторыми браузерами и веб-серверами, причем в Файрфоксе он был по умолчанию отключен из-за проблем с безопасностью. Однако теперь ситуация изменилась. Стандарт приобрел несколько ревизий (в том числе без обратной совместимости), получил статус RFC (6455) и избавился от детских болезней. Во всех современных браузерах, включая IE10, заявлена поддержка одной из версий протокола, и есть вполне готовые к промышленному использованию веб-серверы.
Я решил, что настало время попробовать это на живом проекте.
Суть задачи
На моем личном небольшом сайте Клавогонки.ру есть центральная часть — список действующих в данных момент игр-заездов. Список крайне динамичный: новый заезд создается игроками каждые несколько секунд, иногда несколько в секунду. Заезды начинаются с отсчета времени, по завершению перемещаются из раздела открытых игр в раздел активных. После выхода всех игроков заезд убирается со страницы. В один заезд заходит от одного и иногда до ста игроков, что требуется отображать тут же.
Как это работало раньше
Изначально, когда появилась необходимость сделать эту часть функциональности сайта, возникло много трудностей с динамическим обновлением списка. На странице списка могут находиться десятки человек одновременно, каждый из которых хочет видеть свежую картину. У многих заездов может быть таймаут отсчета от создания до старта всего 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. Она позволяет использовать вебсокеты как основной транспорт, откатываясь при этом на другие транспорты вроде флеша или 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. Если есть интерес, то следующие топики будут на эту тему.
Автор: artch