【毕业项目】自主设计HTTP

博客介绍:运用之前学过的各种知识 自己独立做出一个HTTP服务器

自主设计WEB服务器

  • 背景
  • 目标
  • 描述
  • 技术特点
  • 项目定位
    • 开发环境
    • WWW介绍
  • 网络协议栈介绍
    • 网络协议栈整体
    • 网络协议栈细节
    • 与http相关的重要协议
  • HTTP背景知识补充
    • 特点
    • uri & url & urn
    • 网址url
  • HTTP请求和响应
    • HTTP请求协议格式
    • HTTP响应格式
  • 项目编写
    • TcpSever
    • main.cc
    • makefile
    • HttpServer.hpp
    • Prorocol
  • 接收报头
  • 读取请求
  • 日志
  • 请求读取与解析
    • 解析请求行
    • 解析请求报头
    • 处理请求正文
  • 处理请求并且构建响应
    • HTTP状态码
    • 获取参数
    • Web根目录
    • HTTP CGI机制简单介绍
    • 构建响应
      • 添加状态行
      • 添加响应正文
      • 发送响应
      • 添加响应报头
  • 设计CGI程序
    • 进程间通信
      • 建立管道
      • 进程替换
    • 交互数据
      • POST方法处理
      • GET方法处理
      • 传递传参方法
    • CGI程序接收数据
    • CGI程序处理数据
      • 父进程读取处理完的数据
    • CGI程序总结
  • 状态码介绍
  • 处理读取出错
  • 处理写入错误
  • 多线程转化线程池
    • 设计任务类
    • 设计线程池
  • 补充内容
    • 表单测试
    • cgi程序支持多种语言
    • 项目代码

背景

http协议被广泛使用 从移动端 pc端浏览器 http协议无疑是打开互联网应用窗口的重要协议

http在网络应用层中的地位不可撼动 是能准确区分前后台的重要协议

虽然说现在最常用的是https协议 但是我们学会了http协议之后对于https的学习也能更加轻松

目标

对http协议的理论学习 从零开始完成web服务器开发 坐拥下三层协议 从技术到应用 让网络难点无处遁形

描述

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

技术特点

关于此次WEB服务器项目 我们主要用到的技术有

  • 网络编程 (TCP/IP协议, socket流式套接字,http协议)
  • 多线程技术
  • cgi技术
  • shell脚本
  • 线程池

项目定位

开发环境

此次项目使用的开发环境是 LinuxCentos7

使用的工具有 gcc/g++/gdb + C/C++

WWW介绍

WWW是环球信息网的缩写 (亦作“Web”、“WWW”、“‘W3’”,英文全称为“World Wide Web”) 中文名字为“万维网”,"环球网"等,常简称为Web

它分为web服务器和web客户端

WWW可以让Web客户端(常用浏览器)访问浏览Web服务器上的页面 是一个由许多互相链接的超文本组成的系统 通过互联网访问

在这个大系统中 我们将每一个有用的事务都称为一个资源 并且由一个全局 统一资源标识符 (URI)标识 这些资源通过超文本传输协议 (HTTP协议) 传送给用户 而后者通过点击连接来获取资源

网络协议栈介绍

网络协议栈整体

从之前网络部分的学习我们知道 TCP/IP协议下的网络模型可以分为四层

  • 应用层
  • 传输层
  • 网络层
  • 数据链路层

具体见下图
【毕业项目】自主设计HTTP_第1张图片

网络协议栈细节

【毕业项目】自主设计HTTP_第2张图片

从作用上分类 我们可以将网络四层模型分为两部分

  • 应用层 主要负责对于数据的应用
  • 数据传输 主要负责对于数据的传输

从细节上看

发送端自上而下会经过 应用层 传输层 网络层 数据链路层 其中经过每一层都会进行添加报头的操作来保证数据正确的送达对面

接收端自下而上的会对于这些数据进行解包 所以说接收端和发送端 我们可以认为他们同层之间看到的数据是相同的即同层之间可以看作是能够直接通信

与http相关的重要协议

  • tcp
  • ip
  • dns

tcp和ip协议我们在网络部分已经深入了解学习过了 那么什么是dns协议呢?

【毕业项目】自主设计HTTP_第3张图片

我们在ping www.baidu.com的时候 可以发现下方自动给我们转化为了一个ip地址

事实上这个ip地址就是百度服务器的公网ip 而dns的作用就是将域名转化为ip地址

为什么要有dns协议的存在呢?

因为我们人类更擅长记住一些有意义的字符串而不是数字 域名和dns本质是为了优化用户的使用体验的

HTTP背景知识补充

目前主流的服务器使用的是http/1.1版本 而我们此次项目使用http/1.0来进行讲解 同时我们还会对比1.0和1.1版本的各种区别

此外我们此次项目只会写服务器 客户端使用浏览器代替

特点

HTTP协议有个特点就是C/S模式 (客户端服务器模式)

【毕业项目】自主设计HTTP_第4张图片

客户端通过一些方法(get post等)向服务器发送请求 之后服务器接收到请求之后发送响应

http协议有以下四个特点

  • 简单快速 http服务器的规模很小(比如说我们今天要写的服务器代码只有1000行左右) 所以说通信速度很快
  • 灵活 http协议可以传输任意类型的数据 正在传输的类型由Content-Type (我们后面会详细讲解)加以标记
  • 无连接 每次连接只处理一个请求 服务器处理完客户的请求 收到客户的应答后 即断开连接 采用这种方式可以节省传输时间
  • 无状态

http协议的无连接体现在哪里

http协议的无连接是对比tcp协议的连接而言的

http协议它本身对于连接没有概念 它只知道将自己要发送的数据交给下层协议 然后下层协议就会将数据发送到对端

http协议的无状态体现在哪里

http协议的无状态体现在它并不会记得自己发送或者接受过任何的数据

但是同学们读到这里可能会产生一个疑问 那么为什么我在浏览器上登录一个网站之后这个网站就记得我了呢?

这实际上是由浏览器的cookie和session机制实现的 具体的原理可以参考这篇博客

cookie和session

uri & url & urn

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

URI是一种抽象的 更高层次的一种统一资源标识符 而URL和URN则是一种具体的标识符

URL和URN是URI的子集 不过我们使用URN并不多

简单来说 URI和URL的主要区别是

  • URI强调唯一标识一个资源
  • URL强调唯一定位一个资源
  • URL是URI的子集 因为如果能唯一定位一个资源就一定能唯一标识一个资源

网址url

URL(Uniform Resource Lacator)叫做统一资源定位符也就是我们通常所说的网址

【毕业项目】自主设计HTTP_第5张图片

其中服务器地址就是域名对应着我们的IP地址 带层次的文件路径实际上就是我们Linux中的路径

接下来我们来较为全面的认识下上面URL

一、协议方案名

http://表示的是协议名称 表示请求时需要使用的协议 通常使用的是HTTP协议或安全协议HTTPS

HTTPS是以安全为目标的HTTP通道 在HTTP的基础上通过传输加密和身份认证保证了传输过程的安全性

二、登录信息

usr:pass表示的是登录认证信息 包括登录用户的用户名和密码

虽然登录认证信息可以在URL中体现出来 但绝大多数URL的这个字段都是被省略的 因为登录信息可以通过其他方案交付给服务器

三、服务器地址

www.example.jp表示的是服务器地址 也叫做域名

HTTP请求和响应

HTTP请求协议格式

HTTP的请求协议格式如下:

在这里插入图片描述

我们可以看到HTTP请求由四部分组成

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

其中前面三部分是由HTTP协议自带的 而请求正文则是用户的相关信息和数据 如果说用户没有信息要上传给服务器 此时正文则为空

