實時會話系統實現(2) --- express-ws改寫會話系統

上一篇提到過實際上會話系統最簡單的方式是http輪詢: 用戶發送信息時實現一個http接口保存用戶聊天信息,然後在客戶端實現一個定時器,定時獲取用戶A與用戶B的聊天信息,並且重新渲染聊天界面。我們在上一篇成功通過http輪詢的方式實現繪畫系統,但是我們也有提到過http輪詢的缺點在於輪詢中的大部分請求都是沒有實際意義的,所以會極大的浪費和消耗帶寬和服務器資源。所以本節課我們通過express框架支持的一個websocket庫--express-ws來改寫上一篇實現的會話系統。

歡迎體驗小程序,如果有修改建議歡迎加我微信聊聊。

客戶端代碼其實和上篇文章基本一致,只是增加了個上傳視頻的按鈕,因爲小程序沒有選擇文件的api,所以我們只能通過wx.chooseImage來選擇圖片發送,通過wx.chooseVideo來選擇視頻發送, 實際上效果就是在上一篇的基礎上加了一個視頻按鈕,因爲上一篇沒有提到圖片發送和視頻發送的客戶端邏輯有讀者私聊問到具體的邏輯,這邊簡單講講小程序客戶端如何實現圖片選擇發送以及視頻選擇發送 。實際上我們可以通過wx.chooseImage選擇圖片,這個api實際上很簡單,指定最多可選擇圖片張數以及圖片來源等,選擇成功實際上會返回一個圖片的臨時路徑tempFilePaths,然後使用form-data的方式將tempFilePaths提交到後端接口進行圖片上傳,圖片上傳成功後會返回圖片的url地址,這時候再進行信息發送保存聊天記錄到數據庫。我們可以看一眼邏輯代碼:
//發送圖片  chooseImage() {    var that = this;    wx.chooseImage({      count: 1, // 默認9張圖片      sizeType: ['original', 'compressed'], // 可以指定是原圖還是壓縮圖,默認二者都有      sourceType: ['album', 'camera'], // 可以指定來源是相冊還是相機,默認二者都有      success: function(res) {        // 返回選定照片的本地文件路徑列表,tempFilePath可以作爲img標籤的src屬性顯示圖片        var tempFilePaths = res.tempFilePaths;        that.setData({          loading: true,          increase: false        });
wx.uploadFile({ url: utils.basePath + '/users/upload_avatar', filePath: tempFilePaths[0], name: 'avatar', headers: { 'Content-Type': 'form-data' },
success: function(res) { that.setData({ loading: false });
var result = JSON.parse(res.data); if (result.status == 200) { //圖片上傳成功,將聊天記錄保存數據庫 var chatInfo = that.data.chatInfo; chatInfo.chat_content = result.payload.avatar_path; chatInfo.chat_type = 1; chatInfo = JSON.stringify(chatInfo); websocket.send(chatInfo);
//接受服務器消息 wx.onSocketMessage(function(res) { var data = JSON.parse(res.data).data; data[0].flagtime = true;
for (var i = 1; i < data.length; i++) { var currenttime = new Date(data[i].created_date).getTime(); var begintime = new Date(data[i - 1].created_date).getTime();
if (currenttime - begintime > 1000 * 60) { data[i].flagtime = true; } else { data[i].flagtime = false; } } that.setData({ newslist: data });
//將聊天界面定位到最新的聊天記錄 that.bottom(); }); } else { $Toast({ content: result.err, type: 'error' }); } } }); } }); }

視頻發送實際上和圖片發送幾乎一致,就是將wx.chooseImage換成wx.chooseVideo就可以,但是視頻上傳這裏面有幾個坑需要逃避一下:

  • wx.chooseVideo有個屬性compressed參數可以設置視頻是否需要壓縮,默認是true,視頻會經過壓縮上傳,經過實測發現視頻經過壓縮清晰度極低,所以可以攜帶compressed參數關閉視頻壓縮。

  • 視頻大小實際上和微信是保持一致的,無法發送超過24M的視頻,但是我測試的時候發現超過1M的服務器一直報413狀態碼提示視頻過大,實際上就是我們後端沒有設置body最大的長度,比如我是Nginx對上傳的域名pic.niyueling.cn增加了client_max_body_size實行,設置爲25M,就可以躲過413狀態這個坑。

接下來我們一樣看下代碼:

//發送視頻  chooseVideo() {    var that = this;    wx.chooseVideo({      sourceType: ['album', 'camera'],      maxDuration: 60,      compressed: false,      camera: 'back',      success: function(res) {        var tempFilePaths = res.tempFilePath;
that.setData({ loading: true, increase: false });
wx.uploadFile({ url: utils.basePath + '/users/upload_video', filePath: tempFilePaths, name: 'mp4_url', headers: { 'Content-Type': 'form-data' },
success: function(res) { if (res.statusCode == 413) { that.setData({ loading: false }); $Toast({ content: '視頻過大,請重新上傳', type: 'error' }); } else { that.setData({ loading: false });
var result = JSON.parse(res.data); if (result.status == 200) { //上傳視頻操作 var chatInfo = that.data.chatInfo; chatInfo.chat_content = result.payload; chatInfo.chat_type = 2; chatInfo = JSON.stringify(chatInfo);
websocket.send(chatInfo);
//接受服務器消息 wx.onSocketMessage(function(res) { var data = JSON.parse(res.data).data; data[0].flagtime = true;
for (var i = 1; i < data.length; i++) { var currenttime = new Date(data[i].created_date).getTime(); var begintime = new Date(data[i - 1].created_date).getTime();
if (currenttime - begintime > 1000 * 60) { data[i].flagtime = true; } else { data[i].flagtime = false; } } that.setData({ newslist: data });
that.bottom(); }); } else { $Toast({ content: result.err, type: 'error' }); } } } }); } }); }


