在前面的几篇文章中,我们讲解了RabbitMQ的大致使用情况,对RabbitMQ的使用有了更细致的了解,今天我们来讲解一下如何使用RabbitMQ来代理WebSocket,并结合Spring Boot实现消息实时推送。
一、WebSocket
WebSocket是一种在单个TCP连接上进行全双工通信的协议。WebSocket通信协议于2011年被IETF定为标准RFC6455,并由RFC7936补充规范。WebSocket API也被W3C定为标准。
WebSocket使得客户端和服务器之间的数据交换变得更加简单,允许服务端主动向客户端推送数据。在WebSocket API中,浏览器和服务器只需要完成一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输。
我们知道:HTTP协议是一种无状态的、无连接的、单向的应用层协议。它采用了请求/响应模型。通信请求只能由客户端发起,服务端对请求做出应答处理。
这种通信模型有一个弊端:HTTP 协议无法实现服务器主动向客户端发起消息。
这种单向请求的特点,注定了如果服务器有连续的状态变化,客户端要获知就非常麻烦。大多数 Web 应用程序将通过频繁的异步JavaScript和XML(AJAX)请求实现长轮询。轮询的效率低,非常浪费资源(因为必须不停连接,或者 HTTP 连接始终打开)。
而WebSocket 就是这样发明的,为了解决上面HTTP协议无法解决的问题。WebSocket 连接允许客户端和服务器之间进行全双工通信,以便任一方都可以通过建立的连接将数据推送到另一端。WebSocket 只需要建立一次连接,就可以一直保持连接状态。这相比于轮询方式的不停建立连接显然效率要大大提高。
Web浏览器和服务器都必须实现 WebSockets 协议来建立和维护连接。由于 WebSockets 连接长期存在,与典型的HTTP连接不同,对服务器有重要的影响。
二、在RabbitMQ中启用WebSocket
通过官方的文档查询我们知道,在RabbitMQ中使用WebSocket是通过STOMP协议来实现的,如下:
因此我们需要启用插件rabbitmq_stomp,确保stomp协议可以,接着我们再启用rabbitmq_web_stomp开启websocket协议。
我们在RabbitMQ服务中启动插件,
rabbitmq-plugins enable rabbitmq_stomp
rabbitmq-plugins enable rabbitmq_web_stomp
然后重启RabbitMQ服务service rabbitmq-server restart
。
其中RabbitMQ运行在15672端口,stomp服务运行在15674端口。
更多关于STOMP上使用WebSocket的原理可以查看文档。
三、结合Spring Boot实战
首先我们构建一个Spring Boot工程,然后在pom的依赖里面添加需要的包,这里添加如下:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-reactor-netty</artifactId>
</dependency>
创建名为WebSocketConfig.java的类来配置WebSocket,实现配置接口WebSocketMessageBrokerConfigurer
。
package net.anumbrella.rabbitmq.config;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.ChannelRegistration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketTransportRegistration;
/**
* @author Anumbrella
*/
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
private static final Logger LOGGER = LoggerFactory.getLogger(WebSocketConfig.class);
@Autowired
private MyChannelInterceptor inboundChannelInterceptor;
@Autowired
private AuthHandshakeInterceptor authHandshakeInterceptor;
@Autowired
private MyHandshakeHandler myHandshakeHandler;
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableStompBrokerRelay("/topic", "/queue")
.setRelayHost("localhost") // rabbitmq-host服务器地址
.setRelayPort(61613) // rabbitmq-stomp 服务器服务端口
.setClientLogin("guest") // 登陆账户
.setClientPasscode("guest"); // 登陆密码
//定义一对一推送的时候前缀
registry.setUserDestinationPrefix("/user/");
//客户端需要把消息发送到/message/xxx地址
registry.setApplicationDestinationPrefixes("/message");
LOGGER.info("init rabbitmq websocket MessageBroker complated.");
}
/**
* 连接站点配置
*
* @param registry
*/
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws").setAllowedOrigins("*")
.setHandshakeHandler(myHandshakeHandler)
.addInterceptors(authHandshakeInterceptor)
.withSockJS();
LOGGER.info("init rabbitmq websocket endpoint ");
}
/**
* 输入通道配置
*
* @param registration
*/
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(inboundChannelInterceptor);
registration.taskExecutor() // 线程信息
.corePoolSize(400) // 核心线程池
.maxPoolSize(800) // 最多线程池数
.keepAliveSeconds(60); // 超过核心线程数后,空闲线程超时60秒则杀死
}
/**
* 消息传输参数配置
*
* @param registration
*/
@Override
public void configureWebSocketTransport(WebSocketTransportRegistration registration) {
registration.setSendTimeLimit(15 * 1000) // 超时时间
.setSendBufferSizeLimit(512 * 1024) // 缓存空间
.setMessageSizeLimit(128 * 1024); // 消息大小
}
}
上面的注释讲解得也挺清楚的,这里大致说一下重点:
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws").setAllowedOrigins("*")
.setHandshakeHandler(myHandshakeHandler)
.addInterceptors(authHandshakeInterceptor)
.withSockJS();
LOGGER.info("init rabbitmq websocket endpoint ");
}
registerStompEndpoints(StompEndpointRegistry registry),这个方法的作用是添加一个服务端点,来接收客户端的连接。
addEndpoint("/ws")表示添加了一个/ws端点,客户端就可以通过这个端点来进行连接。
setHandshakeHandler(myHandshakeHandler),这个方法是自定义处理拦截器,在这里可以验证Socket连接的用户是否可靠。
addInterceptors(authHandshakeInterceptor),这个方法是添加一个TCP手势处理连接操作,是在WebSocket连接建立之前的操作。
withSockJS()的作用是开启SockJS支持。
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableStompBrokerRelay("/topic", "/queue")
.setRelayHost("localhost") // rabbitmq-host服务器地址
.setRelayPort(61613) // rabbitmq-stomp 服务器服务端口
.setClientLogin("guest") // 登陆账户
.setClientPasscode("guest"); // 登陆密码
//定义一对一推送的时候前缀
registry.setUserDestinationPrefix("/user/");
//客户端需要把消息发送到/message/xxx地址
registry.setApplicationDestinationPrefixes("/message");
LOGGER.info("init rabbitmq websocket MessageBroker complated.");
}
configureMessageBroker(MessageBrokerRegistry config),这个方法的作用是定义消息代理,通俗一点讲就是设置消息连接请求的各种规范信息。
enableSimpleBroker("/topic"),表示客户端订阅地址的前缀信息,也就是客户端接收服务端消息的地址的前缀信息。
setUserDestinationPrefix("/user/"),表示指定一对一发送队列的前缀。
registry.setApplicationDestinationPrefixes("/message")指服务端接收地址的前缀,意思就是说客户端给服务端发消息的地址的前缀。
自定义AuthHandshakeInterceptor类,是在WebSocket连接建立之前的操作。如下:
package net.anumbrella.rabbitmq.config;
import net.anumbrella.rabbitmq.util.SpringContextUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.http.server.ServletServerHttpRequest;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.server.HandshakeInterceptor;
import javax.servlet.http.HttpSession;
import java.util.Map;
/**
* @author Anumbrella
*/
@Component
public class AuthHandshakeInterceptor implements HandshakeInterceptor {
private static final Logger LOGGER = LoggerFactory.getLogger(AuthHandshakeInterceptor.class);
@Override
public boolean beforeHandshake(ServerHttpRequest serverHttpRequest, ServerHttpResponse serverHttpResponse, WebSocketHandler webSocketHandler, Map<String, Object> map) throws Exception {
LOGGER.info("===============before handshake=============");
// 在beforeHandshake中可以获取socket连接URL中的参数
// 在这里可以获取session,做用户登录判断依据,这里只做了简单处理
// HttpSession session = SpringContextUtils.getSession();
// session.getAttribute("session_key") 判断具体的session存在
// 比如,只有登录后,才可以进行websocket连接
ServletServerHttpRequest serverRequest = (ServletServerHttpRequest) serverHttpRequest;
String user = serverRequest.getServletRequest().getParameter("user");
if (user != null) {
return true;
}
return false;
}
@Override
public void afterHandshake(ServerHttpRequest serverHttpRequest, ServerHttpResponse serverHttpResponse, WebSocketHandler webSocketHandler, Exception e) {
LOGGER.info("===============after handshake=============");
}
}
新建我们频道相关处理类,MyChannelInterceptor。如下:
package net.anumbrella.rabbitmq.config;
import net.anumbrella.rabbitmq.entity.MyPrincipal;
import net.anumbrella.rabbitmq.util.SocketSessionRegistry;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.messaging.Message;
import org.springframework.messaging.MessageChannel;
import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
import org.springframework.messaging.simp.stomp.StompCommand;
import org.springframework.messaging.simp.stomp.StompHeaderAccessor;
import org.springframework.messaging.support.ChannelInterceptor;
import org.springframework.messaging.support.ChannelInterceptorAdapter;
import org.springframework.messaging.support.MessageHeaderAccessor;
import org.springframework.stereotype.Component;
import java.util.LinkedList;
import java.util.Map;
/**
* @author Anumbrella
*/
@Component
public class MyChannelInterceptor extends ChannelInterceptorAdapter {
@Autowired
private SocketSessionRegistry webAgentSessionRegistry;
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
if (StompCommand.CONNECT.equals(accessor.getCommand())) {
System.out.println("连接success");
Object raw = message.getHeaders().get(SimpMessageHeaderAccessor.NATIVE_HEADERS);
if (raw instanceof Map) {
Object name = ((Map) raw).get("name");
if (name instanceof LinkedList) {
String id = ((LinkedList) name).get(0).toString();
//设置当前访问器的认证用户
accessor.setUser(new MyPrincipal(id));
String sessionId = accessor.getSessionId();
// 统计用户在线数,可通过redis来实现更好
webAgentSessionRegistry.registerSessionId(id, sessionId);
}
}
} else if (StompCommand.DISCONNECT.equals(accessor.getCommand())) {
//点击断开连接,这里会执行两次,第二次执行的时候,message.getHeaders.size()=5,第一次是6。直接关闭浏览器,只会执行一次,size是5。
System.out.println("断开连接");
MyPrincipal principal = (MyPrincipal) message.getHeaders().get(SimpMessageHeaderAccessor.USER_HEADER);
// 如果同时发生两个连接,只有都断开才能叫做不在线
if (message.getHeaders().size() == 5 && principal.getName() != null) {
String sessionId = accessor.getSessionId();
webAgentSessionRegistry.unregisterSessionId(principal.getName(), sessionId);
}
}
return message;
}
}
这里我只是做了一个简单WebSocket连接的简单处理,如何在连接中包含name参数,就将其赋值为认证标识,这里的MyPrincipal,做了重新覆写,以用户名为准。然后再做其他的操作。
MyPrincipal,
package net.anumbrella.rabbitmq.entity;
import java.security.Principal;
/**
* @author Anumbrella
*/
public class MyPrincipal implements Principal {
private String loginName;
public MyPrincipal(String loginName) {
this.loginName = loginName;
}
@Override
public String getName() {
return loginName;
}
}
同时新建SocketSessionRegistry类,在里面对用户相关统计操作。
package net.anumbrella.rabbitmq.util;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.annotation.Scope;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
import java.util.Collections;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.CopyOnWriteArraySet;
/**
* @author Anumbrella
*/
@Component
@Scope("singleton")
public class SocketSessionRegistry {
private static final Logger LOGGER = LoggerFactory.getLogger(SocketSessionRegistry.class);
/**
* 这个集合存储session
*/
private final ConcurrentMap<String, Set<String>> userSessionIds = new ConcurrentHashMap();
private final Object lock = new Object();
public SocketSessionRegistry() {
}
/**
* 获取sessionId
*
* @param userId
* @return
*/
public Set<String> getSessionIds(String userId) {
Set set = (Set) this.userSessionIds.get(userId);
return set != null ? set : Collections.emptySet();
}
/**
* 获取所有session
*
* @return
*/
public ConcurrentMap<String, Set<String>> getAllSessionIds() {
return this.userSessionIds;
}
/**
* register session
*
* @param userId
* @param sessionId
*/
public void registerSessionId(String userId, String sessionId) {
Assert.notNull(userId, "User ID must not be null");
Assert.notNull(sessionId, "Session ID must not be null");
synchronized (this.lock) {
Object set = (Set) this.userSessionIds.get(userId);
if (set == null) {
set = new CopyOnWriteArraySet();
this.userSessionIds.put(userId, (Set<String>) set);
}
((Set) set).add(sessionId);
}
LOGGER.info("===============当前在线人数=============: " + userSessionIds.size());
}
/**
* remove session
*
* @param userId
* @param sessionId
*/
public void unregisterSessionId(String userId, String sessionId) {
Assert.notNull(userId, "User ID must not be null");
Assert.notNull(sessionId, "Session ID must not be null");
synchronized (this.lock) {
Set set = (Set) this.userSessionIds.get(userId);
if (set != null && set.remove(sessionId) && set.isEmpty()) {
this.userSessionIds.remove(userId);
}
}
LOGGER.info("===============当前在线人数=============: " + userSessionIds.size());
}
}
然后我们添加两个controller类,SendController,ReceiveController类,模拟发送和接收的测试。在这里会使用SimpMessagingTemplate
。
使用org.springframework.messaging.simp.SimpMessagingTemplate
类可以在服务端的任意地方给客户端发送消息。此外,在我们配置Spring支持STOMP后SimpMessagingTemplate
类就会被自动装配到Spring的上下文中,因此我们只需要在想要使用的地方使用@Autowired注解注入SimpMessagingTemplate
即可使用。
需要说明的是,SimpMessagingTemplate类有两个重要的方法,它们分别是:
- public void convertAndSend(D destination, Object payload):给监听了路径destination的所有客户端发送消息payload
- public void convertAndSendToUser(String user, String destination, Object payload):给监听了路径destination的用户user发送消息payload
SendController类,
package net.anumbrella.rabbitmq.controller;
import com.alibaba.fastjson.JSON;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* @author Anumbrella
*/
@RestController
@RequestMapping("/websocket")
public class SendController {
@Autowired
private SimpMessagingTemplate messagingTemplate;
/**
* 通知消息
*/
@GetMapping("/notice")
public void notice() {
messagingTemplate.convertAndSend("/topic/notice", JSON.toJSONString("这是通知消息!!"));
}
/**
* 具体用户消息
*/
@GetMapping("/user/{name}")
public void user(@PathVariable("name") String name) {
messagingTemplate.convertAndSendToUser(name, "/topic/reply", JSON.toJSONString("这是发送给" + name + "用户的消息!!"));
}
}
ReceiveController类,
package net.anumbrella.rabbitmq.controller;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.stereotype.Controller;
/**
* @author Anumbrella
*/
@Controller
public class ReceiveController {
private static final Logger LOGGER = LoggerFactory.getLogger(ReceiveController.class);
@MessageMapping("/client")
public void all(String message) {
LOGGER.info("*** 来自客户端的消息 ***:" + message);
}
}
到此我们后端代码基本完成,关于前端使用Socket,RabbitMQ推荐使用stomp-websocket。
这里我结合一个React的demo来进行讲解一下,核心代码如下:
let client = Stomp.over(new SockJS('http://localhost:8080/ws?user=211'));
client.heartbeat.outgoing = 0;
client.heartbeat.incoming = 0;
this.setState(() => ({
wsClient: client
}));
client.connect({
name: '211',
}, (frame) => {
this.setState(() => ({
connected: true
}));
client.subscribe('/user/topic/reply', (msg) => {
const messages = this.state.pMessages;
msg.id = Date.now();
const newMessages = messages.concat([msg]);
this.setState(() => ({
pMessages: newMessages
}));
});
client.subscribe('/topic/notice', (msg) => {
const messages = this.state.pMessages;
msg.id = Date.now();
const newMessages = messages.concat([msg]);
console.log("notice");
console.log(msg);
this.setState(() => ({
pMessages: newMessages
}));
});
});
}
首先我们使用Stompjs来连接服务端,配置的端点为/ws,至于后面的参数是我在后台加入的判断,如果没参数是无法认证成功,因此用户可以在这里做一下session判断认证,用户是否登录成功等。
ServletServerHttpRequest serverRequest = (ServletServerHttpRequest) serverHttpRequest;
String user = serverRequest.getServletRequest().getParameter("user");
if (user != null) {
return true;
}
return false;
连接成功后,在前端控制台我们可以看到相关输入信息。
在后台我们也可以看到相关连接信息。
然后我们在浏览器中输入http://localhost:8080/websocket/notice
,可以发现我们接收到相关信息。
我们在发送一对一信息,http://localhost:8080/websocket/user/211
,我们就可以看到接收到具体用户信息。
而当我们输入http://localhost:8080/websocket/user/212
,http://localhost:8080/websocket/user/213
,是没有消息的,因为我们是没有相关用户监听,必须配置定点用户。
当我们点击前端send发送消息,后端接收到相关信息。
到此,我们简单的基于RabbitMQ做WebSocket消息代理,集成Spring Boot实现消息实时推送就完成了。
代码实例:rabbitmq-websocket。