springboot+websocket+sockjs进行消息推送【基于STOMP协议】实现IM的群聊和私聊功能

1、什么是Websocket

websocket,顾名思义就是web端的socket,其作用就是给web端提供了与httpserver端之间的长连接,使得httpserver在建立连接的任何时候都可以主动通知web页面事件,如果没有此协议存在的话,web端需要不断的通过轮询的方式去查询一些服务器端的状态。

1.1、Websocket连接过程

websocket在建立连接的时候首先是发送的http请求进行握手,握手请求成功之后就会变成长连接进行普通的socket通信。具体步骤如下:

  1. 客户端(网页)发起http握手请求,请求内容如下:
GET /chat HTTP/1.1 (http请求行,GET方法,协议是http1.1版本)
Host: example.com:8000 (请求头,指定访问的主机,这里假设是example.com:8000)
Upgrade: websocket (升级协议为websocket)
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== (websocket key)
Sec-WebSocket-Version: 13
  1. 服务器收到握手请求后应答如下:
HTTP/1.1 101 Switching Protocols (应答行,协议是http1.1版本)
Upgrade: websocket (升级协议为websocket)
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo= (该字段是请求中Sec-WebSocket-Key经过sha签名然后base64编码之后的内容)
  1. 若上述都成功后则建立起普通的socket长连接,这个时候不论是服务器还是客户端都可以给对方在任意时刻发送数据。

2、什么是STOMP协议

STOMP即Simple (or Streaming) Text Orientated Messaging Protocol,简单(流)文本定向消息协议,它提供了一个可互操作的连接格式,允许STOMP客户端与任意STOMP消息代理(Broker)进行交互。STOMP协议由于设计简单,易于开发客户端,因此在多种语言和多种平台上得到广泛地应用。

STOMP协议的前身是TTMP协议(一个简单的基于文本的协议),专为消息中间件设计。

STOMP是一个非常简单和容易实现的协议,其设计灵感源自于HTTP的简单性。尽管STOMP协议在服务器端的实现可能有一定的难度,但客户端的实现却很容易。例如,可以使用Telnet登录到任何的STOMP代理,并与STOMP代理进行交互。

3、什么是Sockjs

SockJS是一个浏览器JavaScript库,它提供了一个类似于网络的对象。SockJS提供了一个连贯的、跨浏览器的Javascript API,它在浏览器和web服务器之间创建了一个低延迟、全双工、跨域通信通道。

SockJS的主要作用在于提供了浏览器兼容性。优先使用原生WebSocket,如果在不支持websocket的浏览器中,会自动降为轮询的方式,除此之外,spring也对socketJS提供了支持。

4、实现基于STOMP协议进行WebSocke数据推送

4.1、java后台

4.1.1、pom
        
            org.springframework.boot</groupId>
            spring-boot-starter-websocket</artifactId>
        </dependency>
4.1.2、配置
package top.song.chat.config;

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;

/**
 * WebSocket配置
 */
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
    /**
     * 注册stomp站点
     *
     * @param registry
     */
    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        // 表示定义一个前缀为 /chat 的 endPoint,并开启 sockjs 支持,
        // sockjs 可以解决浏览器对 WebSocket 的兼容性问题,
        // 客户端将通过这里配置的 URL 来建立 WebSocket 连接
        registry.addEndpoint("/ws/ep").setAllowedOrigins("*").withSockJS();

    }

    /**
     * 注册拦截"/topic","/queue"的消息
     *
     * @param registry
     */
    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        // 设置消息代理前缀
        // 即如果消息的前缀是 /topic ,就会将消息转发给消息代理(broker),
        // 再由消息代理将消息广播给当前连接的客户端。
        registry.enableSimpleBroker("/topic", "/queue");
        // 如果以/user/用户id/queue/chat使用该样式
