自主实现HTTP

目录

项目背景

项目介绍

网络协议栈介绍

协议分层

数据的封装和分用

日志编写

日志格式

socket套接字

HTTP服务器主体逻辑

主函数代码

HTTP请求结构设计

HTTP响应结构设计 

EndPoint类编写

EndPoint结构设计

线程回调设计

读取HTTP请求

读取请求行

读取请求报头和空行

解析请求行

解析请求报头

读取请求正文

处理HTTP请求

定义状态码

处理HTTP请求

注意:

CGI处理

什么是CGI机制?

非CGI处理

构建HTTP响应

构建HTTP响应

构建响应报头(处理正常请求)

构建响应报头(处理错误请求)

发送HTTP响应

差错处理

逻辑错误

读取错误

写入错误

接入线程池

任务设计

编写任务回调

编写线程池

设计线程池结构

项目扩展

技术层面的扩展

应用层面的扩展


项目背景

        目前主流的服务器协议是Http/1.1,而我们这次要实现的是1.0,其主要的特点就是短链接,所谓短链接,就是请求,响应,客户端关闭连接,这样就完成了一次http请求,使用其主要的原因是因为其简单。

        Http/1.0版本的特征:

        1、简单快速,HTTP服务器的程序规模小,因而通信速度很快。

        2、灵活,HTTP允许传输任意类型的数据对象,正在传输的类型由Content-Type加以标记。

        3、无连接,每次连接只处理一个请求。服务器处理完客户的请求,并收到客户的应答后,即断开连接。采用这种方式可以节省传输时间。

众所周知,HTTP的底层是基于TCP的,而TCP是面向连接的,为什么还说HTTP是无连接的呢?

答:这个地方可以这么理解,有链接是TCP的概念,而HTTP对于连接是没有感知的,他只是把我的请求信息填写好之后,交给下层协议,而对端的HTTP对于连接也是没有概念的,只是把我的相应信息写好之后交给我的下层协议,而下层协议怎么做,那是下层协议的工作,和我无关,所以对我们来讲,所谓的无连接,就是相当于对比TCP的,因为连接是TCP已经帮我们做了,HTTP就不关心了。

        4.无状态,本身是不会记录对方任何状态。 http/1.1 虽然也是无状态的协议,但是为了保持状态的功能,引入了 cookie 技术。

我们登录过CSDN或者抖音后,退出页面再登录不需要进行身份认证。但是HTTP本身是无状态的,所以这部分并不是HTTP层来完成的。

项目介绍

        本项目实现的是一个HTTP服务器,项目中将会通过基本的网络套接字读取客户端发来的HTTP请求并进行分析,最终构建HTTP响应并返回给客户端。

        HTTP在网络应用层中的地位是不可撼动的,无论是移动端还是PC端浏览器,HTTP无疑是打开互联网应用窗口的重要协议。

        该项目采用C/S(BS)模型,实现支持较为小型应用的http服务。做完项目后可以明显的对打开浏览器上网、关闭浏览器中,很多技术上细节的实现这样的概况有一定的了解。

        该项目主要涉及C/C++、HTTP协议、网络套接字编程、CGI、单例模式、多线程、线程池等方面的技术。

网络协议栈介绍

协议分层

网络协议栈的分层情况:

网络协议栈中各层的功能如下:

1、应用层:根据特定的通信目的,对数据进行分析处理,以达到某种业务性的目的。

2、传输层:处理传输时遇到的问题,主要是保证数据传输的可靠性。

3、网络层:完成数据的转发,解决数据去哪里的问题。

4、链路层:负责数据真正的发生过程。

数据的封装和分用

数据的封装和分用的过程如下:

  也就是说,发送端在发生数据前,该数据需要先自顶向下贯穿网络协议栈完成数据的封装,在这个过程中,每一层协议都会为该数据添加上对应的报头信息。接收端在收到数据后,该数据需要先自底向上贯穿网络协议栈完成数据的解包和分用,在这个过程中,每一层协议都会将对应的报头信息提取出来。

  而本项目要做的就是,在接收到客户端发来的HTTP请求后,将HTTP的报头信息提取出来,然后对数据进行分析处理,最终将处理结果添加上HTTP报头再发送给客户端。

  需要注意的是,该项目中我们所处的位置是应用层,因此我们读取的HTTP请求实际是从传输层读取上来的,而我们发送的HTTP响应实际也只是交给了传输层,数据真正的发送还得靠网络协议栈中的下三层来完成,这里直接说“接收到客户端的HTTP请求”以及“发送HTTP响应给客户端”,只是为了方便大家理解,此外,同层协议之间本身也是可以理解成是在直接通信的。

日志编写

        服务器在运作时会产生一些日志,这些日志会记录下服务器运行过程中产生的一些事件。

日志格式

日志说明:

  • 日志级别: 分为四个等级,从低到高依次是INFO、WARNING、ERROR、FATAL。
  • 时间戳: 事件产生的时间。
  • 日志信息: 事件产生的日志信息。
  • 错误文件名称: 事件在哪一个文件产生。
  • 行数: 事件在对应文件的哪一行产生。

日志级别说明: 

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

#include <iostream>
#include <string>
#include <ctime>

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

#define LOG(level, message) Log(#level, message, __FILE__, __LINE__)

void Log(std::string level, std::string message, std::string file_name, int line)
{
    std::cout << "[" << level << "]" << "[" << time(nullptr) << "]" << "[" << message << "]" << "[" << file_name << "]" << "[" << line << "]" << std::endl;
}

        为避免手动传入参数,我们使用的LOG宏函数,LOG的参数level在预处理后变成数字,为了保证其成为子串在前面加"#"即可。

socket套接字

  我们可以将套接字相关的代码封装到TcpServer类中,在初始化TcpServer对象时完成套接字的创建、绑定和监听动作,并向外提供一个Sock接口用于获取监听套接字。同时,将TcpServer设置成单例模式。

#pragma once

#include <iostream>
#include <cstdlib>
#include <cstring>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <pthread.h>
#include "Log.hpp"

#define BACKLOG 5

