2

一直以来对实时通讯挺感兴趣,本周就抽空了解了一下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请求头。
image.png
从中提取出Sec-WebSocket-Key的值,再根据规则生成webSocketAccept ,拼接到定义好的RESPONSE_HEADERS,将此数据写入socket的outputStream,客户端收到并验证通过后,即成功建立连接。

数据帧格式

成功进行握手后,为了正常通信,还需要了解websocket的数据帧格式:
image.png

  • 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>

效果:
image.png
image.png

https://zhuanlan.zhihu.com/p/...


鲸冬香
456 声望27 粉丝