如何将HTTP请求的报头与有效载荷进行分离?

首先我们要明白哪里是报头哪里是有效载荷

请求报头:请求行+请求报头
有效载荷:请求正文

细心的同学就可以发现了 事实上报头和有效载荷之间隔着一个空行

如果我们将整个http协议想象成一个线性的结构 每一行都是使用\n来进行分隔的 那么如果我们连续读取到两个\n的话就说明报头读取完毕了开始读取有效载荷

获取浏览器的HTTP请求

在网络协议栈中 应用层的下一层叫做传输层 而HTTP协议底层通常使用的传输层协议是TCP协议

因此我们可以用套接字编写一个TCP服务器 然后启动浏览器访问我们的这个服务器

由于我们的服务器是直接用TCP套接字读取浏览器发来的HTTP请求 此时在服务端没有应用层对这个HTTP请求进行过任何解析

因此我们可以直接将浏览器发来的HTTP请求进行打印输出 此时就能看到HTTP请求的基本构成

HTTP响应格式

HTTP响应协议格式如下:

在这里插入图片描述

HTTP响应由以下四部分组成:

  • 状态行:[http版本]+[状态码]+[状态码描述]
  • 响应报头:响应的属性 这些属性都是以key: value的形式按行陈列的
  • 空行:遇到空行表示响应报头结束
  • 响应正文:响应正文允许为空字符串 如果响应正文存在 则响应报头中会有一个Content-Length属性来标识响应正文的长度 比如服务器返回了一个html页面 那么这个html页面的内容就是在响应正文当中的

如何将HTTP响应的报头与有效载荷进行分离?

对于HTTP响应来讲 这里的状态行和响应报头就是HTTP的报头信息 而这里的响应正文实际就是HTTP的有效载荷

而报头信息和响应正文之间我们使用换行符来进行分隔

当客户端收到一个HTTP响应后 就可以按行进行读取 如果读取到空行则说明报头已经读取完毕

再介绍其他的格式细节之前我们先来写上一部分的代码

项目编写

TcpSever

我们首先再Linux服务器上创建一个项目目录 之后在项目目录里面创建一个hpp文件

【毕业项目】自主设计HTTP_第6张图片

这里有同学可能会有疑惑 为什么要写的是一个HTTP的服务器 这里确要先写一个TcpSever呢

这是因为Http是基于Tcp的 我们首先要保证信息的传输之后再设计http

为什么文件格式要是hpp

hpp格式的C++文件通常可以将类的声明和实现放在一起 比较适合存在于开源项目中

我们设计一个TcpSever类只需要提供一个端口就可以了 因为操作系统会给我们自动分配一个ip 需要注意的是因为我们这里使用的是云服务器 最好不要使用云服务器的公网ip和私有ip 否则有可能会出现对端主机连接不上的情况(ip不是真正的公网ip)

下面是TcpServer的代码

  #pragma once     
      
  #include     
  #include     
  #include     
  #include     
  #include     
  #include     
  #include     
  #include     
      
  #define PORT 8081    
  #define BackLog 5    
      
  class TcpServer{    
    private:    
      int _port;    
      int _listen_sock;    
      static TcpServer* svr;    
    private:    
      TcpServer()    
        :_port(PORT),    
        _listen_sock(-1)    
    {}                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                            
W>   TcpServer(const TcpServer &s)    
     {}    
     TcpServer(int port)    
       :_port(port),    
       _listen_sock(-1)    
    {}    
    public:    
      
      static TcpServer* getinstance(int port)    
      {    
        static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;    
        if (nullptr == svr)    
        {    
          pthread_mutex_lock(&lock);    
          if(nullptr == svr)    
          {    
            svr = new TcpServer(port);    
            svr->InitSever();    
          }    
          pthread_mutex_unlock(&lock);    
        }    
        return svr;    
      }    
      void InitSever()    
      {    
        Socket();    
        Bind();    
        Listen();    
      }    
      void Socket()    
      {    
        _listen_sock = socket(AF_INET,SOCK_STREAM,0);    
        if(_listen_sock < 0)    
        {    
          exit(1);    
        }    
      
        // 地址复用 防止突然断开连接 不能立刻复用端口    
        int opt = 1;    
        setsockopt(_listen_sock,SOL_SOCKET,SO_REUSEADDR,&opt , sizeof(opt));    
      }    
      void Bind()    
      {    
        struct sockaddr_in local;    
        memset(&local , 0 ,sizeof(local));    
        local.sin_family = AF_INET;    
        local.sin_port = htons(_port);    
        local.sin_addr.s_addr = INADDR_ANY; // 不能直接绑定公网IP    
        if(bind(_listen_sock , (struct sockaddr*)&local , sizeof(local))<0){    
          exit(2);    
        }    
      }    
      void Listen()    
      {    
        if(listen(_listen_sock,BackLog) < 0)    
        {    
          exit(3);    
        }    
      } 
      int Sock()
      {
         return _listen_sock;
      }   
      ~TcpServer()    
      {    
      
      }    
  };    
      
  TcpServer* TcpServer::svr = nullptr;    

上面是我们使用单例模式设计出来的一个TcpSever 创建Listen套接字 绑定 监听都是网络部分很常见的套路 封装即可

这部分代码其实在网络部分就写过很多遍了 这里就不过多赘述

main.cc

#include     
#include     
#include     
#include "HttpSever.hpp"    
    
    
static void Usage(std::string proc)    
{    
  std::cout << "Usage:\n\t" << proc << " port" << std::endl;    
}    
    
int main(int argc , char* argv[])    
{    
  if (argc != 2)    
  {    
    Usage(argv[0]);    
    exit(4);    
  }                                                                                                                                                                                
    
  int port = atoi(argv[1]);    
    
  std::shared_ptr<HttpSever> http_server(new HttpSever(port));    
    
  http_server->InitSever();    
  http_server->Loop();    
    
  for(;;)    
  {    
    ;    
  }    
  return 0;    
}   

makefile

bin=httpsecer    
cc=g++    
LD_FLAGS=-std=c++11 -lpthread    
src=main.cc    
    
    
$(bin):$(src)    
  $(cc) -o $@ $^ $(LD_FLAGS)     
    
.PHONY:clean    
clean:    
  rm $(bin)   

HttpServer.hpp

  #pragma once     
      
  #include     
  #include     
  #include "TcpServer.hpp"    
  #include "Prorocol.hpp"    
      
  class HttpSever{    
    private:    
      bool _stop; // 是否在运行    
      int _port;    
      TcpServer* _tcp_server;    
    public:    
      HttpSever(int port = 8081)    
W>      :_port(port),    
        _stop(false),    
        _tcp_server(nullptr)    
      {}    
      
      void InitSever()    
      {    
        _tcp_server = TcpServer::getinstance(_port);    
      }    
      
      void Loop()    
      {    
        int listen_sock = _tcp_server->Sock();    
      
        while(!_stop)    
        {    
          struct sockaddr_in peer;    
          socklen_t len = sizeof(peer);    
          int sock = accept(listen_sock ,(struct sockaddr*)&peer , &len);    
          if (sock < 0)    
          {    
            continue; // 获取失败了不暂停    
          }    
      
          int* msock = new int(sock);    
          pthread_t tid;    
          pthread_create(&tid , nullptr , Entrance::HandlerRequest , msock);    
          pthread_detach(tid);    
        }
      }
      ~HttpSever()
      {}                                                                                                                                                                           
  };

Prorocol

#pragma once     
    
#include                                                                                                                                                                 
#include     
    
