การเขียนแอป AngularJS ด้วย Socket.IO

บทนำ

AngularJS เป็นเฟรมเวิร์ก JavaScript ที่ยอดเยี่ยมซึ่งให้การเชื่อมโยงข้อมูลแบบ 2 ทางที่ใช้งานง่ายและรวดเร็ว ระบบคำสั่งที่มีประสิทธิภาพซึ่งให้คุณสร้างคอมโพเนนต์ที่กำหนดเองและนำมาใช้ซ้ำได้ และอีกมากมาย Socket.IO เป็น Wrapper และ polyfill สำหรับ WebSocket ที่ทำงานข้ามเบราว์เซอร์ ซึ่งช่วยให้การพัฒนาแอปพลิเคชันแบบเรียลไทม์เป็นเรื่องง่าย และทั้ง 2 อย่างนี้ทำงานร่วมกันได้ดีทีเดียว

เราได้เขียนบทความเกี่ยวกับการเขียนแอป AngularJS ด้วย Express ไปแล้ว แต่ครั้งนี้เราจะเขียนเกี่ยวกับวิธีผสานรวม Socket.IO เพื่อเพิ่มฟีเจอร์แบบเรียลไทม์ลงในแอปพลิเคชัน AngularJS ในบทแนะนำนี้ เราจะอธิบายการเขียนแอปรับส่งข้อความทันที ซึ่งจะต่อยอดจากบทแนะนำก่อนหน้านี้ (โดยใช้สแต็ก Node.js ที่คล้ายกันในเซิร์ฟเวอร์) เราจึงขอแนะนำให้ดูบทแนะนำนั้นก่อนหากคุณไม่คุ้นเคยกับ Node.js หรือ Express

เปิดการสาธิต

คุณสามารถดาวน์โหลดผลิตภัณฑ์ที่เสร็จสมบูรณ์บน GitHub ได้ตามปกติ

ข้อกำหนดเบื้องต้น

การตั้งค่า Socket.IO และผสานรวมกับ Express ต้องใช้โค้ดที่ต้องเขียนซ้ำอยู่บ้าง เราจึงสร้าง Angular Socket.IO Seed

หากต้องการเริ่มต้นใช้งาน คุณสามารถโคลนที่เก็บ angular-node-seed จาก GitHub ได้โดยทำดังนี้

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

หรือดาวน์โหลดเป็นไฟล์ ZIP

เมื่อได้ข้อมูลเริ่มต้นแล้ว คุณจะต้องดาวน์โหลด npm บางส่วน เปิดเทอร์มินัลไปยังไดเรกทอรีที่มีข้อมูลเริ่มต้น แล้วเรียกใช้คำสั่งต่อไปนี้

npm install

เมื่อติดตั้งข้อกําหนดเหล่านี้แล้ว คุณจะเรียกใช้แอปโครงร่างได้

node app.js

และดูในเบราว์เซอร์ที่ http://localhost:3000 เพื่อให้แน่ใจว่าข้อมูลพร็อพเพอร์ตี้เริ่มต้นทํางานตามที่คาดไว้

การเลือกฟีเจอร์ของแอป

การเขียนแอปพลิเคชันแชทมีหลายวิธี เรามาอธิบายฟีเจอร์ขั้นต่ำของแอปพลิเคชันของเรากัน โดยจะมีเพียงห้องแชทเดียวที่ผู้ใช้ทุกคนจะอยู่ในนั้น ผู้ใช้สามารถเลือกและเปลี่ยนชื่อได้ แต่ชื่อต้องไม่ซ้ำกัน เซิร์ฟเวอร์จะบังคับใช้ความซ้ำกันนี้และประกาศเมื่อผู้ใช้เปลี่ยนชื่อ ไคลเอ็นต์ควรแสดงรายการข้อความและรายชื่อผู้ใช้ที่อยู่ในห้องแชทในขณะนั้น

ฟรอนต์เอนด์ที่ใช้งานง่าย

ข้อกำหนดนี้ช่วยให้เราสร้างหน้าเว็บส่วนหน้าแบบง่ายด้วย Jade ซึ่งให้องค์ประกอบ UI ที่จำเป็น เปิด 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 จะแสดงตัวแปร io ใน window แต่คุณควรรวมไว้ในระบบการฉีด Dependency ของ AngularJS เราจะเริ่มต้นด้วยเขียนบริการเพื่อรวมออบเจ็กต์ socket ที่ Socket.IO แสดงผล เยี่ยมมาก เพราะจะช่วยให้การทดสอบตัวควบคุมของเราในภายหลังง่ายขึ้นมาก เปิด 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 ) แต่ครอบคลุมวิธีการที่ใช้ในบทแนะนำนี้และควรชี้แนะแนวทางที่ถูกต้องหากคุณต้องการขยายการให้บริการ เราอาจกลับมาเขียนรัปเปอร์แบบสมบูรณ์อีกครั้ง แต่นั่นอยู่นอกเหนือขอบเขตของบทแนะนำนี้

ตอนนี้ภายในตัวควบคุม เราสามารถขอออบเจ็กต์ socket ได้เช่นเดียวกับที่ขอ $http

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 ทราบว่าโมเดลมีการเปลี่ยนแปลง ต่อไปเราวางแผนที่จะพูดถึงการใช้ AngularJS กับ D3.js ซึ่งเป็นไลบรารีการแสดงภาพที่ได้รับความนิยม

ข้อมูลอ้างอิง

Angular Socket.IO Seed แอปรับส่งข้อความทันทีที่เสร็จสมบูรณ์ AngularJS Express Socket.IO`