webSocket

14-WebSocket

背景

对于实时响应、更新的应用,即服务器可以即时将数据更新变化反映到客户端,传统的请求-响应模式效率低下,在处理此类业务场景时,通常采用的方案有:

  • 短轮询:浏览器每隔一段时间向服务器发送http请求,这种方法浪费带宽,效率低下;
  • 长轮询(comet):服务器收到客户端请求后,不直接进行响应,先讲请求挂起,判断请求数据是否有更新,如果有,则响应;没有,则到达一定时间限制后,关闭连接。这种方法缺点在于,连续挂起同样导致资源浪费;
  • SSE:全称Server-SentEvents,允许服务器主动推送数据到客户端,本质与长轮询、短轮询不同;
  • WebSocket:HTML5定义的WebSocket协议,它是独立的,创建在TCP之上的协议,该协议可以实现服务器与客户端之间的全双工通信;

本文主要讲解WebSocket。

WebSocket介绍

WebSocket是HTML5一种新的协议,建立在传输层TCP协议之上,实现了客户端和服务器的全双工异步通信。协议简写为wswss是加密版本。

和HTTP协议最大不同有:

  • WebSocket是双向通信协议,服务器和客户端都能主动向对方发送数据;
  • WebSocket需要类似TCP的握手链接,连接成功后才能相互通信;

WebSocket连接后,服务器和客户端就可以发送数据,直到连接关闭。

WebSocket建立连接

WebSocket握手连接阶段,需要使用HTTP协议。

来看一个WeboSocket握手例子。

一个HTTP请求报文。

1
2
3
4
5
6
7
8
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Version: 13
Origin: http://example.com

与普通的HTTP请求相比,多了几样东西。

1
2
Connection: Upgrade
Upgrade: websocket

Connection表示想升级协议,Upgrade表示想升级协议到WebSocket

Set-WebSocket-Version表示websocket版本。

服务器端相应格式为:

1
2
3
4
HTTP/1.1 101 Switching Protocols
Connection:Upgrade
Upgrade: websocket
Sec-WebSocket-Accept: Oy4NRAQ13jhfONC7bP8dTKb4PTU=

状态101表示协议切换成功。

STOMP

STOMP-Simple Text Oriented Mesage Protocol,面向消息的简单文本协议。

因为WebSocket是一个消息架构,不强制使用任何特定的消息协议,它依赖于应用层解释消息的含义;

与处在应用层HTTP不同,WebSocket处在TCP上很薄一层,仅仅将字节流转换成文本/二进制消息,因此对于实际应用来说,WebSocket通信层级过低。

所以可以在WebSocket之上使用STOMP协议,为浏览器和服务器之间通信增加适当的消息语义。

STOMP协议提供了一个可互操作的连接格式,允许STOMP客户端与任意STOMP消息代理(Broker)进行交互。

如何理解STOMP和WebSocket的关系:

  • HTTP协议解决了web浏览器发起请求及服务器响应的细节,直接使用WebSocket编程,就好像没有HTTP协议,直接用TCP套接字编写应用一样;
  • STOMP是上层协议,WebSocket是底层协议,STOMP在WebSocket上,明确了信息交互双方,消息格式等;

STOMP服务端

STOMP服务端被设计为客户端可以向其发送消息的一组目标地址,STOMP协议没有规定目标地址格式,可以由应用定义,例如/topic/atopic/b等。应用可以规定不同格式表明不同含义。

例如应用定义以/topic打头的发布订阅模式,消息被所有消费者客户端收到;以/user开头的为点对点模式,只会被一个消费者客户端收到。

STOMP客户端

对于STOMP协议来说,客户端会扮演下列两种角色一种:

  • 作为生产者,通过SEND帧发送消息到指定地址;
  • 作为消费者,通过发布SUBSCRIBE帧到已知地址进行消息订阅,当生产者发送消息到这个订阅地址后,订阅该地址的其他消费者会通过MESSAGE帧收到该消息。

因此,WebSocket结合STOMP相当于构建了一个消息分发队列,客户端可以在上述两种角色间转换,订阅机制保证了一个客户端消息可以通过服务器广播到其他客户端,作为生产者,又可以通过服务器发送点对点消息。

SockJS

SockJS是一个JavaScript库,是为了应对许多浏览器不支持WebSocket协议的问题。SockJS是WebSocket技术的一种模拟,会尽可能对应WebSocket API,优先选择WebSocket连接。但是当服务器或客户端不支持WebSocket时,会使用其他方案例如轮询、iFrame等中择优连接。

SpringBoot集成WebSocket

SpringBoot集成WebSocket有多种方式,这里介绍三种方法:

  • 原生注解
  • Spring封装
  • STOMP协议集成

首先导入所需库。

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>

原生注解

使用jdk自带的原生注解实现。

ServerEndpoint表示客户端用来连接服务端的地址,例如ServerEndpoint/websocket,则客户端可使用ws://ip:port/websocket进行websocket连接。

创建WebSocketConfig配置文件

1
2
3
4
5
6
7
8
@Configuration
public class WebSocketConfig {

@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
}

这个配置类很简单,注入ServerEndpointExporter,这个Bean会自动注册使用了ServerEndpoint注解声明的WebSocket Endpoint。

通过这个Bean,SpringBoot才能扫描到关于websocket注解。

WebSocket处理类

使用Component注解表示这是由spring容器管理的对象。虽然Component默认是单例模式,但SpringBoot还是会为每个WebSocket连接初始化一个Bean,所以可以用静态Map保存起来。

换句话说,每次一个客户端向服务端发起WebSocket连接时,都会创建一个WebSocket对象,同时建立连接时,会自动创建一个Session对象,也就是说,每个连接对应唯一一个Session,服务端发送消息须通过Session

其他注解,都是在javax.websocket下,并不是spring提供的,而是jdk自带的:

  • @ServerEndpoint:通过这个注解,暴露ws连接路径,类似RequestMapping注解,如果值为ws,可以通过ws://127.0.0.1:80/ws进行websocket连接;
  • @OnOpen:方法注解,当任意一个客户端建立websocket连接成功后会触发这个注解修饰的方法,注意有一个Session参数,这个Session是WebSocket的,还可以加入带@PathParam注解的参数;
  • @OnClose:方法注解,当某个客户端断开websocket连接后会触发这个注解修饰的方法,注意有一个Session参数;
  • @OnMessage:方法注解,当客户端发送消息到服务端时,会触发这个注解修饰的方法,有一个String参数,表示客户端传入消息;
  • @OnError:当websocket建立连接出现异常时,会触发该方法

服务端向客户端发送消息必须通过上面提到的Session类,通常是在OnOpen方法中,当连接成功后把Session存入Map的value中,当需要发送时,通过key获得session发送,通过session.getBasicRemote().sendText()向客户端发送消息。

下面例子使用ConCurrentHashMap保存所有连接,

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
@Slf4j
@Component
@ServerEndpoint("/websocket/{name}")
public class WebSocket {

private Session session;

private String name;

private static ConcurrentHashMap<String, WebSocket> webSockets = new ConcurrentHashMap<>();

// 连接建立成功后,调用的方法
@OnOpen
public void OnOpen(Session session, @PathParam(value = "name") String name) {
log.info("---------------------------");
this.session = session;
this.name = name;

webSockets.put(name ,this);
log.info("WebSocket 连接成功, 当前连接人数为: {}", webSockets.size());
log.info("---------------------------");
groupSending(name + "来了");
}

@OnClose
public void OnClose() {
webSockets.remove(this.name);
log.info("WebSocket 退出成功, 当前连接人数为: {}", webSockets.size());

groupSending(name + "走了");
}

/**
* 消息类型分为两种,群发和私聊
* 消息格式为:
* 群发:group:[消息]
* 私聊: appoint-[用户名]:[消息]
*/
@OnMessage
public void OnMessage(String messageStr) {
log.info("WebSocket 收到消息: {}", messageStr);
if(messageStr.startsWith("group")) {
String message = messageStr.split(":")[1];
groupSending(message);
} else {
String[] strs = messageStr.split(":");
String name = strs[0].split("-")[1];
String message = strs[1];
appointSending(name, message);
}


}


/**
* 群发消息
* @param message
*/
public void groupSending(String message) {
for (String name : webSockets.keySet()) {
try {
webSockets.get(name).session.getBasicRemote().sendText(message);
} catch (Exception e) {
e.printStackTrace();
}
}

}

/**
* 私聊
* @param name
* @param message
*/
public void appointSending(String name, String message) {
try {
webSockets.get(name).session.getBasicRemote().sendText(message);
} catch (Exception e) {
e.printStackTrace();
}
}
}

