第一次接触到服务端开发,应该是学 Java 的时候,了解到了 servlet 这么一个东西。在做 Java 的时候一直以来都是和 Tomcat, Jetty 这样的 Web 容器打交道。记得那段时间找到一本书《How Tomcat works》, 我花了两周时间阅读,感触良多。后来又陆陆续续接触到了 Netty 之类的。

我开始问自己,实现一个 http 服务器,到底需要做什么?

所以,直接上 C 吧。参照某个经典的案例。

首先在读源码之前,可以再温习一下基于 TCP 的协议是如何通信的。

TCP
TCP

结合这个图,从源码的角度一步一步分析。源码地址 https://github.com/razertory/tinyhttpd

主流程

  • 服务端初始化
  • 等待客户端
  • 连接,通信,结束通信,结束连接
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
int 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);

server_sock = startup(&port);// 下面的 starup 函数
printf("httpd running on port %d\n", port);

while (1)
{
client_sock = accept(server_sock,
(struct sockaddr *)&client_name,
&client_name_len);// 上图的 accept 函数
if (client_sock == -1)
error_die("accept");
accept_request(client_sock);
}

close(server_sock);

return(0);
}

启动一个 http server

  • 调用库 socket() 函数产生 httpd
  • 调用 bind() 函数让 socket 绑定给某个端口 (绑定过程比想象复杂)
  • 调用 listen() 函数监听端口
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
int startup(u_short *port)
{
int httpd = 0;
//sockaddr_in 是 IPV4 的套接字地址结构。定义在 <netinet/in.h>, 参读《TLPI》P1202
struct sockaddr_in name;

httpd = socket(PF_INET, SOCK_STREAM, 0);// 上图的 socket 函数
if (httpd == -1)
error_die("socket");

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

//bind() 用于绑定地址与 socket。参读《TLPI》P1153
// 如果传进去的 sockaddr 结构中的 sin_port 指定为 0,这时系统会选择一个临时的端口号
if (bind(httpd, (struct sockaddr *)&name, sizeof(name)) < 0)// 上图的 bind 函数
error_die("bind");

// 如果调用 bind 后端口号仍然是 0,则手动调用 getsockname() 获取端口号
if (*port == 0) /* if dynamically allocating a port */
{
int namelen = sizeof(name);
//getsockname() 包含于 < sys/socker.h> 中,参读《TLPI》P1263
// 调用 getsockname() 获取系统给 httpd 这个 socket 随机分配的端口号
if (getsockname(httpd, (struct sockaddr *)&name, &namelen) == -1)
error_die("getsockname");
*port = ntohs(name.sin_port);
}

// 最初的 BSD socket 实现中,backlog 的上限是 5. 参读《TLPI》P1156
if (listen(httpd, 5) < 0)
error_die("listen");
return(httpd);
}

接受 & 解析请求

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
void accept_request(int client)
{
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;

// 读 http 请求的第一行数据(request line),把请求方法存进 method 中
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';

// 如果请求的方法不是 GET 或 POST 任意一个的话就直接发送 response 告诉客户端没实现该方法
if (strcasecmp(method, "GET") && strcasecmp(method, "POST"))
{
unimplemented(client);
return;
}

// 如果是 POST 方法就将 cgi 标志变量置一 (true)
if (strcasecmp(method, "POST") == 0)
cgi = 1;

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

// 然后把 URL 读出来放到 url 数组中
while (!ISspace(buf[j]) && (i < sizeof(url) - 1) && (j < sizeof(buf)))
{
url[i] = buf[j];
i++; j++;
}
url[i] = '\0';

// 如果这个请求是一个 GET 方法的话
if (strcasecmp(method, "GET") == 0)
{
// 用一个指针指向 url
query_string = url;

// 去遍历这个 url,跳过字符 ?前面的所有字符,如果遍历完毕也没找到字符 ?则退出循环
while ((*query_string != '?') && (*query_string != '\0'))
query_string++;

// 退出循环后检查当前的字符是 ?还是字符串 (url) 的结尾
if (*query_string == '?')
{
// 如果是 ? 的话,证明这个请求需要调用 cgi,将 cgi 标志变量置一 (true)
cgi = 1;
// 从字符 ? 处把字符串 url 给分隔会两份
*query_string = '\0';
// 使指针指向字符 ?后面的那个字符
query_string++;
}
}

// 将前面分隔两份的前面那份字符串,拼接在字符串 htdocs 的后面之后就输出存储到数组 path 中。相当于现在 path 中存储着一个字符串
sprintf(path, "htdocs%s", url);

// 如果 path 数组中的这个字符串的最后一个字符是以字符 / 结尾的话,就拼接上一个 "index.html" 的字符串。首页的意思
if (path[strlen(path) - 1] == '/')
strcat(path, "index.html");

// 在系统上去查询该文件是否存在
if (stat(path, &st) == -1) {
// 如果不存在,那把这次 http 的请求后续的内容 (head 和 body) 全部读完并忽略
while ((numchars> 0) && strcmp("\n", buf)) /* read & discard headers */
numchars = get_line(client, buf, sizeof(buf));
// 然后返回一个找不到文件的 response 给客户端
not_found(client);
}
else
{
// 文件存在,那去跟常量 S_IFMT 相与,相与之后的值可以用来判断该文件是什么类型的
//S_IFMT 参读《TLPI》P281,与下面的三个常量一样是包含在 <sys/stat.h>
if ((st.st_mode & S_IFMT) == S_IFDIR)
// 如果这个文件是个目录,那就需要再在 path 后面拼接一个 "/index.html" 的字符串
strcat(path, "/index.html");

//S_IXUSR, S_IXGRP, S_IXOTH 三者可以参读《TLPI》P295
if ((st.st_mode & S_IXUSR) ||
(st.st_mode & S_IXGRP) ||
(st.st_mode & S_IXOTH) )
// 如果这个文件是一个可执行文件,不论是属于用户 / 组 / 其他这三者类型的,就将 cgi 标志变量置一
cgi = 1;

// 构造响应
if (!cgi)
// 如果不需要 cgi 机制的话,
serve_file(client, path);
else
// 如果需要则调用
execute_cgi(client, path, method, query_string);
}

close(client);
}

