IM跨平臺(tái)技術(shù)學(xué)習(xí)(四):蘑菇街基于Electron開(kāi)發(fā)IM客戶(hù)端的技術(shù)實(shí)踐
本文由蘑菇街前端技術(shù)團(tuán)隊(duì)分享,原題“Electron 從零到一”,有修訂和改動(dòng)。
1、引言
本系列文章的前面幾篇主要是從Electron技術(shù)本身進(jìn)行了討論(包括:第1篇初步了解Electron、第2篇進(jìn)行了快速開(kāi)始和技術(shù)體驗(yàn)、第3篇基于實(shí)際開(kāi)發(fā)考慮的技術(shù)棧選型等),各位讀者也應(yīng)該對(duì)Electron的開(kāi)發(fā)有了較為深入的了解。

本篇將回到IM即時(shí)通訊技術(shù)本身,根據(jù)蘑菇街的實(shí)際技術(shù)實(shí)踐,總結(jié)和分享基于Electron開(kāi)發(fā)跨平臺(tái)IM客戶(hù)端的過(guò)程中,需要考慮的典型技術(shù)問(wèn)題以及我們的解決方案。希望能給你帶來(lái)幫助。
學(xué)習(xí)交流:
- 移動(dòng)端IM開(kāi)發(fā)入門(mén)文章:《新手入門(mén)一篇就夠:從零開(kāi)發(fā)移動(dòng)端IM》
- 開(kāi)源IM框架源碼:https://github.com/JackJiang2011/MobileIMSDK(備用地址點(diǎn)此)
(本文已同步發(fā)布于:http://www.52im.net/thread-4051-1-1.html)
2、系列文章
本文是系列文章中的第4篇,本系列總目錄如下:
《IM跨平臺(tái)技術(shù)學(xué)習(xí)(一):快速了解新一代跨平臺(tái)桌面技術(shù)——Electron》
《IM跨平臺(tái)技術(shù)學(xué)習(xí)(二):Electron初體驗(yàn)(快速開(kāi)始、跨進(jìn)程通信、打包、踩坑等)》
《IM跨平臺(tái)技術(shù)學(xué)習(xí)(三):vivo的Electron技術(shù)棧選型、全方位實(shí)踐總結(jié)》
《IM跨平臺(tái)技術(shù)學(xué)習(xí)(四):蘑菇街基于Electron開(kāi)發(fā)IM客戶(hù)端的技術(shù)實(shí)踐》(* 本文)
《IM跨平臺(tái)技術(shù)學(xué)習(xí)(五):融云基于Electron的IM跨平臺(tái)SDK改造實(shí)踐總結(jié)》(稍后發(fā)布.. )
《IM跨平臺(tái)技術(shù)學(xué)習(xí)(六):網(wǎng)易云信基于Electron的IM消息全文檢索技術(shù)實(shí)踐》(稍后發(fā)布.. )
3、IM消息的加密和解密
3.1需求背景
對(duì)IM聊天軟件而言,聊天消息的保密性就比較重要了,誰(shuí)也不希望自己的聊天內(nèi)容泄露甚至暴露在眾人的前面。
所以在收發(fā)IM信息的時(shí)候,我們需要對(duì)信息做一些加密解密操作,保證信息在網(wǎng)絡(luò)中傳輸?shù)臅r(shí)候是加密的狀態(tài)。
3.2簡(jiǎn)單的實(shí)現(xiàn)方法
可能大家會(huì)說(shuō):這還不簡(jiǎn)單?項(xiàng)目里寫(xiě)個(gè)加密解密的方法——收到消息時(shí)候先解密,發(fā)送消息時(shí)候先加密,服務(wù)端收到加密消息直接存儲(chǔ)起來(lái)。
這樣寫(xiě)理論上也沒(méi)有問(wèn)題,不過(guò)客戶(hù)端直接寫(xiě)加解密方法有一些不好的地方。
比如:
1)容易逆向:前端代碼比較容易被逆向;
2)性能較差:用戶(hù)可能加了很多群組,各群組中都會(huì)收到很多消息,前端處理起來(lái)比較慢;
3)多端實(shí)現(xiàn):如果都在客戶(hù)端實(shí)現(xiàn)加解密算法,那么 ios, android 等不同客戶(hù)端,因?yàn)槭褂玫拈_(kāi)發(fā)語(yǔ)言不同,都要分別實(shí)現(xiàn)相同的算法,增加維護(hù)成本。
3.3我們的方案
我們使用?C++ Addons?提供的能力,在 c++ sdk 中實(shí)現(xiàn)加解密算法,讓 js 可以像調(diào)用 Node 模塊一樣去調(diào)用 c++ sdk 模塊。這樣就一次性解決了上面提到的所有問(wèn)題。
技術(shù)原理如下圖:

開(kāi)發(fā)完 addon,使用?node-gyp?來(lái)構(gòu)建 C++ Addons。node-gyp 會(huì)根據(jù) binding.gyp 配置文件調(diào)用各平臺(tái)上的編譯工具集來(lái)進(jìn)行編譯。
如果要實(shí)現(xiàn)跨平臺(tái),需要按不同平臺(tái)編譯 nodejs addon,在 binding.gyp 中按平臺(tái)配置加解密的靜態(tài)鏈接庫(kù)。
就像下面這樣:
{
????"targets": [{
????????"conditions": [
????????????["OS=='mac'", {
????????????????"libraries": [
????????????????????"<(module_root_dir)/lib/mac/security.a"
????????????????]
????????????}],
????????????["OS=='win'", {??????????????? "libraries": [??????????????????? "<(module_root_dir)/lib/win/security.lib"]
????????????}],
????????????...
????????]
????????...
????}]
當(dāng)然也可以根據(jù)需要添加更多平臺(tái)的支持,如 linux、unix。
對(duì) c++ 代碼進(jìn)程封裝 addon 的時(shí)候,可以使用?node-addon-api。
node-addon-api 包對(duì)?N-API?做了封裝,并抹平了 nodejs 版本間的兼容問(wèn)題。封裝大大降低了非職業(yè) c++ 開(kāi)發(fā)編寫(xiě) node addon 的成本(關(guān)于 node-addon-api、N-API、NAN 等概念可以參考死月同學(xué)的文章《從暴力到 NAN 再到 NAPI——Node.js 原生模塊開(kāi)發(fā)方式變遷》)。
打包出 .node 文件后,可以在 electron 應(yīng)用運(yùn)行時(shí),調(diào)用 process.platform 判斷運(yùn)行的平臺(tái),分別加載對(duì)應(yīng)平臺(tái)的 addon。
if(process.platform === 'win32') {
????????addon = require('../lib/security_win.node');
} else{
????????addon = require('../lib/security_mac.node');
}
3.4進(jìn)一步學(xué)習(xí)
限于篇幅,本篇里沒(méi)辦法對(duì)IM的安全進(jìn)行更深入的總結(jié)和分享,感興趣的讀者可以詳讀:《IM聊天系統(tǒng)安全手段之通信連接層加密技術(shù)》、《IM聊天系統(tǒng)安全手段之傳輸內(nèi)容端到端加密技術(shù)》。
4、IM消息的序列化與反序列化
4.1需求背景
IM聊天消息直接通過(guò) JSON 編解碼和傳輸效率是比較低的,我們可以使用高效的消息序列化與反序列化方案。
4.2我們的方案
這里我們引入谷歌的?Protocol Buffer?提升效率。
PS:關(guān)于 Protocol Buffer 更多的介紹,可以查看《Protobuf通信協(xié)議詳解:代碼演示、詳細(xì)原理介紹等》。
node 環(huán)境中使用 Protocol Buffer 可以用?protobufjs?包。
npm i protobuff -S
然后通過(guò)?pbjs?命令將 proto 文件轉(zhuǎn)換成 pbJson.js
pbjs -t json-module --sparse --force-long -w commonjs -o src/im/data/pbJson.js proto/*.proto
要在 js 中支持后端 int64 格式數(shù)據(jù),需要使用?long?包配置下 protobuf。
var Long = require("long");
$protobuf.util.Long = Long;
$protobuf.configure();
$protobuf.util.LongBits.prototype.toLong = functiontoLong (unsigned) {
????returnnew $protobuf.util.Long(this.lo | 0, this.hi | 0, Boolean(unsigned)).toString();
};
后面就是消息的壓縮轉(zhuǎn)換了,將 js 字符串轉(zhuǎn)成 pb 格式。
import PbJson from './path/to/src/im/data/pbJson.js';
?
// 封裝數(shù)據(jù)
let encodedMsg = PbJson.lookupType('pb-api').ctor.encode(data).finish();
?
// 解封數(shù)據(jù)
let decodedMsg = PbJson.lookupType('pb-api').ctor.decode(buff);
5、網(wǎng)絡(luò)傳輸協(xié)議的選擇
開(kāi)發(fā)IM時(shí)可供選擇的網(wǎng)絡(luò)傳輸層協(xié)議有?UDP、TCP?等。UDP 實(shí)時(shí)性好,但是可靠性不好。這里我們選用 的是 TCP 協(xié)議。
?

PS:關(guān)于TCP和UDP的區(qū)別,以及該如何選擇,可以詳細(xì)閱讀這幾篇:
《快速理解TCP和UDP的差異》
《一泡尿的時(shí)間,快速搞懂TCP和UDP的區(qū)別》
《簡(jiǎn)述傳輸層協(xié)議TCP和UDP的區(qū)別》
《為什么QQ用的是UDP協(xié)議而不是TCP協(xié)議?》
《移動(dòng)端即時(shí)通訊協(xié)議選擇:UDP還是TCP?》
應(yīng)用層分別使用 WebSocket 協(xié)議保持長(zhǎng)連接保證實(shí)時(shí)傳輸消息,HTTPS 協(xié)議傳輸消息外的其他狀態(tài)數(shù)據(jù)。
這里給個(gè)例子實(shí)現(xiàn)一個(gè)簡(jiǎn)單的 WebSocket 管理類(lèi):
import { EventEmitter } from 'events';
const webSocketConfig = 'wss://xxxx';
class SocketServer extends EventEmitter {
????connect () {
????????if(this.socket){
????????????????????????this.removeEvent(this.socket);
????????????????????????this.socket.close();
????????????????}
????????????????this.socket = newWebSocket(webSocketConfig);
????????????????this.bindEvents(this.socket);
????????returnthis;
????}
????close () {}
????async getSocket () {
????}
????bindEvents() {}
????removeEvent() {}
????onMessage (e) {
????????// 消息解包
????????let decodedMSg = 'xxx;
????????this.emit(decodedMSg);
????}
????async send(sendData) {
????????const socket = await this.getSocket()
????????socket.send(sendData);
????}
????...
}
如果你對(duì)WebSocket協(xié)議還不了解,可以從這兩篇入門(mén)文章入手學(xué)習(xí):《新手快速入門(mén):WebSocket簡(jiǎn)明教程》、《WebSocket從入門(mén)到精通,半小時(shí)就夠!》
對(duì)于HTTPS 協(xié)議的話(huà)就不多介紹了,大家天天用。如果你還不是太了解,可以讀讀這兩篇:《如果這樣來(lái)理解HTTPS原理,一篇就夠了》、《一分鐘理解 HTTPS 到底解決了什么問(wèn)題》。
6、IM的私有數(shù)據(jù)通信協(xié)議
上幾節(jié)我們實(shí)現(xiàn)了把IM聊天消息序列化和反序列化,也實(shí)現(xiàn)了通過(guò) WebSocket 發(fā)送和接收消息,但還不能直接這樣發(fā)送聊天消息。
因?yàn)槲覀冞€需要一個(gè)數(shù)據(jù)通信協(xié)議(什么是數(shù)據(jù)通信協(xié)議?可以讀讀這篇《理論聯(lián)系實(shí)際:一套典型的IM通信協(xié)議設(shè)計(jì)詳解》)。也就是給通信層的原始“消息“增加一些屬性,比如:id 用來(lái)關(guān)聯(lián)收發(fā)的消息、type 標(biāo)記消息類(lèi)型、version 標(biāo)記、接口的版本,api 標(biāo)記調(diào)用的接口等。
然后據(jù)此定義一個(gè)編碼格式,用 ArrayBuffer 將消息包裝起來(lái),放到 WebSocket 中發(fā)送,以二進(jìn)制流的方式傳輸。
協(xié)議設(shè)計(jì)需要保證足夠的擴(kuò)展性,不然修改的時(shí)候需要同時(shí)修改前后端,比較麻煩。
下面是個(gè)簡(jiǎn)化的例子:
class PocketManager extends EventEmitter {
????encode (id, type, version, api, payload) {
????????????????let headerBuffer = Buffer.alloc(8);
????????let payloadBuffer = Buffer.alloc(0);
????????let offset = 0;
????????let keyLength = Buffer.from(id).length;
????????headerBuffer.writeUInt16BE(keyLength, offset);
????????offset += 2;
????????headerBuffer.write(id, offset, offset + keyLength, 'utf8');
????????...
????????payloadBuffer = Buffer.from(payload);
????????????????returnBuffer.concat([headerBuffer, payloadBuffer], 8 + payloadBuffer.length);
????}
????decode () {}
}
關(guān)于IM私有數(shù)據(jù)通信協(xié)議/格式的設(shè)計(jì),可以參考《一套海量在線(xiàn)用戶(hù)的移動(dòng)端IM架構(gòu)設(shè)計(jì)實(shí)踐分享(含詳細(xì)圖文)》一文中的“3、協(xié)議設(shè)計(jì)”這一節(jié)。
另外,如果你自認(rèn)為對(duì)于IM的理論知識(shí)很匱乏或不成體系,可以從《新手入門(mén)一篇就夠:從零開(kāi)發(fā)移動(dòng)端IM》入手,系統(tǒng)地進(jìn)行學(xué)習(xí)。
7、IM模塊多進(jìn)程優(yōu)化
IM 界面有很多模塊:聊天模塊,群管理模塊,歷史消息模塊等。
另外:消息通信邏輯不應(yīng)該和界面邏輯放一個(gè)進(jìn)程里,避免界面卡頓時(shí)候影響消息的收發(fā)。
這里有個(gè)簡(jiǎn)單的實(shí)現(xiàn)方法,把不同的模塊放到 electorn 不同的窗口中,因?yàn)椴煌拇翱谟刹煌倪M(jìn)程管理,我們就不需要自己管理進(jìn)程了。
下面實(shí)現(xiàn)一個(gè)窗口管理類(lèi):
import { EventEmitter } from 'events';
class BaseWindow extends EventEmitter {
????open () {}
????close () {}
????isExist () {}
????destroy() {}
????createWindow() {
????????this.win = newBrowserWindow({
????????????????????????...this.browserConfig,
????????????????});
????}
????...
}
其中 browserConfig 可以在子類(lèi)中設(shè)置,不同窗口可以繼承這個(gè)基類(lèi)設(shè)置自己窗口屬性。
通信模塊用作后臺(tái)收發(fā)數(shù)據(jù),不需要顯示窗口,可以設(shè)置窗口 width = 0,height = 0 :
class ImWindow extends BaseWindow {
????browserConfig = {
????????????????width: 0,
????????????????height: 0,
????????????????show: false,
????}
????...
}
8、IM數(shù)據(jù)的本地存儲(chǔ)?
8.1背景
IM 軟件中可能會(huì)有幾千個(gè)聯(lián)系人信息,無(wú)數(shù)的聊天記錄。如果每次都通過(guò)網(wǎng)絡(luò)請(qǐng)求訪問(wèn),比較浪費(fèi)帶寬,影響性能。
那么是否有什么優(yōu)化手段呢?
8.2討論
在Electorn 中可以使用 localstorage, 但是 localstorage 有大小限制,實(shí)際大多只能存 5M 信息,超過(guò)存入大小會(huì)報(bào)錯(cuò)。
有些同學(xué)可能還會(huì)想到?websql, 但這個(gè)技術(shù)標(biāo)準(zhǔn)已經(jīng)被廢棄了。
瀏覽器內(nèi)置的?indexedDB?也是一個(gè)可選項(xiàng)。
不過(guò)這個(gè)也有限制,也沒(méi)有 sqlite 一樣豐富的生態(tài)工具可以用。
8.3方案
這里我們選用 sqlite,在 node 中使用 sqlite 可以直接用?sqlite3?包。
可以先寫(xiě)個(gè) DAO 類(lèi):
import sqlite3 from 'sqlite3';
class DAO {
????constructor(dbFilePath) {
????????this.db = newsqlite3.Database(dbFilePath, (err) => {
????????????//
????????});
????}
????run(sql, params = []) {
????????returnnewPromise((resolve, reject) => {
????????????this.db.run(sql, params, function(err) {
????????????????if(err) {
????????????????????reject(err);
????????????????} else{
????????????????????resolve({ id: this.lastID });
????????????????}
????????????});
????????});
????}
????...
}
再寫(xiě)個(gè) base Model:
class BaseModel {
????constructor(dao, tableName) {
????????this.dao = dao;
????????this.tableName = tableName;
????}
????delete(id) {
????????returnthis.dao.run(`DELETE FROM ${this.tableName} WHERE id = ?`, [id]);
????}
????...
}
其他 Model 比如消息、聯(lián)系人等 Model 可以直接繼承這個(gè)類(lèi),復(fù)用 delete/getById/getAll 之類(lèi)的通用方法。
如果不喜歡手動(dòng)編寫(xiě) SQLite 語(yǔ)句,可以引入?knex?語(yǔ)法封裝器。
當(dāng)然也可以直接時(shí)髦點(diǎn)用上?orm?,比如?typeorm?什么的。
使用如下:
const dao = newAppDAO('path/to/database-file.sqlite3');
const messageModel = newMessageModel(dao);
9、IM新消息托盤(pán)圖標(biāo)閃爍
在Electron 中沒(méi)有提供專(zhuān)用的?tray?閃爍的接口,我們可以簡(jiǎn)單的使用切換 tray 圖標(biāo)來(lái)實(shí)現(xiàn)這個(gè)功能。
import { Tray, nativeImage } from 'electron';
?
class TrayManager {
????...
????setState() {
????????// 設(shè)置默認(rèn)狀態(tài)
????}
????????startBlink(){
????????????????if(!this.tray){
????????????????????????return;
????????????????}
????????????????let emptyImg = nativeImage.createFromPath(path.join(__dirname, './empty.ico'));
????????????????let noticeImg = nativeImage.createFromPath(path.join(__dirname, './newMsg.png'));
????????????????let visible;
????????????????clearInterval(this.trayTimer);
????????????????this.trayTimer = setInterval(()=>{
????????????????????????visible = !visible;
????????????????????????if(visible){
????????????????????????????????this.tray.setImage(noticeImg);
????????????????????????}else{
????????????????????????????????this.tray.setImage(emptyImg);
????????????????????????}
????????????????},500);
????????}
?
????????//停止閃爍
????????stopBlink(){
????????????????clearInterval(this.trayTimer);
????????????????this.setState();
????????}
}
10、IM客戶(hù)端版本更新
一般有幾種不同的更新策略,可以一種或幾種結(jié)合使用,提升體驗(yàn)。
第一種:是整個(gè)軟件更新。這種方式比較暴力,體驗(yàn)不好,打開(kāi)應(yīng)用檢查到版本變更,直接重新下載整個(gè)應(yīng)用替換老版本。改一行代碼,讓用戶(hù)沖下百來(lái)兆的文件。
第二種:是檢測(cè)文件變更,下載替換老文件進(jìn)行升級(jí)。
第三種:是直接將 view 層文件放在線(xiàn)上,electron 殼加載線(xiàn)上頁(yè)面訪問(wèn)。有變更發(fā)布線(xiàn)上頁(yè)面就可以。
關(guān)于版本更新,在本系列的上篇《vivo的Electron技術(shù)棧選型、全方位實(shí)踐總結(jié)》也有提及,可以回顧一下。
11、進(jìn)程間通信
上一篇文章中,有同學(xué)問(wèn)怎么處理進(jìn)程間通信。
electron 進(jìn)程間通信主要用到?ipcMain?和?ipcRenderer。
?

可以先寫(xiě)個(gè)發(fā)消息的方法:
import { remote, ipcRenderer, ipcMain } from 'electron';
?
function sendIPCEvent(event, ...data) {
????if(require('./is-electron-renderer')) {
????????constcurrentWindow = remote.getCurrentWindow();
????????if(currentWindow) {
????????????currentWindow.webContents.send(event, ...data);
????????}
????????ipcRenderer.send(event, ...data);
????????return;
????}
????ipcMain.emit(event, null, ...data);
}
export defaultsendIPCEvent;
這樣不管在主進(jìn)程還是渲染進(jìn)程,直接調(diào)用這個(gè)方法就可以發(fā)消息。
對(duì)于某些特定功能的消息,還可以做一些封裝,比如所有推送消息可以封裝一個(gè)方法,通過(guò)方法中的參數(shù)判斷具體推送的消息類(lèi)型。main 進(jìn)程中根據(jù)消息類(lèi)型,處理相關(guān)邏輯,或者對(duì)消息進(jìn)行轉(zhuǎn)發(fā)。
class ipcMainManager extends EventEmitter {
????constructor() {
????????ipcMain.on('imPush', (name, data) => {
????????????this.emit(name, data);
????????})
????????this.listern();
????}
????listern() {
????????this.on('imPush', (name, data) => {
????????????//
????????});
????}
}
class ipcRendererManager extends EventEmitter {
????push (name, data) {
????????ipcRenderer.send('imPush', name, data);
????}
}
12、其他雜項(xiàng)
還有同學(xué)提到日志處理功能。
這個(gè)和 Electron 關(guān)系不大,是 node 項(xiàng)目通用的功能。
可以選用?winston?之類(lèi)第三方包。
本地日志的話(huà)注意一下存儲(chǔ)的路徑,定期清理等功能點(diǎn),遠(yuǎn)程日志提交到接口就可以了。
獲取路徑可以寫(xiě)些通用的方法,如:
import electron from 'electron';
functiongetUserDataPath() {
????if(require('./is-electron-renderer')) {
????????returnelectron.remote.app.getPath('userData');
????}
????returnelectron.app.getPath('userData');
}
export defaultgetUserDataPath;
13、參考資料
[1]?Protobuf通信協(xié)議詳解:代碼演示、詳細(xì)原理介紹等
[2]?IM聊天系統(tǒng)安全手段之通信連接層加密技術(shù)
[3]?IM聊天系統(tǒng)安全手段之傳輸內(nèi)容端到端加密技術(shù)
[4]?TCP/IP詳解?-?第11章·UDP:用戶(hù)數(shù)據(jù)報(bào)協(xié)議
[5]?TCP/IP詳解?-?第17章·TCP:傳輸控制協(xié)議
[6]?移動(dòng)端即時(shí)通訊協(xié)議選擇:UDP還是TCP?
[7]?WebSocket從入門(mén)到精通,半小時(shí)就夠!
[8]?如果這樣來(lái)理解HTTPS原理,一篇就夠了
[9]?一套海量在線(xiàn)用戶(hù)的移動(dòng)端IM架構(gòu)設(shè)計(jì)實(shí)踐分享(含詳細(xì)圖文)
[10]?理論聯(lián)系實(shí)際:一套典型的IM通信協(xié)議設(shè)計(jì)詳解
(本文已同步發(fā)布于:http://www.52im.net/thread-4051-1-1.html)