7.6.1 什么是WebSocket
WebSocket为浏览器和服务端提供了双工异步通信的功能,即浏览器可以向服务端发送消息,服务端也可以向浏览器发送消息。WebSocket需浏览器的支持,如IE10+、Chrome 13+、Firefox 6+,这对我们现在的浏览器来说都不是问题。
WebSocket是通过一个socket来实现双工异步通信能力的。但是直接使用WebSocket(或者SockJS:WebSocket协议的模拟,增加了当浏览器不支持WebSocket的时候的兼容支持)协议开发程序显得特别烦琐,我们会使用它的子协议STOMP,它是一个更高级别的协议,STOMP协议使用一个基于帧(frame)的格式来定义消息,与HTTP的request和response类似(具有类似于@RequestMapping的@MessageMapping),我们会 后面实战内容中观察STOMP的帧。
7.6.2 Spring Boot提供的自动配置
Spring Boot 对内嵌的Tomcat(7或者8)、Jetty9和Undertow使用WebSocket提供了支持。配置源码存于org.springframework.boot.autoconfigure.websocket下,如图
Spring Boot 为 WebSocket提供的 starter pom是 spring-boot-starter-websocket
7.6.3 实战
1.准备
新建Spring Boot项目,选择Thymeleaf和WebSocket依赖。
2.广播式
广播式即服务端有消息时,会将消息发送给所有连接了当前endpoint的浏览器。
(1)配置WebSocket,需要在配置类上使用@EnableWebSocketMessageBroker开启WebSocket支持,并通过继承AbstractWebSocketMessageBrokerConfigurer类,重写其方法来配置WebSocket。
代码如下:
package com.wisely;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.AbstractWebSocketMessageBrokerConfigurer;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
@Configuration
@EnableWebSocketMessageBroker //通过@EnableWebSocketMessageBroker注解开启使用STOMP协议来传输基于代理(message broker)的消息,这时控制器支持使用@MessageMapping,就像使用@RequestMapping一样。
public class WebSocketConfig extends AbstractWebSocketMessageBrokerConfigurer {
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) { //注册STOMP协议的节点(endpoint),并映射的指定的URL。
registry.addEndpoint("/endpointAdmin").withSockJS(); //注册一个STOMP的endpoint,并指定使用SockJS协议
}
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) { //配置消息代理(Message Broker)
registry.enableSimpleBroker("topic"); // 广播式应配置一个/topic消息代理
}
}
(2)浏览器5向服务端发送的消息用此类接受:
package com.wisely.domain;
public class AdminMessage {
private String name;
public String getName() {
return name;
}
}
(3)服务端向浏览器发送的此类的消息:
package com.wisely.domain;
public class AdminResponse {
private String responseMessage;
public AdminResponse(String responseMessage) {
this.responseMessage=responseMessage;
}
public String getResponseMessage() {
return responseMessage;
}
}
(4)演示控制器,代码如下:
package com.wisely.web;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.SendTo;
import org.springframework.stereotype.Controller;
import com.wisely.domain.AdminMessage;
import com.wisely.domain.AdminResponse;
@Controller
public class AdController {
@MessageMapping("/welcome") // 当浏览器向服务端发送请求时,通过@MessageMapping映射/welcome这个地址,类似一@RequestMapping
@SendTo("/topic/getResponse") //当服务端有消息时,会对订阅了@SendTo中的路径的浏览器发送消息。
public AdminResponse say(AdminMessage adminMessage) throws InterruptedException {
Thread.sleep(3000);
return new AdminResponse("Welcome,"+adminMessage.getName()+"!");
}
}
(5)添加脚本。将stomp.min.js(STOMP协议的客户端脚本)、sockjs.min.js(SockJS的客户端脚本)以及jQuery放置在src/main/resources/static下。
(6)演示页面。在src/main/resource/templates下新建ws.html,代码如下:
<!DOCTYPE html>
<html xmlsn:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Spring Boot+WebSocket+广播式</title>
</head>
<body "disconnect()">
<noscript><h2 style="color: #ff0000">貌似你的浏览器不支持websocket</h2></noscript>
<div>
<div>
<button id="connect" onclick="connect();">连接</button>
<button id="disconnect" disabled="disabled" onclick="disconnect();">断开连接</button>
</div>
<div id="conversationDiv">
<label>输入你的名字</label><input type="text" id="name" />
<button id="sendName" onclick="sendName();">发送</button>
<p id="response"></p>
</div>
</div>
<script th:src="@{sockjs.min.js}"></script>
<script th:src="@{stomp.min.js}"></script>
<script th:src="@{jquery.min.js}"></script>
<script type="text/javascript">
var stompClien = null;
function setConnected(connected){
document.getElementById('connect').disabled=connected;
document.getElementById('disconnect').disabled= !connected;
document.getElementById('conversationDiv').style.visibility=connected?'visible' : 'hidden' ;
$('#response').html();
}
function connect(){
var socket = new SockJS('/endpointAdmin'); //连接SockJS的endpoint名称为"/endpontAdmin"
stompClient = Stomp.over(socket); //使用STOMP子协议的WebSocket客户端。
stompClient.connect({},function(frame){ //连接WebSocket服务器
setConnected(true);
console.log('Connected: '+ frame);
stompClient.subscribe('/topic/getResponse',function(response){ //通过stompClient.subscribe订阅/topic/getResponse目标(destination)发送的消息,这个是在控制器的@SendTo中定义的。
showResponse(JSON.parse(response.body).responseMessage);
});
});
}
function disconnect(){
if(stompClient != null){
stompClient.disconnect();
}
setConnected(false);
console.log("Disconnected");
}
function sendName(){
var name = $('#name').val();
//通过stompClient.send向 /welcome目标(destination)发送消息,这个是在控制器的@MessageMapping中定义的。
stompClient.send("/welcome", {} , JSON.stringify({ 'name' : name }));
}
function showResponse(message){
var response = $("#response");
response.html(message);
}
</script>
</body>
</html>
(7)配置viewController,为ws.html提供便捷的路径映射:
package com.wisely;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;
@Configuration
public class WebMvcConfig extends WebMvcConfigurerAdapter {
@Override
public void addViewControllers(ViewControllerRegistry registry) {
registry.addViewController("/ws").setViewName("/ws");
}
}
(8)运行。我们预期的效果是:当一个浏览器发送一个消息到服务器时,其它浏览器也能接收到从服务端发送来的这个消息。
开启三个浏览器窗口,并访问http://localhost:8080/ws,分别连接服务器。然后在一个浏览器中发送一条消息,其它浏览器接收消息。
连接服务器,如图。
一个浏览器发送消息,如图
所有浏览器接收服务端发送的消息,如图
我们在Chrome浏览器(在Chrome下按F12调出)下观察一下控制台,如图
从上截图可以观察得出,连接服务端的格式为:
>>> CONNECT
accept-version:1.1,1.0
heart-beat:10000,10000
连接成功的返回为:
<<< CONNECTED
version:1.1
heart-beat:0,0
订阅目标(destination)/topic/getResponse:
>>> SUBSCRIBE
id:sub-0
destination:/topic/getResponse
向目标(destination)/welcom发送消息的格式为:
>>> SEND
destination:/welcome
content-length:16
{"name":"admin"}
从目标(destination)/topic/getResponse接收的格式为:
<<< MESSAGE
destination:/topic/getResponse
content-type:application/json;charset=UTF-8
subscription:sub-0
message-id:03oflrlr-3
content-length:36
{"responseMessage":"Welcome,admin!"}
3.点对点式
广播式有自己的应用场景,但是广播式不能解决我们一个常见的场景,即消息由谁发送、由谁接收的问题。
本例中演示了一个简单的聊天室程序。例子中只有两个用户,互相发送消息给彼此,因需要用户相关的内容,所以先在这里引入最简单的Spring Security相关内容。
(1)添加Spring Security的 starter pom:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
(2)Spring Security的简单配置。这里不对Spring Security做过多解释,只解释对本项目有帮助的部分:
package com.wisely;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.util.matcher.AnyRequestMatcher;
@Configuration
@EnableWebSecurity
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests().antMatchers("/","/login").permitAll() //设置Spring Security对/和/login路径不拦截
.anyRequest().authenticated()
.and()
.formLogin()
.loginPage("/login") //设置Spring Security的登录页面访问的路径为/login
.defaultSuccessUrl("/chat") //登录成功后转向/chat路径
.permitAll()
.and()
.logout()
.permitAll();
}
//在内存中分别配置两个用户lmz和admin,密码和用户名一致,角色是USER
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth
.inMemoryAuthentication().passwordEncoder(new BCryptPasswordEncoder())
.withUser("lmz").password(new BCryptPasswordEncoder().encode("lmz")).roles("USER")
.and()
.withUser("admin").password(new BCryptPasswordEncoder().encode("admin")).roles("USER");
}
///resources/static目录下的静态资源,Spring Security不拦截
@Override
public void configure(WebSecurity web) throws Exception {
web.ignoring().antMatchers("/resources/static/**");
}
}
(3)配置WebSocket:
package com.wisely;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.AbstractWebSocketMessageBrokerConfigurer;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
@Configuration
@EnableWebSocketMessageBroker //通过@EnableWebSocketMessageBroker注解开启使用STOMP协议来传输基于代理(message broker)的消息,这时控制器支持使用@MessageMapping,就像使用@RequestMapping一样。
public class WebSocketConfig extends AbstractWebSocketMessageBrokerConfigurer {
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) { //注册STOMP协议的节点(endpoint),并映射的指定的URL。
registry.addEndpoint("/endpointAdmin").withSockJS(); //注册一个STOMP的endpoint,并指定使用SockJS协议
registry.addEndpoint("/endpointChat").withSockJS(); //注册一个名为/endpointChat的endpoint
}
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) { //配置消息代理(Message Broker)
registry.enableSimpleBroker("/queue","/topic"); // 点对点式应增加一个/queue消息代理
}
}
(4)控制器。在WsController内添加如下代码:
package com.wisely.web;
import java.security.Principal;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Controller;
@Controller
public class WsController {
@Autowired
private SimpMessagingTemplate messagingTemplate; //通过SimpMessagingTemplate向浏览器发送消息。
@MessageMapping("/chat")
public void handleChat(Principal principal,String msg) { //在Spring MVC中,可以直接在参数中获得pricipal,pinciple中包含当前用户的信息。
if(principal.getName().equals("lmz")) {
messagingTemplate.convertAndSendToUser("admin", "/queue/notifications", principal.getName()+"-send:"+msg);
}else {
messagingTemplate.convertAndSendToUser("lmz", "/queue/notifications", principal.getName()+"-send:"+msg);
}
}
}
(5)登录页面。在 src/main/resources/templates 下新建login.html,代码如下:
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlsn:th="http://www.thymeleaf.org"
xmlns:sec="http://www.thymeleafe.org/thymmeleaf-extras-springsecurity3">
<head>
<meta charset="UTF-8">
<title>登录页面</title>
</head>
<body>
<div th:if=${param.error}>
无效的账号和密码
</div>
<div th:if="${param.logout}">
你已注销
</div>
<form th:action="@{/login}" method="post">
<div><label>账号:<input type="text" name="username" /></label></div>
<div><label>密码:<input type="password" name="password" /></label></div>
<div><input type="submit" value="登录" /></div>
</form>
</body>
</html>
(6)聊天页面。在 src/main/resources/templates下新建 chat.html,代码如下:
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Home</title>
<script th:src="@{sockjs.min.js}"></script>
<script th:src="@{stomp.min.js}"></script>
<script th:src="@{jquery.min.js}"></script>
</head>
<body>
<p>
聊天室
</p>
<form id="adminForm">
<textarea tows="4" cols="60" name="text"></textarea>
<input type="submit">
</form>
<script th:inline="javascript">
$('#adminForm').submit(function(e){
e.preventDefault();
var text = $('#adminForm').find('textarea[name="text"]').val();
sendSpittle(text);
});
var sock = new SockJS("/endpointChat"); //连接endpoint名称为 "/endpointChat" 的 endpoint
var stomp = Stomp.over(sock);
stomp.connect('guest','guest',function(frame){
stomp.subscribe("/user/queue/notifications",handleNotification); //订阅/user/queue/notifications发送的消息,这里与在控制器的messaginTemplate.convertAndSendToUser中定义的订阅地址保持一致。这里多了一个/user,并且这个/user是必须的,使用了/user才会发送消息到指定用户。
});
function handleNotification(message){
$('#output').append("<b>Received:"+message.body+"</b><br/>")
}
function sendSpittle(text){
stomp.send("/chat",{},text);
}
$('#stop').click(function() {sock.close()});
</script>
<div id="output"></div>
</body>
</html>
(7)增加页面的viewController:
@Override
public void addViewControllers(ViewControllerRegistry registry) {
registry.addViewController("/ws").setViewName("/ws");
registry.addViewController("/login").setViewName("/login");
registry.addViewController("/chat").setViewName("/chat");
}
(8)运行。分别在两用户下的浏览器访问http://localhost:8080/login 并登录,如图,我这里一个是谷歌浏览器,一个是火狐。