Thursday, March 27, 2014

A Chat Application Using Socket.IO

In this article, we are going to build a simple chat application using Socket.IO and Backbone.js. Socket.IO is aimed at developers who want to start developing highly interactive, real-time web applications, such as chat systems or multiplayer games. Backbone.js will add structucture to our client-side code and make it easy to manage and decouple concerns in our application.

Readers should be familiar with Node.js and Express. Familiarity with Backbone is a plus, as well as Underscore.js, which is used for basic templating.

Introduction

A diagram illustrating the structure of our client side code is shown below. In the middle is a controller, which acts as a bridge between the socket client and view. The controller gets updates from the socket client, and changes the model. Updates are reflected in the view using Backbone bindings.

Application Architecture

Client Side

 

We’ll begin by looking at the client side code. All chat interactions are handled in HomeView. Let’s start by defining HomeModel in /public/js/models/main.js.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
var HomeModel = Backbone.Model.extend({
  defaults: {
    // Backbone collection for users
    onlineUsers: new UserCollection(),
 
    // Backbone collection for user chats, initialized with a predefined chat model
    userChats: new ChatCollection([
      new ChatModel({sender: '', message: 'Chat Server v.1'})
    ])
  },
 
  // method for adding a new user to onlineUsers collection
  addUser: function(username) {
    this.get('onlineUsers').add(new UserModel({name: username}));
  },
 
  // method for removing a user from onlineUsers collection
  removeUser: function(username) {
    var onlineUsers = this.get('onlineUsers');
    var u = onlineUsers.find(function(item) {
          return item.get('name') == username;
        });
 
    if (u) {
      onlineUsers.remove(u);
    }
  },
 
  // method for adding new chat to userChats collection
  addChat: function(chat) {
    this.get('userChats').add(new ChatModel({sender: chat.sender, message: chat.message}));
  },
});

We use Backbone collections to listen for changes on the collection. The updates on the collections are reflected automatically by our view.

Next, we define our home template inside /public/index.html.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<script type="text/template" id="home-template">
  <div class="row">
    <div class="col-md-10">
      <div class="panel panel-default">
        <div class="panel-heading">Lobby</div>
        <div class="panel-body">
          <div class="nano">
            <div class="content">
              <div class="list-group" id="chatList"></div>
            </div>
          </div>
          <form>
            <input class="form-control" type="text" id="chatInput"></input>
          </form>
        </div>
      </div>
    </div>
    <div class="col-md-2">
      <div class="panel panel-default">
        <div class="panel-heading">
          <h3 class="panel-title">Online Users <span class="badge pull-right" id="userCount"></span></h3>
        </div>
        <div class="panel-body">
          <div class="nano">
            <div class="content">
              <div class="list-group" id="userList"></div>
            </div>
          </div>
        </div>
      </div>
    </div>
  </div>
</script>

The HomeView is located in /public/js/views/main.js. The file is relatively long, so it is left to the reader to explore.

Chat Client


Next, let’s define our Socket.IO chat client. It communicates with the server by sending messages to the server and listening for notifications. These notifications trigger events on the event bus to communicate with the controller. The following code is found in /public/js/socketclient.js.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
var ChatClient = function(options) {
  // redefine this to avoid conflicts
  var self = this;
 
  // app event bus
  self.vent = options.vent;
 
  // server hostname replace with your server's hostname eg: http://localhost
  self.hostname = 'http://chatfree.herokuapp.com';
 
  // connects to the server
  self.connect = function() {
    // connect to the host
    self.socket = io.connect(self.hostname);
 
    // set responseListeners on the socket
    self.setResponseListeners(self.socket);
  }
 
  // send login message
  self.login = function(name) {
    self.socket.emit('login', name);
  }
 
  // send chat message
  self.chat = function(chat) {
    self.socket.emit('chat', chat);
  }
 
  self.setResponseListeners = function(socket) {
    // handle messages from the server
    socket.on('welcome', function(data) {
      // request server info
      socket.emit('onlineUsers');
 
      self.vent.trigger('loginDone', data);
    });
 
    socket.on('loginNameExists', function(data) {
      self.vent.trigger('loginNameExists', data);
    });
 
    socket.on('loginNameBad', function(data) {
      self.vent.trigger('loginNameBad', data);
    });
 
    socket.on('onlineUsers', function(data) {
      console.log(data);
      self.vent.trigger('usersInfo', data);
    });
 
    socket.on('userJoined', function(data) {
      self.vent.trigger('userJoined', data);
    });
 
    socket.on('userLeft', function(data) {
      self.vent.trigger('userLeft', data);
    });
 
    socket.on('chat', function(data) {
      self.vent.trigger('chatReceived', data);
    });
  }
}