使用websocket在线测试网站,输入url:ws://127.0.0.1:8080/demo/websocket/user1,表示user1对应客户端建立WebSocket连接。

Spring封装

创建WebSocketHandler

继承TextWebSocketHandler类,重写其中的几个方法:

  • afterConnectionEstablished:连接建立后调用,与OnOpen功能相同;
  • afterConnectionClosed:连接关闭后调用,与OnClose功能相同;
  • handleTextMessage:服务端收到文本消息时调用,与OnMessage功能相同;

完整代码为:

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
@Slf4j
@Component
public class WebSocketHandler extends TextWebSocketHandler {

public static ConcurrentHashMap<String, WebSocketSession> sessions = new ConcurrentHashMap<>();

@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
String key = getName(session);
sessions.put(key, session);
log.info(key + ": 成功建立连接");
}

@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
String key = getName(session);
sessions.remove(key);
log.info(key + ": 成功关闭连接");
}

@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
log.info("WebSocket 收到消息");
String messageStr = message.getPayload();
if(messageStr.startsWith("group")) {
String msg = messageStr.split(":")[1];
groupSending(msg);
} else {
String[] strs = messageStr.split(":");
String name = strs[0].split("-")[1];
String msg = strs[1];
appointSending(name, msg);
}
}

/**
* 群发消息
* @param message
*/
public void groupSending(String message) {
for (String name : sessions.keySet()) {
try {
sessions.get(name).sendMessage(new TextMessage(message));
} catch (Exception e) {
e.printStackTrace();
}
}

}

/**
* 私聊
* @param name
* @param message
*/
public void appointSending(String name, String message) {
try {
sessions.get(name).sendMessage(new TextMessage(message));
} catch (Exception e) {
e.printStackTrace();
}
}

// 获得url的path值,与第一种方式的@PathParam功能相同
private String getName(WebSocketSession session) {
URI uri = session.getUri();
if(Objects.isNull(uri) || StrUtil.isEmpty(uri.toString())) {
return "";
}
return uri.getPath().substring(uri.getPath().lastIndexOf("/") + 1);
}
}

创建WebSocketConfig

实现WebSocketConfigurer接口,该接口只有一个方法:

void registerWebSocketHandlers(WebSocketHandlerRegistry registry)

用于注册WebSocket的处理类,并映射到对应地址。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {

// /websocket/*类的接口,都会交由WebSocketHandler处理
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(handler(), "/websocket/*").setAllowedOrigins("*");
}

@Bean
public WebSocketHandler handler() {
return new WebSocketHandler();
}
}

STOMP集成

Spring消息代理的WebSocket架构图为:

image-20221127152608359

图中各个组件介绍:

  • 生产者型客户端(左上组件):通过SEND命令发送消息到某个目的地址
  • 消费者型客户端(左下组件):订阅某个目的地址,并接收此目的地址推送过来的消息
  • request channel:一组用来接收生产者型客户端推送过来消息的线程池
  • response channel:一组用来推送消息给消费者型客户端的线程池
  • borker:消息队列管理者,也可以成为消息代理。有自己的地址,客户端可以向其发送订阅指令,它会记录哪些客户端订阅了这个目的地址
  • 应用目的地址(图中app):发送到这类目的地址的消息,在到达broker之前,会先路由到由应用写的某个方法,相当于对broker的消息进行一次拦截,目的是针对消息做一些业务处理
  • 非应用目的地址(图中/topic,也是消息代理地址):发送到这类目的地址的消息会直接转到broker,不会被应用拦截
  • SimpAnnotationMethod:发送到应用目的地址的消息在到达borker之前,先路由到的方法,由应用控制

消息从生产者发出到消费者消费的流转流程

  1. 生产者通过发送一条SEND命令消息到某个目的地址destination
  2. 服务端request channel接受到该条命令,如果目的地址是应用目的地址,则转到对应业务方法(SimpAnnotationMethod)处理,再转到borker(SimpleBroker);如果目的地址是非应用目的地址,则直接转到borker
  3. borker通过SEND命令消息构建MESSAGE命令消息,再通过response channel推送MESSAGE命令消息到所有订阅此目的地址的消费者

配置类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
// 配置客户端连接地址
registry.addEndpoint("/ws").setAllowedOrigins("*").withSockJS();
}

