1. WebSocket是什么?
websocket是一种网络协议,其于2011年成为了国际标准(rfc6455)。
websocket解决了HTTP只能单向通信的问题。
比如我们需要服务器能定时向客户端传输一份数据,因为HTTP协议做不到服务器主动向客户端推送消息,所以需要client定时到服务器轮询,服务器收到请求后,将客户端需要的数据返回给客户端。这样轮询效率是低下的,并且浪费了资源。
websocket可以解决这样的问题,服务器可以主动向客户端推送消息,客户端也可以向服务器发送消息,实现了全双工。
2. WebSocket如何工作?
websocket是建立于http之上的,当客户端与服务器端建立了websocket连接后,这个连接将一直存在。
websocket工作可以分为两部分:建立连接和发送数据
2.1 建立连接
2.1.1 建立过程
当建立websocket连接的时候,client会先发送一个upgrade http报文到server,然后server再对此进行回复,从而完成一次握手过程。
client发送的数据包是类似这个样子的:
GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Origin: http://example.com
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Version: 13 |
server返回的数据包大概是这个样子:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Protocol: chat |
1)第一行为遵循Request-Line(rfc2616)格式,首先是method,为“GET”,紧跟着Request-URI为“/chat”,“/chat”的是websocket的服务端点(endpoint),最后是http版本号。
2)Host:host是http协议要求必须带的,host指明了client要访问的具体资源,比如一个server(一个IP),部署了多个域名,那么当client访问其中一个域名的时候,就可以通过host进行区分。如果server收到一个client请求,但是没有host,server会返回错误(400)。
3)Upgrade:当client想通过其他协议与server通信的话,就需要带upgrade,服务器统一协议升级后会回复101(switching protocol),并且在response中也会带上upgrade
4)connection: 表名client和server对于长连接如何处理,如果是keep-alive,那么就会保持,如果是close,那么在本次通信完成之后要将连接断开。
5)Sec-WebSocket-Key:这个是为了client和server之间相互验证用的。当server收到Sec-WebSocket-Key之后,它会将这个key链接加上Globally Unique Identifier(GUID)形成一个新的字符串(base64),发送回client(Sec-WebSocket-Accept)。client回进行检查,如果发现收到的不是期望的值,那么websocket的连接就建立不成功。
6)Origin:origin头只包含协议和域名。一般用于CORS跨域请求中
7)Sec-WebSocket-Protocol:指明客户端可以接受那些子协议,可以填写多个,服务器会从中选择一个。
8)Sec-WebSocket-Version:websocket版本
9)Sec-WebSocket-Extensions:websocket扩展头,用于协商本次连接要使用的 WebSocket 扩展:客户端发送支持的扩展,服务器通过返回相同的首部确认自己支持一个或多个扩展,具体扩展内容和含义可以参考(rfc7692)
10)Sec-WebSocket-Accept:服务端的回复,意思见Sec-WebSocket-Key
根据协议,在handshake过程中,客户端和服务器都会有很多的检查,比如服务器会检查http协议版本是否大于1.1并且是GET请求等,这些一般对于普通开发者来说,都是不需要关注的。
2.1.2 wireshark抓包
wireshark抓包看了下,第一个包是客户端发送的:
?可以看到,我实际抓的包中还有cookie。
下一个是服务端返回的报文:
?相对简单好多。
2.2?发送数据
handshake成功之后,客户端和服务器端就可以互相发送报文了。
2.2.1 报文结构
报文结构如下:
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len | Extended payload length |
|I|S|S|S| (4) |A| (7) | (16/64) |
|N|V|V|V| |S| | (if payload len==126/127) |
| |1|2|3| |K| | |
+-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
| Extended payload length continued, if payload len == 127 |
+ - - - - - - - - - - - - - - - +-------------------------------+
| |Masking-key, if MASK set to 1 |
+-------------------------------+-------------------------------+
| Masking-key (continued) | Payload Data |
+-------------------------------- - - - - - - - - - - - - - - - +
: Payload Data continued ... :
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
| Payload Data continued ... |
+---------------------------------------------------------------+
可以看下各个字段都是什么含义:
- FIN: 1bit,用来指示这个数据包是不是一条消息的最后一个包
- RSV1,RSV2,RSV3:3个保留bit
- opcode:操作码,用于表示“payload data”字段的含义:
- 0x0:说明这是一个continuous frame,
- 0x1:说明是文本数据
- 0x2:说明是二进制数据
- 0x3-7:保留
- 0x8:连接断开
- 0x9:表明是一个ping包
- 0xA:表明是一个pong包
- 0xB-F:保留
- Mask:表明payload data是否是mask的,如果设置为1,那么“Masking-kay”字段要有值
- payload len:“payload data”的长度,8bit。又分成了3种情况:
- 如果值为 0-125,那么就表示负载数据的长度;
- 如果是 126,那么接下来的 2 个字节以16 位的无符号整形作为负载数据的长度(无符号整型);
- 如果是 127,那么接下来的 8 个字节作为负载数据的长度(无符号整型)。
- Masking-key:如果“mask”值为0,那么masking-key的长度也是0,否则为4字节。从客户端发送到服务器的数据需要通过masking-key计算掩码后发送。mask算法是公开的,但是我没有细究。
- payload data:数据包中的实际数据。
2.2.2 发送报文
发送报文的时候,需要有以下条件:
- websocket连接处于open状态
- 将要发送的数据封装与websocket数据包中,一个放不下就放在多个包中
- 如果是客户端发包,payload data必须是mask的
- 如果协商的时候有扩展,需要应用这些扩展
2.2.2 关闭连接
一般来说,连接的关闭应该是服务器端发起。
服务器先发送一个关闭的控制包,客户端收到后会返回一个关闭的控制包。当两端都发送和收到 关闭连接的控制包之后,websocket连接就被关闭了。
本来我也想wireshark抓包,看下data报文的样子,但是没有抓到,或者是抓到了我不认识?我wireshark用http过滤没有发现websocket报文,后来用websocket直接过滤也不行。不知道哪个地方弄的不对,以后有时间了再研究。
3. WebSocket实例1
环境是idea+jdk8+springboot2.3.12
功能很简单,客户端启动后和服务端建立websocket连接,客户端给服务端发送消息,服务端收到后,通过websocket给客户端回复一个消息。
pom.xml文件如下:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.3.12.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.example.jpademo</groupId>
<artifactId>demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>demo</name>
<description>Demo project for Spring Boot</description>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<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>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
|
3.1 服务端编写
先看服务端。服务端websocket编写有几种方式,我选择是通过config方式,也可以通过注解方式
1. 先编写websocket handler
package com.example.jpademo.demo.handler;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;
import java.io.IOException;
import java.time.LocalDateTime;
import java.util.concurrent.ConcurrentHashMap;
/**
* ws消息处理类
*/
@Component
@Slf4j
public class MyWsHandler extends TextWebSocketHandler {
//Logger log = LoggerFactory.getLogger(MyWsHandler.class);
public static ConcurrentHashMap<String, WebSocketSession> SESSION_POOL = new ConcurrentHashMap<>();
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
log.info("建立ws连接");
System.out.println("after web socker connection is established");
SESSION_POOL.put(session.getId(),session);
}
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
log.info("发送文本消息");
// 获得客户端传来的消息
String payload = message.getPayload();
payload = payload + " : all well";
log.info("server 接收到消息 " + payload);
session.sendMessage(new TextMessage("server 发送给的消息 " + payload + ",发送时间:" + LocalDateTime.now().toString()));
}
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
log.info("关闭ws连接");
if (session != null) {
try {
// 关闭连接
session.close();
} catch (IOException e) {
// todo: 关闭出现异常处理
log.info(e.getMessage());
}
}
SESSION_POOL.remove(session.getId());
}
@Override
public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
log.error("异常处理");
WsSessionManager.removeAndClose(session.getId());
}
} |
websocket handler要继承TextWebSocketHandler类,当然也可以继承AbstractWebSocketHandler类型,然后override几个方法,通过方法的名字,比较容易看出这些方法都是做什么的:
- afterConnectionEstablished:建立websocket成功后被触发
- handleTextMessage:处理对端发送过来的websocket消息
- afterConnectionClosed:websocket连接关闭后触发
- handleTransportError:传输出问题触发
代码中的SESSION_POOL用于存放建立的session。当一个新websocket session建立后,就将这个session放到pool中,当一个websocket session被关闭后,就从这个pool删除。在例子中,这个pool其实作用不大,因为服务端在收到客户端消息后,直接就回复了消息。但是对于其他情况,保存session就显得有必要了。
2. 注册websocket
package com.example.jpademo.demo.config;
import com.example.jpademo.demo.handler.MyWsHandler;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
@Configuration
@EnableWebSocket
public class WebSocketConfig implements WebSocketConfigurer {
@Autowired
private MyWsHandler myWsHandler;
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
System.out.println("hello web socket");
registry.addHandler(myWsHandler, "/wsok").setAllowedOrigins("*");
}
} |
注册的关键是registry的addHandler方法,这个方法会将"/wsok"(暴露给客户端的请求路径)和第一步创建的MyWsHandler进行绑定。
3.2?客户端编写
这个是前端代码,网上找的:
<!DOCTYPE html> <html> ? ? <head> ? ? ? ? <meta charset="UTF-8"> ? ? ? ? <title></title> ? ? </head> ? ? <body> ? ? ? ? websocket Demo---- user000 <br /> ? ? ? ? <input id="text" type="text" />? ? ? ? ? <button οnclick="send()"> Send </button> ?? ? ? ? ? <button ? οnclick="closeWebSocket()"> Close </button> ? ? ? ? <div id="message"> ? </div> ? ? ? ?? ? ? <script type="text/javascript"> ? ? ?//判断当前浏览器是否支持WebSocket ? ? ? if('WebSocket' in window){ ? ? ? ? ? //websocket = new WebSocket("ws://localhost:8080/Demo/websocketTest/user000"); ?? ??? ? ?websocket = new WebSocket("ws://localhost:8080//wsok"); ? ? ? ? ? console.log("link success") ? ? ? }else{ ? ? ? ? ? alert('Not support websocket') ? ? ? } ? ? ?? ? ? ? //连接发生错误的回调方法 ? ? ? websocket.onerror = function(){ ? ? ? ? ? setMessageInnerHTML("error"); ? ? ? }; ? ? ? ? ? ? ? //连接成功建立的回调方法 ? ? ? websocket.onopen = function(event){ ? ? ? ? ? setMessageInnerHTML("open"); ? ? ? } ? ? ? ?console.log("-----") ? ? ? //接收到消息的回调方法 ? ? ? 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> ? ? ? ?? ? ? </body> </html> |
发送效果如下:
?4. WebSocket实例2
除了上述方法,也可以用原生注解的方法实现websocket
pom.xml文件不变
4.1 服务端编写
1. bean注册
package com.example.jpademo.demo.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;
@Configuration
@EnableWebSocket
public class WebSocketOriginConfig {
@Bean
public ServerEndpointExporter serverEndpoint() {
return new ServerEndpointExporter();
}
} |
2. 编写handler
package com.example.jpademo.demo.handler;
import org.springframework.stereotype.Component;
import org.springframework.web.socket.WebSocketSession;
import javax.websocket.OnClose;
import javax.websocket.OnMessage;
import javax.websocket.OnOpen;
import javax.websocket.Session;
import javax.websocket.server.ServerEndpoint;
import java.io.IOException;
import java.util.concurrent.ConcurrentHashMap;
@Component
@ServerEndpoint("/hellows")
public class WsServerEndpoint {
public static ConcurrentHashMap<String, Session> SESSION_POOL = new ConcurrentHashMap<>();
/**
* 连接成功
*
* @param session
*/
@OnOpen
public void onOpen(Session session) {
System.out.println("连接成功");
SESSION_POOL.put(session.getId(), session);
}
/**
* 连接关闭
*
* @param session
*/
@OnClose
public void onClose(Session session) {
System.out.println("连接关闭");
}
/**
* 接收到消息
*
* @param text
*/
@OnMessage
public String onMsg(Session session, String text) throws IOException {
for(Session session1 : SESSION_POOL.values()){
session1.getBasicRemote().sendText("good morning");
}
//SESSION_POOL.get(session.getId()).getBasicRemote().sendText("good morning");
return "servet 发送:" + text;
}
}
|
?与例子1不同的是,此处用注解实现:
- @ServerEndpoint: 这个注解可以让 spring boot 知道你暴露出去的 ws 应用的路径,与registry.addHandler(myWsHandler, "/wsok").setAllowedOrigins("*");类似
- @OnOpen:当 websocket 建立连接成功后会触发这个注解方法,它有一个 Session 参数,可以在方法中将这个session保存,以供后续使用
- @OnClose:当 websocket 连接断开后会触发这个注解修方法,注意它有一个 Session 参数,可以将这个session从pool中删除
- @OnMessage:当客户端发送消息到服务端时,会触发这个注解方法,?String 参数是客户端传入的值
- @OnError:当 websocket 建立连接时出现异常会触发这个注解修饰的方法,它有一个 Session 参数,这个我在例子中没有写
4.2 客户端编写
客户端与例子1一样,只是改下websocket的url为:
websocket = new WebSocket("ws://localhost:8080//hellows"); |
发送效果如下:
?5?参考文档
参考了网上的文章如下,他们都写的比我好,但是自己做一遍能加深印象
【websocket】spring boot 集成 websocket 的四种方式 - 云+社区 - 腾讯云这个配置类很简单,通过这个配置 spring boot 才能去扫描后面的关于 websocket 的注解https://cloud.tencent.com/developer/article/1530872 万字长文,一篇吃透WebSocket:概念、原理、易错常识、动手实践 - 码农教程本文章向大家介绍万字长文,一篇吃透WebSocket:概念、原理、易错常识、动手实践,主要包括万字长文,一篇吃透WebSocket:概念、原理、易错常识、动手实践使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。http://www.manongjc.com/detail/25-awrtfwjcoejxpjh.html
https://segmentfault.com/a/1190000018214719https://segmentfault.com/a/1190000018214719WebSocket协议分析 - 功夫Panda - 博客园内容不断更新,目前包括协议中握手和数据帧的分析 1.1 背景 1.2 协议概览 协议包含两部分:握手,数据传输。 客户端的握手如下:GET /chat HTTP/1.1Host: server.exahttps://www.cnblogs.com/caosiyang/archive/2012/08/14/2637721.html还有几篇文章,找不到了,也表示感谢!
?
|