毕业设计 HTTP 自助服务

2024-01-09

目录

项目 HTTP 自助服务

介绍

项目展示

背景知识

HTTP 协议

HTTP自主服务编写

sock 套接字编写

Tcp 服务器编写

小组件 锁守卫(lockGuard)

测试Tcp服务器运行

编写 HTTP 服务

Tcp 服务中获取监听套接字函数

Thread 类编写

HTTP 协议处理

工具类 Util

测试对 HTTP 报头处理

Protocol 添加对 HTTP 请求处理

测试对 HTTP 请求处理

Protocol 添加分析请求

处理请求资源路径

测试路径分析

Protocol 添加构建响应

非 CGI 模式

测试响应发送

添加响应报头

Protocol 添加数据发送

添加 CGI 处理模式

测试 CGI 模式

CGI 模式发送数据

HTTP 发送响应

逻辑错误处理

读取错误处理

写入错误处理

引入线程池

引入表单

​编辑

引入数据库

数据库引入测试

HTTP服务项目总结


项目 HTTP 自助服务

介绍

HTTP 是应用层协议,在网页浏览中,一般都是使用的是 HTTP 协议,但是目前由于安全问题,基本都换成了 HTTPS 协议,不过 HTTPS 协议只是在 HTTP 协议上加了安全层(TLS/SSL),所以我们学习 HTTP 协议,以及对 HTTP 协议的理解还是很重要的。

我们这个项目就是可以让学习者更好的理解 HTTP 协议,以及清楚 HTTP 协议中的细节,我们会从 0 开始编写网络套接字代码,知道 HTTP 协议,在这个项目里面,我们可以学习到静态网页的返回,还有使用 HTTP 协议获取计算机其他资源,以及 HTTP 如何响应,以及 HTTP 报头的各种细节,还有 cgi 模式等...

因为 HTTP 目前已经是很庞大了,所以我们将 HTTP 协议里面的重要的内容抽取出来,总结 HTTP 协议来实现一个自己的 HTTP 服务,并且会将每一个模块拆分出来为了更好的理解 HTTP 服务。

该项目采用浏览器/服务器模式(BS)模式,客户端使用浏览器,我们只关心服务器代码的编写。

项目代码:

HTTP自主服务​

项目环境:CentOS7 开发环境:vim + VSCode

项目展示

1.非 CGI 模式 静态网页返回

2.表单 CGI 模式GET方法数据计算

结果:

3.表单 CGI 模式POST方法数据计算

还是上面的算式,我们看结果:

4.CGI 模式 注册登录

结果:

数据库:

背景知识

在介绍 HTTP 服务的时候,我们先介绍一些关于HTTP 的背景知识:

URI:URI 被成为统一资源标识符,可以表示互联网中唯一的一个资源。

URL:URL 其实可以看做特殊的 URI ,它也是唯一的,而URL被成为统一资源定位符,因为一般路径也是唯一的。

WWW:www 是万维网

DNS:域名解析

例如:baidu 就是一个域名,后面的 .com 表示的是公司是盈利公司

在域名后面还可以加端口号,但是一般域名后面不加端口号,因为端口号可以省略,HTTP 服务的端口号默认就是 80,所以可以省略。

在域名后面还可以加资源路径,但是也可以省略,如果省略的话,那就是默认首页,也就是 web 根目录。

访问互联网中的资源一定需要IP和端口,源IP,源端口,目的IP,目的端口,也就是四元组,但是访问某一个网站我们使用的是域名,那么就是没有四元组吗?并不是,而是域名比较好记一些,而域名背后还是四元组,域名和IP及端口就是一个映射的关系。

虽然我们平时使用的是域名,但是域名经过 DNS(域名解析服务)后还是使用的端口+IP。

HTTP 协议

我们在看一下 HTTP 协议,我们之前学习过 HTTP 协议,HTTP 有请求和响应,构成的大体是相同的。

HTTP请求是由四部分构成:

  • 请求行:请求方法、资源路径、协议/版本 请求行结束后用 \r\n 隔开

  • 请求报头:请求报头中有很多字段,每一个字段后面使用 \r\n 隔开

  • 空行:空行就是一个 \r\n

  • 请求正文:里面就是请求的内容

HTTP响应也是由四部分组成:

  • 状态行:协议/版本、状态码、状态码描述 状态行结束后,使用 \r\n 隔开

  • 响应报头:响应报头是由多个字段构成,每个字段后面使用 \r\n 隔开

  • 空行:空行就是一个 \r\n

  • 响应正文:里面就是一些响应内容

HTTP 协议的特点:灵活——因为 HTTP 协议是超文本传输协议,用来传输各种内容,灵活是因为 HTTP 协议的请求报头中有一个字段 Content-Type: 字段,该字段表示的是传输的是哪一种文本类型,所以就是灵活。

简单,因为 HTTP 协议本身并不复杂,所以就是简单。

无链接,为什么说 HTTP 协议无连接呢?HTTP 协议底层不是采用的是 TCP 协议吗?TCP 协议不是面向连接的吗?那么为什么说 HTTP 协议是无连接呢?因为 TCP 的面向连接说的是 TCP 而并不是 HTTP 协议, HTTP 协议就是无连接的,在HTTP 1.0中,采用的就是短链接,处理完一个请求 HTTP 就会断开连接,所以 HTTP 是无连接的。

无状态,无状态也是 HTTP 协议的一个特点,也就是 HTTP 协议不会记得之前发送的内容,也就是 HTTP 协议没有状态,但是我们在登录某个网页的时候不是感觉就是有状态的呀,当我们登陆后,下一次登录,或者后面登录,也是我们当前登录的用户呀,那么为什么说 HTTP 协议是无状态呢?因为实际上 HTTP 协议是不会记录用户状态的,而记录用户状态的实际上是另一种手段 cookie 和 session 来记录用户状态。

下面的 HTTP 服务我们就一边写代码一边讲解其他的内容:

HTTP自主服务编写

首先我们编写 HTTP 服务,因为 HTTP 服务是基于 TCP 所以我们先写一个 TCP 服务器,TCP 服务器可以创建套接字,绑定和设置监听套接字等...

这个就是我们当前的步骤:

编写 TCP 服务,那么我们需要创建套接字等,我们为了更简单,我们将创建套接字(sock)的函数编写为一个类,所以然后我们定义一个 sock 对象,这个对象里面包含了所有关于套接字的函数,我们只需要将这个对象放到 TCP 服务器的成员变量中即可,然后 Tcp 服务器还需要提供构造函数,构造函数里面需要将服务器的资源初始化好,所以在 Tcp 的构造函数中,需要创建套接字,绑定,设置监听套接字。

当TCP 服务器创建好后,那么就可以启动了,也就是还需要一个 start 函数,来启动TCP服务器,所以我们还需要一个 main.cpp 的文件,这个文件里面是主函数,里面用来创建 TCP 对象,并且启动 TCP 服务。

因为 TCP 是一个服务器对象,所以我们想要 TCP 服务只有一个对象,所以我们需要将 TCP 设置为单例对象,我们目前先简单的写这么一些代码,等将套接字创建好后,简单的测试完成后,我们在进行后续的操作。

在这个项目中,使用到了一个 log.hpp 的文件,这个文件其实就是日志文件,而这里也不会多介绍这个文件里面的内容,这个文件就是简单的日志打印等,所以这里如果使用到日志文件里面的函数请不要太过于关注。

因为我们想要写 TCP 服务器,所以我们先需要 sock 对象,里面就是关于创建套接字等的一些接口:

sock 套接字编写

这些文件里面的内容先根据当前需要编写,如果后续有需要,那么还会增加。

#pragma once
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
​
#include <string>
​
#include "log.hpp"
​
#define BACKLOG 20
​
class Sock
{
public:
    int Socket()
    {
        int listensock = socket(AF_INET, SOCK_STREAM, 0);
        if (listensock < 0)
        {
            // 说明创建套接字失败
            log(FATAL, "创建监听套接字失败!");
            exit(1);
        }
        // 设置地址复用
        int reuse = 1;
        int r = setsockopt(listensock, SOL_SOCKET, SO_REUSEADDR, (void *)&reuse, sizeof(reuse));
        if (r < 0)
        {
            // 说明设置地址复用失败!
            log(FATAL, "设置地址复用失败!");
            exit(11);
        }
        log(DEBUG, "TcpServer 创建成功~");
        return listensock;
    }
​
    void Bind(int listensock, int port, std::string ip = "0.0.0.0")
    {
        struct sockaddr_in peer;
        // 将 peer 清0
        bzero(&peer, sizeof(peer));
        // 协议家族
        peer.sin_family = AF_INET;
        // 端口号——需要主机转网络
        peer.sin_port = htons(port);
        // IP 需要主机转网络,需要将点分十进制转为主机序列
        inet_aton(ip.c_str(), &peer.sin_addr);
        // bind
        int r = bind(listensock, (struct sockaddr *)&peer, (socklen_t)sizeof(peer));
        if (r < 0)
        {
            // 表示 bind 失败
            log(FATAL, "绑定失败!");
            exit(2);
        }
    }
​
    void Listen(int listensock)
    {
        int r = listen(listensock, BACKLOG);
        if (r < 0)
        {
            // 设置监听套接字失败
            log(FATAL, "设置监听套接字失败!");
            exit(3);
        }
    }
​
    int Accept(int listensock)
    {
        struct sockaddr_in peer;
        socklen_t len = sizeof(peer);
        int sock = accept(listensock, (struct sockaddr *)&peer, &len);
        if (sock < 0)
        {
            // accept 失败
            log(ERROR, " accept 失败!");
            return -1;
        }
        return sock;
    }
​
private:
    static Log log;
};
​
Log Sock::log;

Tcp 服务器编写

当我们有了 Sock.hpp 文件,那么我们开始写 TcpServer.hpp 文件,该文件就是有一个 TcpServer 的类,这个类里面就是创建将Tcp服务器的资源初始化好,然后开始 accept 在网络中获取想要连接的客户端,然后处理发送过来的亲求即可。

#pragma once
#include <iostream>
​
#include "Sock.hpp"
#include "LockGuard.hpp"
#define PORT 8080
​
class TcpServer
{
public:
    // 单例模式获取单例对象
    static TcpServer *getObject()
    {
        // 判断单例对象是否为 null 如果为 null 那么说明此时还没有构造这个对象,就需要构造这恶鬼对象
        if (_singleton == nullptr)
        {
            // 使用因为创建单例对象使用了 new 是线程不安全的,所以这里创建对象需要加锁
            // 定义一个 static 的锁,让全局只有一个
            static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
            {
                // 而这里加锁使用的是锁守卫
                // 锁守卫在下一个代码,可以先看下一个代码在回过头来看
                LockGuard Mtx(&lock);
                if (_singleton == nullptr)
                {
                    log(DEBUG, "获取一个单例~");
                    _singleton = new TcpServer();
                }
            }
        }
        log(DEBUG, "返回一个单例~");
        return _singleton;
    }
​
    void Start()
    {
        while(true)
        {
            // 这里为了测试获取连接
            int sock = _sock.Accept(_listensock);
            log(DEBUG, "获取到一个链接 sock: %d", sock);
            if(sock > 0)
            {
                // 获取到连接,这里直接将读取到的字符看作是字符串打印出来,这里就是为了看一下 HTTP 请求的样子
                char buffer[1024] = {0};
                ssize_t s = recv(sock, buffer, 1024, 0);
                if(s > 0)
                {
                    std::cout << buffer << std::endl;
                }
            }
        }
    }
​
private:
    // 设计单例模式,将构造函数私有,外面直接构造不了对象,只能调用 getObject 函数获取对象
    TcpServer(int port = 8080)
        : _port(port), _listensock(-1)
    {
        // 1. 创建监听套接字
        _listensock = _sock.Socket();
        // 2. bind 这里让 ip 缺省
        _sock.Bind(_listensock, PORT);
        // 3. 设置监听套接字
        _sock.Listen(_listensock);
    }
    // 将拷贝构造函数删除,防止单例对象拷贝构造
    TcpServer(const TcpServer& tcp) = delete;
    // 将复制重载删除,防止单例对象调用赋值函数
    TcpServer operator=(TcpServer tcp) = delete;
​
private:
    // 套接字对象
    Sock _sock;
    // 端口号
    int _port;
    // 监听套接字
    int _listensock;
    // 单例对象
    static TcpServer *_singleton;
    // 日志对象
    static Log log;
};
// static 的讲台成员对象需要在类外初始化
Log TcpServer::log;
// 单例模式
TcpServer *TcpServer::_singleton = nullptr;

小组件 锁守卫(lockGuard)

锁守卫就是利用 RAII 模式,防止资源泄露,而上面创建单例对象的时候,由于是线程不安全的,所以需要使用加锁来让线程安全而上面的加锁因为害怕加锁后忘记释放,所以使用了锁守卫:

利用 RAII 来达到锁守卫,该对象的成员变量是一个 pthread_mutex_t 类型的指针,当构造该对象的时候,使用传进来的锁加锁,当该对象析构的时候,会自动调用解锁。

#pragma once
#include <pthread.h>
​
class LockGuard
{
public:
    LockGuard(pthread_mutex_t *lock)
        : _lock(lock)
    {
        pthread_mutex_lock(_lock);
    }
​
    ~LockGuard()
    {
        pthread_mutex_unlock(_lock);
    }
​
private:
    pthread_mutex_t *_lock;
};

测试Tcp服务器运行

下面为了测试,我们先简单的的调用一下前面写的代码:

#include "TcpServer.hpp"
#include <unistd.h>
​
​
int main()
{
    TcpServer* svr = TcpServer::getObject();
    svr->Start();
    return 0;
}

当我们启动该函数的时候,然后我们使用浏览器进行访问该该服务,此时该服务就会收到连接,然后进行读取,将浏览器发送来的HTTP请求读取到后以字符串的形式打印出来。

上面这个就是我们获取到的结果,而这个就是 HTTP 请求,但是上面并没有正文。

既然我们看到了请求,那么我们稍微看一下请求里面的字段:

