详解 WebSocket

本文涉及的产品
公共DNS(含HTTPDNS解析),每月1000万次HTTP解析
云解析 DNS,旗舰版 1个月
全局流量管理 GTM,标准版 1个月
简介: 详解 WebSocket


什么是 WebSocket




我们知道 socket 是基于 TCP/IP 协议栈所封装的一组功能接口,这些接口可以让我们很方便地在传输层收发数据,而无需直接面对 TCP/IP 协议栈。那么问题来了, WebSocket 是什么呢?

很简单,WebSocket 是一种和 HTTP 类似的网络传输协议,并提供了和 TCP Socket 类似的功能,使用它可以轻松地调用下层协议栈,收发数据。也就是说,WebSocket 同样是一种基于 TCP 的轻量级网络传输协议,在地位上和 HTTP 是平级的。


不过 HTTP 已经纵横江湖 N 多年了,已经被广泛使用,为啥还要搞出一个 WebSocket 呢?其实 WebSocket 和 HTTP/2 一样,都是为了解决 HTTP 的缺陷而诞生的。HTTP/2 针对的是队头阻塞,WebSocket 针对的是请求-响应这种通信模式。


请求-响应是一种半双工的通信模式,虽然可以双向收发数据,但同一时刻只能在一个方向上有动作,传输效率低。举个例子就是,客户端可以向服务端发数据,服务端也可以向客户端发数据,但两者不能同时发。


更关键的一点,它还是一种被动通信模式,服务端只能被动响应客户端的请求,无法主动向客户端发送数据。服务端表示:"敌不动我不动",客户端发一个请求,自己就给一个响应,你若不来找我,我也不去找你。


虽然后来的 HTTP/2、HTTP/3 新增了 Stream, Server Push 等特性,但请求-响应依然是主要的工作方式,这就导致 HTTP 难以应用在动态页面、即时消息、网络游戏等要求实时通信的领域。


因此在 WebSocket 出现之前,在浏览器环境里用 JavaScript 开发实时 Web 应用很麻烦。因为浏览器是一个受限制的沙箱,不能用 TCP,只有 HTTP 协议可用,所以就出现了很多变通的技术,轮询(polling)就是比较常用的一种。简单地说,轮询就是不停地向服务端发送 HTTP 请求,问有没有数据,有数据的话服务端就用响应报文回应。如果轮询的频率比较高,那么就可以近似地实现实时通信的效果;但轮询的缺点也很明显,反复发送无效查询请求耗费了大量的带宽和 CPU 资源,非常不经济,对服务端而言也是一种压力。


所以为了克服 HTTP 的请求-响应模式的缺点,WebSocket 就应运而生了。它原来是 HTML5 的一部分,后来自立门户,形成了一个单独的标准,RFC 文档编号是 6455。




WebSocket 的特点




WebSocket 是一个真正的全双工通信协议,与 TCP 一样,客户端和服务端都可以随时向对方发送数据,而不用像 HTTP 那么客套。于是,服务端就可以变得更加主动了,一旦后台有新的数据,就能立即推送给客户端,而不需要客户端轮询,实时通信的效率也就提高了。


5750875daaafd79982c67eb61ee868ef.png

然后是报文格式,这一点 WebSocket 与 HTTP 完全不兼容。HTTP 的报文格式采用的是纯文本,而 WebSocket 的报文格式则采用了二进制帧结构,所以它的传输性能要优于 HTTP。因为二进制虽然对人不友好,但却大大方便了计算机的解析。因为使用纯文本的话,很容易出现多义性,比如大小写、空白字符、回车换行、多字少字等等,程序在处理时必须用复杂的状态机,效率低,还麻烦。


但毕竟 WebSocket 的主要应用环境是浏览器,所以为了便于推广和应用,就不得不搭便车,在使用习惯上尽量向 HTTP 靠拢,这就是它名字里 Web 的含义。服务发现方面,WebSocket 没有使用 TCP 的 IP 地址 + 端口号,而是延用了 HTTP 的 URI 格式。但开头的协议名不是 http,引入的是两个新的名字:ws 和 wss,分别表示明文和加密的 WebSocket 协议。