class TcpServer {
private:
    int port;
    int listen_sock;
    static TcpServer* svr;
private:
    TcpServer(int _port) :port(_port), listen_sock(-1)
    {}
    TcpServer(const TcpServer& s) {}
public:
    static TcpServer* getinstance(int port)
    {
        static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
        if (nullptr == svr) {
            pthread_mutex_lock(&lock);
            if (nullptr == svr) {
                svr = new TcpServer(port);
                svr->InitServer();
            }
            pthread_mutex_unlock(&lock);
        }
        return svr;
    }
    void InitServer()
    {
        Socket();
        Bind();
        Listen();
        LOG(INFO, "tcp_server 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;
        // 允许端口重用
        setsockopt(listen_sock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
        LOG(INFO, "create socket ... 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; //云服务器不能直接绑定公网IP

        if (bind(listen_sock, (struct sockaddr*)&local, sizeof(local)) < 0) {
            LOG(FATAL, "bind error!");
            exit(2);
        }
        LOG(INFO, "bind socket ... success");
    }
    void Listen()
    {
        if (listen(listen_sock, BACKLOG) < 0) {
            LOG(FATAL, "listen socket error!");
            exit(3);
        }
        LOG(INFO, "listen socket ... success");
    }
    int Sock()
    {
        return listen_sock;
    }
    ~TcpServer()
    {
        if (listen_sock >= 0) close(listen_sock);
    }
};

TcpServer* TcpServer::svr = nullptr;

注意:

        如果使用的是云服务器,那么在设置服务器的IP地址时,不需要显式绑定IP地址,直接将IP地址设置为INADDR_ANY即可,此时服务器就可以从本地任何一张网卡当中读取数据。此外,由于INADDR_ANY本质就是0,因此在设置时不需要进行网络字节序列的转换。

        在第一次调用GetInstance获取单例对象时需要创建单例对象,这时需要定义一个锁来保证线程安全,代码中以PTHREAD_MUTEX_INITIALIZER方式定义的静态锁是不需要释放的,同时为了保证后续调用GetInstance获取单例对象时不会频繁的加锁解锁,因此代码中以双检查的方式进行加锁。

HTTP服务器主体逻辑

  我们可以将HTTP服务器封装成一个HttpServer类,在构造HttpServer对象时传入一个端口号,之后就可以调用Loop让服务器运行起来了。服务器运行起来后要做的就是,先获取单例对象TcpServer中的监听套接字,然后不断从监听套接字中获取新连接,每当获取到一个新连接后就创建一个新线程为该连接提供服务。

#define PORT 8081

//HTTP服务器
class HttpServer{
    private:
        int _port; //端口号
    public:
        HttpServer(int port)
            :_port(port)
        {}

        //启动服务器
        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)+"]");
                
                //创建新线程处理新连接发起的HTTP请求
                int* p = new int(sock);
                pthread_t tid;
                pthread_create(&tid, nullptr, CallBack::HandlerRequest, (void*)p);
                pthread_detach(tid); //线程分离
            }
        }
        ~HttpServer()
        {}
};

主函数代码

  运行服务器时要求指定服务器的端口号,我们用这个端口号创建一个HttpServer对象,然后调用Loop函数运行服务器,此时服务器就会不断获取新连接并创建新线程来处理连接。

static void Usage(std::string proc)
{
    std::cout<<"Usage:\n\t"<<proc<<" port"<<std::endl;
}
int main(int argc, char* argv[])
{
    if(argc != 2){
        Usage(argv[0]);
        exit(4);
    }
    int port = atoi(argv[1]); //端口号
    std::shared_ptr<HttpServer> svr(new HttpServer(port)); //创建HTTP服务器对象
    svr->Loop(); //启动服务器
    return 0;
}

HTTP请求结构设计

HTTP请求协议格式:

  • 请求行:[请求方法] + [URI] + [HTTP版本]。
  • 请求报头:请求的属性,这些属性都是以key: value的形式按行陈列的。
  • 空行:遇到空行表示请求报头结束。
  • 请求正文:请求正文允许为空字符串,如果请求正文存在,则在请求报头中会有一个Content-Length属性来标识请求正文的长度。

  我们可以将HTTP请求封装成一个类,这个类当中包括HTTP请求的内容、HTTP请求的解析结果以及是否需要使用CGI模式的标志位。后续处理请求时就可以定义一个HTTP请求类,读取到的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; //请求报头中的键值对
        int _content_length;       //正文长度
        std::string _path;         //请求资源的路径
        std::string _query_string; //uri中携带的参数

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

HTTP响应结构设计 

HTTP响应协议格式:

  • 状态行:[HTTP版本] + [状态码] + [状态码描述]。
  • 响应报头:响应的属性,这些属性都是以key: value的形式按行陈列的。
  • 空行:遇到空行表示响应报头结束。
  • 响应正文:响应正文允许为空字符串,如果响应正文存在,则在响应报头中会有一个Content-Length属性来标识响应正文的长度。

  HTTP响应也可以封装成一个类,这个类当中包括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相关)

        //所需数据
        int _status_code;    //状态码
        int _fd;             //响应文件的fd  (非CGI相关)
        int _size;           //响应文件的大小(非CGI相关)
        std::string _suffix; //响应文件的后缀(非CGI相关)
    public:
        HttpResponse()
            :_blank(LINE_END) //设置空行
            ,_status_code(OK) //状态码默认为200
            ,_fd(-1)          //响应文件的fd初始化为-1
            ,_size(0)         //响应文件的大小默认为0
        {}
        ~HttpResponse()
        {}
};

EndPoint类编写

EndPoint结构设计

  EndPoint这个词经常用来描述进程间通信,比如在客户端和服务器通信时,客户端是一个EndPoint,服务器则是另一个EndPoint,因此这里将处理请求的类取名为EndPoint。

//服务端EndPoint
class EndPoint{
    private:
        int _sock;                   //通信的套接字
        HttpRequest _http_request;   //HTTP请求
        HttpResponse _http_response; //HTTP响应
    public:
        EndPoint(int sock)
            :_sock(sock)
        {}
        //读取请求
        void RecvHttpRequest();
        //处理请求
        void HandlerHttpRequest();
        //构建响应
        void BuildHttpResponse();
        //发送响应
        void SendHttpResponse();
        ~EndPoint()
        {}
};

