自主HttpServer实现(C++实战项目)

文章目录

  • 项目介绍
    • CGI技术
      • 概念
      • 原理
  • 设计框架
    • 日志文件
    • TCPServer
    • 任务类
    • 初始化与启动HttpServer
    • HTTP请求结构
    • HTTP响应结构
    • 线程回调
    • EndPoint类
      • EndPoint主体框架
      • 读取HTTP请求
      • 处理HTTP请求
        • CGI处理
        • 非CGI处理
    • 构建HTTP响应
    • 发送HTTP响应
    • 接入线程池
    • 简单测试
  • 项目扩展

项目介绍

该项目是一个基于Http和Tcp协议自主实现的WebServer,用于实现服务器对客户端发送过来的GET和POST请求的接收、解析、处理,并返回处理结果给到客户端。该项目主要背景知识涉及C++、网络分层协议栈、HTTP协议、网络套接字编程、CGI技术、单例模式、多线程编程、线程池等。

项目源码:Click

image-20230311111150579

CGI技术

CGI技术可能大家比较陌生,单拎出来提下。

概念

CGI(通用网关接口,Common Gateway Interface)是一种用于在Web服务器上执行程序并生成动态Web内容的技术。CGI程序可以是任何可执行程序,通常是脚本语言,例如Perl或Python。

CGI技术允许Web服务器通过将Web请求传递给CGI程序来执行任意可执行文件。CGI程序接收HTTP请求,并生成HTTP响应以返回给Web服务器,最终返回给Web浏览器。这使得Web服务器能够动态地生成网页内容,与静态HTML文件不同。CGI程序可以处理表单数据、数据库查询和其他任务,从而实现动态Web内容。一些常见的用途包括创建动态网页、在线购物车、用户注册、论坛、网上投票等。

原理

通过Web服务器将Web请求传递给CGI程序,CGI程序处理请求并生成响应,然后将响应传递回Web服务器,最终返回给客户端浏览器。这个过程可以概括为:

  1. 客户端发送HTTP请求到Web服务器。
  2. Web服务器检查请求类型,如果是CGI请求,Web服务器将环境变量和请求参数传递给CGI程序,并等待CGI程序的响应。
  3. CGI程序接收请求参数,并执行相应的操作,例如读取数据库或处理表单数据等。
  4. CGI程序生成HTTP响应,将响应返回给Web服务器。
  5. Web服务器将响应返回给客户端浏览器。

image-20230311112450625

在这个过程中,Web服务器和CGI程序之间通过标准输入和标准输出(建立管道并重定向到标准输入输出)进行通信。Web服务器将请求参数通过环境变量传递给CGI程序,CGI程序将生成的响应通过标准输出返回给Web服务器。此外,CGI程序还可以通过其他方式与Web服务器进行通信,例如通过命令行参数或文件进行交互。

设计框架

日志文件

用于记录下服务器运行过程中产生的一些事件。日志格式如下:

image-20230311113235121

日志级别说明:

  • INFO: 表示正常的日志输出,一切按预期运行。
  • WARNING: 表示警告,该事件不影响服务器运行,但存在风险。
  • ERROR: 表示发生了某种错误,但该事件不影响服务器继续运行。
  • FATAL: 表示发生了致命的错误,该事件将导致服务器停止运行。

文件名称和行数可以通过C语言中的预定义符号__FILE____LINE__,分别可以获取当前文件的名称和当前的行数。

#define INFO    1
#define WARNING 2
#define ERROR   3
#define FATAL   4

// #将宏参数level转为字符串格式
#define LOG(level, message) Log(#level, message, __FILE__, __LINE__) 

TCPServer

思路是:创建一个TCP服务器,并通过初始化、绑定和监听等步骤实现对外服务。

具体实现中,单例模式通过一个名为GetInstance的静态方法实现,该方法首先使用pthread_mutex_t保证线程安全,然后使用静态变量 _svr指向单例对象,如果 _svr为空,则创建一个新的TcpServer对象并初始化,最后返回 _svr指针。由于 _svr是static类型的,因此可以确保整个程序中只有一个TcpServer实例。

Socket方法用于创建一个监听套接字,Bind方法用于将端口号与IP地址绑定,Listen方法用于将监听套接字置于监听状态,等待客户端连接。Sock方法用于返回监听套接字的文件描述符。

#define BACKLOG 5