//        registry.enableSimpleBroker("/topic", "/user");
//        //点对点使用的订阅前缀(客户端订阅路径上会体现出来),不设置的话,默认也是/user/
//        registry.setUserDestinationPrefix("/user");
    }
}

  1. @EnableWebSocketMessageBroker:开启使用STOMP协议来传输基于代理(message broker)的消息,这时控制器支持使用@MessageMapping,就像使用@RequestMapping一样。

  2. WebSocketMessageBrokerConfigurer:继承WebSocket消息代理的类,配置相关信息。

  3. registry.addEndpoint("/ws/ep").setAllowedOrigins("*").withSockJS(); 添加一个访问端点“/ws/ep”,客户端打开双通道时需要的url,允许所有的域名跨域访问,指定使用SockJS协议。

  4. registry.enableSimpleBroker("/topic", "/queue"); 配置一个/topic广播消息代理和“/queue”一对一消息代理

  5. registry.setUserDestinationPrefix("/user");点对点使用的订阅前缀(客户端订阅路径上会体现出来),不设置的话,默认也是/user/

4.1.3、一对一实现
package top.song.chat.controller;

import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.github.binarywang.java.emoji.EmojiConverter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Controller;
import top.song.chat.api.entity.GroupMsgContent;
import top.song.chat.api.entity.Message;
import top.song.chat.api.entity.MessageContent;
import top.song.chat.api.entity.User;
import top.song.chat.api.utils.TuLingUtil;
import top.song.chat.dao.UserDao;
import top.song.chat.service.GroupMsgContentService;
import top.song.chat.service.MessageContentService;

import javax.annotation.Resource;
import java.io.IOException;
import java.text.SimpleDateFormat;
import java.util.Date;

/**
 * websocket
 */
@Controller
public class WsController {
    @Autowired
    SimpMessagingTemplate simpMessagingTemplate;
    @Resource
    private MessageContentService messageContentService;

    @Resource
    private UserDao userDao;

    @Autowired
    GroupMsgContentService groupMsgContentService;

    EmojiConverter emojiConverter = EmojiConverter.getInstance();

    SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

    /**
     * 单聊的消息的接受与转发
     *
     * @param authentication
     * @param message
     */
    @MessageMapping("/ws/chat")
    public void handleMessage(Authentication authentication, Message message) {
        User user = ((User) authentication.getPrincipal());
        message.setFromNickname(user.getNickname());
        message.setFrom(user.getUsername());
        message.setCreateTime(new Date());

        // 插入数据库
        MessageContent messageContent = new MessageContent();
        // 消息相关
        messageContent.setContent(message.getContent());
        messageContent.setMessageTypeId(message.getMessageTypeId());
        // 发送方相关
        messageContent.setFromUser(user.getUsername());
        messageContent.setFromNickname(user.getNickname());
        messageContent.setFromUserProfile(user.getUserProfile());

        //接收方相关
        String to = message.getTo();

        QueryWrapper<User> queryWrapper = new QueryWrapper<>();
        queryWrapper.eq("username",to);
        User toUser = userDao.selectOne(queryWrapper);

        messageContent.setToUser(to);
        messageContent.setToNickname(toUser.getNickname());
        messageContent.setToUserProfile(toUser.getUserProfile());

        messageContentService.insertMessageContent(messageContent);
        simpMessagingTemplate.convertAndSendToUser(message.getTo(), "/queue/chat", message);
        //如果使用  /user/用户id/queue/chat 这种形式可以使用如下代码,默认情况下订阅地址是 “目标地址/用户名-sessionId” 来完成的 在使用的过程中,如果想使用“/user/用户id/queue/chat” 这种模式来进行发送的话,前端需要使用'/user/'+uid+'/queue/chat'形式来订阅
//        simpMessagingTemplate.convertAndSendToUser(toUser.getId().toString(), "/queue/chat", message);
    }
}
  1. 注入的SimpMessagingTemplate:SpringBoot提供操作WebSocket的对象
  2. 一对一发送数据:simpMessagingTemplate.convertAndSendToUser(message.getTo(), "/queue/chat", message);
    • 参数一:指的是发送人
    • 参数二:目标地址
    • 参数三:发送的数据信息

