go IM聊天系统(二) 整合websocket实现实时聊天功能

传统实时通信采用的方式:

轮询:
客户端通过一定的时间间隔以频繁请求的方式向服务器发送请求,来保持客户端和服务器端的数据同步。问题很明显,当客户端以固定频率向服务器端发送请求时,服务器端的数据可能并没有更新,带来很多无谓请求,浪费带宽,效率低下。

go IM聊天系统(二) 整合websocket实现实时聊天功能_第1张图片

长连接

客户端和服务端建立连接后不进行断开,之后客户端再次访问这个服务端上的内容时,继续使用这一条连接通道优点:消息即时到达,不发无用请求缺点:与长轮询一样,服务器一直保持连接是会消耗资源的,如果有大量的长连接的话,对于服务器的消耗是巨大的,而且服务器承受能力是有上限的,不可能维持无限个长连接。

go IM聊天系统(二) 整合websocket实现实时聊天功能_第2张图片

WebSocket

客户端向服务器发送一个携带特殊信息的请求头(Upgrade:WebSocket )建立连接,建立连接后双方即可实现自由的实时双向通信。

优点:

  • 较少的控制开销。在连接创建后,服务器和客户端之间交换数据时,用于协议控制的数据包头部相对较小。
  • 更强的实时性。由于协议是全双工的,所以服务器可以随时主动给客户端下发数据。相对于HTTP请求需要等待客户端发起请求服务端才能响应,延迟明显更少;即使是和Comet等类似的长轮询比较,其也能在短时间内更多次地传递数据。
  • 保持连接状态。与HTTP不同的是,Websocket需要先创建连接,这就使得其成为一种有状态的协议,之后通信时可以省略部分状态信息。而HTTP请求可能需要在每个请求都携带状态信息(如身份认证等)。

缺点:相对来说,开发成本和难度更高

go IM聊天系统(二) 整合websocket实现实时聊天功能_第3张图片

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

客户端:websocket连接步骤

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发送消息然后写到对方的浏览器中,如果没有 就直接存入数据库,等对方登录后,触发消息记录的函数 看到对方的消息

你可能感兴趣的:(websocket,网络,golang)