class Entrance    
{    
  public:    
    static void* HandlerRequest(void* _sock)    
    {    
      int sock =*(int*)_sock;    
      delete (int*)_sock;    
    
      std::cout << "get a new link ..." << sock << std::endl;    
    
      close(sock);    
      return nullptr;    
    }    
    
};

我们写完上面的代码之后编译运行 之后使用浏览器来访问我们服务器的8081端口

我们可以发现这样的现象

【毕业项目】自主设计HTTP_第7张图片

服务器一直在打开4和5套接字 这是为什么呢?

因为浏览器不知道自己的请求有没有被服务器接收到 所以说浏览器一直重新发送请求 这也就导致了服务器一直打开新的套接字

为什么文件描述符是从4开始的呢?

因为C++文件默认会打开 0 1 2 三个文件描述符 此外监听套接字也会占用一个文件描述符

接收报头

此时我们的服务器就已经可以跑起来了 接下来我们就重点完成Prorocol里面的内容 接收到请求之后应该怎么做

我们首先让协议打印出我们接收的请求 该段代码如下

#ifndef DEBUG     
#define DEBUG     
          char buffer[4096];    
          recv(sock , buffer , sizeof(buffer) , 0);    
          std::cout << "--------------begin-------------" << std::endl;    
          std::cout << buffer << std::endl;    
          std::cout << "-------------- end -------------" << std::endl;                                                        
#endif 

之后我们重新编译运行

【毕业项目】自主设计HTTP_第8张图片

此时我们就可以发现 我们的服务器可以打印浏览器发送过来的一些请求报头

接下来我们逐段分析下这些请求报头

【毕业项目】自主设计HTTP_第9张图片
我们可以看到在请求报头的第一行GET方法后面有个 \ 标志 这个标志和我们的Linux服务器根目录的标志十分相似 那么它到底是不是Linux服务器的根目录呢?
【毕业项目】自主设计HTTP_第10张图片
我们的回答是 不一定

这个路径通常不是我们Linux服务器的根目录 而是由http服务器设置的一个WEB根目录

这个WEB根目录就是Linux下一个特定的路径

读取请求

其实我们HTTP处理请求的过程无非就是三步

  1. 读取请求
  2. 分析请求
  3. 构建响应并返回

我们目前仍然出于处理请求的阶段 有同学可能会有疑问 我们这里不是将数据全部都读取完毕了嘛?

其实我们这里读取的数据是不准确的

          char buffer[4096];    
          recv(sock , buffer , sizeof(buffer) , 0);    
          std::cout << "--------------begin-------------" << std::endl;    
          std::cout << buffer << std::endl;    
          std::cout << "-------------- end -------------" << std::endl;      

我们读取的代码是这样子 它不管客户端每次发送过来多少的请求 我们直接将它放在一个4096字节的数组里面

但是有没有一种可能 客户端一次性会发来多个请求呢? 那么这样子我们还是一次性读取4096个字节 是不是就会发生一个粘包的问题啊 那么这个时候我们就要想办法解决这个问题

我们从它的请求格式来分析

【毕业项目】自主设计HTTP_第11张图片

我们观察上图可以发现 实际上HTTP协议的报头是按照行来分隔的 并且在报头和正文中间有一个空行作为间隔 所以说我们就能很简单的将报头和正文分隔开

但是这里就会出现一个问题 那就是每个平台或是浏览器他们分隔一行的方式可能不同 具体有下面三种

  • xxxxxxx /r/n
  • xxxxxxx /r
  • xxxxxxx /n

所以说我们还要自己主动实现一个类来实现分隔行的问题

代码如下

#pragma once     
    
#include     
#include     
#include     
#include     
// ¹¤¾ßÀà    
    
    
class Util    
{    
  public:    
    static int ReadLine(int sock ,std::string &out)    
    {    
      char ch = 'X';    
      while(ch != '\n')    
      {    
        ssize_t s = recv(sock , &ch , 1 ,0);    
        if (s > 0)    
        {    
          if (ch == '\r')    
          {                                                                                                                                                                                                                                                                                                                                                                                                                                                  
            // ת»¯Îª /n    
            recv(sock , &ch , 1 , MSG_PEEK);    
            if (ch == '\n')    
            {    
              recv(sock , &ch , 1 , 0);    
            }    
            else    
            {    
              ch = '\n';    
            }    
          }    
          // 1. ÆÕͨ×Ö·û    
          // 2 \n    
          out.push_back(ch);    
    
        }    
        else if (s == 0)    
        {    
          return 0;    
        }    
        else    
        {    
          return -1;    
        }    
      }    
    
      return out.size();    
    }    
};    

简单解释下上面的代码 它的作用是将每一行的结束标识符全部统一为\n

因为我们行结束的标志只有三种 所以我们可以做以下区分

  1. 如果读到了\n 则说明一行读取完毕
  2. 如果读到了\r 我们不确定后面一个是不是\n 所以说我们需要窥探下一个字符是什么
  3. 我们使用recv(sock , &ch , 1 , MSG_PEEK)来窥探下一个字符 其中MSG_PEEK选项的含义就是下一个字符
  4. 如果下一个字符是\n 则读取下一个字符将当前的\r覆盖掉
  5. 如果不是\n则直接将当前字符修改为\n

在能够读取单行之后我们可以开始获取一个完整的Http请求了

我们将实现获取完整Http请求这个步骤放在Prorocol这个文件里面方便管理

具体的实现思想是

  • 我们分别设置一个请求类和响应类来保存请求和响应
  • 之后我们再设计一个终端类 在这个终端类当中获取到完整的Http请求并且构建起响应

具体代码如下

#pragma once     
    
#include     
#include     
#include     
#include     
#include     
#include     
#include "Util.hpp"    
    
    
class HttpRequest{    
  public:    
    std::string _request_line;    
    std::vector<std::string> _request_header;    
    std::string _blank;    
    std::string _request_body;    
};    
    
class HttpResponse{    
  public:    
    std::string _response_line;    
    std::vector<std::string> _response_header;    
    std::string _blank;    
    std::string _response_body;    
};    
    
class EndPoint{    
  private:    
    int _sock;    
    HttpRequest http_request;    
    HttpResponse http_response;    
  private:    
    void RecvRequestLine()    
    {    
      Util::ReadLine(_sock ,http_request._request_line);    
    }    
  public:    
    EndPoint(int sock)    
      :_sock(sock)    
    {}    
    void RcvRequest()    
    {}                                                                                                                                                                                                                                                                                                                                                              
    void ParseRequest()    
    {}    
    void SendResponse()    
    {}    
    void BuildResponse()    
    {}    
    ~EndPoint()    
    {    
      close(_sock);    
    }    
};    
    
    
class Entrance    
{    
  public:    
    static void* HandlerRequest(void* _sock)    
    {    
      int sock =*(int*)_sock;    
      delete (int*)_sock;    
    
      std::cout << "get a new link ..." << sock << std::endl;    
    
#ifdef DEBUG     
          char buffer[4096];    
          recv(sock , buffer , sizeof(buffer) , 0);    
          std::cout << "--------------begin-------------" << std::endl;    
          std::cout << buffer << std::endl;    
          std::cout << "-------------- end -------------" << std::endl;    
#else    
          EndPoint* ep = new EndPoint(sock);    
          ep->RcvRequest();    
          ep->ParseRequest();    
          ep->BuildResponse();    
          ep->SendResponse();    
          delete ep;    
#endif    
          return nullptr;    
    }    
};    

日志

为什么要写日志

这个问题也可以是为什么要有日志

日志可以帮助我们了解错误发生的原因和程序运行的状态 从而可以帮助我们更好的去排除错误或者是优化我们的程序

我们要写的日志格式是什么样的

格式如下

【毕业项目】自主设计HTTP_第12张图片

分别介绍下上面各个信息

日志级别

日志也是分级别的 这些级别让我们更好的辨别要如何处理这条日志

级别一般分为下面几种

  • INFO 正常的打印信息
  • WARNING 警告 可能会有错误
  • ERROR 有错误 但是不影响程序运行
  • FATAL 致命的错误 程序运行不了了

时间戳

我们可以直接使用c语言函数来获取时间戳

c语言中的time函数能够返回给我们一个时间戳

函数原型如下

time(nullptr)

使用时我们只需要传入参数nullptr即可 (如果不是C++11以后的版本传入NULL)

日志信息

告诉我们错误和风险提示等信息

错误文件名称和行数

在c语言中预先定义了__FILE__ 以及 __LINE__ 我们直接使用它们来获取错误文件名称和行数即可

代码如下

#pragma once     
    
#include     
#include     
#include     
    
#define INFO  1    
#define WARNING 2    
#define ERROR 3    
#define FATAL 4     
    
    
#define LOG(level , message) Log(#level , message , __FILE__ , __LINE__)                                                                                          
    
void Log(std::string level, std::string message , std::string file_name , int line)    
{    
  std::cout << level << " " << time(nullptr) << " " << message << " " << file_name << " " << line << std::endl;    
} 

之后我们只需要将LOG宏使用起来即可

请求读取与解析

一个完整的请求报文如下

【毕业项目】自主设计HTTP_第13张图片
我们读取请求行只需要读取第一行即可

    void RecvRequestLine()    
    {    
      Util::ReadLine(_sock ,http_request._request_line);    
    }

那么 我们应该如何读取行数不确定的请求报头呢?

我们现在知道的有两点

  1. 请求报头是一行一行发送的
  2. 请求报头读取完毕之后下一个一定是空行

有了上面这两点之后我们就能很轻松的读取完所有的请求报头

只需要按行读取并且读取到\n结束即可

    void RecvRequestLine()    
    {    
      Util::ReadLine(_sock ,http_request._request_line);    
    }    
    void RecvRequestHeader()    
    {    
      std::string line;    
      while(true)    
      {    
        line.clear();    
        Util::ReadLine(_sock , line);                                                                                 
        if (line == "\n")    
        {    
          break;    
        }    
        line.resize(line.size()-1); // remove \n    
        http_request._request_header.push_back(line);    
      }    
    
      if (line == "\n")    
      {    
        http_request._blank = line;    
      }    
    } 

解析请求行

读取到请求行和请求报头之后我们就开始解析它们

首先是请求行 格式如下

在这里插入图片描述

    std::string method;
    std::string uri;
    std::string version;      

所以说我们只需要按照空格作为分隔符 将请求行的代码分隔为三部分即可

而我们这里推荐使用stringstream 这个类中重载了流插入运算符默认会按照空格来进行分隔给字符串赋值 用法代码示例如下

    void ParseHttpRequestLine()
    {
      auto& line = http_request._request_line;
      std::stringstream ss(line);
      ss >> http_request.method >> http_request.uri >> http_request.version;                                          
    }

解释下上面这段代码 我们首先使用line拷贝构造一个stringstream对象 之后让这个对象分别以空格为分隔符将它的内容赋值给http_request.method http_request.uri http_request.version

解析请求报头

【毕业项目】自主设计HTTP_第14张图片

经过观察我们不难发现 这里其实就是一个键值对结构 所以说我们使用哈希表来存储即可

有关于哈希表部分知识不理解的同学可以参考我的这篇博客

unordered_map

但是首先我们需要写一个方法让一个字符串按照冒号分隔为两部分

具体实现方法如下

   static bool CutString(const std::string& target , std::string& key_out , std::string& value_out , std::string sep){      
  size_t pos = target.find(sep);
  if (pos != std::string::npos)     
  {                                
    key_out = target.substr(0 ,pos);
    value_out = target.substr(pos + sep.size());
    return true;                                    
  }                 
  return false;                                                                                                       
}

之后我们只需要遍历整个请求报头 一个个分割key和value之后插入哈希表中就可以

代码表示如下

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

处理请求正文

处理完上面的两个部分之后我们再回过来看请求报文的图

【毕业项目】自主设计HTTP_第15张图片

此时我们就面临两个问题了

  1. 是否存在正文
  2. 如果存在正文 那么正文有多少个字节

关于第一个问题

一般来说我们的请求方法如果是GET 则一般没有正文

如果我们的请求方法是POST 则一般有正文

关于第二个问题

我们可以由请求报头中的 Content–legth字段来知晓

判断是否存在正文的代码如下

    bool IsRecvBody()    
    {    
      auto method = http_request.method;    
      if (method == "POST")    
      {    
        auto &header_kv = http_request.header_kv;    
        auto iter = header_kv.find("Content-Length");    
        if (iter != header_kv.end())    
        {    
          http_request.content_length = atoi(iter->second.c_str());     
          return true;    
        }    
      }                                                                                                               
      return false;    
    }   

读取正文代码如下

   void RecvRequestBody()    
    {    
      if (IsRecvBody())
      {
        int content_length = http_request.content_length;
        auto& body = http_request._request_body;                                                                      

        char ch = 0;
        while(content_length)
        {
          ssize_t s = recv(_sock , &ch , 1, 0);
          if (s > 0)
          {
            body.push_back(ch);
            content_length--;
          }
          else 
          {
            break;
          }
        }
      }
    }

至此 我们的请求和解析报文全部完成

处理请求并且构建响应

在处理这个请求之前 我们首先要考虑这个请求是否是合法的请求

如果是非法的请求 我们应该怎么操作

假设我们现在的Http服务器只接收GET和POST方法的请求 其他的请求只给予一个错误的响应即可 (我们这里最开始默认为404 NOT FOUND)

代码如下

    void BuildResponse()    
    {    
      if (http_request.method != "GET" && http_request.method != "POST")    
      {    
                                                                                                                      
      }    
    } 

对于响应报文来说 最重要的一部分就是我们的状态码了

【毕业项目】自主设计HTTP_第16张图片

HTTP状态码

HTTP的状态码如下:

编号 类别 意义
1XX Informational(信息性状态码) 接收的请求正在处理
2XX Success(成功状态码) 请求正常处理完毕
3XX Redirection(重定向状态码) 需要进行附加操作以完成请求
4XX Client Error(客户端错误状态码) 服务器无法处理请求
5XX Server Error(服务器错误状态码) 服务器处理请求出错

其中我们要记住的有下面这几个

101 信息请求中

101表示客户端发送的请求正在处理中 但是因为网速变快 这种状态已经不怎么常见了

200 OK

这是最常见的一个状态码 也就是我们访问网页成功的时候网页返回的响应行

301 永久重定向

比如果一个老的网站废弃不用了使用一个新的网站 那么此时这个网站就可以使用永久重定向 如果有人还在访问这个网站就会跳转到新网站上

此外如果收藏夹中收藏了老的网站 新的网站会覆盖收藏夹中老的网站

302 307 临时重定向

从名字看就更好理解了 和301永久重定向相比一个是永久的一个是临时的 它并不会覆盖掉收藏夹中的老网站

403 权限不足

这个常见于我们去实习的时候 自己的权限特别低 如果leader丢给你一个文档而你没有观看的权限就会出现这个状态码

404 NOT FOUND

常见于资源消失不见(被删除或过期) 又或者说资源根本不存在

比如说你访问一个网站的时候带上一个不存在的资源路径你就会看到这个状态码

504 Bad Gateway

常见于服务器出现问题 和客户端无关

Redirection(重定向状态码)

除了上面那些要记住的状态码之外 我们还需要更深入的理解重定向状态码

重定向又分为永久重定向和临时重定向 其中301表示永久重定向 302 307表示临时重定向

临时重定向和永久重定向本质是影响客户端的标签 决定客户端是否需要更新目标地址

如果某个网站是永久重定向 那么第一次访问该网站时由浏览器帮你进行重定向 但后续再访问该网站时就不需要浏览器再进行重定向了 此时你访问的直接就是重定向后的网站

而如果某个网站是临时重定向 那么每次访问该网站时如果需要进行重定向 都需要浏览器来帮我们完成重定向跳转到目标网站

获取参数

其实目前从宏观的角度上来说 我们的上网行为可以分为两种

  1. 浏览器向服务器上传数据
  2. 浏览器向服务器请求数据

【毕业项目】自主设计HTTP_第17张图片

其中向浏览器上传数据的时候我们有两种方法

  1. 使用GET方法 通过url来进行传参
  2. 使用POST方法 通过正文来进行传参

客户端为什么要将数据上传到服务器呢?

当然是为了让服务器对客户端传输上来的数据进行处理

这里根据GET和POST传参方式的不同 我们处理请求的方式也需要发生一点变化

由于POST方法传参是通过正文传递参数的 而正文我们已经获取到了 所以说不用关心

但是GET方法传参是通过url传参 而url在请求行中 需要我们特殊处理一下

处理的代码如下 其实就是复用了我们前面剪切字符串的功能函数

      auto& code = http_response.response_code;     
      if (http_request.method != "GET" && http_request.method != "POST")    
      {    
        // waring request     
        code = NOT_FOUND;    
        goto END;    
      }    
      if (http_request.method == "GET")    
      {    
        size_t pos = http_request.uri.find("?");    
        if (pos != std::string::npos)    
        {    
          Util::CutString(http_request.uri,http_request.path, http_request.query_string , "?");    
        }    
        else    
        {    
          http_request.path = http_request.uri;                                                                                
        }    
      }    
      std::cout << "debug url: " << http_request.uri << std::endl;    
      std::cout << "debug path: " << http_request.path << std::endl;    
      std::cout << "debug query_string: " << http_request.query_string << std::endl; 

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

Web根目录

我们得到rul之后可以看到这样的一串标识符
在这里插入图片描述

  1. 这里的路径表明了是请求Linux服务器上的某种资源 那么这种资源是从哪里开始的呢?是根目录嘛?
  2. 这个路径对应的资源是如何判断存在的

问题一:资源是从哪里开始的

这个资源不一定是从根目录开始的

一般来说我们会自己指定一个web根目录 所有的资源都在这个web根目录当中

一般来说web根目录的名称是wwwroot如下

【毕业项目】自主设计HTTP_第18张图片
如果说访问我们服务器的客户端没有指明想要获取什么资源的话我们肯定不可能将web根目录下的所有资源全部发出去

所以说这个时候我们就要指定一个默认的资源 一般来说这个资源就是index.html

【毕业项目】自主设计HTTP_第19张图片

所以说此时我们的path就不能简单的是/a/b/c了

在这里插入图片描述
我们要在前面加上web根目录在Linux服务器中的定位 比如说像这样

 wwwroot(web根目录的路径) 加上 /a/b/c

定位代码如下

#define WEB_ROOT "wwwroot/"

std::string _path = http_request.path;
http_request.path = WEB_ROOT;
http_request.path += _path;
std::cout << "debug: " << http_request.path << std::endl;   

演示效果如下

在这里插入图片描述
我们前面也说过了 如果客户端请求的是Web根目录的话我们不可能将整个Web根目录的所有资源全部给他 所以说针对于请求web根目录的情况我们要做一些特殊处理

如果请求的是我们的web根目录我们就返回index.html页面给它

代码表示如下

      #define HOME_PAGE "index.html"
      http_request.path = WEB_ROOT;    
      http_request.path += _path;    
    
      if (http_request.path[http_request.path.size()-1] == '/')    
      {    
        http_request.path += HOME_PAGE;    
      }    
          
      std::cout << "debug: " << http_request.path << std::endl;  

演示效果如下
在这里插入图片描述

问题二 : 如何确认这个资源是存在的

我们首先使用百度来试验下 如果资源不存在会怎么样

【毕业项目】自主设计HTTP_第20张图片
可以发现 百度服务器直接给我们返回了一个404告知我们该资源不存在

所以说我们在返回给客户端请求之前需要确认一个资源是否存在

这里使用确认资源是否存在的函数是stat函数

stat函数

函数原型如下

int stat(const char* path , struct stat *buf)

参数说明:

  • const char* path 是我们要寻找的路径(是一个字符串)
  • struct stat *buf 这是一个结构体 我们通过该结构体来查看文件的信息

返回值说明:

如果找到该文件返回0 如果没找到返回-1

      struct stat st;
      if (stat(http_request.path.c_str() , &st) == 0)
      {     
        // exist                                                
            
      }     
      else 
      {     
        // not exist 
        code = NOT_FOUND;
        goto END;
      }

所有的目录中都有一个index.html嘛?

是的 因为一个目录中可能有着大量的网页资源 如果我们不设置一个默认的index.html则系统就不知道应该返回哪个资源给客户端了

在回答了问题二之后便会衍生出一个问题三

存在的资源就是可以读取的资源嘛?

  • 存在的资源可能是一个目录
  • 存在的资源可能是一个可执行程序

请求的资源是目录

我们可以使用下面的方法来判断是否请求的资源是一个目录

S_ISDIR(st.st_mode)

S_ISDIR是一个宏 而st_mode则是stat结构体中的一个成员变量

使用这个宏我们就能够判断当前请求的资源是否是一个目录

如果是一个目录 我们前面介绍过 每个目录都会有一个index.html 所以我们直接在请求的路径上加上即可

请求的资源是可执行程序

一般来说 客户端请求可执行程序是被允许的

但是我们这里要对于这种情况做一下特殊处理

我们通过下面的方法来确定文件是否是一个可执行文件

        if ((st.st_mode&S_IXUSR) || (st.st_mode&S_IXGRP) || (st.st_mode&S_IXOTH))    
        {                                                                  
          // special treatment                                                                                        
        }    

关于如何特殊处理 本文后面会详细讲解

HTTP CGI机制简单介绍

CGI(Common Gateway Interface) 是WWW技术中最重要的技术之一 有着不可替代的重要地位

CGI是外部应用程序(CGI程序)与WEB服务器之间的接口标准 是在CGI程序和Web服务器之间传递信息的过程

其实 要真正理解CGI并不简单 首先我们从现象入手

浏览器除了从服务器下获得资源(网页 图片 文字等) 有时候还有能上传一些东西(提交表单 注册用户之类的) 看看我们目前的http只能进行获得资源 并不能够进行上传资源所以目前http并不具有交互式 为了让我们的网站能够实现交互式 我们需要使用CGI完成 时刻记着 我们目前是要写一个http 所以 CGI的所有交互细节 都需要我们来完成

理论上 可以使用任何语言来编写CGI程序

需要注意的是 http提供CGI机制 和CGI程序是两码事 就好比学校(http)提供教学(CGI机制)平台 学生(CGI程序)来学习


反应到具体的web服务器中 什么是CGI机制呢

我们首先创建一个handerdate.exe作为一个可执行文件

【毕业项目】自主设计HTTP_第21张图片
之后的过程如下

  • 浏览器传输数据给服务器中的HTTPSERVER
  • HTTPSERVER接收到数据之后不做处理 将输出传递给HANDERDATE
  • HANDERDATE处理完数据之后再将处理完的数据传递给SERVER
  • HTTPSERVER接收到数据之后将处理过的数据传递给浏览器

【毕业项目】自主设计HTTP_第22张图片

调用目标程序 传递目标数据 拿到目标结果 这中间用到的就是CGI技术

那么我们什么时候需要用到CGI技术呢?

答案是只要用户上传上来数据此时我们就要用到CGI技术 此时我们只需要将cgi标志位设置为开启即可

最后我们通过判断cgi标志位是否开启来判断使用什么方法 代码表示如下

      if (http_request.cgi)      
      {      
         ProcessCgi();      
      }      
      else      
      {      
        ProcessNonCgi(); // return html                                                                               
      }    

关于大小写转化的问题

因为我们对于GET和POST方法并没有做出严格的大小写规定 而我们在项目中却使用了大写作为判定条件

【毕业项目】自主设计HTTP_第23张图片

这就有可能会导致一些错误的发生 所以说我们保证我们接收的method要转化为大写

C++提供了一个函数来实现这个功能

  OutputIterator transform (InputIterator first1, InputIterator last1,
                            OutputIterator result, UnaryOperator op)

参数说明:

  • 第一个参数是要转化起始位置的迭代器
  • 第二个参数是要转化末尾位置的迭代器
  • 第三个参数是最后要存放结果的位置的迭代器
  • 第四个参数是要转化的方式(大写或者小写)

代码表示如下

      auto& method = http_request.method;    
      std::transform(method.begin() , method.end() , method.begin() , ::toupper );    

构建响应

构建响应之前我们首先来回顾下响应的报文是什么样子的

【毕业项目】自主设计HTTP_第24张图片
它要有状态行 响应报头 空行 响应正文

所以说我们不单单要只返回一个静态网页(正文) 还需要加上前面的请求行 报头等信息

添加状态行

状态行由http版本 状态码 状态码描述符组成

其中我们默认http版本就是1.0版本

默认的状态码是200(OK)

状态码描述需要和状态码相匹配

此时我们只要设置一个函数 传入code输出一个状态码的string对象就可以 代码如下

static std::string Code2Desc(int code)    
{        
  std::string desc;    
  switch(code)    
  {      
    case 200:    
      desc = "OK";    
      break;    
    case 404:    
      desc="Not Found";    
      break;                                               
    default:                          
      break;                           
  }               
    return desc; 
 }

之后一步步写好状态行就好了

      http_response._response_line = HTTP_VERSION;             
      http_response._response_line += " ";                     
      http_response._response_line += std::to_string(http_response.response_code);    
      http_response._response_line += " ";                     
      http_request._request_line += Code2Desc(http_response.response_code); 

我们设置默认的响应行分隔符为 \r\n

#define LINE_END "\r\n"  

之后响应报头的内容我们这里暂时跳过

添加响应正文

在前面我们已经获取了请求读取的路径

一般来说现在我们只需要根据那个路径打开对应的资源 之后将资源写到报文的正文中即可

但是在实际填写正文的过程中我们会遇到这样子的问题

  • 我们写的body是用户层的缓冲区
  • 我们需要的网页html是磁盘中的文件
  • 磁盘中的文件要到用户层必须要经历内核层
  • 所以说如果我们使用read write等函数则IO效率较低

【毕业项目】自主设计HTTP_第25张图片

这里给大家介绍一个函数 sendfile

它的作用是不用经历用户层 直接在内核缓冲区拷贝数据 从而提高效率

它的函数原型如下

ssize_t sendfile(int out_fd, int in_fd , off_t* set , size_t count)

参数说明:

  • out_fd 是我们要往这里写数据的文件描述符
  • in_fd 是我们要从这里读数据的文件描述符
  • set 我们不用管 设置为空即可
  • count 表示我们要拷贝的数据大小 以字节为单位

返回值说明:

如果成功拷贝则返回成功拷贝的字节数 失败返回-1

发送响应

我们一步步将状态行 响应报头 空行 正文发送即可

      void SendResponse()    
      {    
      
        write(_sock ,http_response._response_line.c_str() , http_response._response_line.size());    
        for(auto it : http_response._response_header)    
        {    
          write(_sock, it.c_str() , it.size());    
        }    
          write(_sock,http_response._blank.c_str() , http_response._blank.size());    
                                                                                                                      
          sendfile(_sock , http_response.fd , nullptr , http_response.size);    
          close(http_response.fd);    
      }    

最后我们将index.html中写上hello world

编译运行后使用浏览器尝试接收响应

【毕业项目】自主设计HTTP_第26张图片

运行结果如下

【毕业项目】自主设计HTTP_第27张图片

由于博主并没有系统的学习前端知识 所以说这里就不写网页的前端了

如果有同学感兴趣可以自己写一些前端的代码放到web根目录下

添加响应报头

响应报头有很多字段可以填充 我们这里只填充两个比较重要的字段

一个是Content-length 即正文的大小

一个是Content-type 即正文的类型

正文的大小其实我们之前已经有过了 这里我们只需要插入到报头中即可 代码如下

      std::string content_length_string = "Content-Length: ";
      content_length_string += std::to_string(size);  

而正文的类型则是我们比较难判断的一点

一般来说我们会根据文件名的后缀来判断这个文件是什么类型 当构建响应的时候我们也需要告知浏览器我们返回的是什么类型的资源

所以说我们的第一步操作就是后缀提取

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

Content-Type在文件后缀和自身之间有一张对照表

【毕业项目】自主设计HTTP_第28张图片
所以说在我们截取了文件的后缀之后还需要在表中找到对应的内容

我们这里为了方便起见使用静态函数的方式来帮助我们找到后缀对应的内容

同学们也可以尝试使用类来封装

static std::string Suffix2Desc(const std::string& suffix)     
{    
  static std::unordered_map<std::string , std::string> suffix2desc ={    
    {".html" , "text/html"},    
    {".css" , "text/css"},    
    {".jpg" , "text/html"}    
  };    
    
  auto iter = suffix2desc.find(suffix);    
  if (iter != suffix2desc.end())    
  {    
    return iter->second;    
  }    
    
  return "text/html";                                                                                                 
} 

设计CGI程序

当浏览器请求的资源是一个可执行文件的时候

【毕业项目】自主设计HTTP_第29张图片

此时我们的服务器就会触发CGI模式

【毕业项目】自主设计HTTP_第30张图片
现在我们就可以开始编写CGI程序了

【毕业项目】自主设计HTTP_第31张图片
根据上面的原理图我们可以知道

  • httpsever是一个进程
  • CGI程序也是一个进程
  • 那么我们应该如何用一个进程去执行另外一个进程呢?

其实早在进程控制章节我们就学过了方法 那就是进程替换

当然我们在讲解程序替换的时候也说过 我们不能使用主进程进行进程替换(使用主进程该进程就变成一次性的了 处理不了下一个任务) 而应该使用子进程

整体的代码如下

    int ProcessCgi()    
    {    
      pid_t pid = fork();    
      if (pid == 0)    
      {    
        // child    
    
      }    
      else if (pid < 0)    
      {    
        // error    
        LOG(ERROR , "fork error");    
        return 404;    
      }    
      else    
      {    
        // father                                                                                                     
        waitpid(pid , nullptr , 0);    
      }    
      return OK;    
    }  

我们创建子进程的目的当然是为了让他去执行目标程序

那么目标程序是什么呢 它实际上就是浏览器传输给我们的path

进程间通信

建立管道

httpsever需要将数据传输给cgi程序 cgi程序处理完数据之后也需要将数据回传给httpsever

所以说我们这里就要用到进程间通信

因为httpsever进程和我们设计的cgi进程之间本质上是一个父子进程的关系 所以说我们选用进程间通信中的匿名管道

而由于此时我们需要数据进行双向传递 所以说我们可以设计一个双向的管道

为了不混淆这个双向管道的读取 我们约定 所有操作都站在父进程的视角上命名

代码表示如下

        int input[2];
        int output[2]; 
  
        if (pipe(input) < 0)
        {   
          return 404;
        }
        if (pipe(output) < 0)
        {    
          return 404;    
        }  

       pid_t pid = fork();
        if (pid == 0)
        {
          // child
          close(input[0]);
          close(output[1]);    
        }    
        else if (pid < 0)
        {    
          // error    
          LOG(ERROR , "fork error");                                                                                  
          return 404;
        }
        else 
        {
          // father
          close(input[1]);
          close(output[0]);
          waitpid(pid , nullptr , 0);
        }
        return OK;
      }

进程替换

我们选择使用execl函数来进行进程替换

函数原型如下

int execl(const char *path, const char *arg, ...);

我们先看这个函数的名字 相比我们的exec多了一个l

这个l其实就是列表的意思 意味着它的参数要使用列表的形式传入

它的第一个参数是 const char *path 它代表着要执行程序的路径

它的第二个参数是 const char *arg, ... 它代表着可变参数列表 是使用NULL结尾的

例如我们要执行ls程序的话 就可以写出下面的代码

 execl("/usr/bin/ls" , "ls" , "-a" , "-i" , NULL);

当然如果我们直接使用路径作为可执行程序也是可以的 比如

 execl("/usr/bin/ls" , "/usr/bin/ls" , "-a" , "-i" , NULL);

所以说我们程序替换的代码是

 execl(bin.c_str() , bin.c_str() , nullptr);

我们在进行程序替换之后把原先子进程的程序和代码全部替换了

那么替换后的子进程如何得知原先的管道信息呢

代码和数据全部没有了 但是我们要知道的是也仅仅是代码和数据没有了

它并不替换内核进程相关的数据结构 实际上原先子进程的文件描述符表依旧存在

但是数据已经被我们全部删除了 我们要怎么找到呢这两个文件描述符呢?

此时我们可以做出以下的约定

  • 让读取管道等价于读取标准输入
  • 让写入管道等价于写入标准输出

而由于标准输入和标准输出的文件描述符是固定的

所以说我们直接进行重定向即可

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

交互数据

在交互数据之前我们首先要知道父进程的数据在哪里

对于GET方法来说父进程的数据一定是在uri当中

对于POST方法来说 父进程的数据一定是在正文当中

POST方法处理

代码表示如下

          if (method == "POST")
          {
            const char* start = body_text.c_str();
            int total = 0;
            int size = 0;
            while(1)
            {
              size = write(output[1] , start+total , body_text.size()-total);
              if (size > 0)
              {
                total+=size;
              }                                                                                                       
              else 
              {
                break;
              }
            }
          }

上面的代码我们做了一个小处理 让write一直写 直到写入成功的数据为0为止 这主要是为了防止数据太多 一次write写不完的情况出现

GET方法处理

首先我们要知道一点 进程替换是不会替换环境变量的 而子进程会继承父进程的环境变量 所以说我们可以直接使用父进程或者没有替换过的子进程的环境变量给替换后的子进程传递数据

代码标识如下

          if (method == "GET")    
          {            
            query_string_env = "QUERY_STRING=";    
            query_string_env += query_string;    
           putenv(query_string.c_str());                       
          }   

传递传参方法

在子进程接收数据之前我们还要让子进程确认一点

  • 浏览器传递参数使用的到底是什么方法

此时我们还是可以通过环境变量让子进程知道是用什么方法传递的参数

        method_env = "METHOD=";    
        method_env += method;    
        putenv((char *)method_env.c_str());   

CGI程序接收数据

我们首先写出一个CGI程序编译并且将这个程序放到wwwroot目录中

CGI程序代码如下

#include     
#include     
    
    
using namespace std;    
    
int main()    
{    
  cerr  << "Debug Test :" << getenv("METHOD") << endl;          
  return 0;    
}   

这里需要注意的是我们使用的是cerr而不是cout 这是因为我们的cout使用的是标准输出 而标准输出已经被我们重定向了 如果使用cout将不会在显示器上输出任何结果

在这里插入图片描述

此时我们发现确实cgi程序确实能够得到浏览器传参的方法

接下来就是根据传参方法的不同使用不同的方式去获得数据了 具体为

  • GET方法 使用环境变量获得数据
  • POST方法 使用管道获得数据

代码表示如下

  if (method  == "GET")    
  {    
    query_string = getenv("QUERY_STRING");    
    cerr << "Debug QUERY_STRING: " << query_string << endl;       }    
  else if (method == "POST")    
  {    
    int cl = atoi(getenv("CONTENT_LENGTH"));    
    char c = 0;    
    while(cl)    
    {    
      read(0 , &c , 1);    
      query_string.push_back(c);    
      cl--;    
    }    
  }    
  else     
  {    
    ;                                                           
  } 

CGI程序处理数据

我们假设接收的数据是 x=100&y=200

那么首先我们先要得到各个参数的名称和值 很简单的一个字符串分隔即可

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

值得注意的是 第二个参数最好加上const修饰 原因有二

  • 分隔符一般是不做修改的
  • 如果我们不加const修饰 则分隔符必须要用string对象 而不能进行隐式类型转换 比如说填写“=” 这样子就是不可以的

does not name a type 错误

博主在使用auto推导pos类型的时候遇到了这个错误

实际上在这里这个错误的产生的原因是在makefile文件中没有使用c++11来编译该文件的 加上-std=c++11之后错误即可解决


之后我们调用函数处理这批数据即可

  string str1;
  string str2;
  CutString(query_string , "&" , str1 , str2);
    
  string name1;    
  string value1;    
  CutString(str1 , "=" , name1 , value1);    
    
  string name2;                                                 
  string value2;    
  CutString(str2 , "=" , name2 , value2);   

【毕业项目】自主设计HTTP_第32张图片

父进程读取处理完的数据

在cgi程序中 我们让程序向管道中写入了自己处理完的数据

到了父进程当中 我们只需要让父进程从管道中读取数据即可 代码如下

        char ch = 0;    
        while(read(input[0] , &ch , 1))                                                                               
        {    
          response_body.push_back(ch);    
        } 

CGI程序总结

我们的CGI程序总结可以浓缩为下面的一张图

【毕业项目】自主设计HTTP_第33张图片
简单介绍下

  1. 首先浏览器通过GET或者POST方法将想要请求的资源和参数(如果有的话)上传服务器
  2. 服务器进行判断方法是POST还是GET 是否带参
  3. 如果不带参则直接构建响应返回
  4. 如果携带参数则根据方法获得参数
  5. 父进程创建管道之后使用fork函数创建子进程(cgi程序)
  6. 之后通过管道传递数据给子进程处理
  7. 子进程处理完数据之后将处理完的数据传递给父进程
  8. 之后父进程构建响应将响应传递给浏览器

我们如果将中间的步骤全部省略 就能得到这样一个图

【毕业项目】自主设计HTTP_第34张图片

  • cgi程序从浏览器获取数据
  • cgi程序加工完数据之后再传递给浏览器

这样设计有什么好处呢?

实际上我们使用cgi模式将通信和服务高度解耦了

这使得我们的cgi程序不必关注通信细节 只需要专心设计好服务即可

状态码介绍

HTTP的状态码如下:

编号 类别 意义
1XX Informational(信息性状态码) 接收的请求正在处理
2XX Success(成功状态码) 请求正常处理完毕
3XX Redirection(重定向状态码) 需要进行附加操作以完成请求
4XX Client Error(客户端错误状态码) 服务器无法处理请求
5XX Server Error(服务器错误状态码) 服务器处理请求出错

其中我们要记住的有下面这几个

101 信息请求中

101表示客户端发送的请求正在处理中 但是因为网速变快 这种状态已经不怎么常见了

200 OK

这是最常见的一个状态码 也就是我们访问网页成功的时候网页返回的响应行

301 永久重定向

比如果一个老的网站废弃不用了使用一个新的网站 那么此时这个网站就可以使用永久重定向 如果有人还在访问这个网站就会跳转到新网站上

此外如果收藏夹中收藏了老的网站 新的网站会覆盖收藏夹中老的网站

302 307 临时重定向

从名字看就更好理解了 和301永久重定向相比一个是永久的一个是临时的 它并不会覆盖掉收藏夹中的老网站

403 权限不足

这个常见于我们去实习的时候 自己的权限特别低 如果leader丢给你一个文档而你没有观看的权限就会出现这个状态码

404 NOT FOUND

常见于资源消失不见(被删除或过期) 又或者说资源根本不存在

比如说你访问一个网站的时候带上一个不存在的资源路径你就会看到这个状态码

504 Bad Gateway

常见于服务器出现问题 和客户端无关

Redirection(重定向状态码)

除了上面那些要记住的状态码之外 我们还需要更深入的理解重定向状态码

重定向又分为永久重定向和临时重定向 其中301表示永久重定向 302 307表示临时重定向

临时重定向和永久重定向本质是影响客户端的标签 决定客户端是否需要更新目标地址

如果某个网站是永久重定向 那么第一次访问该网站时由浏览器帮你进行重定向 但后续再访问该网站时就不需要浏览器再进行重定向了 此时你访问的直接就是重定向后的网站

而如果某个网站是临时重定向 那么每次访问该网站时如果需要进行重定向 都需要浏览器来帮我们完成重定向跳转到目标网站


本项目中只使用了状态码404来减少工作量

在同学们围绕这个项目做拓展的时候可以参考上面的状态码写出更多 代码大同小异 难度也不大 主要是html页面的编写

处理读取出错

一般来说我们的http项目会出现两种类型的错误

  • 逻辑错误 读取完毕 我们要给予对方回应 告诉对方为什么出错
  • 读取错误 读取不一定完毕 此时不给对方回应 退出即可

此处我们针对读取错误做出一些处理

首先我们在EndPoint类中设置一个成员变量bool stop 并且在构造函数中将它默认设定为false

之后我们将EndPoint里面的一些读取函数中加上读取错误判定

一旦我们判定此处读取错误则设置stop = true

此处列举几处需要添加读取判断的地方 具体内容可以参考gitee上的源码(在文章的最后)

    bool RecvRequestLine()                                                                                            
    {    
      if(Util::ReadLine(_sock ,http_request._request_line) >0 )    
      {    
        std::cout << http_request._request_line ;    
      }    
      else    
      {    
        stop = true;    
      }    
      return stop;    
    }  
    bool RecvRequestHeader()
    {
      std::string line;
      while(true)
      {
        line.clear();
        if (Util:: ReadLine(_sock , line) <= 0)
        {
          stop = true;
        }
        if (line == "\n")
        {                                                                                                             
          break;
        }
        line.resize(line.size()-1); // remove \n
        http_request._request_header.push_back(line);
      }


      if (line == "\n")
      {
        http_request._blank = line;
      }
        return stop;
    }

如果说stop为true 则我们在最后就不构建和发送数据了 代码表示如下

          if (!ep->Stop())                                                                                                     
          {    
            ep->BuildResponse();    
            ep->SendResponse();    
          }  

处理写入错误

在进程间通信这一章中我们讲过两个进程通信的四种特殊情况

这其中有一种情况就是 读取端关闭 写入端就会强制关闭

实际上我们在深入理解之后也知道了 这是因为操作系统发送了13号信号的缘故

而在我们这次的项目当中 服务器是要尽量保持24h开机的 不可能因为读取端退出就关闭

所以说我们要忽略13号信号的作用 代码表示如下

        signal(SIGPIPE , SIG_IGN);  

多线程转化线程池

目前我们所使用的执行任务的方式是多线程 这种方式有以下的缺点

  • 每次都是任务来之后再创建线程 浪费时间
  • 每次都需要创建和销毁线程 浪费时间
  • 如果线程数量并发过多会影响系统性能 从而导致卡顿

而以上的问题我们都可以通过线程池来解决一部分

所以说为了增强代码的健壮性我们将原本的多线程模式转化为线程池模式

【毕业项目】自主设计HTTP_第35张图片

设计思路如下

  1. 在服务器中设计一个任务队列
  2. 每次浏览器上传任务就上传到服务器的任务队列中
  3. 在服务器中设计一个线程池
  4. 线程池中的线程从task_queue中拿任务来执行

有细心的同学可能发现了 这实际上就是一个生产者消费者模型

那么我们首先来设计一个任务类

设计任务类

任务类只需要有两个成员变量

  • 套接字
  • 处理方法

套接字是为了让线程知道要处理什么

处理方法是为了让线程只要要用什么方法处理

代码表示如下

  #pragma once     
      
  #include                                                                                                           
      
  class Task    
  {    
    private:    
      int sock;    
      CallBack handler;    
    public:    
      Task()    
      {}    
      
      Task(int _sock)    
        :sock(_sock)
      {}    
      
      
      void ProcessOn()    
      {    
        handler(sock);    
      }    
      
      ~Task()    
      {}    
  }; 

设计回调函数

调用函数处理这个工作之前已经有一个Entrance 类做到过了 我们要做的只是给他改个名字

class CallBack
{
  public:    
    CallBack()    
    {}    
    
    void operator()(int sock)    
    {    
      HandlerRequest(sock);    
    }    

之后再给这个类加上一个仿函数方便我们后续调用

设计线程池

线程池的设计思路如下

  • 首先要有一个任务队列来存放任务
  • 要有一个num来标志任务队列的最大值
  • 有个锁和条件变量来保证同步和互斥

代码表示如下

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

之后就是一些常见的函数编写

比如说 加锁 解锁 睡眠 唤醒 添加任务 删除任务等等 这些代码在生产者消费者模型那一章节已经写过 这里就不再赘述 需要看全部代码的同学可以在文末连接处查看

补充内容

表单测试

此处会涉及到一些前端知识 有兴趣的同学可以去深入了解下


一般来说我们可以通过表单从前端页面向后端提交数据

表单的格式如下

<form>
 .
form elements
 .
</form>

我们可以写一个表单网页来测试我们的程序

它使用GET方法传参 分别传递两个数据x和y

<!DOCTYPE html>
<html>
<body>

<form action="/testcgi" method="GET">
First name:<br>
<input type="text" name="date_x" value="0">
<br>
Last name:<br>
<input type="text" name="date_y" value="1">
<br><br>
<input type = "submit" value="submit">
</form>

</body>
</html>

我们前面学过了 GET方法是通过url传参的

【毕业项目】自主设计HTTP_第36张图片

那么当我们点击submit提交的时候在url中也应该出现参数

【毕业项目】自主设计HTTP_第37张图片

我们发现结果确实符合预期

cgi程序支持多种语言

除了c++之外 我们的cgi程序还可以使用其他多种语言编写

比如说c语言 python java php

由于博主目前为止只学过c/c++ 没办法给大家演示 同学们如果有什么有意思的程序也可以在cgi上做一些扩展

项目代码

gitee

你可能感兴趣的:(实现项目,http,网络协议,网络)