线程回调设计

  服务器每获取到一个新连接就会创建一个新线程来进行处理,而这个线程要做的实际就是定义一个EndPoint对象,然后依次进行读取请求、处理请求、构建响应、发送响应,处理完毕后将与客户端建立的套接字关闭即可。

class CallBack{
    public:
        static void* HandlerRequest(void* arg)
        {
            LOG(INFO, "handler request begin");
            int sock = *(int*)arg;
            
            EndPoint* ep = new EndPoint(sock);
            ep->RecvHttpRequest();    //读取请求
            ep->HandlerHttpRequest(); //处理请求
            ep->BuildHttpResponse();  //构建响应
            ep->SendHttpResponse();   //发送响应

            close(sock); //关闭与该客户端建立的套接字
            delete ep;

            LOG(INFO, "handler request end");
            return nullptr;
        }
};

读取HTTP请求

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

//服务端EndPoint
class EndPoint{
    private:
        int _sock;                   //通信的套接字
        HttpRequest _http_request;   //HTTP请求
        HttpResponse _http_response; //HTTP响应
    public:
        //读取请求
        void RecvHttpRequest()
        {
            RecvHttpRequestLine();    //读取请求行
            RecvHttpRequestHeader();  //读取请求报头和空行
            ParseHttpRequestLine();   //解析请求行
            ParseHttpRequestHeader(); //解析请求报头
            RecvHttpRequestBody();    //读取请求正文
        }
};
读取请求行

        读取请求行就是从套接字中读取一行内容存储到HTTP请求类中的request_line中。

//服务端EndPoint
class EndPoint{
    private:
        int _sock;                   //通信的套接字
        HttpRequest _http_request;   //HTTP请求
        HttpResponse _http_response; //HTTP响应
    private:
        //读取请求行
        void RecvHttpRequestLine()
        {
            auto& line = _http_request._request_line;
            if(Util::ReadLine(_sock, line) > 0){
                line.resize(line.size() - 1); //去掉读取上来的\n
            }
        }
};

  需要注意的是,这里在按行读取HTTP请求时,不能直接使用C/C++提供的gets或getline函数进行读取,因为不同平台下的行分隔符可能是不一样的,可能是\r、\n或者\r\n。

  因此我们这里需要自己写一个ReadLine函数,以确保能够兼容这三种行分隔符。我们可以把这个函数写到一个工具类当中,后续编写的处理字符串的函数也都写到这个类当中。

ReadLine函数的处理逻辑如下:

从指定套接字中读取一个个字符。
如果读取到的字符既不是\n也不是\r,则将读取到的字符push到用户提供的缓冲区后继续读取下一个字符。
如果读取到的字符是\n,则说明行分隔符是\n,此时将\n push到用户提供的缓冲区后停止读取。
如果读取到的字符是\r,则需要继续窥探下一个字符是否是\n,如果窥探成功则说明行分隔符为\r\n,此时将未读取的\n读取上来后,将\n push到用户提供的缓冲区后停止读取;如果窥探失败则说明行分隔符是\r,此时也将\n push到用户提供的缓冲区后停止读取。
  也就是说,无论是哪一种行分隔符,最终读取完一行后我们都把\npush到了用户提供的缓冲区当中,相当于将这三种行分隔符统一转换成了以\n为行分隔符,只不过最终我们把\n一同读取到了用户提供的缓冲区中罢了,因此如果调用者不需要读取上来的\n,需要后续自行将其去掉。

//工具类
class Util {
public:
    static int ReadLine(int sock, std::string& out)
    {
        char ch = 'X';
        while (ch != '\n') {
            ssize_t s = recv(sock, &ch, 1, 0);
            if (s > 0) {
                if (ch == '\r') {
                    recv(sock, &ch, 1, MSG_PEEK);
                    if (ch == '\n') {
                        //把\r\n->\n
                        //窥探成功,这个字符一定存在
                        recv(sock, &ch, 1, 0);
                    }
                    else {
                        ch = '\n';
                    }
                }
                //1. 普通字符
                //2. \n
                out.push_back(ch);
            }
            else if (s == 0) {
                return 0;
            }
            else {
                return -1;
            }
        }
        return out.size();
    }
}

注意:recv函数的最后一个参数如果设置为MSG_PEEK,那么recv函数将返回TCP接收缓冲区头部指定字节个数的数据,但是并不把这些数据从TCP接收缓冲区中取走,这个叫做数据的窥探功能。 

读取请求报头和空行

  由于HTTP的请求报头和空行都是按行陈列的,因此可以循环调用ReadLine函数进行读取,并将读取到的每行数据都存储到HTTP请求类的request_header中,直到读取到空行为止。

//服务端EndPoint
class EndPoint{
    private:
        int _sock;                   //通信的套接字
        HttpRequest _http_request;   //HTTP请求
        HttpResponse _http_response; //HTTP响应
    private:
        //读取请求报头和空行
        void RecvHttpRequestHeader()
        {
            std::string line;
            while(true){
                line.clear(); //每次读取之前清空line
                Util::ReadLine(_sock, line);
                if(line == "\n"){ //读取到了空行
                    _http_request._blank = line;
                    break;
                }
                //读取到一行请求报头
                line.resize(line.size() - 1); //去掉读取上来的\n
                _http_request._request_header.push_back(line);
            }
        }
};
解析请求行

  解析请求行要做的就是将请求行中的请求方法、URI和HTTP版本号拆分出来,依次存储到HTTP请求类的method、uri和version中,由于请求行中的这些数据都是以空格作为分隔符的,因此可以借助一个stringstream对象来进行拆分。此外,为了后续能够正确判断用户的请求方法,这里需要通过transform函数统一将请求方法转换为全大写。

//服务端EndPoint
class EndPoint{
    private:
        int _sock;                   //通信的套接字
        HttpRequest _http_request;   //HTTP请求
        HttpResponse _http_response; //HTTP响应
    private:
        //解析请求行
        void ParseHttpRequestLine()
        {
            auto& line = _http_request._request_line;

            //通过stringstream拆分请求行
            std::stringstream ss(line);
            ss>>_http_request._method>>_http_request._uri>>_http_request._version;

            //将请求方法统一转换为全大写
            auto& method = _http_request._method;
            std::transform(method.begin(), method.end(), method.begin(), toupper);
        }
};
解析请求报头

  解析请求报头要做的就是将读取到的一行一行的请求报头,以":"为分隔符拆分成一个个的键值对存储到HTTP请求的header_kv中,后续就可以直接通过属性名获取到对应的值了。

