SpringMVC整合WebSocket + RabbitMQ 实现消息实时推送功能

 

在日常的开发过程中我们经常遇到的一个场景就是消息提醒。传统的做法是通过ajax轮训访问后台,如果发现了新的消息则将数据返回给前端。这样的做法相对来说开发成本很低,逻辑也很简单。在实时性要求不高的前提下,使用ajax轮训的方式是一个不错的选择。我们可以将轮训的时间间隔设置得相对较长,比如10分钟甚至是30分钟向后台发起一个请求。但是如果你的业务场景是一个高并发且实时性要求高的场景。在这种情况下再使用ajax轮训的方式就很不合适了。首先如果你需要保持实时性,就不得不缩小轮训的时间间隔。轮训的时间间隔一旦缩小,单位时间内你发送的请求数量就很高,这样你的后台压力就会很大。产生的后果就是轻则响应时间长,用户体验不佳;重则服务器宕机。在这样的背景下,WebSocket就是一个不错的备选方案了。

首先,什么是WebSocket? 以下是我在百度百科上截取的一段有关WebSocket的解释

“WebSocket 是 HTML5 开始提供的一种在单个 TCP 连接上进行全双工通讯的协议。WebSocket 使得客户端和服务器之间的数据交换变得更加简单,允许服务端主动向客户端推送数据。在 WebSocket API 中,浏览器和服务器只需要完成一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输。在 WebSocket API 中,浏览器和服务器只需要做一个握手的动作,然后,浏览器和服务器之间就形成了一条快速通道。两者之间就直接可以数据互相传送。”

简单概括一下就是:在客户端(浏览器)和服务端(后台Tomcat)之间只建立一次连接(一次握手),然后这个连接是长时间生效的。当后台有数据的时候就会给前端浏览器发送数据。

具体实现:

新建一个maven web工程,引入如下需要使用到的jar包。最重要的是引入了spring-websocket的jar包支持。其他的就是基础的SSM jar包依赖了(我这里用的mybatis - plus)。


        UTF-8
        1.8
        1.8
        5.1.5.RELEASE



        
            junit
            junit
            4.12
            test
        
        
            mysql
            mysql-connector-java
            5.1.45
        
        
            org.springframework
            spring-core
            ${spring-version}
        
        
            org.springframework
            spring-context
            ${spring-version}
        
        
            org.springframework
            spring-context-support
            ${spring-version}
        
        
            org.springframework
            spring-webmvc
            ${spring-version}
        
        
            org.springframework
            spring-aop
            ${spring-version}
        
        
            org.springframework
            spring-aspects
            ${spring-version}
        
       
        
            org.springframework
            spring-jdbc
            ${spring-version}
        
        
            org.springframework
            spring-test
            ${spring-version}
        
        
            org.freemarker
            freemarker
            2.3.28
        
        
            com.alibaba
            druid
            1.1.14
        
        
            com.fasterxml.jackson.core
            jackson-core
            2.9.8
        
        
            com.fasterxml.jackson.core
            jackson-annotations
            2.9.8
        
        
            com.fasterxml.jackson.core
            jackson-databind
            2.9.8
        

        
            org.slf4j
            slf4j-api
            1.7.25
        

        
        
            ch.qos.logback
            logback-classic
            1.2.3
        

        
            ch.qos.logback
            logback-core
            1.2.3
        
        
            com.baomidou
            mybatis-plus
            3.3.0
        

        
            org.springframework
            spring-test
            ${spring-version}
        
        
            org.springframework
            spring-websocket
            ${spring-version}
        
        
            org.springframework
            spring-messaging
            ${spring-version}
        
        
            com.alibaba
            fastjson
            1.2.47
        
        
            org.apache.commons
            commons-lang3
            3.9
        
        
            org.apache.commons
            commons-collections4
            4.4
        
        
            org.projectlombok
            lombok
            1.18.10
        
        
            commons-fileupload
            commons-fileupload
            1.3.1
        
        
            com.rabbitmq
            amqp-client
            5.3.0
        
        
            org.springframework.amqp
            spring-rabbit
            2.0.5.RELEASE
        

    

 

设计思路:

一、使用mysql 或者redis(我这里使用的是MySQL)保存推送消息。然后使用spring的定时任务,比如每隔5秒钟扫一次表,如果有尚未发送的数据就将消息数据提取出来。判断消息需要被推送的对象是否已上线,如果确认用户已登录上线后,则使用websocket向用户发送推送消息。

