概述

本篇文章主要记录下我是怎么在项目中实现点对点聊天功能的。关于Websocket和Stomp的概念就不再赘述,直接上代码。

后端代码

Maven

		<dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-websocket</artifactId>
        </dependency>

		<dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>
        
		<!--项目中未使用SpringSecurity的可以忽略-->
		<dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>

配置类

configureClientInboundChannel()相当于拦截器,是拦截Websocket连接发送消息前执行的,这里可以用来对每次sendMessage的用户做权限验证。如果项目里无需验证可以忽略重写此方法。

/**
 * WebSocket配置类
 */
@Slf4j
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
// 	以下注入的都是为了SpringSecurity鉴权的
    @Value("${jwt.tokenHead}")
    private String tokenHead;
    @Autowired
    private JwtUtil jwtTokenUtil;
    @Autowired
    private UserDetailsService userDetailsService;

    /**
     * 添加这个Endpoint,这样在网页可以通过websocket连接上服务
     * 也就是我们配置websocket的服务地址,并且可以指定是否使用socketJS
     * @param registry
     */
    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        /**
         * 1.将ws-pets路径注册为stomp的端点,用户连接了这个端点就可以进行websocket通讯,支持
         socketJS
         * 2.setAllowedOrigins("*"):允许跨域
         * 3.withSockJS():支持socketJS访问
         */
        registry.addEndpoint("/ws/pets").setAllowedOriginPatterns("*").withSockJS();
    }

    /**
     * 输入通道参数拦截配置,可以不写
     * @param registration
     */
    @Override
    public void configureClientInboundChannel(ChannelRegistration registration) {
        registration.interceptors(new ChannelInterceptor() {
            @Override
            public Message<?> preSend(Message<?> message, MessageChannel channel) {
                StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
                //判断是否为连接,如果是,需要获取token,并且设置用户对象
                if (StompCommand.CONNECT.equals(accessor.getCommand())){
                    String token = accessor.getFirstNativeHeader("Auth-Token");
                    if (!StringUtils.isEmpty(token)){
                        String authToken = token.substring(tokenHead.length());
                        String username = jwtTokenUtil.getUsernameByToken(authToken);
                        //token中存在用户名
                        if (!StringUtils.isEmpty(username)){
                            //登录
                            UserDetails userDetails = userDetailsService.loadUserByUsername(username);
                            //验证token是否有效
                            if (jwtTokenUtil.validateToken(authToken,userDetails)){
                                UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
                                SecurityContextHolder.getContext().setAuthentication(authenticationToken);
                                accessor.setUser(authenticationToken);
                            }
                        }
                    }
                }
                return message;
            }
        });
    }

    /**
     * 配置消息代理
     * @param registry
     */
    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        //配置代理域,可以配置多个,配置代理目的地前缀,可以在配置域上向客户端推送消息
        registry.enableSimpleBroker("/broadcast","/message");
//        //设置服务端接收消息的前缀,只有下面注册的前缀的消息才会接收
//        registry.setApplicationDestinationPrefixes("/app");
    }

}

消息实体

定义双方之间发送消息的对象。

/**
 * 聊天消息
 */
@Data
@Accessors(chain = true)
@EqualsAndHashCode(callSuper = false)
public class ChatMsg {
	//发送者唯一标识
    private String from;
    //接收方唯一标识
    private String to;
    //内容
    private String content;

	//发送时间
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss",timezone = "Asia/Shanghai")
    @JsonDeserialize(using = LocalDateTimeDeserializer.class)
    @JsonSerialize(using = LocalDateTimeSerializer.class)
    private LocalDateTime date;
    //发送者用户名
    private String fromNickName;
}

Controller代码

SimpMessagingTemplate是SpringBoot为我们提供发送消息用的统一模板类。
@MessageMapping可以理解为@GetMappring


/**
 * websocket控制器
 */
