Scrittura di un'app AngularJS con Socket.IO

Brian Ford
Brian Ford

Introduzione

AngularJS è un fantastico framework JavaScript che offre il binding dei dati bidirezionale, facile da usare e veloce, un potente sistema di direttive che ti consente di creare componenti personalizzati riutilizzabili e molto altro ancora. Socket.IO è un wrapper e un polyfill cross-browser per websocket che semplifica lo sviluppo di applicazioni in tempo reale. A proposito, i due funzionano piuttosto bene insieme.

In passato ho scritto su come scrivere un'app AngularJS con Express, ma questa volta parlerò di come integrare Socket.IO per aggiungere funzionalità in tempo reale a un'applicazione AngularJS. In questo tutorial ti guiderò nella scrittura di un'app di messaggistica istantanea. Questo tutorial si basa sul mio tutorial precedente (che utilizza uno stack node.js simile sul server), quindi ti consiglio di consultarlo prima se non hai dimestichezza con Node.js o Express.

Apri la demo

Come sempre, puoi ottenere il prodotto finito su GitHub.

Prerequisiti

Per configurare e integrare Socket.IO con Express è necessario un po' di boilerplate, quindi ho creato il seed Angular Socket.IO.

Per iniziare, puoi clonare il repository angular-node-seed da GitHub:

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

oppure scaricalo come file ZIP.

Una volta ottenuto il seed, devi scaricare alcune dipendenze con npm. Apri un terminale nella directory con il seed ed esegui:

npm install

Una volta installate queste dipendenze, puoi eseguire l'app di base:

node app.js

e visualizzalo nel browser all'indirizzo http://localhost:3000 per assicurarti che il seed funzioni come previsto.

Decidere sulle funzionalità dell'app

Esistono diversi modi per scrivere un'applicazione di chat, quindi descriviamo le funzionalità minime che avrà la nostra. Esiste una sola chat room a cui appartengono tutti gli utenti. Gli utenti possono scegliere e modificare il proprio nome, ma i nomi devono essere univoci. Il server applicherà questa unicità e annuncerà quando gli utenti cambiano i propri nomi. Il client deve mostrare un elenco di messaggi e un elenco di utenti attualmente nella chat room.

Un frontend semplice

Con questa specifica, possiamo creare un front-end semplice con Jade che fornisce gli elementi dell'interfaccia utente necessari. Apri views/index.jade e aggiungi questo codice all'interno di 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')

Apri public/css/app.css e aggiungi il CSS per aggiungere colonne e overflow:

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

Interazione con Socket.IO

Sebbene Socket.IO esponga una variabile io su window, è meglio incapsularla nel sistema di Dependency Injection di AngularJS. Inizieremo quindi scrivendo un servizio per avvolgere l'oggetto socket restituito da Socket.IO. Ottimo, perché in questo modo sarà molto più facile testare il nostro controller in un secondo momento. Apri public/js/services.js e sostituisci i contenuti con:

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

Tieni presente che ogni callback del socket è racchiuso in $scope.$apply. Questo indica ad AngularJS che deve controllare lo stato dell'applicazione e aggiornare i modelli se è stata apportata una modifica dopo l'esecuzione del callback a cui è stato passato. All'interno, $http funziona allo stesso modo; dopo alcuni ritorni XHR, chiama $scope.$apply, in modo che AngularJS possa aggiornare le sue visualizzazioni di conseguenza.

Tieni presente che questo servizio non racchiude l'intera API Socket.IO (questo è un esercizio per il lettore ;P). Tuttavia, copre i metodi utilizzati in questo tutorial e dovrebbe indirizzarti nella giusta direzione se vuoi approfondire l'argomento. Potrò rivedere la scrittura di un wrapper completo, ma questo esula dallo scopo di questo tutorial.

Ora, all'interno del nostro controller, possiamo richiedere l'oggetto socket, come faremmo con $http:

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

All'interno del controller, aggiungiamo la logica per l'invio e la ricezione dei messaggi. Apri js/public/controllers.js e sostituisci i contenuti con quanto segue:

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

Questa applicazione avrà una sola visualizzazione, quindi possiamo rimuovere il routing da public/js/app.js e semplificarlo in:

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

Scrittura del server

Apri routes/socket.js. Dobbiamo definire un oggetto per mantenere lo stato del server, in modo che i nomi utente siano univoci.

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

In pratica, viene definito un insieme di nomi, ma con API più adatte al dominio di un server di chat. Collega questo socket al server per rispondere alle chiamate effettuate dal nostro 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);
  });
};

A questo punto, la richiesta dovrebbe essere completata. Prova a eseguire node app.js. L'applicazione dovrebbe aggiornarsi in tempo reale, grazie a Socket.IO.

Conclusione

Puoi aggiungere molto altro a questa app di messaggistica istantanea. Ad esempio, puoi inviare messaggi vuoti. Puoi utilizzare ng-valid per impedirlo sul lato client e un controllo sul server. Forse il server potrebbe conservare una cronologia recente dei messaggi a beneficio dei nuovi utenti che si uniscono all'app.

Scrivere app AngularJS che utilizzano altre librerie è facile una volta capito come inserirle in un servizio e notificare ad Angular che un modello è cambiato. In seguito, ho intenzione di trattare l'utilizzo di AngularJS con D3.js, la popolare libreria di visualizzazione.

Riferimenti

Angular Socket.IO Seed App di messaggistica istantanea completata AngularJS Express Socket.IO`