@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
// 设置广播节点
registry.enableSimpleBroker("/topic", "/user");
// 客户端向服务端发送消息须有/app前缀
registry.setApplicationDestinationPrefixes("/app");
// 指定用户发送(一对一)的前缀/user/
registry.setUserDestinationPrefix("/user");
}
}
  • 通过实现WebSocketMessageBrokerConfigurer接口加上注解@EnableWebSocketMessageBroker进行STOMP的配置和注解扫描
  • 其中覆盖registerStompEndpoints方法设置暴露的STOMP路径,并进行跨域设置,withSockJS()表示允许SockJS
  • 覆盖configureMessageBroker方法进行节点配置:
    1. enableSimpleBroker配置广播节点,也就是服务器发送消息,客户端订阅就能接收消息的节点
    2. 覆盖setApplicationDestinationPrefixes方法,设置客户端向服务器发送消息的节点
    3. 覆盖setUserDestinationPrefix方法,设置一对一通信的节点

捋一下

广播节点是/topic,一对一节点是/user

客户端向服务端发消息必须带上前缀/app,然后不同后缀对应不同处理方法,服务端使用@MessageMapping定义,和@RequestMapping用法基本相同。

使用@MessageMapping需要在配置类开启注解@EnableWebSocketMessageBroker

例如在控制器类(注解了@Controller)中,有个注解了@MessageMapping("/hello")的方法,那么客户端发送消息的URL为:/app/hello,服务端会转给这个方法进行处理。

服务端向客户端发送消息有两种方式:广播和一对一。

广播地址前缀是topic,一对一节点是/user

先说广播:

有两种发送消息方式,使用注解@SendToSimpMessagingTemplate对象。

@SendTo注解修饰方法,该方法的返回值就是SendTo发送的消息,但是@SendTo不能单独使用,需要和@MessageMapping结合使用。

因此这两个注解的分工是:

  • @MessageMapping负责接收客户端发来的消息
  • @SendTo负责发送消息给对应的广播地址

另一种是使用SimpMessagingTemplate对象,该对象会自动注入,该对象有两种发送消息方法:

  • convertAndSend()
  • convertAndSendToUser()

以及对应的重载方法。

一对一节点:

同样可使用SimpMessagingTemplate对象发送,或者注解@SendToUser,用法和@SendTo基本一样。

例子

首先定义一个Message实体类,用于服务端和客户端数据传输。

1
2
3
4
5
6
7
8
9
10
11
@Data
public class Message {
private String code;
private String form;
/**
* 去自(保证唯一)
*/
private String to;
private String content;
}

配置

配置两个ServerEndpoint,分别用于广播和点对点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
// 配置客户端连接地址
// 广播连接地址
registry.addEndpoint("/ws/public").setAllowedOriginPatterns("*").withSockJS();
// 点对点连接地址
registry.addEndpoint("/ws/private").setAllowedOriginPatterns("*").withSockJS();
}

@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
// 设置广播和点对点发送地址
registry.enableSimpleBroker("/topic", "/user");
// 客户端向服务端发送消息须有/app前缀
registry.setApplicationDestinationPrefixes("/app");
// 指定用户发送(一对一)的前缀/user/
registry.setUserDestinationPrefix("/user");
}
}

广播

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Controller
public class WSController {

@Autowired
private SimpMessagingTemplate wsTemplate;

@MessageMapping("/hello")
@SendTo("/topic/hello")
public String hello(String message) {
System.out.println("接受消息: " + message);
Message msg = JSONUtil.toBean(message, Message.class);
// wsTemplate.convertAndSend("/topic/hello", msg.getContent());
return "自定义消息:" + msg.getContent();
}
}

这里服务端向客户端发送消息使用了注解@SendTo,也可以去掉注释行,使用SimpMessagingTemplate对象。

前端代码:

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
<html>
<head>
<meta charset="UTF-8">
<title>等系统推消息</title>
<script src="https://cdn.bootcss.com/sockjs-client/1.1.4/sockjs.min.js"></script>
<script src="https://cdn.bootcss.com/stomp.js/2.3.3/stomp.min.js"></script>
<script src="https://code.jquery.com/jquery-3.2.0.min.js"
integrity="sha256-JAW99MJVpJBGcbzEuXk4Az05s/XyDdBomFqNlM3ic+I=" crossorigin="anonymous"></script>

<script type="text/javascript">

