传统实时通信采用的方式:
轮询:
客户端通过一定的时间间隔以频繁请求的方式向服务器发送请求,来保持客户端和服务器端的数据同步。问题很明显,当客户端以固定频率向服务器端发送请求时,服务器端的数据可能并没有更新,带来很多无谓请求,浪费带宽,效率低下。
客户端和服务端建立连接后不进行断开,之后客户端再次访问这个服务端上的内容时,继续使用这一条连接通道优点:消息即时到达,不发无用请求缺点:与长轮询一样,服务器一直保持连接是会消耗资源的,如果有大量的长连接的话,对于服务器的消耗是巨大的,而且服务器承受能力是有上限的,不可能维持无限个长连接。
客户端向服务器发送一个携带特殊信息的请求头(Upgrade:WebSocket
)建立连接,建立连接后双方即可实现自由的实时双向通信。
优点:
缺点:相对来说,开发成本和难度更高
Websocket创建连接过程
1、客户端发起请求 请求头样式
//第一行为为请求的方法,类型必须为GET,协议版本号必须大于1.1
GET /uin=xxxxxxxx&app=xxxxxxxxx&token=XXXXXXXXXXXX HTTP/1.1
Host: server.example.cn:443
//Upgrade字段必须包含,值为websocket
//Connection字段必须包含,值为Upgrade
//告诉服务器此次请求为websocket请求
Connection: Upgrade
Pragma: no-cache
Cache-Control: no-cache
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.138 Safari/537.36
Upgrade: websocket
Sec-WebSocket-Version: 13
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Cookie: user_id=XXXXX
“Sec-WebSocket-Key”是 WebSocket 客户端发送的一个 base64 编码的密文,要求服务端必须返回一个对应加密的“Sec-WebSocket-Accept”应答
Sec-WebSocket-Key: 1/2hTi/+eNURiekpNI4k5Q==
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits
//Sec-WebSocket-Protocol字段必须包含 ,记录着使用的子协议(即在信息交换时使用的协议)
Sec-WebSocket-Protocol: binary, base64
2、服务端响应报文
HTTP/1.1 101 Switching Protocols
Server: WebSockify Python/2.6.6
Date: Wed, 27 May 2020 03:03:21 GMT
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept字段是由握手请求中的Sec-WebSocket-Key字段生层的
Sec-WebSocket-Accept: hXXXXXXXXXXXXXXxGmM=
Sec-WebSocket-Protocol 则是表示最终使用的协议。
Sec-WebSocket-Protocol: binary
3、握手成功后,通信不再使用HTTP协议,而采用WebSocket独立的数据帧。如下图所示,为协议帧格式:比起普通http报文,其头信息包含更少的信息,因此其效率更高
4、Websocket只需要 一次HTTP握手,所以说整个通讯过程是建立在一次连接/状态中,也就避免了HTTP的非状态性,服务端会一直知道你的信息,直到你关闭请求,这样就解决了Nginx要反复解析HTTP协议,还要查看identity info的信息。
同时由 客户主动询问,转换为 服务器(推送)有信息的时候就发送(当然客户端还是等主动发送信息过来的。。),没有信息的时候就交给Nginx,不需要占用本身速度就慢的 Handler了
1、在用户登录后跳到主界面 触发 created事件对聊天界面进行初始化
created: function () {
//导入好友
this.loadfriends();
//导入群
this.loadcommunitys();
this.loaddoutures();
//开启定时任务 进行心跳连接
setInterval(this.heartbeat, 10 * 1000);
var user = userInfo()
//初始化websocket
this.initwebsocket()
this.initUser();
},
2、初始化websocket,开启相关事件
事件 | 事件处理程序 | 描述 |
---|---|---|
open | Socket.onopen | 连接建立时触发 |
message | Socket.onmessage | 客户端接收服务端数据时触发 |
error | Socket.onerror | 通信发生错误时触发 |
close | Socket.onclose | 连接关闭时触发 |
initwebsocket: function () {
//创建一个websocket实例
var url = "ws://" + location.host + "/chat?userId=" + userId() + "&token=" + util.parseQuery("token");
this.webSocket = new WebSocket(url);
//通过onopen事件判断是否进行了websokect连接
//利用onmessage事件获取来自服务器的消息
this.webSocket.onmessage = function (evt) {
console.log("onmessage", evt.data)
if (evt.data.indexOf("}") > -1) {
console.log("recv json <==" + evt.data)
this.onmessage(JSON.parse(evt.data));
} else {
console.log("recv<==" + evt.data)
}
}.bind(this)
//关闭回调
this.webSocket.onclose = function (evt) {
console.log("您已自动下线") //code 1006
}
//出错回调
this.webSocket.onerror = function (evt) {
console.log(evt.data)
}
/*{
this.webSocket.send()
}*/
},
3、定义send函数完成消息的发送
//跟谁单聊
sendtxtmsg: function (txt) {
if (this.isDisable) {
this.setTimeFlag()
//{id:1,userid:2,dstid:3,cmd:10,media:1,content:"hello"}
var msg = this.createmsgcontext();
msg.Media = 1;
msg.Content = txt;
if (msg.Type == 1) {
this.showmsg(userInfo(), msg);
}
this.webSocket.send(JSON.stringify(msg))
}
},
4、定义onmessage函数处理服务器发来的的消息
onmessage: function (data) {
this.loaduserinfo(data.userId, function (user) {
this.showmsg(user, data)
this.friends.map((item) => {
if (item.ID == data.userId) {
// 1文字 2表情包 3图片 4音频
if (data.Media === 1) {
item.memo = data.Content
} else if (data.Media === 2) {
item.memo = data.Url
} else if (data.Media === 3) {
item.memo = "[语音]"
} else if (data.Media === 4) {
item.memo = "[图片]"
}
}
})
}.bind(this))
},
1、浏览器发送登录请求后,获取用户id和token后,重新发送tochat请求定位到聊天界面,浏览器初始化websocket("ws://" + location.host + "/chat?userId=" + userId() + "&token=" + util.parseQuery("token");客户端的请求升级为websocket连接
//定位到聊天界面
func ToChat(c *gin.Context) {
ind, err := template.ParseFiles("views/chat/index.html",
"views/chat/head.html",
"views/chat/foot.html",
"views/chat/tabmenu.html",
"views/chat/concat.html",
"views/chat/group.html",
"views/chat/profile.html",
"views/chat/createcom.html",
"views/chat/userinfo.html",
"views/chat/main.html")
if err != nil {
panic(err)
}
userId, _ := strconv.Atoi(c.Query("userId"))
token := c.Query("token")
user := models.UserBasic{}
user.ID = uint(userId)
user.Identity = token
// session := sessions.Default(c)
// session.Set("token", "1234")
// c.SetCookie("token", "1234", 300, "/toChat", "localhost:8082", false, true)
fmt.Println("ToChat>>>>>>>>", user)
ind.Execute(c.Writer, user)
// c.JSON(200, gin.H{
// "message": "welcome !! ",
// })
}
/ 需要 :发送者ID ,接受者ID ,消息类型,发送的内容,发送类型
func Chat(writer http.ResponseWriter, request *http.Request) {
//1. 获取参数 并 检验 token 等合法性
//token := query.Get("token")
query := request.URL.Query()
Id := query.Get("userId")
userId, _ := strconv.ParseInt(Id, 10, 64)
//msgType := query.Get("type")
//targetId := query.Get("targetId")
// context := query.Get("context")
isvalida := true //checkToke() 待.........
//将http请求升级为websocket的连接
conn, err := (&websocket.Upgrader{
//token 校验
CheckOrigin: func(r *http.Request) bool {
return isvalida
},
}).Upgrade(writer, request, nil)
if err != nil {
fmt.Println(err)
return
}
//2.获取conn
currentTime := uint64(time.Now().Unix())
//对该用户的连接进行包装
node := &Node{
Conn: conn,
Addr: conn.RemoteAddr().String(), //客户端地址
HeartbeatTime: currentTime, //心跳时间
LoginTime: currentTime, //登录时间
DataQueue: make(chan []byte, 50),
GroupSets: set.New(set.ThreadSafe),
}
//3. 用户关系
//4. userid 跟 node绑定 并加锁
rwLocker.Lock()
clientMap[userId] = node
rwLocker.Unlock()
//5.完成发送逻辑
go sendProc(node)
//6.完成接受逻辑
go recvProc(node)
//7.加入在线用户到缓存
SetUserOnlineInfo("online_"+Id, []byte(node.Addr), time.Duration(viper.GetInt("timeout.RedisOnlineTime"))*time.Hour)
//sendMsg(userId, []byte("欢迎进入聊天系统"))
}
协程阻塞实现消息的传递
func recvProc(node *Node) {
for {
//没有消息时,该协程一直阻塞等消息到来
_, data, err := node.Conn.ReadMessage()
if err != nil {
fmt.Println(err)
return
}
msg := Message{}
//将data包装成msg结构体
err = json.Unmarshal(data, &msg)
if err != nil {
fmt.Println(err)
}
//对消息进行处理
dispatch(data)
broadMsg(data) //todo 将消息广播到局域网
fmt.Println("[ws] recvProc <<<<< ", string(data))
}
}
func sendProc(node *Node) {
for {
select {
//如果没有消息,一直阻塞
case data := <-node.DataQueue:
fmt.Println("[ws]sendProc >>>> msg :", string(data))
//使用targetId的socket将消息写到客户端
err := node.Conn.WriteMessage(websocket.TextMessage, data)
if err != nil {
fmt.Println(err)
return
}
}
}
}
消息的处理
func dispatch(data []byte) {
msg := Message{}
msg.CreateTime = uint64(time.Now().Unix())
err := json.Unmarshal(data, &msg)
if err != nil {
fmt.Println(err)
return
}
//将data转为msg结构体,通过type类型的判断,对消息进行分发
switch msg.Type {
case 1: //私信
fmt.Println("dispatch data :", string(data))
sendMsg(msg.TargetId, data)
case 2: //群发
sendGroupMsg(msg.TargetId, data) //发送的群ID ,消息内容
// case 4: // 心跳
// node.Heartbeat()
//case 4:
//
}
}
func sendMsg(userId int64, msg []byte) {
//取出目标target node节点
rwLocker.RLock()
node, ok := clientMap[userId]
rwLocker.RUnlock()
jsonMsg := Message{}
json.Unmarshal(msg, &jsonMsg)
ctx := context.Background()
targetIdStr := strconv.Itoa(int(userId))
userIdStr := strconv.Itoa(int(jsonMsg.UserId))
jsonMsg.CreateTime = uint64(time.Now().Unix())
//获取socket连接客户端地址
r, err := utils.Red.Get(ctx, "online_"+userIdStr).Result()
if err != nil {
fmt.Println(err)
}
//如果地址不为空,即targetid登录,则将消息写入其对应node节点的消息队列中
if r != "" {
if ok {
fmt.Println("sendMsg >>> userID: ", userId, " msg:", string(msg))
//把消息写进队列
node.DataQueue <- msg
}
}
//设置聊天的key
var key string
if userId > jsonMsg.UserId {
key = "msg_" + userIdStr + "_" + targetIdStr
} else {
key = "msg_" + targetIdStr + "_" + userIdStr
}
//查找之前全部聊天记录
res, err := utils.Red.ZRevRange(ctx, key, 0, -1).Result()
if err != nil {
fmt.Println(err)
}
score := float64(cap(res)) + 1
//添加最新消息
ress, e := utils.Red.ZAdd(ctx, key, &redis.Z{score, msg}).Result() //jsonMsg
//res, e := utils.Red.Do(ctx, "zadd", key, 1, jsonMsg).Result() //备用 后续拓展 记录完整msg
if e != nil {
fmt.Println(e)
}
fmt.Println(ress)
}
整体流程:
登陆后 就会初始化一个websocket 然后用id作为key把这个socket存在map里,初始化之后会开启两个协程,一个接收消息一个发送消息 用户a发了消息后,在map里找目标id的socket 如果有的话 就像该socket发送消息然后写到对方的浏览器中,如果没有 就直接存入数据库,等对方登录后,触发消息记录的函数 看到对方的消息