一直很好奇web的工作原理,加之这阵子也在学习Python爬虫,就有想法了解这部分的知识,所以买了一本图解HTTP。
这本书简洁清晰也很形象地介绍了HTTP协议的工作流程,对零基础了解HTTP协议有着不错的引导作用。
书也很薄,可以很快看完。不过纯粹通过看书学习一个协议难免会浮于表面,因此,我找了TinyHttpd的source code来了解http协议的实现和实际工作场景。
1. 背景
一直很好奇web的工作原理,加之这阵子也在学习Python爬虫,就有想法了解这部分的知识,所以买了一本图解HTTP。这本书简洁清晰也很形象地介绍了HTTP协议的工作流程,对零基础了解HTTP协议有着不错的引导作用。书也很薄,可以很快看完。不过纯粹通过看书学习一个协议难免会浮于表面,因此,我找了TinyHttpd的source code来了解http协议的实现和实际工作场景。
2. 源码解析
声明:这篇里面的代码并不是TinyHttpd的源码,是我自己手动临摹一遍的代码,实测跑通了。一直相信代码自己码一遍会比纯看加注释收获多一些。同时,TinyHttpd只有几百行,自己码一遍也不算什么。关于阅读tinyhttpd的source code,个人觉得可以以如下顺序展开:main –> startup –> accept_request –> execute_cgi –>了解cgi实现,因此本文就按照此顺序展开分享。
主体框架 -> main()
main函数是整个httpd的工作框架,具体的实现流程如下, startup创建socket通信并建立端口监听 –> accept等待客户端连接请求 –> accept_request处理客户端http请求 –> cleanup释放资源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
31int main(int argc,char *argv[])
{
int sever_sock = -1;
u_short port = 5277;
int client_sock = -1;
struct sockaddr_in client_name;
unsigned int client_name_len = sizeof(client_name);
pthread_t newthread;
sever_sock = startup(&port); //建立socket通讯,并进行端口监听
printf("httpd running on port %d\n", port);
while(1)
{
client_sock = accept(sever_sock,
(struct sockaddr *)&client_name,
&client_name_len); // 接受客户端请求
if(client_sock == -1)
{
error_die("accept failed");
}
if(pthread_create(&newthread, NULL, accept_request, (void *)&client_sock) != 0) // 创建子线程处理客户端请求
{
perror("pthread_create failed");
}
}
cleanup(sever_sock); // 关闭socket,释放相关资源
printf("httpd stopped\n");
return 0;
}
基础通讯实现 -> startup()
HTTP是一个应用层协议,通过TCP/IP进行传输的。HTTP协议规定,连接请求从客户端发起,服务端提供资源响应。在客户端无请求的情况下,服务端不会主动发送响应。服务端通讯建立过程: socket创建套接字 –> bind绑定套接字 –> listen监听套接字 –> accept等待客户端连接请求。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
51int startup(u_short *port)
{
int httpd = 0;
struct sockaddr_in name;
// 创建socket描述符:采用TCP通讯方式,在第二个参数确定的情况下,第三个参数可以传0由函数自动匹配对应协议
httpd = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if( httpd == -1 )
{
error_die("socket failed");
}
// 绑定套接字:绑定IP地址和端口号
memset(&name, 0, sizeof(name));
name.sin_family = AF_INET;
name.sin_port = htons(*port); // 指定端口:若端口为0,则自动分配一个端口。将端口转换为网络字节序
name.sin_addr.s_addr = htonl(INADDR_ANY); //IP地址:INADDR_ANY -> 服务器上所有的IP对应端口号都监听
if( bind(httpd,(const struct sockaddr *)&name, sizeof(name) ) < 0 )
{
error_die("bind failed");
}
// 若端口为0,获取自动分配的端口号
if(*port == 0)
{
int namelen = sizeof(name);
if( getsockname(httpd, (struct sockaddr *)&name, &namelen) == -1 ) // 获取套接字信息
{
error_die("getsockname failed");
}
*port = ntohs(name.sin_port); // 获取端口号: 网络字节序转主机字节序
}
// 监听socket
if( listen(httpd, 5) < 0 ) // 监听httpd,等待客户端连接请求,并设置最大可排队连接数为5个
{
error_die("listen failed");
}
return httpd;
}
```
#### 请求处理 -> accept_request() ####
accept_request是这个httpd的主体。通过解析http请求,对应发送资源和响应。http请求报文主要由三部分组成: 报文首部(分请求起始行和可选的请求首部字段)、空行、报文主体。通常并不一定要有报文主体。请求报文中每一行都以回车换行(**CRLF**,即"\r\n")作为结束标志。
``` html
Method URL HTTP_Version<CRLF> // 请求起始行
Header_Name: Header_Value<CRLF> // 请求首部字段,可选
... ...
Header_Name: Header_Value<CRLF>
<CRLF> // 空行,表示报文首部结束
BODY // 报文主体
下文我们用来分析的报头首部是用wireshark抓chrome访问httpd时发出的,只有报文首部,没有报文主体。不同浏览器可能有所差异,具体可用wireshark尝试分析。
TinyHttpd主要是针对请求起始行进行处理。请求起始行由Method、Request-Url和Http版本信息组成,三者通过空格隔开。如下请求起始行中”GET”就是method,表示请求访问服务器的类型,用于告知服务器访问意图。”/“为URL,表示请求访问的资源,也称作Request-URL,”HTTP/1.1”表示http版本信息,用来提示客户端使用的http协议功能。
下面的内容为请求首部字段,是可选的,在accept_request的execute_cgi中,我们只有在处理POST请求时才会去解析这部分的内容,对于GET,我们解析请求起始行后会去清除buf中的这部分数据,避免对后续处理或者下次通讯请求造成影响。1
2
3
4
5
6
7
8
9GET / HTTP/1.1 // 请求起始行
Host: 192.168.179.145:5277 // 以下为可选首部字段,格式为Header-Name: Header-Value<CRLF>
Connection: keep-alive
Cache-Control: max-age=0
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8
Accept-Encoding: gzip, deflate, sdch
Accept-Language: zh-CN,zh;q=0.8,en;q=0.6
解析请求的具体实现。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
117void *accept_request(void *pclient)
{
int client = *(int*)pclient;
char buf[1024];
char method[255] = {0, };
char url[255] = {0, };
char path[255] = {0, };
char *query_string = NULL;
struct stat st;
int i = 0, j = 0, cgi = 0;
unsigned int numofchars = 0;
numofchars = sock_getline(client, buf, sizeof(buf)); // 获取一行请求报文,以LF(\n)作为结尾。
printf("recieve : %s", numofchars == 0 ? "NULL\r\n" : buf);
// 对于http报文来说,第一行即为请求起始行:method url http-version
while( !isspace((int)buf[i]) && (i < sizeof(method) - 1) // 获取请求方法
method[j++] = buf[i++];
method[i] = '\0';
// strcasecmp为忽略大小写,比较字符串是否相同,相同则返回0,否则参数1长度大于参数2时返回正值,反之返回负值。
// TinyHttpd只支持GET和POST两种方法
if( strcasecmp(method, "GET") && strcasecmp(method, "POST") )
{
bad_request(client);
return ;
}
// 检测请求是POST还是GET,若为POST则需要CGI处理,置起对应标志
cgi = strcasecmp(method, "POST") == 0 ? 1 : 0;
//清除多余空格
while( isspace((int)buf[j]) && (j++ < sizeof(buf)) )
;
i = 0;
//获取URL,用于确定访问什么资源
while( !isspace((int)buf[j]) && (i < sizeof(url) - 1) && (j < sizeof(buf)) )
{
url[i++] = buf[j++];
}
url[i] = '\0';
#if DEBUG_ENABLE
printf("Request-URL: %s\r\n", url);
#endif
/* process the request */
if(cgi == 0) /* method : GET */
{
query_string = url;
// 若GET请求的URL带?,则表明有查询参数,须CGI处理
while( (*query_string != '?') && (*query_string != '\0') )
query_string++;
if (*query_string == '?') /* should be process by CGI */
{
cgi = 1;
*query_string = '\0';
query_string++; //截取查询的字符串
}
}
/*以上为请求起始行的解析过程。*/
// 将URL转化为本地资源路径path
sprintf(path, "htdocs%s", url);
// 如果path为目录则返回首页路径
if(path[strlen(path) - 1] == '/')
{
strcat(path, "index.html");
}
#if DEBUG_ENABLE
printf("request path: %s\r\n", path);
#endif
//检测请求文件是否存在
if(stat(path, &st) == -1)
{
//文件不存在则清除剩余header信息,即可选首部字段部分。
while( (numofchars > 0) && strcmp("\n", buf) )
{
numofchars = sock_getline(client, buf, sizeof(buf));
}
not_found(client); // 向浏览器声明没有相应资源
}
else
{
// 若请求URL为路径,则返回首页
// warning: 这里有一个bug,假设URL为"htdocs/index",本地存在这个目录,
// 但不存在"htdocs/index/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 = 1;
}
#if DEBUG_ENABLE
printf("cgi[%d]: goto %s\r\n", cgi, cgi == 0 ? "serve_file":"execute_cgi");
#endif
if (cgi == 0)
{
serve_file(client, path); // 请求文件存在且非执行,则发送文件内容
}
else
{
execute_cgi(client, path, method, query_string); // 需执行CGI获取内容的
}
}
close(client); //释放客户端套接字,通讯结束
}
执行CGI
1 | void execute_cgi( int client, const char *path, const char *method, const char *query_string ) |
文件发送实现
1 | void cat( int client, FILE *resource ) |
相关函数实现
1、获取客户端请求报文的一行内容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
40int sock_getline(int sock, char *buf, unsigned int size)
{
int i = 0;
char ch = '\0';
int n = 0;
if((buf == NULL) && (size == 0) && (sock == -1)) // 参数合法性检查
{
printf("parameter error, please check %s[%d]\n", __func__, __LINE__);
return -1;
}
while( (i < size - 1) && (ch != '\n') ) // \n是行结束标志
{
n = recv(sock, &ch, 1, 0);
if(n > 0)
{
if(ch == '\r')
{
n = recv(sock, &ch, 1, MSG_PEEK); // MSG_PEEK可实现下次读到的,仍是此次读取到的内容
if( (n > 0) && (ch == '\n') ) // 若读取到的\r\n,则此次读取结束,读取到的字符为\n
{
recv(sock, &ch, 1, 0);
}
else
{
ch = '\n'; // 否则设定读取的字符为\n,读取结束
}
}
buf[i] = ch;
i++;
}
else
{
ch = '\n';
}
}
buf[i] = '\0';
return i;
}
2、服务器响应报文实现
为方便代码编写和阅读,我在tinyhttpd的基础上实现了下面这个函数,专门用于发送字符到socket1
2
3
4
5
6
7void send_str(int client, const char *str)
{
unsigned int ret = send(client, str, strlen(str), 0);
ret == strlen(str) ? 0 : printf("send_str error[ret = 0x%02x].\r\n", ret);
}
1 | /*发送文件前的响应头*/ |