概述
- 整个IM系统的一般架构如下
- 我们这张图展示了整个IM系统的一般架构可见分为四层
- 那最上面这一层是前端,包括哪些东西呢?
- 它包括两部分,第一部分是跟用户直接交互的
- 比如说各种IOS APP, 各种安卓 APP
- 还有各种 web APP 在浏览器里面打开的
- 以及windows上面跑的那种客户端
- 第二部分是跟我们程序相关的SDK,API,Websocket
- 这些我们都统称为前端
- 第二个是接入层,这里展示了几种常用的接入协议
- TCP,HTTPS, HTTPS2, Websocket
- 实际上还会用到Mqtt, Xmpp 等各种协议
- 这是接入层
- 然后, 第三层是逻辑层,逻辑层里面比较熟悉的群聊单聊登录消息下发
- 整个消息下发是整个系统应用的重点,
- 最后,第四层是存储层,存储层,包括 Mysql,Redis, Mongodb 等
- 这是用来做消息持久化用的,用来存储消息的历史记录
- Hbase, Hive这些是我们做大数据存储用的
- 当我们后面的数据量越来越大的时候可能会用到这是存储方式
- 后面有一个文件服务器,为了提升系统的抗并发能力
- 我们将应用服务跟文件服务相互分离
- 一些服务器可能用第三方云来提供
- 这样来提升我们系统的抗并发能力
- 这就是我们整个IM系统的一般架构
网络结构
- 我们整个网络结构也可以分为三部分
- 第一部分是 Hybrid APP 浏览器各种微信环境,通过ws(s) 或 http(s) 协议, 接入到我们应用服务
- 第二部分是应用服务,通过其他的网络途径读写我们的数据库(第三部分)
- 这就是我们整个网络架构
- 值得注意的是:
- HTTP提供的是API服务比如说我们用户输入用名密码,点击登录这个调用的就是我们的HTTP服务。
- 然后 websocket 提供的是长链接,比如说用户发送信息给对方,为了保持这个消息的及时性通过websocket 建立一个长链接,这个是用来做消息推送用的
Websocket 的使用
1 )选型
- github.com/gorilla/websocket (生态案例多,推荐)
- golang.org/x/net/websocket
2 )安装 gorilla 的 websocket
- 注意,gorilla 包依赖 x/net 包,要先安装 x/net 包
- 因为网络问题,x/net 包装不了,按照下面处理
- $
cd $GOPATH
- $
mkdir -p golang.org/x/net
- $
cd golang.org/x/net
- $
go get -u github.com/golang/net/websocket
- $
go get github.com/gorilla/websocket
3 )Websocket 的鉴权
3.1 鉴权成功
3.2 鉴权失败
- 如果是我们每个系统里面的用户,才能够接入聊天系统
- 如果不是我们系统里面的用户,我们应该拒绝他,
- 他不能对我们这个应用发送任何消息,这里涉及到鉴权的问题
- 如上,这个鉴权有两个参数,一个是id,一个是token
- id 相当于我们的QQ号,token 是每一个用户登录所产生的唯一的一个标识
- 我们鉴权的思路就是
- id跟token相互匹配,如果匹配,他就成功,这里往前走的就是101
- 如果 id 跟 token 不一致, 我们返回的是 403
- 也就是说以后这个链接,不能通过这个ws发送信息
- 因为它拒绝了,还没有接到我们系统里面去
- 这个就是鉴权失败的一个标识
4 ) 用户的基本结构
- 主要关注 id 和 token, 其他的字段,我们不做过多的一个关注
- 这是接入的用户信息表
5 ) 接入鉴权
- 后端怎么实现这个鉴权的呢,如上,拿到了 id 和 token
- 在这个 CheckOrigin 函数中,验证 id 和 token 是否匹配
- 如果匹配返回 true ,对应200 (101);否则,返回false, 对应 403
- 最后会拿到 conn,是我们每个客户端的标识,基于此来发送信息和读取信息
conn的维护
- 我们怎么来维护这个 conn 呢?
- 这里给出一个最简单的一个维护方案
- userid 和 conn 形成一个映射关系
- 最后形成一个map,这就是我们定义的 ClientMap
- 可见,对应的 userid 是 int64 类型
- 但是往往在实际的生产过程中,这些关系是远远不够的
- 我们需要定义一个结构体,这个结构体包含这个conn
- 也可能包含其他的一些属性,比如说用户的头像,昵称,性别
- 这样的一些东西,放在这个 ClientNode 里面
- 也就是 userid 和 ClientNode 然后形成一个映射关系
- map的维护,还有其他一些技巧
- 比如说这里面会加锁,以及其他的东西,考虑并发性的需求
6 )消息的发送
- 消息发送之前,先回顾一下消息体的一个格式
- Id 是消息的id
- Userid 表示哪个用户发的
- Cmd 是代表群聊还是私聊
- 如果是群聊,Dstid 是 群id
- 如果是私聊,Dstid 是 目标用户id
- 如果 Dstid 是群id
- 则需要通过 Dstid 获取加入群的所有用户id
- 然后通过这个用户ID 获取我们的换取我们的 ClientNode, 拿到里面的 conn
- 如果 Dstid 是userid, 这里就不赘述,参考上面获取 conn
- 其他字段就先略过
7 )消息的接收
- 启动 websocket 的时候,我们要启动一个协程
- 在这个协程里面它是有一个循环,一个阻塞
- readMessage一直在这里,等待一有消息发过来
- 它就把这个数据读到 message 这个字符串里面
- 然后再对这个字符串进行解析到 msg 类型的对象里面去
- 这个就是我们最核心的API readMessage 写它readmessage
- 最后注意这里有一个 go dispatch,也是也是利用协程的一个属性
- 为了让我们整个系统啊跑起来更流畅
8 )消息的发送
- 首先是需要将这个消息的Json对象转成 []byte 类型的 msg 字符串
- 然后, 再通过这个
conn.WriteMessage(websocket.TextMessage, msg)
方法把这个 msg 输出 - 注意这第一个参数是 TextMessage,代表这个地方是文本格式
9 ) 前端 JS 打开 websocket
// 火狐,chrome
var websocket = new WebSocket(url);
// 打开事件回调
websocket.onopen = function(ev) {
// 启用心跳
}
- 首先是通过
new WebSocket
方法, 传入 URL, 这个url 就是有userid 和 token 的url - 然后这里有一个onopen方法,如果我们打开成功了,它就会调用这个onopen方法, 这是一个事件回调
- 在这个回调内启动心跳,什么是心跳呢?
- 在传统的网络结构里面,前端跟后端通信的时候
- 如果我们在一定时间内,比如说一百秒以内
- 没有数据在这个管道里面传输,那么系统就认为这个网络已经是空闲状态了
- 它会把这个网络回收掉,具体这个时间是多少,是由系统配置的
- 有些服务器,它有这个参数可以配置的
- 为了保持这个网络,是一个正常的状态,不让服务器回收它
- 我们需要往里面发送一些特殊字符,这个服务器接收到,就认为这个网络还是连接的
- 这样一个字符,就叫做心跳
10 )WS的心跳机制
- 心跳应该是隔多长时间发一次,还有每次发心跳,要发什么样的一个格式
- 我们这里有几种方案
- 1 )隔30秒发一次,非常简单,非常机械的一个方案,但能达到目的
- 但是我们不建议隔30秒发一次
- 2 )我们建议在距离最近一次发送的时间30秒以内或者45秒或者自己设置秒来重发
- 也就是说你最后一次发送的时候,我们将当前的最后一次发送时间记录下来
- 然后随着这个时间增加,如果在30秒以内,比如说增加了27秒的时候,
- 有数据发送有数据更新,那这时候我们可以将这个当前的最后一次发送这个时间清零, 这时候我们又从零开始往前计数
- 这个就是最近一次发送的30秒这个有效范围内发送,这个是心跳机制 (重要)
- 心跳机制在物联网应用里面,心跳设置的这时间间隔会影响你整个系统的复杂程度,也会影响整个系统的负载和抗并发的能力
- 打个比方,如果线下有一批设备都停电了,后续统一都连上来了
- 这时候服务器在一瞬间,各种数据,各种心跳一下子都来了,而且都在同样一个时间段过来了,服务器承受的负担是非常大的。
- 但是如果它是非常均匀的啊,比如说一到五秒是十台设备
- 五到十秒是另外十台设备,然后是非常均匀的部署,后端服务器的这个负担是非常轻的
11 )前端发送消息
data = JSON.stringify(msg对象)
websocket.send(data)
// 队列发送
- 这里有一个API是 send,参数 data 是一个序列化之后的字符串
- 这里还有一个技巧,就是用队列发送
- 比如说有些消息是讲究时序的,也就是先后顺序
- 可以对它进行用队列来发送,这里还有一个简单demo
- 这里定义了一个数组叫做 dataqueue
- push方法就是往这个队列里面添加数据
- 然后pop就是把这个数据从队列里取出来
- 可以通过一个 while true 的循环,不断的pop,然后那边添加进来push进去
- 这就是我们简单的一个队列
- 上面是消息发送的格式,对象序列化以后,就是上述字符串
- 这里,content 是 你好, media 是 1 代表文字
- 然后这个 cmd 是 10 代表点对点的单聊模式
- userid 代表谁发的消息
- dstid 就是目标用户id
12 )前端接收的消息
websocket.onmessage = function(event) {
// 处理 event
data = JSON.parse(event.data)
}
整个消息流程梳理
我们看下,A 如何发送消息给B?
1 )A 尝试打开 websocket,路径 /chat?id=xxx&token=sdsdfss
2 ) 后端通过鉴权,建立 userid => websocket 的映射
3 )启用协程,通过 conn.ReadMessage 等待和读取消息
4 )A 发送 Json 字符串消息,里面携带了目标用户 dstid
5 ) 如果是群消息,则分解成群用户ID,进行群发处理
6 ) 后端通过 ClientMap[userid]获得目的用户的 conn
7 ) conn.WriteMessage,这时候在存在连接的情况下就发送给客户端的B了
设计高质量代码
1 )优化Map
- 现在,我们探讨下对单机性能的优化,如何支持高并发
- 那为什么要用map呢?因为我们对map的频繁读写,就会导致map的一个安全性问题
- 这里,我们需要加锁,典型的就是读写锁,读写锁非常适合的场景是什么?
- 读的次数非常多,写的次数非常少的一个场景
- 所以这跟我们的业务正好非常切合,我们写的场景就是用户的接入
- 我们读的次数非常多,每次群发都需要通过这个map来获得用户的 conn
- 我们map不能太大
- 一个map为十万个用户, 已经非常不错了,维护一百万个用户是没有意义的
- 这是我们的map相应的这一个优化
2 ) 突破系统的瓶颈, 优化最大的连接数
- 典型的我们普遍认为 linux 系统会 优于 windows系统, 我们要把系统迁移到 linux
- 但是,linux 下有一个最大的文件数,他会直接影响的我们网络的连接数
- 我们要把最大的文件数先解除掉,这是我们对系统的一个优化
3 )优化这个CPU资源的使用率
- 典型的占用CPU资源的编码,出现在对JSON的编码上
- 每次我们对JSON进行编码占用的CPU资源非常多
- 要尽量降低这种编码的使用频次,做到一次编码多次使用
4 )降低IO资源的使用
- 怎么会降低IO资源的使用呢?比如说我们用户访问数据库,数据库连接需要时间
- 另外数据库访问是一个非常耗时的过程,所以我们在这个过程中,整个IO也是处于占用状态
- 那这应该怎么办呢?那很简单,我们需要合并这个写数据库的这个次数
- 以前是一秒钟写五次,我们可以让他五秒钟写一次,只是把这个数据合并起来,再写
- 以前每次要去数据库里面读数据,比如用户的头像信息,我们可以引入缓存
- 这样就可以直接先去缓存中去查,降低对数据库的依赖,这是对IO资源的这样一个优化
5 )对应用服务和资源分布服务相互分离的这一个优化策略
- 我们应用服务是指什么呢?
- 是指提供动态的这样一个服务
- 比如说用户注册,登录这样一个服务
- 然后我们资源服务是指什么呢?
- 是指我们的图片文件CSS等文件
- 我们把这个资源服务部署到我们的云服务上,由第三方服务提供,比如 OSS
- 这样就可以极大的降低资源服务对我们服务器的这样一个压力
- 这样就能够提升我们整个系统的性能