阅读 2158

WebSocket 从入门到写出开源库

前言

我已经 2 个月没有发文了,看到有人问: '那个专注爬虫小奎因去哪了?',我就赶紧跳出来了。

另外说明一下,德玛西亚之翼-奎因这个 ID 现在换成了 AsyncIns

我计划在今年的夏天去北京,在去之前我需要做好技术准备,所以最近一直是在学习。我的学习方式很简单明了:看文档、读源码、造轮子。造轮子是我认为能让人进步的最快、最有效的方法。

前段时间需要通过 WebSocket 爬取一些数据,网上文章介绍中,都是使用了 websocket-client 这个库。但我的项目是异步的,我希望 websocket 数据读取也能够是异步的,然后我在 github 上搜索到了 websockets 这个库,在使用和源码阅读中,我发现 websockets 仍然不是我认为理想的库,所以我决定自己开发一个异步的 WebSocket 连接客户端(async websocket client)。

这一次我就跟大家分享 WebSocket 协议知识以及介绍我的开源库 aiowebsocket。

WebSocket 协议和知识

WebSocket是一种在单个TCP连接上进行全双工通信的协议。WebSocket通信协议于2011年被IETF定为标准RFC 6455,并由RFC7936补充规范。WebSocket API也被W3C定为标准。

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

为什么会有 WebSocket

以前,很多网站为了实现推送技术,所用的技术都是轮询。轮询是在特定的的时间间隔(如每1秒),由浏览器对服务器发出HTTP请求,然后由服务器返回最新的数据给客户端的浏览器。这种传统的模式带来很明显的缺点,即浏览器需要不断的向服务器发出请求,然而HTTP请求可能包含较长的头部,其中真正有效的数据可能只是很小的一部分,显然这样会浪费很多的带宽等资源。 而比较新的技术去做轮询的效果是Comet。这种技术虽然可以双向通信,但依然需要反复发出请求。而且在Comet中,普遍采用的长链接,也会消耗服务器资源。 在这种情况下,HTML5定义了WebSocket协议,能更好的节省服务器资源和带宽,并且能够更实时地进行通讯。

WebSocket 有什么优点

开销少、时时性高、二进制支持完善、支持扩展、压缩更优。

  • 较少的控制开销。在连接创建后,服务器和客户端之间交换数据时,用于协议控制的数据包头部相对较小。在不包含扩展的情况下,对于服务器到客户端的内容,此头部大小只有2至10字节(和数据包长度有关);对于客户端到服务器的内容,此头部还需要加上额外的4字节的掩码。相对于HTTP请求每次都要携带完整的头部,此项开销显著减少了。
  • 更强的实时性。由于协议是全双工的,所以服务器可以随时主动给客户端下发数据。相对于HTTP请求需要等待客户端发起请求服务端才能响应,延迟明显更少;即使是和Comet等类似的长轮询比较,其也能在短时间内更多次地传递数据。 保持连接状态。与HTTP不同的是,Websocket需要先创建连接,这就使得其成为一种有* 状态的协议,之后通信时可以省略部分状态信息。而HTTP请求可能需要在每个请求都携带状态信息(如身份认证等)。
  • 更好的二进制支持。Websocket定义了二进制帧,相对HTTP,可以更轻松地处理二进制内容。
  • 可以支持扩展。Websocket定义了扩展,用户可以扩展协议、实现部分自定义的子协议。如部分浏览器支持压缩等。
  • 更好的压缩效果。相对于HTTP压缩,Websocket在适当的扩展支持下,可以沿用之前内容的上下文,在传递类似的数据时,可以显著地提高压缩率。

握手是怎么回事?

WebSocket 是独立的、创建在 TCP 上的协议。

Websocket 通过HTTP/1.1 协议的101状态码进行握手。

为了创建Websocket连接,需要通过浏览器发出请求,之后服务器进行回应,这个过程通常称为“握手”(handshaking)。

WebSocket 协议规范

WebSocket 是一个通信协议,它规定了一些规范和标准。它的协议标准为 RFC 6455,具体的协议内容可以在tools.ietf.org中查看。

协议共有 14 个部分,其中包括协议背景与介绍、握手、设计理念、术语约定、双端要求、掩码以及连接关闭等内容。