#define SEP ": "

//服务端EndPoint
class EndPoint{
    private:
        int _sock;                   //通信的套接字
        HttpRequest _http_request;   //HTTP请求
        HttpResponse _http_response; //HTTP响应
    private:
        //解析请求报头
        void ParseHttpRequestHeader()
        {
            std::string key;
            std::string value;
            for(auto& iter : _http_request._request_header){
                //将每行请求报头打散成kv键值对,插入到unordered_map中
                if(Util::CutString(iter, key, value, SEP)){
                    _http_request._header_kv.insert({key, value});
                }
            }
        }
};

   此处用于切割字符串的CutString函数也可以写到工具类中,切割字符串时先通过find方法找到指定的分隔符,然后通过substr提取切割后的子字符串即可。

//工具类
class Util{
    public:
        //切割字符串
        static bool CutString(std::string& target, std::string& sub1_out, std::string& sub2_out, std::string sep)
        {
            size_t pos = target.find(sep, 0);
            if(pos != std::string::npos){
                sub1_out = target.substr(0, pos);
                sub2_out = target.substr(pos + sep.size());
                return true;
            }
            return false;
        }
};
读取请求正文

  在读取请求正文之前,首先需要通过本次的请求方法来判断是否需要读取请求正文,因为只有请求方法是POST方法才可能会有请求正文,此外,如果请求方法为POST,我们还需要通过请求报头中的Content-Length属性来得知请求正文的长度。

  在得知需要读取请求正文以及请求正文的长度后,就可以将请求正文读取到HTTP请求类的request_body中了。

//服务端EndPoint
class EndPoint{
    private:
        int _sock;                   //通信的套接字
        HttpRequest _http_request;   //HTTP请求
        HttpResponse _http_response; //HTTP响应
    private:
        //判断是否需要读取请求正文
        bool IsNeedRecvHttpRequestBody()
        {
            auto& method = _http_request._method;
            if(method == "POST"){ //请求方法为POST则需要读取正文
                auto& header_kv = _http_request._header_kv;
                //通过Content-Length获取请求正文长度
                auto iter = header_kv.find("Content-Length");
                if(iter != header_kv.end()){
                    _http_request._content_length = atoi(iter->second.c_str());
                    return true;
                }
            }
            return false;
        }
        //读取请求正文
        void RecvHttpRequestBody()
        {
            if(IsNeedRecvHttpRequestBody()){ //先判断是否需要读取正文
                int content_length = _http_request._content_length;
                auto& body = _http_request._request_body;

                //读取请求正文
                char ch = 0;
                while(content_length){
                    ssize_t size = recv(_sock, &ch, 1, 0);
                    if(size > 0){
                        body.push_back(ch);
                        content_length--;
                    }
                    else{
                        break;
                    }
                }
            }
        }
};

由于后续还会用到请求正文的长度,因此代码中将其存储到了HTTP请求类的content_length中。

在通过Content-Length获取到请求正文的长度后,需要将请求正文长度从字符串类型转换为整型。

处理HTTP请求

定义状态码

  在处理请求的过程中可能会因为某些原因而直接停止处理,比如请求方法不正确、请求资源不存在或服务器处理请求时出错等等。为了告知客户端本次HTTP请求的处理情况,服务器需要定义不同的状态码,当处理请求被终止时就可以设置对应的状态码,后续构建HTTP响应的时候就可以根据状态码返回对应的错误页面。

#define OK 200
#define BAD_REQUEST 400
#define NOT_FOUND 404
#define INTERNAL_SERVER_ERROR 500
处理HTTP请求

处理HTTP请求的步骤如下:

1、判断请求方法是否是正确,如果不正确则设置状态码为BAD_REQUEST后停止处理。

2、如果请求方法为GET方法,则需要判断URI中是否带参。如果URI不带参,则说明URI即为客户端请求的资源路径;如果URI带参,则需要以?为分隔符对URI进行字符串切分,切分后?左边的内容就是客户端请求的资源路径,而?右边的内容则是GET方法携带的参数,由于此时GET方法携带了参数,因此后续处理需要使用CGI模式,于是需要将HTTP请求类中的cgi设置为true。

3、如果请求方法为POST方法,则说明URI即为客户端请求的资源路径,由于POST方法会通过请求正文上传参数,因此后续处理需要使用CGI模式,于是需要将HTTP请求类中的cgi设置为true。

4、接下来需要对客户端请求的资源路径进行处理,首先需要在请求的资源路径前拼接上web根目录,然后需要判断请求资源路径的最后一个字符是否是/,如果是则说明客户端请求的是一个目录,这时服务器不会将该目录下全部的资源都返回给客户端,而是默认将该目录下的index.html返回给客户端,因此这时还需要在请求资源路径的后面拼接上index.html。

5、对请求资源的路径进行处理后,需要通过stat函数获取客户端请求资源文件的属性信息。如果客户端请求的是一个目录,则需要在请求资源路径的后面拼接上/index.html并重新获取资源文件的属性信息;如果客户端请求的是一个可执行程序,则说明后续处理需要使用CGI模式,于是需要将HTTP请求类中的cgi设置为true。

6、根据HTTP请求类中的cgi分别进行CGI或非CGI处理。

#define WEB_ROOT "wwwroot"
#define HOME_PAGE "index.html"

//服务端EndPoint
class EndPoint{
    private:
        int _sock;                   //通信的套接字
        HttpRequest _http_request;   //HTTP请求
        HttpResponse _http_response; //HTTP响应
    public:
        //处理请求
        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;
            }

            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, "?");
                    _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{
                //Do Nothing
            }

            //给请求资源路径拼接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] == '/'){
                //拼接上该目录下的index.html
                _http_request._path += HOME_PAGE;
            }
            
            //获取请求资源文件的属性信息
            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处理
            if(_http_request._cgi == true){
                code = ProcessCgi(); //以CGI的方式进行处理
            }
            else{
                code = ProcessNonCgi(); //简单的网页返回,返回静态网页
            }
        }
};
注意:

