8

浅谈tcp流的解析

背景

曾几何时,我们从一些书上看到了这样一个词——粘包。粘包,包子粘在一起了?这跟tcp有啥关系。
所以,我们google了一下,跳到了百度,瞧到这样一段解释:

网络技术术语。指TCP协议中,发送方发送的若干包数据到接收方接收时粘成一包,从接收缓冲区看,后一包数据的头紧接着前一包数据的尾。

看得我是一愣一愣的,TCP啥时候有包这个概念了,不是一直都是字节流吗?
024F54EE-0651-45FE-AA8D-BEB6B28C340B.png

聊聊tcp

基本概念

tcp是什么东西来的?
大家这东西听多了吧,但让你说一下这是啥东西,怎么说呢?
我们还是抄一下百科上面的定义吧

传输控制协议(TCP,Transmission Control Protocol)是一种面向连接的、可靠的、基于字节流的传输层通信协议,由IETF的RFC 793 [1]  定义。
TCP旨在适应支持多网络应用的分层协议层次结构。 连接到不同但互连的计算机通信网络的主计算机中的成对进程之间依靠TCP提供可靠的通信服务。TCP假设它可以从较低级别的协议获得简单的,可能不可靠的数据报服务。 原则上,TCP应该能够在从硬线连接到分组交换或电路交换网络的各种通信系统之上操作。

这里我们抽取几个关键的点:

面向连接

这很简单理解啦,就是在要传输之前会需要先建立连接。怎么建立?三次握手啊。这个网上很多文章啦,大家可以去看看。
为什么需要三次呢?我这里大概解释一下:

  1. 客户端发起连接,这只是一次初始的
  2. 服务端收到连接建立的要求,表明自己接收是OK的,客户端发送也是OK的,但自己的发送和客户端的接收能力是咋样的,这还不清楚。
  3. 服务端要确认一下自己的发送能力和客户端的接收能力,因此再发送一次回复进行确认。如果客户端收到了,证明两方的能力都正常,这时连接才正式建立。

可靠

为什么叫可靠呢?是因为它可以帮我们重传数据校验。这些不在我们此次的重点内。

基于字节流

这时我们这次的重头戏,正是因为tcp是基于字节流的,才会导致出现一些奇怪的问题,比如上一次发送的东西跟下一次的合在一起了,导致解析的时候出现一些奇怪的东西。
比如第一次客户端发了一句:你什么时候到那里的?,那假设我们服务端的解析方式不对,导致了发送的内容被切割了,那么就有可能会变成:你什么时候到,后面才收到那里的,导致变成了你什么时候到?那里的?。语义可能就完全不一样了,要是跟女朋友或老婆大人聊天的时候变成这样,估计晚上就要回去跪键盘了。

所谓的“粘包”

基于上面我们的分析,tcp是基于字节流的,没有所谓的包,那这里的粘包是啥东西来的?我们还是直接通过google一下来到百度百科(想想就觉得奇怪)

网络技术术语。指TCP协议中,发送方发送的若干包数据到接收方接收时粘成一包,从接收缓冲区看,后一包数据的头紧接着前一包数据的尾。

tcp协议中?包数据?奇怪咧,tcp不是字节流来的吗?哪来的包。
我们再在网上找找资料,发现粘包基本上都是中文资料才有的,从哪里来的我们也找不到了,但在国外的资料里面我们都看不到类似的说法。难道这个说法是错的?我们先不确定哈,我们先来看一下java里面的Socket的示例,再来说说所谓的粘包究竟对不对。

简单的客户端
public class ClientSocketTest {

    public static void main(String[] args) throws IOException {
        //建立和服务端的连接
        Socket socket = new Socket("localhost", 8080);
        //发送消息给服务端
        socket.getOutputStream().write("helloworld".getBytes());
        socket.getOutputStream().write("helloworld".getBytes());
        socket.close();
    }

}

我们简单说明一下这里做的事情,我们发送了两条消息到服务端,按我们的理解,这肯定是要在服务端分两次接收才可以的。

简单的服务端代码
public class ServerSocketTest {
    public static void main(String[] args) throws IOException {
        ServerSocket serverSocket = new ServerSocket(8080);
        //这里会阻塞一直到有连接建立
        Socket socket = serverSocket.accept();
        socket.getInputStream();
        //这里读取由客户端发过来的内容
        byte[] bytes = new byte[1024];
        int length = socket.getInputStream().read(bytes);
        System.out.println(new String(bytes, 0, length));

        socket.close();
        serverSocket.close();
    }

}

这里服务端只是简单的进行一次接收,而接收的长度就是1024长度,因为我们希望特意营造一种粘包的情况。

