目录
为什么需要websocket
使用场景
在线教育
视频弹幕
Web端即时通信方式
什么是web端即时通讯技术?
轮询
长轮询
长连接 SSE
websocket
通信方式总结
Websocket介绍
协议升级
连接确认
数据帧
socket和websocket
常见状态码
gorilla/websocket实战和底层代码分析
简单使用
Upgrader
Conn
服务端示例
客户端示例
源码走读
Upgrade 协议升级
ReadMessage 读消息
WriteMessage 写消息
advanceFrame 解析数据帧
heartbeat 心跳
总结
为什么需要websocket
初次接触 websocket 的人,可能都会有这样的疑问:我们已经有了 http 协议,为什么还需要websocket协议?它带来了什么好处?
原因是http每次请求只能由客户发起,而websocket最大特点就是,服务器可以主动向客户端推送信息,客户端也可以主动向服务器发送信
使用场景
在线教育
老师进行一对多的在线授课,在客户端内编写的笔记、大纲等信息,需要实时推送至多个学生的客户端,需要通过WebSocket协议来完成。
视频弹幕
终端用户A在自己的手机端发送了一条弹幕信息,但是您也需要在客户A的手机端上将其他N个客户端发送的弹幕信息一并展示。需要通过WebSocket协议将其他客户端发送的弹幕信息从服务端全部推送至客户A的手机端,从而使客户A可以同时看到自己发送的弹幕和其他用户发送的弹幕。
当然还有体育实况更新、视频会议和聊天等等,这里都不一一列举了
Web端即时通信方式
什么是web端即时通讯技术?
可以理解为实现这样一种功能:服务器端可以即时地将数据的更新或变化反应到客户端,例如消息推送等功能都是通过这种技术实现的。
但是在Web中,由于浏览器的限制,实现即时通讯需要借助一些方法。这种限制出现的主要原因是,一般的Web通信都是浏览器先发送请求到服务器,服务器再进行响应完成数据的现实更新。
Web端实现即时通讯主要有四种方式:
轮询、长轮询(comet)、长连接(SSE)、WebSocket。
它们大体可以分为两类,一种是在HTTP基础上实现的,包括短轮询、长轮询(comet)、长连接(SSE);另一种不是在HTTP基础上实现是,即WebSocket。下面分别介绍一下这四种轮询方式。
轮询
基本思路就是客户端每隔一段时间向服务器发送http请求,服务器端在收到请求后,不管是否有所需数据返回,都直接进行响应。
这种方式本质上还是客户端不断发送请求,才形成客户端能实时接收服务端数数据变化的假象。
实现比较简单,缺点是需要不断建立http连接,浪费资源,而且在客户端数量级很大的情况下会导致服务器压力陡增,显然不是好选择!
长轮询
长轮询方式是服务器收到客户端发来的请求后,想挂起请求,服务器端不会直接进行响应,在超时时间内(比如20S),接收请求和处理请求进行响应。
有两种情况长轮询会响应:
- 达到http请求超时时间
- 服务器正常处理请求返回响应结果
长轮询和短轮询比起来,明显减少了很多不必要的http请求次数,但是连接挂起也会导致资源的浪费!
长连接 SSE
长连接是指在一个连接上可以连续发送多个数据包,在连接保持期间,如果没有数据包发送,需要双方发链路检测包。
SSE是HTML5新增的功能,全称为Server-Sent Events,它可以允许服务器推送数据到客户端。
SSE在本质上就与之前的长轮询、轮询不同,虽然都是基于http协议的,但是轮询需要客户端先发送请求,服务端才能响应。而SSE最大的特点就是不需要持续客户端发送请求,可以实现只要服务器端数据有更新,就可以马上发送到客户端。
长链接流程:连接->传输数据->保持连接 -> 传输数据-> ....->直到一方关闭连接,客户端关闭连接
SSE的优势在于,它不需要建立或保持大量的客户端发往服务器端的请求,节约了很多资源,提升应用性能,但是可以关闭一些长时间不读写操作的连接,这样可以避免一些恶意连接导致server端压力。
websocket
WebSocket协议是基于TCP的一种新的网络协议,它实现了客户端与服务器全双工(full-duplex)通信(同一时间里,双方都可以主动向对方发送数据)。
在WebSocket中,客户端和服务器只需要完成一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输。
通信方式总结
✏️兼容性角度:短轮询>长轮询>长连接SSE>WebSocket
✏️性能方面:WebSocket>长连接SSE>长轮询>短轮询
Websocket介绍
我们已经知道了WebSocket 是一种网络传输协议,可在单个 TCP 连接上进行全双工通信,位于 OSI 模型的应用层。
而通过WebSocket使得客户端和服务器之间的数据交换变得更加简单,允许服务端主动向客户端推送数据,只需要完成一次握手,两者之间就直接可以创建持久性的连接。
协议升级
出于兼容性的考虑,websocket 的握手使用 HTTP 来实现,客户端的握手消息就是一个「普通的,带有 Upgrade 头的,HTTP Request 消息」。
📢 想建立websoket连接,就需要在http请求上带一些特殊的header头才行!
我们看下WebSocket协议客户端请求和服务端响应示例,关于http这里就不多介绍了(这里自行回想下Http请求的request和reposone部分)
header头的意思是,浏览器想升级http协议,并且想升级成websocket协议
客户端请求:
//以下是WebSocket请求头中的一些字段:Upgrade: websocket // 1
Connection: Upgrade // 2
Sec-WebSocket-Key: xx== // 3
Origin: http: // 4
Sec-WebSocket-Protocol: chat, superchat // 5
Sec-WebSocket-Version: 13 // 6
上述字段说明如下:
- Upgrade:字段必须设置 websocket,表示希望升级到 WebSocket 协议
- Connection:须设置 Upgrade,表示客户端希望连接升级
- Sec-WebSocket-Key:是随机的字符串,服务器端会用这些数据来构造出一个 SHA-1 的信息摘要
- Origin:字段是可选的,只包含了协议和主机名称
- Sec-WebSocket-Extensions:用于协商本次连接要使用的 WebSocket 扩展
- Sec-WebSocket-Version:表示支持的 WebSocket 版本,RFC6455 要求使用的版本是 13
服务端响应:
HTTP/1.1 101 Web Socket Protocol Handshake // 1
Connection: Upgrade // 2
Upgrade: websocket // 3
Sec-WebSocket-Accept: 2mQFj9iUA/Nz8E6OA4c2/MboVUk= //4
上述字段说明如下:
- 101 响应码确认升级到 WebSocket 协议
- Connection:值为 “Upgrade” 来指示这是一个升级请求
- Upgrade:表示升级为 WebSocket 协议
- Sec-WebSocket-Accept:签名的键值验证协议支持
🚩 1:ws 协议默认使用 80 端口,wss 协议默认使用 443 端口,和 http 一样
🚩 2:WebSocket 没有使用 TCP 的“IP 地址 + 端口号”,开头的协议名不是“http”,引入的是两个新的名字:“ws”和“wss”,分别表示明文和加密的 WebSocket 协议
连接确认
发建立连接是前提,但是只有当请求头参数Sec-WebSocket-Key字段的值经过固定算法加密后的数据和响应头里的Sec-WebSocket-Accept的值保持一致,该连接才会被认可建立。
如下图从浏览器截图的两个关键参数:
服务端返回的响应头字段 Sec-WebSocket-Accept 是根据客户端请求 Header 中的Sec-WebSocket-Key计算出来。那么时如何进行参数加密验证和比对确认的呢,如下图。
具体流程如下:
- 客户端握手中的 Sec-WebSocket-Key 头字段的值是16字节随机数,并经过base64编码
- 服务端需将该值和固定的 GUID 字符串( 258EAFA5-E914-47DA-95CA-C5AB0DC85B11)拼接后使用 SHA-1 进行哈希,并采用 base64 编码后
- 服务端将编码后的值作为响应作为的Sec-WebSocket-Accept 值返回。
- 客户端也必须按照服务端生成 Sec-WebSocket-Accept 的方式一样生成字符串,与服务端回传的进行对比
- 相同就是协议升级成功,不同就是失败
在协议升级完成后websokcet就建立完成了,接下来就是客户端和服务端使用websocket进行数据传输通信了!
数据帧
一旦升级成功 WebSocket 连接建立后,后续数据都以帧序列的形式传输
📄协议规定了数据帧的格式,服务端要想给客户端推送数据,必须将要推送的数据组装成一个数据帧,这样客户端才能接收到正确的数据;同样,服务端接收到客户端发送的数据时,必须按照帧的格式来解包,才能真确获取客户端发来的数据
我们来看下对帧的格式定义吧!
看看数据帧字段代表的含义吧:
- FIN 1个bit位,用来标记当前数据帧是不是最后一个数据帧
- RSV1, RSV2, RSV3 这三个,各占用一个bit位用做扩展用途,没有这个需求的话设置位0
- Opcode 的值定义的是数据帧的数据类型。值为1 表示当前数据帧内容是文本;值为2 表示当前数据帧内容是二进制;值为8表示请求关闭连接
- MASK 表示数据有没有使用掩码
服务端发送给客户端的数据帧不能使用掩码,客户端发送给服务端的数据帧必须使用掩码
- Payload len 数据的长度,Payload data的长度,占7bits,7+16bits,7+64bits
- Masking-key 数据掩码 (设置位0,则该部分可以省略,如果设置位1,则用来解码客户端发送给服务端的数据帧)
- Payload data 帧真正要发送的数据,可以是任意长度
上面我们说到Payload len三种长度(最开始的7bit的值)来标记数据长度,这里具体看下是哪三种:
🚩 情况1:值设置在0-125
那么这个有效载荷长度(Payload len)就是对应的数据的值
🚩 情况2:值设置为126
如果设置为 126,可表示payload的长度范围在 126~65535 之间,那么接下来的 2 个字节(扩展用16bit Payload长度)会包含Payload真实数据长度
🚩 情况3:值设置为127
可表示payload的长度范围在 >=65535 ,那么接下来的 8 个字节(扩展用16bit + 32bit + 16bit Payload长度)会包含Payload真实数据长度,这种情况能表示的数据就很大了,完全够用
socket和websocket
这两者名字上差距不大,虽然都有带个socket,但是完全是两个不同的东西, 大家千万别被名字给带的傻傻分不清楚了!
我们来看下之间的区别
socket:是在应用层和传输层之间的一个中间软件抽象层,是一组接口,它把TCP/IP层复杂的操作抽象为几个简单的接口供应用层调用以实现进程在网络中通信。
websocket:是基于TCP的一种新的网络协议,和http协议一样属于应用层协议。
下图中分别表示了socket和websocket在网络中的位置
常见状态码
下面显示了从服务器到客户端的通信的 WebSocket 状态码和错误提示,WebSocket 状态码遵循 RFC 正常关闭连接标准
- 1000 CLOSE_NORMAL 连接正常关闭
- 1001 CLOSE_GOING_AWAY 终端离开 例如:服务器错误,或者浏览器已经离开此页面
- 1002 CLOSE_PROTOCOL_ERROR 因为协议错误而中断连接
- 1003 CLOSE_UNSUPPORTED 端点因为受到不能接受的数据类型而中断连接
- 1004 保留
- 1005 CLOSE_NO_STATUS 保留, 用于提示应用未收到连接关闭的状态码
- 1006 CLOSE_ABNORMAL 期望收到状态码时连接非正常关闭 (也就是说, 没有发送关闭帧)
- 1007 Unsupported Data 收到的数据帧类型不一致而导致连接关闭
- 1008 Policy Violation 收到不符合约定的数据而断开连接
- 1009 CLOSE_TOO_LARGE 收到的消息数据太大而关闭连接
- 1010 Missing Extension 客户端因为服务器未协商扩展而关闭
- 1011 Internal Error 服务器因为遭遇异常而关闭连接
- 1012 Service Restart 服务器由于重启而断开连接
- 1013 Try Again Later 服务器由于临时原因断开连接, 如服务器过载因此断开一部分客户端连接
- 1015 TLS握手失败关闭连接
gorilla/websocket实战和底层代码分析
相信很多使用Golang的小伙伴都知道Gorilla这个工具包,长久以来gorilla/websocket 都是比官方包更好的websocket包。
gorilla/websocket 框架开源地址为: https://github.com/gorilla/websocket
简单使用
安装Gorilla Websocket Go软件包,只需要使用即可go get
go get github.com/gorilla/websocket
在正式使用之前我们先简单了解下两个数据结构 Upgrader 和 Conn
Upgrader
Upgrader指定用于将 HTTP 连接升级到 WebSocket 连接
type Upgrader struct {HandshakeTimeout time.DurationReadBufferSize, WriteBufferSize intWriteBufferPool BufferPoolSubprotocols []stringError func(w http.ResponseWriter, r *http.Request, status int, reason error)CheckOrigin func(r *http.Request) boolEnableCompression bool
}
- HandshakeTimeout: 握手完成的持续时间
- ReadBufferSize和WriteBufferSize:以字节为单位指定I/O缓冲区大小。如果缓冲区大小为零,则使用HTTP服务器分配的缓冲区
- CheckOrigin : 函数应仔细验证请求来源 防止跨站点请求伪造
这里一般会设置下CheckOrigin来解决跨域问题
Conn
Conn类型表示WebSocket连接,这个结构体的组成包括两部分,写入字段(Write fields)和 读取字段(Read fields)
type Conn struct {conn net.ConnisServer bool...// Write fieldswriteBuf []byte writePool BufferPoolwriteBufSize intwriter io.WriteCloser isWriting bool ...// Read fieldsreadRemaining int64readFinal bool readLength int64 messageReader *messageReader ...
}
- isServer : 字段来区分我们是否用Conn作为客户端还是服务端,也就是说说gorilla/websocket中同时编写客户端程序和服务器程序,但是一般是Web应用程序使用单独的前端作为客户端程序。
部分字段说明如下图:
服务端示例
出于说明的目的,我们将在Go中同时编写客户端程序和服务端程序(其实因为本人不会前端)。
当然我们在开发程序的时候基本都是单独的前端,通常使用(Javascript,vue等)实现websocket客户端,这里为了让大家有比较直观的感受,用【gorilla/websocket】分别写了服务端和客户端示例。
var upGrader = websocket.Upgrader{CheckOrigin: func(r *http.Request) bool {return true},
}func main() {http.HandleFunc("/ws", wsUpGrader)err := http.ListenAndServe("localhost:8080", nil)if err != nil {log.Println("server start err", err)}
}func wsUpGrader(w http.ResponseWriter, r *http.Request) {//转换为升级为websocketconn, err := upGrader.Upgrade(w, r, nil)if err != nil {log.Println(err)return}//释放连接defer conn.Close()for {//接收消息messageType, message, err := conn.ReadMessage()if err != nil {log.Println(err)return}log.Println("server receive messageType", messageType, "message", string(message))//发送消息err = conn.WriteMessage(messageType, []byte("pong"))if err != nil {log.Println(err)return}}
}
我们知道websocket协议是基于http协议进行upgrade升级的, 这里使用 net/http提供原始的http连接。
http.HandleFunc接受两个参数:第一个参数是字符串表示的 url 路径,第二个参数是该 url 实际的处理对象
http.ListenAndServe 监听在某个端口,启动服务,准备接受客户端的请求
HandleFunc的作用:通过类型转换让我们可以将普通的函数作为HTTP处理器使用
服务端代码流程:
- Gorilla在使用websocket之前是先将http装为websocket,用的是初始化的upGrader结构体变量调用Upgrade方法进行请求协议升级
- 升级后返回 *Conn(此时isServer = true),后续使用它来处理websocket连接
- 服务端消息读写分别用 ReadMessage()、WriteMessage()
客户端示例
import ("fmt""github.com/gorilla/websocket""log""time"
)func main() {//服务器地址 websocket 统一使用 ws://url := "ws://localhost:8080/ws" //使用默认拨号器,向服务器发送连接请求ws, _, err := websocket.DefaultDialer.Dial(url, nil)if err != nil {log.Fatal(err)}//关闭连接defer ws.Close()//发送消息go func() {for {err := ws.WriteMessage(websocket.BinaryMessage, []byte("ping"))if err != nil {log.Fatal(err)}//休眠两秒time.Sleep(time.Second * 2)}}()//接收消息for {_, data, err := ws.ReadMessage()if err != nil {log.Fatal(err)}fmt.Println("client receive message: ", string(data))}
}
客户端的实现看起来也是简单,先使用默认拨号器,向服务器地址发送连接请求,拨号成功时也返回一个*Conn,开启一个协程每隔两秒向服务端发送消息,同样都是使用ReadMessage和W riteMessage读写消息。
示例代码运行结果如下:
源码走读
看完上面基本的客户端和服务端案例之后,我们对整个消息发送和接收的使用已经熟悉了,实际开发中要做的就是如何结合业务去定义消息类型和发送场景了,我们接着走读下底层的实现逻辑!
Upgrade 协议升级
Upgrade顾名思义【升级】,在进行协议升级之前是需要对协议进行校验的,之前我们知道待升级的http请求是有固定请求头的,这里列举几个:
✏️ Upgrade进行校验的目的是看该请求是否符合协议升级的规定
Upgrade的部分校验代码如下,return处进行了省略
func (u *Upgrader) Upgrade(w http.ResponseWriter, r *http.Request, responseHeader http.Header) (*Conn, error) {if !tokenListContainsValue(r.Header, "Connection", "upgrade") {return ...}if !tokenListContainsValue(r.Header, "Upgrade", "websocket") {return ...}//必须是get请求方法if r.Method != http.MethodGet {return ...}if !tokenListContainsValue(r.Header, "Sec-Websocket-Version", "13") {return ...}if _, ok := responseHeader["Sec-Websocket-Extensions"]; ok {return ...}...c := newConn(netConn, true, u.ReadBufferSize, u.WriteBufferSize, u.WriteBufferPool, br, writeBuf)...
}
tokenListContainsValue的目的是校验请求的Header中是否有upgrade需要的特定参数,比如我们上图列举的一些。
newConn就是初始化部分Conn结构体的,方法中的第二个参数为true代表这是服务端
computeAcceptKey 计算接受密钥:
这个函数重点说下,在上一期中在websocket【连接确认】这一章节中知道,websocket协议升级时,需要满足如下条件:
✏️只有当请求头参数Sec-WebSocket-Key字段的值经过固定算法加密后的数据和响应头里的Sec-WebSocket-Accept的值保持一致,该连接才会被认可建立。
var keyGUID = []byte("258EAFA5-E914-47DA-95CA-C5AB0DC85B11")func computeAcceptKey(challengeKey string) string {h := sha1.New() h.Write([]byte(challengeKey))h.Write(keyGUID)return base64.StdEncoding.EncodeToString(h.Sum(nil))
}
上面 computeAcceptKey 函数的实现,验证了之前说的关于 Sec-WebSocket-Accept的生成
服务端需将Sec-WebSocket-Key和固定的 GUID 字符串( 258EAFA5-E914-47DA-95CA-C5AB0DC85B11)拼接后使用 SHA-1 进行哈希,并采用 base64 编码后返回
ReadMessage 读消息
ReadMessage方法内部使用NextReader获取读取器并从该读取器读取到缓冲区,如果是一条消息由多个数据帧,则会拼接成完整的消息,返回给业务层。
func (c *Conn) ReadMessage() (messageType int, p []byte, err error) {var r io.ReadermessageType, r, err = c.NextReader()if err != nil {return messageType, nil, err}//ReadAll从r读取,直到出现错误或EOF,并返回读取的数据p, err = io.ReadAll(r)return messageType, p, err
}
该方法,返回三个参数,分别是消息类型、内容、error
messageType是int型,值可能是 BinaryMessage(二进制消息) 或 TextMessage(文本消息)
NextReader:该方法得到一个消息类型 messageType,io.Reader,err
func (c *Conn) NextReader() (messageType int, r io.Reader, err error) {...for c.readErr == nil {//解析数据帧方法advanceFrame// frameType : 帧类型frameType, err := c.advanceFrame()if err != nil {c.readErr = hideTempErr(err)break}//数据类型是 文本或二进制类型if frameType == TextMessage || frameType == BinaryMessage {c.messageReader = &messageReader{c}c.reader = c.messageReaderif c.readDecompress {c.reader = c.newDecompressionReader(c.reader)}return frameType, c.reader, nil}}...
}
c.advanceFrame() 是核心代码,主要是实现解析这条消息,这里在最后章节会讲。
这里有个 c.messageReader (当前的低级读取器),赋值给c.reader,为什么要这样呢?
c.messageReader 是更低级读取器,而 c.reader 的作用是当前读取器返回到应用程序。简单就是messageReader 是实现了 c.reader 接口的结构体, 从而也实现了 io.Reader接口
图上加一个 bufio.Read方法:Read读取数据写入p。本方法返回写入p的字节数。本方法一次调用最多会调用下层Reader接口一次Read方法,因此返回值n可能小于len(p)。读取到达结尾时,返回值n将为0而err将为io.EOF
messageReader的 Read方法: 我们看下Read的具体实现,Read方法主要是读取数据帧内容,直到出现并返回io.EOF或者其他错误为止,而实际调用它的正是 io.ReadAll。
func (r *messageReader) Read(b []byte) (int, error) {...for c.readErr == nil {//当前帧中剩余的字节if c.readRemaining > 0 {if int64(len(b)) > c.readRemaining {b = b[:c.readRemaining]}//读取到切片b中n, err := c.br.Read(b)c.readErr = hideTempErr(err)//当Conn是服务端if c.isServer {c.readMaskPos = maskBytes(c.readMaskKey, c.readMaskPos, b[:n])}//readRemaining字节数转int64rem := c.readRemainingrem -= int64(n)//跟踪连接上剩余的字节数if err := c.setReadRemaining(rem); err != nil {return 0, err}if c.readRemaining > 0 && c.readErr == io.EOF {c.readErr = errUnexpectedEOF}//返回读后字节数return n, c.readErr}//标记是否最后一个数据帧if c.readFinal {// messageRader 置为nilc.messageReader = nilreturn 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.readErrif err == io.EOF && c.messageReader == r {err = errUnexpectedEOF}return 0, err
}
io.ReadAll :ReadAll从r读取,这里是实现如果一条消息由多个数据帧,会一直读直到最后一帧的关键。
func ReadAll(r Reader) ([]byte, error) {b := make([]byte, 0, 512)for {if len(b) == cap(b) {// 给[]byte添加更多容量b = append(b, 0)[:len(b)]}n, err := r.Read(b[len(b):cap(b)])b = b[:len(b)+n]if err != nil {if err == EOF {err = nil}return b, err}}
}
可以看出在for 循环中一直读取,直至读取到最后一帧,直到返回io.EOF或网络原因错误为止,否则一直进行阻塞读,这些 error 可以从上面讲到的messageReader的 Read方法可以看出来。
总结下,整个流程如下:
WriteMessage 写消息
既然读消息是对数据帧进行解析,那么写消息就自然会联想到将数据按照数据帧的规范组装写入到一个writebuf中,然后写入到网络中。
我们继续看WriteMessage是如何实现的
func (c *Conn) WriteMessage(messageType int, data []byte) error {...//w 是一个io.WriteCloserw, err := c.NextWriter(messageType)if err != nil {return err}//将data写入writeBuf中if _, err = w.Write(data); err != nil {return err}return w.Close()
}
WriteMessage方法接收一个消息类型和数据,主要逻辑是先调用Conn的NextWriter方法得到一个io.WriteCloser,然后写消息到这个Conn的writeBuf,写完消息后close它。
NextWriter实现如下:
func (c *Conn) NextWriter(messageType int) (io.WriteCloser, error) {var mw messageWriterif err := c.beginMessage(&mw, messageType); err != nil {return nil, err}c.writer = &mw...return c.writer, nil
}
注意看这里有个messageWriter赋值给了Conn的writer,也就是说messageWriter实现了io.WriterCloser接口。
这里的实现跟读消息中的NextReader方法中的messageReader很像,也是通过实现io.Reader接口,然后赋值给了Conn的Reader,这里可以做个小联动,找到读写消息实际的实现者 messageReader、messageWriter。
messageWriter的Write实现:
前置知识:如果没有设置Conn中writeBufferSize, 默认情况下会设置为 4096个字节,另外加上14字节的数据帧头部大小【这些在newConn中初始化的时候有代码说明】
func (w *messageWriter) Write(p []byte) (int, error) {...//如果字节长度大于初始化的writeBuf空间大小if len(p) > 2*len(w.c.writeBuf) && w.c.isServer {//写入方法err := w.flushFrame(false, p)...}//字节长度不大于初始化的writeBuf空间大小nn := len(p)for len(p) > 0 {//内部也是调用的flushFramen, err := w.ncopy(len(p))...}return nn, nil
}
messageWriter中的Write方法主要的目的是将数据写入到writeBuf中,它主要存储结构化的数据帧内容,所谓结构化就是按照数据帧的格式,用Go实现写入的。
总结下,整个流程如下:
而flushFrame方法将缓冲数据和额外数据作为帧写入网络,这个final参数表示这是消息中的最后一帧。
至于flushFrame内部是如何实现写入网络中的,你可以看看 net.Conn 是怎么Write的,因为最终就是调这个写入网络的,这里就不再深究了,有兴趣的可以自己挖一挖!
advanceFrame 解析数据帧
解析数据帧放在最后,前面的代码走读主要是为了方便能把整体流程搞清楚,而数据帧的解析,是更加需要对websocket基础有了解,特别是数据帧的组成,因为解析就是按照协定用Go代码实现的一种方式而已。
根据上图回顾下数据帧各部分代表的意思:
FIN :1个bit位,用来标记当前数据帧是不是最后一个数据帧
RSV1, RSV2, RSV3 :这三个各占用一个bit位用做扩展用途,没有这个需求的话设置位0 Opcode :该值定义的是数据帧的数据类型 1 表示文本 2 表示二进制
MASK: 表示数据有没有使用掩码
Payload length :数据的长度,Payload data的长度,占7bits,7+16bits,7+64bits Masking-key :数据掩码 (设置位0,则该部分可以省略,如果设置位1,则用来解码客户端发送给服务端的数据帧)
Payload data : 帧真正要发送的数据,可以是任意长度
advanceFrame 解析方法
实现代码会比较长,如果直接贴代码,会看不下去,该方法返回数据类型和error, 这里我们只会截取其中一部分
func (c *Conn) advanceFrame() (int, error) {...//读取前两个字节p, err := c.read(2)if err != nil {return noFrame, err}//数据帧类型frameType := int(p[0] & 0xf)// FIN 标记位final := p[0]&finalBit != 0//三个扩展用rsv1 := p[0]&rsv1Bit != 0rsv2 := p[0]&rsv2Bit != 0rsv3 := p[0]&rsv3Bit != 0//mask :是否使用掩码mask := p[1]&maskBit != 0...switch c.readRemaining {case 126:p, err := c.read(2)if err != nil {return noFrame, err}if err := c.setReadRemaining(int64(binary.BigEndian.Uint16(p))); err != nil {return noFrame, err}case 127:p, err := c.read(8)if err != nil {return noFrame, err}if err := c.setReadRemaining(int64(binary.BigEndian.Uint64(p))); err != nil {return noFrame, err}}..
}
整个流程分为了 7 个部分:
- 跳过前一帧的剩余部分,毕竟这是之前帧的数据
- 读取并解析帧头的前两个字节(从上面图中可以看出只读取到 Payload len)
- 根据读取和解析帧长度(根据 Payload length的值来获取Payload data的长度)
- 处理数据帧的mask掩码
- 如果是文本和二进制消息,强制执行读取限制并返回 (结束)
- 读取控制帧有效载荷 即 play data,设置setReadRemaining以安全地更新此值并防止溢出
- 过程控制帧有效载荷,如果是ping/pong/close消息类型,返回 -1 (noFrame) (结束)
advanceFrame方法的主要目的就是解析数据帧,获取数据帧的消息类型,而对于数据帧的解析都是按照上图帧格式来的!
heartbeat 心跳
WebSocket 为了确保客户端、服务端之间的 TCP 通道连接没有断开,使用心跳机制来判断连接状态。如果超时时间内没有收到应答则认为连接断开,关闭连接,释放资源。流程如下
- 发送方 -> 接收方:ping
- 接收方 -> 发送方:pong
ping、pong 消息:它们对应的是 WebSocket 的两个控制帧,opcode分别是0x9、0xA,对应的消息类型分别是PingMessage, PongMessage,前提是应用程序需要先读取连接中的消息才能处理从对等方发送的 close、ping 和 pong 消息。
总结
本文主要了解 什么是Websocket以及gorilla/websocket 框架的使用和部分底层实现原理代码走读。
不过流行的开源 Go 语言 Web 工具包 Gorilla 宣布已正式归档,目前已进入只读模式。“它发出的信号是,这些库在未来将不会有任何发展。也就是说 gorilla/websocket 这个被广泛使用的 websocket 库也会停止更新了,真是个令人悲伤的消息!
正如作者所说的那样:“没有一个项目需要永远存在。这可能不会让每个人都开心,但生活就是这样。”