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
相关文章
相关标签/搜索