接下來就開始正式講講websocket在小程序的使用了,其實websocket在小程序封裝的很完美,可以讓沒接觸過websocket開發的快速上手。我們在utils下創建一個websocket.js,在裏面封裝websocket的基本操作。實際上在會話系統我們目前僅僅需要websocket連接,發送消息,接受消息三個方法,所以我們在websocket.js中定義這三個方法,然後使用module.exports導出,使得在任何界面都可以調用這幾個方法,我們看下代碼:
const util = require('./util.js');
//發起websocket連接function connect(user, func) { wx.connectSocket({ url: util.wssPath + '/chat/v1/message?friendphone=' + user.friendInfo.account + '&userphone=' + user.userInfo.account + '&app_sid=' + user.userInfo.app_sid, header: { 'content-type': 'application/json' }, success: function (res) { console.log(res) },
fail: function (res) { console.log(res); } });
wx.onSocketOpen(function (res) { //接受服務器消息 wx.onSocketMessage(func);//func回調可以拿到服務器返回的數據 });
wx.onSocketError(function (res) { wx.showToast({ title: res.errMsg, icon: "none", duration: 1000 }); });}
//發送消息function send(msg) { wx.sendSocketMessage({ data: msg });}
module.exports = { connect: connect, send: send}

然後在會話界面的onLoad方法連接websocket,連接成功接口會返回歷史聊天記錄,可以渲染出聊天界面。我們可以看下onLoad的關鍵代碼:

websocket.connect(this.data.chatInfo, function(res) {        if (JSON.parse(res.data).data.length == 0) {          that.setData({            newslist: []          });        } else {          var data = JSON.parse(res.data).data;          data[0].flagtime = true;
for (var i = 1; i < data.length; i++) { var currenttime = new Date(data[i].created_date).getTime(); var begintime = new Date(data[i - 1].created_date).getTime();
if (currenttime - begintime > 1000 * 60) { data[i].flagtime = true; } else { data[i].flagtime = false; } } that.setData({ newslist: data });
that.bottom(); } });


然後用戶發送消息之後使用剛纔封裝好的方法send發送消息,消息發送成功服務端會返回新的聊天記錄,動態渲染聊天界面。可以看下關鍵代碼:
//封裝聊天記錄參數      var chatInfo = that.data.chatInfo;      chatInfo.chat_content = that.data.message;      chatInfo.chat_type = 0;      chatInfo = JSON.stringify(chatInfo);
websocket.send(chatInfo);
//接受服務器消息 wx.onSocketMessage(function(res) { var data = JSON.parse(res.data).data; data[0].flagtime = true;
for (var i = 1; i < data.length; i++) { var currenttime = new Date(data[i].created_date).getTime(); var begintime = new Date(data[i - 1].created_date).getTime();
if (currenttime - begintime > 1000 * 60) { data[i].flagtime = true; } else { data[i].flagtime = false; } } that.setData({ newslist: data });
that.bottom(); });