数据推送的过程:服务器端启动的时候,会创建broker,客户端请求连接的时候,会向broker上进行订阅,每一个的客户端订阅信息都是唯一的,当服务器端数据进行推送的时候,会根据订阅信息进行匹配,然后推送到对应的客户端上。

4.1.4、一对多实现
/**
     * 群聊的消息接受与转发
     * @param authentication
     * @param groupMsgContent
     */
    @MessageMapping("/ws/groupChat")
    public void handleGroupMessage(Authentication authentication, GroupMsgContent groupMsgContent) {
        User currentUser = (User) authentication.getPrincipal();
        //处理emoji内容,转换成unicode编码
        groupMsgContent.setContent(emojiConverter.toHtml(groupMsgContent.getContent()));
        //保证来源正确性,从Security中获取用户信息
        groupMsgContent.setFromId(currentUser.getId());
        groupMsgContent.setFromName(currentUser.getNickname());
        groupMsgContent.setFromProfile(currentUser.getUserProfile());
        groupMsgContent.setCreateTime(new Date());
        //保存该条群聊消息记录到数据库中
        groupMsgContentService.insert(groupMsgContent);
        //转发该条数据
        simpMessagingTemplate.convertAndSend("/topic/greetings", groupMsgContent);
    }
  1. 注入的SimpMessagingTemplate:SpringBoot提供操作WebSocket的对象
  2. 一对多发送数据: simpMessagingTemplate.convertAndSend("/topic/greetings", groupMsgContent);
    • 参数一:目标地址
    • 参数二:发送的数据信息

数据推送的过程:服务器端启动的时候,会创建broker,客户端请求连接的时候,会向broker上进行订阅,每一个的客户端注册信息都是相同的,当服务器端数据进行推送的时候,会根据订阅信息进行匹配,匹配到所有的客户端,然后推送到对应的客户端上,实现一个群发的功能。

4.1.5、Websocket 注解介绍
  1. @MessageMapping:用户处理client发送过来的消息,被注解的方法可以具有以下参数。

    • Message:用于接收完整的消息
    • MessageHeaders:用于接收消息中的头信息
    • MessageHeaderAccessor/SimpMessageHeaderAccessor/StompHeaderAccessor:用于接收消息中的头信息,并且构建绑定Spring中的一些附加信息
    • @Headers:用于接收消息中的所有header。这个参数必须用java.util.Map
    • @Header:用于接收特定的头值
    • @Payload:接受STOMP协议中的Body,可以用@javax.validation进行注释, Spring的@Validated自动验证 (类似@RequestBody)
    • DestinationVariable: 用于提取header中destination模板变量 (类似@PathVariable)
    • java.security.Principal:接收在WebSocket HTTP握手时登录的用户,当@MessageMapping方法返回一个值时,默认情况下,该值在被序列化为Payload后,作为消息发送到向订阅者广播的“brokerChannel”,且消息destination与接收destination相同,但前缀为变为配置的值,可以使用@SendTo指定发送的destination,将Payload消息,进行广播发送到订阅的客户端。@SendToUser是会向与当条消息关联的用户发送回执消息,还可以使用SimpMessagingTemplate发送代替SendTo/@SendToUserji进行消息的发送
    1. @SubscribeMapping

      • @SubscribeMapping注释与@MessageMapping结合使用,以缩小到订阅消息的映射。在这种情况下,@MessageMapping注释指定目标,而@SubscribeMapping仅表示对订阅消息的兴趣。
      • @SubscribeMapping通常与@MessageMapping没有区别。关键区别在于,@SubscribeMapping的方法的返回值被序列化后,会发送到“clientOutboundChannel”,而不是“brokerChannel”,直接回复到客户端,而不是通过代理进行广播。这对于实现一次性的、请求-应答消息交换非常有用,并且从不占用订阅。这种模式的常见场景是当数据必须加载和呈现时应用程序初始化。
      • @SendTo注释@SubscribeMapping方法,在这种情况下,返回值被发送到带有显式指定目标目的地的“brokerChannel”。
    2. @MessageExceptionHandler

      • 应用程序可以使用@MessageExceptionHandler方法来处理@MessageMapping方法中的异常。
      • @MessageExceptionHandler方法支持灵活的方法签名,并支持与@MessageMapping方法相同的方法参数类型和返回值。与Spring MVC中的@ExceptionHandler类似。

