本篇来学习如何在SpringBoot中整合WebSocket,如果你之前没有使用过WebSocket,那么就有一个疑问,为什么需要使用WebSocket,以及它能做什么事情,这些当你阅读完本篇文章后,都会有一个较为清晰的答案。

为什么需要WebSocket

在HTTP协议中,所有的请求都是由客户端发起的,由服务端进行响应,服务端无法向客户端推送消息,但是在一些需要即时通信的应用中,又不可避免的需要服务端向客户端推送消息,传统的解决方案主要有如下几种:

1、轮询。轮询是最简单的一种解决方案,所谓轮询就是客户端在固定的时候间隔下不停地向服务端发送请求,查看服务段是否有最新的数据,若服务端有最新的数据,则返回给客户端;若服务端没有最新数据,则返回一个空的JSON或者XML文档。轮询对于开发人员而言实现起来非常方便,但是弊端也很明显:客户端每次都要新建HTTP请求,服务端要处理大量的无效请求,在高并发场景下会严重拖慢服务端的运行效率,同时服务端的资源被极大的浪费了,因此这种方式在实际开发中并不可取,不到万不得已不会使用这种方式。
2、长轮询。长轮询是对传统轮询的升级,开发人员看到传统轮询存在的弊端后,就开始解决这个弊端,于是就有了长轮询。前面说过传统轮询最大的弊端就是服务端要处理大量的无效请求,因此在长轮询中服务端不是每次都会立即响应客户端的请求,只有在服务端有最新数据的时候,才会立即响应客户端的请求,否则服务端会持有这个请求而不返回,直到有新数据时才返回。这种方式在一定程度上节省了网络资源和服务器资源,但是也存在一些弊端:(1)如果浏览器在服务器响应之前有新的数据需要发送,就只能创建一个新的并发请求,或者先尝试断掉当前请求,再创建新的请求。(2)TCP和HTTP规范中都有连接超时一说,所以所谓的长轮询并不能一直持续,服务端和客户端的连接需要定期的连接和关闭再连接,这又增加了开发人员的工作量,当然也有一些技术能够延长每次连接的时间,但毕竟是非主流的解决方案。
3、Applet和Flash。Applet 和Flash 都己经是明日黄花,不过在这两个技术存在的岁月里,除了可以让我们的HTML页面更加绚丽之外,还可以解决消息推送问题。开发者可以使用Applet和Flash来模拟全双工通信,
通过创建一个只有1个像素点大小的透明的Applet或者Flash,然后将之内嵌在网页中,再从Applet或者Flash的代码中创建一个Socket连接进行双向通信。这种连接方式消除了HTTP协议中的诸多限制,当服务器有消息发送到客户端的时候,开发者可以在Applet或者Flash中调用JavaScript函数将数据显示在页面上,当浏览器有数据要发送给服务器时也一样,通过Applet或者Flash来传递。这种方式真正地实现了全双工通信,不过也有问题,说明如下:(1)浏览器必须能够运行Java或者Flash;(2)无论是Applet 还是Flash都存在安全问题;(3)随着HTML5标准被各浏览器厂商广泛支持, Flash下架已经被提上日程(Adobe宣布2020年正式停止支持Flash)。

其实,传统的解决方案不止这三种,但是无论哪种解决方案都有自身的缺陷,于是就有了WebSocket的诞生。

WebSocket简介

WebSocket是一种在单个TCP连接上进行全双工通信的协议,目前已经是W3C标准。使用WebSocket可以使得客户端和服务器之间的数据交换变得更加简单,它允许服务端主动向客户端推送数据。在WebSocket协议中,浏览器和服务器只需要完成一次握手,两者之间就可以直接创建持久性的连接,并进行双向数据传输。

