是什么

WebSocket从字面意思来看,便是两个单词的拼接,别离是Web和Socket。学过网络协议的同学都知道传输层协议别离有TCP和UDP协议,都是双工协议,Socket便是操作体系对传输层协议的笼统完成,不过Socket除了支撑网络通讯,也支撑像unix socket这样的基于本地文件体系的通讯。而Web的释义也是字面意思,便是基于http协议的通讯体系,所以望文生义,websocket便是web技术和socket技术的结合,也便是双工协议的http,作业的网络协议层在应用层。

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

websocket与hijack

在http2协议中完成了server push,最典型的约束便是http2的是一个server push协议,只能在收到request后,才干进行push,并且http2只能推送静态数据(如js,css等)。

websocket与hijack

而web socket是全双工协议,客户端和服务端都可以不受约束的随意推送恣意数据,十分适宜用到谈天、游戏这样的场景上。

为什么需求

before websocket

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

websocket与hijack

两个用户谈天,为了获取有没有新音讯,主要是通过轮询的方式来获取, 每隔一段时刻,去恳求服务端,拉取新的音讯。

websocket与hijack

这种交互有两个问题。

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

但是也是有优势的,便是完成的成本很低,在小量的业务形状下,也是比较适宜的。

in websocket

在两边和服务器树立websocket衔接后,服务器即可在恣意时刻向恣意客户端推送音讯,此刻的交互是这样的。

websocket与hijack

协议交互

握手

也便是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的概念相同,增加了TLSTransport 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的挥手进程。

实战

完成代码:github.com/yinpeihao/w…

需求剖析

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

需求很简略,完成一个定时提示喝水的谈天机器人,默许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 = "<b>Connection closed.</b>";
    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的结构:

websocket与hijack

分成以下几部分:

全体结构

  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 & 0b0000_1111 //后4位为opCode
   b, _ = rd.ReadByte()
   if b>>7 == 1 {
      f.Mask = true
   }
   b = b & 0b0111_1111 //清掉第一位(也便是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(0b_1000_0000)
   }
   b = b | f.OpCode
   bytes = append(bytes, b)
   b = 0
   if f.Mask {
      b = byte(0b_1000_0000)
   }
   writeLenBytes := 0
   if f.Length <= 125 {
      b = b | byte(f.Length)
   } else if f.Length > 125 && f.Length <= 65535 {
      bytes = append(bytes, b|0b_1111_1110) //写入126
      writeLenBytes = 2                     //需求2bit
   } else {
      bytes = append(bytes, b|0b_1111_1111) //写入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:github.com/gorilla/web…

结果展现

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

websocket与hijack

  1. 咱们可以输入提示的时刻距离,比如输入1,点击Send,提示时刻会调整为1分钟一次。

websocket与hijack
websocket与hijack

  1. 抵达时刻距离,开端提示。

websocket与hijack

总结

本文咱们先剖析了为什么需求websocket及协议的完成细节,然后运用golang进行了websocket协议的握手、协议帧的编解码,最终完成了一个定时提示喝水的工具。

参阅文档

  1. RFC6455 www.rfc-editor.org/rfc/rfc6455
  2. /post/714416…
  3. www.cnblogs.com/zhangmingda…

都看到这啦,点点赞大火