请求行:

  • GET:表示请求方法

  • /:就是 URL 表示请求的资源路径

  • HTTP/1.1:表示协议以及版本

请求报头:

  • Host:后面用冒号分割表示请求的主机的IP和端口

  • Connection:后面用冒号分割,有一个 keep-alive 表示长连接,但是长连接需要连接管理,我们不考虑

  • 后面剩下的字段表示一些请求主机的浏览器信息,以及语言等,这些不重要

那么我们看一下这一次我们想要谈论的话题,也就是 URL 这个字段,URL 在请求行中,这里的 URL 我们前面说了一下,这个URL是反斜杠,在 Linux 中表示的根目录,但是这里真的表示的是根目录吗?并不是,这里其实表示的是 web 根目录,那么web根目录又是什么呢?其实 web 根目录就是 Linux 下的一个特定的目录。

上面的这个 recv 的代码只是为了测试一下,下面我们还是会一边测试一遍写。

既然是 Http 服务,那么我们当然也是需要一个 HttpServer.hpp 的文件的。

编写 HTTP 服务

因为 HTTP 服务是基于 TCP 的,所以 HttpServer 的类中需要一个 Tcp 的类对象,其实也可以直接在 Tcp 的类中把 Http 写出来,但是因为我们这里写的是 Http 所以我们还是将其分开的好一点。、

Http 服务中,我们在 Http 的构造函数中,我们需要将 Tcp 的对象构造好,此时我们就可以进行 Accept 了,也就是获取连接了,获取到连接后,我们就可以进行读写了,这里的话我们获取到连接我们打算创建线程,然后让线程执行对应的函数,然后主线程继续 Accept ,然后新线程进行处理请求,响应等等...

当 Http 服务构造好后,然后进行启动,启动了之后在进行 accept

#pragma once
​
#include <string>
#include "TcpServer.hpp"
#include "Pthread.hpp"
#include "Protocol.hpp"
​
#define PORT 8080
​
​
class HttpServer
{
public:
    HttpServer(int port = PORT)
        :_listensock(-1)
        ,_svr(nullptr)
    {
        // 调用 TcpServer 类中的获取单例的函数
        _svr = TcpServer::getObject(port);
        // 因为在 Http 是需要 Accept 的,所以我们是需要监听套接字的,所以这里为 TcpServer 类中写了一个获取监听套接字的函数 getListensock 函数 这个函数只需要返回监听套接字即可
        _listensock = _svr->getListensock();
    }
​
    void Loop()
    {
        while(true)
        {
            // 连接,获取套接字
            int sock = _sock.Accept(_listensock);
            // 创建线程
            int* args = new int(sock);
            // 创建一个线程对象(这个线程类下一个代码看)
            // 这里传入的函数时一个 Entrance 类的一个函数,这里的参数只是一个 sock,但是这里是 new 出来的,所以再线程执行的函数里面还是需要 delete 的,否则就会导致内存泄露,这个类也是下面看
            Thread t(Entrance::Handler, args);
            // 让线程对象执行传入的函数
            t.Run();
        }
    }
private:
    int _listensock;
    Sock _sock;
    TcpServer* _svr;
};

Tcp 服务中获取监听套接字函数

上面因为再 Http 中需要获取 Tcp 中的监听套接字,所以需要再 Tcp 中添加一个获取监听套接字的函数:

    // 获取监听套接字
    int getListensock()
    {
        return _listensock;
    }
    // 因为TcpServer中不需要 Start 函数了,因为 Http 中有 Loop 函数了,所以 accept 的任务就交给了 Http 的 Loop 函数,所以就可以删除 TcpServer 中的 Start 函数,但是不删除也是可以的,因为我们不调用的话就不影响

Thread 类编写

因为上面再创建线程的时候,使用了一个线程的对象,这里看一下线程的类:

#pragma once
​
#include <functional>
#include "log.hpp"
#include <pthread.h>
​
typedef void*(*threadCallBack_t)(void*);
​
class Thread
{
public:
    Thread(threadCallBack_t thread_cb, void* args)
        :_thread_cb(thread_cb)
        ,_args(args)
    {}
​
    void Run()
    {
        // 创建线程,让线程执行对应的函数
        int r = pthread_create(&_tid, nullptr, _thread_cb, _args);
        if(r != 0)
        {
            log(ERROR, "创建线程失败!");
        }
        // 将创建好的线程分离
        log(DEBUG, "分离线程 tid: %u", _tid);
        pthread_detach(_tid);
    }
private:
    // 线程 id
    pthread_t _tid;
    // 线程执行函数的参数
    void* _args;
    // 线程的回调函数
    threadCallBack_t _thread_cb;
    // 日志对象
    static Log log;
};
​
Log Thread::log;

HTTP 协议处理

这个文件里面就是线程执行的函数,目前只有这么一个函数,而这个就是协议,也就是处理 HTTP 协议等...

#pragma once
#include <iostream>
#include <string>
#include <vector>
#include "Sock.hpp"
#include "Util.hpp"
​
class Entrance
{
public:
    //  线程执行的函数
    static void *Handler(void *args)
    {
        // 这里将 args 参数转化为 sock
        int sock = *(int *)args;
        //释放 args 占用的内存
        delete (int *)args;
        // 这里是用来读取请求报头的,也就是空行之前的内容
        std::vector<std::string> reqHead;
        while (true)
        {
            std::string line;
            // 使用 ReadLine 函数来读取,该函数传入一个文件描述符就可以读取该文件描述符中的一行数据,这个函数放在 util 类中,这个类中会有一些小工具函数
            Util::ReadLine(sock, &line);
            // 如果 line 是空的,表示没有读取到内容,也就是读取到了空行,如果读取到空行就退出
            if (line.empty()) break;
            // 如果读取到了数据,那么就放到请求报头中
            reqHead.push_back(move(line));
        }
        // 这里是为了测试,所以将读取到的报头打印出来
        for(auto& str : reqHead)
            std::cout << "debug: " << str << std::endl;
    }
};

工具类 Util

这个类就是工具类,里面放的就是一些需要的字符串处理等的一些小函数,目前只有一个读取一行内容的函数,也就是上面用到的一个函数:

#pragma once

#include <iostream>
#include <string>
#include "Sock.hpp"

class Util
{
public:
    static int ReadLine(int sock, std::string* out)
    {
        while(true)
        {
            char ch = 0;
            ssize_t s = recv(sock, &ch, sizeof(ch), 0);
            if(s < 0)
            {
                // 读取错误
                return -1;
            }
            else if(s == 0)
            {
                // 对端关闭连接
                return 0;
            }
            else
            {
                // 判断 ch 是否为 \n 表示读取到了完整的一行,但是因为在不同的环境下,行分割符可能不一样
                // \r\n
                // \r
                // \n
                if(ch == '\r')
                {
                    // 如果是 \r 那么就需要判断下一个字符是不是 \n 如果是 \n 那么就需要将下一个字符读取出去,如果不是,那么就可以将 \r 修改为 \n 然后退出
                    // 但是怎么判断下一个字符是否是 \n ,读取出来判断吗?不需要读取出来再判断,我们可以利用 recv 函数的数据窥探功能 MSG_PEEK 标记
                    s = recv(sock, &ch, sizeof(ch), MSG_PEEK);// 数据窥探
                    if(ch == '\n')
                    {
                        // 如果是 \n 那么就将 \n 读取出去,这一行读取完成
                        recv(sock, &ch, sizeof(ch), 0);
                    }
                    else
                    {
                        // 不是 \n 那么只需要将 ch 置为 \n 即可,表示这一行读取完成
                        ch = '\n';
                    }
                }
                if(ch == '\n') break;
                *out += ch;
            }
        }
        return out->empty() ? INT32_MAX : out->size();
    }
};


    static void CutString(const std::string& message, std::vector<std::string>* out, const std::string& sep)
    {
        size_t pos = 0, start = 0;
        while(true)
        {
            pos = message.find(sep, start);
            if(pos == std::string::npos)
            {
                if(pos != start)
                {
                    out->push_back(message.substr(start, pos - start));
                }
                break;
            }
            out->push_back(message.substr(start, pos - start));
            start = pos + sep.size();
        }
    }

测试对 HTTP 报头处理

因为我们对上面代码进行了修改,所以我们修改 main.cc 里面的代码,然后再一次进行测试,看是否可以将请求报头全部分开:

int main()
{
    std::unique_ptr<HttpServer> httpSev(new HttpServer());
    httpSev->Loop();
    return 0;
}

那么我们后面如何对待 HTTP 请求呢:

1.读取 HTTP 请求

2.分析 HTTP 请求

3.处理 HTTP 请求

4.响应 HTTP 请求

上面就是我们处理 HTTP 请求的几个大的步骤,其中还有小步骤我们一遍写一遍看,那么目前我们读取 HTTP 氢气正确吗?不正确!因为我们一次是按照固定的大小读取的,一次读取 1024 字节,但是因为 HTTP 协议底层采用的是 TCP 而TCP协议是面向字节流的,也就是说可能会有粘包等问题,所以我们需要处理这些问题。

上面我们直接在线程执行的函数里面读取了报头,但是我们是不能直接这样读取的,既然是 http 服务,那么我们当然是需要 http 读取到的请求与响应的。

所以我们就需要在 Protocol 协议文件中添加对应的请求与响应。

Protocol 添加对 HTTP 请求处理

因为我们要读取 http 请求,那么我们就需要将读取到的内容存储起来,供以后分析以及处理使用,因为要处理请求,所以还需要在请求里面有处理过后的内容也需要保存起来,那么请求和响应里面分别需要哪些字段呢?

HTTP请求:

1.请求行 2.请求报头 3.空行 4.正文

还需要将处理过的请求也保存起来,目前我们只需要处理请求行即可,那么请求行需处理后有哪些字段呢?

请求行:

1.请求方法 2.请求资源路径 3.协议/版本

HTTP响应:

1.状态行 2.响应报头 3.空行 4.正文

目前我们先就写这些,等后面不够了在继续添加,那么有了请求和响应那么就行了吗?我们还需要进行读取请求的函数,以及处理请求的函数,所

以我们还需要一个类,这个类里面就是对请求的读取以及处理,还需要可以分析请求,和发送请求,所以我们可以写一个 EndPoint 类,这个类里面就是我们想要的方法

EndPoint类:

该类中因为需要读取请求以及处理请求,还有将响应发送,所以这个类中一定需要请求对象,响应对象,因为是从 sock 中读取,所以还需要 sock 套接字。

那么该类的方法就是我们上面说的那些,但是一定还是有一些其他的小的方法来供上面说的那些方法来调用:

成员变量:

1.sock 套接字 2.http请求 3.http响应

成员方法:

1.RecvHttpRequest 读取请求 2.AnalysisHttpRequest 分析请求 3.BuildResponse 构建响应 4.SendResponse 发送响应

因为读取请求其实是需要分开读取的,首先是需要读取请求行,所以在读取请求的时候,我们先需要读取请求行,然后再读取请求报头,接着读取到空行请求报头就读取完毕了,正文我们后面再处理,因为我们并不知道正文有多少字节,所以我们是需要分析后才能进行读取的:

#pragma once
#include <iostream>
#include <string>
#include <sstream>
#include <vector>
#include "Sock.hpp"
#include <memory>
#include "Util.hpp"
#include "log.hpp"

class Request
{
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;
};

class Response
{
public:
    // 状态行
    std::string status_line;
    // 响应报头
    std::vector<std::string> responset_header;
    // 空行
    std::string blank;
    // 响应正文
    std::string response_body;
};

class EndPoint
{
private:
    // 读取请求行
    void RecvHttpRequestLine()
    {
        Util::ReadLine(sock, &http_request.request_line);
    }
	// 读取请求报头
    void RecvHttpRequestHeader()
    {
        while(true)
        {
            // 每次读取一行
            std::string line;
            Util::ReadLine(sock, &line);
            // 如果读取到空行就出去
            if(line.empty()) break;
            // 如果不是空行那么就添加到请求报头中
            http_request.request_header.push_back(line);
        }
    }

public:
    EndPoint(int _sock)
        :sock(_sock)
    {}
    ~EndPoint()
    {
        if(sock > 0)
            close(sock);
    }
    void RecvHttpRequest()
    {
        // 读取请求行
        RecvHttpRequestLine();
        // 读取请求报头
        RecvHttpRequestHeader();
        // debug: For Test 这里的打印是为了测试读取到的请求行与请求报头
        std::cout << "--------------begin---------------" << std::endl;
        log(DEBUG, "request_line: %s", http_request.request_line.c_str());
        for(auto& str : http_request.request_header)
            log(DEBUG, "request_header: %s", str.c_str());
    }

    void AnalysisHttpRequest()
    {
        // 处理请求行
        // stringstream 是 C++ 的一个类,这个类可以将输入的内容按照流的方式输出到 string 中
        std::stringstream ss(http_request.request_line);
        // 以空格分割,分别输入到下面三个对象中
        // 这里也可以自己写一个按照特定字符分割的一个函数
        ss >> http_request.method >> http_request.uri >> http_request.version;
        // debug: For  Test 这里打印是为了测试处理后的请求行
        log(DEBUG, "method: %s", http_request.method.c_str());
        log(DEBUG, "uri: %s", http_request.uri.c_str());
        log(DEBUG, "version: %s", http_request.version.c_str());
        std::cout << "--------------end---------------" << std::endl;
    }

    void BuildResponse()
    {

    }

    void SendResponse()
    {

    }
private:
    // 从该套接字中读取
    int sock;
    // 请求类的对象,里面存放的是关于 HTTP 请求的数据
    Request http_request;
    // 响应类对象,里面存放的是构建的响应的数据
    Response http_response;
    // 日志类
    static Log log;
};

Log EndPoint::log;

