项目自主实现HTTP服务器

Posted 呆呆兽学编程

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了项目自主实现HTTP服务器相关的知识,希望对你有一定的参考价值。

⭐️ 本博客介绍的是一个自主实现HTTP服务的一个项目,这要介绍的是项目实现的整个过程,用到的技术、遇到的问题以及都是如何解决的。想完成该项目,需要我们对HTTP有了解,这里可以查看我的往期博客——HTTP协议。这里还会用到流式套接字,也可以翻阅我的往期博客进行查看——流式套接字。下面就开始正式介绍~
⭐️ 项目代码:https://gitee.com/byte-binxin/http-project

目录


项目介绍

该项目采用C/S模型,从零开始编写支持中小型应用的http,并结合mysql。整个项目服务器大体分为客户端建立连接,读取分析请求、处理请求、构建响应、构建响应几个部分。该服务器能够根据用户的请求返回简单的静态网页和动态网页,应对处理常见的错误请求。此外为了能够处理客户端发起的请求,在HTTP服务器提供的平台上搭建了CGI机制,CGI机制可以处理HTTP 的一些数据请求,并作出相应的处理。为了能够让项目更加完善,我在该服务器之上增加了一个登录和注册模块,结合mysql存储用户数据,并且部署了一个简单的计算器服务。

开发环境

  • Centos7.6、C/C++、vim、g++、Makefile、Postman

主要技术

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

项目框架图

项目演示

服务器启动,绑定一个8081的端口号运行,如下:

服务器启动后,使用浏览器进行访问,获取到一个登录页面:

请求的日志信息:

登录后成功后就会返回一个计算器页面,同时服务器后台也会进行核对:

后台打印的日志信息:

当然这个项目的核心在服务器处理HTTP协议细节分析和处理上,上面演示的都是服务器正常处理的情况,一些错误请求都能够正确处理,后面我们再详谈。

项目实现

项目文件部署

  • main.cc:用来编译整个项目,启动服务器
  • TcpServer.hpp:存放单例TcpServer类,使用SockAPI和单例模式编写一个TcpServer,成为一个独立的组件,插入HttpServer中进行使用
  • ThreadPool.hpp:存放单例ThreadPool类,使用POSXI线程库的线程、互斥量和条件变量编写一个单例模式的线程池,也作为一个独立的组件,插入HttpServer中使用
  • Task.hpp:存放任务类,用来将每一个连接封装成任务,里面有对应的回调机制,可以执行任务
  • HttpServer.hpp:存放HttpServer类,该类调用TcpServer和ThreadPool组件,每次获取一个连接都将其封装成为一个任务,并且放入线程池中进行处理
  • Protocol.hpp:存放一些HTTP协议处理的类,协议请求类,协议响应类,协议处理类,还包括一个回调类,可以调用前面三个类中的成员方法,供任务类使用
  • Util.hpp:存放工具类,该类提供了一些字符串处理的方法,方便我们使用
  • Log.hpp:存放打印日志函数,可以帮我打印日志
  • cgi目录:该目录下可以存放cgi程序,供服务器调用
  • wwwroot目录:这是服务器的web根目录,里面存放了一些网页资源和cgi程序,服务器启动自动从该目录下进行路径搜索资源

打印日志

为了方便后期编码调试和项目演示,这里设计了一个日志打印函数,日志打印的格式如下:

日志的四个级别:

  • INFO:正常信息
  • WARNING:警告信息
  • ERROR:错误信息
  • FATAL:致命信息

我们可以将它们定义为四个宏:

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

时间戳可以通过time库函数进行获取,错误文件名称和错误行分别通过可通过分别通过__FILE____LINE__两个宏进行获取,于是我们就可以写出一个日志函数:

void Log(std::string level, std::string message, std::string filename, int line)

  std::cout << "[" << level << "][" << message << "][" << time(nullptr) << "][" << filename << "][" << line << "]" << std::endl;

