Écrire une application AngularJS avec Socket.IO

Introduction

AngularJS est un excellent framework JavaScript qui vous offre une liaison de données bidirectionnelle à la fois simple 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 multinavigateur pour les websockets qui facilite le développement d'applications en temps réel. Par ailleurs, les deux fonctionnent très bien ensemble.

J'ai déjà expliqué comment é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 chat. Il s'appuie sur mon tutoriel précédent (qui utilise une pile node.js similaire sur le serveur). Je vous recommande donc de le consulter d'abord si vous ne connaissez pas Node.js ni Express.

Ouvrir la démonstration

Comme toujours, vous pouvez télécharger le produit fini sur GitHub.

Prérequis

La configuration et l'intégration de Socket.IO avec Express nécessitent un peu de code standard. J'ai donc créé le démarrage Socket.IO Angular.

Pour commencer, vous pouvez cloner le dépôt angular-node-seed depuis 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 avez le seed, vous devez récupérer quelques dépendances avec npm. Ouvrez un terminal dans le répertoire contenant le seed, 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 l'afficher dans votre navigateur à l'adresse http://localhost:3000 pour vous assurer que le seed fonctionne comme prévu.

Décider des fonctionnalités de l'application

Il existe plusieurs façons d'écrire une application de chat. Définissons donc les fonctionnalités minimales de la nôtre. Il n'y aura qu'un seul salon de chat auquel tous les utilisateurs appartiendront. Les utilisateurs peuvent choisir et modifier leur nom, mais celui-ci doit être unique. Le serveur applique cette unicité et annonce lorsque les utilisateurs changent de nom. Le client doit afficher une liste de messages et une liste des utilisateurs actuellement dans la salle de chat.

Une interface simple

Avec cette spécification, nous pouvons créer un front-end simple avec Jade qui fournit les éléments d'interface utilisateur nécessaires. Ouvrez views/index.jade et ajoutez le code suivant 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ébordements:

/* 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 le 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 nous permettra de tester notre manette plus facilement plus tard. Ouvrez public/js/services.js et remplacez son 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 si un changement a été apporté après l'exécution du rappel qui lui a été transmis. En interne, $http fonctionne de la même manière. Après certains retours XHR, il appelle $scope.$apply afin 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 (c'est un exercice à laisser au lecteur ;P). Toutefois, il couvre les méthodes utilisées dans ce tutoriel et devrait vous orienter dans la bonne direction si vous souhaitez les développer. Je reviendrai peut-être sur l'écriture 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 */
}

Dans le contrôleur, ajoutons une logique d'envoi et de réception de messages. Ouvrez js/public/controllers.js et remplacez son contenu par le code suivant:

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 ne comporte qu'une seule vue. Nous pouvons donc supprimer le routage de public/js/app.js et le simplifier comme suit:

// 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 gérer 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
  };
}());

Cela définit essentiellement un ensemble de noms, mais avec des API plus adaptées au domaine d'un serveur de chat. Connectons-le au socket du serveur pour répondre aux appels de 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);
  });
};

L'application devrait maintenant être terminée. Essayez-le en exécutant node app.js. L'application doit se mettre à jour en temps réel, grâce à Socket.IO.

Conclusion

Vous pouvez ajouter beaucoup d'autres éléments à cette application de chat. Par exemple, vous pouvez envoyer des messages vides. Vous pouvez utiliser ng-valid pour éviter cela côté client et effectuer une vérification sur le serveur. Le serveur pourrait peut-être conserver un historique récent des messages pour le bénéfice des nouveaux utilisateurs qui rejoignent l'application.

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

Références

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