1、本项目实现的HTTP服务器只支持GET方法和POST方法,因此如果客户端发来的HTTP请求中不是这两种方法则认为请求方法错误,如果想让服务器支持其他的请求方法则直接增加对应的逻辑即可。

2、服务器向外提供的资源都会放在web根目录下,比如网页、图片、视频等资源,本项目中的web根目录取名为wwwroot。web根目录下的所有子目录下都会有一个首页文件,当用户请求的资源是一个目录时,就会默认返回该目录下的首页文件,本项目中的首页文件取名为index.html。

3、stat是一个系统调用函数,它可以获取指定文件的属性信息,包括文件的inode编号、文件的权限、文件的大小等。如果调用stat函数获取文件的属性信息失败,则可以认为客户端请求的这个资源文件不存在,此时直接设置状态码为NOT_FOUND后停止处理即可。

4、当获取文件的属性信息后发现该文件是一个目录,此时请求资源路径一定不是以/结尾的,因为在此之前已经对/结尾的请求资源路径进行过处理了,因此这时需要给请求资源路径拼接上/index.html。

5、只要一个文件的拥有者、所属组、other其中一个具有可执行权限,则说明这是一个可执行文件,此时就需要将HTTP请求类中的cgi设置为true。

6、由于后续构建HTTP响应时需要用到请求资源文件的后缀,因此代码中对请求资源路径通过从后往前找.的方式,来获取请求资源文件的后缀,如果没有找到.则默认请求资源的后缀为.html。

7、由于请求资源文件的大小后续可能会用到,因此在获取到请求资源文件的属性后,可以将请求资源文件的大小保存到HTTP响应类的size中。

CGI处理
什么是CGI机制?

        我们在HTTP的角度来看,实际上HTTP对于可执行文件是不会直接处理的,他会把客户端传来的参数传递给其可执行程序,可执行程序进行程序的执行,当结束的时候,再把处理的结果返回给我们的HTTP,HTTP拿到处理结果再返回给我们的客户端,这套机制就是CGI机制

        但是这个时候又有一个问题了,就是说我们CGI这个机制是一个程序,当被加载到内存的时候是一个进程,我们怎么通过进程来去执行另一部分的程序呢?——这就用到了程序替换,但是直接替换的话,我们之前的代码就都被覆盖了,我们肯定不能直接替换,那么应该怎么做呢,我们可以来创建子进程帮助我们完成程序替换,然后再把结果返回给我们。

        这个时候问题又来了,我们需要执行的资源在哪里?简而言之,经过了前面的分析之后,请求资源可执行程序其实就是我们的路径

对于GET方法而言:就是路径path+query_string。

对于POST的方法而言:就是路径path+body(传参)。

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

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

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

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

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

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

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

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

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

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

  • 如果请求方法为POST方法,则父进程需要将请求正文中的参数写入管道中,以供被替换后的CGI程序进行读取。
  • 然后父进程要做的就是不断调用read函数,从管道中读取CGI程序写入的处理结果,并将其保存到HTTP响应类的response_body当中。
  • 管道中的数据读取完毕后,父进程需要调用waitpid函数等待CGI程序退出,并关闭两个管道对应的文件描述符,防止文件描述符泄露。
//服务端EndPoint
class EndPoint{
    private:
        int _sock;                   //通信的套接字
        HttpRequest _http_request;   //HTTP请求
        HttpResponse _http_response; //HTTP响应
    private:
        //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、创建两个匿名管道(管道命名站在父进程角度)
            //创建从子进程到父进程的通信信道
            int input[2];
            if(pipe(input) < 0){ //管道创建失败,则返回对应的状态码
                LOG(ERROR, "pipe input error!");
                code = INTERNAL_SERVER_ERROR;
                return code;
            }
            //创建从父进程到子进程的通信信道
            int output[2];
            if(pipe(output) < 0){ //管道创建失败,则返回对应的状态码
                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); //替换失败
            }
            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程序)退出
                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; //返回状态码
        }
};

注意:

1、在CGI处理过程中,如果管道创建失败或者子进程创建失败,则属于服务器端处理请求时出错,此时返回INTERNAL_SERVER_ERROR状态码后停止处理即可。

2、环境变量是key=value形式的,因此在调用putenv函数导入环境变量前需要先正确构建环境变量,此后被替换的CGI程序在调用getenv函数时,就可以通过key获取到对应的value。

3、子进程传递参数的代码最好放在重定向之前,否则服务器运行后无法看到传递参数对应的日志信息,因为日志是以cout的方式打印到标准输出的,而dup2函数调用后标准输出已经被重定向到了管道,此时打印的日志信息将会被写入管道。

4、父进程循环调用read函数从管道中读取CGI程序的处理结果,当CGI程序执行结束时相当于写端进程将写端关闭了(文件描述符的生命周期随进程),此时读端进程将管道当中的数据读完后,就会继续执行后续代码,而不会被阻塞。

5、父进程在等待子进程退出后,可以通过WIFEXITED判断子进程是否是正常退出,如果是正常退出再通过WEXITSTATUS判断处理结果是否正确,然后根据不同情况设置对应的状态码(此时就算子进程异常退出或处理结果不正确也不能立即返回,需要让父进程继续向后执行,关闭两个管道对应的文件描述符,防止文件描述符泄露)。

非CGI处理

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

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

  可以看到上述过程涉及数据在用户层和内核层的来回拷贝,但实际这个拷贝操作是不需要的,我们完全可以直接将磁盘当中的目标文件内容读取到内核,再由内核将其发送给对应的网卡进行发送。

  要达到上述效果就需要使用sendfile函数,该函数的功能就是将数据从一个文件描述符拷贝到另一个文件描述符,并且这个拷贝操作是在内核中完成的,因此sendfile比单纯的调用read和write更加高效。

  但是需要注意的是,这里还不能直接调用sendfile函数,因为sendfile函数调用后文件内容就发送出去了,而我们应该构建HTTP响应后再进行发送,因此我们这里要做的仅仅是将要发送的目标文件打开即可,将打开文件对应的文件描述符保存到HTTP响应的fd当中。

