im服务器搭建(IM跨平台技术学习)
im服务器搭建(IM跨平台技术学习)可能大家会说:这还不简单?项目里写个加密解密的方法——收到消息时候先解密,发送消息时候先加密,服务端收到加密消息直接存储起来。这样写理论上也没有问题,不过客户端直接写加解密方法有一些不好的地方。比如:对IM聊天软件而言,聊天消息的保密性就比较重要了,谁也不希望自己的聊天内容泄露甚至暴露在众人的前面。所以在收发IM信息的时候,我们需要对信息做一些加密解密操作,保证信息在网络中传输的时候是加密的状态。- 开源IM框架源码:https://github.com/JackJiang2011/MobileIMSDK(备用地址点此)(本文已同步发布于:http://www.52im.net/thread-4051-1-1.html)本文是系列文章中的第4篇,本系列总目录如下:
1、引言本系列文章的前面几篇主要是从Electron技术本身进行了讨论(包括:第1篇初步了解Electron、第2篇进行了快速开始和技术体验、第3篇基于实际开发考虑的技术栈选型等),各位读者也应该对Electron的开发有了较为深入的了解。
本篇将回到IM即时通讯技术本身,根据蘑菇街的实际技术实践,总结和分享基于Electron开发跨平台IM客户端的过程中,需要考虑的典型技术问题以及我们的解决方案。希望能给你带来帮助。
学习交流:
- 移动端IM开发入门文章:《新手入门一篇就够:从零开发移动端IM》
- 开源IM框架源码:https://github.com/JackJiang2011/MobileIMSDK(备用地址点此)
(本文已同步发布于:http://www.52im.net/thread-4051-1-1.html)
2、系列文章本文是系列文章中的第4篇,本系列总目录如下:
- 《IM跨平台技术学习(一):快速了解新一代跨平台桌面技术——Electron》
- 《IM跨平台技术学习(二):Electron初体验(快速开始、跨进程通信、打包、踩坑等)》
- 《IM跨平台技术学习(三):vivo的Electron技术栈选型、全方位实践总结》
- 《IM跨平台技术学习(四):蘑菇街基于Electron开发IM客户端的技术实践》(* 本文)
- 《IM跨平台技术学习(五):融云基于Electron的IM跨平台SDK改造实践总结》(稍后发布.. )
- 《IM跨平台技术学习(六):网易云信基于Electron的IM消息全文检索技术实践》(稍后发布.. )
对IM聊天软件而言,聊天消息的保密性就比较重要了,谁也不希望自己的聊天内容泄露甚至暴露在众人的前面。
所以在收发IM信息的时候,我们需要对信息做一些加密解密操作,保证信息在网络中传输的时候是加密的状态。
可能大家会说:这还不简单?项目里写个加密解密的方法——收到消息时候先解密,发送消息时候先加密,服务端收到加密消息直接存储起来。
这样写理论上也没有问题,不过客户端直接写加解密方法有一些不好的地方。
比如:
- 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');
}
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通信协议设计详解
学习交流:
- 移动端IM开发入门文章:《新手入门一篇就够:从零开发移动端IM》
- 开源IM框架源码:https://github.com/JackJiang2011/MobileIMSDK(备用地址点此)
(本文已同步发布于:http://www.52im.net/thread-4051-1-1.html