WebSocket 的由来

  • 在 WebSocket 出现之前,我们想实现实时通信、变更推送、服务端消息推送功能,我们一般的方案是使用 Ajax 短轮询、长轮询两种方式:
  • 比如我们想实现一个服务端数据变更时,立即通知客户端功能,没有 WebSocket 之前我们可能会采用以下两种方案:短轮询或长轮询

短轮询、长轮询(来源:即时通讯网)

  • 上面两种方案都有比较明显的缺点:
    1. HTTP 协议包含的较长的请求头,有效数据只占很少一部分,浪费带宽
    2. 短轮询频繁轮询对服务器压力较大,即使使用长轮询方案,客户端较多时仍会对客户端造成不小压力

WebSocket 是什么

  • WebSocket 是一种网络传输协议,可在单个 TCP 连接上进行全双工通信,位于 OSI 模型的应用层。
  • WebSocket 使得客户端和服务器之间的数据交换变得更加简单,允许服务端主动向客户端推送数据。客户端和服务器只需要完成一次握手,两者之间就可以创建持久性的连接,并进行双向数据传输。

短轮询和WebSocket的区别(来源:即时通讯网)

WebSocket 优缺点

优点

  • 实时性: WebSocket 提供了双向通信,服务器可以主动向客户端推送数据,实现实时性非常高,适用于实时聊天、在线协作等应用。
  • 减少网络延迟: 与轮询和长轮询相比,WebSocket 可以显著减少网络延迟,因为不需要在每个请求之间建立和关闭连接。
  • 较小的数据传输开销: WebSocket 的数据帧相比于 HTTP 请求报文较小,减少了在每个请求中传输的开销,特别适用于需要频繁通信的应用。
  • 较低的服务器资源占用: 由于 WebSocket 的长连接特性,服务器可以处理更多的并发连接,相较于短连接有更低的资源占用。
  • 跨域通信: 与一些其他跨域通信方法相比,WebSocket 更容易实现跨域通信。

缺点

  • 连接状态保持: 长时间保持连接可能会导致服务器和客户端都需要维护连接状态,可能增加一些负担。
  • 不适用于所有场景: 对于一些请求-响应模式较为简单的场景,WebSocket 的实时特性可能并不是必要的,使用 HTTP 请求可能更为合适。
  • 复杂性: 与传统的 HTTP 请求相比,WebSocket 的实现和管理可能稍显复杂,尤其是在处理连接状态、异常等方面。

WebSocket 适用场景

  • 实时聊天应用: WebSocket 是实现实时聊天室、即时通讯应用的理想选择,因为它能够提供低延迟和高实时性。
  • 在线协作和协同编辑: 对于需要多用户协同工作的应用,如协同编辑文档或绘图,WebSocket 的实时性使得用户能够看到其他用户的操作。
  • 实时数据展示: 对于需要实时展示数据变化的应用,例如股票行情、实时监控系统等,WebSocket 提供了一种高效的通信方式。
  • 在线游戏: 在线游戏通常需要快速、实时的通信,WebSocket 能够提供低延迟和高并发的通信能力。
  • 推送服务: 用于实现消息推送服务,向客户端主动推送更新或通知。

WebSocket 通信过程以及原理

建立连接

  • WebSocket 协议属于应用层协议,依赖传输层的 TCP 协议。它通过 HTTP/1.1 协议的 101 状态码进行握手建立连接。