而 WebSocket 的默认端口也选择了 80 和 443,因为现在互联网上的防火墙屏蔽了绝大多数的端口,只对 HTTP 的 80、443 端口放行,所以 WebSocket 就可以伪装成 HTTP 协议,比较容易地穿透防火墙,与服务器建立连接。下面举几个 WebSocket URI 的例子,看看是不是和 HTTP 高度相似呢?


  • ws://www.xxx.com
  • ws://www.xxx.com:8080/chat
  • wss://www.xxx.com:445/chat?user_id=xxx

除了协议名不一样,其它部分基本一致。


要注意的一点是,WebSocket 的名字容易让人产生误解,虽然大多数情况下我们会在浏览器里调用 API 来使用 WebSocket,但它不是一个调用接口的集合,而是一个通信协议,或者说网络传输协议,可以简单把它理解成 TCP over Web。





WebSocket 的报文结构



重点来了,WebSocket 的报文长什么样子呢?不过在此之前,先来看看 HTTP 的报文结构。


首先是请求报文:


b3b176d0da599ebf64d4903d3995e964.png


接下来是响应报文:


31e5a03df92860658d8935bb6b73f363.png


所以无论是请求报文还是响应报文,都由 起始行 + 请求头/响应头 + 请求体/响应体 组成。而我们在拿到原始的报文之后,也可以很方便地进行解析,从图中可以看出最后一个 Header 字段和响应体之间有两个换行,而换行用 \r\n 表示。因此我们只要按照 "\r\n\r\n" 进行 split 即可,会得到一个数组,数组的第二个元素就是请求体/响应体,第一个元素就是起始行 + 请求头/响应头


然后对数组的第一个元素按照 "\r\n" 再进行 split,又可以得到一个数组,该数组的第一个元素就是起始行,剩余的元素就是请求头/响应头。我们举个例子,这里我用 Tornado 写了一个服务,监听 8080 端口,通过 POST 请求向根路径传递一个 JSON,即可返回一个字符串。下面看看如何使用 socket 发请求,并进行解析:

from pprint import pprint
import socket
client = socket.socket()
client.connect(("localhost", 8080))
# 构造请求报文,别忘了请求体的前面要有一个换行
message = b"""POST / HTTP/1.1
Host: localhost:8080
Connection: close
Content-Length: 26
{"name":"satori","age":17}"""
# 发送请求
client.send(message)
# 获取响应
content = client.recv(4096)
# 按照 \r\n\r\n 进行分隔
data = content.split(b"\r\n\r\n")
# 第二个元素就是响应体
print(data[1].decode("utf-8"))  # name: satori, age: 17
# 对第一个元素使用 \r\n 进行分隔
headers = data[0].split(b"\r\n")
# 起始行
print(headers[0].decode("utf-8"))  # HTTP/1.1 200 OK
# 响应头
for header in headers[1:]:
    print(header)
"""
b'Server: TornadoServer/6.1'
b'Content-Type: text/html; charset=UTF-8'
b'Date: Sun, 22 May 2022 17:54:11 GMT'
b'Content-Length: 21'
b'Connection: close'
"""
# 当然啦,我们还可以将每个响应头按照冒号进行 split
# 然后将它们组成键值对放在一个字典里面
headers_dict = {}
for header in headers[1:]:
    key, val = header.decode("utf-8").split(":", 1)
    # 由于 val 前面多一个空格,所以要 strip 一下
    headers_dict[key] = val.strip()
pprint(headers_dict)
"""
{'Connection': 'close',
 'Content-Length': '21',
 'Content-Type': 'text/html; charset=UTF-8',
 'Date': 'Sun, 22 May 2022 17:59:08 GMT',
 'Server': 'TornadoServer/6.1'}
"""

以上就是 HTTP 报文的解析和处理,这里我们是客户端,所以解析的是响应报文。当然作为服务端,解析请求报文也是类似的,这里 Tornado 帮我们做了。总之报文的解析非常简单,因此你可以不断地完善,然后实现一个属于自己的网络请求库。比如 Python 的 requests 模块,你完全有能力自己封装一个。


这里稍微啰嗦了一下 HTTP 相关的内容,但完全是值得的,因为 WebSocket 要用到 HTTP。


和 TCP、TLS 一样,WebSocket 也要有一个握手过程,然后才能正式收发数据。在 WebSocket API 中,浏览器和服务器只需要完成一次握手,两者之间就可以创建持久性的连接, 并进行双向数据传输。