@Slf4j
@RestController
public class WebSocketController {
    @Autowired
    private SimpMessagingTemplate simpMessagingTemplate;

	//Authentication是SpringSecurity提供的全局对象,用来获取登录成功的User,若没用到 SpringSecurity可删除此形参
    @MessageMapping("/sendMsg")
    public void handleMsg(Authentication authentication, ChatMsg chatMsg){
        MyUserDetails userDetails = (MyUserDetails)authentication.getPrincipal();
        //获取发送者的用户名
        User user = userDetails.getUser();
        
        chatMsg.setFrom(user.getUsername());
        chatMsg.setFromNickName(user.getNickName());
        /**
         * 点对点发送消息
         * 1.消息接收者
         * 2.消息队列
         * 3.消息对象
         * 消息的类型默认是/user,这个是websocket对单个客户端发送消息特殊的消息类型
         */
        log.info("用户[{}]发送消息=========={}",user.getNickName(), chatMsg);
        simpMessagingTemplate.convertAndSendToUser(chatMsg.getTo(),"/message/chat",chatMsg);
    }

}

写到这里后端的Websocket基础框架就搭好了,接下来写前端代码测试一下能不能实现单聊。

Vue前端代码

Vuex

我把用户聊天模块的数据全部交给Vuex的一个模块来管理,从而实现多个页面之间数据的共享。
目录结构
在这里插入图片描述
chat.js就是我们放聊天数据和聊天用户数据的地方。

chat.js

别忘了npm install stomp,具体版本如下

	"sockjs-client": "^1.5.1",
    "stompjs": "^2.3.3",
import Stomp from 'stompjs'
import SockJS from 'sockjs-client'
import { Notify } from 'vant';
import Vue from "vue";
import {getUsers} from "../../api/chat";
import store from "../index";

const chat = {
  namespaced: true,
  state: {
    sessions: []//会话,是一个map,储存聊天信息
    currentAdmin: JSON.parse(window.localStorage.getItem('user')),//当前用户
    admins: [],//所有聊天对象
    currentSession: null,//当前聊天对象
    sockJs: null,
    stomp: null,
  },
  mutations:{
    //改变当前聊天会话session
    changeCurrentSession(state, currentSession) {
      state.currentSession = currentSession;
      console.log('当前聊天用户:' + state.currentSession.username)
    },
    //添加一条消息进session
    addMessage(state, msg) {
      //会话key的定义 自己的username+’#‘+对方的username
      const sessionKey = state.currentAdmin.username + '#' + msg.to;
      //找到该会话,如果会话从未创建就初始化,然后把message push进去
      let mss = state.sessions[sessionKey];
      if (!mss) {
        Vue.set(state.sessions, sessionKey, []);
      }
      state.sessions[sessionKey].push({
        content: msg.content,
        date: new Date(),
        self: !msg.notSelf//是否是自己发的消息
      })
      console.log(state.sessions)
    },
    //设置所有聊天用户
    INIT_ADMIN(state, data) {
      state.admins = data;
    }
  },
  actions: {
    //连接websocket
    connect(context) {
      context.dispatch('initChatUsers')//获取所有聊天用户
      const { state } = context
      //连接wbsocket
      let socket = new SockJS('/ws/pets')
      state.stomp = Stomp.over(socket);
      const token = store.state.user.token;
      //连接携带鉴权token
      state.stomp.connect({'Auth-Token': token}, success => {
        //订阅聊天消息,注意加上默认前缀/user,这点在后端代码已经指出,点对点通信的默认前缀
        state.stomp.subscribe('/user/message/chat', msg => {
          let receiveMsg = JSON.parse(msg.body);
          //当前不在消息页面或者正在和另一个人聊天,消息提示
          if (!state.currentSession || receiveMsg.from != state.currentSession.username){
            Notify({type: 'primary',message: receiveMsg.fromNickName+'发来了信息'})
          }
          //接收到的消息设为不是自己发的
          receiveMsg.notSelf = true;
          receiveMsg.to = receiveMsg.from;
          //收到的别人的消息放进session
          context.commit('addMessage', receiveMsg);
        })
      }, error => {
      })

      //监听窗口关闭
      window.onbeforeunload = function (event) {
        socket.close()
      }
    },

    //自己发送消息
    sendMessage({ commit, state}, msgObj){
      state.stomp.send('/sendMsg', {}, JSON.stringify(msgObj));
      //自己发送的消息添加进session
      commit('addMessage',msgObj);
    },
    //初始化所有聊天用户,向后端请求数据
    initChatUsers(context) {
      getUsers().then(res=>{
        if (res.data){
          context.commit('INIT_ADMIN', res.data.users);
        }
      })
    }
  }
}