具体过程
  • 客户端发送一个 HTTP GET 请求到服务器,请求的路径是 WebSocket 的路径(类似 ws://example.com/socket)。请求中包含一些特殊的头字段,如 Upgrade: websocket 和 Connection: Upgrade,以表明客户端希望升级连接为 WebSocket。
  • 服务器收到这个请求后,会返回一个 HTTP 101 状态码(协议切换协议)。同样在响应头中包含 Upgrade: websocket 和 Connection: Upgrade,以及一些其他的 WebSocket 特定的头字段,例如 Sec-WebSocket-Accept,用于验证握手的合法性。
  • 客户端和服务器之间的连接从普通的 HTTP 连接升级为 WebSocket 连接。之后,客户端和服务器之间的通信就变成了 WebSocket 帧的传输,而不再是普通的 HTTP 请求和响应。`
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 客户端请求
GET ws://localhost:8888/ HTTP/1.1
Host: localhost:8888
Connection: Upgrade
Upgrade: websocket
Origin: http://localhost:63342
Sec-WebSocket-Version: 13
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9,ja;q=0.8,en;q=0.7
Sec-WebSocket-Key: b7wpWuB9MCzOeQZg2O/yPg==
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits

// 服务端响应
HTTP/1.1 101 Web Socket Protocol Handshake
Connection: Upgrade
Date: Wed, 22 Nov 2023 08:15:00 GMT
Sec-WebSocket-Accept: Q4TEk+qOgJsKy7gedijA5AuUVIw=
Server: TooTallNate Java-WebSocket
Upgrade: websocket

Sec-WebSocket-Key

  • 与服务端响应头部的 Sec-WebSocket-Accept 是配套的,提供基本的防护,比如恶意的连接,或者无意的连接;这里的“配套”指的是:Sec-WebSocket-Accept 是根据请求头部的 Sec-WebSocket-Key 计算而来,计算过程大致为基于 SHA1 算法得到摘要并转成 base64 字符串。

Sec-WebSocket-Extensions

  • 用于协商本次连接要使用的 WebSocket 扩展。

数据通信

  • WebSocket 的每条消息可能会被切分成多个数据帧(最小单位)。发送端会将消息切割成多个帧发送给接收端,接收端接收消息帧并将关联的帧重新组装成完整的消息。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
 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 ... |
+---------------------------------------------------------------+

帧头(Frame Header)

  • FIN(1比特): 表示这是消息的最后一个帧。如果消息分成多个帧,FIN 位在最后一个帧上设置为 1。
  • RSV1、RSV2、RSV3(各1比特): 保留位,用于将来的扩展。
  • Opcode(4比特): 指定帧的类型,如文本帧、二进制帧、连接关闭等。
  • Mask(1比特): 指示是否使用掩码对负载进行掩码操作。
  • Payload Length: 指定数据的长度。如果小于 126 字节,直接表示数据的长度。如果等于 126 字节,后面跟着 16 比特的无符号整数表示数据的长度。如果等于 127 字节,后面跟着 64 比特的无符号整数表示数据的长度。

掩码(Masking)

  • 如果 Mask 位被设置为 1,则帧头后面的 4 字节即为掩码,用于对负载数据进行简单的异或操作,以提高安全性。

    负载数据(Payload Data)

  • 实际要传输的数据,可以是文本、二进制数据等

使用 WebSocket 实现一个简易聊天室

前端源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<title>实时监控</title>
</head>
<style>
body {
background: linear-gradient(90deg, #ffd7e4 0%, #c8f1ff 100%);
}

.item {
display: flex;
border-bottom: 1px solid #000000;
justify-content: space-between;
line-height: 50px;
height: 50px;
}

.item span:nth-child(2) {
margin-right: 10px;
margin-top: 15px;
width: 20px;
height: 20px;
border-radius: 50%;
background: #55ff00;
}

.nowI {
background: #ff0000 !important;
}
.sendBut{
width: 100%;
position: absolute;
bottom: 20px;
margin-bottom: 20px;
left: 200px;
right: 0;
}
</style>
<body>
<div class="sendBut">
<div id="chat"></div>
<input type="text" id="messageInput" placeholder="Type your message">
<button onclick="sendMessage()">Send</button>
</div>
<div id="app" style="right: 0;float: right">
<div v-for="item in list" class="item">
<span>{{item.id}}.{{item.name}}</span>
<span :class='item.state==-1?"nowI":""'></span>
</div>
</div>
</body>
<script src="https://cdn.jsdelivr.net/npm/vue@2.5.16/dist/vue.js"></script>
<script type="text/javascript">
var vm = new Vue({
el: "#app",
data: {
list: [{
id: 1,
name: '张三',
state: 1
},
{
id: 2,
name: '李四',
state: 1
},
{
id: 3,
name: '王五',
state: 1
},
{
id: 4,
name: '韩梅梅',
state: 1
},
{
id: 5,
name: '李磊',
state: 1
},
]
}
})

let webSocket = new WebSocket('ws://localhost:9001/user/'+ getUUID());;
if ('WebSocket' in window) {
//连接成功
webSocket.onopen = function () {
console.log("已连接");
webSocket.send("消息发送测试")
}
//接收到消息
webSocket.onmessage = function (msg) {
//处理消息
const messageDiv = document.getElementById('chat');
const messageParagraph = document.createElement('p');
messageParagraph.textContent = event.data;
messageDiv.appendChild(messageParagraph);
};

//关闭事件
webSocket.onclose = function () {
console.log("websocket已关闭");
};
//发生了错误事件
webSocket.onerror = function () {
console.log("websocket发生了错误");
}
} else {
alert("很遗憾,您的浏览器不支持WebSocket!")
}

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

function getUUID() { //获取唯一的UUID
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {

var r = Math.random() * 16 | 0,
v = c == 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
</script>
</html>

后端代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
package com.panther.springboot.websocket;

import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import javax.websocket.*;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;
import java.io.IOException;
import java.util.HashSet;
import java.util.concurrent.CopyOnWriteArraySet;
import java.util.concurrent.atomic.AtomicInteger;

/**
* @author Gin 琴酒
* @data 2023/11/19 18:00
*/
@Slf4j
@ServerEndpoint("/user/{userId}")
@Component
public class WebSocketServer {

private static AtomicInteger onlineCount = new AtomicInteger(0);

/**
* 网络套接字设置 concurrent包的线程安全Set,用来存放每个客户端对应的WebSocket对象。
*/
private static CopyOnWriteArraySet<WebSocketServer> webSocketSet = new CopyOnWriteArraySet<>();

/**
* 会话 与某个客户端的连接会话,需要通过它来给客户端发送数据
*/
private Session session;
/**
* id
*/
private String userId = "";

@OnOpen
public void onOpen(Session session, @PathParam("userId") String userId) {
this.session = session;
webSocketSet.add(this); // 加入set中
this.userId = userId;
addOnlineCount(); // 在线数加1
log.info("有新客户端开始监听,userId=" + userId + ",当前在线人数为:" + getOnlineCount());
}

@OnClose
public void onClose() {
webSocketSet.remove(this); // 从set中删除
subOnlineCount(); // 在线数减1
// 断开连接情况下,更新主板占用情况为释放
log.info("释放的userId=" + userId + "的客户端");
releaseResource();
}
private void releaseResource() {
log.info("有一连接关闭!当前在线人数为" + getOnlineCount());
}

@OnMessage
public void onMessage(String message, Session session) {
log.info("收到来自客户端 userId=" + userId + " 的信息:" + message);
// 群发消息
HashSet<String> userIds = new HashSet<>();
for (WebSocketServer item : webSocketSet) {
userIds.add(item.userId);
}
try {
sendMessage("客户端 " + this.userId + "发布消息:" + message, userIds);
} catch (IOException e) {
e.printStackTrace();
}
}

@OnError
public void onError(Session session, Throwable error) {
log.error(session.getBasicRemote() + "客户端发生错误");
error.printStackTrace();
}

public void sendMessage(String message, HashSet<String> toSids) throws IOException {
log.info("推送消息到客户端 " + toSids + ",推送内容:" + message);
for (WebSocketServer item : webSocketSet) {
try {
//这里可以设定只推送给传入的userId,为null则全部推送
item.sendMessage(message);
} catch (IOException e) {
continue;
}
}
}

public void sendMessage(String message) throws IOException {
this.session.getBasicRemote().sendText(message);
}
public static int getOnlineCount() {
return onlineCount.get();
}
public static void addOnlineCount() {
onlineCount.getAndIncrement();
}
public static void subOnlineCount() {
onlineCount.getAndDecrement();
}

}