Bootstrap

C++实现简易版http server

mini服务器简介

mini服务器功能

1.实现了GET和POST方法的HTTP request和HTTP respond的构建和发送,使服务器可以完成基本通信功能。

2.使用了线程池技术,使服务器可以一次接收更多的链接和加快了服务器处理数据的速度。

3.实现了简易的CGI,使得服务器可以完成接收表单提交的数据,然后返回后台处理好的数据给用户。

4.cgi功能分别实现了简易计算器,使用MySQL往database插入数据两个功能。

5.实现了简易的错误处理,比如若访问的资源不存在,可以返回404 NOT FOUND

mini服务器技术栈关键字

  1. Linux系统编程
  2. Linux网络编程
  3. HTTP协议和TCP协议
  4. CGI

mini服务器的大体实现逻辑

图片描述:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-6iWm1amI-1655709609653)(C:\Users\86135\AppData\Roaming\Typora\typora-user-images\image-20220618155654396.png)]

文字描述:

1.首先客户端(浏览器)和我们的server连接了,监听套接字accept到了之后创建了一个新的socket,用于数据传输。socket里面有两个重要成员,一个是sk_write_queue,一个是sk_receive_queue。从socket里面读数据本质是从sk_receive_queue里面读数据。写数据进socket本质上是写进sk_write_queue里面。总结来说就是用户层从内核中读和写数据,内核中负责帮我们把数据发出去。(要有这个意识,后面写代码要关注这个问题)

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-AVa7VaxH-1655709609657)(C:\Users\86135\AppData\Roaming\Typora\typora-user-images\image-20220618161130955.png)]

2.拿到新的socket的文件描述符之后,把它丢到ThreadPool里面,具体处理的如何,HTTPServer已经不关心了,HTTPServer只负责监听到连接,然后把对应的socket的fd丢到ThreadPool里面。(解耦)

3.处理手段就是三个:1.接收HTTP请求报文 2.构建HTTP响应报文 3.发送HTTP响应报文。在这中途,要抽空判断一下是否需要去调用CGI程序。等到HTTP响应报文成功写入到了socket的发送队列中时,整一套流程就结束了。

mini服务器各部分的实现逻辑

总共有几部分:

  1. socket套接字接口的封装
  2. TCP server和HTTP server封装
  3. ThreadPool
  4. LOG类

socket套接字接口的封装

服务端标准的一套流程:

  1. socket
  2. setsockopt
  3. bind
  4. listen
  5. accept

这里重点讲一个setsockopt。有可能这个mini服务器会因为连接过多然后崩掉(很正常,没有用epoll很容易崩)。服务器崩溃就意味着服务器成了先退出的那一方。那么服务器最后就会进入到TIME_WAIT状态等待2MSL。这段时间内由于内核的一些数据结构还没有被清除(因为没有进入closed状态),无法对同一个端口进行重复绑定。这样会让服务器在一定时间内跑不起来(bind error),很不方便调试。因此要使用setsockopt来避免这个问题。

查看源图像

这是setsockopt函数说明

int setsockopt(int sockfd, int level int optname, const void* optval, socklen_t optlen)

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-5Hy2XOw4-1655709609659)(C:\Users\86135\AppData\Roaming\Typora\typora-user-images\image-20220618164911697.png)]

  1. 框框那一句话翻译一下就是:操作套接字选项时,必须指定选项所在的级别和选项的名称。 要在套接字 API 级别操作选项,请将级别指定为SOL_SOCKET。

  2. 选项我们设置为SO_REUSEADDR,这个可以让我们对同一个端口进行重复绑定。

  3. 这是关于optval和optlen的说明:[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-lnnl2Aew-1655709609660)(C:\Users\86135\AppData\Roaming\Typora\typora-user-images\image-20220618165343836.png)]

​ 谷歌翻译一下就是:大多数套接字级选项都使用 int 参数作为optval。 对于 setsockopt(),参数应为非零以 启用布尔选项,如果要禁用该选项,则参数应为零

因此参数这么填:

int opt = 1;
setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof opt);

TCP server和HTTP server封装

TCP负责socket,bind,listen。

HTTP server负责accept,并且把accept到的socket fd放进线程池当中。

具体代码看tcpServer.hpp && HTTP server.hpp

ThreadPool 封装

ThreadPool既是一个生产消费模型。HTTP server充当生产者,后面处理服务的EndPoint(我起名叫EndPoint,叫其他也可以)充当消费者。那么这个线程池就是一个临界资源了。因此就要实现同步机制了。

这里的同步机制采用条件变量+互斥锁来实现。生产者消费者模型用阻塞队列的模式来完成。这种模式可以减轻代码编写的负担。但效率并不高。若想提高效率使用环形队列+信号量来完成。

下面是项目中实现threadpool的代码,使用的是C++11的特性:

class threadPool
{
    public:
        mutex mtx;
        condition_variable cv;
        size_t cap;
        queue<Task> q;

    threadPool()
    {
        cap = 5;
        vector<thread> v(cap);
        for(int i = 0; i < 5; i++)
        {
            v[i] = thread([&]{
                while(true)
                {
                    Task t;
                    get(t);
                    t.handler();
                }
            });
        }
        for(int i = 0; i < 5; i++) v[i].detach();
    }
    
