Tinywebserver:一个简易的web服务器

Posted wangxiaobao的博客

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Tinywebserver:一个简易的web服务器相关的知识,希望对你有一定的参考价值。

这是学习网络编程后写的一个练手的小程序,可以帮助复习I/O模型,epoll使用,线程池,HTTP协议等内容。

程序代码是基于《Linux高性能服务器编程》一书编写的。

首先回顾程序中的核心内容和主要问题,最后给出相关代码。

0. 功能和I/O模型

实现简易的HTTP服务端,现仅支持GET方法,通过浏览器访问可以返回相应内容。

I/O模型采用Reactor(I/O复用 + 非阻塞I/O) + 线程池。 使用epoll事件循环用作事件通知,如果listenfd上可读,则调用accept,把新建的fd加入epoll中;

是已连接sockfd,将其加入到线程池中由工作线程竞争执行任务。

 

1. 线程池怎么实现?

程序采用c++编写,要自己封装一个简易的线程池类。大致思路是创建固定数目的线程(如跟核数相同),然后类内部维护一个生产者—消费者队列。

提供相应的添加任务(生产者)和执行任务接口(消费者)。按照操作系统书中典型的生产者—消费者模型维护增减队列任务(使用mutex和semaphore)。

mutex用于互斥,保证任意时刻只有一个线程读写队列,semaphore用于同步,保证执行顺序(队列为空时不要读,队列满了不要写)。

 

2. epoll用条件触发(LT)还是边缘触发(ET)?

考虑这样的情况,一个工作线程在读一个fd,但没有读完。如果采用LT,则下一次事件循环到来的时候,又会触发该fd可读,此时线程池很有可能将该fd分配给其他的线程处理数据。

这显然不是我们想要看到的,而ET则不会在下一次epoll_wait的时候返回,除非读完以后又有新数据才返回。所以这里应该使用ET。

当然ET用法在《Tinychatserver: 一个简易的命令行群聊程序》也有总结过。用法的模式是固定的,把fd设为nonblocking,如果返回某fd可读,循环read直到EAGAIN。

 

3. 继续上面的问题,如果某个线程在处理fd的同时,又有新的一批数据发来(不是老数据没读完,是来新数据了),即使使用了ET模式,因为新数据的到来,仍然会触发该fd可读,所以仍然存在将该fd分给其他线程处理的情况。

这里就用到了EPOLLONESHOT事件。对于注册了EPOLLONESHOT事件的文件描述符,操作系统最大触发其上注册的一个可读、可写或者异常事件,且只触发一次。

除非我们使用epoll_ctl函数重置该文件描述符上注册的EPOLLONESHOT事件。这样,当一个线程处理某个socket时,其他线程是不可能有机会操作该socket的,

即可解决该问题。但同时也要注意,如果注册了EPOLLONESHOT的socket一旦被某个线程处理完毕,则应该立即重置这个socket上的EPOLLONESHOT事件,

以确保下一次可读时,其EPOLLIN事件能够触发。

 

4. HTTP协议解析怎么做?数据读到一半怎么办?

首先理解这个问题。HTTP协议并未提供头部字段的长度,判断头部结束依据是遇到一个空行,该空行只包含一对回车换行符(<CR><LF>)。同时,如果一次读操作没有读入整个HTTP请求

的头部,我们必须等待用户继续写数据再次读入(比如读到 GET /index.html HTT就结束了,必须维护这个状态,下一次必须继续读‘P’)。

即我们需要判定当前解析的这一行是什么(请求行?请求头?消息体?),还需要判断解析一行是否结束?

解决上述问题,可以采取有限状态机。

参考【1】中设计方式,设计主从两个状态机(主状态机解决前半部分问题,从状态机解决后半部分问题)。

先分析从状态机,从状态机用于处理一行信息(即parse_line函数)。其中包括三个状态:LINE_OPEN, LINE_OK,LINE_BAD,转移过程如下所示:

当从状态机parse_line读到完整的一行,就可以将改行内容递交给process_read函数中的主状态机处理。

 

主状态机也有三种状态表示正在分析请求行(CHECK_STATE_REQUESTINE),正在分析头部字段(CHECK_STATE_HEADER),和正在分析内容(CHECK_CONTENT)。

主状态机使用checkstate变量来记录当前的状态。

如果当前的状态是CHECK_STATE_REQUESTLINE,则表示parse_line函数解析出的行是请求行,于是主状态机调用parse_requestline来分析请求行;

如果当前的状态是CHECK_STATE_HEADER,则表示parse_line函数解析出来的是头部字段,于是主状态机调用parse_header来分析头部字段。

