TinyHttpd 是一个用 C 语言写的及其简洁的 HTTP 服务程序,一共只有 500 行左右代码,非常适合拿来学习 HTTP。
这篇博客也是自己一边学习一遍记录的学习笔记。
2020-4-18
效果演示
先 clone 下源码到本地:
1
git clone https://github.com/EZLippi/Tinyhttpd.git
进入 Tinyhttpd/htdocs 目录,将 cgi 后缀结尾的文件赋予执行权限(我在这里困扰了好久,一直没能执行成功的原因就是没有给 cgi 文件加执行权限)。
1
2
3cd Tinyhttpd/htdocs
chmod a+x *.cgi
cd ../编译并运行 Tinyhttpd:
1
2make
./httpd执行结果:
1
2🐠 jony@deepin # ./httpd
httpd running on port 4000在浏览器输入
localhost:4000
,进入测试页面:输入
blue
,点击提交。
源码解析
TinyHttpd 源码在 github上有,这里也贴出来吧,反正也只有500行左右。
源码实现的功能也比较简单,就是通过浏览器向 httpd 服务发送数据请求,然后接收 httpd 服务返回的数据。
每个函数的作用:
accept_request: 处理从套接字上监听到的一个 HTTP 请求,在这里可以很大一部分地体现服务器处理请求流程。
bad_request: 返回给客户端这是个错误请求,HTTP 状态吗 400 BAD REQUEST.
cat: 读取服务器上某个文件写到 socket 套接字。
cannot_execute: 主要处理发生在执行 cgi 程序时出现的错误。
error_die: 把错误信息写到 perror 并退出。
execute_cgi: 运行 cgi 程序的处理,也是个主要函数。
get_line: 读取套接字的一行,把回车换行等情况都统一为换行符结束。
headers: 把 HTTP 响应的头部写到套接字。
not_found: 主要处理找不到请求的文件时的情况。
sever_file: 调用 cat 把服务器文件返回给浏览器。
startup: 初始化 httpd 服务,包括建立套接字,绑定端口,进行监听等。
unimplemented: 返回给浏览器表明收到的 HTTP 请求所用的 method 不被支持。
main 函数入手
源码阅读从 main
函数切入即可:
1 | int main(void) |
startup
初始化 httpd 服务
1 | /**********************************************************************/ |
startup
函数用于初始化 httpd 服务,包括建立套接字,绑定端口,进行监听等。
1 | httpd = socket(PF_INET, SOCK_STREAM, 0); |
PF_INET 指定使用 ipv4,SOCK_STREAM 指定使用 TCP 通信,第三个参数 0 表示根据前面两个参数使用默认协议。
setsockopt
函数用于设置套接字的关联项,为了操作套接字层的选项,应该将层的值指定为SOL_SOCKET。SO_REUSEADDR 表示允许重用本地地址和端口,一般来说,一个端口释放后会等待两分钟之后才能再被使用,SO_REUSEADDR是让端口释放后立即就可以被再次使用。有关 SO_REUSEADDR 的说明可以参考 这篇文章 。
设置完 socket 后使用 bind 绑定 socket 到指定地址。
listen
使得一个进程可以接受其它进程的请求,从而成为一个服务器进程。其中 listen
的第二个参数为 backlog。
这个参数涉及到一些网络的细节。在进程正理一个一个连接请求的时候,可能还存在其它的连接请求。因为TCP连接是一个过程,所以可能存在一种半连接的状态,有时由于同时尝试连接的用户过多,使得服务器进程无法快速地完成连接请求。如果这个情况出现了,服务器进程希望内核如何处理呢?内核会在自己的进程空间里维护一个队列以跟踪这些完成的连接但服务器进程还没有接手处理或正在进行的连接,这样的一个队列内核不可能让其任意大,所以必须有一个大小的上限。这个backlog告诉内核使用这个数值作为上限。
毫无疑问,服务器进程不能随便指定一个数值,内核有一个许可的范围。这个范围是实现相关的。很难有某种统一,一般这个值会小30以内。
TCP的服务器端socket基本流程socket->bind->listen->accept->send/recv->closesocket,客户端基本流程socket->[bind->]->connect->send/recv->closesocket,其中客户端connect函数应该是和服务器端的listen函数相互作用,而不是accept函数。在listen函数中的第二个参数backlog代表着等待处理的连接队列(以下简称队列)的长度,神马意思?我也不太懂,但是通过代码实践,我可以简单的说,每当有一个客户端connect了,listen的队列中就加入一个连接,每当服务器端accept了,就从listen的队列中取出一个连接,转成一个专门用来传输数据的socket(accept函数的返回值),所以在服务器端程序中有两个socket,前者是用来接收客户端连接的socket.
最后返回这个 socket,至此 httpd 就初始化完成了,创建并初始化了一个 socket,绑定并监听指定 IP 和端口后返回这个 socket。
accept_request
接受请求
1 | /**********************************************************************/ |
accept_request
是处理请求的主体,在服务端初始化 httpd 后并接受客户端的连接请求后,服务端与客户端建立了 TCP 连接,接收客户端发来的请求信息,通过 get_line
函数获取一行数据内容,然后进行相应的字符串解析,获取出请求类型、URL等信息。
根据请求是 POST
还是 GET
分别进行不同的处理。
默认情况下是使用 htdocs
目录下的 index.html 页面作为默认请求页面,如果自己手动指定请求页面就以用户指定页面为准,这里我们使用的是 color.cgi
文件。
最后根据请求类型以及 path 文件的可执行权限决定采用不同的执行方式, serv_file
是将 path 文件读取展示在浏览器上,execute_cgi
是执行本地的 cgi 文件。
上面程序中的 ISspace
函数是用于判断空白字符的:
1 |
isspace
是系统函数,用于检查参数c是否为空格字符,也就是判断是否为空格(‘ ‘)、水平定位字符
(‘\t’)、归位键(‘\r’)、换行(‘\n’)、垂直定位字符(‘\v’)或翻页(‘\f’)的情况。如果是空白字符就返回 TRUE,不是空白字符就返回 NULL;
get_line
函数
get_line
函数就是读取一行以 \r\n
结尾的文本数据,这是基本的字符串处理程序,了解好指针和基本库函数的使用就比较简单了。
1 | /**********************************************************************/ |
unimplemented
和 not_found
函数分别用于返回 501 页面和 404 页面
unimplemented
用于向客户端发送方法未实现的页面信息,具体内容就是 send
函数发送的那些字符串。
这里只有一个 send
函数需要留意,send
1 | /**********************************************************************/ |
同理 not_found
返回 404 页面。
1 | /**********************************************************************/ |
serv_file
返回文件内容给客户端
1 | /**********************************************************************/ |
这里的逻辑也比较简单,读取文件内容,发送给客户端。给客户端发送数据时需要先使用 headers
组织数据头信息,然后发送文件内容。
headers 函数
很简单,没什么说的,就是发送一些字符串信息,用于标示 HTTP 头部信息。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20/**********************************************************************/
/* Return the informational HTTP headers about a file. */
/* Parameters: the socket to print the headers on
* the name of the file */
/**********************************************************************/
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);
}cat
发送文件内容cat
函数用于发送文件内容到客户端,在客户端浏览器上显示出来。代码也比较简单。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18/**********************************************************************/
/* 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];
fgets(buf, sizeof(buf), resource);
while (!feof(resource))
{
send(client, buf, strlen(buf), 0);
fgets(buf, sizeof(buf), resource);
}
}
execute_cgi
执行 cgi 文件
1 | /**********************************************************************/ |
execute_cgi
先从 client socket 中继续读取数据,判断 Content-Length
的大小是正确,然后创建管道。
管道是进程间通信的一种方式,这里创建了两个管道 cgi_input
和 cgi_output
,并 fork 出一个子进程。
在子进程中,把 STDOUT 重定向到 cgi_outputt 的写入端,把 STDIN 重定向到 cgi_input 的读取端,关闭 cgi_input 的写入端 和 cgi_output 的读取端,设置 request_method 的环境变量,GET 的话设置 query_string 的环境变量,POST 的话设置 content_length 的环境变量,这些环境变量都是为了给 cgi 脚本调用,接着用 execl 运行 cgi 程序。
在父进程中,关闭 cgi_input 的读取端 和 cgi_output 的写入端,如果 POST 的话,把 POST 数据写入 cgi_input,已被重定向到 STDIN,读取 cgi_output 的管道输出到客户端,该管道输入是 STDOUT。接着关闭所有管道,等待子进程结束。
用图表示出来就是:
图1 管道初始状态:
图2 管道最终状态:
图片直观地显示了 GET 和 POST 请求通过管道将数据发送到客户端浏览器的过程。
还有一张图片描述了 execute_cgi 的整个流程:
这里重点关注一下管道的概念和使用方式。
网络抓包
在运行 httpd 服务端后打开 wireshark 抓包看一下具体的数据信息;在输入颜色提交后,可以抓取到如下数据:
执行提交后客户端向浏览器发送了一个 POST 请求,附带有颜色信息。
其实向前倒还能看到建立连接的三次握手信息,以及服务端返回的 200 OK 的成功信息。
总结
到这里源码就分析完了,整个过程涉及到网络的连接建立,网络数据的收发,cgi 程序的运行,浏览器页面的展示等内容;有一些字符串的处理,管道的使用,非常短小精悍,值得好好学习一些。