كتابة تطبيق AngularJS باستخدام Socket.IO

مقدمة

AngularJS هو إطار عمل رائع لبرنامج JavaScript يوفّر لك ربط بيانات ثنائي الاتجاه سهل الاستخدام وسريع، ونظام توجيهات فعّال يتيح لك إنشاء مكوّنات مخصّصة قابلة لإعادة الاستخدام، بالإضافة إلى الكثير من الميزات الأخرى. ‫Socket.IO هو حزمة برمجية شاملة لمتصفحات الويب وبرنامج polyfill لبروتوكول websockets، ما يسهّل تطوير التطبيقات في الوقت الفعلي. يُرجى العِلم أنّ هذين الخيارَين يعملان معًا بشكل جيد.

لقد كتبتُ من قبل عن كتابة تطبيق 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 توفّر عناصر واجهة المستخدم اللازمة. افتح 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);
          }
        });
      })
    }
  };
});

يُرجى العلم أنّنا نلف كل وظيفة استدعاء مقبس في $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
  };
}());

يحدِّد هذا بشكل أساسي مجموعة من الأسماء، ولكن باستخدام واجهات برمجة التطبيقات التي تُعدّ أكثر منطقية لنطاق خادم المحادثة. لنربط هذا العنصر بمقبس الخادم للردّ على المكالمات التي يجريها العميل:

// 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`