class TcpServer{
    private:
        int _port; // 端口号
        int _listen_sock; // 监听套接字
        static TcpServer* _svr; // 指向单例对象的static指针
    private:
        TcpServer(int port)
            :_port(port)
            ,_listen_sock(-1)
        {}
        TcpServer(const TcpServer&) = delete;
        TcpServer* operator=(const TcpServer&) = delete;
    public:
        static TcpServer* GetInstance(int port)// 单例
        {
            static pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;
            if (_svr == nullptr)
            {
                pthread_mutex_lock(&mtx); 
                if (_svr == nullptr)// 为什么要两个if? 原因:当首个拿锁者完成了对象创建,之后的线程都不会通过第一个if了,而这期间阻塞的线程开始唤醒,它们则需要靠第二个if语句来避免再次创建对象。
                {
                    _svr = new TcpServer(port);
                    _svr -> InitServer();
                }
                pthread_mutex_unlock(&mtx);
            }
            return _svr;
        }
        void InitServer()
        {
            Socket(); // 创建
            Bind();   // 绑定
            Listen(); // 监听
            LOG(INFO, "TcpServer Init Success");
        }
        void Socket() // 创建监听套接字
        {
            _listen_sock = socket(AF_INET, SOCK_STREAM, 0);
            if (_listen_sock < 0)
            {
                LOG(FATAL, "socket error!");
                exit(1);
            }
            int opt = 1;// 将 SO_REUSEADDR 设置为 1 将允许在端口上快速重启套接字
            setsockopt(_listen_sock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
            LOG(INFO, "creat listen_sock success");
        }
        void Bind() // 绑定端口
        {
            struct sockaddr_in local;
            memset(&local, 0, sizeof(local));
            local.sin_family = AF_INET;
            local.sin_port = htons(_port);
            local.sin_addr.s_addr = INADDR_ANY;
            
            if (bind(_listen_sock, (struct sockaddr*)&local, sizeof(local)) < 0)
            {
                LOG(FATAL, "bind error");
                exit(2);
            }
            LOG(INFO, "port bind listen_sock success");
        }
        void Listen() // 监听
        {
            if (listen(_listen_sock, BACKLOG) < 0) // 声明_listen_sock处于监听状态,并且最多允许有backlog个客户端处于连接等待状态,如果接收到更多的连接请求就忽略
            {
                LOG(FATAL, "listen error");
                exit(3);
            }
            LOG(INFO, "listen listen_sock success");
        }
        int Sock() // 获取监听套接字fd
        {
            return _listen_sock;
        }
        ~TcpServer()
        {
            if (_listen_sock >= 0)
            {
                close(_listen_sock);
            }
        }
};

// 单例对象指针初始化
TcpServer* TcpServer::_svr = nullptr;

任务类

// 任务类
class Task{
    private:
        int _sock;          // 通信套接字
        CallBack _handler;  // 回调函数
    public:
        Task()
        {}
        ~Task()
        {}

        Task(int sock)     // accept建立连接成功产生的通信套接字sock
            :_sock(sock)
        {}
        
        // 执行任务
        void ProcessOn()
        {
            _handler(_sock); //_handler对象的运算符()已经重装,直接调用重载的()
        }
};

初始化与启动HttpServer

这部分包含一个初始化服务器的方法InitServer()和一个启动服务器的方法Loop()。其中InitServer()函数注册了一个信号处理函数,忽略SIGPIPE信号(避免写入崩溃)。而Loop()函数则通过调用TcpServer类的单例对象获取监听套接字,然后通过accept()函数等待客户端连接,每当有客户端连接进来,就创建一个线程来处理该客户端的请求,并把任务放入线程池中。这里的Task是一个简单的封装,它包含一个处理客户端请求的成员函数,该成员函数读取客户端请求,解析请求,然后调用CGI程序来执行请求,最后将响应发送给客户端。

#define PORT 8081

class HttpServer
{
private:
    int _port;// 端口号
public:
    HttpServer(int port)
        :_port(port)
    {}
    ~HttpServer()
    {}

    // 初始化服务器
    void InitServer()
    {
        signal(SIGPIPE, SIG_IGN); // 直接粗暴处理cgi程序写入管道时崩溃的情况,忽略SIGPIPE信号,避免因为一个被关闭的socket连接而使整个进程终止
    }

    // 启动服务器
    void Loop()
    {
        LOG(INFO, "loop begin");
        TcpServer* tsvr = TcpServer::GetInstance(_port); // 获取TCP服务器单例对象
        int listen_sock = tsvr->Sock(); // 获取单例对象的监听套接字
        while(true)
        {
            struct sockaddr_in peer;
            memset(&peer, 0, sizeof(peer));
            socklen_t len = sizeof(peer);
            int sock = accept(listen_sock, (struct sockaddr*)&peer, &len);// 跟客户端建立连接
            if (sock < 0)
            {
                continue;
            }

            // 打印客户端信息
            std::string client_ip = inet_ntoa(peer.sin_addr);
            int client_port = ntohs(peer.sin_port);
            LOG(INFO, "get a new link:[" + client_ip + ":" + std::to_string(client_port) + "]");

            // 搞个线程池,代替下面简单的线程分离方案
            // 构建任务并放入任务队列
            Task task(sock);
            ThreadPool::GetInstance()->PushTask(task);
        }
    }
};

HTTP请求结构

将HTTP请求封装成一个类,这个类当中包括HTTP请求的内容、HTTP请求的解析结果以及是否需要使用CGI模式的标志位。后续处理请求时就可以定义一个HTTP请求类,读取到的HTTP请求的数据就存储在这个类当中,解析HTTP请求后得到的数据也存储在这个类当中。

class HttpRequest{
    public:
        // Http请求内容
        std::string _request_line;                 // 请求行
        std::vector<std::string> _request_header;  // 请求报头
        std::string _blank;                        // 空行
        std::string _request_body;                 // 请求正文

        // 存放解析结果
        std::string _method;       // 请求方法                 
        std::string _uri;          // URI               
        std::string _version;      // 版本号
        std::unordered_map<std::string, std::string> _header_kv; // 请求报头的内容是以键值对的形式存在的,用hash保存
        int _content_length;       // 正文长度
        std::string _path;         // 请求资源的路径  
        std::string _query_string; // URI携带的参数

        // 是否使用CGI
        bool _cgi;
    public:
        HttpRequest()
            :_content_length(0)  // 默认请求正文长度为0
            ,_cgi(false)         // 默认不适用CGI模式
        {}
        ~HttpRequest()
        {}
};

HTTP响应结构

类似的,HTTP响应也封装成一个类,这个类当中包括HTTP响应的内容以及构建HTTP响应所需要的数据。构建响应需要使用的数据就存储在这个类当中,构建后得到的响应内容也存储在这个类当中。

class HttpResponse{
    public:
        // Http响应内容
        std::string _status_line;                  // 状态行
        std::vector<std::string> _response_header; // 响应报头
        std::string _blank;                        // 空行
        std::string _response_body;                // 响应正文(如果CGI为true(即Get带_query_string或者Post),响应正文才存在)