class Entrance
{
public:
    // 线程执行的方法
    static void *Handler(void *args)
    {
        int sock = *(int *)args;
        delete (int *)args;
// 从 sock 中读取数据
        //这个是为了测试
#ifndef DEBUG
        char buffer[1024];
        ssize_t s = recv(sock, buffer, 1024, 0);
        if (s > 0)
        {
            buffer[s] = 0;
            std::cout << buffer << std::endl;
        }
#endif
        // 这个是正式的执行逻辑
        // 线程执行的函数里面,有一个 EndPoint 的对象,执行里面对 HTTP 请求的处理与响应
        // 这里使用只能指针,防止资源泄露
        std::unique_ptr<EndPoint> ep(new EndPoint(sock));
        // 1. 读取 HTTP 请求
        ep->RecvHttpRequest();
        // 2. 分析 HTTP 请求
        ep->AnalysisHttpRequest();
        // 3. 构建 HTTP 响应
        ep->BuildResponse();
        // 4. 发送 HTTP 响应
        ep->SendResponse();
        // 目前只有这 4 步,如果后续有需要那么还回添加
    }
};

测试对 HTTP 请求处理

从我们上面的测试发现,我们确实读取到了请求行,请求报头,以及对请求行做处理得到的结果也是正确的。

Protocol 添加分析请求

既然已经上面测试完毕了,那么我们就可以将上面用于测试的代码注释掉,或者删除掉,我们接下来我们就要分析 HTTP 请求了,那么首先我们当然是分析 HTTP 的请求行,上面我们已经分析过了,但是因为这样直接写再分析的请求的函数里面不太好,所以我们可以将分析也拆分成多个小函数。

那么如果要分析的话,那么首先就是分析请求行,那么分析好的数据也需要存放,这里我们前面说了,这里只是把分析请求行给另外写了一个函数。

请求行里面有方法、uri、协议/版本,这里我们只处理 GET 和 POST 方法,所以再处理请求行的时候,我们还需要将请求方法全部转化为大写,这样方便我们后面进行判断。

请求行处理完成后,还需要处理请求报头,再 HTTP 里面请求报头是 KV 结构的,中间使用 ": "(冒号空格分割),所以我们需要将请求报头也分开,那么请求报头处理好之后,也是需要存起来的,既然是 KV 结构,那么我们可以使用 map 来存储,也可以使用 unordered_map 来存储,这里我们使用 unordered_map来存储。

请求处理完之后,因为可能还会有正文,所以我们需要将正文也读取出来,那么我们怎么知道是否有正文呢?在我们目前访问网页的时候,一般只会有两种:

1.从网站上获取数据(也就是GET方法)

2.上传数据,上传数据一般是从正文里面上传,但是 GET 方法也可以上传,不过只有 POST 方法才有正文(POST方法)

所以我们就需要判断是否为 POST 方法。

那么如果是 POST 方法,我们怎么知道正文里面有多少字节的数据呢?其实在请求报头中,有一个字段 Content-Length: 字段,这个字段后面就是表示正文里面有多少数据,而刚好我们也会把请求报头处理,所以我们只需要在请求里面的 unordered_map 的对象里面查看是否有 Content-length 这个字段,如果有的话,那么就可以读取正文了,所以在读取正文之前我们是需要判断是否有正文的,也就是判断是否为 POST 方法,以及是 POST 方法的话,报头里面有没有 Content-Length 字段,如果有该字段的话,那么才可以读取正文,然后我们将读取到的正文存放到 Request 的 body 中。

读取完正文就分析请求结束了吗?没有结束,因为 HTTP 请求里面的 GET 方法和 POST 方法的处理是不同的,前面我们也说了 GET 方法主要是为了获取数据,而 POST 方法是为了上传数据,但是 GET 方法也是可以上传数据的,只不过是通过 uri 同时也可以携带参数,虽然默认是不需要携带参数的,我们可以看一下我们在百度里面发起一个请求

百度的搜索就是GET方法提交数据,而当我们搜索的时候,我们搜索的 NBA就会以URI的形式递交给百度的搜索服务,其中 /s 就是路径,而中间使用 ?分割,?后面就是参数,其中参数与参数之间使用 & 连接。

所以我们知道了 GET 方法里面可能还回有参数,所以我们在分析请求的时候,还需要将请求行里面的 URI 中的路径和参数分别提取出来,不过只有有参数的话,才需要进行提取,所以我们还需要进行判断是否有参数,只有URI 中有?能说明是有参数的,所以我们就需要判断 URI 中是否有问好,有?才需要分割路径和参数,当然发那个好的路径和参数还需要保存起来:

private:
    void AnalysisRequestLine()
    {
        // 将请求行拆分为 方法、uri、协议/版本
        std::stringstream ss(http_request.request_line);
        ss >> http_request.method >> http_request.uri >> http_request.version;
        // 将请求方法全部转化为大写
        for (char &ch : http_request.method)
        {
            if (ch >= 'a')
            {
                ch -= 32;
            }
        }
        log(DEBUG, "method: %s", http_request.method.c_str());
    }
    // 分析请求报头
    void AnalysisRequestHeader()
    {
        std::vector<std::string> out;
        for (auto &message : http_request.request_header)
        {
            out.resize(0);
            // 使用字符串分割的这个函数可以以 ": "(冒号空格为分隔符)来分割
            Util::CutString(message, &out, HTTP_REQ_SEP);
            // 将分割好的 key 和 value 存放到请求对象中的 map 中
            // 这里就不将请求中的 unordered_map 在这里写一遍了,所以现在请求中多了一个成员变量就是:unordered_map<string string> header_kv
            http_request.header_kv.insert({out[0], out[1]});
        }
    }
​
    // 判断是有正文,如果有正文的话,那么需要让我们知道正文的长度是多少,并且有正文的话就返回 true
    bool IsNeedReadRequestBody()
    {
        // 只有 POST 方法才有正文
        if (http_request.method == "POST")
        {
            // 如果是 POST 方法,并且请求报头中还需要有 content-Length 字段
            if (http_request.header_kv.count("Content-Length"))
            {
                // 到了这里说明有正文的,不过可能长度为0,所以我们还需要将正文长度记录下来
                // 所以我们需要在请求中添加一个成员变量 int Content_Length; 里面存储正文的长度
                // 这里添加这个成员变量这里也就不写了
                http_request.Content_Length = stoi(http_request.header_kv["Content-Length"]);
                return true;
            }
        }
        return false;
    }
    
    // 读取正文,在读取正文前一定是需要判断是否有正文的
    void RecvRequestBody()
    {
        // 判断是否有正文,如果有正文,那么 Content_length 字段就被设置了
        if (IsNeedReadRequestBody())
        {
            int n = http_request.Content_Length;
            char ch = 0;
            while (n)
            {
                // 每次读取一个字节,知道将正文全部读取完毕
                ssize_t s = recv(sock, &ch, sizeof(ch), 0);
                if (s > 0)
                {
                    --n;
                    http_request.request_body.push_back(ch);
                }
                else if (s == 0)
                {
                    // 对端关闭连接
                    log(DEBUG, "%d 号文件描述符关闭连接", sock);
                    break;
                }
                else
                {
                    // 这里就是读取出错了,这里我们先不处理
                    break;
                }
            }
        }
    }
​
    // 判断是否有参数,有参数就返回 true 否则返回 false
    bool IsArgs()
    {
        std::string &uri = http_request.uri;
        // 如果找到了 ? 那么说明就是有参数的
        size_t pos = uri.find("?");
        if (pos == std::string::npos)
        {
            return false;
        }
        return true;
    }
​
    //  分析路径与参数
    void AnalysisUriPathAndArgs()
    {
        // 首先判断是否有参数
        if (IsArgs())
        {
            // 有参数,那么就分离参数与路径
            std::vector<std::string> out;
            Util::CutString(http_request.uri, &out, "?");
            // 为请求对象中添加路径和参数成员变量
            // string path; 路径
            // string query_string; 参数
            http_request.path = out[0];
            http_request.query_string = out[1];
        }
        else
        {
            http_request.path = http_request.uri;
        }
    }
​
public:
    void AnalysisHttpRequest()
    {
        // 1. 处理请求行
        AnalysisRequestLine();
        // 2. 处理请求报头
        AnalysisRequestHeader();
        // 3. 读取请求正文
        RecvRequestBody();
        // 4. 分析路径与是否带参
        AnalysisUriPathAndArgs();
    }

处理请求资源路径

当我们获得了 uri 中的路径与参数,前面我们看到,如果我们不带路径的话,那么就是默认路径也就是 /,那么这里的/指的是Linux下的根目录吗?其实不一定,而是指的是web根目录,而这个web根目录是一个特定的目录,这个,所以我们是需要对只有一个/进行处理的,也就是添加上我们的web根目录。

但是因为在一般情况下,我们都请求的是某一个特定的资源,如果只是一个目录的话,那么明显是没有指定我们想要请求的资源的,所以我们还是需要对请求资源是一个目录的情况下做特殊处理的,也就是如果请求是一个目录,那么就需要返回默认的首页, index.html。

    void DisposPath()
    {
        // 1. 为路径添加 web 根目录
        // #define WWWROOT "wwwroot" 表示的是web根目录,当前文件下的一个目录
        http_request.path.insert(0, WWWROOT);
        // 2. 因为一般的请求都是一个特定的资源,所以请求的资源是一个目录,那么就返回当前目录的首页信息
        if (http_request.path[http_request.path.size() - 1] == '/')
        {
            // 说明请求的是一个目录
            // HONE_PAGE 也是一个宏,其中表示的就是 "index.html" 表示首页信息
            http_request.path += HOME_PAGE;
        }
        else
        {
            //这里还需要判断请求的虽然不是/结尾,但是任然可能是一个目录,所以还需要处理
            // 那么到了这里说明文件一定可以访问吗?文件一定存在吗?
            // 到了这里说明不是以/结尾,但是任然可能是一个目录
            // 这里使用 stat 系统调用来查看文件属性
            struct stat st;
            int r = stat(http_request.path.c_str(), &st);
            if (r == 0)
            {
                // 这里说明文件是存在的,但是文件存在也不意味着一定可以访问,因为还是可能是一个目录
                // 判断是否是一个目录使用 stat 里面的一个宏 S_ISDIR(mode)
                if (S_ISDIR(st.st_mode))
                {
                    // 说明是一个目录,既然是一个目录,那么就需要添加首页信息,但是这里是没有以结尾的,所以还需要添加/
                    http_request.path += '/';
                    http_request.path += HOME_PAGE;
                }
                // 判断是否是一个可执行程序,如果是可执行程序,那么就需要是 cgi 模式
                // 判断是否是可执行程序在 struct stat 对象里面有一个属性 mode 这个里面就是常见的属性,里面包含了文件的类型以及可执行权限等、
                // 判断可执行权限只需要文件的拥有者,所属组,其他人,中任意一个有可执行权限那么就是一个可执行文件
                // 判断拥有者、所属组、其他人是否有可执行权限的话,mode 定义了很多属性,其中也是宏没其中就包含文件的可执行权限
                r = stat(http_request.path.c_str(), &st);
                if (r == 0)
                {
                    if ((st.st_mode & S_IXUSR) || (st.st_mode & S_IXGRP) || (st.st_mode & S_IXOTH))
                    {
                        // 说明是拥有可执行权限 需要特殊处理
                        log(DEBUG, "请求的是可执行文件,特殊处理!");
                    }
                }
                else
                {
                    // 文件不存在
                    log(WARNING, "访问的资源不存在!");
                }
            }
            else
            {
                // 说明文件不存在
                log(WARNING, "访问的资源不存在!");
            }
        }
        // 处理结束后,还需要判断这个路径是否存在,如果不存在那么就是 404
        log(DEBUG, "uri: %s", http_request.uri.c_str());
        log(DEBUG, "path: %s", http_request.path.c_str());
        log(DEBUG, "query_string: %s", http_request.query_string.c_str());
    }
private:
    void AnalysisHttpRequest()
    {
        // 处理请求行
        AnalysisRequestLine();
        // 处理请求报头
        AnalysisRequestHeader();
        // 读取请求正文
        RecvRequestBody();
        // 分析路径与是否带参
        AnalysisUriPathAndArgs();
        // 处理路径
        DisposPath();
    }

测试路径分析

这里看到我们发起的请求方法是 GET

uri是上面的,然后通过路径解析,以及处理,在为路径添加了web根目录和首页,此时打印的路径就是正确的

还有参数也是正确的分析出来了

下面我们请求一个不存在的目录或者文件:

既然将请求也分析完成了,那么下面就需要构建响应,但是在构建响应的过程中也是参杂着分析,所以也是一遍构建一遍分析。

Protocol 添加构建响应

通过上面的测试,我们以及将路径的处理也测试完毕了,路径可以很好的处理了,但是我们因为在构建响应的时候,我们是需要查看路径是否存在等等,如果不存在那么就返回 404 或者请求出错的话,就返回 400 所以我们想把路径的分析放在构建响应中。

并且我们还想要给路径的分析添加一个返回值, bool 类型,如果路径存在,那么就返回 true,如果不存在,那么就返回 false,所以如果路径不存在那么就可以直接构建 404 NotFound。

并且如果请求的资源是一个可执行程序怎么办?这个就是我需要引入的 CGI 模式。

CGI 模式是 HTTP 一个非常重要的一个功能,CGI 模式就是可以让 HTTP 可以进行交互,因为 HTTP 只能返回静态的网页,但是如果用户想要上传数据,也就是有数据需要交给 HTTP 服务来处理,那么就需要 CGI 模式了,所以 CGI 模式非常重要。

那么有哪些需要进行 CGI 程序处理呢:

1.方法为 GET 且有参数 2.方法为 POST 3.请求的资源是可执行程序

上面的三种情况都是需要 CGI 程序处理的。

CGI 程序就是可以从 HTTP 的 body 或者 HTTP 服务创建子进程,然后通过环境变量和 HTTP 的请求 body 来作为标准输入,而 GCI 程序的标准输出就是想要的 body 这样来达到 HTTP 可以进行数据交互。

如果想要了解一下 CGI 那么可以看一下这篇文章:

[什么是CGI]