而握手这一过程是基于 HTTP 实现的,利用 HTTP 本身的协议升级特性,伪装成 HTTP,这样就能绕过浏览器沙箱、网络防火墙等限制,这也是 WebSocket 与 HTTP 的另一个重要关联点。所以 WebSocket 的握手没有使用 TCP,而是使用一个标准的 HTTP GET 请求,但要带上两个表示协议升级的专用头字段。


  • Connection: Upgrade,表示要求协议升级;
  • Upgrade: websocket,表示要升级成 WebSocket 协议;


另外,为了防止普通的 HTTP 消息被意外识别成 WebSocket,握手消息还增加了两个额外的用于认证的头字段。


  • Sec-WebSocket-Key:一个 Base64 编码的 16 字节随机数,作为简单的认证密钥;
  • Sec-WebSocket-Version:协议的版本号,当前必须是 13


服务端收到 HTTP 请求报文,看到上面的四个字段,就知道这不是一个普通的 GET 请求了,而是 WebSocket 的升级请求,也就是握手。于是就不走普通的 HTTP 处理流程,而是构造一个特殊的 101 Switching Protocols 响应报文,通知客户端,接下来就不用 HTTP 了,全改用 WebSocket 协议进行通信,有点像 TLS 的 Change Cipher Spec。


当然 WebSocket 的握手响应报文也是有特殊格式的,要包含以下字段:


  • Connection: Upgrade,表示同意协议升级;
  • Upgrade: websocket,表示同意升级成 WebSocket 协议;
  • Sec-WebSocket-Accept


返回的响应头中还要额外添加一个字段 Sec-WebSocket-Accept,用于验证客户端请求报文,同样也是为了防止误连接。具体的做法是把请求头里 Sec-WebSocket-Key 的值,加上一个专用的 UUID:258EAFA5-E914-47DA-95CA-C5AB0DC85B11(也被称为魔法字符串),计算 SHA-1 摘要,再进行 base64 编码。然后将编码的结果,作为 Sec-WebSocket-Accept 字段的值。

b64encode(
    sha1(Sec-WebSocket-Key + 
         b'258EAFA5-E914-47DA-95CA-C5AB0DC85B11')
)

客户端收到响应报文,就可以用同样的算法进行计算,然后对比值是否相等。如果相等,就说明认证成功,握手完成,后续传输的数据就不再是 HTTP 报文,而是 WebSocket 格式的二进制帧了。


并且握手在建立完成之后,后续就直接走 TCP 协议了,只有在握手的时候才会使用 HTTP 协议。所以 WebSocket 和 HTTP 是平级的,都是应用层传输协议。


然后就是重点了,WebSocket 传输的数据长什么样子呢?首先 WebSocket 和 HTTP/2 一样,用的都是二进制帧,不过两者的关注点不同。WebSocket 侧重于实时通信,而 HTTP/2 更侧重于提高传输效率,因此 WebSocket 没有像 HTTP/2 那样定义流,也就不存在多路复用、优先级等复杂的特性。所以 WebSocket 的帧结构要比 HTTP/2 简单很多,下面看一下它的结构。


169c66e87dce68a0b660481b6d875d27.png

首先是 data[0],也就是第一个字节:

它的第一位是 FIN,也就是消息结束的标志位,相当于 HTTP/2 里的 END_STREAM,表示数据发送完毕。因为一个消息可以拆成多个帧,接收方看到 FIN 后,就可以把前面的帧拼起来,组成完整的消息。FIN 后面的三个位是保留位,目前没有任何意义,但必须是 0。


data[0] 的后四位叫 OPCODE(操作码),说白了就是帧类型,比如 1 表示帧内容是纯文本,2 表示帧内容是二进制数据,8 是关闭连接,9 和 10 分别是连接保活的 PING 和 PONG。


然后是 data[1],也就是第二个字节:

它的第一个位是掩码标志位 MASK,表示帧内容是否使用异或操作(xor)做简单的加密,当该位被设置为 1 时表示加密,设置为 0 时表示不加密。如果加密了,那么必须解密才能得到正确内容,而解密规则也很简单,一会儿代码中有体现。目前的 WebSocket 标准规定,客户端发送数据必须使用掩码加密,而服务器发送则不使用掩码加密。


