כתיבת אפליקציית AngularJS באמצעות Socket.IO

מבוא

AngularJS היא מסגרת JavaScript נהדרת שמספקת קישור נתונים דו-כיווני שקל להשתמש בו והוא גם מהיר, מערכת הוראות חזקה שמאפשרת ליצור רכיבים מותאמים אישית לשימוש חוזר ועוד הרבה יותר. Socket.IO הוא מעטפת ו-polyfill ל-websockets בכל הדפדפנים, שמאפשרים לפתח אפליקציות בזמן אמת בקלות. דרך אגב, השניים פועלים יחד די טוב!

כתבתי בעבר על כתיבה של אפליקציית AngularJS באמצעות Express, אבל הפעם אכתוב על שילוב של Socket.IO כדי להוסיף תכונות בזמן אמת לאפליקציית AngularJS. במדריך הזה אראה איך לכתוב אפליקציית הודעות מיידיות. המדריך הזה מבוסס על המדריך הקודם שלי (שבו נעשה שימוש ב-stack דומה של 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.

אחרי שתקבלו את ה-seed, תצטרכו להוסיף כמה יחסי תלות באמצעות npm. פותחים טרמינל בספרייה עם המקור ומריצים את הפקודה:

npm install

אחרי שתתקינו את יחסי התלות האלה, תוכלו להריץ את האפליקציה השלד:

node app.js

ואפשר לראות אותו בדפדפן בכתובת http://localhost:3000 כדי לוודא שהזרע פועל כצפוי.

בחירת התכונות של האפליקציה

יש כמה דרכים שונות לכתוב אפליקציית צ'אט, אז ננסה לתאר את התכונות המינימליות שיהיו לאפליקציה שלנו. יהיה רק חדר צ'אט אחד שאליו כל המשתמשים יהיו שייכים. המשתמשים יכולים לבחור ולשנות את השם שלהם, אבל השמות חייבים להיות ייחודיים. השרת יאכוף את הייחודיות הזו ויודיע כשמשתמשים ישנו את השמות שלהם. הלקוח צריך לחשוף רשימה של הודעות ורשימת משתמשים שנמצאים כרגע בחדרי הצ'אט.

ממשק קצה פשוט

בעזרת המפרט הזה, אפשר ליצור ממשק קצה פשוט ב-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 חושף משתנה io ב-window, אבל עדיף להכיל אותו במערכת ההזרקה של יחסי התלות של 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);
          }
        });
      })
    }
  };
});

שימו לב שאנחנו עוטפים כל קריאה חוזרת (callback) של שקע ב-$scope.$apply. הפקודה הזו מורה ל-AngularJS לבדוק את מצב האפליקציה ולעדכן את התבניות אם היה שינוי אחרי הרצת הפונקציה החוזרת (callback) שהועברה אליה. באופן פנימי, $http פועלת באותו אופן: אחרי כמה החזרות של XHR, היא קוראת ל-$scope.$apply כדי ש-AngularJS תוכל לעדכן את התצוגות שלה בהתאם.

חשוב לזכור שהשירות הזה לא מכסה את כל ה-API של Socket.IO (זה נשאר כתרגיל לקורא ;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 שמתאימים יותר לדומיין של שרת צ'אט. נקשר את זה ליציאה (socket) של השרת כדי להגיב לשיחות שהלקוח שלנו מבצע:

// 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 כדי למנוע זאת בצד הלקוח, ובבדיקה בשרת. יכול להיות שהשרת ישמור היסטוריה של הודעות מהזמן האחרון כדי לעזור למשתמשים חדשים שמצטרפים לאפליקציה.

כשתדעו איך לעטוף אותן בשירות ולהודיע ל-Angular שהמודל השתנה, תוכלו לכתוב בקלות אפליקציות AngularJS שמשתמשות בספריות אחרות. בפרק הבא אדבר על שימוש ב-AngularJS עם D3.js, ספריית התצוגה החזותית הפופולרית.

קובצי עזר

Angular Socket.IO Seed אפליקציית הודעות מיידיות מוכנה AngularJS Express Socket.IO`