export default chat

getters.js

getter的chatMessages是取出某一个会话的聊天记录的

const getters = {
  token: state => state.user.token,
  user: state => state.user.userInfo,
  chatMessages: state => {
    return state.chat.sessions[state.chat.currentAdmin.username+'#'+state.chat.currentSession.username]
  }
}
export default getters

main.js

main.js中加入导函守卫这段代码,这是websocket连接的入口,如果用户已经具有token说明处于登录状态,若此时stomp未初始化的话,就去连接后端。

import Vue from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'

router.beforeEach(async (to, from, next) => {

  if (store.getters.token){
    //用户已登录且未连接websocket
    if (store.state.chat.stomp == null){
      await store.dispatch('chat/connect')
    }
    next()
  }else {
    await next({name:'Login'})
  }

})


new Vue({
  router,
  store,
  render: h => h(App)
}).$mount('#app')

HTML

页面部分html、样式什么的太多了,这里只给出重要的代码。

联系人列表页面

在这里插入图片描述

<script>
  import {getAllUsers, searchUsers} from "../../api/chat";
  import {mapState} from 'vuex'
  export default {
    name: "Chat",
    data(){
      return{
        searchStr: '',
        displayUsers: [],
        self: this.$store.getters.user
      }
    },
    mounted() {
      this.displayUsers = this.admins
    },
    computed: {
      ...mapState('chat',['currentSession', 'admins']),
    },
    watch:{
      searchStr(val, oldVal){
        if (!val){
          this.displayUsers = this.admins
        }
      }
    },
    methods:{
    //改变当前会话
      changeCurrentSession(session) {
        this.$store.commit('chat/changeCurrentSession',session)
        this.$router.push({
          name: 'ChatDetail'
        })
      },
      //搜索用户
      search(){
        if (this.searchStr){
          searchUsers(this.searchStr).then(res => {
            this.displayUsers = res.data.users
            console.log(this.displayUsers);
          })
        }
      }
    }
  }
</script>

聊天页面

在这里插入图片描述
聊天页面就一个方法,就是发送聊天消息的方法

<script>
  import {mapState, mapMutations} from 'vuex'
  import MessageList from "./components/MessageList";
  import MessageHeader from "./components/MessageHeader";
  export default {
    //......................
    methods:{
    //发送聊天消息
      addMsg(e) {
        if (this.content.length) {
          let msgObj = new Object();
          msgObj.to = this.currentSession.username;
          msgObj.content = this.content;
          msgObj.self = true;
          this.$store.dispatch('chat/sendMessage', msgObj)
          this.content = '';
        }
      }
    }
    //............
  }
</script>

测试

在这里插入图片描述
控制台出现这些log就说明连接websocket并订阅成功了。
在这里插入图片描述
在这里插入图片描述

总结

初步搭建已经完成了,但还有很多问题有待解决。
(1)消息持久化,聊天记录放在vuex一刷新页面就丢失了,应该在后端落库。
(2)用户在线状态,以及用户上下线的实时性提示
(3)消息的“已读”,“未读”功能怎么实现
(4)群聊怎么实现

Logo

前往低代码交流专区

更多推荐