        // 所需数据
        int _status_code;    // 状态码
        int _fd;             // 响应文件的fd
        int _size;           // 响应文件的大小
        std::string _suffix; // 响应文件的后缀
    public:
        HttpResponse()
            :_blank(LINE_END)  
            ,_status_code(OK)
            ,_fd(-1)
            ,_size(0)
        {}
        ~HttpResponse()
        {}
};

线程回调

该回调函数实际上是一个函数对象,其重载了圆括号运算符“()”。当该函数对象被调用时,会传入一个int类型的套接字描述符作为参数,代表与客户端建立的连接套接字。该函数对象内部通过创建一个EndPoint对象来处理该客户端发来的HTTP请求,包括读取请求、处理请求、构建响应和发送响应。处理完毕后,该连接套接字将被关闭,EndPoint对象也会被释放。

class CallBack{
    public:
        CallBack()
        {}
        ~CallBack()
        {}
        // 重载运算符 ()
        void operator()(int sock)
        {
            HandlerRequest(sock);
        }
        void HandlerRequest(int sock)
        {
            LOG(INFO, "HandlerRequest begin");
            EndPoint* ep = new EndPoint(sock);
            ep->RecvHttpRequest();          //读取请求
            if (!ep->IsStop())
            {
                LOG(INFO, "RecvHttpRequest Success");
                ep->HandlerHttpRequest();  //处理请求
                ep->BulidHttpResponse();   //构建响应
                ep->SendHttpResponse();    //发送响应
                if (ep->IsStop())
                {
                    LOG(WARNING, "SendHttpResponse Error, Stop Send HttpResponse");
                }
            }
            else 
            {
                LOG(WARNING, "RecvHttpRequest Error, Stop handler Response");
            }
            close(sock); //响应完毕,关闭与该客户端建立的套接字
            delete ep;
            LOG(INFO, "handler request end");
        }
};

EndPoint类

image-20230311134619091

EndPoint主体框架

EndPoint类中包含三个成员变量:

  • sock:表示与客户端进行通信的套接字。
  • http_request:表示客户端发来的HTTP请求。
  • http_response:表示将会发送给客户端的HTTP响应。
  • _stop:是否异常停止本次处理

EndPoint类中主要包含四个成员函数:

  • RecvHttpRequest:读取客户端发来的HTTP请求。
  • HandlerHttpRequest:处理客户端发来的HTTP请求。
  • BuildHttpResponse:构建将要发送给客户端的HTTP响应。
  • SendHttpResponse:发送HTTP响应给客户端。
//服务端EndPoint
class EndPoint{
    private:
        int _sock;                   //通信的套接字
        HttpRequest _http_request;   //HTTP请求
        HttpResponse _http_response; //HTTP响应
    	bool _stop;                          //是否停止本次处理
    public:
        EndPoint(int sock)
            :_sock(sock)
        {}
        //读取请求
        void RecvHttpRequest();
        //处理请求
        void HandlerHttpRequest();
        //构建响应
        void BuildHttpResponse();
        //发送响应
        void SendHttpResponse();
        ~EndPoint()
        {}
};

读取HTTP请求

读取HTTP请求的同时可以对HTTP请求进行解析,这里我们分为五个步骤,分别是读取请求行、读取请求报头和空行、解析请求行、解析请求报头、读取请求正文。

// 读取请求:如果请求行和请求报头正常读取,那先解析请求行和请求报头,然后读取请求正文
void RecvHttpRequest()
{
    if (!RecvHttpRequestLine() && !RecvHttpRequestHeader())// 请求行与请求报头读取均正常读取
    {
        ParseHttpRequestLine();
        ParseHttpRequestHeader();
        RecvHttpRequestBody();
    }
}

处理HTTP请求

首先判断请求方法是否为GET或POST,如果不是则返回错误信息;然后判断请求是GET还是POST,设置对应的cgi、路径和查询字符串;接着拼接web根目录和请求资源路径,并判断路径是否以/结尾,如果是则拼接index.html;获取请求资源文件的属性信息,并根据属性信息判断是否需要使用CGI模式处理;获取请求资源文件的后缀,进行CGI或非CGI处理。

image-20230311134644961

// 处理请求
void HandlerHttpRequest()
{
    auto& code = _http_response._status_code;

    //非法请求
    if (_http_request._method != "GET" && _http_request._method != "POST")
    {
        LOG(WARNING, "method is not right");
        code = BAD_REQUEST;
        return;
    }

    // 判断请求是get还是post,设置cgi,_path,_query_string
    if (_http_request._method == "GET")
    {
        size_t pos = _http_request._uri.find('?');
        if (pos != std::string::npos)// uri中携带参数
        {
            // 切割uri,得到客户端请求资源的路径和uri中携带的参数
            Util::CutString(_http_request._uri, _http_request._path, _http_request._query_string, "?");
            LOG(INFO, "GET方法分割路径和参数");
            _http_request._cgi = true;// 上传了参数,需要使用CGI模式
        }
        else // uri中没有携带参数
        {
            _http_request._path = _http_request._uri;// uri即是客户端请求资源的路径
        }
    }
    else if (_http_request._method == "POST")
    {
        _http_request._path = _http_request._uri;// uri即是客户端请求资源的路径
        _http_request._cgi = true; // 上传了参数,需要使用CGI模式
    }
    else 
    {
        // 只是为了代码完整性
    }

    // 为请求资源路径拼接web根目录
    std::string path = _http_request._path;
    _http_request._path = WEB_ROOT;
    _http_request._path += path;

    // 请求资源路径以/结尾,说明请求的是一个目录
    if (_http_request._path[_http_request._path.size() - 1] == '/')
    {
        _http_request._path += HOME_PAGE; // 拼接上该目录下的index.html
    }
    LOG(INFO, _http_request._path);

    //获取请求资源文件的属性信息
    struct stat st;
    if (stat(_http_request._path.c_str(), &st) == 0) // 属性信息获取成功,说明该资源存在
    {
        if (S_ISDIR(st.st_mode)) // 该资源是一个目录
        {
            _http_request._path += "/"; // 以/结尾的目录前面已经处理过了,这里处理不是以/结尾的目录情况,需要拼接/
            _http_request._path += HOME_PAGE; // 拼接上该目录下的index.html
            stat(_http_request._path.c_str(), &st); // 重新获取资源文件的属性信息
        }
        else if (st.st_mode&S_IXUSR||st.st_mode&S_IXGRP||st.st_mode&S_IXOTH) // 该资源是一个可执行程序
        {
            _http_request._cgi = true; //需要使用CGI模式
        }
        _http_response._size = st.st_size; //设置请求资源文件的大小
    }
    else // 属性信息获取失败,可以认为该资源不存在
    {
        LOG(WARNING, _http_request._path + "NOT_FOUND");
        code = NOT_FOUND;
        return;
    }

    // 获取请求资源文件的后缀
    size_t pos = _http_request._path.rfind('.');
    if (pos == std::string::npos)
    {
        _http_response._suffix = ".html";
    }
    else
    {
        _http_response._suffix = _http_request._path.substr(pos);// 把'.'也带上
    }

    // 进行CGI或非CGI处理
    // CGI为true就三种情况,GET方法的uri带参(_query_string),或者POST方法,又或者请求的资源是一个可执行程序
    if (_http_request._cgi == true) 
    {
        code = ProcessCgi(); // 以CGI的方式进行处理
    }
    else
    {
        code = ProcessNonCgi(); // 简单的网页返回,返回静态网页
    }
}

CGI处理

CGI处理时需要创建子进程进行进程程序替换,但是在创建子进程之前需要先创建两个匿名管道。这里站在父进程角度对这两个管道进行命名,父进程用于读取数据的管道叫做input,父进程用于写入数据的管道叫做output。

image-20230311132028564

创建匿名管道并创建子进程后,需要父子进程各自关闭两个管道对应的读写端:

