Springboot + vue3 + vite 实现 Redis消息订阅 + WebSocket

一、 项目背景:

最近在做驿站的项目,前端展示设备列表,包括设备名称、编号、是否在线、开关状态。通过网页上的开关按钮来实现远程控制设备的开关功能。

  • 关于WebSocket
    WebSocket是一种在单个TCP连接上进行全双工通信的协议。WebSocket通信协议于2011年被IETF定为标准RFC 6455,并由RFC7936补充规范。WebSocket API也被W3C定为标准。
    WebSocket使得客户端和服务器之间的数据交换变得更加简单,允许服务端主动向客户端推送数据。在WebSocket API中,浏览器和服务器只需要完成一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输。
    什么时候使用WebSocket
    延迟本身不是决定因素。如果消息量相对较低(例如,监视网络故障),HTTP流或轮询可以提供有效的解决方案。

低延迟、高频率和高容量的组合,是使用WebSocket的最佳选择。

WebSocket连接头

GET /spring-websocket-portfolio/portfolio HTTP/1.1
Host: localhost:8080
Upgrade: websocket 
Connection: Upgrade 
Sec-WebSocket-Key: Uc9l9TMkWGbHFD2qnFHltg==
Sec-WebSocket-Protocol: v10.stomp, v11.stomp
Sec-WebSocket-Version: 13
Origin: http://localhost:8080

服务器返回状态码
具有WebSocket支持的服务器返回类似于以下内容的输出,而不是通常的200状态代码

HTTP/1.1 101 Switching Protocols 
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: 1qVdfYHU9hPOl4JYYNXF623Gzn0=
Sec-WebSocket-Protocol: v10.stomp

二、架构介绍:

后端采用SpringBoot + redis + stomp 实现跟设备的消息订阅。
前端采用:Vue3 +Vite + sockjs-client + stompjs

三、流程介绍:

后端设备服务将设备的所有信息都存到redis 里面,
web 服务取出所有设备最新信息展示到前台。点击开关按钮,将开关状态发送到后台controller, 在controller 里执行redis 主题发布,设备收到订阅主题后,执行相关的动作,执行完成后给予WEB服务反馈。web 服务收到反馈后将相应的状态信息通过 websocket 发布到主题里面,前端页面收到订阅消息后执行相应的状态同步

  1. 后台通过redisTemplate.convertAndSend 发布redis订阅主题,
    通过redis 配置监听和handler来处理接收到的消息。
  2. 通过配置 WebSocket stomp 来配置前端页面的订阅endpoint,广播、点对点发布等。
    通过SimpMessagingTemplate.convertAndSend 实现后台WEBSocket 订阅消息发布。

四、配置过程:

1.后端配置:

1.1 配置redis 订阅消息类:实现通过redis 订阅消息的功能

RedisSubConfig.java

package com.dechnic.waystation.config;

import com.dechnic.waystation.service.handler.RedisMessageHandler;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Scope;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.listener.ChannelTopic;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
import org.springframework.data.redis.listener.adapter.MessageListenerAdapter;

import javax.annotation.Resource;

/**
 * @description:
 * @author:houqd
 * @time: 2022/5/27 15:27
 */
@Configuration
public class RedisSubConfig {
   
    @Resource
    RedisMessageHandler redisReceiver;

    private static class  ContainerHolder {
   
        private static RedisMessageListenerContainer container = new RedisMessageListenerContainer();
        public static RedisMessageListenerContainer getInstance(){
   
            return container;
        }
    }



    /**
     * redis 消息监听容器
     *
     * @param connectionFactory
     * @param ctrlRetryListener
     * @return
     */
    @Bean
    RedisMessageListenerContainer container(RedisConnectionFactory connectionFactory, MessageListenerAdapter ctrlRetryListener, MessageListenerAdapter realDataListener) {
   
//        RedisMessageListenerContainer container = new RedisMessageListenerContainer();
        RedisMessageListenerContainer container = ContainerHolder.getInstance();
        container.setConnectionFactory(connectionFactory);
        container.addMessageListener(ctrlRetryListener, new ChannelTopic(CustomConfig.REDIS_CHANNEL_CTRL_RETRY));
        container.addMessageListener(realDataListener, new ChannelTopic(CustomConfig.REDIS_CHANNEL_DATA));
        return container;
    }

