socket04---流协议和粘包问题及解决

首先来说说什么是流协议和粘包问题:

之前我们写的那几篇都是基于TCP传输协议的socket通信,而我们知道TCP传输数据是基于STREAM流的,什么意思呢?

这么说吧,TCP在传输数据的时候,是不区分边界的(数据和数据之间没有边界),因为是基于字节流,所以数据对TCP来说就是一大堆没有结构区别的字节块。那意味着什么?意味着TCP并不能对多个数据的整体的信息进行区分(打个比方:就像是你说一堆话没有标点符号全部连在一起,别人很可能弄错)或者对单个整体信息的错误区分(比如你要发送的整块数据被分成了好几块发送,然后这些数据在传输过程中可能由于网络原因,有的大数据块中被分片后的部分片段到了,可能由于接收端缓冲区满了,开始读取,而它们又没有边界之分,这时候就解释错误了)。那样就会使得我们要传输的信息可能粘连在一起,而被计算机解释错误。

这就叫粘包,而我们要怎么解决这种问题呢?
1.定长包
2.在包的尾部加上\r,\n等字符(ftp使用这种策略,如果包的内容中也包含\r,\n,这时候需要用转义字符 \ 处理)
3.包头加上包体长度(接收时先接收包头根据包头计算出包体的长度来接收包体)
4.更复杂的应用层协议

首先第一种定长包:
每一个发送的包的长度都定好,无论你一个消息包多大,到我们发送区都划成定长发送,假设我们这个定长为1024(一般编程设定可以取46~1500字节之间,低于46发送时会为你填充至46字节,超过1500传输过程中会由以太网为你分片)。
有几种可能:
1.包长小于1024,消息包最后面的那部分都用空白字节补全,凑齐1024字节这个长度,发送。
2.包长大于1024,这时包被分成多个部分,假设是2049字节,那么分成3个段,编号1:0~1023,编号2:1024~2047,编号3:(2048+填充空白位1023个字节)。最后发送的字节就是3072字节的数据。
由于这样的数据包都是定长度,不会出现像上面那样的一次发送多个具有不同数据结构的消息,也就意味这接受的数据包不能无脑地组合在一起被解释,因为单个数据包都是独立的数据结构。
最大缺陷就是它并不能发挥TCP协议的高效性,而且极大地浪费了网络流量,很多无效数据在网络上传输,不推荐使用。

第二种在包的尾部加上\r,\n等字符
我们常用的ftp服务器就是这样的设计方式,在区分包内的\r和\n等字符时候使用转义字符\解决,但是这种限制了只能做某种特殊的服务,因为我们必须保证结束符的唯一性,在通常的数据传输中,由于用户的输入是不能限定的,什么样的字符都有,所以不能随便应用。

第三种:包头加上包体长度
这是我们应用得最多的一种传输的方式,保证了TCP的高效性,而且解决了粘包问题。
下面来模拟一下这个传输方式:其实就是封装一个发送和接受的函数,然后在接受数据的时候先对接受的包头数据分析,求出包头告诉我们的实际数据的长度,再进行二次接受,那就是我们要的数据

注:这是基于前两篇的那个多用户/服务器的交互
首先看server.c:

//定义一个带len的数据包结构
struct packet
{
    int len;            //记录
    char buf[1024];     //这里的1024只做单个最长数据包最长的限定
}

//简单说下这里的ssize_t:是有符号的整形,在32位机上是4个字节的long,在64位机上是8个字节的long,这个区别暂时不多讲,之后作者会单独写一个大致的总结聊聊32和64位的由来相关和异同
//size_t是无符号的整形
//封装一个readn
ssize_t readn(int fd,void* buf,size_t count)  
{
    size_t nleft = count;  //定义一个每次读取完剩下的字节数
    ssize_t nread;
    char* bufp = (char*)buf;

    while(nleft > 0)
    {
        if((nread = read(fd,bufp,nleft)) < 0) //read若成功返回读取的字节数目
        {
            if(errno == EINTR)         //如果是被信号打断,不算错误,重新read
                continue;
            return -1;
        }
        else if(nread == 0)
            return count-nleft;         //读到的数据为空,返回0,表示连接断开
        bufp += nread;         //读完nread字节数据之后,指针往下偏移这个量,下次的&bufd就是从这里开始
        nleft -= nread;        //这里的设定是为了多次把nleft读取完,只要nleft还有即>0,就重复这个循环    
    }   
    return count;          //全部执行正确返回读取的count,一开始是4,之后是len
}