  • 对于父进程来说,input管道是用来读数据的,因此父进程需要保留input[0]关闭input[1],而output管道是用来写数据的,因此父进程需要保留output[1]关闭output[0]。
  • 对于子进程来说,input管道是用来写数据的,因此子进程需要保留input[1]关闭input[0],而output管道是用来读数据的,因此子进程需要保留output[0]关闭output[1]。

此时父子进程之间的通信信道已经建立好了,但为了让替换后的CGI程序从标准输入读取数据等价于从管道读取数据,向标准输出写入数据等价于向管道写入数据,因此在子进程进行进程程序替换之前,还需要对子进程进行重定向。

假设子进程保留的input[1]和output[0]对应的文件描述符分别是3和4,那么子进程对应的文件描述符表的指向大致如下:
image-20230311132058745

现在我们要做的就是将子进程的标准输入重定向到output管道,将子进程的标准输出重定向到input管道,也就是让子进程的0号文件描述符指向output管道,让子进程的1号文件描述符指向input管道。

image-20230311132120757

此外,在子进程进行进程程序替换之前,还需要进行各种参数的传递:

  • 首先需要将请求方法通过putenv函数导入环境变量,以供CGI程序判断应该以哪种方式读取父进程传递过来的参数。
  • 如果请求方法为GET方法,则需要将URL中携带的参数通过导入环境变量的方式传递给CGI程序。
  • 如果请求方法为POST方法,则需要将请求正文的长度通过导入环境变量的方式传递给CGI程序,以供CGI程序判断应该从管道读取多少个参数。

此时子进程就可以进行进程程序替换了,而父进程需要做如下工作:

  • 如果请求方法为POST方法,则父进程需要将请求正文中的参数写入管道中,以供被替换后的CGI程序进行读取。
  • 然后父进程要做的就是不断调用read函数,从管道中读取CGI程序写入的处理结果,并将其保存到HTTP响应类的response_body当中。
  • 管道中的数据读取完毕后,父进程需要调用waitpid函数等待CGI程序退出,并关闭两个管道对应的文件描述符,防止文件描述符泄露。
// CGI = true,处理cgi
int ProcessCgi()
{
    int code = OK; // 要返回的状态码,默认设置为200

    auto& bin = _http_request._path;      // 需要执行的CGI程序
    auto& method = _http_request._method; // 请求方法

    //需要传递给CGI程序的参数
    auto& query_string = _http_request._query_string; // GET
    auto& request_body = _http_request._request_body; // POST

    int content_length = _http_request._content_length;  // 请求正文的长度
    auto& response_body = _http_response._response_body; // CGI程序的处理结果放到响应正文当中

    // 1、创建两个匿名管道(管道命名站在父进程角度)
    // 在调用 pipe 函数创建管道成功后,pipefd[0] 用于读取数据,pipefd[1] 用于写入数据。
    // 1.1 创建从子进程到父进程的通信信道
    int input[2];
    if(pipe(input) < 0){ // 管道创建失败,pipe()返回-1
        LOG(ERROR, "pipe input error!");
        code = INTERNAL_SERVER_ERROR;
        return code;
    }
    // 1.2 创建从父进程到子进程的通信信道
    int output[2];
    if(pipe(output) < 0){ // 管道创建失败,pipe()返回-1
        LOG(ERROR, "pipe output error!");
        code = INTERNAL_SERVER_ERROR;
        return code;
    }

    //2、创建子进程
    pid_t pid = fork();
    if(pid == 0){ //child
        // 子进程关闭两个管道对应的读写端
        close(input[0]);
        close(output[1]);

        //将请求方法通过环境变量传参
        std::string method_env = "METHOD=";
        method_env += method;
        putenv((char*)method_env.c_str());

        if(method == "GET"){ //将query_string通过环境变量传参
            std::string query_env = "QUERY_STRING=";
            query_env += query_string;
            putenv((char*)query_env.c_str());
            LOG(INFO, "GET Method, Add Query_String env");
        }
        else if(method == "POST"){ //将正文长度通过环境变量传参
            std::string content_length_env = "CONTENT_LENGTH=";
            content_length_env += std::to_string(content_length);
            putenv((char*)content_length_env.c_str());
            LOG(INFO, "POST Method, Add Content_Length env");
        }
        else{
            //Do Nothing
        }

        //3、将子进程的标准输入输出进行重定向,子进程会继承了父进程的所有文件描述符
        dup2(output[0], 0); //标准输入重定向到管道的输入
        dup2(input[1], 1);  //标准输出重定向到管道的输出

        //4、将子进程替换为对应的CGI程序,代码、数据全部替换掉
        execl(bin.c_str(), bin.c_str(), nullptr);
        exit(1); // 替换失败则exit(1)
    }
    else if(pid < 0){ //创建子进程失败,则返回对应的错误码
        LOG(ERROR, "fork error!");
        code = INTERNAL_SERVER_ERROR;
        return code;
    }
    else{ //father
        //父进程关闭两个管道对应的读写端
        close(input[1]);
        close(output[0]);

        if(method == "POST") // 将正文中的参数通过管道传递给CGI程序
        { 
            const char* start = request_body.c_str();
            int total = 0;
            int size = 0;
            while(total < content_length && (size = write(output[1], start + total, request_body.size() - total)) > 0)
            {
                total += size;
            }
        }

        // 读取CGI程序的处理结果
        char ch = 0;
        while(read(input[0], &ch, 1) > 0)// 不会一直读,当另一端关闭后会继续往下执行
        {
            response_body.push_back(ch);
        } 

        // 等待子进程(CGI程序)退出
        // status 保存退出状态
        int status = 0;
        pid_t ret = waitpid(pid, &status, 0);
        if(ret == pid){
            if(WIFEXITED(status)){ // 子进程正常退出
                if(WEXITSTATUS(status) == 0){ // 子进程退出码结果正确
                    LOG(INFO, "CGI program exits normally with correct results");
                    code = OK;
                }
                else{
                    LOG(INFO, "CGI program exits normally with incorrect results");
                    code = BAD_REQUEST;
                }
            }
            else{
                LOG(INFO, "CGI program exits abnormally");
                code = INTERNAL_SERVER_ERROR;
            }
        }

        //关闭两个管道对应的文件描述符
        close(input[0]);
        close(output[1]);
    }
    return code; //返回状态码
}

非CGI处理

​ 非CGI处理时只需要将客户端请求的资源构建成HTTP响应发送给客户端即可,理论上这里要做的就是打开目标文件,将文件中的内容读取到HTTP响应类的response_body中,以供后续发送HTTP响应时进行发送即可,但这种做法还可以优化。

因为HTTP响应类的response_body属于用户层的缓冲区,而目标文件是存储在服务器的磁盘上的,按照这种方式需要先将文件内容读取到内核层缓冲区,再由操作系统将其拷贝到用户层缓冲区,发送响应正文的时候又需要先将其拷贝到内核层缓冲区,再由操作系统将其发送给对应的网卡进行发送。我们完全可以调用sendfile函数直接将磁盘当中的目标文件内容读取到内核,再由内核将其发送给对应的网卡进行发送

​ sendfile函数是一个系统调用函数,用于将一个文件描述符指向的文件内容直接发送给另一个文件描述符指向的套接字,从而实现了零拷贝(Zero Copy)技术。这种技术避免了数据在用户态和内核态之间的多次拷贝,从而提高了数据传输效率。

sendfile函数的使用场景通常是在Web服务器中,用于将静态文件直接发送给客户端浏览器,从而避免了将文件内容复制到用户空间的过程。在Linux系统中,sendfile函数的原型为:

#include <sys/sendfile.h>
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);

​ 其中,out_fd表示目标文件描述符,in_fd表示源文件描述符,offset表示源文件偏移量,count表示要发送的字节数。函数返回值表示成功发送的字节数,如果返回-1则表示出现了错误。

// CGI = false
int ProcessNonCgi()
{
    // 打开客户端请求的资源文件,以供后续发送
    _http_response._fd = open(_http_request._path.c_str(), O_RDONLY);
    if(_http_response._fd >= 0){ // 打开文件成功
        return OK;
    }
    return INTERNAL_SERVER_ERROR; // 打开文件失败
}

构建HTTP响应

构建 HTTP 响应报文,首先根据响应的状态码构建状态行(包含 HTTP 版本、状态码和状态码描述),然后根据状态码分别构建不同的响应报头和响应正文。如果状态码为 200 OK,则调用 BuildOkResponse() 函数构建成功的响应报头和响应正文;如果状态码为 404 NOT FOUND、400 BAD REQUEST 或 500 INTERNAL SERVER ERROR,则根据不同的状态码构建相应的错误响应报头和响应正文,并调用 HandlerError() 函数处理错误。

