从零开始用C实现一个基本Web服务器

从零开始用C实现一个基本Web服务器

摘要: 用C实现一个基本Web服务器

0x01 写在前面

一直以来我的想法都是:学东西一定是要深入底层学习到本质的,特别是在我仍处本科羽翼未丰的阶段——当然了,不排除那些本科就已经各显神通的超级大牛们的存在早已深谙底层运作原理——因此在一两个月前萌生出了实现一个简单的Web服务器的想法。最开始的想法是通过Microsoft 的HTTP Server API来实现,奈何本人实在没有耐心去读这么长的文档,时间也不多。后来想起当初走马观花般草草读完的CSAPP的网络编程部分貌似有这么一个部分实现了一个名为TINY的迭代服务器,便想着既然当年自己因为基础不太好对其一直半解没有去实现,要不自己现在在填补了诸多当年所欠缺的专业知识后将当年留下的坑补上吧,于是就有了这一出。

目前的计划是,打算以CSAPP中的TINY作为蓝本——因为其只是个十分简单的只能处理GET请求的迭代服务器——而后不断完善加入新的功能。

0x02 实现环境

ubuntu 14.04

0x03 前置知识

在这里,我们默认大家理解了HTTP、TCP/IP的基本原理以及工作方式;基础的C/S架构。主要去介绍C语言中用于实现Web服务器所需要的接口函数。

在正式开始前,我们需要了解C的套接字接口(socket interface),其和Unix I/O相结合可以用来创建Web应用。整个套接字接口在创建Web应用时大体遵循以下的调用关系:

因此,我们需要去了解这些函数,熟悉他们的传入参数、返回结果、使用场景,我将其整理如下:

首先了解下存储套接字地址的结构体

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 早期定义如此,其实直接定义为标量更好,但改不过来了
struct in_addr{
uint32_t s_addr;
}
// 可以理解为ip用结构
struct sockaddr_in {
uint16_t sin_family;
uint16_t sin_port;
struct in_addr sin_addr;
unsigned char sin_zero[8];
}
// 通用的结构,忽略不同协议差异
struct sockaddr {
uint16_t sa_family;
char sa_data[14];
}

typedef struct sockaddr SA;

而后再来看看套接字相关的函数

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
#include <sys/socket.h>
/*
创建套接字描述符(socket descriptor)
domain用于设置网络通信的域,即通信协议簇
type设置套接字类型,一般设为SOCK_STREAM,指TCP连接
protocol用于设置type中的某个类似,一般一个type一个类型,所以通常我们将protocol设为0
clientfd = socket(AF_INET, SOCK_STREAM, 0); 创建一个连接端点
配合getaddrinfo使用
*/
int socket(int domain, int type, int protocol);

/*
为addr创建连接
可以通过读/写clientfd与服务器通信
配合getaddrinfo使用
*/
int connect(int clientfd, const struct sockaddr *addr, socklen_t addrlen);

/*
将addr中服务器套接字地址和sockfd套接字描述符联系起来
配合getaddrinfo使用
*/
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

/*
将套接字描述符sockfd从主动套接字转化为监听套接字
backlog与TCP/IP协议相关,内核空间中TCP连接队列数量的上限
*/
int listen(int sockfd, int backlog);

/*
等待连接请求到达监听描述符,而后返回一个同addr练习的已连接描述符(connected descriptor)
监听描述符存在于全局,服务器中只创建一次
已连接描述符每次接受连接都创建
可以通过读/写listenfd与客户端通信
方便并发
*/
int accept(int listenfd, struct sockaddr *addr, int *addrlen);

除此以外,Linux提供了两个十分强大的函数实现二进制套接字地址结构和主机名、主机地址、服务名和端口号字符串表示之间的相互转换。

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
#include <sys/types.h>
#include <sys/socket.h>
#include <netdb.h>
/*
addrinfo 结构体
*/
struct addrinfo {
int ai_flags;
int ai_famliy;
int ai_socktype;
int ai_protocol;
char *ai_canonname;
size_t ai_addrlen;
struct sockaddr *ai_addr;
struct addrinfo *ai_next;
}

/*
host和service为套接字地址的两个组成部分
return值成功返回1不成功返回0
还有一个result返回值,为addrinfo结构体的链表也即套接字地址的列表
hints用于对返回的套接字地址列表进行约束,只能设置ai_family、ai_socketype、ai_protocol和ai_flag字段
*/
int getaddrinfo(const char *host,
const char *service,
const struct addrinfo *hints,
struct addrinfo **result);

/*
释放getaddrinfo返回的result链表,防止leaking
*/
void freeaddrinfo(struct addrinfo *result);

/*
返回getaddrinfo返回的非0错误码对应的字符描述
*/
const char *gai_strerror(int errcode);

/*
将套接字地址结构sa转换为响应的主机名host和服务名service
hostlen为host指向的缓冲区大小
servlen为service指向的缓冲区大小
可将host或service设为NULL从而不获取对应的值
*/
int getnameinfo(const struct sockaddr *sa,
socklen_t salen,
char *host,
size_t hostlen,
char *service,
size_t servlen, int flags);

上面的这些套接字接口函数可以说是非常的复杂了,因此,将上面的函数结合起来,实现一个客户端连接套接字的生成的函数封装,再实现一个服务端监听监听套接字的封装。如下所示(摘自csapp.c):

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
int open_clientfd(char *hostname, int port) 
{
int clientfd;
struct hostent *hp;
struct sockaddr_in serveraddr;

if ((clientfd = socket(AF_INET, SOCK_STREAM, 0)) < 0)
return -1; /* Check errno for cause of error */

/* Fill in the server's IP address and port */
if ((hp = gethostbyname(hostname)) == NULL)
return -2; /* Check h_errno for cause of error */
bzero((char *) &serveraddr, sizeof(serveraddr));
serveraddr.sin_family = AF_INET;
bcopy((char *)hp->h_addr_list[0],
(char *)&serveraddr.sin_addr.s_addr, hp->h_length);
serveraddr.sin_port = htons(port);

/* Establish a connection with the server */
if (connect(clientfd, (SA *) &serveraddr, sizeof(serveraddr)) < 0)
return -1;
return clientfd;
}
/* $end open_clientfd */

/* $begin open_listenfd */
int open_listenfd(int port)
{
int listenfd, optval=1;
struct sockaddr_in serveraddr;

/* Create a socket descriptor */
if ((listenfd = socket(AF_INET, SOCK_STREAM, 0)) < 0)
return -1;

/* Eliminates "Address already in use" error from bind */
if (setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR,
(const void *)&optval , sizeof(int)) < 0)
return -1;

/* Listenfd will be an endpoint for all requests to port
on any IP address for this host */
bzero((char *) &serveraddr, sizeof(serveraddr));
serveraddr.sin_family = AF_INET;
serveraddr.sin_addr.s_addr = htonl(INADDR_ANY);
serveraddr.sin_port = htons((unsigned short)port);
if (bind(listenfd, (SA *)&serveraddr, sizeof(serveraddr)) < 0)
return -1;

/* Make it a listening socket ready to accept connection requests */
if (listen(listenfd, LISTENQ) < 0)
return -1;
return listenfd;
}
/* $end open_listenfd */

有了这些前置知识,对于我们实现一个显示静态内容的web服务器是足够的了,但如果想要执行一个动态的程序并返回该如何去实现呢?这就得扯到cgi了。

CGI(Common Gateway Interface)通用网关接口解决了以下几个问题:

  • 客户端传递参数给服务器的问题;
  • 服务器传递参数给子进程的问题;
  • 子进程如何输出。

首先是第一个问题,这个很好解决,如GET在url中?开头表明需要传递参数,&连接各个参数;

第二个问题,在CGI标准中,所有要执行的程序运行于/cgi-bin目录下。可以调用fork来创建一个子程序,而后调用execve在子进程的上下文中/cgi-bin目录下的程序。我们可以通过设置CGI定义的环境变量的方式来将参数传递给CGI程序(使用setenv设置,使用getenv获取)。

CGI定义了大量的环境变量,部分如下:

第三个问题,可以通过dup2函数将标准输出重定向到和客户端相关联的已连接描述符中,从而达到将CGI程序的输出发送到客户端的目的。而后我们将会具体来实现这些对应的功能。

至此,我们所需要实现Web服务器所需要的所有前置知识都已经列举完毕,可以开始书写我们的Web服务器了。代码不长,总共也就几百行,我会一块一块的来进行解读。

0x04 动手实现Web服务器吧

我们的服务器将完全参考CSAPP上的Tiny服务器来进行实现,并将在之后于其上面添加对应的功能。实现过程中,需要加入csapp.h和csapp.c两个封装好的包含了我们实现所需要的所有的函数的文件。可以在这里下载,不多说,直接上代码。

1
2
3
4
5
6
7
void doit(int fd);
void read_requesthdrs(rio_t *rp);
int parse_uri(char *uri, char *filename, char *cgiargs);
void serve_static(int fd, char *filename, int filesize);
void get_filetype(char *filename, char *filetype);
void serve_dynamic(int fd, char *filename, char *cgiargs);
void clienterror(int fd, char *cause, char *errnum, char *shortmsg, char *longmsg);

先贴出各个函数定义。

第一个doit是对请求进行处理的函数,可以看见传入了一个文件描述符——其实就是连接描述符,而后会对这个传入的描述符进行对应处理;

第二个read_requesthdrs用于读取请求头,传入的参数为rio_t的结构体,这个结构体存在于拥有鲁棒性的I/O函数包中,定义如下:

1
2
3
4
5
6
typedef struct {
int rio_fd; // Descriptor for this internal buf
int rio_cnt; // Unread bytes in internal buf
char *rio_bufptr; // Next unread byte in internal buf
char rio_buf[RIO_BUFSIZE]; // Internal buffer
} rio_t

第三个parse_uri用于解析uri,最后返回uri、文件名和cgi参数;

第四、第六个拥有处理静态的和动态的请求,至于如何区分两者主要通过请求中是否包含cgi-bin来确定;

第五个get_filetype用于根据文件尾缀获取文件类型;

第七个函数用于检测到错误信息后返回错误信息。

首先来看一下主调函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
int main(int argc, char **argv){
int listenfd, connfd;
// MAXLINE is defined in csapp.h, the value of it is 8192
char hostname[MAXLINE], port[MAXLINE];
// socklen_t is a kind of value type, it can be seem as int. Used by the third arg of accept()
socklen_t clientlen;
struct sockaddr_storage clientaddr;

if (argc != 2){
fprintf(stderr, "usage: %s <port>\n", argv[0]);
exit(1);
}

listenfd = Open_listenfd(atoi(argv[1]));

while (1) {
clientlen = sizeof(clientaddr);
connfd = Accept(listenfd, (SA *)&clientaddr, &clientlen);
doit(connfd);
Close(connfd);
}
}

主调函数非常的简单,先通过监听用户指定的端口创建一个监听描述符;而后进入一个while循环,在这个while循环中accept请求,并对accept到的文件描述符(已连接描述符)放入doit函数中进行处理;最后不忘显式地关闭文件描述符。

而后,再一起看看最为关键的doit函数,定义如下:

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
void doit(int fd){
int is_static;
struct stat sbuf;
char buf[MAXLINE], method[MAXLINE], uri[MAXLINE], version[MAXLINE];
char filename[MAXLINE], cgiargs[MAXLINE];
rio_t rio;

// Read the request line and headers
Rio_readinitb(&rio, fd);
Rio_readlineb(&rio, buf, MAXLINE);
printf("Request headers:\n");
printf("%s", buf);
sscanf(buf, "%s %s %s", method, uri, version);
if(strcasecmp(method, "GET")){
clienterror(fd, method, "501", "Not implemented",
"Hn13 does not implemented this method");
return ;
}
read_requesthdrs(&rio);

// Parse URI from GET requset
is_static = parse_uri(uri, filename, cgiargs);
// user stat() to find if this file exsit or not.
// as well as the status of this file
if(stat(filename, &sbuf)<0){
clienterror(fd, filename, "404", "Not Found",
"Hn13 couldnt find this file");
return ;
}

if(is_static){
// judge if this file is a regular file or not
// privilege judging
if(!(S_ISREG(sbuf.st_mode))||!(S_IRUSR & sbuf.st_mode)){
clienterror(fd, filename, "403", "Forbidden",
"Hn13 couldnt read the file");
return ;
}
serve_static(fd, filename, sbuf.st_size);
}else{
if(!(S_ISREG(sbuf.st_mode))||!(S_IRUSR & sbuf.st_mode)){
clienterror(fd, filename, "403", "Forbidden",
"Hn13 couldnt read the file");
return ;
}
serve_dynamic(fd, filename, cgiargs);
}

}

上来先读取了fd中对应的请求头到buf这个char数组中,并将其打印出来,而后通过sscanf

函数将HTTP头中的请求方法、uri、HTTP版本放入method、uri、version三个数组中。

然后对请求方法进行判断,因为我们这个服务器非常的原始,非GET方法将返回错误信息。这里我们来看一下错误处理函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void clienterror(int fd, char *cause, char *errnum, 
char *shortmsg, char *longmsg){

char buf[MAXLINE], body[MAXLINE];

// HTTP response body
sprintf(body, "<html><title>Hn13 Error</title>");
sprintf(body, "%s<body bgcolor=""ffffff"">\r\n", body);
sprintf(body, "%s%s: %s\r\n", body, errnum, shortmsg);
sprintf(body, "%s<p>%s: %s\r\n", body, longmsg, cause);
sprintf(body, "%s<hr><em>The Hn13 Web server</em>\r\n", body);

// HTTP response header
sprintf(buf, "HTTP/1.0 %s %s\r\n", errnum, shortmsg);
Rio_writen(fd, buf, strlen(buf));
sprintf(buf, "Content-type: text/html\r\n");
Rio_writen(fd, buf, strlen(buf));
sprintf(buf, "Content-length: %d\r\n\r\n", (int)strlen(body));
Rio_writen(fd, buf, strlen(buf));
Rio_writen(fd, body, strlen(body));
}

很简单,我们通过sprintf将我们想要构造的html body和响应头放入到buf中,而后通过Rio_writen写入到fd(已连接文件描述符)中,从而得以将错误消息传入到前端。这边在请求投中一定要注意最后一个请求头必须带上两个CRLF。

回到我们的doit函数中,下面就是读取请求头这个函数,我们对请求头并没有特殊处理,读取后便丢弃了,不多讲。

再后来到我们的parse_uri函数:

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
int parse_uri(char *uri, char *filename, char *cgiargs){

char *ptr;

if(!strstr(uri, "cgi-bin")){
strcpy(cgiargs, "");
strcpy(filename, ".");
strcat(filename, uri);
if(uri[strlen(uri)-1]=='/')
strcat(filename, "home.html");
return 1;
}else{
// get address of ?
ptr = index(uri, '?');
if(ptr){
strcpy(cgiargs, ptr+1);
*ptr = '\0';
}else
strcpy(cgiargs, "");

strcpy(filename, ".");
strcat(filename, uri);
return 0;

}

很明显,感觉是否存在cgi-bin字段来确定是进行静态处理还是动态处理(1为静态,2位动态)。静态解析中,首先将cgi参数置空,而后拼接文件路径;动态解析中,通过获取?的位置截断了请求的文件地址和cgi参数,而后返回cgiargs和filename。

回到doit,下一步就是判断文件是否存在并获取文件的相关信息了。这里使用了stat函数,stat()用来将参数file_name 所指的文件状态, 复制到参数buf 所指的结构中。详情可以看这篇文章

再后进入到服务处理了,在静态中

1
!(S_ISREG(sbuf.st_mode))||!(S_IRUSR & sbuf.st_mode)

第一个S_ISREG判断文件是否为常规文件;第二个判断服务器是否对其有读权限,过了这个判断后,进入到serve_static处理函数中。

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
void serve_static(int fd, char *filename, int filesize){

int srcfd;
char *srcp, filetype[MAXLINE], buf[MAXLINE];

// constructe response headers to clinet
get_filetype(filename, filetype);
sprintf(buf, "HTTP/1.0 200 OK\r\n");
sprintf(buf, "%sServer: Hn13 Web Server \r\n", buf);
sprintf(buf, "%sConnection: Close\r\n", buf);
sprintf(buf, "%sContent-length: %d\r\n", buf, filesize);
sprintf(buf, "%sContent-type: %s\r\n\r\n", buf, filetype);
Rio_writen(fd, buf, strlen(buf));
printf("Response headers: \n");
printf("%s",buf);

// send the response body
srcfd = Open(filename, O_RDONLY, 0);
// map the srcfd's pre filesiOze bytes data to the virsual memory space.
srcp = Mmap(0, filesize, PROT_READ, MAP_PRIVATE, srcfd, 0);
Close(srcfd);
Rio_writen(fd, srcp, filesize);
Munmap(srcp, filesize);

}

首先,构造了响应头,同样,千万注意最后一条响应头的尾巴有两个CRLF;而后Open打开文件,赋给文件描述符srcfd,而后,通过Mmap函数将文件映射到虚拟空间中,地址返回给srcp,最后通过Rio_writen写入到fd中并解映射srcp。

最后就是我们的动态处理函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void serve_dynamic(int fd, char *filename, char *cgiargs){

char buf[MAXLINE], *emptylist[] = { NULL };

// the first part of HTTP response
sprintf(buf, "HTTP/1.0 200 OK\r\n");
Rio_writen(fd, buf, strlen(buf));
sprintf(buf, "Server: Hn13 Web Server\r\n");
Rio_writen(fd, buf, strlen(buf));

if(Fork() == 0){
// set the environment value
setenv("QUERY_STRING", cgiargs, 1);
//use dup2 to redirect the file descriptor
Dup2(fd, STDOUT_FILENO);
Execve(filename, emptylist, environ);
}
Wait(NULL);
}

关键点在于我们首先使用了Fork()创建了子进程,而后在子进程中首先将cgi参数写入环境变量QUERY_STRING中,而后使用Dup2重定向标准输出符到fd中,最后执行我们的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
int main(void){
char *buf, *p;
char arg1[MAXLINE], arg2[MAXLINE], content[MAXLINE];
int n1=0, n2=0;

if((buf = getenv("QUERY_STRING")) != NULL){
p = strchr(buf, '&');
*p = '\0';
strcpy(arg1, buf);
strcpy(arg2, p+1);
n1 = atoi(arg1);
n2 = atoi(arg2);
}

// Response body
sprintf(content, "QUERY_STRING=%s", buf);
sprintf(content, "This is hn13's demo: ");
sprintf(content, "%sThe Internet addition portal.\r\n<p>", content);
sprintf(content, "%sThe answer is: %d+%d=%d",content, n1, n2, n1+n2);
sprintf(content, "%sThanks for visting!", content);

// Response header
printf("Connection: Close\r\n");
printf("Content-length: %d\r\n", (int)strlen(content));
printf("Content-type: text/html\r\n\r\n");
printf("%s",content);
fflush(stdout);
}

注意到我们的最后那几个printf()看到那,相信聪明的你就已经明白了怎么回事了。

至此,我们整个web服务器就已经实现完毕了,我们可以运行来看一看。

首先编译:

以上代码本人都已经上传到了github中。

1
$ gcc -o web webserv.c csapp.c -lpthread

而后运行

1
$ ./web 1026

打开浏览器,访问结果如下:

至此,我们的十分简陋的Web服务器就已经完成了。在之后的几篇博客里,我将逐步将这个服务器完善起来,添加更多的功能。

0x05 写在后面

这次写的这个轮子让我对计算机网络有了更为深刻的理解,可以说收获颇丰,纸上学来终觉浅,最终总归是要回到实践上来的。

源码我放到github上了,各位感兴趣的请自行访问并本地部署。


评论