上面的这一个函数用到了四个参数,每次调用传四个参数会显得比较麻烦,且后面两个参数是比较固定的,所以为了方便,这里采用一个宏来封装该函数,如下:

// 替换,方便调用日志打印函数  # 数字转宏字符串
#define LOG(level, message) Log(#level, message, __FILE__, __LINE__)

这样,以后调用日志函数就只需要调用该宏即可,__FILE____LINE__两个宏变量都会在所替换的文件中进行替换。

看下面的一个打印效果:

组件模块

TcpServer类

该类的编写主要用到的是SocketAPI,主要过程为:

  • 创建套接字
  • 绑定端口号
  • 将套接字设置为监听状态

成员变量:

  • port:端口号
  • listen_sock:监听套接字
  • svr:单例TcpServer
  • cg:内嵌垃圾回收类

成员方法:

  • InitServer:服务器初始化
  • GetInstance:单例获取方法

代码实现:

#define BACKLOG 5

class TcpServer

public:
  void InitServer()
  
    Socket();
    Bind();
    Listen();
    LOG(INFO, "TcpServer Init Success");
  
  static TcpServer* GetInstance(int port)
  
    static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;// 静态的锁,不需要destroy 
    if (_svr == nullptr)
      pthread_mutex_lock(&lock);
      if (_svr == nullptr)
        _svr = new TcpServer(port);
        _svr->InitServer();
      
      pthread_mutex_unlock(&lock);
    

    return _svr;
  
  class CGarbo
  
  public:
    ~CGarbo()
    
      if (TcpServer::_svr == nullptr)
        delete TcpServer::_svr;
      
    
  ;
  int GetListenSock()
  
    return _listen_sock;
  
  ~TcpServer()
  
    if (_listen_sock >= 0) close(_listen_sock);
  
private:
  // 构造私有
  TcpServer(int port)
    :_port(port)
     ,_listen_sock(-1)
  
  // 禁止拷贝
  TcpServer(const TcpServer&) = delete;
  TcpServer& operator=(const TcpServer&) = delete;
  void Socket()
  
    _listen_sock = socket(AF_INET, SOCK_STREAM, 0);
    if (_listen_sock < 0)
      LOG(FATAL, "create socket error!");
      exit(1);
    
    LOG(INFO, "create socket success");
    // 将套接字设置为可以地址复用
    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;
    
    if (bind(_listen_sock, (struct sockaddr*)&local, sizeof(local)) < 0)
      LOG(FATAL, "bind error!");
      exit(2);
    
    LOG(INFO, "bind success");

  
  void Listen()
  
    if (listen(_listen_sock, BACKLOG) < 0)
      LOG(FATAL, "listen error!");
      exit(3);
    
    LOG(INFO, "lieten success");
  
private:
  int _port;
  int _listen_sock;
  static TcpServer* _svr;// 单例
  static CGarbo _cg;// 内嵌垃圾回收
;

TcpServer* TcpServer::_svr = nullptr;
TcpServer::CGarbo _cg;

说明几点:

  • 这里获取单例的方法中用到的互斥量使用PTHREAD_MUTEX_INITIALIZER字段进行初始化,这样的好处就是改互斥量出来作用域可以自动销毁,更加方便
  • 更详细的这部分内容可以查看往期博客,有更详细介绍

ThreadPool类

该项目频繁获取连接,需要派出一个线程去处理相应的任务,如果每次来一个连接就去创建一个线程,断开连接就销毁线程的话,这样对操作系统开销比较大,同时也会带来一定的负担。如果使用线程池的话,来一个任务就立即处理,不需要去创建线程,这样就节省了创建线程时间,同时也可以防止服务器线程过多导致操作系统过载的问题。

该类用到了POSIX线程库的一套接口,成员变量有:

  • q:任务队列
  • num:线程池线程个数
  • lock:互斥量
  • cond:条件变量
  • tp:单例线程池
  • cg:内嵌垃圾回收类(析构时回收单例资源)

成员方法:

  • InitThreadPool:初始化线程池
  • GetInstance:获取单例线程池
  • Routine:线程执行方法
  • Put:放任务
  • Get:取任务