    @Bean("ctrlRetryListener")
    MessageListenerAdapter ctrlRetryListener() {
   
        return new MessageListenerAdapter(redisReceiver, "ctrlRetryMessage");
    }

    @Bean("realDataListener")
    MessageListenerAdapter realDataListener() {
   
        return new MessageListenerAdapter(redisReceiver, "realDataMessage");
    }


}

1.2 redis 消息处理类 RedisMessageHandler.java

实现接收到redis 订阅主题后,具体业务逻辑的处理

package com.dechnic.waystation.service.handler;

import com.dechnic.waystation.domain.VDeviceInfo;
import com.dechnic.waystation.model.CtrlMsg;
import com.dechnic.waystation.model.CtrlRetryMsg;
import com.dechnic.waystation.service.IVDeviceInfoService;
import com.dechnic.waystation.util.MapperUtil;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.enums.DeviceType;
import com.ruoyi.common.props.WebSocketProps;
import com.ruoyi.framework.aspectj.DataScopeAspect;
import lombok.extern.slf4j.Slf4j;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.DependsOn;
import org.springframework.context.annotation.Import;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Service;

import javax.annotation.PostConstruct;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.stream.Collectors;

/**
 * @description: 接收订阅消息
 * @author:houqd
 * @time: 2022/5/27 15:42
 */
@Slf4j
@Service
public class RedisMessageHandler {
   
    @Autowired
    SimpMessagingTemplate simpMessagingTemplate;
    @Autowired
    WebSocketProps webSocketProps;
    private IVDeviceInfoService vDeviceInfoService;

    @Autowired
    private void setvDeviceInfoService(IVDeviceInfoService vDeviceInfoService){
   
        this.vDeviceInfoService = vDeviceInfoService;
    }


    private  List<VDeviceInfo> vDeviceInfoList = null;
    private  List<String> deviceCodeList=null;


//    @PostConstruct
//    public void init(){
   
//         vDeviceInfoList = vDeviceInfoService.selectVDeviceInfoList(null);
//         if (vDeviceInfoList!=null && !vDeviceInfoList.isEmpty()){
   
//             deviceCodeList = vDeviceInfoList.stream().map(vDeviceInfo -> vDeviceInfo.getDeviceCode()).collect(Collectors.toList());
//         }
//    }


    /**
     * 控制反馈
     * @param message
     */
    public  void ctrlRetryMessage(String message){
   
        log.debug("设备反馈:"+message);
        CtrlRetryMsg ctrlRetryMsg = MapperUtil.jsonToObject(message, CtrlRetryMsg.class);
       if (ctrlRetryMsg.getDevType().equals(DeviceType.ACS.name())){
   // 门禁
           simpMessagingTemplate.convertAndSend(webSocketProps.getAcsTopic(), ctrlRetryMsg);
       }else if (ctrlRetryMsg.getDevType().equals(DeviceType.AIR.name())){
   // 空调
           simpMessagingTemplate.convertAndSend(webSocketProps.getAirTopic(), ctrlRetryMsg);
       }else if (ctrlRetryMsg.getDevType().equals(DeviceType.LIGHT.name())){
   // 灯
           simpMessagingTemplate.convertAndSend(webSocketProps.getLightTopic(), ctrlRetryMsg);
       }

    }

    public void realDataMessage(String message){
   
        log.debug("实时数据:"+message);
        LinkedHashMap resultMap = MapperUtil.jsonToObject(message, LinkedHashMap.class);
        String deviceCode = (String) resultMap.get("deviceCode");
        String deviceType = (String) resultMap.get("deviceType");
        if (deviceType!=null&&deviceType.equals(DeviceType.AIR.name())){
   
            // 空调设备
            simpMessagingTemplate.convertAndSend(webSocketProps.getAirRealDataTopic(),resultMap);
        }

    }


}


1.3 WebSocketConfig 配置类WebSocketConfig.java
package com.dechnic.waystation.config;

import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Configuration;
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;

/**
 * @description:
 * @author:houqd
 * @time: 2022/5/28 16:22
 */