//服务端EndPoint
class EndPoint{
    private:
        int _sock;                   //通信的套接字
        HttpRequest _http_request;   //HTTP请求
        HttpResponse _http_response; //HTTP响应
    private:
        //非CGI处理
        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响应首先需要构建的就是状态行,状态行由状态码、状态码描述、HTTP版本构成,并以空格作为分隔符,将状态行构建好后保存到HTTP响应的status_line当中即可,而响应报头需要根据请求是否正常处理完毕分别进行构建。

#define HTTP_VERSION "HTTP/1.0"
#define LINE_END "\r\n"

#define PAGE_400 "400.html"
#define PAGE_404 "404.html"
#define PAGE_500 "500.html"

//服务端EndPoint
class EndPoint{
    private:
        int _sock;                   //通信的套接字
        HttpRequest _http_request;   //HTTP请求
        HttpResponse _http_response; //HTTP响应
    public:
        //构建响应
        void BuildHttpResponse()
        {
            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;
            }
        }
};

注意: 本项目中将服务器的行分隔符设置为\r\n,在构建完状态行以及每行响应报头之后都需要加上对应的行分隔符,而在HTTP响应类的构造函数中已经将空行初始化为了LINE_END,因此在构建HTTP响应时不用处理空行。

  对于状态行中的状态码描述,我们可以编写一个函数,该函数能够根据状态码返回对应的状态码描述。

//根据状态码获取状态码描述
static std::string CodeToDesc(int code)
{
    std::string desc;
    switch(code){
        case 200:
            desc = "OK";
            break;
        case 400:
            desc = "Bad Request";
            break;
        case 404:
            desc = "Not Found";
            break;
        case 500:
            desc = "Internal Server Error";
            break;
        default:
            break;
    }
    return desc;
}
构建响应报头(处理正常请求)

  构建HTTP的响应报头时,我们至少需要构建Content-Type和Content-Length这两个响应报头,分别用于告知对方响应资源的类型和响应资源的长度。

  对于请求正常处理完毕的HTTP请求,需要根据客户端请求资源的后缀来得知返回资源的类型。而返回资源的大小需要根据该请求被处理的方式来得知,如果该请求是以非CGI方式进行处理的,那么返回资源的大小早已在获取请求资源属性时被保存到了HTTP响应类中的size当中,如果该请求是以CGI方式进行处理的,那么返回资源的大小应该是HTTP响应类中的response_body的大小。

//服务端EndPoint
class EndPoint{
    private:
        int _sock;                   //通信的套接字
        HttpRequest _http_request;   //HTTP请求
        HttpResponse _http_response; //HTTP响应
    private:
        void BuildOkResponse()
        {
            //构建响应报头
            std::string content_type = "Content-Type: ";
            content_type += SuffixToDesc(_http_response._suffix);
            content_type += LINE_END;
            _http_response._response_header.push_back(content_type);

            std::string content_length = "Content-Length: ";
            if(_http_request._cgi){ //以CGI方式请求
                content_length += std::to_string(_http_response._response_body.size());
            }
            else{ //以非CGI方式请求
                content_length += std::to_string(_http_response._size);
            }
            content_length += LINE_END;
            _http_response._response_header.push_back(content_length);
        }
};

  对于返回资源的类型,我们可以编写一个函数,该函数能够根据文件后缀返回对应的文件类型。查看Content-Type转化表可以得知后缀与文件类型的对应关系,将这个对应关系存储一个unordered_map容器中,当需要根据后缀得知文件类型时直接在这个unordered_map容器中进行查找,如果找到了则返回对应的文件类型,如果没有找到则默认该文件类型为text/html。

//根据后缀获取资源类型
static std::string SuffixToDesc(const std::string& suffix)
{
    static std::unordered_map<std::string, std::string> suffix_to_desc = {
        {".html", "text/html"},
        {".css", "text/css"},
        {".js", "application/x-javascript"},
        {".jpg", "application/x-jpg"},
        {".xml", "text/xml"}
    };
    auto iter = suffix_to_desc.find(suffix);
    if(iter != suffix_to_desc.end()){
        return iter->second;
    }
    return "text/html"; //所给后缀未找到则默认该资源为html文件
}
构建响应报头(处理错误请求)

  对于请求处理过程中出现错误的HTTP请求,服务器将会为其返回对应的错误页面,因此返回的资源类型就是text/html,而返回资源的大小可以通过获取错误页面对应的文件属性信息来得知。此外,为了后续发送响应时可以直接调用sendfile进行发送,这里需要将错误页面对应的文件打开,并将对应的文件描述符保存在HTTP响应类的fd当中。

//服务端EndPoint
class EndPoint{
    private:
        int _sock;                   //通信的套接字
        HttpRequest _http_request;   //HTTP请求
        HttpResponse _http_response; //HTTP响应
    private:
        void HandlerError(std::string page)
        {
            _http_request._cgi = false; //需要返回对应的错误页面(非CGI返回)

            //打开对应的错误页面文件,以供后续发送
            _http_response._fd = open(page.c_str(), O_RDONLY);
            if(_http_response._fd > 0){ //打开文件成功
                //构建响应报头
                struct stat st;
                stat(page.c_str(), &st); //获取错误页面文件的属性信息

                std::string content_type = "Content-Type: text/html";
                content_type += LINE_END;
                _http_response._response_header.push_back(content_type);

                std::string content_length = "Content-Length: ";
                content_length += std::to_string(st.st_size);
                content_length += LINE_END;
                _http_response._response_header.push_back(content_length);

                _http_response._size = st.st_size; //重新设置响应文件的大小
            }
        }
};

注意: 对于处理请求时出错的HTTP请求,需要将其HTTP请求类中的cgi重新设置为false,因为后续发送HTTP响应时,需要根据HTTP请求类中的cgi来进行响应正文的发送,当请求处理出错后要返回给客户端的本质就是一个错误页面文件,相当于是以非CGI方式进行处理的。

发送HTTP响应

发送HTTP响应的步骤如下:

1、调用send函数,依次发送状态行、响应报头和空行。

