MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

WebSocket协议详解及其应用场景

2023-03-133.5k 阅读

WebSocket协议基础

WebSocket协议的诞生背景

在传统的HTTP协议下,客户端与服务器之间的通信是基于请求 - 响应模式的。这种模式在很多场景下是高效的,比如用户请求网页资源,服务器返回对应的HTML、CSS、JavaScript文件等。然而,当应用场景涉及到实时数据交互,如在线聊天、实时游戏、股票行情实时推送等时,HTTP协议的局限性就凸显出来了。

传统的实现方式,例如轮询(Polling)和长轮询(Long - Polling)。轮询是指客户端定时向服务器发送请求,询问是否有新的数据。这种方式的缺点很明显,频繁的请求会增加服务器和网络的负担,并且数据获取存在一定的延迟,因为只有在下次轮询时才能获取到新数据。长轮询则是客户端发送一个请求后,服务器如果没有新数据,不会立即响应,而是保持连接,直到有新数据或者连接超时才响应。虽然长轮询减少了请求次数,但仍然存在连接频繁建立和断开的问题,并且在连接超时时还需要重新发起请求。

WebSocket协议应运而生,它旨在解决HTTP协议在实时双向通信方面的不足。WebSocket提供了一种在单个TCP连接上进行全双工通信的机制,使得客户端和服务器可以在任何时刻相互发送消息,实现真正意义上的实时通信。

WebSocket协议概述

WebSocket协议是一种基于TCP的应用层协议,它通过HTTP协议进行握手,建立连接后就可以在TCP连接上进行双向通信。WebSocket协议的URL格式如下:

ws://host:port/path
wss://host:port/path

其中,ws 表示非加密的WebSocket连接,类似于HTTP;wss 表示加密的WebSocket连接,类似于HTTPS。

WebSocket协议在设计上尽可能地与现有Web技术兼容,这使得它可以很容易地集成到现有的Web应用中。它复用了HTTP的一些概念,如握手过程,但又在TCP连接之上建立了自己的通信机制。

WebSocket协议握手过程

  1. 客户端发起握手请求 客户端通过HTTP协议向服务器发送一个特殊的HTTP请求,这个请求包含了一些WebSocket特定的头部字段,用于表明这是一个WebSocket握手请求。以下是一个典型的WebSocket握手请求示例:
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec - WebSocket - Key: dGhlIHNhbXBsZSBub25jZQ==
Sec - WebSocket - Version: 13
  • Upgrade: websocketConnection: Upgrade 头字段表明客户端希望将当前的HTTP连接升级为WebSocket连接。
  • Sec - WebSocket - Key 是一个随机生成的Base64编码字符串,用于服务器验证请求的合法性。
  • Sec - WebSocket - Version 表示客户端支持的WebSocket协议版本,目前最新版本是13。
  1. 服务器响应握手请求 服务器收到握手请求后,如果请求合法,会返回一个HTTP响应,同样包含一些WebSocket特定的头部字段。示例如下:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec - WebSocket - Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
  • HTTP/1.1 101 Switching Protocols 状态码表示服务器同意将连接升级为WebSocket连接。
  • Sec - WebSocket - Accept 字段的值是通过将客户端发送的 Sec - WebSocket - Key 加上一个固定字符串 258EAFA5 - E914 - 47DA - 95CA - C5AB0DC85B11,然后进行SHA - 1哈希计算,最后进行Base64编码得到的。这个值用于验证服务器的响应是对客户端请求的合法回应。
  1. 连接建立 一旦客户端收到服务器的握手响应并且验证通过,WebSocket连接就建立成功了。此后,客户端和服务器之间就可以通过这个TCP连接进行全双工通信,不再受HTTP请求 - 响应模式的限制。

WebSocket协议数据帧格式

数据帧的基本结构

