浅谈tcp流的解析

逆变苔原
• 阅读 6141

浅谈tcp流的解析

背景

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

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

看得我是一愣一愣的,TCP啥时候有包这个概念了,不是一直都是字节流吗?
浅谈tcp流的解析

聊聊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的发送情况来说,我们以两次来举例:

  • 字节流正常

浅谈tcp流的解析

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

浅谈tcp流的解析

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

浅谈tcp流的解析

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

浅谈tcp流的解析

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

由于上面的情况要一一复现会比较麻烦,我们这里就不详细写示例,大家可以类似上面去写示例。这些情况涉及到比较多的情况,包括网络顺畅情况等。
我们就来说一下为什么会出现上面的情况,如果我们的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

点赞
收藏
评论区
推荐文章
blmius blmius
4年前
MySQL:[Err] 1292 - Incorrect datetime value: ‘0000-00-00 00:00:00‘ for column ‘CREATE_TIME‘ at row 1
文章目录问题用navicat导入数据时,报错:原因这是因为当前的MySQL不支持datetime为0的情况。解决修改sql\mode:sql\mode:SQLMode定义了MySQL应支持的SQL语法、数据校验等,这样可以更容易地在不同的环境中使用MySQL。全局s
Oracle 分组与拼接字符串同时使用
SELECTT.,ROWNUMIDFROM(SELECTT.EMPLID,T.NAME,T.BU,T.REALDEPART,T.FORMATDATE,SUM(T.S0)S0,MAX(UPDATETIME)CREATETIME,LISTAGG(TOCHAR(
Wesley13 Wesley13
4年前
MySQL部分从库上面因为大量的临时表tmp_table造成慢查询
背景描述Time:20190124T00:08:14.70572408:00User@Host:@Id:Schema:sentrymetaLast_errno:0Killed:0Query_time:0.315758Lock_
Easter79 Easter79
4年前
tcp粘包与udp丢包的原因
tcp粘包与udp丢包的原因一,什么是tcp粘包与udp丢包TCP是面向流的, 流要说明就像河水一样, 只要有水, 就会一直流向低处, 不会间断. TCP为了提高传输效率, 发送数据的时候, 并不是直接发送数据到网路, 而是先暂存到系统缓冲, 超过时间或者缓冲满了, 才把缓冲区的内容发送
Wesley13 Wesley13
4年前
TCP协议粘包问题详解
TCP协议粘包问题详解前言在本章节中,我们将探讨TCP协议基于流式传输的最大一个问题,即粘包问题。本章主要介绍TCP粘包的原理与其三种解决粘包的方案。并且还会介绍为什么UDP协议不会产生粘包。基于TCP协议的socket实现远程命令输入我们准备做一个可以在Clie
Stella981 Stella981
4年前
Dubbo处理TCP拆包粘包问题
Dubbo处理TCP拆包粘包问题在TCP网络传输工程中,由于TCP包的缓存大小限制,每次请求数据有可能不在一个TCP包里面,或者也可能多个请求的数据在一个TCP包里面。那么如果合理的decode接受的TCP数据很重要,需要考虑TCP拆包和粘包的问题。我们知道在Netty提供了各种Decoder来解决此类问题,比如LineBasedFrameDecod
Stella981 Stella981
4年前
Netty中粘包和拆包的解决方案
粘包和拆包是TCP网络编程中不可避免的,无论是服务端还是客户端,当我们读取或者发送消息的时候,都需要考虑TCP底层的粘包/拆包机制。TCP粘包和拆包TCP是个“流”协议,所谓流,就是没有界限的一串数据。TCP底层并不了解上层业务数据的具体含义,它会根据TCP缓冲区的实际情况进行包的划分,所以在业务上认为,一个完整的包可能会被TCP拆分成多个包
Wesley13 Wesley13
4年前
mysql设置时区
mysql设置时区mysql\_query("SETtime\_zone'8:00'")ordie('时区设置失败,请联系管理员!');中国在东8区所以加8方法二:selectcount(user\_id)asdevice,CONVERT\_TZ(FROM\_UNIXTIME(reg\_time),'08:00','0
Wesley13 Wesley13
4年前
Vertx eventbus模块解析
eventbus事件總線協議棧TCP分包,粘包解決採用方案:消息定长(定義消息体總长度),消息分为消息头和消息体dataTypebytesdescriptionint4包体总大小code:<<buffer.setInt(0,buffer.length()4)by
Wesley13 Wesley13
4年前
34.TCP取样器
阅读文本大概需要3分钟。1、TCP取样器的作用   TCP取样器作用就是通过TCP/IP协议来连接服务器,然后发送数据和接收数据。2、TCP取样器详解!(https://oscimg.oschina.net/oscnet/32a9b19ba1db00f321d22a0f33bcfb68a0d.png)TCPClien
Python进阶者 Python进阶者
2年前
Excel中这日期老是出来00:00:00,怎么用Pandas把这个去除
大家好,我是皮皮。一、前言前几天在Python白银交流群【上海新年人】问了一个Pandas数据筛选的问题。问题如下:这日期老是出来00:00:00,怎么把这个去除。二、实现过程后来【论草莓如何成为冻干莓】给了一个思路和代码如下:pd.toexcel之前把这