如果当前状态是CHECK_CONTENT,则表示parse_line函数解析出来的是消息体,我们调用parse_content来分析消息体(实际上实现时候并没有分析,只是判断是否完整读入)

checkstate变量的初始值是CHECK_STATE_REQUESTLINE,调用相应的函数(parse_requestline,parse_header)后更新checkstate实现状态转移。

与主状态机有关的核心函数如下:

http_conn::HTTP_CODE http_conn::process_read()//完整的HTTP解析
{
    LINE_STATUS line_status = LINE_OK;
    HTTP_CODE ret = NO_REQUEST;
    char* text = 0;

    while ( ( ( m_check_state == CHECK_STATE_CONTENT ) && ( line_status == LINE_OK  ) )
                || ( ( line_status = parse_line() ) == LINE_OK ) ){//满足条件:正在进行HTTP解析、读取一个完整行
        text = get_line();//从读缓冲区(HTTP请求数据)获取一行数据
        m_start_line = m_checked_idx;//行的起始位置等于正在每行解析的第一个字节
        printf( "got 1 http line: %s", text );

        switch ( m_check_state )//HTTP解析状态跳转
        {
            case CHECK_STATE_REQUESTLINE://正在分析请求行
            {
                ret = parse_request_line( text );//分析请求行
                if ( ret == BAD_REQUEST )
                {
                    return BAD_REQUEST;
                }
                break;
            }
            case CHECK_STATE_HEADER://正在分析请求头部
            {
                ret = parse_headers( text );//分析头部
                if ( ret == BAD_REQUEST )
                {
                    return BAD_REQUEST;
                }
                else if ( ret == GET_REQUEST )
                {
                    return do_request();//当获得一个完整的连接请求则调用do_request分析处理资源页文件
                }
                break;
            }
            case CHECK_STATE_CONTENT:// 解析消息体
            {
                ret = parse_content( text );
                if ( ret == GET_REQUEST )
                {
                    return do_request();
                }
                line_status = LINE_OPEN;
                break;
            }
            default:
            {
                return INTERNAL_ERROR;//内部错误
            }
        }
    }

    return NO_REQUEST;
}

 

5. HTTP响应怎么做?怎么发送效率高一些?

首先介绍readv和writev函数。其功能可以简单概括为对数据进行整合传输及发送,即所谓分散读,集中写

也就是说,writev函数可以把分散保存在多个缓冲中的数据一并发送,通过readv函数可以由多个缓冲分别接收。因此适当采用这两个函数可以减少I/O次数。

例如这里要做的HTTP响应。其包含一个状态行,多个头部字段,一个空行和文档的内容。前三者可能被web服务器放置在一块内存中,

而文档的内容则通常被读入到另外一块单独的内存中(通过read函数或mmap函数)。这里可以采用writev函数将他们一并发出。

相关接口如下:

ssize_t readv(int fd, const struct iovec *iov, int iovcnt);

ssize_t writev(int fd, const struct iovec *iov, int iovcnt);

其中第二个参数为如下结构体的数组
struct iovec {
    void  *iov_base;    /* Starting address */
    size_t iov_len;     /* Number of bytes to transfer */
};
第三个参数为第二个参数的传递的数组的长度。

这里还可以再学习一下mmap与munmap函数。但是这里关于mmap与read效率的比较,应该没有那么简单的答案。mmap可以减少系统调用和内存拷贝,但是其引发的pagefault也是开销。效率的比较取决于不同系统对于这两个效率实现的不同,所以这里就简单谈一谈用法。

#include <sys/mman.h>
/**addr参数允许用户使用某个特定的地址作为这段内存的起始地址,设置为NULL则自动分配地址。
*length参数指定内存段的长度.
*prot参数用来设置内*存段的访问权限,比如PROT_READ可读, PROT_WRITE可写。
*flags控制内存段内容被修改后程序的行为。如MAP_PRIVATE指内存段为调用进程所私有,对该内存段的修改不会反映到被映射的文件中。
*/
void *mmap(void *addr, size_t length, int prot, int flags,
           int fd, off_t offset);
int munmap(void *addr, size_t length);

所以根据不同情况(200,404)填充HTTP的程序如下:

bool http_conn::process_write( HTTP_CODE ret )//填充HTTP应答
{
    switch ( ret )
    {
        case INTERNAL_ERROR:
        {
            add_status_line( 500, error_500_title );
            add_headers( strlen( error_500_form ) );
            if ( ! add_content( error_500_form ) )
            {
                return false;
            }
            break;
        }
        case BAD_REQUEST:
        {
            add_status_line( 400, error_400_title );
            add_headers( strlen( error_400_form ) );
            if ( ! add_content( error_400_form ) )
            {
                return false;
            }
            break;
        }
        case NO_RESOURCE:
        {
            add_status_line( 404, error_404_title );
            add_headers( strlen( error_404_form ) );
            if ( ! add_content( error_404_form ) )
            {
                return false;
            }
            break;
        }
        case FORBIDDEN_REQUEST:
        {
            add_status_line( 403, error_403_title );
            add_headers( strlen( error_403_form ) );
            if ( ! add_content( error_403_form ) )
            {
                return false;
            }
            break;
        }
        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_file_stat资源页文件状态
                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_file_address地址
                m_iv[ 1 ].iov_len = m_file_stat.st_size;//文件长度就是该块内存长度
                m_iv_count = 2;
                return true;
            }
            else
            {
                const char* ok_string = "<html><body></body></html>";//请求页位空白
                add_headers( strlen( ok_string ) );
                if ( ! add_content( ok_string ) )
                {
                    return false;
                }
            }
        }
        default:
        {
            return false;
        }
    }

    m_iv[ 0 ].iov_base = m_write_buf;
    m_iv[ 0 ].iov_len = m_write_idx;
    m_iv_count = 1;
    return true;
}
填充HTTP应答
bool http_conn::write()//将资源页文件发送给客户端
{
    int temp = 0;
    int bytes_have_send = 0;
    int bytes_to_send = m_write_idx;
    if ( bytes_to_send == 0 )
    {
        modfd( m_epollfd, m_sockfd, EPOLLIN );//EPOLLONESHOT事件每次需要重置事件
        init();
        return true;
    }

    while( 1 )//
    {
        temp = writev( m_sockfd, m_iv, m_iv_count );//集中写,m_sockfd是http连接对应的描述符,m_iv是iovec结构体数组表示内存块地址,m_iv_count是数组的长度即多少个内存块将一次集中写到m_sockfd
        if ( temp <= -1 )//集中写失败
        {
            if( errno == EAGAIN )
            {
                modfd( m_epollfd, m_sockfd, EPOLLOUT );//重置EPOLLONESHOT事件,注册可写事件表示若m_sockfd没有写失败则关闭连接
                return true;
            }
            unmap();//解除内存映射
            return false;
        }

        bytes_to_send -= temp;//待发送数据
        bytes_have_send += temp;//已发送数据
        if ( bytes_to_send <= bytes_have_send )
        {
            unmap();//该资源页已经发送完毕该解除映射
            if( m_linger )//若要保持该http连接
            {
                init();//初始化http连接
                modfd( m_epollfd, m_sockfd, EPOLLIN );
                return true;
            }
            else
            {
                modfd( m_epollfd, m_sockfd, EPOLLIN );
                return false;
            } 
        }
    }
}
将应答发送给客户端

 

6.忽略SIGPIPE

这是一个看似很小,但是如果不注意会直接引发bug的地方。如果往一个读端关闭的管道或者socket中写数据,会引发SIGPIPE,程序收到SIGPIPE信号后默认的操作时终止进程。

这也就是说,如果客户端意外关闭,那么服务器可能也就跟着直接挂了,这显然不是我们想要的。所以网络程序中服务端一般会忽略SIGPIPE信号

 

7. 程序代码