WebSocket协议通过数据帧(Frame)来传输数据。每个数据帧由以下几部分组成:

  1. Opcode(操作码):4位,用于标识数据帧的类型。常见的Opcode值有:
    • 0x0:表示这是一个延续帧(Continuation Frame),用于将一个较大的消息拆分成多个帧传输。
    • 0x1:表示文本帧(Text Frame),数据是UTF - 8编码的文本。
    • 0x2:表示二进制帧(Binary Frame),数据是二进制数据。
    • 0x8:表示关闭连接帧(Close Frame),用于关闭WebSocket连接。
    • 0x9:表示ping帧(Ping Frame),用于客户端或服务器向对方发送心跳检测消息。
    • 0xA:表示pong帧(Pong Frame),是对ping帧的响应,用于确认收到ping帧。
  2. FIN(结束标志):1位,用于表示这是否是一个消息的最后一帧。如果 FIN = 1,表示这是最后一帧;如果 FIN = 0,则表示后面还有延续帧。
  3. Mask(掩码标志):1位,对于客户端发送给服务器的帧,这个位必须设置为1,并且客户端需要对数据进行掩码处理;对于服务器发送给客户端的帧,这个位必须设置为0。
  4. Payload length(负载长度):7位、7 + 16位或7 + 64位,取决于负载的长度。如果值小于126,那么这7位直接表示负载的长度;如果值为126,则接下来的2个字节表示负载长度;如果值为127,则接下来的8个字节表示负载长度。
  5. Masking key(掩码密钥):如果 Mask = 1,则有4个字节的掩码密钥,用于对负载数据进行掩码和解掩码操作。
  6. Payload data(负载数据):实际传输的数据内容。

数据帧的组装与解析示例(以Python为例)

下面通过Python代码示例来展示如何组装和解析WebSocket数据帧。我们使用 websockets 库,它是Python中常用的WebSocket库。

import websockets
import asyncio
import struct


# 解析WebSocket数据帧
async def parse_frame(frame):
    fin = frame[0] & 0x80
    opcode = frame[0] & 0x0F
    mask = frame[1] & 0x80
    payload_length = frame[1] & 0x7F

    if payload_length == 126:
        payload_length = struct.unpack('!H', frame[2:4])[0]
        offset = 4
    elif payload_length == 127:
        payload_length = struct.unpack('!Q', frame[2:10])[0]
        offset = 10
    else:
        offset = 2

    if mask:
        masking_key = frame[offset:offset + 4]
        offset += 4
        data = bytearray()
        for i in range(payload_length):
            data.append(frame[offset + i] ^ masking_key[i % 4])
    else:
        data = frame[offset:offset + payload_length]

    return fin, opcode, data


# 组装WebSocket数据帧
def assemble_frame(data, opcode=0x1, fin=1):
    frame = bytearray()
    frame.append((fin << 7) | opcode)

    payload_length = len(data)
    if payload_length < 126:
        frame.append(payload_length)
    elif payload_length < 65536:
        frame.append(126)
        frame.extend(struct.pack('!H', payload_length))
    else:
        frame.append(127)
        frame.extend(struct.pack('!Q', payload_length))

    frame.extend(data)
    return frame


async def main():
    async with websockets.connect('ws://localhost:8765') as websocket:
        # 发送数据帧
        message = "Hello, WebSocket!".encode('utf - 8')
        frame = assemble_frame(message)
        await websocket.send(frame)

        # 接收数据帧
        received_frame = await websocket.recv()
        fin, opcode, data = await parse_frame(received_frame)
        print(f"FIN: {fin}, Opcode: {opcode}, Data: {data.decode('utf - 8')}")


if __name__ == "__main__":
    asyncio.run(main())

在上述代码中,parse_frame 函数用于解析接收到的WebSocket数据帧,assemble_frame 函数用于组装要发送的数据帧。main 函数展示了如何连接到WebSocket服务器,发送一个数据帧并接收解析服务器返回的数据帧。

WebSocket协议的应用场景

在线聊天应用

在线聊天是WebSocket协议最常见的应用场景之一。无论是一对一聊天还是群聊,都需要实时地将消息从一方发送到另一方或多方。传统的HTTP轮询或长轮询方式无法满足即时性的要求,而WebSocket则可以实现实时双向通信。

以一个简单的基于WebSocket的在线聊天应用为例,服务器端可以使用Python的 websockets 库来搭建。

import asyncio
import websockets


connected_clients = set()


async def chat_handler(websocket, path):
    # 将新连接的客户端添加到集合中
    connected_clients.add(websocket)
    try:
        async for message in websocket:
            # 广播消息给所有连接的客户端
            for client in connected_clients:
                if client != websocket:
                    await client.send(message)
    finally:
        # 客户端断开连接时,从集合中移除
        connected_clients.remove(websocket)