var stompClient = null;

function setConnected(connected) {
document.getElementById("connect").disabled = connected;
document.getElementById("disconnect").disabled = !connected;
$("#response").html();
}

function connect() {
// 建立WebSocket连接
var socket = new SockJS("http://localhost:8080/ws/public");
stompClient = Stomp.over(socket);
stompClient.connect({}, function (frame) {
setConnected(true);
console.log('Connected: ' + frame);
// 订阅/topic/hello广播
stompClient.subscribe('/topic/hello', function (response) {
var responseData = document.getElementById('responseData');
var p = document.createElement('p');
p.style.wordWrap = 'break-word';
p.appendChild(document.createTextNode(response.body));
responseData.appendChild(p);
});
},{});
}

function disconnect() {
if (stompClient != null) {
stompClient.disconnect();
}
setConnected(false);
console.log("Disconnected");
}
// 向服务端发送消息,需要加上/app前缀,/hello是自定义的,后端须有对应处理方法
function sendMsg() {
var content = document.getElementById('content').value;
stompClient.send("/app/hello",{},JSON.stringify({'content': content}));
}
</script>
</head>
<body onload="disconnect()">
<noscript><h2 style="color: #ff0000">Seems your browser doesn't support Javascript! Websocket relies on Javascript being
enabled. Please enable
Javascript and reload this page!</h2></noscript>
<div>
<div>
<labal>连接广播频道</labal>
<button id="connect" onclick="connect();">Connect</button>
<labal>取消连接</labal>
<button id="disconnect" disabled="disabled" onclick="disconnect();">Disconnect</button>
</div>
<div id="conversationDiv">
<labal>广播消息</labal>
<input type="text" id="content"/>
<button id="sendMsg" onclick="sendMsg();">Send</button>

</div>
<div>
<labal>接收到的消息:</labal>
<p id="responseData"></p>

</div>
</div>
</body>
</html>

实现效果:

325c6b4a-2dc9-4d1c-8e9e-972a344b45c9

一对一

1
2
3
4
5
6
7
8
9
10
11
@Controller
public class WSController {

@Autowired
private SimpMessagingTemplate wsTemplate;

@MessageMapping("/alone")
public void pushToOne(@RequestBody Message msg) {
wsTemplate.convertAndSendToUser(msg.getTo(), "/message", msg.getContent());
}
}

这里服务端向指定客户端发送消息使用的是SimpMessagingTemplate对象,不能使用注解@SendToUser,因为Message对象决定了给哪一个客户端发送消息,使用注解无法提前确定客户端。

前端代码:

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
<html>
<head>
<meta charset="UTF-8">
<title>聊起来</title>
<script src="https://cdn.bootcss.com/sockjs-client/1.1.4/sockjs.min.js"></script>
<script src="https://cdn.bootcss.com/stomp.js/2.3.3/stomp.min.js"></script>
<script src="https://code.jquery.com/jquery-3.2.0.min.js"
integrity="sha256-JAW99MJVpJBGcbzEuXk4Az05s/XyDdBomFqNlM3ic+I=" crossorigin="anonymous"></script>

<script type="text/javascript">
var stompClient = null;

function setConnected(connected) {
document.getElementById("connect").disabled = connected;
document.getElementById("disconnect").disabled = !connected;
$("#response").html();
}

function connect() {
var socket = new SockJS("http://localhost:8080/ws/private");
stompClient = Stomp.over(socket);
stompClient.heartbeat.outgoing = 20000;
// client will send heartbeats every 20000ms
stompClient.heartbeat.incoming = 0;
stompClient.connect({}, function (frame) {
setConnected(true);
console.log('Connected: ' + frame);
stompClient.subscribe('/user/' + document.getElementById('user').value + '/message', function (response) {
var responseData = document.getElementById('responseData');
var p = document.createElement('p');
p.style.wordWrap = 'break-word';
p.appendChild(document.createTextNode(response.body));
responseData.appendChild(p);
});


});

}

function disconnect() {
if (stompClient != null) {
stompClient.disconnect();
}
setConnected(false);
console.log("Disconnected");

}

