前言:最近看了几天的WebSocket,从以前的只闻其名,到现在也算是有一点点的了解了。所以就准备用博客记录一下自己的学习过程,希望也能帮助其它学习的人,因为我本人学习的过程中也是参考了很多其它人的博文。 这里主要是想了解一下WebSocket传输的协议帧,并使用java来模拟一个WebSocket客户端向服务端发送、接收数据。
注:对于网络协议的学习,通过自己编程实现一些简单的功能是一种很有效的方式。例如使用Socket去下载网络图片或者访问接口等,你会遇到很多问题,解决它们之后,也会收获很多东西!
在介绍WebSocket之前先来简单说一下HTTP吧,因为WebSocket本身就是为了补充或者取代一部分HTTP的功能。在这之前的几天,我对WebSocket的理解也只是浮于表面,只是听过名字,知道它是全双工的工作特点。但是经过这几天的了解以及实际的编程操作,我对于它的协议有了一定的认识了,对于为什么是全双工有了一个较为深入的理解。所以,如果你对WebSocket不是很了解的话,推荐直接拉到文章最后,先阅读参考资料,然后了解一波内容之后,再过来阅读我的这篇博客,相信你会收获很多的!
全双工和半双工:
全双工:全双工(Full Duplex)是允许数据在两个方向上同时传输。
半双工:半双工(Half Duplex)是允许数据在两个方向上传输,但是同一个时间段内只允许一个方向上传输。
这里半双工我们可以类比我们熟知的HTTP协议,它的工作方式就是类似于半双工(但是,我们也应该明白,它还不如半双工呢!)。并且,它是只允许客户端主动请求,而服务器端被动响应,即所谓的请求响应模式。显然这种模式是有一种缺陷的,对于某些功能的实现是很麻烦的!
例如,如果需要在客户端上维持某个数据的实时性,那么该如何实现呢? 如果这个数据发生了改变,服务器并不能主动通知到客户端,因此需要客户端自己去服务器上拉取数据。但是请求一次,只能知道当前数据是否更新,如果数据早就更新了呢?因此需要客户端不断的请求服务器,询问数据是否改变,这种方式即称为轮询(通常是ajax轮询)。轮询是一种很低效的方式,并且它只是伪实时的,因为轮询需要间隔一定的时间,如果时间长了数据的实时性就低了,时间短了,服务器的压力也很大的(客户端也会有一定的压力)。这里的伪实时指的是假如轮询间隔时间为t,那么数据更新以后到客户端获取到数据的时间间隔即为0-t。
再举一个很常见的例子,如果开发web的话,有时候出了问题,我们通常会刷新一下,这就是HTTP协议的特性限制的,你不刷新的话,是无法得到响应的。
那么有什么解决办法呢? 既然有需求,一定会有解决办法的!Http本身是基于请求响应的,但是它的底层是TCP,如果你有Socket编程经验的话,应该知道只要连接建立以后,任何一端都可以同时向对方发送数据,这本身就是一种全双工的工作方式。
注: 套接字是通信的基石,是支持TCP/IP协议的通信的基本操作单元。可以将套接字看作不同主机间的进程进行双间通信的端点,它构成了单个主机内及整个网络间的编程界面。
有一种技术应用称为RSS(简易信息聚合),它算是Web1.0时代的东西了,也算是客户端拉取数据的应用 之一了。现在,这种技术已经使用的不多了,普通用户基本没有接触过了。因为,现在的信息的更新已经可以推送到你面前了(手机上的主动推送信息),那你为什么还要去拉取呢?
WebSocket的百度百科定义:
WebSocket是一种在单个TCP连接上进行全双工通信的协议。WebSocket通信协议于2011年被IETF定为标准RFC6455,并由RFC7936补充规范。WebSocket API也被W3C定为标准。WebSocket使得客户端和服务器之间的数据交换变得更加简单,允许服务端主动向客户端推送数据。在WebSocket API中,浏览器和服务器只需要完成一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输。
因此当一个WebSocket连接建立以后,通信的两端就可以以全双工的模式进行通信了。这样对于上面那种需要维持数据实时性的需求,就可以抛弃掉轮询这种方式,转而使用WebSocket解决,并且大大提高了实时性,而且只需要维持一个WebSocket连接即可,这样也减轻了网络压力。
上面这幅图很好的展示了AJAX轮询和WebSocket之间的区别,并且你可以发现WebSocket是需要使用HTTP去建立连接的,这一点很重要,因为待会的代码实战需要用到它!
上面算是一个简单的了解了,相信你已经对WebSocket有了一个认识了,下面让我们进入编程实战的部分吧!Talk is cheap, show me your code!
WebSocket是一个应用层协议,并且它是建立在TCP之上的,具体可以看下图即可知道它们的关系了。因此,这里的编程目标是使用Socket模拟一个WebSocket客户端与服务器进行通信。
这幅图很有意思,如果你看完了文章,再回头看一下它,会加深你的理解的。
WebSocket连接的建立及数据传输:
首先会发送一个HTTP报文,然后会响应一个HTTP报文,接下来会传输WebSocket协议的数据帧。
可以这样来理解,HTTP是建立在TCP上的协议,HTTP协议本身是TCP的数据部分(首部+实体),然后WebSocket是和HTTP平级的另一种应用层协议,它的协议数据帧部分也是TCP的数据部分。所以,理解上面,这个步骤之后,我们就可以去着手准备模拟的工作。
非常重要的部分:
模拟工作主要就是仿照上面这个步骤,首先建立一个TCP连接(使用java的Socket类),然后发送一个HTTP请求和服务端建立WebSocket连接,然后接下来发送WebSocket数据帧(控制部分+数据部分),最后关闭WebSocket连接,关闭Socket连接(即TCP连接)。
我们首先需要明白建立一个WebSocket连接的具体步骤,这里我们换一种方式了解一下!
因为我的目标是模拟WebSocket客户端,所以我首先需要有一个WebSocket服务器和客户端,这里提供一个简单的demo工程。
注:这个的demo是基于SpringBoot的工程。
1.工程结构(这里的错误可以忽略,eclipse手动建立的springboot项目似乎不怎么支持。)
2.这里是需要导入的依赖:
<dependencies>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-webartifactId>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-testartifactId>
<scope>testscope>
<exclusions>
<exclusion>
<groupId>org.junit.vintagegroupId>
<artifactId>junit-vintage-engineartifactId>
exclusion>
exclusions>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-validationartifactId>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-websocketartifactId>
dependency>
dependencies>
package websocket_learn;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class WebApplication {
public static void main(String[] args) {
SpringApplication.run(WebApplication.class, args);
}
}
package websocket_learn.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 {
/**
* 注入websocket的bean,不过这种直接创建对象的方式,
* 应该不是一个效率好的方式,不过这里作为演示是足够了。
* */
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
}
package websocket_learn.server;
import java.io.IOException;
import javax.websocket.OnClose;
import javax.websocket.OnMessage;
import javax.websocket.OnOpen;
import javax.websocket.server.ServerEndpoint;
import javax.websocket.Session;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
@Component
@ServerEndpoint("/server/{userId}")
public class WebSocketServer {
static Logger log = LoggerFactory.getLogger(WebSocketServer.class);
/**
* 连接成功
*
* @param session
*/
@OnOpen
public void onOpen(Session session) {
System.out.println("连接成功");
}
/**
* 连接关闭
*
* @param session
*/
@OnClose
public void onClose(Session session) {
System.out.println("连接关闭");
}
/**
* 接收到消息
*
* @param text
*/
@OnMessage
public void onMsg(Session session, String msg) throws IOException {
// 接收客户端发送的消息
log.info("WebSocket Client: {}", msg);
// 给客户端发送一条消息
session.getBasicRemote().sendText("WebSocket Server: " + "Time and tide wait for no man.");
}
}
这里使用的是Chrome浏览器的Console。
依次执行上面代码,即完成了一个WebSocket连接的建立–>发送(接收)–>关闭。
上面已经执行了一个完整的WebSocket流程,并且我们通过上面已经了解到了大概的步骤了,但是由于我们看不到具体的细节,因此还是无法进行模拟。为了了解到这些具体的细节,我们需要针对一个真实的协议通信进行分析,所以这里需要抓取网络数据包。这里介绍两个抓包工具:Fiddler(HTTP)、WireShark(TCP)。 这里的推荐使用WireShark,因为Fiddler是偏向于应用层了(据说高版本是支持WebSocket的,但是我可能没有设置好,所以主要还是使用了WireShark)。
让我们首先打开Fiddler和WireShark,再次运行下面的代码,这里添加了一个输出接收到的信息的函数,刚才忘记了。
然后打开Fiddler,可以发现居然抓到了一个HTTP的包!但是,它之抓到了建立WebSocket连接的HTTP包,之后的WebSocket数据帧就没有了。
HTTP请求报文
GET http://127.0.0.1:9000/server/1 HTTP/1.1
Host: 127.0.0.1:9000
Connection: Upgrade // 非常重要的字段
Pragma: no-cache
Cache-Control: no-cache
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36
Upgrade: websocket
Origin: chrome-search://local-ntp
Sec-WebSocket-Version: 13
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9
Sec-WebSocket-Key: q/rOgMV7pqwCKewaLidYTQ==
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits
HTTP响应报文
HTTP/1.1 101
Upgrade: websocket // 非常重要的字段
Connection: upgrade
Sec-WebSocket-Accept: YQCqLSE6C+J3G3YM9eQ7DSlDMtM=
Sec-WebSocket-Extensions: permessage-deflate;client_max_window_bits=15
Date: Thu, 31 Dec 2020 10:26:52 GMT
EndTime: 18:27:26.287
ReceivedBytes: 56
SentBytes: 44
注意这两个字段,这里的意思是:客户端请求连接升级为WebSocket协议,然后服务器响应消息提示客户端连接已经升级了。
Connection: Upgrade
Upgrade: websocket
补充:
我发现了,Fiddler确实是有WebSocket,但是由于这里数据是被压缩了,所以无法看到每一个到底是什么了。不过其实这样也足够了,我只需要按照顺序发送这些数据就行了,但是这样对于学习的理解不够友好了。
这里有一个坑,由于我是抓取的浏览器的包,但是它默认会对数据进行压缩(由下面这一行首部进行控制),但是我不知道这个压缩方法是什么,所以也就没有办法模拟数据了。因此模拟操作一度中断了,后来的解决办法是不抓取浏览器的包,转而使用一个网上下载的WebSocket客户端工具。其实,如果不压缩的话,那么传输的数据就是原始数据,客户端发送给服务器的需要进行掩码操作,服务器发送给客户端的是不需要掩码操作的,但这个是模拟成功后才了解到的。
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits
我这里查到了Fiddler高版本已经支持了WS协议,但是我这里没有找到,但是也没关系,我还有另一个更加强大的工具呢——WireShark。
测试
注意:它建立连接后会默认发送一条消息:
与服务器建立连接成功!(吾爱破解:skyxiaoyu. www.52pojie.cn)
这点我感觉不太好,虽然我的目的也是模拟这个软件的功能,实现一个简易的版本。
WireShark抓包截图:
注:
1.protocol为TCP的包可以忽略不看。
2.[MASKED]表示该包由客户端发送,服务器发送的都是没有masked的,这点很重要!
WebSocket也不是直接在Socket中传输的,它也是有固定结构的协议帧组成的,这样是为了避免TCP的一些问题,TCP只是提供了一条通路,具体传输数据的格式还是要自己设定。
所以,发送的数据需要以这种数据帧的形式发送。这里对于这个数据帧的解析,就不多介绍了,因为我也是参考的别人的资料,推荐你也去阅读。你可以在最后的参考资料中详细的了解到每一位的具体作用。
Masking-Key: 0 or 4 bytes,所有从客户端发往服务端的数据帧都已经与一个包含在这一帧中的32bit的掩码进行过了运算。为什么需要掩码?为了安全,但并不是为了防止数据泄密,而是为了防止早期版本的协议中存在的代理缓存污染攻击(proxy cache poisoning attacks)等问题。
这里这个第3行:Per-Message Compressed: True。表示是对消息进行压缩,我在这里被卡住了一段时间,所以我需要使用非浏览器的方式发送数据,来规避这一条。
客户端主动发送的WebSocket数据帧
注意:Mask: True 表示数据进行了掩码操作。
因为客户端的数据都需要进行一个掩码操作,所以以二进制显示的时候,后面那部分就是数据,但是它是显示不正常的。(Masked Data)
服务器端主动发送的WebSocket数据帧
注意:Mask: False 表示数据没有进行掩码操作。
服务器端发送的数据都是没有进行掩码操作的,因此你可以看到右下角那块数据是可以直接解码显示出来的。
服务端收到客户端消息后,响应关闭WebSocket连接的数据帧
建立WebSocket连接的HTTP请求和响应就直接发送即可,因为它们都说文本格式的。但是WebSocket的数据帧是二进制的,所以就直接发送截图下面的二进制数据。
例如最后客户端主动关闭WebSocket连接的数据帧是:
2字节控制信息 + 4字节掩码 = 6字节的数据,这个就直接仿照上面抓取的包来发送即可了。
closeOutput.write((byte)0x88);
closeOutput.write((byte)0x80);
closeOutput.write(mask);
好了,介绍了这么多了,那么就开始我们最后的代码吧。代码写了很多注释了,如果有问题,可以在评论区留言。
下面是模拟的代码了,完全就是仿照WireShark抓包的数据包格式,进行模拟的。但是,这里我注释了那行会导致数据压缩的首部。
package dragon;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
public class WebSocketClient {
public static void main(String[] args) throws Exception {
/**
* ws://127.0.0.1:9000/server/1
* */
// 首先建立一个TCP连接,底层是TCP连接,至于更加底层的部分,我们不需要考虑。
// Socket是对于TCP的一种封装,所以我们获取的数据其实只是TCP的数据部分(报文头部在这里是不可见的)
Socket client = new Socket("127.0.0.1", 9000);
// 获取输入输出流,HTTP或者是WebSocket对于Socket来说都是输入或者输出流
// 全部使用缓冲流,提高程序的性能。
InputStream input = new BufferedInputStream(client.getInputStream());
OutputStream output = new BufferedOutputStream(client.getOutputStream());
// 现在底层的网络连接已经具备了,即TCP连接已经建立,开始接下来的工作了。
// 建立一个HTTP连接,websocket连接是需要通过HTTP来建立的,所以需要由客户端主动发起请求
String requestMsg = "GET http://127.0.0.1:9000/server/1 HTTP/1.1\r\n" +
"Host: 127.0.0.1:9000\r\n" +
"Connection: Upgrade\r\n" +
"Pragma: no-cache\r\n" +
"Cache-Control: no-cache\r\n" +
"User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36\r\n" +
"Upgrade: websocket\r\n" +
"Origin: chrome-search://local-ntp\r\n" +
"Sec-WebSocket-Version: 13\r\n" +
"Accept-Encoding: gzip, deflate, br\r\n" +
"Accept-Language: zh-CN,zh;q=0.9\r\n" +
"Sec-WebSocket-Key: z0hM8Z8r+RJwx6rCn+mzqg==\r\n" /* +
"Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits\r\n" */
+ "\r\n"; // 刚开始忘记了这个最后的 \r\n,导致程序无响应
// 发送请求报文
output.write(requestMsg.getBytes(StandardCharsets.UTF_8));
// 刷新输出流
output.flush();
// 这里是一个粗略的处理,直接使用一个较大的数组读取所有的响应报文,然后直接输出
// 这个输出流,即响应报文,表示已经建立了WebSocket连接了,但是我们可以忽略它,因为连接已经建立了。
byte[] data = new byte[1024];
int len = input.read(data);
System.out.println("接收到的HTTP响应报文:");
System.out.println(new String(data, 0, len, StandardCharsets.UTF_8));
// 然后一个websocket连接就已经建立完毕了
// 接下来就和HTTP没有关系了,完全回归了TCP的范围了。现在是属于WebSocket的内容了。
// 当WebSocket连接建立时,服务器和客户端的通信形式是WebSocket数据帧
// 它和HTTP报文的区别在于,数据帧可以由双方各自发起,没有先后顺序之分。
// 而对于HTTP来说,必须由客户端主动发起,服务器被动响应,所以说它是基于请求响应式的。
// WebSocket数据帧构建。
// 数据帧包括开头的控制部分,以及接下来的负载部分(payload)
ByteArrayOutputStream header = new ByteArrayOutputStream();
// 这个和下面的形式一样,但是可以直观看到每一位的作用,但是使用不太方便。
// header.write((byte)0b1100_0001);
// header.write((byte)0b1010_0000);
// header.write((byte)0b0100_1100);
// header.write((byte)0b0110_1100);
// header.write((byte)0b0011_1111);
// header.write((byte)0b0000_1110);
// 起始2字节 控制信息,至于这个值具体是多少,我是参考抓包的数据,直接使用的。
// 因为这里的两字节其实是二进制形式的,具体的每一位都有不同的含义了。
header.write((byte)0x81);
header.write((byte)0x9f);
// 掩码4字节 自定义一个浪漫的掩码:1314520
byte[] mask = new byte[] {
(byte)0x13,
(byte)0x14,
(byte)0x52,
(byte)0x0
};
header.write(mask);
// payload
// 对发送数据和掩码进行操作
byte[] payload = "I love you yesterday and today!".getBytes(StandardCharsets.UTF_8);
ByteArrayOutputStream maskPayload = new ByteArrayOutputStream();
int count = 0; // 计数器
byte temp1, temp2;
for (byte b : payload) {
temp1 = mask[count];
temp2 = b;
// 这里是很复杂的一个操作,但是它是协议中规定好的,只需要这样写即可。
maskPayload.write((byte)((~temp1)&temp2) | (temp1&(~temp2)));
count++;
if (count == 4) {
count = 0; // 循环使用掩码
}
}
// 发送应用数据
output.write(header.toByteArray()); // websocket数据帧-->不包括数据体
output.write(maskPayload.toByteArray()); // websocket数据帧--> 应用数据部分
output.flush();
System.out.println("服务器端响应的数据为:");
// 接收数据: 2字节首部,剩下的是应用数据部分。
len = input.read(data);
int size = data[1]; // 第二字节的后7位表示长度,但是服务器发送的数据是没有进行掩码操作的,因此首位为0,可以直接使用(前提是payload长度 < 126)。
System.out.println(new String(Arrays.copyOfRange(data, len-size, len), StandardCharsets.UTF_8));
// 客户端主动关闭websocket连接,虽然可以直接关闭底层的socket连接,
// 但是这样会产生一个问题,它是属于一种异常的关闭
ByteArrayOutputStream closeOutput = new ByteArrayOutputStream();
closeOutput.write((byte)0x88);
closeOutput.write((byte)0x80);
closeOutput.write(mask);
output.write(closeOutput.toByteArray());
output.flush();
// 接收服务器最后的响应,即关闭连接的响应
len = input.read(data);
System.out.println("\nwebsocket关闭连接的最后报文,其本身形式是人不可读形式,所以应该以二进制形式打印,长度为:" + len + " 字节");
// 这里包括2字节的控制部分,以及2字节的payload,即最后的status code。
// 这个status code的意思是 Normal Closure 即正常关闭
for (int i = 0; i < len; i++) {
System.out.println(Arrays.toString(toBinaryString(Byte.toUnsignedInt(data[i]))));
}
// 关闭上层的websocket连接,然后最后关闭底层的socket连接,
// 至于更底层的连接,这个不属于我们考虑的范围了。
client.close();
}
// 一个自定义的工具类,用于将字节转成固定的8位二进制形式
// 形参是int的原因是因为,byte是无符号的,但是Java没有无符号的数,所以只能转成int
static char[] toBinaryString(int x) {
// 将字节转成字符数组,每一位对应一个字符0/1
char[] chs = new char[] {
'0','0','0','0',
'0','0','0','0'
};
// 注意口诀是:除2取余,逆序输出,所以需要倒过来存储。
int i = 7;
while (x != 0) {
int a = x%2;
x = x/2;
chs[i--] = (a == 1 ? '1' : '0');
}
return chs;
}
}
运行结果
客户端输出,服务器端数据不变。
接收到的HTTP响应报文:
HTTP/1.1 101
Upgrade: websocket
Connection: upgrade
Sec-WebSocket-Accept: kYmvW5MHdERfStkaIg8UFv4hboc=
Date: Thu, 31 Dec 2020 12:56:33 GMT
服务器端响应的数据为:
WebSocket Server: Time and tide wait for no man.
websocket关闭连接的最后报文,其本身形式是人不可读形式,所以应该以二进制形式打印,长度为:4 字节
[1, 0, 0, 0, 1, 0, 0, 0]
[0, 0, 0, 0, 0, 0, 1, 0]
[0, 0, 0, 0, 0, 0, 1, 1]
[1, 1, 1, 0, 1, 0, 0, 0]
这里的代码基本上是很丑的,毕竟也是从不懂到懂开始写的,也没有进行提前的规划,只是感觉可以玩一玩就试了一下,后来太难了,我还准备放弃了呢!但是,也总算是多坚持了一会,找到了解决办法。通过这种方式的学习,我认为我自己对于WebSocket是有点理解了。如果别人问你WebSocket为什么是全双工的?相信你可以说一些自己的理解了。
不过,我现在还是不会WebSocket的编程,因为这里学习的是关于协议的一些知识,对于哪些实际的编程操作还是需要自己学习的!
我写了一个GUI的客户端,在正确的操作下没有问题,但是代码写得也不是很好,就不贴出来了。如果有人感兴趣的话,那我就放到码云上面,这里放一个演示GIF吧。
数据帧——WebSocket协议翻译
websocket 建立过程以及数据帧分析
WebSocket 和socket 的区别
WebSocket 教程
看完让你彻底搞懂Websocket原理