本篇文章主要记录下我是怎么在项目中实现点对点聊天功能的。关于Websocket和Stomp的概念就不再赘述,直接上代码。
org.springframework.boot
spring-boot-starter-websocket
org.projectlombok
lombok
org.springframework.boot
spring-boot-starter-security
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;
}
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基础框架就搭好了,接下来写前端代码测试一下能不能实现单聊。
我把用户聊天模块的数据全部交给Vuex的一个模块来管理,从而实现多个页面之间数据的共享。
目录结构
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
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中加入导函守卫这段代码,这是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、样式什么的太多了,这里只给出重要的代码。
<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)群聊怎么实现