今天我们来聊一聊WebSocket(iOS/Golang)

avatar
工程师 @掘金

前言:
前段时间,在公司的项目中用到了WebSocket,当时没有时间好好整理。
最近,趁着有时间,就好好梳理了一下WebSocket的相关知识。


本篇将介绍以下内容:
1、什么是WebSocket
2、WebSocket使用场景
3、WebSocket底层原理(协议)
4、iOSWebSocket的相关框架
5、使用StarscreamSwift)完成长链需求( 客户端 )
6、使用Golang完成长链需求( 服务端 )


一、什么是 WebSocket ?

WebSocket = “HTTP第1次握手” + TCP的“全双工“通信 的网络协议。

主要过程:

  • 首先,通过HTTP第一次握手保证连接成功。
  • 其次,再通过TCP实现浏览器与服务器全双工(full-duplex)通信。(通过不断发ping包、pang包保持心跳)

最终,使得 “服务端” 拥有 “主动” 发消息给 “客户端” 的能力。

这里有几个重点:

  1. WebSocket是基于TCP的上部应用层网络协议。
  2. 它依赖于HTTP的第一次握手成功 + 之后的TCP双向通信。

二、WebSocket 应用场景

1. IM(即时通讯)

典型例子:微信、QQ等 当然,用户量如果非常大的话,仅仅依靠WebSocket肯定是不够的,各大厂应该也有自己的一些优化的方案与措施。但对于用户量不是很大的即时通讯需求,使用WebSocket是一种不错的方案。

2. 游戏(多人对战)

典型例子:王者荣耀等(应该都玩过)

3. 协同编辑(共享文档)

多人同时编辑同一份文档时,可以实时看到对方的操作。 这时,就用上了WebSocket

4. 直播/视频聊天

对音频/视频需要较高的实时性。

5. 股票/基金等金融交易平台

对于股票/基金的交易来说,每一秒的价格可能都会发生变化。

6. IoT(物联网 / 智能家居)

例如,我们的App需要实时的获取智能设备的数据与状态。 这时,就需要用到WebSocket

...... 等等等等

只要是一些对 “实时性” 要求比较高的需求,可能就会用到WebSocket


三、WebSocket 底层原理

WebSocket是一个网络上的应用层协议,它依赖于HTTP协议的第一次握手,握手成功后,数据就通过TCP/IP协议传输了。

WebSocket分为握手阶段和数据传输阶段,即进行了HTTP一次握手 + 双工的TCP连接。

1、握手阶段

首先,客户端发送消息:(本例是:用Golang编写的本地服务)

GET /chat HTTP/1.1
Host: 127.0.0.1:8000
Origin: ws://127.0.0.1:8000
Qi-WebSocket-Version: 0.0.1
Sec-WebSocket-Version: 13
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: aGFjb2hlYW9rd2JtdmV5eA==

然后,服务端返回消息:(本例是:用swift编写的客户端接收)

"Upgrade": "websocket", 
"Connection": "Upgrade", 
"Sec-WebSocket-Accept": "NO+pj7z0cvnNj//mlwRuAnCYqCE="

这里值得注意的是Sec-WebSocket-Accept的计算方法: base64(hsa1(sec-websocket-key + 258EAFA5-E914-47DA-95CA-C5AB0DC85B11))

  • 如果这个Sec-WebSocket-Accept计算错误,浏览器会提示:Sec-WebSocket-Accept dismatch
  • 如果返回成功,Websocket就会回调onopen事件

2、传输阶段

WebSocket是以 frame 的形式传输数据的。 比如会将一条消息分为几个frame,按照先后顺序传输出去。

这样做会有几个好处:

  • 较大的数据可以分片传输,不用考虑到数据大小导致的长度标志位不足够的情况。
  • HTTPchunk一样,可以边生成数据边传递消息,即提高传输效率。

WebSocket传输过程使用的报文,如下所示:

 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 ...                |
 +---------------------------------------------------------------+