@Slf4j
@Configuration
//注解开启使用STOMP协议来传输基于代理(message broker)的消息,这时控制器支持使用@MessageMapping,就像使用@RequestMapping一样
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
   
    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
   
        registry.addEndpoint("/webSocket")//注册为STOMP的端点
                .setAllowedOriginPatterns("*")//可以跨域
                .withSockJS();//支持sockJs
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
   
        // 设置广播节点
        registry.enableSimpleBroker("/topic");
//        // 客户端向服务端发送消息需有/app 前缀
//        registry.setApplicationDestinationPrefixes("/app");
//        // 指定用户发送(一对一)的前缀 /user/
//        registry.setUserDestinationPrefix("/user");
    }
}

解释:

使用@EnableWebSocketMessageBroker开启WebSocket的子协议STOMP,配置类需要实现WebSocketMessageBrokerConfigurer接口,重写其中的注册STOMP节点方法和配置信息代理者方法

在注册STOMP节点方法中我们需要:

添加监听节点addEndpoint 设置跨域setAllowedOriginPatterns
设置使用SockJSwithSockJS(你也可以选择使用原生方式) 配置信息代理者中需要:

设置目的地前缀setApplicationDestinationPrefixes 设置代理者(代理者对应订阅者)

1.4 SecurityConfig 放开 webSocket 相关资源访问权限
.antMatchers("/login","/webSocket/**").permitAll()

完整代码:

package com.dechnic.oms.framework.config;