2、发送响应正文时需要判断本次请求的处理方式,如果本次请求是以CGI方式成功处理的,那么待发送的响应正文是保存在HTTP响应类的response_body中的,此时调用send函数进行发送即可。

3、如果本次请求是以非CGI方式处理或在处理过程中出错的,那么待发送的资源文件或错误页面文件对应的文件描述符是保存在HTTP响应类的fd中的,此时调用sendfile进行发送即可,发送后关闭对应的文件描述符。

//服务端EndPoint
class EndPoint{
    private:
        int _sock;                   //通信的套接字
        HttpRequest _http_request;   //HTTP请求
        HttpResponse _http_response; //HTTP响应
    public:
        //发送响应
        void SendHttpResponse()
        {
            //发送状态行
            send(_sock, _http_response._status_line.c_str(), _http_response._status_line.size(), 0);
            //发送响应报头
            for(auto& iter : _http_response._response_header){
                send(_sock, iter.c_str(), iter.size(), 0);
            }
            //发送空行
            send(_sock, _http_response._blank.c_str(), _http_response._blank.size(), 0);
            //发送响应正文
            if(_http_request._cgi){
                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{
                sendfile(_sock, _http_response._fd, nullptr, _http_response._size);
                //关闭请求的资源文件
                close(_http_response._fd);
            }
        }
};

差错处理

  至此服务器逻辑其实已经已经走通了,但你会发现服务器在处理请求的过程中有时会莫名其妙的崩溃,根本原因就是当前服务器的错误处理还没有完全处理完毕。

逻辑错误

  逻辑错误主要是服务器在处理请求的过程中出现的一些错误,比如请求方法不正确、请求资源不存在或服务器处理请求时出错等等。逻辑错误其实我们已经处理过了,当出现这类错误时服务器会将对应的错误页面返回给客户端。

读取错误

  逻辑错误是在服务器处理请求时可能出现的错误,而在服务器处理请求之前首先要做的是读取请求,在读取请求的过程中出现的错误就叫做读取错误,比如调用recv读取请求时出错或读取请求时对方连接关闭等。

  出现读取错误时,意味着服务器都没有成功读取完客户端发来的HTTP请求,因此服务器也没有必要进行后续的处理请求、构建响应以及发送响应的相关操作了。

  可以在EndPoint类中新增一个bool类型的stop成员,表示是否停止本次处理,stop的值默认设置为false,当读取请求出错时就直接设置stop为true并不再进行后续的读取操作,因此读取HTTP请求的代码需要稍作修改。

//服务端EndPoint
class EndPoint{
    private:
        int _sock;                   //通信的套接字
        HttpRequest _http_request;   //HTTP请求
        HttpResponse _http_response; //HTTP响应
        bool _stop;                  //是否停止本次处理
    private:
        //读取请求行
        bool RecvHttpRequestLine()
        {
            auto& line = _http_request._request_line;
            if(Util::ReadLine(_sock, line) > 0){
                line.resize(line.size() - 1); //去掉读取上来的\n
            }
            else{ //读取出错,则停止本次处理
                _stop = true;
            }
            return _stop;
        }
        //读取请求报头和空行
        bool RecvHttpRequestHeader()
        {
            std::string line;
            while(true){
                line.clear(); //每次读取之前清空line
                if(Util::ReadLine(_sock, line) <= 0){ //读取出错,则停止本次处理
                    _stop = true;
                    break;
                }
                if(line == "\n"){ //读取到了空行
                    _http_request._blank = line;
                    break;
                }
                //读取到一行请求报头
                line.resize(line.size() - 1); //去掉读取上来的\n
                _http_request._request_header.push_back(line);
            }
            return _stop;
        }
        //读取请求正文
        bool RecvHttpRequestBody()
        {
            if(IsNeedRecvHttpRequestBody()){ //先判断是否需要读取正文
                int content_length = _http_request._content_length;
                auto& body = _http_request._request_body;

                //读取请求正文
                char ch = 0;
                while(content_length){
                    ssize_t size = recv(_sock, &ch, 1, 0);
                    if(size > 0){
                        body.push_back(ch);
                        content_length--;
                    }
                    else{ //读取出错或对端关闭,则停止本次处理
                        _stop = true;
                        break;
                    }
                }
            }
            return _stop;
        }
    public:
        EndPoint(int sock)
            :_sock(sock)
            ,_stop(false)
        {}
        //本次处理是否停止
        bool IsStop()
        {
            return _stop;
        }
        //读取请求
        void RecvHttpRequest()
        {
            if(!RecvHttpRequestLine()&&!RecvHttpRequestHeader()){ //短路求值
                ParseHttpRequestLine();
                ParseHttpRequestHeader();
                RecvHttpRequestBody();
            }
        }
};

注意:

1、可以将读取请求行、读取请求报头和空行、读取请求正文对应函数的返回值改为bool类型,当读取请求行成功后再读取请求报头和空行,而当读取请求报头和空行成功后才需要进行后续的解析请求行、解析请求报头以及读取请求正文操作,这里利用到了逻辑运算符的短路求值策略。

2、EndPoint类当中提供了IsStop函数,用于让外部处理线程得知是否应该停止本次处理。

  此时服务器创建的新线程在读取请求后,就需要判断是否应该停止本次处理,如果需要则不再进行处理请求、构建响应以及发送响应操作,而直接关闭于客户端建立的套接字即可。

class CallBack{
    public:
        static void* HandlerRequest(void* arg)
        {
            LOG(INFO, "handler request begin");
            int sock = *(int*)arg;

            EndPoint* ep = new EndPoint(sock);
            ep->RecvHttpRequest(); //读取请求
            if(!ep->IsStop()){
                LOG(INFO, "Recv No Error, Begin Handler Request");
                ep->HandlerHttpRequest(); //处理请求
                ep->BuildHttpResponse();  //构建响应
                ep->SendHttpResponse();   //发送响应
            }
            else{
                LOG(WARNING, "Recv Error, Stop Handler Request");
            }

            close(sock); //关闭与该客户端建立的套接字
            delete ep;

            LOG(INFO, "handler request end");
            return nullptr;
        }
};
写入错误

  除了读取请求时可能出现读取错误,处理请求时可能出现逻辑错误,在响应构建完毕发送响应时同样可能会出现写入错误,比如调用send发送响应时出错或发送响应时对方连接关闭等。

