【实战项目】自主web服务器

项目简介

什么是web服务器: 通过HTTP协议构建一个web服务器,该服务器能够处理浏览器发过来的http请求,并根据http请求返回相应的http响应给浏览器。相当于搭建个人网站,你可以在个人网站上存放各种资源,别人可以通过浏览器访问你的网站,并获取资源。

背景: 该项目主要用http协议,在网络中,http协议被广泛使用如移动端,pc端浏览器。http协议是打开互联网应用窗口的重要协议,它在网络应用层中的地位不可撼动,是能准确区分前后台的重要协议。

目标: 该项目的目标是,对http协议的理论有更深刻的理解,从零开始完成web服务器开发,连接下三层协议,从技术到应用,让网络难点无处遁形。

简述: 采用C/S模型,编写支持中小型应用的http,理解常见互联网应用行为,做完该项目,你可以从技术上完全理解从你上网开始,到关闭浏览器的所有操作中的技术细节。

技术特点: 该项目是一个后端开发项目,开发环境为centos 7 + vim/gcc/gdb/VS Code,开发语言为C/C++,应用网络编程(TCP/IP协议, socket流式套接字,http协议),多线程,cgi,线程池等技术。

认识http协议

http分层概览

在这里插入图片描述

与http相关的重要协议有TCP,IP,DNS

这里介绍一下DNS

DNS(域名系统)是将域名和IP地址相互映射的一个分布式数据库,能够使人更方便地访问互联网。

在这里插入图片描述
协议之间是如何协同运作的呢?

在这里插入图片描述

http背景知识补充

目前主流服务器使用的是http/1.1版本,该项目是按照http/1.0版本来完成讲解,同时,我们还会对比1.1和1.0的区别。

在这里插入图片描述

http协议的特点如下

  • 简单快速,HTTP服务器的程序规模小,因而通信速度很快。
  • 灵活,HTTP允许传输任意类型的数据对象,正在传输的类型由Content-Type加以标记。
  • 无连接,每次连接只处理一个请求。服务器处理完客户的请求,并收到客户的应答后,即断开连接。采用这种方式可以节省传输时间。(http/1.0具有的功能,http/1.1兼容)。
  • 无状态(HTTP协议自身不具有保存之前发送过的请求或响应的功能)

在这里插入图片描述

注意: http协议每当有新的请求产生,就会有对应的新响应产生。协议本身并不会保留你之前的一切请求或者响应,这是为了更快的处理大量的事务,确保协议的可伸缩性。

URI & URL & URN

我们想访问web上资源需要用资源标志符(URI)进行定位,和URI相关的还有URL,URN。

  • URI,是uniform resource identifier,统一资源标识符,用来唯一的标识一个资源。
  • URL,是uniform resource locator,统一资源定位符,它是一种具体的URI,即URL可以用来标识一个资源,而且还指明了如何locate这个资源。
  • URN,uniform resource name,统一资源命名,是通过名字来标识资源。

URI是以一种抽象的,高层次概念定义统一资源标识,而URL和URN则是具体的资源标识的方式。URL和URN都是一种URI,URL是 URI 的子集。任何东西,只要能够唯一地标识出来,都可以说这个标识是 URI 。如果这个标识是一个可获取到上述对象的路径,那么同时它也可以是一个 URL ;但如果这个标识不提供获取到对象的路径,那么它就必然不是URL 。

HTTP URL (URL是一种特殊类型的URI,包含了如何获取指定资源)的格式如下:

http://host[":"port][abs_path]
  • http表示要通过HTTP协议来定位网络资源
  • host表示合法的Internet主机域名或者IP地址,本主机IP:127.0.0.1
  • port指定一个端口号,为空则使用缺省端口80。(通常会根据协议采用默认端口号)
  • abs_path指定请求资源的URI
  • 如果URL中没有给出abs_path,那么当它作为请求URI时,必须以“/”的形式给出,通常这个工作浏览器自动帮我们完成。(如果没有abs_path,会默认访问该服务端首页)。

例:

一个较为完整的http请求:

 http://www.aspxfans.com:8080/news/index.asp?boardID=5&ID=24618&page=1

构建tcp服务器

我们这里的tcp服务器是对网络套接字创建,绑定,监听等进行封装,方便之后的使用。

这里我们把 tcp server 类设计为单例模式,让程序访问到的 tcp server 是同一个。代码如下

#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<pthread.h>

#define PORT 8081
#define BACKLOG 5

class TcpServer{
    private:
        int port;// 端口号
        int listen_sock;// 套接字
        static TcpServer *svr;
    private:
        TcpServer(int _port = 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);// 创建tcpserver
                    svr -> InitServer();// 初始化
                }
                pthread_mutex_unlock(&lock);
            }
            return svr;
        }

        void InitServer()// 初始化
        {
            Socket();
            Bind();
            Listen();
        }

        void Socket()
        {
            listen_sock = socket(AF_INET,SOCK_STREAM,0);// 创建套接字
            if(listen_sock < 0){
                exit(1);
            }
            int opt = 1;
            setsockopt(listen_sock,SOL_SOCKET,SO_REUSEADDR,&opt,sizeof(opt));// 设置套接字选项,允许地址复用
        }

        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){// 绑定失败
                exit(2);
            }
        }

        void Listen()
        {
            if(listen(listen_sock,BACKLOG)<0){
                exit(3);
            }
        }

        ~TcpServer()
        {}
};