创建Web项目启动器。制定spring以及springmvc 配置类。在springmvc配置类中开启对websocket的支持,在spring配置类中开启定时任务功能。

@Slf4j
public class WebInitializer extends AbstractAnnotationConfigDispatcherServletInitializer {
    @Override
    protected Class[] getRootConfigClasses() {
        return new Class[]{AppConfig.class};
    }

    @Override
    protected Class[] getServletConfigClasses() {
        return new Class[]{WebMvcConfig.class};
    }

    @Override
    protected String[] getServletMappings() {
        return new String[]{"/"};
    }
}
@Configuration
@ComponentScan(basePackages = {"com.itsu.service","com.itsu.dao","com.itsu.compoment"})
@EnableTransactionManagement
@MapperScan(basePackages = "com.itsu.dao")
@EnableScheduling
public class AppConfig {

    @Bean
    public DruidDataSource dataSource() {
        DruidDataSource druidDataSource = new DruidDataSource();
        druidDataSource.setUrl("jdbc:mysql://localhost:3306/local_db?useSSL=false&useUnicode=true&characterEncoding=UTF8");
        druidDataSource.setUsername("root");
        druidDataSource.setPassword("123456");
        druidDataSource.setDriverClassName("com.mysql.jdbc.Driver");
        return druidDataSource;
    }

    @Bean
    public MybatisSqlSessionFactoryBean sessionFactoryBean(){
        MybatisSqlSessionFactoryBean sqlSessionFactoryBean = new MybatisSqlSessionFactoryBean();
        sqlSessionFactoryBean.setDataSource(dataSource());
//        Resource mapperResource = new ClassPathResource("classpath:mappers/MessageMapper.xml");
//        sqlSessionFactoryBean.setMapperLocations(mapperResource);
        return sqlSessionFactoryBean;
    }


    @Bean
    public DataSourceTransactionManager transactionManager() {
        DataSourceTransactionManager transactionManager = new DataSourceTransactionManager();
        transactionManager.setDataSource(dataSource());
        return transactionManager;
    }
}
@Configuration
@ComponentScan(basePackages = "com.itsu.controller")
@EnableWebMvc
@EnableAspectJAutoProxy
@EnableWebSocket
public class WebMvcConfig implements WebMvcConfigurer, WebSocketConfigurer {
    @Bean
    public FreeMarkerViewResolver freeMarkerViewResolver() {
        FreeMarkerViewResolver markerViewResolver =
                new FreeMarkerViewResolver();
        markerViewResolver.setSuffix(".html");
        markerViewResolver.setOrder(1);
        markerViewResolver.setCache(false);
        markerViewResolver.setViewClass(FreeMarkerView.class);
        markerViewResolver.setContentType("text/html;charset=utf-8");
        return markerViewResolver;
    }

    @Bean
    public FreeMarkerConfigurer freeMarkerConfigurer() {
        FreeMarkerConfigurer freeMarkerConfigurer =
                new FreeMarkerConfigurer();
        freeMarkerConfigurer.setTemplateLoaderPath("/WEB-INF/views/");

        Properties settingProperties = new Properties();
        // 刷新模板的周期,单位为秒
        settingProperties.setProperty("template_update_delay", "5");
        settingProperties.setProperty("default_encoding", "utf-8");
        settingProperties.setProperty("datetime_format", "yyyy-MM-dd HH:mm:ss");
        settingProperties.setProperty("time_format", "HH:mm:ss");
        settingProperties.setProperty("url_escaping_charset", "utf-8");

        freeMarkerConfigurer.setFreemarkerSettings(settingProperties);

        return freeMarkerConfigurer;
    }

    //文件上传,bean必须写name属性且必须为multipartResolver,不然取不到文件对象
    @Bean(name = "multipartResolver")
    public CommonsMultipartResolver multipartResolver() {
        CommonsMultipartResolver multipartResolver =
                new CommonsMultipartResolver();
        multipartResolver.setMaxUploadSize(2097152); //2M
        //单个文件大小限制
        multipartResolver.setMaxUploadSizePerFile(0);
        multipartResolver.setDefaultEncoding("UTF-8");
        return multipartResolver;
    }

    //静态资源的处理
    @Override
    public void configureDefaultServletHandling(DefaultServletHandlerConfigurer configurer) {
        configurer.enable();
    }