data[1] 的后 7 位表示 Payload Length,也就是有效负载、或者说有效业务消息的长度,并且采用的是大端存储。所以在获取到字节流的时候,只需要让第二个字节和 0b0111_1111、即 127 做按位与,即可得到有效负载的长度。但这里就产生一个问题,因为一个字节的后 7 位最大能表示的数字就是 127,如果有效负载的长度大于 127 该怎么办?


所以 WebSocket 这里给出了一个规则,让 data[1]和 127 进行按位与,计算得到结果


  • 如果结果等于 127,那么由 data[2: 10] 表示有效负载的长度;
  • 如果结果等于 126,那么由 data[2: 4] 表示有效负载的长度;
  • 如果结果小于 126,那么由 data[1] & 127 表示有效负载的长度;


所以一个 WebSocket 帧的最大长度可以用 8 字节表示。


然后长度字段后面是 Masking-key(掩码密钥),它是由上面的标志位 MASK 决定的。如果 MASK 为 1,那么 Masking-key 是一个 4 字节的随机数;如果 MASK 为 0,那么 Masking-key 不存在。所以对于客户端发送的数据而言:


  • 当 data[1] & 127 等于 127 时,那么由 data[10: 14] 表示 Masking-key;
  • 当 data[1] & 127 等于 126 时,那么由 data[4: 8] 表示 Masking-key;
  • 当 data[1] & 127 小于 126 时,那么由 data[2: 6] 表示 Masking-key;

Masking-key 后面就是有效负载了,也就是 Payload,比如客户端发送了一个字符串 "hello world",那么这个字符串就是有效负载,或者说有效的业务消息。


  • 当 data[1] & 127 等于 127 时,那么由 data[14:] 表示 Payload;
  • 当 data[1] & 127 等于 126 时,那么由 data[8:] 表示 Payload;
  • 当 data[1] & 127 小于 126 时,那么由 data[6:] 表示 Payload;

所以在不同情况下, WebSocket 的二进制帧格式如下:

63c56b945012290097c1306f160aa9af.png





手动模拟 WebSocket 服务端



那么下面,我们就可以通过 TCP 手动模拟 WebSocket 服务端接收请求了。