代码实现:

#define NUM 5

class ThreadPool

private:
  ThreadPool(int max_pthread = NUM)
    :_stop(false)
     ,_max_thread(max_pthread)
  
  ThreadPool(const ThreadPool&) = delete;
  ThreadPool& operator=(const ThreadPool&) = delete;
public:
  static ThreadPool* GetInstance(int num = NUM)
  
    static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;// 静态的锁,不需要destroy 
    if (_tp == nullptr)
      pthread_mutex_lock(&lock);
      if (_tp == nullptr)
        _tp = new ThreadPool(num);
        if (!_tp->InitThreadPool())
          exit(-1);
      
      pthread_mutex_unlock(&lock);
    

    return _tp;
  
  class CGarbo
  
  public:
    ~CGarbo()
    
      if (ThreadPool::_tp == nullptr)
        delete ThreadPool::_tp;
      
    
  ;
  static void* Runtine(void* arg)
  
    pthread_detach(pthread_self());
    ThreadPool* this_p = (ThreadPool*)arg;

    while (1)
      this_p->LockQueue();
      // 防止伪唤醒使用while
      while (this_p->IsEmpty())
        this_p->ThreadWait();
      
      Task* t;
      this_p->Get(t);
      this_p->UnlockQueue();
      // 解锁后处理任务
      t->ProcessOn();
      delete t;// 任务统一在堆上创建
    
  
  bool InitThreadPool()
  
    pthread_mutex_init(&_mutex, nullptr);
    pthread_cond_init(&_cond, nullptr);
    pthread_t t[_max_thread];
    for(int i = 0; i < _max_thread; ++i)
    
      if (pthread_create(t + i, nullptr, Runtine, this) != 0)
        LOG(FATAL, "ThreadPool Init Fail!");
        return false;
      
    
    LOG(INFO, "ThreadPool Init Success");
    return true;
  
  void Put(Task* data)
  
    LockQueue();
    _q.push(data);
    UnlockQueue();
    WakeUpThread();
  
  void Get(Task*& data)
  
    data = _q.front();
    _q.pop();
  
  ~ThreadPool()
  
    pthread_mutex_destroy(&_mutex);
    pthread_cond_destroy(&_cond);
  
public:
  void LockQueue()
  
    pthread_mutex_lock(&_mutex);
  
  void UnlockQueue()
  
    pthread_mutex_unlock(&_mutex);
  
  void ThreadWait()
  
    pthread_cond_wait(&_cond, &_mutex);
  
  void WakeUpThread()
  
    pthread_cond_signal(&_cond);
    //pthread_cond_broadcast(&_cond);
  
  bool IsEmpty()
  
    return _q.empty();
   
private:
  std::queue<Task*>  _q;
  bool _stop;
  int             _max_thread;
  pthread_mutex_t _mutex;
  pthread_cond_t  _cond;
  static ThreadPool* _tp;
  static CGarbo cg;// 内嵌垃圾回收
;

任务类

每一个获取到的连接都可以封装称为一个任务类,然后放进线程池中,让线程池中的线程取出然后执行对应的方法。

成员变量:

  • sock:获取到的连接的套接字
  • handlerRequest:处理任务的回调机制(该类在Protocol.hpp中进行了编写)

成员方法:

  • handlerRequest:处理任务

代码实现:

class Task

public:
  Task(int sock)
    :_sock(sock)
  
  // 处理任务
  void ProcessOn()
  
    _handlerRequest(_sock);
  
private:
  int _sock;
  CallBack _handlerRequest;// 设置回调,处理请求与构建响应
;

Util类

工具类主要提供了一些分析协议时会用到的字符串处理的方法,这里写了两个:

  • ReadLine

我们都知道,HTTP协议用行的方式来陈列协议内容,其中不同的浏览器下行分隔符的表示方式是不一样的,一般有下面三种:

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

