Electron IM 應用開發實踐

蘑菇街前端團隊正式入駐掘金,但願你們不要吝嗇大家手中的贊(比心)!javascript

零、 介紹

上一節Electron 從零到一 介紹了 electron 的基礎使用,介紹的比較簡單,照着文章一步步基本能夠作出一個簡單的原型項目啦。html

這篇文章介紹一下 electron IM 應用開發中要考慮的一些問題。前端

本文主要包括:java

  1. 消息加密解密
  2. 消息序列化
  3. 網絡傳輸協議
  4. 私有數據通訊協議
  5. 多進程優化
  6. 消息本地存儲
  7. 新消息 tray 圖標閃爍
  8. 項目自動更新
  9. 進程間通訊
  10. 其餘

1、消息加密解密

背景

對聊天軟件而言,消息的保密性就比較重要了,誰也不但願本身的聊天內容泄露甚至暴露在衆人的前面。因此在收發信息的時候,咱們須要對信息作一些加密解密操做,保證信息在網絡中傳輸的時候是加密的狀態。node

簡單的實現方法

可能你們立刻就想這還不簡單,項目裏寫個加密解密的方法。收到消息時候先解密,發送消息時候先加密,服務端收到加密消息直接存儲起來。linux

這樣寫理論上也沒有問題,不過客戶端直接寫加解密方法有一些很差的地方。android

  1. 容易被逆向。前端代碼比較容易被逆向。
  2. 性能較差。在公司中可能加了不少項目的羣組,各個羣組中都會收到不少消息,前端處理起來比較慢。
  3. 相似的若是都在客戶端實現加解密算法,那麼 ios, android 等不一樣客戶端,由於使用的開發語言不一樣,都要要分別實現相同的算法,增長維護成本。

咱們的方案

咱們使用C++ Addons 提供的能力,在 c++ sdk 中實現加解密算法,讓 js 能夠像調用 Node 模塊同樣去調用 c++ sdk 模塊。這樣就一次性解決了上面提到的全部問題。ios

開發完 addon, 使用 node-gyp 來構建 C++ Addons。node-gyp 會根據 binding.gyp 配置文件調用各平臺上的編譯工具集來進行編譯。若是要實現跨平臺,須要按不一樣平臺編譯 nodejs addon,在 binding.gyp 中按平臺配置加解密的靜態連接庫。c++

