Реалтайм-система мониторинга активности пользователей на сайте. Теперь на Node.js + Socket.IO

в 8:21, , рубрики: node.js, мониторинг активности пользователей, метки:

Добрый день.

В этой статье я расскажу о том, как реализовать систему мониторинга активности пользователей с помощью Node.js и Socket.IO. Выглядит это примерно так:
image

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

Задача

Итак, для сервера на Node.js (express, connect) необходимо было реализовать данную систему мониторинга, которая информировала бы об активности пользователей в реальном времени (т.е. вышедший из системы пользователь сразу пропадал из списка "Пользователи онлайн", перешедший на другую страницу — пропадал из "Эту страницу просматривают", а вошедший, соответственно, появлялся в списках).
Сразу оговорюсь, что ввиду закрытости системы от остального мира, обязательным началом работы является аутентификация.

Ну и прежде чем приступать — тем, кто не очень знаком с азами Socket.IO советую посмотреть на типовую реализацию чата.

Клиент

Клиентская часть в данном деле реализуется довольно просто, поэтому не буду заострять внимание на внешней красоте. Упомяну лишь что нижеприведённый код включён в базовый шаблон, т.е. присутствует на каждой загружаемой странице.
Вот список того что нам понадобится: элементы с id=«sockstat» (для отображения статуса подключения), с id=«alsohere» (для списка тех, кто просматривает страницу), id=«online» (для списка всех тех, кто онлайн), естественно вот такая штука

<script type="text/javascript" src="/js/lib/jquery.js"></script>
<script type="text/javascript" src="/socket.io/lib/socket.io.js"></script>

,
и маленькая вспомогательная ф-ия, делающая из массива элементы маркированного списка:

function lister(arr) {
    var s = '';
    $.each(arr, function(key, value) {                            
        s += '<li><b>' + value.name + '</b> (login: ' + value.login + 
             ', id: ' + value.user_id + ')</li>';
    });
    return s;
}

Теперь всё готово. Механизм таков:


$(document).ready(function() {                                                                
    var socket = io.connect("http://localhost:8080"); // пытаемся подключиться к серверу сокетов
    
    socket.on("connect", function () { // подключившись, делаем следующее:
        $("#sockstat").text("connected ok");  
                // пишем в соответствующий элемент что связь установлена
        socket.emit("iamhere", { location: document.URL } ); 
                // и посылаем серверу своё местоположение
                
        socket.on("alsohere", function (alsohere) { 
                // получив от него список просматривающих эту же страницу, выводим его:           
            $("#alsohere").html('Эту страницу просматривают: <ul>' + lister(alsohere) + '</ul>');
        });
        
        socket.on("online", function (online) {  
                // аналогично поступаем со списком всех онлайн-пользователей
            $("#online").html('Пользователи онлайн: <ul>' + lister(online) + '</ul>');            
        });
                            
        socket.on("disconnect", function () {   
                // если связь потеряна - пишем об этом (почему спустя полсекунды, объясню чуть ниже)
            setTimeout(function () {
                $("#sockstat").text("connection lost!");
            }, 500);                        
        });
    });
});   

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

Вроде просто? А так и есть.

Сервер: поверхность

В коде запускаемого файла проекта — в index.js, помимо всяких штук типа

app.configure();

подключаем наш рукописный sockets.js, отвечающий за работу с сокетами, необходимыми в нашем деле:

require('./sockets');

В нём помимо собственно обработки событий, содержится функция авторизации — которая и даёт разрешение на создание подключения для тех, кто подходит по определённым критериям. В нашем случае это те, кто предварительно залогинился.
В общем виде это выглядит вот так (обратите внимание на sio.set('authorization'...)).

А теперь сам алгоритм. Для начала — на русском языке.
Обозначений тут будет 2:

  • online — список всех пользователей он-лайн
  • alsohere — список пользователей на той же странице, что и клиент

Этапов в алгоритме тоже 2 — обработка нового подключения и обработка сообщения о том что клиент отключился.
Как только к нам (серверу) подключается, т.е. проходит авторизацию, заходит на страницу и сообщает своё местоположение —

socket.emit("iamhere", { location: document.URL } ); 

клиент, мы должны:

  1. запомнить кто зашёл и куда (добавить его в online)
  2. разослать всем активным обновлённый online
  3. разослать всем просматривающим ту же страницу — обновлённый alsohere

Когда же клиент отключается —

  1. запомнить кто вышел и откуда(убрать его из online)
  2. разослать всем активным обновлённый online
  3. разослать просматривающим страницу, с которой только что ушёл клиент, обновлённый alsohere

Логично? Продолжаем.

Сервер: чуть глубже

Для реализации вышеописанного я решил особо не нагружать файл socket.js и вынес функции «подай-принеси» в отдельный файл — auth.js.
Он устроен так:

var auth = function () {
    "use strict";
                                     // Private - наши внутренние переменные/функции
    var __users = [],
        ...,

	return {
        ...
                                      // Public - экспортируемые переменные/функции
	};
}();
module.exports = auth;

Основной же блок socket.js таков:

sio.sockets.on('connection', function (socket) {
    var hs = socket.handshake;             

    auth.addActiveUser({ login: hs.session.user, name: hs.session.username, id: hs.session.user_id });
        // вот он, первый шаг при подключении - добавляем пользователя в список активных
    socket.on('iamhere', function (msg) {   
        // а получив от него сигнал о местоположении - запоминаем его
        auth.addPageActiveUser({                                                
            login: hs.session.user, 
            path: msg.location, 
            path_id: socket.id 
        });

        auth.getListActiveUser(function (online) {
        // затем рассылаем всем обновлённый online (шаг 2)                         
            socket.emit('online', online);
            socket.broadcast.emit('online', online);
        });    
        
        auth.getListByPageActiveUser({ path: msg.location }, function (alsohere) {
                           // блок проще чем кажется - всего лишь рассылаем пользователям, сидящим
                           // на той же странице, куда зашёл новенький, обновлённый список alsohere
            var i,
                len;
                            
            auth.getListByPageConnection({ path: msg.location, users: alsohere }, function (connections) {
                len = connections.length;
                for (i = 0; i < len; i++) {
                    sio.sockets.sockets[connections[i].id].emit('alsohere', alsohere);
                }
            });
        });
    });    

    socket.on('disconnect', function () {     // действия при дисконнекте аналогичны:                                
        var s = auth.getPageByIdConnection(socket.id);  
                // определяем, какую страницу закрыл пользователь,
        setTimeout(function () {
            
            auth.removeActiveUser({ login: hs.session.user }); // вычёркиваем его из списка активных,
            auth.getListActiveUser(function (online) {        // рассылаем всем обновлённый online,
                socket.broadcast.emit('online', online);
            });          
            
            auth.removePageActiveUser({ login: hs.session.user, path_id: socket.id }); 
                                        // вычёркиваем закрытую страницу из списка открытых им
                                                    
            auth.getListByPageActiveUser({ path: s }, function (alsohere) { 
                                        // и рассылаем всем тем, кто сидел на свежезакрытой странице
                                        // их новый список alsohere
                var i,
                    len;
                    
                auth.getListByPageConnection({ path: s, users: alsohere }, function (connections) {
                    len = connections.length;
                    for (i = 0; i < len; i++) {
                        sio.sockets.sockets[connections[i].id].emit('alsohere', alsohere);
                    }                
                });              
            });        
        }, 1000); // и снова хитрость с таймером - дабы не смущать 
        // других клиентов "мельканием" человека, переходящего со страницы на страницу
    });
    
});

Вуаля.

Раскрытие магии

В модуле auth все эти данные о пользователях — об их подключениях и страницах, сессиях — хранятся в виде объекта в приватной переменной __activeUsers.
Для каждого пользователя создаётся поле — __activeUsers[login], содержащее в себе поля:

  • name — имя пользователя — не путать с логином («vasya»/«Василий Иванович»)
  • user_id — внутренний id пользователя — как и предыдущее поле, используется для последующей передачи в клиент, например для формирования ссылок "/users/user_id"
  • locations — массив объектов, состоящих из двух полей: path — собственно путь к открытой странице и id — id данного подключения к сокету

Соответственно, модуль auth выставляет наружу только public-методы, работающие с вышеописанной переменной. Они представляют собой ф-ии, состоящие из переборов for, for..in, push'ей и splice'ов:

  • addActiveUser — добавляет данные о новом пользователе в __activeUsers
  • addPageActiveUser — добавляет пользователю новую открытую страницу (__activeUsers[sLogin].locations.push({ path: sPath, id: sPath_id });)
  • getListActiveUser — возвращает список активных пользователей (for (i in __activeUsers) { list.push(...
  • getListByPageActiveUser — по переданному пути возвращает список юзеров, просматривающих ту же страницу
  • getListByPageConnection — по переданному адресу страницы возвращает список айдишников коннектов, ссылающихся на ту же страницу
  • getPageByIdConnection — по id коннекта возвращает адрес страницы (покидая страницу клиент передаёт нам только id, а не путь)
  • removeActiveUser — декрементирует юзеру его кол-во открытых страниц
  • removePageActiveUser — удаляет закрытую страницу из списка открытых

Подключаясь с серверу и успешно проходя аутентификацию, пользователь получает socket.handshake с уникальным id для данного пользователя.
Открывая новую страницу, на это подключение заводится уникальный socket.id для данной страницы. То есть 1 пользователь с 10ю открытыми страницами — это 1 handshake.id и 10 разных socket.id.
А дальше дело техники — манипулируя этими данными, мы с использованием вышеописанной структуры данных всегда можем сказать кто и что у нас просматривает/редактирует.
И вся «сложность» реализации состоит в том чтобы перебирать поля объекта и массивы, доставая нужное. А это мы умеем :)

Так что теперь можно ещё раз посмотреть на вышеприведённый кусок кода и всё станет ясно.
Надеюсь принцип работы вы поняли, где какие id не перепутаете, куда нужно вставите таймауты для сглаживания переданной клиенту информации; ну а с реализацией для вашей конкретной задачи проблем возникнуть не должно.
Удачи.

P.S. Opera (тестировал на v.11.62) при закрытии страниц, в отличие от FF и хрома, не утруждает себя отсылкой серверу сообщения о дисконнекте клиента. Потому отключившиеся/покинувшие страницу пользователи висят в списке активных ещё несколько секунд, пока сервер их не отключит автоматически по таймауту.

Автор: Keenest


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js