// 构建响应
void BulidHttpResponse()
{
    int code = _http_response._status_code;
    //构建状态行
    auto& status_line = _http_response._status_line;
    status_line += HTTP_VERSION;
    status_line += " ";
    status_line += std::to_string(code);
    status_line += " ";
    status_line += CodeToDesc(code); //根据状态码获取状态码描述
    status_line += LINE_END;

    //构建响应报头
    std::string path = WEB_ROOT;
    path += "/";
    switch(code){
        case OK:
            BuildOkResponse();
            break;
        case NOT_FOUND:
            path += PAGE_404;
            HandlerError(path);
            break;
        case BAD_REQUEST:
            path += PAGE_400;
            HandlerError(path);
            break;
        case INTERNAL_SERVER_ERROR:
            path += PAGE_500;
            HandlerError(path);
            break;
        default:
            break;
    }
}

发送HTTP响应

发送HTTP响应的步骤如下:

  • 调用send函数,依次发送状态行、响应报头和空行。
  • 发送响应正文时需要判断本次请求的处理方式,如果本次请求是以CGI方式成功处理的,那么待发送的响应正文是保存在HTTP响应类的response_body中的,此时调用send函数进行发送即可。
  • 如果本次请求是以非CGI方式处理或在处理过程中出错的,那么待发送的资源文件或错误页面文件对应的文件描述符是保存在HTTP响应类的fd中的,此时调用sendfile进行发送即可,发送后关闭对应的文件描述符。
// 发送响应
bool SendHttpResponse()
{
    //发送状态行
    if(send(_sock, _http_response._status_line.c_str(), _http_response._status_line.size(), 0) <= 0)
    {
        _stop = true; //发送失败,设置_stop
    }
    //发送响应报头
    if(!_stop){
        for(auto& iter : _http_response._response_header)
        {
            if(send(_sock, iter.c_str(), iter.size(), 0) <= 0)
            {
                _stop = true; //发送失败,设置_stop
                break;
            }
        }
    }
    //发送空行
    if(!_stop)
    {
        if(send(_sock, _http_response._blank.c_str(), _http_response._blank.size(), 0) <= 0)
        {
            _stop = true; //发送失败,设置_stop
        }
    }
    //发送响应正文
    if(_http_request._cgi)
    {
        if(!_stop)
        {
            auto& response_body = _http_response._response_body;
            const char* start = response_body.c_str();
            size_t size = 0;
            size_t total = 0;
            while(total < response_body.size()&&(size = send(_sock, start + total, response_body.size() - total, 0)) > 0){
                total += size;
            }
        }
    }
    else
    {
        if(!_stop)
        {
            // sendfile:这是一个系统调用,用于高效地从文件传输数据到套接字中。它避免了在内核空间和用户空间之间复制数据的需求,从而实现更快的数据传输。
            if(sendfile(_sock, _http_response._fd, nullptr, _http_response._size) <= 0)
            {
                _stop = true; //发送失败,设置_stop
            }
        }
        //关闭请求的资源文件
        close(_http_response._fd);
    }
    return _stop;
}

接入线程池

当前多线程版服务器存在的问题:

  • 每当获取到新连接时,服务器主线程都会重新为该客户端创建为其提供服务的新线程,而当服务结束后又会将该新线程销毁,这样做不仅麻烦,而且效率低下。
  • 如果同时有大量的客户端连接请求,此时服务器就要为每一个客户端创建对应的服务线程,而计算机中的线程越多,CPU压力就越大,因为CPU要不断在这些线程之间来回切换。此外,一旦线程过多,每一个线程再次被调度的周期就变长了,而线程是为客户端提供服务的,线程被调度的周期变长,客户端也就迟迟得不到应答。

考虑接入线程池简单优化下(其实也可以直接上epoll)

  • 在服务器端预先创建一批线程和一个任务队列,每当获取到一个新连接时就将其封装成一个任务对象放到任务队列当中。
  • 线程池中的若干线程就不断从任务队列中获取任务进行处理,如果任务队列当中没有任务则线程进入休眠状态,当有新任务时再唤醒线程进行任务处理。
#define NUM 6

//线程池
class ThreadPool{
    private:
        std::queue<Task> _task_queue; //任务队列
        int _num;                     //线程池中线程的个数
        pthread_mutex_t _mutex;       //互斥锁
        pthread_cond_t _cond;         //条件变量
        static ThreadPool* _inst;     //指向单例对象的static指针
    private:
        //构造函数私有
        ThreadPool(int num = NUM)
            :_num(num)
        {
            //初始化互斥锁和条件变量
            pthread_mutex_init(&_mutex, nullptr);
            pthread_cond_init(&_cond, nullptr);
        }
        // 删除拷贝构造函数(防拷贝)
        ThreadPool(const ThreadPool&)=delete;

        //判断任务队列是否为空
        bool IsEmpty()
        {
            return _task_queue.empty();
        }

        //任务队列加锁
        void LockQueue()
        {
            pthread_mutex_lock(&_mutex);
        }
        
        //任务队列解锁
        void UnLockQueue()
        {
            pthread_mutex_unlock(&_mutex);
        }

        //让线程在条件变量下进行等待
        void ThreadWait()
        {
            pthread_cond_wait(&_cond, &_mutex);
        }
        
        //唤醒在条件变量下等待的一个线程
        void ThreadWakeUp()
        {
            pthread_cond_signal(&_cond);
        }

