【Go】-Websocket的使用

news2024/10/2 22:25:20

目录

为什么需要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

上述字段说明如下:

  1. Upgrade:字段必须设置 websocket,表示希望升级到 WebSocket 协议
  2. Connection:须设置 Upgrade,表示客户端希望连接升级
  3. Sec-WebSocket-Key:是随机的字符串,服务器端会用这些数据来构造出一个 SHA-1 的信息摘要
  4. Origin:字段是可选的,只包含了协议和主机名称
  5. Sec-WebSocket-Extensions:用于协商本次连接要使用的 WebSocket 扩展
  6. 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

上述字段说明如下:

  1. 101 响应码确认升级到 WebSocket 协议
  2. Connection:值为 “Upgrade” 来指示这是一个升级请求
  3. Upgrade:表示升级为 WebSocket 协议
  4. 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 连接建立后,后续数据都以帧序列的形式传输

📄协议规定了数据帧的格式,服务端要想给客户端推送数据,必须将要推送的数据组装成一个数据帧,这样客户端才能接收到正确的数据;同样,服务端接收到客户端发送的数据时,必须按照帧的格式来解包,才能真确获取客户端发来的数据

        我们来看下对帧的格式定义吧!

看看数据帧字段代表的含义吧:

  1. FIN 1个bit位,用来标记当前数据帧是不是最后一个数据帧
  2. RSV1, RSV2, RSV3 这三个,各占用一个bit位用做扩展用途,没有这个需求的话设置位0
  3. Opcode 的值定义的是数据帧的数据类型。值为1 表示当前数据帧内容是文本;值为2 表示当前数据帧内容是二进制;值为8表示请求关闭连接
  4. MASK 表示数据有没有使用掩码

服务端发送给客户端的数据帧不能使用掩码,客户端发送给服务端的数据帧必须使用掩码

  1. Payload len 数据的长度,Payload data的长度,占7bits,7+16bits,7+64bits
  2. Masking-key 数据掩码 (设置位0,则该部分可以省略,如果设置位1,则用来解码客户端发送给服务端的数据帧)
  3. 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.Duration
    
	ReadBufferSize, WriteBufferSize int

	WriteBufferPool BufferPool

	Subprotocols []string

	Error func(w http.ResponseWriter, r *http.Request, status int, reason error)

	CheckOrigin func(r *http.Request) bool

	EnableCompression bool
}
  • HandshakeTimeout: 握手完成的持续时间
  • ReadBufferSize和WriteBufferSize:以字节为单位指定I/O缓冲区大小。如果缓冲区大小为零,则使用HTTP服务器分配的缓冲区
  • CheckOrigin : 函数应仔细验证请求来源 防止跨站点请求伪造

这里一般会设置下CheckOrigin来解决跨域问题

Conn

        Conn类型表示WebSocket连接,这个结构体的组成包括两部分,写入字段(Write fields)和 读取字段(Read fields)

