Como escrever um aplicativo AngularJS com Socket.IO

Introdução

O AngularJS é um ótimo framework de JavaScript que oferece vinculação de dados bidirecional fácil de usar e rápida, um sistema de diretiva eficiente que permite criar componentes personalizados reutilizáveis e muito mais. O Socket.IO é um wrapper de vários navegadores e um polyfill para websockets que facilita muito o desenvolvimento de aplicativos em tempo real. Aliás, os dois funcionam muito bem juntos!

Escrevi antes sobre como criar um aplicativo AngularJS com Express, mas desta vez vou escrever sobre como integrar o Socket.IO para adicionar recursos em tempo real a um aplicativo AngularJS. Neste tutorial, mostrarei como criar um app de mensagens instantâneas. Isso é baseado no meu tutorial anterior (usando uma pilha node.js semelhante no servidor). Portanto, recomendamos verificar isso primeiro se você não estiver familiarizado com Node.js ou Express.

Abrir a demonstração

Como sempre, você pode acessar o produto final no GitHub.

Pré-requisitos

Há um pouco de código clichê para configurar e integrar o Socket.IO ao Express, então criei o Angular Socket.IO Seed.

Para começar, clone o repositório angular-node-seed do GitHub:

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

ou faça o download dele como um arquivo ZIP (link em inglês).

Quando tiver a sugestão, você vai precisar de algumas dependências com o npm. Abra um terminal no diretório com a sugestão e execute:

npm install

Com essas dependências instaladas, você pode executar o app esqueleto:

node app.js

e vê-lo no navegador em http://localhost:3000 para garantir que o modelo esteja funcionando conforme o esperado.

Como decidir sobre os recursos do app

Existem diversas maneiras de criar um aplicativo de chat, então vamos descrever os recursos mínimos que o nosso terá. Todos os usuários vão pertencer a apenas uma sala de chat. Os usuários podem escolher e mudar o próprio nome, mas os nomes precisam ser exclusivos. O servidor aplicará essa exclusividade e anunciará quando os usuários alterarem seus nomes. O cliente vai expor uma lista de mensagens e uma lista de usuários na sala de chat.

Um front-end simples

Com essa especificação, podemos fazer um front-end simples com Jade que fornece os elementos de interface necessários. Abra views/index.jade e adicione isto a 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')

Abra public/css/app.css e adicione o CSS para fornecer colunas e estouros:

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

Interagir com o Socket.IO

Embora o Socket.IO exponha uma variável io no window, é melhor encapsulá-la no sistema de injeção de dependências (link em inglês) do AngularJS. Portanto, começaremos escrevendo um serviço para unir o objeto socket retornado pelo Socket.IO. Isso é incrível, porque vai ficar muito mais fácil testar nosso controlador mais tarde. Abra public/js/services.js e substitua o conteúdo por:

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

Unimos cada callback de soquete no $scope.$apply. Isso informa à AngularJS que precisa verificar o estado do aplicativo e atualizar os modelos se houver uma mudança após a execução do callback passado para ele. Internamente, $http funciona da mesma maneira. Depois que um XHR é retornado, ele chama $scope.$apply para que o AngularJS possa atualizar as visualizações adequadamente.

Observe que esse serviço não envolve toda a API Socket.IO (que é deixada como um exercício para o leitor ;P ). No entanto, ele abrange os métodos usados neste tutorial e deve apontá-lo na direção correta se você quiser expandi-lo. Posso voltar a escrever um wrapper completo, mas isso está além do escopo deste tutorial.

Agora, no nosso controlador, podemos solicitar o objeto socket, assim como faríamos com $http:

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

Dentro do controlador, vamos adicionar lógica para enviar e receber mensagens. Abra js/public/controllers.js e substitua o conteúdo pelo seguinte:

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

Esse aplicativo terá apenas uma visualização, então podemos remover o roteamento de public/js/app.js e simplificá-lo para:

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

Como gravar o servidor

Abra o routes/socket.js. Precisamos definir um objeto para manter o estado do servidor, de modo que os nomes de usuário sejam exclusivos.

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

Basicamente, isso define um conjunto de nomes, mas com APIs que fazem mais sentido para o domínio de um servidor de chat. Vamos conectar isso ao soquete do servidor para responder às chamadas feitas pelo 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);
  });
};

Com isso, o aplicativo deve estar completo. Faça um teste executando node app.js. O aplicativo será atualizado em tempo real, graças ao Socket.IO.

Conclusão

Você pode adicionar muito mais a esse app de mensagens instantâneas. Por exemplo, envie mensagens vazias. É possível usar ng-valid para evitar isso no lado do cliente e fazer uma verificação no servidor. Talvez o servidor possa manter um histórico recente de mensagens para beneficiar os novos usuários que acessarem o app.

É fácil escrever aplicativos AngularJS que usam outras bibliotecas depois de entender como incorporá-los em um serviço e notificar o Angular que um modelo foi alterado. Em seguida, pretendo abordar o uso de AngularJS com D3.js, a conhecida biblioteca de visualização.

Referências

Angular Socket.IO Seed App de mensagens instantâneas concluído AngularJS Express Socket.IO`