具体的参数说明如下:

  • FIN(1 bit): 表示信息的最后一帧,flag,也就是标记符。 PS:当然第一个消息片断也可能是最后的一个消息片断;

  • RSV1、RSV2、RSV3(均为1 bit): 默认均为0。如果有约定自定义协议则不为0,一般均为0。(协议扩展用)

  • Opcode(4 bit): 定义有效负载数据,如果收到了一个未知的操作码,连接也必须断掉,以下是定义的操作码:

操作码 含义
%x0 连续消息片断
%x1 文本消息片断
%x2 二进制消息片断
%x3-7 (预留位)为将来的非控制消息片断保留的操作码。
%x8 连接关闭
%x9 心跳检查ping
%xA 心跳检查pong
%xB-F (预留位)为将来的控制消息片断的保留操作码。
  • Mask(1 bit): 是否传输数据添加掩码。 若为1,掩码必须放在masking-key区域。(后面会提到..) 注:客户端给服务端发消息Mask值均为1

  • Payload length: Payload字段用来存储传输数据的长度。

本身Payload报文字段的大小可能有三种情况:7 bit7+16 bit7+64 bit

第一种:7 bit,表示从0000000 ~ 1111101(即0~125),表示当前数据的length大小(较小数据,最大长度为125)。

第二种:(7+16) bit:前7位为1111110(即126)126代表后面会跟着2个字节无符号数,用来存储数据length大小(长度最小126,最大为65 535)。

第三种:(7+64) bit:前7位为1111111(即127)127代表后面会跟着8个字节无符号数,用来存储数据length大小(长度最小为65536,最大为2^16-1)。

Payload报文长度 所传输的数据大小区间
7 bit [ 0, 125]
7 +16 bit [ 126 , 65535]
7 + 64 bit [ 65536, 2^16 -1]

说明:
传输数据的长度,以字节的形式表示:7位、7+16位、或者7+64位。
1)如果这个值以字节表示是0-125这个范围,那这个值就表示传输数据的长度;
2)如果这个值是126,则随后的2个字节表示的是一个16进制无符号数,用来表示传输数据的长度;
3)如果这个值是127,则随后的是8个字节表示的一个64位无符号数,这个数用来表示传输数据的长度。

  • Masking-key(0 bit / 4 bit): 0 bit:说明mask值不为1,无掩码。 4 bit:说明mask值为1,添加掩码。

PS:客户端发送给服务端数据时,mask均为1。 同时,Masking-key会存储一个32位的掩码。

  • Payload data(x+y byte): 负载数据为扩展数据及应用数据长度之和。

  • Extension data(x byte): 如果客户端与服务端之间没有特殊约定,那么扩展数据的长度始终为0,任何的扩展都必须指定扩展数据的长度,或者长度的计算方式,以及在握手时如何确定正确的握手方式。如果存在扩展数据,则扩展数据就会包括在负载数据的长度之内。

  • Application data(y byte): 任意的应用数据,放在扩展数据之后。 应用数据的长度 = 负载数据的长度 - 扩展数据的长度 即:Application data = Payload data - Extension data


四、iOS 中 WebSocket 相关框架

WebSocket(iOS客户端):
Socket(iOS客户端):

五、使用Starscream(swift)完成客户端长链需求

首先附上Starscream:GitHub地址

第一步:将Starsream导入到项目。

打开Podfile,加上:

pod 'Starscream', '~> 4.0.0'

接着pod install

第二步:实现WebSocket能力。

  • 导入头文件,import Starscream

  • 初始化WebSocket,把一些请求头包装一下(与服务端对好)

private func initWebSocket() {
    // 包装请求头
    var request = URLRequest(url: URL(string: "ws://127.0.0.1:8000/chat")!)
    request.timeoutInterval = 5 // Sets the timeout for the connection
    request.setValue("some message", forHTTPHeaderField: "Qi-WebSocket-Header")
    request.setValue("some message", forHTTPHeaderField: "Qi-WebSocket-Protocol")
    request.setValue("0.0.1", forHTTPHeaderField: "Qi-WebSocket-Version")
    request.setValue("some message", forHTTPHeaderField: "Qi-WebSocket-Protocol-2")
    socketManager = WebSocket(request: request)
    socketManager?.delegate = self
}

