第四章:基于TCP的服务器端/客户端
通过套接字收发数据。
4、1 理解 TCP 和 UDP
-
根据数据传输的方式不同,套接字一般分为TCP套接字和UDP套接字。TCP套接字是面向连接的,所以又称为基于流的套接字。
-
TCP(Transmission Control Protocol)传输控制协议, 意为“对数据传输过程的控制。”
-
TCP/IP协议栈:
- TCP/IP协议栈共分为4层,可以理解为数据收发分成了4个层次化过程。要解决“基于互联网的有效数据传输”的问题,通过TCP套接字收发数据需要借助这4层。基于UDP套接字收发数据时,利用右边4层协议栈完成。各层可能通过操作系统等软件实现,也可能通过类似NIC的硬件设备实现。
- 依多个标准设计的系统称为开放式系统。
-
链路层:
- 链路层是物理链接领域标准化的结果,定义各种网络标准,负责主机通过网络数据交换时的物理连接标准。
-
IP层:
- 解决的问题是:向目标传输数据需要经过哪条路径,即路径的选择,该层使用的协议就是IP。
- IP本身是面向消息的、不可靠的协议。每次数据传输都会选择路径但并不一致,传输过程中发生路径错误还会选择其他路径,但是数据缺失或错误则无法解决。IP协议无法应对数据错误。
-
TCP/UDP层:
- TCP/UDP层以IP层提供的路径信息为基础完成实际的数据传输,所以又称为传输层(Transport)。TCP可以保证可靠的数据传输,但是其发送数据时以IP层为基础。
- IP层只关注1个数据包(数据传输的基本单位)的传输过程。即使传输多个数据包,每个数据包也是由IP层实际传输的,即传输顺序及传输本身是不可靠的。可能后传输的数据先到或者发生数据包丢失等情况。
- TCP的作用:数据交换过程中可以确认对方已收到数据,并重传丢失的数据。TCP/UDP存在于IP层之上,决定主机之间的数据传输方式,TCP协议确认后向不可靠的IP协议赋予可靠性。
-
应用层:
- 选择数据传输路径、数据确认过程都被隐藏到套接字内部了。编写软件过程中,根据程序特点决定服务器端和客户端之间的数据传输规则这就是应用层协议。
4、2 实现基于TCP的服务器端/客户端:(理解套接字使用方法及数据传输 方法)
-
TCP服务器端的默认函数调用顺序
-
进入等待连接请求状态:
-
调用listen函数进入等待连接请求状态,只有调用了listen函数,客户端才能调用connect函数,提前调用将发生错误。
-
#include <sys/socket.h> int listen(int sock, int backlog); // 成功时返回0,失败时返回-1
- sock :希望进入等待连接请求状态的套接字文件描述符,传递的套接字参数成为服务器端套接字。
- backlog:连接请求等待队列(Queue)的长度,若为5则队列长度为5表示最多使5个连接请求进入队列。
-
服务器端处于等待连接请求状态:客户端请求连接时,受理连接前一直使请求处于等待状态。服务器端套接字是接收连接请求的一扇门。listen函数的第二个参数决定了等候室的大小,等候室称为连接请求等待队列,准备好服务器端套接字和连接请求等待队列后,这种可接收连接请求的状态称为等待连接请求状态。
-
listen函数的第二个参数值与服务器端特性有关,频繁接收请求的Web服务器端至少是15
-
-
受理客户端连接请求:
-
调用listen函数后,有新的连接请求,应按序受理。受理请求意味着进入可接受数据的状态。accept函数自动创建套接字并连接到发起请求的客户端。
-
#include <sys/socket.h> int accept(int sock,struct sockaddr *addr,socklen_t *addrlen);// 成功返回创建的套接字文件描述符,失败返回-1.
- sock :服务器套接字的文件描述符。
- addr :保存发起连接请求的客户端地址信息的变量地址值,向传递来的地址变量参数填充客户端地址信息。
- addrlen :第二个参数addr结构体长度,存有长度的变量地址,函数调用完,变量即被填入客户端地址长度。
-
accept 函数受理连接请求等待队列中待处理的客户端连接请求。函数调用成功,accept函数内部产生用于数据I/O的套接字,并返回其文件描述符。套接字自动创建,自动与发起连接请求的客户端建立连接。
-
从队列中取出1个连接请求,创建套接字并完成连接请求,服务器端单独创建的套接字与客户端建立连接后进行数据交换。
-
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <sys/socket.h> #include <unistd.h> #include <arpa/inet.h> void error_handling(char*message); int main(int argc,char *argv[]){ int serv_sock; int clnt_sock; // 定义在头文件 <arpa/inet.h>中,定义了Inernet socket address // sockaddr_in表示IPv4地址的结构体 struct sockaddr_in serv_addr; struct sockaddr_in clnt_addr; socklen_t clnt_addr_size; char message[] = "Hello World!"; if(argc != 2){ printf("Usage : %s <port>\n",argv[0]); exit(1); } // PF_INET ,IP协议簇,#define PF_INET 2, PF_INET表示IPv4 // SOCK_STREAM 有序的、可信赖的、基于连接的字节流,TCP连接 // 调用socket函数生成服务器socket套接字,此时套接字并不是真正的服务器端套接字。 serv_sock = socket(PF_INET,SOCK_STREAM,0); // socket函数成功返回文件描述符,失败返回-1 if(serv_sock == -1) error_handling("socket() error"); // 为完成套接字地址分配,初始化结构体遍历并调用bind函数。 // sin_family 端口号,#define AF_INET PF_INET memset(&serv_addr,0,sizeof(serv_addr)); // 地址族选择IPv4地址族 serv_addr.sin_family = AF_INET; // 32位ip地址 serv_addr.sin_addr.s_addr = htonl(INADDR_ANY); // 端口号 serv_addr.sin_port = htons(atoi(argv[1])); // bind函数将创建好的套接字分配IP地址和端口号 if(bind(serv_sock,(struct sockaddr*) &serv_addr,sizeof(serv_addr)) == -1) error_handling("bind() error"); // listen函数将套接字转化为可接受连接状态,此时的套接字才是服务器端套接字。 if(listen(serv_sock,5) == -1) error_handling("listen() error"); clnt_addr_size = sizeof(clnt_addr); // accept函数受理连接请求,如果在没有连接请求的情况下调用该函数,则不会返回,直到有连接请求为止 // 调用accept函数从队头取1个请求连接与客户端建立连接,返回创建的套接字文件描述符。请求队列为空,不返回 clnt_sock= accept(serv_sock,(struct sockaddr*)&clnt_addr,&clnt_addr_size); if (clnt_sock == -1) error_handling("accept() error"); //write 函数用于传输数据,若程序经过 accept 这一行执行到本行,则说明已经有了连接请求 write(clnt_sock, message, sizeof(message)); close(clnt_sock); close(serv_sock); return 0; } void error_handling(char *message) { // 将字符串写入 stderr 流中 fputs(message, stderr); // 将字符写入erroe stream中 fputc('\n', stderr); exit(1); }
-
-
TCP客户端的默认函数调用顺序:
- 创建套接字和请求连接就是客户端的全部内容。
-
客户端的“请求连接”是创建客户端套接字后向服务器端发起的连接请求,服务器端调用listen函数后创建连接请求等待队列,之后客户端即可请求连接。通过调用connect函数发起连接请求。
-
#include <sys/socket.h> int connect (int sock,struct sockaddr * servaddr,socklen_t addrlen); // 成功时返回0,失败返回-1
- sock:客户端套接字文件描述符
- servaddr 保存目标服务器地址信息的变量地址值。
- addrlen :以字节为单位已传递给第二个结构体参数servaddr的地址变量长度。
-
客户端调用connect函数后,发生以下情况之一才会返回(完成函数调用)
- 服务器端接收连接请求(不是说服务器端调用accept函数,而是服务器端把连接请求信息记录到等待队列,connect函数返回后并不立即进行数据交换。)
- 发生断网等异常情况而中断连接请求。
-
网络数据交换必须分配IP和端口,客户端何时、何地、如何分配地址:
- 何时:调用connect函数时
- 何地:操作系统或者说在内核
- 如何:IP用计算机的IP,端口随机
-
客户端的IP地址和端口在调用connect函数时自动分配,无需调用标记的bind函数进行分配。
-
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <arpa/inet.h> #include <sys/socket.h> void error_handing(char *message); int main(int argc,char*argv[]){ int sock; struct sockaddr_in serv_addr; char message[30]; int str_len = 0; int idx = 0, read_len = 0; if(argc!= 3){ printf("Usage : %s <IP> <port>\n",argv[0]); exit(1); } // 创建 IPv4协议族中面向连接的套接字, 创建TCP套接字,前两个参数是PF_INET、SOCK_STREAM时,可以省略IPPROTO_TCP // 创建准备连接服务器端的套接字,此时创建的是TCP套接字 sock = socket(PF_INET,SOCK_STREAM,IPPROTO_TCP); if(sock == -1) error_handing("socket() error"); // 结构体变量serv_addr中初始化IP和端口信息,初始化值为目标服务器端套接字的IP和端口信息 memset(&serv_addr, 0, sizeof(serv_addr)); serv_addr.sin_family = AF_INET; serv_addr.sin_addr.s_addr = inet_addr(argv[1]); serv_addr.sin_port = htons(atoi(argv[2])); // connect函数向服务器发送连接请求 if(connect(sock,(struct sockaddr*)&serv_addr,sizeof(serv_addr)) == -1) error_handing("connect() error"); // read函数读取数据,成功时返回接收的字节数,失败返回-1 // 反复调用read函数,每次读取1字节,read返回0,说明遇到了文件结尾, // read函数:完成连接后,接收服务器端传输的数据 while(read_len=read(sock,&message[idx++],1)){ if(read_len == -1) error_handing("read() error"); // 累加read调用接收到的信息的字节数。 str_len += read_len; } printf("Message from server : %s \n", message); // 由于一次读取一个字节,所以传递了多少数据说明调用了多少此read printf("Function read call count : %d \n",str_len); // 接受完数据后调用close函数关闭套接字,结束与服务器端的连接 close(sock); return 0; } void error_handing(char *message){ fputs(message,stderr); fputc('\n',stderr); exit(1); }
-
基于TCP的服务器端/客户端函数调用关系
- 总体流程:服务器端创建套接字后连续调用bind、listen函数进入等待状态,客户端通过调用connect函数发起连接请求。客户端只能等到服务器端调用listen函数后才能调用connect函数。客户端调用connect函数前,服务器端有可能率先调用accept函数,服务器在调用accept函数时进入阻塞状态,直到客户端调用connect函数为止。
4、3 实现迭代服务器端/客户端
- 设置好等待队列的大小后,应向所有客户端提供服务。如果想继续受理后续的客户端连接请求,可以通过插入循环语句反复调用accept函数。
-
调用accept函数后,调用I/O相关的read、write函数,然后调用close函数,close函数针对accept函数调用时创建的套接字。调用close意味结束某一客户端服务,若还想服务于其他客户端,需要重新调用accept函数。
-
迭代回声服务器端/客户端:
-
服务器端在同一时刻只与一个客户端相连,提供回声服务。
-
服务器端依次向5个客户端提供服务并退出
-
客户端接收用户输入的字符串并发送到服务器端
-
服务器端将接收的字符串数据传回客户端即“回声”
-
服务器端与客户端之间的字符串回声一直执行到客户端输入Q为止。
-
服务器端代码:
-
#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(const char *message); int main(int argc,char *argv[]){ int serv_sock, clnt_sock; char message[BUF_SIZE]; int str_len, i; struct sockaddr_in serv_adr, clnt_adr; socklen_t clnt_adr_sz; if(argc != 2){ printf("Usage : %s <port> \n", 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); // 为处理5个客户端连接而添加的循环语句,调用5次accept函数,依次向5个客户端提供服务。 for(i = 0; i < 5; i++){ clnt_sock = accept(serv_sock,(struct sockaddr*)&clnt_adr,&clnt_adr_sz); if(clnt_sock == -1) error_handling("accept() error"); else printf("Connected client %d \n",i+1); // 完成回声服务,原封不动的传输读取的字符串 while((str_len = read(clnt_sock,message,BUF_SIZE))!= 0) write(clnt_sock,message,str_len); // 针对套接字调用close函数,向连接的相应套接字发送EOF。 close(clnt_sock); } // 完成5个客户端提供服务后关闭服务器端套接字并终止程序 close(serv_sock); return 0; } void error_handling(const char *message){ fputs(message,stderr); fputc('\n',stderr); exit(1); }
-
运行结果:
-
-
客户端代码:
-
#include <stdio.h> #include <stdlib.h> #include <string.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; struct sockaddr_in serv_adr; if(argc != 3){ printf("Usage : %s <IP> <port>\n",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])); // 调用coonect函数,引起的连接请求被注册到服务器端等待队列,connect函数完成正常调用,即使代码输出了连接提示字符,如果服务器端未调用accept函数,也不是真正建立服务关系 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\n") || !strcmp(message,"Q\n")) break; write(sock,message,strlen(message)); str_len = read(sock,message,BUF_SIZE-1); message[str_len] = 0; printf("Message from server: %s",message); } close(sock); return 0; } void error_handling(char*message){ fputs(message,stderr); fputc('\n',stderr); exit(1); }
-
运行结果:
-
回声服务器端/客户端以字符串为单位传递数据。错误的假设:“每次调用read、write函数都会以字符串为单位执行实际的I/O操作。”
-
由于客户端是基于TCP的,TCP不存在数据边界,调用write函数传递的字符串可能一次性传递到服务器端,客户端可能从服务器端收到多个字符串。还有可能服务器端调用1次write函数传输数据,数据大时操作系统会将多个数据包发送到客户端,客户端可能再未收到数据包时就调用read函数。
-
-