    public:
        //获取单例对象
        static ThreadPool* GetInstance()
        {
            static pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER; //定义静态的互斥锁
            //双检查加锁
            if(_inst == nullptr){
                pthread_mutex_lock(&mtx); //加锁
                if(_inst == nullptr){
                    //创建单例线程池对象并初始化
                    _inst = new ThreadPool();
                    _inst->InitThreadPool();
                }
                pthread_mutex_unlock(&mtx); //解锁
            }
            return _inst; //返回单例对象
        }

        //线程的执行例程
        static void* ThreadRoutine(void* arg)
        {
            pthread_detach(pthread_self()); //线程分离
            ThreadPool* tp = (ThreadPool*)arg;
            while(true){
                tp->LockQueue(); //加锁
                while(tp->IsEmpty()){
                    //任务队列为空,线程进行wait
                    tp->ThreadWait();
                }
                Task task;
                tp->PopTask(task); //获取任务
                tp->UnLockQueue(); //解锁

                task.ProcessOn(); //处理任务
            }
        }
        
        //初始化线程池
        bool InitThreadPool()
        {
            //创建线程池中的若干线程
            pthread_t tid;
            for(int i = 0;i < _num;i++){
                if(pthread_create(&tid, nullptr, ThreadRoutine, this) != 0){
                    LOG(FATAL, "create thread pool error!");
                    return false;
                }
            }
            LOG(INFO, "create thread pool success");
            return true;
        }
        
        //将任务放入任务队列
        void PushTask(const Task& task)
        {
            LockQueue();    //加锁
            _task_queue.push(task); //将任务推入任务队列
            UnLockQueue();  //解锁
            ThreadWakeUp(); //唤醒一个线程进行任务处理
        }

        //从任务队列中拿任务
        void PopTask(Task& task)
        {
            //获取任务
            task = _task_queue.front();
            _task_queue.pop();
        }

        ~ThreadPool()
        {
            //释放互斥锁和条件变量
            pthread_mutex_destroy(&_mutex);
            pthread_cond_destroy(&_cond);
        }
};
//单例对象指针初始化为nullptr
ThreadPool* ThreadPool::_inst = nullptr;

简单测试

默认页面测试:

image-20230311134104523

带query_string,CGI传参测试:

image-20230311134047088

项目扩展

当前项目的重点在于HTTP服务器后端的处理逻辑,主要完成的是GET和POST请求方法,以及CGI机制的搭建。还可以进行不少扩展,比如:

  • 当前项目编写的是HTTP1.0版本的服务器,每次连接都只会对一个请求进行处理,当服务器对客户端的请求处理完毕并收到客户端的应答后,就会直接断开连接。可以将其扩展为HTTP1.1版本,让服务器支持长连接,即通过一条连接可以对多个请求进行处理,避免重复建立连接(涉及连接管理)。
  • 当前项目虽然在后端接入了线程池,但是效果有限,可以将线程池换成epoll版本,让服务器的IO变得更高效。
  • 可以给当前的HTTP服务器新增代理功能,也就是可以替代客户端去访问某种服务,然后将访问结果再返回给客户端(比如课题中的数据备份、数据计算等等)。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.kler.cn/a/1310.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

带你了解Redis及安装Redis的全过程

文章目录Redis是什么&#xff1f;官网介绍与传统的数据库的区别优势Redis下载安装Redis①配置gcc②开始安装redisRedis是什么&#xff1f; Redis&#xff1a;REmote Dictionary Server&#xff08;远程字典服务&#xff09;基于内存的Key—Value键值对内存数据库 官网介绍 R…

长肥网络与TCP的长肥管道

本文目录1、简化的理解网络模型2、时延带宽积的定义3、长肥网络与TCP长肥管道的定义4、TCP长肥管道的特征及对TCP性能的影响4.1、TCP长肥管道的二大特征&#xff1a;4.2、TCP长肥管道对TCP性能的影响&#xff1a;5、TCP长肥管道中如何正确设置iperf3的参数从文章ip网络的时延&a…

漫画:什么是选择排序?

选择排序是一种简单直观的算法&#xff0c;今天我们聊聊选择排序的思想&#xff0c;代码以及复杂度 排序思想 一天&#xff0c;小一尘和师傅下山去了&#xff0c;在集市中路经一个水果摊&#xff0c;只见水果摊上摆着色泽基本相同但大小不一的苹果 师傅答应后&#xff0c;小一…

比df更好用的命令!

大家好&#xff0c;我是良许。 对于分析磁盘使用情况&#xff0c;有两个非常好用的命令&#xff1a;du 和 df 。简单来说&#xff0c;这两个命令的作用是这样的&#xff1a; du 命令&#xff1a;它是英文单词 disk usage 的简写&#xff0c;主要用于查看文件与目录占用多少磁…

一行代码“黑”掉任意网站

文章目录只需一行代码&#xff0c;轻轻一点就可以把任意网站变成暗黑模式。 首先我们先做一个实验&#xff0c;在任意网站中&#xff0c;打开浏览器开发者工具(F12)&#xff0c;在 C1onsole 控制台输入如下代码并回车&#xff1a; document.documentElement.style.filterinve…

没有关系的话,那就去建立关系吧

今天给大家分享一道链表的好题--链表的深度拷贝&#xff0c;学会这道题&#xff0c;你的链表就可以达到优秀的水平了。力扣 先来理解一下题目意思&#xff0c;即建立一个新的单向链表&#xff0c;里面每个结点的值与对应的原链表相同&#xff0c;并且random指针也要指向新链表中…

【宝塔面板部署nodeJs项目】网易云nodeJs部署在云服务器上,保姆级教程,写网易云接口用自己的接口不受制于人

