关于Socket,看我这几篇就够了(三)原来你是这样的Websocket

5,325 阅读15分钟

期刊列表

  1. 关于Socket,看我这几篇就够了(一)
  2. 关于Socket,看我这几篇就够了(二)之HTTP
  3. 关于Socket,看我这几篇就够了(三)原来你是这样的Websocket

在上一篇中,我们介绍了HTTP协议。HTTP协议是一种无状态、无连接的协议。

在HTTP 1.1 版本之前,客户端到服务器的TCP/IP连接是使用完毕便断开的,而服务器的TCP/IP的socket层是有开销的,而客户端又很可能请求多次连接,每次建立连接都需要进行三次握手,断开连接需要进行四次挥手,我们便可以思考如何简化这些步骤。

于是,HTTP 1.1的版本中,便正式增加了一系列头部字段如Connection: keep-alive等等,使得客户端到服务器的socket连接可以维持一定时间不被销毁。因此客户端到服务器的每一次请求便不必都重新建立一次socket连接了,可以在已经建立的连接上直接发送数据了。

HTTP协议的缺点

即便是HTTP协议已经进化到可以复用连接了,它依然是有许多部分让人不满意:

1. HTTP请求的无关内容(协议相关内容)开销大

我们上一篇文章中讲过 HTTP协议中 我们操作的部分一般是body,也有一部分的header

这里我们按照字节Byte来简述下:

这里假设我们需要定时刷新一个GET接口获取信息(我们只分析发送请求),则我们请求的数据文本结构便为如下结构:

GET / HTTP/1.1\r\n
Host: www.example.com\r\n
\r\n

可能有人会觉得,这个数据并不多啊。

这里我们需要注意,开销大并不是一个绝对的含义,它是一种相对的。我们可以观察一下,在这样的一个简单请求中,我们究竟发送了多少字节,一共是42个字节。也就是说,每次我们执行这个请求都需要发送这42个字节,其中用于格式相关的便占有14个字节(HTTP/1.1\r\n)。这些数据每次请求都需要重复发送,我们也可以说,HTTP请求相对较重

2. HTTP请求只能单向发送

HTTP请求采用的是请求-应答模式,即客户端发出请求,服务器给出回应。这样就产生了一个弊端,服务器只能被动回应数据,无法主动推送数据。

我们虽然可以主动轮询请求,但是这就又引发了问题1,HTTP请求的开销很大,服务器又是资源紧缺型的

因此这就导致了Websocket的产生:

Websocket

Websocket是一种在建立在TCP连接上进行的全双工通信的协议

全双工 指的是通信的两端都具有主动发送数据的能力

WebSocket使得客户端和服务器之间的数据交换变得更加简单,允许服务端主动向客户端推送数据。在WebSocket API中,浏览器和服务器只需要完成一次额外握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输。

协议

连接建立

我们所说的连接建立都是已经建立在TCP/IP三次握手后。

Websocket 在连接建立后 需要额外进行一次HTTP握手,目的是确定通信双方都可以支持 此协议(防止误访问)。

  1. 客户端发起协议升级请求

客户端需要先发送一个HTTP头(包含Websocket指定信息,与其他头部信息如cookie等),客户端头部结构如下所示:

GET /访问路径 HTTP/1.1\r\n 
Host: www.example.com\r\n
Connection: Upgrade\r\n
Upgrade: websocket\r\n
Sec-WebSocket-Version: 13\r\n
Sec-WebSocket-Key: mgZ6+kXU1+mEgOXWDPPsBg==\r\n
\r\n

上述为websocket规定的固定的头部信息

  • Connection字段必须为Upgrade,用以标志着客户端需要连接升级
  • Upgrade字段必须为websocket,标志着客户端需要由http请求升级成websocket
  • Sec-WebSocket-Version字段为13,代表着当前协议的版本号(目前一般采用13)
  • Sec-WebSocket-Key字段为必填项,值一般为16个字节的随机数据转成base64字符串。该字段用以提供给服务器做头部返回凭证校验(用于客户端确定服务器是否支持websocket)

Websocket的请求头字段与标准的HTTP并无两样,但是协议规定,Websocket请求只能为GET类型,其余头部字段可由服务器与客户端双方协商增加。

Sec-WebSocket-Key主要是用于客户端确定服务器是否支持,因为客户端有可能因为某些原因错误的访问了一个HTTP服务器,该服务器并不支持Websocket,但是可以响应对应的GET请求,这个时候,客户端便可以通过服务器对应的返回字段确定是否应该继续建立连接或者是关闭连接

  1. 服务器响应请求数据

当服务器收到客户端的请求头的时候,便需要作出响应,响应数据也为标准的HTTP请求头

HTTP/1.1 101 Switch Protocol\r\n
Connection: Upgrade\r\n
Upgrade: websocket\r\n
Sec-WebSocket-Accept: qIs5tRK57T9vTjEtFfTLOSe3K3w=\r\n
\r\n

服务器首先要返回状态码101,用以表明服务端切换协议了,以后的数据解析协议将不再是HTTP超文本协议

服务器同样也要返回对应的ConnectionUpgrade 字段,同时服务器需要对客户端传入Sec-WebSocket-Key进行一定的处理,将处理结果返回至Sec-WebSocket-Accept中供客户端校验。

  • Sec-WebSocket-Key处理方法:

Sec-WebSocket-Key拼接字符串 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 然后将其进行sha1计算hash,最后将得出的hash进行base64转码成字符串,放入至Sec-WebSocket-Accept

当客户端收到对应的Sec-WebSocket-Accept时,用自己传的Sec-WebSocket-Key进行同样的处理,并比较服务器返回结果,如果结果一致则客户端认为服务器支持请求。当比较不一致时,按照协议要求,客户端应该主动断开连接。


我们可以看到,Websocket连接建立事实上就相当于客户端向服务器发起了一次普通的Body为空的HTTP请求,而服务器做出了同样的响应

Websocket如此做,是为了兼容标准的HTTP协议,因为对于一台服务器应用而言,它不必同时监听多个端口,就可以同时满足充当HTTP服务器和Websocket服务器。

同样Websocket请求也可以支持Cookie等等的HTTP头部规定。

在这里我们还看不出来Websocket如何解决HTTP的缺点的,因为这个只是Websocket的额外握手过程,并非真正数据发送。

数据发送

这里就要讲到Websocket最重要的环节了

首先我们需要明确两个定义ByteBit:

  • Byte:计算机存储与传输的标准单位(字节),转成非负整数能支持最大的数为(2^8 - 1) = 255,一个Byte转成二进制位的时候:0 0 0 0 0 0 0 0 由8个可以为0或1的组成,其中每个0或1均为1个Bit
  • Bit:二进制数系统中,每个0或1就是一个位(bit),位是数据存储的最小单位。1 Byte = 8 Bit

接下来还是要讲Websocket的数据发送结构,我们习惯称每一次完整的数据包为一

帧的数据结构:

Websocket帧数据结构

在上图中,我们是以Bit为单位,但是在真实数据处理过程中,我们操作内存的最小单位也就是Byte,也就是8*Bit,在Swift中我们可以使用UInt8将Byte转为无整形进行处理。

我们可以看出来,Websocket的数据包的协议相关部分只占2-10个字节,如果算上相关掩码,也最多占用14个字节,和http相比,这也就是说,Websocket的额外消耗小。

这里我们开始按照顺序开始讲解协议相关内容:

  • FIN:

该位是整个帧的首位,用以标志该帧是否为连续帧的结束

0: 连续数据包尚未结束

1: 当前帧为数据包的最后一帧

  • RSV1-RSV3:

用于子协议,或者其他相关。官方要求这3位均为0,子协议可以对此进行拓展。当这三位中有1-3位为1的时候,如果接收端不能正确理解相关数据,则应关闭相关连接

关闭:并非指TCP/IP层的连接关闭,而是Websocket协议层定义的关闭,接下来的所有关闭都是如此,我们将在接下来解释关闭含义

  • 操作码(opcode):