import asyncio
from asyncio import StreamReader, StreamWriter
from base64 import b64encode
from hashlib import sha1
from struct import pack, unpack
class WebSocket:
    def __init__(self, host="0.0.0.0", port=9999):
        self.host = host
        self.port = port
    @staticmethod
    def parse_request_headers(data: bytes) -> dict:
        """
        WebSocket 建立握手时,是一个标准的 HTTP GET 请求
        所以发送的数据格式就是一个普通的 HTTP 请求报文
        此函数负责从原始字节流中解析出请求头
        :param data:
        :return:
        """
        headers = data.split(b"\r\n\r\n")[0].split(b"\r\n")
        header_dict = {}
        for header in headers[1:]:
            key, val = header.decode("utf-8").split(":", 1)
            header_dict[key] = val.strip()
        return header_dict
    @staticmethod
    def make_response(request_headers: dict) -> bytes:
        """
        拿到请求头之后,还要给客户端一个响应
        该响应的起始行是 HTTP/1.1 101 Switching Protocols
        此函数负责基于客户端的请求头构造响应
        :param request_headers:
        :return:
        """
        response = (b"HTTP/1.1 101 Switching Protocols\r\n"
                    b"Connection: Upgrade\r\n"
                    b"Upgrade: websocket\r\n"
                    b"Sec-WebSocket-Accept: %s\r\n\r\n")
        # 魔法字符串,写死的
        magic_string = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
        val = (request_headers["Sec-WebSocket-Key"] + magic_string).encode("utf-8")
        # 进行 sha1 加密、计算摘要,然后进行 base64 编码
        response = response % b64encode(sha1(val).digest())
        return response
    @staticmethod
    def parse_recv_message(data: bytes) -> tuple:
        """
        如果握手完成,那么后续就直接走 TCP 协议了
        客户端发送的数据也不再是 HTTP 报文
        而是 WebSocket 的二进制帧
        此函数负责对帧进行解析,拿到有效的实体数据、即有效负载
        :param data: 客户端发送的字节流
        :return:
        """
        # 计算有效负载的长度
        payload_len = data[1] & 127
        if payload_len == 127:
            # data[2: 10] 真正表示有效负载的长度
            # 但它是一个字节串,要用 struct.unpack 进行解析
            # 由于是 8 字节,所以解析的结果是一个 uint64_t
            extended_payload_len = unpack(">Q", data[2: 10])
            # data[10: 14] 表示 masking_key
            masking_key = data[10: 14]
            # data[14:] 表示有效负载
            payload = data[14:]
        elif payload_len == 126:
            # data[2: 4] 真正表示有效负载的长度
            # 由于是 2 字节,所以解析的结果是一个 uint16_t
            extended_payload_len = unpack(">H", data[2: 4])
            # data[4: 8] 表示 masking_key
            masking_key = data[4: 8]
            # data[8:] 表示有效负载
            payload = data[8:]
        else:
            # 显然此时有效负载的长度就是 payload_len
            extended_payload_len = payload_len
            # data[2: 6] 表示 masking_key
            masking_key = data[2: 6]
            # data[6:] 表示有效负载
            payload = data[6:]
        # 但我们说 payload 是经过加密的,使用之前要进行解密
        # 做法也很简单:将每个字节 和 masking_key[i % 4] 做异或操作即可
        # 其中 i 是该字节所在的索引
        recv_message = bytes(
            [char ^ masking_key[i % 4] for i, char in enumerate(payload)]
        )
        return extended_payload_len, recv_message
    @staticmethod
    def make_send_message(extended_payload_len: int,
                          recv_message: bytes) -> bytes:
        """
        收到消息之后,我们还要客户端返回消息
        此函数负责构造返回给客户端的消息
        :param extended_payload_len: 客户端发来的消息的长度
        :param recv_message: 客户端发来的消息的实体
        :return:
        """
        # 对客户端发来的消息进行简单的封装,然后返回回去
        send_message = (f"消息长度: {extended_payload_len}, "
                        f"消息内容: ").encode("utf-8") + recv_message
        # 但是需要注意,这里的 send_message 不能直接发
        # 否则我们就没有必要单独再写一个方法了
        # 虽然服务端返回的数据不要求加密,但也是有格式的
        length = len(send_message)
        # 第一个字节必须是 b"\x81"
        token = b"\x81"
        if length < 126:
            # 如果长度小于 126,那么直接加上 length 即可
            # 但是要将 length 打包成字节流,并且是大端
            token += pack(">B", length)
        elif length < 0xFFFF:
            # 如果长度小于 65535,length 占两字节
            # 那么将 126 和 length 以大端方式打包成字节流
            token += pack(">BH", 126, length)
        else:
            # 否则长度按照 8 字节算
            # 那么将 126 和 length 以大端方式打包成字节流
            token += pack(">BQ", 126, length)
        # 然后再加上要发送的消息
        send_message = token + send_message
        return send_message
    async def handler_requests(self,
                               reader: StreamReader,
                               writer: StreamWriter):
        """
        负责处理来自客户端的请求
        每来一个客户端连接,就会基于此函数创建一个协程
        并且自动传递两个参数:reader 和 writer
        reader.read  负责读取数据,等价于 socket.recv
        writer.write 负责发送数据,等价于 socket.send
        :param reader:
        :param writer:
        :return:
        """
        # 第一次读取数据显然是在握手阶段
        # 显然读到的数据是一个 HTTP 报文
        data = await reader.readuntil(b"\r\n\r\n")
        # 解析出请求头
        request_headers = self.parse_request_headers(data)
        # 基于请求头构造出响应
        response = self.make_response(request_headers)
        # 写入数据,返回给客户端
        writer.write(response)
        await writer.drain()
        # 连接一旦建立,后续客户端发送的数据就不再是 HTTP 报文格式
        # 而是 WebSocket 定义的二进制帧
        while data := await reader.read(8096):
            # 当客户端关闭连接时,会再次发送一个 6 字节的数据
            # 并且 OPCODE 为 8,表示已断开连接,那么服务端也应断开连接
            if len(data) == 6 and data[0] & 0b1111 == 8:
                writer.close()
                break
            # 拿到客户端发送的数据,并进行解析
            extended_payload_len, recv_message = \
                self.parse_recv_message(data)
            # 生成要发送给客户端的数据
            send_message = self.make_send_message(extended_payload_len,
                                                  recv_message)
            writer.write(send_message)
            await writer.drain()
    async def __create_server(self):
        # 创建服务,第一个参数是一个回调函数
        # 当连接过来的时候就会根据此函数创建一个协程
        # 后面是绑定的 ip 和 端口
        server = await asyncio.start_server(self.handler_requests,
                                            self.host,
                                            self.port)
        # 然后开启无限循环
        async with server:
            await server.serve_forever()
    
    def run_server(self):
        loop = asyncio.get_event_loop()
        loop.run_until_complete(self.__create_server())