WebSocket使用了HTTP/1.1的协议升级特性,一个WebSocket请求首先使用非正常的HTTP请求以特定的模式访问一个URL,这个URL有两种模式,分别是ws和wss,对应于HTTP协议中的HTTP和HTTPS,在请求头中有一个Connetion:Upgrade字段,表示客户端想对协议进行升级,另外还有一个Upgrade:websocket字段,表示客户端想将请求协议升级为WebSocket协议。这两个字段共同告诉服务器要将连接升级为WebSocket这样一种全双工协议。如果服务端同意协议升级,那么在握手完成之后,文本消息或者其他二进制消息就可以同时在两个方向上进行发送,而不需要关闭和重建连接。此时的客户端和服务端关系是对等的,它们可以互相向对方主动发送消息。和传统的解决方案相比,WebSocket主要有以下几个特点:(1)WebSocket使用是需要先创建连接,这使得WebSocket成为一种有状态的协议,在之后的通信过程中可以省略部分状态信息(如身份验证等);(2)WebSocket连接在端口80(ws)或者443(wss)上创建,与HTTP使用的端口相同,这样基本上所有的防火墙都不会阻止WebSocket的连接;(3)WebSocket使用HTTP协议进行握手,因此它可以自然而然地集成到网络浏览器和HTTP服务器中,而不需要额外的成本;(4)心跳消息(ping和pong)将被反复的发送,进而保持WebSocket连接一直处于活跃状态;(5)使用WebSocket协议时,当消息启动或者到达的时候,服务端和客户端都可以知道;(6)WebSocket连接关闭时将发送一个特殊的关闭消息;(7)WebSocket支持跨域,可以避免Ajax的限制;(8)HTTP规范要求浏览器将并发连接数限制为每个主机名两个连接,但是当我们使用WebSocket的时候,当握手完成之后,该限制就不存在了,因为此时的连接已经不再是HTTP连接了;(9)WebSocket协议支持扩展,用户可以扩展该协议,用来实现部分自定义的子协议;(10)WebSocket提供了更好的二进制支持以及更好的压缩效果。

WebSocket既然有上面提到的至少10个优势,其使用场景也是非常广泛的,如在线股票网站、即时聊天、多人在线游戏、应用集群通信等,在了解了这么多WebSocket的基本信息后,接下来学习如何在SpringBoot中使用WebSocket。

SpringBoot整合WebSocket

SpringBoot对WebSocket提供了非常友好的支持,可以方便开发者在项目中快速集成WebSocket功能,实现单人或者多人聊天等功能。

消息群发

第一步,创建项目并添加依赖。*使用spring Initializr构建工具构建一个SpringBoot的Web应用,名称为websocketmessages,然后在pom.xml文件中添加如下依赖:

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
<!--添加webSocket依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency>
<groupId>org.webjars</groupId>
<artifactId>webjars-locator-core</artifactId>
</dependency>
<dependency>
<groupId>org.webjars</groupId>
<artifactId>sockjs-client</artifactId>
<version>1.1.2</version>
</dependency>
<dependency>
<groupId>org.webjars</groupId>
<artifactId>stomp-websocket</artifactId>
<version>2.3.3</version>
</dependency>
<dependency>
<groupId>org.webjars</groupId>
<artifactId>jquery</artifactId>
<version>3.3.1</version>
</dependency>
<!--添加web依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!--添加lombok依赖-->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>

spring-boot-starter-websocket依赖的是WebSocket相关依赖,其他的都是前端库,使用jar包的形式对这些前端库进行统一管理,使用webjar添加到项目中的前端库,在SpringBoot项目中已经默认添加了静态资源过滤,因此可以直接使用。
第二步,配置WebSocket。Spring框架提供了基于WebSocket的STOMP支持,STOMP是一个简单的可互操作的协议,通常被用于通过中间服务器在客户端之间进行异步信息传递。新建一个config包,然后在其中创建WebSocketConfig类并实现WebSocketMessageBrokerConfigurer接口中的configureMessageBrokerregisterStompEndpoints方法,其中的代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/topic");
registry.setApplicationDestinationPrefixes("/app");
}

@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/chat").withSockJS();
}
}

解释一下上述代码的含义:

  • 自定义WebSocketConfig类实现了WebSocketMessageBrokerConfigurer接口并进行WebSocket的设置,然后通过@EnableWebSocketMessageBroker注解开启WebSocket消息代理。
  • registry.enableSimpleBroker("/topic")表示设置消息代理的前缀,即如果消息的前缀是/topic,那么就会将消息转发给消息代理(broker),再由消息代理将消息广播给当前连接的客户端。
  • registry.setApplicationDestinationPrefixes("/app")表示配置一个或多个前缀,通过这些前缀过滤出需要被注解方法处理的消息。如前缀为/app的destination可以通过@MessageMapping注解的方法处理,而其他的destination(如/topic或者/queue)将被直接交给broker处理。
  • registry.addEndpoint("/chat").withSockJS()则表示定义一个前缀为/chat的endPoint,并开启sockjs支持,sockjs可以解决浏览器对WebSocket的兼容性问题,客户端将通过这里配置的URL来建立WebSocket连接。

