websocket与hijack

是什么

WebSocket从字面意思来看,就是两个单词的拼接,分别是Web和Socket。学过网络协议的同学都知道传输层协议分别有TCP和UDP协议,都是双工协议,Socket就是操作系统对传输层协议的抽象实现,不过Socket除了支持网络通信,也支持像unix socket这样的基于本地文件系统的通信。而Web的释义也是字面意思,就是基于http协议的通信系统,所以顾名思义,websocket就是web技术和socket技术的结合,也就是双工协议的http,工作的网络协议层在应用层。

在http1.1及之前,都是一个经典的拉模式,也叫做ping pang模式,客户端请求和服务端响应,服务端不能主动推送通信到客户端,只能客户端来请求服务端,从服务端拉取数据。

image.png

在http2协议中实现了server push,最典型的限制就是http2的是一个server push协议,只能在收到request后,才能进行push,并且http2只能推送静态数据(如js,css等)。

image.png

而web socket是全双工协议,客户端和服务端都可以不受限制的随意推送任意数据,非常适合用到聊天、游戏这样的场景上。

为什么需要

before websocket

websocket最经典的应用就是聊天,单聊聊天的典型架构如下:

image.png

两个用户聊天,为了获取有没有新消息,主要是通过轮询的方式来获取, 每隔一段时间,去请求服务端,拉取新的消息。

image.png

这种交互有两个问题。

  1. 频繁的轮询,导致无用的用户流量和服务器带宽浪费,有作用的轮询占比很低。
  2. 收消息不及时,在轮询的空档期是收不到消息的,只有下一个轮询周期才能收到新的消息。

但是也是有优势的,就是实现的成本很低,在小量的业务形态下,也是比较合适的。

in websocket

在双方和服务器建立websocket连接后,服务器即可在任意时刻向任意客户端推送消息,此时的交互是这样的。

image.png

协议交互

握手

也就是websocket的建连过程,我们上边一直在说websocket握手,其实这是websocket为了兼容http协议设计的,一次经典的websocket握手如下:

  1. 客户端也需要发一个http的请求包,首先协议(schema)使用ws协议或wss协议,URI里也能带上host、path、query等参数,类似这种:

ws://example.com/chat wss://example.com/chat

如果对http和https协议熟悉的同学也就很好理解ws和wss的schema,ws协议其实也就对标http协议,一般也会使用80端口,是未加密的内容,wss和https的概念相同,增加了TLS(Transport Layer Security)层的握手过程,是加密后的内容,默认情况下也会选用443端口。

  1. 其次是协议头header的设置,首先要求请求必须是GET请求,而且使用的http协议版本必须是http 1.1。然后再增加一些额外的header(其他的像Origin、Cookie这些header),其中前三个是比较重要的header,header定义和解释如下

``` Connection: Upgrade;客户端向服务端通知,想进行协议升级,看服务器支不支持。 Upgrade: websocket;客户端想升级的协议是websocket协议 Sec - WebSocket - Key:dfsakljkfjads;一段随机的base64字符串,用来进行后续的验证操作

Sec-WebSocket-Version: 13; 指定websocket的协议版本,版本必须是13
    Sec-WebSocket-Protocal: chat,multichat; 可选header,使用逗号分隔的协议,客户端支持的子协议格式,服务端会在相应里放上服务端支持的子协议格式
    Sec-WebSocket-Extensions: xxx; 可选header,传递额外信息。

```

  1. 服务端收到客户端的请求后,如果支持websocket协议,就会开始响应握手内容,也是带上几个额外的header

Sec-WebSocket-Accept: dfsakljkfjads;这里是将客户端传递过来的Sec-WebSocket-Key使用公开算法进行加密转换 Connection: Upgrade;同意客户端本次的协议升级 Upgrade: websocket;协议升级为websocket Sec-WebSocket-Protocal: chat; 服务端支持的websocket子协议 同时本次的http响应码为101 Switching Protocol,表示服务器应客户端升级协议的请求Upgrade正在切换协议。

  1. 客户端收到服务端的响应后,会将自己请求中的Sec-WebSocket-Key使用与服务端相同的加密算法进行加密转换,并和服务端返回的Sec-WebSocket-Accept进行对比,如果相同,则代表握手建立成功,后续通信协议就会使用websocket协议,我们继续看一下协议帧的数据结构。
协议帧

在握手完成后,后续就会使用websocket协议进行通信,websocket的协议帧格式如下,工作在tcp协议,属于应用层协议。