看了很多部署的&#xff0c;要么少步骤&#xff0c;要么就是写的太简洁&#xff0c;对新手不友好 文章目录前言一、下载网易云nodejs项目1. git clone下载&#xff0c;两种方式2. 运行项目二、使用步骤1. 先在本地运行2.测试接口三、部署服务器1. 在宝塔面板安装pm2管理器2. 压…

第一个 Qt 程序

第一个 Qt 程序 “hello world ”的起源要追溯到 1972 年&#xff0c;贝尔实验室著名研究员 Brian Kernighan 在撰写 “B 语言教程与指导(Tutorial Introduction to the Language B)”时初次使用&#xff08;程序&#xff09;&#xff0c;这是目前已 知最早的在计算机著作中将…

【linux】多线程概念详述

文章目录一、线程基本概念1.1 进程地址空间与页表1.2 页表结构1.3 线程的理解1.3.1 如何描述线程1.4 再谈进程1.5 代码理解1.5.1 原生库提供线程pthread_create1.6 资源共享问题1.7 资源私有问题二、总结2.1 什么是线程2.2 并行与并发2.3 线程的优点2.4 线程的缺点2.5 线程异常…

OpenCV实战——拟合直线

OpenCV实战——拟合直线0. 前言1. 直线拟合2. 完整代码相关链接0. 前言 在某些计算机视觉应用中&#xff0c;不仅要检测图像中的线条&#xff0c;还要准确估计线条的位置和方向。本节将介绍如何找到最适合给定点集的线。 1. 直线拟合 首先要做的是识别图像中可能沿直线对齐的…

7个最受欢迎的Python库,大大提高开发效率

当第三方库可以帮我们完成需求时&#xff0c;就不要重复造轮子了 整理了GitHub上7个最受好评的Python库&#xff0c;将在你的开发之旅中提供帮助 PySnooper 很多时候时间都花在了Debug上&#xff0c;大多数人呢会在出错位置的附近使用print&#xff0c;打印某些变量的值 这个…

设计模式之单例模式~

设计模式包含很多&#xff0c;但与面试相关的设计模式是单例模式&#xff0c;单例模式的写法有好几种&#xff0c;我们主要学习这三种—饿汉式单例&#xff0c;懒汉式单例、登记式单例,这篇文章我们主要学习饿汉式单例 单例模式&#xff1a; 满足要点&#xff1a; 私有构造 …

同一片天空共眠,同一个梦想奋斗《大抠车始歌》(1)

同一片天空共眠&#xff0c;同一个梦想奋斗《大抠车始歌》&#xff08;1&#xff09; English version&#xff1a;Sleeping under the same sky, chasing the same dream - "The Beginning Song of Dakouche" (1) 飞链云 《Sleeping under the same sky, chasing …

Nacos 注册中心 - 健康检查机制源码

目录 1. 健康检查介绍 2. 客户端健康检查 2.1 临时实例的健康检查 2.2 永久实例的健康检查 3. 服务端健康检查 3.1 临时实例的健康检查 3.2 永久实例服务端健康检查 1. 健康检查介绍 当一个服务实例注册到 Nacos 中后&#xff0c;其他服务就可以从 Nacos 中查询出该服务…

jupyter的安装和使用

目录 ❤ Jupyter Notebook是什么&#xff1f; notebook jupyter 简介 notebook jupyter 组成 网页应用 文档 主要特点 ❤ jupyter notebook的安装 notebook jupyter 安装有两种途径 1.通过Anaconda进行安装 2.通过pip进行安装 启动jupyter notebook ❤ jupyter …

10 个超赞的 C 语言开源项目

今天给大家分享10个超赞的C语言开源项目&#xff0c;希望这些内容能对大家有所帮助&#xff01;01.WebbenchWebbench是一个在 Linux 下使用的非常简单的网站压测工具。它使用fork()模拟多个客户端同时访问我们设定的URL&#xff0c;测试网站在压力下工作的性能。最多可以模拟 3…

【深度强化学习】(8) iPPO 模型解析,附Pytorch完整代码

大家好&#xff0c;今天和各位分享一下多智能体深度强化学习算法 ippo&#xff0c;并基于 gym 环境完成一个小案例。完整代码可以从我的 GitHub 中获得&#xff1a;https://github.com/LiSir-HIT/Reinforcement-Learning/tree/main/Model 1. 算法原理 多智能体的情形相比于单智…

《C++ Primer Plus》(第6版)第12章编程练习

《C Primer Plus》&#xff08;第6版&#xff09;第12章编程练习《C Primer Plus》&#xff08;第6版&#xff09;第12章编程练习1. Cow类2. String类3. Stock类4. Stack类5. 排队时间不超过1分钟6. 再开设一台ATM&#xff0c;重新求解第五题《C Primer Plus》&#xff08;第6版…

JAVA 多线程

目录 P1多线程01&#xff1a;概述 P2多线程02&#xff1a;线程、进程、多线程 P3多线程03&#xff1a;继承Thread类 P4多线程04&#xff1a;网图下载 P5多线程05&#xff1a;实现Runnable接口 P6多线程06&#xff1a;初识并发问题 P7多线程07&#xff1a;龟兔赛跑 P8多…

PyTorch深度学习实战 | 搭建卷积神经网络进行图像分类与图像风格迁移

PyTorch是当前主流深度学习框架之一&#xff0c;其设计追求最少的封装、最直观的设计&#xff0c;其简洁优美的特性使得PyTorch代码更易理解&#xff0c;对新手非常友好。本文为实战篇&#xff0c;介绍搭建卷积神经网络进行图像分类与图像风格迁移。1、实验数据准备本文中准备使…
最新文章