对于多次tcp的发送情况来说,我们以两次来举例:

  • 字节流正常

02ADBA98-2120-4A17-B3E4-5DB0802B91AE.png

这里字节流一切正常,并没有发生合并的情况
  • 两次发送的字节流完全合并

23CB0C65-3882-4B76-B0F2-B8DEFB30400C.png

这里我们看到两个字节流完全合并了——也就是我们上面的例子中写到的情况。
  • 第一个字节流有部分被第二个字节流合并了

0A7B0F02-0A56-4B07-8742-7479CF62C7C6.png

这里我们看到第一个字节流的部分被合并第二个字节流了,就是类似被过去了。但要非常注意,这并不是包,而tcp也并没有明确的包的概念。
  • 第一个字节流把第二个字节流的部分合并了

B7698345-A70B-46C6-8BD6-CD1D3E53F63F.png

这里我们看到第一个字节流把第二个字节流的部分字节给合并过来了,跟上面的情况类似。

由于上面的情况要一一复现会比较麻烦,我们这里就不详细写示例,大家可以类似上面去写示例。这些情况涉及到比较多的情况,包括网络顺畅情况等。
我们就来说一下为什么会出现上面的情况,如果我们的tcp是一个个的包,那么一个个的包,肯定会有自己的界限,也就不可能会出现所谓的粘包情况。唯一可以解释的就是tcp根本就不是一个个的包,这也是我们正常学习tcp的时候学到的知识,tcp是字节流,没有明显的界限,所以当缓存区满了之后,网卡就会把内容传输到服务端,而服务端也并没有明确知道这些流应该怎么分割,所以当多个字节流由于某种原因粘在了一起,那么就会出现了内容错误的情况了。

解决方案

那我们都已经知道有这样的问题,那应该怎么解决呢?我们来聊聊正常情况下我们的处理方案。
我们先看看导致字节流粘在一起的原因是什么?是因为我们不知道怎么去切割消息。
那我们是不是让服务端知道怎么分割消息就好了,那要让服务端知道怎么分割消息,我们有几种思路:

消息体长度标识
这里我们可以通过在消息体最前面的byte中增加当前消息体的长度,在解析的过程中,我们先解析最前面的一个byte,然后按照该长度去解析后面的内容,这样就可以达到分割消息的作用了。

我们这里给个小示例:

public class LengthServerSocketTest {

    public static void main(String[] args) throws IOException {
        ServerSocket serverSocket = new ServerSocket(8080);
        //这里会阻塞一直到有连接建立
        Socket socket = serverSocket.accept();

        process(socket);
        process(socket);
        socket.close();
        serverSocket.close();
    }

    /**
     * 处理客户端输入
     * @param socket
     * @throws IOException
     */
    private static void process(Socket socket) throws IOException {
        byte[] bytes = new byte[1];
        socket.getInputStream().read(bytes);
        bytes = new byte[(int)bytes[0]];
        socket.getInputStream().read(bytes);
        System.out.println(new String(bytes));
    }

}

这里比较简单,我们就是先读第一位byte,就可以拿到此次传输的消息字节流的长度,然后我们再读指定长度的字节流,那么我们就把当次的消息读完了。

而客户端的话我们就只是在前面加上单次消息的长度:

public class LengthClientSocketTest {

    public static void main(String[] args) throws IOException {
        //建立和服务端的连接
        Socket socket = new Socket("localhost", 8080);
        //发送消息给服务端
        process(socket);
        process(socket);

        socket.close();
    }

    /**
     * 发送消息体
     * @param socket
     * @throws IOException
     */
    private static void process(Socket socket) throws IOException {
        byte[] bytes = new byte[1 + "helloworld".getBytes().length];
        bytes[0] = (byte)("helloworld".length());
        for (int i = 1; i < bytes.length; i ++) {
            bytes[i] = "helloworld".getBytes()[i - 1];
        }
        socket.getOutputStream().write(bytes);
    }

}

由于是示例,这里写法比较飘逸,大家就不要太讲究了哈。

在末尾加上统一的标识符,比如换行符或者其他约定的

这里我们看个例子:
服务端代码如下:

public class LineBreakServerSocketTest {

    public static void main(String[] args) throws IOException {
        ServerSocket serverSocket = new ServerSocket(8080);
        //这里会阻塞一直到有连接建立
        Socket socket = serverSocket.accept();
        process(socket);
        process(socket);

        socket.close();
        serverSocket.close();
    }

