彻底弄懂WebSocket

前言 在WebSocket出现之前,前端和后端交互通常使用Ajax进行HTTP API 通讯,然而若有实时性要求的项目,如聊天室或游戏中PVP对战或推送消息等场景,需要前端定时向后端轮询,然而轮询过快可能导致后端服务压力过大,轮询过慢可能导致实时性不高。WebSocket则为浏览器/客户端和服务器和服务端提供了双向通信的能力,保持了客户端和服务端的长连接,支持双向推送消息
什么是WebSocket WebSocket和HTTP一样属于OSI网络协议中的第七层,支持双向通信,底层连接采用TCP。WebSocket并不是全新的协议,使用时需要由HTTP升级,故它使用的端口是80(或443,有HTTPS升级而来),WebSocket Secure (wss)是WebSocket (ws)的加密版本,下图是WebSocket的建立和通信示意图。
彻底弄懂WebSocket
文章图片

需要特别注意的有
快速入门 (注:本文使用golang语言,不过原理都是想通的)
使用Go语言,通常有两种方式,一种是使用Go 语言内置的net/http 库编写WebSocket服务器,另一种是使用gorilla封装的Go语言的WebSocket语言官方库,Github地址:gorilla/websocket.(注,当然还有其他的Websocket封装库,目前gorilla这个比较常用),gorilla库提供了一个聊天室的demo,本文将以这个例子上手入门.
1. 启动服务 gorilla/websocket 的聊天室的README.md

$ go get github.com/gorilla/websocket $ cd `go list -f '{{.Dir}}' github.com/gorilla/websocket/examples/chat` $ go run *.go

2. 打开浏览器页面,输入http://localhost:8080/ 作为示例,我打开了两个页面,如下图所示
彻底弄懂WebSocket
文章图片

在左边输入hello,myname is james,两个窗口同时显示了这句话,F12打开调试窗口,在network那个tab下的ws 中左边的data有刚输入的上行和下行消息,而右边只有下行消息,说明消息确实确实通过左边的连接发送到服务端,并进行了广播给所有的客户端。
3. 如何建立连接 同样还是上述的F12调试窗口中的network tab 下的ws,打开请求头
彻底弄懂WebSocket
文章图片
彻底弄懂WebSocket
文章图片

请求消息:
GET ws://localhost:8080/ws HTTP/1.1 Host: localhost:8080 Connection: Upgrade Pragma: no-cache Cache-Control: no-cache User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.110 Safari/537.36 Upgrade: websocket Origin: http://localhost:8080 Sec-WebSocket-Version: 13 Accept-Encoding: gzip, deflate, br Accept-Language: zh-CN,zh; q=0.9 Cookie: Goland-cd273d2a=102d1f43-0418-4ea3-9959-2975794fdfe3 Sec-WebSocket-Key: 2e1HXejEZhjvYEEVOEE79g== Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits

  1. 其中GET请求是ws://开头,区别于HTTP /path
  2. Upgrade: websocketConnection: Upgrade标识升级HTTP为WebSocket
  3. Sec-WebSocket-Key: 2e1HXejEZhjvYEEVOEE79g==其中2e1HXejEZhjvYEEVOEE79g==为6个随机字节的base64用于标识一个连接,并非用于加密
  4. Sec-WebSocket-Version: 13指定了WebSocket的协议版本
应答消息:
HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: WPaVPwi6nk4cFFxS8NJ3BIwAtNE=

