目录
WebSocket
是一种基于 TCP
的网络协议。在 2009 年诞生,于 2011 年被 IETF 定为标准 RFC 6455
通信标准,并由 RFC7936
补充规范。WebSocket API
也被 W3C
定为标准。
WebSocket
也是一种全双工通信的协议,既允许客户端向服务器主动发送消息,也允许服务器主动向客户端发送消息。在 WebSocket
中,浏览器和服务器只需要完成一次握手,两者之间就可以建立持久性的连接,进行双向数据传输。
HTTP
协议;ws
,如果采用加密则是 wss
;TCP
协议之上,服务器端的实现比较容易;WebSocket
可以发送文本,也可以发送二进制数据;HTTP
协议有着良好的兼容性。默认端口也是 80
和 443
,并且握手阶段采用 HTTP
协议,因此握手时不容易屏蔽,能通过各种 HTTP
代理服务器;谈起为什么需要 WebSocket
前,那得先了解在没有 WebSocket
那段时间说起,那时候基于 Web
的消息基本上是靠 Http
协议进行通信,而经常有”聊天室”、”消息推送”、”股票信息实时动态”等这样需求,而实现这样的需求常用的有以下几种解决方案:
短轮询是指客户端每隔一段时间就询问一次服务器是否有新的消息,如果有就接收消息。这样方式会增加很多次无意义的发送请求信息,每次都会耗费流量及处理器资源。
优点:短连接,服务器处理简单,支持跨域、浏览器兼容性较好。
缺点:有一定延迟、服务器压力较大,浪费带宽流量、大部分是无效请求。
长轮询是段轮询的改进,客户端执行 HTTP
请求发送消息到服务器后,等待服务器回应,如果没有新的消息就一直等待,知道服务器有新消息传回或者超时。
这也是个反复的过程,这种做法只是减小了网络带宽和处理器的消耗,但是带来的问题是导致消息实时性低,延迟严重。而且也是基于循环,最根本的带宽及处理器资源占用并没有得到有效的解决。
优点:减少轮询次数,低延迟,浏览器兼容性较好。
缺点:服务器需要保持大量连接。
“目前除了
IE/Edge
,其他浏览器都支持。
服务器发送事件是一种服务器向浏览器客户端发起数据传输的技术。一旦创建了初始连接,事件流将保持打开状态,直到客户端关闭。该技术通过传统的 HTTP
发送,并具有 WebSockets
缺乏的各种功能,例如”自动重新连接”、”事件ID” 及 “发送任意事件”的能力。
“服务器发送事件是单向通道,只能服务器向浏览器发送,因为流信息本质上就是下载。
优点:适用于更新频繁、低延迟并且数据都是从服务端发到客户端。
缺点:浏览器兼容难度高。
显然,上面这几种方式都有各自的优缺点,虽然靠轮询方式能够实现这些一些功能,但是其对性能的开销和低效率是非常致命的,尤其是在移动端流行的现在。
现在客户端与服务端双向通信的需求越来越多,且现在的浏览器大部分都支持 WebSocket
。所以对实时性和双向通信及其效率有要求的话,比较推荐使用 WebSocket
。
客户端先用带有 Upgrade:Websocket
请求头的 HTTP
请求,向服务器端发起连接请求,实现握手(HandShake
)。
客户端 HTTP
请求的 Header
头信息如下:
Connection: Upgrade
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits
Sec-WebSocket-Key: IRQYhWINfX5Fh1zdocDl6Q==
Sec-WebSocket-Version: 13
Upgrade: websocket
Connection
: Upgrade 表示要升级协议。Upgrade
: Websocket 要升级协议到 websocket 协议。Sec-WebSocket-Extensions
: 表示客户端所希望执行的扩展(如消息压缩插件)。Sec-WebSocket-Key
: 主要用于WebSocket协议的校验,对应服务端响应头的 Sec-WebSocket-Accept
。Sec-WebSocket-Version
: 表示 websocket
的版本。如果服务端不支持该版本,需要返回一个Sec-WebSocket-Versionheader
,里面包含服务端支持的版本号。握手成功后,由 HTTP
协议升级成 Websocket
协议,进行长连接通信,两端相互传递信息。
服务端响应的 HTTP Header 头信息如下:
Connection: upgrade
Sec-Websocket-Accept: TSF8/KitM+yYRbXmjclgl7DwbHk=
Upgrade: websocket
Connection
: Upgrade 表示要升级协议。Upgrade
: Websocket 要升级协议到 websocket 协议。Sec-Websocket-Accept
: 对应 Sec-WebSocket-Key
生成的值,主要是返回给客户端,让客户端对此值进行校验,证明服务端支持 WebSocket
。WebSocket
确实指定了一种消息传递体系结构,但并不强制使用任何特定的消息传递协议。而且它是 TCP 上的一个非常薄的层,它将字节流转换为消息流(文本或二进制)仅此而已。由应用程序来解释消息的含义。
与 HTTP
(它是应用程序级协议)不同,在 WebSocket
协议中,传入消息中根本没有足够的信息供框架或容器知道如何路由或处理它。因此,对于非常琐碎的应用程序而言 WebSocket
协议的级别可以说太低了。
可以做到的是引导在其上面再创建一层框架。这就相当于当今大多数 Web
应用程序使用的是 Web
框架,而不直接仅使用 Servlet API
进行编码一样。
WebSocket RFC
定义了子协议的使用。在握手过程中,客户机和服务器可以使用头 Sec-WebSocket
协议商定子协议,即使不需要使用子协议,而是用更高的应用程序级协议,但应用程序仍需要选择客户端和服务器都可以理解的消息格式。且该格式可以是自定义的、特定于框架的或标准的消息传递协议。
Spring
框架支持使用 STOMP
,这是一个简单的消息传递协议,最初创建用于脚本语言,框架灵感来自 HTTP
。STOMP
被广泛支持,非常适合在 WebSocket
和 web
上使用。
(1). STOMP 协议概述
“STOMP(Simple Text-Orientated Messaging Protocol)是一种简单的面向文本的消息传递协议。
它提供了一个可互操作的连接格式,允许 STOMP
客户端与任意 STOMP
消息代理(Broker
)进行交互。STOMP
协议由于设计简单,易于开发客户端,因此在多种语言和多种平台上得到广泛地应用。
(2). 简单介绍可以分为以下几点:
STOMP
是基于帧的协议,其帧以 HTTP
为模型。
STOMP
框架由命令,一组可选的标头和可选的主体组成。
STOMP
基于文本,但也允许传输二进制消息。
STOMP
的默认编码为 UTF-8
,但它支持消息正文的替代编码的规范。
(3). STOMP 客户端是一种用户代理
作为生产者,通过 SEND 帧将消息发送到目标服务器上。
作为消费者,对目标地址发送 SUBSCRIBE 帧,并作为 MESSAGE 帧从服务器接收消息。
(4). STOMP 帧
STOMP
是基于帧的协议,其帧以 HTTP
为模型。STOMP
结构为:
COMMAND
header1:value1
header2:value2
Body^@
客户端可以使用 SEND
或 SUBSCRIBE
命令发送或订阅消息,还可以使用 “destination
” 头来描述消息的内容和接收者。
这支持一种简单的发布-订阅机制,可用于通过代理将消息发送到其他连接的客户端,或将消息发送到服务器以请求执行某些工作。
(5). Stomp 常用帧
STOMP
的客户端和服务器之间的通信是通过”帧“(Frame
)实现的,每个帧由多”行“(Line
)组成,其包含的帧如下:
(6). Stomp 与 WebSocket 的关系
直接使用 WebSocket
就很类似于使用 TCP
套接字来编写 Web
应用,因为没有高层级的应用协议(wire protocol),因而就需要我们定义应用之间所发送消息的语义,还需要确保连接的两端都能遵循这些语义。
同 HTTP
在 TCP
套接字上添加请求-响应模型层一样,STOMP
在 WebSocket
之上提供了一个基于帧的线路格式层,用来定义消息语义。
(7). 使用 STOMP 作为 WebSocket 子协议的好处
RabbitMQ
,ActiveMQ
等)进行广播的选项STOMP
(相对于普通 WebSocket
)使 Spring Framework
能够为应用程序级使用提供编程模型,就像 Spring MVC
提供基于 HTTP
的编程模型一样。使用 Spring
的 STOMP
支持时,Spring WebSocket
应用程序充当客户端的 STOMP
代理。
消息被路由到 @Controller
消息处理方法或简单的内存中代理,该代理跟踪订阅并向订阅的用户广播消息。
还可以将 Spring
配置为与专用的 STOMP
代理(例如 RabbitMQ
,ActiveMQ
等)一起使用,以实际广播消息。在那种情况下,Spring
维护与代理的 TCP
连接,将消息中继到该代理,并将消息从该代理向下传递到已连接的 WebSocket
客户端。
因此 Spring Web
应用程序可以依赖基于统一 HTTP
的安全性,通用验证以及熟悉的编程模型消息处理工作。
Spring 官方提供的处理流图:
上面中的一些概念关键词:
Message
: 消息,里面带有 header
和 payload
。MessageHandler
: 处理 client
消息的实体。MessageChannel
: 解耦消息发送者与消息接收者的实体clientInboundChannel
:用于从 WebSocket 客户端接收消息。clientOutboundChannel
:用于将服务器消息发送给 WebSocket 客户端。brokerChannel
:用于从服务器端、应用程序中向消息代理发送消息Broker
: 存放消息的中间件,client
可以订阅 broker
中的消息。上面的设置包括3个消息通道:
clientInboundChannel
: 用于来自WebSocket客户端的消息。clientOutboundChannel
: 用于向WebSocket客户端发送消息。brokerChannel
: 从应用程序内部发送给代理的消息。WebSocket
常分为广播与队列模式,广播模式是向订阅广播的用户发送信息,只要订阅相关广播就能收到对应信息。
队列模式常用于点对点模式,为单个用户向另一个用户发送信息,这里先介绍下广播模式的实现示例。
这里使用 Maven 工具管理依赖包,分别引入下面依赖:
lombok
: Lombok 工具依赖,便于生成实体对象的 Get 与 Set 方法。spring-boot-starter-websocket
:SpringBoot 实现 WebSocket 的依赖,里面对 WebSocket 进行了一些列封装,并且也包含了 SpringBoot Web 依赖。<dependencies>
<!-- SpringBoot WebSocket -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<scope>provided</scope>
</dependency>
</dependencies>
创建便于传输消息的实体类,里面字段内容如下:
import lombok.Data;
@Data
public class MessageBody {
/** 消息内容 */
private String content;
/** 广播转发的目标地址(告知 STOMP 代理转发到哪个地方) */
private String destination;
}
创建 WebSocket
配置类,配置进行连接注册的端点 /mydlq
和消息代理前缀 /topic
及接收客户端发送消息的前缀 /app
。
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
/**
* 配置 WebSocket 进入点,及开启使用 SockJS,这些配置主要用配置连接端点,用于 WebSocket 连接
*
* @param registry STOMP 端点
*/
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/mydlq").withSockJS();
}
/**
* 配置消息代理选项
*
* @param registry 消息代理注册配置
*/
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
// 设置一个或者多个代理前缀,在 Controller 类中的方法里面发生的消息,会首先转发到代理从而发送到对应广播或者队列中。
registry.enableSimpleBroker("/topic");
// 配置客户端发送请求消息的一个或多个前缀,该前缀会筛选消息目标转发到 Controller 类中注解对应的方法里
registry.setApplicationDestinationPrefixes("/app");
}
}
创建 Controller
类,该类也类似于正常 Web
项目中 Controller
写法一样,在方法上面添加 @MessageMapping
注解,当客户端发送消息请求的前缀匹配上 WebSocket 配置类中的 /app
前缀后,会进入到 Controller
类中进行匹配,如果匹配成功则执行注解所在的方法内容。
@Controller
public class MessageController {
/** 消息发送工具对象 */
@Autowired
private SimpMessageSendingOperations simpMessageSendingOperations;
/** 广播发送消息,将消息发送到指定的目标地址 */
@MessageMapping("/test")
public void sendTopicMessage(MessageBody messageBody) {
// 将消息发送到 WebSocket 配置类中配置的代理中(/topic)进行消息转发
simpMessageSendingOperations.convertAndSend(messageBody.getDestination(), messageBody);
}
}
创建用于操作 WebSocket 的 JS 文件 app-websocket.js,内容如下:
// 设置 STOMP 客户端
var stompClient = null;
// 设置 WebSocket 进入端点
var SOCKET_ENDPOINT = "/mydlq";
// 设置订阅消息的请求前缀
var SUBSCRIBE_PREFIX = "/topic"
// 设置订阅消息的请求地址
var SUBSCRIBE = "";
// 设置服务器端点,访问服务器中哪个接口
var SEND_ENDPOINT = "/app/test";
/* 进行连接 */
function connect() {
// 设置 SOCKET
var socket = new SockJS(SOCKET_ENDPOINT);
// 配置 STOMP 客户端
stompClient = Stomp.over(socket);
// STOMP 客户端连接
stompClient.connect({}, function (frame) {
alert("连接成功");
});
}
/* 订阅信息 */
function subscribeSocket(){
// 设置订阅地址
SUBSCRIBE = SUBSCRIBE_PREFIX + $("#subscribe").val();
// 输出订阅地址
alert("设置订阅地址为:" + SUBSCRIBE);
// 执行订阅消息
stompClient.subscribe(SUBSCRIBE, function (responseBody) {
var receiveMessage = JSON.parse(responseBody.body);
$("#information").append("<tr><td>" + receiveMessage.content + "</td></tr>");
});
}
/* 断开连接 */
function disconnect() {
stompClient.disconnect(function() {
alert("断开连接");
});
}
/* 发送消息并指定目标地址(这里设置的目标地址为自身订阅消息的地址,当然也可以设置为其它地址) */
function sendMessageNoParameter() {
// 设置发送的内容
var sendContent = $("#content").val();
// 设置待发送的消息内容
var message = '{"destination": "' + SUBSCRIBE + '", "content": "' + sendContent + '"}';
// 发送消息
stompClient.send(SEND_ENDPOINT, {}, message);
}
创建用于展示 WebSocket 相关功能的 WEB HTML 页面 index.html,内容如下:
<!DOCTYPE html>
<html>
<head>
<title>Hello WebSocket</title>
<link href="https://cdn.bootcdn.net/ajax/libs/twitter-bootstrap/3.4.1/css/bootstrap.min.css" rel="stylesheet">
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
<script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.5.1/jquery.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/sockjs-client/1.4.0/sockjs.min.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/stomp.js/2.3.3/stomp.min.js"></script>
<script src="app-websocket.js"></script>
</head>
<body>
<div id="main-content" class="container" style="margin-top: 10px;">
<div class="row">
<form class="navbar-form" style="margin-left:0px">
<div class="col-md-12">
<div class="form-group">
<label>WebSocket 连接:</label>
<button class="btn btn-primary" type="button" onclick="connect();">进行连接</button>
<button class="btn btn-danger" type="button" onclick="disconnect();">断开连接</button>
</div>
<label>订阅地址:</label>
<div class="form-group">
<input type="text" id="subscribe" class="form-control" placeholder="订阅地址">
</div>
<button class="btn btn-warning" onclick="subscribeSocket();" type="button">订阅</button>
</div>
</form>
</div>
</br>
<div class="row">
<div class="form-group">
<label for="content">发送的消息内容:</label>
<input type="text" id="content" class="form-control" placeholder="消息内容">
</div>
<button class="btn btn-info" onclick="sendMessageNoParameter();" type="button">发送</button>
</div>
</br>
<div class="row">
<div class="col-md-12">
<h5 class="page-header" style="font-weight:bold">接收到的消息:</h5>
<table class="table table-striped">
<tbody id="information"></tbody>
</table>
</div>
</div>
</div>
</body>
</html>
输入地址 http://localhost:8080/index.html
访问测试的前端页面,然后执行下面步骤进行测试:
hello world!
,然后点击发送按钮发送消息;执行完上面步骤成后,可以观察到成功接收到订阅地址的消息,如下:
这里使用 Maven 工具管理依赖包,分别引入下面依赖:
lombok
: Lombok 工具依赖,便于生成实体对象的 Get 与 Set 方法。spring-boot-starter-websocket
:SpringBoot 实现 WebSocket 的依赖,里面对 WebSocket 进行了一些列封装,并且也包含了 SpringBoot Web 依赖。spring-boot-starter-security
:Spring Security,这是一种基于 Spring AOP 和 Servlet 过滤器的安全框架。它提供全面的安全性解决方案,同时在 Web 请求级和方法调用级处理身份确认和授权。<dependencies>
<!-- SpringBoot WebSocket -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<!-- Security -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<scope>provided</scope>
</dependency>
</dependencies>
创建便于传输消息的实体类,里面字段内容如下:
@Data
public class MessageBody {
/** 发送消息的用户 */
private String from;
/** 消息内容 */
private String content;
/** 目标用户(告知 STOMP 代理转发到哪个用户) */
private String targetUser;
/** 广播转发的目标地址(告知 STOMP 代理转发到哪个地方) */
private String destination;
}
创建 WebSocket
配置类,配置进行连接注册的端点/mydlq
和消息代理前缀 /queue
及接收客户端发送消息的前缀 /app
。
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
/**
* 配置 WebSocket 进入点,及开启使用 SockJS,这些配置主要用配置连接端点,用于 WebSocket 连接
*
* @param registry STOMP 端点
*/
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/mydlq").withSockJS();
}
/**
* 配置消息代理选项
*
* @param registry 消息代理注册配置
*/
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
// 设置一个或者多个代理前缀,在 Controller 类中的方法里面发生的消息,会首先转发到代理从而发送到对应广播或者队列中。
registry.enableSimpleBroker("/queue");
// 配置客户端发送请求消息的一个或多个前缀,该前缀会筛选消息目标转发到 Controller 类中注解对应的方法里
registry.setApplicationDestinationPrefixes("/app");
// 服务端通知特定用户客户端的前缀,可以不设置,默认为user
registry.setUserDestinationPrefix("/user");
}
}
Spring Security 的配置类,可以在该类中配置权限认证及测试的两个用户相关信息:
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
/**
* 设置密码编码的配置参数,这里设置为 NoOpPasswordEncoder,不配置密码加密,方便测试。
*
* @return 密码编码实例
*/
@Bean
PasswordEncoder passwordEncoder() {
return NoOpPasswordEncoder.getInstance();
}
/**
* 设置权限认证参数,这里用于创建两个用于测试的用户信息。
*
* @param auth SecurityBuilder 用于创建 AuthenticationManager。
* @throws Exception 抛出的异常
*/
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication()
.withUser("mydlq1")
.password("123456")
.roles("admin")
.and()
.withUser("mydlq2")
.password("123456")
.roles("admin");
}
/**
* 设置 HTTP 安全相关配置参数
*
* @param http HTTP Security 对象
* @throws Exception 抛出的异常信息
*/
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest().authenticated()
.and()
.formLogin()
.permitAll();
}
}
跟上面介绍广播模式一样,作用也是根据 WebSocket
配置类中 /app
前缀匹配后进入 Controller
类进行逻辑处理操作。
@Controller
public class MessageController {
@Autowired
private SimpMessageSendingOperations simpMessageSendingOperations;
/**
* 点对点发送消息,将消息发送到指定用户
*/
@MessageMapping("/test")
public void sendUserMessage(Principal principal, MessageBody messageBody) {
// 设置发送消息的用户
messageBody.setFrom(principal.getName());
// 调用 STOMP 代理进行消息转发
simpMessageSendingOperations.convertAndSendToUser(messageBody.getTargetUser(), messageBody.getDestination(), messageBody);
}
}
创建用于操作 WebSocket 的 JS 文件 app-websocket.js,内容如下:
// 设置 STOMP 客户端
var stompClient = null;
// 设置 WebSocket 进入端点
var SOCKET_ENDPOINT = "/mydlq";
// 设置订阅消息的请求前缀
var SUBSCRIBE_PREFIX = "/topic"
// 设置订阅消息的请求地址
var SUBSCRIBE = "";
// 设置服务器端点,访问服务器中哪个接口
var SEND_ENDPOINT = "/app/test";
/* 进行连接 */
function connect() {
// 设置 SOCKET
var socket = new SockJS(SOCKET_ENDPOINT);
// 配置 STOMP 客户端
stompClient = Stomp.over(socket);
// STOMP 客户端连接
stompClient.connect({}, function (frame) {
alert("连接成功");
});
}
/* 订阅信息 */
function subscribeSocket(){
// 设置订阅地址
SUBSCRIBE = SUBSCRIBE_PREFIX + $("#subscribe").val();
// 输出订阅地址
alert("设置订阅地址为:" + SUBSCRIBE);
// 执行订阅消息
stompClient.subscribe(SUBSCRIBE, function (responseBody) {
var receiveMessage = JSON.parse(responseBody.body);
$("#information").append("<tr><td>" + receiveMessage.content + "</td></tr>");
});
}
/* 断开连接 */
function disconnect() {
stompClient.disconnect(function() {
alert("断开连接");
});
}
/* 发送消息并指定目标地址 */
function sendMessageNoParameter() {
// 设置发送的内容
var sendContent = $("#content").val();
// 设置待发送的消息内容
var message = '{"destination": "' + SUBSCRIBE + '", "content": "' + sendContent + '"}';
// 发送消息
stompClient.send(SEND_ENDPOINT, {}, message);
}
创建用于展示 WebSocket 相关功能的 WEB HTML 页面 index.html,内容如下:
<!DOCTYPE html>
<html>
<head>
<title>Hello WebSocket</title>
<link href="https://cdn.bootcdn.net/ajax/libs/twitter-bootstrap/3.4.1/css/bootstrap.min.css" rel="stylesheet">
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
<script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.5.1/jquery.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/sockjs-client/1.4.0/sockjs.min.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/stomp.js/2.3.3/stomp.min.js"></script>
<script src="app-websocket.js"></script>
</head>
<body>
<div id="main-content" class="container" style="margin-top: 10px;">
<div class="row">
<form class="navbar-form" style="margin-left:0px">
<div class="col-md-12">
<div class="form-group">
<label>WebSocket 连接:</label>
<button class="btn btn-primary" type="button" onclick="connect();">进行连接</button>
<button class="btn btn-danger" type="button" onclick="disconnect();">断开连接</button>
</div>
<label>订阅地址:</label>
<div class="form-group">
<input type="text" id="subscribe" class="form-control" placeholder="订阅地址">
</div>
<button class="btn btn-warning" onclick="subscribeSocket();" type="button">订阅</button>
</div>
</form>
</div>
</br>
<div class="row">
<div class="form-group">
<label for="content">发送的消息内容:</label>
<input type="text" id="content" class="form-control" placeholder="消息内容">
</div>
<button class="btn btn-info" onclick="sendMessageNoParameter();" type="button">发送</button>
</div>
</br>
<div class="row">
<div class="col-md-12">
<h5 class="page-header" style="font-weight:bold">接收到的消息:</h5>
<table class="table table-striped">
<tbody id="information"></tbody>
</table>
</div>
</div>
</div>
</body>
</html>
为了方便测试,需要打开两个不同类型浏览器(因为用户登录后会存 Session
,如果一个浏览器不同用户登录会使之前 Session
失效)来进行测试,两个浏览器同时输入地址 http://localhost:8080/index.html
访问测试的前端页面,然后可以看到并没有进入 /index.html
页面,而是跳转到Spring Security
提供的登录的 /login
页面,如下:
两个浏览器中都输入用户名/密码 mydlq1/123456
与 mydlq2/123456
进行登录,然后会回到 /index.html
页面,然后执行下面步骤进行测试:
mydlq1
)设置发送目标用户为”/mydlq2
”,”浏览器2”(用户 mydlq2
)设置发送目标用户为”/mydlq1
”;mydlq1
)设置发送消息为Hi, I’m mydlq1
,”浏览器2”(用户 mydlq2
)设置发送消息为Hi, I’m mydlq2
;发送
按钮发送消息;执行完上面步骤成后,可以在两个不同浏览器中观察到如下内容:
“同示例二
@Data
public class MessageBody {
/** 发送消息的用户 */
private String from;
/** 消息内容 */
private String content;
/** 目标用户(告知 STOMP 代理转发到哪个用户) */
private String targetUser;
/** 广播转发的目标地址(告知 STOMP 代理转发到哪个地方) */
private String destination;
}
@Data
@AllArgsConstructor
public class User {
private String username;
private String token;
}
配置 WebSocket 通道拦截器,里面添加两个模拟用户:
mydlq1
,Token
:123456-1mydlq2
,Token
:123456-2/**
* WebSocket 通道拦截器(这里模拟两个测试 Token 方便测试,不做具体 Token 鉴权实现)
*
* @author mydlq
*/
public class MyChannelInterceptor implements ChannelInterceptor {
/** 测试用户与 token 1 */
private User mydlq1 = new User("","123456-1");
/** 测试用户与 token 2 */
private User mydlq2 = new User("","123456-2");
/**
* 从 Header 中获取 Token 进行验证,根据不同的 Token 区别用户
*
* @param message 消息对象
* @param channel 通道对象
* @return 验证后的用户信息
*/
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
String token = getToken(message);
if (token!=null && accessor != null && StompCommand.CONNECT.equals(accessor.getCommand())) {
Principal user = null;
// 提前创建好两个测试 token 进行匹配,方便测试
if (mydlq1.getToken().equals(token)){
user = () -> mydlq1.getUsername();
} else if (mydlq2.getToken().equals(token)){
user = () -> mydlq2.getUsername();
}
accessor.setUser(user);
}
return message;
}
/**
* 从 Header 中获取 TOKEN
*
* @param message 消息对象
* @return TOKEN
*/
private String getToken(Message<?> message){
Map<String,Object> headers = (Map<String, Object>) message.getHeaders().get("nativeHeaders");
if (headers !=null && headers.containsKey("token")){
List<String> token = (List<String>)headers.get("token");
return String.valueOf(token.get(0));
}
return null;
}
}
创建 WebSocket 配置类,配置进行连接注册的端点 /mydlq
和消息代理前缀 /queue
及接收客户端发送消息的前缀 /app
。
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
/**
* 配置 WebSocket 进入点,及开启使用 SockJS,这些配置主要用配置连接端点,用于 WebSocket 连接
*
* @param registry STOMP 端点
*/
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/mydlq").withSockJS();
}
/**
* 配置消息代理选项
*
* @param registry 消息代理注册配置
*/
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
// 设置一个或者多个代理前缀,在 Controller 类中的方法里面发生的消息,会首先转发到代理从而发送到对应广播或者队列中。
registry.enableSimpleBroker("/queue");
// 配置客户端发送请求消息的一个或多个前缀,该前缀会筛选消息目标转发到 Controller 类中注解对应的方法里
registry.setApplicationDestinationPrefixes("/app");
// 服务端通知特定用户客户端的前缀,可以不设置,默认为user
registry.setUserDestinationPrefix("/user");
}
/**
* 配置通道拦截器,用于获取 Header 的 Token 进行鉴权
*
* @param registration 注册通道配置类
*/
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(new MyChannelInterceptor());
}
}
@Controller
public class MessageController {
@Autowired
private SimpMessageSendingOperations simpMessageSendingOperations;
/**
* 点对点发送消息,将消息发送到指定用户
*/
@MessageMapping("/test")
public void sendUserMessage(Principal principal, MessageBody messageBody) {
// 设置发送消息的用户
messageBody.setFrom(principal.getName());
// 调用 STOMP 代理进行消息转发
simpMessageSendingOperations.convertAndSendToUser(messageBody.getTargetUser(), messageBody.getDestination(), messageBody);
}
}
创建用于操作 WebSocket 的 JS 文件 app-websocket.js,内容如下:
// 设置 STOMP 客户端
var stompClient = null;
// 设置 WebSocket 进入端点
var SOCKET_ENDPOINT = "/mydlq";
// 设置订阅消息的请求地址前缀
var SUBSCRIBE_PREFIX = "/queue";
// 设置订阅地址
var SUBSCRIBE = "";
// 设置服务器端点,访问服务器中哪个接口
var SEND_ENDPOINT = "/app/test";
/* 进行连接 */
function connect() {
// 设置 SOCKET
var socket = new SockJS(SOCKET_ENDPOINT);
// 配置 STOMP 客户端
stompClient = Stomp.over(socket);
// 获取 TOKEN
var myToken = $("#myToken").val();
// STOMP 客户端连接
stompClient.connect({token: myToken}, function (frame) {
alert("连接成功");
});
}
/* 订阅信息 */
function subscribeSocket(){
// 设置订阅地址
SUBSCRIBE = SUBSCRIBE_PREFIX + $("#subscribe").val();
// 输出订阅地址
alert("设置订阅地址为:" + SUBSCRIBE);
// 执行订阅消息
stompClient.subscribe("/user" + SUBSCRIBE, function (responseBody) {
var receiveMessage = JSON.parse(responseBody.body);
console.log(receiveMessage);
$("#information").append("<tr><td>" + receiveMessage.content + "</td></tr>");
});
}
/* 断开连接 */
function disconnect() {
stompClient.disconnect(function() {
alert("断开连接");
});
}
/* 发送消息并指定目标地址 */
function sendMessageNoParameter() {
// 设置发送的内容
var sendContent = $("#content").val();
// 设置发送的用户
var sendUser = $("#targetUser").val();
// 设置待发送的消息内容
var message = '{"targetUser":"' + sendUser + '", "destination": "' + SUBSCRIBE + '", "content": "' + sendContent + '"}';
// 发送消息
stompClient.send(SEND_ENDPOINT, {}, message);
}
创建用于展示 WebSocket 相关功能的 WEB HTML 页面 index.html,内容如下:
<!DOCTYPE html>
<html>
<head>
<title>Hello WebSocket</title>
<link href="https://cdn.bootcdn.net/ajax/libs/twitter-bootstrap/3.4.1/css/bootstrap.min.css" rel="stylesheet">
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
<script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.5.1/jquery.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/sockjs-client/1.4.0/sockjs.min.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/stomp.js/2.3.3/stomp.min.js"></script>
<script src="app-websocket.js"></script>
</head>
<body>
<div id="main-content" class="container" style="margin-top: 10px;">
<div class="row">
<form class="navbar-form" style="margin-left:0px">
<div class="col-md-12">
<div class="form-group">
<label>WebSocket 连接:</label>
<button class="btn btn-primary" type="button" onclick="connect();">进行连接</button>
<button class="btn btn-danger" type="button" onclick="disconnect();">断开连接</button>
</div>
<label>订阅地址:</label>
<div class="form-group">
<input type="text" id="subscribe" class="form-control" placeholder="订阅地址">
</div>
<button class="btn btn-warning" onclick="subscribeSocket();" type="button">订阅</button>
</div>
</form>
</div>
</br>
<div class="row">
<div class="form-group">
<label>TOKEN 信息:</label>
<input type="text" id="myToken" class="form-control" placeholder="TOKEN 信息">
<label>发送的目标用户:</label>
<input type="text" id="targetUser" class="form-control" placeholder="发送的用户">
<label for="content">发送的消息内容:</label>
<input type="text" id="content" class="form-control" placeholder="消息的内容">
</div>
<button class="btn btn-info" onclick="sendMessageNoParameter();" type="button">发送</button>
</div>
</br>
<div class="row">
<div class="col-md-12">
<h5 class="page-header" style="font-weight:bold">接收到的消息:</h5>
<table class="table table-striped">
<tbody id="information"></tbody>
</table>
</div>
</div>
</div>
</body>
</html>
为了方便测试,需要打开两个不同类型浏览器(这里模拟通过 Header 传 Token 的方式进行用户验证,具体登录逻辑不实现,而是直接使用事先配置好的两个用户 Token 进行模拟)来进行测试,两个浏览器同时输入地址 http://localhost:8080/index.html
访问测试的前端页面 ``/index.html` 如下:
两个浏览器中都执行下面步骤进行测试:
/abc
,然后点击订阅按钮进行消息订阅;/mydlq2
,浏览器2(用户 mydlq2)设置发送目标用户为/mydlq1
;执行完上面步骤成后,可以在两个不同浏览器中观察到如下内容:
WebSocket 配置类,里面设置允许跨域,内容如下:
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/queue");
registry.setApplicationDestinationPrefixes("/app");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/mydlq")
// 设置允许跨域,设置为"*"则为允许全部域名
.setAllowedOrigins("*")
.withSockJS();
}
}
创建 WebSocket 用户上线、下线处理器,内容如下:
@Configuration
public class HttpWebSocketHandlerDecoratorFactory implements WebSocketHandlerDecoratorFactory {
/**
* 配置 webSocket 处理器
*
* @param webSocketHandler webSocket 处理器
* @return webSocket 处理器
*/
@Override
public WebSocketHandler decorate(WebSocketHandler webSocketHandler) {
return new WebSocketHandlerDecorator(webSocketHandler) {
/**
* websocket 连接时执行的动作
* @param session websocket session 对象
* @throws Exception 异常对象
*/
@Override
public void afterConnectionEstablished(final WebSocketSession session) throws Exception {
// 输出进行 websocket 连接的用户信息
if (session.getPrincipal() != null) {
String username = session.getPrincipal().getName();
System.out.println("用户:" + username + "上线");
super.afterConnectionEstablished(session);
}
}
/**
* websocket 关闭连接时执行的动作
* @param session websocket session 对象
* @param closeStatus 关闭状态对象
* @throws Exception 异常对象
*/
@Override
public void afterConnectionClosed(final WebSocketSession session, CloseStatus closeStatus) throws Exception {
// 输出关闭 websocket 连接的用户信息
if (session.getPrincipal() != null) {
String username = session.getPrincipal().getName();
System.out.println("用户:" + username + "下线");
super.afterConnectionClosed(session, closeStatus);
}
}
};
}
}
WebSocket 配置类中实现 configureWebSocketTransport()
方法,将上面 WebSocket 处理器加到其中,如下:
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/queue");
registry.setApplicationDestinationPrefixes("/app");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/mydlq").withSockJS();
}
/**
* 添加 WebSocket 用户上、下线监听器
*/
@Override
public void configureWebSocketTransport(WebSocketTransportRegistration registry) {
registry.addDecoratorFactory(new HttpWebSocketHandlerDecoratorFactory());
}
}