Écrire une application AngularJS avec Socket.IO

Introduction

AngularJS est un framework JavaScript performant qui fournit une liaison de données bidirectionnelle facile à utiliser et rapide, un système de directives puissant qui vous permet de créer des composants personnalisés réutilisables, et bien plus encore. Socket.IO est un wrapper et un polyfill multi-navigateurs pour les websockets, qui simplifie le développement d'applications en temps réel. Au fait, les deux fonctionnent très bien ensemble !

J'ai déjà écrit pour écrire une application AngularJS avec Express, mais cette fois, je vais vous expliquer comment intégrer Socket.IO pour ajouter des fonctionnalités en temps réel à une application AngularJS. Dans ce tutoriel, je vais vous expliquer comment écrire une application de messagerie instantanée. Cette présentation s'appuie sur mon précédent tutoriel (utilisant une pile Node.js similaire sur le serveur). Je vous recommande donc de commencer par consulter ce tutoriel si vous ne connaissez pas Node.js ni Express.

Ouvrir la démo

Comme toujours, vous pouvez obtenir le résultat final sur GitHub.

Prérequis

Il y a un peu de code récurrent pour configurer et intégrer Socket.IO à Express. J'ai donc créé la graine Angular Socket.IO.

Pour commencer, vous pouvez cloner le dépôt angular-node-seed à partir de GitHub:

git clone git://github.com/btford/angular-socket-io-seed my-project

ou téléchargez-le sous forme de fichier ZIP.

Une fois que vous disposez de la valeur initiale, vous devez récupérer quelques dépendances avec npm. Ouvrez un terminal dans le répertoire contenant la valeur de départ, puis exécutez la commande suivante:

npm install

Une fois ces dépendances installées, vous pouvez exécuter l'application squelette:

node app.js

et la consulter dans votre navigateur à l'adresse http://localhost:3000 pour vérifier que la graine fonctionne comme prévu.

Choisir les fonctionnalités de l'application

Il existe de nombreuses façons d'écrire une application de chat. Décrivons donc les fonctionnalités minimales de la nôtre. Il n'existe qu'un seul salon de discussion auquel tous les utilisateurs appartiendront. Les utilisateurs peuvent choisir et modifier leur nom, mais celui-ci doit être unique. Le serveur appliquera ce caractère unique et annoncera les changements de nom des utilisateurs. Le client doit afficher une liste de messages et la liste des utilisateurs actuellement dans la salle de discussion.

Une interface simple

Cette spécification nous permet de créer une interface simple avec Jade qui fournit les éléments d'interface utilisateur nécessaires. Ouvrez views/index.jade et ajoutez ceci dans block body:

div(ng-controller='AppCtrl')
.col
  h3 Messages
  .overflowable
    p(ng-repeat='message in messages') : 

.col
  h3 Users
  .overflowable
    p(ng-repeat='user in users') 

.clr
  form(ng-submit='sendMessage()')
    | Message: 
    input(size='60', ng-model='message')
    input(type='submit', value='Send')

.clr
  h3 Change your name
  p Your current user name is 
  form(ng-submit='changeName()')
    input(ng-model='newName')
    input(type='submit', value='Change Name')

Ouvrez public/css/app.css et ajoutez le CSS pour fournir des colonnes et des dépassements:

/* app css stylesheet */

.overflowable {
  height: 240px;
  overflow-y: auto;
  border: 1px solid #000;
}

.overflowable p {
  margin: 0;
}

/* poor man's grid system */
.col {
  float: left;
  width: 350px;
}

.clr {
  clear: both;
}

Interagir avec Socket.IO

Bien que Socket.IO expose une variable io sur window, il est préférable de l'encapsuler dans le système d'injection de dépendances d'AngularJS. Nous allons donc commencer par écrire un service pour encapsuler l'objet socket renvoyé par Socket.IO. C'est génial, car cela facilitera grandement le test de la manette par la suite. Ouvrez public/js/services.js et remplacez le contenu par:

app.factory('socket', function ($rootScope) {
  var socket = io.connect();
  return {
    on: function (eventName, callback) {
      socket.on(eventName, function () {  
        var args = arguments;
        $rootScope.$apply(function () {
          callback.apply(socket, args);
        });
      });
    },
    emit: function (eventName, data, callback) {
      socket.emit(eventName, data, function () {
        var args = arguments;
        $rootScope.$apply(function () {
          if (callback) {
            callback.apply(socket, args);
          }
        });
      })
    }
  };
});

Notez que nous encapsulons chaque rappel de socket dans $scope.$apply. Cela indique à AngularJS qu'il doit vérifier l'état de l'application et mettre à jour les modèles en cas de modification après l'exécution du rappel qui lui a été transmis. En interne, $http fonctionne de la même manière. Après un retour XHR, il appelle $scope.$apply pour qu'AngularJS puisse mettre à jour ses vues en conséquence.

Notez que ce service n'encapsule pas l'intégralité de l'API Socket.IO (il s'agit toutefois d'un exercice pour le lecteur ;P ). Cependant, il couvre les méthodes utilisées dans ce tutoriel et doit vous indiquer la bonne direction si vous souhaitez développer cette API. Je reviendrai peut-être sur la rédaction d'un wrapper complet, mais cela dépasse le cadre de ce tutoriel.

Dans notre contrôleur, nous pouvons maintenant demander l'objet socket, comme nous le ferions avec $http:

function AppCtrl($scope, socket) {
  /* Controller logic */
}