    @Override
    public void addViewControllers(ViewControllerRegistry registry) {
        registry.addViewController("/home").setViewName("home");
        registry.addViewController("/index").setViewName("index");
        registry.addViewController("/").setViewName("login");
        registry.addViewController("/login").setViewName("login");
        registry.addViewController("/submsg").setViewName("submsg");

    }

    @Override
    public void configureMessageConverters(List> converters) {
        MappingJackson2HttpMessageConverter msgConverter = new MappingJackson2HttpMessageConverter();
        msgConverter.setSupportedMediaTypes(Arrays.asList(MediaType.APPLICATION_JSON_UTF8, MediaType.TEXT_HTML, MediaType.APPLICATION_FORM_URLENCODED));
        converters.add(msgConverter);
    }


    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry.addResourceHandler("/**").addResourceLocations("/");
    }

    @Override
    public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
        registry.addHandler(webSocketHandler(), "/websocket").addInterceptors(new HttpSessionHandshakeInterceptor()).setAllowedOrigins("*");
    }

    @Bean
    public WebSocketHandler webSocketHandler() {
        return new MyWebSocketHandler();
    }
}

新建一张表tb_msg,以及这张表对应的java bean Message。其中最重要的字段为stat,表示这一条推送消息是否被推送成功。后续也需要用的这个状态来抓取数据。

SpringMVC整合WebSocket + RabbitMQ 实现消息实时推送功能_第1张图片

@Data
@EqualsAndHashCode
@AllArgsConstructor
@NoArgsConstructor
@TableName("tb_msg")
public class Message implements Serializable {
    private static final long serialVersionUID = -9137338846595226658L;

    @TableId(type = IdType.AUTO)
    private int id;
    @TableField(value = "from_user_id")
    private String fromUserId;
    @TableField(value = "to_user_id")
    private String toUserId;
    @TableField(value = "content")
    private String content;
    @TableField(value = "stat")
    private String stat;
    @TableField(value = "create_time")
    @JsonFormat(pattern = "yyyy-MM-dd hh:mm:ss")
    private Date createTime;
}

定义websockethandler 通过继承TextWebSocketHandler的方式完成。因为这里只做简单示例,仅做了对普通文本内容消息通知。不支持图片、表情消息的推送处理。如果需要集成这些功能,需要继承AbstractWebSocketHandler 并重写handleTextMessage、handleBinaryMessage、handlePongMessage 三个方法。

/**
 * @author 苏犇
 * @create time 2019/12/4 21:15
 */
@Slf4j
public class MyWebSocketHandler extends TextWebSocketHandler {

    private static final ConcurrentMap USERS;

    private static final String WEBSOCKT_USER = "userId";

    static {
        USERS = new ConcurrentHashMap<>();
    }

    public static Set getLoginUsers() {
        return USERS.keySet();
    }


    @Override
    public void afterConnectionEstablished(WebSocketSession session) throws Exception {
        log.info("新的客服端连接 。。。 session id:{}", session.getId());
        String userId = (String) session.getAttributes().get(WEBSOCKT_USER);
        USERS.putIfAbsent(userId, session);
        log.info("当前在线人数为:{}", USERS.size());
    }

    @Override
    protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
        String msgStr = message.getPayload();
        JSONObject json = JSON.parseObject(msgStr);
        Message msg = json.toJavaObject(Message.class);
        String userId = msg.getToUserId();
        if (StringUtils.isNoneEmpty(userId)) {
            this.sendMessageToUser(userId, message);
        } else
            this.sendMessageToUsers(message);
    }

    @Override
    public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
        log.error("发生异常", exception);
        if (session.isOpen()) {
            session.close();
        }
        WebSocketSession execSession = USERS.remove(session.getId());
        log.error("异常产生的session id:{}", execSession.getId());
    }

    @Override
    public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
        log.info("有socket连接关闭,session id:{}", session.getId());
        log.info("reason: {}", status.getReason());
        String userId = (String) session.getAttributes().get(WEBSOCKT_USER);
        USERS.remove(userId);
        log.info("剩余在线人数为:{}", USERS.size());

    }

    public static synchronized void sendMessageToUser(String userId, TextMessage message) {

        USERS.forEach((key, value) -> {
            if (key.equals(userId)) {
                try {
                    value.sendMessage(message);
                } catch (IOException e) {
                    log.error("exception happened on send socket message");
                }
            }
        });
    }

    public static synchronized void sendMessageToUsers(TextMessage message) {
        USERS.forEach((key, value) -> {
            try {
                value.sendMessage(message);
            } catch (IOException e) {
                log.error("exception happened on send socket message");
            }
        });
    }
}

