Introducción
AngularJS es un excelente framework de JavaScript que te brinda una vinculación de datos de dos vías que es fácil de usar y rápida, un sistema de directivas potente que te permite crear componentes personalizados reutilizables y mucho más. Socket.IO es un wrapper y un polyfill multinavegador para WebSockets que facilita el desarrollo de aplicaciones en tiempo real. Por cierto, ambos funcionan muy bien juntos.
Anteriormente, escribí sobre cómo escribir una app de AngularJS con Express, pero esta vez hablaré sobre cómo integrar Socket.IO para agregar funciones en tiempo real a una aplicación de AngularJS. En este instructivo, te explicaré cómo escribir una app de mensajería instantánea. Esto se basa en mi instructivo anterior (que usa una pila de node.js similar en el servidor), por lo que te recomiendo que lo consultes primero si no conoces Node.js o Express.
Como siempre, puedes obtener el producto terminado en GitHub.
Requisitos previos
Hay un poco de código estándar para configurar Socket.IO y, luego, integrarlo con Express, por lo que creé el Angular Socket.IO Seed.
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 información inicial, debes 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 esqueleto:
node app.js
y búscala en tu navegador en http://localhost:3000
para asegurarte de que el valor inicial funcione como se espera.
Cómo decidir las funciones de la app
Existen varias formas de escribir una aplicación de chat, así que describamos las funciones mínimas que tendrá la nuestra. Solo habrá una sala de chat a la que pertenecerán todos los usuarios. Los usuarios pueden elegir y cambiar su nombre, pero los nombres deben ser únicos. El servidor aplicará esta unicidad y anunciará cuando los usuarios cambien sus nombres. El cliente debe exponer una lista de mensajes y una lista de usuarios que están en la sala de chat.
Un frontend simple
Con esta especificación, podemos crear un frontend simple con Jade que proporcione los elementos de la IU necesarios. 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;
}
Cómo interactuar con Socket.IO
Aunque Socket.IO expone una variable io
en window
, es mejor encapsularla en el sistema de inserción de dependencias de AngularJS. Por lo tanto, comenzaremos por escribir un servicio para unir el objeto socket
que devuelve Socket.IO. Esto es genial, ya que nos permitirá probar nuestro controlador más adelante con mayor facilidad. 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 un cambio después de ejecutar la devolución de llamada que se le pasó. De forma interna, $http
funciona de la misma manera. Después de que se devuelve un 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 queda como ejercicio para el lector ;P). Sin embargo, abarca los métodos que se usan en este instructivo y debería guiarte en la dirección correcta si quieres ampliarlos. Es posible que vuelva a escribir un wrapper completo, pero eso está fuera del alcance de este instructivo.
Ahora, dentro de nuestro controlador, podemos solicitar el objeto socket
, de la misma manera que lo haríamos con $http
:
function AppCtrl($scope, socket) {
/* Controller logic */
}
Dentro del controlador, agreguemos lógica para enviar y recibir mensajes. Abre js/public/controllers.js
y reemplaza el contenido por 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 tendrá una vista, por lo que podemos quitar el enrutamiento de public/js/app.js
y simplificarlo a lo siguiente:
// 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
};
}());
Básicamente, define un conjunto de nombres, pero con APIs que tienen más sentido para el dominio de un servidor de chat. Conectemos esto al socket del servidor para responder las llamadas que realiza nuestro 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);
});
};
Con eso, la solicitud debería estar completa. Para probarlo, ejecuta node app.js
. 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 esto del lado del cliente y realizar una verificación en el servidor. Tal vez el servidor podría mantener un historial reciente de mensajes para beneficiar a los usuarios nuevos que se unen a la app.
Escribir apps de AngularJS que usen otras bibliotecas es fácil una vez que comprendas cómo unirlas en un servicio y notificar a Angular que cambió un modelo. A continuación, planeo explicar el uso de AngularJS con D3.js, la popular biblioteca de visualización.
Referencias
Angular Socket.IO Seed App de mensajería instantánea terminada AngularJS Express Socket.IO`