学习笔记,非纯原创
Tiny-httpd[1] 是一个轻量型 Http Server,使用 C 语言开发,可以通过阅读这段代码理解一个 Http Server 的本质。本文分析的Tiny-httpd 并非原版,博主为了方便理解进行了重写并添加了注释。同时本文聚焦于对理解服务器原理最核心的函数,所以一些不重要的辅助函数也未出现于此文中,完整代码可见此处。
1. 概述
以下是原理剖析图[2]。
2. Server 主进程
main 函数原理很简单:
- 启动服务器,在指定端口或随机选取端口绑定 httpd 服务
- 收到一个 HTTP 请求,即 listen 的端口 accept 请求时,派生一个线程处理请求并发送回客户端
- 关闭与浏览器的连接,完成 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,包括:
- 解析 http header 中的请求方法(GET/POST)和 URL
- 通过请求方法和 URL 判断客户端是在请求静态网页文件还是 CGI
- 如果是请求 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 程序替换了子进程的正文、数据、堆和栈段
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;