zoukankan      html  css  js  c++  java
  • TCP/IP网络编程之基于TCP的服务端/客户端(二)

    回声客户端问题

    上一章TCP/IP网络编程之基于TCP的服务端/客户端(一)中,我们解释了回声客户端所存在的问题,那么单单是客户端的问题,服务端没有任何问题?是的,服务端没有问题,现在先让我们回顾下服务端的I/O代码

    echo_server.c

    ……
    while ((str_len = read(clnt_sock, messag, 1024)) != 0)
    	write(clnt_sock, messag, str_len);
    ……
    

        

    接着,我们回顾客户端的代码

    echo_client.c

    ……
    write(sock, message, strlen(message));
    str_len = read(sock, message, 1024 - 1);
    ……
    

      

    二者都在循环调用read或write函数,实际上之前的回声客户端将100%接收自己传输的数据,只不过接收数据时的单位有些问题

    观察下面的代码,我们可以知道回声客户端传输的是字符串,而且是通过调用write函数一次性发送,之后还调用了一次read函数,期待着接收自己传输的字符串,这就是问题所在

    echo_client.c

    ……
    while (1)
    {
    	fputs("Input message(Q to quit):", stdout);
    	fgets(message, 1024, stdin);
    	if (!strcmp(message, "q
    ") || !strcmp(message, "Q
    "))
    		break;
    	write(sock, message, strlen(message));
    	str_len = read(sock, message, 1024 - 1);
    	message[str_len] = 0;
    	printf("Message from server:%s", message);
    }
    ……
    

      

    既然回声客户端会收到所有字符串数据,是否只需多等一会?过一段时间再调用read函数是否可以一次性读取所有的字符串内容?的确,过一段时间后即可接收,但需要多久?1秒还是1分钟?没人知道。而且这也不符合常理,正常应该客户端在收到字符串数据时立即读取并输出

    其实问题很容易解决,因为可以提前确定接收数据的长度,若之前传输了20个字节长的字符串,则在接收时循环调用read函数读取20个字节即可,下面,我们解决方案的代码

    echo_client2.c

    #include <stdio.h>
    #include <stdlib.h>
    #include <string.h>
    #include <unistd.h>
    #include <arpa/inet.h>
    #include <sys/socket.h>
    #define BUF_SIZE 1024
    void error_handling(char *message);
    
    int main(int argc, char *argv[])
    {
        int sock;
        char message[BUF_SIZE];
        int str_len, recv_len, recv_cnt;
        struct sockaddr_in serv_adr;
    
        if (argc != 3)
        {
            printf("Usage:%s<IP><port>
    ", argv[0]);
            exit(1);
        }
    
        sock = socket(PF_INET, SOCK_STREAM, 0);
        if (sock == -1)
            error_handling("socket()error");
    
        memset(&serv_adr, 0, sizeof(serv_adr));
        serv_adr.sin_family = AF_INET;
        serv_adr.sin_addr.s_addr = inet_addr(argv[1]);
        serv_adr.sin_port = htons(atoi(argv[2]));
    
        if (connect(sock, (struct sockaddr *)&serv_adr, sizeof(serv_adr)) == -1)
            error_handling("connect()error");
        else
            puts("Connected..........");
    
        while (1)
        {
            fputs("Input message(Q to quit):", stdout);
            fgets(message, BUF_SIZE, stdin);
            
            if (!strcmp(message, "q
    ") || !strcmp(message, "Q
    "))
                break;
    
            str_len = write(sock, message, strlen(message));
    
            recv_len = 0;
            while (recv_len < str_len)
            {
                recv_cnt = read(sock, &message[recv_len], BUF_SIZE - 1);
                if (recv_cnt == -1)
                    error_handling("read()error!");
                recv_len += recv_cnt;
            }
    
            message[recv_len] = 0;
            printf("Message from server:%s", message);
        }
        close(sock);
        return 0;
    }
    
    void error_handling(char *message)
    {
        fputs(message, stderr);
        fputc('
    ', stderr);
        exit(1);
    }
    

      

    在45~56行是变更及添加部分,echo_client.c仅调用了一次read函数,上述示例为了接收所有传输数据而循环调用read函数。另外代码48行还可以写成如下形式:

    while (recv_len != str_len)
    {
    	……
    }
    

      

    接收的数据大小应和传输的相同,因此recv_len中保存的值等于str_len保存的值时,即可跳出while循环

    回声客户端可以提前知道接收数据的长度,但这只是特例,多数情况下,我们是不知道接收数据的长度,那么我们应该如何收发数据?此时需要的就是应用层协议的定义,之前回声服务端/客户端中曾经定义:收到Q就立即终止连接

    同样,收发数据过程中也需要定好规则(协议)以表示数据的边界,或提前告知收发数据的大小。服务端/客户端实现过程中逐步定义的这些规则集合就是应用层协议,可以看出应用层协议并不是什么高深的技术,仅仅是为了特定程序而制定的规则

    下面我们就来编写一个应用层协议的程序,该程序中服务端从客户端获得多个数字和运算符信息。服务器端收到这些信息后根据运算符对数字做处理再返回给客户端,例如:客户端向服务端传递3、5、9的同时请求加法运算,那么服务端做完3+5+9=17的结果后会返回给客户端

    op_client.c

    #include <stdio.h>
    #include <stdlib.h>
    #include <string.h>
    #include <unistd.h>
    #include <arpa/inet.h>
    #include <sys/socket.h>
    #define BUF_SIZE 1024
    #define RLT_SIZE 4
    #define OPSZ 4
    void error_handling(char *message);
    int main(int argc, char *argv[])
    {
        int sock;
        char opmsg[BUF_SIZE];
        int result, opnd_cnt, i;
        struct sockaddr_in serv_adr;
        if (argc != 3)
        {
            printf("Usage:%s <IP><port>
    ", argv[0]);
            exit(1);
        }
        sock = socket(PF_INET, SOCK_STREAM, 0);
        if (sock == -1)
        {
            error_handling("socket() error");
        }
        memset(&serv_adr, 0, sizeof(serv_adr));
        serv_adr.sin_family = AF_INET;
        serv_adr.sin_addr.s_addr = inet_addr(argv[1]);
        serv_adr.sin_port = htons(atoi(argv[2]));
    
        if (connect(sock, (struct sockaddr *)&serv_adr, sizeof(serv_adr)) == -1)
            error_handling("connect() error!");
        else
            puts("Connected......");
        fputs("Operand count:", stdout);
        scanf("%d", &opnd_cnt);
        opmsg[0] = (char)opnd_cnt;
    
        for (i = 0; i < opnd_cnt; i++)
        {
            printf("Operand %d:", i + 1);
            scanf("%d", (int *)&opmsg[i * OPSZ + 1]);
        }
        fgetc(stdin);
        fputs("Operator:", stdout);
        scanf("%c", &opmsg[opnd_cnt * OPSZ + 1]);
        write(sock, opmsg, opnd_cnt * OPSZ + 2);
        read(sock, &result, RLT_SIZE);
        printf("Operation result:%d
    ", result);
        close(sock);
        return 0;
    }
    void error_handling(char *message)
    {
        fputs(message, stderr);
        fputc('
    ', stderr);
        exit(1);
    }
    

      

    • 第8、9行:将待计算的数字的字节数和运算结果的字节数设为常数
    • 第14行:为收发数据准备的内存空间,需要数据积累到一定程度后再收发,因此通过数组创建
    • 第37、38行:从用户的输入中得到待算数个数后,保存至数组opmsg。强制转换成char类型,因为协议规定待算数个数应通过一个字节整数型传递,因此不能超过一个字节整数型能够表示的范围。示例中用的是有符号整数型,但待算数个数不能是负数,因此使用无符号整数型更合理
    • 第40~44行:从用户的输入中得到待算整数,保存到数组opmsg。4字节int型数据要保存到保存到char数组,因而在转换成int指针类型
    • 第45行:第47行中输入字符,在此之前调用fgetc函数删掉缓冲中的字符' '
    • 第47行:最后输入运算符信息,保存到opmsg数组
    • 第48行:调用write函数一次性传输opmsg数组中的运算相关信息,可以调用一次write函数进行传输,也可以分多次调用
    • 第49行:保存服务端传输的运算结果,待接收的数据长度为4字节,因此调用一次read函数即可接收

     

    图1-1   客户端op_client.c的数据传送格式

    从图1-1可以看出,若想在同一数组中保存并传输多种数据类型,应把数组声明为char类型。而且需要额外做一些指针及数组运算。接下来给出服务端代码:

    op_server.c

    #include <stdio.h>
    #include <stdlib.h>
    #include <string.h>
    #include <unistd.h>
    #include <arpa/inet.h>
    #include <sys/socket.h>
    #define BUF_SIZE 1024
    #define OPSZ 4
    void error_handling(char *message);
    int calculate(int opnum, int opnds[], char operator);
    
    int main(int argc, char *argv[])
    {
        int serv_sock, clnt_sock;
        char opinfo[BUF_SIZE];
        int result, opnd_cnt, i;
        int recv_cnt, recv_len;
        struct sockaddr_in serv_adr, clnt_adr;
        socklen_t clnt_adr_sz;
        if (argc != 2)
        {
            printf("Usage:%s<port>
    ", argv[0]);
            exit(1);
        }
        serv_sock = socket(PF_INET, SOCK_STREAM, 0);
        if (serv_sock == -1)
            error_handling("socket() error");
        memset(&serv_adr, 0, sizeof(serv_adr));
        serv_adr.sin_family = AF_INET;
        serv_adr.sin_addr.s_addr = htonl(INADDR_ANY);
        serv_adr.sin_port = htons(atoi(argv[1]));
        if (bind(serv_sock, (struct sockaddr *)&serv_adr, sizeof(serv_adr)) == -1)
            error_handling("bind() error");
        if (listen(serv_sock, 5) == -1)
            error_handling("listen() error");
        clnt_adr_sz = sizeof(clnt_adr);
    
        for (i = 0; i < 5; i++)
        {
            opnd_cnt = 0;
            clnt_sock = accept(serv_sock, (struct sockaddr *)&clnt_adr, &clnt_adr_sz);
            read(clnt_sock, &opnd_cnt, 1);
    
            recv_len = 0;
            while ((opnd_cnt * OPSZ + 1) > recv_len)
            {
                recv_cnt = read(clnt_sock, &opinfo[recv_len], BUF_SIZE - 1);
                recv_len += recv_cnt;
            }
            result = calculate(opnd_cnt, (int *)opinfo, opinfo[recv_len - 1]);
            write(clnt_sock, (char *)&result, sizeof(result));
            close(clnt_sock);
        }
        close(serv_sock);
        return 0;
    }
    int calculate(int opnum, int opnds[], char op)
    {
        int result = opnds[0], i;
        switch (op)
        {
        case '+':
            for (i = 1; i < opnum; i++)
                result += opnds[i];
            break;
        case '-':
            for (i = 1; i < opnum; i++)
                result -= opnds[i];
            break;
        case '*':
            for (i = 1; i < opnum; i++)
                result *= opnds[i];
            break;
        }
        return result;
    }
    void error_handling(char *message)
    {
        fputs(message, stderr);
        fputc('
    ', stderr);
        exit(1);
    }
    

      

    • 第38行:为了接收5个客户端的连接请求而编写的for语句
    • 第42行:首先接收待算数个数
    • 第45~49行:根据第42行中的待算数个数接收待算数
    • 第50行:调用calculate函数的同时传递待算数和运算符信息参数
    • 第51行:向客户端传输calculate函数返回的运算结果

    编译op_server.c并运行

    # gcc op_server.c -o op_server
    # ./op_server 8500
    

      

    编译op_client.c并运行

    # gcc op_client.c -o op_client
    # ./op_client 127.0.0.1 8500
    Connected......
    Operand count:3
    Operand 1:1
    Operand 2:2
    Operand 3:3
    Operator:+
    Operation result:6
    # ./op_client 127.0.0.1 8500
    Connected......
    Operand count:2
    Operand 1:2
    Operand 2:15
    Operator:*
    Operation result:30
    

      

    TCP原理

    之前我们说过,TCP套接字的数据收发无边界,服务端即使调用一次write函数传输60个字节的数据,客户端也有可能分3次调用read函数,每次读取20个字节的数据。但此处也有些疑问,服务端一次性传输60个字节的数据,而客户端分批读取。客户端在读取20个字节后,剩下的40个字节在何处等候呢?实际上,write函数调用后并非立即传输数据,read函数调用后也并非马上接收数据。更准确地说,图1-2所示,write函数调用瞬间,数据将移至输出缓冲;read函数调用瞬间,从输入缓冲读取数据

    图1-2   TCP套接字的I/O缓冲

    如图1-2所示,调用write函数时,数据将移到输出缓冲,在适当的时候(不管是分别传送还是一次性传送)传向对方的输入缓冲。这时对方将调用read函数从输入缓冲读取数据。这些I/O缓冲特性如下:

    • I/O缓冲在每个TCP套接字中单独存在
    • I/O缓冲在创建套接字时自动生成
    • 即使关闭套接字也会继续传递输出缓冲中遗留的数据
    • 关闭套接字将丢失输入缓冲中的数据

    如果客户端输入缓冲有50个字节,但服务端却有100个字节需要传输,是否会造成数据丢失?之前说过TCP是可靠的,不会发生超过输入缓冲大小的数据传输,因为TCP会控制数据流,其中有滑动窗口协议,接收数据的套接字每次会告诉发送数据的套接字可传递的最大字节数,于是发送数据的套接字收到这个数字后传递等长度大小的数据,待接收数据的套接字发现缓冲中腾出更多位置,会告诉发送数据的套接字,接收更多的数据。因此,TCP不会因为缓冲满了而丢失数据

    TCP内部工作原理1:与对方套接字的连接

    TCP套接字从创建到消失所经过程分为如下三步:

    1. 与对方套接字建立连接
    2. 与对方套接字进行数据交换
    3. 断开与对方套接字的连接

    TCP在实际通信过程中也会经历三次对话过程,因此,该过程又称为Three-way handshake(三次握手),如图1-3所示:

    图1-3   TCP套接字的连接设置过程

    套接字是以双全工方式工作的,也就是说它可以双向传递数据。因此,收发数据前需要做一些准备,首先,请求连接的主机A向主机B传递如下消息:

    [SYN] SEQ:1000, ACK:-

    该消息中SEQ为1000,ACK为空,而SEQ为1000的含义是:主机A现传递的数据包序号为1000给主机B,如果接收无误,请通知主机A向主机B传递1001号数据包。这是首次请求连接时使用的消息,又称SYN。SYN是Synchronize的简写,表示收发数据前传输的同步消息。接下来,主机B向主机A传递消息:[SYN+ACK] SEQ:2000, ACK:1001。此时SEQ为2000,ACK为1001,而SEQ具体含义和之前一样:主机B现传递的数据包序号为2000给主机A,如果接收无误,请通知主机B向主机A传递2001号数据包

    而ACK 1001的含义为:刚才传输的SEQ为1000的数据包接收无误,请传递SEQ为1001的数据包。对于主机A首次传输的数据包的确认消息(ACK 1001)和主机B传输数据做准备的同步消息(SEQ 2000)捆绑发送,因此,此种类型的消息又称SYN+ACK

    收发数据前向数据包分配序号,并向对方通报此序号,这都是为防止数据丢失所做的准备。通过向数据包分配序号并确认,可以在数据丢失时马上查看并重传丢失的数据包。因此,TCP可以保证可靠的数据传输。最后观察主机A向主机B传输消息:[ACK] SEQ: 1001, ACK: 2001。我们都明白SEQ和ACK的含义了,主机A现在向主机B传递1001号数据包,并表示2001号数据包接收无误。至此,主机A和主机B确认了彼此均就绪

    TCP内部工作原理2:与对方主机的数据交换

    通过第一步三次握手过程完成了数据交换准备,下面就开始正式收发收据。其默认方式如图1-4所示:

    图1-4   TCP套接字的数据交换过程

    图1-4给出了主机A分两次向主机B传输各100字节的过程。首先主机A通过一个数据包发送100个字节的数据,数据包的SEQ为1200。主机B为了确认这一点,向主机A发送ACK1301消息。此时的ACK号为1301而非1201,原因在于ACK号的增量为传输的数据字节数。假设每次ACK号不加传输的字节数,这样虽然可以确认数据包到达目标主机,但无法明确100字节全部正确到达还是丢失了一部分。因此ACK消息公式为:ACK号 = SEQ号 + 传递字节数 + 1

    与三次握手协议相同,最后加一是为了告知对方下次要传递的SEQ号。下面分析传输过程中数据包消失的情况,如图1-5:

    如图1-5   TCP套接字数据传输过程中发生错误

    图1-5表示通过SEQ 1301数据包向主机B传递100字节数据。但中间发生了错误,主机B未收到,经过一段时间,主机A仍为收到对于SEQ 1301的ACK确认,因此试着重传该数据包。为了完成数据包的重传,TCP套接字启动计时器以等待ACK应答。若计时器发生超时则重传

    TCP的内部工作原理3:断开与套接字的连接

    TCP套接字的结束过程也与之前相似,如果对方还有数据需要传输时直接断掉连接会出现问题,所以断开连接时需要双方协商,先由套接字A向套接字B传递断开连接的消息,套接字B发出确认收到的消息,然后向套接字A传递可以断开连接的消息,套接字A同样发出确认的消息,如图1-6所示:

    图1-6   TCP套接字断开连接过程

    图1-6数据包内的FIN表示断开连接。也就是说,双方各发送一次FIN消息后断开连接,此过程经历四个阶段,因此称为四次握手(Four-way handshaking)。图1-6向主机A传递两次ACK 5001,也许这会让大家感到困惑,这里做一下解答:主机A向主机B发送FIN消息,告诉主机B自己没有数据可以发送,但如果主机B还有数据没发送完,可以不必急着关闭套接字,可以继续发送数据。于是,主机B发送ACK。当主机B确认数据发送完毕,则向主机A发送FIN消息,告诉主机A数据全部发送关闭,准备关闭连接。主机A收到FIN消息,还是不相信网络,怕主机B不知道要关闭,所以发送ACK,如果主机B没有收到可以重传。主机B收到ACK后,就知道可以断开连接了,于是主机A等待在超时时间内没有收到回复, 则证明主机B已关闭连接,于是主机A也跟着关闭连接

  • 相关阅读:
    《把时间当作朋友》读书笔记
    Oracle&SQLServer中实现跨库查询
    Android学习——界面互调2
    《IT不再重要》读后感
    Android学习——数据存储
    Android学习——编写菜单
    Android学习——后台程序
    Android学习——写个小实例
    Android学习——界面编程!
    深入理解JavaScript系列(42):设计模式之原型模式
  • 原文地址:https://www.cnblogs.com/beiluowuzheng/p/9656284.html
Copyright © 2011-2022 走看看