TcpServer* TcpServer::svr = nullptr;

关于setsockopt函数可以参考这篇博客setsockopt()函数功能介绍

HTTP请求和响应

请求和响应过程

在这里插入图片描述

浏览器请求服务器资源需要给服务器发送http请求,服务器收到请求需要返回http响应给浏览器,我们需要详细了解请求和响应的格式,为之后编码做准备。

HTTP请求报文

在这里插入图片描述

HTTP请求报文分为 请求行,请求报头,空行,请求正文

  • 请求行: 请求方法,请求URL,HTTP协议和版本
  • 请求报头: 请求属性,每一行表示一个属性,遇到空行表示请求报头结束。
  • 空行:用于分割请求报头和请求正文
  • 请求正文:如果存在正文,请求报头中就需要Content-Length属性来标识正文的大小

HTTP响应报文

在这里插入图片描述
HTTP响应报文分为 响应行,响应报头,空行,响应正文

  • 响应行:http协议和版本,状态码,状态描述符
  • 响应报头:响应属性,每一行表示一个属性,遇到空行表示响应报头结束。
  • 空行:分割响应报头和响应正文
  • 响应正文:服务器返回的HTML页面或者json数据

工具类

我们在编写协议时难免会有一些程序经常用到,如:读取报头中的一行。我们可以设计一个工具类,将这些程序存放其中。

ReadLine()函数

ReadLine()函数的作用是读取sock套接字中的一行数据。无论是请求报头还是响应报头,它们的属性都是按行进行划分的。但是不同的浏览器发送过来的http请求,它们中的行分割符是不同的,大致分为如下三类

  1. xxxxx \r\n
  2. xxxxx \n
  3. xxxxx \r

我们要设计一个算法,兼容各种行分割符。

读取数据,如果读取到 \n 表示该行已结束,如果读取到 \r ,需要判断下一个字符是否为 \n 。不是,那么该行已结束,否则需要将 \r\n 转变为 \n。

这里我们需要用到 recv 函数

recv函数

功能: 接收已连接的数据报或流式套接口的数据。

函数原型:

int recv( int sockfd, void *buf, size_t len,int flags);

参数说明:

  • sockfd :发送数据的套接字
  • buf :存放recv函数接收到的数据
  • len :buf的长度
  • flags :recv发送数据的选项,一般设置为0

返回值: 成功返回0,错误返回-1

编码

#pragma once

#include<iostream>
#include<string>
#include<sys/types.h>
#include<sys/socket.h>

//工具类
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);// 从sock中读取一个字符到ch中
                if(s > 0){                 
                    if(ch == '\r'){
                        recv(sock,&ch,1,MSG_PEEK);// MSG_PEEK 窥探下一个字符(不从sock中取走)
                        if(ch == '\n'){
                            // 把\r\n -> \n
                            // 窥探成功读取该字符
                            recv(sock,&ch,1,0);
                        }
                        else{
                            ch = '\n';// \r -> \n
                        }
                    }
                    // 到这有两种情况 1.普通字符 2.\n
                    out.push_back(ch);
                }
                else if(s == 0){
                    return 0;
                }
                else{
                    return -1;
                }
            }
            return out.size();
        }
};

CutString()函数

CutString()函数的功能是将字符串按特定字符切分成左右两部分,代码如下

#pragma once

#include<iostream>
#include<string>
#include<sys/types.h>
#include<sys/socket.h>

//工具类
class Util{
    public:
        static int ReadLine(int sock,std::string &out)
        {
        	...
        }
        
        static bool CutString(const std::string &target, std::string &sub1_out, std::string &sub2_out, std::string sep)
        {
            size_t pos = target.find(sep);// 查找分割符
            if(pos != std::string::npos){
                sub1_out = target.substr(0,pos);
                sub2_out = target.substr(pos+sep.size());
                return true;
            }
            return false;
        }			
		// 传入 
		// target = Content-Length : 18  
		// sep = " : "
		// 传出 
		// sub1_out = Content-Length
		// sub2_out = sep
};

日志信息

为了方便代码解析和调试,我们需要设计函数来记录日志信息,日志信息的内容如下

在这里插入图片描述

乍一看传入的参数是不是有点多,实际上我们只需传入 日志级别日志信息 这两个参数,剩下的参数我们可以让操作系统去查找,为此可以设置宏,通过宏替换来获取 日志文件代码行数

#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;
}

请求和响应类的设计

http服务器收到一个请求时,服务器需要做如下工作:读取请求,分析请求,构建响应,发送响应。为了更好的管理请求报头和响应报头,我们需要设计一个类来存放请求报头和响应报头。