那么也就是我们再构架响应的时候是需要判断是否是 CGI 的,如果不是 CGI 那么就是简单的静态网页返回,如果出错了那么就需要构建 HTTP 响应,所以当方法是 GET 并且有参数的时候,我们就需要标注是 CGI,如果是 POST 方法那么也是 CGI,或者请求的资源是可执行程序,那么也是 CGI 模式,所以我们需要对上面的代码做一些小的改动:

1.在分析路径和参数的时候,我们就可以判断是否是 CGI 模式

   bool IsArgs()
    {
        if (http_request.method != "GET")
            return false;
        std::string &uri = http_request.uri;
        size_t pos = uri.find("?");
        if (pos == std::string::npos)
        {
            return false;
        }
        // 如果是 GET 方法,且有参数,那么就是 CGI 模式
        http_request.cgi = true;
        return true;
    }

2.在构建响应的时候,我们也可以判断是否为 POST 方法

    void BuildResponse()
    {
        // 我们目前只处理 http  中的两种方法:GET  POST
        if (http_request.method != "GET" && http_request.method != "POST")
        {
            // 说明不是 get 方法也不是 post 方法 那么就是有问题的
            log(WARNING, "请求方法错误!");
            goto END;
        }
        // 判断是否为 POST POST 也是 CGI 模式
        // 这里我们围殴请求中添加了一个成员变量, cgi 表示的是是否为 cgi 模式,如果是 true 那么表示就是 cgi 模式
        if(http_request.method == "POST") http_request.cgi = true;
        // 这里是处理路径,之前我们将处理路径放在分析请求中,但是我们现在将处理路径放在构建响应中,并且我们为处理路径添加了返回值,bool 类型,如果资源存在返回 true,否则返回 false
        // 在处理路径中,我们在判断如果是可执行程序的话,那么我们就需要将 cgi 设置为 true
        if (DisposPath() == false)
        {
            // 资源不存在直接到最后
            goto END;
        }
        // 如果是 CGI 那么就执行 CGI 程序
        // 这里我们1前面的分析路径,以及前提工作已经做完了,我们就可以开始处理请求了,并且在处理请求的时候,我们也需要构建响应
        // 到了这里,只有两种:
        // 1. 是 CGI 模式,也就是需要调用 CGI 程序
        // 2. 非 CGI 模式,那么就是 GET 方法,且没有参数,那么就是想要从服务器上下拉数据,也就是静态网页返回
        if(http_request.cgi)
        {
            // CGI
        }
        else
        {
            // 非 CGI
            ProcessNonCgi();
        }
​
    END:
        return;
    }

非 CGI 模式

上面已经将请求分析完毕了,到了下面,那么就剩下 CGI 模式,或者非 CGI 模式了,因为非 CGI模式比较简单,所以我们就处理非 CGI 模式,非 CGI 模式目前就是返回静态资源。

首先我们需要在非 CGI 里面构建 HTTP 响应,HTTP 响应分为四部分:

1.状态行:状态行里面又又协议/版本,状态码,状态码描述

2.响应报头:

3.空行:

4.响应正文:响应正文就是 HTTP 请求里面解析出来的路径,里面的文件就是想要的资源,所以就需要将该资源发送

但是因为是 HTTP 协议,所以不能直接发送,必须要构建 HTTP 响应才能发送。

    int ProcessNonCgi()
    {
        // 打开文件
        http_response.fd = open(http_request.path.c_str(), O_RDONLY);
        if (http_response.fd < 0)
        {
            // 打开文件失败
            log(ERROR, "文件打开失败!");
            http_response.code = NotFound;
            return http_response.code;
        }
        // 1. 构建 HTTP 响应的状态行
        // (1).协议/版本
        http_response.status_line += HTTP_VERSION;
        http_response.status_line += " ";
        // (2).状态码,如果在非 CGI 模式里面的话,那么状态码一定是 200 表示 OK
        // 因为响应里面需要状态码,所以我们在响应里面添加状态码的成员变量
        http_response.status_line += std::to_string(http_response.code);
        http_response.status_line += " ";
        // (3).状态码描述
        // 这里为了方便及简单,直接写一个函数,传入状态码就会返回状态码描述
        http_response.status_line += http_response.code2desc(http_response.code);
        http_response.status_line += HTTP_LINE_SEP;
        // 2. 构建 HTTP 响应报头
        // 先不处理报头
        // 3. 构建 HTTP 空行
        http_response.blank = HTTP_LINE_SEP;
        // 4. 正文
        // 因为 HTTP 服务是在用户层,而用户想要获取的数据在磁盘,如果读取的话,那么就是在内核层,但是如果我们将数据读取到 HTTP 服务中,那么就是
        // 又从内核读取到了用户层,但是发送的时候又会将数据发送到内核中,所以这明显是一个浪费,所以可以使用 sendfile 函数,将数据在内核中进行拷
        // 贝,从一个文件描述符直接在内核中拷贝给另一个文件描述符,但是这里不能直接将数据拷贝给 sock 因为发送是有顺序的,必须按照 HTTP 协议来发
        // 送,所以需要先发送 HTTP 的状态行,然后是报头,接着是空行,最后才是正文,所以这里我们可以将文件描述符打开,以供在发送函数里面直接将数据
        // 拷贝。
        // 如果要打开的话,那么直接打开的话,那么文件秒送护肤就会丢失,所以还需要再响应中保存一个可以储存文件描述符的属性,所以为响应类中添加一个成
        // 员变量 int fd; 表示的就是打开的文件描述符,然后再发送的时候直接将该文件描述符的内容拷贝到打开的 sock 中。
        // 但是打开文件再最开始打开文件,如果打开文件失败了,那么也就没有必要继续构建了,直接返回状态码 404 即可,然后再构建响应的函数里里面会处理
​
        // debug: 这里是为了测试,然后下面的发送代码也是测试
        std::string &body = http_response.response_body;
        char buffer[1024];
        while (true)
        {
            ssize_t s = read(http_response.fd, buffer, 1023);
            if (s > 0)
            {
                buffer[s] = 0;
                body += buffer;
            }
            else
                break;
        }
        return OK;
    }

这个函数如果成功的话,那么就返回状态码 200,如果失败了,那么就返回

那么当非 CGI 基本已经将轮廓处理完毕了,虽然我们还没有添加相应报头,但是目前已经可以可以达到返回数据了,所以我们现在先简单的写一下 send 函数,发送函数也只是测试,后续一定还回修改:

    void SendResponse()
    {
        std::string response;
        // 状态行
        response += http_response.status_line;
        // 响应报头
        for (auto &header : http_response.responset_header)
        {
            response += header;
        }
        // 空行
        response += "\r\n";
        // 正文
        response += http_response.response_body;
        // 发送
        // 这里发送采用循环发送,因为主要发送了多少数据,还是需要看 s 是多少,所以目前就这样发送
        while (response.size() > 0)
        {
            ssize_t s = send(sock, response.c_str(), response.size(), 0);
            response.erase(0, s);
        }
    }

测试响应发送

目前通过 telnet 测试,返回了数据,我们再首页中写入了一条信息, HelloWorld 所以目前也可以返回。

既然现在可以返回了,那么现在我们就可以写一些 html 静态网页了,但是写起来还是比较麻烦的,所以可以直接再往上下载模板,可以使用这个网站:

[模板王]  https://sc.chinaz.com/moban/

下载好了就可以直接使用了,下载好的网页解压好,然后将里面的内容拷贝到 wwwroot 目录低下可以了。

上面是为了测试,所以就方便一点,下面我们开始正式的添加相应报头

添加响应报头

上面我们是再 非CGI 函数里面直接构建的响应,为了代码简洁我们可以构建响应的各种函数分开,首先是构建状态行,状态行我们上卖弄已经写好了,只需要截切一下,然后重新写一个函数就好了。

接着是构建响应报头,响应报头里面也是kv结构,那么相应报头里面都会有哪些字段呢?其中相应报头里面最重要的两个字段就是 Content-Type: 和 Content-Length: 这两个字段,所以我们至少需要将这两个字段添加上,其中 Content-Length 表示的就是正文的长度,而Content-Type 就是表示的是返回数据的类型,其中类型有很多,想知道,返回数据的类型和什么有关呢?和请求资源的后缀,如果是 .html 那么类型就是 text/html 还有其他的类型,所以想要了解类型的,可以看一下这个网站:

Content-Type 对照表

如果还有想法的那么也可以添加其他的报头,但是目前我们只关系这两个。

报头结束后就是空行,空行就是 "\r\n"。

空行后面就是正文,正文我们不会读取到应用层,我们可以使用 snedfile 函数来直接再内核层拷贝,这样效率快一些。

    void AddStatus200Line()
    {
        // (1).协议/版本
        http_response.status_line += HTTP_VERSION;
        http_response.status_line += " ";
        // (2).状态码,如果在非 CGI 模式里面的话,那么状态码一定是 200 表示 OK
        // 因为响应里面需要状态码,所以我们在响应里面添加状态码的成员变量
        http_response.status_line += std::to_string(http_response.code);
        http_response.status_line += " ";
        // (3).状态码描述
        // 这里为了方便及简单,直接写一个函数,传入状态码就会返回状态码描述
        http_response.status_line += http_response.code2desc(http_response.code);
        http_response.status_line += HTTP_LINE_SEP;
    }
​
    void AddResponseBodySzie()
    {
        // 添加正文长度
        std::string content_length = "Content-Length: ";
        content_length += std::to_string(http_response.body_size);
        content_length += HTTP_LINE_SEP;
        http_response.responset_header.push_back(content_length);
    }
​
    std::string Suffix2Desc(const std::string suffix)
    {
        static std::unordered_map<std::string, std::string> Type{
            {".htm", "text/html"},
            {".htm", "text/html"},
            {".css", "text/css"},
            {".jpg", "image/jpeg"},
            {".jpeg", "image/jpeg"},
            {".png", "image/png"},
            {".woff", "application/font-woff"},
            {".js", "application/javascript"},
            {".svg", "text/xml"},
            {".*", "application/octet-stream"},
            {".", "application/x-"},
        };
        if (Type.count(suffix) > 0)
        {
            return Type[suffix];
        }
        return {};
    }
​
    void AddResponseBodyType()
    {
        // 1. 查看请求资源后缀
        std::string suffix;
        size_t pos = http_request.path.rfind('.');
        if (pos >= 0)
        {
            // 找到了
            suffix = http_request.path.substr(pos);
            std::string content_type = "Content-Type: ";
            content_type += Suffix2Desc(suffix);
            content_type += HTTP_LINE_SEP;
            // 添加相应报头
            http_response.responset_header.push_back(content_type);
        }
    }
​
    void AddResponseHeader()
    {
        // 1. 添加传输文本类型
        AddResponseBodyType();
        // 2. 添加正文长度
        AddResponseBodySzie();
    }
    
    // 非 CGI 函数
    int ProcessNonCgi()
    {
        // 打开文件
        http_response.fd = open(http_request.path.c_str(), O_RDONLY);
        if (http_response.fd < 0)
        {
            // 打开文件失败
            log(ERROR, "文件打开失败!");
            http_response.code = NotFound;
            return http_response.code;
        }
        // 1. 构建 HTTP 响应的状态行
        AddStatus200Line();
        // 2. 构建 HTTP 响应报头
        AddResponseHeader();
        // 3. 构建 HTTP 空行
        http_response.blank = HTTP_LINE_SEP;
        // 4. 正文
        // 因为 HTTP 服务是在用户层,而用户想要获取的数据在磁盘,如果读取的话,那么就是在内核层,但是如果我们将数据读取到 HTTP 服务中,那么就是
        // 又从内核读取到了用户层,但是发送的时候又会将数据发送到内核中,所以这明显是一个浪费,所以可以使用 sendfile 函数,将数据在内核中进行拷
        // 贝,从一个文件描述符直接在内核中拷贝给另一个文件描述符,但是这里不能直接将数据拷贝给 sock 因为发送是有顺序的,必须按照 HTTP 协议来发
        // 送,所以需要先发送 HTTP 的状态行,然后是报头,接着是空行,最后才是正文,所以这里我们可以将文件描述符打开,以供在发送函数里面直接将数据
        // 拷贝。
        // 如果要打开的话,那么直接打开的话,那么文件秒送护肤就会丢失,所以还需要再响应中保存一个可以储存文件描述符的属性,所以为响应类中添加一个成
        // 员变量 int fd; 表示的就是打开的文件描述符,然后再发送的时候直接将该文件描述符的内容拷贝到打开的 sock 中。
​
        return OK;
    }

正文的处理,我们直接再发送的时候进行。

Protocol 添加数据发送

响应的发送是需要更具 HTTP 协议来的,HTTP 协议里面首先是HTTP响应的状态行,接着是 HTTP 响应报头,然后是空行,最后就是发送正文部分。

    void SendResponse()
    {
        std::string response;
        // 状态行
        response += http_response.status_line;
        // 响应报头
        for (auto &header : http_response.responset_header)
        {
            response += header;
        }
        // 空行
        response += "\r\n";
        // 发送报头
        while (response.size() > 0)
        {
            ssize_t s = send(sock, response.c_str(), response.size(), 0);
            response.erase(0, s);
        }
        // 非 CGI 模式需要读取资源数据,然后发送,CGI 模式是根据 CGI 程序的标准输出决定的 
        if (http_request.cgi == false)
        {
            // 非 CGI 模式需要发送文件内容
            // 发送正文
            ssize_t s = sendfile(sock, http_response.fd, 0, http_response.body_size);
        }
        else
        {
            // CGI 正文发送
        }
    }

添加 CGI 处理模式

那么 CGI 怎么写呢?前面我们已经简单的介绍了一下 CGI 模式, CGI 就是当用户使用 GET 方法提交数据,并且带有参数的时候,说明用户有数据想让处理,那么就需要 CGI 函数来完成,所以就需要 CGI 处理,还有就是用户使用 POST 提交数据,也说明用户有1数据想让处理,所以还是需要 CGI 函数来处理,既然是用户想让 HTTP 服务执行其他的函数那么怎么办呢?程序替换!

