Tiny Web服务器

本文最后更新于:2021年4月1日 晚上

概览:web服务器基础、http事务。

源自CSAPP 第3版

Web服务器基础

Web客户端和服务器之间的交互是基于HTTP协议的。

HTTP是 Hypertext Transfer Protocol 超文本传输协议,是一个基于文本的应用级协议。

一个Web客户端打开一个到服务器的因特网链接,并且请求某些内容。

服务器响应所请求的内容,然后关闭链接。

客户端读取这些内容,并将其显式在屏幕上。

Web内容常用HTML来编写。HTML可包含指令,来告诉浏览器如何显式这些文本和图形对象。并且它还可以包含超链接,指向存放在任何因特网主机上的内容。

Web内容

Web内容是一个MIME类型相关的字节序列。

常用的MIME类型

MIME类型 描述
text/html HTML页面
text/plain 无格式文本
applocation/postscript Postscript文档
image/gif GIF的二进制图像
image/png PNG的二进制图像
image/jpeg JPEG的二进制图像

静态内容:服务器取出一个磁盘文件,然后返回其内容。

动态内容:服务器运行一个可执行文件,并将它的输出返回。

URL

URL 统一资源定位符:web服务器返回的内容都和他管理的某个文件向关联,而这些文件都有其唯一的名字,URL。

http://www.baidu.com:80/index.html

表示www.baidu.com上的一个叫做index.html的文件。

这个文件由一个监听端口80的Web服务器来进行管理。

端口号可选,默认是HTTP的知名端口80.

同时URL在文件名之后可以跟程序参数,?分割文件名和参数,且每个参数都用&字符分割开。

例如:http:www.baidu.com:80/cgi-bin/adder?150&34

http://www.baidu.com:80/index.html中的前缀部分http://www.baidu.com:80会决定客户端和谁联系、服务器在哪里、以及其监听的端口号是多少?

后缀部分/index.html,也叫URI(统一资源标识符)由服务器来使用,服务器由此来发现它文件系统中的文件,并确定请求的是静态内容还是动态内容。

服务器如何解释URL的后缀:

  • 是指向静态或者动态没有标准的规则,一个经典的方法就是确定一组目录,将所有的可执行性文件都放在这个目录中。
  • 后缀中最开始的/不表示Linux的根目录,它表示被请求内容类型的主目录。
  • 最小的URL后缀是/字符,所有的服务器将其扩展为某个默认的主页,例如/index.html。这就解释了为什么简单的在浏览器输入一个域名就能打开一个网站的主页:浏览器会在URL后添加/,传递给服务器,服务器会把/扩展到某个默认的文件名之上的。

HTTP事务

HTTP标准要求每个文本行都由一对回车和换行符来结束。

HTTP请求

1
2
3
一个请求行:method URI version
零个或多个请求报头:header-name:header-data
【空的文本行】--> 表示报头列表终止

HTTP支持多种方法:GET、POST、OPTIONS、HEAD、PUT、DELETE、TRACE.

最常用的GET方法:指导服务器生成和返回URI标识的内容。

版本有:HTTP/1.0、HTTP/1.1,HTTP/1.1可以支持客户端和服务器持久连接。两个版本互相兼容。

例如:

1
2
3
GET / HTTP/1.1
Host:www.aol.com -->【请求报头】

HTTP响应:

1
2
3
4
一个响应行:version status-code status--message,即版本 状态码 状态信息
零个或多个响应报头:header-name:header-data
【空的文本行】--> 表示报头列表终止
响应主体,如HTML

常用的状态码:

状态码 状态信息 描述
200 OK 处理请求无误
301 永久移动 内容已经移动到location头中指明的主机上
400 错误请求 服务器不能理解请求
403 禁止 服务器无权访问所请求的文件
404 未发现 服务器不能找到所请求的文件
501 未实现 服务器不支持请求的方法

实例:

1
2
3
4
5
6
7
8
HTTP/1.0 200 OK
MIME-Version: 1.0
Content-Type: text/html
Content-Length: 150

<html>
...
</html>

如何服务动态内容–CGI标准

