Writing an AngularJS App with Socket.IO

HTML5 Rocks

Introduzione

AngularJS è un incredibile framework JavaScript che offre un meccanismo di data binding a due vie sia veloce che facile da utilizzare, un potente sistema di direttive che consente di creare componenti riusabili personalizzati, più molto altro ancora. Socket.IO funge da polyfill e wrapper multi-browser per le websocket, e fa sembrare lo sviluppo di applicazioni real-time una passeggiata. Per puro caso, le due cose lavorano piuttosto bene insieme!

Ho già scritto in passato circa lo sviluppo di una applicazione AngularJS app con Express, ma questa volta parlerò di come integrare Socket.IO per fornire funzionalità real-time ad una applicazione AngularJS. In questo tutorial affronterò l'implementazione di un'applicazione di messaggistica istantanea. tutto questo si avvale di un mio precedente tutorial (il quale utilizza lato server uno stack node.js molto simile), quindi vi raccomando di dargli un'occhiata se non avete familiarità con Node.js o Express.

Come sempre, è possibile ottenere il prodotto finito su Github.

Prerequisiti

Non manca del codice ripetuto nell'impostazione di Socket.IO in cui sia integrato Express, quindi ho creato il progetto Angular Socket.IO Seed.

Per iniziare, si può scaricare la repository angular-node-seed da Github:

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

oppure la si più scaricare come zip.

Preso seed, occorre installare alcune dipendenze con npm. Apri un terminale nella directory di seed ed esegui:

npm install

Con tutte le dipendenze installate si può lanciare l'esoscheletro dell'applicazione:

node app.js

e controllarlo nel browser all'indirizzo http://localhost:3000 per assicurarsi che seed funzioni come dovrebbe.

Scegliere le Funzionalità dell'Applicazione

Esistono diversi modi per scrivere un'applicazione di chat, quindi espongo solo le funzionalità minime che saranno messe a disposizione dalla nostra. Ci sarà solo una chat room, cui apparterranno tutti gli utenti. Gli utenti possono scegliere un nome e cambiarlo, ma questo nome deve essere unico. Il server si occuperà di imporre tale unicità e di annunciare quando gli utenti cambiano il proprio nome. Il client deve esporre una lista di messaggi, e la lista di utenti attualmente nella chat room.

Un Semplice Front End

Con queste specifiche, possiamo creare un semplice front end tramite Jade, il quale fornisce gli elementi UI necessari. Apri views/index.jade e aggiungi questo nel blocco 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 del CSS per disporre 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;
}

Interagire con Socket.IO

Sebbene Socket.IO esponga una variabile io nella window, è preferibile incapsulare tale variabile in un sistema Dependency Injection di AngularJS. Quindi, iniziamo con lo scrivere un servizio che incapsuli l'oggetto socket restituito da Socket.IO. Una cosa fantastica, visto che più avanti renderà molto più semplice testare il controller. Apri public/js/services.js e rimpiazza il contenuto 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);
          }
        });
      })
    }
  };
});

Nota che abbiamo incapsulato ogni callback di socket in uno $scope.$apply. Questo suggerisce ad AngularJS che ha bisogno di controllare lo stato dell'applicazione e aggiornare i template se è occorso un cambiamento dopo l'esecuzione della callback che gli è stata passata. Internamente, $http funziona alla stessa maniera; dopo che qualche XHR è stato restituito, chiama $scope.$apply di modo che AngularJS possa aggiornare le sue viste di conseguenza.

Questo service non incapsula tutta la API Socket.IO (questo è lasciato come esercizio per il lettore ;P ). In ogni caso, esso include i metodi che vengono usati in questo tutorial e dovrebbe metterti sulla giusta strada se hai intenzione di ampliarlo. Potrei anche rivederlo scrivendo un wrapper completo, ma questo non rientra nel contesto del tutorial.

A questo punto, internamente al controller possiamo richiedere l'oggetto socket, un po' come faremmo con $http:

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

Dentro il controller aggiungiamo la logica per l'invio e la ricezione dei messaggi. Apri js/public/controllers.js e rimpiazza il contenuto 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 rappresenta solo una vista, quindi è possibile rimuovere il routing da public/js/app.js e semplificarlo con:

// 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 conservare lo stato del server, in modo che i nomi degli utenti siano unici.

// 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 sostanza questo definisce un insieme di nomi, ma utilizza API conformi al dominio di un server di chat. Agganciamo l'applicativo alla socket del server perché risponda alle chiamate effettuate dai 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);
  });
};

Con questo, l'applicazione dovrebbe essere completa. Provala eseguendo node app.js. L'applicazione dovrebbe aggiornarsi in tempo reale, grazie a Socket.IO.

Conclusioni

Ancora tanto può essere fatto per questa applicazione di messageria istantanea. Ad esempio, l'invio di messaggi vuoti. Potresti usare ng-valid per evitarlo lato client, e un controllo lato server. Magari il server potrebbe mantenere una cronologia recente dei messaggi, a beneficio dei nuovi utenti che si aggiungono all'applicazione.

Scrivere applicazioni AngularJS che facciano uso di altre librerie è semplice una volta capito come incapsularle in un servizio ed avvisare Angular che un model è cambiato. Come prossimo passo ho in mente di trattare l'uso di AngularJS con D3.js, la popolare libreria di visualizzazione.

Riferimenti

Comments

0