跳转至

13 项目梳理

1. 什么是Web Server(网络服务器)

一个Web Server就是一个服务器软件(程序),或者是运行这个服务器软件的硬件(计算机)。其主要功能是通过HTTP协议与客户端(通常是浏览器(Browser))进行通信,来接收,存储,处理来自客户端的HTTP请求,并对其请求做出HTTP响应,返回给客户端其请求的内容(文件、网页等)或返回一个Error信息。

https://developer.mozilla.org/en-US/docs/Learn/Common_questions/What_is_a_web_server

2. 用户如何与你的Web服务器进行通信

通常用户使用Web浏览器与相应服务器进行通信。在浏览器中键入“域名”或“IP地址:端口号”,浏览器则先将你的域名解析成相应的IP地址或者直接根据你的IP地址向对应的Web服务器发送一个HTTP请求。这一过程首先要通过TCP协议的三次握手建立与目标Web服务器的连接,然后HTTP协议生成针对目标Web服务器的HTTP请求报文,通过TCP、IP等协议发送到目标Web服务器上。

3. Web服务器如何接收客户端发来的HTTP请求报文呢?

Web服务器端通过socket监听来自用户的请求。

#include <sys/socket.h>
#include <netinet/in.h>
/* 创建监听socket文件描述符 */
int listenfd = socket(PF_INET, SOCK_STREAM, 0);
/* 创建监听socket的TCP/IP的IPV4 socket地址 */
struct sockaddr_in address;
bzero(&address, sizeof(address));
address.sin_family = AF_INET;
address.sin_addr.s_addr = htonl(INADDR_ANY);  /* INADDR_ANY:将套接字绑定到所有可用的接口 */
address.sin_port = htons(port);

int flag = 1;
/* SO_REUSEADDR 允许端口被重复使用 */
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &flag, sizeof(flag));
/* 绑定socket和它的地址 */
ret = bind(listenfd, (struct sockaddr*)&address, sizeof(address));  
/* 创建监听队列以存放待处理的客户连接,在这些客户连接被accept()之前 */
ret = listen(listenfd, 5);