http请求类

class HttpRequest{
    public:
        std::string request_line;// 请求行
        std::vector<std::string> request_header;// 请求报头
        std::string blank;// 请求空行
        std::string request_body;// 请求正文

        // 请求行的解析
        std::string method;
        std::string uri;
        std::string version;
			
		// 请求报头解析
        std::unordered_map<std::string, std::string> header_kv;// 属性名:属性信息
        int content_length;// 请求正文长度

		// URI解析
        std::string suffix;// 请求文件名的后缀
        std::string path;// URI路径
        std::string query_string;// URI参数

        bool cgi;// cgi处理标志符
        int size;// 打开的文件大小
    public:
        HttpRequest():content_length(0),cgi(false){}
        ~HttpRequest(){}
};

http响应类

#define LINE_END "\r\n"

class HttpResponse{
    public:
        std::string status_line;// 响应行
        std::vector<std::string> response_header;// 响应报头
        std::string blank;// 响应空行
        std::string response_body;// 响应正文  

        int status_code;// 响应状态码
        int fd;// 暂时保存文件描述符
    public:   
        HttpResponse():blank(LINE_END),status_code(OK),fd(-1){}
        ~HttpResponse(){}
};

请求处理

请求报文
在这里插入图片描述
处理请求报头时,先将报头的数据读取出来,再对其进行处理

void RecvHttpRequest()
{
    // 读取请求行和报头
    if((!RecvHttpRequestLine()) && (!RecvHttpRequestHeader())){
        ParseHttpRequestLine();// 解析请求行
        ParseHttpRequestHeader();// 解析请求报头
        RecvHttpRequestBody();// 读取请求正文
    }
}

读取请求行

利用工具类中的ReadLine()函数将请求行读入到请求类中

bool RecvHttpRequestLine()
{
    auto& line = http_request.request_line;
    if(Util::ReadLine(sock,line) > 0){
        line.resize(line.size()-1);// 去除行尾 \n
        LOG(INFO,http_request.request_line);// 记录日志
    }
    else{
        stop = true;// 标记读取失败
    }
    return stop;
}

读取请求报头

按行为单位,将读取到的报头属性插入到请求报头中。

bool RecvHttpRequestHeader()
{
    std::string line;
    while(line!="\n"){
        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);// 将读取到的报头属性尾插入请求报头中
        LOG(INFO,line);
    }
    return stop;
}

解析请求行

请求行由 请求方法,请求URI,HTTP协议版本 构成,它们中间由空格隔开,如下图

在这里插入图片描述

解析请求行就是将字符串按空格分割。

将字符串按空格分割我们可以调用库函数中的stringstream

stringstream使用示例:

#include<iostream>
#include<string>
#include<sstream>

int main()
{
    std::string msg = "GET /a/b/c.html http/1.0";
    std::string method;
    std::string uri;
    std::string version;
    std::stringstream ss(msg);

    ss >> method >> uri >> version;
    std::cout << method << std::endl;
    std::cout << uri << std::endl;
    std::cout << version << std::endl;
}

运行结果
在这里插入图片描述

解析请求行代码

void ParseHttpRequestLine()
{
    auto &line = http_request.request_line;// 获取请求行
    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);// 字母转大写,方便后文使用
}

解析请求报头

请求报头中包含了请求的各种信息,它们都是以 属性名:属性信息 的形式存放在vector容器中,为了方便找到请求报头中的信息,我们需要将请求报头中的每种属性拆分成 (属性名,属性信息)键值对存放在unordered_map中。

报头中属性和属性名间用 :隔开如下图

在这里插入图片描述

为此,我们把 :当成分隔符,将属性名和属性信息分开

#define SEP ": "

void ParseHttpRequestHeader()
{
    std::string key;
    std::string value;
    for(auto &iter : http_request.request_header)
    {
        if(Util::CutString(iter,key,value,SEP)){
            http_request.header_kv.insert({key,value});
        }
    }
}

读取请求正文

请求正文不一定存在,如果请求方法为GET,那么请求正文被设置为空,即GET方法不需要读取请求正文。如果请求方法为POST,请求正文可以存在,通过请求报头中的Content-Length获取请求正文的大小,但是Content-Length为0时,请求正文不存在。

// 判断是否需要读取请求正文
bool IsNeedRecvHttpRequestBody()
{
    auto &method = http_request.method;
    if(method == "POST"){// 请求方法为POST时可能存在请求正文
        auto &header_kv = http_request.header_kv;
        auto iter = header_kv.find("Content-Length");
        if(iter != header_kv.end()){
            LOG(INFO,"Post Method, Content-Length: "+iter->second);
            http_request.content_length = atoi(iter->second.c_str());// 将字符串转换为数字
            return true;
        }
    }
    return false;
}

