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

38次阅读

共计 9603 个字符,预计需要花费 25 分钟才能阅读完成。

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

1、引言
本系列文章的后面几篇次要是从 Electron 技术自身进行了探讨(包含:第 1 篇初步理解 Electron、第 2 篇进行了疾速开始和技术体验、第 3 篇基于理论开发思考的技术栈选型等),各位读者也应该对 Electron 的开发有了较为深刻的理解。

本篇将回到 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 模块。这样就一次性解决了下面提到的所有问题。
技术原理如下图:

开发完 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 协定。

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

正文完
 0