既然我们已经有执行对应 CGI 程序的手段了,但是因为用户的请求是一个 HTTP 请求,所以请求的数据都在 HTTP应用层里面,因为进程是独立的,那么怎么样可以把数据传输给子进程呢?当子进程发生程序替换的时候,那么子进程之前的代码和数据可就都被替换了,但是进程关于内核的数据并不会被替换,例如文件描述符表、环境变量,那么如何交给子进程呢?可以通过匿名管道,因为是父子进程,所以匿名管道就可以通信,但是管道只能进行单方面的通信,那么又该怎么办呢?两个管道!

既然父进程1现在有手段可以将数据交给子进程了,但是现在又有了一个问题,如果使用管道通信,那么也就是需要向文件描述符中写数据,可是虽然打开的文件描述符不会消失,但是我们再 HTTP 应用层的文件描述符通过程序替换那么就会被替换,那么怎么办呢?重定向!

那么如何重定向呢?因为打开了两个匿名管道,也就是四个文件描述符我们打开的管道的读写都是以父进程的角度来看待的 inpipe 和 outpipe 其中每个管道都打开了两个文件描述符,这里的 inpipe 和 outpipe 都是以父进程的角度,其中 inpipe 表示读,outpipe 表示写,而 inpipe[0] 表示父亲从这个里面读,outpipe[1] 父进程从这个里面写,而子进程就是和父进程相反,也就是子进程以 inpipe 为写 也就是 inpipe[1] 里面写,outpipe 为读,从 outpipe[0] 中读,所以我们想要将子进程从 outpipe[0] 中写,重定向导标准输入,而 inpipe[1] 为标准输出。

上面解决了父子进程如何通信的问题,也就是如何将用户想要处理的数据交给 CGI 程序,同时 CGI 程序也可以将处理完的数据返回给父进程,而 CGI 程序的返回值我们就认为是正文,因为父进程需要将数据写给子进程,因为是写所以效率是比较低下的,但是如果使用 GET 方法的话,那么需要传输的数据是比较短小的,所以如果使用IO写的话,效率是慢的多,所以我们并不想对 GET 方法的 query_string 也进行写给子进程,那么怎么办呢?因为程序替换的话,环境变量也不会被替换,所以我可以将 query_string 导为环境变量,此时就可以让替换后的子进程也看到GET提交的数据了,而 POST 方法由于是正文,所以大概率是比较长一些的,所以我们就使用管道的方式来进行数据传输。

因为上面 GET 和 POST 方法使用了不同的数据传输手段,那么作为 CGI 程序,怎么知道应该从环境变量里面读呢?还是从标准输入中读呢?所以为了让 CGI 程序知道,我们首先得告诉 CGI 程序我们使用的是什么方法,所以我们需要将使用的方法也导为环境变量,交给子进程,所以在 CGI 程序中,就可以先获取方法的环境变量,然后根据方法判断使用哪一种读取数据的机制。

目前我们先了解 CGI 程序处理过程的这些步骤,等这些步骤通过测试了,那么我们在进行后续步骤:

    bool CreateReadWritePipe(int inpipe[2], int outpipe[2])
    {
        // 这里的读取还是写入都是以父进程的角度
        int r = pipe(inpipe);
        if (r < 0)
        {
            log(ERROR, "创建 inpipe 管道失败!");
            return false;
        }
        r = pipe(outpipe);
        if (r < 0)
        {
            log(ERROR, "创建 outpipe 管道失败!");
            // 需要关闭前面创建的两个管道
            close(inpipe[0]);
            close(inpipe[1]);
            return false;
        }
        return true;
    }
​
    // 重定向
    bool Redirection(int readfd, int writefd)
    {
        int r = dup2(readfd, 0);
        if (r < 0)
        {
            log(ERROR, "重定向失败!");
            false;
        }
        r = dup2(writefd, 1);
        if (r < 0)
        {
            log(ERROR, "重定向失败!");
            return false;
        }
        return true;
    }
​
    int ProcessCgi()
    {
        log(DEBUG, "CGI模式处理任务~");
        // 处理 CGI 就是执行 CGI 函数
        std::string &path = http_request.path;
        std::string &query_string = http_request.query_string;
        // 创建管道
        int inpipe[2];
        int outpipe[2];
        if (CreateReadWritePipe(inpipe, outpipe) == false)
        {
            http_response.code = NotFound;
            return NotFound;
        }
​
        int pid = fork();
        if (pid == 0)
        {
            // 子进程
            // 1. 因为程序替换后,数据和代码会被替换掉,所以当发生程序替换后,上面的 inpipe 和 outpipe 都会被替换掉,因为这两个数据是应用层的数据,
            // 虽然说应用层的数据被替换了,但是内核的数据并不会被替换,也就是打开的文件描述符,以及环境变量等,并不会被替换,这里为了让读写更好,所
            // 以这里先关闭掉子进程不需要的文件描述符
            // 上面的 inpipe 和 outpipe 是对于父进程而言的,如果父进程是写的话,那么子进程就是读,如果父进程是读的话,那么子进程就是写
            // 父进程再 inpipe 中式读,所以子进程就是 inpipe 中写,所以子进程保留 inpipe[1]
            close(inpipe[0]);
            // 父进程再 outpipe 中是写,所以子进程就是再 outpipe 中读,所以子进程保留 outpipe[0]
            close(outpipe[1]);
            // 2. 匿名管道创建好后,因为程序替换后会将代码与数据全部替换掉,但是并不会替换内核层的数据,所以这里为了让程序替换后子进程还是可以看到文件
            // 描述符,所以这里就将打开的管道文件描述符 dup 重定向,我们认为,子进程从标准输入中读,就是读取管道,子进程输入到标准输出,我们认为就是
            // 写到管道中
            // 重定向
            if (Redirection(outpipe[0], inpipe[1]) == false)
            {
                close(outpipe[0]);
                close(inpipe[1]);
                exit(1);
            }
            // 导环境变量
            // 为了让子进程区分是 GET 还是 POST 方法,所以需要将方法导成环境变量给子进程
            // static char Method_Env[128];
            std::string method_env = "METHOD=";
            method_env += http_request.method;
            int r = putenv((char *)method_env.c_str());
            if (r < 0)
            {
                log(ERROR, "METHOD 环境变量导入失败!");
            }
            // log(DEBUG, "METHOD: %s", getenv("METHOD"));
            // 判断是否是 GET 方法,如果是 GET 方法,那么还需要将 GET 方法后面的 QUERY_STRING 也导进去
            // 将数据传给子进程
            if (http_request.method == "GET")
            {
                std::string query_string_env = "QUERY_STRING=";
                query_string_env += http_request.query_string;
                int r = putenv((char *)query_string_env.c_str());
                if (r < 0)
                {
                    log(ERROR, "QUERY_STRING 环境变量导入失败!");
                }
                else
                {
                    log(DEBUG, "QUERY_CTRING: %s", getenv("QUERY_STRING"));
                }
            }
            else if(http_request.method == "POST")
            {
                // 如果是 POST 方法,那么就将
                std::string BODY_SIZE = "BODY_SIZE=";
                BODY_SIZE += std::to_string(http_request.request_body.size());
​
                putenv((char*)BODY_SIZE.c_str());
            }
            // 程序替换
            execl(http_request.path.c_str(), http_request.path.c_str(), nullptr);
            // 如果到了这里,说明程序替换失败,直接让子进程退出即可
            exit(1);
        }
        else if (pid < 0)
        {
            // 创建子进程出错
            http_response.code = NotFound;
            return NotFound;
        }
        else
        {
            // 父进程
            // 父进程需要 input 中读,而 input 中有两个文件描述符,一般 pipe 创建的匿名管道 [0] 表示读,[1] 表示写,而inpipe 是父进程的读 所以需要保留
            // inpipe[0],需要close inpipe[1] 写
            // outpipe 中也是有两个,父进程角度认为 outpipe 是写,所以需要保留 outpipe[1] 关闭 outpipe[0]
            close(inpipe[1]);
            close(outpipe[0]);
            // 将数据转给子进程,如果是 POST 方法就使用管道,如果是 GET 方法就使用环境变量的方式
​
            if (http_request.method == "POST")
            {
                // POST 方法
                while (true)
                {
                    ssize_t s = write(outpipe[1], http_request.request_body.c_str(), http_request.request_body.size());
                    if (s > 0)
                    {S
                        log(DEBUG, "POST 写入数据:%s", http_request.request_body.c_str());
                        http_request.request_body.erase(0, s);
                        log(DEBUG, "POST 剩余数据 size: %d :%s", http_request.request_body.size() ,http_request.request_body.c_str());
                        if (http_request.request_body.empty())
                            break;
                    }
                }
                log(DEBUG, "POST 方法将 BODY 写入完毕!");
            }
            waitpid(pid, nullptr, 0);
            // 子进程执行完毕,父进程关闭对应的管道
            close(inpipe[0]);
            close(outpipe[1]);
        }
    }

测试 CGI 模式

因为要测试 CGI 模式还需要些一个 CGI 程序,那么我们就写一个测试的 CGI 程序,这个 CGI 程序很简单,就是获取环境变量 METHOD 这个里面存的是 方法,如果是 GET,那么就获取 QUERY_STRING 环境变量,如果不是 GET 那么就从标准输入中读

#define READ_SIZE 1024
​
int main()
{
  cerr << "我是一个 CGI 程序" << endl;
  cerr << "我是一个 CGI 程序" << endl;
  cerr << "我是一个 CGI 程序" << endl;
  cerr << "我是一个 CGI 程序" << endl;
  cerr << "我是一个 CGI 程序" << endl;
  cerr << "我是一个 CGI 程序" << endl;
  cerr << "我是一个 CGI 程序" << endl;
  cerr << "我是一个 CGI 程序" << endl;
​
  // 判断请求方法,请求方法不同的话,数据来源就不同
  string method_env = getenv("METHOD");
  if (method_env.empty())
  {
    cerr << "没有请求方法" << endl;
  }
  else
  {
    cerr << "请求方法:" << method_env << endl;
  }
  string message;
   // GET 方法读取数据从环境变量中读取
  if (method_env == "GET")
  {
    // cerr << "QUERY_STRING: " << getenv("QUERY_STRING") << endl;
    message += getenv("QUERY_STRING");
  }
  else
  {
    // POST 方法读取数据从正文中读取,但是由于不知道读取多少数据,所以还需要从环境变量中获取正文大小
    int body_size = stoi(getenv("BODY_SIZE")); 
    char buffer[READ_SIZE];
    while (true)
    {
      ssize_t s = read(0, buffer, READ_SIZE - 1);
      if(s > 0)
      {
        cerr << "POST 方法 CGI 程序在读取正文..." << endl;
        buffer[s] = 0;
        message += buffer;
        if(message.size() >= body_size) break;
      }
      else break;
    }
  }
  cerr << "message: " << message << endl;
  cerr << "CGI 程序退出" << endl;
  return 0;
}

说明一下这里为什么要使用 cerr 因为在 CGI 程序中的时候,已经经过重定向了,而 cout 是标准输出,所以从 cout 打是无法看到的,所以只能使用 cerr 打印。

这里看一下结果,这里我们看到 CGI 程序被成功的调用了,且将使用的方法也打印出来了,说明至少 GET 方法是没有问题的,那么下面测试一下 POST 的 CGI 程序

这里使用 POST 访问也是没问题的。

CGI 模式测试通过了,下面我们看一下 CGI 模式如何发送数据

CGI 模式发送数据

CGI 程序执行的时候,已经经过程序替换了,同时也经过了重定向,所以 CGI 程序想要获取数据就从标准输入中读取,像压迫输出数据就聪从标准输出中写入即可,所以当 CGI 程序想要输出的时候,只需要从标准输出中写即即可。

那么 CGI 程序写了的话,HTTP 服务就拿到了吗?并没有,因为 CGI 程序写的话,是写道管道里面了,如果 HTTP 服务想要拿到数据,然后返回给用户的话,那么 HTTP 服务还需要从管道里面读取,而 CGI 程序的输出就是 HTTP 服务需要给用户返回的响应正文。

那么先看一下 CGI 程序的输出:

#include <iostream>
using namespace std;
#include <string>
#include <fcntl.h>
#include <vector>
#include <unistd.h>
#include "Util.hpp"
​
#define READ_SIZE 1024
​
​
bool readMessage(string *out)
{
  string method_env = getenv("METHOD");
  if (method_env.empty())
  {
    cerr << "没有请求方法" << endl;
    return false;
  }
  if (method_env == "GET")
  {
    *out += getenv("QUERY_STRING");
  }
  else
  {
    int body_size = stoi(getenv("BODY_SIZE"));
    // cerr << "BODY_SIZE: " << body_size << endl;
    char buffer[READ_SIZE];
    while (true)
    {
      ssize_t s = read(0, buffer, READ_SIZE - 1);
      if (s >= 0)
      {
        cerr << "POST 方法 CGI 程序在读取正文..." << endl;
        buffer[s] = 0;
        *out += buffer;
        if (out->size() >= body_size)
          break;
      }
      else
        return false;
    }
  }
  return true;
}
​
int main()
{
  string message;
  if(readMessage(&message))
  {
    cerr << "message: " << message << endl;
  }
  vector<string> out;
  Util::CutString(message, &out, "&");
  // cout 就是 CGI 程序输出
  for(auto& str : out)
  {
    cout << str << endl;
  }
  return 0;
}

这里的 CGI 程序对于上面稍微修改了一下,获取数据封装为了一个函数,主要看 cout 输出即可

但是这里 CGI 程序 cout 了,可不意味着 HTTP 服务就拿到了,而是需要 HTTP 服务也需要 read,而且当 HTTP 服务读取到数据后,那么就可以将数据返沪了吗?并不是,不要忘记了我们的是 HTTP 服务,所以还需要构建 HTTP 响应,而我们只是在 NonCgi 的函数里面构建了响应,而并没有在 Cgi 函数里面构建,所以当读取到数据后,就需要构建响应。

那么怎么读取数据呢?直接使用 read 接口阻塞读取,可以吗?

