以下的内容:整理自深入理解计算机系统 第11章 网络编程
1、客户端-服务器编程模型
总结:需要认识到客户端和服务器端是进程,而不是常常提到的机器或主机。一台主机上可以同时运行许多不同的客户端进程和服务器端进程。无论客户端和服务器是如何映射到主机上的,客户端-服务器的模型是相同的。
2、网络
客户端和服务器通常运行在不同的主机上,并且通过计算机网络的硬件和软件进行通信。网络对于主机而言,仅是作为I/O设备,作为数据源和数据接受方。
如上:展示的是因特网客户端-服务器端应用程序的基本硬件和软件组织,每台因特网主机都运行TCP/IP协议(内核实现)。客户端和服务器混合使用套接字接口函数和Unix I/O函数来进行通信,(因为网络对于主机来说都是文件,所以,Unix I/O操作文件,当然也包括操作网络)。套接字函数典型地是作为陷入内核的系统调用来实现的,并调用各种内核模式的TCP/IP函数。
3、因特网连接
因特网客户端和服务器通过在连接上发送和接收字节流来通信。从连接一对进程的意义上而言,连接是点对点的。从数据可以同时双向流动的角度说,它是全双工的。并且从源进程发出的字节流最終被目的进程以它发出的顺序收到而言,它是可靠的,TCP是可靠传输协议。
4、套接字接口
套接字接口(socket interface)是一组函数,它们和Unix I/O函数结合起来。用以创建整个网络应用。图11-14给出了一个典型的客户端-服务器事务的上下文中的套接字接口描述。
解释:当客户端-服务器建立连接后,客户端进程会有一个描述符,服务器进程会有一个描述符,可以调用rio_readlineb函数,封装好的函数 (在csapp.c中),往描述符里面写数据(其实,描述赋想当于一个对外的缓冲区),然后只有这个描述符里面有数据,TCP/IP协议就会自动把数据传给客户端进程,我们无需干预。
从Unix内核的角度来看,一个套接字就是通信的一个端点。从Unix程序的角度来看,套接字就是一个有相应描述符的打开文件。
开始研究代码:下面的代码也都是基于linux平台下的,windows下面无法运行
echo客户端程序
/* * echoclient.c - An echo client */ /* $begin echoclientmain */ #include "csapp.h" int main(int argc, char **argv) { int clientfd, port; char *host, buf[MAXLINE]; rio_t rio; if (argc != 3) { fprintf(stderr, "usage: %s <host> <port>\n", argv[0]); exit(0); } host = argv[1]; port = atoi(argv[2]); clientfd = Open_clientfd(host, port); Rio_readinitb(&rio, clientfd); while (Fgets(buf, MAXLINE, stdin) != NULL) { Rio_writen(clientfd, buf, strlen(buf)); Rio_readlineb(&rio, buf, MAXLINE); Fputs(buf, stdout); } Close(clientfd); //line:netp:echoclient:close exit(0); } /* $end echoclientmain */
使用该代码说明:编译 执行要加上2个参数:服务器的主机名 服务器端口号
举例:./test handsome 8000 服务器的主机名是handsome 端口号是8000,想运行客户端程序,这两个参数必须设法得到
该代码的功能:在和服务器建立连接后,客户端进入一个循环,反复从标准输入读取文本行,发送给服务器,从服务器读取回送的行,并输出到标准输出。
现解释几个关键的函数:
//---------------------------------------------------open_clientfd-------------------------------------------------------------------------------------------------------
csapp.h
/* External variables */ extern int h_errno; /* defined by BIND for DNS errors */
csapp.c
void dns_error(char *msg) /* dns-style error */ { fprintf(stderr, "%s: DNS error %d\n", msg, h_errno); exit(0); }
int Open_clientfd(char *hostname, int port) { int rc; if ((rc = open_clientfd(hostname, port)) < 0) { if (rc == -1) unix_error("Open_clientfd Unix error"); else dns_error("Open_clientfd DNS error"); } return rc; }
int open_clientfd(char *hostname, int port) { int clientfd; struct hostent *hp; struct sockaddr_in serveraddr; if ((clientfd = socket(AF_INET, SOCK_STREAM, 0)) < 0) return -1; /* check errno for cause of error */ /* Fill in the server's IP address and port */ if ((hp = gethostbyname(hostname)) == NULL) return -2; /* check h_errno for cause of error */ bzero((char *) &serveraddr, sizeof(serveraddr)); serveraddr.sin_family = AF_INET; bcopy((char *)hp->h_addr_list[0], (char *)&serveraddr.sin_addr.s_addr, hp->h_length); serveraddr.sin_port = htons(port); /* Establish a connection with the server */ if (connect(clientfd, (SA *) &serveraddr, sizeof(serveraddr)) < 0) return -1; return clientfd; }
void Close(int fd) { int rc; if ((rc = close(fd)) < 0) unix_error("Close error"); }
函数说明:
1、Open_clientfd是open_clientfd的包装函数
2、clientfd = socket(AF_INET, SOCK_STREAM, 0) 创建了一个套接字描述符,socket返回的描述符仅是部分打开的,还 不能用于读写
3、connect(clientfd, (SA *) &serveraddr, sizeof(serveraddr)) 客户端调用connect函数来建立和服务器的连接,connect函数试图与套接字地址为serveraddr的服务器建立连接,connect函数会阻塞,一直到连接成功建立或是发生错误。如果成功,clientfd
描述符就准备好可以写了。
4、open_clientfd(char* hostname,int port)函数和运行在主机hostname上的服务器建立起连接,并在知名端口port上监听连接请求。返回的是一个打开的套接字描述符,该描述符准备好,可以用Unix/IO函数做输入输出。
5、Close 显式地关闭打开的任何描述符是一个良好的编程习惯
//---------------------------------------------------------------------------------------------------------------------------------------------------------------------------
echo 迭代服务器的主程序
/* * echo - read and echo text lines until client closes connection */ /* $begin echo */ #include "csapp.h" void echo(int connfd) { size_t n; char buf[MAXLINE]; rio_t rio; Rio_readinitb(&rio, connfd); while((n = Rio_readlineb(&rio, buf, MAXLINE)) != 0) { printf("server received %d bytes\n", n); Rio_writen(connfd, buf, n); } } /* $end echo */// echo(int connfd)函数,只需要传送参数已连接的描述符,就可以在这个描述符上进行I/O操作。
Rio_readinitb(rio_t *rp,int fd):将描述符fd和地址rp处的一个类型为rio_t的读缓冲区联系起来
Rio_writtrn(int fd,void* usrbuf,size_t n):从缓冲去usrbuf写入到描述符fd。
/* * echoserveri.c - An iterative echo server */ /* $begin echoserverimain */ #include "csapp.h" void echo(int connfd); int main(int argc, char **argv) { int listenfd, connfd, port, clientlen; struct sockaddr_in clientaddr; struct hostent *hp; char *haddrp; if (argc != 2) { fprintf(stderr, "usage: %s <port>\n", argv[0]); exit(0); } port = atoi(argv[1]); listenfd = Open_listenfd(port); while (1) { clientlen = sizeof(clientaddr); connfd = <span style="color:#ff0000;">Accept</span>(listenfd, (SA *)&clientaddr, &clientlen); /* determine the domain name and IP address of the client */ hp = Gethostbyaddr((const char *)&clientaddr.sin_addr.s_addr, sizeof(clientaddr.sin_addr.s_addr), AF_INET); haddrp = inet_ntoa(clientaddr.sin_addr); printf("server connected to %s (%s)\n", hp->h_name, haddrp); echo(connfd); Close(connfd); } exit(0); } /* $end echoserverimain */
上面的echo服务器,一次只能处理一个客户端,成为迭代服务器
echo函数,只是简单地将客户端发来的内容回送回去
使用该代码说明:编译 执行要加上1个参数:服务器端口号
举例:./test 8000 服务器的 端口号是8000,想运行服务器程序,必须设置此参数,不过,作为实验,参数似乎可以随便填
//---------------------------------------------------------Open_listenfd-----------------------------------------------------------------------------------------------
int open_listenfd(int port) { int listenfd, optval=1; struct sockaddr_in serveraddr; /* Create a socket descriptor */ if ((<span style="color:#ff0000;">listenfd = socket(AF_INET, SOCK_STREAM, 0)</span>) < 0) return -1; /* Eliminates "Address already in use" error from bind. */ if (setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, (const void *)&optval , sizeof(int)) < 0) return -1; /* Listenfd will be an endpoint for all requests to port on any IP address for this host */ bzero((char *) &serveraddr, sizeof(serveraddr)); serveraddr.sin_family = AF_INET; serveraddr.sin_addr.s_addr = htonl(INADDR_ANY); serveraddr.sin_port = htons((unsigned short)port); if (<span style="color:#ff0000;">bind(listenfd, (SA *)&serveraddr, sizeof(serveraddr))</span> < 0) return -1; /* Make it a listening socket ready to accept connection requests */ if (<span style="color:#ff0000;">listen(listenfd, LISTENQ)</span> < 0) return -1; return listenfd; }
2、listen函数,客户端是发起连接请求的主动实体。服务器是等待来自客户端的连接请求的被动实体。默认情况下,内核会认为socket函数创建的描述符对应与主动套接字,它存在于一个连接的客户端。服务器调用listen函数告诉内核,描述符是被服务器而不是客户端使用。listen函数将listenfd从一个主动套接字转化为一个监听套接字,该套接字可以接受来自客户端的请求。
3、open_listenfd函数打开和返回一个监听描述符,这个描述符准备好在知名端口port上接受连接请求。
//---------------------------------------------------------accept----------------------------------------------------------------------------------------------------------
服务器通过调用accept函数来等待来自客户端的连接请求。
<span style="font-size:14px;">int accept(int listenfd,struct sockaddr *addr,int *addrlen)</span>
accept返回的是一个已连接的描述符,这个描述符可以用来利用Unix/IO函数与客户端通信。
接下来,要讲述已连接的描述符和监听描述符的区别。
监听描述符是作为客户端连接请求的一个端点,它被创建一次,并存在于服务器的整个生命周期。总结:监听描述符只接受客户端的请求数据,而不接受客户端的业务数据,且一个服务器进程只有一个监听描述符。
已连接描述符是客户端和服务器之间已经建立起来的连接的一个端点。服务器每次接受请求时都会创建一次,它只存在于服务器为一个客户端服务的过程中。总结,每次连接请求到达监听描述符,就会创建一个已连接描述符,已连接描述符,相当于是和客户端程序建立起来传送业务数据的通道。
下面,来一张图,详细说明一下: