TinyHttpd 是 J. David Blackstone 于 1999 年写的一个 500 行左右的超轻量级 http server,用来学习服务器的工作原理十分不错。
intro
本篇 blog 为自己学习 tinyhttpd 这个服务器小项目所写的学习笔记,内容不多,但很细致。另外,本文所使用的源码来自于Tiny HTTPd’s tiny homepage,按照源码中的注释,修改掉对应的部分就可以在 Linux 上运行了。实际上,按照注释修改的版本是个无线程版的 tinyhttpd,而现在 Linux 也有了pthread.h
这个头文件,也就可以使用pthread_create
函数(这个函数可以创建线程)了,但在源码基础上,仍然需要做一些小修改才可以运行。
如果要在 Linux 上使用线程函数pthread_create
,在进行下面的改动之前,忽略掉源码本来的提示修改注释。
首先是accept_request
函数,需要修改函数的声明和定义的局部内容(具体可以参考后面贴出来的源码):1
2
3
4
5
6
7
8- void accept_request(int);
+ void *accept_request(void *);
+ void *accept_request(void *pclient) {
...
+ return NULL;
...
+ return NULL;
+ }
接着在accept_request
函数内第一行再添加int client = *(int*)pclient;
即可,后面会解释原因。
然后在main
函数中,将pthread_create
函数的第四个参数改为传入client_sock
的地址即可:1
2- if (pthread_create(&newthread , NULL, accept_request, client_sock) != 0)
+ if (pthread_create(&newthread , NULL, accept_request, &client_sock) != 0)
现在通过编译所得到的 httpd 是一个线程版本的服务器。
但无论是线程版还是无线程版,都需要修改以下内容才可以在 Linux 下运行:
首先是Makefile
,与源码中注释 5 一样,需要删除-lsocket
指令,并将-lpthread
指令放在最后面,具体如下:1
2- gcc -W -Wall -lsocket -lpthread -o httpd httpd.c
+ gcc -W -Wall -o httpd httpd.c -lpthread
然后,还需要修改index.html
的权限,去掉这个文件的可执行权限,终端下执行命令chmod 600 index.html
即可。
最后,还需要提醒的是此源码的 CGI 脚本需要用 perl 来执行,所以要先安装 perl,如何安装,此处不表,自行百度,但 perl 安装好之后又会存在一个 perl 路径的问题。
具体而言,源码中 CGI 脚本的路径是#!/usr/local/bin/perl -Tw
,但不同 Linux 发行版的软件安装路径可能不一致,比如 Ubuntu 16.04 下需要将路径改为#!/usr/bin/perl -Tw
。所以,如若可以访问index.html
页面,但无法执行 CGI 脚本,可以尝试从这方面调试。
最后的最后(再废话一下😂),还需要说明的是本文所有的代码部分,不会修改或删除 J. David Blackstone 曾经留下的注释,这部分注释也可以读读,对直接理解函数的功能有用,但是英文的,读起来可能不太方便🙂。
下面就开始逐个分析一下关键的函数。
main
先从main
函数开始,先搞清楚大致的工作流程是什么,相关的注释已经写在对应位置:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29int main(void) {
// 初始化变量
int server_sock = -1; // 服务器套接字
u_short port = 0; // 服务器端口
int client_sock = -1; // 与客户端连接的句柄
struct sockaddr_in client_name;
int client_name_len = sizeof(client_name);
pthread_t newthread; // 声明线程 id
server_sock = startup(&port); // 启动服务器,用指定端口开启 http 服务
printf("httpd running on port %d\n", port); // debug 信息
while (1) {
// 调用 accept 函数,返回与客户端连接的文件描述符
// accept 函数是一种阻塞函数,阻塞等待用户浏览器发起访问
client_sock = accept(server_sock, (struct sockaddr *)&client_name, &client_name_len);
if (client_sock == -1)
error_die("accept");
/* accept_request(client_sock); */
// 通过 client_sock 调用 accept_request 函数对用户进行访问
// 在这个过程中,会解析用户浏览器发来的请求
if (pthread_create(&newthread, NULL, (void*)accept_request, (void*)&client_sock) != 0)
perror("pthread_create");
}
// 关闭服务器套接字
close(server_sock);
return 0;
}
以下是相关知识点简要说明:
sockaddr_in
是网络编程中的一种结构,在不同的环境中其定义也不一致。in_addr
是一个结构体,可以用来表示一个 32 位的 IPv4 地址。accept
函数是库函数,作用是通过套接字接受一个连接。
pthread_create
这里,再着重说一下pthread_create
函数,它是类 Unix 操作系统中创建线程的库函数(Linux 现在也有了)。功能是先将线程创建好,然后开始运行相关的线程函数。
前面已经提到了需要对pthread_create
函数的调用做一些修改:1
2- if (pthread_create(&newthread , NULL, accept_request, client_sock) != 0)
+ if (pthread_create(&newthread , NULL, accept_request, &client_sock) != 0)
实际上,在笔者的 Ubuntu 16.04 系统下,pthread_create
函数的第三个参数需要一个返回空指针且参数为void *
的函数指针(类型为void *(*)(void *)
),所以需要修改accept_request
函数的声明和定义;第四个参数则需要一个空指针(类型为void *
),而这第四个参数实际上就是给函数指针指向的函数(也就是accept_request
)使用的,之所以能直接传入client_sock
的地址是因为笔者使用的 GCC 编译器会做隐式转换(implicit conversion),所以直接传指针即可,其他 C/C++ 系编译器会不会转换就不知道了。
startup
startup
主要作用是用来启动服务,相关的注释已经写在对应位置:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37/**********************************************************************/
/* This function starts the process of listening for web connections
* on a specified port. If the port is 0, then dynamically allocate a
* port and modify the original port variable to reflect the actual
* port.
* Parameters: pointer to variable containing the port to connect on
* Returns: the socket */
/**********************************************************************/
int startup(u_short *port) {
int httpd = 0;
struct sockaddr_in name;
// socket 函数会返回一个套接字
httpd = socket(PF_INET, SOCK_STREAM, 0);
if (httpd == -1)
error_die("socket");
memset(&name, 0, sizeof(name));
name.sin_family = AF_INET; // 协议族
name.sin_port = htons(*port); // 端口号
name.sin_addr.s_addr = htonl(INADDR_ANY); // IP 地址
// 绑定套接字
if (bind(httpd, (struct sockaddr *)&name, sizeof(name)) < 0)
error_die("bind");
// 如果没有设置端口,就动态申请一个端口
if (*port == 0) /* if dynamically allocating a port */
{
int namelen = sizeof(name);
// 利用 getsockname 函数来动态申请一个端口,这个端口会保存在结构体中
if (getsockname(httpd, (struct sockaddr *)&name, &namelen) == -1)
error_die("getsockname");
// 通过结构体的成员赋值给解引用的指针,利用指针改变原变量的值
*port = ntohs(name.sin_port);
}
// 创建请求队列大小为 5 的监听队列
if (listen(httpd, 5) < 0)
error_die("listen");
return httpd;
}
从源码中可以发现,startup
函数不仅创建好了套接字、端口,还开始进行监听了。虽然startup
这个函数使用的系统 API 非常多,但结合注释也能大概知道功能是什么了。
accept_request
这个函数是整个项目的关键函数,功能是接收客户端请求,并解析客户端发来的报文,再根据不同的方法和文件是否可执行来决定是否执行 CGI 程序。
详细的注释请看下面的修改后源码:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93/**********************************************************************/
/* A request has caused a call to accept() on the server port to
* return. Process the request appropriately.
* Parameters: the socket connected to the client */
/**********************************************************************/
void *accept_request(void *pclient) {
int client = *(int*)pclient;
char buf[1024];
int numchars;
char method[255];
char url[255];
char path[512];
size_t i, j;
struct stat st;
int cgi = 0; /* becomes true if server decides this is a CGI
* program */
char *query_string = NULL;
// 利用 get_line 函数读取请求头的一行,返回字符串的长度
numchars = get_line(client, buf, sizeof(buf));
i = 0; j = 0;
// 截取出请求报文中的方法字符串
while(!ISspace(buf[j]) && (i < sizeof(method) - 1)) {
method[i] = buf[j];
i++; j++;
}
method[i] = '\0';
// strcasecmp 库函数,忽略大小写进行字符串比较,功能与 strcmp 函数类似
if(strcasecmp(method, "GET") && strcasecmp(method, "POST")) {
// 整个测试程序只测试 GET 和 POST 方法,所以二者同时不满足条件时,说明是整个程序没有实现的方法
// 调用 unimplemented 函数向客户端直接发送异常页面
unimplemented(client);
return NULL; // 因为修改了函数返回值,这里也加上
}
// 如果客户端发来的是 post 请求
if(strcasecmp(method, "POST") == 0)
cgi = 1; // 将 cgi 标志置 1
// 重新使用 i 作为 url 字符数组的下标
i = 0;
// 跳过请求报文中的空格
while(ISspace(buf[j]) && (j < sizeof(buf))) j++;
// 解析出请求报文中的 url
while(!ISspace(buf[j]) && (i < sizeof(url) - 1) && (j < sizeof(buf))) {
url[i] = buf[j];
i++; j++;
}
url[i] = '\0';
// 如果客户端使用 GET 方法
// url 可能会带有 ?,且有查询参数
if(strcasecmp(method, "GET") == 0) {
query_string = url;
while((*query_string != '?') && (*query_string != '\0'))
query_string++;
if (*query_string == '?') {
// 如果 url 中有 ?,说明是查询请求,需要执行 CGI
// 同时将这个字符串改为'\0',并将 query_string 指针指向后面的字符串
cgi = 1;
*query_string = '\0';
query_string++;
}
}
// 资源路径设置为 htdocs/
sprintf(path, "htdocs%s", url);
// 在路径后面加上具体的页面名称,现在路径是 htdocs/index.html,这个路径与此项目文件目录的结构一致
if(path[strlen(path) - 1] == '/')
strcat(path, "index.html");
if(stat(path, &st) == -1) {
// 如果无法按照路径获取到文件信息,说明文件不存在,此时的报文信息丢弃
// 并向浏览器发送 404 页面
while((numchars > 0) && strcmp("\n", buf)) /* read & discard headers */
numchars = get_line(client, buf, sizeof(buf));
not_found(client);
} else {
// stat(path, &st) != -1,说明 path 是个目录
// 如果 path 是个目录,就将 path 改为 目录/index.html 这种文件路径的形式
if ((st.st_mode & S_IFMT) == S_IFDIR)
strcat(path, "/index.html");
if((st.st_mode & S_IXUSR) ||
(st.st_mode & S_IXGRP) ||
(st.st_mode & S_IXOTH))
// 如果文件可执行,当作 cgi 脚本执行
cgi = 1;
if(!cgi)
// 不执行 cgi 脚本,直接向客户端发送 HTML 文件
serve_file(client, path);
else
// 执行 cgi 脚本,此时要额外传入所使用的方法和查询字符串
execute_cgi(client, path, method, query_string);
}
// 执行完毕,关闭套接字
close(client);
return NULL; // 加上函数返回值
}
前面提到过需要修改index.html
的文件权限,修改后这个文件的权限是-rw-------
,可以发现只保留了用户对其的读写权限,没有可执行权限。
为什么要这样改?
可以从上面代码的 77 - 81 行发现答案,如果index.html
文件可执行,那么就会被当作 CGI 脚本来执行,那这个文件就不会发送给客户端了。
而且,从源码中还可以看出服务器解析客户端请求报文时,其实就是通过解析字符串来完成的。
另外,前面还提到了需要在首行添加int client = *(int*)pclient;
,之所以要添加这一句,其实就是尽可能的避免修改更多的代码,因为源码是直接使用client
变量的。
get_line
get_line
函数的功能与其名称一致,就是读取一行字符,以 CRLF 为结尾。可能会有人疑问为什么是 CRLF 为结尾的标志,因为这是 HTTP 协议规定的。
下面是对应的注释和源码:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45/**********************************************************************/
/* Get a line from a socket, whether the line ends in a newline,
* carriage return, or a CRLF combination. Terminates the string read
* with a null character. If no newline indicator is found before the
* end of the buffer, the string is terminated with a null. If any of
* the above three line terminators is read, the last character of the
* string will be a linefeed and the string will be terminated with a
* null character.
* Parameters: the socket descriptor
* the buffer to save the data in
* the size of the buffer
* Returns: the number of bytes stored (excluding null) */
/**********************************************************************/
int get_line(int sock, char *buf, int size) {
// CRLF == "\r\n"
int i = 0;
char c = '\0';
int n;
while ((i < size - 1) && (c != '\n')) {
// 利用 recv 函数接受数据,长度为 1,用字符变量 c 来保存,recv 函数返回实际复制的字节数
n = recv(sock, &c, 1, 0);
/* DEBUG printf("%02X\n", c); */
if (n > 0) {
if (c == '\r') { // 读到换行符时
// 读取最后的回车符,但不删除输入流中的回车(MSG_PEEK 参数的意义)
n = recv(sock, &c, 1, MSG_PEEK);
/* DEBUG printf("%02X\n", c); */
if ((n > 0) && (c == '\n'))
// 如果是回车,就吃掉最后一个回车符
recv(sock, &c, 1, 0);
else
c = '\n';
}
// 如果 c 不是'\r'或'\n' ,就放到缓存字符串中
buf[i] = c;
i++;
} else
c = '\n';
}
// 完成一行的读取,最后加上 C 字符串结束的标志
buf[i] = '\0';
return(i);
}
这个函数并不复杂,本质上就可以当作一个字符串相关的处理函数来理解。
not_found/bad_request/cannot_execute/headers/unimplemented
not_found
、bad_request
、cannot_execute
、headers
和unimplemented
这五个函数都很简单,只有一个功能就是向客户端发送信息,这里不细说了,直接看源码和对应注释即可:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26/**********************************************************************/
/* Give a client a 404 not found status message. */
/**********************************************************************/
void not_found(int client) {
// 与 unimplemented 函数类似,向客户端发送 404 页面
char buf[1024];
sprintf(buf, "HTTP/1.0 404 NOT FOUND\r\n");
send(client, buf, strlen(buf), 0);
sprintf(buf, SERVER_STRING);
send(client, buf, strlen(buf), 0);
sprintf(buf, "Content-Type: text/html\r\n");
send(client, buf, strlen(buf), 0);
sprintf(buf, "\r\n");
send(client, buf, strlen(buf), 0);
sprintf(buf, "<HTML><TITLE>Not Found</TITLE>\r\n");
send(client, buf, strlen(buf), 0);
sprintf(buf, "<BODY><P>The server could not fulfill\r\n");
send(client, buf, strlen(buf), 0);
sprintf(buf, "your request because the resource specified\r\n");
send(client, buf, strlen(buf), 0);
sprintf(buf, "is unavailable or nonexistent.\r\n");
send(client, buf, strlen(buf), 0);
sprintf(buf, "</BODY></HTML>\r\n");
send(client, buf, strlen(buf), 0);
}
1 | /**********************************************************************/ |
1 | /**********************************************************************/ |
1 | /**********************************************************************/ |
1 | /**********************************************************************/ |
serve_file
serve_file
函数用来向客户端发送文件,需要有客户端套接字和对应的文件路径作为参数。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31/**********************************************************************/
/* Send a regular file to the client. Use headers, and report
* errors to client if they occur.
* Parameters: a pointer to a file structure produced from the socket
* file descriptor
* the name of the file to serve */
/**********************************************************************/
void serve_file(int client, const char *filename) {
FILE *resource = NULL;
int numchars = 1;
char buf[1024];
// 默认字符
buf[0] = 'A'; buf[1] = '\0';
// 与 accept_request 函数一样,丢弃剩余的报文信息
while ((numchars > 0) && strcmp("\n", buf)) /* read & discard headers */
numchars = get_line(client, buf, sizeof(buf));
// 用文件指针打开文件
resource = fopen(filename, "r");
if (resource == NULL)
// 如果文件未找到,发送 404 页面
not_found(client);
else {
// 先发送报文头
headers(client, filename);
// 再发送 html 文件
cat(client, resource);
}
// 关闭文件指针
fclose(resource);
}
cat
cat
函数的功能类似 Linux 中的cat
命令,都是读取逐行读取文件,不过这里的cat
函数需要逐行读取文件并发送给客户端。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16/**********************************************************************/
/* Put the entire contents of a file out on a socket. This function
* is named after the UNIX "cat" command, because it might have been
* easier just to do something like pipe, fork, and exec("cat").
* Parameters: the client socket descriptor
* FILE pointer for the file to cat */
/**********************************************************************/
void cat(int client, FILE *resource) {
char buf[1024];
// 逐行读取 html 文件,并发送给客户端,读到 EOF 时,读取完毕,跳出循环
fgets(buf, sizeof(buf), resource);
while (!feof(resource)) {
send(client, buf, strlen(buf), 0);
fgets(buf, sizeof(buf), resource);
}
}
execute_cgi
execute_cgi
函数也是一个关键函数,具体功能是执行 CGI 程序,具体请看下面的源码和注释:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130/**********************************************************************/
/* Execute a CGI script. Will need to set environment variables as
* appropriate.
* Parameters: client socket descriptor
* path to the CGI script */
/**********************************************************************/
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)) /* read & discard headers */
numchars = get_line(client, buf, sizeof(buf));
else {
/* POST */
// 如果是 POST 方法,逐行读取获取 content_length
numchars = get_line(client, buf, sizeof(buf));
while ((numchars > 0) && strcmp("\n", buf)) {
// 加上字符串结束符,便于使用 strcasecmp 函数
buf[15] = '\0';
if(strcasecmp(buf, "Content-Length:") == 0)
content_length = atoi(&(buf[16]));
numchars = get_line(client, buf, sizeof(buf));
}
// 错误请求
if(content_length == -1) {
bad_request(client);
return; // 直接返回
}
}
// 成功读取请求后,向客户端发送请求成功报文
// 不管 GET 还是 POST,都需要
sprintf(buf, "HTTP/1.0 200 OK\r\n");
send(client, buf, strlen(buf), 0);
// 创建 2 个管道
// pipe 函数会建立管道
// 默认数组的第一个元素是读入端,第二个元素是写入端
// cgi_output[0] 是读入端,cgi_output[1] 是写入端
if(pipe(cgi_output) < 0) {
cannot_execute(client);
return;
}
// cgi_input[0] 是读入端,cgi_input[1] 是写入端
if(pipe(cgi_input) < 0) {
cannot_execute(client);
return;
}
// 创建子进程
// fork 函数在子进程中返回 0
// 在父进程中返回子进程的 id
// 当返回负数时,表示子进程创建失败
if((pid = fork()) < 0 ) {
cannot_execute(client);
return;
}
if(pid == 0) {
/* child: CGI script */
// 子进程运行 CGI 脚本,利用管道接受从父进程发来的客户端的请求
char meth_env[255];
char query_env[255];
char length_env[255];
// 把 stdout 重定向到 cgi_output[1] 写入端
// 对于子进程而言,这是写入端,但对父进程而言,这是读入端
// 也就是说子进程向 cgi_output[1] 写入内容
// 父进程从 cgi_output[0] 读取内容
dup2(cgi_output[1], 1);
// 把 stdin 重定向到 cgi_input[0] 读入端
// 对于子进程而言,这是读入端,但对父进程而言,这是写入端
// 也就是说父进程向 cgi_input[1] 写入内容
// 子进程从 cgi_input[0] 读取内容
dup2(cgi_input[0], 0);
// 以上两个管道的两个通道,都是子进程在使用
// 下面两个管道的通道要留给父进程使用,在子进程中关闭掉
// 分别是管道 cgi_output 的读入端和管道 cgi_input 的写入端
close(cgi_output[0]);
close(cgi_input[1]);
sprintf(meth_env, "REQUEST_METHOD=%s", method);
// 设置环境变量
// 注意:设置的环境仅对程序本身有效。你在程序里做的改变不会
// 反映到外部环境中,这是因为变量的值不会从子进程传播到父
// 进程,这样做更安全。
putenv(meth_env);
if(strcasecmp(method, "GET") == 0) {
// GET 方法对应 query_string
sprintf(query_env, "QUERY_STRING=%s", query_string);
putenv(query_env);
} else {
/* POST */
// POST 方法对应 content_length
sprintf(length_env, "CONTENT_LENGTH=%d", content_length);
putenv(length_env);
}
// 执行对应的 CGI 脚本
execl(path, path, NULL);
exit(0);
} else {
/* parent */
// 父进程先读取客户端请求,再通过管道发给子进程
// 子进程运行 CGI 脚本得到的结果也通过管道发给父进程
// 父进程再发给客户端
// 关闭子进程所使用的管道通道
close(cgi_output[1]);
close(cgi_input[0]);
if(strcasecmp(method, "POST") == 0)
for(i = 0; i < content_length; i++) {
// 从客户端接收请求报文
recv(client, &c, 1, 0);
// 父进程通过管道向子进程写入从客户端接收的报文
write(cgi_input[1], &c, 1);
}
// 父进程通过管道读取子进程写入管道的信息
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);
}
}
这个函数的难点在于对父子进程之间管道通信的理解,单纯读注释可能会被绕晕,这里引用别人 blog 的一张图,结合下面这张图应该会清楚一些。
summary
现在回过头来,可以发现服务器的工作流程其实很简单:
- 启动本地服务
- 接收并分析客户端请求
- 执行对应 CGI 程序
- 发送 CGI 程序执行结果给客户端
- 关闭套接字
也就是下面这张图:
实际上,如果单纯是针对 tinyhttpd 这个小项目而言,下面这张图会更细节一点:
同样的,tinyhttpd 项目所设计的函数也可以按功能分类:
最后,不得不感叹,麻雀虽小,但是依然五脏俱全~