RabbitMQ学习(八)——做WebSocket消息代理,集成Spring Boot实现消息实时推送

在前面的几篇文章中,我们讲解了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协议来实现的,如下:

websocket

因此我们需要启用插件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的原理可以查看文档

port

三、结合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;

连接成功后,在前端控制台我们可以看到相关输入信息。

websocket info

在后台我们也可以看到相关连接信息。
info

然后我们在浏览器中输入http://localhost:8080/websocket/notice,可以发现我们接收到相关信息。

message

我们在发送一对一信息,http://localhost:8080/websocket/user/211,我们就可以看到接收到具体用户信息。

message2

而当我们输入http://localhost:8080/websocket/user/212http://localhost:8080/websocket/user/213,是没有消息的,因为我们是没有相关用户监听,必须配置定点用户。

当我们点击前端send发送消息,后端接收到相关信息。

message3

到此,我们简单的基于RabbitMQ做WebSocket消息代理,集成Spring Boot实现消息实时推送就完成了。

代码实例:rabbitmq-websocket

参考

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值