本文由蘑菇街前端技術(shù)團(tuán)隊(duì)分享,原題“Electron 從零到一”,有修訂和改動。
1、引言
本系列文章的前面幾篇主要是從Electron技術(shù)本身進(jìn)行了討論(包括:第1篇初步了解Electron、第2篇進(jìn)行了快速開始和技術(shù)體驗(yàn)、第3篇基于實(shí)際開發(fā)考慮的技術(shù)棧選型等),各位讀者也應(yīng)該對Electron的開發(fā)有了較為深入的了解。
本篇將回到IM即時通訊技術(shù)本身,根據(jù)蘑菇街的實(shí)際技術(shù)實(shí)踐,總結(jié)和分享基于Electron開發(fā)跨平臺IM客戶端的過程中,需要考慮的典型技術(shù)問題以及我們的解決方案。希望能給你帶來幫助。

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

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

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

可以先寫個發(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)用這個方法就可以發(fā)消息。
對于某些特定功能的消息,還可以做一些封裝,比如所有推送消息可以封裝一個方法,通過方法中的參數(shù)判斷具體推送的消息類型。main 進(jìn)程中根據(jù)消息類型,處理相關(guān)邏輯,或者對消息進(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é)提到日志處理功能。
這個和 Electron 關(guān)系不大,是 node 項(xiàng)目通用的功能。
可以選用?winston?之類第三方包。
本地日志的話注意一下存儲的路徑,定期清理等功能點(diǎn),遠(yuǎn)程日志提交到接口就可以了。
獲取路徑可以寫些通用的方法,如:
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:用戶數(shù)據(jù)報協(xié)議
[5]?TCP/IP詳解?-?第17章·TCP:傳輸控制協(xié)議
[6]?移動端即時通訊協(xié)議選擇:UDP還是TCP?
[9]?一套海量在線用戶的移動端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)