概述
在著名的《深入了解计算机系统》书中,作者通过开发一个虽小但功能齐全的称为 TINY 的 Web 服务器来结束对网络编程的讨论。TINY 是一个有趣的程序,作者尽可能简短地将许多已经学习到的计算机原理融入在该程序中,如进程控制、Unix I/O、套接字接口和 HTTP 协议等。虽然它缺乏一个实际服务器所具备的功能性、健壮性和安全性,但是它足够用来为实际的 Web 浏览器提供静态和动态的内容。现在,我开始对它进行深入地理解和研究,源代码可以在 CSAPP 官方网站上获取。
main 函数
下面展示了 TINY 的主程序。TINY 主体是一个死循环,不停地监听端口中传来的连接请求。具体地说,TINY 通过调用 Open_listenfd()
函数打开了一个监听套接字,然后 TINY 执行典型的无限服务器循环,在听到客户端发来的请求后(Accept()
函数),先转换套接字结构(使用 getnameinfo()
函数)将客户端的主机名打印出来,再执行事务(doit()
函数),并关闭连接的它那一端(Close()
函数)。
int main(int argc, char **argv) {
int listenfd, connfd;
char hostname[MAXLINE], port[MAXLINE];
socklen_t clientlen;
struct sockaddr_storage clientaddr;
/* Check command line args */
if (argc != 2) {
fprintf(stderr, "usage: %s <port>\n", argv[0]);
exit(1);
}
listenfd = Open_listenfd(argv[1]);
while (1) {
clientlen = sizeof(clientaddr);
connfd = Accept(listenfd, (SA *)&clientaddr, &clientlen);
Getnameinfo((SA *) &clientaddr, clientlen, hostname, MAXLINE,
port, MAXLINE, 0);
printf("Accepted connection from (%s, %s)\n", hostname, port);
doit(connfd);
Close(connfd);
}
}
打开监听套接字
在之前介绍协议无关编程博客中,详细介绍了 getaddrinfo()
函数和 getnameinfo()
函数的作用以及使用方法。在 open_listenfd()
函数中,TINY 程序使用了它:
首先,程序将结构体 addrinfo
类型的参数 hints 全部置为 0,然后设置相应值:SOCK_STREAM 是基于连接的双工可靠的字节流传输方式,AI_PASSIVE 表示套接字地址是用于监听的,AI_NUMERICSERV 则要求以数字地址方式指定数字端口号。最后,使用 getaddrinfo()
函数获得可能的服务地址。
struct addrinfo hints, *listp, *p;
memset(&hints, 0, sizeof(struct addrinfo));
/* Internet连接 监听端口 IPv4 使用数字地址端口 */
hints.ai_socktype = SOCK_STREAM; /* Accept connections */
hints.ai_flags = AI_PASSIVE | AI_ADDRCONFIG; /* ... on any IP address */
hints.ai_flags |= AI_NUMERICSERV; /* ... using port number */
// 若执行成功 则返回 0
if ((rc = getaddrinfo(NULL, port, &hints, &listp)) != 0) {
fprintf(stderr, "getaddrinfo failed (port %s): %s\n", port,
gai_strerror(rc));
return -2;
}
使用 getaddrinfo()
函数获得的地址都存放在以 listp 为头指针的列表里。因此需要一个循环来遍历所有可能的套接字服务地址。socket()
函数创建一个监听套接字 listenfd,然后使用 bind()
函数将 listenfd 与服务器端的 IP 地址绑定,如果出现错误,那就需要先关闭该套接字,并尝试下一个可用的接口。
/* 遍历列表,找到可以bind的套接字接口 */
for (p = listp; p; p = p->ai_next) {
/* 使用 addrinfo 中的变量创建套接字 */
/* 如果失败就遍历下一个 */
if ((listenfd = socket(p->ai_family, p->ai_socktype, p->ai_protocol)) < 0)
continue;
/* Eliminates "Address already in use" error from bind */
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR,
(const void *)&optval, sizeof(int));
/* 绑定对应的套接字 */
if (bind(listenfd, p->ai_addr, p->ai_addrlen) == 0)
break;
/* 若失败就下一个 */
if (close(listenfd) < 0) {
fprintf(stderr, "open_listenfd close failed: %s\n", strerror(errno));
return -1;
}
}
最后使用 freeaddrinfo()
函数清除列表 listp,并使用 listen()
函数,开始监听客户端发来的请求。最后,返回该套接字描述符。
freeaddrinfo(listp);
/* 监听端口开始工作,并返回 */
if (listen(listenfd, LISTENQ) < 0) {
close(listenfd);
return -1;
}
return listenfd;
}
处理事务
接下来就是 main()
函数的主体循环部分,在听到客户端发来的请求后,服务器需要处理事务,首先使用 getnameinfo()
函数将客户端地址翻译成主机名,将收到的信息打印出来,然后就是处理 doit()
函数。
while (1) {
clientlen = sizeof(clientaddr);
connfd = Accept(listenfd, (SA *)&clientaddr, &clientlen);
Getnameinfo((SA *) &clientaddr, clientlen, hostname, MAXLINE,
port, MAXLINE, 0);
printf("Accepted connection from (%s, %s)\n", hostname, port);
doit(connfd);
Close(connfd);
}
解析报文
接下来介绍 doit()
函数主体,以后的函数需要大量使用在健壮性 I/O 博客提到的 Robust I/O (RIO),所有的读取和写入都是通过 RIO 来完成的。doit()
函数先初始化 RIO,然后服务器端需要对客户端发来的 GET 报文做解析(目前仅支持 GET 报文)。而在 HTTP 协议中,GET 报文的头部格式是:method URI version。故不难理解 sscanf()
函数中的字符串形式了。
void doit(int fd) {
int is_static;
struct stat sbuf;
char buf[MAXLINE], method[MAXLINE], uri[MAXLINE], version[MAXLINE];
char filename[MAXLINE], cgiargs[MAXLINE];
rio_t rio;
/* RIO 初始化 */
Rio_readinitb(&rio, fd);
if (!Rio_readlineb(&rio, buf, MAXLINE))
return;
printf("%s", buf);
/* 读取 GET 报文 并解析 */
sscanf(buf, "%s %s %s", method, uri, version);
if (strcasecmp(method, "GET")) {
clienterror(fd, method, "501", "Not Implemented",
"Tiny does not implement this method");
return;
}
/* GET 请求头读取完毕 */
read_requesthdrs(&rio);
clienterror()
函数用于指出服务器遇到的严重错误,返回给客户端。TINY 虽缺乏实际服务器的许多错误处理特性,但它仍会检査一些明显的错误,并把它们报告给客户端。clienterror()
函数发送一个 HTTP 响应到客户端,在响应行中包含相应的状态码和状态消息,响应主体中包含一个 HTML 文件,向浏览器的用户解释这个错误。read_requesthdrs()
函数作用是读取请求头,比较简单,略去不讲。
void clienterror(int fd, char *cause, char *errnum,
char *shortmsg, char *longmsg) {
char buf[MAXLINE];
/* HTTP 响应头 */
sprintf(buf, "HTTP/1.0 %s %s\r\n", errnum, shortmsg);
Rio_writen(fd, buf, strlen(buf));
sprintf(buf, "Content-type: text/html\r\n\r\n");
Rio_writen(fd, buf, strlen(buf));
/* HTTP 响应主体 html语言 */
sprintf(buf, "<html><title>Tiny Error</title>");
Rio_writen(fd, buf, strlen(buf));
sprintf(buf, "<body bgcolor=""ffffff"">\r\n");
Rio_writen(fd, buf, strlen(buf));
sprintf(buf, "%s: %s\r\n", errnum, shortmsg);
Rio_writen(fd, buf, strlen(buf));
sprintf(buf, "<p>%s: %s\r\n", longmsg, cause);
Rio_writen(fd, buf, strlen(buf));
sprintf(buf, "<hr><em>The Tiny Web server</em>\r\n");
Rio_writen(fd, buf, strlen(buf));
}
静态服务
接着说 doit()
函数,随后解析上图高亮的请求头。TINY 程序一共需要处理两种情况:静态请求与动态请求。其中静态请求仅会展示一页 html 和一张图片,而动态请求会对用户提供的参数求和。区别在于动态请求的 URI 多了 “/cgi-bin/” 一节。parse_uri()
函数可先放在一旁,仅需知道它可以区分这两种情况即可。然后,调用 stat()
函数检查请求的文件是否存在。
/* Parse URI from GET request */
is_static = parse_uri(uri, filename, cgiargs);
if (stat(filename, &sbuf) < 0) {
clienterror(fd, filename, "404", "Not found",
"Tiny couldn't find this file");
return;
}
若为静态函数,那就准备静态服务,但前提是请求的文件对于服务器端的程序是有权限使用的。serve_static()
函数发送一个 HTTP 响应报文,其主体包含一个本地文件的内容。本地文件的发送过程值得注意:需要使用 mmap()
函数来将文件全部映射到内存,并获得指向首地址的指针,然后使用 RIO 写入到套接字中,即可认为传输完成。因为是读文件,故使用 PROT_READ,且不需要共享,使用 MAP_PRIVATE。最后使用 munmap()
函数释放了映射的虚拟内存空间,避免潜在的内存泄漏。
mmap()
函数原型为void *mmap(void *start, size_t length, int prot, int flags, int fd, off_t offset)
,可将一个文件或者其它对象映射到进程的地址空间,实现文件磁盘地址和进程一段虚拟地址的一一映射。随后,进程就可以采用指针的方式读写这一段内存,系统会自动回写脏页面到对应的文件磁盘上,不必再调用read/write等系统调用函数。相反,内核空间对这段区域的修改也直接反映用户空间,通常用于实现不同进程间的文件共享。
if (is_static) { /* Serve static content */
if (!(S_ISREG(sbuf.st_mode)) || !(S_IRUSR & sbuf.st_mode)) {
clienterror(fd, filename, "403", "Forbidden",
"Tiny couldn't read the file");
return;
}
serve_static(fd, filename, sbuf.st_size);
}
/* 静态服务函数 */
void serve_static(int fd, char *filename, int filesize) {
int srcfd;
char *srcp, filetype[MAXLINE], buf[MAXBUF];
/* 发送响应头 */
get_filetype(filename, filetype);
sprintf(buf, "HTTP/1.0 200 OK\r\n");
Rio_writen(fd, buf, strlen(buf));
sprintf(buf, "Server: Tiny Web Server\r\n");
Rio_writen(fd, buf, strlen(buf));
sprintf(buf, "Content-length: %d\r\n", filesize);
Rio_writen(fd, buf, strlen(buf));
sprintf(buf, "Content-type: %s\r\n\r\n", filetype);
Rio_writen(fd, buf, strlen(buf));
/* 响应主体 就是html文件 */
srcfd = Open(filename, O_RDONLY, 0);
srcp = Mmap(0, filesize, PROT_READ, MAP_PRIVATE, srcfd, 0);
Close(srcfd);
Rio_writen(fd, srcp, filesize);
Munmap(srcp, filesize);
}
从下图不难看出,发出的响应头与程序中写入文件的内容完全一致。
动态服务
动态服务需要获取客户端发来的两个参数,然后将它们相加,开头的步骤都与静态服务一致,但调用了 serve_dynamic()
函数。serve_dynamic()
函数一开始就向客户端发送一个表明成功的响应头。而 CGI 子程序负责处理并发送响应的剩余部分。当然,这里 TINY 程序的健壮性有待提升,很明显它没有考虑到 CGI 程序出现错误的可能性。
无论如何,TINY 发送了响应的第一部分后,会 fork
一个新的子进程。而父进程则会调用 wait()
函数等待子进程完成执行。子进程先将来自 URI 的参数,设定为 QUERY_STRING 的环境变量(setenv()
函数),随后将它的标准输出重定向到套接字描述符(dup2()
函数),这样一来,程序的所有标准输出事实上都会写入到套接字中。然后,加载并运行 CGI 程序(execve()
函数)。由于 CGI 程序运行在子进程的上下文中,因此能访问所有在调用 execve()
函数之前存在的打开文件和环境变量。因此,CGI 程序写到标准输出上的任何东西都将直接送到套接字中,其间,父进程阻塞 wait()
的调用中,当子进程终止,回收操作系统分配给子进程的资源。
else { /* Serve dynamic content */
if (!(S_ISREG(sbuf.st_mode)) || !(S_IXUSR & sbuf.st_mode)) {
clienterror(fd, filename, "403", "Forbidden",
"Tiny couldn't run the CGI program");
return;
}
serve_dynamic(fd, filename, cgiargs);
}
void serve_dynamic(int fd, char *filename, char *cgiargs) {
char buf[MAXLINE], *emptylist[] = { NULL };
/* Return first part of HTTP response */
sprintf(buf, "HTTP/1.0 200 OK\r\n");
Rio_writen(fd, buf, strlen(buf));
sprintf(buf, "Server: Tiny Web Server\r\n");
Rio_writen(fd, buf, strlen(buf));
if (Fork() == 0) { /* Child */
/* Real server would set all CGI vars here */
setenv("QUERY_STRING", cgiargs, 1);
Dup2(fd, STDOUT_FILENO); /* Redirect stdout to client */
Execve(filename, emptylist, environ); /* Run CGI program */
}
Wait(NULL); /* Parent waits for and reaps child */
}
CGI 程序中,加法是如何实现的?首先,需要从环境变量 QUERY_STRING 中提取出两个数字,其格式为 xx&xx,因此需要进行字符串切分,并转化为整数。然后,开始填入 HTTP 响应报文主体,并计算两数之和。调用 fflush()
函数来确保缓冲区中的数据已全部输出。注意到,这里需使用 printf()
函数,而不是写入到指定的套接字中,因为在调用该程序前,我们已经将标准输出重定位到套接字去了,这就实现了 CGI 程序与 Web 服务程序的解耦合。
int main(void) {
char *buf, *p;
char arg1[MAXLINE], arg2[MAXLINE], content[MAXLINE];
int n1=0, n2=0;
/* Extract the two arguments */
if ((buf = getenv("QUERY_STRING")) != NULL) {
p = strchr(buf, '&');
*p = '\0';
strcpy(arg1, buf);
strcpy(arg2, p+1);
n1 = atoi(arg1);
n2 = atoi(arg2);
}
/* Make the response body */
sprintf(content, "Welcome to add.com: ");
sprintf(content, "%sTHE Internet addition portal.\r\n<p>", content);
sprintf(content, "%sThe answer is: %d + %d = %d\r\n<p>",
content, n1, n2, n1 + n2);
sprintf(content, "%sThanks for visiting!\r\n", content);
/* Generate the HTTP response */
printf("Connection: close\r\n");
printf("Content-length: %d\r\n", (int)strlen(content));
printf("Content-type: text/html\r\n\r\n");
printf("%s", content);
fflush(stdout);
exit(0);
}