浅谈tcp流的解析

浅谈tcp流的解析

背景

曾几什么时候,咱们从一些书上看到了这样一个词——粘包。粘包,包子粘在一块儿了?这跟tcp有啥关系。
因此,咱们google了一下,跳到了百度,瞧到这样一段解释:html

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

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

聊聊tcp

基本概念

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

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

这里咱们抽取几个关键的点:网络

面向链接

这很简单理解啦,就是在要传输以前会须要先创建链接。怎么创建?三次握手啊。这个网上不少文章啦,你们能够去看看。
为何须要三次呢?我这里大概解释一下:框架

  1. 客户端发起链接,这只是一次初始的
  2. 服务端收到链接创建的要求,代表本身接收是OK的,客户端发送也是OK的,但本身的发送和客户端的接收能力是咋样的,这还不清楚。
  3. 服务端要确认一下本身的发送能力和客户端的接收能力,所以再发送一次回复进行确认。若是客户端收到了,证实两方的能力都正常,这时链接才正式创建。

可靠

为何叫可靠呢?是由于它能够帮咱们 重传数据校验。这些不在咱们这次的重点内。

基于字节流

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

所谓的“粘包”

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

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

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

简单的客户端
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长度,由于咱们但愿特地营造一种粘包的状况。ui

对于屡次tcp的发送状况来讲,咱们以两次来举例:google

  • 字节流正常

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

相关文章
相关标签/搜索