    void put(Task& task)
    {
        unique_lock<mutex> lck(mtx);
        cv.wait(lck, [&]{return q.size() < cap;});
        q.push(task);
        cv.notify_one();
    }

    void get(Task& task)
    {
        unique_lock<mutex> lck(mtx);
        cv.wait(lck, [&]{return q.size() > 0;});
        task = q.front();
        q.pop();
    }
};

与下面使用POSIX库的代码效果是一样:

class threadPool
{
    public:
        pthread_mutex_t lock;
        pthread_cond_t cond;
        size_t cap;
        queue<Task> q;

    threadPool()
    {
        cap = 5;
        pthread_mutex_init(&lock, nullptr);
        pthread_cond_init(&cond, nullptr);
        for(size_t i = 0; i < cap; i++)
        {
            pthread_t tid;
            pthread_create(&tid, nullptr, routine, this);
        }
    }

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

    static void* routine(void* arg)
    {
        threadPool* threadpool = (threadPool*)arg;
        while(true)
        {
           Task t;
           threadpool->get(t);
           t.handler();
        }
    }

    void put(Task task)
    {
        pthread_mutex_lock(&lock);
        while(q.size() > cap) pthread_cond_wait(&cond, &lock);
        q.push(task);
        pthread_cond_signal(&cond);
        pthread_mutex_unlock(&lock);
    }

    void get(Task& task)
    {
        pthread_mutex_lock(&lock);
        while(q.size() <= 0) pthread_cond_wait(&cond, &lock);
        task = q.front();
        q.pop();
        pthread_mutex_unlock(&lock);
    }
};

注: 这里的同步机制保证的是:当没有连接到来的时候,thread全部在等待,一旦HTTP server把连接放进来的时候,就会唤醒其中一个线程,然后让他去处理这个连接的情况。处理完后无需再次唤醒生产者了,生产者是客户端连接还会有socket产生的,没有就算了。

与HTTP协议有关的函数封装

里面写了关于接收HTTP报文,解析HTTP报文,判断是否需要CGI,构建HTTP响应,发送HTTP报文等一系列函数,后面再说。

日志类LOG

为了方便调试,写了一个简单的日志类。该日志类可以打印出消息,代码对应的行数,代码所在的文件

由于要经常和HTTP报文里面的信息打交道,因此最好可以把它们都打印出来,那样就可以验证是否成功的做到了一些事情。

代码很短,可以贴在这看一下。

由于这个log函数每次都要输入四个参数,太麻烦了,因此用一个LOG宏来封装一下,文件名和行数可以用系统给的宏__FILE__和__LINE__来获取

#define LOG(info, msg) log(info, msg, __FILE__, __LINE__)

void log(const char* info, const char* msg, const char* file, int line)
{
    int t = time(nullptr);
    printf("[%s][%d][%s][%s][%d]\n", info, t, msg, file, line);
}

HTTP请求和响应解释(与项目有关的点)

HTTP请求

HTTP请求由以下几点组成如下:

  1. 请求行(请求方法只实现了GET和POST,因此关注这两个即可)
  2. 请求报头
  3. 空行
  4. 正文(可有可无,看具体的请求方法)

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-QlJeR8iX-1655709609661)(C:\Users\86135\AppData\Roaming\Typora\typora-user-images\image-20220619162224197.png)]

ps:每一行都要以\r\n结尾,这是用来解决TCP粘包问题的。TCP只保证按序到达,但是没有对一个明确的包给定边界(面向字节流),因此HTTP要用\r\n来做定界符。读到\r\n就代表这一行读完了。继续往下读就是下一行的内容了。

fiddle抓包结果:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-EDwhUchB-1655709922833)(C:\Users\86135\AppData\Roaming\Typora\typora-user-images\image-20220619163401656.png)]


请求行

url和uri

我自己说的不严谨,以下说法参考**《图解HTTP》**

url就是往浏览器输入的网址。url表示资源的地点。比如(img-IixAzEvL-1655709609664)(C:\Users\86135\AppData\Roaming\Typora\typora-user-images\image-20220619170504002.png)]

uri表示某一个互联网上的资源,这是一个比url更详细的信息。比如:[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-MrgSmalU-1655709609666)(C:\Users\86135\AppData\Roaming\Typora\typora-user-images\image-20220619170712629.png)]

uri会比url多一个斜杠,这个斜杠代表是web根目录,一般请求web根目录资源,服务器会返回当前目录的主页给用户。也就是说uri指定了我要访问baidu的首页的html文件,而url并没有指定说我要访问什么文件。

事实上,当我们回车www.baidu.com之后,浏览器会自动帮我们补上一个/,用fiddle抓包就可以看出来这个效果了。

总结一下:url是uri的子集。url就好像今天我要去你家,要干嘛不知道。uri就好像我今天要去你家吃饭,有具体的事情。


uri也分为绝对uri和相对uri。

指定了所有所有信息的叫绝对uri。比如https://www.baidu.com/,这就是绝对uri

绝对的uri格式如下:

http://user:[email protected]:80/dir/index.html?uid=1#ch1
  1. http是协议方案,可以是ftp等等

  2. user:pass是登陆信息,一般不填。

  3. www.example.cn是服务器地址,必须要填的,要么是DNS可解析的名称,要么是ip地址

  4. 80是端口号,由于一般的服务器都是使用http协议的,http协议端口是80(https是443),因此一般不用填。

  5. /dir/index.html就是用户想要访问的文件了

  6. ?后面接的是参数,可以不传参数。是可选的

  7. #这个一般不用。

相对uri是相对于服务器的根目录的 , 若在百度服务器上了, /就等价于上面的绝对uri所代表的信息。怎么设置取决于客户端。一般都是使用相对uri。(fiddle上面会把相对uri给补全成绝对uri,但我们实现的时候使用相对的uri)

请求方法

方法是用来告知服务器意图的。不同的方法代表着客户有着不同的意图。

GET方法:用来请求访问网站资源的。如果请求的是文本资源,服务器就把这个文本传给你。如果请求的是CGI程序,那么就返回执行后的结果。

例如:GET /index.html HTTP/1.1

服务器看到之后,就会把web root的index.html(主页)返回给用户

POST方法:POST方法也是用来请求网站资源的,但是POST和GET有点不同。如果url当中有参数需要传入,POST方法会把参数以正文的方式传输,而GET方法会把参数放在url当中。

像下面这种就是用GET方法的uri,因为参数都放在uri上面了。可以用fiddle抓包验证一下。

https://cn.bing.com/search?mkt=zh-cn&pc=LVBT&form=CNTP59&ensearch=0&q=www.baidu.com

如果像输入账号密码这种隐私一点的信息的时候,参数肯定不能放在uri里面,用POST方法可以更加隐私一点,因为POST方法可以用正文传参。

大部分web服务器对于这两种方法的使用策略是:

  1. POST用来请求动态资源,不允许请求静态资源
  2. GET即可以用来请求静态资源也可以请求动态资源

那我们也这么实现。


其他

由于http request是浏览器发送的,因此我们不需要了解那么多。了解到这里就足够写项目了。

HTTP回应

HTTP回应由几个部分组成

  1. 状态行
  2. 回应报头。重点关注Content-Type。Content-Type的意思是正文的文件类型和Content-Length是正文的大小,还是为了解决tcp粘包问题而出现的。有了Content-Type,我们就不会少读也不会多读一个字节。(这两个在项目中要使用到)
  3. 空行
  4. 正文

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Pn58b5zr-1655709609667)(C:\Users\86135\AppData\Roaming\Typora\typora-user-images\image-20220619162144670.png)]

可以看到返回来的是一个text/html,也就是文本类型,编码方式是utf8.正是百度服务器向浏览器发送了这个HTTP respond,浏览器把正文部分的html文件解析成了一张页面。

html文件被浏览器解析后就成了这张网页。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-n8JdG1qk-1655709609668)(C:\Users\86135\AppData\Roaming\Typora\typora-user-images\image-20220619163802432.png)]

状态码

在状态行中有一个字段是状态码。状态码决定了这次网络通信的状态。

1xx 接收的请求正在处理

2xx 成功状态码

3xx 重定向

4xx 客户端错误状态码

5xx 服务器错误状态码

了解404和200即可,这个项目并没有返回其他的状态码。404就是客户端访问的资源不存在,200就是成功返回的意思。

协议类的实现

里面的功能包括

  1. 接收HTTP请求
  2. 解析HTTP请求报文
  3. 构建HTTP响应报文
  4. 发送HTTP响应报文

接收HTTP请求

总共有这么几个函数,最重要的是recvRequestLine,recvRequestHeader,recvRequestBody。

void recvRequest();
bool hasBody();
bool recvRequestLine();
bool recvRequestHeader();
bool recvRequestBody();

recvRequestLine

请求行只有一行,直接读一行就好了。思路非常简单,问题来了,怎么从sk_receive_queue里面读一行呢?

我们之前讲了,为了解决粘包问题,http加入了\r\n的定界符。因此我们一个一个字符读,如果读到了\n,就停下来。

由于读一行这个操作使用频率很高,因此封装成一个函数ReadLine

实现如下:

static int ReadLine(int sock, string& str)
{
      char ch = '0';
      while(ch != '\n')
      {
          ssize_t s = recv(sock, &ch, 1, 0);
          if(s > 0)
          {
              if(ch == '\r')
              {
                  recv(sock, &ch, 1, MSG_PEEK);
                  if(ch == '\n') recv(sock, &ch, 1, 0);
                  else ch = '\n';
              }
              str += ch;
          }
          else if(s == 0) {
            LOG("INFO", "client quit");
            return -1;
          }
          else {
            LOG("WARNING", "ReadLine recv error"); 
            return -1;
          }
      }
      return str.size();
 }

这里实现的时候多考虑的一些情况。由于HTTP并没有严格的说明定界符一定是\r\n,有可能定界符是\r,也有可能定界符是\n.因此这里处理的时候多考虑了这两种不标准的情况。

