WebSocket 从入门到写出开源库

前言

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

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

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

前段时间须要经过 WebSocket 爬取一些数据,网上文章介绍中,都是使用了 websocket-client 这个库。但个人项目是异步的,我但愿 websocket 数据读取也可以是异步的,而后我在 github 上搜索到了 websockets 这个库,在使用和源码阅读中,我发现 websockets 仍然不是我认为理想的库,因此我决定本身开发一个异步的 WebSocket 链接客户端(async websocket client)。github

这一次我就跟你们分享 WebSocket 协议知识以及介绍个人开源库 aiowebsocket。web

WebSocket 协议和知识

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

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状态码进行握手。bash

为了建立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、RSV一、RSV二、RSV三、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 那就更美了。

相关文章
相关标签/搜索