import com.dechnic.oms.framework.security.filter.JwtAuthenticationTokenFilter;
import com.dechnic.oms.framework.security.filter.OpenApiFilter;
import com.dechnic.oms.framework.security.handler.AuthenticationEntryPointImpl;
import com.dechnic.oms.framework.security.handler.LogoutSuccessHandlerImpl;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.
  • 3
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
这里提供一个简单的示例代码,实现Spring Boot和Vue.js的单聊功能,使用WebSocket进行实时通信,并使用Redis存储历史消息。 后端代码(Spring Boot): 1. 依赖: ```xml <dependencies> <!-- Spring Boot Websocket --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId> </dependency> <!-- Spring Boot Redis --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <!-- JSON --> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> </dependency> </dependencies> ``` 2. 配置文件: ```yml spring: redis: host: localhost port: 6379 logging: level: org.springframework.web.socket: DEBUG ``` 3. 实体类: ```java public class Message { private String from; private String to; private String content; private Date time; // getters and setters } ``` 4. WebSocket配置: ```java @Configuration @EnableWebSocket public class WebSocketConfig implements WebSocketConfigurer { @Autowired private WebSocketHandler webSocketHandler; @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(webSocketHandler, "/chat").setAllowedOrigins("*"); } } ``` 5. WebSocket处理器: ```java @Component public class WebSocketHandler extends TextWebSocketHandler { @Autowired private RedisTemplate<String, Message> redisTemplate; private ObjectMapper objectMapper = new ObjectMapper(); private static final String KEY_PREFIX = "chat:"; @Override public void afterConnectionEstablished(WebSocketSession session) throws Exception { // 获取当前用户 String user = (String) session.getAttributes().get("user"); // 订阅Redis频道 redisTemplate.execute(new RedisCallback<Void>() { @Override public Void doInRedis(RedisConnection connection) throws DataAccessException { connection.subscribe(new MessageListener(), KEY_PREFIX + user); return null; } }); // 发送历史消息 List<Message> messages = redisTemplate.opsForList().range(KEY_PREFIX + user, 0, -1); if (messages != null && messages.size() > 0) { for (Message message : messages) { session.sendMessage(new TextMessage(objectMapper.writeValueAsString(message))); } } } @Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { // 获取当前用户 String user = (String) session.getAttributes().get("user"); // 解析消息 Message msg = objectMapper.readValue(message.getPayload(), Message.class); msg.setFrom(user); msg.setTime(new Date()); // 存储到Redis redisTemplate.opsForList().rightPush(KEY_PREFIX + msg.getTo(), msg); // 发送给对方 WebSocketSession targetSession = sessions.get(msg.getTo()); if (targetSession != null && targetSession.isOpen()) { targetSession.sendMessage(new TextMessage(objectMapper.writeValueAsString(msg))); } } @Override public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception { // 获取当前用户 String user = (String) session.getAttributes().get("user"); // 取消订阅Redis频道 redisTemplate.execute(new RedisCallback<Void>() { @Override public Void doInRedis(RedisConnection connection) throws DataAccessException { connection.unsubscribe(KEY_PREFIX + user); return null; } }); } private Map<String, WebSocketSession> sessions = new ConcurrentHashMap<>(); private class MessageListener implements MessageListenerAdapter { @Override public void onMessage(Message message, byte[] pattern) { WebSocketSession session = sessions.get(message.getTo()); if (session != null && session.isOpen()) { try { session.sendMessage(new TextMessage(objectMapper.writeValueAsString(message))); } catch (Exception e) { e.printStackTrace(); } } } } } ``` 6. 控制器: ```java @RestController @RequestMapping("/api/chat") public class ChatController { @Autowired private RedisTemplate<String, Message> redisTemplate; @PostMapping("/send") public void send(@RequestBody Message message) { // 存储到Redis redisTemplate.opsForList().rightPush("chat:" + message.getFrom(), message); redisTemplate.opsForList().rightPush("chat:" + message.getTo(), message); // 发布消息 redisTemplate.convertAndSend("chat:" + message.getTo(), message); } @GetMapping("/history") public List<Message> history(String user1, String user2) { String key = "chat:" + user1 + ":" + user2; List<Message> messages = redisTemplate.opsForList().range(key, 0, -1); Collections.reverse(messages); return messages; } } ``` 前端代码(Vue.js): 1. 依赖: ```html <script src="/js/vue.min.js"></script> <script src="/js/sockjs.min.js"></script> <script src="/js/stomp.min.js"></script> <script src="/js/lodash.min.js"></script> ``` 2. HTML: ```html <div id="app"> <div> <label>当前用户:</label> <select v-model="currentUser" @change="connect"> <option v-for="user in users" :value="user">{{ user }}</option> </select> </div> <div v-if="connected"> <div> <label>对方用户:</label> <input v-model="otherUser"> </div> <div> <textarea v-model="message"></textarea> <button @click="send">发送</button> </div> <div> <ul> <li v-for="msg in messages">{{ msg.from }} -> {{ msg.to }}: {{ msg.content }}</li> </ul> </div> </div> </div> ``` 3. JavaScript: ```javascript var app = new Vue({ el: '#app', data: { users: ['user1', 'user2', 'user3'], currentUser: 'user1', otherUser: '', message: '', connected: false, messages: [] }, methods: { connect: function () { var self = this; if (self.stompClient != null) { self.stompClient.disconnect(); } var socket = new SockJS('/chat'); self.stompClient = Stomp.over(socket); self.stompClient.connect({}, function () { self.stompClient.subscribe('/user/queue/messages', function (msg) { var message = JSON.parse(msg.body); self.messages.push(message); }); self.connected = true; }, function (error) { console.log(error); }); }, send: function () { var self = this; var message = { from: self.currentUser, to: self.otherUser, content: self.message }; self.stompClient.send('/app/chat/send', {}, JSON.stringify(message)); self.message = ''; }, loadHistory: function () { var self = this; axios.get('/api/chat/history', { params: { user1: self.currentUser, user2: self.otherUser } }).then(function (response) { self.messages = response.data; }).catch(function (error) { console.log(error); }); } }, watch: { otherUser: function (newValue) { var self = this; self.loadHistory(); } } }); ``` 注意事项: 1. Redis的键名使用了前缀“chat:”,以便区分其他数据; 2. 存储历史消息订阅消息时,使用了当前用户的名称作为频道名称; 3. 在订阅消息时,使用了内部类MessageListener处理接收到的消息,然后发送给对应的WebSocketSession; 4. 在WebSocketSession关闭时,需要取消订阅Redis频道,以免造成资源浪费; 5. 前端使用了STOMP协议进行通信,需要安装sockjs-client和stompjs库; 6. 前端通过WebSocket连接到后端时,需要指定当前用户; 7. 前端通过WebSocket接收到消息时,需要将消息添加到消息列表中; 8. 前端通过REST API加载历史消息时,需要指定当前用户和对方用户。 这是一个基础的示例,具体实现可以根据自己的需求进行调整。
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值