Công nghệ web thời sơ khai có một nhược điểm hiển thị dữ liệu không nhất quán, tức là sẽ có trường hợp 2 trình duyệt cùng trỏ đến một địa chỉ URL nhưng nội dung trả về lại khác nhau. Ví dụ như có 2 người cùng truy cập một trang wiki, sau đó một trong hai người chỉnh sửa lại trang này, thì sau khi người chỉnh sửa xong bấm lưu trang, chỉ có người đó mới thấy được sự thay đổi, người kia nếu muốn thấy sự thay đổi ngay lúc đó thì phải refresh lại trang đó mới thấy được. Với sự phát triển của công nghệ web ngày nay thì chúng ta đã có thể làm cho web thực hiện các công việc theo thời gian thực (real-time), tức là khi trang web của trình duyệt bên này có sự thay đổi thì lập tức trình duyệt bên kia bằng cách nào có cũng sẽ tự cập nhật thay đổi đó luôn. Ví dụ điển hình nhất là Facebook, mỗi khi có người bấm “like” hoặc bình luận là ngay lập tức bạn sẽ thấy ngay, hoặc chí ít là sẽ có thông báo đến cho bạn.
Trong phần này chúng ta sẽ tìm hiểu cách xây dựng các tính năng real-time như vậy trong ứng dụng Notes, thực ra ngay từ đầu thì mục đích phát minh ra Node là để hỗ trợ các tính năng real-time rồi.
Việc code tính năng real-time cũng sẽ đụng chạm rất nhiều đến phần giao thức mạng, vốn dĩ là thứ mà chúng ta không nên quan tâm, do đó chúng ta sẽ sử dụng thư viện cho nhanh 🙂 Ở đây chúng ta sẽ dùng thư viện Socket.IO, thư viện này đơn giản hóa quá trình tuyền thông giữa trình duyệt và server, hỗ trợ rất nhiều giao thức, ngoài ra còn hỗ trợ cả Internet Explorer tới cả phiên bản 5.5 nữa.
Một thư viện khác thường đi chung với Socket.IO là Backbone.js, đây là một thư viện hỗ trợ xây dựng model nhanh chóng. Tuy nhiên chúng ta sẽ không dùng đến thư viện này vì dữ liệu model của chúng ta rất đơn giản, không phức tạp.
Khởi tạo Socket.IO với Express
Trong các bài trước, chúng ta đã biết là đối tượng http.Server
là đối tượng chính thực hiện phần mạng, Express được xây dựng dựa trên đối tượng http.Server,
và Socket.IO cũng hoạt động tương tự như vậy.
Đầu tiên chúng ta khai báo trong file package.json:
{ "name": "notes", "version": "0.0.0", "private": true, "scripts": { "start": "node ./bin/www" }, "dependencies": { "body-parser": "~1.15.1", "cookie-parser": "~1.4.3", "debug": "~2.2.0", "ejs": "~2.4.1", "express": "~4.13.4", "morgan": "~1.7.0", "serve-favicon": "~2.3.0", "async": "*", "sqlite3": "*", "mongoose": "*", "sequelize": "*", "connect-flash": "*", "passport": "*", "passport-local": "*", "express-session": "*", "workforce": "*", "socket.io": "*" } }
Sau đó chạy lệnh npm install
để cài module socket.io.
Chỉnh sửa app.js
File app.js
được sửa lại như sau
var http = require('http'); var flash = require('connect-flash'); var passport = require('passport'); var LocalStrategy = require('passport-local').Strategy; var expressSession = require('express-session'); var express = require('express'); var path = require('path'); var favicon = require('serve-favicon'); var logger = require('morgan'); var cookieParser = require('cookie-parser'); var bodyParser = require('body-parser'); var routes = require('./routes/index'); var users = require('./routes/users'); var notes = require('./routes/notes'); //var models = require('./models-fs/notes'); //var models = require('./models-sqlite3/notes'); //var models = require('./models-mongoose/notes'); var models = require('./models-sequelize/notes'); var usersModels = require('./models-sequelize/users'); models.connect(require('./sequelize-params'), function(err) { if(err) throw err; }); usersModels.connect(require('./sequelize-params'), function(err) { if(err) throw err; }); users.configure({ users: usersModels, passport: passport }); notes.configure(models); routes.configure(models); var app = express(); passport.serializeUser(users.serialize); passport.deserializeUser(users.deserialize); passport.use(users.strategy); // view engine setup app.set('views', path.join(__dirname, 'views')); app.set('view engine', 'ejs'); // uncomment after placing your favicon in /public //app.use(favicon(path.join(__dirname, 'public', 'favicon.ico'))); app.use(logger('dev')); app.use(bodyParser.json()); app.use(bodyParser.urlencoded({ extended: false })); app.use(cookieParser()); app.use(express.static(path.join(__dirname, 'public'))); app.use(expressSession({secret: 'keyboard cat'})); app.use(flash()); app.use(passport.initialize()); app.use(passport.session()); app.use('/', routes.index); app.use('/noteview', notes.view); app.use('/noteadd', users.ensureAuthenticated, notes.add); app.use('/noteedit', users.ensureAuthenticated, notes.edit); app.use('/notedestroy', users.ensureAuthenticated, notes.destroy); app.post('/notedodestroy', users.ensureAuthenticated, notes.dodestroy); app.post('/notesave', users.ensureAuthenticated, notes.save); app.use('/account', users.ensureAuthenticated, users.doAccount); app.use('/login', users.doLogin); app.post('/doLogin', passport.authenticate('local', { failureRedirect: '/login', failureFlash: true }), users.postLogin); app.use('/logout', users.doLogout); // catch 404 and forward to error handler app.use(function(req, res, next) { var err = new Error('Not Found'); err.status = 404; next(err); }); // error handlers // development error handler // will print stacktrace if (app.get('env') === 'development') { app.use(function(err, req, res, next) { res.status(err.status || 500); res.render('error', { message: err.message, error: err }); }); } // production error handler // no stacktraces leaked to user app.use(function(err, req, res, next) { res.status(err.status || 500); res.render('error', { message: err.message, error: {} }); }); module.exports = app; var server = http.Server(app); var io = require('socket.io').listen(server); app.set('port', 3000); server.listen(app.get('port'), function() { console.log("Express server listening on port " + app.get('port')); }); io.sockets.on('connection', function(socket) { socket.on('notetitles', function(fn) { models.titles(function(err, titles) { if(err) { util.log(err); } else { fn(titles); } }); }); var broadcastUpdated = function(newnote) { socket.emit('noteupdated', newnote); } models.emitter.on('noteupdated', broadcastUpdated); socket.on('disconnect', function() { models.emitter.removeListener('noteupdated', broadcastUpdated); }); var broadcastDeleted = function(notekey) { socket.emit('notedeleted', notekey); } models.emitter.on('notedeleted', broadcastDeleted); socket.on('disconnect', function() { models.emitter.removeListener('notedeleted', broadcastDeleted); }); });
Chúng ta sẽ chạy trực tiếp server từ file app.js
chứ không chạy theo file www
trong thư mục bin
do Express tự tạo nữa.
var http = require('http');
Chúng ta sẽ cần đến module http.
var server = http.Server(app); var io = require('socket.io').listen(server); app.set('port', 3000); server.listen(app.get('port'), function() { console.log("Express server listening on port " + app.get('port')); });
Tiếp theo chúng ta tạo đối tượn http.Server
và đối tượng socket.io
, về cơ bản thì đối tượng socket.io sẽ bọc lấy đối tượng server. Sau đó chúng ta thiết lập biến môi trường port
là 3000 và cho server lắng nghe trên cổng port.
io.sockets.on('connection', function(socket) { socket.on('notetitles', function(fn) { models.titles(function(err, titles) { if(err) { util.log(err); } else { fn(titles); } }); }); var broadcastUpdated = function(newnote) { socket.emit('noteupdated', newnote); } models.emitter.on('noteupdated', broadcastUpdated); socket.on('disconnect', function() { models.emitter.removeListener('noteupdated', broadcastUpdated); }); var broadcastDeleted = function(notekey) { socket.emit('notedeleted', notekey); } models.emitter.on('notedeleted', broadcastDeleted); socket.on('disconnect', function() { models.emitter.removeListener('notedeleted', broadcastDeleted); }); });
Tiếp theo chúng ta có đoạn code chính như trên, như đã nói, Socket.IO được xây dựng dựa trên lớp EventEmitter,
tức là đối tượng này sẽ lắng nghe các sự kiện và trả lời các sự kiện đó. Bắt đầu từ đây mọi thứ sẽ hơi rối rắm một chút xíu.
Đầu tiên chúng ta cho socket lắng nghe sự kiện connection,
sự kiện này sẽ được phát ra khi có trình duyệt trỏ tới website. Trong đó, chúng ta cho socket lắng nghe 2 sự kiện là notetitles
và disconnect,
cả 2 sự kiện này đều được trình duyệt phát đi, tức là chúng ta sẽ code phần phát sự kiện này trong các file .ejs,
nhưng chúng ta không dùng Node mà dùng jQuery. Ngoài ra khi người dùng chỉnh sửa một ghi chú nào đó hoặc xóa một ghi chú thì đối tượng models
sẽ phát đi 2 sự kiện là noteupdated
và notedeleted,
chúng ta sẽ code trong file models-sequelize/notes.js
.
Cuối cùng sự kiện disconnect
được phát đi khi người dùng thoát hẳn khỏi website.
Trong đối tượng models
(ở file sequelize-models/notes.js
) cũng sẽ có một đối tượng EventEmitter
mà chúng ta sẽ định nghĩa sau, đối tượng này được dùng để phát đi sự kiện noteupdated
và notedeleted
đã nói ở trên, trong file app.js
chúng ta lại dùng chính đối tượng emitter
đó để lắng nghe 2 sự kiện này, việc xảy ra tiếp theo sẽ là gọi hàm broadcastUpdated()
hoặc broadcastDeleted()
, 2 hàm này sẽ lại phát sinh 2 sự kiện cùng tên là noteupdated
và notedelete
bằng đối tượng socket, tuy nhiên 2 sự kiện này sẽ không được bắt bởi đối tượng models
mà sẽ được bắt ở trình duyệt vì chúng ta dùng đối tượng socket để gửi đi.
Phát sự kiện từ model
Trong file models-sequelize/notes.js
chúng ta sửa lại như sau:
var events = require('events'); var emitter = module.exports.emitter = new events.EventEmitter(); var Sequelize = require('sequelize'); var Note = undefined; module.exports.connect = function(params, callback) { var sequlz = new Sequelize( params.dbname, params.username, params.password, params.params ); Note = sequlz.define('Note', { notekey: { type: Sequelize.STRING, primaryKey: true, unique: true }, title: Sequelize.STRING, body: Sequelize.TEXT }); Note.sync().then(function() { callback(); }).error(function(err) { callback(err); }); } exports.disconnect = function(callback) { callback(); } exports.create = function(key, title, body, callback) { Note.create({ notekey: key, title: title, body: body }).then(function(note) { exports.emitter.emit('noteupdated', { key: key, title: title, body: body }); callback(); }).error(function(err) { callback(err); }); } exports.update = function(key, title, body, callback) { Note.find({ where:{ notekey: key} }).then(function(note) { if(!note) { callback(new Error("No note found for key " + key)); } else { note.updateAttributes({ title: title, body: body }).then(function() { exports.emitter.emit('noteupdated', { key: key, title: title, body: body }); callback(); }).error(function(err) { callback(err); }); } }).error(function(err) { callback(err); }); } exports.read = function(key, callback) { Note.find({ where:{ notekey: key} }).then(function(note) { if(!note) { callback("Nothing found for " + key); } else { callback(null, { notekey: note.notekey, title: note.title, body: note.body }); } }); } exports.destroy = function(key, callback) { Note.find({ where:{ notekey: key} }).then(function(note) { note.destroy().then(function() { emitter.emit('notedeleted', key); callback(); }).error(function(err) { callback(err); }); }); } exports.titles = function(callback) { Note.findAll().then(function(notes) { var noteList = []; notes.forEach(function(note) { noteList.push({ key: note.notekey, title: note.title }); }); callback(null, noteList); }); }
Chúng ta chỉnh sửa lại để mỗi khi người dùng tạo mới, cập nhật hoặc xóa một ghi chú thì sẽ có các sự kiện phát ra tương ứng.
var events = require('events'); var emitter = module.exports.emitter = new events.EventEmitter(); ... exports.emitter.emit('noteupdated', { key: key, title: title, body: body }); ... emitter.emit('notedeleted', key);
Việc code cũng khá đơn giản, chúng ta chỉ cần tạo đối tượng EventEmitter,
rồi gọi phương thức emit()
khi cần là được.
Khởi tạo Socket.IO và jQuery trên trình duyệt
Ở đây ứng dụng của chúng ta cần phải có sự tương tác giữa cả server và client, và Socket.IO đều được viết ra để chạy trên server và client.
Trong file views/top.ejs
chúng ta sửa lại như sau:
<html> <head> <title><%= title %></title> <link rel='stylesheet' href='/stylesheets/style.css' /> <script src="https://ajax.googleapis.com/ajax/libs/jquery/1.12.4/jquery.min.js"></script> <script src="/socket.io/socket.io.js"></script> <script> var socket = io.connect('/'); </script> </head> <body> <h1><%= title %></h1> <div class='navbar'> <p> <a href='/'>Home</a> | <a href='/noteadd'>ADD Note</a> <% if(user) { %> | <a href='/logout'>Log Out</a> | logged in as <a href='/account'><%= user.username %></a> <% } else { %> | <a href='/login'>Log in</a> <% } %> </p> </div>
Chủ yếu là thêm các thẻ <script>
tham chiếu đến file socket.io.js,
file này sẽ tự động tải về khi chúng ta gọi phương thức io.connect().
Ngoài ra ở đây chúng ta còn cần dùng thêm jQuery nữa để thực hiện việc thay đổi nội dung trên trang web.
Xử lý sự kiện trên client
Đầu tiên chúng ta sửa file views/index.ejs
như sau:
<% include top %> <div id='notetitles'> <% if(notes) { for(var i in notes) { %><p><%= notes[i].key %>: <a href="/noteview?key=<%= notes[i].key %>"><%= notes[i].title %></a> </p><% } } %> </div> <script> $(document).ready(function() { var getnotetitles = function() { socket.emit('notetitles', function(notes) { $('#notetitles').empty(); for(var i in notes) { var str = '<p>' + notes[i].key + ': <a href="noteview?key=' + notes[i].key + '">' + notes[i].title + '</a>' + '</p>'; $('#notetitles').append(str); } }); } socket.on('noteupdated', getnotetitles); socket.on('notedeleted', getnotetitles); }); </script> <% include bottom %>
Đoạn code trên sẽ thay đổi nội dung trên trình duyệt khi có thay đổi phát ra.
<div id='notetitles'> ... </div>
Đầu tiên chúng ta bọc danh sách các ghi chú trong thẻ <div>
có id
là notetitles
để có thể dễ dàng tham chiếu tới từ jQuery.
$(document).ready(function() { var getnotetitles = function() { socket.emit('notetitles', function(notes) { $('#notetitles').empty(); for(var i in notes) { var str = '<p>' + notes[i].key + ': <a href="noteview?key=' + notes[i].key + '">' + notes[i].title + '</a>' + '</p>'; $('#notetitles').append(str); } }); } socket.on('noteupdated', getnotetitles); socket.on('notedeleted', getnotetitles); });
Tiếp theo chúng ta viết đoạn code lắng nghe sự kiện noteupdated
và notedeleted
từ server, và ở đây chúng ta xử lý 2 sự kiện này bằng cách phát ra sự kiện notetitles,
và server sẽ bắt sự kiện này rồi trả về danh sách các ghi chú mới cho chúng ta, sau đó chúng ta tham chiếu đến thẻ <div>
ở trên và cập nhật lại danh sách ghi chú mới này bằng jQuery.
Bây giờ chúng ta sửa lại file views/noteview.ejs
như sau:
<% include top %> <div id="noteview"> <h3 id="notetitle"><%= note ? note.title : "" %></h3> <p id="notebody"><%- note ? note.body : "" %></p> <p>Key: <%= notekey %></p> <% if(user && notekey) { %> <hr/> <p><a href="/notedestroy?key=<%= notekey %>">Delete</a> | <a href="/noteedit?key=<%= notekey %>">Edit</a></p> <% } %> </div> <script> $(document).ready(function() { var updatenote = function(newnote) { $('#notetitle').empty(); $('#notetitle').append(newnote.title); $('#notebody').empty(); $('#notebody').append(newnote.body); } socket.on('noteupdated', function(newnote) { if(newnote.key === "<%= notekey %>") { updatenote(newnote); } }); socket.on('notedeleted', function(notekey) { if(notekey === "<%= notekey %>") { document.location.href = "/"; } }); }); </script> <% include bottom %>
Trong ứng dụng Notes chúng ta không xây dựng chức năng phân quyền, tức là ở đây bất cứ ai đăng nhập vào cũng có thể chỉnh sửa một ghi chú bất kỳ, giả sử có 2 người đang cùng thực hiện chỉnh sửa một ghi chú, thì khi một người lưu lại ghi chú đó, chúng ta sẽ cập nhật lại nội dung mới đó trên trang web của người kia luôn. Và để làm việc này thì chúng ta cũng làm tương tự như trong file views/index.ejs
Vậy là xong, bây giờ chúng ta có thể chạy ứng dụng được rồi. Tuy nhiên chúng ta sẽ không dùng lệnh npm start
để chạy server của Express mà chúng ta phải dùng lệnh node app.js
để chạy server do chúng ta cấu hình.
Để kiểm tra thì bạn có thể mở 2 trình duyệt khác nhau như Chrome và Firefox rồi cùng trỏ đến website, sau đó một bên chỉnh sửa một ghi chú, thì bên kia cũng sẽ tự động cập nhật lại ghi chú mới.