Socket.IO로 AngularJS 앱 작성

소개

AngularJS는 사용하기 쉽고 빠른 양방향 데이터 바인딩을 제공하는 멋진 JavaScript 프레임워크로, 재사용 가능한 맞춤 구성요소 등을 만들 수 있게 해주는 강력한 지시문 시스템입니다. Socket.IO는 실시간 애플리케이션 개발을 간편하게 만들어 주는 WebSocket용 크로스 브라우저 래퍼 및 폴리필입니다. 게다가 이 둘은 굉장히 잘 작동합니다.

이전에 Express를 사용하여 AngularJS 앱을 작성하는 것에 대해 작성했지만 이번에는 Socket.IO를 통합하여 AngularJS 애플리케이션에 실시간 기능을 추가하는 방법에 대해 작성하겠습니다. 이 튜토리얼에서는 채팅 앱을 작성하는 방법을 안내해 드리겠습니다. 이 튜토리얼은 이전 튜토리얼 (서버에서 유사한 node.js 스택 사용)을 기반으로 빌드되므로 Node.js 또는 Express에 익숙하지 않다면 먼저 이 튜토리얼을 확인하는 것이 좋습니다.

데모 열기

언제나 그렇듯이 GitHub에서 완성된 제품을 제공할 수 있습니다.

기본 요건

Socket.IO를 설정하고 Express와 통합하는 데는 약간의 상용구가 있으므로 Angular Socket.IO Seed를 만들었습니다.

시작하려면 GitHub에서 angular-node-seed 저장소를 클론하면 됩니다.

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

또는 ZIP 파일로 다운로드하세요.

시드가 있으면 npm으로 몇 가지 종속 항목을 가져와야 합니다. 시드가 있는 디렉터리로 연결되는 터미널을 열고 다음을 실행합니다.

npm install

이러한 종속 항목이 설치되어 있으면 스켈레톤 앱을 실행할 수 있습니다.

node app.js

http://localhost:3000의 브라우저에서 이를 확인하여 시드가 예상대로 작동하는지 확인합니다.

앱 기능 결정하기

채팅 애플리케이션을 작성하는 방법에는 여러 가지가 있으므로 여기서 사용할 수 있는 최소한의 기능을 설명하겠습니다. 모든 사용자가 참여할 수 있는 채팅방은 하나만 생성됩니다. 사용자는 자신의 이름을 선택하고 변경할 수 있지만 이름은 고유해야 합니다. 서버는 이러한 고유성을 적용하여 사용자가 이름을 변경하면 알려드립니다. 클라이언트는 메시지 목록과 현재 채팅방에 있는 사용자 목록을 노출해야 합니다.

간단한 프런트 엔드

이 사양을 통해, 필요한 UI 요소를 제공하는 Jade로 간단한 프런트엔드를 만들 수 있습니다. views/index.jade를 열고 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')

public/css/app.css를 열고 CSS를 추가하여 열과 오버플로를 제공합니다.

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

Socket.IO와 상호작용

Socket.IO는 window에서 io 변수를 노출하지만 AngularJS의 종속 항목 삽입 시스템에 캡슐화하는 것이 좋습니다. 따라서 먼저 Socket.IO에서 반환된 socket 객체를 래핑하는 서비스를 작성해 보겠습니다. 이렇게 하면 나중에 컨트롤러를 테스트하기가 훨씬 더 쉬워집니다. public/js/services.js를 열고 콘텐츠를 다음으로 바꿉니다.

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

각 소켓 콜백을 $scope.$apply에 래핑합니다. 이는 AngularJS에 애플리케이션의 상태를 확인하고, 전달된 콜백을 실행한 후에 변경사항이 발생한 경우 템플릿을 업데이트해야 한다고 알려줍니다. 내부적으로 $http는 동일한 방식으로 작동합니다. 일부 XHR이 반환된 후에는 $scope.$apply를 호출하므로 AngularJS가 그에 따라 뷰를 업데이트할 수 있습니다.

이 서비스는 전체 Socket.IO API를 래핑하지 않습니다 (P 를 위한 연습으로 남김). 그러나 이 튜토리얼에서 사용되는 메서드를 다루며 이를 확장하려는 경우 올바른 방향을 가리켜야 합니다. 완전한 래퍼 작성을 다시 할 수도 있지만, 이 튜토리얼에서는 다루지 않습니다.

이제 $http에서와 마찬가지로 컨트롤러 내에서 socket 객체를 요청할 수 있습니다.

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

컨트롤러 내에서 메시지를 보내고 받기 위한 로직을 추가해 보겠습니다. js/public/controllers.js를 열고 콘텐츠를 다음으로 바꿉니다.

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

이 애플리케이션에는 하나의 보기만 제공되므로 public/js/app.js에서 경로를 삭제하고 다음과 같이 단순화할 수 있습니다.

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

서버 작성

routes/socket.js를 엽니다. 서버 상태를 유지하기 위한 객체를 정의하여 사용자 이름이 고유하도록 해야 합니다.

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

이는 기본적으로 이름 집합을 정의하지만 채팅 서버의 도메인에 더 적합한 API를 사용합니다. 이를 서버의 소켓에 연결하여 클라이언트가 수행하는 호출에 응답해 보겠습니다.

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

이제 신청서가 완성됩니다. node app.js를 실행하여 사용해 보세요. Socket.IO 덕분에 애플리케이션이 실시간으로 업데이트되어야 합니다.

결론

이 채팅 앱에 추가할 수 있는 다른 항목이 많습니다. 예를 들어 빈 메시지를 제출할 수 있습니다. ng-valid를 사용하여 클라이언트 측에서 이를 방지하고 서버에서 검사를 방지할 수 있습니다. 신규 사용자가 앱에 참여하는 데 도움이 되도록 서버에서 최근 메시지 기록을 유지할 수도 있습니다.

다른 라이브러리를 사용하는 AngularJS 앱을 손쉽게 작성하는 방법은 이 라이브러리를 서비스에서 래핑하는 방법을 이해하고 Angular에 모델이 변경되었음을 알리는 것입니다. 다음으로 널리 사용되는 시각화 라이브러리인 D3.js와 함께 AngularJS를 사용하는 방법을 다룰 계획입니다.

참조

Angular Socket.IO Seed 완성된 인스턴트 메시징 앱 AngularJS Express Socket.IO`