认识 WebSocket

认识 WebSocket

即时通讯之路:WebSocket 的诞生

2005年,随着 AJAX 技术的诞生和应用,由网页向服务端请求数据并动态渲染页面已经成为可能。那么由服务端向网页推送数据是否可行?HTTP 是一种只能由客户端发送的单向传输协议,要获取连续实时的信息确实很难。人们开始了这种花式实时获取信息技术的探索。

依靠 HTTP 协议,比较广泛的方式是轮询。客户端定时不断地发送请求,服务端把最新的数据返回。轮询时间越短,消息就越实时。这种方式弊端很明显。由于 HTTP 是无状态的,每一次请求都是完整的 HTTP 请求,大量轮询会不断携带无用的 Header,浪费资源。此外,如果服务器的消息不太多,那么大部分轰炸式的轮询请求其实都是无用的,效率很低。在实时对战游戏等一些要求低延时的场景,这种定时请求的设计也是没办法满足要求的。

轮询

另一种技术是长轮询,这是对前面短轮询方案的一种改进。在长轮询技术中,客户端发送 HTTP 请求到服务端,服务端收到并保持此次连接。等到服务端有新的数据时再返回。接着客户端发起下一次请求,重新与服务器发起连接。

长轮询

此外,还有其他一些更加 Hack 的方案,比如 iframe、htmlfile 等,它们和长轮询可以合称 Comet。

这几种方案,都是无奈之举。因为 HTTP 机制限制,无法由服务端主动发起连接通知客户端,因此出现了这么多 Hack 方案。

为了解决这些问题,2008年,WebSocket 协议诞生了。这是一种建立在 TCP 连接上的全双工通讯协议,只要一次握手就可以不断发送和接收数据。有了 WebSocket 后,服务端和客户端之间终于可以双向实时通讯了。2011年,WebSocket 成为国际标准,并且被广泛支持。

WebSocket

不同于 HTTP 协议,WebSocket 解决了上述众多问题:

  • 被动性。HTTP 只能由客户端发送给服务端,而 WebSocket 可以借助自身的建立的 TCP 长连接通道,实现服务端主动推消息给客户端。
  • 无状态性。HTTP 在请求完成后,不会保存客户端的信息,再有 HTTP 请求时,还需要重新传输客户端的信息来告诉服务端自己是谁。而在 WebSocket 通信的整个过程中,服务器始终知道客户端的信息,所以无需再次携带自身信息,只需要传内容本体,因此可以大大节省通信流量。

如何进行一次 WebSocket 连接

建立连接

在 WebSocket 连接的握手阶段,客户端向服务器发送一个 HTTP 请求(这是 WebSocket 和 HTTP 之间唯一的联系):

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

服务器的回应:

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Protocol: chat

在客户端的发送请求中,核心的两个字段是 Connection: UpgradeUpgrade: websocket,Connection=Upgrade 头表示客户端希望连接升级,而 Upgrade=websocket 头表示希望升级的目标协议是 WebSocket。

此外,Sec-WebSocket-KeySec-WebSocket-Accept 是为了验证服务端是否真的支持 WebSocket 而做的一层校验,类似于签名机制。

握手完成后,服务端将 HTTP 协议升级为 WebSocket 协议,两端就可以互发数据了。

数据传输

前面说到,WebSocket 可以大大节省流量,因为它的每次请求不是通过 HTTP 承载的。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 ...                |
 +---------------------------------------------------------------+
  • Opcode:数据包的类型(中间包、text类型、binary类型、断开连接类型、ping-pong类型等)
  • Payload Data:数据内容

在数据传输方面,因为是一种双工通信,客户端和服务端的事件和方法基本相同,包括 OnOpen(连接成功)、OnError(连接失败)、Send(发送消息)、OnMessage(接收消息)、Close(关闭连接)、OnClose(连接被关闭)、Ping、Pong等。

关闭连接

