始终以来对实时通信挺感兴趣,本周就抽空理解了一下 websocket。
websocket
WebSocket 是一种网络传输协定,可在单个 TCP 连贯上进行全双工通信,位于 OSI 模型的应用层。
WebSocket 使得客户端和服务器之间的数据交换变得更加简略,容许服务端被动向客户端推送数据。在 WebSocket API 中,浏览器和服务器只须要实现一次握手,两者之间就能够创立持久性的连贯,并进行双向数据传输。
实际是最好的老师, 为了理解 websocket
具体实现,打算用 websocket
与 “java socket 进行通信。java socket 应用的是传输层协定,而 websocket 是应用层协定,这就须要咱们手动解决数据。
首先要理解的就是 websocket 的握手过程和数据帧格局。
websocket 握手过程
申请
websocket 应用 http 协定进行握手,首先应用 http 协定发送申请报文,次要是询问服务器是否反对 websocket 服务,申请头次要信息如下:
GET ws://localhost:7000/ HTTP/1.1
Host: localhost:7000
Connection: Upgrade
Sec-WebSocket-Key: kvMm3tIaxXRCmGHuY01eQw==
Sec-WebSocket-Version: 13
Upgrade: websocket
这里最重要的就是Sec-WebSocket-Key
, 这是客户端生成的随机字符串并 base64 编码,服务端要对此编码进行响应。
响应
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
WebSocket-Location: ws://127.0.0.1:9527
Sec-WebSocket-Accept: Mf7ptCXn+TYF9XtDt8w+j9FCBEg=
最重要的是 Sec-WebSocket-Accept
, 客户端会对此进行验证,不合乎验证规定都会被视为服务端回绝连贯。生成规定为客户端Sec-WebSocket-Key
去除首尾空白,连贯固定字符串 (258EAFA5-E914-47DA-95CA-C5AB0DC85B11) 之后,应用 sha- 1 进行 hash 操作,后果再用 base64 编码即可。
java 代码实现:
public static final String RESPONSE_HEADERS = "HTTP/1.1 101 Switching Protocols\r\n" +
"Upgrade: websocket\r\n" +
"Connection: Upgrade\r\n" +
"WebSocket-Location: ws://127.0.0.1:9527\r\n";
ServerSocket serverSocket = new ServerSocket(7000);
while (true) {Socket socket = serverSocket.accept();
// 开启一个新线程
Thread thread = new Thread(() -> {
// 响应握手信息
try {
// 读取申请头
byte[] bytes = new byte[10000000];
socket.getInputStream().read(bytes);
String requestHeaders = new String(bytes, StandardCharsets.UTF_8);
// 获取申请头中的
String webSocketKey = "";
for (String header : requestHeaders.split("\r\n")) {if (header.startsWith("Sec-WebSocket-Key")) {webSocketKey = header.split(":")[1].trim();}
}
// 将 webSocketKey 与 magicKey 拼接用 sha1 加密之后在进行 base64 编码
String value = webSocketKey + magicKey;
String webSocketAccept = new String(Base64.encodeBase64(DigestUtils.sha1(value.getBytes(StandardCharsets.UTF_8))), StandardCharsets.UTF_8);
// 写入返回头 握手完结 胜利建设连贯
String responseHeaders = RESPONSE_HEADERS + "Sec-WebSocket-Accept:" + webSocketAccept + "\r\n\r\n";
socket.getOutputStream().write(responseHeaders.getBytes(StandardCharsets.UTF_8));
System.out.println("握手胜利, 胜利建设连贯");
}
}
}
首先新建 ServerSocket 监听 7000 端口,当 tcp 连贯建设时,从 inputStream 中读取客户端发送的 http 字节数组,将其转换为字符串,此时 requestHeaders 的值应为 http 申请头。
从中提取出 Sec-WebSocket-Key
的值,再依据规定生成webSocketAccept
,拼接到定义好的RESPONSE_HEADERS
,将此数据写入 socket 的outputStream
,客户端收到并验证通过后,即胜利建设连贯。
数据帧格局
胜利进行握手后,为了失常通信,还须要理解 websocket 的数据帧格局:
- FIN:1 bit
示意这是不是音讯的最初一帧。第一帧也有可能是最初一帧。0:还有后续帧。1:最初一帧 - RSV1、RSV2、RSV3:1 bit
扩大字段,除非一个扩大通过协商赋予了非零值的某种含意,否则必须为 0 - opcode:4 bit
解释 payload data 的类型,如果收到辨认不了的 opcode,间接断开。分类值如下:0:间断的帧. 1:text 帧. 2:binary 帧 .3 – 7:为非管制帧而预留的 .8:敞开握手帧 .9:ping 帧.A:pong 帧 .B – F:为非管制帧而预留的 - MASK:1 bit
标识 Payload data 是否通过掩码解决,如果是 1,Masking-key 域的数据即为掩码密钥,用于解码 Payload data。协定规定客户端数据须要进行掩码解决,所以此位为 1 - Payload len:7 bit | 7+16 bit | 7+64 bit
示意了“无效负荷数据 Payload data”,以字节为单位:– 如果是 0~125,那么就间接示意了 payload 长度 – 如果是 126(二进制 111 1110),那么 先存储 0x7E(=126)接下来的两个字节示意的 16 位无符号整型数的值就是 payload 长度 – 如果是 127,那么 先存储 0x7F(=127)接下来的八个字节示意的 64 位无符号整型数的值就是 payload 长度. - Masking-key:0 | 4 bytes 掩码密钥,所有从客户端发送到服务端的帧都蕴含一个 32bits 的掩码(如果 mask 被设置成 1),否则为 0。一旦掩码被设置,所有接管到的 payload data 都必须与该值以一种算法做异或运算来获取实在值。
- Payload data 利用发送的数据信息
基于 websocket 的数据帧,咱们须要实现两个办法,一是提取数据帧中的数据,二是将数据转化为数据帧。
解码数据帧
核心思想就是依据管制字段来确定数据字段的读取形式,将其读取并解码。
/**
* 将字节数组解码为字符串
* @param bytes websocket 帧字节数组
* @return 解析为字符串
*/
public static String decodeMessage(byte[] bytes) {
int col = 0;
boolean isMask = false;
int dataStart = 2;
// 提取 websocket 帧中的 mask 位
if ((bytes[1] & 0x80) == 0x80) {isMask = true;}
// 提取 playload len
int len = bytes[1] & 0x7f;
byte[] maskKey = new byte[4];
if (len == 126) {
// 如果为 126 持续往后两个字节读取作为 playload len
len = bytes[2];
len = (len << 8) + bytes[3];
// 如 mask 为 1 向后读取 4 个字节作为 maskKey
if (isMask) {maskKey[0] = bytes[4];
maskKey[1] = bytes[5];
maskKey[2] = bytes[6];
maskKey[3] = bytes[7];
// payload data 开始的地位在 maskKey 之后
dataStart = 8;
} else {dataStart = 4;}
} else if (len == 127) {
// 如果为 126 持续往后八个字节读取作为 playload len
// 这里跳过 bytes[2]~bytes[5]
len = bytes[6];
len = (len << 8) + bytes[7];
len = (len << 8) + bytes[8];
len = (len << 8) + bytes[9];
if (isMask) {maskKey[0] = bytes[10];
maskKey[1] = bytes[11];
maskKey[2] = bytes[12];
maskKey[3] = bytes[13];
dataStart = 14;
} else {dataStart = 10;}
} else {
// 既不是 126 也不是 127 阐明长度仅占七位 不必解决
if (isMask) {maskKey[0] = bytes[2];
maskKey[1] = bytes[3];
maskKey[2] = bytes[4];
maskKey[3] = bytes[5];
dataStart = 6;
} else {dataStart = 2;}
}
// 读取 payload data 并依据 isMask 判读是否进行 mask 加密
for (int i = 0, count = 0; i < len; i++) {byte t1 = maskKey[count];
byte t2 = bytes[i + dataStart]; // 从 datastart 开始读取 data
char c = isMask ? (char) (((~t1) & t2) | (t1 & (~t2))) : (char) t2; // isMask 为真,进行 mask 加密
bufferRes[col++] = c;
count = (count + 1) % 4;
}
bufferRes[col++] = '\0';
return new String(bufferRes);
}
编码信息为数据帧
核心思想就是依据信息格式将要发送的数据转化为 websocket 数据帧。
/**
* 将 message 编码为 websocket 帧
* @param message 字符信息
* @param isMask 是否进行 mask 加密
* @param result 保留帧的字节数组
* @return 字节长度
*/
public static int encodeMessage(String message, boolean isMask, byte[] result) {
int dataEnd = 0;
// 帧的第一个字节为类型,设置为默认类型为 text 帧
result[dataEnd++] = (byte) 0x81;
byte[] maskKey = new byte[4];
// 获取 message 的字节数组
byte[] messageBytes = message.getBytes(StandardCharsets.UTF_8);
// isMask 为真 设置 mask 位为 1
if (isMask) {result[dataEnd] = (byte) 0x80;
}
// 判断数据的长度
long dataLen = messageBytes.length;
if (dataLen < 126) {
// 小于 126 字节 间接赋值
result[dataEnd++] |= dataLen & 0x7f;
} else if (dataLen < 65536) {
// 小于 65536 字节,往后赋值两个字节
result[dataEnd++] |= 0x7E;
result[dataEnd++] = (byte) ((dataLen >> 8) & 0xFF);
result[dataEnd++] = (byte) ((dataLen >> 0) & 0xFF);
} else if (dataLen < 0xFFFFFFFF) {
// 小于 0xFFFFFFFF 个字节,往后赋值八个字节
// 防止数据过大 跳过 4 个字节
result[dataEnd++] |= 0x7F;
result[dataEnd++] |= 0;
result[dataEnd++] |= 0;
result[dataEnd++] |= 0;
result[dataEnd++] |= 0;
result[dataEnd++] = (byte) ((dataLen >> 24) & 0xFF);
result[dataEnd++] = (byte) ((dataLen >> 16) & 0xFF);
result[dataEnd++] = (byte) ((dataLen >> 8) & 0xFF);
result[dataEnd++] = (byte) ((dataLen >> 0) & 0xFF);
}
if (isMask) {
// 如果 isMask 为真 将数据进行 mask 加密再保留到帧中
new Random().nextBytes(maskKey);
result[dataEnd++] = maskKey[0];
result[dataEnd++] = maskKey[1];
result[dataEnd++] = maskKey[2];
result[dataEnd++] = maskKey[3];
for (int i = 0, count = 0; i < dataLen; i++) {byte t1 = maskKey[count];
byte t2 = messageBytes[i];
result[dataEnd++] = (byte) (((~t1) & t2) | (t1 & (~t2)));
count = (count + 1) % 4;
}
} else {
// 间接保留到帧中
for (int i = 0; i < dataLen; i++) {result[dataEnd++] = messageBytes[i];
}
}
return dataEnd;
}
试验
java
端提供 ServerSocket
用于建设 socket 连贯,连贯胜利后,对 websocket 做出握手响应,读取 websocket 帧
时解码读取信息,发送信息时转化为websocket 帧
。
public static int userCount = 0;
public static char[] bufferRes = new char[131072];
public static Scanner sc = new Scanner(System.in);
public static final String RESPONSE_HEADERS = "HTTP/1.1 101 Switching Protocols\r\n" +
"Upgrade: websocket\r\n" +
"Connection: Upgrade\r\n" +
"WebSocket-Location: ws://127.0.0.1:9527\r\n";
public static String magicKey = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
public static void main(String[] args) throws IOException {ServerSocket serverSocket = new ServerSocket(7000);
while (true) {Socket socket = serverSocket.accept();
userCount++;
// 开启一个新线程
Thread thread = new Thread(() -> {
// 响应握手信息
try {
// 读取申请头
byte[] bytes = new byte[10000000];
socket.getInputStream().read(bytes);
String requestHeaders = new String(bytes, StandardCharsets.UTF_8);
// 获取申请头中的
String webSocketKey = "";
for (String header : requestHeaders.split("\r\n")) {if (header.startsWith("Sec-WebSocket-Key")) {webSocketKey = header.split(":")[1].trim();}
}
// 将 webSocketKey 与 magicKey 拼接用 sha1 加密之后在进行 base64 编码
String value = webSocketKey + magicKey;
String webSocketAccept = new String(Base64.encodeBase64(DigestUtils.sha1(value.getBytes(StandardCharsets.UTF_8))), StandardCharsets.UTF_8);
// 写入返回头 握手完结 胜利建设连贯
String responseHeaders = RESPONSE_HEADERS + "Sec-WebSocket-Accept:" + webSocketAccept + "\r\n\r\n";
socket.getOutputStream().write(responseHeaders.getBytes(StandardCharsets.UTF_8));
System.out.println("握手胜利, 胜利建设连贯");
// 承受客户端信息
while (true) {System.out.println("读取信息");
socket.getInputStream().read(bytes);
String message = decodeMessage(bytes);
System.out.println("读取到的信息为:" + message);
System.out.println("请回复信息");
String res = sc.next();
byte[] result = new byte[10000000];
int len = encodeMessage(res, false, result);
socket.getOutputStream().write(result, 0, len);
}
} catch (IOException e) {e.printStackTrace();
}
System.out.println("finish Read");
});
thread.setName("用户" + userCount);
thread.start();}
}
客户端用简略 js
代码实现(感激赵凯强同学提供)。
<!DOCTYPE html>
<html>
<head>
<title>websocket test</title>
</head>
<body>
<ul id="ul">
</ul>
<input id="input" type="input" />
<button onclick="onClick()"> 发送 </button>
<script type="text/javascript">
var ws = new WebSocket("ws://localhost:7000");
ws.onopen = function(evt) {console.log("Connection open ...");
};
ws.onmessage = function(evt) {console.log( "Received Message:" + evt.data);
addLi('承受:' + evt.data);
};
ws.onclose = function(evt) {console.log("Connection closed.");
};
// 点击发送 读取 input 的值打印到管制台上
function onClick() {var value = '发送:' + document.getElementById("input").value;
ws.send(value);
document.getElementById("input").value = '';
addLi(value);
}
// 一个办法 sring 当调用时,把字符串插入到列表中
function addLi(value) {document.getElementById("ul").innerHTML += "<li>" + value + "</li>";
}
</script>
</body>
</html>
成果:
https://zhuanlan.zhihu.com/p/…