1.实验系列
·Linux NAP-Linux网络应用编程系列
2.实验目的
·理解并掌握在程序运行时从命令行读取数据的C语言编程方法;
·理解并掌握基于命令参数设置并获取IP与Port的C语言编程方法;
·理解并掌握套接字地址的数据结构定义与地址转换函数应用;
·理解并掌握网络字节序与主机字节序的定义、转换及其在网络编程中的具体应用;
·理解并掌握TCP单进程循环服务器与单进程客户端程序的基本编程模式,包括:
a.客户端与服务器套接字系统调用基本流程;
b.服务器对于客户端正常结束的识别与处理;
c.客户端基于命令行指令的退出设计与实现;
3.实验内容
编写TCP单进程循环服务器程序与单进程客户端程序,实现以下主体功能:。客户端启动连接服务器之后,进入命令行交互模式。
。操作人员在命令行窗口输入一行字符并回车后,客户端进程立刻从命令行(本质即stdin)读取数据,并将该行信息发送给服务器。
。服务器收到该行信息后,会将该信息原封不动的返回给客户端,即所谓消息回声(Message Echo)
。客户端收到服务器返回的消息回声后,将其打印输出至屏幕(本质即stdout)。
。客户端在从命令行收到EXIT指令后退出。
。在启动1个客户端连接上服务器开展交互时,再启动另一个客户端连接服务器,观察体验是什么现象,并尝试分析现象背后的底层逻辑。
本实验不考核以下内容:SIGPIPE信号处理、基于多次读取的PDU完整获取、PDU完整设计(仅要求PDU负载,不涉及头部以及头部的长度字段)、PDU字节序转换(本实验仅涉及套接字地址的字节序转换)。
本实验不设置复杂业务,仅要求实现简单的消息回声服务,以帮助理解并构建单进程循环服务器程序与单进程客户端程序的基本框架。
【重要假设】
。当网络与主机环境均比较理想时,可以支持客户端与服务器实现对于PDU的「一次性收发」,即仅通过 read()/write()的一次调用,即可实现PDU(本实验中即消息/消息回声)的「完整收发」。
。本实验中,数据传输量很小(将明确限定一行数据的上限),且测评时客户端服务器均在同一容器内,故而不会出现一次收发不能处理「单一PDU」的场景。
服务器端参考代码:
#include <stdio.h> #include <stdlib.h>//exit()函数相关#include <unistd.h>//C 和 C++ 程序设计语言中提供对 POSIX 操作系统 API 的访问功能的头文件#include <sys/types.h> //Unix/Linux系统的基本系统数据类型的头文件,含有size_t,time_t,pid_t等类型#include <sys/socket.h> //套接字基本函数相关#include <netinet/in.h> //IP地址和端口相关定义,比如struct sockaddr_in等#include <arpa/inet.h> //inet_pton()等函数相关#include <string.h> //bzero()函数相关#include <signal.h>#include <errno.h>#define BACKLOG 5//listen函数参数#define MAXDATASIZE 138char p[MAXDATASIZE + 100];void handle_sigint(int sig);void srv_biz(int connfd,char* veri_code);char buf[MAXDATASIZE];int sigint_flag = 0; // 标记服务器进程是否受到signal信号int main(int argc, char *argv[]) {int listenfd, connectfd; //分别是监听套接字和连接套接字struct sockaddr_in server, client; //存放服务器和客户端的地址信息(前者在bind时指定,后者在accept时得到)int sin_size; // accept时使用,得到客户端地址大小信息//安装SIGINT信号处理器struct sigaction sa;sa.sa_flags = 0;sa.sa_handler = handle_sigint;sigemptyset(&sa.sa_mask);sigaction(SIGINT, &sa, NULL);listenfd = socket(AF_INET, SOCK_STREAM, 0);int opt = SO_REUSEADDR;setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); //将地址和端口设为可立即重用(后续再解释)//这4行是设置地址结构变量的标准做法,可直接套用bzero(&server, sizeof(server));server.sin_family = AF_INET;server.sin_port = htons(atoi(argv[2]));inet_pton(AF_INET, argv[1], &server.sin_addr);//把server里的地址信息绑定到监听套接字上bind(listenfd, (struct sockaddr *)&server, sizeof(struct sockaddr));listen(listenfd, BACKLOG);sprintf(p,"[srv] server[%s:%s][%s] is initializing!\n",argv[1],argv[2],argv[3]);fputs(p, stdout);sin_size = sizeof(struct sockaddr_in);while(!sigint_flag) {//接受客户端连接(从监听队列里取出)if ((connectfd = accept(listenfd, (struct sockaddr *)&client, (socklen_t *)&sin_size)) == -1) {if(errno == EINTR){continue;} else{perror("accept error.");exit(-1);}}char *ip1, *ip2;sprintf(p,"[srv] client[%s:%d] is accepted!\n",inet_ntoa(client.sin_addr),client.sin_port);fputs(p, stdout);srv_biz(connectfd,argv[3]);close(connectfd); //关闭连接套接字sprintf(p,"[srv] client[%s:%d] is closed!\n",inet_ntoa(client.sin_addr),client.sin_port);fputs(p, stdout);} close(listenfd); //关闭监听套接字sprintf(p, "[srv] listenfd is closed!\n");fputs(p, stdout);sprintf(p, "[srv] server is to return!\n");}void handle_sigint(int sig){ // 定义SIGNAL信号处理器sprintf(p,"[srv] SIGINT is coming!\n");fputs(p, stdout);sigint_flag = 1;}void srv_biz(int connfd,char* veri_code){while(1){int numbytes; // 从客户端接收字节数if((numbytes = read(connfd,buf,MAXDATASIZE)) == -1) {perror("recv error.");exit(1);}if(numbytes == 0){break;}sprintf(p,"[ECH_RQT]%s",buf);fputs(p,stdout);char tep[MAXDATASIZE];memset(tep, 0, MAXDATASIZE);tep[0] = '(';int i, n;n = strlen(veri_code);for(i = 1; i <= n; i++){tep[i] = veri_code[i - 1];}tep[n + 1] = ')';strcat(tep,buf);write(connfd, tep, sizeof(tep));}}
客户端参考代码:
#include <stdio.h> #include <stdlib.h>//exit()函数,atoi()函数#include <unistd.h>//C 和 C++ 程序设计语言中提供对 POSIX 操作系统 API 的访问功能的头文件#include <sys/types.h> //Unix/Linux系统的基本系统数据类型的头文件,含有size_t,time_t,pid_t等类型#include <sys/socket.h> //套接字基本函数#include <netinet/in.h> //IP地址和端口相关定义,比如struct sockaddr_in等#include <arpa/inet.h> //inet_pton()等函数#include <string.h>//bzero()函数#include <signal.h>#include <errno.h>#define MAXDATASIZE 138char p[MAXDATASIZE + 100];void handle_sigint(int sig);void cli_biz(int fd);int sigint_flag = 0; // 标记服务器进程是否受到signal信号int main(int argc, char *argv[]){int clientfd;//clientfd是客户端套接字struct sockaddr_in server_addr; //存放服务器端地址信息,connect()使用//安装SIGINT信号处理器struct sigaction sa;sa.sa_flags = 0;sa.sa_handler = handle_sigint;sigemptyset(&sa.sa_mask);sigaction(SIGINT, &sa, NULL);clientfd = socket(AF_INET, SOCK_STREAM, 0);bzero(&server_addr, sizeof(server_addr));server_addr.sin_family = AF_INET;// argv[1] 为服务器IP字符串,需要用inet_pton转换为IP地址inet_pton(AF_INET, argv[1], &server_addr.sin_addr);// argv[2] 为服务器端口,需要用atoi及htons转换server_addr.sin_port = htons(atoi(argv[2]));connect(clientfd, (struct sockaddr *)&server_addr, sizeof(struct sockaddr));sprintf(p,"[cli] server[%s:%s] is connected!\n",argv[1],argv[2]);fputs(p,stdout);cli_biz(clientfd);close(clientfd);sprintf(p,"[cli] clientfd is closed!\n");fputs(p, stdout);sprintf(p,"[cli] clinetfd is to return!\n");fputs(p, stdout);return 0;}void handle_sigint(int sig){ // 定义SIGNAL信号处理器sprintf(p,"[srv] SIGINT is coming!\n");fputs(p, stdout);sigint_flag = 1;}void cli_biz(int clientfd){char buf[MAXDATASIZE];//缓冲区,用于存放从服务器接收到的信息char msg[MAXDATASIZE]; // 从命令行中读取的消息while(1){int numbytes; // numbytes是服务器端接收到的字节数int msg_len;fgets(msg,MAXDATASIZE,stdin);msg_len = strlen(msg);if(sigint_flag || strncmp(msg, "EXIT", 4) == 0){sprintf(p,"[ECH_RQT]%s",msg);fputs(p,stdout);break;}if(msg_len > MAXDATASIZE - 2){printf("messge is too long!\n");exit(1);}msg[msg_len] = '\0';sprintf(p,"[ECH_RQT]%s",msg);fputs(p,stdout);write(clientfd, msg, sizeof(msg)); //发送原始数据到服务器端if((numbytes = read(clientfd, buf, MAXDATASIZE)) == -1) {perror("recv error.");exit(1);}sprintf(p,"[ECH_REP]%s",buf);fputs(p,stdout);}}
注意事项:
【案例1】学生客户端与服务器收发数据时随意设置长度且长度恰巧一致
·学生客户端与服务器收发数据时随意设置收发系统调用的长度参数且「恰巧保证了长度参数一致」,本地测试看起来一切正常,但究其根本,是因为学生客户端与服务器程序恰巧在数据收发时设置了同样的长度参数,使得读写边界的问题并未暴露。但标准服务器与客户端并不遵循这样的逻辑,所以上线测试即表现出各种问题。
【特别提示】
·在发送数据时指定的长度参数用于指示计划发送的数据量。
。例:发送时,长度参数设定为200,则如果一次性成功发送,那么就意味着一次性会发送出去 200字节的数据。
·在接收数据时指定的长度参数用于指示期望接收的数据量。
。例:读取时,长度参数设定为200,意味着本次读取最多读200字节,若一次性读取数据不足200字节,则程序必须根据协议定义判断是否需要再次读取直至读够 200 字节。若实验环境可以保证数据读写都能够一次性完成,那么一次性读取时即便没有读满200字节,也无需再次读取,因为这意味着对方本来就没有发送200字节的数据。
·本实验中,假设实际待发送数据为150字节,但是发送时指定长度为200 字节,那么就意味着发送端在发送150字节的有效数据之外,还会发送了50字节的垃圾数据。
·此时如果接收端指定的长度参数恰好与发送端的一致,刚好也是读200字节,那么正好可以把那50字节的垃圾数据一并收下,系统缓存中并没有垃圾数据留下,第二轮读取时还是可以接收到客户端第二轮发来的正常数据。
但是标准客户端与服务器的接收缓存假如只有150字节,在没有PDU头部长度的引导时,标准客户端与标准服务器在接收数据时,只会按照自己的PDU缓存大小去设定接收长度参数,也就是每次最多只接收150字节数据。因此,当学生客户端第一轮发来包含50字节垃圾数据的200字节数据时,标准服务器只能读取其中的前150字节,尚有50字节垃圾数据残留在系统缓存中。
标准服务器第二轮读取时,将首先读取这50字节的垃圾数据,随后才是读取学生客户端第二次发来的200 字节数据中的头100字节(每次只能读150字节),此时系统缓存中上有50字节的有效数据,以及50字节的垃圾数据。标准服务器随后按照PDU既有设计,从PDU缓存第一字节开始进行字符串读取与解析,自然就从第二轮的数据接收解析开始,就大错特错。
·事实上,本实验中,标准客户端与标准服务器每次发送的数据长度,必然是「完整语句字符数」+2「即\n\ø」],而标准客户端与标准服务器的接收缓存则按照符合题意的最小值设定。
【案例2】学生服务器在线测评时超时。
·在线测评超时通常是因为程序由于逻辑错误导致挂起在各类慢系统调用上。学生服务器典型错误逻辑包括:
·学生服务器程序在 read()之后没有正确 send()就又开始了新一轮的 read()等待,导致标准客
户端在发送测试数据之后一直未能收到学生服务器的回复,因为也挂起在 read(),形成超时。【案例3】学生客户端在线测评时超时。
·在线测评超时通常是因为程序由于逻辑错误导致挂起在各类慢系统调用上。学生客户端典型错误逻辑包括:
·学生客户端从 stdin 读取数据时使用系统调用 read(θ,buf,120)。因测评脚本将 stdin重定向为测试数据文件,以模拟基于键盘的多次输入,故而该方法会从测试数据文件一次性读取120字节数据,而非读到\n就停止,从而导致学生客户端每次都会固定读取120字节数据,直至最后一次读取时测试数据文件已经没有120字节的剩余数据(最后一次文件剩余多少数据读多少,直至EoF)。学生客户端判定退出的检查条件为 strcmp(buf,"EXIT\n")。然后学生客户端因为上述原因造成的读取逻辑错误,已经无法有效识别退出指令EXIT(EXIT 会在学生客户端最后一次读取时,连同上一行残留的部分测试数据一起被读入),导致学生客户端在EXIT指令出现时也无法退出,最终挂起在read(),形成超时。