À l'intérieur du contrôleur, ajoutons une logique pour envoyer et recevoir des messages. Ouvrez js/public/controllers.js et remplacez son contenu par ce qui suit:

function AppCtrl($scope, socket) {

  // Socket listeners
  // ================

  socket.on('init', function (data) {
    $scope.name = data.name;
    $scope.users = data.users;
  });

  socket.on('send:message', function (message) {
    $scope.messages.push(message);
  });

  socket.on('change:name', function (data) {
    changeName(data.oldName, data.newName);
  });

  socket.on('user:join', function (data) {
    $scope.messages.push({
      user: 'chatroom',
      text: 'User ' + data.name + ' has joined.'
    });
    $scope.users.push(data.name);
  });

  // add a message to the conversation when a user disconnects or leaves the room
  socket.on('user:left', function (data) {
    $scope.messages.push({
      user: 'chatroom',
      text: 'User ' + data.name + ' has left.'
    });
    var i, user;
    for (i = 0; i < $scope.users.length; i++) {
      user = $scope.users[i];
      if (user === data.name) {
        $scope.users.splice(i, 1);
        break;
      }
    }
  });

  // Private helpers
  // ===============

  var changeName = function (oldName, newName) {
    // rename user in list of users
    var i;
    for (i = 0; i < $scope.users.length; i++) {
      if ($scope.users[i] === oldName) {
        $scope.users[i] = newName;
      }
    }

    $scope.messages.push({
      user: 'chatroom',
      text: 'User ' + oldName + ' is now known as ' + newName + '.'
    });
  }

  // Methods published to the scope
  // ==============================

  $scope.changeName = function () {
    socket.emit('change:name', {
      name: $scope.newName
    }, function (result) {
      if (!result) {
        alert('There was an error changing your name');
      } else {

        changeName($scope.name, $scope.newName);

        $scope.name = $scope.newName;
        $scope.newName = '';
      }
    });
  };

  $scope.sendMessage = function () {
    socket.emit('send:message', {
      message: $scope.message
    });

    // add the message to our model locally
    $scope.messages.push({
      user: $scope.name,
      text: $scope.message
    });

    // clear message box
    $scope.message = '';
  };
}

Cette application n'offrira qu'une seule vue. Nous pouvons donc supprimer le routage de public/js/app.js et le simplifier pour:

// Declare app level module which depends on filters, and services
var app = angular.module('myApp', ['myApp.filters', 'myApp.directives']);

Écrire le serveur

Ouvrez routes/socket.js. Nous devons définir un objet pour maintenir l'état du serveur, afin que les noms d'utilisateur soient uniques.

// Keep track of which names are used so that there are no duplicates
var userNames = (function () {
  var names = {};

  var claim = function (name) {
    if (!name || userNames[name]) {
      return false;
    } else {
      userNames[name] = true;
      return true;
    }
  };

  // find the lowest unused "guest" name and claim it
  var getGuestName = function () {
    var name,
      nextUserId = 1;

    do {
      name = 'Guest ' + nextUserId;
      nextUserId += 1;
    } while (!claim(name));

    return name;
  };

  // serialize claimed names as an array
  var get = function () {
    var res = [];
    for (user in userNames) {
      res.push(user);
    }

    return res;
  };

  var free = function (name) {
    if (userNames[name]) {
      delete userNames[name];
    }
  };

  return {
    claim: claim,
    free: free,
    get: get,
    getGuestName: getGuestName
  };
}());

Ce fichier définit un ensemble de noms, mais avec des API plus appropriées pour le domaine d'un serveur de chat. Connectons-le au socket du serveur pour répondre aux appels effectués par notre client:

// export function for listening to the socket
module.exports = function (socket) {
  var name = userNames.getGuestName();

  // send the new user their name and a list of users
  socket.emit('init', {
    name: name,
    users: userNames.get()
  });

  // notify other clients that a new user has joined
  socket.broadcast.emit('user:join', {
    name: name
  });

  // broadcast a user's message to other users
  socket.on('send:message', function (data) {
    socket.broadcast.emit('send:message', {
      user: name,
      text: data.message
    });
  });

  // validate a user's name change, and broadcast it on success
  socket.on('change:name', function (data, fn) {
    if (userNames.claim(data.name)) {
      var oldName = name;
      userNames.free(oldName);

      name = data.name;

      socket.broadcast.emit('change:name', {
        oldName: oldName,
        newName: name
      });

      fn(true);
    } else {
      fn(false);
    }
  });

  // clean up when a user leaves, and broadcast it to other users
  socket.on('disconnect', function () {
    socket.broadcast.emit('user:left', {
      name: name
    });
    userNames.free(name);
  });
};

La candidature devrait être complète. Essayez-la en exécutant node app.js. L'application doit se mettre à jour en temps réel, grâce à Socket.IO.

Conclusion

Vous pourriez ajouter beaucoup d'autres fonctionnalités à cette application de messagerie instantanée. Par exemple, vous pouvez envoyer des messages vides. Vous pouvez utiliser ng-valid pour empêcher cela côté client et effectuer une vérification sur le serveur. Le serveur pourrait peut-être conserver un historique récent des messages au profit des nouveaux utilisateurs qui rejoignent l'application.

Il est facile d'écrire des applications AngularJS qui utilisent d'autres bibliothèques une fois que vous savez comment les encapsuler dans un service et que vous avez informé Angular qu'un modèle a changé. Je prévois ensuite d'utiliser AngularJS avec D3.js, la bibliothèque de visualisation populaire.

Références

Angular Socket.IO Seed Application de messagerie instantanée terminée AngularJS Express Socket.IO