手写WEB服务器-03-相关代码

手写WEB服务器-03-相关代码

1.基础通信部分

前文说到,web服务器本质上是一个tcp服务器,因此跟tcp服务器相关的所有逻辑都需要实现。

1.1 忽略SIGPIPE信号

这是最常见的容易被忽略的一个逻辑,当客户端连接断开,而服务器还尝试往客户端套接字上写数据时(这个场景实际运行时经常遇到),就会触发SIGPIPE信号,触发这个信号的默认行为是退出进程,因此服务器必须要捕捉并忽略这个信号,否则你的服务器就会在实际运行时莫名其妙的退出,没有任何错误日志和内存转储文件等,让人十分头疼。

1.2 打开侦听端口

首先你应该调用socket函数建立一个侦听套接字:

然后填上服务器的侦听地址:

在我们的服务器中,这两个也是个配置:

其中80是侦听端口,这个我们之前已经提过,不再解释。

0.0.0.0表示,服务器可接收来自所有本地地址(可以简单理解为网卡地址)的连接。

在web服务器实际所部署的服务器主机上,一般都有多个网卡,假设对外的网卡地址是192.168.1.1,对内的网卡地址是192.168.2.1,我们可以把侦听地址配置为192.168.2.1,这样该web服务器就只能通过192.168.2.1访问,不能通过192.168.1.1访问了(注意这个是操作系统层面实现的逻辑,不是我们的web服务器实现的逻辑)。

然后就是常见的bind、listen,bind用于绑定上面提到的本地地址(包括网卡ip和端口),listen用于告诉操作系统,我准备好接受来自上述地址的链接了。

我们准备用epoll实现多路复用机制,因此要调用相关接口将其加到epoll的事件侦听列表中:

上述代码表达的含义是:告诉(EPOLL_CTL_ADD)epoll,我关注侦听套接字(acceptfd)的可读事件(POLLIN | POLLPRI),如果上述事件发生的话,通知我去处理(即有客户端连接到我所侦听的地址和端口时,通知我去处理)。

1.3 事件轮询

先看代码:

上述代码是web服务器的主流程,是我们的web服务器中最重要的一段代码。

第一个要点是epoll_wait,这是操作系统提供的epoll机制的其中一个系统函数。

前面我们提到,epoll是一种多路复用的机制。

什么是多路复用呢?简单理解就是,在一个进程/线程中处理多个套接字连接的一种机制。

我们的web服务器是一个单进程(单线程)的tcp服务器程序,tcp服务器的一个刚需就是同时(或者看似同时)处理多个客户端连接的请求(这就是所谓的高并发),多个客户端连接在服务器程序看来就是多个套接字,这些套接字对应的连接上可能同时都有数据要收取或者发送(比如100个浏览器同时向你发送GET请求),你怎么用一个进程同时处理这100个套接字呢?

最简单的思路是,我们可以写个for循环一个一个地去检测套接字上是否有数据需要收取,这样当然可以,但是效率不高,因为服务器需要不停地轮询,跑在一种类似死循环的状态,比较浪费CPU资源。

好在操作系统直接提供了这种轮询机制,你可以把需要检测的套接字列表告诉操作系统(epoll_ctl,EPOLL_CTL_ADD),然后调用操作系统的轮询函数(epoll_wait),让操作系统帮你去检测各个套接字上是否有事件发生(比如有数据需要收取)。当调用这个函数的时候,你的程序会阻塞在轮询函数(epoll_wait)上,等待该函数返回。当轮询函数返回后,他会给你一个参数,告诉你有几个套接字上事件发生(比如),然后你挨个遍历处理就行了。

值得一提的是,操作系统提供的轮询接口经历过几次更新,最古老的是select和poll,这个是从UNIX时代就继承过来的,后来LINUX平台增加了epoll接口,相比select和poll更高效,更公平,所以我们的程序中直接使用epoll来实现。

知道原理后,再回头看代码:

这行代码中,events是一个结构体数组(结构体类型为struct epoll_event),它是epoll_wait的一个输出参数,里面存放的是需要我们处理的事件信息,这些信息包括套接字文件描述符以及事件类型等。numev是epoll_wait函数的返回值,他表示有几个事件需要我们处理,假设这个值是10,这表示有10个事件需要我们处理,我们遍历events数组的前10个元素查看相关信息并处理即可。

注意到,我们分别用到了events的三个元素:

  • data.fd:用于存放套接字的文件描述符,即哪个连接发生了需要处理的事件
  • data.ptr:用于存放一些自定义的信息,以指针的形式存在,在EPOLL_CTL_ADD的时候自己填写,当ADD的套接字上有事件发生时,操作系统会把这个指针原样返回给你,方便你使用
  • events:指示发生了哪些需要处理的事件

在上述代码中,我们共处理了2类共4个事件:

  • 侦听事件:当有客户端连接到服务器的时候触发
  • 客户事件:
    • 连接关闭事件:包括主动关闭和被动关闭,当客户端连接断开时触发
    • 套接字可读事件:表明客户端连接给你发了一些数据,你需要收取并处理
    • 套接字可写事件:表明客户端连接有数据接收能力,你可以给他发应答了

1.4 实际例子

下面我们用一个实际的例子来把整个代码流程串起来。

当我们执行(./websvr -d)启动web服务器的时候,websvr进程会执行到打开侦听端口这个过程:

建立侦听套接字:

填写服务器地址:

绑定端口、开始侦听:

然后我们调用epoll_ctl(EPOLL_CTL_ADD)把刚才建立的侦听套接字加到epoll的轮询列表中,开始轮询侦听套接字的事件:

然后websvr程序就会进入到事件轮询的过程中:

其中web_loop函数内部就是在执行上面我们提到的epoll_wait等过程:

当没有客户端(浏览器)发起连接时,epoll_wait就会一直返回0,下面处理事件的循环就无法进入,相当于web_loop函数一直在空转。

当我们在浏览器输入地址发起连接时:

这里的epoll_wait函数就会返回1,然后我们遍历events数组的第一个元素,就会发现data.fd正好是我们在前面EPOLL_CTL_ADD的时候添加进去的那个侦听套接字的fd,然后我们就进入到了下面的web_event_accept执行过程中。

在这个过程中,我们调用accept4系统函数接收客户端浏览器的连接,这个函数返回一个fd(套接字文件描述符),这个fd就对应了我们前面发起的那个客户端浏览器的TCP链接,我们从这个fd上收数据,就是从客户端浏览器上收数据,我们朝这个fd上发数据,就是向客户端浏览器上发数据。

我们仍然调用epoll_ctl(EPOLL_CTL_ADD),把这个fd加到epoll关注的套接字列表中,让epoll帮我们轮询这个fd上的事件,包括客户端浏览器是否向我们发送了数据等事件。

然后我们又进到了web_loop这个函数中:

不同的是,之前epoll_wait只关注一个套接字的事件,即侦听套接字的事件,现在epoll_wait会关注两个套接字的事件,包括侦听套接字和刚才我们通过客户端浏览器发起的TCP连接的套接字。

在前一篇文章中我们提到,我们在浏览器输入地址并回车后,浏览器在发起TCP连接后会自动发送以下数据:

当上述数据到达服务器网卡,被操作系统感知后,操作系统会唤醒上述epoll_wait函数,然后我们的epoll_wait函数就会返回1:

我们会检测到客户端浏览器的fd上EPOLLIN事件触发,然后我们就进入到web_event_read函数中了。

这个函数最核心的代码在下面:

我们调用操作系统提供的recv函数,就能把客户端浏览器发送的那一堆字符串收下并保存到recvbuf变量中,以备后续解析使用。

recv的返回值有三种可能:

  • 等于0,表示对端主动关闭了连接,即浏览器主动关闭了连接
  • 小于0,一般表示连接异常断开了,当然也有例外,这不在我们的讨论范围内(感兴趣的可以查下非阻塞套接字)
  • 大于0,才是真正收到了数据,此时的返回值表示收到了多少字节的数据

收据收完后(即前面那一堆字符串收完后),服务器就能处理了,包括字符串的解析,应答文件的打开和发送等,这个属于业务部分,我们后面再叙。

发送应答时,最终会调用到web_clnt_send这个函数,这个函数有两段核心逻辑,第一段是第一次调用的时候,我们拿到了一个字节数组,这代表了我们要发送的数据(即上一篇文章中那个index.html中的内容),我们把这个字节数组中的数据先保存到缓存中,先不发送。

然后为客户端浏览器的套接字注册一个可写信号,让epoll_wait过程驱动进行发送发送。

注册可写信号的过程如下:

可以看到,我们调用的还是epoll_ctl函数,只不过这次我们注册的信号是EPOLLOUT,而非EPOLLIN。

这时候,程序又进到了epoll_wait那个函数中了:

只不过这次触发的不是EPOLLIN,而是EPOLLOUT了。

然后我们又进入到了web_clnt_send这个函数中了,这时候才会运行它真正的发送逻辑:

与recv不同的是,send的返回值只有两种可能,一种是小于0,一般表示连接断开了,一种是大于0,表示真正发送了一些数据(但是不一定一次发完,一次没发完后续再尝试接续发送即可)。

send函数发完后,数据就跑到了网络上,通过服务器的网卡发出去,再被客户端浏览器的网卡收走,最终被浏览器软件处理并展现。

2.http协议部分

2.1 主要流程

在前面的代码分析中,我们提到,web服务器收到那一堆http请求字符串后,调用了web_clnt_req进行处理。

这个函数的主要过程如下:

首先是从那一堆字符串中解析出相关信息,其中最主要的是要发送的文件路径。

然后是打开要发送的文件,把文件中的内容发送给客户端浏览器。

2.2 http头解析

首先是第一行的三个信息:

直接使用下面的代码读出:

结果如下:

然后解析uri,拼接出来一个完整的路径名:

首先第一行的strcpy,会把”/”处理成”/root/www”。

然后下面的代码会判断客户端浏览器是否提供了具体文件名,比如”index.html”,如果没有,服务器就会根据配置为它加上一个文件名,即把”/root/www”变成”/root/www/index.html”。最终文件名被保存在了req->filename变量中。

然后调用下面的代码,判断一下客户端请求的是动态页面还是静态页面:

以php结尾的我们称为动态页面(因为需要动态生成网页内容),否则我们称为静态页面。

然后我们就会进入到下面处理静态页面的分支中去:

2.3 http应答发送

处理静态页面包括两个主要过程。

第一个过程是http应答头发送,在我们的例子中,文件请求没啥问题,因此我们返回200错误码,表示http请求成功(而如果请求的文件不存在,我们需要返回404错误)。

http应答头长这样:

其中最主要的信息是Content-Length,表示我们接下来要发送多少字节的信息给客户端浏览器,在我们的例子中,就是下面这些信息的长度:

<!DOCTYPE html>
<body>Hello World!</body>
</html>

第二个过程就是客户端浏览器所请求的具体文件的发送了:

首先是打开这个文件:

然后我们一边从文件read,一边调用我们前面封装好的接口给客户端发送数据即可:

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注