可以这样读取,那么不害怕阻塞住吗?不害怕,因为 CGI 程序被调用只是为了处理一下请求,当请求处理完毕后,一定会 cout 的,当 CGI 程序处理完毕了,那么就调用结束了,而调用结束后 CGI 程序的文件描述符也就是随进程关闭了,所以 HTTP 服务这边就会 read 返回值为 0,表示对端关闭连接,此时我们就出来了,所以并不会阻塞。

那么当数据读取到,应该怎么构建 HTTP 响应呢?其实这里也得构建响应,NonCgi也构建响应,所以其实可以写一个专门构建响应的函数,但是这里为了简单就不做代码优化了,在处理 CGI 模式的函数里面,等 waitpid 回来的时候,说明正文已经全部读取完毕,所以就可以开始构建响应,而这里的正文一定需要读取到 http_response 里面的 response_body 中,这个就是存储响应正文的变量。

    int ProcessCgi()
    {
        int code = OK;
        log(DEBUG, "CGI模式处理任务~");
        // 处理 CGI 就是执行 CGI 函数
        std::string &path = http_request.path;
        std::string &query_string = http_request.query_string;
        // 创建管道
        int inpipe[2];
        int outpipe[2];
        if (CreateReadWritePipe(inpipe, outpipe) == false)
        {
            http_response.code = NotFound;
            code = NotFound;
        }
​
        int pid = fork();
        if (pid == 0)
        {
            // 子进程
            // 1. 因为程序替换后,数据和代码会被替换掉,所以当发生程序替换后,上面的 inpipe 和 outpipe 都会被替换掉,因为这两个数据是应用层的数据,
            // 虽然说应用层的数据被替换了,但是内核的数据并不会被替换,也就是打开的文件描述符,以及环境变量等,并不会被替换,这里为了让读写更好,所
            // 以这里先关闭掉子进程不需要的文件描述符
            // 上面的 inpipe 和 outpipe 是对于父进程而言的,如果父进程是写的话,那么子进程就是读,如果父进程是读的话,那么子进程就是写
            // 父进程再 inpipe 中式读,所以子进程就是 inpipe 中写,所以子进程保留 inpipe[1]
            close(inpipe[0]);
            // 父进程再 outpipe 中是写,所以子进程就是再 outpipe 中读,所以子进程保留 outpipe[0]
            close(outpipe[1]);
            // 2. 匿名管道创建好后,因为程序替换后会将代码与数据全部替换掉,但是并不会替换内核层的数据,所以这里为了让程序替换后子进程还是可以看到文件
            // 描述符,所以这里就将打开的管道文件描述符 dup 重定向,我们认为,子进程从标准输入中读,就是读取管道,子进程输入到标准输出,我们认为就是
            // 写到管道中
            // 重定向
            if (Redirection(outpipe[0], inpipe[1]) == false)
            {
                close(outpipe[0]);
                close(inpipe[1]);
                exit(1);
            }
            // 导环境变量
            // 为了让子进程区分是 GET 还是 POST 方法,所以需要将方法导成环境变量给子进程
            // static char Method_Env[128];
            std::string method_env = "METHOD=";
            method_env += http_request.method;
            int r = putenv((char *)method_env.c_str());
            if (r < 0)
            {
                log(ERROR, "METHOD 环境变量导入失败!");
            }
            // log(DEBUG, "METHOD: %s", getenv("METHOD"));
            // 判断是否是 GET 方法,如果是 GET 方法,那么还需要将 GET 方法后面的 QUERY_STRING 也导进去
            // 将数据传给子进程
            if (http_request.method == "GET")
            {
                std::string query_string_env = "QUERY_STRING=";
                query_string_env += http_request.query_string;
                int r = putenv((char *)query_string_env.c_str());
                if (r < 0)
                {
                    log(ERROR, "QUERY_STRING 环境变量导入失败!");
                }
                else
                {
                    // log(DEBUG, "QUERY_CTRING: %s", getenv("QUERY_STRING"));
                }
            }
            else if (http_request.method == "POST")
            {
                // 如果是 POST 方法,那么就将
                std::string BODY_SIZE = "BODY_SIZE=";
                BODY_SIZE += std::to_string(http_request.request_body.size());
​
                putenv((char *)BODY_SIZE.c_str());
            }
            // 程序替换
            execl(http_request.path.c_str(), http_request.path.c_str(), nullptr);
            // 如果到了这里,说明程序替换失败,直接让子进程退出即可
            exit(1);
        }
        else if (pid < 0)
        {
            // 创建子进程出错
            http_response.code = NotFound;
            code = NotFound;
        }
        else
        {
            // 父进程
            // 父进程需要 input 中读,而 input 中有两个文件描述符,一般 pipe 创建的匿名管道 [0] 表示读,[1] 表示写,而inpipe 是父进程的读 所以需要保留
            // inpipe[0],需要close inpipe[1] 写
            // outpipe 中也是有两个,父进程角度认为 outpipe 是写,所以需要保留 outpipe[1] 关闭 outpipe[0]
            close(inpipe[1]);
            close(outpipe[0]);
            // 将数据转给子进程,如果是 POST 方法就使用管道,如果是 GET 方法就使用环境变量的方式
​
            if (http_request.method == "POST")
            {
                // POST 方法
                while (true)
                {
                    ssize_t s = write(outpipe[1], http_request.request_body.c_str(), http_request.request_body.size());
                    if (s >= 0)
                    {
                        // log(DEBUG, "POST 写入数据:%s", http_request.request_body.c_str());
                        http_request.request_body.erase(0, s);
                        // log(DEBUG, "POST 剩余数据 size: %d :%s", http_request.request_body.size(), http_request.request_body.c_str());
                        if (http_request.request_body.empty())
                            break;
                    }
                }
                // log(DEBUG, "POST 方法将 BODY 写入完毕!");
            }
            // 父进程在这里读取 CGI 程序处理的结果
            char buffer[1024];
            while (true)
            {
                // 阻塞读取
                ssize_t s = read(inpipe[0], buffer, 1023);
                if (s <= 0)
                {
                    // 当 read 读取错误,或者对端关闭连接,那么就可以返回了
                    log(DEBUG, "CGI 程序数据返回完毕!");
                    break;
                }
                buffer[s] = 0;
                http_response.response_body += buffer;
            }
            int status = 0;
            waitpid(pid, &status, 0);
            log(DEBUG, "父进程等待成功: %d", pid);
            if (WIFEXITED(status))
            {
                code = WEXITSTATUS(status);
                if (code != 0)
                {
                    http_response.code = NotFound;
                    code = NotFound;
                }
            }
            else
            {
                http_response.code = NotFound;
                code = NotFound;
            }
            // 子进程执行完毕,父进程关闭对应的管道
            close(inpipe[0]);
            close(outpipe[1]);
            // 构建想要报头
            // debug
            AddStatus200Line();
            CgiAddHeaderType();
            CgiAddHeaderSize();
            http_response.blank = HTTP_LINE_SEP;
        }
        return code;
    }

HTTP 发送响应

因为加入了 CGI 模式,而 CGI 模式的正文并不是主机上的资源,而是 CGI 程序处理后的数据,所以 CGI 程序处理后的数据是放在 http_response.response_body (响应正文中的),前面因为还有静态网页的缘故,我们为了让效率更高一点,我们直接使用了在内核层发送数据,并没有读取到 HTTP 应用层,所以 非 CGI 的正文发送和 CGI 的正文发送是不同的。

所以我们就需要处理一下 CGI 和 非 CGI 的发送差别了,我们在发送的时候需要判断 CGI 还是非 CGI 如果是 CGI 那么我们就使用正文发送的方式,如果是 非 CGI 我们就采用内核层发送的方式,而非 CGI 还会打开新用户想要获取的文件,所以等发送结束后,还需要关闭对应的文件。

    void SendResponse()
    {
        std::string response;
        // 状态行
        response += http_response.status_line;
        // 响应报头
        for (auto &header : http_response.responset_header)
        {
            response += header;
        }
        // 空行
        response += "\r\n";
        // 发送报头
        while (response.size() > 0)
        {
            ssize_t s = send(sock, response.c_str(), response.size(), 0);
            response.erase(0, s);
        }
        if (http_request.cgi == false)
        {
            // 非 CGI 模式需要发送文件内容
            // 发送正文
            ssize_t s = sendfile(sock, http_response.fd, 0, http_response.targetfile_size);
            close(http_response.fd);
        }
        else
        {
            // CGI 正文发送
            while (true)
            {
                ssize_t s = send(sock, http_response.response_body.c_str(), http_response.response_body.size(), 0);
                if (s >= 0)
                {
                    http_response.response_body.erase(0, s);
                    if (http_response.response_body.empty())
                        break;
                }
            }
        }
    }

逻辑错误处理

上面我们一直没有做差错处理,但是我们对于 http_response 里面的 code 一直在关注,如果有问题的话,那么 code 也就是响应的状态码就会被修改,如果等构建响应的函数到了最后,如果 code 不是 OK 也就是 200 那么就说明前面一定出错了,所以这时候就需要做错误处理。

但是因为错误的种类是比较多的,而我们只用 404 来说明问题,我们将所有的错误都归为 404,也就是到了错误处理的时候只剩下 404 的错误了,但是也可以将错误的种类细分一下,并不困难,所以想做的话也是可以做的,但是这里我们认为错误只有 404.

如果有错误的话,那么我们就需要返回 404,页面,404 页面可以下载,也可以自己写,包括不用 404 页面也可以,可以直接返回 404 状态行即可,但是这里我们下载了一个 404 页面:

404页面

想用的可以自己下载,404 页面和静态网页返回没有什么不同,但是因为是 404 页面,所以如果是错误处理的话,那么就需要打开 404 页面,然后发送 404 页面里面的内容,所以因为 发送 404 页面时静态页面,所以就需要采用发送的方式时 非 CGI 发送,所以在处理 404 的时候,还需要将 CGI 标志修改为 false。

但是处理 404 并不是简单的打开 404 就好了,因为 404 还是没有构建响应呢,所以需要先构建响应,而响应状态码就是 404,状态码描述就是 NotFound,如果想要使用之前的函数构建的话,那么就需要修改一下请求路径,让请求路径变为 404 页面路径,然后就可以使用之前的函数了。

    void Add404StatusLine()
    {
        // 添加协议版本
        std::string status_line = http_request.version;
        status_line += " ";
        // 添加 404 状态码
        status_line += "404";
        status_line += " ";
        // 添加状态码描述
        status_line += http_response.code2desc(404);
        status_line += HTTP_LINE_SEP;
​
        http_response.status_line.swap(status_line);
    }
​
    void Add404ResponseHeader()
    {
        // 添加返回类型
        // 修改请求路径
        http_request.path = WWWROOT;
        http_request.path += "/";
        http_request.path += PAGE_404;
        AddResponseBodyType();
        // 修改请求资源大小
        struct stat st;
        int r = stat(http_request.path.c_str(), &st);
        if (r < 0)
        {
            log(DEBUG, "打开 404 页面失败");
        }
        http_response.targetfile_size = st.st_size;
        AddResponseBodySzie();
    }
​
    void Build404Response()
    {
        // 添加404状态行
        Add404StatusLine();
        // 添加 404 报头,返回 404 页面
        Add404ResponseHeader();
        // 添加空行
        http_response.blank = HTTP_LINE_SEP;
        // 将模式改为非 cgi 模式,因为 非 cgi 模式发送数据是发送的是请求的资源,而 404 也是网页资源
        http_request.cgi = false;
        http_response.fd = open(http_request.path.c_str(), O_RDONLY);
        // // debug:
        // log(DEBUG, "path: %s", http_request.path.c_str());
        // log(DEBUG, "404 status_line: %s", http_response.status_line.c_str());
        // for (auto &str : http_response.responset_header)
        // {
        //     log(DEBUG, "404 response_header: %s", str.c_str());
        // }
    }
​
    // 在错误里面我们只处理 404
    void HandlerError(int code)
    {
        // log(DEBUG, "差错处理 code: %d", code);
        switch (code)
        {
        case NotFound:
        {
            Build404Response();
        }
        break;
        case 500:
        {
        }
        break;
        defaule:
            break;
        }
    }   
​
    void BuildResponse()
    {
        // 我们目前只处理 http  中的两种方法:GET  POST
        if (http_request.method != "GET" && http_request.method != "POST")
        {
            // 说明不是 get 方法也不是 post 方法 那么就是有问题的
            log(WARNING, "请求方法错误!");
            goto END;
        }
        // 判断是否为 POST POST 也是 CGI 模式
        if (http_request.method == "POST")
            http_request.cgi = true;
        // 处理路径
        if (DisposPath() == false)
        {
            http_response.code = NotFound;
            goto END;
        }
        // 如果是 CGI 那么就执行 CGI 程序
        if (http_request.cgi)
        {
            // CGI
            ProcessCgi();
        }
        else
        {
            // 非 CGI
            ProcessNonCgi();
        }
​
    END:
        // 差错处理
        if (http_response.code != OK)
        {
            // 状态码有问题
            // log(DEBUG, "code: %d", http_response.code);
            HandlerError(http_response.code);
        }
        return;
    }

读取错误处理

上面这个错误是属于逻辑错误,但是在这里并不是只有逻辑错误,而逻辑错误是在处理 HTTP 请求的时候才会发生的,也就是例如,请求方法错误,使用的不是 GET 或者 POST,又或者在 CGI 模式下创建子进程失败,或者程序替换失败等等问题,而这些都是请求读取完毕后处理的过程,那么如果在读取请求的时候就出现了问题呢?

所以当请求读取错误的时候,我们不能给对方响应,所以我们在读取请求完毕后,我们就需要判断是否在读取请求的时候发生了问题,例如对端关闭了连接,等等。

那么我们如何做呢?我们可以在 Endpoint 类中添加一个是否 stop 的成员变量,如果该成员变量为 true 那么就表示不能再运行了,所以线程就直接退出即可,如果不为 true ,那么就可以一直运行。

所以我们还需要再读取请求的时候记录一下是否读取出错,然后相对的设置 stop 标记位。

这里的代码我就不放出了,要不然会将之前的代码再放出来,这里并不难,所以这里我就直接表述了。