第三步,自定义Mesage实体类和Controller类。新建一个pojo包,并在其中新建Message实体类,其中的代码为:

1
2
3
4
5
@Data
public class Message {
private String name;
private String content;
}

接着新建一个controller包,并在其中新建GreetingController,其中的代码为:

1
2
3
4
5
6
7
8
@Controller
public class GreetingController {
@MessageMapping("/hello")
@SendTo("/topic/greetings")
public Message greeting(Message message) throws Exception{
return message;
}
}

根据第二步的配置,@MessageMapping("/hello")注解的方法将用来接收/app/hello路径发送来的消息,在注解方法中对消息进行处理后,再将该消息转发到@SendTo定义的路径上,而@SendTo路径是一个前缀为/topic的路径,因此该消息将被交给消息代理broker,再由broker进行广播。
第四步,搭建聊天页面。resources/static目录下创建chat.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
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>群聊页面</title>
<script src="/webjars/jquery/jquery.min.js"></script>
<script src="/webjars/sockjs-client/sockjs.min.js"></script>
<script src="/webjars/stomp-websocket/stomp.min.js"></script>
<script src="./app.js"></script>
</head>
<body>

<div>
<label for="name">请输入用户名:</label>
<input type="text" id="name" placeholder="用户名">
</div>

<div>
<button id="connect" type="button">连接</button>
<button id="disconnect" type="button" disabled="disabled">断开连接</button>
</div>
<div id="chat" style="display: none;"></div>

<div>
<label for="name">请输入聊天内容:</label>
<input type="text" id="content" placeholder="聊天内容">
</div>
<button type="button" id="send">发送</button>
<div id="greetings">
<div id="conversation" style="display: none">群聊进行中...</div>
</div>
</body>
</html>

注意前面四个script脚本引入的是外部的JS库,这些JS库在pom.xml文件中通过依赖加载进来。其中app.js是一个自定义的JS,里面的代码为:

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
var stompClient = null;
function setConnected(connected) {
$("#connect").prop("disabled",connected);
$("#disconnect").prop("disabled",!connected);
if(connected){
$("#conversation").show();
$("#chat").show();
}else{
$("#conversation").hide();
$("#chat").hide();
}
$("#greetings").html("");
}
function connect() {
if(!$("#name").val()){
return;
}
var socket = new SockJS('/chat');
stompClient =Stomp.over(socket);
stompClient.connect({},function (frame) {
setConnected(true);
stompClient.subscribe('/topic/greetings',function (greeting) {
showGreeting(JSON.parse(greeting.body));
})
})
}
function disconnect() {
if(stompClient !==null){
stompClient.disconnect();
}
setConnected(false);
}
function sendName() {
stompClient.send('/app/hello',{},JSON.stringify({
'name': $("#name").val(),
'content': $("#content").val()
}));
}
function showGreeting(message) {
$("#greetings").append("<div>"+message.name+":"+message.content +"</div>");
}
$(function () {
$("#connect").click(function () {
connect();
});
$("#disconnect").click(function () {
disconnect();
});
$("#send").click(function () {
sendName();
})
});

解释一下上述代码的含义:

  • connect方法表示建立一个WebSocket连接,在建立WebSocket连接时,用户必须先输入用户名,然后才能建立连接。
  • 接着首先使用SockJS建立连接,然后创建一个STOMP实例发起连接请求,在连接成功的回调方法中,首先调用setConnected(true)方法进行页面的设置,然后调用STOMP中的subscribe方法订阅服务端发送回来的消息,并将服务端发送来的消息通过showGreeting方法展示出来。
  • 最后调用STOMP中的disconnect方法可以断开一个WebSocket连接。