远端的很多用户会尝试去connect()这个Web Server上正在listen的这个port,而监听到的这些连接会排队等待被accept()。由于用户连接请求是随机到达的异步事件,每当监听socket(listenfdlisten到新的客户连接并且放入监听队列,我们都需要告诉我们的Web服务器有连接来了,accept这个连接,并分配一个逻辑单元来处理这个用户请求。而且,我们在处理这个请求的同时,还需要继续监听其他客户的请求并分配其另一逻辑单元来处理(并发,同时处理多个事件,后面会提到使用线程池实现并发)。这里,服务器通过**epoll**这种I/O复用技术(还有select和poll)来实现对监听socket(listenfd)和连接socket(客户请求)的同时监听。注意I/O复用虽然可以同时监听多个文件描述符,但是它本身是阻塞的,并且当有多个文件描述符同时就绪的时候,如果不采取额外措施,程序则只能按顺序处理其中就绪的每一个文件描述符,所以为提高效率,我们将在这部分通过线程池来实现并发(多线程并发),为每个就绪的文件描述符分配一个逻辑单元(线程)来处理。

#include <sys/epoll.h>
/* 将fd上的EPOLLIN和EPOLLET事件注册到epollfd指示的epoll内核事件中 */
void addfd(int epollfd, int fd, bool one_shot) {
    epoll_event event;
    event.data.fd = fd;
    event.events = EPOLLIN | EPOLLET | EPOLLRDHUP;
    /* 针对connfd,开启EPOLLONESHOT,因为我们希望每个socket在任意时刻都之被一个线程处理 */
    if(one_shot)
        event.events |= EPOLLONESHOT;
    epoll_ctl(epollfd, EPOLL_CTL_ADD, fd, &event);
    setnonblocking(fd);
}
/* 创建一个额外的文件描述符来唯一标识内核中的epoll事件表 */
int epollfd = epoll_create(5);  
/* 用于存储epoll事件表中就绪事件的event数组 */
epoll_event events[MAX_EVENT_NUMBER];  
/* 主线程往epoll内核事件表中注册监听socket事件,当listen到新的客户连接时,listenfd变为就绪事件 */
addfd(epollfd, listenfd, false);  
/* 主线程调用epoll_wait等待一组文件描述符上的事件,并将当前所有就绪的epoll_event复制到events数组中 */
int number = epoll_wait(epollfd, events, MAX_EVENT_NUMBER, -1);
/* 然后我们遍历这一数组以处理这些已经就绪的事件 */
for(int i = 0; i < number; ++i) {
    int sockfd = events[i].data.fd;  // 事件表中就绪的socket文件描述符
    if(sockfd == listenfd) {  // 当listen到新的用户连接,listenfd上则产生就绪事件
        struct sockaddr_in client_address;
        socklen_t client_addrlength = sizeof(client_address);
        /* ET模式 */
        while(1) {
            /* accept()返回一个新的socket文件描述符用于send()和recv() */
            int connfd = accept(listenfd, (struct sockaddr *) &client_address, &client_addrlength);
            /* 并将connfd注册到内核事件表中 */
            users[connfd].init(connfd, client_address);
            /* ... */
        }
    }
    else if(events[i].events & (EPOLLRDHUP | EPOLLHUP | EPOLLERR)) {
        // 如有异常,则直接关闭客户连接,并删除该用户的timer
        /* ... */
    }
    else if(events[i].events & EPOLLIN) {
        /* 当这一sockfd上有可读事件时,epoll_wait通知主线程。*/
        if(users[sockfd].read()) { /* 主线程从这一sockfd循环读取数据, 直到没有更多数据可读 */
            pool->append(users + sockfd);  /* 然后将读取到的数据封装成一个请求对象并插入请求队列 */
            /* ... */
        }
        else
            /* ... */
    }
    else if(events[i].events & EPOLLOUT) {
        /* 当这一sockfd上有可写事件时,epoll_wait通知主线程。主线程往socket上写入服务器处理客户请求的结果 */
        if(users[sockfd].write()) {
            /* ... */
        }
        else
            /* ... */
    }
}

服务器程序通常需要处理三类事件:I/O事件,信号及定时事件。有两种事件处理模式:

  • Reactor模式:要求主线程(I/O处理单元)只负责监听文件描述符上是否有事件发生(可读、可写),若有,则立即通知工作线程(逻辑单元),将socket可读可写事件放入请求队列,交给工作线程处理。
  • Proactor模式:将所有的I/O操作都交给主线程和内核来处理(进行读、写),工作线程仅负责处理逻辑,如主线程读完成后users[sockfd].read(),选择一个工作线程来处理客户请求pool->append(users + sockfd)

通常使用同步I/O模型(如epoll_wait)实现Reactor,使用异步I/O(如aio_readaio_write)实现Proactor。但在此项目中,我们使用的是**同步I/O模拟的Proactor**事件处理模式。那么什么是同步I/O,什么是异步I/O呢? 廖雪峰:异步IO一节给出解释

  • 同步(阻塞)I/O:在一个线程中,CPU执行代码的速度极快,然而,一旦遇到IO操作,如读写文件、发送网络数据时,就需要等待IO操作完成,才能继续进行下一步操作。这种情况称为同步IO。
  • 异步(非阻塞)I/O:当代码需要执行一个耗时的IO操作时,它只发出IO指令,并不等待IO结果,然后就去执行其他代码了。一段时间后,当IO返回结果时,再通知CPU进行处理。

Epoll对文件操作符的操作有两种模式:LT(电平触发)和ET(边缘触发),二者的区别在于当你调用epoll_wait的时候内核里面发生了什么:

  • LT(电平触发):类似select,LT会去遍历在epoll事件表中每个文件描述符,来观察是否有我们感兴趣的事件发生,如果有(触发了该文件描述符上的回调函数),epoll_wait就会以非阻塞的方式返回。若该epoll事件没有被处理完(返回EWOULDBLOCK),该事件还会被后续的epoll_wait再次触发。
  • ET(边缘触发):ET在发现有我们感兴趣的事件发生后,立即返回,并且sleep这一事件的epoll_wait,不管该事件有没有结束。

在使用ET模式时,必须要保证该文件描述符是非阻塞的(确保在没有数据可读时,该文件描述符不会一直阻塞);并且每次调用readwrite的时候都必须等到它们返回EWOULDBLOCK(确保所有数据都已读完或写完)。

4. Web服务器如何处理以及响应接收到的HTTP请求报文呢?

该项目使用线程池(半同步半反应堆模式)并发处理用户请求,主线程负责读写,工作线程(线程池中的线程)负责处理逻辑(HTTP请求报文的解析等等)。通过之前的代码,我们将listenfd上到达的connection通过 accept()接收,并返回一个新的socket文件描述符connfd用于和用户通信,并对用户请求返回响应,同时将这个connfd注册到内核事件表中,等用户发来请求报文。这个过程是:通过epoll_wait发现这个connfd上有可读事件了(EPOLLIN),主线程就将这个HTTP的请求报文读进这个连接socket的读缓存中users[sockfd].read(),然后将该任务对象(指针)插入线程池的请求队列中pool->append(users + sockfd);,线程池的实现还需要依靠**锁机制**以及**信号量**机制来实现线程同步,保证操作的原子性。 在线程池部分做几点解释,然后大家去看代码的时候就更容易看懂了:

  • 所谓线程池,就是一个pthread_t类型的普通数组,通过pthread_create()函数创建m_thread_number个**线程**,用来执行worker()函数以执行每个请求处理函数(HTTP请求的process函数),通过pthread_detach()将线程设置成脱离态(detached)后,当这一线程运行结束时,它的资源会被系统自动回收,而不再需要在其它线程中对其进行 pthread_join() 操作。
  • 操作工作队列一定要加锁(locker),因为它被所有线程共享。
  • 我们用信号量来标识请求队列中的请求数,通过m_queuestat.wait();来等待一个请求队列中待处理的HTTP请求,然后交给线程池中的空闲线程来处理。

OK,接下来说说每个read()后的HTTP请求是如何被处理的,我们直接看这个处理HTTP请求的入口函数:

void http_conn::process() {
    HTTP_CODE read_ret = process_read();
    if(read_ret == NO_REQUEST) {
        modfd(m_epollfd, m_sockfd, EPOLLIN);
        return;
    }
    bool write_ret = process_write(read_ret);
    if(!write_ret)
        close_conn();
    modfd(m_epollfd, m_sockfd, EPOLLOUT);
}

首先,process_read(),也就是对我们读入该connfd读缓冲区的请求报文进行解析。 HTTP请求报文由请求行(request line)、请求头部(header)、空行和请求数据四个部分组成有。两种请求报文: GET(Example)

GET /562f25980001b1b106000338.jpg HTTP/1.1
Host:img.mukewang.com
User-Agent:Mozilla/5.0 (Windows NT 10.0; WOW64)
AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.106 Safari/537.36
Accept:image/webp,image/*,*/*;q=0.8
Referer:http://www.imooc.com/
Accept-Encoding:gzip, deflate, sdch
Accept-Language:zh-CN,zh;q=0.8
空行
请求数据为空

POST(Example,注意POST的请求内容不为空)

POST / HTTP1.1
Host:www.wrox.com
User-Agent:Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1; SV1; .NET CLR 2.0.50727; .NET CLR 3.0.04506.648; .NET CLR 3.5.21022)
Content-Type:application/x-www-form-urlencoded
Content-Length:40
Connection: Keep-Alive
空行
name=Professional%20Ajax&publisher=Wiley

process_read()函数的作用就是将类似上述例子的请求报文进行解析,因为用户的请求内容包含在这个请求报文里面,只有通过解析,知道用户请求的内容是什么,是请求图片,还是视频,或是其他请求,我们根据这些请求返回相应的HTML页面等。项目中使用**主从状态机**的模式进行解析,每解析一部分都会将整个请求的m_check_state状态改变,状态机也就是根据这个状态来进行不同部分的解析跳转的:

  • parse_request_line(text),解析请求行,也就是GET中的GET /562f25980001b1b106000338.jpg HTTP/1.1这一行,或者POST中的POST / HTTP1.1这一行。通过请求行的解析我们可以判断该HTTP请求的类型(GET/POST),而请求行中最重要的部分就是URL部分,我们会将这部分保存下来用于后面的生成HTTP响应。
  • parse_headers(text);,解析请求头部,GET和POST中空行以上,请求行以下的部分。
  • parse_content(text);,解析请求数据,对于GET来说这部分是空的,因为这部分内容已经以明文的方式包含在了请求行中的URL部分了;只有POST的这部分是有数据的,项目中的这部分数据为用户名和密码,我们会根据这部分内容做登录和校验,并涉及到与数据库的连接。

OK,经过上述解析,当得到一个完整的,正确的HTTP请求时,就到了do_request代码部分,我们需要首先对GET请求和不同POST请求(登录,注册,请求图片,视频等等)做不同的预处理,然后分析目标文件的属性,若目标文件存在、对所有用户可读且不是目录时,则使用mmap将其映射到内存地址m_file_address处,并告诉调用者获取文件成功。

抛开mmap这部分,先来看看这些不同请求是怎么来的: 假设你已经搭好了你的HTTP服务器,然后你在本地浏览器中键入localhost:9000,然后回车,这时候你就给你的服务器发送了一个GET请求,什么都没做,然后服务器端就会解析你的这个HTTP请求,然后发现是个GET请求,然后返回给你一个静态HTML页面,也就是项目中的judge.html页面,那POST请求怎么来的呢?这时你会发现,返回的这个judge页面中包含着一些新用户已有账号这两个button元素,当你用鼠标点击这个button时,你的浏览器就会向你的服务器发送一个POST请求,服务器段通过检查action来判断你的POST请求类型是什么,进而做出不同的响应。