想了想还是分开来写,下面继续封装一个writen

//还是简要讲一下这个函数的功能,它是照着write的原型来的,从传入的buf这个指针所指的位置开始,发送大小为count的数据,read也是一样,同理可以知道它的作用,不同的是我们在read之上加上了返回值,实现我们需要的功能
ssize_t writen(int fd,const void* buf,size_t count)
{
    size_t nleft = count;
    ssize_t nwritten;
    char* bufp = (char*)buf;

    while(nleft > 0)
    {
        if((nwritten = write(fd,bufp,nleft)) < 0)
        {
            if(errno == EINTR)
                continue;
            return -1;
        }
        else if(nwritten == 0)
            continue;
        bufp += nwritten;
        nleft -= nwritten;
    }
    return count;
}

接下来我们将使用readn和writen应答的过程封装成一个函数,在fork子进程的时候调用—-就是之前我们写过的先读取read缓冲区,然后回射write到客户端。不同的是这里我们都是用自己封装的readn和writen,还加上了二次读取的判断。

void do_service(int conn)
{
    struct packet recvbuf;
    int n;         //这里我们可能不太能看懂这个n是什么,先记住这个在客户端操作的时候是标识发送数据包的长度,也就是用它来标识struct packet中的len,写完客户端再回来看就明白了

    while(1)
    {
        memset(&recvbuf,0,sizeof(recvbuf));
        int ret = readn(conn,&recvbuf.len,4);

        if(ret == -1)
            ERR_EXIT("readn");
        else if(ret < 4)
        {
            printf("client closed_1,原因可能是关闭了,也可能是连接断了....\n");
            break;
        }
        n = ntohl(recvbuf.len);       //将网络字节序转换为机器字节序
        ret = readn(conn,recvbuf.buf,n);       //这里的buf指针已经指向了第5个字节,经过上面第一次readn
        if(ret == -1)
            ERR_EXIT("readn");
        else if(ret < n)
        {
            printf("client closed_2,原因可能是关闭了,也可能是连接断了....\n");
            break;
        }
        fputs(recvbuf.buf,stdout);
        writen(conn,&recvbuf,4+n);     //回射客户端,写入到套接字
    }


}

最后是main中的代码—-先理清思路,前面还是一样,创建一个套接字,bind地址,监听它,之后等待accept事件发生,一旦客户端连接上来,fork一个子进程来处理读取操作,父进程继续监听是否有新的客户端连接,有的话继续fork….

//创建,bind,监听...等等代码就省略了,详情看前面几章,接下来直接写对连入的客户端的操作
int main(void)
{
    //.......略

    struct sockaddr_in peeraddr;
    socklen_t peerlen = sizeof(peeraddr);

    int conn;
    pid_t pid;

    while(1)
    {
        if((conn = accept(listenfd,(struct sockaddr*)&peeraddr,&peerlen)) < 0)
            ERR_EXIT("accept");
        printf("ip地址是:%s 端口是:%d\n",inet_ntoa(peeraddr.sin_addr),ntohs(peeraddr.sin_port));

        pid = fork();
        if(pid == -1)
            ERR_EXIT("fork");
        if(pid == 0)
        {
            close(listenfd);
            do_service(conn);
            exit(EXIT_SUCCESS);         //跳出了do_service这个循环,说明连接已经断了,直接退出子进程
        }
        else
            close(conn);
    }
    return 0;
}