双端交互流程

客户端与服务端交互流程如下所示:

客户端 - 发起握手请求 - 服务器接到请求后返回信息 - 连接建立成功 - 消息互通

所以,要解决的第一个问题就是握手问题。

握手 - 客户端

关于握手标准,在协议中有说明:

The opening handshake is intended to be compatible with HTTP-based server-side software and intermediaries, so that a single port can be used by both HTTP clients talking to that server and WebSocket clients talking to that server. To this end, the WebSocket client's handshake is an HTTP Upgrade request:

    GET /chat HTTP/1.1
    Host: server.example.com
    Upgrade: websocket
    Connection: Upgrade
    Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
    Origin: http://example.com
    Sec-WebSocket-Protocol: chat, superchat
    Sec-WebSocket-Version: 13
复制代码

In compliance with [RFC2616], header fields in the handshake may be sent by the client in any order, so the order in which different header fields are received is not significant.

WebSocket 握手时使用的并不是 WebSocket 协议,而是 HTTP 协议,握手时发出的请求可以叫做升级请求。客户端在握手阶段通过:

Upgrade: websocket
Connection: Upgrade
复制代码

Connection 和 Upgrade 这两个头域告知服务端,要求将通信的协议转换为 websocket。其中 Sec-WebSocket-Version、Sec-WebSocket-Protocol 这两个头域表明通信版本和协议约定, Sec-WebSocket-Key 则作为一个防止无端连接的保障(其实并没有什么保障作用,因为 key 的值完全由客户端控制,服务端并无验证机制),其他几个头域则与 HTTP 协议的作用一致。

握手 - 服务端

刚才只是客户端发出一个 HTTP 请求,表明想要握手,服务端需要对信息进行验证,确认以后才算握手成功(连接建立成功,可以双向通信),然后服务端会给客户端回复:"小老弟你好,没有内鬼,连接达成!"

服务端需要回复什么内容呢?

Status Code: 101 Web Socket Protocol Handshake
Sec-WebSocket-Accept: T5ar3gbl3rZJcRmEmBT8vxKjdDo=
Upgrade: websocket
Connection: Upgrade
复制代码

首先,服务端会给出状态码,101 状态码表示服务器已经理解了客户端的请求,并且回复 Connection 和 Upgrade 表示已经切换成 websocket 协议。Sec-WebSocket-Accept 则是经过服务器确认,并且加密过后的 Sec-WebSocket-Key。

这样,客户端与服务端就完成了握手操作,达成一致,使用 WebSocket 协议进行通信。

你来我往 - 数据交流

双方握手成功并确认协议后,就可以互相发送信息了。它们的信息是如何发送的呢?难道是:

client: Hello, server boy

server: Hello, client girl
复制代码

跟我们在微信和 QQ 中发信息是一样的吗?

虽然我们看到的信息是这样的,但是在传输过程中可不是这样子的。传输这部也有相应的规定:

In the WebSocket Protocol, data is transmitted using a sequence of frames. To avoid confusing network intermediaries (such as intercepting proxies) and for security reasons that are further discussed in Section 10.3, a client MUST mask all frames that it sends to the server (see Section 5.3 for further details). (Note that masking is done whether or not the WebSocket Protocol is running over TLS.) The server MUST close the connection upon receiving a frame that is not masked. In this case, a server MAY send a Close frame with a status code of 1002 (protocol error) as defined in Section 7.4.1. A server MUST NOT mask any frames that it sends to the client. A client MUST close a connection if it detects a masked frame. In this case, it MAY use the status code 1002 (protocol error) as defined in Section 7.4.1. (These rules might be relaxed in a future specification.)

The base framing protocol defines a frame type with an opcode, a payload length, and designated locations for "Extension data" and "Application data", which together define the "Payload data". Certain bits and opcodes are reserved for future expansion of the protocol.

协议中规定传输时并不是直接使用 unicode 编码进行传输,而是使用帧(frame),数据帧协议定义了带有操作码的帧类型,有效载荷长度,以及“扩展数据”和的指定位置应用程序数据”,它们共同定义“有效载荷数据”。某些位和操作码保留用于将来的扩展协议。

数据帧的格式如图所示:

帧由以下几部分组成: FIN、RSV1、RSV2、RSV3、opcode、MASK、Payload length、Masking-key、Payload-Data。它们的含义和作用如下:

1.FIN: 占 1bit

0:不是消息的最后一个分片

1:是消息的最后一个分片
复制代码

2.RSV1, RSV2, RSV3:各占 1bit

一般情况下全为 0。当客户端、服务端协商采用 WebSocket 扩展时,这三个标志位可以非 0,且值的含义由扩展进行定义。如果出现非零的值,且并没有采用 WebSocket 扩展,连接出错。

3.Opcode: 4bit

%x0:表示一个延续帧。当 Opcode 为 0 时,表示本次数据传输采用了数据分片,当前收到的数据帧为其中一个数据分片;

%x1:表示这是一个文本帧(text frame);

%x2:表示这是一个二进制帧(binary frame);

%x3-7:保留的操作代码,用于后续定义的非控制帧;

%x8:表示连接断开;

%x9:表示这是一个心跳请求(ping);

%xA:表示这是一个心跳响应(pong);

%xB-F:保留的操作代码,用于后续定义的控制帧。
复制代码

4.Mask: 1bit

表示是否要对数据载荷进行掩码异或操作。

0:否

1:是
复制代码

5.Payload length: 7bit or (7 + 16)bit or (7 + 64)bit

表示数据载荷的长度。

0~126:数据的长度等于该值;

126:后续 2 个字节代表一个 16 位的无符号整数,该无符号整数的值为数据的长度;

127:后续 8 个字节代表一个 64 位的无符号整数(最高位为 0),该无符号整数的值为数据的长度。
复制代码

6.Masking-key: 0 or 4bytes

当 Mask 为 1,则携带了 4 字节的 Masking-key;

当 Mask 为 0,则没有 Masking-key。

掩码算法:按位做循环异或运算,先对该位的索引取模来获得 Masking-key 中对应的值 x,然后对该位与 x 做异或,从而得到真实的 byte 数据。
复制代码

注意:掩码的作用并不是为了防止数据泄密,而是为了防止早期版本的协议中存在的代理缓存污染攻击(proxy cache poisoning attacks)等问题。

7.Payload Data: 载荷数据

双端接收到数帧之后,就可以根据数据帧各个位置的值进行处理或信息提取。

掩码

这里要注意的是从客户端向服务端发送数据时,需要对数据进行掩码操作;从服务端向客户端发送数据时,不需要对数据进行掩码操作。如果服务端接收到的数据没有进行过掩码操作,服务端需要断开连接。如果Mask是1,那么在Masking-key中会定义一个掩码键(masking key),并用这个掩码键来对数据载荷进行反掩码。所有客户端发送到服务端的数据帧,Mask都是1。

保持连接

刚才提到 WebSocket 协议是双向通信的,那么一旦连接上,就不会断开了吗?

事实上确实是这样,但是服务端不可能让所有的连接都一直保持,所以服务端通常会在一个定期的时间给客户端发送一个 ping 帧,而客户端收到 Ping 帧后则回复一个 Pong 帧,如果客户端不响应,那么服务端就会主动断开连接。

opcode 帧为 0x09 则代表这是一个 Ping ,为 0x0A 则代表这是一个 Pong。

WebSocket 协议学习小结

WebSocket 的协议写得比较规范,比较容易阅读和理解。只要遵循协议中的规定,就可以实现稳定的通信连接和数据传输。

aiowebsocket 设计

基于对协议的学习,我编了一个开源的异步 WebSocket 库 - aiowebsocket,它的文件结构和类的设计如下图所示:

aiowebsocket

aiowebsocket 是一个比同类型库更快、更轻、更灵活的 WebSocket 客户端,它基于 asyncio 开并具备了与 websocket-client 和 websockets 库简单易用的特点。这是我用 7 天时间学习 WebSocket 知识以及 Python 文档 Stream 知识的成果。

安装与使用

安装:跟其他库一样,你可以通过 pip 进行安装:pip install aiowebsocket,也可以在 github 上 clone 到本地使用。

使用:WebSocket 协议的简写是 ws,它与 http/https 类似,具有更安全的协议 wss。使用上的区别并不大,只需要在创建连接时打开 ssl 即可。