  出现写入错误时,服务器也没有必要继续进行发送了,这时需要直接设置stop为true并不再进行后续的发送操作,因此发送HTTP响应的代码也需要进行修改。

//服务端EndPoint
class EndPoint{
    private:
        int _sock;                   //通信的套接字
        HttpRequest _http_request;   //HTTP请求
        HttpResponse _http_response; //HTTP响应
    public:
        //发送响应
        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){
                    if(sendfile(_sock, _http_response._fd, nullptr, _http_response._size) <= 0){
                        _stop = true; //发送失败,设置_stop
                    }
                }
                //关闭请求的资源文件
                close(_http_response._fd);
            }
            return _stop;
        }
};

  此外,当服务器发送响应出错时会收到SIGPIPE信号,而该信号的默认处理动作是终止当前进程,为了防止服务器因为写入出错而被终止,需要在初始化HTTP服务器时调用signal函数忽略SIGPIPE信号。

//HTTP服务器
class HttpServer{
    private:
        int _port; //端口号
    public:
        //初始化服务器
        void InitServer()
        {
            signal(SIGPIPE, SIG_IGN); //忽略SIGPIPE信号,防止写入时崩溃
        }
};

接入线程池

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

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

这时可以在服务器端引入线程池:

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

任务设计

  当服务器获取到一个新连接后,需要将其封装成一个任务对象放到任务队列当中。任务类中首先需要有一个套接字,也就是与客户端进行通信的套接字,此外还需要有一个回调函数,当线程池中的线程获取到任务后就可以调用这个回调函数进行任务处理。

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

编写任务回调

  任务类中处理任务时需要调用的回调函数,实际就是之前创建新线程时传入的执行例程CallBack::HandlerRequest,我们可以将CallBack类的()运算符重载为调用HandlerRequest函数,这时CallBack对象就变成了一个仿函数对象,这个仿函数对象被调用时实际就是在调用HandlerRequest函数。

class CallBack{
    public:
        CallBack()
        {}
        void operator()(int sock)
        {
            HandlerRequest(sock);
        }
        void HandlerRequest(int sock)
        {
            LOG(INFO, "handler request begin");

            EndPoint* ep = new EndPoint(sock);
            ep->RecvHttpRequest(); //读取请求
            if(!ep->IsStop()){
                LOG(INFO, "Recv No Error, Begin Handler Request");
                ep->HandlerHttpRequest(); //处理请求
                ep->BuildHttpResponse();  //构建响应
                ep->SendHttpResponse();   //发送响应
                if(ep->IsStop()){
                    LOG(WARNING, "Send Error, Stop Send Response");
                }
            }
            else{
                LOG(WARNING, "Recv Error, Stop Handler Request");
            }

            close(sock); //关闭与该客户端建立的套接字
            delete ep;

            LOG(INFO, "handler request end");
        }
        ~CallBack()
        {}
};

编写线程池

设计线程池结构

可以将线程池设计成单例模式:

  • 将ThreadPool类的构造函数设置为私有,并将拷贝构造和拷贝赋值函数设置为私有或删除,防止外部创建或拷贝对象。
  • 提供一个指向单例对象的static指针,并在类外将其初始化为nullptr。
  • 提供一个全局访问点获取单例对象,在单例对象第一次被获取时就创建这个单例对象并进行初始化。

ThreadPool类中的成员变量包括:

  • 任务队列:用于暂时存储未被处理的任务对象。
  • num:表示线程池中线程的个数。
  • 互斥锁:用于保证任务队列在多线程环境下的线程安全。
  • 条件变量:当任务队列中没有任务时,让线程在该条件变量下进行等等,当任务队列中新增任务时,唤醒在该条件变量下进行等待的线程。
  • 指向单例对象的指针:用于指向唯一的单例线程池对象。

ThreadPool类中的成员函数主要包括:

  • 构造函数:完成互斥锁和条件变量的初始化操作。
  • 析构函数:完成互斥锁和条件变量的释放操作。
  • InitThreadPool:初始化线程池时调用,完成线程池中若干线程的创建。
  • PushTask:生产任务时调用,将任务对象放入任务队列,并唤醒在条件变量下等待的一个线程进行处理。
  • PopTask:消费任务时调用,从任务队列中获取一个任务对象。
  • ThreadRoutine:线程池中每个线程的执行例程,完成线程分离后不断检测任务队列中是否有任务,如果有则调用PopTask获取任务进行处理,如果没有则进行休眠直到被唤醒。
  • GetInstance:获取单例线程池对象时调用,如果单例对象未创建则创建并初始化后返回,如果单例对象已经创建则直接返回单例对象。
#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;
        ThreadPool* operator=(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;

注意:

  • 由于线程的执行例程的参数只能有一个void*类型的参数,因此线程的执行例程必须定义成静态成员函数,而线程执行例程中又需要访问任务队列,因此需要将this指针作为参数传递给线程的执行例程,这样线程才能够通过this指针访问任务队列。
  • 在向任务队列中放任务以及从任务队列中获取任务时,都需要通过加锁的方式来保证线程安全,而线程在调用PopTask之前已经进行过加锁了,因此在PopTask函数中不必再加锁。
  • 当任务队列中有任务时会唤醒线程进行任务处理,为了防止被伪唤醒的线程调用PopTask时无法获取到任务,因此需要以while的方式判断任务队列是否为空。

  引入线程池后服务器要做的就是,每当获取到一个新连接时就构建一个任务,然后调用PushTask将其放入任务队列即可。

//HTTP服务器
class HttpServer{
    private:
        int _port; //端口号
    public:
        //启动服务器
        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服务器后端的处理逻辑,主要完成的是GET和POST请求方法,以及CGI机制的搭建。如果想对当前项目进行扩展,可以选择在技术层面或应用层面进行扩展。

技术层面的扩展

技术层面可以选择进行如下扩展:

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

应用层面的扩展

应用层面可以选择进行如下扩展:

  • 基于当前HTTP服务器,搭建在线博客。
  • 基于当前HTTP服务器,编写在线画图板。
  • 基于当前HTTP服务器,编写一个搜索引擎。
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值