SpringBoot集成WebSocket
1 简介
websocket是一种在单个TCP连接上进行全双工通讯的协议,websocket使得客户端和服务器之间的数据交换变得更加简单,允许服务端主动向客户端推送数据,在websocket中,浏览器和服务器只需要完成一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输。
我们都知道HTPP协议是基于请求响应模式,并且无状态的。HTTP通信只能由客户端发起,HTTP 协议做不到服务器主动向客户端推送信息。
2 为什么要使用WebSocket
通常我们开发网站的时候,HTTP协议就差不多多满足需求了,可以发送GET,POST,PUT等请求.但是对于需要实时更新的数据,HTTP协议就有些力不从心了,像下图这样的数据大屏,我们在生活中也经常看到,这里面的没一个模块的数据都需要实时的进行更新,才能起到监控的作用,如果还是使用HTTP协议会怎么做呢.
数据大屏在编辑的时候,每一个模块都是一个可以拖动的组件,针对每一个组件都可以设置它获取数据的方式,可以是静态数据,当然这个不太常用,可以设置为动态获取数据,只需要填写上接口地址,接口方式,设置刷新时间,例如下图每5秒刷新一次.
这样有什么弊端呢?我们知道,每发送一次请求,就要进行一次HTTP的连接和断开,包括三次握手和四次挥手,这样轮询,效率是非常低的,HTTP请求还包含较长的头部,也会非常浪费资源.
使用WebSocket可以使客户端和服务器之间的数据交换变得简单,浏览器和服务器只需要一次握手的动作,就可以形成一条快速的通道,两者之间就直接可以进行数据传输了,WebSocket协议能够节省服务器资源和带宽.
举例来说,我们想要查询当前的排队情况,只能是页面轮询向服务器发出请求,服务器返回查询结果。轮询的效率低,非常浪费资源(因为必须不停连接,或者 HTTP 连接始终打开)。因此WebSocket 就是在这样的背景下发明的。
3 SpringBoot整合WebSocket
1.依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
- 配置类
首先要注入ServerEndpointExporter,这个bean会自动注册使用了@ServerEndpoint注解声明的WebsocketEndpoint。
@Configuration
public class WebsocketAutoConfig {
@Bean
public ServerEndpointExporter endpointExporter() {
return new ServerEndpointExporter();
}
}
1.@ServerEndpoint(“/api/pushMessage/{userId}”) 前端通过此 URI 和后端交互,建立连接
2.@Component 不用说将此类交给 spring 管理
3.@OnOpen websocket 建立连接的注解,前端触发上面 URI 时会进入此注解标注的方法
4.@OnMessage 收到前端传来的消息后执行的方法
5.@OnClose 顾名思义关闭连接,销毁 session
6.因为WebSocket是类似客户端服务端的形式(采用ws协议),那么这里的WebSocketServer其实就相当于一个ws协议的Controller
7.新建一个ConcurrentHashMap webSocketMap 用于接收当前userId的WebSocket,方便IM之间对userId进行推送消息
- WebsocketEndpoint具体的实现类
@ServerEndpoint("/websocket/{sid}")
@Component
public class WebSocketServer {
private static Logger logger = LoggerFactory.getLogger(WebSocketServer.class);
private static int onlineCount = 0;
private static CopyOnWriteArraySet<WebSocketServer> webSocketSet = new CopyOnWriteArraySet<WebSocketServer>();
private Session session;
private String sid="";
@OnOpen
public void onOpen(Session session, @PathParam("sid") String sid) {
this.session = session;
webSocketSet.add(this);
addOnlineCount();
logger.info("有新窗口开始监听:"+sid+",当前在线人数为" + getOnlineCount());
this.sid=sid;
try {
sendMessage("连接成功");
} catch (IOException e) {
logger.error("websocket IO异常");
}
}
@OnClose
public void onClose() {
webSocketSet.remove(this);
subOnlineCount();
logger.info("有一连接关闭!当前在线人数为" + getOnlineCount());
}
@OnMessage
public void onMessage(String message, Session session) {
logger.info("收到来自窗口"+sid+"的信息:"+message);
for (WebSocketServer item : webSocketSet) {
try {
item.sendMessage(message);
} catch (IOException e) {
e.printStackTrace();
}
}
}
@OnError
public void onError(Session session, Throwable error) {
logger.error("发生错误");
error.printStackTrace();
}
public void sendMessage(String message) throws IOException {
this.session.getBasicRemote().sendText(message);
}
public static void sendInfo(String message,@PathParam("sid") String sid) throws IOException {
logger.info("推送消息到窗口" + sid + ",推送内容:" + message);
for (WebSocketServer item : webSocketSet) {
try {
if(sid==null) {
item.sendMessage(message);
}else if(item.sid.equals(sid)){
item.sendMessage(message);
}
} catch (IOException e) {
continue;
}
}
}
public static synchronized int getOnlineCount() {
return onlineCount;
}
public static synchronized void addOnlineCount() {
WebSocketServer.onlineCount++;
}
public static synchronized void subOnlineCount() {
WebSocketServer.onlineCount--;
}
}
- 页面
<!DOCTYPE HTML>
<html>
<head>
<title>My WebSocket</title>
</head>
<body>
Welcome<br/>
<input id="text" type="text" />
<button onclick="send()">Send</button>
<button onclick="closeWebSocket()">Close</button>
<div id="message">
</div>
</body>
<script type="text/javascript">
var websocket = null;
//判断当前浏览器是否支持WebSocket
if('WebSocket' in window){
websocket = new WebSocket("ws://localhost:8080/websocket/1");
}
else{
alert('Not support websocket')
}
//连接发生错误的回调方法
websocket.onerror = function(){
setMessageInnerHTML("error");
};
//连接成功建立的回调方法
websocket.onopen = function(event){
setMessageInnerHTML("open");
}
//接收到消息的回调方法
websocket.onmessage = function(event){
setMessageInnerHTML(event.data);
}
//连接关闭的回调方法
websocket.onclose = function(){
setMessageInnerHTML("close");
}
//监听窗口关闭事件,当窗口关闭时,主动去关闭websocket连接,防止连接还没断开就关闭窗口,server端会抛异常。
window.onbeforeunload = function(){
websocket.close();
}
//将消息显示在网页上
function setMessageInnerHTML(innerHTML){
document.getElementById('message').innerHTML += innerHTML + '<br/>';
}
//关闭连接
function closeWebSocket(){
websocket.close();
}
//发送消息
function send(){
var message = document.getElementById('text').value;
websocket.send(message);
}
</script>
</html>
OK,现在服务端和前端已经可以建立WebSocket连接,进行通信了,现在还只是一个简单的Demo,浏览器发送数据给后端,后端@onMessage注解的方法接受到消息,再给浏览器发送消息,是你来我往的形式.
4. 服务器主动推送数据给前端
那么像数据大屏那种如何由后端服务器自动推送数据,而不是你来我往交替发送呢?于是我想到了定时任务,设置时间间隔,比如每五秒发送一次数据给前端,当然,只是发送给那些已经建立好WebSocket连接的前端浏览器.发送数据也可以群发或者根据id单独发送,这里数据大屏就选择群发的形式.
@Slf4j
@Configuration
@EnableScheduling
public class SendMessageToWeb {
//每10秒发送一次
@Scheduled(cron = "0/10 * * * * ?")
public void sendTestMessage() {
if(WebsocketServerEndpoint.websocketServerSet.size()>0){
log.info("发送数据");
try{
//这里省略获取到需要发送数据data的逻辑
WebsocketServerEndpoint.sendMessage(null,data);
}catch (Exception e){
log.error(e.toString());
}
}
}
}
现在,再打开前端已经可以看到每隔10秒都会接收到服务器推送来的数据了,但是还有一个问题,现在发送的数据是字符串格式的,而我们在前后分离开发时,前端通常希望接收到JSON格式或者是List类型的数据,这要怎么发送呢?
在上面sendMessage方法中,我们是调用了Basic的sendText()方法用来发送文本.
我们看到RemoteEndpoint接口中不止有sendText()方法,还有sendObject()方法,用这个方法就可以发送任何对象了.
于是,我便在WebsocketServerEndpoint类中写了两个方法,如下:
private void sendObject(Object object) throws IOException, EncodeException {
this.session.getBasicRemote().sendObject(object);
}
* id为null时是群发
* @param map
* @param id
*/
public static void sendData(String id, Object map) {
for (WebsocketServerEndpoint endpoint : websocketServerSet) {
try {
if (id == null) {
endpoint.sendObject(map);
} else if (endpoint.id.equals(id)) {
endpoint.sendObject(map);
}
} catch (Exception e) {
e.printStackTrace();
continue;
}
}
}
5 WebSocket发送Object类型的数据
然后我在定时任务代码中调用sendData()方法,发送我从数据库获取到的数据(因为数据大屏前端需要展示很多数据,所以我将每一种数据封装成Map格式,然后再将所有的Map封装到一个List中,就是List<Map<Object,Object>>)格式的.然而,发送的时候却出现了错误,报错信息如下:
/details/111318414
javax.websocket.EncodeException: No encoder specified for object of class [class java.util.ArrayList]
at org.apache.tomcat.websocket.WsRemoteEndpointImplBase.sendObject(WsRemoteEndpointImplBase.java:604)
at org.apache.tomcat.websocket.WsRemoteEndpointBasic.sendObject(WsRemoteEndpointBasic.java:74)
at com.software.nju.WebSocket.WebsocketServerEndpoint.sendObject(WebsocketServerEndpoint.java:131)
at com.software.nju.WebSocket.WebsocketServerEndpoint.sendData(WebsocketServerEndpoint.java:170)
at com.software.nju.Service.SendMessageSercice.sendDataToWeb(SendMessageSercice.java:48)
at com.software.nju.WebSocket.WebsocketServerEndpoint.onOpen(WebsocketServerEndpoint.java:71)
错误信息也很明显,是没有ArrayList类型的编码器,需要自己实现一个编码器并使用,
所以新建一个ServerEncoder类,实现Encoder.Text
我们需要实现的解码器类型是List的,所以将泛型T换成List,实现encode方法时将List先转换成JSON格式,然后再转换成String类型,代码如下:
import com.alibaba.fastjson.JSON;
import javax.websocket.EncodeException;
import javax.websocket.Encoder;
import javax.websocket.EndpointConfig;
import java.util.List;
/**
* 配置WebSocket编码器,用于发送请求的时候可以发送Object对象,实则是json数据
* sendObject()
* @ClassNmae:ServerEncoder
*
*/
public class ServerEncoder implements Encoder.Text<List> {
@Override
public void destroy() {
// TODO Auto-generated method stub
}
@Override
public void init(EndpointConfig arg0) {
// TODO Auto-generated method stub
}
@Override
public String encode(List list) throws EncodeException {
try {
return JSON.toJSON(list).toString();
} catch (Exception e) {
// TODO Auto-generated catch block
e.printStackTrace();
return "";
}
}
}
然后修改WebsocketServerEndpoint类的注解@ServerEndpoint中指定使用的编码器
@ServerEndpoint(value = "/websocket/{id}",encoders = {ServerEncoder.class})
public class WebsocketServerEndpoint {
}
然后前端接收到数据之后需要进行JSON解析,再从List中分离出不同的数据赋值给不同的变量,这里根据自己的业务需求实现.
//接收到消息的回调方法
websocket.onmessage = function(event){
console.log(JSON.parse(event.data))
}
6 心跳及重连机制
在使用websocket的过程中,有时候会遇到网络断开的情况,但是在网络断开的时候服务器端并没有触发onclose的事件。这样会有:服务器会继续向客户端发送多余的链接,并且这些数据还会丢失。所以就需要一种机制来检测客户端和服务端是否处于正常的链接状态。因此就有了websocket的心跳了。还有心跳,说明还活着,没有心跳说明已经挂掉了。
为什么叫心跳包呢?
它就像心跳一样每隔固定的时间发一次,来告诉服务器,我还活着。
心跳机制是?
心跳机制是每隔一段时间会向服务器发送一个数据包,告诉服务器自己还活着,同时客户端会确认服务器端是否还活着,如果还活着的话,就会回传一个数据包给客户端来确定服务器端也还活着,否则的话,有可能是网络断开连接了。需要重连
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
</dependency>
@Configuration
public class WebSocketConfig {
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
}
@Component
@Slf4j
@ServerEndpoint("/api/pushMessage/{userId}")
public class WebSocketServer {
/**静态变量,用来记录当前在线连接数。应该把它设计成线程安全的。*/
private static int onlineCount = 0;
/**concurrent包的线程安全Set,用来存放每个客户端对应的WebSocket对象。*/
private static ConcurrentHashMap<String,WebSocketServer> webSocketMap = new ConcurrentHashMap<>();
/**与某个客户端的连接会话,需要通过它来给客户端发送数据*/
private Session session;
/**接收userId*/
private String userId = "";
/**
* 连接建立成
* 功调用的方法
*/
@OnOpen
public void onOpen(Session session,@PathParam("userId") String userId) {
this.session = session;
this.userId=userId;
if(webSocketMap.containsKey(userId)){
webSocketMap.remove(userId);
//加入set中
webSocketMap.put(userId,this);
}else{
//加入set中
webSocketMap.put(userId,this);
//在线数加1
addOnlineCount();
}
log.info("用户连接:"+userId+",当前在线人数为:" + getOnlineCount());
sendMessage("连接成功");
}
/**
* 连接关闭
* 调用的方法
*/
@OnClose
public void onClose() {
if(webSocketMap.containsKey(userId)){
webSocketMap.remove(userId);
//从set中删除
subOnlineCount();
}
log.info("用户退出:"+userId+",当前在线人数为:" + getOnlineCount());
}
/**
* 收到客户端消
* 息后调用的方法
* @param message
* 客户端发送过来的消息
**/
@OnMessage
public void onMessage(String message, Session session) {
log.info("用户消息:"+userId+",报文:"+message);
//可以群发消息
//消息保存到数据库、redis
if(StringUtils.isNotBlank(message)){
try {
//解析发送的报文
JSONObject jsonObject = JSON.parseObject(message);
//追加发送人(防止串改)
jsonObject.put("fromUserId",this.userId);
String toUserId=jsonObject.getString("toUserId");
//传送给对应toUserId用户的websocket
if(StringUtils.isNotBlank(toUserId)&&webSocketMap.containsKey(toUserId)){
webSocketMap.get(toUserId).sendMessage(message);
}else{
//否则不在这个服务器上,发送到mysql或者redis
log.error("请求的userId:"+toUserId+"不在该服务器上");
}
}catch (Exception e){
e.printStackTrace();
}
}
}
/**
* @param session
* @param error
*/
@OnError
public void onError(Session session, Throwable error) {
log.error("用户错误:"+this.userId+",原因:"+error.getMessage());
error.printStackTrace();
}
/**
* 实现服务
* 器主动推送
*/
public void sendMessage(String message) {
try {
this.session.getBasicRemote().sendText(message);
} catch (IOException e) {
e.printStackTrace();
}
}
/**
*发送自定
*义消息
**/
public static void sendInfo(String message, String userId) {
log.info("发送消息到:"+userId+",报文:"+message);
if(StringUtils.isNotBlank(userId) && webSocketMap.containsKey(userId)){
webSocketMap.get(userId).sendMessage(message);
}else{
log.error("用户"+userId+",不在线!");
}
}
/**
* 获得此时的
* 在线人数
* @return
*/
public static synchronized int getOnlineCount() {
return onlineCount;
}
/**
* 在线人
* 数加1
*/
public static synchronized void addOnlineCount() {
WebSocketServer.onlineCount++;
}
/**
* 在线人
* 数减1
*/
public static synchronized void subOnlineCount() {
WebSocketServer.onlineCount--;
}
}
@Controller
@RequestMapping("/api/test")
public class TestController {
@Autowired
private TestServiceImpl testServiceImpl;
/**
* 启动页面
* @return
*/
@GetMapping("/start")
public String start(){
return "index";
}
@PostMapping("/pushToWeb")
public String pushToWeb() throws IOException {
testServiceImpl.printTime();
return "success";
}
}
@Service
@EnableScheduling
public class TestServiceImpl {
//打印时间
@Scheduled(fixedRate=1000) //1000毫秒执行一次
public void printTime() throws IOException {
SimpleDateFormat dateFormat = new SimpleDateFormat("HH:mm:ss");
String date = dateFormat.format(new Date());
WebSocketServer.sendInfo(date,"10");
System.out.println(date);
}
}
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>websocket通讯</title>
</head>
<script src="https://cdn.bootcss.com/jquery/3.3.1/jquery.js"></script>
<script>
let socket;
function openSocket() {
const socketUrl = "ws://localhost:9091/api/pushMessage/" + $("#userId").val();
console.log(socketUrl);
if(socket!=null){
socket.close();
socket=null;
}
socket = new WebSocket(socketUrl);
//打开事件
socket.onopen = function() {
console.log("websocket已打开");
};
//获得消息事件
socket.onmessage = function(msg) {
console.log(msg.data);
//发现消息进入,开始处理前端触发逻辑
};
//关闭事件
socket.onclose = function() {
console.log("websocket已关闭");
};
//发生了错误事件
socket.onerror = function() {
console.log("websocket发生了错误");
}
}
function sendMessage() {
socket.send('{"toUserId":"'+$("#toUserId").val()+'","contentText":"'+$("#contentText").val()+'"}');
console.log('{"toUserId":"'+$("#toUserId").val()+'","contentText":"'+$("#contentText").val()+'"}');
}
</script>
<body>
<p>【socket开启者的ID信息】:<div><input id="userId" name="userId" type="text" value="10"></div>
<p>【客户端向服务器发送的内容】:<div><input id="toUserId" name="toUserId" type="text" value="20">
<input id="contentText" name="contentText" type="text" value="hello websocket"></div>
<p>【操作】:<div><a onclick="openSocket()">开启socket</a></div>
<p>【操作】:<div><a onclick="sendMessage()">发送消息</a></div>
</body>
</html>