前言
之前写过一篇,当时是刚接触tio的时候,那时候记得读解码类的第一行代码 boolean fin = (first & 0x80) > 0; 时,这一行代码纠结了我一下午,因为当时误把一个字节当成了一位。后来又去看了看 & |等操作,才渐渐缓过神来,每次多看一遍都会有不一样的收货。其实本来想先写websocket篇的,不过在握手部分涉及到tio-http部分的编解码,所以后来索性先去研究tio-http源码了。
目录
- 握手过程
- 协议帧介绍
- 解码解析
- 编码解析
握手过程
之所以去分析tio-http的源码,是因为websocket(下文简称ws)的握手过程其实是一个http请求的处理过程。简单来讲,客户端(浏览器)发送一个特殊的http请求告诉服务端,这是一个ws的握手请求,麻烦处理一下。服务端经过验证,然后将请求结果返回。握手成功就可以进行ws通信了。那么怎么界定为客户端发起的是ws请求呢,如下图:
图中被红框圈起的部分。首先 Connection为Upgrade. 升级为什么呢?Upgrade:WebSocket。另外还要带上版本号,图中版本为13,外加一个base64编码的Sec-WebSocket-Key.另外,发起请求不是以http(s)://开头,而是以 ws(s)://开头。接下来我们看根据源码去详细了解服务器的握手处理过程。
首先,同样tio的老套路,请求进来,数据会流向继承自ServerAioHandler的WsServerAioHandler。
进入decode解码方法,因为是第一次连接,需要走握手流程。握手阶段就要用 HttpRequestDecoder 进行解析。解析成功之后,服务端对请求进行升级。升级成功之后发送响应消息给客户端完成握手过程。其中 IWsMsgHander的handshake方法可以进行业务级别的握手干预。
@Override
public Packet decode(ByteBuffer buffer, ChannelContext channelContext) throws AioDecodeException {
WsSessionContext sessionContext = (WsSessionContext) channelContext.getAttribute();
//没有握手
if (!sessionContext.isHandshaked()){
//调用Http解码方法
HttpRequest request = HttpRequestDecoder.decode(buffer,channelContext);
if (request == null){
return null;
}
//升级 websocket 协议
HttpResponse httpResponse = WsProtocol.updateToWebSocket(request,channelContext);
if (httpResponse == null){
throw new AioDecodeException("http协议升级到websocket协议失败");
}
//解析成为握手包
WsRequest wsRequestPacket = new WsRequest();
wsRequestPacket.setHandShake(true);
return wsRequestPacket;
}
//其他代码略
}
其中,WsProtocol.updateToWebSocket 方法完成了具体的升级过程。
/**
* 升级websocket协议
* */
public static HttpResponse updateToWebSocket(HttpRequest request, ChannelContext channelContext){
//获取请求头部信息
Map headers = request.getHeaders();
//获取 Sec_WebSocket_Key
String Sec_WebSocket_Key = headers.get(HttpConst.RequestHeaderKey.Sec_WebSocket_Key);
if (StringUtils.isNotBlank(Sec_WebSocket_Key)){
//要添加一个 固定的值进行 SHA1编码,然后Base64编码
String Sec_WebSocket_Key_Magic = Sec_WebSocket_Key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
//sha1
byte[] keyArray = SHA1Util.SHA1(Sec_WebSocket_Key_Magic);
//base64
String acceptKey = BASE64Util.byteArrayToBase64(keyArray);
//构建响应
HttpResponse httpResponse = new HttpResponse(request);
//响应状态码为 101 Switching protocols
httpResponse.setStatus(HttpResponseStatus.C101);
Map respHeaders = new HashMap<>(3);
//Connection:Upgrade
respHeaders.put(HttpConst.ResponseHeaderKey.Connection,HttpConst.ResponseHeaderValue.Connection.Upgrade);
//Upgrade:WebSocket
respHeaders.put(HttpConst.ResponseHeaderKey.Upgrade,"WebSocket");
//Sec_WebSocket_Accept:base64
respHeaders.put(HttpConst.ResponseHeaderKey.Sec_WebSocket_Accept,acceptKey);
httpResponse.setHeaders(respHeaders);
return httpResponse;
}
return null;
}
最后在调用handler方法时,进行业务级别的判断。如果是握手包,发送一个handshake属性为true的包即可。因为,在调用encode方法时,会进行判断,如果为握手包的返回,则直接返回上文中已经被升级的响应。
HttpResponse handshakeResp = sessionContext.getHandshakeResponsePacket();
return HttpResponseEncoder.encode(handshakeResp ,groupContext,channelContext,false);
那么到此为止,握手阶段结束。
ws 协议帧介绍
上图就是ws的报文传输格式。
FIN:1bit,指示这个消息是否为最后片段,1是,0否。如果不是最后片段,则服务端需要将所有消息接受完并组装成一个完整的消息才可以。(t-io中目前只支持FIN=1)
RSV123每个长度为1bit,目前就都是固定 0。
opcode:4bit,数据操作类型。
- %x0 代表一个继续帧
- %x1 代表一个文本帧
- %x2 代表一个二进制帧
- %x3-7 保留用于未来的非控制帧
- %x8 代表连接关闭
- %x9 代表ping
- %xA 代表pong
- %xB-F 保留用于未来的控制帧
MASK:1bit,是否掩码,1掩码,0非掩码。从客户端发送到服务端的这个值必须为1,否则服务端不接受。服务端返回到客户端的这个值必须为 0.
Payload len:负载数据的长度,7bit。由于7bit只能存储0-127,所以为了能够表示准确的长度,在这个值为0-125区间的时候,payload length的长度就是该值。当 值为126的时候,后边两个字节(16位)的值表示长度。当值为127的时候,后边8字节(64位)的值表示长度。
Mask key:掩码,0或4个bit。值取决于MASK是否为1.在有掩码的情况下,数据就要根据掩码来解析。否则不用解析。解析规则为:每个字节的值与掩码的索引(字节索引值对4取模)异或运算。(array[i] = array[i] ^ mask[i % 4])
解码解析
读取第一个字节。8位,包含了1位FIN,3位RSV,4位Opcode。
first & 10000000 = 1(0) 0000000 所以,通过和 0x80 的 & 操作就得到第一位的值。
234位的RSV暂且不管。
同样的道理,first & 0x0f 得到后四位的值就是opcode的值。每个值代表的意思参考上文。
//读取第一个字节
byte first = buf.get();
//128 10000000 & frist 取第一位
boolean fin = (first & 0x80) > 0;
//01110000
@SuppressWarnings("unused")
int rsv = (first & 0x70) >>> 4;
//00001111 取后四位
byte opCodeByte = (byte)(first & 0x0f);
接下来读取第二个字节。8位,包含了1位MASK,7位PayloadLength。PayLoadLength的值的意思参考上文。
同样,利用 & 操作获取MASK值和PayLoadLength的值。转化为代码如下:
byte second = buf.get();
//11111111
boolean hasMask = (second & 0xff) >> 7 == 1;
if (!hasMask){
}else{
//mask 占 4 个字节
headLength += 4;
}
//01111111
int payloadLength = second & 0x7f;
byte[] mask = null;
if (payloadLength == 126){
//payloadLength 长度为2
headLength += 2;
if(readableLength < headLength){
return null;
}
payloadLength = ByteBufferUtils.readUB2WithBigEdian(buf);
}else if (payloadLength == 127){
//payloadLength 长度为8
headLength += 8;
if(readableLength < headLength){
return null;
}
payloadLength = (int) buf.getLong();
}
然后读取4位的Masking-Key,在之后就是具体的内容了。
if (hasMask){
//读取mask key
mask = ByteBufferUtils.readBytes(buf,4);
}
//读取payloadlength长度的内容
byte[] array = ByteBufferUtils.readBytes(buf,payloadLength);
//掩码解码
if (hasMask){
for (int i=0; i
解码完毕,封装到 WsRequestPacket中,返回。
编码解析
先构造第一字节。我们都知道第一位为FIN位,设置为1,中间三位为0,后四位为opcode。
byte header0 = (byte)(0x8f & (response.getWsOpcode().getCode() | 0xf0));
上述代码的意思很明确,例如opcode为 2,计算过程如下;
00000010 | 11110000 = 11110010
10001111 & 11110010 = 10000010 (第一位FIN 为1,后四位为Opcode 为 0010)
然后根据bodyLength创建ByteBuffer。
byte header0 = (byte)(0x8f & (response.getWsOpcode().getCode() | 0xf0));
ByteBuffer buf = null;
if (bodyLength < 126){
buf = ByteBuffer.allocate(2 + bodyLength);
buf.put(header0);
buf.put((byte)bodyLength);
}else if(bodyLength < (1 << 16) - 1){
buf = ByteBuffer.allocate(2 + 2 + bodyLength);
buf.put(header0);
buf.put((byte)126);
ByteBufferUtils.writeUB2WithBigEdian(buf,bodyLength);
}else{
buf = ByteBuffer.allocate(2 + 8 + bodyLength);
buf.put(header0);
buf.put((byte)127);
//这行代码问过作者,他说需要确认一下,后来我的理解是这里由于bodyLength是int类型的只占有 32 位,
//所以 8 位中的前四位 给 0。(我不知道是不是这个解释,需要确认)
buf.put(new byte[]{0,0,0,0});
ByteBufferUtils.writeUB4WithBigEdian(buf,bodyLength);
}
if (body != null && body.length >0){
buf.put(body);
}
return buf;
总结
本文根据ws的协议帧图,简单的分析了 tio-websocket-server 的编码解码过程。其实深入到数据的每个字节甚至每一位上还是挺有意思的。