操作码占用4个Bit,所以操作码的一共有2^4=16种可能

下面我将以16进制列举情况:

  1. 0:代表着当前帧是一个继续帧
  2. 1:代表着当前帧是一个文本帧(传输数据为UTF8编码的文本)
  3. 2:代表着当前帧是一个二进制数据流帧(Swift中为Data)
  4. 3-7:用于未来的非控制帧
  5. 8:代表着当前帧是一个关闭帧
  6. 9:代表着当前帧是一个心跳检测Ping帧
  7. A:代表着当前帧是一个心跳检测回复Pong帧
  8. B-F:用于未来的控制帧

在这里,一个有两种情况,控制帧非控制帧

控制帧

控制帧有一定的特殊要求:

  1. 控制帧不能处于一个连续的数据帧中
  2. 控制帧的真实发送数据大小不能超过125字节
  3. 控制帧的FIN(终止位)必须是1

控制帧意味着,当收到对应帧的时候,接收方应该做出一定的响应或者操作。

8:关闭帧

当接收方收到关闭帧的时候,有如下两种情况:

  1. 若接收方之前尚未发送过关闭帧

如果此时接收方正在发送连续的数据帧过程中,则可以继续发送数据帧(此时无法确定另一方还会继续处理数据)。随后应该回复一个关闭帧,随后完成断开TCP/IP连接操作。

  1. 若接收方之前已经发送过关闭帧

接收方在发送关闭帧之后不应再发送任何数据帧,当收到关闭帧后,断开TCP/IP连接

  1. 关闭帧为控制帧,因此可以携带不超过125个字节的数据,该帧携带的数据前两个字节为错误码,随后的字节为对应的描述原因(UTF8编码文本)

关闭: 若一方发起关闭,则该方主动发送关闭帧,并最终执行关闭TCP/IP连接的一整套流程被称为关闭

9:Ping

Ping为Websocket的心跳包机制帧,主要用于确认另一方未因为异常关闭连接,当我们接收到Ping帧时,我们应该响应Pong帧作为回应。若长时间未收到回应,我们应该考虑主动关闭连接

A:Pong

Pong帧为Websocket的心跳包机制帧中的响应帧。

其余控制帧

在现有协议中未做定性要求,可能在未来Websocket升级增加(或者子协议中定义)

如果接收方未定义该帧的相应处理方法,则应该关闭连接

非控制帧

非控制帧也就是我们通常意义上的数据帧,主要是用于双方发送数据,也是我们平时用的最多的

0:继续帧(分片)

分片

分片的主要目的是允许当消息开始但不必缓冲该消息时发送一个未知大小的消 息。如果消息不能被分片,那么端点将不得不缓冲整个消息以便在首字节发生之 前统计出它的长度。对于分片,服务器或中间件可以选择一个合适大小的缓冲, 当缓冲满时,写一个片段到网络。

第二个分片的用例是用于多路复用,一个逻辑通道上的一个大消息独占输出通道 是不可取的,因此多路复用需要可以分割消息为更小的分段来更好的共享输出通道。

数据分片发送的要求:

  1. 数据的首帧与过程帧的FIN位为0
  2. 数据的首帧的操作码必须为对应的非控制帧操作码,且不能为继续帧
  3. 数据的过程帧与终止帧的操作码必须为继续帧
  4. 数据的终止帧的操作码必须为1

我们可以这样理解:

首先当我们需要发送分片数据的时候,我们最开始肯定要告诉对方,我们的这个数据是什么类型的,同时我们肯定不能在发送过程中告诉对方,数据发送完了。同时在发送过程中,我们得告诉对方,我们的数据还没有发送完成,这个数据是其中的一部分。当发送到最后一个的时候,我们又需要告诉对方,发送完了。

其实简化来说,规则如下:

  1. 发送开始确定数据类型,过程与结尾均不可更改
  2. 发送截止告诉对方数据完成

对应的接收处理方式也如上面所说,先解析首帧,确定数据类型,然后接收中间数据,最后接收尾帧,数据处理完成。过程中如果接收到不符合分片发送的数据要求,则应该关闭连接