所以为了方便分析协议,我们可以在读取协议的每一行时候都将其行分隔符进行统一处理,统一转为\\n的形式。所以这里设计了一个ReadLine的方法进行处理。

思路如下:

  1. 该函数从sock中读取一行协议内容,然后将行分割符进行处理,然后返回,所以这里使用两个参数:sock、out转换之后的一行)
int ReadLine(int sock, std::string& out);
  1. 逐个读取字符,如果不是\\r\\n,就直接将该字符加入out中。如果此时是\\r,那么改行分隔符可能是\\r或·\\r\\n,所以接下来读取的字符可能是\\n或下一行的其它字符,所以此时需要根据下一个字符判断是哪一种情况,如果此时直接使用recv读取下一个字符,会将缓冲区的字符拷贝到上层,这样对下一次读取一行很不利。能不能放回去能?这是一个很麻烦的事情,所以有没有一种方法能够让只查看下一个字符而不拷走的方法呢?答案是有的,我们可以调整recv的选项字段,选择MSG_PEEK选项,只读不拷走下一个字符,所以这里我们选择使用MSG_PEEK选项进行窥探

​ 如果下一个字符为\\n,代表该协议的行分隔符是\\r\\n类型的,所以我们将该字符读走,否则我们直接把要添加的字符改成\\n

if (ch == '\\r')
    // 使用MSG选项进行窥探,不取走接受缓冲区的数据 
    recv(sock, &ch, 1, MSG_PEEK);
    if (ch == '\\n')
        // 情况1
        // 窥探成功,将该数据从接受缓冲区取走
        recv(sock, &ch, 1, 0);
    
    else
        // 情况2
        ch = '\\n';
    

  1. 最后处理完上面两种情况之后,接下来检验ch这个字符,如果是\\n,就将该字符添加至out,并停止读取,返回out的大小

整个函数代码如下:

static int ReadLine(int sock, std::string& out)

    char ch = '*';
    while (ch != '\\n')
        ssize_t sz = recv(sock, &ch, 1, 0);

        //std::cout << "debug: " << sz << " " << ch << " " << __LINE__ << std::endl;
        if (sz > 0)
            // 三种情况都转为\\n
            // 1. \\r\\n
            // 2. \\n
            // 3. \\r
            if (ch == '\\r')
                // 使用MSG选项进行窥探,不取走接受缓冲区的数据 
                recv(sock, &ch, 1, MSG_PEEK);
                if (ch == '\\n')
                    // 情况1
                    // 窥探成功,将该数据从接受缓冲区取走
                    recv(sock, &ch, 1, 0);
                
                else
                    // 情况2
                    ch = '\\n';
                
            
            // 正常或者转换后
            out += ch;
        
        else if (sz == 0)
            return 0; 
        
        else
            return -1;
        
    

    return out.size();

  • CurString

    我们都知道,HTTP报头中的信息是以key:value的方式行陈列出来的,所以我们需要将其进行解析,分割成两个字符串。所以这里实现了一个简单的字符串分割方法:

    static bool CutString(const std::string& s, std::string& sub1_out, std::string& sub2_out, std::string sep)
    
        size_t pos = s.find(sep);
        if (pos != std::string::npos)
            sub1_out = s.substr(0, pos);
            sub2_out = s.substr(pos+sep.size());
            return true;
        
    
        return false;
    
    

HttpServer类

http服务器类在启动时,会将TcpServer和ThreadPool加载进HttpServer,http服务主要负责获取连接,并将器封装成任务,然后放进线程池中进行任务处理。

成员变量:

  • port:绑定端口号

  • stop:停止运行标志位

成员方法:

  • InitServer:初始化服务器
  • Loop:运行服务器

代码实现:

#define PORT 8081

class HttpServer

public:
  HttpServer项目自主实现HTTP服务器

自主HttpServer实现(C++实战项目)

项目设计自主HTTP服务器

项目设计自主HTTP服务器

(项目)Web服务器的实现——自主实现一个Web服务器项目,通过该服务器搭建个人网站(保姆级教程),可写在简历上

自主WebServer实现