start_server = websockets.serve(chat_handler, "localhost", 8765)

asyncio.get_event_loop().run_until_complete(start_server)
asyncio.get_event_loop().run_forever()

在客户端,可以使用JavaScript的 WebSocket 对象来连接到服务器并发送接收消息。

<!DOCTYPE html>
<html>

<head>
    <title>WebSocket Chat</title>
</head>

<body>
    <input type="text" id="messageInput" placeholder="Type your message">
    <button onclick="sendMessage()">Send</button>
    <div id="chatWindow"></div>

    <script>
        const socket = new WebSocket('ws://localhost:8765');

        socket.onopen = function (event) {
            console.log('Connected to the server');
        };

        socket.onmessage = function (event) {
            const chatWindow = document.getElementById('chatWindow');
            const messageElement = document.createElement('p');
            messageElement.textContent = event.data;
            chatWindow.appendChild(messageElement);
        };

        function sendMessage() {
            const messageInput = document.getElementById('messageInput');
            const message = messageInput.value;
            socket.send(message);
            messageInput.value = '';
        }

        socket.onclose = function (event) {
            console.log('Disconnected from the server');
        };
    </script>
</body>

</html>

在这个示例中,服务器端维护了一个连接的客户端集合,当接收到某个客户端的消息时,会将消息广播给其他所有客户端。客户端通过JavaScript的 WebSocket 对象与服务器建立连接,发送消息并在收到消息时更新聊天窗口。

实时游戏

实时游戏对实时性和低延迟要求极高。例如多人在线对战游戏,每个玩家的操作(如移动、攻击等)都需要实时地同步到其他玩家的客户端。WebSocket协议的全双工通信特性可以很好地满足这一需求。

假设我们开发一个简单的多人实时对战的网页游戏,服务器端可以使用Node.js和 ws 库来实现。

const WebSocket = require('ws');

const wss = new WebSocket.Server({ port: 8765 });

const players = {};

wss.on('connection', function connection(ws) {
    const playerId = Date.now().toString();
    players[playerId] = { ws, x: 0, y: 0 };

    ws.on('message', function incoming(message) {
        const data = JSON.parse(message);
        if (data.type === 'move') {
            players[playerId].x = data.x;
            players[playerId].y = data.y;
            // 广播玩家位置给其他玩家
            for (const id in players) {
                if (id !== playerId) {
                    players[id].ws.send(JSON.stringify({
                        type: 'playerMove',
                        playerId,
                        x: players[playerId].x,
                        y: players[playerId].y
                    }));
                }
            }
        }
    });

    ws.on('close', function () {
        delete players[playerId];
    });
});

在客户端,使用JavaScript的 WebSocket 对象来连接服务器并处理游戏逻辑。

<!DOCTYPE html>
<html>

<head>
    <title>Real - Time Game</title>
    <style>
        #gameCanvas {
            border: 1px solid black;
        }
    </style>
</head>

<body>
    <canvas id="gameCanvas" width="800" height="600"></canvas>

    <script>
        const socket = new WebSocket('ws://localhost:8765');
        const canvas = document.getElementById('gameCanvas');
        const ctx = canvas.getContext('2d');

        const players = {};

        socket.onopen = function () {
            console.log('Connected to the game server');
        };

        socket.onmessage = function (event) {
            const data = JSON.parse(event.data);
            if (data.type === 'playerMove') {
                players[data.playerId] = { x: data.x, y: data.y };
                drawPlayers();
            }
        };

        function drawPlayers() {
            ctx.clearRect(0, 0, canvas.width, canvas.height);
            for (const id in players) {
                ctx.beginPath();
                ctx.arc(players[id].x, players[id].y, 10, 0, 2 * Math.PI);
                ctx.fillStyle = 'blue';
                ctx.fill();
            }
        }

        document.addEventListener('keydown', function (event) {
            let x = 0;
            let y = 0;
            if (event.key === 'ArrowUp') {
                y = -5;
            } else if (event.key === 'ArrowDown') {
                y = 5;
            } else if (event.key === 'ArrowLeft') {
                x = -5;
            } else if (event.key === 'ArrowRight') {
                x = 5;
            }
            if (x || y) {
                socket.send(JSON.stringify({ type: 'move', x: players[Object.keys(players)[0]].x + x, y: players[Object.keys(players)[0]].y + y }));
            }
        });
    </script>