// 读取请求正文
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 s = recv(sock,&ch,1,0);// 从套接字中读取请求正文
            if(s > 0){
                body.push_back(ch);
                content_length--;
            }
            else{
                stop = true;
                break;
            }
        }
        LOG(INFO, body);
    }
    return stop;
}

构建响应预处理

浏览器给服务器发送http请求的目的是让服务器完成某种任务,或想访问服务器上的资源。当服务器收到浏览器的http请求后,会根据请求行中的URI去在本地寻找资源处理问题,问题处理完后将处理结果构建成一个http响应报头返回给浏览器。

  1. 构建响应前先要确定资源的路径,这就需要我们去解析请求行中的URI。

不同请求方法下的URI格式是不同的,GET方法中,URI路径和参数间用?隔开,而POST方法中,URI只有路径。

在这里插入图片描述

  1. 获得路径后,就要去路径下寻找资源了。访问路径前,我们需要对路径做修饰,在其前面添加wwwroot目录。因为我们的资源目录是wwwroot,它和http进程在同一目录下,所以我们的http进程是可以通过相对路径去访问wwwroot目录。例如请求的路径是/test_cgi,修饰后变为wwwroot/test_cgi,此时http进程就会去wwwroot目录下寻找test_cgi。

在这里插入图片描述

  1. 寻找资源又会有4种情况

1)资源不存在。此时我们将错误码设置为404,也就是资源不存在。
2)资源是一个文件。我们需要记录文件的大小,将文件名添加到路径即可。
3)资源是一个可执行程序。我们需要标记cgi为真,表示需要cgi处理。
4)资源是一个目录。我们会在路径中添加目录下的默认文件index.html(在设计wwwroot资源时,我们会在每个目录下添加默认文件index.html)。

那么如何获取文件属性呢?我们利用 stat 函数

stat() 函数

在这里插入图片描述

功能: 查看一个文件是否存在,并将文件的属性存放在struct stat变量中。
返回值: 成功返回0,失败返回-1

struct stat

在这里插入图片描述
其中st_size属性是查看文件的大小,以字节为单位,st_mode存储了文件的类型和权限。

S_ISDIR (st_mode) 是一个宏定义,可以判断文件是否为目录。

st.st_mode&S_IXGRPst.st_mode&S_IXOTHst.st_mode&S_IXUSR 分别判断文件所属组,文件的其他人,文件所属人是否具有可执行权限,如果其中有一个为真,那么该文件具有可执行权限。

  1. 此外我们还需从请求行中获取文件名的后缀,如果获取失败默认为html。

  2. GET方法和POST方法的区别

  • GET方法从浏览器传参数给http服务器时,是需要将参数跟到URI后面的
  • POST方法从浏览器传参数给http服务器时,是需要将参数放放到请求正文中的
  • GET方法,如果没有传参,http按照一般的方式进行,返回资源即可
  • GET方法,如果有参数传入,http就需要按照CGI方式处理参数,并将执行结果(期望资源)返回给浏览器
  • POST方法,一般都需要使用CGI方式来进行处理

总体代码如下

#define NOT_FOUND 404
#define BAD_REQUEST 400

// 构建响应
void BuildHttpResponse()
{
    auto &code = http_response.status_code;// 状态码
    std::string _path;// 路径
    struct stat st;
    int size = 0;// 记录文件大小
    std::size_t found = 0;
    if(http_request.method != "GET" && http_request.method != "POST"){// 方法不存在设置错误码为400
        // 非法请求
        LOG(WARNING,"method is not right");
        code = BAD_REQUEST;// 设置错误码
        goto END;
    }
    if(http_request.method == "GET"){
        // 将获取URI中的路径和参数
        size_t pos = http_request.uri.find('?');
        if(pos != std::string::npos){
            Util::CutString(http_request.uri,http_request.path,http_request.query_string,"?");
            http_request.cgi = true;// 需要cgi处理
        }
        else{
            http_request.path = http_request.uri;
        }
    }
    else if(http_request.method == "POST"){
        // POST
        http_request.cgi = true;// 需要cgi处理
        http_request.path = http_request.uri;
    }

    _path = http_request.path;
    // 构建资源路径
    http_request.path = WEB_ROOT;// 在路径前添加wwwroot目录
    http_request.path += _path;
    if(http_request.path[http_request.path.size()-1] == '/'){
        http_request.path += HOME_PAGE;// 添加当前目录下的html网页
    }

    if(stat(http_request.path.c_str(),&st)==0){
        //资源是目录
        if(S_ISDIR(st.st_mode)){
            // 请求的资源是一个目录需要做相关处理
            // 注意,前文中我们把最后的'/'删去了,这里要添加上来
            http_request.path += "/";
            http_request.path += HOME_PAGE;
            stat(http_request.path.c_str(),&st);// 更新st
        }
        // 资源是可执行程序
        if((st.st_mode&S_IXUSR) || (st.st_mode&S_IXGRP) || (st.st_mode&S_IXOTH)){
            // cgi处理
            http_request.cgi = true;
        }
        http_request.size = st.st_size;// 记录打开文件的大小
    }
    else{
        //资源不存在
        LOG(WARNING,http_request.path + "Not Found");
        code = NOT_FOUND;// 标记错误码
        goto END;
    }

    // 找后缀
    found = http_request.path.rfind(".");
    if(found == std::string::npos){
        http_request.suffix = ".html";
    }
    else{
        http_request.suffix = http_request.path.substr(found);
    }

    if(http_request.cgi){
        code = ProcessCgi();// 执行目标程序,拿到结果:http_response.response_body;
    }
    else{
        code = ProcessNonCgi();// 简单的网页返回,返回静态网页(打开即可)
    }
END:

    BuildHttpResponseHelper();// 构建响应报文
}