101表示本次连接的HTTP协议即将被更改,更改后的协议就是Upgrade: websocket指定的WebSocket协议
其中Sec-WebSocket-Accept: WPaVPwi6nk4cFFxS8NJ3BIwAtNE=是服务器获取了Request Header 中的Sec-WebSocket-Keybase64解码后,并拼接上258EAFA5-E914-47DA-95CA-C5AB0DC85B11再用通过 SHA1 计算出摘要,再base64编码,并填写到Sec-WebSocket-Accept域.
所以Sec-WebSocket-KeySec-WebSocket-Accept的作用是主要是为了避免客户端不小心升级websocket,也即用来验证WebSocket的handshake,避免接受 non-WebSocket 的client(如HTTP客户端).具体可参见RFC6455或What is Sec-WebSocket-Key for?
4. gorilla/websocket代码 查看上面chat的demo代码是学习的好资料
var addr = flag.String("addr", ":8080", "http service address")func serveHome(w http.ResponseWriter, r *http.Request) { log.Println(r.URL) if r.URL.Path != "/" { http.Error(w, "Not found", http.StatusNotFound) return } if r.Method != "GET" { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } http.ServeFile(w, r, "home.html") }func main() { flag.Parse() hub := newHub() go hub.run() http.HandleFunc("/", serveHome) http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) { serveWs(hub, w, r) }) err := http.ListenAndServe(*addr, nil) if err != nil { log.Fatal("ListenAndServe: ", err) } }

其中serveHome主要返回聊天室的HTML资源,serveWs主要接受HTTP client 升级websocket请求,并处理聊天信息的广播(至整个房间的全部ws连接)
Chat Example - 锐客网html { overflow: hidden; } ...

以上是home.html这里主要是conn.onclose处理连接关闭conn.onmessage处理收到消息,conn.send处理发送消息,当然还有conn.onopen处理连接建立等,这里就不赘述了.
// serveWs handles websocket requests from the peer. func serveWs(hub *Hub, w http.ResponseWriter, r *http.Request) { conn, err := upgrader.Upgrade(w, r, nil) if err != nil { log.Println(err) return } client := &Client{hub: hub, conn: conn, send: make(chan []byte, 256)} client.hub.register <- client// Allow collection of memory referenced by the caller by doing all work in // new goroutines. go client.writePump() go client.readPump() }

conn, err := upgrader.Upgrade(w, r, nil)主要是升级HTTP到WebSocket,底层使用http.Hijacker来劫持底层的TCP 连接,后续就可以使用这个连接双端通信了.
// Hub maintains the set of active clients and broadcasts messages to the // clients. type Hub struct { // Registered clients. clients map[*Client]bool// Inbound messages from the clients. broadcast chan []byte// Register requests from the clients. register chan *Client// Unregister requests from clients. unregister chan *Client }

Hub主要是维护这个房间的所有连接,当用个client 建立添加到clients这个map中,每次连接断开就会从clients这个map中移除
go client.readPump()负责将客户端发送的消息写到broadcast,而go client.writePump()负责将broadcast中的消息广播到clients记录的这个房间的全部client ,细节下文在讲完websocket 协议细节会继续来看这个源码。
WebSocket协议 上面已经对ws进行了快速入门,那么WebSocket的通信格式是怎么样定义?这节就来介绍下
彻底弄懂WebSocket
文章图片

图片截至rfc6455#section-5.2
FIN:占1 bit
1表示分片消息的最后一个后片
RSV1, RSV2, RSV3:各占 1 个bit
【彻底弄懂WebSocket】一般全0,当客户端和服务端协商协商扩展时,值由协商定义
Opcode: 4 个bit
操作代码,Opcode 的值决定了应该如何解析后续的数据载荷(data payload)
  • %x0:表示一个延续帧。当 Opcode 为 0 时,表示本次数据传输采用了数据分片,当前收到的数据帧为其中一个数据分片。
  • %x1:表示这是一个文本帧(frame)
  • %x2:表示这是一个二进制帧(frame)
  • %x3-7:保留的操作代码,用于后续定义的非控制帧。
  • %x8:表示连接断开。
  • %x9:表示这是一个 ping 操作。
  • %xA:表示这是一个 pong 操作。
  • %xB-F:保留的操作代码,用于后续定义的控制帧
Mask: 1 个bit
是否对数据载荷掩码操作。掩码只能客户端对服务端发送数据时可以掩码操作。
如果 Mask 是 1,那么在 Masking-key 中会定义一个掩码键(masking key),并用这个掩码键来对数据载荷进行反掩码。所有客户端发送到服务端的数据帧,Mask 都是 1。
掩码的作用主要是防止恶意的客户端将其他网站的资源缓存在反向代理上,导致其他网站的用户使用恶意攻击者防止的恶意代码
Payload length:数据载荷的长度,单位是字节。为 7bit,或 7+16 bit,或 1+64 bit。
假设数 Payload length === x,如果
  • x 为 0~126:数据的长度为 x 字节。
  • x 为 126:后续 2 个字节代表一个 16 位的无符号整数,该无符号整数的值为数据的长度。
  • x 为 127:后续 8 个字节代表一个 64 位的无符号整数(最高位为 0),该无符号整数的值为数据的长度。
