Escribe una aplicación de AngularJS con Socket.IO

Introducción

AngularJS es un increíble framework de JavaScript que te ofrece una vinculación de datos bidireccional que es rápida y fácil de usar, un potente sistema de directivas que te permite crear componentes personalizados reutilizables, y mucho más. Socket.IO es un wrapper de varios navegadores y un polyfill para websockets que facilita el desarrollo de aplicaciones en tiempo real. Por cierto, los dos funcionan bastante bien juntos.

Escribí antes sobre cómo escribir una app de AngularJS con Express, pero esta vez escribiré sobre cómo integrar Socket.IO para agregar funciones en tiempo real a una aplicación de AngularJS. En este instructivo, explicaré cómo escribir una app de mensajería instantánea. Esto se basa en mi instructivo anterior (con una pila node.js similar en el servidor), por lo que te recomiendo que la revises primero si no conoces Node.js o Express.

Abrir la demostración

Como siempre, puedes obtener el producto terminado en GitHub.

Requisitos previos

Hay un poco de código estándar para configurar Socket.IO e integrarlo en Express, así que creé el Seed de Angular Socket.IO.

Para comenzar, puedes clonar el repositorio angular-node-seed desde GitHub:

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

o descárgalo como un archivo ZIP.

Una vez que tengas la semilla, deberás obtener algunas dependencias con npm. Abre una terminal en el directorio con el valor inicial y ejecuta lo siguiente:

npm install

Con estas dependencias instaladas, puedes ejecutar la app de base:

node app.js

y míralo en tu navegador, en http://localhost:3000, para asegurarte de que el valor inicial funcione según lo esperado.

Cómo decidir las funciones de la app

Hay más de unas cuantas formas diferentes de escribir una aplicación de chat, así que vamos a describir las funciones mínimas que tendrán las nuestras. Habrá una sola sala de chat a la que pertenecerán todos los usuarios. Los usuarios pueden elegir y cambiar sus nombres, pero los nombres deben ser únicos. El servidor aplicará esta unicidad y anunciará cuando los usuarios cambien sus nombres. El cliente debería mostrar una lista de mensajes y una lista de usuarios que se encuentran actualmente en la sala de chat.

Una interfaz simple

Con esta especificación, podemos crear un frontend simple con Jade que proporcione los elementos necesarios de la IU. Abre views/index.jade y agrega lo siguiente dentro de 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')

Abre public/css/app.css y agrega el CSS para proporcionar columnas y desbordamientos:

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

Interactúa con Socket.IO

Aunque Socket.IO expone una variable io en window, es mejor encapsularla en el sistema de inyección de dependencias de AngularJS. Por lo tanto, comenzaremos por escribir un servicio para unir el objeto socket que muestra Socket.IO. Es fantástico, ya que facilitará probar nuestro control más adelante. Abre public/js/services.js y reemplaza el contenido por lo siguiente:

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

Observa que unimos cada devolución de llamada de socket en $scope.$apply. Esto le indica a AngularJS que debe verificar el estado de la aplicación y actualizar las plantillas si hubo algún cambio después de ejecutar la devolución de llamada que se le pasó. Internamente, $http funciona de la misma manera; después de que se devuelven algunas XHR, llama a $scope.$apply para que AngularJS pueda actualizar sus vistas según corresponda.

Ten en cuenta que este servicio no une toda la API de Socket.IO (eso se deja como un ejercicio para el lector ;P). Sin embargo, cubre los métodos usados en este instructivo y debería orientarte en la dirección correcta si quieres expandirlo. Podría volver a revisar cómo escribir un wrapper completo, pero eso está fuera del alcance de este instructivo.

Ahora, dentro de nuestro controlador, podemos solicitar el objeto socket, al igual que lo haríamos con $http:

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

En el controlador, agreguemos lógica para enviar y recibir mensajes. Abre js/public/controllers.js y reemplaza el contenido con lo siguiente:

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

Esta aplicación solo presentará una vista, por lo que podemos quitar el enrutamiento de public/js/app.js y simplificarlo a:

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

Cómo escribir el servidor

Abre routes/socket.js. Debemos definir un objeto para mantener el estado del servidor, de modo que los nombres de usuario sean únicos.

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

Esto define básicamente un conjunto de nombres, pero con APIs que tienen más sentido para el dominio de un servidor de chat. Conectamos esto al socket del servidor para responder las llamadas que hace el cliente:

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

Y con eso, la aplicación debería estar completa. Ejecuta node app.js para probarla. La aplicación debería actualizarse en tiempo real, gracias a Socket.IO.

Conclusión

Hay mucho más que puedes agregar a esta app de mensajería instantánea. Por ejemplo, puedes enviar mensajes vacíos. Puedes usar ng-valid para evitar que esto suceda en el cliente y en una verificación en el servidor. Tal vez el servidor podría mantener un historial reciente de mensajes para el beneficio de que nuevos usuarios se unan a la app.

Escribir apps de AngularJS que usen otras bibliotecas es fácil cuando entiendes cómo unirlas en un servicio y notificas a Angular que un modelo cambió. Ahora, abordaré el uso de AngularJS con D3.js, la popular biblioteca de visualización.

Referencias

Seed de Angular Socket.IO App de mensajería instantánea finalizada AngularJS Expreso Socket.IO`