Tiny-Web

TINY WEB

​ 基于CSAPP的课本教程,我实现了一个简单的服务器demo。该demo仅仅只有250行,但是存在着一个服务器所必须的简单的提供服务的功能,在这里对其进行一个简单的复盘。

服务器

单连接测试

多连接测试

接下来一步步对这段代码进行简单的分析,重温下我的tiny web开发过程

程序入口

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 main(int argc,char** argv)
{
int listenfd,connfd;
char hostName[MAXLINE],port[MAXLINE]; //the storge of client information
socklen_t clientlen; //the length of client socket
struct sockaddr_storage clientaddr; //the address of client

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

listenfd=open_listenfd(argv[1]); //used port to create listen fd
while (1)
{
clientlen = sizeof(clientaddr);
connfd = Accept(listenfd,(SA*)&clientaddr,&clientlen);
Getnameinfo((SA*)&clientaddr,clientlen,hostName,MAXLINE,port,MAXLINE,0);
printf("Accepted connection from (%s, %s)\n",hostName,port);
//doit(connfd);
optDoit(connfd);
Close(connfd);
}
}

​ 对于目前的服务器,启动的实现是一个单线程迭代阻塞服务器,每次只能处理一个来自服务器端的连接,在启动连接后,之后所有的连接都会被阻塞。

​ 回顾一个服务器简单的开发流程。

  • 创建服务器本地的套接字节点,实际上是一个监听节点,该节点是为了提供外部客户端的连接处理的。
  • 为了创建一个监听节点,我们需要先创建一个套接字节点,然后再将其转换为一个监听节点,也就是说,监听节点本质上也还是一个套接字节点,只是通过系统调用为其赋予了不同的属性使其成为了一个专门用于监听客户端请求的节点。
  • 在监听节点被创建完之后,本地的监听节点将会阻塞当前的服务器线程,知道收到来自客户端的请求,此时,其会在服务器这个接受处理的线程中去请求和创建一个新的文件描述符并返回,这个描述符会被关联到对应的对应的客户端连接中
  • 此时,本地的对应的监听文件套接字创建的子文件就会被与某个网络上的文件关联起来,这一块我还不是很了解这种不可见的信息传输,所以,这里可以假设对应的文件会储存每一次来自用户端的请求,我们只需要对这个文件进行处理即可。

​ 对于我们上面给出的main函数代码,其实可以看到一个对应的开发流程

1
2
3
4
int listenfd,connfd;
char hostName[MAXLINE],port[MAXLINE]; //the storge of client information
socklen_t clientlen; //the length of client socket
struct sockaddr_storage clientaddr; //the address of client

​ 这一段都是对应的需求内存设计。可以看到,这里的设计其实相当简单,一个服务器上只有一个connfd,所以你应该也可以推断对应的,服务器每次只能创建出一个连接文件来进行客户端的处理,一但其他客户端同时请求,其会被阻塞。

1
2
3
4
5
if(argc!=2)
{
fprintf(stderr,"usage: %s <port>\n",argv[0]); //did`t used port num
exit(1);
}

​ 这一段是命令行参数检测这一块,对于该服务器,我们要求在启动时指定对应的本机上要使用的端口号。毕竟对于服务器来说,其使用的就是本地的域名,套接字这块的地址直接使用本地地址,只需要指定对应的端口号即可。

​ 接下来看到这一行

1
listenfd=open_listenfd(argv[1]);            //used port to create listen fd

​ 这一行中去创建对应的监听操作符,open_listenfd是一个封装好的函数,直接使用本地地址和传进的端口号来创建一个监听套接字,我们来看一下它的源码。

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
/* $begin open_listenfd */
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) {
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;
}
/* $end open_listenfd */

​ 这个源码的功能其实很简单,就是使用默认的配置去调用UNIX环境下的一些系统调用来生成一个监听套接字

1
2
struct addrinfo hints, *listp, *p;
int listenfd, rc, optval=1;

​ 在这段声明中,addrinfo结构的几个变量都是会被提供给getaddrinfo系统调用的,这里不知道的还需要回去补一下CSAPP的网络编程基础。其中,由于hints是对于最终结果的属性约束,为了避免机器上莫名其妙的内存残留影响,在每次使用前最好对其进行一次刷新

1
2
3
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 */

​ 这一段就是对应的我们对于getaddrinfo解析地址的参数,在这其中

  • SOCK_STREAM:指定 TCP 连接。
  • AI_PASSIVE:用于监听套接字,指示 getaddrinfo 返回的 IP 地址应可用于 bind
  • AI_ADDRCONFIG:启用地址配置,仅返回当前系统有可用 IP 地址的协议族(IPv4/IPv6)。例如,如果机器只分配了 IPv4 地址,则不会返回 IPv6 地址。
  • AI_NUMERICSERV:端口号直接使用数字,而不是服务名。

​ 这些字段的内容其实很多,没必要去记,在需要的时候再去查一下即可,只需要了解,通过设置hints结构能够对于我们最终生成的addrinfo进行一次属性上的控制即可。

​ 在设置完基本的本地服务器要求的addrinfo属性后,我们就可以进行对应的getaddrinfo系统调用查询了。对于这个系统调用,只简单说明一下它的功能。其的功能是:通过使用该系统调用参数,去生成一个结构体addrinfo链表,在这个链表中,储存了一些节点的重要信息。getaddrinfo 的前两个参数分别是域名(或者 IP 地址)和端口号。它们都是必填项,但可以传 NULL 作为默认值。 如果 hostnameNULL,则表示使用本地地址。 如果 servnameNULL,则表示调用者需要手动绑定端口,通常不推荐这样做,因为系统自动分配的端口号需要额外查询才能得知。

​ 除此之外,该函数还返回一个状态码,当且仅当函数正常运行时会返回0,以此来进行了一次简单的错误处理。接下来是这里的重头戏,当该函数正常处理需求时,其会使用前三个参数去进行查询和构建对应的addrinfo链表,这个链表的储存位置是我们第四个参数的位置。这种参数位置设置是不是很熟悉。

getaddrinfo 生成的是一个 addrinfo 结构体的链表,每个节点都包含了一个可用的 IP 地址及其相关的配置信息。在这里需要注意的,对于传进来的域名(第一个参数),如果不是本地地址,那么其会去先本地的DNS缓存去查找,再然后,去连接到外部的DNS服务器去进行对应的ip的查找。总的来说,这里的函数会将在当前时刻可以查找到的对应的域名的所有的ip地址都进行储存,每个ip地址都对应着一个结构体,一个结构体中又包含着一些配置信息,目前了解这些即可。

​ 总之,目前在了解完这个函数之后,我们知道了上面调用getaddrinfo方法是为了去生成一些与传进去的域名和端口号相关联的信息方便后面的套接字创建。接下来就看懂我们的套接字创建吧。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/* 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;
}
}

​ 可以看到,这该循环中,我们去遍历了生成的addrinfo链表,因为这里可能会生成多个ip地址,但是不保证哪个可以被我们本地连接,所以这里需要循环测试直至成功创建出对于的套接字

​ 接下来主要看一个socket函数,在该函数中,我们看到了几个熟悉的字段,前面设置过的ai_family字段,ai_socktype字段,ai_socktype字段,再来对其对应关系其重复一下

  • socket(int domain, int type, int protocol) 用于创建套接字:

  • p->ai_family:地址族(AF_INET 表示 IPv4,AF_INET6 表示 IPv6)。

  • p->ai_socktype:套接字类型(SOCK_STREAM 表示 TCP,SOCK_DGRAM 表示 UDP)。

  • p->ai_protocol:通常为 0,表示使用默认协议(TCP/UDP)。

​ 接下来的setsockopt用于设置一些socket的属性,这里可以先不对其进行了解。

​ 一般在创建完套接字之后,即可以进行该套接字与端口的绑定了。这里再对套接字进行一下解释,所谓的套接字,本质上就还是一个文件,可以将其视作在网络中的文件单位,我们现在所谓的穿件套接字,只是创建出一个稍微普通点的文件而已,如果不对其进行使用,那么其没有什么实际的意义。而我们这里,就要将其与某一个本地的端口进行绑定。这样才能够使得该套接字工作起来。而这种赋予工作

的方法,就是使用bind系统调用。

bind顾名思义,就是建立起连接,而这里的俩段则是套接字与某一个特定的端口,在链接完之后,简单的可以理解为,所有想要传输进这个端口的数据都会被储存到这个套接字中,这里的套接字就类似于一个大麻袋,将所有的来自客户端的信息都进行一个接受,当然,其也只负责接受。而对于bind函数

1
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

​ 其中第一个参数就是我们将要绑定的监听套接字,第二个参数是一个sockaddr结构体指针,也就是我们前面得到的addrinfo结构体,在这个函数内其会对其进行自动的解析,无需关系,第三个参数是第二个指针指向的结构体的大小,而这个信息也被储存到了对应的结构体中,所以可以看到,这里是直接传递一个对应与结构体的属性的。

​ 最后是一个简单的连接失败处理,你可能会疑惑为什么在这里会进行一个close,但是在仔细看一看,什么时候才会进入到这里的close呢?

​ 在这个函数的最后,我们不能忘记对于我们getaddrinfo函数产生的结构体链表进行清理,毕竟只要稍微了解下内存系统就知道这里是一个什么风险。

​ 最后的最后,在离开函数前,我们调用了listen方法让监听套接字进行了工作。其实可以这么简单的理解。创建出一个套接字,其实就是一个人想要通过高考考上大学的过程;套接字转换为监听套接字,就是一个大学学习技能特化自己的过程;监听套接字被绑定到对应的端口,就是对应的找工作收offer的过程;最后的listen方法,就是去公司上工的过程。通过这个小例子你应该能够更轻松的了解这个创建过程。


接下来我们跳出这里的方法,回到我们的主函数

​ 在创建完监听操作符之后,我们进入了一个程序死循环,并且可以看到,该程序循环没有一个出口(你在之后可以看到,循环内部的方法不会导致循环退出)

1
2
3
4
5
6
7
8
9
10
while (1)
{
clientlen = sizeof(clientaddr);
connfd = Accept(listenfd,(SA*)&clientaddr,&clientlen);
Getnameinfo((SA*)&clientaddr,clientlen,hostName,MAXLINE,port,MAXLINE,0);
printf("Accepted connection from (%s, %s)\n",hostName,port);
//doit(connfd);
optDoit(connfd);
Close(connfd);
}

​ 对于这个循环,流程其实很简单,我们前面已经让监听套接字进行工作了,而在这里,我们则是指定对应的监听套接字所需要执行的工作。即不断监听这里服务器的对应端口(前面已绑定)。对于Accep函数,其本身其实是对于accept函数的一个错误处理的包装,包括接下来的结果一大写开头的系统调用函数,其只是一个错误处理包装,由于没什么好讲的,所以这一类的源码不进行分析,请自行阅读。

​ 主要来了解一个accept函数,该函数原型如下

1
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

​ 其接受一个监听套接字,需要注意的是,这个监听套接字必须已经处于监听状态,否则会出现错误,这也是前面给监听套接字调用listen方法的原因,第二个参数是一个结构体指针,指向的是在监听到底的客户端时要储存的位置,第三个则是对应的第二个结构体的内存大小,对于后俩个参数,都可以传进NULL使用系统自动判断的结构体大小。

​ 需要注意的是,accept是一个阻塞型的函数,一但进入该函数体,除非存在来自客户端的连接来解除阻塞,否则当前进程会一直阻塞下去。

​ 之后的几行代码也很轻松,就是调用我们的getnameinfo函数去解析当前接受到的客户端信息,并在服务端进行一次访问打印而已。

对于 optDoit(connfd); Close(connfd);俩行代码,其中optDoit是对应的服务器对于接受到的信息的解析和回应,这里我们将不会使用optDoit进行分析,而是使用doit进行分析,该optDoit涉及到了对应与CSAPP中关于服务器的作业的一个实现,感兴趣可以自己去实现一个自己的optDoit函数。

​ 在处理完对应的逻辑之后,我们服务器端将会将对应的创建出的connfd函数进行一次回送,自此,一个来自客户端的处理结束,服务器一个循环迭代结束,重新进行循环,重新开始我们之前的流程,知道关闭服务器进程之前运行不止…


​ 这也就是我为什么将该服务器视为一个循环迭代服务器的原因,该服务器每次能且只能处理一个客户端连接。接下来我们就要进入对应的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
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
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); //read informations from client fd
if (!Rio_readlineb(&rio, buf, MAXLINE)) //line:netp:doit:readrequest
return;//read informations from rio to locla buf
sscanf(buf , "%s %s %s",method,uri,version); //parse the header of client input

Rio_writen(fd,buf,strlen(buf));

if(strcasecmp(method,"GET")) //compare two strings ignore case only return 0 when equal
{
//block non-get request
clienterror(fd,method,"501","Not Implemented",
"Tiny could not implement this method");
return ;
}
read_requesthdrs(&rio); //read informations from client fd,ignore the header

/* for a parse_uri function
* translate a uri and the next two parameter to store the result of parsing
* the function return 1 when the request is static
* return 0 when the request is dynamic
*/
is_static = parse_uri(uri,filename,cgiargs); //parse the uri