1:文本帧

文本帧就是标志着,传输的数据是使用UTF8编码的文本,当我们使用的时候,就需要将数据转换为UTF8字符串,当转换失败的时候我们需要关闭连接

2:二进制帧

二进制帧代表着发送的数据为二进制文件

3-7: 其余非控制帧

用以在未来协议升级,或者子协议拓展

操作码算是整个协议头里很关键的部分,它定义了数据的处理方式,与一些其他的操作

掩码(MASK)

掩码占位1个Bit 用以标志着该字段发送是否使用了掩码,以及是否需要对真实数据进行解码。

若掩码位为1: 则标志着存在掩码,并需要进行转码

为什么要设计掩码?

协议规定,客户端到服务器数据发送必须包含掩码,服务器返回数据不能携带掩码

数据长度(Payload Len)

数据长度占用7个Bit(可能更多),所以该段最大有可能2^7 - 1 = 127,但是真实的发送数据可能远远超过这个值,应该怎么处理呢?

所以协议制定者在这里规定了:

  1. 当该值小于等于125时表示真正的数据长度(Byte)
  2. 当该值等于126时,我们需要取接下来的16个Bit(2个Byte)作为长度,使得长度可以支持到2^16 - 1 = 65535(Byte)
  3. 当该值等于127时,我们需要取接下来的64个Bit(8个Byte)作为长度,使得长度可以支持到2^64 - 1 = 很大的一个数

如果还不够怎么办?

可以考虑分片发送了-_-

Masking-Key(真实掩码)

真实掩码一共占用32个Bit(4个Byte)

该字段是我们根据上述掩码标志位获取的,如果掩码标志位为1,则该字段存在;为0则该位为空。

协议规定,真实掩码应该是我们使用不可预测的算法得出的随机32个Bit(4个Byte)

在Swift中我们可以使用Security.SecRandomCopyBytes()方法获取随机值

当我们拥有掩码与真实数据后,我们需要按照如下操作对真实数据进行处理(直接展示Swift代码)

func maskData(payloadData: Data, maskingKey: Data) -> Data {
    let finalData = Data(count: payloadData.count)
    // 转化Data为指针,方便处理
    let payloadPointer: UnsafePointer<UInt8> = payloadData.withUnsafeBytes({$0})
    let maskPointer: UnsafePointer<UInt8> = maskingKey.withUnsafeBytes({$0})
    let finalPointer: UnsafeMutablePointer<UInt8> = finalData.withUnsafeBytes({UnsafeMutablePointer(mutating: $0)})


    for index in 0..<payloadData.count {
        let indexMod = index % 4
        // 对应位异或XOR(^)
        (finalPointer + index).pointee = (payloadPointer + index).pointee ^ (maskPointer + indexMod).pointee
    }

    return finalData
}

掩码与解码均是按照此算法进行计算

真实数据(Payload Data)

也可以称作负载数据(或许应该被称为负载数据而不是真实数据,不过没什么关系),也就是我们主要使用的数据。也就不再多说了。

其他

关于Websocket还有一些东西我们尚未讲述,如子协议之类的,这些东西作者还需要再进行深入研究。因此,在以后将会以补充文章进行讲述。

什么时候需要使用Websocket

作为iOS开发人员,我们使用这个的机会不多。但是当我们希望服务器能主动推送数据到我们这,同时又不希望再进行自行开发上层协议的时候我们可以考虑这个协议,还是很好用的。

为什么要写这篇文章?

作者最近正在研究这个协议,同时正在使用纯swift语言开发一个Websocket客户端三方库: SwiftAsyncWebsocket,目前正处于开发阶段。觉得对Websocket有一定的研究心得,故此写下这篇文章

结尾

我们现在前行的每一步,都是前人为我们铺好的道路。

文章中如果有错误,还请各位评论指出

PS: 又用PPT画了一张图,感觉好费劲啊,-_-

参考:

SocketRocket源码

RFC 6455