关于学习websocket遇到的技术问题及总结
(注:解决后的源代码在最下面的技术总结里)
最近本来是为一个课程作业做设计(不过老师有提供课题,我这个怕是留着做毕设了),设计着设计着,就发现我可能需要实现一个web端的聊天功能,巧的是,最近在学javasocket编程,不巧的是,socket和websocket还有点不太一样(握手,解析数据,巴拉巴拉)。
先写一下目前遇到的问题:
客户端发送一个数据给服务端的时候,我们不是要解析这个数据嘛,按照这个数据帧的格式来看
(来源:https://www.cnblogs.com/laohaozi/p/12537571.html)
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 ... |
+---------------------------------------------------------------+
别的不看,就看第二个字节Payload len,原本我是把所有的接收数据的payloadlen都输出出来的,结果发现全部是负值,观察了一下规律后发现,其实data[1]的值其实是补码,数据真正的payloadlen取data[1]补码就行,但是和0x7f与一下也行,原因在下面。
data[1]:-113
payloadlen:15
原本应该这样结束才对,但是我试了一下边缘长度125,126,127,128,试出问题了
真正的数据长度(byte) | data[1](byte) | payloadlen(byte) |
---|---|---|
124 | -4 | 124 |
125 | -3 | 125 |
126 | -2 | 126 |
127 | -2 | 126 |
128 | -2 | 126 |
数据长度再大往下都是126,然后我看着上面那个数据帧的格式陷入了沉思:127呢?
在思考了很长一段时间后我意识到一件事情,我是个傻X,126提供的数据长度已经很长了,我可能没有耐心测试到127出现。
然后我就一直在测试数据范围,发现了几件事情,一件一件说:
①当发送数据过于长的时候,就会进行分片(写到后面发现这个“分片”是不对的,大家看个乐呵就行)。
②当客户端发送数据过长的时候偶尔会出现mask位变成0的情况,随后连接会因为这个关闭,也可能不会关闭,挺玄学的。(同上)
③当mask位变成0的时候,data[1]就和payloadlen一样了,这个很好解释,mask位被当作符号位处理了,所以正常来说data[1]后7位就是原码,而且本来就应该和0x7f相与。
④当发生分片时,第一个分片的fin=1,后面的分片可能是1也可能是0。(同①②)
-------------------------------------------------------分割线,此处为浪费时间,没有看的价值--------------------------------------------
然后再测了几次发现是在1024个数字字符时发生分片。
具体如下(某一次测试的数据,分片后fin和mask可能都会变,payloadlen在第二个分片开始也是不一样的,即便是一样的数据)
数据长度(byte) | 分片? | payloadlen(byte) | fin | mask |
---|---|---|---|---|
128 | N | 126 | 1 | 1 |
256 | N | 126 | 1 | 1 |
512 | N | 126 | 1 | 1 |
1024 | Y | 126 | 1 | 1 |
7 | 0 | 0 | ||
2048 | Y | 126 | 1 | 1 |
111 | 1 | 1 | ||
111 | 1 | 1 |
由于是1024开始有分片,所以我要找一下512-1024中间,什么时候开始有分片
数据长度(byte) | 分片? | payloadlen(byte) | fin | mask |
---|---|---|---|---|
768 | N | 126 | 1 | 1 |
896 | N | 126 | 1 | 1 |
960 | N | 126 | 1 | 1 |
992 | N | 126 | 1 | 1 |
1008 | N | 126 | 1 | 1 |
1016 | N | 126 | 1 | 1 |
1020 | Y | 126 | 1 | 1 |
24 | 1 | 1 | ||
1018 | Y | 126 | 1 | 1 |
91 | 0 | 1 | ||
1017 | Y | 126 | 1 | 1 |
126 | 0 | 1 |
最终测试出来是在1017这里,刚刚好就是1017,往下不分片,1017及以上一定会分片,而且很有意思,两个分片的payloadlen都是126,试了很多次都不会变,似乎当发送数据的频次过快时,连接就会关闭。
为什么是1017呢?为什么1017个字符刚刚好两个分片都是126。
这个问题先搁置在这里。(1017已经解决,但是为什么“分片”都是126还不知道)
-----------------------------------------------------------------------分割线完-----------------------------------------------------------------------
然后我就想输出一下数据的真实长度看看呢,126的情况下输出extendedpayloadlen
fin和mask现在没啥意义就不写了
数据长度 | 分片code | Ext |
---|---|---|
1024 | 1 | 13621 |
2 | 1542 | |
2048 | 1 | 25987 |
2 | 22102 | |
3 | 22102 | |
4096 | 1 | 22359 |
2 | 25700 | |
3 | 25700 | |
4 | 25700 | |
5 | 25700 |
//看到接收到的数据是这样,我就忍不住把代码贴出来了
long extendPayloadLen = ((data[2]&0xff)<<8)+data[3]; //byte[] data = byte[1024];,然后通过inputStream一次性全读进来的,每次读1024个。。。我写到这里才恍然大悟,为什么前面1017字节数据后数据会被分次读入,原因就在这里,一次读1024个字节,超过的数据下次再加载,所以得到数据是这样的,偶尔mask和fin会变成0的原因就在这里,根本就是读错了。后面我把缓冲区改成2048后果然1024字节的数据就不‘分片’了
再重新捋一下,1017个字节后数据会被分次读入,因为一次读1024字节,那除了我发送的1017个字节的数据外,剩下7个字节就是数据帧的组成部分了。为了保证7个字节结论的准确性,我在缓冲区2048和4096的情况下发送数据做测试,结果2040字节不会“分片”,2041会,4088不会,4089会。
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 ... |
+---------------------------------------------------------------+
也就是说,我一次能够接收的数据的最大数量=缓冲区大小-7,那7个字节就是数据帧的重要组成部分,但是回过头来看除了Payload Data的其他字节,一共有8个字节(126的情况下),这就给我整不会了。
那现在就有两个问题:
①这7个字节代表什么?数据帧中明明有8个字节是必要的
②为什么发送相同的数据,extendedpayloadlen值是不一样的,虽然差别不大(没有'分片时)
先解决第②个问题,我犯了个错误,就是我一直在写2个字节,然后虽然data[0]和data[1]没写错,但是我漏了个data[2],直接写data[3]和data[4],data[3]不变,看来纯度还是不够,因为是高位,所以看上去数据变化不大。我发现一边默念一边思考一边记下来能发现好多之前光想想不到的事情,特别是我要写成一篇文章发出去的时候。
面对①,我有一个猜想:其实7字节结论不对,或许发送的数据存在缺少的部分没有接收到,为了验证这个是不是对的,只能先解析出数据输出看看,为了方便,缓冲区设置为256字节,发送的数据设为248字节。
-----------------------------------------------------------------------分割线-----------------------------------------------------------------------
以上是一夜未睡写的,睡到下午起床神清气爽了不少,当然①我也不管了,上面的内容就当放屁了,至于为什么保留这些放屁的内容,因为这是我思考过程中踩的坑走错的路,所以发出了铭记一下。
ok,因为接收数据的read()方法可以根据给的byte数组长度去读取对应字节的数据,所以只要在payloadData前的标志位,掩码等信息解析出来,然后创建payloadlen长度的数组去接收数据帧剩下的所有字节就行了。
-----------------------------------------------------------------------技术总结-----------------------------------------------------------------------
首先是握手信息,浏览器和服务端建立连接时,浏览器会发送一个握手请求,这个握手请求的opcode是1,也就是文本信息,直接用BufferedReader也是可以读出来的。这个信息不会加密,可以直接打印出来。如下:
GET / HTTP/1.1
Host: localhost:13655
Connection: Upgrade
Pragma: no-cache
Cache-Control: no-cache
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/99.0.4844.51 Safari/537.36
Upgrade: websocket
Origin: http://localhost
Sec-WebSocket-Version: 13
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9
Sec-WebSocket-Key: hoBQ13wddX4Zsisa/o7oYg==
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits
这段信息中最重要的就是Sec-WebSocket-Key字段
收到这段信息后反正用尽任何手段获取到这个字段就行了,在这里我用的是正则表达式匹配
"Sec-WebSocket-Key: (.+)"后的内容(:后有空格要加上去)
public static String getWSKeyInfo(String keymsg){
String pattern = "Sec-WebSocket-Key: (.+)"; //匹配模式
Pattern p = Pattern.compile(pattern);
Matcher m = p.matcher(keymsg);
if (m.find()){
return m.group(1); //group(1)的值就是(.+)中的内容,即我要的key
}else{
return null;
}
}
获取到key之后,我们要用到一个magic的字符串"258EAFA5-E914-47DA-95CA-C5AB0DC85B11",然后和key拼接起来,这里用的DigestUtils和Base64是apache的codec的包。拼接结果先经过sha1编码,再经过base64编码(得到一个byte数组),后面要把这个accept作字符串拼接,所以最后把byte转为str返回
public static String getWSKeyAccept(String key) throws UnsupportedEncodingException {
String magic_str = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
String value = key+magic_str;
byte[] sha1encode = DigestUtils.sha1(value);
byte[] base64encode = Base64.encodeBase64(sha1encode);
String accept = new String(base64encode,"utf-8");
return accept;
}
得到accept信息后,进行字符串拼接,没什么好说的,照这个格式写返回信息就可以。
public static String getRetMsg(String key) throws UnsupportedEncodingException {
String accept = getWSKeyAccept(key);
StringBuffer sb = new StringBuffer();
sb.append("HTTP/1.1 101 Switching Protocols\r\n");
sb.append("Upgrade: websocket\r\n");
sb.append("Connection: Upgrade\r\n");
sb.append("Sec-WebSocket-Accept: ");
sb.append(accept);
sb.append("\r\n");
sb.append("\r\n");
String retMsg = sb.toString();
return retMsg;
}
获取到返回信息后,把输出流返回信息write再flush就行了。
-----------------------------------------------------------------------分割线-----------------------------------------------------------------------
然后是获取信息的部分,这部分卡了我好几天,我觉得原因在于我对这个websocket的数据帧了解甚少,而且不愿意去理解它,希望从其他博文里拿现成的,但是这东西真的不难,自己不理解的话就没有学的意义了。
废话不多说,握手建立后,从浏览器发送过来的字符信息的数据帧的opcode值为2,也就是代表字节流信息,事实上也要基于字节去解析这些数据,为了方便起见,我就直接用InputStream和OutputStream,包括上面的握手返回信息,将握手信息转为二进制流,直接用OutputStream去write也是OK的。
以下为接收数据并输出
while (true){
byte[] first = new byte[1];
ins.read(first); //将流中第一个字节的数据读入first
int finflag = (first[0]&0x80)>>7; //终止位信息fin = (xyyyyyyyy & 10000000)>>7 = x,
int opcode = first[0]&0x0f; //表示接收的信息类型opcode = yyyyxxxx & 0000ffff = 0000xxxx
//opcode为0时是接收附加数据,1是文本数据,2是二进制流数据,8是关闭连接
if (opcode == 8){
System.out.println("连接被浏览器关闭");
webSocketServer.close(); //收到opcode=8时就关闭连接,把所有的流和socket关闭
break;
}
byte[] second = new byte[1];
ins.read(second); //读入第二个字节
int mask = (second[0]&0x80)>>7; //是否有掩码,从浏览器发送过来的信息一定有,mask = (xyyyyyyyy & 10000000)>>7 = x
int payloadlen = second[0]&0x7f; // payloadlen = yxxxxxxxx & 011111111 = 0xxxxxxxx
int extendPayloadLen = 0;
if (payloadlen == 126){
// 若payloadlen == 126,则接收的数据长度为后两个字节的值,
int index = 0;
byte[] extended = new byte[2];//附加长度
ins.read(extended); //读入附加长度的值
while(index < 2){
extendPayloadLen = extendPayloadLen<<8; //当前的获得的字节全部左移8位
extendPayloadLen += (extended[index++]&0xff); //当前获得的数据加上下一个字节
}
payloadlen = extendPayloadLen; //得到真正的数据长度
}else if(payloadlen == 127){
// 若payloadlen == 127,则接收的数据长度为后八个字节的值。,同上一个if中的内容
int index = 0;
byte[] extended = new byte[8];
ins.read(extended);
while(index < 8){
extendPayloadLen = extendPayloadLen<<8;
extendPayloadLen += (extended[index++]&0xff);
}
payloadlen = extendPayloadLen;
}else{
// 若payloadlen < 126,接收的数据长度为0~125,
payloadlen = payloadlen; //脱裤子放屁
}
byte[] maskingkey = new byte[4];
ins.read(maskingkey); //读入maskingkey
byte[] themsg = new byte[payloadlen]; //真正的信息占payloadlen个字节
ins.read(themsg,0,payloadlen); //原本设置themsg的长度是payloadlen+128,感觉安全一点,但是转str后在linux下会把空字节段也输出出来,不是很好看,所以就直接用payloadlen,跟着协议走应该OK
int index = 0;
if (mask == 1){
//如果数据帧有掩码
while(index < payloadlen){
//themsg[index]和maskingkey[index%4]进行异或运算后得到真正的数据
themsg[index] = (byte) ((themsg[index]^maskingkey[index%4])&0xff);
index++;
}
}
String msg = new String(themsg,"UTF-8"); //把byte数组转为str,编码是utf8
System.out.println(msg);
System.out.println(msg.length());
}
发送数据的部分还没有写,之后写了会连同接收数据的部分一起再发一次博文。