处理请求行读取错误

在读取请求的时候,我们使用了读取请求行,那么如果再读取请求行的时候,出现了问题,也就是对端关闭了连接,那么我们是不是就应该设置标记位 stop 为 true

处理请求报头读取出错

在读取请求报头的时候,如果对端关闭了连接,那么我们是不是也就是请求读取出错,如果是请求读取出错了,那么还有必要继续向后执行吗?请求出错了,那么也就说明后面的分析,也就没有必要进行了,所以也就需要设置 stop 标记。

处理正文读取出错

在 GET 方法里面我们这样奇景读取完毕了,但是如果是 POST 方法呢?我们是不是还需要读取正文部分,那么如果在读取正文部分的时候对端关闭了连接呢?我们怎么办呢?我们是不是也不能给对方返回数据。 所以在读取正文的部分我们也需要对 stop 标记进行设置。当读取出现问题的时候,那么就需要设置 stop 标记为 true,然后在读取正文后进行检查 stop 标记。

写入错误处理

在读取的时候会发生错误,当然在写入的时候也会发生错误,我们前面已经将逻辑错误,读取错误都处理了,下面我们处理一下写入错误。

写入错误我们就简单暴力的处理一下,首先我们需要明白写入的时候会有哪些错误呢?当我们写入的时候对端关闭了连接怎么办?我们就是处理这个。

在系统编程的时候,在文件的那一块,如果我们向一个已近关闭的文件描述符里面写入数据会怎么办呢?会引起写入进程奔溃,为什么?因为写入主要是为了让读端拿到数据,但是现在读端已经关闭了,那么写入还有意义吗?所以就会引起写入进程的奔溃,但是具体是因为什么而奔溃的呢?因为信号!

这里就和信号扯上关系了,因为向一个已经关闭的文件描述符写入数据,写入端就会收到一个 SIGPIPE 的信号,所以就会奔溃,那么我们怎么办呢?我们直接忽略掉 SIGPIPE 信号即可,这就是我们的暴力处理方法。

这一块的代码也就不写了,就是在 HTTP 服务起来的时候然后调用 signal 函数:

signal(SIGPIPE, SIGIGN);  // 调用这个即可

引入线程池

上面我们的 HTTP 服务就已经能跑起来了,我们也将一些错误也处理了,下面我们就可以开始稍微优化一下该服务了,那么怎么优化呢?我们可以先想一下这个服务的很明显的缺点在哪?

我们可以想一下,如果突然一瞬间有一大批请求到了呢?那么是不是会一瞬间创建一大批线程,虽然说线程的量级比进程小很多,但是忽然来了一大批那么也是对系统资源不小的消耗,那么还有就是如果有些连接连上之后不发送数据呢?那么是不是为该连接提供服务的线程就一直待在系统中呢?是的,所以这也是一种不小的资源消耗。

所以为了解决这个,我们有几种可以采用方法:

1.就是采用多路转接的方式

2.采用线程池

那么我们采用的是线程池,因为线程池相对比较简单一些,所以我们就直接采用线程池来解决,我们这里主要是为了让代码是一个一个的模块,以及模块之间是如何进行数据传输的,我们并不是要写一个完美的 HTTP 服务,我们也只是为了了解一下 HTTP 服务以及 HTTP 服务如何交互,所以我们就采用简单的方法来。

那么我们如何引入线程池呢?我们的思路是在 HTTP 类中加入线程池,然后当 HTTP 服务在 loop 的时候, accept 到数据后,并不是创建线程,而是将收到的 sock 打包为一个任务然后交给线程池,调用线程池中的push任务的函数。

线程池实际上就是生产者消费者模型,主线程就是接收连接,然后将连接打包为任务,放到线程池的任务队列中,然后线程池中的线程就是消费者,他们去消费队列中的资源,线程去执行线程池中的一个 Routine 的方法,然后 Routine 方法里面,线程就在一直读取任务队列,而任务队列是一个临界资源,所以需要加锁访问。

那么现在就是我们需要一个任务,那么我们的任务是什么呢?首先想一下,我们想让线程执行什么方法,我们想让线程执行处理请求的方法,而处理请求的方法就在 Entryance 这个类中的 Handler 方法,但是我们每一次处理的 Handler 的话,我们都是要读取数据,也就是从 sock 中读取,但是因为我们的是短连接,所以每一次的 sock 都不一样,所以我们处理完一个请求 sock 就会变化,所以我们就是想让线程处理 HTTP 请求,那么处理 HTTP 请求的函数就是 Entryance::Handler 方法,所以我们的任务中就需要让线程执行这个方法,而我们的线程池中的线程是不能退出的,所以线程池中的线程不能之间执行这个方法(这个方法是处理一次请求后退出),所以我们线程需要有一个函数,这个函数可以一直执行任务,而任务中包含的就是这个处理请求的方法,任务还需要提供一个仿函数,只要调用任务的仿函数,那么任务就自动处理。

任务类

我们先处理任务,在写任务类的时候,我们在梳理一下我们想要的任务类:

我们想让任务中有一个仿函数,这个调用仿函数就会处理这个任务,而我们想要处理的就是 Handler 方法,但是因为此时 Handler 不用线程之间执行,所以我们也可以稍微修改一下 Handler 方法的参数,这个我们稍后说,我们现在直接设计任务类,最后根据任务类中的回调函数来修改 Handler 方法,我们因为 Handler 的整个流程就是从 sock 中读取数据,然后分析数据,处理数据,最后发送数据,不过读取和发送都是需要 sock 的,所以我们需要一个 sock 和 handler 方法放在一起,好了目前就这两个成员变量。

而仿函数中只需要调用 Handler 方法然后将sock 传入即可,其实不修改 handler方法也可以,但是这里为了看起来更好看一些,我们还是修改一下 Handler 方法, Handler 之前是返回值为 void* 参数为 Void* 因为 Handler 方法只是需要从 sock 中读取数据,所以我们可以将 Handler 的返回值和参数稍微修改一下,修改为 void(*)(int) 类型,所以此时任务中就有一个void(*)(int) 的类型的指针,到时候仿函数中只需要调用这个函数,然后将 sock传入即可。

#pragma once
#include "Protocol.hpp"
​
typedef void (*CallBack_t)(int);
​
class Task
{
public:
    Task()
    {}
​
    Task(int _sock, CallBack_t _callback)
        :sock(_sock)
        ,callback(_callback)
    {}
​
    Task(const Task& task)
    {
        sock = task.sock;
        callback =  task.callback;
    }
​
    Task& operator= (const Task& task)
    {
        sock = task.sock;
        callback = task.callback;
    }
    // 处理任务直接调用仿函数
    void operator()()
    {
        callback(sock);
    }
​
    ~Task()
    {}
private:
    int sock;
    CallBack_t callback;
};

因为我们的任务比较简单,所以实际上里面的拷贝构造赋值重载这些函数不写也可以。

线程池

既然有了任务类,那么我们也可以开始写线程池了,在写线程池之前,我们也顺一下如何写线程池:

首先我们想要线程池中有哪些属性和方法呢?第一就是我们需要一个队列,来存放任务,为了可以存放各种任务,我们可以把线程池设计为模板,既然有了队列,那么我们还有就是需要从队列中放入任务的函数,当然线程也需要能取出来任务,所以我们还需要一个拿出任务的函数,还有就是构造和析构了。

因为线程池里面的线程一定是大于等于1的,所以当多线程情况下访问一个队列,那么这个队列就是临界资源,所以当我们访问的时候就是需要进行线程互斥的,所以我们就需要锁的成员变量。那么光有锁就够了吗?不够,一位内只有锁确实可以保证临界资源的安全,但是如果现在一个线程一直在读取数据,处理数据,那么请问这样合理吗?不合理!这样会导致其他线程饥饿问题,所以我们还需要开保证线程间运行有一定的顺序,所以我们还需要加条件变量,那么我们就需要一个 cond 的属性,但是一个条件变量够吗?因为现在有两个角色,如果使用一个条件变量,那么 wait 的时候到底是谁在 wait 呢,唤醒的时候究竟唤醒的是哪种角色呢?所以我们需要两个条件比哪里,一个生产者一个消费者。

因为是线程池,所以我们至少得知道一次创建多少个线程,所以我们还需要一个线程的个数的成员变量。

成员变量和方法我们大概清楚有哪些了,那么我们在顺一下应该怎么写呢:

首先是构造方法,在构造方法中,我们需要先初始化锁和条件变量这些东西,因为初始化会初始化锁,所以等析构的时候,我们还是需要释放锁的。

然后在初始化完成后,我们就需要启动线程池了,所以我们需要提供一个 Run 的方法,让线程池创建线程,然后执行线程的函数。

那么线程执行的函数里面需要做什么呢?因为我们想让线程处理任务,那么任务在那里呢?在任务队列,所以我们就需要让线程一直从任务队列中拿数据,然后处理任务。

那么拿任务也就需要一个拿任务的函数,那么拿任务的时候可以一直拿吗?如果里面没有任务怎么办呢?所以我们就需要判断任务队列是否为空,但是可以直接判断吗?不可以,因为任务队列是临界资源,而判断也是计算的一种,所以我们需要先加锁,然后再判断是否有数据,有数据就直接拿,那么如果没有任务怎么办呢?如果没有任务那么就需要 wiat ,所以我们还需要 wait 的函数,当 wait 出来后,一定是有数据的,那么当数据拿走后就结束了吗?没有,我们当访问完了任务队列,那么我们就需要解锁,因为还有其他人也想访问任务队列中拿任务,解锁后就好了吗?也没有,因为当我们拿走数据后,就会有空位置出来,有空位置出来就可以让生产者放数据,所以此时我们就要通知生产者放数据,也就是唤醒生产者,所以我们还需要唤醒的函数。

那么如果没有任务是不是就需要有函数来放任务,所以我们还需要一个放任务的函数,放任务的函数由主线程执行,那么放任务就可以直接放吗?不可以需要先判断任务队列是否满了,如果满了,就不能放了,需要等有空位置才能放,所以就需要 wait ,当 wait 出来后一定是有空位置的,也就是可以放数据,那么当数据放完就可以了嘛?还需要解锁,解锁后因为有任务了,所以就需要通知消费者来消费。

上面提到的 wait 还有加锁解锁的这些小函数就不说了,因为很简单就是调用一下系统调用就好了。

还有因为是线程池,所以我们可以将线程池设计为单例模式,如何设计单例我也不说了。

剩下的看代码以及注释:

#pragma once
#include <queue>
#include "Pthread.hpp"
#include "log.hpp"
​
#define THREAD_NUM 5
​
// 这里的线程池是模板的,而线程池处理的任务也就是 T 必须提供仿函数,只要调用仿函数就是处理任务
template <class T>
class ThreadPool
{
private:
    // 线程执行的回调
    static void *ThreadRoutine(void *args)
    {
        // 因为回调只能有一个参数,所以需要将成员函数设置为 static 的,但是因为该函数里面有访问线程里面的方法,但是因为没有 this 指针,所以不能访问,所以需要将 this 指针传过来
        ThreadPool* tp = reinterpret_cast<ThreadPool<T>*>(args);
        while(true)
        {
            log(DEBUG, "[%u]线程获取任务~", pthread_self());
            T task;
            // PopTask 就是拿任务
            tp->PopTask(&task);
            log(DEBUG, "[%u]线程获取任务成功,处理任务~",pthread_self());
            // 调用任务的仿函数就是处理任务
            task();
        }
        return nullptr;
    }
    // 加锁
    void Lock()
    {
        pthread_mutex_lock(&lock);
    }
    // 解锁
    void UnLock()
    {
        pthread_mutex_unlock(&lock);
    }
    // 再条件变量下等待
    void Wait(pthread_cond_t& cond)
    {
        log(DEBUG, "[%u]线程被挂起", pthread_self());
        pthread_cond_wait(&cond, &lock);
    }
    // 唤醒该条件变量下的一个线程
    void WeakUp(pthread_cond_t& cond)
    {
        log(DEBUG, "[%u]线程被唤醒", pthread_self());
        pthread_cond_signal(&cond);
    }
    // 构造函数
    ThreadPool(int _thread_num)
        : thread_num(_thread_num)
    {
        // 初始化锁
        pthread_mutex_init(&lock, nullptr);
        // 初始化条件变量
        pthread_cond_init(&product_cond, nullptr);
        pthread_cond_init(&consum_cond, nullptr);
    }
    // 因为时单例,所以需要删除掉拷贝构造和赋值重载
    ThreadPool(const ThreadPool& tp) = delete;
​
    ThreadPool& operator=(ThreadPool tp) = delete;
public:
    // 获取单例
    static ThreadPool<T>* getObject(int thread_num = THREAD_NUM)
    {
        // 这里使用双判断,为了防止调用 getIbject 一直需要加锁后再判断
        if(single == nullptr)
        {
            static pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;
            pthread_mutex_lock(&mtx);
            if(single == nullptr)
            {
                single = new ThreadPool<T>(thread_num);
                if(single == nullptr)
                {
                    log(FATAL, "获取线程池单例失败!");
                    exit(1);
                }
                log(INFO, "获取线程池单例成功~");
                log(INFO, "启动线程池");
                single->Run();
            }
            pthread_mutex_unlock(&mtx);
        }
        return single;
    }
    // 析构,需要释放锁和条件变量等资源
    ~ThreadPool()
    {
        pthread_mutex_destroy(&lock);
        pthread_cond_destroy(&product_cond);
        pthread_cond_destroy(&consum_cond);
    }
    // 启动线程的函数
    void Run()
    {
        pthread_t tid;
        for (int i = 0; i < thread_num; ++i)
        {
            int r = pthread_create(&tid, nullptr, ThreadRoutine, this);
            if (r != 0)
            {
                log(FATAL, "线程创建失败!");
                exit(1);
            }
            pthread_detach(tid);
            log(DEBUG, "线程 %u 创建成功~", tid);
        }
    }
    // 为任务队列中添加任务
    void PushTask(const T& task)
    {
        // 因为需要判断是否为满,所以需要先加锁
        Lock();
        while(task_queue.size() > 100)
        {
            Wait(product_cond);
        }
        task_queue.push(task);
        // 等任务放入完毕,需要解锁
        UnLock();
        // 当任务放入后,说明有任务了,所以需要唤醒消费者
        WeakUp(consum_cond);
        // log(DEBUG, "插入数据唤醒消费者线程");
    }
​
    // 获取任务函数
    void PopTask(T* task)
    {
        // 需要判断是否为空,所以需要先加锁
        Lock();
        while(task_queue.empty())
        {
            // log(DEBUG, "没有任务需要获取");
            Wait(consum_cond);
            // log(DEBUG,"线程被唤醒了准备读取任务");
        }
        // log(DEBUG, "[%u]线程正在获取任务~~~");
        *task = task_queue.front();
        task_queue.pop();
        // 当任务获取完毕需要解锁
        UnLock();
        // 当任务拿走后,说明有空位置了,所以需要唤醒生产者
        WeakUp(product_cond);
    }
​
private:
    int thread_num;
    std::queue<T> task_queue;
    pthread_mutex_t lock;
    pthread_cond_t product_cond;
    pthread_cond_t consum_cond;
    static ThreadPool* single;
    static Log log;
};
​
template<class T>
Log ThreadPool<T>::log;
​
template<class T>
ThreadPool<T>* ThreadPool<T>::single = nullptr;