0 1 2 3 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 +-+-+-+-+-------+-+-------------+-------------------------------+ |F|R|R|R| opcode|M| Payload len | Extended payload length | |I|S|S|S| (4) |A| (7) | (16/64) | |N|V|V|V| |S| | (if payload len==126/127) | | |1|2|3| |K| | | +-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - + | Extended payload length continued, if payload len == 127 | + - - - - - - - - - - - - - - - +-------------------------------+ | |Masking-key, if MASK set to 1 | +-------------------------------+-------------------------------+ | Masking-key (continued) | Payload Data | +-------------------------------- - - - - - - - - - - - - - - - + : Payload Data continued ... : + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + | Payload Data continued ... | +---------------------------------------------------------------+

整个协议栈分为这几段

  1. FIN标志位。1bit,用于描述消息是否结束,如果为1则该消息为消息尾部,如果为零则还有后续数据包。

  2. RSV预留位。3bit,用于扩展定义的,给用户自己发挥的,类似我们比较常见的extra这种字段,如果没有扩展约定的情况则必须为0。

  3. opcode操作码。4bit,用于定义本次协议帧的操作,有如下几个枚举值。

    1. 0x0表示附加数据帧(会搭配FIN标志位进行使用)
    2. 0x1表示文本数据帧
    3. 0x2表示二进制数据帧
    4. 0x3-7暂时无定义,为以后的非控制帧保留
    5. 0x8表示连接关闭,双方都不会继续处理该websocket连接的后续数据
    6. 0x9表示ping
    7. 0xA表示pong
    8. 0xB-F暂时无定义,为以后的控制帧保留
  4. Mask是否掩码。1bit,用于标记是否有掩码,1为掩码,0为非掩码,有掩码的帧的data需要通过掩码计算。

  5. PayloadLen消息数据的长度。7/23/55 bit,根据实际长度,选用不同规格的bit。

    1. 如果前7bit值在0-125,则是7bit是payload的真实长度。
    2. 如果前7bit126,则后面2个字节形成的16位无符号整型数的值是payload的真实长度。
    3. 如果前7bit127,则后面8个字节形成的64位无符号整型数的值是payload的真实长度。
  6. MaskingKey掩码。0/32bit,跟 Mask标识位配合使用,Mask为1时才会有32bit的掩码,否则无掩码。

  7. PayloadData数据。长度为PayloadLen指定的,发送的数据,可能会经过掩码计算。

挥手

也就是断连操作,连接关闭和建连不同,关闭连接不会使用http协议,会使用websocket协议,操作码使用0x8,其中断连的数据会有点不同,对PayloadData做了拆分,分成了两部分

0 1 2 3 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 +-+-+-+-+-------+-+-------------+-------------------------------+ |F|R|R|R| opcode|M| Payload len | Extended payload length | |I|S|S|S| (4) |A| (7) | (16/64) | |N|V|V|V| |S| | (if payload len==126/127) | | |1|2|3| |K| | | +-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - + | Extended payload length continued, if payload len == 127 | + - - - - - - - - - - - - - - - +-------------------------------+ | |Masking-key, if MASK set to 1 | +-------------------------------+-------------------------------+ | Masking-key (continued) | status code | +-------------------------------- - - - - - - - - - - - - - - - + : reason : + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +

  1. status code,2字节,指示本次webSocket关闭的状态码。

    1. 1000,正常关闭。
    2. 1001,某端离开,例如服务器关闭或者浏览器页面关闭。
    3. 1002,协议错误。
    4. 1003,无法处理数据格式。
    5. 1009,消息过大。
    6. .....
  2. reason,对StatusCode的描述。

挥手的过程如下:

  1. 浏览器/服务端发送OpCode = 0x8,并将关闭状态码和原因写入payload data,注意,关闭帧的data必须经过mask计算。
  2. 对端收到关闭帧后,尽快回复一个关闭帧(允许一定的延迟,比如对端正在发送连续的数据)。
  3. 在 发送且收到/收到且发送 关闭帧后,websocket连接断开,必须立即关闭底层的tcp连接,开始tcp的挥手过程。

实战

完成代码:https://github.com/yinpeihao/ws_demo

需求分析

本来想写个聊天的,但是还有点复杂,还是写个简单的不需要各个客户端同步信息的吧。。。

需求很简单,实现一个定时提醒喝水的聊天机器人,默认1小时提醒一次,支持用户设置提醒时间间隔,类似下图。

client

client的架构比较简单,就是使用浏览器提供的标准WebSocket接口,界面是从网上扒的一个简单的聊天框的样式,核心code及相关注释如下:

``` //建立websocket连接 conn = new WebSocket("ws://localhost:8080/connect");

//监听close事件,如果关闭连接,则在页面上展示Connection closed conn.onclose = function (evt) { var item = document.createElement("div"); item.innerHTML = "Connection closed."; appendLog(item); };

//监听message事件,message事件也就是收到了服务端推送的消息,则展示在聊天窗口里 conn.onmessage = function (evt) { var messages = evt.data.split('\n'); for (var i = 0; i < messages.length; i++) { var item = document.createElement("div"); item.innerText = messages[i]; appendLog(item); } };

//监听按钮的提交事件,如果用户有输入数据,则将消息推送到服务端(conn.send(msg.value); ) document.getElementById("form").onsubmit = function () { if (!conn) { return false; } if (!msg.value) { return false; } conn.send(msg.value); msg.value = ""; return false; }; ```

server

没有区分目录结构,就一个main.go文件,我们先看一下main.go的结构:

6.png

分成以下几部分:

整体结构
  1. main函数,就注册了一个chat路由。

func main() { http.HandleFunc("/connect", Chat) err := http.ListenAndServe(":8080", nil) if err != nil { panic(err) } }

  1. chat函数,整体的交互流程,包含握手和双方分别读写。

func Chat(resp http.ResponseWriter, req *http.Request) { _, rw, err := Shank(resp, req) if err != nil { resp.WriteHeader(http.StatusForbidden) return } reader := rw.Reader writer := rw.Writer Write("连接成功,准备开始喝水提醒,默认60分钟提醒一次", writer) var ticker *time.Ticker minute := 60 ticker = time.NewTicker(time.Minute * time.Duration(minute)) for { go func() { select { case <-ticker.C: Write(fmt.Sprintf("过去%d分钟啦,注意喝水!", minute), writer) } }() userMessage := Read(reader) minute, err := strconv.ParseInt(userMessage, 10, 64) if err != nil { Write("输入指令错误,请输入整数调整提醒间隔(单位:分钟)", writer) } else { Write(fmt.Sprintf("重置成功,提醒间隔已重置为%d分钟。", minute), writer) ticker.Reset(time.Minute * time.Duration(minute)) } } }

hijack 握手
  1. 握手函数,Shank,基于hijack去接管链接,防止http自动释放,按照标准的握手流程进行实现。

```

// websocket握手 func Shank(resp http.ResponseWriter, req *http.Request) (net.Conn, *bufio.ReadWriter, error) { fmt.Println("connect begin") secKet := req.Header.Get("Sec-WebSocket-Key") //未升级到websocket协议 if req.Header.Get("Connection") != "Upgrade" || req.Header.Get("Upgrade") != "websocket" || secKet == "" { fmt.Printf("upgrade error,connetion:%v,upgrade:%v,websocket:%v\n", req.Header.Get("Connection"), req.Header.Get("Upgrade"), secKet) return nil, nil, errors.New("upgrade err") } //使用http.hijacker接管http连接,否则连接会在返回后进行释放,就无法进行持续的websocket通信了 jk, ok := resp.(http.Hijacker) if !ok { fmt.Println("hijack conv err") //正常返回,无法建立websocket连接 return nil, nil, errors.New("hijack conv err") } conn, buf, err := jk.Hijack() if err != nil { fmt.Println("hijack err") //正常返回,无法建立websocket连接 return nil, nil, errors.New("hijack err") } acceptSecKey := make([]byte, 28) hash := sha1.New() hash.Write([]byte(secKet)) hash.Write([]byte(WebSocketGUID)) base64.StdEncoding.Encode(acceptSecKey, hash.Sum(nil)) writer := buf.Writer writer.WriteString("HTTP/1.1 101 Switching Protocols\r\n") writer.WriteString("Connection: Upgrade\r\n") writer.WriteString("Upgrade: websocket\r\n") writer.WriteString("Sec-WebSocket-Accept: " + string(acceptSecKey) + "\r\n") writer.WriteString("\r\n") writer.Flush() fmt.Println("write resp success") return conn, buf, nil } ```

协议帧
  1. 协议帧,Frame,也就是websocket的协议帧,偏底层。

WebSocketGUID 这个是在握手时需要的一个常量,rfc定义为258EAFA5-E914-47DA-95CA-C5AB0DC85B11字符串,后边我们可以看到这个常量使用的地方。

``` const WebSocketGUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"

type Frame struct { Fin bool //  FIN:1位,用于描述消息是否结束,如果为1则该消息为消息尾部,如果为零则还有后续数据包;

Rsv [3]bool //  RSV1,RSV2,RSV3:各1位,用于扩展定义的,如果没有扩展约定的情况则必须为0

OpCode byte //4位,如果接收到未知的opcode,接收端必须关闭连接 //  OPCODE定义的范围: // //    0x0表示附加数据帧 //    0x1表示文本数据帧 //    0x2表示二进制数据帧 //    0x3-7暂时无定义,为以后的非控制帧保留 //    0x8表示连接关闭 //    0x9表示ping //    0xA表示pong //    0xB-F暂时无定义,为以后的控制帧保留 Mask bool //1位,用于标识PayloadData是否经过掩码处理,客户端发出的数据帧需要进行掩码处理,所以此位是1。数据需要解码。 Length int64 //如果 x值在0-125,则是7位是payload的真实长度。 //  如果 x值是126,则后面2个字节形成的16位无符号整型数的值是payload的真实长度。 //  如果 x值是127,则后面8个字节形成的64位无符号整型数的值是payload的真实长度。 MaskingKey []byte //32位的掩码 data []byte //消息体 } ```

  1. 协议帧解码,Decode函数,从Reader中读取数据写入到Frame结构中,偏底层。