4.2、Vue前端

4.2.1、引入sockjs.js、stomp.js

springboot+websocket+sockjs进行消息推送【基于STOMP协议】实现IM的群聊和私聊功能_第1张图片

4.2.2、前端配置
import  Vue from 'vue'
import  Vuex from 'vuex'
import {getRequest, postRequest} from "../utils/api";
import SockJS from '../utils/sockjs'
import  '../utils/stomp'
import { Notification } from 'element-ui';

Vue.use(Vuex)

const now = new Date();

const store =  new Vuex.Store({
  state:sessionStorage.getItem('state') ? JSON.parse(sessionStorage.getItem('state')) :{
    routes:[],
    sessions:{},//聊天记录
    users:[],//用户列表
    currentUser:null,//当前登录用户
    currentSession:{username:'群聊',nickname:'群聊'},//当前选中的用户,默认为群聊
    currentList:'群聊',//当前聊天窗口列表
    filterKey:'',
    stomp:null,
    isDot:{},//两用户之间是否有未读信息
    errorImgUrl:"http://39.108.169.57/group1/M00/00/00/J2ypOV7wJkyAAv1fAAANuXp4Wt8303.jpg",//错误提示图片
    shotHistory:{}//拍一拍的记录历史
  },
  mutations:{
    initRoutes(state,data){
      state.routes=data;
    },
    changeCurrentSession (state,currentSession) {
      //切换到当前用户就标识消息已读
      Vue.set(state.isDot,state.currentUser.username+"#"+currentSession.username,false);
      //更新当前选中的用户
      state.currentSession =currentSession;
    },
    //修改当前聊天窗口列表
    changeCurrentList(state,currentList){
      state.currentList=currentList;
    },
    //保存群聊消息记录
    addGroupMessage(state,msg){
      let message=state.sessions['群聊'];
      if (!message){
        //state.sessions[state.currentHr.username+"#"+msg.to]=[];
        Vue.set(state.sessions,'群聊',[]);
      }
      state.sessions['群聊'].push({
        fromId:msg.fromId,
        fromName:msg.fromName,
        fromProfile:msg.fromProfile,
        content:msg.content,
        messageTypeId:msg.messageTypeId,
        createTime: msg.createTime,
      })
    },
    //保存单聊数据
    addMessage (state,msg) {
      let message=state.sessions[state.currentUser.username+"#"+msg.to];
      if (!message){
        //创建保存消息记录的数组
        Vue.set(state.sessions,state.currentUser.username+"#"+msg.to,[]);
      }
      state.sessions[state.currentUser.username+"#"+msg.to].push({
        content:msg.content,
        date: new Date(),
        fromNickname:msg.fromNickname,
        messageTypeId:msg.messageTypeId,
        self:!msg.notSelf
      })
    },
    /**
     *  获取本地聊天记录,同步数据库的记录保存到localStorage中。
     *  不刷新情况下都是读取保存再localStorage中的记录
     * @param state
     * @constructor
     */
    INIT_DATA (state) {
        //同步数据库中的群聊数据
        getRequest("/groupMsgContent/").then(resp=>{
          if (resp){
            Vue.set(state.sessions,'群聊',resp);
          }
        })
    },
    //保存系统所有用户
    INIT_USER(state,data){
      state.users=data;
    },
    //请求并保存所有系统用户
    GET_USERS(state){
      getRequest("/chat/users").then(resp=>{
        if (resp){
          state.users=resp;
        }
      })
    }
  },
  actions:{
    /**
     * 作用:初始化数据
     * action函数接受一个与store实例具有相同方法和属性的context对象
     * @param context
     */
    initData (context) {
      //初始化聊天记录
      context.commit('INIT_DATA')
      //获取用户列表
      context.commit('GET_USERS')
    },
    /**
     * 实现连接服务端连接与消息订阅
     * @param context 与store实例具有相同方法和属性的context对象
     */
    connect(context){
      //连接Stomp站点
      context.state.stomp=Stomp.over(new SockJS('/ws/ep'));
      context.state.stomp.connect({},success=>{
        /**
         * 订阅系统广播通知消息
         */
        context.state.stomp.subscribe("/topic/notification",msg=>{
          //判断是否是系统广播通知
            Notification.info({
              title: '系统消息',
              message: msg.body.substr(5),
              position:"top-right"
            });
            //更新用户列表(的登录状态)
            context.commit('GET_USERS');
        });
        /**
         * 订阅群聊消息
         */
        context.state.stomp.subscribe("/topic/greetings",msg=>{
          //接收到的消息数据
          let receiveMsg=JSON.parse(msg.body);
          console.log("收到消息"+receiveMsg);
          //当前点击的聊天界面不是群聊,默认为消息未读
          if (context.state.currentSession.username!="群聊"){
            Vue.set(context.state.isDot,context.state.currentUser.username+"#群聊",true);
          }
          //提交消息记录
          context.commit('addGroupMessage',receiveMsg);
        });
        /**
         * 订阅机器人回复消息
         */
        context.state.stomp.subscribe("/user/queue/robot",msg=>{
          //接收到的消息
          let receiveMsg=JSON.parse(msg.body);
          //标记为机器人回复
          receiveMsg.notSelf=true;
          receiveMsg.to='机器人';
          receiveMsg.messageTypeId=1;
          //添加到消息记录保存
          context.commit('addMessage',receiveMsg);
        })
        /**
         * 订阅私人消息
         */
        // 如果以/user/用户id/queue/chat使用以下注释掉的代码
        // var userinfo = window.sessionStorage.getItem('user')
        // let uid=JSON.parse(userinfo).id
        // context.state.stomp.subscribe('/user/'+uid+'/queue/chat',msg=>{
        context.state.stomp.subscribe('/user/queue/chat',msg=>{
          //接收到的消息数据
          let receiveMsg=JSON.parse(msg.body);
          //没有选中用户或选中用户不是发来消息的那一方
          if (!context.state.currentSession||receiveMsg.from!=context.state.currentSession.username){
            Notification.info({
              title:'【'+receiveMsg.fromNickname+'】发来一条消息',
              message:receiveMsg.content.length<8?receiveMsg.content:receiveMsg.content.substring(0,8)+"...",
              position:"bottom-right"
            });
            //默认为消息未读
            Vue.set(context.state.isDot,context.state.currentUser.username+"#"+receiveMsg.from,true);
          }
          //标识这个消息不是自己发的
          receiveMsg.notSelf=true;
          //获取发送方
          receiveMsg.to=receiveMsg.from;
          //提交消息记录
          context.commit('addMessage',receiveMsg);
        })
      },error=>{
        Notification.info({
          title: '系统消息',
          message: "无法与服务端建立连接,请尝试重新登陆系统~",
          position:"top-right"
        });
      })
    },
    //与Websocket服务端断开连接
    disconnect(context){
     if (context.state.stomp!=null) {
       context.state.stomp.disconnect();
       console.log("关闭连接~");
     }
    },
  }
})

/**
 * 监听state.sessions,有变化就重新保存到local Storage中chat-session中
 */
store.watch(function (state) {
  return state.sessions
},function (val) {
  console.log('CHANGE: ', val);
  localStorage.setItem('chat-session', JSON.stringify(val));
},{
  deep:true/*这个貌似是开启watch监测的判断,官方说明也比较模糊*/
})


export default store;

完整地址如下:
服务端
客户端

你可能感兴趣的:(Websocket,IM即时通讯,springboot,websocket,spring,boot,私聊,群聊,IM即时通讯)