因此总体思路就是:

  1. 如果读到\r,就去探测一下缓冲区(也就是sk_receive_queue里面还未读取的第一个字符),如果这个字符是\n,那么就读它出来,此时\r\n都读到了,那么ReadLine完成。

  2. 如果下一个字符不是\n,那么证明定界符是\r,下一个字符也不读了。我们手动为它添加一个\n,这样最终也变成了\r\n结尾的了。(不添加也可以,那样就是\r\r结尾的了,不过只要解决了粘包问题,怎么样都可以,毕竟是mini server)

  3. 如果\n是定界符的话,那么读到\n就直接退出循环了。

recv的第四个选项是一个flags,填入MSG_PEEK就可以窥探缓冲区的下一个字符。且不读出来。文档中描述是return data from the beginning of the receive queue without removing that data,也就是窥探的意思了

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-x7Ze5oye-1655709609669)(C:\Users\86135\AppData\Roaming\Typora\typora-user-images\image-20220619204215078.png)]

recvRequestHeader

读请求报头思路也非常简单,直接一行一行读。直到读到空行即代表所有请求报头都被读完了。读到空行就跳出循环即可。ps:这里把空行也读了出来的,因此下一步就是读正文了

实现如下:

bool EndPoint::recvRequestHeader()
{
    string s;
    while(true)
    {
        s.clear();
        if(Util::ReadLine(sock, s) < 0)
        {
            stop = true;
            break;
        }
        if(s == "\n") break;
        s.pop_back();//方便打印,带着endl不方便打印
        request->request_header.push_back(s);
        LOG("INFO", s.c_str());
    }
    return stop;
}

hasBody

这里写的很暴力,只要是GET方法默认它没有body了(毕竟大部分GET都没有body)。

因此判断它是否有body就变成了判断它的请求方法,如果是GET就默认没有body,如果是POST就默认它有正文,即使没有也没关系,因为Content-Length是0,不会越界读到下一个包的内容的。

bool EndPoint::hasBody()
{
    if(request->method == "POST")
    {
        request->Content_Length = request->header["Content-Length"];
        return true;
    }
    return false;
}

recvRequestBody

这个和上面的思路都一样,就是一个一个字节读,把Content-Length全部读完即可。

代码不贴了,详见源文件。

解析HTTP请求

parseRequestLine

解析请求行的主要目的是为了拿出请求方法 uri,当然可以顺便把http版本拿出来,但是这个项目用不上这个字段。

由于请求行是以空格分开三个字段的,因此使用stringstream很容易就可以把它们提取出来。

void EndPoint::parseRequestLine()
{
    string &method = request->method, &uri = request->uri, &version = request->version;
    stringstream ss;
    ss << request->request_line;
    ss >> method >> uri >> version;
    for(size_t i = 0; i < method.size(); i++) method[i] = toupper(method[i]); //处理method输入大小写问题
}

parseRequestHeader

请求报头的每一行都是 xxx: xxxx这样的格式,因此找到冒号就可以把请求报头的信息解析出来了。

这里选择使用map来存储,冒号前作为key,冒号后作为val。

void EndPoint::parseRequestHeader()
{
    for(size_t i = 0; i < request->request_header.size(); i++)
    {
        string s = request->request_header[i];
        int pos = s.find(": ");
        request->header.insert({s.substr(0, pos), s.substr(pos + 2)});            
    }
}

parsePathAndArgs

之前说过,中间的uri是会包含文件路径和参数的(参数是可选的)。因此我们还要对uri进行解析,拿到路径和参数。

由于我们使用的是相对uri,因此只要没有参数,相对uri就是我们需要的文件路径。因此我们只需要找到?所在的位置,?前面就是文件路径,?后面就是参数。

void EndPoint::parsePathAndArgs()
{
    size_t pos = request->uri.find('?');
    if(pos == string::npos) request->path = request->uri;
    else
    {
        request->path = request->uri.substr(0, pos);
        request->args = request->uri.substr(pos + 1);
    }
}

构建HTTP响应

总共有几个函数

  1. buildRespond**(通过uri知道用户需要什么资源并为用户准备好这些资源)**
  2. buildOKRespond
  3. build404Respond

buildRespond

文件路径总共有这么些情况

  1. 请求根目录,也就是uri为/。 eg. GET / HTTP/1.1

  2. 请求的资源仍然是一个目录,但不是根目录。 eg. GET /dir HTTP/1.1

  3. 请求的是一个静态的资源(不是目录) eg. GET /index.html HTTP/1.1

  4. 请求的是一个cgi程序 eg. GET /cgi.py HTTP/1.1

这四种需求我们都要实现。

在这里做个约定,GET方法实现这四种情况,POST方法只实现可以请求cgi程序的功能。原因在上面介绍请求方法的时候说过了。

GET请求根目录

题外话:

其实这里不仅仅处理了根目录,还处理了另外一种情况。

我们知道linux里面/home/user/dir//home/user/dir这两种写法是等价的,都表示的是dir目录.因此只要最后一个符号是’/',要么它是根目录,要么它采用的写法是第一种写法。不想那么多也是可以的,基本不会在非根目录后面加上这一个斜杠

如果最后一个字符是’/',那么请求的就是目录文件。

因此我们要返回对应的首页。并且我们要填上最重要的一个字段Content-Length。Content-Length的大小就是首页的大小。那么如何在程序中拿到一个文件的大小是多少呢?

使用stat函数。stat函数让你传一个输出型参数,拿到对应的文件状态stat,stat里面有一个字段是文件的大小st_size.

> [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ySbiXnkU-1655709609671)(C:\Users\86135\AppData\Roaming\Typora\typora-user-images\image-20220620091554645.png)]

最后把状态码也给附上,由于成功构建respond了,因此状态码附上200。

代码实现如下:

if((request->path[request->path.size() - 1]) == '/')
{
    //目录,返回主页
    request->path += Home_Page;
    path += request->path;
    request->path = path;
    struct stat buf;
    stat(request->path.c_str(), &buf);
    //该目录下index.html的大小,是respond的正文大小
    respond->size = buf.st_size;
    respond->status_code = OK;
}
GET请求目录

如果访问的路径存在且是目录,那么还是直接返回一张主页,和GET请求根目录思路是一样的。

这里讲一下如何使用stat判断路径是否存在和如何用stat判断该文件是否为目录。

stat有返回值,存在该路径返回0,否则返回-1

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-l2hCnBlF-1655709609672)(C:\Users\86135\AppData\Roaming\Typora\typora-user-images\image-20220620092446615.png)]

使用stat里的st_mode字段,并把它传入S_ISDIR这个宏里面就可以了。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-V4x0bNCG-1655709609673)(C:\Users\86135\AppData\Roaming\Typora\typora-user-images\image-20220620092557187.png)]

if(stat(request->path.c_str(), &buf) == 0)//如果路径存在
{
    if(S_ISDIR(buf.st_mode))//如果这个路径还是目录
	{
    	//返回主页,代码和上面一致
     }
}
GET请求静态资源

GET方法只要没有传参就是请求的静态资源了。因此我们的判断标准是:uri有没有?(或者直接看parsePathAndArgs有没有解析到参数),没有就证明是静态资源了。反之,有参数就正常请求的是cgi程序了。

size_t pos = request->uri.find('?');
if(pos == string::npos)
{
    respond->size = buf.st_size;//资源的大小,也就是respond正文的大小
    respond->status_code = OK;
}
GET请求动态资源

剩下最后一种情况,留到后面讲cgi的时候再讲。这里可以给他打个标记,cgi = true。代表访问的资源是cgi程序

request->cgi = true;
POST请求动态资源

要讲这个就要先知道表单提交的概念。下面这种框框输入的就是表单提交。

> [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-FTEwN3tH-1655709609674)(C:\Users\86135\AppData\Roaming\Typora\typora-user-images\image-20220620094234701.png)]

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-NNHHCgsK-1655709609675)(C:\Users\86135\AppData\Roaming\Typora\typora-user-images\image-20220620094408400.png)]

也就是说,一旦用户输入完成并点击提交按钮之后,浏览器会根据这个主页的form action和method自动生成一个url,然后发送http请求给服务器。

我们可以看一下浏览器生成的url是什么。可以看到点击提交之后,浏览器自动帮我们访问这个cgi程序(cgi程序名字叫mysql_conn)

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-qiZH5Wv3-1655709609676)(C:\Users\86135\AppData\Roaming\Typora\typora-user-images\image-20220620094538415.png)]

上面说一堆就是为了说明一个东西:按点击之后浏览器会帮你自动生成一个新的url,然后发送给服务器。


好了,现在回到服务器视角,那么POST请求动态资源的处理也变得很简单了,和GET请求动态资源的方法没有区别。

这里说一下这个request->path是什么?指的是在服务器上cgi程序所在的文件路径。

request->cgi = true;
string root = Web_Root;
string path = root + request->uri;
request->path = path;
respond->status_code = OK;

至此,所有资源的准备工作都已经做好了,可以准备构建真正的respond了。

buildOKRespond

由于respond分为两种,一种是经过cgi后的respond,一种是没有经过cgi之后的respond。因此也得分情况讨论。

经过cgi后的respond,处理的极其粗暴,直接返回一张html的大字报。没有做任何处理。(因为不会html)

没有经过cgi的respond,就直接返回对应的资源即可。这种操作挺频繁的,可以封装成一个函数processNonCgi

void EndPoint::buildOKRespond()
{
    //bug, cgi传回来的respond_body的类型不知道,因为目前cgi返回的都是html,因此暂时也不需要这个功能。
    if(request->cgi)
    {
        respond->version = request->version;
        respond->status_line = respond->version + ' ' + to_string(respond->status_code) + ' ' + respond->codeDesc + "\r\n";
        respond->respond_header.push_back("Content-Length: " + to_string(respond->respond_body.size()) + "\r\n");
        respond->respond_header.push_back("Content-Type: text/html\r\n");
    }
    else//非cgi
    {
        processNonCgi(200); 
    }
}

build404Respond

直接返回一张404的html文件即可。剩下的http respond字段根据需要自行填充即可。

void EndPoint::build404Respond()
{
    respond->version = request->version;
    respond->status_line = respond->version + ' ' + to_string(respond->status_code) + ' ' + code2Desc(respond->status_code) + "\r\n";
    struct stat st;
    request->path = Web_Root;
    request->path += "/404_NOT_FOUND.html";
    stat(request->path.c_str(), &st);
    respond->size = st.st_size;
    respond->respond_header.push_back("Content-Length: " + to_string(respond->size) + "\r\n");
    respond->respond_header.push_back("Content-Type: text.html\r\n");
    request->cgi = false;
}

processNonCgi

要做的事情有以下几个

  1. 把Content-Type设置好
  2. 把Content-Length设置好

Content-Length和之前一样,用stat.st_size即可,Content-Type要说明一下。

> [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-V1YidIH3-1655709609678)(C:\Users\86135\AppData\Roaming\Typora\typora-user-images\image-20220620104423502.png)]

可以看到,资源最后是有一个.jpg的,说明这个东西的格式是jpg。不同的资源有着不同的文件格式,因此Content-Type也不同,我们要根据uri的文件后缀来设置Content-Type。

但是请注意,.jpg文件的Content-Type并不是.jpg,这是有http协议规定的。详情请看

比如:jpg的Content-Type是application/x-jpg

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-K9xfWcuw-1655709609679)(C:\Users\86135\AppData\Roaming\Typora\typora-user-images\image-20220620105617606.png)]

我们把常用的设置一下即可。

map<string, string> suffixDesc = {  
									{".html", "text/html"}, 
									{".css", "text/css"}, 
									{".js", "application/javascript"}, 
									{".jpg", "application/x-jpg"}, 
									{".xml", "application/xml"}
								  };

为了方便,写一个后缀转Content-Type的函数。

如果map里面没有这个文件格式,那么就返回text/html回去。(其实这么做在这个项目里面没什么意义)

string& suffix2Desc(const string& suffix)
{
    if(suffixDesc.find(suffix) != suffixDesc.end()) return suffixDesc[suffix];
    else return "text/html";
}

剩下的工作就是填字符串了,非常简单。

void EndPoint::processNonCgi(int code)
{
    respond->version = request->version;
    respond->status_code = code;
    respond->codeDesc = code2Desc(200);
    respond->status_line = respond->version + ' ' + to_string(respond->status_code) + ' ' + respond->codeDesc + "\r\n";
    size_t pos = request->path.rfind('.');//找后缀
    if(pos != string::npos) respond->suffix = request->path.substr(pos);
    else respond->suffix = ".html";
    respond->respond_header.push_back("Content-Length: " + to_string(respond->size) + "\r\n");
    respond->respond_header.push_back("Content-Type: " + suffix2Desc(respond->suffix) + "\r\n");
    //respond_body在sendRespond那里直接发送出去,不在这处理了
}

发送HTTP响应

发送HTTP响应要干的几件事

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

状态行,响应报头全部都在构建HTTP响应的时候构建好了,直接发送即可。处理一下正文即可。

若请求的是静态资源,那么正文就是对应的文件。常规思路是把一个文件先读到用户的缓冲区,然后再从用户的缓冲区写进socket文件里面(sk_write_queue),但是这样写起来麻烦且效率低,因此这样就涉及从内核到用户的转换了。

因此使用接口sendfile,sendfile可以直接从内核当中把文件进行拷贝。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-dzzrZBk4-1655709609680)(C:\Users\86135\AppData\Roaming\Typora\typora-user-images\image-20220620111004061.png)]

sendfile(sock, fd, 0, respond->size);

sock是目标文件的fd,fd是源文件的fd,0是源文件开始拷贝的偏移量,respond->size是拷贝的字节数量。

代码实现:

int fd = open(request->path.c_str(), O_RDONLY);
send(sock, respond->status_line.c_str(), respond->status_line.size(), 0);
for(size_t i = 0; i < respond->respond_header.size(); i++)
{
    string s = respond->respond_header[i];
    send(sock, s.c_str(), s.size(), 0);
}
send(sock, respond->blank.c_str(), respond->blank.size(), 0);
sendfile(sock, fd, 0, respond->size);
close(fd);

若请求的是cgi程序,处理cgi的时候已经把正文处理好了,因此全部一次性发送即可。

send(sock, respond->status_line.c_str(), respond->status_line.size(), 0);
for(size_t i = 0; i < respond->respond_header.size(); i++)
{
    string s = respond->respond_header[i];
    send(sock, s.c_str(), s.size(), 0);
}
send(sock, respond->blank.c_str(), respond->blank.size(), 0);
send(sock, respond->respond_body.c_str(), respond->respond_body.size(), 0);

至此,接收,解析,构建,发送都完成了,所有架构也完成了,唯一剩下一个cgi要讲了。


CGI逻辑实现

cgi的原理就是http server创建一个子进程,然后让子进程exec变成对应的cgi程序,然后执行完再将结果返回给http server。

图示:

> [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-JdWNfq6A-1655709609681)(C:\Users\86135\AppData\Roaming\Typora\typora-user-images\image-20220620133415663.png)]

道理很简单,但是涉及的知识点比较多。有管道,exec,环境变量,最重要的是对底层结构的熟悉程度。

这次先贴代码,然后按代码解释各个细节原理。

int EndPoint::processCgi()
{
    int code = 200;
    int output[2];
    int input[2];

    if(pipe(output) == -1)
    {
        LOG("ERROR", "output pipe create error");
        code = 404;
        exit(1);
    }
    if(pipe(input) == -1)
    {
        LOG("ERROR", "input pipe create error");
        code = 404;
        exit(1);
    }
    //千万不要一创建好管道就开始关闭fd,fork之后才能关
    string env_method = "METHOD=" + request->method;
    string env_content_length = "CONTENT_LENGTH=" + request->Content_Length;
    putenv((char*)env_method.c_str());//为了cgi程序可以知道如何处理,传入方法环境变量
    putenv((char*)env_content_length.c_str());//加入正文长度的环境变量给cgi程序,方便它读取正文
    if(fork() == 0)
    {
        //child
        close(output[1]), close(input[0]);//关闭子进程的无用fd
        dup2(output[0], 0), dup2(input[1], 1);//重定向,原因替换之后原来的fd数据就消失了
        if(request->method == "GET")
        {
            string env_args = "ARGS=" + request->args;
            putenv((char*)env_args.c_str());
        }
        //cerr << request->path << __LINE__ << endl;
        if(execl(request->path.c_str(), nullptr) < 0)
        {
            cerr << "execl error" << endl;
        }//程序替换成cgi程序
    }
    else
    {
        close(output[0]), close(input[1]);//关闭httpServer(父进程)的两个无用fd
        if(request->method == "POST")
        {
            write(output[1], request->request_body.c_str(), request->request_body.size());
        }

        char ch;
        while(read(input[0], &ch, 1) > 0) respond->respond_body.push_back(ch); 
        int st;
        waitpid(-1, &st, 0);
        if(WIFEXITED(st)) code = 200;
        else code = 404;
    }
    return code;
}

环境变量的导入

总共有三个东西要导入:

  1. 参数
  2. 方法
  3. Content-Length

对于GET方法来说:GET方法的参数是在uri上的,是比较短的,因此可以让父进程(http server)通过导入环境变量使子进程(cgi)获得参数。

对于POST方法来说:由于参数在正文当中,有可能很长,因此不采用环境变量,采用的方式是父进程(http server)用管道写,子进程(cgi)用管道读。(这部分不写了,看代码应该没问题)

对于POST方法,Content-Length是必须要导入的,GET如果没有正文也可以不需要Content-Length。实现的时候全部都导入了。

导入环境变量用putenv这个函数,使用很简单。

函数声明:

int putenv(char *string);

这里说个题外话,环境变量是可以继承的,为什么呢?

原因就是环境变量也存在进程的虚拟进程空间mm_struct当中。如图:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-WdxWm3m8-1655709609682)(C:\Users\86135\AppData\Roaming\Typora\typora-user-images\image-20220620135357635.png)]

fork之后,子进程“继承”了父进程的大部分没有经过修改的数据。因此子进程可以获得父进程的环境变量。

管道

管道是单向通信的,因为我们这里要http server和cgi双向通信,因此创建两个管道。一个叫output,一个叫input。命名都是站在http server的角度来讲的,output管道用来给http server写东西,input管道用于给http读数据。

pipe(output);
pipe(input);

然后fork之后父子进程各解开自己不需要的文件描述符。(切记不要fork前就解绑了,这样子进程和管道的关系就不正确了)

对于http server来说,output[0]是不需要的,input[1]也是不需要的。

对于cgi来说,output[1]是不需要的,input[0]是不需要的.

解绑后如图示:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-bCbaXxAi-1655709609683)(C:\Users\86135\AppData\Roaming\Typora\typora-user-images\image-20220620141213641.png)]


重定向

子进程需要对两个管道的fd重定向。

dup2(output[0], 0), dup2(input[1], 1);

解释一下原因:**exec族会把虚拟进程空间的栈,堆,bss segment, data segment , text segment全部清空,换成要换的程序。**然而output[0]是在栈上的一个数组,一旦exec之后,output和input全部都已经消失了,我们无法用output[0]的fd和input[1]的fd来操作管道了

这里强调一下,是fd没了,而不是文件描述符表里面存的文件没了。本质上子进程的文件描述符表里面的某一个下标中还是存着这个管道的,只是我们无法拿到这个文件描述符了。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-VF4TI3n0-1655709609685)(C:\Users\86135\AppData\Roaming\Typora\typora-user-images\image-20220620142815979.png)]

如果重定向成0和1,那么虽然output[0]和input[1]拿不到了,但是我们通过0和1这个文件描述符就可以访问到对应的管道了。


题外话:为什么exec不会把文件描述符表给替换掉?其实想多一点就能想到这个问题。

原因是文件描述符表并不存在于mm_struct,而存在于task_struct.

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-PbO3CP8X-1655709609686)(C:\Users\86135\AppData\Roaming\Typora\typora-user-images\image-20220620143235738.png)]

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-EbvLRv2g-1655709609688)(C:\Users\86135\AppData\Roaming\Typora\typora-user-images\image-20220620143258225.png)]

execl

剩下的工作就是用exec族函数替换成cgi程序,这里采用execl。

execl第一个参数是要替换的程序的路径。第二个参数是需要传的参数,这是一个可变长的参数,最后要加null来表示参数传完了。

由于我们没有参数要传给cgi程序,因此这么写:

execl(request->path.c_str(), nullptr)

CGI程序实现

这里实现了两个CGI程序,一个是简易加减乘除计算器,一个是连接mysql然后往数据库里面插入数据。

但是不管是什么cgi程序,都需要把拿一下参数。因此最重要的还是拿参数的函数。

共有两个函数

  1. getArgs
  2. cutArgs(拿到的参数还不能直接用,因为是一个完整的字符串,要取出关键部分)

getArgs

思路很简单。

  1. 先把方法从环境变量里面拿出来,然后判断是GET方法还是POST方法。
  2. 如果是GET方法,那么直接把参数的环境变量拿出来即可。
  3. 如果是POST方法,那么从正文里面读数据。

对于第三点要注意:正文是http server把正文写进管道了,cgi程序直接从管道里面读即可。

cutArgs

参数一般都长这样:data1=10&data2=100

因此先把&给去掉,剩下两边的字符串。然后再把等号去掉就可以获得10和100两个数字了。

void cutArgs(string& s, string& t, string& args, string sep)
{
    size_t pos = args.find(sep);
    if(pos == string::npos) s = args;
    else s = args.substr(0, pos), t = args.substr(pos + 1);
}

ps:其实是有bug的,万一分隔符不只一个长度就无法拿出正确的参数了。但是mini server不考虑那么多。

加减乘除计算器

之间讲过,实现的时候cgi程序统一返回大字报,因此就写了个html大字报(原因是不会写html)。

强调一下:cout并不是往屏幕打印东西了,之前重定向把fd = 1变成input[1]了,因此cout是往管道里面写东西。

string args = getArgs();
string sub1, sub2;
string name1, val1, name2, val2;
cutArgs(sub1, sub2, args, "&");
cutArgs(name1, val1, sub1, "=");
cutArgs(name2, val2, sub2, "=");

int x = stoi(val1), y = stoi(val2);

cout << "<html>" << endl;
cout << "<body>" << endl;
cout << "<h2>val1 + val2 = " + to_string(x + y) << endl;
cout << "<h2>val1 - val2 = " + to_string(x - y) << endl;
cout << "<h2>val1 * val2 = " + to_string(x * y) << endl;
cout << "<h2>val1 / val2 = " + to_string(x / y) << endl;
cout << "</body>" << endl;
cout << "</html>";

连接mysql往数据库插入数据

主要代码就是这段,其他代码和上面计算器是差不多的,不贴了。

void InsertSql(string& sql)
{
    MYSQL* conn = mysql_init(nullptr);
    mysql_set_character_set(conn, "utf8");
    if(nullptr == mysql_real_connect(conn, "127.0.0.1", "http_test", "1353601324ERIC", "http_test", 3306, nullptr, 0))
    {
        cerr << "db connect error" << endl;
        return;
    }
    mysql_query(conn, sql.c_str());
    mysql_close(conn);
}

args.substr(pos + 1);
}


ps:其实是有bug的,万一分隔符不只一个长度就无法拿出正确的参数了。但是mini server不考虑那么多。



## 加减乘除计算器

之间讲过,实现的时候cgi程序统一返回大字报,因此就写了个html大字报(原因是不会写html)。



**强调一下:cout并不是往屏幕打印东西了,之前重定向把fd = 1变成input[1]了,因此cout是往管道里面写东西。**

```c++
string args = getArgs();
string sub1, sub2;
string name1, val1, name2, val2;
cutArgs(sub1, sub2, args, "&");
cutArgs(name1, val1, sub1, "=");
cutArgs(name2, val2, sub2, "=");

int x = stoi(val1), y = stoi(val2);

cout << "<html>" << endl;
cout << "<body>" << endl;
cout << "<h2>val1 + val2 = " + to_string(x + y) << endl;
cout << "<h2>val1 - val2 = " + to_string(x - y) << endl;
cout << "<h2>val1 * val2 = " + to_string(x * y) << endl;
cout << "<h2>val1 / val2 = " + to_string(x / y) << endl;
cout << "</body>" << endl;
cout << "</html>";

连接mysql往数据库插入数据

主要代码就是这段,其他代码和上面计算器是差不多的,不贴了。

void InsertSql(string& sql)
{
    MYSQL* conn = mysql_init(nullptr);
    mysql_set_character_set(conn, "utf8");
    if(nullptr == mysql_real_connect(conn, "127.0.0.1", "http_test", "1353601324ERIC", "http_test", 3306, nullptr, 0))
    {
        cerr << "db connect error" << endl;
        return;
    }
    mysql_query(conn, sql.c_str());
    mysql_close(conn);
}
;