    /**
     * 处理客户端的输入
     * @param socket
     * @throws IOException
     */
    private static void process(Socket socket) throws IOException {
        byte[] bytes = new byte[1024];
        int idx = 0;
        socket.getInputStream().read(bytes, idx, 1);
        while ("\n".getBytes()[0] != (int)bytes[idx ++]) {
            socket.getInputStream().read(bytes, idx, 1);
        }

        //去掉末尾的\n
        byte[] newBytes = new byte[idx - 1];
        for (int i = 0; i < newBytes.length; i ++) {
            newBytes[i] = bytes[i];
        }
        System.out.println(new String(newBytes));
    }

}

这里我们可以看到,我们是循环的读每一位,当遇到我们约定的\n符时,我们认为是一次消息的结束,此时我们就输出,再继续处理下一个输入字节流。

而客户端代码比较简单,就是在输入后面加上\n作为结尾。

public class LinkBreakClientSocketTest {

    public static void main(String[] args) throws IOException {
        //建立和服务端的连接
        Socket socket = new Socket("localhost", 8080);
        //发送消息给服务端
        socket.getOutputStream().write("helloworld\n".getBytes());
        socket.getOutputStream().write("helloworld\n".getBytes());
        socket.close();
    }

}
每个消息都使用固定的长度

既然服务端不知道每个消息应该怎么分割,那么我们所有消息一样长,那不就可以了,反正服务端每次都读这么多消息,超过的我也不管了。
基于这种思想,我们就可以定义一个固定的长度,每次发送消息都是按这样的长度,也就不会导致消息在一起了。
我们来看一下例子。
服务端代码如下:

public class FixLengthServerSocketTest {

    public static void main(String[] args) throws IOException {
        ServerSocket serverSocket = new ServerSocket(8080);
        //这里会阻塞一直到有连接建立
        Socket socket = serverSocket.accept();

        process(socket);
        process(socket);
        socket.close();
        serverSocket.close();
    }

    /**
     * 处理客户端输入
     * @param socket
     * @throws IOException
     */
    private static void process(Socket socket) throws IOException {
        byte[] bytes = new byte[1024];
        socket.getInputStream().read(bytes);

        int newByteLength = 0;
        for (byte b:bytes) {
            if (b != 0) {
                newByteLength++;
            }
        }
        byte[] newBytes = new byte[newByteLength];
        IntStream.range(0, newBytes.length).forEach(idx -> newBytes[idx] = bytes[idx]);
        System.out.println(new String(newBytes));
    }

}

这里我们可以看到,比如简单,就是按照固定的长度读取输入,然后拿到真正的内容(为0的我们认为他是空闲的,当然真正实现时可能不应该这样)。
而客户端我们也需要配套:

public class FixLengthClientSocketTest {

    public static void main(String[] args) throws IOException {
        //建立和服务端的连接
        Socket socket = new Socket("localhost", 8080);
        //发送消息给服务端
        process(socket);
        process(socket);

        socket.close();
    }

    /**
     * 发送消息体
     * @param socket
     * @throws IOException
     */
    private static void process(Socket socket) throws IOException {
        byte[] bytes = new byte[1024];
        byte[] exactBytes = "helloworld".getBytes();
        for (int i = 0; i < exactBytes.length; i ++) {
            bytes[i] = exactBytes[i];
        }
        socket.getOutputStream().write(bytes);
    }

}

我们把客户端每次的消息都限制为1024个byte,超出的我们也没办法处理了。这样在客户端和服务端的配合下,我们就可以保证消息被正常处理。

业界的处理方案

为了解决这个字节流在一起的问题,每次都要写那么一堆代码,这好像也不是我们想要的。所以业界的一些比较流行的框架,如netty,它会为我们做好这些事情,它提供了一些通用的处理逻辑。如:

  • FixedLengthFrameDecoder
定长解析器,类似我们上面的FixLength处理逻辑,当然,工业化的处理方式肯定没有我们上面那么简单
  • LineBasedFrameDecoder
换行解析器,类似我们上面的LineBreak处理逻辑。
  • DelimiterBasedFrameDecoder
分割符解析器,它的底层实际上也是通过LineBaseFrameDecoder,只是它可以定义多个,并且会选择一个最为合适的分割符。
  • LengthFieldBasedFrameDecoder
域长度解析器,可以理解为类似我们上面的Length的处理逻辑,当然这里的处理逻辑没那么简单,有兴趣的可以去了解一下。

当然,除了上面的一些,还有一些使用自己的处理方案的,如protobufferthrift等,他们使用自己的方案,但底层大同小异。大家可以自己了解一下。

总结

今天,我们聊了一下tcp的解析相关的,当然,主要集中在流的上面,其他的我们并没有太多涉及,我们后面有机会再细谈。

参考文章

https://www.cnblogs.com/panchanggui/p/9748204.html


shun记忆
92 声望4 粉丝