和建立连接不同,关闭连接不需要发送 HTTP 请求,只需要主动关闭的一方在通道内发送一个关闭类型的数据包即可。在 Paylaod Data 中,可以进一步携带关闭连接的原因。

关闭连接

心跳机制

心跳机制不是必须的,但是十分有必要。如果网络足够好,只要长连接通道被建立起来,任何时候一端发送了消息,另一端一定可以收到;一端主动下线前,也能够通过长连接通道通知到另一端。但有些时候,由于网络不稳定等原因,会产生客户端或服务端突然掉线,但是没有通知给另一端的情况。而 TCP 的连接状态不是个物理状态,是由三次握手而确立的逻辑状态,因此另一端没有收到下线通知的情况下,这个逻辑连接一直是畅通的,直到下一次客户端主动发送消息才会发现另一端已经不在了。

客户端断连情况

心跳机制就是为了尽早发现物理断连,避免闲置占用而形成的机制。在 WebSocket 连接被建立后,在连接通道内一端定时发送 Ping 消息给另一端,另一端收到后返回内容(也可以叫 Pong)。如果主动发起的一端收到了 Pong 消息,就说明连接是正常的,反之连接就可能出现了问题。

心跳机制

Socket.io

客户端和服务端的 WebSocket 框架的 API 大都比较“原始”,除了收发消息、连接服务器等常用方法外都需要自己实现。而 Socket.io 是基于 WebSocket 的封装,也不仅仅是一个封装。为了解决可能的兼容性问题,在不支持 WebSocket 协议的情况下,Socket.io 支持自动降级为轮询等方案。此外,Socket.io 还提供了即开即用的广播、房间、命名空间等操作,也提供了自动维护心跳的方案。用 Socket.io 很大程度上降低了 WebSocket 的入门和使用门槛。

一个简单实现

服务器端(Node)

在 Node.js 中,可以使用 ws 快速开始一个服务器:

const WebSocket = require('ws')
const wss = new WebSocket.Server({ port: 8080 })

wss.on('connection', ws => {
  ws.on('message', message => {
    console.log('received: %s', message)
    // Broadcast
    wss.clients.forEach(client => {
      if (client !== ws && client.readyState === WebSocket.OPEN) {
        client.send(message)
      }
    })
  })
  ws.on('close', () => {
    console.log('closed')
  })
  ws.send('Connect success.')
})

客户端(JavaScript)

在浏览器中,可以使用 WebSocket 对象直接启动一个客户端程序:

const ws = new WebSocket('wss://echo.websocket.org')

ws.onopen = (event) => {
	console.log('Connection open.')
}

ws.onmessage = (event) => {
  console.log('Received Message: ' + event.data)
}

ws.onclose = function (event) {
  console.log('Connection closed.')
}

客户端(iOS)

iOS客户端中,客户端的选择同样有很多,比如 SocketRocket

@interface YourObject () <SRWebSocketDelegate>
@property (strong, nonatomic) SRWebSocket *socket;
@end
  
@implementation YourObject

- (void)initSocket {
    NSURL *url = [NSURL URLWithString:@"wss://echo.websocket.org"];
    SRWebSocket *socket = [[SRWebSocket alloc] initWithURL:url];
    socket.delegate = self;
    self.socket = socket;
    
    [self.socket open];
}

- (void)webSocket:(SRWebSocket *)webSocket didReceiveMessage:(id)message {
    NSLog(@"didReceiveMessage: %@", message);
}

- (void)webSocket:(SRWebSocket *)webSocket didFailWithError:(NSError *)error {
    NSLog(@"didFailWithError: %@", error);
}

- (void)webSocket:(SRWebSocket *)webSocket didCloseWithCode:(NSInteger)code reason:(NSString *)reason wasClean:(BOOL)wasClean {
    NSLog(@"didCloseWithCode: %@", @(code));
}

- (void)webSocketDidOpen:(SRWebSocket *)webSocket {
    NSLog(@"webSocketDidOpen: %@", webSocket);
  
    [self.socket send:@"Hello WebSocket"];
}

参考资料