编写一个controller控制器实现简单登录逻辑。用户名和密码不为空则登录成功。

@Controller
public class MyController {

    @Resource
    private MessageService messageService;

    @PostMapping("/login.do")
    public String login(String userName, String password, HttpServletRequest request, Model model) {
        if (StringUtils.isNotBlank(userName) && StringUtils.isNotBlank(password)) {
            request.getSession().setAttribute("userId", userName);
            return "redirect:/toIndex";
        } else {
            model.addAttribute("errorMsg", "login fail");
            return "login";
        }
    }

    @GetMapping("/toIndex")
    public ModelAndView toIndex() {
        return new ModelAndView("index");
    }


    @PostMapping(value = "/submsg.do", produces = "application/json")
    @ResponseBody
    public Map subMsg(@RequestBody Message message) {
        Map map = new HashMap();
        try {
            message.setCreateTime(new Date());
            message.setStat("N");
            messageService.saveOne(message);
            map.put("result", true);
        } catch (Exception e) {
            map.put("result", false);
            map.put("msg", e.getMessage());

        }

        return map;
    }

}

业务层代码

@Service
public class MessageService {

    @Resource
    private MessageDAO messageDAO;

    @Transactional(rollbackFor = Throwable.class)
    public void saveOne(Message message) {
        messageDAO.insert(message);
    }

}

编写一个定时器任务,时间每间隔5秒钟查询一次数据库。当发现存在未推送的消息时,检查被推送消息的用户是否已登录。如果已登录则通过WebSocketHandler发送消息到前端。

@Component
@Slf4j
public class MessagePushTask {

    @Resource
    private MessageDAO messageDAO;

    @Scheduled(cron = "0/5 * * * * ? ")
    public void sendMessage() {
        System.err.println("task start ... ");
        QueryWrapper condition = new QueryWrapper<>();
        condition.eq("stat", "N");
        List messages = messageDAO.selectList(condition);
        Set loginUsers = MyWebSocketHandler.getLoginUsers();
        for (Message message : messages) {
            if (loginUsers.contains(message.getToUserId())) {
                TextMessage msg = new TextMessage(JSON.toJSONString(message));
                MyWebSocketHandler.sendMessageToUser(message.getToUserId(), msg);
                log.info("message send ... : {}", JSON.toJSONStringWithDateFormat(message, "yyyy-MM-dd hh:mm:ss"));
                message.setStat("Y");
                messageDAO.update(message, null);
            }
        }
    }
}

前端页面的编写:




    
    Title



当前选择的好友是:

启动服务器查看效果,可以看到,后台已经开始每隔5秒钟查询一次db了。

SpringMVC整合WebSocket + RabbitMQ 实现消息实时推送功能_第2张图片

浏览器访问http://localhost:8080/submsg,简单填写如下表单,点击提交,成功返回success。

SpringMVC整合WebSocket + RabbitMQ 实现消息实时推送功能_第3张图片SpringMVC整合WebSocket + RabbitMQ 实现消息实时推送功能_第4张图片

这时我们查看数据库,发现表中已经插入刚才我们输入的数据了。 Stat = N 表示此消息尚未推送

SpringMVC整合WebSocket + RabbitMQ 实现消息实时推送功能_第5张图片

再看看后台控制台,可以看到已经可以查询到这一条数据了。但由于此被推送的对象尚未登录,所以这条消息并没有被推送。

此时,我们使用userId=jack登录。浏览器地址栏输入http://localhost:8080/login

SpringMVC整合WebSocket + RabbitMQ 实现消息实时推送功能_第6张图片

登录成功后,我们看到收到了来自tom的消息:消息测试。正是我们刚刚创建的那一条消息。

SpringMVC整合WebSocket + RabbitMQ 实现消息实时推送功能_第7张图片

再看看数据库,可以看到stat字段的值已经被改成了Y,已发送状态。

 

二、使用RabbitMQ做为消息中间件,不再使用定时任务定期查表的方式获取数据。而改为消息消费者从消息队列Broker中提取数据。后监听被推送的用户是否已登录,如果已登录则确认签收消息,否则则拒收消息,并返回消息至队列中。