CGI——Common Gateway Interface 通用网关接口,这是一种标准,通过特定方式解决如下一些问题。

  • 客户端如何传递程序参数给服务器:GET方法在URI中传递,POST在请求主体中传递;
  • 服务器如何将参数传递给子进程:服务器会调用fork创建子进程,然后子进程执行execve加载CGI程序。子进程可以拿到父进程相关的资源。
  • 服务器如何将大量的信息传递给子进程:通过<stdlib.h>中的setenv以及getenv方法来实现,类似于设置环境变量,赋予对应的信息,然后子进程通过环境变量名即可获取对应信息。
1
2
3
setenv("QUERY_STRING", "123&34", 1); //设置环境变量

char* buf = getenv("QUERY_STRING"); //获取环境变量字符串的首地址

一些环境变量:

环境变量 描述
QUERY_STRING 程序参数
SERVER_PORT 父进程侦听的端口
REQUEST_METHOD GET还是POST
REMOTE_HOST 客户端域名
REMOTE_ADDR 客户端的点分十进制IP地址
CONTENT_TYPE 只对POST:请求体的MIME类型
CONTENT_LENGTH 只对POST而言:请求体的字节大小
  • 子程序将它的输出发送到哪里:一个CGI程序将它的动态内容发送到标准输出。在子进程加载并运行CGI程序之前,它使用dup2函数将标准输出重定向到和客户端相关联的已连接描述符,这样任何CGI程序写到标准输出的东西都会直达客户端。

套接字接口相关函数

套接字地址结构

  • 从Linux程序的角度来看,套接字就是一个有相应描述符的打开文件。
  • 而从Linux内核角度来看,一个套接字就是通信的一个端点。
1
2
3
4
5
6
7
8
9
10
11
12
13
 /* IP socket address structure */
struct sockaddr_in {
uint16_t sin_family; /* Protocol family (always AF_INET) */
uint16_t sin_port; /* Port number in network byte order */
struct in_addr sin_addr; /* IP address in network byte order */
unsigned char sin_zero[8]; /* Pad to sizeof(struct sockaddr) */
};

/* Generic socket address structure (for connect, bind, and accept) */
struct sockaddr {
uint16_t sa_family; /* Protocol family */
char sa_data[14]; /* Address data */
};
  • IP地址和端口号总是以网络字节顺序(大端法)存放。

open_clientfd函数

客户端通过调用open_client建立与服务器的连接,若出错返回-1.

该服务器运行再主机hostname上,并在端口号port上监听连接请求。它会返回一个打开的套接字描述符,该描述符可以适用Unix I/O函数做输入和输出。

我们调用getaddrinfo,它返回addrinfo结构的列表,每个结构指向一个套接字地址结构,可用于建立与服务器的连接,该服务器运行在hostname.上并监听port端口。然后遍历该列表,依次尝试列表中的每个条目,直到调用socket和connect成功。如果connect失败,在尝试下一个条目之前,要小心地关闭套接字描述符。如果connect成功,我们会释放列表内存,并把套接字描述符返回给客户端,客户端可以立即开始用Unix I/O与服务器通信了。

注意,所有的代码都与任何版本的IP无关。socket和connect的参数都是用getaddrinfo自动产生的,这使得我们的代码干净可移植。

  • socket函数创建一个套接字描述符,其返回的描述符仅是部分打开,还不能用于读写,如何打开,取决于客户端还是服务器。
  • connect函数:客户端通过其来建立和服务器的连接,它会与指定的套接字地址建立因特网连接,该函数会阻塞,一直到连接成功建立或者是发生错误,若成功,则对应的描述符就可以读写,且其建立的连接是由套接字对刻画。IP:port会唯一确定客户端主机上的一个客户进程。
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
int open_clientfd(char *hostname, char *port) {
int clientfd, rc;
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 */
if ((rc = getaddrinfo(hostname, port, &hints, &listp)) != 0) {
fprintf(stderr, "getaddrinfo failed (%s:%s): %s\n", hostname, port, gai_strerror(rc));
return -2;
}

/* Walk the list for one that we can successfully connect to */
for (p = listp; p; p = p->ai_next) {
/* Create a socket descriptor */
if ((clientfd = socket(p->ai_family, p->ai_socktype, p->ai_protocol)) < 0)
continue; /* Socket failed, try the next */

/* Connect to the server */
if (connect(clientfd, p->ai_addr, p->ai_addrlen) != -1)
break; /* Success */
if (close(clientfd) < 0) { /* Connect failed, try another */ //line:netp:openclientfd:closefd
fprintf(stderr, "open_clientfd: close failed: %s\n", strerror(errno));
return -1;
}
}

/* Clean up */
freeaddrinfo(listp);
if (!p) /* All connects failed */
return -1;
else /* The last connect succeeded */
return clientfd;
}

