前言
最近在工作碰到一个需求,项目背景是一款线索分享系统,主要给公安系统使用。其中一个基本功能是,用户A在系统中提交了一条线索,这条线索根据一定的逻辑,判断需要推送给其它某些用户,例如这里举例,这条消息需要推送给用户B,此时B用户的系统页面中需要进行实时弹窗提示
。
这个推送弹窗提示的效果,就可以使用webSocket
来实现,webSocket不像传统的http请求,只能客户端去主动请求服务端,它可以实现服务端与客户端之间互发消息,进行实时的沟通。比方说在线的聊天室,就是使用了webSocket实现。
这个功能实现的过程中,还希望这个推送的过程,是一个异步
操作,保存完线索信息之后,直接返回前端页面保存成功,推送的过程异步操作,不要影响保存的线程。因为这个项目是一个Springboot单体项目,所以这里就借助于SpringBoot的事件发布
机制来完成。
这个项目由于是给公安系统使用的,所以项目是直接部署到公安内网,通过ip访问,所以本文就针对这种方式来演示如何使用webSocket,至于比方说配置了nginx反向代理的场景,这里就不涉及了。
项目依赖
真实项目不便透露,这里做了一个demo项目,SpringBoot版本为2.6.13,pom文件如下:
xml
<dependencies>
<!--springsecurity-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!--thymeleaf-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<!--web场景启动器-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!--websocket-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<!--mysql驱动-->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.17</version>
</dependency>
<!--mybatis-plus依赖-->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.3.1</version>
</dependency>
</dependencies>
准备工作
表设计
数据表就简单设计了两个,一个是用户表,一个是线索表。
用户表:
线索表:
SpringSecurity
项目的登录功能是引入了SpringSecurity来做的,这里为了简单,直接使用了默认的表单登录流程,配置类如下:
java
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
UserServiceImpl userService;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userService);
}
}
注入的UserService实例中,实现了UserDetailsService
接口,代表这是SpringSecurity中的用户信息数据源。然后实现了loadUserByUsername
方法,也就是说登录时,会调用这个方法来根据username获取用户信息。
项目还引入Mybatis-plus,所以这里UserService继承了ServiceImpl类
java
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, UserEntity> implements UserService, UserDetailsService {
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
UserEntity user = getOne(new QueryWrapper<UserEntity>().eq("username", username));
if (user == null) {
throw new UsernameNotFoundException("用户名不存在");
}
return user;
}
}
流程
整个流程这里就简单规划一下,有两个用户 user1、user2,user1登录系统后,保存一条线索,保存完成后。假定这条线索需要推送给user2,这里让user2的页面,弹出提醒。
实现功能
引入依赖
要使用WebSocket,首先需要引入依赖,上面的pom文件中已经有了
配置类
需要提供一个配置类,里边定义一个ServerEndpointExporter
,注册到容器中,如下
java
@Configuration
public class WebSocketConfig {
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
}
定义WebSocket Server
这里需要定义一个WebSocket Server,下面就简称server,这个server可以理解成是一个websocket请求访问的服务端。因为websocket的工作机制,是需要客户端 先发起一个请求,请求被服务端接收到,服务端与客户端建立一个长链接,然后双方接下来可以互发消息。
而且server也可以定义多个,比方说有线索保存的推送,有人员报警的推送等等,就好比是一个个的controller。
首先定义一个类,标注上@Component
注解以及@ServerEndpoint
注解,后者可以用来指定当前类是一个WebSocket Server的服务端,同时也可以设置它的请求地址:
java
@Component
@ServerEndpoint("/webSocket/clue/{id}")
public class WebSocketServer {
}
整个类的具体配置如下:
- @ServerEndpoint设置了当前服务端的请求路径,为/webSocket/clue/{id},这里的{id},其实就是一个
路径参数
,每一个id就对应一个websocket连接
,建议这里的id直接和用户对应,也就是说,每个用户对应一个连接。 - 类中设置了几个成员变量:
onlineSessionClientCount
表示当前连接数,onlineSessionClientMap
是一个map集合,里面存放所有在线的用户连接,pendingMap
也是一个map集合,这里是对用户不在线时,堆积消息的一个处理,这里只是针对用户量不大时的简单处理,并且数据也是保存到内存中的。如果要求数据不能丢失,或者用户量比较大。也可以存到数据库,或者引入redis。 onOpen、onClose、onMessage、onError
这四个方法,分别标注了对应的注解,代表当用户连接成功时的操作、关闭连接时的操作、收到消息时的操作、连接出错时的操作。- 最下面还有两个自定义方法,
sendToOne
也就是对某个id发消息,也就是对某个用户发消息。getWebSocketUrl
是提供了一个静态方法,当有地方想要使用这个server时,就调用这个方法,获取webSocket的连接地址,返回给前端,由前端发起webSocket请求。
java
@Component
@ServerEndpoint("/webSocket/clue/{id}")
public class WebSocketServer {
//在线数量
private static AtomicInteger onlineSessionClientCount = new AtomicInteger(0);
//在线客户端集合
private static Map<String, Session> onlineSessionClientMap = new ConcurrentHashMap<>();
//堆积消息集合
private static Map<String, List<String>> pendingMap = new ConcurrentHashMap<>();
/**
* 连接创建成功
*
* @param id
* @param session
*/
@OnOpen
public void onOpen(@PathParam("id") String id, Session session) {
//保存到在线客户端集合
onlineSessionClientMap.put(id, session);
//记录在线客户端数
onlineSessionClientCount.incrementAndGet();
//如果当前用户存在堆积数据 进行推送
if (pendingMap.containsKey(id)) {
List<String> list = pendingMap.get(id);
pendingMap.remove(id);
list.forEach(message -> sendToOne(id, message));
}
}
/**
* 连接关闭回调
*
* @param id
* @param session
*/
@OnClose
public void onClose(@PathParam("id") String id, Session session) {
//从map集合中移除
onlineSessionClientMap.remove(id);
//在线数减1
onlineSessionClientCount.decrementAndGet();
}
/**
* 收到消息后的回调
*
* @param message
* @param session
*/
@OnMessage
public void onMessage(String message, Session session) {
}
/**
* 发生错误时的回调
*
* @param session
* @param error
*/
@OnError
public void onError(Session session, Throwable error) {
}
/**
* 向指定的id发送消息
*
* @param id
* @param message
*/
public void sendToOne(String id, String message) {
Session session = onlineSessionClientMap.get(id);
if (session == null) {
//如果该id不在线,记录消息 等待用户访问时推送
List<String> msgs = new ArrayList<>();
if (pendingMap.containsKey(id)) {
msgs = pendingMap.get(id);
}
msgs.add(message);
return;
}
session.getAsyncRemote().sendText(message);
}
/**
* 获取websocket地址
*
* @param request
* @return
*/
public static String getWebSocketUrl(HttpServletRequest request) {
String ip = request.getLocalAddr();
if (ip.contains(":")) {
ip = "localhost";
}
int port = request.getLocalPort();
return "ws://" + ip + ":" + port + "/webSocket/clue/" + UserUtil.getLoginUser().getId();
}
}
前端发起webSocket请求
可以参考下面的代码,具体就是页面初始化时,获取到对应的url,然后在js中发起请求,等待接收消息。接收到消息之后,就做某些操作,比如弹窗或者显示消息等等。
controller
java
@RequestMapping("/")
@Controller
public class IndexController {
@GetMapping
public String index(HttpServletRequest request, Model model) {
model.addAttribute("webSocketUrl", WebSocketServer.getWebSocketUrl(request));
return "index";
}
}
index.html
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<h1>首页</h1>
<input type="hidden" id="webSocketUrl" th:value="${webSocketUrl}"/>
<div id="tip" style="border:1px solid #F00;width:30%;display: none"/>
<script type="text/javascript">
window.onload = function () {
//创建websocket连接
let url = document.getElementById("webSocketUrl").value;
let socket = new WebSocket(url);
//打开事件
socket.onopen = function () {
console.log("webSocket正在连接");
};
//获得消息事件
socket.onmessage = function (msg) {
let tip = document.getElementById("tip");
tip.textContent = msg.data;
tip.style.display = "";
};
//关闭事件
socket.onclose = function () {
console.log("webSocket已关闭");
};
//发生了错误事件
socket.onerror = function () {
console.log("webSocket发生错误")
}
}
</script>
</body>
</html>
保存线索
接下来就剩在保存线索数据的时候,调用server来进行推送了。
这里为了不让推送的过程影响到保存线程,使用@Async+事件发布机制
,完成一个异步操作。
开启异步支持
首先为了开启异步支持,需要在主启动类或者配置类上,标注@EnableAsync
注解
定义事件类
java
public class ClueEvent extends ApplicationEvent {
private String message;
public ClueEvent(Object source, String message) {
super(source);
this.message = message;
}
public String getMessage() {
return message;
}
public void setMessage(String message) {
this.message = message;
}
}
定义监听器
java
@Component
public class ClueEventListener {
@Autowired
private WebSocketServer webSocketServer;
/**
* 监听器处理消息
*
* @param event
*/
@Async
@EventListener
public void onEventListener(ClueEvent event) {
//这里的id就写死成user2的id
webSocketServer.sendToOne("2", event.getMessage());
}
}
保存时发布事件
java
@Controller
@RequestMapping("/clue")
public class ClueController {
@Autowired
private ClueService clueService;
@Autowired
private ApplicationEventPublisher applicationEventPublisher;
@PostMapping("/save")
@ResponseBody
public void save(ClueEntity clueEntity) {
clueService.save(clueEntity);
//发布推送事件
applicationEventPublisher.publishEvent(new ClueEvent(this, "您有一条新线索待接收"));
}
}
总结
完成上述操作之后,就能够实现在保存线索时,实时的推送消息到指定的用户了。