使用Express + Socket.io + MongoDB實現簡單的聊天室
來自: http://my.oschina.net/voler/blog/626226
基礎能力
1. 你需要有一定的JavaScript基礎知識. 不推薦沒有任何JS基礎的人學習Node.js及其生態圈. 如果想學習JS,推薦兩本書:<JavaScript高級程序設計>和<JavaScript權威指南>.
2. 你需要有一定的英文閱讀能力.針對Node.js及其生態圈的飛速發展,任何書籍都會很快的過時,但官方文檔永遠是最新的.目前Node.js的生態圈,如Socket.io,可能并沒有中文文檔.
3. 你需要對Node.js有一定的認識. 不同于HTML/CSS等前端技術,Node.js目前算是一門成熟的后端技術,需要花大量的時間進行學習.如果想學習Node.js, 推薦官網文檔和三本書:<Node.js IN ACTION>(看過,入門型書籍,不錯,難度低), <Manning Node.js in Practice>(此書較老,只需要看前八章即可,后面幾章程序不一定跑的通,而且不一定是目前主流的選擇,難度中等), <Node.js Design Patterns>(只看過前兩章,但難度有點大,屬于工作后需要深入理解的書籍).
前世之因
我目前的工作主要使用的技術是: 基于tornado框架, 使用Python編寫后端業務邏輯,使用JS編寫前端業務邏輯(使用jQuery). 隨著工作的進行, 溝通的比重越來越大,大概會占到了30%的工作量. 但是前后端的分離是必然的趨勢, 而我在工作中遇到的一個問題是: 前端工程師往往看不懂后端寫的代碼(目前我前后端一起寫,所以影響不大),導致一旦接口沒編寫好,前后端沒溝通好,會產生一些莫名的隱患.于是我去年十二月份的時候開始尋找答案, 最終選擇了學習Node.js.
工作前三年,我始終堅持做三件事: 1.不以賺錢為目的學習任何我感興趣的技術. 2. 每天堅持學習90分鐘. 3. 對技術懷有感恩的心,感謝它帶給我的一切快樂和幸福.
隨緣, 不是說一切順其自然,而是盡全力去完成,至于結果如何則不要太在意.
準備工作
1. 確保你安裝了Node.js, Express, MongoDB(我曾經多次運行程序,但是忘記了啟動MongoDB服務器)
lgtdeMacBook-Pro:multiroom-chat lgt$ node -v v5.6.0 lgtdeMacBook-Pro:multiroom-chat lgt$ express --version 4.13.1 lgtdeMacBook-Pro:multiroom-chat lgt$ mongo --version MongoDB shell version: 3.0.2
開始編程
1. 構建Express項目
lgtdeMacBook-Pro:~ lgt$ express -e multiroom-chat如果對 "-e"參數不理解,輸入: express --help. "-e"參數說明我們使用 ejs engine來將后端的數據傳遞到前端操作的方式.
接著,我們進入multiroom-chat目錄,通過npm install來安裝必備的庫. 由于我們需要用到socket.io來進行通信,使用mongoose來操作MongoDB, 所以我們額外執行如下指令:
lgtdeMacBook-Pro:multiroom-chat lgt$ npm install --save socket.io lgtdeMacBook-Pro:multiroom-chat lgt$ npm install --save mongoose最后運行: npm start, 輸入:localhost:3000就可以看到結果了.
2. Express項目結構
bogon:multiroom-chat lgt$ tree -L 1 . ├── app.js ├── bin ├── node_modules ├── package.json ├── public ├── routes └── views
app.js: 主文件,直接理解為C/C++的main.cpp即可.
bin: 啟動目錄. 在bin/www的文件中你可以看到HTTP服務是如何啟動,如何綁定端口等信息.
node_modules: 存放所安裝模塊.
package.json: 項目的基本信息, 最主要的就是所安裝的模塊版本信息.
public: 存放images/javascripts/stylesheets文件. 在這個項目中,我會把jQuery文件,boostrap文件放在這里.
routes: 存儲后臺邏輯業務代碼文件.
views: 存儲展現層的代碼文件.
3. 編寫需求文檔
這里我只編寫了數據庫的設計文檔, 具體請查看GitHub(https://github.com/leicj/multiroom-chat)中的需求文檔目錄.
4. 實現登陸注冊頁面
1) 界面效果圖
mongoose參考: http://mongoosejs.com/
登陸界面效果圖如下:
注冊界面效果如下:
2) 數據庫表存儲結構
首先,在編寫登陸/注冊的后臺代碼時,我們需要使用mongoose來操作數據庫:
var mongoose = require('mongoose'); // 連接數據庫庫的users數據表. var db = mongoose.createConnection('localhost', 'multiroom'); db.on('error', function(err) { console.error(err); }); var Schema = mongoose.Schema; // 用戶表 var UserSchema = new Schema({ username: String, nickname: String, password: String, status: String }); var UserModel = db.model('users', UserSchema); // 用戶關聯表 var ChatinfoSchema = new Schema({ users: Array, mapusers: Array }); var ChatinfoModel = db.model('chatinfos', ChatinfoSchema);這里簡單講解一下這段代碼:
1. 之所以要使用mongoose來操作數據庫,是因為JavaScript作為"非正統"的后端語言,在操作數據庫上本身就先天不足.如果涉及復雜的數據庫操作,則直接使用MongoDB則不太明智.
2. 使用createConnection創建一個連接本地數據庫multiroom(要保證已經開了MongoDB服務)
lgtdeMacBook-Pro:~ lgt$ sudo mongod3. Schema只是一個數據結構表示,我們使用db.model('users', UserSchema)將這個數據結構和數據表users關聯起來. 后期我們可以直接對關聯的UserModel對象(需要通過new實例化)進行復制和save操作.
3) 登陸和注冊
// 用戶登錄 router.post('/login', function(req, res) { UserModel.findOne({username: req.body.loginname, password: req.body.loginpwd}, function(err, user) { if (!user) { console.error(err); res.send({status: false, data: '登錄失敗!', loginname: req.body.loginname}); } else { res.send({status: true, data: '登錄成功!', loginname: req.body.loginname}); } }); });
1. 我們使用findOne查詢數據庫是否存在此用戶.
2. 這里不能判斷err,因為無論是否有此用戶都不會發生錯誤,err永遠為null.
3. 無論登陸成功還是失敗,都要給前端傳遞具體的信息.所以使用res.send()將數據發送到前端.
// 用戶注冊 router.post('/reg', function(req, res) { var registername = req.body.registername; var registerpwd = req.body.registerpwd; UserModel.findOne({username: registername}, function(err, user) { if (user) { res.send({status: false, data: '此用戶已經存在!'}); } else { var user = new UserModel(); user.username = registername; user.password = registerpwd; user.save(function(err) { if (err) throw err; updateChatinfo(registername); res.send({status: true, data: '注冊成功!', registername: registername}); }); } }); });1. 這里得預先判斷注冊的用戶是否已經存在.如果沒有存在,則執行save操作,將注冊的用戶存儲進數據庫.
備注: 這里密碼是明文存儲的,實際的項目中是絕對不允許的,需要通過一些加密的庫進行加密即可.
// 更新chatinfos信息 function updateChatinfo(username) { ChatinfoModel.findOne({}, function(err, data) { if (err) { console.error(err); return; } var users = [username], mapusers = []; if (data && data.users) users = data.users; if (data && data.mapusers) mapusers = data.mapusers; if (data && data.users) { for (var oneuser of users) { mapusers.push(oneuser + '_' + username); } users.push(username); } ChatinfoModel.remove({}, function(err, data) { if (err) throw err; var chatinfo = new ChatinfoModel(); chatinfo.users = users; chatinfo.mapusers = mapusers; chatinfo.save(function(err) { if (err) throw err; }); }); }); }這里對chatinfo數據表的操作異常的重要(chatinfos表的作用,具體查看"數據庫設計文檔"). 假設我一次注冊了:leicj1, leicj2, leicj3,則數據表chatinfos的信息如下(只存儲一條數據):
users: [leicj1, leicj2, leicj3] mapusers: [leicj1_leicj2, leicj1_leicj3, leicj2_leicj3]這樣,我就能保證任意兩個人(A和B)的聊天,則其聊天記錄會存儲在數據表A_B或者B_A.如果A先于B注冊,則存儲在數據表A_B,如果B先于A注冊,則存儲在數據表B_A.
而前端的代碼查看文件: index.ejs. 在登陸/注冊成功后,頁面會進行跳轉.
4) routes和views文件的關聯
routes編寫后臺邏輯業務,而views為前端展現:
/* GET home page. */ router.get('/', function(req, res, next) { res.render('index'); });
這里使用res.render('index')展現views中的index.ejs文件.這里index的后綴名去掉了.我們添加上也無所謂(但請不要這么無聊...):
/* GET home page. */ router.get('/', function(req, res, next) { res.render('index.ejs'); });我們查看app.js中的一段代碼:
app.use('/', routes); app.use('/users', users);假設我們在users.js中編寫一個post('/chat'),則實際的URL為: /users/chat.
備注: 將GitHub上的public里面的JS/CSS文件拷貝到當前目錄中,運行npm start, 在localhost:3000下就可以看到效果.
5. 實現聊天界面
我在學習Node.js的時候,最驚艷到我的不是它的fs模塊,也不是http模塊,而是其中的event.EventEmitter思想(QT中就使用emit/on組合進行編程,這是否是它可跨平臺開發運行的原因呢?).
而Socket.io充分使用了emit/on的思想.
Socket.io參考: http://socket.io/get-started/chat/
1) 聊天界面效果圖
這里我們需要在routes中新建chat.js來處理聊天界面的后臺邏輯,在views中新建chat.ejs來展現聊天界面. 首先我們在app.js中增加兩行代碼:
var chat = require('./routes/chat'); app.use('/chat', chat);而chat.js的功能很簡單,就是讀取所有的用戶并將數據發送到前端(具體查看chat.js文件):
router.get('/', function(req, res, next) { console.log(req.query.currentname); res.render('chat', {"users": chatinfo.users, 'currentname' : req.query.currentname}); });
2) Socket.io的使用
首先先簡單講解下Socket.io的原理. 操作系統有一個非常偉大的設計就是輪詢機制,而Node.js中的callback機制正是基于此機制:
JS的異步編程就是這么來的.但是對于類似聊天這種應用,使用輪詢機制明顯不合理.輪詢機制在于你觸發了一個事件后異步處理,但這里異步本身就是硬傷,畢竟聊天要實時的.
而Node.js中有另外一種偉大的模型: 觀察者模式. 即我就一直監聽,監聽到的某個事件后,執行相應的處理函數.
我們首先在bin/www文件中增加以下代碼:
var io = require('socket.io')(server); /** * socket.io數據處理模塊 * */ var mongoose = require('mongoose'); // 連接數據庫 var db = mongoose.createConnection('localhost', 'multiroom'); db.on('error', function(err) { console.error(err); }); var Schema = mongoose.Schema; // 聊天信息表 var ChatSchema = new Schema({ from: String, to: String, time: Number, msg: String, status: String }); // 獲取用戶關聯表的信息 var ChatinfoSchema = new Schema({ users: Array, mapusers: Array }); var ChatinfoModel = db.model('chatinfos', ChatinfoSchema); var chatinfo = {}; ChatinfoModel.findOne({}, function(err, data) { if (err) throw err; chatinfo = data; }); io.on('connection', function(socket) { console.log('a user connect'); // 處理所有的聊天信息,所傳遞的參數包括: from(發送者), to(接收者), msg(聊天信息) socket.on('chat message', function(from, to, msg) { var time = Date.now(); // 將聊天信息存入數據庫中 if (chatinfo.users) { var ChatModel = db.model(from + "_" + to, ChatSchema); if (chatinfo.users.indexOf(from) > chatinfo.users.indexOf(to)) { ChatModel = db.model(to + "_" + from, ChatSchema); } var chat = new ChatModel(); chat.from = from; chat.to = to; chat.time = Date.now(); chat.msg = msg; chat.save(function(err) { if (err) throw err; }); } // 將信息發送給to(接收者) io.emit(to + '_message', from, msg, time); }); });
1. 假設A和B聊天,而且A先于B注冊,則A和B的所有聊天信息均存儲在數據表A_B中.
2. 我們使用socket.on('chat message', function(from, to, msg))捕獲項目中任何地方使用socket.emit('chat message', from, to, msg)發射數據. 任何一次聊天只要發射(emit)"chat message"事件即可,只要傳遞from(發送者), to(接收者), msg(聊天信息)即可.
3. 我們通過save函數將聊天信息存儲起來.并且將此條信息通過io.emit(to + '_message', from, msg, time)發射出去. 如果是A在聊天,則A只要捕獲socket.on("A_message"),就可以獲取任何人發送給A的信息.
我們來看下前端chat.ejs的關鍵代碼:
// 發送信息 $('.sendmsg').on('click', function() { var to = $(this).attr('to'); var msg = $('.message').val(); if (from && to && msg) { socket.emit('chat message', from, to, msg); var message = from + " " + new Date().toLocaleString() + "\n" + msg + "\n"; updateMsgForm(message); $('.message').val(''); } }); // 接收信息 socket.on(from + '_message', function(from, msg, time) { var message = from + " " + new Date(time).toLocaleString() + "\n" + msg + '\n'; updateMsgForm(message); });
1. 發送信息時候只要socket.emit即可.
2. 這里from指的是當前的用戶. 這里接收信息只要socket.on(from + "_message")即可.
6. 項目不足之處和可擴展的功能模塊
不足之處:
1) 密碼使用明文,應該進行加密操作.
2) 界面很單調(我不會寫CSS......)
3) 異步編程不嚴謹,例如操作B必須在操作A成功后才能執行,但是代碼中并未嚴格遵守.實際項目中要使用promise庫.
可擴展的功能模塊:
1) 實現未讀信息功能.
2) 實現聊天記錄的查看和查詢.
3) 實現類似QQ討論組的功能(也可以通過Socket.io來實現,使用broadcast即可)
后記
我曾經使用tornado + Python + websocket實現了一個聊天室. 但相信我, 那個程序寫的又臭又長. 而使用Node.js + Socket.io,就可以輕易寫出一個一個聊天室. 我大概花了半天復習下Express的基礎知識,再花兩個小時那里過一遍Socket.io的官方文檔和demo,然后就花了一天半實現了這個聊天室.
這里使用jQuery而非React.js/Angular.js的原因是: 我對React.js/Angular.js不熟悉.
我覺得學好前端最重要的是要學好JS.在這里粘貼我在周老師評論下的一個回復,關于學習JS的:
JS是一門語言,需要大量的時間用來專研......很可悲的是:很多人做前端完全是奔著錢去的.他們以很快的速度學習完HTML/CSS,卻以同樣的心態學習JS. 你跟他們解釋說:JS是一門語言,而HTML/CSS僅僅是一門技術.如果想成為好的前端,HTML/CSS僅僅學習一本書后去實踐即可,而JS卻需要你花大量的時間去專研......
參考網址:
GitHub地址: https://github.com/leicj/multiroom-chat
Socket.io: http://socket.io/get-started/chat/
Express: http://expressjs.com/
mongoose: http://mongoosejs.com/