目录
WebSocket:
WebSocket 是基于 TCP 的一种新的网络协议 。它实现了浏览器与服务器的全双工通信------浏览器和服务器只需要完成一次握手,两者之间就可以创建持久性 的连接, 并进行双向数据传输。

WebSocket的缺点:
服务器长期维护长连接需要一定的成本
各个浏览器支持程度不一
WebSocket 是长连接,受网络限制比较大,需要处理好重连
**结论:**WebSocket并不能完全取代HTTP,它只适合在特定的场景下使用
中州养老项目集成WebSocket:
在zzyl-nursing-platform中如导入以下依赖:
XML
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
定义配置类,注册WebSocket的服务端组件(在platform模块下新建config包以及配置类):
java
package com.zzyl.nursing.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;
@Configuration
public class WebSocketConfig {
/**
* 注册基于@ServerEndpoint声明的Websocket Endpoint
* @return
*/
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
}
定义WebSocket服务端组件:
java
package com.zzyl.nursing.config;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.json.JSONUtil;
import com.zzyl.common.exception.base.BaseException;
import com.zzyl.nursing.vo.AlertNotifyVo;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import javax.websocket.*;
import javax.websocket.server.PathParam;
import javax.websocket.server.ServerEndpoint;
import java.io.IOException;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Slf4j
@Component
@EnableWebSocket
@ServerEndpoint("/ws/{sid}")
public class WebSocketServer {
private static Map<String, Session> sessionMap = new HashMap<>();
/**
* 连接建立时触发
*
* @param session
* @param sid
*/
@OnOpen
public void onOpen(Session session, @PathParam("sid") String sid) {
log.info("有客户端连接到了服务器 , {}", sid);
sessionMap.put(sid, session);
}
/**
* 服务端接收到消息时触发
*
* @param session
* @param message
* @param sid
*/
@OnMessage
public void onMessage(Session session, String message, @PathParam("sid") String sid) {
log.info("接收到了客户端 {} 发来的消息 : {}", sid, message);
}
/**
* 连接关闭时触发
*
* @param session
* @param sid
*/
@OnClose
public void onClose(Session session, @PathParam("sid") String sid) {
System.out.println("连接断开:" + sid);
sessionMap.remove(sid);
}
/**
* 通信发生错误时触发
*
* @param session
* @param sid
* @param throwable
*/
@OnError
public void onError(Session session, @PathParam("sid") String sid, Throwable throwable) {
System.out.println("出现错误:" + sid);
throwable.printStackTrace();
}
/**
* 广播消息
*
* @param message
* @throws IOException
*/
public void sendMessageToAll(String message) throws IOException {
Collection<Session> sessions = sessionMap.values();
if (!CollectionUtils.isEmpty(sessions)) {
for (Session session : sessions) {
//服务器向客户端发送消息
session.getBasicRemote().sendText(message);
}
}
}
/**
* 发送websocket消息给指定消费者
*
* @param alertNotifyVo 报警消息
* @param userIds 报警数据map
* @throws IOException io异常
*/
public void sendMessageToConsumer(AlertNotifyVo alertNotifyVo, Collection<Long> userIds) {
//如果消费者为空,程序结束
if (CollUtil.isEmpty(userIds)) {
return;
}
//如果websoket客户端为空,程序结束
if (ObjectUtil.isEmpty(sessionMap)) {
return;
}
//遍历消费者,发送消息
//key为消息接收人id,value为报警数据id
userIds.forEach(userId -> {
//获取该消费者的websocket连接,如果不存在,跳出本次循环
Session session = sessionMap.get(String.valueOf(userId));
if (ObjectUtil.isEmpty(session)) {
return;
}
//获取该消费者的websocket连接,并发送消息
try {
session.getBasicRemote().sendText(JSONUtil.toJsonStr(alertNotifyVo));
} catch (IOException e) {
throw new BaseException("websocket推送消息失败");
}
});
}
}
消息通知Vo:
java
package com.zzyl.nursing.vo;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 报警通知消息对象
*
**/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class AlertNotifyVo {
/**
* 报警数据id
*/
private Long id;
/**
* 接入位置
*/
private String accessLocation;
/**
* 位置类型 0:随身设备 1:固定设备
*/
private Integer locationType;
/**
* 物理位置类型 0楼层 1房间 2床位
*/
private Integer physicalLocationType;
/**
* 设备位置
*/
private String deviceDescription;
/**
* 产品名称
*/
private String productName;
/**
* 功能名称
*/
private String functionName;
/**
* 数据值
*/
private String dataValue;
/**
* 报警数据类型,0:老人异常数据,1:设备异常数据
*/
private Integer alertDataType;
/**
* 语音通知状态,0:关闭,1:开启
*/
private Integer voiceNotifyStatus;
/**
* 报警通知类型,0:解除报警,1:报警
*/
private Integer notifyType;
/**
* 是否全员通知<br>
* 智能床位的报警消息是全员通知,对于护理员和固定设备维护人员不是全员通知
*/
private Boolean isAllConsumer;
}
AlertRuleServiceImpl:
java
/**
* 过滤数据是否触发报警规则
*
* @param rule
* @param deviceData
*/
private void deviceDataAlarmHandler(AlertRule rule, DeviceData deviceData) {
// 判断上报时间是否在规则的生效时段内 00:00:00~23:59:59
String[] split = rule.getAlertEffectivePeriod().split("~");
LocalTime startTime = LocalTime.parse(split[0]);
LocalTime endTime = LocalTime.parse(split[1]);
// 获取上报时间
LocalTime time = LocalDateTimeUtil.of(deviceData.getAlarmTime()).toLocalTime();
// 不在上报时间内,则结束请求
if (time.isBefore(startTime) || time.isAfter(endTime)) {
return;
}
// 获取IOTID
String iotId = deviceData.getIotId();
// 统计次数的key
String aggCountKey = CacheConstants.ALERT_TRIGGER_COUNT_PREFIX + iotId + ":" + deviceData.getFunctionId() + ":" + rule.getId();
// 数据对比,上报的数据与规则中的阈值进行对比
// 两个参数x,y(参数有顺序要求,左边是上报的数据,后边是规则的数据) x==y 返回0 x>y 返回大于0 x<y 返回小于0的数值
int compare = NumberUtil.compare(Double.valueOf(deviceData.getDataValue()), rule.getValue());
if ((rule.getOperator().equals(">=") && compare >= 0) || (rule.getOperator().equals("<") && compare < 0)) {
log.info("当前上报的数据符合规则异常");
} else {
// 正常的数据
redisTemplate.delete(aggCountKey);
return;
}
// 异常的数据会走到这里
// 判断是否在沉默周期内
String silentKey = CacheConstants.ALERT_SILENT_PREFIX + iotId + ":" + deviceData.getFunctionId() + ":" + rule.getId();
String silentData = redisTemplate.opsForValue().get(silentKey);
if (StringUtils.isNotEmpty(silentData)) {
return;
}
// 持续周期的逻辑
String aggData = redisTemplate.opsForValue().get(aggCountKey);
int count = StringUtils.isEmpty(aggData) ? 1 : Integer.parseInt(aggData) + 1;
// 如果count与持续周期的值相等,则触发报警
if (ObjectUtil.notEqual(count, rule.getDuration())) {
// 不相等
redisTemplate.opsForValue().set(aggCountKey, count + "");
return;
}
// 删除redis的报警数据
redisTemplate.delete(aggCountKey);
// 存储数据到沉默周期,设置一个过期时间,规则中的沉默周期
redisTemplate.opsForValue().set(silentKey, "1", rule.getAlertSilentPeriod(), TimeUnit.MINUTES);
// 报警数据,需要找到对应的人
List<Long> userIds = new ArrayList<>();
if (rule.getAlertDataType().equals(0)) {
// 老人异常数据
if (deviceData.getLocationType().equals(0)) {
// 说明是报警手表,直接可以找到老人的id,通过老人id,找到对应的护理员
userIds = deviceMapper.selectNursingIdsByIotIdWithElder(iotId);
} else if (deviceData.getLocationType().equals(1) && deviceData.getPhysicalLocationType().equals(2)) {
// 说明是床位设备,可以通过床位id找到老人,通过老人id,找到对应的护理员
userIds = deviceMapper.selectNursingIdsByIotIdWithBed(iotId);
}
} else {
// 设备异常数据,找维修工,或者是行政人员
userIds = userRoleMapper.selectUserIdByRoleName(deviceMaintainerRole);
}
// 不论是哪种情况,都要通知超级管理员
List<Long> managerIds = userRoleMapper.selectUserIdByRoleName(managerRole);
Collection<Long> allUserIds = CollUtil.addAll(userIds, managerIds);
// 去重
allUserIds = CollUtil.distinct(allUserIds);
// 批量保存异常数据
List<AlertData> alertDataList = insertAlertData(allUserIds, rule,deviceData);
// websocket推送消息
webSocketNotity(alertDataList.get(0), rule, allUserIds);
}
/**
* websocket推送消息
* @param alertData
* @param rule
* @param allUserIds
*/
private void webSocketNotity(AlertData alertData, AlertRule rule, Collection<Long> allUserIds) {
//属性拷贝
AlertNotifyVo alertNotifyVo = BeanUtil.toBean(alertData, AlertNotifyVo.class);
alertNotifyVo.setAccessLocation(alertData.getRemark());
alertNotifyVo.setFunctionName(rule.getFunctionName());
alertNotifyVo.setAlertDataType(rule.getAlertDataType());
alertNotifyVo.setNotifyType(1);
// 向指定的人推送消息
webSocketServer.sendMessageToConsumer(alertNotifyVo, allUserIds);
}
/**
* 保存报警数据
*
* @param allUserIds
* @param rule
* @param deviceData
*/
private List<AlertData> insertAlertData(Collection<Long> allUserIds, AlertRule rule, DeviceData deviceData) {
// 对象拷贝
AlertData alertData = BeanUtil.toBean(deviceData, AlertData.class);
alertData.setAlertRuleId(rule.getId());
// 心率<60,持续3个周期就报警
String alertReason = CharSequenceUtil.format("{}{}{},持续{}个周期就报警", rule.getFunctionName(), rule.getOperator(), rule.getValue(), rule.getDuration());
alertData.setAlertReason(alertReason);
alertData.setStatus(0);
alertData.setType(rule.getAlertDataType());
// 遍历allUserIds
List<AlertData> list = allUserIds.stream().map(userId -> {
AlertData dbAlertData = BeanUtil.toBean(alertData, AlertData.class);
dbAlertData.setUserId(userId);
dbAlertData.setId(null);
return dbAlertData;
}).collect(Collectors.toList());
// 批量保存
alertDataService.saveBatch(list);
return list;
}
前端index.vue文件:
javascript
<template>
<div
:class="classObj"
class="app-wrapper"
:style="{ '--current-color': theme }"
>
<div
v-if="device === 'mobile' && sidebar.opened"
class="drawer-bg"
@click="handleClickOutside"
/>
<sidebar v-if="!sidebar.hide" class="sidebar-container" />
<div
:class="{ hasTagsView: needTagsView, sidebarHide: sidebar.hide }"
class="main-container"
>
<div :class="{ 'fixed-header': fixedHeader }">
<navbar @setLayout="setLayout" />
<tags-view v-if="needTagsView" />
</div>
<app-main />
<settings ref="settingRef" />
</div>
</div>
<!-- 报警提示弹层 -->
<Warn
:visible="visibleWarn"
:data="warnData"
:time="time"
@handleSubmit="handleSubmit"
@handleClose="handleWarnClose"
></Warn>
<!-- end -->
</template>
<script setup>
import { useWindowSize } from '@vueuse/core';
import Sidebar from './components/Sidebar/index.vue';
import Warn from '@/components/warn/index.vue';
import { AppMain, Navbar, Settings, TagsView } from './components';
import defaultSettings from '@/settings';
import useAppStore from '@/store/modules/app';
import useSettingsStore from '@/store/modules/settings';
import { onMounted } from 'vue';
import useUserStore from '@/store/modules/user';
const settingsStore = useSettingsStore();
const userStore = useUserStore();
const theme = computed(() => settingsStore.theme);
const sideTheme = computed(() => settingsStore.sideTheme);
const sidebar = computed(() => useAppStore().sidebar);
const device = computed(() => useAppStore().device);
const needTagsView = computed(() => settingsStore.tagsView);
const fixedHeader = computed(() => settingsStore.fixedHeader);
const classObj = computed(() => ({
hideSidebar: !sidebar.value.opened,
openSidebar: sidebar.value.opened,
withoutAnimation: sidebar.value.withoutAnimation,
mobile: device.value === 'mobile',
}));
const { width, height } = useWindowSize();
const WIDTH = 992; // refer to Bootstrap's responsive design
const visibleWarn = ref(false); // 报警弹层
const operateTitle = ref(''); // 操作弹层标题
const operateText = ref(''); // 要操作的内容提示
const audioVo = ref(null);
watchEffect(() => {
if (device.value === 'mobile' && sidebar.value.opened) {
useAppStore().closeSideBar({ withoutAnimation: false });
}
if (width.value - 1 < WIDTH) {
useAppStore().toggleDevice('mobile');
useAppStore().closeSideBar({ withoutAnimation: true });
} else {
useAppStore().toggleDevice('desktop');
}
});
onMounted(() => {
setwebSocket();
});
function handleClickOutside() {
useAppStore().closeSideBar({ withoutAnimation: false });
}
const settingRef = ref(null);
function setLayout() {
settingRef.value.openSetting();
}
// 语音播报/报警异常
const socket = ref(null);
const warnData = ref({}); // 报警数据
const setwebSocket = () => {
// TODO 一般传一个随机数字,不过本项目传的是用户id
// const clientId = Math.random().toString(36).substr(2)
// socket.value = new WebSocket(`ws://172.16.17.191:9000/ws/${userStore.id}`)
console.log(`${import.meta.env.VITE_APP_SOCKET_URL}/ws/${userStore.id}`);
socket.value = new WebSocket(
`${import.meta.env.VITE_APP_SOCKET_URL}/ws/${userStore.id}`
);
socket.value.onmessage = (event) => {
const res = JSON.parse(event.data);
warnData.value = res;
console.log(res);
if (res.notifyType === 1) {
// 报警异常
if (res.isAllConsumer) {
if (res.physicalLocationType === 0) {
userStore.setUnusualFloorId(res.deviceDescription?.split(',')[0]);
} else if (res.physicalLocationType === 2) {
userStore.setUnusualBedId(res.deviceDescription?.split(',')[2]);
}
} else {
// 添加语音播报/弹层提示
// 报警提示弹层
visibleWarn.value = true;
}
} else {
// 解除报警异常
if (res.physicalLocationType === 0) {
return userStore.deleteUnusualFloorId(
res.deviceDescription.split(',')[0]
);
}
if (res.physicalLocationType === 2) {
return userStore.deleteUnusualBedId(
res.deviceDescription.split(',')[2]
);
}
}
};
};
// 关闭警告弹层
const handleWarnClose = () => {
visibleWarn.value = false;
};
</script>
<style lang="scss" scoped>
@import '@/assets/styles/mixin.scss';
@import '@/assets/styles/variables.module.scss';
.app-wrapper {
@include clearfix;
position: relative;
height: 100%;
width: 100%;
&.mobile.openSidebar {
position: fixed;
top: 0;
}
}
.drawer-bg {
background: #000;
opacity: 0.3;
width: 100%;
top: 0;
height: 100%;
position: absolute;
z-index: 999;
}
.fixed-header {
position: fixed;
top: 0;
right: 0;
z-index: 9;
width: calc(100% - #{$base-sidebar-width});
transition: width 0.28s;
}
.hideSidebar .fixed-header {
width: calc(100% - 54px);
}
.sidebarHide .fixed-header {
width: 100%;
}
.mobile .fixed-header {
width: 100%;
}
</style>
然后修改前端的ws的连接地址:

由于前端要发请求到后端建立连接,咱们还需要在SpringSecurity中放行ws开头的请求:

代码推送:
