IM跨平臺技術(shù)學(xué)習(xí)(四):蘑菇街基于Electron開發(fā)IM客戶端的技術(shù)實(shí)踐

本文由蘑菇街前端技術(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ì)閱讀這幾篇:

快速理解TCP和UDP的差異

一泡尿的時間,快速搞懂TCP和UDP的區(qū)別

簡述傳輸層協(xié)議TCP和UDP的區(qū)別

為什么QQ用的是UDP協(xié)議而不是TCP協(xié)議?

移動端即時通訊協(xié)議選擇:UDP還是TCP?

應(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?

[7]?WebSocket從入門到精通,半小時就夠!

[8]?如果這樣來理解HTTPS原理,一篇就夠了

[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

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容