同时,我用三个Button的点击事件,分别模拟了connect(连接)、write(通信)、disconnect(断开)。

    // Mark - Actions
    // 连接
    @objc func connetButtonClicked() {
        socketManager?.connect()
    }
    // 通信
    @objc func sendButtonClicked() {
        socketManager?.write(string: "some message.")
    }
    // 断开
    @objc func closeButtonCliked() {
        socketManager?.disconnect()
    }

第三步:实现WebSocket回调方法(接收服务端消息)

遵守并实现WebSocketDelegate

extension ViewController: WebSocketDelegate {
    // 通信(与服务端协商好)
    func didReceive(event: WebSocketEvent, client: WebSocket) {
        switch event {
        case .connected(let headers):
            isConnected = true
            print("websocket is connected: \(headers)")
        case .disconnected(let reason, let code):
            isConnected = false
            print("websocket is disconnected: \(reason) with code: \(code)")
        case .text(let string):
            print("Received text: \(string)")
        case .binary(let data):
            print("Received data: \(data.count)")
        case .ping(_):
            break
        case .pong(_):
            break
        case .viablityChanged(_):
            break
        case .reconnectSuggested(_):
            break
        case .cancelled:
            isConnected = false
        case .error(let error):
            isConnected = false
            // ...处理异常错误
            print("Received data: \(String(describing: error))")
        }
    }
}

分别对应的是:

public enum WebSocketEvent {
    case connected([String: String])  //!< 连接成功
    case disconnected(String, UInt16) //!< 连接断开
    case text(String)                 //!< string通信
    case binary(Data)                 //!< data通信
    case pong(Data?)                  //!< 处理pong包(保活)
    case ping(Data?)                  //!< 处理ping包(保活)
    case error(Error?)                //!< 错误
    case viablityChanged(Bool)        //!< 可行性改变
    case reconnectSuggested(Bool)     //!< 重新连接
    case cancelled                    //!< 已取消
}

这样一个简单的客户端WebSocket demo就算完成了。

  • 客户端成功,日志截图:


六、使用Golang完成简单服务端长链需求

仅仅有客户端也无法验证WebSocket的能力。
因此,接下来我们用Golang简单做一个本地的服务端WebSocket服务。

PS:最近,正好在学习Golang,参考了一些大神的作品。

直接上代码了:

package main

import (
	"crypto/sha1"
	"encoding/base64"
	"errors"
	"io"
	"log"
	"net"
	"strings"
)

func main() {
	ln, err := net.Listen("tcp", ":8000")
	if err != nil {
		log.Panic(err)
	}
	for {
		log.Println("wss")
		conn, err := ln.Accept()
		if err != nil {
			log.Println("Accept err:", err)
		}
		for {
			handleConnection(conn)
		}
	}
}

func handleConnection(conn net.Conn) {
	content := make([]byte, 1024)
	_, err := conn.Read(content)
	log.Println(string(content))
	if err != nil {
		log.Println(err)
	}
	isHttp := false
	// 先暂时这么判断
	if string(content[0:3]) == "GET" {
		isHttp = true
	}
	log.Println("isHttp:", isHttp)
	if isHttp {
		headers := parseHandshake(string(content))
		log.Println("headers", headers)
		secWebsocketKey := headers["Sec-WebSocket-Key"]
		// NOTE:这里省略其他的验证
		guid := "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
		// 计算Sec-WebSocket-Accept
		h := sha1.New()
		log.Println("accept raw:", secWebsocketKey+guid)
		io.WriteString(h, secWebsocketKey+guid)
		accept := make([]byte, 28)
		base64.StdEncoding.Encode(accept, h.Sum(nil))
		log.Println(string(accept))
		response := "HTTP/1.1 101 Switching Protocols\r\n"
		response = response + "Sec-WebSocket-Accept: " + string(accept) + "\r\n"
		response = response + "Connection: Upgrade\r\n"
		response = response + "Upgrade: websocket\r\n\r\n"
		log.Println("response:", response)
		if lenth, err := conn.Write([]byte(response)); err != nil {
			log.Println(err)
		} else {
			log.Println("send len:", lenth)
		}
		wssocket := NewWsSocket(conn)
		for {
			data, err := wssocket.ReadIframe()
			if err != nil {
				log.Println("readIframe err:", err)
			}
			log.Println("read data:", string(data))
			err = wssocket.SendIframe([]byte("good"))
			if err != nil {
				log.Println("sendIframe err:", err)
			}
			log.Println("send data")
		}
	} else {
		log.Println(string(content))
		// 直接读取
	}
}