//uri referance to a file on server,to detection if it exist
/* the stat return 0 only exact find the file
* when success if finding the file, system will write content to second argument
*/
if(stat(filename,&uri)<0)
{
clienterror(fd,filename,"404","Not found","Tiny could not find this file");
return ;
}

//struct stat sbuf;
if(is_static)
{
//S_ISREG is a Macro ,used to dected whether the file is a regular file
//st_mode is a field symbol the file type and permissions
//S_IRUSR&sbuf.st_mode dected whether the server has the permission to visit the file
if (!(S_ISREG(sbuf.st_mode)) || !(sbuf.st_mode & S_IRUSR))
{
clienterror(fd,filename,"403","Forbidden","tiny could not read the file");
return ;
}
server_static(fd,filename,sbuf.st_size);
}
else //a dynamic request , want to request a progress
{
//S_IXUSR & sbuf.st_mode will used & to detect if the file is valid for this server
if(!(S_ISREG(sbuf.st_mode))||!(S_IXUSR & sbuf.st_mode))
{
clienterror(fd,filename,"403","Forbidden","tiny could not run the CGI program");
return ;
}
server_dynamic(fd,filename,cgiargs);
}
}

​ 在该方法中,参数fd指向的是客户端连接上的服务器端fd,对于服务器来说,其可以将其抽象为一个文件,不过这个文件时刻有可能有外部的输入流入(实际上,该输入是通过外部网络来传输的)。

​ 在该函数中,可以看到一系列的Rio函数,这些是在csapp.h中包含的函数,这些函数本质上其实就是一些包装的网络安全的读写函数,毕竟你应该知道,在网络中使用一般的printf和scanf函数是不安全的。

​ 我们简单来分析下这个函数的功能,具体代码留给你自己去看。

  • 首先,你需要知道一个服务器接受到的监听文件中储存的信息格式,或者说,你需要知道对应的客户端发送过来的信息格式。在对应的信息文本中,第一行是一个HTTP请求,格式为 method uri version,这正是该段代码中前几行进行解析的原因,在该请求中包含了一些相对重要的信息,具体可以自行查阅资料。

  • 由于当前服务器小demo只支持GET请求,所以你可以看到在代码中存在一些关于读取头非GET的错误处理

  • 在确认由客户端送来的HTTP请求格式正确之后,程序开始解析接下来的uri字段,对于uri字段,你可以看到存在俩种不同执行流的处理,这里又涉及到了服务器端提供的服务,即静态资源和动态资源。当然,这一块还是交给你自己去进行了解

  • 如果你稍微了解所谓的动静态资源,你应该能够读到对应的if (!(S_ISREG(sbuf.st_mode)) || !(sbuf.st_mode & S_IRUSR))

    判断,这里的权限判断是每个服务器都必须的,毕竟你也不想你的服务器主机被渗透的千疮百孔吧,当然,这里的语句含义,还是留给你去阅读源码,我在其中已经写了一定的解析。

  • 在对应权限判断之后,服务器将会进一步的开始处理对应的请求,对uri进行进一步的解析以及产生进一步的动作

    其实在该函数这里,你应该相对来说会在逻辑上感觉到得心应手。你应该可以明显感觉的是,在该函数中,好像就脱离了所谓的网络结构,对于该函数来说,所谓的运行环境只是一个普通的本机环境,其所需要关心的,其实只是对于所谓的监听文件的内容的解析和处理,其他内容不用其进行处理。这里,正是解耦合的魅力啊。


​ 至此,我们完成了对于源码中的函数入口以及对应的简单处理分流函数的了解。对于剩下的代码,这一块将不会进行解释,毕竟存在着更详细的教材 CSAPP 。该小demo是参照该书中的tiny web进行制作的,在这之后的每一个函数,都在书中有着详细的解释,为了不误导各位,我在合理不再对源码进行分析,请各位自行阅读源码并参照CSAPP中的内容进行学习。

-------------本文结束 感谢阅读-------------