Scrittura di un'app AngularJS con Socket.IO

Brian Ford
Brian Ford

Introduzione

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

Ho già scritto in precedenza sulla scrittura di un'app AngularJS con Express, ma questa volta ti scriverò come integrare Socket.IO per aggiungere funzionalità in tempo reale a un'applicazione AngularJS. In questo tutorial vedremo come scrivere un'app di messaggistica immediata. Questa si basa sul tutorial precedente (utilizzando uno stack node.js simile sul server), quindi ti consiglio di farlo se non conosci Node.js o Express.

Apri la demo

Come sempre, puoi trovare il prodotto finito su GitHub.

Prerequisiti

Abbiamo bisogno di chiarimenti per la configurazione e l'integrazione di Socket.IO con Express, quindi ho creato il seed di 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 scaricarlo come file ZIP.

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

npm install

Con queste dipendenze installate, puoi eseguire l'app skeleton:

node app.js

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

Scelta delle funzionalità dell'app

Esistono diversi modi per scrivere un'applicazione di chat, quindi descriviamo le funzionalità minime che la nostra applicazione avrà. Ci sarà una sola chat room a cui apparterranno tutti gli utenti. Gli utenti possono scegliere e modificare il proprio nome, ma deve essere univoco. Il server imporrà questa univocità e annuncerà quando gli utenti cambieranno il proprio nome. Il client deve esporre un elenco di messaggi e un elenco degli utenti che si trovano attualmente nella chat room.

Un front-end semplice

Con questa specifica, possiamo creare un semplice front-end con Jade che fornisca gli elementi UI necessari. Apri views/index.jade e aggiungi questo 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 fornire 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

Anche se Socket.IO espone una variabile io in window, è meglio incapsularla nel sistema Dependency Injection di AngularJS. Inizieremo quindi scrivendo un servizio per eseguire il wrapping dell'oggetto socket restituito da Socket.IO. È fantastico, perché 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 socket viene aggregato in $scope.$apply. Questo indica ad AngularJS che deve controllare lo stato dell'applicazione e aggiornare i modelli in caso di modifica dopo l'esecuzione del callback passato all'applicazione. Internamente, $http funziona allo stesso modo: dopo il ritorno di alcuni XHR, chiama $scope.$apply, in modo che AngularJS possa aggiornare le sue visualizzazioni di conseguenza.

Tieni presente che questo servizio non aggrega l'intera API Socket.IO (che rimane come esercizio per il lettore ;P). Tuttavia, tratta i metodi utilizzati in questo tutorial e dovrebbe indirizzarti nella giusta direzione se vuoi espanderti. Potrei rivedere di nuovo la scrittura di un wrapper completo, ma questo non rientra nell'ambito di questo tutorial.

Ora, all'interno del nostro controller, possiamo chiedere l'oggetto socket, proprio come faresti 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 il seguente:

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 sul 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 definisce un insieme di nomi, ma con API che hanno più senso per il dominio di un server di chat. Collegalo al socket del server per rispondere alle chiamate che il nostro client effettua:

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

E ora la richiesta dovrebbe essere completa. Prova eseguendo node app.js. Grazie a Socket.IO, l'applicazione dovrebbe aggiornarsi in tempo reale.

Conclusione

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

Scrivere app AngularJS che utilizzano altre librerie è facile dopo aver capito come includerle in un servizio e aver comunicato ad Angular che un modello è cambiato. In seguito, prevedo di usare AngularJS con D3.js, la popolare libreria di visualizzazione.

Riferimenti

Seed di Angular Socket.IO App di messaggistica immediata completata AngularJS Espresso Socket.IO`