Страницы

воскресенье, 16 февраля 2014 г.

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

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

Предлагаю следующие варианты: оперативная память, файлы, Redis и MongoDB (с помощью Native NodeJS Driver и Mongoose).

В завершении мы получим несколько рабочих копий приложения, исходный код которых вы без труда сможете найти на GitHub.

Исхожу из того, что Node.js у вас уже установлен и JavaScript, который, как известно, everywhere, вам тоже знаком.

Для начала познакомимся с модулями.
Открываем какой-нибудь текстовый редактор, пишем код нашего первого модуля:
var color = 'blue';

exports.get = function() {
  return color;
}

exports.set = function(newColor) {
  color = newColor;
}

Для того, чтобы использовать модуль в приложении, нам необходимо его вызвать с помощью инструкции require:
var color = require('./myModule');

console.log(color.get());
color.set('red');
console.log(color.get());
console.log(color.color);


Понимаем, что в переменную color мы получаем объект, свойство которого color остается приватным, но мы можем получить его через предоставленные объектом методы.
Создадим еще один похожий модуль, немного изменив его исходный код:
module.exports = function() {
  this.color = 'blue';

  this.get = function () {
    return this.color;
  }

  this.set = function(newColor) {
    this.color = newColor;
  }
}

Вызываем новый модуль:
var MyModule1 = require('./myModule1');
var color = new MyModule1();

console.log(color.get());
color.set('red');
console.log(color.get());
console.log(color.color);


Обращаю внимание на то, что на этот раз мы получили свойство объекта color без использования методов.
В остальном все отработало точно так же, как и в предыдущий раз.

Если нужно запретить изменение свойства объекта без использования соответствующих методов - просто выносим свойство за пределы области видимости экспортируемого объекта:
var color = 'blue';

module.exports = function() {  
  this.get = function () {
    return color;
  }

  this.set = function(newColor) {
    color = newColor;
  }
}

Тестируем:
var MyModule2 = require('./myModule2');
var color = new MyModule2();

console.log(color.get());
color.set('red');
console.log(color.get());
console.log(color.color);


С модулями разобрались, переходим к модулю net.
Открываем в браузере главную страницу проекта Node.js.
Копируем в текстовый редактор (на ваш вкус) исходный код эхо-сервера, размещенный на главной странице проекта:
var net = require('net');

var server = net.createServer(function (socket) {
  socket.write('Echo server\r\n');
  socket.pipe(socket);
});

server.listen(8124, '127.0.0.1');

Открываем консоль, запускаем сервер: node echo

Открываем еще один экземпляр консоли, запускаем telnet: telnet localhost 8124

Пытаемся отправить серверу наше Hello:

So far so good. Дальше - самое страшное - понять что из себя представляет паттерн event emitter.
Не напрягайтесь, скажу просто: event emitter - это объект, который генерирует события, а все иные объекты, подписанные на определенные события этого объекта получают соответствующее уведомление о наступлении этого самого события.

Просто не получилось, приведу пример:
var events = require('events');

var pubsub = new events.EventEmitter();

pubsub.on('ping', function(arg) {
  console.log(arg);
});

pubsub.emit('ping', 'pong');

Выполняем:

Немного усложним:
var events = require('events');

var pubsub = new events.EventEmitter();

pubsub.clients = {};
pubsub.subscriptions = {};

pubsub.on('ping', function(arg) {
  this.clients[arg] = 'client-' + arg;
  this.subscriptions[arg] = function() {
    console.log(this.clients[arg]);
  }
  this.on('pong', this.subscriptions[arg]);
});

pubsub.emit('ping', 0);
pubsub.emit('ping', 1);
console.log("I've got the power...")
pubsub.emit('pong');

Выполняем:

Если здесь все понятно, дальше - проще.

Пишем код TCP-сервера:
var net = require('net');
var PubSub = require('./pubsub'), 
    pubsub = new PubSub;
    
var server = net.createServer(function(socket) {
  socket.setEncoding('utf8');
  console.log('--- socket connected ---\nfrom: %s', socket.remoteAddress + ':' + socket.remotePort);  
  
  pubsub.emit('join', socket);  
  
  socket.on('data', function(data) {
    data = data.replace(/\r\n$/, '');
    console.log('--- socket data ---\n%s', data);    
    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, close, end и error, поэтому некоторые сообщения в консоли могут показаться лишними, но на самом деле я сделал это намеренно, для лучшего понимания.

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

Самое время написать код модуля pubsub:
var events = require('events');

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

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

    this.subscriptions[socket['_id']] = function(client, data) {
      if (socket['_id'] != client['_id']) {
        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!');
  });

  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;
}

Модуль экспортирует объект, который слушает события join, leave и error, в случае наступления которых выполняет определенные действия. 

Остается написать код клиента:
var net = require('net');

var socket = new net.Socket();
socket.setEncoding('utf8');

socket.connect('8124', 'localhost', function() {
  console.log('--- connected to server ---');  
});

process.stdin.resume();

process.stdin.on('data', function(data) {
  socket.write(data);
});

socket.on('data', function(data) {
  console.log(data);
});

socket.on('close', function() {
  console.log('--- connection closed ---');
  process.exit();
});

socket.on('end', function() {
  console.log('--- connection end ---');
});

socket.on('error', function(e) {
  console.log('--- socket error ---\ncode: %s', e.code);
});

Клиент слушает те же события, что и сервер, в ответ на которые также совершает те или иные движения.

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

Тайная комната открыта :).

Запускаем пару клиентов, начинаем общение:


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

Приступаем к реализации первого из заявленных вариантов хранения данных - в оперативной памяти.
Пишем код модуля, который будет этим заниматься:
var messageData = [];

module.exports = function(len, size) {
  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, new Date(data.ts).toLocaleTimeString() + '  ' + data.from + ' >>> ' + data.data);
  }
}

Предлагаю сразу ограничить длину сообщения - 100 символов, а также размер кэша - 20 сообщений, оперативная память не резиновая :).
Идентифицируем клиентов по ip адресу и порту.

Редактируем код сервера: в самом начале импортируем модуль хранения сообщений по имени message - модель, если угодно, и вместо того, чтобы просто закидывать полученные сообщения в сокет используем метод insert только что созданной модели сообщений:
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);  
  
  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']);  
});

Изменения в модуле pubsub - косметические:
var events = require('events');

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

  pubsub.on('join', function(socket) {
    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!');
  });

  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;
}

Запускаем сервер, клиента, приветствуем публику:

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

Консоль сервера должна выглядеть примерно так:

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

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

Но об этом - в следующей серии. Продолжение следует...