Веб-приложения реального времени: jSocket,Node.JS, Redis, MQ.
Приветствую своих читателей. Сегодня мы продолжим начатую ранее тему о веб-приложениях реального времени и поговорим о серверной части. Буквально на днях по аське у меня состоялся разговор по теме онлайн игр и архитектуры движка для реалтайм игры. Оказалось, мы оба думали про одно и то же, а именно, использование NodeJS как сервера для ядра системы, обслуживающего клиентские подключения. Конечно, построить весь технологический стек современной браузерной игры полностью на NodeJS все ещё затруднительно, да и сам процесс написания масштабных приложений на серверном JS еще не изучен. А вот держать комет-подсистему, которая отвечает за мгновенную доставку нужных данных клиентам – для этого Node подходит идеально. Но давайте отложим рассмотрение этой части, здесь тема не на одну статью, а поговорим про создание распределенной системы обмена данными для серверной платформы.
Репост с http://abrdev.com/?p=1121
Для примера возьмем систему новостных лент. У вас есть сервис, который предоставляет пользователям возможность просматривать новости, при этом необходимо максимально упростить получение новостей, чтобы очень быстро новости оказались в браузере пользователя. Конечно, лучшим выбором был бы pubsubhubbub от Google, но пока сложно с его поддержкой. Поэтому, логично, мы разделяем свой сервис на несколько независимых элементов – планировщик фидов будет периодически рассылать задания на проверку той или иной ленты, согласно своему алгоритму, обработчик лент будет загружать ленту и проверять, появились ли новые сообщения, и комет-сервер, получая новые сообщения, будет отправлять их пользователям. Казалось бы, все просто. Но нет. Каждая из подсистем это отдельный сервис или даже сервер, и перед вами станет задача эффективного хранения разделяемых данных, быстрая их передача и уведомление других компонент. Для этого применяют системы очередей сообщений (MQ) о которых мы уже неоднократно говорили. Сегодня же поговорим о младшем брате MQ, а именно – архитектуре Pub/Sub. При таком подходе одни системы являются подписчиками специальных каналов, а другие публикуют в них информацию. При этом обе стороны могут ничего не знать друг о друге, публикующий может не знать кто и сколько народу слушает канал, к какому серверу они подключены, на каком языке написаны и т.п. Он не занимается адресной доставкой всем клиентам, для него публикация – это однократная запись на сервер в указанный канал. С другой стороны, подписчики также не знают друг о друге, а просто следуют указаниям слушать канал и реагировать на поступление новой информации. Всю промежуточную работу по координации сообщений и доставке их всем подписчикам берет на себя сервер сообщений. Почему просто сообщений, а не очередей? Очереди, хранение сообщений для отключенных клиентов и т.п., это уже свойства более высокоуровневых систем, нас же интересует только онлайн часть, когда сообщения только передаются тем клиентам, кто в момент публикации был подключен к серверу.
Из самых простых, не удержусь, чтобы не сказать, приятных, такого рода серверов является, как ни странно, Redis. Да, в первую очередь это отличный NoSQL сервер, позволяющий оперировать не только классическими парами ключ/значение, но и более продвинутыми вроде списков, наборов значение, атомарными счетчиками, сортированными наборами и т.п. Это существенно расширяет возможности применения сервера как замену традиционным SQL базам в тех приложениях, где необходима высокая скорость работы и гибкость. С таким функционалом Redis уже может выступать как ядро веб-приложения, однако для построения законченной инфраструктуры был необходим механизм подписок, который приходилось эмулировать через атомарные операции со списками. В версии 2.0 появился встроенный Pub/Sub механизм, и теперь создавать распределенные системы на базе редиса стало очень легко (однако со многими предостережениями и зависимостями от клиентских библиотек, например, я не уверен что во всех вариантах РНР API есть поддержка этого функционала).
Pub/Sub в Redis-е предоставлен несколькими командами. Простейшая, PUBLISH, принимает только имя канала, произвольную строку, и данные, которые в него необходимо сохранить. Это атомарная операция, которая выполняется, конечно же, полностью асинхронно. Ваше приложение сразу же получает ответ сервера (или сообщение об ошибке), также сервер информирует о количестве клиентов, слушающих канал. Но само сообщение будет опубликовано в любом случае, вне зависимости от количества слушателей и вообще их наличия. Важно заметить, что команда публикации может вызываться вместе с любыми другими командами. К примеру, у нас в проекте мы сначала сохраняем в определенном буфере (List) новое значение, получая всегда актуальный список последних данных длиной в размер листа (у нас это 100 элементов), и только после этого публикуем в канал. Этим мы гарантируем, что даже если клиент подключиться после публикации данных, он всегда сможет получить последнее значение, без ожидания новых данных.
Команды подписки уже несколько сложнее. Первое и самое важное – подписка является блокирующей операцией, а это значит что ваше приложение, используя одно подключение к Redis-серверу, не может параллельно выполнять никакие другие команды, кроме подписок или отписки. В случае, если вам необходимо работать с базой и использовать другие команды, это следует делать или до первой команды подписки, или после отписки от всех каналов, либо создавать новое подключение. Подписка на канал означает, что клиент оставляет подключение к серверу открытым и ожидает, когда придут данные. Сервер, опираясь на канал, указанный в команде публикации, просматривает список всех клиентов, собирает тех, кто подписан и рассылает всем новое сообщение.
Подписаться можно как на произвольный канал, так и, что самое приятное, на неограниченное количество каналов, используя маску (символ *, который традиционно означает любое количество символов). Если у вас есть два канала, news_finance и news_internet, вы можете подписаться на оба из них путем команды SUBSCRIBE news_finance news_internet, либо сразу на оба используя метаподписку PSUBSCRIBE news_*. Кстати, можно подписываться сразу на несколько каналов по маске, например, PSUBSCRIBE news_* users_*. Обратите внимание, что следующая команда подписки отменяет предыдущую, то есть мы автоматически отписываемся от всех каналов прежде чем подписаться на новые (однако я не полностью уверен в таком алгоритме работы, в документации об этом ничего не сказано). А можно вообще подписаться на канал *, то есть на любые каналы, куда бы не было опубликовано сообщение.
Клиент также может отписаться в любое время от одного или нескольких (или всех) каналов, используя точно такой же синтаксис, команды UNSUBSCRIBE и PUNSUBSCRIBE, которые, если вызваны без параметров, отписывают от всех каналов.
И наконец, сообщения. Это могут быть любые данные, приведенные к целому числу и строке. То есть, для обмена специфическими для приложения структурами, их следует сериализовать, например в JSON. Клиент, подписанный на канал, получает не только сами данные, как в остальных командах Redis-а, а также и дополнительную информацию – название канала, куда было опубликовано сообщений. Это надо в случаях, когда вы подписываетесь по маске, но хотите предпринимать те или иные действия в зависимости от того, куда было отправлено сообщение.
Вот такой механизм. Теперь посмотрим, как его использовать на практике. Как уже говорилось выше, у нас есть система, проверяющая различные новостные ленты и, если обнаруживает новые сообщения, сохраняет их а базу данных, а потом публикует в Redis. Это сделано, чтобы обработчик лент не останавливал свою работу пока не оповестит всех клиентов – зачем ему вообще знать, кто что делает с сообщениями. Он просто запаковывает новое сообщение в JSON-пакет, сериализирует его в строку, а потом публикует в канал. Но вот вопрос, а каким образом создавать каналы? Если на каждую ленту свой канал, то будет достаточно сложно подписываться на несколько каналов сразу. Публиковать же все новости в один общий канал также не выход, для клиента, которого интересует только одна лента, будет много трафика ему не нужного. Да и незабываем, что несмотря на снятие нагрузки с самого сервера новостей, нагрузка остается на Redis. Самой сложной операцией является как раз PUBLISH, его сложность O(N+M), где N – число подключенных к каналу клиентов, а M – общее количество подписок по маске на всем сервере. А вот подписка на один канал, без маски, самая простая операция. Поэтому для сохранения гибкости, в более менее сложных системах приходится прибегать к комбинации каналов, дублируя данные в несколько каналов, для того, чтобы клиентам было легче подписываться на интересующие их данные и избегать подписок на ненужные. Для нашего примера я бы предложил следующую схему – группируем все новости по тематике (например, tech, politics, art, people, economic), и для каждой темы создаем свою очередь, допустим, news.tech, news.polytics и т.п. Также создаем каналы индивидуально для каждой новостной ленты, используя для этого какой либо уникальный идентификатор, не обязательно читабельный для человека, например, MD5 хеш от URL-а ленты (полного, а не только домена). Это позволит клиентам, которым надо только одна-две ленты, получать только их и игнорировать все остальное. И создаем общий канал news_all куда будут публиковаться все новости из всех лент. Это для тех клиентов, которым надо весь поток вне зависимости от количества лент. Каждое новое сообщение будет дублироваться в три канала – в свой личный, в общетематический и в общий для всех. Да, это немного усложняет сам процес публикации, дублирует данные (но за счет того, что данные не хранятся, можно не опасаться перерасхода памяти на сервере, только следить за трафиком между подписчиками и сервером).
Писать мы конечно же будет это все на базе NodeJS. Честно говоря, я не знаю даже, какие сейчас еще клиенты для Redis поддерживают Pub/Sub. Так что это, можно сказать, небольшая победа NodeJS. С публикацией думаю все разберутся самостоятельно, а мы сейчас напишем варианты клиентского кода, который подписывается на каналы.
Использовать мы будем клиент http://github.com/fictorial/redis-node-client который, на мой взгляд, сейчас самый лучший и наиболее поддерживающий все фичи сервера. Обратите внимание, что вся его архитектура асинхронная и вы должны всегда указывать обработчик для команды, даже если фактически ничего делать не будете. Первым параметром в обработчик передается err – значение null означает, что команда выполнена успешно, другое же будет свидетельствовать об ошибке. Вторым параметром идет Buffer, в котором содержится сам ответ с данными. Для дальнейшей работы обычно его приводят к строке через вызов toString(‘ascii’) указывая желаемую кодировку. А вот для команд подписки логика немного иная – первым параметром будет не ошибка, а имя канала, вторым – само сообщение. Это актуально при подписке на каналы по маске – так мы сможем определить, какому каналу принадлежит полученное сообщение.
И так, подключаем библиотеку:
1 |
<span style="color: #008000; font-weight: bold;">var</span> redisClient <span style="color: #666666;">=</span> require(<span style="color: #ba2121;">"./lib/redis-client"</span>); |
Colored with dumpz.org
Теперь устанавливаем соединение с сервером, а также добавляем стандартные обработчики событий на подключение, ошибки и т.п. После подключения сразу переключаемся на вторую базу, которая будет использоваться только системой обработки новостей. Свойство debugMode полезно при отладке, так как показывает весь процесс подключения и формат запроса/ответа, если вы впервые работаете с Redis оно очень вам поможет.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
redis <span style="color: #666666;">=</span> redisClient.createClient(<span style="color: #666666;">6379,</span> <span style="color: #ba2121;">'127.0.0.1'</span>); redisClient.debugMode <span style="color: #666666;">=</span> <span style="color: #008000; font-weight: bold;">false</span><span style="color: #666666;">;</span> redis.addListener(<span style="color: #ba2121;">'connected'</span><span style="color: #666666;">,</span> <span style="color: #008000; font-weight: bold;">function</span>(){ sys.puts(<span style="color: #ba2121;">'We connected to Redis server'</span>); redis.select(<span style="color: #666666;">2</span>); <span style="color: #408080; font-style: italic;">//выбираем базу под номером 2</span> }); redis.addListener(<span style="color: #ba2121;">'reconnected'</span><span style="color: #666666;">,</span> <span style="color: #008000; font-weight: bold;">function</span>(){ sys.puts(<span style="color: #ba2121;">'Successful reconnect to Redis server...'</span>); redis.select(<span style="color: #666666;">2</span>); }); redis.addListener(<span style="color: #ba2121;">'end'</span><span style="color: #666666;">,</span> <span style="color: #008000; font-weight: bold;">function</span>(){ sys.puts(<span style="color: #ba2121;">'Close connection to Redis server...'</span>); }); redis.addListener(<span style="color: #ba2121;">'noconnection'</span><span style="color: #666666;">,</span> <span style="color: #008000; font-weight: bold;">function</span>(){ sys.puts(<span style="color: #ba2121;">'No connection to Redis server'</span>); }); |
Colored with dumpz.org
Теперь можно работать с подписками. Как мы помним, нельзя комбинировать подписку с другими командами, поэтому если вам необходимы другие возможности Redis-а, придется создавать второе подключение. Ещё одно замечание относительно библиотеки для доступа к Redis-у. Автор постарался перевести весь API точно так же как описаны команды в документации, однако для команд подписки синтаксиc немного другой. Для подписки используйте команду subscribeTo. Однако в документации и исходном коде последних версий это уже исправлено и все команды соответствуют описаниям в Redis API. А дальше все просто: подписываемся на все каналы новостей, начинающиеся на news_:
1 2 3 4 5 6 7 8 9 10 |
redis.subscribeTo(<span style="color: #ba2121;">'news_*'</span><span style="color: #666666;">,</span> <span style="color: #008000; font-weight: bold;">function</span>(ch<span style="color: #666666;">,</span> dt){ <span style="color: #008000; font-weight: bold;">var</span> _ch <span style="color: #666666;">=</span> ch.toString(<span style="color: #ba2121;">'utf8'</span>); <span style="color: #408080; font-style: italic;">//это имя канала</span> <span style="color: #008000; font-weight: bold;">var</span> _dt <span style="color: #666666;">=</span> dt.toString(<span style="color: #ba2121;">'utf8'</span>); <span style="color: #408080; font-style: italic;">//данные</span> <span style="color: #008000; font-weight: bold;">if</span> (<span style="color: #666666;">!</span>_.isEmpty(_dt)) { sys.log(<span style="color: #ba2121;">'New data: '</span> <span style="color: #666666;">+</span> _dt); } }); |
Colored with dumpz.org
Теперь наша функция будет автоматически вызвана при поступлении любого нового сообщения в канал, а мы можем обрабатывать данные в зависимости от имени канала или же сначала преобразовать сообщение с JSON-строки в объект.
Что ещё я не тестировал, и о чем нет данных в документации, так это как согласуется система подписок и репликация master-slave. Возможно ли публиковать на мастер-сервере, а клиенты будут подписываться к второму серверу. Также хотелось бы получить и постоянные очереди, например, хранящие последние N сообщений и передавать их каждому новому клиенту. Но это уже дополнительные функции, свою главную задачу, мгновенного оповещения клиентов о новых данных, система выполняет очень хорошо и быстро.