此外,如果 payload length 占用了多个字节的话,payload length 的二进制表达采用网络序(big endian,重要的位在前)。
Masking-key:0 或 4 bytes(32 bit)
所有从客户端传送到服务端的数据帧,数据载荷都进行了掩码操作,Mask 为 1,且携带了 4 字节的 Masking-key。如果 Mask 为 0,则没有 Masking-key。
备注:载荷数据的长度,不包括 mask key 的长度。
Payload data:(x+y) bytes
载荷数据:包括了扩展数据、应用数据。其中,扩展数据 x 字节,应用数据 y 字节。
扩展数据:如果没有协商使用扩展的话,扩展数据数据为 0 字节。所有的扩展都必须声明扩展数据的长度,或者可以如何计算出扩展数据的长度。此外,扩展如何使用必须在握手阶段就协商好。如果扩展数据存在,那么载荷数据长度必须将扩展数据的长度包含在内。
应用数据:任意的应用数据,在扩展数据之后(如果存在扩展数据),占据了数据帧剩余的位置。载荷数据长度 减去 扩展数据长度,就得到应用数据的长度
示例 不知道你是否对FINOpcode中的延续针感到困惑?
第一条消息
FIN=1, 表示是当前消息的最后一个数据帧。服务端收到当前数据帧后,可以处理消息。opcode=0x1,表示客户端发送的是文本类型。
第二条消息
  1. FIN=0,opcode=0x1,表示发送的是文本类型,且消息还没发送完成,还有后续的数据帧。
  2. FIN=0,opcode=0x0,表示消息还没发送完成,还有后续的数据帧,当前的数据帧需要接在上一条数据帧之后。
  3. FIN=1,opcode=0x0,表示消息已经发送完成,没有后续的数据帧,当前的数据帧需要接在上一条数据帧之后。服务端可以将关联的数据帧组装成完整的消息。
Client: FIN=1, opcode=0x1, msg="hello" Server: (process complete message immediately) Hi. Client: FIN=0, opcode=0x1, msg="and a" Server: (listening, new message containing text started) Client: FIN=0, opcode=0x0, msg="happy new" Server: (listening, payload concatenated to previous message) Client: FIN=1, opcode=0x0, msg="year!" Server: (process complete message) Happy new year to you too!

gorilla/websocket源码解析 读取消息
gorilla/websocket使用(c *Conn) ReadMessage() (messageType int, p []byte, err error)的helper方法来快速读取一条消息,如果是一条消息由多个数据帧,则会拼接成完整的消息,返回给业务层
// ReadMessage is a helper method for getting a reader using NextReader and // reading from that reader to a buffer. func (c *Conn) ReadMessage() (messageType int, p []byte, err error) { var r io.Reader messageType, r, err = c.NextReader() if err != nil { return messageType, nil, err } p, err = ioutil.ReadAll(r) return messageType, p, err }