首先创建一个rabbitmq的spring配置文件。(题外话:这里使用xml而不适用java config的方式是因为我个人没用过javaconfig 配置过rabbitmq,仅仅在springboot 整合rabbitmq的时候接触过。不过这就是另一个故事了。)



    
    
        
            
        
    
    

    

    

    
        
    

    
    
    

定义消息confirm & return 监听,仅做简单打印输出。

@Component
@Slf4j
public class BootMsgConfirm implements RabbitTemplate.ConfirmCallback {
    @Override
    public void confirm(CorrelationData correlationData, boolean ack, String cause) {
        log.info("correlationData:{}", correlationData);
        if (!ack) {
            log.warn(cause);
        }
    }
}
@Component
@Slf4j
public class BootMsgReturn implements RabbitTemplate.ReturnCallback {
    @Override
    public void returnedMessage(Message message, int replyCode, String replyText, String exchange, String routingKey) {
        log.info("replyCode:{}; replayText:{}; exchange:{} ; routintKey:{}", replyCode, replyText, exchange, routingKey);
    }
}

定义消费者消息监听器,根据被推送用户是否登录来判断是否需要签收消息或者退还消息。

@Slf4j
public class BootMsgConfirmListener implements ChannelAwareMessageListener {

    @Override
    public void onMessage(Message message, Channel channel) throws Exception {
        log.info("收到消息:{}", JSON.toJSONString(message));
        bytes = message.getBody();
        ByteArrayInputStream bis = new ByteArrayInputStream(bytes);
        ObjectInputStream ois = new ObjectInputStream(bis);
        com.itsu.compoment.socket.Message msg = (com.itsu.compoment.socket.Message) ois.readObject();
        Set loginUsers = MyWebSocketHandler.getLoginUsers();
        if (loginUsers.contains(msg.getToUserId())) {
            TextMessage tms = new TextMessage(JSON.toJSONStringWithDateFormat(msg,"yyyy-MM-dd hh:mm:ss"));
            MyWebSocketHandler.sendMessageToUser(msg.getToUserId(),tms);
            channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
            log.info("用户已上线,确认签收消息");
        } else {
            channel.basicNack(message.getMessageProperties().getDeliveryTag(), false, true);
            log.info("用户尚未上线,退还消息至rabbitmq");
        }

    }
}

在messageService中添加生产方法sandToQueue()发送数据到消息Broker ,这里可以看成是自己定义的boot-message交换机。

@Service
public class MessageService {

    @Resource
    private MessageDAO messageDAO;

    @Resource
    private RabbitTemplate rabbitTemplate;

    @Transactional(rollbackFor = Throwable.class)
    public void saveOne(Message message) {
        messageDAO.insert(message);
    }

    public void sendToQueue(Message message) {
        rabbitTemplate.convertAndSend("socket.msg.push", message);
    }

}

启动服务器看看效果如何;

先在浏览器中访问http://localhost:15672,进入RabbitMQ Management后台界面。可以看到交换机和队列已经完成了绑定。

SpringMVC整合WebSocket + RabbitMQ 实现消息实时推送功能_第8张图片

我们尝试着写入如下一条数据。提交成功。

SpringMVC整合WebSocket + RabbitMQ 实现消息实时推送功能_第9张图片SpringMVC整合WebSocket + RabbitMQ 实现消息实时推送功能_第10张图片

再次查看后台,发现后台爆出了大量日志。这是因为目前kobe还没登录,消息被退回。之后又被发送到消费者。。。

SpringMVC整合WebSocket + RabbitMQ 实现消息实时推送功能_第11张图片

使用kobe用户登录。

SpringMVC整合WebSocket + RabbitMQ 实现消息实时推送功能_第12张图片

可以看到,马上就收到了刚刚创建的那一条消息。并且后台也不再打出消息退回的日志了,而是打出了确认签收的日志。

SpringMVC整合WebSocket + RabbitMQ 实现消息实时推送功能_第13张图片

 

到此,已经完成了全部功能。其实这个命题并不是我一开始脑子里想到的,而是我之前在工作闲暇时想着做一个web聊天室,于是乎去看了几篇有关websocket的博文,然后就撸起袖子写了一个web聊天室。当时写到一半我突然想到了实时消息推送的功能,然后一想这玩意用websocket不是正好,于是便构思了一下,做了这么一个简单项目。

你可能感兴趣的:(技术博文,Java,WebSocket,SpringMvc,RabbitMQ)