מבוא
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
אחרי שתקבלו את ה-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`