Страницы

воскресенье, 2 марта 2014 г.

Пишем простой чат на Node.js. Вторая серия

В этом посте я продолжу свой рассказ о способах хранения данных приложения Node.js на примере создания простого чата на TCP-сокетах. Рассмотрим вариант хранения данных в файлах. Думаю сохранять данные через определенные промежутки времени будет дешевле, чем после каждого сообщения пользователей нашего чата, и здесь у меня два варианта реализации: с помощью функций setInterval или setTimeout.

Выбираю второй, объясню почему: в этом случае мы можем просечь момент завершения выполнения очередной итерации кода прежде, чем уйдем еще на один круг, что на мой взгляд надежнее, чем выполнять код по setInterval, когда мы не знаем наверняка завершилась предыдущая итерация или нет. Много букаффф :), сейчас станет понятнее....

Редактируем исходный код модели сообщений - модуль по имени message:
var messageData = require('./data');
var fs = require('fs');

module.exports = function(len, size, interval) {
  this.len = len || 100; // длина сообщения по умолчанию - 100 символов
  this.size = size || 19; //размер кэша сообщений по умолчанию - 20 штук  
  this.data = Array.isArray(messageData) ? messageData : [];

  this.insert = function(client, data, cb) {
    if(data.length > this.len) {
      cb('Too much information!');
      return;
    }
    if (this.data.length > this.size) {
      this.data.pop();
    }
    data = {data: data, from: client['_id'], ts: Date.now()};
    this.data.unshift(data);
    this.updated = true;
    cb(false, createMessage(data));
  }

  this.get = function(n) {
    var i = n || 5; // количество сообщений по умолчанию - 5 штук
    return this.data.slice(0, i).map(function(msg) {
      return createMessage(msg);
    }).reverse().join('\n');
  }
 
  var createMessage = function(msg) {
    return new Date(msg.ts).toLocaleTimeString() + '  ' + msg.from + ' >>> ' + msg.data;
  }
 
  this.interval = interval || 30000;
  var self = this;
 
  (function schedule() {
    setTimeout(function() {
      if(!self.updated) {
        console.log('--- no data to update ---');
        schedule();
        return;
      }
      fs.writeFile('./data.js', 'module.exports = ' + JSON.stringify(self.data), function(e) {
        if(e) {
          console.log('--- error updating data ---');
          schedule();
          return;
        }
        self.updated = false;
        console.log('--- data updated ---');
        schedule();
      });
    }, self.interval);
  }());
}

Что изменилось: на самой первой строке импортируем содержимое модуля data, в котором я предлагаю хранить сообщения чата - не самый лучший вариант, зато самый простой, позже рассмотрим более "правильный" - хранение данных в формате JSON.

Предлагаю сразу создать этот модуль - файл data.js - следующего содержания:
module.exports = []

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

Вернемся к модели данных - исходному коду модуля message.
Если раньше, до последней правки, объект, который экспортирует этот модуль содержал всего один метод - insert - для хранения сообщений в массиве, то теперь у нас появился еще один метод - get - который возвращает несколько последних по времени создания сообщений - по умолчанию 5.
И наконец то, о чем выше было сказано так много букаффф :) - функция schedule, которая через определенные интервалы - по умолчанию 30 секунд - сохраняет данные - сообщения - в файл.

В общем как сумел - рассказал, лучше у меня не получится.

На этом этапе у нас еще есть пара изменений в модулях pubsub и server - просто реализовал еще одну функцию обратного вызова - по событию join модуля pubsub - по паре строчек на каждый модуль.

pubsub.js:
var events = require('events');

module.exports = function() {
  var pubsub = new events.EventEmitter();
  pubsub.clients = {};
  pubsub.subscriptions = {};

  pubsub.on('join', function(socket, cb) {
    socket['_id'] = socket.remoteAddress + ':' + socket.remotePort;    
    this.clients[socket['_id']] = socket;

    this.subscriptions[socket['_id']] = function(client, data) {
      if (socket['_id'] == client['_id']) {
        data = '\033[1A' + data;        
      }
      this.clients[socket['_id']].write(data);     
    }
    this.on('broadcast', this.subscriptions[socket['_id']]);

    console.log('--- socket saved ---\nusers online: %d', this.listeners('broadcast').length);
    socket.write('Welcome to Chamber of Secrets!');
    cb();
  });

  pubsub.on('leave', function(socket) {
    delete pubsub.clients[socket['_id']];
    this.removeListener('broadcast', this.subscriptions[socket['_id']]);    
    socket.destroy();
    console.log('--- socket destroyed ---\nusers online: %d', this.listeners('broadcast').length);
  });

  pubsub.on('error', function(e) {
    console.log('--- pubsub error ---\n%s', e.message);
  });

  return pubsub;
}