该方法主要是获取一个Reader,然后将Reader中的数据全部读出来
func (c *Conn) NextReader() (messageType int, r io.Reader, err error) { // Close previous reader, only relevant for decompression. if c.reader != nil { c.reader.Close() c.reader = nil }c.messageReader = nil c.readLength = 0for c.readErr == nil { frameType, err := c.advanceFrame() if err != nil { c.readErr = hideTempErr(err) break }if frameType == TextMessage || frameType == BinaryMessage { c.messageReader = &messageReader{c} c.reader = c.messageReader if c.readDecompress { c.reader = c.newDecompressionReader(c.reader) } return frameType, c.reader, nil } }

由于Reader不是并发安全的,故每次之后一个协程处理Reader的读操作,c.advanceFrame()是核心代码,主要是解析这条消息的类型,如果一个消息拆成多个帧,那消息类型在第一个帧中给出,解析数据帧的格式同上述协议讲解一致,详细可以查看源码,这里就不再赘述。
你是否好奇,为啥ioutil.ReadAll(r)能读出整个消息的数据,如果一个消息拆分成多个数据帧呢?一起剖析下源码
func (r *messageReader) Read(b []byte) (int, error) { c := r.c if c.messageReader != r { return 0, io.EOF }for c.readErr == nil {if c.readRemaining > 0 { if int64(len(b)) > c.readRemaining { b = b[:c.readRemaining] } n, err := c.br.Read(b) c.readErr = hideTempErr(err) if c.isServer { c.readMaskPos = maskBytes(c.readMaskKey, c.readMaskPos, b[:n]) } rem := c.readRemaining rem -= int64(n) c.setReadRemaining(rem) if c.readRemaining > 0 && c.readErr == io.EOF { c.readErr = errUnexpectedEOF } return n, c.readErr }if c.readFinal { c.messageReader = nil return 0, io.EOF }frameType, err := c.advanceFrame() switch { case err != nil: c.readErr = hideTempErr(err) case frameType == TextMessage || frameType == BinaryMessage: c.readErr = errors.New("websocket: internal error, unexpected text or binary in Reader") } }err := c.readErr if err == io.EOF && c.messageReader == r { err = errUnexpectedEOF } return 0, err }

ioutil.ReadAll(r)会在遇到io.EOF时返回,nextReader中会放回messageReader该方法会在for 循环中一直读取,直至读取到最后一帧,返回io.EOF,若网络原因导致最后一帧由于网络原因迟迟没到服务器,该方法将一直阻塞,直至触发func (c *Conn) SetReadDeadline(t time.Time) error返回上层超时
if c.readFinal { c.messageReader = nil return 0, io.EOF }

写消息
若有数据比较大需要拆成多个帧,原理和读取消息类似,不在赘述。
w, err := c.conn.NextWriter(websocket.TextMessage) if err != nil { return } w.Write(message) c.conn.Close()

保持连接-心跳机制 WebSocket 为了确保客户端、服务端之间的 TCP 通道连接没有断开,使用心跳机制来判断连接状态。如果超时时间内没有收到应答则认为连接断开,关闭连接,释放资源。流程如下
  • 发送方 -> 接收方:ping
  • 接收方 -> 发送方:pong
ping、pong 的操作,对应的是 WebSocket 的两个控制帧,opcode分别是0x90xA
gorilla/websocket代码分析
func (c *Client) writePump() { ticker := time.NewTicker(pingPeriod) defer func() { ticker.Stop() c.conn.Close() }() for { select { case message, ok := <-c.send: //广播聊天信息,略.. case <-ticker.C: //定时发送心跳 c.conn.SetWriteDeadline(time.Now().Add(writeWait)) if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil { return } } } }

如果定时没有收到Pong应答可以主动关闭连接,释放资源。
当然在func (c *Conn) advanceFrame() (int, error)方法中收到Ping/Pong的控制帧,会自动调用注册在conn上的钩子函数
case PongMessage: if err := c.handlePong(string(payload)); err != nil { return noFrame, err } case PingMessage: if err := c.handlePing(string(payload)); err != nil { return noFrame, err }

写在最后 本文用介绍了websocket 协议,并通过gorilla/websocket 封装库的chat 示例展示了实战websocket。
不知道你注意没,以上那个chat 示例并不能用于生产环境,因为实际客户端有很多,可能会与多台服务器建立连接,那么需要进行如何改造?
  • 房间内客户端与房间的映射关闭如何维护?使用Redis 代替示例中的Hub
  • 消息如何保证顺序?每个房间一个分布式队列,并授予一个管理线程来处理?
留下你的思考,我们一起探讨
参考文档
  1. 廖雪峰网站:WebSocket
  2. WebSocket 协议深入探究
  3. 使用Go语言创建WebSocket服务
  4. how to build websockets in go
  5. RFC6455

    推荐阅读