到這裏我們小程序端的websocket連接全部實現了。下一步需要在服務端實現wss接口。首先和https一樣,小程序只支持wss,所以我們需要申請證書先在Nginx配置wss:
upstream backend_chatws {    server 127.0.0.1:3001 weight=10;}
server { listen 443 ssl; server_name ws.niyueling.cn; ssl_certificate /etc/nginx/ctr/ws_niyueling_cn.crt; ssl_certificate_key /etc/nginx/ctr/ws_niyueling_cn.key; ssl_session_cache shared:SSL:1m; ssl_session_timeout 5m; ssl_protocols TLSv1 TLSv1.1 TLSv1.2; ssl_prefer_server_ciphers on; server_tokens off; access_log /var/log/nginx/api.log main;
location / { client_max_body_size 100m; proxy_redirect off; proxy_pass http://backend_chatws; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_read_timeout 604800s; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "Upgrade"; }

error_page 500 502 503 504 /50x.html; location = /50x.html { root /usr/share/nginx/html; }}

剛纔已經說過了本篇文章使用express-ws庫來封裝websocket,接下來我們看看express-ws庫的基本使用。因爲我們正式開發一般後端不可能把所有接口寫在同一個文件中,所以我們這邊也通過分文件來實現。首先npm安裝express-ws依賴,然後在app.js引入express-ws:

var express = require('express');var app = express();var expressWs = require('express-ws')(app);var chat = require('./routes/chat'); app.use('/chat/v1', chat);
app.listen(3001);


可以看到我們在app.js引用了chat.js文件表示我們實際上websocket接口是在chat.js中實現,接下來我們在chat.js中引用express-ws,這裏需要注意如果分文件實現接口必須在app.js和具體的接口js文件都引入express-ws纔可以正常使用。然後接口的實現實際上和http接口實現方法類似,我們引入express-ws後實際上router就多了一個ws方法,就是用來書寫websocket接口,然後接口中實際上是存在兩部分邏輯,第一次調用就等於websocket連接事件,這時候我們要查詢好友的聊天記錄返回,當用戶發送消息時,會觸發message事件,這時候先保存用戶聊天記錄再查詢最新的聊天記錄並返回。我們可以看下代碼:
router.ws('/message', function (ws, req) {  var par = paramAll(req);
if (!par.friendphone || !par.userphone || !par.app_sid) { return ws.send(JSON.stringify({ code: 0, msg: '參數不全!' })); }
//查詢用戶歷史記錄 chatDao.getOnlineChat(par, function (err, data) { if (err) { return ws.send(JSON.stringify({ code: 0, msg: err })); }
return ws.send(JSON.stringify({ code: 1, data: data })); });
ws.on('message', function (msg) { par.msg = JSON.parse(msg); //將記錄添加到數據庫,並返回最新記錄列表 chatDao.saveOnlineChat(par.msg, function (err, data) { if (err) { return ws.send(JSON.stringify({ code: 0, msg: err })); }
return ws.send(JSON.stringify({ code: 1, data: data })); }); });});

數據庫操作邏輯實際上也分別對應兩部分,websocket連接時會返回兩個好友間的歷史聊天記錄:

async.waterfall([            function (callback) {                connection.beginTransaction(function (err) {                    return callback(err);                });            },            //通過friendphone查詢好友信息            function (callback) {                var sql = 'select username, avatar from users where account = ? and app_sid = ?';                var value = [data.friendphone, data.app_sid];
connection.query(sql, value, function (err, result) { if (err) { return callback(err); }
if (!result[0]) { return callback('用戶不存在!'); } data.friendname = result[0].username; data.friendavatar = result[0].avatar;
return callback(null, 200); }); }, //通過userphone查詢好友信息 function (info, callback) { var sql = 'select username, avatar from users where account = ? and app_sid = ?'; var value = [data.userphone, data.app_sid];
connection.query(sql, value, function (err, result) { if (err) { return callback(err); }
if (!result[0]) { return callback('用戶不存在!'); } data.username = result[0].username; data.useravatar = result[0].avatar;
return callback(null, 200); }); }, function (release_info, callback) { var sql = 'select id, friendphone, friendname, friendavatar, app_sid, DATE_FORMAT(created_date, "%Y-%m-%d %H:%i:%s") as created_date, userphone, username, useravatar, content, chat_type from online_chat ' + 'where (friendphone = ? and userphone = ?) or (friendphone = ? and userphone = ?)'; var value = [data.friendphone, data.userphone, data.userphone, data.friendphone];
connection.query(sql, value, function (err, result) { if (err) { return callback(err); }
var del_info = result && result.length > 0 ? result : null;
if (!del_info) { return callback(null, true, []); }
return callback(null, true, del_info); }); } ], function (DbErr, isSuccess, uidOrInfo) { if (DbErr || !isSuccess) { connection.rollback(function () { connection.release(); });
return cb(DbErr); }
connection.commit(function (e) { if (e) { connection.rollback(function () { connection.release(); });
return cb(e); }
connection.release(); cb(null, uidOrInfo); }); });


當客戶端用戶發送消息就會觸發message事件,這時候保存用戶聊天信息並返回最新的聊天記錄:
async.waterfall([            function (callback) {                connection.beginTransaction(function (err) {                    return callback(err);                });            },            function (callback) {                var sql = 'insert into online_chat set ?';                var value = {                    friendphone: data.friendInfo.account,                    friendname: data.friendInfo.username,                    friendavatar: data.friendInfo.avatar,                    app_sid: data.friendInfo.app_sid,                    userphone: data.userInfo.account,                    username: data.userInfo.username,                    useravatar: data.userInfo.avatar,                    created_date: new Date(),                    status: 1,                    content: data.chat_content,                    chat_type: data.chat_type                };
connection.query(sql, value, function (err, result) { if (err) { return callback(err); }
if (result.affectedRows == 0) { return callback('聊天出現故障!'); }
return callback(null, '保存聊天記錄成功!'); }); }, function (release_info, callback) { var sql = 'select id, friendphone, friendname, friendavatar, app_sid, DATE_FORMAT(created_date, "%Y-%m-%d %H:%i:%s") as created_date, userphone, username, useravatar, content, chat_type from online_chat ' + 'where (friendphone = ? and userphone = ?) or (friendphone = ? and userphone = ?)'; var value = [data.friendInfo.account, data.userInfo.account, data.userInfo.account, data.friendInfo.account];
connection.query(sql, value, function (err, result) { if (err) { return callback(err); }
var del_info = result && result.length > 0 ? result : null;
if (!del_info) { return callback(null, true, []); }
return callback(null, true, del_info); }); } ], function (DbErr, isSuccess, uidOrInfo) { if (DbErr || !isSuccess) { connection.rollback(function () { connection.release(); });
return cb(DbErr); }
connection.commit(function (e) { if (e) { connection.rollback(function () { connection.release(); });
return cb(e); }
connection.release(); cb(null, uidOrInfo); }); });

到這裏我們使用express-ws改寫會話系統就完成了,我們可以測試下:


可以發現我們使用websocket可以開啓一個長連接成功實現實時會話系統,有消息送達馬上接收渲染,而不用像http輪詢一樣不斷地重複請求接口造成貸款和服務器資源的浪費。目前整個項目前後端已開源於碼雲,歡迎來一個star。源碼地址:
https://gitee.com/mqzuimeng_admin/wx_blog.git

歡迎體驗小程序,如果有修改建議可以在小程序提交意見反饋或加入技術羣諮詢。

歡迎關注公衆號:程序猿周先森。查看更多精彩文章。

本文分享自微信公衆號 - 程序猿周先森(zhanyue_org)。
如有侵權,請聯繫 [email protected] 刪除。
本文參與“OSC源創計劃”,歡迎正在閱讀的你也加入,一起分享。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章