{
    "targets": [{
        "conditions": [
            ["OS=='mac'", {
                "libraries": [
                    "<(module_root_dir)/lib/mac/security.a"
                ]
            }],
            ["OS=='win'", {
                "libraries": [
                    "<(module_root_dir)/lib/win/security.lib"
                ]
            }],
            ...
        ]
        ...
    }]
複製代碼

固然也能夠根據須要添加更多平臺的支持,如 linux、unix。git

對 c++ 代碼進程封裝 addon 的時候,可使用 node-addon-api。 node-addon-api 包對 N-API 作了封裝,並抹平了 nodejs 版本間的兼容問題。封裝大大下降了非職業 c++ 開發編寫 node addon 的成本。關於 node-addon-api、N-API、NAN 等概念能夠參考死月同窗的文章從暴力到 NAN 再到 NAPI——Node.js 原生模塊開發方式變遷

打包出 .node 文件後,能夠在 electron 應用運行時,調用 process.platform 判斷運行的平臺,分別加載對應平臺的 addon。

if (process.platform === 'win32') {
	addon = require('../lib/security_win.node');
} else {
	addon = require('../lib/security_mac.node');
}
複製代碼

2、消息序列化和反序列化

背景

聊天消息直接經過 JSON 解碼和傳輸效率都比較低。

咱們的方案

這裏咱們引入谷歌的 Protocol Buffer 提高效率。關於 Protocol Buffer 更多的介紹,能夠查看底部的參考文章。

node 環境中使用 Protocol Buffer 能夠用 protobufjs 包。

npm i protobuff -S
複製代碼

而後經過 pbjs 命令將 proto 文件轉換成 pbJson.js

pbjs -t json-module --sparse --force-long -w commonjs -o src/im/data/pbJson.js proto/*.proto

要在 js 中支持後端 int64 格式數據,須要使用 long 包配置下 protobuf。

var Long = require("long");
$protobuf.util.Long = Long;
$protobuf.configure();
$protobuf.util.LongBits.prototype.toLong = function toLong (unsigned) {
    return new $protobuf.util.Long(this.lo | 0, this.hi | 0, Boolean(unsigned)).toString();
};
複製代碼

後面就是消息的壓縮轉換了,將 js 字符串轉成 pb 格式。

import PbJson from './path/to/src/im/data/pbJson.js';

// 封裝數據
let encodedMsg = PbJson.lookupType('pb-api').ctor.encode(data).finish();

// 解封數據
let decodedMsg = PbJson.lookupType('pb-api').ctor.decode(buff);
複製代碼

3、網絡傳輸協議

傳輸層協議有 UDP、TCP 等。UDP 實時性好,可是可靠性很差。這裏選用 TCP 協議。應用層分別使用 WS 協議保持長鏈接保證明時傳輸消息,HTTPS 協議傳輸消息外的其餘狀態數據。這裏給個例子實現一個簡單的 WS 管理類。

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 = new WebSocket(webSocketConfig);
		this.bindEvents(this.socket);
        return this;
    }
    close () {}
    async getSocket () {
    }
    bindEvents() {}
    removeEvent() {}
    onMessage (e) {
        // 消息解包
        let decodedMSg = 'xxx;
        this.emit(decodedMSg);
    }
    async send(sendData) {
        const socket = await this.getSocket()
        socket.send(sendData);
    }
    ...
}
複製代碼

https 協議的就不介紹了,你們每天用。

4、私有數據通訊協議

上幾步實現了把聊天消息序列化和反序列化,也實現了經過 websocket 發送和接收消息,但還不能直接這樣發送聊天消息。咱們還須要一個數據通訊協議。給消息增長一些屬性,如 id 用來關聯收發的消息,type 標記消息類型,version 標記調用接口的版本,api 標記調用的接口等。而後定義一個編碼格式,用 ArrayBuffer 將消息包裝起來,放到 ws 中發送,以二進制流的方式傳輸。

協議設計須要保證足夠的擴展性,否則修改的時候須要同時修改先後端,比較麻煩。

下面是個簡化的例子:

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);
		return Buffer.concat([headerBuffer, payloadBuffer], 8 + payloadBuffer.length);
    }
    decode () {}
}
複製代碼

5、多進程優化

IM 界面有不少模塊,聊天模塊,羣管理模塊,歷史消息模塊等。另外消息通訊邏輯不該該和界面邏輯放一個進程裏,避免界面卡頓時候影響消息的收發。這裏有個簡單的實現方法,把不一樣的模塊放到 electorn 不一樣的窗口中,由於不一樣的窗口由不一樣的進程管理,咱們就不須要本身管理進程了。下面實現一個窗口管理類。

import { EventEmitter } from 'events';
class BaseWindow extends EventEmitter {
    open () {}
    close () {}
    isExist () {}
    destroy() {}
    createWindow() {
        this.win = new BrowserWindow({
			...this.browserConfig,
		});
    }
    ...
}
複製代碼

其中 browserConfig 能夠在子類中設置,不一樣窗口能夠繼承這個基類設置本身窗口屬性。通訊模塊用做後臺收發數據,不須要顯示窗口,能夠設置窗口 width = 0,height = 0 。

class ImWindow extends BaseWindow {
    browserConfig = {
		width: 0,
		height: 0,
		show: false,
    }
    ...
}
複製代碼

6、消息存儲

背景

IM 軟件中可能會有幾千個聯繫人信息,無數的聊天記錄。若是每次都經過網絡請求訪問,比較浪費帶寬,影響性能。

討論

electorn 中可使用 localstorage, 可是 localstorage 有大小限制,實際大多隻能存 5M 信息,超過存入大小會報錯。

有些同窗可能還會想到 websql, 但這個技術標準已經被廢棄了。

瀏覽器內置的 indexedDB 也是一個可選項。不過這個也有限制,也沒有 sqlite 同樣豐富的生態工具能夠用。

方案

這裏咱們選用 sqlite。在 node 中使用 sqlite 能夠直接用 sqlite3 包。

能夠先寫個 DAO 類

import sqlite3 from 'sqlite3';
class DAO {
    constructor(dbFilePath) {
        this.db = new sqlite3.Database(dbFilePath, (err) => {
            //
        });
    }
    run(sql, params = []) {
        return new Promise((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) {
        return this.dao.run(`DELETE FROM ${this.tableName} WHERE id = ?`, [id]);
    }
    ...
}
複製代碼

其餘 Model 好比消息、聯繫人等 Model 能夠直接繼承這個類,複用 delete/getById/getAll 之類的通用方法。若是不喜歡手動編寫 SQLite 語句,能夠引入 knex 語法封裝器。固然也能夠直接時髦點用上 orm ,好比 typeorm 什麼的。

使用以下:

const dao = new AppDAO('path/to/database-file.sqlite3');
const messageModel = new MessageModel(dao);
複製代碼

7、新消息 tray 圖標閃爍

electron 沒有提供專用的 tray 閃爍的接口,咱們能夠簡單的使用切換 tray 圖標來實現這個功能。

import { Tray, nativeImage } from 'electron';

class TrayManager {
    ...
    setState() {
        // 設置默認狀態
    }
	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();
	}
}
複製代碼

8、項目自動更新

通常有幾種不一樣的更新策略,能夠一種或幾種結合使用,提高體驗:

第一種是整個軟件更新。這種方式比較暴力,體驗很差,打開應用檢查到版本變動,直接從新下載整個應用替換老版本。改一行代碼,讓用戶衝下百來兆的文件;

第二種是檢測文件變動,下載替換老文件進行升級;

第三種是直接將 view 層文件放在線上,electron 殼加載線上頁面訪問。有變動發佈線上頁面就能夠。

9、進程間通訊

上一篇文章中,有同窗問怎麼處理進程間通訊。electron 進程間通訊主要用到 ipcMainipcRenderer.

能夠先寫個發消息的方法。

import { remote, ipcRenderer, ipcMain } from 'electron';

function sendIPCEvent(event, ...data) {
    if (require('./is-electron-renderer')) {
        const currentWindow = remote.getCurrentWindow();
        if (currentWindow) {
            currentWindow.webContents.send(event, ...data);
        }
        ipcRenderer.send(event, ...data);
        return;
    }
    ipcMain.emit(event, null, ...data);
}
export default sendIPCEvent;
複製代碼

這樣無論在主進程仍是渲染進程,直接調用這個方法就能夠發消息。對於某些特定功能的消息,還能夠作一些封裝,好比全部推送消息能夠封裝一個方法,經過方法中的參數判斷具體推送的消息類型。main 進程中根據消息類型,處理相關邏輯,或者對消息進行轉發。

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);
    }
}
複製代碼

10、其餘

還有同窗提到日誌處理功能。這個和 electron 關係不大,是 node 項目通用的功能。能夠選用 winston 之類第三方包。本地日誌的話注意一下存儲的路徑,按期清理等功能點,遠程日誌提交到接口就能夠了。獲取路徑能夠寫些通用的方法,如:

import electron from 'electron';
function getUserDataPath() {
    if (require('./is-electron-renderer')) {
        return electron.remote.app.getPath('userData');
    }
    return electron.app.getPath('userData');
}
export default getUserDataPath;
複製代碼

PS

有問題能夠加我微信交流:

還能夠關注個人博客前端印象 https://wuwb.me/,跟蹤最新分享。

參考文章

  1. node-cpp-addon
  2. serialization-vs-deserialization
  3. Protobuf比JSON性能更好
  4. Node.js 和 C++ 之間的類型轉換
  5. npmtrends
相關文章
相關標籤/搜索