第五步,测试。接下来启动SpringBoot项目进行测试,在浏览器地址栏中输入http://localhost:8080/chat.html,显示结果如下图所示:

用户首先输入用户名,然后单击“连接”按钮,注意先不要在聊天框内填写任何信息:

然后换一个浏览器或者使用Chrome浏览器的多用户(注意不是多选项卡),重复刚才的步骤,这样就有两个用户连接上了(当然也可以有更多的用户连接上来),这样就可以开始群聊了,如下图所示:

消息点对点发送

在前面的消息发送中使用到了@SendTo注解,该注解将方法处理过的消息转发到broker,再由broker进行消息广播。除了@SendTo注解外,Spring还提供了SimpMessagingTemplate类来让开发者更加灵活地发送消息,使用SimpMessagingTemplate类可以对上述例子中的GreetingController类进行改造:

1
2
3
4
5
6
7
8
9
10
@Controller
public class GreetingController {
@Autowired
SimpMessagingTemplate simpMessagingTemplate;

@MessageMapping("/hello")
public void greeting(Message message) throws Exception{
simpMessagingTemplate.convertAndSend("/topic/greetings",message);
}
}

改造完成后,直接运行和前面是没有什么区别的。这里使用了SimpMessagingTemplate进行消息的发送,在SpringBoot中,SimpMessagingTemplate已经配置好,开发者可以直接注入进来即可。使用SimpMessagingTemplate,开发者可以在任意地方发送消息到broker,也可以发送消息给某一个用户,这就是点对点的消息发送。接下来看看如何实现消息的点对点发送。(请注意本例子是在前面的基础上进行,但是考虑到以后复习的方便,此时我又重新创建了一个SpringBoot项目,名称为Websocketm2m这个项目其实就是将前面的项目改了一个名字,顺便将GreetingController进行了改造而已,其他的没有什么变动)。

第一步,添加依赖。既然是点对点发送,那么就应该有用户这个概念,因此需要在项目中添加Spring Security依赖:

1
2
3
4
5
<!--添加Spring Security依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>

第二步,配置Spring Security。简单起见,这里就不使用数据库了,而是直接在内存中进行验证,添加两个用户,同时配置所有地址都需要认证后才能访问。新建一个WebSecurityConfig类,需要继承WebSecurityConfigurerAdapter类并重写其中的configure(AuthenticationManagerBuilder auth)configure(HttpSecurity http)方法,相应的代码为:

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
@Configuration
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

@Bean
PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder(10);
}

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.inMemoryAuthentication()
.withUser("admin")
.password("$2a$10$LE86rB.N5wYXAVRkLIEtGuLnPzvccwWN2/V5ZWa6g0Drk3WfvV5ou")
.roles("admin")
.and()
.withUser("envy")
.password("$2a$10$LE86rB.N5wYXAVRkLIEtGuLnPzvccwWN2/V5ZWa6g0Drk3WfvV5ou")
.roles("user");
}

@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.anyRequest().authenticated()
.and()
.formLogin().permitAll()
.and()
.csrf().disable();
}
}

上述这些代码是常规配置,在学习Spring Security框架时就已经介绍了,因此这里就不再赘述了。
第三步,改造WebSocket配置。接下来对前面的WebSocketConfig类进行改造,改造后的代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/topic","/queue");
registry.setApplicationDestinationPrefixes("/app");
}

@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/chat").withSockJS();
}
}

所谓改造,只是在configureMessageBroker(MessageBrokerRegistry registry)方法的enableSimpleBroker("/topic","/queue")内新增了一个broker前缀/queue,这样方便对群发消息和点对点消息进行管理。

第四步,自定义Chat实体类和修改Controller类。在pojo包内新建Chat实体类,其中的代码为:

1
2
3
4
5
6
@Data
public class Chat {
private String to;
private String from;
private String content;
}

接着继续对GreetingController类进行进行改造,改造后的代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Controller
public class GreetingController {
@Autowired
SimpMessagingTemplate simpMessagingTemplate;

/**群发消息**/
@MessageMapping("/hello")
@SendTo("/topic/greetings")
public Message greeting(Message message) throws Exception{
return message;
}

/**消息点对点**/
@MessageMapping("/chat")
public void chat(Principal principal, Chat chat) throws Exception{
String from = principal.getName();
chat.setFrom(from);
simpMessagingTemplate.convertAndSendToUser(chat.getTo(), "/queue/chat", chat);
}
}