返回网页

网页本质是一个超文本文件,也就是我们的前端代码,当返回这些代码给浏览器的时候,浏览器就会解析成一个网页。

所以浏览器访问的资源是文件时,直接打开文件并将文件描述符添加到响应报头中。

// 非cgi处理
int ProcessNonCgi()
{
    http_response.fd = open(http_request.path.c_str(),O_RDONLY);// 以只读形式打开文件
    if(http_response.fd >= 0){// 打开成功
        LOG(INFO,http_request.path + " open success!");
        return OK;// 打开失败
    }           
    return 404;
}

CGI机制

基本概念

公共网关接口(Common Gateway Interface,CGI)是Web 服务器运行时外部程序的规范,按CGI 编写的程序可以扩展服务器功能。CGI(Common Gateway Interface) 是WWW技术中最重要的技术之一,有着不可替代的重要地位。CGI是外部应用程序(CGI程序)与WEB服务器之间的接口标准,是在CGI程序和Web服务器之间传递信息的过程。

CGI程序就是在服务器上的可执行程序,当浏览器访问的资源是一个可执行程序时,http进程就会创建一个子进程,通过execl函数将可执行程序替换为子进程。然后http进程会把参数传递给子进程,子进程将参数处理结果返回给http进程,http进程再通过网络返回给浏览器。这种http进程去调用CGI处理数据的方式就叫做CGI机制。

CGI机制使用

CGI机制过程如下图
在这里插入图片描述

当触发了cgi机制时,http需要创建子进程,这样在程序替换时可以用子进程进行替换。父进程和子进程间需要进行通信,父进程传递参数给子进程,而子进程通过CGI程序得到的结果需要返回给父进程。这里我们创建匿名管道方便父子进程间通信,因为管道数据传输是单向的,所以我们需要创建两条匿名管道。

对于子进程来说,子进程被程序替换后,它拿到两个管道的文件描述符的数据也会被替换掉,此时子进程不知道管道的文件描述符,也就无法与父进程通信,为了解决这个问题我们可以将管道重定向到0号文件描述符和1号文件描述符,这样父子进程就可以通过 cin 和 cout 进行交流。

在这里插入图片描述

http进程给子进程传参的时候,GET请求和POST请求传参方式是不同的。

  1. 如果是GET方法,传递参数给子进程是通过设置环境变量的方式给子进程,因为URI中参数是有大小限制,一般都不会太长,并且程序替换,只替换进程的代码和数据,不会替换环境变量,因此在子进程被execl之前,提前设置一个PATAMETER的环境变量。
  2. 如果是POST方法,传递参数给子进程是通过管道的方式给子进程,因为POST中的请求正文的参数是没有限制的。但是子进程怎么知道要在管道中读取多少个字符呢?此时就需要将通过设置一个环境变量Content-Length来标识参数的大小,让子进程知道需要从管道中读取多少个字符。
  3. 为了知道是GET请求还是POST请求,我们需要设置一个环境变量METHOD来标识请求方法。

代码实现:

// CGI处理
int ProcessCgi()
{
    int code = OK;
    LOG(INFO,"process cgi mthod!");
    // 父进程数据
    auto &method = http_request.method;// 请求方法
    auto &query_string = http_request.query_string; // 请求报头
    auto &body_text = http_request.request_body; // 请求正文
    auto &bin = http_request.path;// 请求路径
    int content_length = http_request.content_length;// 请求正文长度
    auto &response_body = http_response.response_body;// 响应正文

    std::string query_string_env;
    std::string method_env;
    std::string content_length_env;

    int input[2];
    int output[2];

    // 创建双向管道
    if(pipe(input) < 0){
        // 创建失败
        LOG(ERROR,"pipe input error");
        code = SERVER_ERROR;
        return code;
    } 
    if(pipe(output) < 0){
        LOG(ERROR,"pipe output error");
        code = SERVER_ERROR;
        return code;
    }

    // 创建子进程
    pid_t pid = fork();
    if(pid == 0){// 子进程
        close(input[0]);
        close(output[1]);

        // 站在子进程角度
        // input[1]: 写出
        // output[0]: 读入

        method_env = "METHOD=";
        method_env += method;

        putenv((char*)method_env.c_str());// 增加环境变量
        // 环境变量不会被env给替换掉

        if(method == "GET"){
            query_string_env = "QUERY_STRING=";
            query_string_env += query_string;
            putenv((char*)query_string_env.c_str());
            LOG(INFO,"Get Method,Add Query_String Env");
        }
        else if(method == "POST"){
            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{

        }
        
        // 重定向
        dup2(output[0],0);// 重定向到标准输入
        dup2(input[1],1);// 重定向到标准输出

        // 替换成功之后,目标子进程如何得知,对应读写文件描述符是多少呢?不需要,只要读0,写1即可
        execl(bin.c_str(),bin.c_str(),nullptr);// 进程替换
        exit(1);
    }
    else if(pid < 0){// 创建子进程失败
        LOG(ERROR,"fork error!");
        return 404;
    }
    else{// 父进程
        close(input[1]);
        close(output[0]);

        // 站在父进程角度
        // input[0]: 读入
        // output[1]: 写出

        if(method == "POST"){
            // 将请求报头读入管道
            const char *start = body_text.c_str();
            int total = 0;
            int size = 0;
            while(total < content_length && (size = write(output[1],start+total,body_text.size()-total)) > 0){
                total += size;
            }
        }

        // 获取响应主体
        char ch = 0;
        while(read(input[0],&ch,1) > 0){
            response_body.push_back(ch);
        }

        int status = 0;
        pid_t ret = waitpid(pid,&status,0);// 等待子进程
        if(ret == pid){
            if(WIFEXITED(status)){// 非0 表明进程正常结束
                if(WEXITSTATUS(status) == 0){// 0表示进程正常终止
                    code = OK;
                }
                else{
                    code = SERVER_ERROR;
                }
            }
            else{
                code = SERVER_ERROR;
            }
        }

        close(input[0]);
        close(output[1]);
    }
    return OK;
}

CGI程序

这里我们设计一个简单的CGI程序,计算两个数的加减乘除。

#include <iostream>
#include <cstdlib>
#include <unistd.h>


bool GetQueryString(std::string &query_string)
{
    bool result = false;
    std::string method = getenv("METHOD");
    if(method == "GET"){
        query_string = getenv("QUERY_STRING");
        result = true;
    }
    else if(method == "POST"){
        int content_length = atoi(getenv("CONTENT_LENGTH"));
        char c = 0;
        while(content_length){
            read(0,&c,1);
            query_string.push_back(c);
            content_length--;
        }
        result = true;
    }
    else{
        result = false;
    }
    return result;
}

void CutString(std::string &in, const std::string &sep, std::string &out1, std::string &out2)
{
    auto pos = in.find(sep);
    if(std::string::npos != pos){
        out1 = in.substr(0,pos);
        out2 = in.substr(pos+sep.size());
    }
}

int main()
{
    std::string query_string;
    GetQueryString(query_string);
    // a=100&b=200

    std::string str1;
    std::string str2;
    CutString(query_string,"&",str1,str2);

    std::string name1;
    std::string value1;
    CutString(str1,"=",name1,value1);

    std::string name2;
    std::string value2;
    CutString(str2,"=",name2,value2);

    std::cout << name1 << " : " << value1 << std::endl;
    std::cout << name2 << " : " << value2 << std::endl;

    std::cerr << name1 << " : " << value1 << std::endl;
    std::cerr << name2 << " : " << value2 << std::endl;
    int x = atoi(value1.c_str());
    int y = atoi(value2.c_str());

    //可能向进行某种计算(计算,搜索,登陆等),想进行某种存储(注册)
    std::cout << "<html>";
    std::cout << "<head><meta charset=\"utf-8\"></head>";
    std::cout << "<body>";
    std::cout << "<h3> " << value1 << " + " << value2 << " = "<< x+y << "</h3>";
    std::cout << "<h3> " << value1 << " - " << value2 << " = "<< x-y << "</h3>";
    std::cout << "<h3> " << value1 << " * " << value2 << " = "<< x*y << "</h3>";
    std::cout << "<h3> " << value1 << " / " << value2 << " = "<< x/y << "</h3>";
    std::cout << "</body>";
    std::cout << "</html>";

    return 0;
}

构建响应

响应报文

在这里插入图片描述

构建响应行

如上图所示,响应行由协议版本,状态码和状态描述组成,两两间用空格隔开。

// 将状态码转换为字符串
static std::string Code2Desc(int code)
{
    std::string desc;
    switch(code){
        case 200:
            desc = "OK";
            break;
        case 404:
            desc = "Not Found";
            break;
        default:
            break;
    }
    return desc;
}

// 构建响应报文
void BuildHttpResponseHelper()
{   
    auto &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 += Code2Desc(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_404;
            HandlerError(path);
            break;
        case SERVER_ERROR:
            path += PAGE_404;
            HandlerError(path);
            break;
        default:
            break;
    }
}

构建响应报头

构建OK响应报头

构建响应报头需要文件类型(Content-Type),我们可以建立文件后缀和文件类型的映射,通过文件后缀返回对应的文件类型。

// 转换后缀
static std::string Suffix2Desc(const std::string &suffix)
{
    static std::unordered_map<std::string,std::string> suffix2desc = {
        {".html","text/html"},
        {".css","text/css"},
        {".js","application/javascript"},
        {".jpg","application/x-jpg"},
        {".xml","application/xml"}
    };

    auto iter = suffix2desc.find(suffix);
    if(iter != suffix2desc.end()){
        return iter -> second;
    }
    return "text/html";
}

此外响应报头还需要Content-Length,如果是cgi处理,cgi处理时会将结果添加到响应正文中,content-length 就是响应正文的大小。如果是非cgi处理,content-length 是打开文件的大小。不要忘记在每行结尾要添加换行符、

#define LINE_END "\r\n"

// 构建OK响应报头
void BuildOkResponse()
{
    std::string line = "Content-Type: ";
    line += Suffix2Desc(http_request.suffix);// 添加文件类型            
    line += LINE_END;// 添加/r/n
    http_response.response_header.push_back(line);// 添加到响应报头

    line = "Content-Length: ";
    if(http_request.cgi){
        line += std::to_string(http_response.response_body.size());// cgi的结果的大小
    }
    else{
        line += std::to_string(http_request.size);// 打开文件的大小
    }
    line += LINE_END;
    http_response.response_header.push_back(line);
}

错误码响应报头

我们还需设置一个错误码响应报头,该报头打开对应错误码网页,并将错误码网页的信息添加到响应报头中。

#define LINE_END "\r\n"

// 构建错误码的响应报头 
void HandlerError(std::string page)
{
    std::cout<<"debug: "<<page<<std::endl;
    http_request.cgi = false;
    
    http_response.fd = open(page.c_str(),O_RDONLY);// 给用户返回对应的错误页面
    if(http_response.fd > 0)
    {
        struct stat st;
        stat(page.c_str(),&st);
        http_request.size = st.st_size;// 更新打开页面的信息

        std::string line = "Content-Type: text/html";// 所以错误页面都是文本文件,文件类型都为text/html
        line += LINE_END;
        http_response.response_header.push_back(line);
        
        line = "Content-Length: ";// 错误页面文本文件的大小
        line += std::to_string(st.st_size);
        line += LINE_END;
        http_response.response_header.push_back(line);
    }
}

发送响应

构建完响应后,我们需要将响应中响应行响应报头,响应空行,响应正文依次发送给浏览器。

我们非cgi处理时用的是sendfile()函数,sendfile()函数在两个文件描述符之间传递数据完全在内核中操作,从而避免了内核缓冲区和用户缓冲区之间的数据拷贝,效率很高,被称为零拷贝。

在这里插入图片描述

senfile()函数原型

ssize_t senfile(int out_fd,int in_fd,off_t* offset,size_t count);

参数:

  • out_fd参数是待写入内容的文件描述符
  • in_fd参数是待读出内容的文件描述符
  • offset参数指定从读入文件流的哪个位置开始读,如果为空,则使用读入文件流默认的起始位
  • count参数指定文件描述符in_fd和out_fd之间传输的字节数。
// 发送响应
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){
        // 如果是cgi处理,直接发送响应正文给浏览器
        auto &response_body = http_response.response_body;
        size_t size = 0;
        size_t total = 0;
        const char* start = response_body.c_str();
        while(total < response_body.size() && (size = send(sock,start+total,response_body.size() - total,0))>0)
        {
            total += size;
        }
    }
    else{
        // 非cgi处理,将打开文件的内容发送给浏览器。
        sendfile(sock,http_response.fd,nullptr,http_request.size);
        close(http_response.fd);
    }
}

