IM跨平台技术学习(四):蘑菇街基于Electron开发IM客户端的技术实践

本文由蘑菇街前端技术团队分享,原题“Electron 从零到一”,有修订和改动。

1、引言
本系列文章的前面几篇主要是从Electron技术本身进行了讨论(包括:第1篇初步了解Electron、第2篇进行了快速开始和技术体验、第3篇基于实际开发考虑的技术栈选型等),各位读者也应该对Electron的开发有了较为深入的了解。
IM跨平台技术学习(四):蘑菇街基于Electron开发IM客户端的技术实践_第1张图片

本篇将回到IM即时通讯技术本身,根据蘑菇街的实际技术实践,总结和分享基于Electron开发跨平台IM客户端的过程中,需要考虑的典型技术问题以及我们的解决方案。希望能给你带来帮助。

学习交流:

移动端IM开发入门文章:《新手入门一篇就够:从零开发移动端IM》
开源IM框架源码:https://github.com/JackJiang2...(备用地址点此)

(本文已同步发布于:[http://www.52im.net/thread-40...]

2、系列文章
本文是系列文章中的第4篇,本系列总目录如下:

  • 《IM跨平台技术学习(一):快速了解新一代跨平台桌面技术——Electron》
  • 《IM跨平台技术学习(二):Electron初体验(快速开始、跨进程通信、打包、踩坑等)》
  • 《IM跨平台技术学习(三):vivo的Electron技术栈选型、全方位实践总结》
  • 《IM跨平台技术学习(四):蘑菇街基于Electron开发IM客户端的技术实践》(* 本文)
  • 《IM跨平台技术学习(五):融云基于Electron的IM跨平台SDK改造实践总结》(稍后发布.. ) 《IM跨平台技术学习(六):网易云信基于Electron的IM消息全文检索技术实践》(稍后发布.. )

3、IM消息的加密和解密
3.1需求背景对IM聊天软件而言,聊天消息的保密性就比较重要了,谁也不希望自己的聊天内容泄露甚至暴露在众人的前面。所以在收发IM信息的时候,我们需要对信息做一些加密解密操作,保证信息在网络中传输的时候是加密的状态。
3.2简单的实现方法可能大家会说:这还不简单?项目里写个加密解密的方法——收到消息时候先解密,发送消息时候先加密,服务端收到加密消息直接存储起来。这样写理论上也没有问题,不过客户端直接写加解密方法有一些不好的地方。
比如:
1)容易逆向:前端代码比较容易被逆向;
2)性能较差:用户可能加了很多群组,各群组中都会收到很多消息,前端处理起来比较慢;
3)多端实现:如果都在客户端实现加解密算法,
那么 ios, android 等不同客户端,因为使用的开发语言不同,都要分别实现相同的算法,增加维护成本。
3.3我们的方案
我们使用 C++ Addons 提供的能力,在 c++ sdk 中实现加解密算法,让 js 可以像调用 Node 模块一样去调用 c++ sdk 模块。这样就一次性解决了上面提到的所有问题。
技术原理如下图:
IM跨平台技术学习(四):蘑菇街基于Electron开发IM客户端的技术实践_第2张图片
开发完 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');}

3.4进一步学习
限于篇幅,本篇里没办法对IM的安全进行更深入的总结和分享,感兴趣的读者可以详读:《IM聊天系统安全手段之通信连接层加密技术》、《IM聊天系统安全手段之传输内容端到端加密技术》。4、IM消息的序列化与反序列化
4.1需求背景
IM聊天消息直接通过 JSON 编解码和传输效率是比较低的,我们可以使用高效的消息序列化与反序列化方案。
4.2我们的方案
这里我们引入谷歌的 Protocol Buffer 提升效率。
PS:关于 Protocol Buffer 更多的介绍,可以查看《Protobuf通信协议详解:代码演示、详细原理介绍等》。
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 = functiontoLong (unsigned) {    returnnew $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);

5、网络传输协议的选择
开发IM时可供选择的网络传输层协议有 UDP、TCP 等。UDP 实时性好,但是可靠性不好。这里我们选用 的是 TCP 协议。 
IM跨平台技术学习(四):蘑菇街基于Electron开发IM客户端的技术实践_第3张图片
PS:关于TCP和UDP的区别,以及该如何选择,可以详细阅读这几篇:
《快速理解TCP和UDP的差异》
《一泡尿的时间,快速搞懂TCP和UDP的区别》
《简述传输层协议TCP和UDP的区别》
《为什么QQ用的是UDP协议而不是TCP协议?》
《移动端即时通讯协议选择:UDP还是TCP?》
应用层分别使用 WebSocket 协议保持长连接保证实时传输消息,HTTPS 协议传输消息外的其他状态数据。
这里给个例子实现一个简单的 WebSocket 管理类:

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

如果你对WebSocket协议还不了解,可以从这两篇入门文章入手学习:《新手快速入门:WebSocket简明教程》、《WebSocket从入门到精通,半小时就够!》
对于HTTPS 协议的话就不多介绍了,大家天天用。如果你还不是太了解,可以读读这两篇:《如果这样来理解HTTPS原理,一篇就够了》、《一分钟理解 HTTPS 到底解决了什么问题》。
6、IM的私有数据通信协议
上几节我们实现了把IM聊天消息序列化和反序列化,也实现了通过 WebSocket 发送和接收消息,但还不能直接这样发送聊天消息。因为我们还需要一个数据通信协议(什么是数据通信协议?可以读读这篇《理论联系实际:一套典型的IM通信协议设计详解》)。也就是给通信层的原始“消息“增加一些属性,比如:id 用来关联收发的消息、type 标记消息类型、version 标记、接口的版本,api 标记调用的接口等。然后据此定义一个编码格式,用 ArrayBuffer 将消息包装起来,放到 WebSocket 中发送,以二进制流的方式传输。协议设计需要保证足够的扩展性,不然修改的时候需要同时修改前后端,比较麻烦。
下面是个简化的例子:

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);                
returnBuffer.concat([headerBuffer, payloadBuffer], 8 + payloadBuffer.length);    
}    
decode () {}}

