携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第8天,点击查看活动详情
WebSocket
1. WebSocket介绍
1.1. 什么是websocket
- WebSocket是HTML5下一种新的协议(websocket协议本质上是一个基于tcp的协议)
- 它实现了浏览器与服务器全双工通信,能更好的节省服务器资源和带宽并达到实时通讯的目的
- Websocket是一个持久化的协议
1.2. websocket的原理
websocket约定了一个通信的规范,通过一个握手的机制,客户端和服务器之间能建立一个类似tcp的连接,从而方便它们之间的通信
在websocket出现之前,web交互一般是基于http协议的短连接或者长连接 websocket是一种全新的协议,不属于http无状态协议,协议名为"ws"
1.3. WebSocket与http
1.3.1. 相同点
- 都是基于tcp的,都是可靠性传输协议
- 都是应用层协议
1.3.2. 不同点
| WebSocket | http |
|---|---|
| 双向通信协议,模拟Socket协议,可以双向发送或接受信息 | HTTP是单向的 |
| 需要浏览器和服务器握手进行建立连接的 | 浏览器发起向服务器的连接,服务器预先并不知道这个连接 |
1.3.3. 联系
WebSocket在建立握手时,数据是通过HTTP传输的。但是建立之后,在真正传输时候是不需要HTTP协议的
1.3.4. 总体过程
- 首先,客户端发起http请求,经过3次握手后,建立起TCP连接;http请求里存放WebSocket支持的版本号等信息,如:Upgrade、Connection、WebSocket-Version等;
- 然后,服务器收到客户端的握手请求后,同样采用HTTP协议回馈数据;
- 最后,客户端收到连接成功的消息后,开始借助于TCP传输信道进行全双工通信。
1.4. websocket解决的问题
1.4.1. http存在的问题
- http是一种无状态协议,每当一次会话完成后,服务端都不知道下一次的客户端是谁,需要每次知道对方是谁,才进行相应的响应,因此本身对于实时通讯就是一种极大的障碍
- http协议采用一次请求,一次响应,每次请求和响应就携带有大量的header头,对于实时通讯来说,解析请求头也是需要一定的时间,因此,效率也更低下
- 最重要的是,需要客户端主动发,服务端被动发,也就是一次请求,一次响应,不能实现主动发送
1.4.2. long poll(长轮询)
对于以上情况就出现了http解决的第一个方法——长轮询
基于http的特性,简单点说,就是客户端发起长轮询,如果服务端的数据没有发生变更,会 hold 住请求,直到服务端的数据发生变化,或者等待一定时间超时才会返回。返回后,客户端又会立即再次发起下一次长轮询 优点是解决了http不能实时更新的弊端,因为这个时间很短,发起请求即处理请求返回响应,实现了“伪·长连接”
长轮询存在的问题:
- 推送延迟。服务端数据发生变更后,长轮询结束,立刻返回响应给客户端。
- 服务端压力。长轮询的间隔期一般很长,例如 30s、60s,并且服务端 hold 住连接不会消耗太多服务端资源。
1.4.3. Ajax轮询
基于http的特性,简单点说,就是规定每隔一段时间就由客户端发起一次请求,查询有没有新消息,如果有,就返回,如果没有等待相同的时间间隔再次询问
优点是解决了http不能实时更新的弊端,因为这个时间很短,发起请求即处理请求返回响应,把这个过程放大n倍,本质上还是request = response
总的来看,Ajax轮询存在的问题:
- 推送延迟。
- 服务端压力。配置一般不会发生变化,频繁的轮询会给服务端造成很大的压力。
- 推送延迟和服务端压力无法中和。降低轮询的间隔,延迟降低,压力增加;增加轮询的间隔,压力降低,延迟增高
1.4.4. websocket的改进
一旦WebSocket连接建立后,后续数据都以帧序列的形式传输。在客户端断开WebSocket连接或Server端中断连接前,不需要客户端和服务端重新发起连接请求。在海量并发及客户端与服务器交互负载流量大的情况下,极大的节省了网络带宽资源的消耗,有明显的性能优势,且客户端发送和接受消息是在同一个持久连接上发起,实现了“真·长链接”,实时性优势明显。
WebSocket有以下特点:
- 是真正的全双工方式,建立连接后客户端与服务器端是完全平等的,可以互相主动请求。而HTTP长连接基于HTTP,是传统的客户端对服务器发起请求的模式。
- HTTP长连接中,每次数据交换除了真正的数据部分外,服务器和客户端还要大量交换HTTP header,信息交换效率很低。Websocket协议通过第一个request建立了TCP连接之后,之后交换的数据都不需要发送 HTTP header就能交换数据,这显然和原有的HTTP协议有区别所以它需要对服务器和客户端都进行升级才能实现(主流浏览器都已支持HTML5)
2. 代码实践
2.1. 通过注解构建
由于我们写的是一个SpringBoot项目,所以我们需要引入依赖,并且数据从MySQL数据库中读取,需求就是每隔3秒钟刷新一下前端获取到的数据(这个demo里面是一个在线人数,可以理解为b站实时显示观看视频的人数)
依赖
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>2.4.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<version>2.4.0</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.73</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.24</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.15</version>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.1.3</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
<version>2.4.0</version>
</dependency>
</dependencies>
-
引入WebSocket依赖
<!-- SpringBoot Websocket --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId> <version>2.4.0</version> </dependency> -
配置WebSocket
@Configuration public class WebSocketConfig { /** * 这个bean的注册,用于扫描带有@ServerEndpoint的注解成为websocket ,如果你使用外置的tomcat就 * 不需要该配置文件 */ @Bean public ServerEndpointExporter serverEndpointExporter() { return new ServerEndpointExporter(); } } -
编写业务代码
由于我自己写的就是通过Mybatis读取数据库中一张表的一个在线数据,比较简单,就不写代码了
-
WebSocket业务类
import com.alibaba.fastjson.JSON; import com.he.websocket.service.IndexService; import lombok.Data; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.stereotype.Component; import javax.annotation.PostConstruct; import javax.annotation.Resource; import javax.websocket.*; import javax.websocket.server.ServerEndpoint; import java.io.IOException; import java.util.concurrent.CopyOnWriteArraySet; @ServerEndpoint(value = "/webSocket") //主要是将目前的类定义成一个websocket服务器端, 注解的值将被用于监听用户连接的终端访问URL地址,客户端可以通过这个URL来连接到WebSocket服务器端 @Component @EnableScheduling// cron定时任务 @Data public class WebSocket { private static final Logger logger = LoggerFactory.getLogger(WebSocket.class); /** * 静态变量,用来记录当前在线连接数。应该把它设计成线程安全的。 */ private static int onlineCount = 0; /** * concurrent包的线程安全Set,用来存放每个客户端对应的MyWebSocket对象。 */ private static CopyOnWriteArraySet<WebSocket> webSocketSet = new CopyOnWriteArraySet<>(); /** * 与某个客户端的连接会话,需要通过它来给客户端发送数据 */ private Session session; public static CopyOnWriteArraySet<WebSocket> getWebSocketSet() { return webSocketSet; } public static void setWebSocketSet(CopyOnWriteArraySet<WebSocket> webSocketSet) { WebSocket.webSocketSet = webSocketSet; } /** * 从数据库查询相关数据信息,可以根据实际业务场景进行修改 */ @Resource private IndexService indexService; private static IndexService indexServiceMapper; @PostConstruct public void init() { WebSocket.indexServiceMapper = this.indexService; } /** * 连接建立成功调用的方法 * * @param session 会话 */ @OnOpen public void onOpen(Session session) throws Exception { this.session = session; webSocketSet.add(this); //查询当前在线人数 long nowOnline = indexServiceMapper.nowOnline(); this.sendMessage(JSON.toJSONString(nowOnline)); } /** * 收到客户端消息后调用的方法 * * @param message 客户端发送过来的消息 */ @OnMessage public void onMessage(String message, Session session) throws IOException { logger.info("参数信息:{}", message); //群发消息 for (WebSocket item : webSocketSet) { try { item.sendMessage(JSON.toJSONString(message)); } catch (IOException e) { e.printStackTrace(); } } } /** * 连接关闭调用的方法 */ @OnClose public void onClose() { webSocketSet.remove(this); if (session != null) { try { session.close(); } catch (IOException e) { e.printStackTrace(); } } } /** * 发生错误时调用 * * @param session 会话 * @param error 错误信息 */ @OnError public void onError(Session session, Throwable error) { logger.error("连接异常!"); error.printStackTrace(); } /** * 发送信息 * * @param message 消息 */ public void sendMessage(String message) throws IOException { this.session.getBasicRemote().sendText(message); } /** * 自定义消息推送、可群发、单发 * * @param message 消息 */ public static void sendInfo(String message) throws IOException { logger.info("信息:{}", message); for (WebSocket item : webSocketSet) { item.sendMessage(message); } } }这里有个很重要的点要说一下,我上面的代码是cv别的大佬的,但是我阅读代码的时候很好奇,为什么需要两个IndexService,并且一个还是静态的,我就把static的注释掉,结果发现报错了,为什么呢?
Spring管理采用单例模式(singleton),而 WebSocket 是多对象的,即每个客户端对应后台的一个 WebSocket 对象,也可以理解成 new 了一个 WebSocket,这样当然是不能获得自动注入的对象了,因为这两者刚好冲突。
@Autowired 注解注入对象操作是在启动时执行的,而不是在使用时,而 WebSocket 是只有连接使用时才实例化对象,且有多个连接就有多个对象。
所以我们可以得出结论,这个 Service 根本就没有注入到 WebSocket 当中。
如何解决?
-
就是使用static
private static OrderService orderService; @Autowired public void setOrderService(OrderService service) { WebSocketServer.orderService = orderService; }或者上面所写的,再使用
@PostConstruct,如果不知道这个注解的,可以自行百度学习一下。 -
动态的从 Spring 容器中取出 OrderService
/** * 获取spring容器 * 当一个类实现了这个接口ApplicationContextAware之后,这个类就可以方便获得ApplicationContext中的所有bean。 * 换句话说,这个类可以直接获取spring配置文件中所有有引用到的bean对象 * 前提条件需作为一个普通的bean在spring的配置文件中进行注册 */ public class SpringCtxUtils implements ApplicationContextAware { private static ApplicationContext applicationContext; @Override public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { SpringCtxUtils.applicationContext = applicationContext; } public static <T> T getBean(Class<T> type) { try { return applicationContext.getBean(type); } catch (NoUniqueBeanDefinitionException e) { //出现多个,选第一个 String beanName = applicationContext.getBeanNamesForType(type)[0]; return applicationContext.getBean(beanName, type); } } public static <T> T getBean(String beanName, Class<T> type) { return applicationContext.getBean(beanName, type); } }在 WebSocketServer 中调用:
private OrderService orderService = SpringCtxUtils.getBean(OrderService.class);
几个注解:
@ServerEndpoint(value = "/webSocket"):目前的类定义成一个websocket服务器端, 注解的值将被用于监听用户连接的终端访问URL地址,客户端可以通过这个URL来连接到WebSocket服务器端@OnOpen:连接建立成功后第一次调用的方法@OnMessage:前端发送信息后,客户端收到消息后调用的方法@OnClose:前端的网页关闭后调用的方法@OnError:发生错误时调用
-
-
要定时刷新,那定时函数就需要了
@Slf4j @Component public class IndexScheduled { @Autowired private IndexMapper indexMapper; /** * 每3秒执行一次 */ @Scheduled(cron = "0/3 * * * * ? ") //我这里暂时不需要运行这条定时任务,所以将注解注释了,朋友们运行时记得放开注释啊 public void nowOnline() { System.err.println("********* 首页定时任务执行 **************"); CopyOnWriteArraySet<WebSocket> webSocketSet = WebSocket.getWebSocketSet(); long nowOnline = indexMapper.getPV(); webSocketSet.forEach(c -> { try { c.sendMessage(JSON.toJSONString(nowOnline)); } catch (IOException e) { e.printStackTrace(); } }); System.err.println("/n 首页定时任务完成......."); } } -
主方法
@SpringBootApplication @EnableScheduling// cron定时任务 public class WebSocketMainApplication { public static void main(String[] args) { SpringApplication.run(WebSocketMainApplication.class, args); } }这里主要是想要注意一下
@EnableScheduling开启定时检测 -
前端页面
基本阅览器自带websocket了,所以直接搞
<!DOCTYPE html> <html lang="en" xmlns:th="http://www.w3.org/1999/xhtml"> <head> <meta charset="UTF-8"> <title>Title</title> <script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.6.0/jquery.js"></script> </head> <body class="gray-bg"> <div class="online"> <span class="online">测试在线人数:<span id="online"></span> 人</span> </div> <script th:inline="javascript"> let websocket = null; let host = document.location.host; //判断当前浏览器是否支持WebSocket if ('WebSocket' in window) { //连接WebSocket节点 websocket = new WebSocket("ws://" + host + "/webSocket"); } else { alert('浏览器不支持webSocket'); } //连接发生错误的回调方法 websocket.onerror = function () { setMessageInnerHTML("error"); }; //连接成功建立的回调方法 websocket.onopen = function (event) { setMessageInnerHTML("open"); }; //接收到消息的回调方法 websocket.onmessage = function (event) { let data = event.data; console.log("后端传递的数据:" + data); //将后端传递的数据渲染至页面 $("#online").html(data); }; //连接关闭的回调方法 websocket.onclose = function () { setMessageInnerHTML("close"); }; //监听窗口关闭事件,当窗口关闭时,主动去关闭websocket连接,防止连接还没断开就关闭窗口,server端会抛异常。 window.onbeforeunload = function () { websocket.close(); }; //将消息显示在网页上 function setMessageInnerHTML(innerHTML) { }; //关闭连接 function closeWebSocket() { websocket.close(); }; //发送消息 function send() { let message = document.getElementById('text').value; websocket.send(message); }; </script> </body>