``` func (f *Frame) Decode(rd *bufio.Reader) error { b, _ := rd.ReadByte() if b>>7 == 1 { f.Fin = true } f.OpCode = b & 0b00001111 //后4位为opCode b, _ = rd.ReadByte() if b>>7 == 1 { f.Mask = true } b = b & 0b01111111 //清掉第一位(也就是mask字段) if b <= 125 { f.Length = int64(b) } if b == 126 { bs := make([]byte, 2) rd.Read(bs) f.Length = parseByteToLen(bs) } if b == 127 { bs := make([]byte, 4) rd.Read(bs) f.Length = parseByteToLen(bs) } //读取掩码 if f.Mask { f.MaskingKey = make([]byte, 4) rd.Read(f.MaskingKey) //4字节掩码 } //根据len去read data f.data = make([]byte, f.Length) rd.Read(f.data)

//解掩码 if f.Mask { decodeData := make([]byte, f.Length) //这段逻辑也是rfc定义的标准解掩码格式 for i := int64(0); i < f.Length; i++ { decodeData[i] = f.data[i] ^ f.MaskingKey[i%4] } f.data = decodeData } return nil }

func parseByteToLen(bs []byte) int64 { length := int64(0) for _, b := range bs { length = length + int64(b) length = length << 8 } return length } ```

  1. 协议帧编码,Encode函数,将frame结构编码成字节码,偏底层。

``` func (f *Frame) Encode() ([]byte, error) { bytes := []byte{} var b byte if f.Fin { b = byte(0b10000000) } b = b | f.OpCode bytes = append(bytes, b) b = 0 if f.Mask { b = byte(0b10000000) } writeLenBytes := 0 if f.Length <= 125 { b = b | byte(f.Length) } else if f.Length > 125 && f.Length <= 65535 { bytes = append(bytes, b|0b11111110) //写入126 writeLenBytes = 2 //需要2bit } else { bytes = append(bytes, b|0b11111111) //写入127 writeLenBytes = 4 //需要4bit } bytes = append(bytes, b) //长度写入 bytes = append(bytes, parseLenToByte(f.Length, writeLenBytes)...) //marking写入 if f.Mask { bytes = append(bytes, f.MaskingKey[:4]...) //32位的掩码 } //data写入 bytes = append(bytes, f.data...) return bytes, nil }

// payload length的二进制表达采用网络序(big endian,低地址 存高位字节)。 func parseLenToByte(i int64, lenBytes int) []byte { if lenBytes == 0 { return nil } bytes := []byte{} for lenBytes != 0 { lenBytes-- bytes = append(bytes, byte(i>>(lenBytes*8))) } return bytes } ```

  1. 协议帧读写,Read/Write,对Encode/Decode做了封装,方便使用,偏用户层。

```

func Read(rd *bufio.Reader) string { f := Frame{} f.Decode(rd)

data := string(f.data) fmt.Println("read message,", data, ",frame:%v", f) return data }

func Write(serverData string, writer *bufio.Writer) { frame := Frame{ Fin: true, Rsv: [3]bool{}, OpCode: 0x1, Mask: false, Length: int64(len(serverData)), MaskingKey: []byte{}, data: []byte(serverData), } data, err := frame.Encode() if err != nil { fmt.Println("frame encode err,", err) //163 180 } nn, err := writer.Write(data) if err != nil { fmt.Println("write err,", err, "nn,", nn) } err = writer.Flush() if err != nil { fmt.Println("write flush err,", err) } fmt.Println("write data success") } ```

我们这里只实现了一个最简单的demo,很多功能,例如ping/pang等并没有实现,更完善的实现可以参考开源的sdk:https://github.com/gorilla/websocket(sad(⊙︿⊙),这个repo因为长期没人维护,已经archived了)。

结果展示

  1. 启动服务端和客户端,页面如下:

  1. 我们可以输入提醒的时间间隔,比如输入1,点击Send,提醒时间会调整为1分钟一次。

  1. 到达时间间隔,开始提醒。

总结

本文我们先分析了为什么需要websocket及协议的实现细节,然后使用golang进行了websocket协议的握手、协议帧的编解码,最终实现了一个定时提醒喝水的工具。

参考文档

  1. RFC6455 https://www.rfc-editor.org/rfc/rfc6455
  2. https://juejin.cn/post/7144161126652051464
  3. https://www.cnblogs.com/zhangmingda/p/12678630.html

都看到这啦,点点赞大火🙏

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值