function sendMsg() {
var content = document.getElementById('content').value;
var to = document.getElementById('to').value;
stompClient.send("/app/alone", {'accessToken': 'HWPO325J9814GBHJF933'}, JSON.stringify({
'content': content,
'to': to
}));
}
</script>
</head>
<body onload="disconnect()">
<noscript><h2 style="color: #ff0000">Seems your browser doesn't support Javascript! Websocket relies on Javascript being
enabled. Please enable
Javascript and reload this page!</h2></noscript>
<div>
<div>
<labal>连接用户</labal>
<input type="text" id="user"/>
<button id="connect" onclick="connect();">Connect</button>

</div>

<div>
<labal>取消连接</labal>
<button id="disconnect" disabled="disabled" onclick="disconnect();">Disconnect</button>
</div>

<div id="conversationDiv">
<labal>发送消息</labal>
<div>
<labal>内容</labal>
<input type="text" id="content"/>
</div>
<div>
<labal>发给谁</labal>
<input type="text" id="to"/>
</div>
<button id="sendMsg" onclick="sendMsg();">Send</button>

</div>
<div>
<labal>接收到的消息:</labal>
<p id="responseData"></p>

</div>
</div>

</body>
</html>

注意,前端代码在建立WebSocket连接时,同时订阅了/user/<>/message这个地址,/user开头,是一个一对一地址。其中<>是用户自定义的名字,例如自定义为name1,则该客户端订阅了/user/name1/message这个地址,其他用户想要给该用户发消息,就通过这个name1

至于为什么地址是这种格式,看一下convertAndSendToUser的源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Override
public void convertAndSendToUser(String user, String destination,
Object payload,
@Nullable Map<String, Object> headers,
@Nullable MessagePostProcessor postProcessor)throws MessagingException {
Assert.notNull(user, "User must not be null");
Assert.isTrue(!user.contains("%2F"),
"Invalid sequence \"%2F\" in user name: " + user);
user = StringUtils.replace(user, "/", "%2F");
destination = destination.startsWith("/") ? destination : "/" + destination;
super.convertAndSend(this.destinationPrefix + user + destination,
payload, headers, postProcessor);
}

可以看到,在最后一行,调用了convertAndSend方法,发送地址拼接为this.destinationPrefix + user + destination,其中this.destinationPrefix就是在配置类WebSocketConfig中设置的,这里this.destinationPrefix = "/user/"

user是入参,指定了要发送的用户名,destination也是入参,一个自定义参数,前后端代码保持一致即可。

服务端的注解@MessageMapping("/alone"),不需要加/app,但是客户端向服务端发送消息时,需要加上前缀变成:/app/alone

实现效果为:

c621db70-9109-46d7-aa81-96c049ba7330

备注

还有一个注解,@SubscribeMapping

标记在方法上,用于将客户端以app开头的订阅路由到对应的方法上,默认情况下,返回值会直接发送给客户端,不经过消息代理Broker,是一次性的。可以通过@SendTo@SendToUser修改这一行为,也就是先将返回值发给消息代理borker,然后广播出去。

一般用于初始化数据,例如登入聊天室后,初始化在线人员列表和历史消息等。

例如@SubscribeMapping("/init"),则前端代码通过stompClient.subscribe("/app/init"),路由到该注解修饰的方法。

总结

对于客户端或用户,有三种动作:

  1. 根据服务端设定的ServerEndpoint地址,建立WebSocket连接
  2. 订阅地址,包括广播地址(以/topic开头)和一对一地址(以/user开头),这两种地址都自定义在配置类中
  3. 向服务端发送消息,所有消息发送地址均以/app开头

对于服务端,有四种动作:

  1. 处理客户端的WebSocket连接请求,不需要代码自定义配置,设定好ServerEndpoint地址即可
  2. 处理客户端的订阅请求,可以使用@SubscribeMapping接收,但这是一次性的,较少使用。服务端需要配置好广播地址和一对一地址,保证正确发送消息,一般也不需要代码自定义配置订阅
  3. 接收客户端的消息,以/app开头的消息,通过@MessageMapping接收,并根据参数广播消息或一对一发送消息
  4. 向客户端发送消息,既可以是一个客户端向另一个客户端发送的消息,例如聊天室;也可以是服务端主动推送某些更新消息,例如天气应用,实时更新,通过注解@SendTo@SendToUser,不过注解只能和@MessageMapping结合,使用范围比较窄。SimpMessagingTemplate对象使用范围广,推荐使用。

webSocket
https://zhaoquaner.github.io/2022/11/23/SpringBoot/14-WebSocket/
更新于
2022年11月27日
许可协议