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

בריאן פורד
בריאן פורד

מבוא

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

כתבתי בעבר על כתיבת אפליקציית AngularJS באמצעות Express, אך הפעם אכתוב על אופן השילוב של Socket.IO כדי להוסיף תכונות בזמן אמת לאפליקציית AngularJS. במדריך הזה אסביר על כתיבת אפליקציה להעברת הודעות מיידיות. ההסבר הזה מבוסס על המדריך הקודם (שהשתמשתי בסטאק דומה ב-צומת.js בשרת), ולכן אם אתם לא מתמצאים ב-Node.js או ב-Express.

לפתיחת ההדגמה

כמו תמיד, אתה יכול לקבל את המוצר המוגמר ב-GitHub.

דרישות מוקדמות

צריך להגדיר ולשלב את Socket.IO עם תבנית סטנדרטית, אז יצרתי את Angular Socket.IOSeed.

כדי להתחיל, תוכלו לשכפל את מאגר הזרעים הזוויתיים מ-GitHub:

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

או להוריד אותו כקובץ ZIP.

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

npm install

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

node app.js

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

החלטה לגבי פיצ'רים באפליקציה

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

ממשק קצה פשוט

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

לתשומת ליבכם, שירות זה לא מקיף את כל ה-API של Socket.IO (נשאר תרגיל ל- ;P ). עם זאת, הוא מכסה את השיטות המשמשות במדריך זה, והוא צריך להפנות אותך לכיוון הנכון אם ברצונך להרחיב אותו. אנסה שוב לכתוב wrapper, אבל זה מעבר להיקף של המדריך הזה.

עכשיו, בבקר שלנו, אנחנו יכולים לבקש את האובייקט 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.IOSeed סיימת את האפליקציה להעברת הודעות מיידיות AngularJS Express Socket.IO'