关于IM私有数据通信协议/格式的设计,可以参考《一套海量在线用户的移动端IM架构设计实践分享(含详细图文)》一文中的“3、协议设计”这一节。
另外,如果你自认为对于IM的理论知识很匮乏或不成体系,可以从《新手入门一篇就够:从零开发移动端IM》入手,系统地进行学习。
7、IM模块多进程优化
IM 界面有很多模块:聊天模块,群管理模块,历史消息模块等。
另外:消息通信逻辑不应该和界面逻辑放一个进程里,避免界面卡顿时候影响消息的收发。这里有个简单的实现方法,把不同的模块放到 electorn 不同的窗口中,因为不同的窗口由不同的进程管理,我们就不需要自己管理进程了。
下面实现一个窗口管理类:

import { EventEmitter } from 'events';
class BaseWindow extends EventEmitter {    
open () {}    
close () {}    
isExist () {}    
destroy() {}    
createWindow() {        
this.win = newBrowserWindow({                        
...this.browserConfig,                
});    
}    
...}

其中 browserConfig 可以在子类中设置,不同窗口可以继承这个基类设置自己窗口属性。
通信模块用作后台收发数据,不需要显示窗口,可以设置窗口 width = 0,height = 0 :

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

8、IM数据的本地存储 
8.1背景IM
软件中可能会有几千个联系人信息,无数的聊天记录。如果每次都通过网络请求访问,比较浪费带宽,影响性能。那么是否有什么优化手段呢?
8.2讨论
在Electorn 中可以使用 localstorage, 但是 localstorage 有大小限制,实际大多只能存 5M 信息,超过存入大小会报错。有些同学可能还会想到 websql, 但这个技术标准已经被废弃了。浏览器内置的 indexedDB 也是一个可选项。不过这个也有限制,也没有 sqlite 一样丰富的生态工具可以用。
8.3方案
这里我们选用 sqlite,在 node 中使用 sqlite 可以直接用 sqlite3 包。
可以先写个 DAO 类:

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

其他 Model 比如消息、联系人等 Model 可以直接继承这个类,复用 delete/getById/getAll 之类的通用方法。如果不喜欢手动编写 SQLite 语句,可以引入 knex 语法封装器。当然也可以直接时髦点用上 orm ,比如 typeorm 什么的。
使用如下:
const dao = newAppDAO('path/to/database-file.sqlite3');
const messageModel = newMessageModel(dao);
9、IM新消息托盘图标闪烁
在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();        
}}

10、IM客户端版本更新
一般有几种不同的更新策略,可以一种或几种结合使用,提升体验。
第一种:是整个软件更新。这种方式比较暴力,体验不好,打开应用检查到版本变更,直接重新下载整个应用替换老版本。改一行代码,让用户冲下百来兆的文件。
第二种:是检测文件变更,下载替换老文件进行升级。
第三种:是直接将 view 层文件放在线上,electron 壳加载线上页面访问。有变更发布线上页面就可以。
关于版本更新,在本系列的上篇《vivo的Electron技术栈选型、全方位实践总结》也有提及,可以回顾一下。
11、进程间通信
上一篇文章中,有同学问怎么处理进程间通信。electron 进程间通信主要用到 ipcMain 和 ipcRenderer。 
图片
可以先写个发消息的方法:

import { remote, ipcRenderer, ipcMain } from 'electron'; 
function sendIPCEvent(event, ...data) {    
if(require('./is-electron-renderer')) {        
constcurrentWindow = remote.getCurrentWindow();        
if(currentWindow) {            
currentWindow.webContents.send(event, ...data);        
}        
ipcRenderer.send(event, ...data);        
return;    
}    
ipcMain.emit(event, null, ...data);
}export defaultsendIPCEvent;

这样不管在主进程还是渲染进程,直接调用这个方法就可以发消息。对于某些特定功能的消息,还可以做一些封装,比如所有推送消息可以封装一个方法,通过方法中的参数判断具体推送的消息类型。
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);    
}}
12、其他杂项
还有同学提到日志处理功能。这个和 Electron 关系不大,是 node 项目通用的功能。可以选用 winston 之类第三方包。本地日志的话注意一下存储的路径,定期清理等功能点,远程日志提交到接口就可以了。
获取路径可以写些通用的方法,如:

import electron from 'electron';functiongetUserDataPath() {    
if(require('./is-electron-renderer')) {        
returnelectron.remote.app.getPath('userData');    
}    
returnelectron.app.getPath('userData');
}export defaultgetUserDataPath;

13、参考资料
[1] Protobuf通信协议详解:代码演示、详细原理介绍等
[2] IM聊天系统安全手段之通信连接层加密技术
[3] IM聊天系统安全手段之传输内容端到端加密技术
[4] TCP/IP详解 - 第11章·UDP:用户数据报协议
[5] TCP/IP详解 - 第17章·TCP:传输控制协议
[6] 移动端即时通讯协议选择:UDP还是TCP?
[7] WebSocket从入门到精通,半小时就够!
[8] 如果这样来理解HTTPS原理,一篇就够了
[9] 一套海量在线用户的移动端IM架构设计实践分享(含详细图文)
[10] 理论联系实际:一套典型的IM通信协议设计详解

(本文已同步发布于:http://www.52im.net/thread-40...

你可能感兴趣的:(前端)