if __name__ == '__main__':
    websocket = WebSocket()
    websocket.run_server()

以上就是 WebSocket 服务端,下面我们来编写客户端:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
    <script>
        ws = new WebSocket("ws://localhost:9999");
        //如果连接成功, 会打印下面这句话, 否则不会打印
        ws.onopen = function () {
            console.log('连接成功')
        };
        //接收数据, 服务端有数据过来, 会执行
        ws.onmessage = function (event) {
            console.log(event)
        };
        //服务端主动断开连接, 会执行
        //客户端主动断开的话, 不执行
        ws.onclose = function () {  }
    </script>
</body>
</html>

下面我们来测试一下,用浏览器打开 HTML,然后启动控制台:


235af0828ca7328d6a5cdc9c82918ba9.png

可以看到输出结果一切正常,以上我们就手动实现了 WebSocket 服务端。当然了,很多 Web 框架都内置了对 WebSocket 的支持。而且 Python 有一个第三方库,就叫 websocket,它即可以充当服务端,也可以充当客户端。我们用这个库继续访问一下刚才的服务:

from websocket import WebSocket
ws = WebSocket()
ws.connect("ws://localhost:9999")
ws.send("你好呀")
print(ws.recv())
"""
消息长度: 9, 消息内容: 你好呀
"""
ws.send("古明地觉")
print(ws.recv())
"""
消息长度: 12, 消息内容: 古明地觉
"""

结果也是正常的。




小结



浏览器是一个沙箱环境,有很多的限制,不允许建立 TCP 连接收发数据,但 HTTP 的请求-响应通信模式又无法满足实时通信的要求。而有了 WebSocket,我们就可以在浏览器里与服务端直接建立 TCP 连接(但握手走的是 HTTP),获得更多的自由。只不过自由也是有代价的,WebSocket 虽然是在应用层,但使用方式却与 TCP Socket 差不多,过于原始,用户必须自己管理连接、缓存、状态,开发上比 HTTP 复杂的多,所以是否要在项目中引入 WebSocket 必须慎重考虑。


7b689513437cd2981f87c50271c29efa.png




本文章参考自:


  • 极客时间,罗剑锋《透视 HTTP 协议》
相关文章
|
前端开发 网络协议 API
什么是WebSocket?
WebSocket是一种在单个TCP连接上进行全双工通信的协议。WebSocket通信协议于2011年被IETF定为标准RFC 6455,并由RFC7936补充规范。WebSocket API也被W3C定为标准。
1986 0
什么是WebSocket?
|
7天前
|
移动开发 网络协议 前端开发
H5与WebSocket
H5与WebSocket
|
2月前
|
网络协议 API 数据安全/隐私保护
websocket初识
websocket初识
30 2
|
5月前
|
XML JSON 前端开发
WebSocket是什么
【4月更文挑战第27天】WebSocket,即Web浏览器与Web服务器之间全双工通信标准。
|
5月前
|
移动开发 网络协议 前端开发
WebSocket一
WebSocket一
|
5月前
|
移动开发 网络协议 安全
Websocket
Websocket
|
资源调度 网络协议 JavaScript
[NsetJs] 使用websocket简单介绍
WebSocket 是一种基于 TCP 的协议,它提供了双向实时通信的能力。相比传统的 HTTP 协议,WebSocket 具有更低的延迟和更高的性能。以下是 WebSocket 的一些特点和优势:
113 0
|
移动开发 网络协议 前端开发
WebSocket理解
WebSocket理解
|
网络协议 定位技术 PHP
【说说你对webSocket的理解?】
【说说你对webSocket的理解?】
|
网络协议 数据安全/隐私保护 Windows
了解WebSocket
熟悉下websocket协议的相关原理和优缺点
332 0
了解WebSocket