server.js:
var net = require('net');
var PubSub = require('./pubsub'), 
    pubsub = new PubSub;
var Message = require('./message'), 
    message = new Message();
    
var server = net.createServer(function(socket) {
  socket.setEncoding('utf8');
  console.log('--- socket connected ---\nfrom: %s', socket.remoteAddress + ':' + socket.remotePort);  
  
  pubsub.emit('join', socket, function() {
    socket.write('\n' + message.get());
  });
  
  socket.on('data', function(data) {
    data = data.replace(/\r\n$/, '');
    console.log('--- socket data ---\n%s', data);    
    message.insert(this, data, function(err, data) {
      if(err) {
        socket.write(err);
        return;
      }
      pubsub.emit('broadcast', socket, data);
    });
  });

  socket.on('close', function() {
    console.log('--- socket closed ---');
    pubsub.emit('leave', this);
  });

  socket.on('end', function() {
    console.log('--- socket end ---');   
    pubsub.emit('leave', this);
  });
  
  socket.on('error', function(e) {
    console.log('--- server error ---\ncode: %s', e.code);
  });
});
server.listen(8124, function() {
  console.log('Chamber of Secrets is opened on port %d...', this.address()['port']);  
});

Приступаем к тестированию. Запускаем сервер, через минуту наблюдаем в консоли сервера пару сообщений о том, что обновлять ему пока что нечего - сообщений нет:

Запускаем клиента, здороваемся с публикой:

Обращаем внимание на то, что в консоли сервера появились сообщения об успешном сохранении данных:

Запускаем еще одного клиента - и теперь только что подключившийся клиент в курсе последних событий в чате:

Рестартуем сервер, переподключаем клиентов...:

... и вуаля, сообщения никуда не пропали:

Можно посмотреть в каком виде эти самые сообщения хранятся в файле data.js.
module.exports = [
  {"data":"Hi, Anatoly","from":"127.0.0.1:54756","ts":1393749711726},
  {"data":"My name is Anatoly","from":"127.0.0.1:54753","ts":1393749283526},
  {"data":"Hello!!!","from":"127.0.0.1:54753","ts":1393749271127}
]

По-моему неплохо получилось.

Теперь как и обещал рассмотрим вариант хранения сообщений в формате JSON.
Для реализации всего лишь добавим пару строчек в исходный код нашей модели данных - модуль message:
var messageData = require('./data');
var fs = require('fs');
var path = './data.json';
var messageData = fs.existsSync(path) ? JSON.parse(fs.readFileSync(path, {encoding: 'utf8'})) : [];

module.exports = function(len, size, interval) {
  this.len = len || 100; // длина сообщения по умолчанию - 100 символов
  this.size = size || 19; //размер кэша сообщений по умолчанию - 20 штук  
  this.data = Array.isArray(messageData) ? messageData : [];

  this.insert = function(client, data, cb) {
    if(data.length > this.len) {
      cb('Too much information!');
      return;
    }
    if (this.data.length > this.size) {
      this.data.pop();
    }
    data = {data: data, from: client['_id'], ts: Date.now()};
    this.data.unshift(data);
    this.updated = true;
    cb(false, createMessage(data));
  }

  this.get = function(n) {
    var i = n || 5; // количество сообщений по умолчанию - 5 штук
    return this.data.slice(0, i).map(function(msg) {
      return createMessage(msg);
    }).reverse().join('\n');
  }
 
  var createMessage = function(msg) {
    return new Date(msg.ts).toLocaleTimeString() + '  ' + msg.from + ' >>> ' + msg.data;
  }
 
  this.interval = interval || 30000;
  var self = this;
 
  (function schedule() {
    setTimeout(function() {
      if(!self.updated) {
        console.log('--- no data to update ---');
        schedule();
        return;
      }
      fs.writeFile(path, JSON.stringify(self.data), function(e) {
        if(e) {
          console.log('--- error updating data ---');
          schedule();
          return;
        }
        self.updated = false;
        console.log('--- data updated ---');
        schedule();
      });
    }, self.interval);
  }());
}

Вместо того, чтобы читать данные с помощью инструкции require мы попробуем прочитать их с помощь модуля fs - по-моему более гибкое решение - даже если файл с данными будет не найден, чат все равно запустится.

Запускаем сервер...:

... клиента ...:

... еще одного клиента:

Проверяем что у нас хранится в файле data.json:
[
  {"data":"Hellooooooooo","from":"127.0.0.1:54925","ts":1393751276110},
  {"data":"peopleeeeee","from":"127.0.0.1:54924","ts":1393751100230},
  {"data":"Hiiii","from":"127.0.0.1:54924","ts":1393751093932}
]

Все по фэн-шую :).

На этом варианты хранения данных в файлах у меня закончились.
В следующей серии рассмотрим вариант с использованием хранилища Redis. Продолжение следует...