Tiny Web服务器是《深入理解计算机系统》一书中11.6节的内容,该节演示了一个只有250行代码的服务器,非常适合对C语言和网络编程感兴趣的朋友进行学习!话不多说,让我们开始正式的学习!
基本使用
Tiny Web服务器的主要代码在原书上已经给出,如果要下载完整的代码,可以在官网上进行下载,具体地址请点击这里,除此之外,在Github上也可以搜到热心用户上传的代码。
下载好代码之后,我们要进行编译才可以使用。如果是官网代码的话,直接使用make
命令进行编译就可以了,如果不是的话(没有Makefile文件),则具体编译操作如下:
gcc tiny.c csapp.c -o tiny
cd cgi-bin
gcc adder.c -o adder -I../
cd ..
编译完成后,我们只要执行./tiny 8888
,就可以开启Tiny Web服务器了!
首先输入127.0.0.1:8888,可以对Tiny Web服务器的静态页面进行测试,然后输入127.0.0.1:8888/cgi-bin/adder?1&2,可以对服务器的动态页面进行测试。这里值得一提的是,Tiny Web服务器只支持GET请求,如果是POST请求的话,会返回错误信息。
源码解读
套接字接口
套接字接口(socket interface)是一组函数,它们和Unix I/O函数结合起来,用以创建网络应用。学习socket编程能够让我们加深对网络框架的理解,socket编程的函数可以通过下图体现:
通过此图,我们可以大致了解socket包含哪些函数,接下来我们对几个重要的函数进行一一介绍:
socket函数
客户端和服务器使用socket函数来创建一个套接字描述符,具体使用如下:
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
其中,domain指定socket的通信协议集(AF_UNIX和AF_INET等),AF_UNIX只能用于单一的*nix系统,而AF_INET是用于网络的,所以可以允许远程主机之间的通信;type指定socket类型(SOCK_STREAM和SOCK_DGRAM等),SOCK_STREAM表明使用TCP协议,SOCK_DGRAM表明使用UDP协议;protocol指定实际使用的传输协议,如果该项为0,则使用缺省协议。调用socket函数后,成功会返回文件描述符,失败返回-1。
bind函数
bind函数是服务器特有的函数,其使用如下:
#include <sys/socket.h>
int bind(int socket, const struct sockaddr *address, socklen_t address_len);
bind函数为socket分配地址,其中socket是套接字描述符,address指向sockaddr结构(用于表示所分配地址)的指针,address_len是sockaddr结构的长度。
listen函数
listen函数将sockfd从一个主动套接字转化为一个监听套接字,该套接字可以接受来自客户端的连接请求,具体使用如下:
#include <sys/socket.h>
int listen(int socket, int backlog);
其中backlog是一个决定监听队列大小的整数,当有连接请求到来,就会进入此监听队列,当队列满后,新的连接请求会返回错误。
accept函数
服务器通过调用accept函数来等待来自客户端的连接请求,具体使用如下:
#include <sys/socket.h>
int accept(int socket, struct sockaddr *restrict address, socklen_t *restrict address_len);
其中socket是监听的socket描述符,address是指向sockaddr结构体的指针,address_len是客户机地址结构体的大小。
connect函数
客户端通过调用connect函数来建立与服务器的连接,具体使用如下:
#include <sys/socket.h>
int connect(int socket, const struct sockaddr *address, socklen_t address_len);
socket类型
socket类型主要有SOCK_STREAM、SOCK_DGRAM、SOCK_RAW三种,其中SOCK_RAW工作在网络层,可以处理ICMP、IGMP等网络报文以及特殊的IPv4报文,而SOCK_STREAM(TCP)、SOCK_DGRAM(UDP)工作在传输层。
csapp.c的open_clientfd函数
具体代码
/*
* open_clientfd - Open connection to server at <hostname, port> and
* return a socket descriptor ready for reading and writing. This
* function is reentrant and protocol-independent.
*
* On error, returns -1 and sets errno.
*/
int open_clientfd(char *hostname, char *port) {
int clientfd;
struct addrinfo hints, *listp, *p;
/* Get a list of potential server addresses */
memset(&hints, 0, sizeof(struct addrinfo));
hints.ai_socktype = SOCK_STREAM; /* Open a connection */
hints.ai_flags = AI_NUMERICSERV; /* ... using a numeric port arg. */
hints.ai_flags |= AI_ADDRCONFIG; /* Recommended for connections */
Getaddrinfo(hostname, port, &hints, &listp);
/* Walk the list for one that we can successfully connect to */
for (p = listp; p; p = p->ai_next) {
/* Create the socket descriptor */
if ((clientfd = socket(p->ai_family, p->ai_socktype, p->ai_protocol)) < 0)
continue; /* Socket failed, try the next */
if (connect(clientfd, p->ai_addr, p->ai_addrlen) != -1)
break; /* Success */
Close(clientfd); /* Connect failed, try another */
}
/* Clean up */
Freeaddrinfo(listp);
if (!p) /* All connects failed */
return -1;
else /* The last connect succeeded */
return clientfd;
}
详细解析
- 将socket和connect函数包装成一个叫做open_clientfd的辅助函数是很方便的,客户端可以用它来和服务器建立连接
- 函数中需要使用到结构体addrinfo,这是头文件netdb.h中带有的结构体,netdb.h提供了设置及获取域名的函数
- 函数先为结构体addrinfo变量hints动态分配内存空间,然后设置结构体成员的值,这里的“|=”运算符是表示或运算
- 配置好hints后,open_clientfd函数将调用Getaddrinfo函数(用于通过域名获取IP)返回一个指向addrinfo结构体链表的指针,这个指针也就是listp
- 接下来函数将遍历listp。每次遍历都是先调用socket函数,如果获取套接字描述符失败,则会执行下一次遍历,如果成功,则会尝试连接。如果连接成功,则会跳出循环,如果失败,则继续尝试
- 结束遍历后,函数会释放listp占有的内存,并且判断是否获取到能够连接的socket,如果获取到直接返回,反之则返回-1
csapp.c的open_listenfd函数
具体代码
/*
* open_listenfd - Open and return a listening socket on port. This
* function is reentrant and protocol-independent.
*
* On error, returns -1 and sets errno.
*/
int open_listenfd(char *port)
{
struct addrinfo hints, *listp, *p;
int listenfd, optval=1;
/* Get a list of potential server addresses */
memset(&hints, 0, sizeof(struct addrinfo));
hints.ai_socktype = SOCK_STREAM; /* Accept TCP connections */
hints.ai_flags = AI_PASSIVE; /* ... on any IP address */
hints.ai_flags |= AI_NUMERICSERV; /* ... using a numeric port arg. */
hints.ai_flags |= AI_ADDRCONFIG; /* Recommended for connections */
Getaddrinfo(NULL, port, &hints, &listp);
/* Walk the list for one that we can bind to */
for (p = listp; p; p = p->ai_next) {
/* Create a socket descriptor */
if ((listenfd = socket(p->ai_family, p->ai_socktype, p->ai_protocol)) < 0)
continue; /* Socket failed, try the next */
/* Eliminates "Address already in use" error from bind */
Setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR,
(const void *)&optval , sizeof(int));
/* Bind the descriptor to the address */
if (bind(listenfd, p->ai_addr, p->ai_addrlen) == 0)
break; /* Success */
Close(listenfd); /* Bind failed, try the next */
}
/* Clean up */
Freeaddrinfo(listp);
if (!p) /* No address worked */
return -1;
/* Make it a listening socket ready to accept connection requests */
if (listen(listenfd, LISTENQ) < 0)
return -1;
return listenfd;
}
详细解析
- 将socket、bind和listen函数包装成一个叫做open_listenfd的辅助函数是很方便的,服务端可以用它来创建一个监听描述符
- open_listenfd函数用与open_clientfd函数类似的方法获取到可能的地址列表listp
- 然后遍历listp。每次遍历都是先调用socket函数,如果获取套接字描述符失败,则会执行下一次遍历,反之则进行套接字设置。设置完套接字后,则尝试绑定描述符,如果成功,则会跳出循环,如果失败,则继续尝试
- 结束遍历后,函数会释放listp占有的内存,并且判断是否获取到能够套接字,如果获取不到则返回-1,反之则尝试调用listen函数,调用成功返回listenfd,反之则返回-1
tiny.c的main函数
具体代码
int main(int argc, char **argv)
{
int listenfd, connfd;
char hostname[MAXLINE], port[MAXLINE];
socklen_t clientlen;
struct sockaddr_storage clientaddr;
/* Check command line args */
if (argc != 2) {
fprintf(stderr, "usage: %s <port>\n", argv[0]);
exit(1);
}
listenfd = Open_listenfd(argv[1]);
while (1) {
clientlen = sizeof(clientaddr);
connfd = Accept(listenfd, (SA *)&clientaddr, &clientlen); //line:netp:tiny:accept
Getnameinfo((SA *) &clientaddr, clientlen, hostname, MAXLINE,
port, MAXLINE, 0);
printf("Accepted connection from (%s, %s)\n", hostname, port);
doit(connfd); //line:netp:tiny:doit
Close(connfd); //line:netp:tiny:close
}
}
详细解析
- main函数中的argc表示命令行参数的数目,argv表示参数列表。参数数目默认大于等于1,因为至少有一个参数,也就是argv[0]为程序名
- main函数定义完变量之后会判断传入参数是不是为2个,如果不是则输出错误信息并退出。这里需要的两个参数分别为程序名tiny和端口号。这里的fprintf函数的作用是格式化输出到流或文件中,其中第一个参数是文件指针,这里用的是stderr(Linux内核启动时默认打开三个I/O设备文件:标准输入文件stdin,标准输出文件stdout和标准错误输出文件stderr),后面两个参数分别是输出内容和参数列表
- 如果参数数目满足要求的话,就可以调用Open_listendfd创建监听描述符
- 打开监听套接字后,服务器将会执行典型的无限服务器循环,不断地接受连接请求,执行事务,并关闭连接它的那一端
tiny.c的doit函数
具体代码
/*
* doit - handle one HTTP request/response transaction
*/
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 request line and headers */
Rio_readinitb(&rio, fd);
if (!Rio_readlineb(&rio, buf, MAXLINE)) //line:netp:doit:readrequest
return;
printf("%s", buf);
sscanf(buf, "%s %s %s", method, uri, version); //line:netp:doit:parserequest
if (strcasecmp(method, "GET")) { //line:netp:doit:beginrequesterr
clienterror(fd, method, "501", "Not Implemented",
"Tiny does not implement this method");
return;
} //line:netp:doit:endrequesterr
read_requesthdrs(&rio); //line:netp:doit:readrequesthdrs
/* Parse URI from GET request */
is_static = parse_uri(uri, filename, cgiargs); //line:netp:doit:staticcheck
if (stat(filename, &sbuf) < 0) { //line:netp:doit:beginnotfound
clienterror(fd, filename, "404", "Not found",
"Tiny couldn't find this file");
return;
} //line:netp:doit:endnotfound
if (is_static) { /* Serve static content */
if (!(S_ISREG(sbuf.st_mode)) || !(S_IRUSR & sbuf.st_mode)) { //line:netp:doit:readable
clienterror(fd, filename, "403", "Forbidden",
"Tiny couldn't read the file");
return;
}
serve_static(fd, filename, sbuf.st_size); //line:netp:doit:servestatic
}
else { /* Serve dynamic content */
if (!(S_ISREG(sbuf.st_mode)) || !(S_IXUSR & sbuf.st_mode)) { //line:netp:doit:executable
clienterror(fd, filename, "403", "Forbidden",
"Tiny couldn't run the CGI program");
return;
}
serve_dynamic(fd, filename, cgiargs); //line:netp:doit:servedynamic
}
}
详细解析
- doit函数负责处理HTTP事务,这里需要指出的是,Tiny Web服务器只支持GET方法,不支持POST方法,对于POST请求,会发送一个错误提示并返回程序
- doit函数会首先调用Rio_readinitb函数进行初始化,将socket描述符和读缓存区和重置缓冲区关联起来
- 然后读取数据,如果读取失败则返回,成功则利用sscanf函数将buf按照“%s %s %s”的格式分别读取到method、uri和version
- 接下来doit函数将判断method是不是get,如果不是则提示错误信息并返回,如果是则调用read_requesthdrs函数读取请求报头信息并进行忽略,因为Tiny并不需要使用到
- 接着doit函数将GET请求中的URI解析为一个文件名和一个可能为空的CGI参数字符串,并设置标志is_static标志是静态内容还是动态内容。如果文件不存在,函数将发送错误信息并返回
- 如果请求的是静态内容,将验证是否是普通文件且拥有读权限,如果是,则提供静态内容。如果请求的是动态内容,则验证是否为可执行文件,如果是,则继续并提供动态内容
tiny.c的serve_static函数
具体代码
/*
* serve_static - copy a file back to the client
*/
void serve_static(int fd, char *filename, int filesize)
{
int srcfd;
char *srcp, filetype[MAXLINE], buf[MAXBUF];
/* Send response headers to client */
get_filetype(filename, filetype); //line:netp:servestatic:getfiletype
sprintf(buf, "HTTP/1.0 200 OK\r\n"); //line:netp:servestatic:beginserve
sprintf(buf, "%sServer: Tiny 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)); //line:netp:servestatic:endserve
printf("Response headers:\n");
printf("%s", buf);
/* Send response body to client */
srcfd = Open(filename, O_RDONLY, 0); //line:netp:servestatic:open
srcp = Mmap(0, filesize, PROT_READ, MAP_PRIVATE, srcfd, 0);//line:netp:servestatic:mmap
Close(srcfd); //line:netp:servestatic:close
Rio_writen(fd, srcp, filesize); //line:netp:servestatic:write
Munmap(srcp, filesize); //line:netp:servestatic:munmap
}
详细解析
- TINY提供四种不同类型的静态内容:HTML文件、无格式的文本文件,以及编码为GIF和JPEG格式的图片
- serve_static函数首先判断文件的类型,然后发送响应行和响应报头给客户端。注意用一个空行终止报头
- 接着函数以读形式打开请求文件,并获取它的描述符,然后用Mmap函数将被请求文件映射到一个虚拟存储器空间。一旦将文件映射到存储器,我们就可以关闭文件了。最后再用Rio_writen函数将文件传送到客户端并用Munmap函数释放映射的虚拟存储器区域
tiny.c的serve_dynamic函数
具体代码
/*
* serve_dynamic - run a CGI program on behalf of the client
*/
void serve_dynamic(int fd, char *filename, char *cgiargs)
{
char buf[MAXLINE], *emptylist[] = { NULL };
/* Return first part of HTTP response */
sprintf(buf, "HTTP/1.0 200 OK\r\n");
Rio_writen(fd, buf, strlen(buf));
sprintf(buf, "Server: Tiny Web Server\r\n");
Rio_writen(fd, buf, strlen(buf));
if (Fork() == 0) { /* Child */ //line:netp:servedynamic:fork
/* Real server would set all CGI vars here */
setenv("QUERY_STRING", cgiargs, 1); //line:netp:servedynamic:setenv
Dup2(fd, STDOUT_FILENO); /* Redirect stdout to client */ //line:netp:servedynamic:dup2
Execve(filename, emptylist, environ); /* Run CGI program */ //line:netp:servedynamic:execve
}
Wait(NULL); /* Parent waits for and reaps child */ //line:netp:servedynamic:wait
}
详细解析
- Tiny通过派生一个子进程并在子进程的上下文中运行一个CGI程序,来提供各种类型的动态内容
- serve_dynamic函数首先向客户端发送一个表明成功的响应行,同时还包括带有信息的Server报头。CGI程序负责发送响应的剩余部分。注意,这并不像我们可能希望的那样健壮,因为它没有考虑到CGI程序会遇到某些错误的可能性
- serve_dynamic函数会派生一个子进程,用来自请求URI的CGI参数初始化QUERY_STRING环境变量,接着子进程重定向它的标准输出到已连接文件描述符,然后加载并运行CGI程序
- 等子进程终止后,父进程会回收操作系统分配给子进程的资源,函数执行结束