蘑菇街前端团队正式入驻掘金,但愿你们不要吝啬大家手中的赞(比心)!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/,跟踪最新分享。