本文由蘑菇街前端技术团队分享,原题“Electron 从零到一”,有修订和改动。
本系列文章的前面几篇主要是从Electron技术本身进行了讨论(包括:第1篇初步了解Electron、第2篇进行了快速开始和技术体验、第3篇基于实际开发考虑的技术栈选型等),各位读者也应该对Electron的开发有了较为深入的了解。
本篇将回到IM即时通讯技术本身,根据蘑菇街的实际技术实践,总结和分享基于Electron开发跨平台IM客户端的过程中,需要考虑的典型技术问题以及我们的解决方案。希望能给你带来帮助。
本文是系列文章中的第4篇,本系列总目录如下:
对IM聊天软件而言,聊天消息的保密性就比较重要了,谁也不希望自己的聊天内容泄露甚至暴露在众人的前面。 所以在收发IM信息的时候,我们需要对信息做一些加密解密操作,保证信息在网络中传输的时候是加密的状态。
可能大家会说:这还不简单?项目里写个加密解密的方法——收到消息时候先解密,发送消息时候先加密,服务端收到加密消息直接存储起来。 这样写理论上也没有问题,不过客户端直接写加解密方法有一些不好的地方。 比如:
我们使用 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'); }
限于篇幅,本篇里没办法对IM的安全进行更深入的总结和分享,感兴趣的读者可以详读:《IM聊天系统安全手段之通信连接层加密技术》、《IM聊天系统安全手段之传输内容端到端加密技术》。
IM聊天消息直接通过 JSON 编解码和传输效率是比较低的,我们可以使用高效的消息序列化与反序列化方案。
这里我们引入谷歌的 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);
开发IM时可供选择的网络传输层协议有 UDP、TCP 等。UDP 实时性好,但是可靠性不好。这里我们选用 的是 TCP 协议。
PS:关于TCP和UDP的区别,以及该如何选择,可以详细阅读这几篇:
应用层分别使用 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 到底解决了什么问题》。
上几节我们实现了把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》入手,系统地进行学习。
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, } ... }
IM 软件中可能会有几千个联系人信息,无数的聊天记录。如果每次都通过网络请求访问,比较浪费带宽,影响性能。
那么是否有什么优化手段呢?
在Electorn 中可以使用 localstorage, 但是 localstorage 有大小限制,实际大多只能存 5M 信息,超过存入大小会报错。
有些同学可能还会想到 websql, 但这个技术标准已经被废弃了。
浏览器内置的 indexedDB 也是一个可选项。
不过这个也有限制,也没有 sqlite 一样丰富的生态工具可以用。
这里我们选用 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);
在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 壳加载线上页面访问。有变更发布线上页面就可以。
关于版本更新,在本系列的上篇《vivo的Electron技术栈选型、全方位实践总结》也有提及,可以回顾一下。
上一篇文章中,有同学问怎么处理进程间通信。
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); } }
还有同学提到日志处理功能。
这个和 Electron 关系不大,是 node 项目通用的功能。
可以选用 winston 之类第三方包。
本地日志的话注意一下存储的路径,定期清理等功能点,远程日志提交到接口就可以了。
获取路径可以写些通用的方法,如:
import electron from 'electron'; functiongetUserDataPath() { if(require('./is-electron-renderer')) { returnelectron.remote.app.getPath('userData'); } returnelectron.app.getPath('userData'); } export defaultgetUserDataPath;
[1] Protobuf通信协议详解:代码演示、详细原理介绍等
[4] TCP/IP详解 - 第11章·UDP:用户数据报协议
[5] TCP/IP详解 - 第17章·TCP:传输控制协议
[9] 一套海量在线用户的移动端IM架构设计实践分享(含详细图文)
学习交流:
- 移动端IM开发入门文章:《新手入门一篇就够:从零开发移动端IM》 - 开源IM框架源码:https://github.com/JackJiang2011/MobileIMSDK(备用地址点此)
(本文已同步发布于:http://www.52im.net/thread-4051-1-1.html)
本文系转载,前往查看
如有侵权,请联系 cloudcommunity@tencent.com 删除。
本文系转载,前往查看
如有侵权,请联系 cloudcommunity@tencent.com 删除。