websocket,顾名思义就是web端的socket,其作用就是给web端提供了与httpserver端之间的长连接,使得httpserver在建立连接的任何时候都可以主动通知web页面事件,如果没有此协议存在的话,web端需要不断的通过轮询的方式去查询一些服务器端的状态。
websocket在建立连接的时候首先是发送的http请求进行握手,握手请求成功之后就会变成长连接进行普通的socket通信。具体步骤如下:
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
HTTP/1.1 101 Switching Protocols (应答行,协议是http1.1版本)
Upgrade: websocket (升级协议为websocket)
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo= (该字段是请求中Sec-WebSocket-Key经过sha签名然后base64编码之后的内容)
STOMP即Simple (or Streaming) Text Orientated Messaging Protocol,简单(流)文本定向消息协议,它提供了一个可互操作的连接格式,允许STOMP客户端与任意STOMP消息代理(Broker)进行交互。STOMP协议由于设计简单,易于开发客户端,因此在多种语言和多种平台上得到广泛地应用。
STOMP协议的前身是TTMP协议(一个简单的基于文本的协议),专为消息中间件设计。
STOMP是一个非常简单和容易实现的协议,其设计灵感源自于HTTP的简单性。尽管STOMP协议在服务器端的实现可能有一定的难度,但客户端的实现却很容易。例如,可以使用Telnet登录到任何的STOMP代理,并与STOMP代理进行交互。
SockJS是一个浏览器JavaScript库,它提供了一个类似于网络的对象。SockJS提供了一个连贯的、跨浏览器的Javascript API,它在浏览器和web服务器之间创建了一个低延迟、全双工、跨域通信通道。
SockJS的主要作用在于提供了浏览器兼容性。优先使用原生WebSocket,如果在不支持websocket的浏览器中,会自动降为轮询的方式,除此之外,spring也对socketJS提供了支持。
org.springframework.boot</groupId>
spring-boot-starter-websocket</artifactId>
</dependency>
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");
}
}
@EnableWebSocketMessageBroker:
开启使用STOMP协议来传输基于代理(message broker)的消息,这时控制器支持使用@MessageMapping
,就像使用@RequestMapping
一样。
WebSocketMessageBrokerConfigurer
:继承WebSocket消息代理的类,配置相关信息。
registry.addEndpoint("/ws/ep").setAllowedOrigins("*").withSockJS();
添加一个访问端点“/ws/ep”
,客户端打开双通道时需要的url,允许所有的域名跨域访问,指定使用SockJS协议。
registry.enableSimpleBroker("/topic", "/queue");
配置一个/topic
广播消息代理和“/queue”
一对一消息代理
registry.setUserDestinationPrefix("/user");
点对点使用的订阅前缀(客户端订阅路径上会体现出来),不设置的话,默认也是/user/
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);
}
}
SimpMessagingTemplate
:SpringBoot提供操作WebSocket的对象simpMessagingTemplate.convertAndSendToUser(message.getTo(), "/queue/chat", message);
数据推送的过程:服务器端启动的时候,会创建broker,客户端请求连接的时候,会向broker上进行订阅,每一个的客户端订阅信息都是唯一的,当服务器端数据进行推送的时候,会根据订阅信息进行匹配,然后推送到对应的客户端上。
/**
* 群聊的消息接受与转发
* @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);
}
SimpMessagingTemplate
:SpringBoot提供操作WebSocket的对象 simpMessagingTemplate.convertAndSend("/topic/greetings", groupMsgContent);
数据推送的过程:服务器端启动的时候,会创建broker,客户端请求连接的时候,会向broker上进行订阅,每一个的客户端注册信息都是相同的,当服务器端数据进行推送的时候,会根据订阅信息进行匹配,匹配到所有的客户端,然后推送到对应的客户端上,实现一个群发的功能。
@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进行消息的发送@SubscribeMapping
@SubscribeMapping
注释与@MessageMapping
结合使用,以缩小到订阅消息的映射。在这种情况下,@MessageMapping
注释指定目标,而@SubscribeMapping
仅表示对订阅消息的兴趣。@SubscribeMapping
通常与@MessageMapping
没有区别。关键区别在于,@SubscribeMapping
的方法的返回值被序列化后,会发送到“clientOutboundChannel”
,而不是“brokerChannel”,直接回复到客户端,而不是通过代理进行广播。这对于实现一次性的、请求-应答消息交换非常有用,并且从不占用订阅。这种模式的常见场景是当数据必须加载和呈现时应用程序初始化。@SendTo
注释@SubscribeMapping
方法,在这种情况下,返回值被发送到带有显式指定目标目的地的“brokerChannel”。@MessageExceptionHandler
@MessageExceptionHandler
方法来处理@MessageMapping
方法中的异常。@MessageExceptionHandler
方法支持灵活的方法签名,并支持与@MessageMapping
方法相同的方法参数类型和返回值。与Spring MVC中的@ExceptionHandler
类似。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;
完整地址如下:
服务端
客户端