Linux 网络编程之 TINY 程序

概述

在著名的《深入了解计算机系统》书中,作者通过开发一个虽小但功能齐全的称为 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));
}

chrome浏览器中显示的请求头

静态服务

接着说 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);
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值