之前有个需求,就是要在用户从门户下单购买服务类型商品的时候,需要发送通知信息到管理端,在管理端有个站内信功能,在站内信可以实时获取推送提醒,由于门户网站和管理系统是两个独立的项目,所以需要将门户网站产生的订单信息以及提醒事项推送到管理端,使管理端可以获取到门户网站的信息,然后再利用websocket实时推送给前端。由于本项目中只是简单的信息提示,数据量不是特别大,而且业务不算复杂,所以感觉没有必要选用消息队列,这样会增加一个中间件的维护,由于项目本身使用了Redis,所以选用Redis来进行消息的传递。门户网站项目和管理系统需要连接同一个Redis的同一个库。
后端的主要代码有门户网站Redis发布订阅信息服务类:
门户端需要在Redis配置类中创建一个发布信息的通道
@Bean
public ChannelTopic topic() {
return new ChannelTopic("topic");
}
然后创建一个发布信息的服务类:
import com.alibaba.fastjson.JSONObject;
import com.xiaoifeng1010.core.dto.SendMsg;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
/**
* @author xiaomifeng1010
* @version 1.0
* @date:
* @Description
*/
@Component
@Setter(onMethod_ = @Autowired)
@Slf4j
public class RedisService {
private RedisTemplate<String, Object> redisTemplate;
public Boolean sentMsg(SendMsg sendMsg){
try {
String msgContent = sendMsg.getMsgContent();
log.info("redis开始发送信息到channel:{}", msgContent);
String jsonString = JSONObject.toJSONString(sendMsg, false);
log.info("发送的对象:{}",jsonString);
redisTemplate.convertAndSend("topic",jsonString);
log.info("redis发送信息到channel:{}",msgContent);
return true;
} catch (Exception e) {
log.error("redis发布消息出错:{}",e);
return false;
}
}
}
消息对象:
import lombok.Data;
import java.io.Serializable;
/**
* @author xiaomifeng1010
* @version 1.0
* @date:
* @Description
*/
@Data
public class SendMsg implements Serializable {
private static final long serialVersionUID = 1881467659037505830L;
private String msgContent;
private Integer itemType;
}
itemType(消息事项类型)设置了枚举类:
import lombok.AllArgsConstructor;
import lombok.Getter;
import org.apache.commons.lang3.StringUtils;
import java.util.Objects;
import java.util.stream.Stream;
/**
* @author xiaomifegn1010
* @version 1.0
* @date:
* @Description 推送站内信息类别
*/
@AllArgsConstructor
@Getter
public enum PushMsgTypeEnum {
TYPE_NOTICEMENT(1,"提醒"),
PRODUCT_ORDER(2,"服务商品"),
CUSTOMER_APPEAL(3,"客户申诉"),
DECLARATION(4,"公司声明");
public static String _name = "推送信息类别";
private final Integer code;
private final String name;
public static String getName(Integer code) {
if (Objects.isNull(code)){
return StringUtils.EMPTY;
}
return Stream.of(values()).filter(bean -> bean.code.equals(code))
.map(PushMsgTypeEnum::getName).findFirst().orElse(StringUtils.EMPTY);
}
}
然后在各项业务发生时,就可以调用发送信息服务进行发送消息了。
在管理端呢,就需要有一个监听信息的监听器:
import com.dcboot.module.customerManagement.config.WebsocketConfig;
import com.dcboot.module.customerManagement.msg.server.WebSocketServer;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.connection.Message;
import org.springframework.data.redis.connection.MessageListener;
import org.springframework.data.redis.core.RedisTemplate;
/**
* @author xiaomifeng1010
* @version 1.0
* @date:
* @Description
*/
@Slf4j
public class ConsumerRedisListener implements MessageListener {
@Autowired
private RedisTemplate<String, Object> gateWayRedisTemplate;
/**
* Callback for processing received objects through Redis.
*
* @param message message must not be {@literal null}.
* @param pattern pattern matching the channel (if specified) - can be {@literal null}.
*/
@SneakyThrows
@Override
public void onMessage(Message message, byte[] pattern) {
String msg = String.valueOf(gateWayRedisTemplate.getValueSerializer().deserialize(message.getBody()));
log.info("收到redis中订阅的消息:{}", msg);
log.info("发送websocket信息");
WebSocketServer.broadCastInfo(msg);
}
}
还需要将监听器以及订阅的通道配置到redis中:
import com.xiaomifeng1010.module.customerManagement.listener.ConsumerRedisListener;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.listener.ChannelTopic;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
/**
* @author xiaomifeng1010
* @version 1.0
* @date:
* @Description
*/
@Configuration
public class RedisChannelConfig {
@Autowired
private LettuceConnectionFactory connectionFactory;
@Bean
public ConsumerRedisListener consumeRedis() {
return new ConsumerRedisListener();
}
@Bean
public ChannelTopic topic() {
return new ChannelTopic("topic");
}
@Bean
public RedisMessageListenerContainer redisMessageListenerContainer() {
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
container.setConnectionFactory(connectionFactory);
container.addMessageListener(consumeRedis(), topic());
return container;
}
}
在管理端监听到Redis订阅中的信息,然后使用WebSocketServer向浏览器推送信息
使用websocket需要在项目中引入websocket集成的springboot的依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
websocket基本配置:
package com.xiaomifeng1010.module.core.config;
import org.springframework.context.annotation.Bean;
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;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;
/**
* @author xiaomifeng1010
* @version 1.0
* @date:
* @Description 启用websocket
* 首先要注入ServerEndpointExporter,这个bean会自动注册使用了@ServerEndpoint注解声明的Websocket endpoint。
* 要注意,如果使用独立的servlet容器,而不是直接使用springboot的内置容器,就不要注入ServerEndpointExporter,
* 因为它将由容器自己提供和管理。
*/
@Configuration
public class WebsocketConfig {
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
}
注意如果使用stomp协议方式,则配置类是这样的:比如官网配置:
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/portfolio").withSockJS();
}
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
config.setApplicationDestinationPrefixes("/app");
config.enableSimpleBroker("/topic", "/queue");
}
}
stomp方式进行websocket通信的案例可以参考我的博客:springboot整合websocket实现简单聊天功能
然后创建websocket服务:
package com.xiaomifeng1010.module.core.msg.server;
import cn.hutool.json.JSONUtil;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.xiaomifeng1010.api.cms.msg.entity.StationMsg;
import com.xiaomifeng1010.api.cms.msg.service.StationMsgService;
import com.google.common.collect.Maps;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import javax.websocket.*;
import javax.websocket.server.ServerEndpoint;
import java.io.IOException;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArraySet;
import java.util.concurrent.atomic.AtomicInteger;
/**
* @author xiaomifeng1010
* @version 1.0
* @date:
* @Description
*/
@Component
@Slf4j
@ServerEndpoint(value = "/ws/msg")
public class WebSocketServer {
private static StationMsgService stationMsgService;
/**
* 用来统计连接客户端的数量
*/
private static final AtomicInteger OnlineCount = new AtomicInteger(0);
/**
* concurrent包的线程安全Set,用来存放每个客户端对应的Session对象。
*/
private static CopyOnWriteArraySet<WebSocketServer> SessionSet = new CopyOnWriteArraySet<>();
private static ConcurrentHashMap<String, Session> userInfoMap = new ConcurrentHashMap<>();
private Session session;
@Autowired
public void setService(StationMsgService stationMsgService) {
WebSocketServer.stationMsgService = stationMsgService;
}
/**
* 连接建立成功调用的方法
*/
@OnOpen
public void onOpen(Session session) throws IOException {
this.session = session;
//加入set中
SessionSet.add(this);
// 在线数加1
int cnt = OnlineCount.incrementAndGet();
log.info("有连接加入,当前连接数为:{}", cnt);
}
/**
* 连接关闭调用的方法
*/
@OnClose
public void onClose() {
SessionSet.remove(this);
int cnt = OnlineCount.decrementAndGet();
log.info("有连接关闭,当前连接数为:{}", cnt);
}
/**
* 出现错误
*/
@OnError
public void onError(Session session, Throwable error) {
log.error("发生错误:{},Session ID: {}", error.getMessage(), session.getId());
}
/**
* 收到客户端消息后调用的方法
*
* @param userAccount 客户端发送过来的消息
*/
@OnMessage
public void onMessage(String userAccount,Session session) throws IOException {
log.info("session对象:{}",session);
log.info("来自客户端的用户账号:{}", userAccount);
if (StringUtils.isNotBlank(userAccount)){
userInfoMap.put(userAccount, session);
log.info("userInfoMap:{}",userInfoMap);
}
sendMessage("收到来自客户端的消息内容:" + "你好客户端", 1);
// broadCastInfo(message); 群发消息
}
/**
* 发送消息
*
* @param message 消息
*/
public void sendMessage(String message, Integer msgType) throws IOException {
log.info("session信息:{}", session.getId());
log.info("发送站内信息:{}", message);
long count = stationMsgService.count(Wrappers.<StationMsg>lambdaQuery()
.eq(StationMsg::getReadFlag, 0));
Map<String, Object> messageVO = Maps.newLinkedHashMap();
messageVO.put("msgContent", message);
messageVO.put("unReadCount", count);
messageVO.put("msgType", msgType);
String sendMessage = JSONUtil.toJsonStr(messageVO);
session.getBasicRemote().sendText(sendMessage);
}
/**
* 群发消息
*
* @param message 消息
* @param msgType 消息类型 1站内信 2 token过期通知
*/
public static void broadCastInfo(String message,String userAccount,Integer msgType) throws IOException {
if (StringUtils.isNotBlank(userAccount)){
Session session = userInfoMap.get(userAccount);
log.info("获取到的session用户:{}",session);
if (session != null && session.isOpen()) {
WebSocketServer webSocketServer = new WebSocketServer();
webSocketServer.session=session;
webSocketServer.sendMessage(message, msgType);
}
}
}
}
由于使用的SpringSecurity在进行用户及权限管理,所以无法再websocket通信中直接使用SpringSecurity的相关api去获取用户信息,由于推送的信息具有用户属性,不同的用户只能接收到自己权限内的信息通知,所以websocket连接需要知道是哪个用户,然后将该用户的信息推送给该用户,所以在websocket建立了连接后,需要前端发送一次通信信息,把用户信息推给后端,后端保存在HashMap中,并与session进行绑定,然后就可以针对具体用户各自推送信息。
注意:在websocket中使用 注入Spring管理的bean的时候,比如直接@Autowired private StationMsgService stationMsgService;会报空指针异常,自动注入不成功,其实并不是service 不能被注入,其实是因为Spring管理的bean默认是单例的,和 websocket (多对象)是相冲突,所以需要在websocket的工具类中将StationMsgService声明为静态的,通过set方式注入就可以了,让多个websocket对象共用同一个StationMsgService
开发环境以http协议配置即可,在生产环境中需要升级https,对应的ws协议也需要升级到wss,前端在连接路径中需要修改协议,后端服务则需要配置修改Nginx配置即可
下边贴上Nginx配置
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
上边这段映射配置放在http块下
location ^~ /msgApi/ws {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_buffering off;
#rewrite ^/dcbootApi/(.*)$ /$1 break;
proxy_pass http://127.0.0.1:8080/msgApi/ws;
proxy_http_version 1.1;
proxy_connect_timeout 5s;
proxy_read_timeout 600s;
proxy_send_timeout 30s;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "$connection_upgrade";
}
location是在server块中的,配置的是websocket请求的代理(请求路径中有ws可以区分),主要是最下边两行的配置:
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "$connection_upgrade";
$http_upgrade和$connection_upgrade就是引用的上边map配置的值
相比较stomp方式,不使用stomp客户端的时候,前端也不需要额外引入socket.js和stomp.js,可以使用原生写法,例如:
写一个自定义的js文件websocket.js
var wsObj = null;
var wsUri = null;
var userId = -1;
var lockReconnect = false;//避免重复连接
var wsCreateHandler = null;
function createWebSocket() {
var host = window.location.host; // 带有端口号
userId = GetQueryString("userId");
// wsUri = "ws://" + host + "/websocket?userId=" + userId;
wsUri = "ws://" + host + "/websocket/" + userId;
try {
wsObj = new WebSocket(wsUri);
initWsEventHandle();
} catch (e) {
writeToScreen("执行关闭事件,开始重连");
reconnect();
}
}
function initWsEventHandle() {
try {
wsObj.onopen = function (evt) {
heartCheck.start();
onWsOpen(evt);
};
wsObj.onmessage = function (evt) {
heartCheck.start();
onWsMessage(evt);
};
wsObj.onclose = function (evt) {
writeToScreen("执行关闭事件,开始重连");
//reconnect();
onWsClose(evt);
};
wsObj.onerror = function (evt) {
writeToScreen("执行error事件,开始重连");
reconnect();
onWsError(evt);
};
} catch (e) {
writeToScreen("绑定事件没有成功");
reconnect();
}
}
function onWsOpen(evt) {
writeToScreen("CONNECTED");
}
function onWsClose(evt) {
writeToScreen("DISCONNECTED");
}
function onWsError(evt) {
writeToScreen(evt.data);
}
function writeToScreen(message) {
if(DEBUG_FLAG)
{
$("#debuggerInfo").val($("#debuggerInfo").val() + "\n" + message);
}
}
function reconnect() {
if(lockReconnect) {
return;
};
writeToScreen("1秒后重连");
lockReconnect = true;
//没连接上会一直重连,设置延迟避免请求过多
wsCreateHandler && clearTimeout(wsCreateHandler);
wsCreateHandler = setTimeout(function () {
writeToScreen("重连..." + wsUri);
createWebSocket();
lockReconnect = false;
writeToScreen("重连完成");
}, 1000);
}
var heartCheck = {
//15s之内如果没有收到后台的消息,则认为是连接断开了,需要再次连接
timeout: 5000,
timeoutObj: null,
serverTimeoutObj: null,
//重启
reset: function(){
clearTimeout(this.timeoutObj);
clearTimeout(this.serverTimeoutObj);
this.start();
},
//开启定时器
start: function(){
var self = this;
this.timeoutObj && clearTimeout(this.timeoutObj);
this.serverTimeoutObj && clearTimeout(this.serverTimeoutObj);
this.timeoutObj = setTimeout(function(){
writeToScreen("发送ping到后台");
try
{
wsObj.send("ping");
}
catch(ee)
{
writeToScreen("发送ping异常");
}
//内嵌计时器
self.serverTimeoutObj = setTimeout(function(){
//如果onclose会执行reconnect,我们执行ws.close()就行了.如果直接执行reconnect 会触发onclose导致重连两次
writeToScreen("没有收到后台的数据,关闭连接");
//wsObj.close();
reconnect();
}, self.timeout);
}, this.timeout)
},
}
function GetQueryString(name) {
var reg = new RegExp("(^|&)" + name + "=([^&]*)(&|$)", "i");
var r = window.location.search.substr(1).match(reg); //获取url中"?"符后的字符串并正则匹配
var context = "";
if (r != null)
context = r[2];
reg = null;
r = null;
return context == null || context == "" || context == "undefined" ? "" : context;
}
然后在html文件中引用这个js文件即可:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Floor View</title>
<script src="/js/websocket.js"></script>
<script src="/js/jquery.min.js"></script>
<script id="code">
var DEBUG_FLAG = true;
$(function()
{
//启动websocket
createWebSocket();
});
// 当有消息推送后触发下面事件
function onWsMessage(evt) {
var jsonStr = evt.data;
writeToScreen(jsonStr);
}
function writeToScreen(message) {
if(DEBUG_FLAG)
{
$("#debuggerInfo").val($("#debuggerInfo").val() + "\n" + message);
}
}
function sendMessageBySocket()
{
var msg = $("#msg").val();
wsObj.send(msg);
}
</script>
</head>
<body style="margin: 0px;padding: 0px;overflow: hidden; ">
<!-- 显示消息-->
<textarea id="debuggerInfo" style="width:100%;height:200px;"></textarea>
<!-- 发送消息-->
<div>消息:<input type="text" id="msg"></input></div>
<div><input type="button" value="发送消息" onclick="sendMessageBySocket()"></input></div>
</body>
</html>