ws 协议示例代码:

import asyncio
import logging
from datetime import datetime
from aiowebsocket.converses import AioWebSocket


async def startup(uri):
    async with AioWebSocket(uri) as aws:
        converse = aws.manipulator
        message = b'AioWebSocket - Async WebSocket Client'
        while True:
            await converse.send(message)
            print('{time}-Client send: {message}'
                  .format(time=datetime.now().strftime('%Y-%m-%d %H:%M:%S'), message=message))
            mes = await converse.receive()
            print('{time}-Client receive: {rec}'
                  .format(time=datetime.now().strftime('%Y-%m-%d %H:%M:%S'), rec=mes))


if __name__ == '__main__':
    remote = 'ws://echo.websocket.org'
    try:
        asyncio.get_event_loop().run_until_complete(startup(remote))
    except KeyboardInterrupt as exc:
        logging.info('Quit.')
复制代码

运行后就会得到如下结果:

2019-03-04 15:11:25-Client send: b'AioWebSocket - Async WebSocket Client'
2019-03-04 15:11:25-Client receive: b'AioWebSocket - Async WebSocket Client'
2019-03-04 15:11:25-Client send: b'AioWebSocket - Async WebSocket Client'
2019-03-04 15:11:25-Client receive: b'AioWebSocket - Async WebSocket Client'
复制代码

这代表客户端与服务连接成功并正常通信。

wss 协议示例代码:

# 开启 ssl 即可
import asyncio
import logging
from datetime import datetime
from aiowebsocket.converses import AioWebSocket


async def startup(uri):
    async with AioWebSocket(uri, ssl=True) as aws:
        converse = aws.manipulator
        message = b'AioWebSocket - Async WebSocket Client'
        while True:
            await converse.send(message)
            print('{time}-Client send: {message}'
                  .format(time=datetime.now().strftime('%Y-%m-%d %H:%M:%S'), message=message))
            mes = await converse.receive()
            print('{time}-Client receive: {rec}'
                  .format(time=datetime.now().strftime('%Y-%m-%d %H:%M:%S'), rec=mes))


if __name__ == '__main__':
    remote = 'wss://echo.websocket.org'
    try:
        asyncio.get_event_loop().run_until_complete(startup(remote))
    except KeyboardInterrupt as exc:
        logging.info('Quit.')
复制代码

运行结果与上方运行结果类似。除此之外,aiowebsocket 还允许自定义请求头,在连接一些需要校验 origin、user-agent 和 host 头域信息的网站时,自定义请求头就非常有用了:

import asyncio
import logging
from datetime import datetime
from aiowebsocket.converses import AioWebSocket


async def startup(uri, header):
    async with AioWebSocket(uri, headers=header) as aws:
        converse = aws.manipulator
        message = b'AioWebSocket - Async WebSocket Client'
        while True:
            await converse.send(message)
            print('{time}-Client send: {message}'
                  .format(time=datetime.now().strftime('%Y-%m-%d %H:%M:%S'), message=message))
            mes = await converse.receive()
            print('{time}-Client receive: {rec}'
                  .format(time=datetime.now().strftime('%Y-%m-%d %H:%M:%S'), rec=mes))


if __name__ == '__main__':
    remote = 'ws://123.207.167.163:9010/ajaxchattest'
    header = [
        'GET /ajaxchattest HTTP/1.1',
        'Connection: Upgrade',
        'Host: 123.207.167.163:9010',
        'Origin: http://coolaf.com',
        'Sec-WebSocket-Key: RmDgZzaqqvC4hGlWBsEmwQ==',
        'Sec-WebSocket-Version: 13',
        'Upgrade: websocket',
        ]
    try:
        asyncio.get_event_loop().run_until_complete(startup(remote, header))
    except KeyboardInterrupt as exc:
        logging.info('Quit.')

复制代码

ws://123.207.167.163:9010/ajaxchattest 是一个免费的、开放的 WebSocket 连接测试接口,它在握手阶段会校验 origin 头域,如果不符合规范则不允许客户端连接。

项目 Github 地址为

https://github.com/asyncins/aiowebsocket

欢迎各位前去 star ,如果能给出建议或者发现 bug 那就更美了。

关注下面的标签,发现更多相似文章
评论