测试工具推荐使用 http://ws.douqq.com/
网上有很多关于WebSocket用法的文章,但是有一些内容错误或者代码不全的,这里做个记录,方便自己记忆。
首先介绍一下业务需求背景,在一个商品购物系统中,某些订单需要运营后台进行审核通过后才可进行提货等后续操作,这就对及时的获取到是否存在待审核订单有较高的要求;这样就需要后端与前端通知做到紧密互动,在此就不介绍前端轮询请求后端数据与WebSocket方式的优缺点对比了。
需要向前端有审批权限的管理员发送通知的有已下三个场景
1. 运营后台管理员登陆
2. 出现新的待审核订单时
3. 10分钟内仍然存在待审核订单
好了,现在开始搞WebSocket
- 在项目引入包文件,pom文件中添加依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
- 配置文件
@Configuration
@EnableWebSocket
public class WebSocketConfig {
/**
* ServerEndpointExporter 作用
*
* 这个Bean会自动注册使用@ServerEndpoint注解声明的websocket endpoint
*
* @return
*/
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
}
- 创建一个WsServerEndpoint类,用来服务端与客户端进行交互
/**
* 前后端交互的类实现消息的接收推送(自己发送给自己)
*
* @ServerEndpoint(value = "/websocket/{name}") 前端通过此URI和后端交互,建立连接
*/
@ServerEndpoint("/websocket/{name}")
@Slf4j
@Component
public class WsServerEndpoint {
/**
* 与某个客户端的连接对话,需要通过它来给客户端发送消息
*/
private Session session;
/**
* 标识当前连接客户端的用户名
*/
private String name;
/**
* 用于存所有的连接服务的客户端,这个对象存储是安全的
*/
private static ConcurrentHashMap<String, WsServerEndpoint> webSocketSet = new ConcurrentHashMap<>();
/**
* 连接成功
*
* @param session
*/
@OnOpen
public void onOpen(Session session, @PathParam(value = "name") String name) {
this.session = session;
this.name = name;
webSocketSet.put(name, this);
log.info("[WebSocket] 连接成功,当前用户{}, 当前连接人数为:={}", name, webSocketSet.size());
}
/**
* 连接关闭
*
* @param session
*/
@OnClose
public void onClose(Session session) {
webSocketSet.remove(this.name);
log.info("[WebSocket] 退出成功,当前用户{}, 当前连接人数为:={}", name, webSocketSet.size());
}
/**
* 接收到消息
*
* @param message
*/
@OnMessage
public String onMsg(String message) throws IOException {
log.info("[WebSocket] 收到消息:{}",message);
//判断是否需要指定发送,具体规则自定义
if(message.indexOf("TOUSER") == 0){
String name = message.substring(message.indexOf("TOUSER")+6,message.indexOf(";"));
AppointSending(name,message.substring(message.indexOf(";")+1,message.length()));
}else{
GroupSending(message);
}
return "servet 发送:" + message;
}
/**
* 群发
* @param message
*/
public void GroupSending(String message){
for (String name : webSocketSet.keySet()){
try {
webSocketSet.get(name).session.getBasicRemote().sendText(message);
}catch (Exception e){
e.printStackTrace();
}
}
}
/**
* 指定发送
* @param name
* @param message
*/
public void AppointSending(String name, String message){
try {
webSocketSet.get(name).session.getBasicRemote().sendText(message);
}catch (Exception e){
e.printStackTrace();
}
}
好了,上述的基础代码是可以做到简单发送的,但我们是需要登录时发送的,并且要判断审批权限与待审核订单数量,在这里刚开始通过@Resource或者@Autowired注解注入,
@Resource
private OrderService orderService;
使用时发现根本就不行,查找相关资料得知WebSocket是多例,而springboot采用的是单例方式,所以呢,webSocket不归springboot管,解决方式(只介绍一下我使用的方式,感兴趣的小伙伴可以在网上查一下,有很多)
- 创建一个SpringUtil文件代码如下
import org.springframework.beans.BeansException;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.stereotype.Component;
@Component
public class SpringUtil implements ApplicationContextAware {
private static ApplicationContext applicationContext;
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
SpringUtil.applicationContext = applicationContext;
}
public ApplicationContext getApplicationContext(){
return applicationContext;
}
public static Object getBean(String beanName){
return applicationContext.getBean(beanName);
}
public static <T> T getBean(Class<T> clazz){
return (T)applicationContext.getBean(clazz);
}
}
- 在WsServerEndpoint类中调用
private OrderService orderService = SpringUtil.getBean(OrderService.class);
接下来就可以愉快的使用注入的service了
登陆时使用onOpen方法创建连接的时候进行验证判断发送信息,这样就OK了,完美!
但是但是但是,重要转折说三个但是,你以为这样就可以了吗?真正的坑在后面,在编写订单状态变更触发时通过引入WsServerEndpoint 是不可用的!启动异常,创建bean失败!而同时运行的定时任务没有任何问题,正常发送!那么显而易见,将触发挪移到定时任务中5秒钟查看一次是否存在新的触发,当然了,这里使用的是redis存储状态!运行成功,自测成功,发布测试环境成功!然而,高兴的太早,在linux测试环境发布启动时偶尔才会成功,其他均是启动异常,创建bean失败!这才是最大的坑,本地没有任何问题,linux服务器环境异常,还是偶发成功!经过多次验证得到结果
1. 测试环境发布成功时定时任务经常是不执行的,将这个定时任务内容注释之后启动成功通知成功
2. 将WsServerEndpoint中注入service注释,开启定时任务,启动成功通知成功!
那么最终的问题终于确认了,就是WsServerEndpoint类注入service与被引用不能同时存在!
根据需求也没有别的办法只能全部迁入到定时任务中,WsServerEndpoint类中不注入service
下面为定时任务部分简易代码
import com.alibaba.fastjson.JSONObject;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import javax.annotation.Resource;
import java.util.Arrays;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
/**
* <p>WebSocket通知定时发送</p>
**/
@Slf4j
@Component
@EnableScheduling
public class WebSocketNoticeTask {
private static final String WEBSOCKET_FIRST_CACHEKEY = "WEBSOCKET_FIRST";
private static final String WEBSOCKET_CACHEKEY = "websocket_";
private static final String WEBSOCKET_LOGIN_CACHEKEY = "websocket_login";
@Resource
private OrderService orderService;
@Resource
private StringRedisTemplate stringRedisTemplate;
// WebSocket通知(每10分钟发送一次通知)
@Scheduled(cron = "${per10Minute.cron}")
public void webSocketNoticeTask() {
log.info("WebSocket通知开始");
LambdaQueryWrapper<OrderEntity> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(OrderEntity::getStatus, OrderStatus.CHECKED.getStatus());// 待复核(已审核)
int count = orderService.count(queryWrapper);
if (count > 0){
JSONObject jsonObject = new JSONObject();
jsonObject.put("count", count);
ConcurrentHashMap<String, WsServerEndpoint> webSocketHashMap = WsServerEndpoint.getWebSocketHashMap();
for (Map.Entry<String, WsServerEndpoint> entry : webSocketHashMap.entrySet()) {
boolean result = this.validAuthority(entry.getKey());
if (!result){
continue;
}
webSocketHashMap.get(entry.getKey()).AppointSending(entry.getKey(), jsonObject.toJSONString());
}
}
log.info("WebSocket通知结束");
}
/**
* 验证权限
* @return
*/
private boolean validAuthority(String name){
ValueOperations<String, String> opsForValue = stringRedisTemplate.opsForValue();
String cacheValue = opsForValue.get(WEBSOCKET_CACHEKEY+name);
return cacheValue != null && cacheValue.equals("1");
}
}
现在可以说任务基本完成了,可优化的一个点是:同一账号多点登陆时,只会向最后一个登陆的用户发送通知! 对此只需要对代码进行小小的改动即可
WsServerEndpoint类中添加map集合,并在连接成功是存储name改为存储id,
WebSocketNoticeTask类定时任务中根据map通过登陆用户名获取发送标识ID进行轮询发送
@ServerEndpoint("/websocket/{name}")
@Slf4j
@Component
public class WsServerEndpoint {
/**
* 与某个客户端的连接对话,需要通过它来给客户端发送消息
*/
private Session session;
/**
* 标识当前连接客户端的用户名
*/
private String name;
/**
* 用于存所有的连接服务的客户端,这个对象存储是安全的
*/
private static ConcurrentHashMap<String, WsServerEndpoint> webSocketSet = new ConcurrentHashMap<>();
private static HashMap<String, Set<String>> nameMap = new HashMap<>();
/**
* 连接成功
*
* @param session
*/
@OnOpen
public void onOpen(Session session, @PathParam(value = "name") String name) {
this.session = session;
this.name = name;
Set<String> set = new HashSet<>();
if (nameMap.containsKey(name)){
set = nameMap.get(name);
}
set.add(session.getId());
nameMap.put(name, set);
webSocketSet.put(session.getId(), this);
log.info("[WebSocket] 连接成功,当前用户{}, 当前连接人数为:={}", name, webSocketSet.size());
}
/**
* 连接关闭
*
* @param session
*/
@OnClose
public void onClose(Session session) {
webSocketSet.remove(session.getId());
log.info("[WebSocket] 退出成功,当前用户{}, 当前连接人数为:={}", name, webSocketSet.size());
}
/**
* 接收到消息
*
* @param message
*/
@OnMessage
public String onMsg(String message) throws IOException {
log.info("[WebSocket] 收到消息:{}",message);
//判断是否需要指定发送,具体规则自定义
if(message.indexOf("TOUSER") == 0){
String name = message.substring(message.indexOf("TOUSER")+6,message.indexOf(";"));
AppointSending(name,message.substring(message.indexOf(";")+1,message.length()));
}else{
GroupSending(message);
}
return "servet 发送:" + message;
}
/**
* 群发
* @param message
*/
public void GroupSending(String message){
for (String id : webSocketSet.keySet()){
try {
webSocketSet.get(id).session.getBasicRemote().sendText(message);
}catch (Exception e){
e.printStackTrace();
}
}
}
/**
* 指定发送
* @param name
* @param message
*/
public void AppointSending(String name, String message){
try {
webSocketSet.get(name).session.getBasicRemote().sendText(message);
}catch (Exception e){
e.printStackTrace();
}
}
public static ConcurrentHashMap<String, WsServerEndpoint> getWebSocketHashMap() {
return webSocketSet;
}
public static HashMap<String, Set<String>> getNameMap(){
return nameMap;
}
@Slf4j
@Component
@EnableScheduling
public class WebSocketNoticeTask {
private static final String WEBSOCKET_FIRST_CACHEKEY = "WEBSOCKET_FIRST";
private static final String WEBSOCKET_CACHEKEY = "websocket_";
private static final String WEBSOCKET_LOGIN_CACHEKEY = "websocket_login";
@Resource
private OrderService orderService;
@Resource
private StringRedisTemplate stringRedisTemplate;
// WebSocket通知(每10分钟发送一次通知)
@Scheduled(cron = "${per10Minute.cron}")
public void webSocketNoticeTask() {
log.info("WebSocket通知开始");
HashMap<String, Set<String>> nameHashMap = WsServerEndpoint.getNameMap();
ConcurrentHashMap<String, WsServerEndpoint> webSocketHashMap = WsServerEndpoint.getWebSocketHashMap();
JSONObject jsonObject = new JSONObject();
jsonObject.put("count", 1);
for (Map.Entry<String, Set<String>> entry : nameHashMap.entrySet()) {
boolean result = this.validAuthority(entry.getKey());
if (!result){
continue;
}
Set<String> set = entry.getValue();
for (String id : set) {
webSocketHashMap.get(id).AppointSending(id, jsonObject.toJSONString());
}
}
log.info("WebSocket通知结束");
}
/**
* 验证权限
* @return
*/
private boolean validAuthority(String name){
ValueOperations<String, String> opsForValue = stringRedisTemplate.opsForValue();
String cacheValue = opsForValue.get(WEBSOCKET_CACHEKEY+name);
return cacheValue != null && cacheValue.equals("1");
}
}
到这里算是基本结束了,后续如果有时间再深入研究一下!