整理一下上面的程序的大致流程:
1.首先封装两个函数readn和writen,作用呢是实现收发消息包的时候都能先指定发送或者读取 一定长度的数据,这里我们使用int型4个字节来记录实际要传递的数据的大小,所以收发都是先指定为4个字节。
2.收到4个字节之后,读取其中的len长度,注意这里要先将网络字节序转换为机器字节序,再执行下一次读readn,从上一个readn读到的指针处开始,指定读取字节数目是len,这一次读取会一直循环到将len长度的字节读取完。
3.读取完之后,像客户端writen总共len+4字节的recvbuf数据,可以想到客户端也是经由上面1,2两个过程去读取server发回的消息。
4.fork进程的过程就是老套路:创建->bind->监听->accept不阻塞->fork子进程开始处理…..执行上面3个过程。

OK,完了,来写我们的client端:
首先,两个readn和writen函数是完全一样的,直接粘贴过去就可以(代码重用so easy —>__—>)

//同样,定义一个数据包结构
struct packet
{
    int len;
    char buf[1024];
}

//直接写我们的主函数,因为是客户端,咱们要做的只是客户的输入即可
int main(void)
{
    //......创建,地址什么的....略
    //连接成功,开始下面的过程
    if(connect(sock,(struct sockaddr*)&servaddr,sizeof(servaddr)) < 0)
        ERR_EXIT("connect");
    //客户端有两个把,一个发送,一个是存放server回射的数据
    struct packet sendbuf;
    struct packet recvbuf;

    memset(&sendbuf,0,sizeof(sendbuf));
    memset(&recvbuf,0,sizeof(recvbuf));

    int n;
    while(fgets(sendbuf.buf,sizeof(sendbuf.buf),stdin) != NULL)
    {
        n = strlen(sendbuf.buf);
        sendbuf.len = htonl(n);           //len标识要发送数据的长度

        writen(sock,&sendbuf,4+n);

        int ret = readn(sock,&recvbuf.len,4);    //先读4个字节
        if(ret == -1)
            ERR_EXIT("readn");
        else if(ret < 4)
        {
            printf("client_close_1,原因可能是关闭了,也可能是链接断了\n");
            break;
        }
        n = ntohl(recvbuf.len);
        ret = readn(sock,recvbuf.buf,n);
        if(ret == -1)
            ERR_EXIT("readn");
        else if(ret < n)       //注意这里是丢包的时候,说明出错了
        {
            printf("client_close_2,原因可能是关闭了,也可能是链接断了\n");
            break;
        }

        fputs(recvbuf.buf,stdout);

        memset(&sendbuf,0,sizeof(sendbuf));
        memset(&recvbuf,0,sizeof(recvbuf));
    }
    close(sock);
    return 0;
}

总结下client端的流程:
1.首先老套路走一遍,之后尝试connectserver端,连接上了就阻塞在fgets,从键盘输入。
2.将输入的数据存储在包sendbuf中的buf中,sendbuf的len标识着buf的长度,也就是从键盘输入数据的长度,封装好数据包之后,执行writen,将整个sendbuf发送写入到sock。
3.发送完了,进程没啥事情可以干了,那就让它接受server回射回来的数据吧,于是接下来,先读4个字节(int型),如果,读取成功的话,将读取到的len转换为本地字节序n == ntohl(recvbuf.len),接下来继续执行一个readn,从上一个指针指向的所在开始,读取n长度个字符,完成再将其打印到标准输出。

程序运行截图:
socket04---流协议和粘包问题及解决_第1张图片

总结:
理清思路是最重要的一环,需要熟悉man帮助手册,像上面自己封装的readn和writen都是用的read和write的原型,一般清楚的函数可以直接man 函数名,man 2 函数名 —- 2表示一些系统调用内核的命令,man 3 函数名 —- 标准c库函数。其余的以后用得到再学习吧~~~

代码经过测试均可使用,有错误希望指出,谢谢!

你可能感兴趣的:(网络编程学习)