</body>

</html>

在这个示例中,服务器端维护了玩家的信息,当接收到玩家的移动消息时,会广播给其他玩家。客户端通过监听键盘事件发送移动消息,并在收到其他玩家的移动消息时更新游戏画面。

股票行情实时推送

在金融领域,股票行情的实时推送对投资者来说至关重要。使用WebSocket协议,服务器可以实时地将股票价格、成交量等数据推送给客户端,客户端能够及时显示最新的行情信息。

以下是一个简单的股票行情实时推送示例,服务器端使用Java和 javax.websocket 库。

import javax.websocket.*;
import javax.websocket.server.ServerEndpoint;
import java.io.IOException;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;

@ServerEndpoint("/stock")
public class StockServer {
    private static final ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
    private Session session;

    @OnOpen
    public void onOpen(Session session) {
        this.session = session;
        scheduler.scheduleAtFixedRate(() -> {
            double stockPrice = Math.random() * 100; // 模拟股票价格
            try {
                session.getBasicRemote().sendText(String.valueOf(stockPrice));
            } catch (IOException e) {
                e.printStackTrace();
            }
        }, 0, 5, TimeUnit.SECONDS);
    }

    @OnClose
    public void onClose() {
        scheduler.shutdown();
    }
}

在客户端,使用JavaScript的 WebSocket 对象来接收股票行情数据。

<!DOCTYPE html>
<html>

<head>
    <title>Stock Quote</title>
</head>

<body>
    <div id="stockPrice"></div>

    <script>
        const socket = new WebSocket('ws://localhost:8080/stock');

        socket.onmessage = function (event) {
            const stockPriceDiv = document.getElementById('stockPrice');
            stockPriceDiv.textContent = `Current Stock Price: ${event.data}`;
        };
    </script>
</body>

</html>

在这个示例中,服务器端每隔5秒生成一个随机的股票价格并推送给客户端,客户端在收到数据时更新页面显示的股票价格。

WebSocket协议的优势与挑战

优势

  1. 实时性:WebSocket实现了全双工通信,客户端和服务器可以随时主动发送消息,无需像HTTP轮询那样等待特定的时机,大大提高了数据传输的实时性,适用于对实时性要求极高的应用场景,如在线聊天、实时游戏等。
  2. 减少网络开销:与HTTP轮询相比,WebSocket建立连接后使用单个TCP连接进行通信,避免了频繁的HTTP请求 - 响应带来的额外头部开销,减少了网络流量,提高了通信效率。
  3. 易于集成:WebSocket协议与现有Web技术兼容性好,它基于HTTP协议进行握手,在Web应用中可以很方便地集成,无论是前端使用JavaScript的 WebSocket 对象,还是后端使用各种编程语言的WebSocket库,都能相对轻松地实现WebSocket功能。

挑战

  1. 协议复杂性:虽然WebSocket协议的核心概念相对清晰,但数据帧格式、握手过程等细节较为复杂,在实现自定义的WebSocket服务器或客户端时,需要对协议有深入的理解,否则容易出现错误,比如数据帧解析错误、握手失败等问题。
  2. 安全性:WebSocket连接建立在TCP之上,虽然可以使用 wss 协议进行加密传输,但在实际应用中,仍然需要注意防范各种安全威胁,如跨站WebSocket劫持(CSWSH)等。需要采取适当的安全措施,如身份验证、授权等,来确保WebSocket通信的安全性。
  3. 兼容性:尽管大多数现代浏览器和服务器端框架都支持WebSocket协议,但在一些老旧的浏览器或特定的网络环境中,可能存在兼容性问题。在开发面向广泛用户的应用时,需要考虑如何处理这些兼容性问题,例如提供降级方案,当WebSocket不可用时,使用其他替代方案(如长轮询)来实现基本功能。

通过深入了解WebSocket协议及其应用场景,开发人员可以更好地利用这一强大的技术,为用户提供更加实时、高效的应用体验。在实际应用中,需要根据具体的业务需求和场景,合理地选择和使用WebSocket,并充分考虑其优势与挑战,以构建健壮、安全的应用程序。