Web Server 201 - 剖析 tiny-httpd


学习笔记,非纯原创

Tiny-httpd[1] 是一个轻量型 Http Server,使用 C 语言开发,可以通过阅读这段代码理解一个 Http Server 的本质。本文分析的Tiny-httpd 并非原版,博主为了方便理解进行了重写并添加了注释。同时本文聚焦于对理解服务器原理最核心的函数,所以一些不重要的辅助函数也未出现于此文中,完整代码可见此处

1. 概述

以下是原理剖析图[2]

Tiny-Httpd 工作原理

2. Server 主进程

main 函数原理很简单:

  1. 启动服务器,在指定端口或随机选取端口绑定 httpd 服务
  2. 收到一个 HTTP 请求,即 listen 的端口 accept 请求时,派生一个线程处理请求并发送回客户端
  3. 关闭与浏览器的连接,完成 HTTP 请求与回应,因为 HTTP 是无连接的。
int main(void) {
  // Init server
  int server_sock = -1;
  u_short port = 0;
  server_sock = setup_server(&port);
  
  int client_sock = -1;
  struct sockaddr_in client_name;
  int client_name_len = sizeof(client_name);
  pthread_t newthread;
  
  // Open thread for each incoming request
  while (1) {
    client_sock = accept(server_sock,
                         (struct sockaddr *)&client_name,
                         &client_name_len);
    if (client_sock == -1)
      error_die("accept");
    if (pthread_create(&newthread, NULL, accept_request, client_sock) != 0)
      perror("pthread_create");
  }

  close(server_sock);
  return (0);
} 

2.1. Server 初始化

Server 初始化主要是将 socket 和 IP 与 port 绑定。

在 TCP/IP 协议中,IP + TCP/UDP + port 就可以唯一标识网络通讯中的一个进程。 socket = IP 地址 + port