open_listenfd函数

服务器会调用open_listenfd函数,来创建一个监听描述符,准备好接收连接请求。

open_ listenfd 的风格类似于open_ clientfd。 调用getaddrinfo,然后遍历结果列表,直到调用socket和bind成功。注意,在第20行,我们使用setsockopt 函数来配置服务器,使得服务器能够被终止、重启和立即开始接收连接请求。一个重启的服务器默认将在大约30秒内拒绝客户端的连接请求,这严重地阻碍了调试。

因为我们调用getaddrinfo时,使用了AI_ PASSIVE标志并将host 参数设置为NULL,每个套接字地址结构中的地址字段会被设置为通配符地址,这告诉内核这个服务器会接收发送到本主机所有IP地址的请求。

最后,我们调用listen函数,将listenfd转换为一个监听描述符,并返回给调用者。如果listen失败,我们要小心地避免内存泄漏,在返回前关闭描述符。

  • bind函数:会使内核将指定的服务器套接字地址和套接字描述符联系起来。
  • listen(sockfd)函数:默认情况下,内核会认为socket函数创建的描述符对应于主动套接字,它存在于一个连接的客户端,而服务器调用listen函数告诉内核,描述符是被服务器而不是客户端使用的。
    • listen函数将socket创建的套接字从主动套接字转化为监听套接字listenfd,从而可以接收来自客户端的连接请求。
  • accept(listenfd)函数:服务器调用来等待来自客户端的连接请求到达listenfd,然后返回一个已连接描述符connfd,这个描述符可以被利用UnixI/O函数来与客户端通信。

监听描述符和已连接描述符

监听描述符是作为客户端连接请求的一个端点。它通常被创建一次,并存在于服务器的整个生命周期

已连接描述符是客户端和服务器之间已经建立起来了的连接的一个端点。服务器每次接受连接请求时
都会创建一次,它只存在于服务器为一个客户端服务的过程中。

两者的区分,使得我们可以建立并发服务器。

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
int open_listenfd(char *port) 
{
struct addrinfo hints, *listp, *p;
int listenfd, rc, optval=1;

/* Get a list of potential server addresses */
memset(&hints, 0, sizeof(struct addrinfo));
hints.ai_socktype = SOCK_STREAM; /* Accept connections */
hints.ai_flags = AI_PASSIVE | AI_ADDRCONFIG; /* ... on any IP address */
hints.ai_flags |= AI_NUMERICSERV; /* ... using port number */
if ((rc = getaddrinfo(NULL, port, &hints, &listp)) != 0) {//getaddrinfo生成的,代码与协议无关
fprintf(stderr, "getaddrinfo failed (port %s): %s\n", port, gai_strerror(rc));
return -2;
}

/* 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, //line:netp:csapp:setsockopt
(const void *)&optval , sizeof(int));

/* Bind the descriptor to the address */
if (bind(listenfd, p->ai_addr, p->ai_addrlen) == 0)
break; /* Success */
if (close(listenfd) < 0) { /* Bind failed, try the next */
fprintf(stderr, "open_listenfd close failed: %s\n", strerror(errno));
return -1;
}
}


/* 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) {
close(listenfd);
return -1;
}
return listenfd;
}

Tiny Web服务器

启动服务器的同时监听命令行传递来的端口

首先读并且解析请求行,使用rio_readlineb来读取。

然后将URI解析为一个文件名和一个可能为空的CGI参数字符串,并设置一个标志,表明请求的是静态内容还是动态内容。若文件再在磁盘上不存在,则立即发送一个错误信息给客户端并返回。

如果请求的是静态内容,则验证该文件是一个普通文件,且拥有读权限,则就像客户端提供静态内容。

而如果请求的是动态内容,则验证文件时可执行文件,如果是,就继续并且提供动态内容。

主程序源码

1

程序目录结构

编译

在cgi-bin目录下,编译动态程序adder

1
gcc -o adder adder.c -I ../include/ ../src/csapp.c -lpthread

在整体目录下,编译tinyserver服务器

1
gcc -o tinyserve tinyserve.c -I ./include/ ./src/csapp.c -lpthread

执行

1
./tinyserve 1266

服务器端口可以指定一个非知名的端口,用于监听。


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!