解释一下上述代码的含义:

  • 群发消息依然使用@SendTo注解来实现,消息点对点发送则使用了SimpMessagingTemplate来实现。
  • 之后定义了一个新的消息处理接口,@MessageMapping("/chat")注解表示来自/app/chat路径的消息将被chat方法所处理,chat方法第一个参数Principal可以用来获取当前登录用户的信息,第二个参数则是客户端发送过来的信息。
  • 在chat方法中,首先需要获取当前的用户名,然后设置chat对象的from属性,再将消息发送出去,发送的目标用户就是chat对象的to属性的值。
  • 消息发送使用的方法是convertAndSendToUser,该方法内部调用了convertAndSend方法,并且对消息路径做了处理,部分源码如下:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    private String destinationPrefix = "/user/";
    ......

    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");
    user = StringUtils.replace(user, "/", "%2F");
    destination = destination.startsWith("/") ? destination : "/" + destination;
    super.convertAndSend(this.destinationPrefix + user + destination, payload, headers, postProcessor);
    }
    可以看到这个destinationPrefix的默认值是/user,也就是说消息的最终发送路径是/user/用户名/queue/chat。前面的chat是一个普通的JavaBean,to属性表示消息到哪里去(消息的目标用户),from表示消息从哪里来,content则是消息的主题内容。

第五步,创建在线聊天页面。resources/static目录下创建onlinechat.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
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>单聊页面</title>
<script src="/webjars/jquery/jquery.min.js"></script>
<script src="/webjars/sockjs-client/sockjs.min.js"></script>
<script src="/webjars/stomp-websocket/stomp.min.js"></script>
<script src="./chat.js"></script>
</head>
<body>

<!--聊天信息显示-->
<div id="chat">
<div id="chatsContent"></div>
</div>

<div>
请输入聊天内容:
<input type="text" id="content" placeholder="聊天内容">
目标用户:
<input type="text" id="to" placeholder="目标用户">
</div>
<button type="button" id="send">发送</button>
</body>
</html>

这个页面和chat.html页面基本相似,不同的是这里需要手动输入目标用户名。另外这里还有一个chat.js文件,其中的代码为:

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
var stompClient = null;
function connect() {
var socket = new SockJS('/chat');
stompClient =Stomp.over(socket);
stompClient.connect({},function (frame) {
stompClient.subscribe('/user/queue/chat',function (chat) {
showGreeting(JSON.parse(chat.body));
})
})
}
function sendMsg() {
stompClient.send('/app/chat',{},JSON.stringify({
'to': $("#to").val(),
'content': $("#content").val()
}));
}
function showGreeting(message) {
$("#chatsContent").append("<div>"+message.from+":"+message.content +"</div>");
}
$(function () {
connect();
$("#send").click(function () {
sendMsg();
})
});

chat.js文件基本上和前面的app.js文件内容一致,但是也存在一些差异:(1)连接成功后,订阅地址为/user/queue/chat,该地址比服务端配置的地址多了/user前缀,这是因为SimpMessagingTemplate类中自动添加了路径前缀;(2)聊天消息发送的路径为/app/chat;(3)发送的消息内容中有一个to字段,该字段用来描述消息的目标用户。

第六步,开始测试。经过上述五个步骤后,一个点对点的聊天服务就搭建成功了,接下来直接在浏览器地址栏中输入http://localhost:8080/onlinechat.html链接,首页页面会跳转到SpringSecurity的默认登录页面,分别使用一开始配置的两个用户admin/1234和envy/1234进行登录,登录成功后就可以开始在线聊天了,如下图所示:

整合WebSocket小结

本篇学习了SpringBoot整合WebSocket,总的来说,经过SpringBoot自动化配置后的WebSocket使用起来还是非常方便的。通过@MessageMapping注解配置消息接口,通过@SendTo或者SimpMessagingTemplate进行消息转发,通过简单的几行配置就能实现点对点、点对面(群发)的消息发送。在企业信息管理系统中,一般即时通信,公告发布等功能都会使用到WebSocket。