type WsSocket struct {
	MaskingKey []byte
	Conn       net.Conn
}

func NewWsSocket(conn net.Conn) *WsSocket {
	return &WsSocket{Conn: conn}
}

func (this *WsSocket) SendIframe(data []byte) error {
	// 这里只处理data长度<125的
	if len(data) >= 125 {
		return errors.New("send iframe data error")
	}
	lenth := len(data)
	maskedData := make([]byte, lenth)
	for i := 0; i < lenth; i++ {
		if this.MaskingKey != nil {
			maskedData[i] = data[i] ^ this.MaskingKey[i%4]
		} else {
			maskedData[i] = data[i]
		}
	}
	this.Conn.Write([]byte{0x81})
	var payLenByte byte
	if this.MaskingKey != nil && len(this.MaskingKey) != 4 {
		payLenByte = byte(0x80) | byte(lenth)
		this.Conn.Write([]byte{payLenByte})
		this.Conn.Write(this.MaskingKey)
	} else {
		payLenByte = byte(0x00) | byte(lenth)
		this.Conn.Write([]byte{payLenByte})
	}
	this.Conn.Write(data)
	return nil
}

func (this *WsSocket) ReadIframe() (data []byte, err error) {
	err = nil
	//第一个字节:FIN + RSV1-3 + OPCODE
	opcodeByte := make([]byte, 1)
	this.Conn.Read(opcodeByte)
	FIN := opcodeByte[0] >> 7
	RSV1 := opcodeByte[0] >> 6 & 1
	RSV2 := opcodeByte[0] >> 5 & 1
	RSV3 := opcodeByte[0] >> 4 & 1
	OPCODE := opcodeByte[0] & 15
	log.Println(RSV1, RSV2, RSV3, OPCODE)

	payloadLenByte := make([]byte, 1)
	this.Conn.Read(payloadLenByte)
	payloadLen := int(payloadLenByte[0] & 0x7F)
	mask := payloadLenByte[0] >> 7
	if payloadLen == 127 {
		extendedByte := make([]byte, 8)
		this.Conn.Read(extendedByte)
	}
	maskingByte := make([]byte, 4)
	if mask == 1 {
		this.Conn.Read(maskingByte)
		this.MaskingKey = maskingByte
	}

	payloadDataByte := make([]byte, payloadLen)
	this.Conn.Read(payloadDataByte)
	log.Println("data:", payloadDataByte)
	dataByte := make([]byte, payloadLen)
	for i := 0; i < payloadLen; i++ {
		if mask == 1 {
			dataByte[i] = payloadDataByte[i] ^ maskingByte[i%4]
		} else {
			dataByte[i] = payloadDataByte[i]
		}
	}
	if FIN == 1 {
		data = dataByte
		return
	}
	nextData, err := this.ReadIframe()
	if err != nil {
		return
	}
	data = append(data, nextData...)
	return
}

func parseHandshake(content string) map[string]string {
	headers := make(map[string]string, 10)
	lines := strings.Split(content, "\r\n")
	for _, line := range lines {
		if len(line) >= 0 {
			words := strings.Split(line, ":")
			if len(words) == 2 {
				headers[strings.Trim(words[0], " ")] = strings.Trim(words[1], " ")
			}
		}
	}
	return headers
}

完成后,在本地执行:

go run WebSocket_demo.go

即可开启本地服务。

这时候访问ws://127.0.0.1:8000/chat接口,即可调用长链服务。

  • 服务端,成功日志截图:


相关参考链接:
《微信,QQ这类IM app怎么做——谈谈Websocket》(冰霜大佬)
《WebSocket的实现原理》