HTTP服务器

HTTP服务器的功能是等待客户端连接,连接成功后将客户端请求添加到线程中的任务队列中。

#pragma once

#include<iostream>
#include<pthread.h>
#include<signal.h>
#include"Log.hpp"
#include"TcpServer.hpp"
#include"Protocol.hpp"
#include"Task.hpp"
#include"ThreadPool.hpp"

#define PORT 8081

class HttpServer{
    private:
        int port;
        bool stop;

    public:
        HttpServer(int _port = PORT):port(_port),stop(false)
        {}

        void InitServer()
        {
            // 如果是服务器正往sock中写入,而浏览器将连接关掉后,那么浏览器就会收到一个SIGPIPE的信号,
            // 此时服务器就会崩掉,因此我们在初始化服务器的时候需要忽略该SIGPIPE信号。
            signal(SIGPIPE,SIG_IGN);
            //tcp_server = TcpServer::getinstance(port);
        }

        void Loop()
        {
            TcpServer *tsvr = TcpServer::getinstance(port);
            LOG(INFO,"Loop begin");
            while(!stop){
                struct sockaddr_in* peer;
                socklen_t len = sizeof(peer);
                int sock = accept(tsvr->Sock(),(struct sockaddr*)&peer,&len);// 等待客户端连接
                if(sock < 0){
                    continue;
                }
                LOG(INFO,"Get a new link");
                Task task(sock);// 创建任务
                ThreadPool::getinstance()->PushTask(task);// 将任务添加到线程池中
            }
        }