int setup_server(u_short *port) {
  // 创建 socket
  int server_sock =  socket(PF_INET, SOCK_STREAM, 0); // PF_INET 与 AF_INET 同义
  if (server_sock == -1)
    error_die("socket");

  // 创建 sockaddr,指定了 IP 和 port
  struct sockaddr_in server_addr;
  memset(&server_addr, 0, sizeof(server_addr));
  server_addr.sin_family = AF_INET;
  server_addr.sin_port = htons(*port);             // htons(),ntohs() 和 htonl()包含于<arpa/inet.h>, 将 *port 转换成以网络字节序表示的16位整数
  server_addr.sin_addr.s_addr = htonl(INADDR_ANY); // INADDR_ANY是一个 IPV4 通配地址的常量,包含于<netinet/in.h>,大多实现都将其定义成了0.0.0.0

  // 将 socket 和 sockaddr 绑定
  if (bind(server_sock, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) // 由于传进去的 sockaddr 结构中的 sin_port 指定为 0,系统会选择一个临时的端口号
    error_die("bind");

  // 如果调用 bind 后端口号仍然是0,则手动调用 getsockname() 获取端口号
  if (*port == 0) /* if dynamically allocating a port */ {
    int namelen = sizeof(server_addr);
    if (getsockname(server_sock, (struct sockaddr *)&server_addr, &namelen) == -1) //调用 getsockname() 获取系统给 httpd 这个 socket 随机分配的端口号
      error_die("getsockname");
    *port = ntohs(server_addr.sin_port);
  }

  // 进入监听状态,等待用户发起请求
  if (listen(server_sock, 5) < 0) // 最初的 BSD socket 实现中,backlog 的上限是5
    error_die("listen");
  printf("httpd running on http://localhost:%d\n", *port);

  return (server_sock);
}

2.2. 服务器子线程

子线程主要调用 accept_request 函数处理 request http,包括:

  1. 解析 http header 中的请求方法(GET/POST)和 URL
  2. 通过请求方法和 URL 判断客户端是在请求静态网页文件还是 CGI
  3. 如果是请求 CGI,则调用 accept_request 函数,否则视为请求静态页面,调用 serve_file 处理
void accept_request(int client_sock) {
  char buf[MAXBUFFSIZE];
  int numchars;
  char method[255];
  char url[255];
  char path[512];
  size_t i, buff_ptr;
  struct stat st;
  int is_cgi = 0;
  char *query_string = NULL;

  // Step 1: 读 http 请求的第一行(request line),并把请求方法存进 method 中
  numchars = get_line(client_sock, buf, sizeof(buf));
  i = 0;
  buff_ptr = 0;
  while (!ISspace(buf[buff_ptr]) && (i < sizeof(method) - 1)) {
    method[i] = buf[buff_ptr];
    i++;
    buff_ptr++;
  }
  method[i] = '\0';

  i = 0;
  while (ISspace(buf[buff_ptr]) && (buff_ptr < sizeof(buf))) // 跳过所有的空白字符(空格)
    buff_ptr++;

  // Step 2: 把 URL 存到 url 数组中
  while (!ISspace(buf[buff_ptr]) && (i < sizeof(url) - 1) && (buff_ptr < sizeof(buf))) {
    url[i] = buf[buff_ptr];
    i++;
    buff_ptr++;
  }
  url[i] = '\0';
  
  // Step 3: 分析当前请求是否为 CGI 请求
  if (strcasecmp(method, "POST") == 0) { // POST 请求一定是 CGI
    is_cgi = 1;
  } else if (strcasecmp(method, "GET") == 0) { // GET 请求不一定是 CGI,如果带参数则是,不带则默认为请求 index.html
    query_string = url;

    while ((*query_string != '?') && (*query_string != '\0')) // 检查 url 中有无参数, 如 /color.cgi?color=red
      query_string++;

    if (*query_string == '?') { // 如果是 CGI 请求,则将 query_string 指向 ? 后的参数,如 color=red
      is_cgi = 1;
      *query_string = '\0';
      query_string++;
    }
  } else { // Neither POST nor GET
    unimplemented(client_sock);
    return;
  }

  sprintf(path, "htdocs%s", url);

  // "/" => "/index.html"
  if (path[strlen(path) - 1] == '/')
    strcat(path, "index.html");

  // Step 4: 查找 path 指向的文件
  if (stat(path, &st) == -1) { // 如果不存在,那把这次 http 的请求后续的内容(head 和 body)全部读完并忽略
    while ((numchars > 0) && strcmp("\n", buf)) /* read & discard headers */
      numchars = get_line(client_sock, buf, sizeof(buf));
    not_found(client_sock);
  }
  else {
    // 如果 path 是目录,则默认使用该目录下 index.html 文件
    if ((st.st_mode & S_IFMT) == S_IFDIR)
      strcat(path, "/index.html");

    // 如果 path 是可执行文件,设置 cgi 标识
    if ((st.st_mode & S_IXUSR) ||
        (st.st_mode & S_IXGRP) ||
        (st.st_mode & S_IXOTH))
      is_cgi = 1;

    if (!is_cgi) // 静态页面请求
      serve_file(client_sock, path);
    else // 动态页面请求
      execute_cgi(client_sock, path, method, query_string);
  }

  close(client_sock);
}

2.2.1. 回应静态文件请求

serve_file 函数查找请求的文件,一旦找到则将信息分装在 response http并发送回去。

void serve_file(int client, const char *filename) {
  FILE *resource = NULL;
  int numchars = 1;
  char buf[MAXBUFFSIZE];

  buf[0] = 'A'; // 确保 buf 里面有东西,能进入下面的 while 循环
  buf[1] = '\0';
  while ((numchars > 0) && strcmp("\n", buf)) // 读取并忽略掉这个 http 请求后面的所有内容
    numchars = get_line(client, buf, sizeof(buf));
  
  resource = fopen(filename, "r");
  if (resource == NULL)
    not_found(client);
  else {
    headers(client, filename); // 将请求文件的基本信息封装成 response 的头部 (header) 并返回
    cat(client, resource); // 把请求文件的内容读出来作为 response 的 body 发送到客户端
  }

  fclose(resource);
}

void headers(int client, const char *filename) {
  char buf[1024];
  (void)filename; /* could use filename to determine file type */

  strcpy(buf, "HTTP/1.0 200 OK\r\n");
  send(client, buf, strlen(buf), 0);
  strcpy(buf, SERVER_STRING);
  send(client, buf, strlen(buf), 0);
  sprintf(buf, "Content-Type: text/html\r\n");
  send(client, buf, strlen(buf), 0);
  strcpy(buf, "\r\n");
  send(client, buf, strlen(buf), 0);
}

void cat(int client, FILE *resource) {
  char buf[1024];
  do {
    fgets(buf, sizeof(buf), resource);
    send(client, buf, strlen(buf), 0);
  } while(!feof(resource));
}

2.2.2. 回应 CGI 请求

execute_cgi 函数创建子进程执行 CGI 脚本,父进程通过 pipe 读取子进程的运行结果,并包装成 response http 发回给客户端。

下图展示了父/子进程是如何通过两个 pipe 通信的。进一步了解 pipe 工作原理可移步 「Linux 201: Pipe」 这篇博文。

为什么 CGI program 位于 child process 内部
子进程调用 execl 函数运行 CGI 程序后,CGI 程序会自动继承子进程的环境。换言之,execl 并不创建新进程,进程 ID 不会改变,只是用全新的 CGI 程序替换了子进程的正文、数据、堆和栈段

父子进程 pipe 通信原理

void execute_cgi(int client, const char *path, const char *method, const char *query_string) {
  char buf[1024];
  int cgi_output[2];
  int cgi_input[2];
  pid_t pid;
  int status;
  int i;
  char c;
  int numchars = 1;
  int content_length = -1;

  buf[0] = 'A';
  buf[1] = '\0';

  if (strcasecmp(method, "GET") == 0) /* GET */
    while ((numchars > 0) && strcmp("\n", buf)) // 读取并忽略 header,因为信息都在 url (query_string) 里
      numchars = get_line(client, buf, sizeof(buf));
  else { /* POST */
    // 找出 header 中的 Content-Length (size of the entity-body) 的值,其余都忽略。因为只有该值对确定客户端传来的请求参数有用
    numchars = get_line(client, buf, sizeof(buf));
    while ((numchars > 0) && strcmp("\n", buf)) {
      buf[CONTENT_LENGTH_POS] = '\0';
      if (strcasecmp(buf, "Content-Length:") == 0){
        content_length = atoi(&(buf[16]));
      }
      numchars = get_line(client, buf, sizeof(buf));
    }

    // 如果 http 请求的 header 没有 Content-Length,则报错返回
    if (content_length == -1) {
      bad_request(client);
      return;
    }
  }

  sprintf(buf, "HTTP/1.0 200 OK\r\n");
  send(client, buf, strlen(buf), 0);

  // 创建两个管道,用于父子进程间通信
  if (pipe(cgi_output) < 0) {
    cannot_execute(client);
    return;
  }
  if (pipe(cgi_input) < 0) {
    cannot_execute(client);
    return;
  }

  // 创建子进程
  if ((pid = fork()) < 0) {
    cannot_execute(client);
    return;
  }

  if (pid == 0) /* 子进程: CGI script */ {
    char meth_env[255];
    char query_env[255];
    char length_env[255];

    dup2(cgi_output[1], STDOUT); // 将输出管道的写端和 STDOUT 绑定(重定向)
    dup2(cgi_input[0], STDIN); // 将输入管道的读端和 STDIN 绑定
    close(cgi_output[0]); // 关闭输出管道的读端
    close(cgi_input[1]); // 关闭输入管道的写端

    sprintf(meth_env, "REQUEST_METHOD=%s", method);
    putenv(meth_env);

    // 根据 http 请求的不同方法,构造并存储不同的环境变量
    if (strcasecmp(method, "GET") == 0) {
      /* 设置 query_string 的环境变量 */
      // 如果服务器与CGI程序信息的传递方式是GET,这个环境变量的值即使所传递的信息。这个信息经跟在CGI程序名的后面,两者中间用一个问号'?'分隔。
      sprintf(query_env, "QUERY_STRING=%s", query_string);
      putenv(query_env);
    } else { /* POST */
      /* 设置 content_length 的环境变量 */
      // 如果服务器与 CGI 程序信息的传递方式是POST,这个环境变量即使从标准输入 STDIN 中可以读到的有效数据的字节数。这个环境变量在读取所输入的数据时必须使用。
      sprintf(length_env, "CONTENT_LENGTH=%d", content_length);
      putenv(length_env);
    }

    // exec 函数簇,执行 CGI 脚本,获取 cgi 的标准输出作为相应内容发送给客户端
    // 将子进程替换成另一个进程并执行 cgi 脚本
    execl(path, path, NULL);

    // 子进程退出
    exit(0);
  } else { /* parent */
    // 父进程则关闭了 cgi_output 管道的写端和 cgi_input 管道的读端
    close(cgi_output[1]);
    close(cgi_input[0]);

    // 如果是 POST 方法的话就继续读 body 的内容,并写到 cgi_input 管道里让子进程去读
    if (strcasecmp(method, "POST") == 0) {
      for (i = 0; i < content_length; i++) {
        recv(client, &c, MAXDATASIZE, 0);
        write(cgi_input[1], &c, 1);
      }
    }

    // 从 cgi_output 管道中读子进程的输出,并发送到客户端去
    while (read(cgi_output[0], &c, 1) > 0)
      send(client, &c, 1, 0);

    // 关闭管道
    close(cgi_output[0]);
    close(cgi_input[1]);
    // 等待子进程的退出
    waitpid(pid, &status, 0);
  }
}

2.3. CGI Script

更多 CGI 是怎样工作的,请移步「Web Server 101 - CGI」一文

#!/usr/bin/perl -Tw

use strict;
use CGI;

my($cgi) = new CGI;

print $cgi->header; # stdout 被重定向到了 cgi_output[1]
my($color) = "blue";
$color = $cgi->param('color') if defined $cgi->param('color'); # the param method merges POST and GET parameters

print $cgi->start_html(-title => uc($color),
                       -BGCOLOR => $color);
print $cgi->h1("This is $color");

print $cgi->end_html;

3. 参考资料

  1. Tiny-httpd 源码
  2. TinyHTTPd–超轻量型Http Server源码分析
  3. What is the difference between AF_INET and PF_INET in socket programming?

文章作者: Shane Tsui
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 Shane Tsui !

  目录