type Conn struct {
	conn        net.Conn
	isServer    bool
    ...

	// Write fields
	writeBuf      []byte        
	writePool     BufferPool
	writeBufSize  int
	writer        io.WriteCloser 
	isWriting     bool           
	...
	// Read fields
	readRemaining int64
	readFinal     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) {
    //转换为升级为websocket
	conn, 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.Reader
	messageType, 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.messageReader
			if 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字节数转int64
			rem := c.readRemaining
			rem -= 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 置为nil
			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
}

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.WriteCloser
	w, 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 messageWriter
	if 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 {
        //内部也是调用的flushFrame
		n, 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 != 0
	rsv2 := p[0]&rsv2Bit != 0
	rsv3 := 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 个部分:

  1. 跳过前一帧的剩余部分,毕竟这是之前帧的数据
  2. 读取并解析帧头的前两个字节(从上面图中可以看出只读取到 Payload len)
  3. 根据读取和解析帧长度(根据 Payload length的值来获取Payload data的长度)
  4. 处理数据帧的mask掩码
  5. 如果是文本和二进制消息,强制执行读取限制并返回 (结束)
  6. 读取控制帧有效载荷 即 play data,设置setReadRemaining以安全地更新此值并防止溢出
  7. 过程控制帧有效载荷,如果是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 库也会停止更新了,真是个令人悲伤的消息!

        正如作者所说的那样:“没有一个项目需要永远存在。这可能不会让每个人都开心,但生活就是这样。”

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2165911.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

10-pg内核之锁管理器(五)行锁

概念 数据库采用MVCC方式进行并发控制,读写并不会互相阻塞,但是写之间仍然存在冲突。如果还是采用常规锁那样加锁,则会耗费大量共享内存,进而影响性能。所以行锁通过元组级常规锁和xmax结合的方式实现。一般先通过xmax进行可见性…

Unity 新导航寻路演示(2)

对于静态场景来说,只需要3步 1.为场景Ground添加网格表面组件并烘焙 2.为player添加导航代理 using System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.AI;public class PlayerMove : MonoBehaviour {private NavMes…

2D动画转3D角色!无需建模- comfyUI工作流一键生成3d效果图!

如何将2d角色转化成3d角色? 不需要建模,通过一个2d转3d的工作流可以直接将你的2d图片转化成3d效果图。 而且操作特别简单,只需要3个步骤,这篇内容我们来说下这个工作路的使用 工作流特点 任意2D图片转换成3D风格 基于sd1.5模型…

ftdi_sio驱动学习笔记 3 - 端口操作

目录 1. ftdi_port_probe 1.1 私有数据结构ftdi_private 1.2 特殊probe处理 1.3 确定FTDI设备类型 1.4 确定最大数据包大小 1.5 设置读取延迟时间 1.6 初始化GPIO 1.6.1 使能GPIO 1.6.2 添加到系统 1.6.2.1 设置GPIO控制器的基本信息 1.6.2.2 设置GPIO控制器的元信息…

Apache Iceberg 与 Spark整合-使用教程(Iceberg 官方文档解析)

官方文档链接(Spark整合Iceberg) 1.Getting Started Spark 目前是进行 Iceberg 操作最丰富的计算引擎。官方建议从 Spark 开始,以理解 Iceberg 的概念和功能。 The latest version of Iceberg is 1.6.1.(2024年9月24日11:45:55&…

如何在云端使用 Browserless 进行网页抓取?

云浏览器是什么? 云浏览器是一种基于云的组合,它将网页浏览器应用程序与一个虚拟化的容器相结合,实现了远程浏览器隔离的概念。开发人员可以使用流行的工具(如 Playwright 和​ Puppeteer​)来自动化网页浏览器&#…

repo 查看指定日期内,哪些仓库有修改,具体的修改详情

文章目录 想看指定时间段内仓库中修改了哪些具体的文件,是谁修改的,commit的备注信息等详情只想看某段时间内有更改的仓库的修改详情,其他没有修改的仓库不显示。 想看指定时间段内仓库中修改了哪些具体的文件,是谁修改的&#xf…

VSCode#include头文件时找不到头文件:我的解决方法

0.前言 1.在学习了Linux之后,我平常大部分都使用本地的XShell或者VSCode连接远程云服务器写代码,CentOS的包管理器为我省去了不少繁琐的事情,今天使用vscode打开本地目录想写点代码发现#include头文件后,下方出现了波浪线&#…

SparkSQL-初识

一、概览 Spark SQL and DataFrames - Spark 3.5.2 Documentation 我们先看下官网的描述: SparkSQL是用于结构化数据处理的Spark模块,与基本的Spark RDD API不同。Spark SQL提供的接口为Spark提供了更多关于正在执行的数据和计算结构的信息。在内部&a…

C++中vector类的使用

目录 1.vector类常用接口说明 1.1默认成员函数 1.1.1构造函数(constructor) 1.1.2 赋值运算符重载(operator()) 2. vector对象的访问及遍历操作(Iterators and Element access) 3.vector类对象的容量操作(Capacity) 4. vector类对象的修改及相关操作(Modifiers and Stri…

【Java数据结构】 ---对象的比较

乐观学习,乐观生活,才能不断前进啊!!! 我的主页:optimistic_chen 我的专栏:c语言 ,Java 欢迎大家访问~ 创作不易,大佬们点赞鼓励下吧~ 前言 上图中,线性表、堆…

[Redis][主从复制][上]详细讲解

目录 0.前言1.配置1.建立复制2.断开复制3.安全性4.只读5.传输延迟 2.拓扑1.一主一从结构2.一主多从结构2.树形主从结构 0.前言 说明:该章节相关操作不需要记忆,理解流程和原理即可,用的时候能自主查到即可主从复制? 分布式系统中…

PyTorch自定义学习率调度器实现指南

在深度学习训练过程中,学习率调度器扮演着至关重要的角色。这主要是因为在训练的不同阶段,模型的学习动态会发生显著变化。 在训练初期,损失函数通常呈现剧烈波动,梯度值较大且不稳定。此阶段的主要目标是在优化空间中快速接近某…

ResNet残差网络:深度学习的里程碑

引言 在深度学习领域,卷积神经网络(CNN)的发展一直推动着图像识别、目标检测等任务的进步。然而,随着网络层数的增加,传统的CNN面临着梯度消失和梯度爆炸等难题,限制了深层网络的训练效果。为了克服这些挑…

oracle direct path read处理过程

文章目录 缘起处理过程1.AWR Report 分析2.调查direct path read发生的table3.获取sql text4.解释sql并输出执行计划: 结论:补充direct path read等待事件说明 缘起 记录direct path read处理过程 处理过程 1.AWR Report 分析 问题发生时间段awr如下…

FortiGate OSPF动态路由协议配置

1.目的 本文档针对 FortiGate 的 OSPF 动态路由协议说明。OSPF 路由协议是一种 典型的链路状态(Link-state)的路由协议,一般用于同一个路由域内。在这里,路由 域是指一个自治系统,即 AS,它是指一组通过统一的路由政策或路由协议互相交 换路由信息的网络。在这个 AS 中,所有的 …

基于JSP+Servlet+Layui实现的博客系统

> 这是一个使用 Java 和 JSP 开发的博客系统,并使用 Layui 作为前端框架。 > 它包含多种功能,比如文章发布、评论管理、用户管理等。 > 它非常适合作为 Java 初学者的练习项目。 一、项目演示 - 博客首页 - 加载动画 - 右侧搜索框可以输入…

开源服务器管理软件Nexterm

什么是 Nexterm ? Nexterm 是一款用于 SSH、VNC 和 RDP 的开源服务器管理软件。 安装 在群晖上以 Docker 方式安装。 在注册表中搜索 nexterm ,选择第一个 germannewsmaker/nexterm,版本选择 latest。 本文写作时, latest 版本对…

【STM32】RTT-Studio中HAL库开发教程七:IIC通信--EEPROM存储器FM24C04

文章目录 一、简介二、模拟IIC时序三、读写流程四、完整代码五、测试验证 一、简介 FM24C04D,4K串行EEPROM:内部32页,每个16字节,4K需要一个11位的数据字地址进行随机字寻址。FM24C04D提供4096位串行电可擦除和可编程只读存储器&a…

Excel 设置自动换行

背景 版本:office 专业版 11.0 表格内输入长信息,发现默认状态时未自动换行的,找了很久设置按钮,遂总结成经验帖。 操作 1)选中需设置的单元格/区域/行/列。 2)点击【开始】下【对齐方式】中的【自动换…