程序中有比较详细的注释,虽然主干在上面问题中分析过了,但是诸如如何解析一行数据之类的操作,还是很烦的...可以直接参考代码

  1 #ifndef THREADPOOL_H
  2 #define THREADPOOL_H
  3 
  4 #include <list>
  5 #include <cstdio>
  6 #include <exception>
  7 #include <pthread.h>
  8 #include "locker.h" //简单封装了互斥量和信号量的接口
  9 
 10 //线程池类模板参数T是任务类型,T中必须有接口process
 11 template< typename T >
 12 class threadpool 
 13 {
 14 public:
 15     threadpool( int thread_number = 8, int max_requests = 10000 );//线程数目和最大连接处理数
 16     ~threadpool();
 17     bool append( T* request );
 18 
 19 private:
 20     static void* worker( void* arg );//线程工作函数
 21     void run(); //启动线程池
 22 
 23 private:
 24     int m_thread_number;//线程数量
 25     int m_max_requests;//最大连接数目
 26     pthread_t* m_threads;//线程id数组
 27     std::list< T* > m_workqueue;//工作队列:各线程竞争该队列并处理相应的任务逻辑T
 28     locker m_queuelocker;//工作队列互斥量
 29     sem m_queuestat;//信号量:用于工作队列
 30     bool m_stop;//终止标志
 31 };
 32 
 33 template< typename T >
 34 threadpool< T >::threadpool( int thread_number, int max_requests ) : 
 35         m_thread_number( thread_number ), m_max_requests( max_requests ), m_stop( false ), m_threads( NULL )
 36 {
 37     if( ( thread_number <= 0 ) || ( max_requests <= 0 ) )
 38     {
 39         throw std::exception();
 40     }
 41 
 42     m_threads = new pthread_t[ m_thread_number ];//工作线程数组
 43     if( ! m_threads )
 44     {
 45         throw std::exception();
 46     }
 47 
 48     for ( int i = 0; i < thread_number; ++i )//创建工作线程
 49     {
 50         printf( "create the %dth thread\\n", i );
 51         if( pthread_create( m_threads + i, NULL, worker, this ) != 0 )
 52         {
 53             delete [] m_threads;
 54             throw std::exception();
 55         }
 56         if( pthread_detach( m_threads[i] ) ) //分离线程使得其它线程回收和杀死该线程
 57         {
 58             delete [] m_threads;
 59             throw std::exception();
 60         }
 61     }
 62 }
 63 
 64 template< typename T >
 65 threadpool< T >::~threadpool()
 66 {
 67     delete [] m_threads;
 68     m_stop = true;
 69 }
 70 
 71 template< typename T >
 72 bool threadpool< T >::append( T* request )//向工作队列添加任务T
 73 {
 74     m_queuelocker.lock();//对工作队列操作前加锁
 75     if ( m_workqueue.size() > m_max_requests )//任务队列满,不能加进去
 76     {
 77         m_queuelocker.unlock();
 78         return false;
 79     }
 80     m_workqueue.push_back( request );
 81     m_queuelocker.unlock();
 82     m_queuestat.post();//信号量的V操作,多了一个工作任务T使得信号量+1
 83     return true;
 84 }
 85 
 86 template< typename T >
 87 void* threadpool< T >::worker( void* arg )//工作线程函数
 88 {
 89     threadpool* pool = ( threadpool* )arg; //获取线程池对象,之前创建的时候传的this
 90     pool->run(); //调用线程池run函数
 91     return pool;
 92 }
 93 
 94 template< typename T >
 95 void threadpool< T >::run() //工作线程真正工作逻辑:从任务队列领取任务T并执行任务T,消费者
 96 {
 97     while ( ! m_stop )
 98     {
 99         m_queuestat.wait();//信号量P操作,申请信号量获取任务T
100         m_queuelocker.lock();//对工作队列操作前加锁
101         if ( m_workqueue.empty() )
102         {
103             m_queuelocker.unlock();//任务队列空无法消费
104             continue;
105         }
106         T* request = m_workqueue.front();//获取任务T
107         m_workqueue.pop_front();
108         m_queuelocker.unlock();
109         if ( ! request )
110         {
111             continue;
112         }
113         request->process();//执行任务T的相应逻辑,任务T中必须有process接口
114     }
115 }
116 
117 #endif
threadpool.h
#ifndef HTTPCONNECTION_H
#define HTTPCONNECTION_H

#include <unistd.h>
#include <signal.h>
#include <sys/types.h>
#include <sys/epoll.h>
#include <fcntl.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <assert.h>
#include <sys/stat.h>
#include <string.h>
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/mman.h>
#include <stdarg.h>
#include <errno.h>
#include "locker.h"

class http_conn
{
public:
    static const int FILENAME_LEN = 200;//文件名最大长度,文件是HTTP请求的资源页文件
    static const int READ_BUFFER_SIZE = 2048;//读缓冲区,用于读取HTTP请求
    static const int WRITE_BUFFER_SIZE = 1024;//写缓冲区,用于HTTP回答
    enum METHOD { GET = 0, POST, HEAD, PUT, DELETE, TRACE, OPTIONS, CONNECT, PATCH };//HTTP请求方法,本程序只定义了GET逻辑
    enum CHECK_STATE { CHECK_STATE_REQUESTLINE = 0, CHECK_STATE_HEADER, CHECK_STATE_CONTENT };//HTTP请求状态:正在解析请求行、正在解析头部、解析中
    enum HTTP_CODE { NO_REQUEST, GET_REQUEST, BAD_REQUEST, NO_RESOURCE, FORBIDDEN_REQUEST, FILE_REQUEST, INTERNAL_ERROR, CLOSED_CONNECTION };//HTTP请求结果:未完整的请求(客户端仍需要提交请求)、完整的请求、错误请求...只用了前三个
    enum LINE_STATUS { LINE_OK = 0, LINE_BAD, LINE_OPEN };//HTTP每行解析状态:改行解析完毕、错误的行、正在解析行

public:
    http_conn(){}
    ~http_conn(){}

public:
    void init( int sockfd, const sockaddr_in& addr );//初始化新的HTTP连接
    void close_conn( bool real_close = true );
    void process();//处理客户请求,这是HTTP请求的入口函数,与在线程池中调用!!!
    bool read();//读取客户发送来的数据(HTTP请求)
    bool write();//将请求结果返回给客户端
private:
    void init();//初始化连接,用于内部调用
    HTTP_CODE process_read();//解析HTTP请求,内部调用parse_系列函数
    bool process_write( HTTP_CODE ret );//填充HTTP应答,通常是将客户请求的资源页发送给客户,内部调用add_系列函数

    HTTP_CODE parse_request_line( char* text );//解析HTTP请求的请求行
    HTTP_CODE parse_headers( char* text );//解析HTTP头部数据
    HTTP_CODE parse_content( char* text );//获取解析结果
    HTTP_CODE do_request();//处理HTTP连接:内部调用process_read(),process_write()
    char* get_line() { return m_read_buf + m_start_line; }//获取HTTP请求数据中的一行数据
    LINE_STATUS parse_line();//解析行内部调用parse_request_line和parse_headers
    //下面的函数被process_write填充HTTP应答    
    
    void unmap();//解除内存映射,这里内存映射是指将客户请求的资源页文件映射通过mmap映射到内存
    bool add_response( const char* format, ... );
    bool add_content( const char* content );
    bool add_status_line( int status, const char* title );
    bool add_headers( int content_length );
    bool add_content_length( int content_length );
    bool add_linger();
    bool add_blank_line();

public:
    static int m_epollfd;//所有socket上的事件都注册到一个epoll事件表中所以用static
    static int m_user_count;//用户数量

private:
    int m_sockfd;//HTTP连接对应的客户在服务端的描述符m_sockfd和地址m_address
    sockaddr_in m_address;

    char m_read_buf[ READ_BUFFER_SIZE ];//读缓冲区,读取HTTP请求
    int m_read_idx;//已读入的客户数据最后一个字节的下一个位置,即未读数据的第一个位置
    int m_checked_idx;//当前已经解析的字节(HTTP请求需要逐个解析)
    int m_start_line;//当前解析行的起始位置
    char m_write_buf[ WRITE_BUFFER_SIZE ];//写缓冲区
    int m_write_idx;//写缓冲区待发送的数据

    CHECK_STATE m_check_state;//HTTP解析的状态:请求行解析、头部解析
    METHOD m_method;//HTTP请求方法,只实现了GET

    char m_real_file[ FILENAME_LEN ];//HTTP请求的资源页对应的文件名称,和服务端的路径拼接就形成了资源页的路径
    char* m_url;//请求的具体资源页名称,如:www.baidu.com/index.html
    char* m_version;//HTTP协议版本号,一般是:HTTP/1.1
    char* m_host;//主机名,客户端要在HTTP请求中的目的主机名
    int m_content_length;//HTTP消息体的长度,简单的GET请求这个为空
    bool m_linger;//HTTP请求是否保持连接

    char* m_file_address;//资源页文件内存映射后的地址
    struct stat m_file_stat;//资源页文件的状态,stat文件结构体
    struct iovec m_iv[2];//调用writev集中写函数需要m_iv_count表示被写内存块的数量,iovec结构体存放了一段内存的起始位置和长度,
    int m_iv_count;//m_iv_count是指iovec结构体数组的长度即多少个内存块
};

#endif
http_conn.h
  1 #include "http_conn.h"
  2 
  3 const char* ok_200_title = "OK";
  4 const char* error_400_title = "Bad Request";
  5 const char* error_400_form = "Your request has bad syntax or is inherently impossible to satisfy.\\n";
  6 const以上是关于Tinywebserver:一个简易的web服务器的主要内容,如果未能解决你的问题,请参考以下文章

用线程池实现的简单web服务器--tinywebserver

C++轻量级Web服务器TinyWebServer源码分析之threadpool篇

c++ 经典服务器开源项目 Tinywebserver的使用与配置(百度智能云服务器安装ubuntu18.04可用公网ip访问)

C++ tinyWebServer [零]

如何搭建一个简易的Web框架

express搭建简易web的服务器