不需要 cgi 机制的 response

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
void serve_file(int client, const char *filename)
{
FILE *resource = NULL;
int numchars = 1;
char buf[1024];

// 确保 buf 里面有东西,能进入下面的 while 循环
buf[0] = 'A'; buf[1] = '\0';
// 循环作用是读取并忽略掉这个 http 请求后面的所有内容
while ((numchars> 0) && strcmp("\n", buf)) /* read & discard headers */
numchars = get_line(client, buf, sizeof(buf));

// 打开这个传进来的这个路径所指的文件
resource = fopen(filename, "r");
if (resource == NULL)
not_found(client);
else
{
// 打开成功后,将这个文件的基本信息封装成 response 的头部 (header) 并返回
headers(client, filename);
// 接着把这个文件的内容读出来作为 response 的 body 发送到客户端
cat(client, resource);
}

fclose(resource);
}

需要 CGI 机制的 response

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
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 中填东西以保证能进入下面的 while
buf[0] = 'A'; buf[1] = '\0';
// 如果是 http 请求是 GET 方法的话读取并忽略请求剩下的内容
if (strcasecmp(method, "GET") == 0)
while ((numchars> 0) && strcmp("\n", buf)) /* read & discard headers */
numchars = get_line(client, buf, sizeof(buf));
else /* POST */
{
// 只有 POST 方法才继续读内容
numchars = get_line(client, buf, sizeof(buf));
// 这个循环的目的是读出指示 body 长度大小的参数,并记录 body 的长度大小。其余的 header 里面的参数一律忽略
// 注意这里只读完 header 的内容,body 的内容没有读
while ((numchars> 0) && strcmp("\n", buf))
{
buf[15] = '\0';
if (strcasecmp(buf, "Content-Length:") == 0)
content_length = atoi(&(buf[16])); // 记录 body 的长度大小
numchars = get_line(client, buf, sizeof(buf));
}

// 如果 http 请求的 header 没有指示 body 长度大小的参数,则报错返回
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;
}

// 子进程用来执行 cgi 脚本
if (pid == 0) /* child: CGI script */
{
char meth_env[255];
char query_env[255];
char length_env[255];

//dup2() 包含 < unistd.h> 中,参读《TLPI》P97
// 将子进程的输出由标准输出重定向到 cgi_ouput 的管道写端上
dup2(cgi_output[1], 1);
// 将子进程的输出由标准输入重定向到 cgi_ouput 的管道读端上
dup2(cgi_input[0], 0);
// 关闭 cgi_ouput 管道的读端与 cgi_input 管道的写端
close(cgi_output[0]);
close(cgi_input[1]);

// 构造一个环境变量
sprintf(meth_env, "REQUEST_METHOD=%s", method);
//putenv() 包含于 < stdlib.h> 中,参读《TLPI》P128
// 将这个环境变量加进子进程的运行环境中
putenv(meth_env);

// 根据 http 请求的不同方法,构造并存储不同的环境变量
if (strcasecmp(method, "GET") == 0) {
sprintf(query_env, "QUERY_STRING=%s", query_string);
putenv(query_env);
}
else { /* POST */
sprintf(length_env, "CONTENT_LENGTH=%d", content_length);
putenv(length_env);
}

//execl() 包含于 < unistd.h> 中,参读《TLPI》P567
// 最后将子进程替换成另一个进程并执行 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, 1, 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);
}
}

补充

CGI 机制

CGI 全称是 “通用网关接口”(Common Gateway Interface),它可以让一个客户端,从网页浏览器向执行在 Web 服务器上的程序请求数据。 CGI 描述了客户端和这个程序之间传输数据的一种标准。 CGI 的一个目的是要独立于任何语言的,所以 CGI 可以用任何一种语言编写,只要这种语言具有标准输入、输出和环境变量。 如 php,perl,tcl 等。

  1. 客户端访问某个 URL 地址之后,通过 GET/POST/PUT 等方式提交数据,并通过 HTTP 协议向 Web 服务器发出请求。
  2. 服务器端的 HTTP Daemon(守护进程)启动一个子进程。然后在子进程中,将 HTTP 请求里描述的信息通过标准输入 stdin 和环境变量传递给 URL 指定的 CGI 程序,并启动此应用程序进行处理,处理结果通过标准输出 stdout 返回给 HTTP Daemon 子进程。
  3. 再由 HTTP Daemon 子进程通过 HTTP 协议返回给客户端。