Socket.IO really makes it easy to send/receive messages between the client and server. Here, we use two methods:

  • socket.emit(message, [callback]) – Used to send messages to the server.
  • socket.on(message, callback) – Used to receive messages from the server. callback is invoked on reception.

Below is a diagram showing what our chat protocol looks like:

Chat Protocol

Main Controller

 

For the final part on client side, we have our controller, orchestrating between views, models, and the socket client. Place this in /public/js/main.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
var MainController = function() {
  var self = this;
 
  // Event Bus for socket client
  self.appEventBus = _.extend({}, Backbone.Events);
  // Event Bus for Backbone Views
  self.viewEventBus = _.extend({}, Backbone.Events);
 
  // initialize function
  self.init = function() {
    // create a chat client and connect
    self.chatClient = new ChatClient({vent: self.appEventBus});
    self.chatClient.connect();
 
    // create our views, place login view inside container first.
    self.loginModel = new LoginModel();
    self.containerModel = new ContainerModel({
      viewState: new LoginView({
        vent: self.viewEventBus,
        model: self.loginModel
      })
    });
    self.containerView = new ContainerView({model: self.containerModel});
    self.containerView.render();
  };
 
  // View Event Bus Message Handlers
  self.viewEventBus.on('login', function(name) {
    // socketio login
    self.chatClient.login(name);
  });
 
  self.viewEventBus.on('chat', function(chat) {
    // socketio chat
    self.chatClient.chat(chat);
  });
 
  // Socket Client Event Bus Message Handlers
 
  // triggered when login success
  self.appEventBus.on('loginDone', function() {
    self.homeModel = new HomeModel();
    self.homeView  = new HomeView({vent: self.viewEventBus, model: self.homeModel});
 
    // set viewstate to homeview
    self.containerModel.set('viewState', self.homeView);
  });
 
  // triggered when login error due to bad name
  self.appEventBus.on('loginNameBad', function(name) {
    self.loginModel.set('error', 'Invalid Name');
  });
 
  // triggered when login error due to already existing name
  self.appEventBus.on('loginNameExists', function(name) {
    self.loginModel.set('error', 'Name already exists');
  });
 
  // triggered when client requests users info
  // responds with an array of online users.
  self.appEventBus.on('usersInfo', function(data) {
    var onlineUsers = self.homeModel.get('onlineUsers');
    var users = _.map(data, function(item) {
      return new UserModel({name: item});
    });
 
    onlineUsers.reset(users);
  });
 
  // triggered when a client joins the server
  self.appEventBus.on('userJoined', function(username) {
    self.homeModel.addUser(username);
    self.homeModel.addChat({sender: '', message: username + ' joined room.'});
  });
 
  // triggered when a client leaves the server
  self.appEventBus.on('userLeft', function(username) {
    self.homeModel.removeUser(username);
    self.homeModel.addChat({sender: '', message: username + ' left room.'});
  });
 
  // triggered when chat receieved
  self.appEventBus.on('chatReceived', function(chat) {
    self.homeModel.addChat(chat);
  });
}

To bootstrap everything, we simply create a MainController and call it’s init method, inside /public/js/main.js:

1
2
3
4
5
$(document).ready(function() {
  var mainController = new MainController();
 
  mainController.init();
});
That’s it for the client side. If you encounter any errors, Chrome has excellent debugging tools. Use it’s network tab to see if messages are really exchanged.

Server Side

 

Next, we’ll turn to the server side which is implemented in Node.js, Express, and Socket.IO. Place this code, which implements the Express server component, in /scripts/web.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// requirements
var express = require('express');
var http = require('http');
var socketio = require('socket.io');
var path = require('path');
 
// routes
var routes = require('../routes/index.js');
 
var app = express();
 
// routes middleware
app.use(app.router);
// serve public folder
app.use(express.static(path.join(__dirname, '../public')));
 
// serve index.html for every path
app.use(routes.index);
 
// this is how you use socket io with express
var server = http.createServer(app);
var io = socketio.listen(server);
 
var port = process.env.PORT || 8080;
 
server.listen(port, function() {
  console.log(' - listening on ' + port+ ' ' + __dirname);
});
 
// require our chatserver
var ChatServer = require('./chatserver');
 
// initialize a new chat server.
new ChatServer({io: io}).init();

 

Chat Server

 

The last part of our application is the chat server. This is responsible for keeping a list of online users, and broadcasting chat messages. The first event that our server will receive on a new client connection is aptly named connection. connection events handlers, pass along the socket that was just established. The socket handles the following events:

  • socket.on(message, callback)callback is called when a new message is received. message can be any type of data, depending on what was sent.
  • socket.on('disconnect', callback)callback is called when the socket disconnects.
  • socket.emit(message, args) – Send message over the socket.
  • socket.broadcast.send(message, args) – Broadcasts message to all sockets except the sender.

Now we’ve seen how to handle sockets, let’s define a user model inside /scripts/chatserver.js:

1
2
3
4
5
6
7
8
9
// User Model
var User = function(args) {
  var self = this;
 
  // Socket field
  self.socket = args.socket;
  // username field
  self.user = args.user;
}

Finally our chat server goes in /scripts/chatserver.js:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
var Server = function(options) {
  var self = this;
 
  self.io = options.io;
 
  // users array
  self.users = [];
 
  // initialize function
  self.init = function() {
    // Fired upon a connection
    self.io.on('connection', function(socket) {
      self.handleConnection(socket);
    });
  }
 
  // socket handler for an incoming socket
  self.handleConnection = function(socket) {
    // wait for a login message
    socket.on('login', function(username) {
      var nameBad = !username || username.length < 3 || username.length > 10;
 
      // check for badname
      if (nameBad) {
        socket.emit('loginNameBad', username);
        return;
      }
 
      var nameExists = _.some(self.users, function(item) {
        return item.user == username;
      });
 
      // check for already existing name
      if (nameExists) {
        socket.emit('loginNameExists', username);
      } else {
        // create a new user model
        var newUser = new User({ user: username, socket: socket });
        // push to users array
        self.users.push(newUser);
        // set response listeners for the new user
        self.setResponseListeners(newUser);
        // send welcome message to user
        socket.emit('welcome');
        // send user joined message to all users
        self.io.sockets.emit('userJoined', newUser.user);
      }
    });
  }
 
  // method to set response listeners
  self.setResponseListeners = function(user) {
    // triggered when a socket disconnects
    user.socket.on('disconnect', function() {
      // remove the user and send user left message to all sockets
      self.users.splice(self.users.indexOf(user), 1);
      self.io.sockets.emit('userLeft', user.user);
    });
    // triggered when socket requests online users
    user.socket.on('onlineUsers', function() {
      var users = _.map(self.users, function(item) {
        return item.user;
      });
 
      user.socket.emit('onlineUsers', users);
    });
 
    // triggered when socket send a chat message
    user.socket.on('chat', function(chat) {
      if (chat) {
        self.io.sockets.emit('chat', { sender: user.user, message: chat });
      }
    });
  }
}

No comments:

Post a Comment