/* judge.html */
<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8">
        <title>WebServer</title>
    </head>
    <body>
    <br/>
    <br/>
    <div align="center"><font size="5"> <strong>欢迎访问</strong></font></div>
    <br/>
        <br/>
        <form action="0" method="post">
            <div align="center"><button type="submit">新用户</button></div>
        </form>
        <br/>
        <form action="1" method="post">
            <div align="center"><button type="submit" >已有账号</button></div>
        </form>

        </div>
    </body>
</html>

5. 数据库连接池是如何运行的

在处理用户注册,登录请求的时候,我们需要将这些用户的用户名和密码保存下来用于新用户的注册及老用户的登录校验,相信每个人都体验过,当你在一个网站上注册一个用户时,应该经常会遇到“您的用户名已被使用”,或者在登录的时候输错密码了网页会提示你“您输入的用户名或密码有误”等等类似情况,这种功能是服务器端通过用户键入的用户名密码和数据库中已记录下来的用户名密码数据进行校验实现的。若每次用户请求我们都需要新建一个数据库连接,请求结束后我们释放该数据库连接,当用户请求连接过多时,这种做法过于低效,所以类似**线程池**的做法,我们构建一个数据库连接池,预先生成一些数据库连接放在那里供用户请求使用。 我们首先看单个数据库连接是如何生成的:

  1. 使用mysql_init()初始化连接
  2. 使用mysql_real_connect()建立一个到mysql数据库的连接
  3. 使用mysql_query()执行查询语句
  4. 使用result = mysql_store_result(mysql)获取结果集
  5. 使用mysql_num_fields(result)获取查询的列数,mysql_num_rows(result)获取结果集的行数
  6. 通过mysql_fetch_row(result)不断获取下一行,然后循环输出
  7. 使用mysql_free_result(result)释放结果集所占内存
  8. 使用mysql_close(conn)关闭连接

对于一个数据库连接池来讲,就是预先生成多个这样的数据库连接,然后放在一个链表中,同时维护最大连接数MAX_CONN,当前可用连接数FREE_CONN和当前已用连接数CUR_CONN这三个变量。同样注意在对连接池操作时(获取,释放),要用到锁机制,因为它被所有线程共享。

6. 什么是CGI校验

OK,弄清楚了数据库连接池的概念及实现方式,我们继续回到第4部分,对用户的登录及注册等POST请求,服务器是如何做校验的。当点击新用户按钮时,服务器对这个POST请求的响应是:返回用户一个登录界面;当你在用户名和密码框中输入后,你的POST请求报文中会连同你的用户名密码一起发给服务器,然后我们拿着你的用户名和密码在数据库连接池中取出一个连接用于mysql_query()进行查询,逻辑很简单,同步线程校验SYNSQL方式相信大家都能明白,但是这里书上又给出了其他两种校验方式,CGI什么的,就很容易让小白一时摸不到头脑,接下来就简单解释一下CGI是什么。

CGI(通用网关接口),它是一个运行在Web服务器上的程序,在编译的时候将相应的.cpp文件编程成.cgi文件并在主程序中调用即可(通过makefile文件内容也可以看出)。这些CGI程序通常通过客户在其浏览器上点击一个button时运行。这些程序通常用来执行一些信息搜索、存储等任务,而且通常会生成一个动态的HTML网页来响应客户的HTTP请求。我们可以发现项目中的sign.cpp文件就是我们的CGI程序,将用户请求中的用户名和密码保存在一个id_passwd.txt文件中,通过将数据库中的用户名和密码存到一个map中用于校验。在主程序中通过execl(m_real_file, &flag, name, password, NULL);这句命令来执行这个CGI文件,这里CGI程序仅用于校验,并未直接返回给用户响应。这个CGI程序的运行通过多进程来实现,根据其返回结果判断校验结果(使用pipe进行父子进程的通信,子进程将校验结果写到pipe的写端,父进程在读端读取)。

7. 生成HTTP响应并返回给用户

通过以上操作,我们已经对读到的请求做好了处理,然后也对目标文件的属性作了分析,若目标文件存在、对所有用户可读且不是目录时,则使用mmap将其映射到内存地址m_file_address处,并告诉调用者获取文件成功FILE_REQUEST。 接下来要做的就是根据读取结果对用户做出响应了,也就是到了process_write(read_ret);这一步,该函数根据process_read()的返回结果来判断应该返回给用户什么响应,我们最常见的就是404错误了,说明客户请求的文件不存在,除此之外还有其他类型的请求出错的响应,具体的可以去百度。然后呢,假设用户请求的文件存在,而且已经被mmapm_file_address这里了,那么我们就将做如下写操作,将响应写到这个connfd的写缓存m_write_buf中去:

case FILE_REQUEST: {
    add_status_line(200, ok_200_title);
    if(m_file_stat.st_size != 0) {
        add_headers(m_file_stat.st_size);
        m_iv[0].iov_base = m_write_buf;
        m_iv[0].iov_len = m_write_idx;
        m_iv[1].iov_base = m_file_address;
        m_iv[1].iov_len = m_file_stat.st_size;
        m_iv_count = 2;
        bytes_to_send = m_write_idx + m_file_stat.st_size;
        return true;
    }
    else {
        const char* ok_string = "<html><body></body></html>";
        add_headers(strlen(ok_string));
        if(!add_content(ok_string))
            return false;
    }
}

首先将状态行写入写缓存,响应头也是要写进connfd的写缓存(HTTP类自己定义的,与socket无关)中的,对于请求的文件,我们已经直接将其映射到m_file_address里面,然后将该connfd文件描述符上修改为EPOLLOUT(可写)事件,然后epoll_Wait监测到这一事件后,使用writev来将响应信息和请求文件**聚集写**到**TCP Socket**本身定义的发送缓冲区(这个缓冲区大小一般是默认的,但我们也可以通过setsockopt来修改)中,交由内核发送给用户。OVER!

8. 服务器优化:定时器处理非活动链接

项目中,我们预先分配了MAX_FD个http连接对象:

// 预先为每个可能的客户连接分配一个http_conn对象
http_conn* users = new http_conn[MAX_FD];

如果某一用户connect()到服务器之后,长时间不交换数据,一直占用服务器端的文件描述符,导致连接资源的浪费。这时候就应该利用定时器把这些超时的非活动连接释放掉,关闭其占用的文件描述符。这种情况也很常见,当你登录一个网站后长时间没有操作该网站的网页,再次访问的时候你会发现需要重新登录。 项目中使用的是SIGALRM信号来实现定时器,利用alarm函数周期性的触发SIGALRM信号,信号处理函数利用管道通知主循环,主循环接收到该信号后对升序链表上所有定时器进行处理,若该段时间内没有交换数据,则将该连接关闭,释放所占用的资源。我们接下来看项目中的具体实现。

/* 定时器相关参数 */
static int pipefd[2];
static sort_timer_lst timer_lst

/* 每个user(http请求)对应的timer */
client_data* user_timer = new client_data[MAX_FD];
/* 每隔TIMESLOT时间触发SIGALRM信号 */
alarm(TIMESLOT);
/* 创建管道,注册pipefd[0]上的可读事件 */
int ret = socketpair(PF_UNIX, SOCK_STREAM, 0, pipefd);
/* 设置管道写端为非阻塞 */
setnonblocking(pipefd[1]);
/* 设置管道读端为ET非阻塞,并添加到epoll内核事件表 */
addfd(epollfd, pipefd[0], false);

addsig(SIGALRM, sig_handler, false);
addsig(SIGTERM, sig_handler, false);

alarm函数会定期触发SIGALRM信号,这个信号交由sig_handler来处理,每当监测到有这个信号的时候,都会将这个信号写到pipefd[1]里面,传递给主循环:

主循环中:

/* 处理信号 */
else if(sockfd == pipefd[0] && (events[i].events & EPOLLIN)) {
    int sig;
    char signals[1024];
    ret = recv(pipefd[0], signals, sizeof(signals), 0);
    if(ret == -1) {
        continue;  // handle the error
    }
    else if(ret == 0) {
        continue;
    }
    else {
        for(int j = 0; j < ret; ++j) {
            switch (signals[j]) {
                case SIGALRM: {
                    timeout = true;
                    break;
                }
                case SIGTERM: {
                    stop_server = true;
                }
            }
        }
    }
}

当我们在读端pipefd[0]读到这个信号的的时候,就会将timeout变量置为true并跳出循环,让timer_handler()函数取出来定时器容器上的到期任务,该定时器容器是通过升序链表来实现的,从头到尾对检查任务是否超时,若超时则调用定时器的回调函数cb_func(),关闭该socket连接,并删除其对应的定时器del_timer

void timer_handler() {
    /* 定时处理任务 */
    timer_lst.tick();
    /* 重新定时以不断触发SIGALRM信号 */
    alarm(TIMESLOT);
}

9. 优化

  • 定时器的实现改成时间轮模式