2

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

 2 years ago
source link: https://juejin.cn/post/6844904062408720391
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

前言:
前段时间,在公司的项目中用到了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的实现原理》


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK