蘑菇街前端團隊正式入駐掘金,但願你們不要吝嗇大家手中的贊(比心)!javascript
上一節Electron 從零到一 介紹了 electron 的基礎使用,介紹的比較簡單,照着文章一步步基本能夠作出一個簡單的原型項目啦。html
這篇文章介紹一下 electron IM 應用開發中要考慮的一些問題。前端
本文主要包括:java
對聊天軟件而言,消息的保密性就比較重要了,誰也不但願本身的聊天內容泄露甚至暴露在衆人的前面。因此在收發信息的時候,咱們須要對信息作一些加密解密操做,保證信息在網絡中傳輸的時候是加密的狀態。node
可能你們立刻就想這還不簡單,項目裏寫個加密解密的方法。收到消息時候先解密,發送消息時候先加密,服務端收到加密消息直接存儲起來。linux
這樣寫理論上也沒有問題,不過客戶端直接寫加解密方法有一些很差的地方。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');
}
複製代碼
聊天消息直接經過 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);
複製代碼
傳輸層協議有 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 協議的就不介紹了,你們每天用。
上幾步實現了把聊天消息序列化和反序列化,也實現了經過 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 () {}
}
複製代碼
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,
}
...
}
複製代碼
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);
複製代碼
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();
}
}
複製代碼
通常有幾種不一樣的更新策略,能夠一種或幾種結合使用,提高體驗:
第一種是整個軟件更新。這種方式比較暴力,體驗很差,打開應用檢查到版本變動,直接從新下載整個應用替換老版本。改一行代碼,讓用戶衝下百來兆的文件;
第二種是檢測文件變動,下載替換老文件進行升級;
第三種是直接將 view 層文件放在線上,electron 殼加載線上頁面訪問。有變動發佈線上頁面就能夠。
上一篇文章中,有同窗問怎麼處理進程間通訊。electron 進程間通訊主要用到 ipcMain 和 ipcRenderer.
能夠先寫個發消息的方法。
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);
}
}
複製代碼
還有同窗提到日誌處理功能。這個和 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;
複製代碼
有問題能夠加我微信交流:
還能夠關注個人博客前端印象 https://wuwb.me/,跟蹤最新分享。