【TCP/IP网络编程】:04基于TCP的服务器端/客户端
结合前面所讲述的知识,本篇文章主要介绍了简单服务器端和客户端实现的框架流程及相关函数接口。
理解TCP和UDP
根据数据传输方式的不同,基于网络协议的套接字一般分为TCP套接字和UDP套接字(本系列文章主要围绕TCP的内容讲解)。
TCP(Transmission Control Protocol)即传输控制协议,意为“对数据传输过程的控制”。因此,关注控制方法及范围有助于正确理解TCP套接字。
TCP/IP协议栈
TCP/IP协议栈共分为4层,可以理解为将数据收发分为了4个层次化的过程,如下图所示。各层可以通过操作系统等软件实现,也可通过类似NIC的硬件设备实现。相较于数据通信过程的7层协议栈(OSI 7层模型),对于普通程序员来说掌握这四层就可以了。
TCP/IP协议栈
TCP/IP协议的诞生背景
“通过因特网完成有效的数据传输”这一课题是涉及到了硬件、系统、路由算法等各个领域的一个大系统。因此,当时相关领域的专家就聚在一起讨论,确定将这一大课题按不同领域分成若干小模块,这就出现了多种协议,它们通过层级结构建立了紧密联系。
将协议分为多个层次有很多优点,最重要的原因是为了通过标准化操作设计开放式系统。标准本身就在于对外公开,引导更多人遵守规范。其中,以多个标准为依据设计的系统称为开放式系统,TCP/IP协议栈便是其中之一。
链路层
链路层是物理链接领域标准化的结果,也是最基本的领域,专门定义LAN、WAN、MAN等网络标准。若两台主机通过网络进行数据交换,则需要通过下图所示的物理连接,链路层就负责这些标准。
网络连接结构
IP层
准备好物理连接后就需要传输数据,而在复杂的网络中传输数据,首先就是要考虑通过哪条路径将数据传输至目标主机?这就是IP层协议解决的问题。
IP本身是面向消息、不可靠的协议,因此,IP协议无法应对各种可能的数据错误。
TCP/UDP层
TCP和UDP层以IP层提供的路径信息为基础完成实际的数据传输,故该层又称为传输层。IP层只关注1个数据包(数据传输的基本单位)的传输过程,对于多个数据包的传输也是由IP层完成对每个数据包的实际传输。因此,正如前面所述,IP层对数据传输的过程并不可靠。而TCP协议的性质则向不可靠的IP协议赋予了可靠性,下图是TCP对网络丢包的处理。
TCP协议
应用层
以上协议的处理过程都是套接字通信自动处理的,如选择数据传输路径、数据确认过程,这些都被隐藏到了套接字内部。编写软件的过程中,需要根据程序特点决定服务器端和客户端之间的数据传输规则,这便是应用层协议。而网络编程的大部分内容就是设计并实现应用层协议。
实现基于TCP的服务器端/客户端
TCP服务器端的默认函数调用
大部分服务器端默认函数调用都是按照下图所示的顺序来执行的,其中socket及bind函数前文已有介绍,下面介绍之后的实现过程。
TCP服务器端函数调用顺序
进入等待连接请求状态 - listen
调用listen函数使服务器端进入等待连接请求的状态,此时客户端才能调用connect函数进入发出连接请求的状态,若提前调用connect则会报错(Connection refused)。
#include <sys/socket.h> int listen(int sock, int backlog); -> 成功时返回0,失败时返回-1
其中,backlog为连接请求等待队列的长度,若为N则表示最多使N个连接请求进入队列(连接请求等待队列又分为已连接和未连接等待队列,这里backlog表示已连接等待队列长度,其实目前并未有对backlog参数的确切定义,需要根据实际环境确定)。“服务器端处于等待连接请求状态”是指,客户端连接请求时,受理连接前一直使连接请求处于等待状态,该过程如下图所示。
等待连接请求状态
listen函数的第一个参数是服务器端套接字,如同一个门卫监听到来的连接请求,并将这些请求送往连接请求等候室;第二个参数与服务器的特性有关,根据服务器的工作性质来决定适当的队列大小值。
受理客户端连接请求 - accept
调用listen函数后,若有连接请求则按序受理,受理请求则意味着进入可收发数据的状态。监听套接字已有自己的工作职责,此时需要创建一个新的会话套接字来服务发起连接的客户端套接字。
#include <sys/socket.h> int accept(int sock, struct sockaddr *addr, socklen_t *addrlen); -> 成功时返回创建的套接字文件描述符,失败时返回-1
第一个参数是服务器套接字文件描述符;第二个参数用于保存客户端地址信息;第三个参数用于保存客户端地址信息的长度,但首先需要传入地址信息结构长度信息。
accept函数受理连接请求等待队列中待处理的客户端连接请求,成功时返回新生成的用于数据I/O套接字的文件描述符。该I/O套接字是自动创建的,且已自动建立了与发起连接请求的客户端之间的连接。accept函数的调用过程如下图所示。
受理连接请求状态
调用accept函数会从等待连接请求队列头处取1个连接请求与客户端建立连接,并返回创建的套接字文件描述符。如果此时等待队列为空,则accept函数会发生阻塞,直到队列中出现新的客户端连接。
TCP客户端的默认函数调用顺序
客户端的函数调用相较于服务器端要简单许多,因为套接字创建和连接请求便是一个简单客户端的全部内容,其函数调用过程如下。
TCP客户端函数调用顺序
服务器端调用listen函数创建连接请求队列,之后客户端即可发起请求连接。
#include <sys/socket.h> int connect(int sock, struct sockaddr *servaddr, socklen_t addrlen); -> 成功时返回0,失败时返回-1
客户端调用connect函数后,发生以下情况之一时才会返回:
a. 服务器端接收连接请求
b. 发生断网等异常情况而中断连接请求
所谓“接收连接”并不意味着服务器端需要调用accept函数,其实是服务器端把连接请求信息记录到等待队列的过程。因此,connect函数成功返回并不意味着可以立即进行数据交换。
之前的文章中有提到过这样一个疑问,服务器端需要调用bind函数绑定地址信息到服务器端套接字,那为何客户端没有这一过程呢?其实客户端套接字也是需要分配IP和端口号等地址信息的,只不过这一步骤被操作系统隐藏了。那客户端又是何时、何地、如何分配地址呢?
何时? 调用connect函数时
何地? 操作系统,准确说时在内核中
如何? IP使用计算机的IP,端口号随机
基于TCP的服务器端/客户端函数调用关系
服务器端与客户端的函数调用关系并非相互独立的,其交互关系大致如下。其中,需要重点理解客户端connect函数的调用时机及服务器端对connect函数发起连接请求的反馈动作(大名鼎鼎的三次握手就这这个过程中完成)。
函数调用关系
实现迭代服务器端/客户端
以上介绍了TCP的相关知识,下面给出回声服务器端/客户端相关源码以供浏览学习。
1 #include <stdio.h> 2 #include <stdlib.h> 3 #include <string.h> 4 #include <unistd.h> 5 #include <arpa/inet.h> 6 #include <sys/socket.h> 7 8 #define BUF_SIZE 1024 9 void error_handling(char *message); 10 11 int main(int argc, char *argv[]) 12 { 13 int serv_sock, clnt_sock; 14 char message[BUF_SIZE]; 15 int str_len, i; 16 17 struct sockaddr_in serv_adr; 18 struct sockaddr_in clnt_adr; 19 socklen_t clnt_adr_sz; 20 21 if(argc!=2) { 22 printf("Usage : %s <port>\n", argv[0]); 23 exit(1); 24 } 25 26 serv_sock=socket(PF_INET, SOCK_STREAM, 0); 27 if(serv_sock==-1) 28 error_handling("socket() error"); 29 30 memset(&serv_adr, 0, sizeof(serv_adr)); 31 serv_adr.sin_family=AF_INET; 32 serv_adr.sin_addr.s_addr=htonl(INADDR_ANY); 33 serv_adr.sin_port=htons(atoi(argv[1])); 34 35 if(bind(serv_sock, (struct sockaddr*)&serv_adr, sizeof(serv_adr))==-1) 36 error_handling("bind() error"); 37 38 if(listen(serv_sock, 5)==-1) 39 error_handling("listen() error"); 40 41 clnt_adr_sz=sizeof(clnt_adr); 42 43 for(i=0; i<5; i++) 44 { 45 clnt_sock=accept(serv_sock, (struct sockaddr*)&clnt_adr, &clnt_adr_sz); 46 if(clnt_sock==-1) 47 error_handling("accept() error"); 48 else 49 printf("Connected client %d \n", i+1); 50 51 while((str_len=read(clnt_sock, message, BUF_SIZE))!=0) 52 write(clnt_sock, message, str_len); 53 54 close(clnt_sock); 55 } 56 57 close(serv_sock); 58 return 0; 59 } 60 61 void error_handling(char *message) 62 { 63 fputs(message, stderr); 64 fputc('\n', stderr); 65 exit(1); 66 }
1 #include <stdio.h> 2 #include <stdlib.h> 3 #include <string.h> 4 #include <unistd.h> 5 #include <arpa/inet.h> 6 #include <sys/socket.h> 7 8 #define BUF_SIZE 1024 9 void error_handling(char *message); 10 11 int main(int argc, char *argv[]) 12 { 13 int sock; 14 char message[BUF_SIZE]; 15 int str_len; 16 struct sockaddr_in serv_adr; 17 18 if(argc!=3) { 19 printf("Usage : %s <IP> <port>\n", argv[0]); 20 exit(1); 21 } 22 23 sock=socket(PF_INET, SOCK_STREAM, 0); 24 if(sock==-1) 25 error_handling("socket() error"); 26 27 memset(&serv_adr, 0, sizeof(serv_adr)); 28 serv_adr.sin_family=AF_INET; 29 serv_adr.sin_addr.s_addr=inet_addr(argv[1]); 30 serv_adr.sin_port=htons(atoi(argv[2])); 31 32 if(connect(sock, (struct sockaddr*)&serv_adr, sizeof(serv_adr))==-1) 33 error_handling("connect() error!"); 34 else 35 puts("Connected..........."); 36 37 while(1) 38 { 39 fputs("Input message(Q to quit): ", stdout); 40 fgets(message, BUF_SIZE, stdin); 41 42 if(!strcmp(message,"q\n") || !strcmp(message,"Q\n")) 43 break; 44 45 write(sock, message, strlen(message)); 46 str_len=read(sock, message, BUF_SIZE-1); 47 message[str_len]=0; 48 printf("Message from server: %s", message); 49 } 50 51 close(sock); 52 return 0; 53 } 54 55 void error_handling(char *message) 56 { 57 fputs(message, stderr); 58 fputc('\n', stderr); 59 exit(1); 60 }
服务器端通过如下实现方式可循环服务发起连接的客户端,但每次仅能服务一个客户端(后续使用多线程或多进程的框架实现便可处理并发的情况)。客户端通过调用close函数主动发起断连请求,服务器端收到该消息(EOF)便从阻塞的read函数中返回,此时read返回值为0。
迭代服务器端代码实现流程
回声客户端存在的问题
回声客户端传输接收数据的流程如下。回顾之前关于TCP性质的介绍,我们知道TCP是没有数据边界的,即write函数传输的数据可能在多次调用之后一次发送;同样read函数的调用也可能在尚未收到全部数据包时返回。那么这个问题该如何解决?
write(sock, message, strlen(message)); str_len=read(sock, message, BUF_SIZE-1); message[str_len]=0; printf("Message from server: %s", message);
结合服务器端的代码来看,很容易可以知道客户端需要接收数据的大小,因此加一个循环判断read结束条件即可。
recv_len=0; str_len=write(sock, message, strlen(message)); 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);
上面的实现确实解决了当前所面临的问题,但更多的时候接收数据端并不能确定待接收数据的大小等相关信息。因此,问题的根因并不在于客户端,而是我们应该定义符合需求的应用层协议。比如上面的问题,如果数据收发双方预先协定好数据的边界规则,或将数据包大小等相关信息写入特定字段来表示问题便可得到解决。