        ~HttpServer()
        {}
};

引入线程池

为了避免同一时间有大量链接过来导致服务器内部线程暴增,从而引起服务器效率降低或挂掉,我们引入了线程池。线程池节省链接请求到来时,创建线程的时间成本,同时也让服务器的效率在一个恒定的稳定区间内。

#pragma once

#include <iostream>
#include <queue>
#include <pthread.h>
#include "Task.hpp"
#include "Log.hpp"

#define NUM 6

class ThreadPool{
    private:
        int num;
        bool stop;
        std::queue<Task> task_queue;// 任务队列
        pthread_mutex_t lock;
        pthread_cond_t cond;

        ThreadPool(int _num = NUM):num(_num),stop(false)
        {
            pthread_mutex_init(&lock,nullptr);
            pthread_cond_init(&cond,nullptr);
        }
        ThreadPool(const ThreadPool &){}// 防拷贝

        static ThreadPool* single_instance;
    public:
        static ThreadPool* getinstance()
        {
            static pthread_mutex_t _mutex = PTHREAD_MUTEX_INITIALIZER;// 静态初始化互斥锁
            if(single_instance == nullptr){
                pthread_mutex_lock(&_mutex);
                if(single_instance == nullptr){
                    single_instance = new ThreadPool();
                    single_instance->InitThreadPool();
                }
                pthread_mutex_unlock(&_mutex);
            }
            return single_instance;
        }

        bool IsStop()
        {
            return stop;
        }

        bool TaskQueueIsEmpty()
        {
            return task_queue.size()==0?true:false;
        }

        void Lock()
        {
            pthread_mutex_lock(&lock);
        }

        void Unlock()
        {
            pthread_mutex_unlock(&lock);
        }

        void ThreadWait()
        {
            pthread_cond_wait(&cond,&lock);
        }

        void ThreadWakeup()
        {
            pthread_cond_signal(&cond);
        } 

        static void *ThreadRoutine(void *args)
        {
            ThreadPool *tp = (ThreadPool*)args;

            while(true){
                Task t;
                tp->Lock();
                while(tp->TaskQueueIsEmpty()){
                    tp->ThreadWait();// 当我醒来时,一定是有互斥锁的
                }
                tp->PopTask(t);
                tp->Unlock();
                t.ProcessOn();
            }
        }

        bool InitThreadPool()
        {
            // 创建线程
            for(int i=0;i<num;i++)
            {
                pthread_t tid;
                if(pthread_create(&tid,nullptr,ThreadRoutine,this)!=0){
                    LOG(FATAL,"create thread pool error!");
                    return false;
                }
            }
            LOG(INFO,"create thread success!");
            return true;
        }

        void PushTask(const Task &task)
        {
            Lock();
            task_queue.push(task);
            Unlock();
            ThreadWakeup();
        }

        void PopTask(Task &task)
        {
            task = task_queue.front();           
            task_queue.pop();
        }

        ~ThreadPool()
        {
            pthread_mutex_destroy(&lock);
            pthread_cond_destroy(&cond);            
        }
};

ThreadPool* ThreadPool::single_instance = nullptr;

项目流程

整体框架

  1. 启动服务端,对http服务器初始化

在这里插入图片描述

  1. 等待客户端连接,连接成功后将客户端请求添加到线程中的任务队列中。

在这里插入图片描述

任务类

在这里插入图片描述

  1. 在线程池中处理任务队列

在这里插入图片描述

4.处理http请求

在这里插入图片描述

在这里插入图片描述

处理http请求

  1. 整体概括

在这里插入图片描述

  1. 读取请求行

在这里插入图片描述
在这里插入图片描述

  1. 读取请求报头

在这里插入图片描述

  1. 解析请求行

在这里插入图片描述

  1. 解析请求报头

在这里插入图片描述
在这里插入图片描述

  1. 读取请求正文

在这里插入图片描述

构建响应

  1. 提取资源路径

在这里插入图片描述

  1. 获取资源路径

在这里插入图片描述

  1. 非cgi处理

在这里插入图片描述

  1. cgi处理

准备操作:创建双向管道方便程序替换后子进程和父进程间的交流

在这里插入图片描述

子进程和cgi处理进行程序替换

在这里插入图片描述

父进程将请求正文读入管道,并从管道中获取响应主体

在这里插入图片描述

  1. 构建响应报文

构建响应报头行

在这里插入图片描述

状态码转字符串

在这里插入图片描述

构建响应报头,这里只有两条资源 文件类型 和 文件大小

在这里插入图片描述
文件后缀转换

在这里插入图片描述

返回响应

在这里插入图片描述

总结

该项目涉及了很多知识点,博主花了将近两个月的时间,断断续续完成了该项目。下面是该项目的全部代码

码云链接

  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值