跳到主要内容

electron 从一到二

〇、介绍​

上一节介绍了 electron 的基础使用,介绍的比较简单,照着文章一步步基本可以打出一个简单的原型项目啦。

这篇文章介绍一下 electron IM 应用开发中要考虑的一些问题。

本文主要包括:

  1. 消息加密解密
  2. 消息序列化
  3. 网络传输协议
  4. 私有数据通信协议
  5. 多进程优化
  6. 消息本地存储
  7. 新消息 tray 图标闪烁
  8. 项目自动更新
  9. 进程间通信
  10. 其他

一、消息加密解密​

背景​

对聊天软件而言,消息的保密性就比较重要了,谁也不希望自己的聊天内容泄露甚至暴露在众人的前面。所以在收发信息的时候,我们需要对信息做一些加密解密操作,保证信息在网络中传输的时候是加密的状态。

简单的实现方法​

可能大家马上就想这还不简单,项目里写个加密解密的方法。收到消息时候先解密,发送消息时候先加密,服务端收到加密消息直接存储起来。

这样写理论上也没有问题,不过客户端直接写加解密方法有一些不好的地方。

  1. 容易被逆向。前端代码比较容易被逆向。
  2. 性能较差。在公司中可能加了很多项目的群组,各个群组中都会收到很多消息,前端处理起来比较慢。
  3. 类似的如果都在客户端实现加解密算法,那么 ios, android 等不同客户端,因为使用的开发语言不同,都要要分别实现相同的算法,增加维护成本。

我们的方案​

我们使用C++ Addons 提供的能力,在 c++ sdk 中实现加解密算法,让 js 可以像调用 Node 模块一样去调用 c++ sdk 模块。这样就一次性解决了上面提到的所有问题。

开发完 addon, 使用 node-gyp 来构建 C++ Addons. node-gyp 会根据 binding.gyp 配置文件调用各平台上的编译工具集来进行编译。如果要实现跨平台,需要按不同平台编译 nodejs addon,在 binding.gyp 中按平台配置加解密的静态链接库。

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

当然也可以根据需要添加更多平台的支持,如 linux、unix。

对 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

shell 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 字符串转成 bp 格式。

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);

七、新消息 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();
}
}

八、项目自动更新​

一般有几种不同的更新策略,可以一种或几种结合使用,提升体验。

第一种是整个软件更新。这种方式比较暴力,体验不好,打开应用检查到版本变更,直接重新下载整个应用替换老版本。改一行代码,让用户冲下百来兆的文件

第二种是检测文件变更,下载替换老文件进行升级。

第三种是直接将 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;

PS​

有问题可以加我微信交流:

还可以关注我的博客前端印象 https://wuwb.me/,跟踪最新分享。

参考​