引入表单

表单是 HTML 的内容,但是我们这里还是要简单的说一下,因为表单可以和 HTTP 服务进行交互,所i有我们看一下。

表单是什么不懂的可以搜索一下表单,然后赋值粘贴到一个 .html 文件,然后访问这个文件即可。

<head>
    <meta charset="UTF-8">
</head>
<form method="get" action="/cgi/cal">
    <h1>
    算式:<input type="text" name="computer"><br>
    <input type="submit" value="Submit">
    </h1>
</form>

这个就是一个简单的表单,其中 <head><meta charset="utf-8"></head>这个表示让网页的编码是 utf-8 的。

下面的 <form></form> 里面的内容就是表单里面的内容,而 <h1></h1> 里面表示的是最大的字体。

<input type="text" name="computer"> 这个就是输入,后面的表示提交按钮。

而<form method="get" action="/cgi/cal">表示 method 表示方法,也就是和 HTTP 服务交互的方法, GET 或者是 POST, action 表示的是等数据提交后,交给哪一个资源,因为这里我们想要和计算进行交互,所以我们就把路径写计算器的路径。

但是因为有了表单的参与,因为很多特殊字符是不可以出现在 URL 中的,所以会进行特殊的编码,例如:+ 就是 %2B 还有/ 就是 %2F ,等数据交给计算机的时候还需要特殊处理。以及使用表单的话就会有name=输入信息,所以还需要进行数据分离。

其中这里的面的提交方法是不同的,下面看一下两种方法的测试:

1.GET方法

这里我稍微修改了一下调试信息,第一次我们使用 GET 方法,我们看到我们提交的信息提交到了 URI 中,并且我们提交的是1+ 1 这里看到 1+1其中+被修改了,所以我们再读取的时候也需要相应的处理。

2.POST方法:

我们还是提交1+1,这里向改为 POST方法只需要将 html 中的 action 修改为 post 即可。

这次我们看到就是 POST 方法,而且POST方法提交的数据会再正文出现,所以 body 就是提交的数据。

下面总结一下表单和提交方法:

1.GET方法提交表单的话,提交的数据会被以?为分隔符追加到action的后面,所以数据的隐私性相对较差,所以如果是一下不隐私的数据,那么还是可以使用GET方法的。

2.POST方法提交表单通过正文提交,所以POST方法的隐私相对于GET要好一些,但并不是说安全。

3.GET方法将数据交给CGI程序是通过环境变量,所以GET方法给CGI程序发送数据叫快一些,不过因为环境变量一定不会无限大,所以GET方法提交的数据大小也是有限制的。

4.POST方法将数据交给CGI程序是通过IO,所以一般速度较慢,但是因为POST的数据是再正文里,所以POST方法可以给子进程传输大量的数据,并不像GET一样只能由少量的数据。

看一下我们通过表单提交数据后的计算结果:

引入数据库

既然我们已经可以通过表单将数据传给 CGI 程序了,那么如果我们像让后端的 CGI 程序连接数据库,然后将数据插入到数据库呢?我们像实现一个注册的表单,然后将注册信息保存到后端的数据库中。

不过映入数据库的话,我们就需要使用语言来连接数据库,那么怎么连接呢?直接连接吗?并不是,我们使用的是 C语言或者c++,所以我们就使用 mysql 的C语言连接的软件,我们下载一个。

数据库连接

里面选则自己的系统和想要的版本即可,版本的话直接选则最新的。

下载好后,可以直接将里面的里面的代码 include 和 lib 拷贝到系统目录下,这样就不用指明 -I 和 -L 了,只需要指明 -l 即可。但是我这里就将里面的 include 和 lib 拷贝到了 HTTP 服务的当前目录下了,这样也可以直接调用了,虽然需要指明头文件和库文件的路径,但是这样编译的话就是默认会动态编译,动态编译的话,这个代码拿出去,或者下载到其他人的主机上就跑不起来了,只有别人的主机和我的路径想逃才可以,所以我们选则静态编译,但是静态编译会产生交叉编译的问题,所以在静态编译的时候需要将其他的库的路径也带上,这个编译的代码一会稍微看一下。

那么如果环境弄好了的话,想要连接数据库海曙不难的,我们可以查一下C语言和数据库交互的常用的函数即可,我们稍微看一下就可以了。

这里只说一下我们使用到的:

MYSQL* mysql_init(MYSQL *mysql);
  • 该函数为初始化函数

  • 返回值是一个句柄,如果为 null 那么就有问题

  • 参数写 null 即可

MYSQL* mysql_real_connect(MYSQL *mysql, const char *host,
                    const char *user,
                    const char *passwd,
                    const char *db,
                    unsigned int port,
                    const char *unix_socket,
                    unsigned long clientflag);
  • 该函数为连接的函数

  • 第一个参数为 mysq_init 返回值

  • 最后两个参数一个写null一个写0即可

  • 返回值如果为 null 那么就是有问题的

  • 如果连接出错的话,那么看一下是不是自己的用户或者账号写错了,又或者是权限的问题,或者登录主机的问题

void mysql_close(MYSQL *sock);
  • 该函数就是关闭连接的函数

int mysql_set_character_set(MYSQL *mysql, const char *csname);
  • 该函数为设置编码的函数

  • 因为有时候语言的编码和mysql的编码不同,所以要设置一样

  • 第二个参数就是设置的编码,一般为 "utf8"

int mysql_query(MYSQL *mysql, const char *q);
  • 该函数就是发送 mysql 请求的函数

  • 如果失败,那么返回不为0

下面就开始看一下关于mysql 的表的问题,我们想设计一个怎么样的表,然后我们更具表来简单的写一下 html 表单。

我们想要设计一个表为 user 表,这个表在 http 的数据库中,user 表里面又三个属性:

name 表示名字,varchar 类型,不能为空

account 表示账户,varchar 类型,不能为空

password 表示密码,varchar 类型,不能为空

我们就是简单的设置这三个属性,所以我们的表也要设计三个输入框,和上面的三个对照。

那么当表单的数据提交给了 mysql 连接的 CGI 程序,然后 CGI 程序读取数据,进行数据分析,其中又三个参数,每个参数中间被 & 分割,参数的变量与值之间使用 = 分割,所以我们需要做字符串分割,得到想要的字符串,然后我们获得到 name, account, password 后就可以构建 sql 语句了,构建好后就可以调用 mysql_query 函数了。

我们先看注册的表单:

注册表单

<head>
    <meta charset="utf-8">
</head>
<form action="/cgi/register" , method="post">
    Username:<input type="text" name="user">0~12字符<br>
    Account: <input type="text" name="account">0~12字符<br>
    Password:<input type="password" name="password">0~12字符<br>
    注册:<input type="submit" value="register">
</form>

数据库连接 CGI 程序

#include <iostream>
#include "include/mysql.h"
#include "Util.hpp"
#include <vector>
#include <string>
using namespace std;
// 这里定义的是宏,但是实际并不是后面的内容,这里是自己的主机以及账号和密码
#define MYSQL_LOCAL "XXXXXXXXXX"
#define MYSQL_PORT XXXXXXXXX
#define MYSQL_USER "XXXXXXXXXX"
#define MYSQL_PASSWD "XXXXXXXXXX"
#define MYSQL_DB "http"
​
#define READ_SIZE 1024
​
bool readMessage(string *out)
{
    string method_env = getenv("METHOD");
    if (method_env.empty())
    {
        return false;
    }
    if (method_env == "GET")
    {
        *out += getenv("QUERY_STRING");
    }
    else
    {
        int body_size = stoi(getenv("BODY_SIZE"));
        char buffer[READ_SIZE];
        while (true)
        {
            int size = READ_SIZE < body_size ? READ_SIZE : body_size;
            if (size < 0)
                size = 0;
            ssize_t s = read(0, buffer, size - 1);
            if (s >= 0)
            {
                buffer[s] = 0;
                *out += buffer;
                if (out->size() >= body_size)
                    break;
            }
            else
                return false;
        }
    }
    return true;
}
​
void InsertMysql(std::string sql)
{
    // debug
    cerr << sql << endl;
    // 初始化函数
    MYSQL *fd = mysql_init(nullptr);
    // 设置字符编码 utf8
    mysql_set_character_set(fd, "utf8");
    // std::cerr << "fd: " << fd << std::endl;
    // 连接 Mysql 服务器
    if (mysql_real_connect(fd, MYSQL_LOCAL, MYSQL_USER, MYSQLPASSWD, MYSQL_DB, MYSQL_PORT, nullptr, 0))
    {
        std::cerr << "mysql 连接成功" << std::endl;
    }
    else
    {
        std::cerr << "mysql 连接失败" << std::endl;
    }
    // 调用
    int r = mysql_query(fd, sql.c_str());
    cerr << "r: " << r << endl;
    cout << "<head><meta charset=\"utf-8\"></head>";
    if(r == 0)
        cout << "<html><h1>注册成功</h1></html>" << endl;
    else
        cout << "注册失败: 不能使用中文" << endl;
    // 关闭连接
    mysql_close(fd);
}
​
void Analysis(const string &message, vector<string> *out)
{
    // 分析字符串
    // user 表里面需要 name,account,password
    // 上面的三个参数分别用&分开
    // name=XXX  account=XXX password=XXX
    vector<string> args, tmp;
    Util::CutString(message, &args, "&");
    // 将每个参数分开
    Util::CutString(args[0], &tmp, string("="));
    out->push_back(tmp[1]);
    tmp.resize(0);
    Util::CutString(args[1], &tmp, string("="));
    out->push_back(tmp[1]);
    tmp.resize(0);
    Util::CutString(args[2], &tmp, string("="));
    out->push_back(tmp[1]);
    tmp.resize(0);
}
​
void BuildSql(string &name, string &account, string &password, string *out)
{
    string sql = "insert into user(name, account, password) values(";
    sql += "\'";
    sql += name;
    sql += "\'";
    sql += ",";
    sql += "\'";
    sql += account;
    sql += "\'";
    sql += ",";
    sql += "\'";
    sql += password;
    sql += "\'";
    sql += ")";
    out->swap(sql);
}
​
int main()
{
    cerr << "注册用户" << endl;
    string message;
    if (readMessage(&message) == false)
    {
        cout << "读取出错了" << endl;
        return 0;
    }
    vector<string> out;
    // 分析字符
    Analysis(message, &out);
    string name = out[0], account = out[1], password = out[2];
    // 构建 sql
    string sql;
    BuildSql(name, account, password, &sql);
    // 插入
    InsertMysql(sql);
    return 0;
}

数据库引入测试

点击提交后,我们看一下数据库中是否插入进去数据:

到了这里我们的这个项目也就结束了,最后我们总结一下我们的这个项目

HTTP服务项目总结

HTTP 服务是基于 TCP 的,虽然说 TCP 是面向连接的但是 HTTP 是无连接的,虽然 HTTP/1.1 可以进行长连接,但是我们的并不是长连接。

这个项目我们从 sock 套接字开始编写,然后编写 TCP 服务器,基于 TCP 服务器,我们搭建了 HTTP 服务器,我们的服务只处理两种请求:GET 和 POST

该项目还有 CGI 以及非 CGI 处理模式,如果是 非 CGI 那么就是返回资源,如果是 CGI 那么就让 CGI 程序处理用户请求后,返回数据。

GET 请求如果是访问数据的话,那么就是静态网页返回,也就是非 CGI ,那么就是放回用想要的资源,如果是 GET 并且有参数的话,那么就是 CGI 也就是想让数据交给 CGI 程序处理。

POST 方法就是 CGI 模式,因为 POST 是通过正文传输数据,交给 CGI 程序来处理的。

GET 方法将数据交给 CGI 程序通过环境变量,而 POST 是通过 IO 管道来传输。

该项目为了说明 GET 方法和 POST 的不同,还引入了表单,其中表单可以很好的体现 GET 和 POST 的不同,其中如果表单使用 GET 方法提交,那么就会将数据拼接到 uri 后面用?做分隔符,然后每个参数之间使用&分割,POST就是之间交给了正文将数据。

因为刚开始写 HTTP 服务的时候为了简单,所以接收到套接字后之间创建线程来处理,所以为了提高一下效率,该项目引入线程池,虽然线程池并不是最优的解决方案,但是相对比较简单,所以当接收到套接字后包装成任务,然后放到线程池中没让线程去处理。

为了将数据的流通从用户到数据的数据流通说明白,还引入了数据库,而数据库就需要 CGI 程序来控制,用户通过 POST 或者 GET 将数据交给 CGI 程序,然后 CGI 程序又连接数据库,然后将数据交给 mysql。

这就是这个项目的虽有内容了。

本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

毕业设计 HTTP 自助服务 的相关文章

随机推荐