Viết ứng dụng AngularJS bằng Socket.IO

Giới thiệu

AngularJS là một khung JavaScript tuyệt vời cung cấp cho bạn tính năng liên kết dữ liệu hai chiều vừa dễ sử dụng vừa nhanh chóng. Đây là một hệ thống lệnh mạnh mẽ cho phép bạn tạo các thành phần tuỳ chỉnh có thể tái sử dụng và nhiều lợi ích khác. Socket.IO là một trình bao bọc và polyfill trên nhiều trình duyệt cho websockets giúp việc phát triển các ứng dụng theo thời gian thực trở nên dễ dàng. Tình cờ là cả hai kết hợp khá ăn ý!

Trước đây tôi đã từng viết về cách viết ứng dụng AngularJS bằng Express, nhưng lần này tôi sẽ viết về cách tích hợp Socket.IO để thêm các tính năng theo thời gian thực vào ứng dụng AngularJS. Trong hướng dẫn này, tôi sẽ tìm hiểu cách viết ứng dụng nhắn tin nhanh. Cách này được xây dựng dựa trên hướng dẫn trước đó của tôi (sử dụng ngăn xếp nút.js tương tự trên máy chủ), vì vậy bạn nên kiểm tra trước nếu chưa quen với Node.js hoặc Express.

Mở bản minh hoạ

Như thường lệ, bạn có thể nhận thành phẩm trên GitHub.

Điều kiện tiên quyết

Có một số bản mẫu để thiết lập và tích hợp Socket.IO với Express, vì vậy tôi đã tạo Angular Socket.IO Seed.

Để bắt đầu, bạn có thể sao chép kho lưu trữ angular-node-seed từ GitHub:

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

hoặc tải tệp xuống dưới dạng tệp zip.

Sau khi đã có nội dung gốc, bạn cần lấy một vài phần phụ thuộc với npm. Mở một cửa sổ dòng lệnh đến thư mục có tệp gốc rồi chạy:

npm install

Khi đã cài đặt các phần phụ thuộc này, bạn có thể chạy ứng dụng skeleton:

node app.js

và xem mã này trong trình duyệt của bạn tại http://localhost:3000 để đảm bảo nội dung gốc đang hoạt động như mong đợi.

Quyết định tính năng của ứng dụng

Có nhiều cách để viết ứng dụng trò chuyện, vì vậy hãy cùng mô tả các tính năng tối thiểu mà chúng ta sẽ có. Sẽ chỉ có một phòng trò chuyện cho tất cả người dùng. Người dùng có thể chọn và đổi tên, nhưng tên phải là duy nhất. Máy chủ sẽ thực thi tính duy nhất này và thông báo khi người dùng thay đổi tên của họ. Ứng dụng sẽ hiển thị một danh sách tin nhắn và danh sách người dùng hiện có trong phòng trò chuyện.

Giao diện người dùng đơn giản

Với quy cách này, chúng ta có thể tạo một giao diện người dùng đơn giản bằng Jada để cung cấp các phần tử cần thiết trên giao diện người dùng. Mở views/index.jade và thêm đoạn mã này bên trong 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')

Mở public/css/app.css rồi thêm CSS để cung cấp cột và tràn:

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

Tương tác với Socket.IO

Mặc dù Socket.IO hiển thị biến io trên window, nhưng tốt hơn là bạn nên đóng gói biến đó trong hệ thống Chèn phần phụ thuộc của AngularJS. Vì vậy, chúng ta sẽ bắt đầu bằng cách viết một dịch vụ gói đối tượng socket do Socket.IO trả về. Thật tuyệt vời vì nó sẽ giúp sau này kiểm thử bộ điều khiển dễ dàng hơn nhiều. Mở public/js/services.js rồi thay thế nội dung bằng:

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

Xin lưu ý rằng chúng ta gói từng lệnh gọi lại socket trong $scope.$apply. Điều này cho AngularJS biết rằng cần kiểm tra trạng thái của ứng dụng và cập nhật các mẫu nếu có thay đổi sau khi chạy lệnh gọi lại được truyền đến ứng dụng đó. Về phía nội bộ, $http hoạt động theo cách tương tự; sau khi một số XHR trả về, nó sẽ gọi $scope.$apply để AngularJS có thể cập nhật các khung hiển thị của nó cho phù hợp.

Lưu ý rằng dịch vụ này không bao bọc toàn bộ Socket.IO API (đây là một bài tập dành cho người đọc ;P). Tuy nhiên, dịch vụ này bao gồm các phương thức được sử dụng trong hướng dẫn này và sẽ chỉ cho bạn đúng hướng nếu bạn muốn mở rộng. Tôi có thể xem lại cách viết một trình bao bọc hoàn chỉnh, nhưng việc này nằm ngoài phạm vi của hướng dẫn này.

Bây giờ, trong bộ điều khiển, chúng ta có thể yêu cầu đối tượng socket, giống như cách chúng ta sẽ yêu cầu $http:

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

Bên trong trình điều khiển, hãy thêm logic để gửi và nhận thông báo. Mở js/public/controllers.js rồi thay thế nội dung bằng nội dung sau:

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

Ứng dụng này sẽ chỉ có một chế độ xem, vì vậy chúng ta có thể xoá định tuyến từ public/js/app.js và đơn giản hoá nó thành:

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

Viết máy chủ

Mở routes/socket.js. Chúng ta cần xác định một đối tượng để duy trì trạng thái của máy chủ, đảm bảo tên người dùng là duy nhất.

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

Về cơ bản, điều này xác định một tập hợp các tên, nhưng với các API phù hợp hơn cho miền của máy chủ trò chuyện. Hãy kết nối máy chủ này với ổ cắm của máy chủ để phản hồi các lệnh gọi mà ứng dụng của chúng ta thực hiện:

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

Vậy là đơn đăng ký của bạn đã hoàn tất. Hãy dùng thử bằng cách chạy node app.js. Ứng dụng sẽ cập nhật theo thời gian thực, nhờ Socket.IO.

Kết luận

Có rất nhiều nội dung khác bạn có thể thêm vào ứng dụng nhắn tin nhanh này. Ví dụ: bạn có thể gửi tin nhắn trống. Bạn có thể dùng ng-valid để ngăn điều này ở phía máy khách và kiểm tra máy chủ. Có thể máy chủ giữ nhật ký tin nhắn gần đây để phục vụ cho những người dùng mới tham gia ứng dụng.

Việc viết ứng dụng AngularJS để tận dụng các thư viện khác sẽ rất dễ dàng sau khi bạn hiểu cách gói chúng trong một dịch vụ và thông báo cho Angular rằng một mô hình đã thay đổi. Tiếp theo, tôi dự định sẽ đề cập đến việc sử dụng AngularJS với D3.js, thư viện hình ảnh phổ biến.

Tài liệu tham khảo

Angular Socket.IO Seed Ứng dụng nhắn tin nhanh đã hoàn tất AngularJS Express Socket.IO`