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

目录

  • 项目介绍
  • 开发环境
  • 主要技术
  • 项目框架图
  • 项目演示
  • 项目实现
    • 项目文件部署
    • 打印日志
    • 组件模块
      • TcpServer类
      • ThreadPool类
    • 任务类
    • Util类
    • HttpServer类
    • 协议模块
      • 协议请求类
      • 协议响应类
      • EndPoint类
        • 框架
        • 设计回调
        • 读取请求
          • 读取请求行
          • 读取请求报头
          • 分析请求报头
          • 分析请求正文
          • 读取请求正文
        • 构建响应
          • 主体
          • 非cgi处理
          • cgi机制处理
          • 根据状态码构建响应
            • 构建正确响应
            • 构建错误响应
        • 发送响应
    • 部署cgi程序
      • 简单的计算程序
      • 用户数据存储程序
  • Postman测试
    • GET方法不带参数
    • GET方法带参数
    • POST方法
  • 项目总结

项目介绍

该项目采用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 INFO1#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 5class 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 5class 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();// 防止伪唤醒使用whilewhile (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{// 情况2ch = '\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. \rif (ch == '\r'){// 使用MSG选项进行窥探,不取走接受缓冲区的数据 recv(sock, &ch, 1, MSG_PEEK);if (ch == '\n'){// 情况1// 窥探成功,将该数据从接受缓冲区取走recv(sock, &ch, 1, 0);}else{// 情况2ch = '\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 8081class HttpServer{public:HttpServer(int port = PORT, int num = NUM):_port(port) ,_num(num){}void InitServer(){signal(SIGPIPE, SIG_IGN);//忽略该信号,否则服务器写入时,对端关闭会发送该信号导致服务器关闭}void Loop(){LOG(INFO, "Loop Begin");// 两个组件TcpServer* tsvr = TcpServer::GetInstance(_port);ThreadPool* tp= ThreadPool::GetInstance(_num);int lsock = tsvr->GetListenSock();struct sockadd_in* peer;socklen_t len = sizeof(peer);while (!_stop){int sock = accept(lsock, (struct sockaddr*)&peer, &len);if (sock < 0){continue;}LOG(INFO, "Get a new link");// 构建任务Task* t = new Task(sock);tp->Put(t);}LOG(INFO, "Loop End");}private:int _port;int _num;static bool _stop;};

注意: 服务器初始化的时候,将SIGPIPE设置为SIG_IGN,忽略该信号。考虑到服务器给客户端发送响应时,也就是往sock写入时,客户端关闭连接,此时操作系统会向服务器发送SIGPIPE信号终止服务器,导致服务器崩溃,这样显然是不行的,所以我们选择在服务器初始化的时候就忽略该信号。

协议模块

协议请求类

下面是HTTP请求协议内容:

整个项目主要分析GET和POST请求,需要知道的是,GET方法可以获取服务器资源,也可以上传数据,且是通过uri中’” />class HttpRequest{public:std::string _request_line;std::vector<std::string> _request_header;std::string _blank;std::string _request_body;// 解析后std::string _method;// 请求方法std::string _uri;// 资源标识std::string _version;// HTTP版本// 存储报头中的k: vstd::unordered_map<std::string, std::string> _http_header_kv;int _content_length = 0;// 正文长度std::string _uri_path;std::string _uri_query;// ?后面的参数bool _cgi = false;// 是否需要使用cgi模式GET方法带参数POST方法};

协议响应类

下面是HTTP响应协议的内容:

根据响应协议内容定制的四个成员变量:

  • status_line:状态行
  • response_header:响应报头
  • blank:空行
  • response_body:响应正文

状态行可以要填充三个内容:版本、状态码、状态码描述,我们只需要设置状态码即可,第一填充的是HTTP/1.0,状态码根据执行情况进行填充,状态码描述根据对应状态码返回html文件。所以这里需要四个成员变量(具体设计原因下个板块介绍):

  • status_code:状态码
  • fd:要返回资源的文件描述符
  • body_size:返回资源的大小
  • suffix:文件后缀

代码实现:

class HttpResponse{public:std::string _status_line;std::vector<std::string> _response_header;std::string _blank = LINE_END;// #define LINE_END "\r\n"std::string _response_body;// _status_line: version status_code status_desc 状态描述int _status_code = OK;int _fd = -1;// 最终要打开的文件int _body_size = 0;// 文件的大小std::string _suffix = ".html";// 文件后缀};

EndPoint类

框架

端点类主要是做这几件事:读取请求、分析请求、处理请求、构建响应、返回响应。

四个成员变量:

  • sock:与对端进行通信的套接字
  • stop:停止标志位
  • http_request:http请求
  • http_reponse:http响应

主要的成员方法:

  • RecvHttpRequest:读取HTTP协议请求
  • BuildHttpResponse:构建HTTP协议响应
  • SendHttpResponse:发送HTTP协议响应

处理每一个节点时,都只需要依次调用上面的三个方法,注意这里有一个停止标志位,是为了应对读取请求过程中发送错误,这时可以将该标志位设置为true,后面根据该标志位判断是否需要继续构建响应和发送响应。

代码实现:

// 读取请求,分析请求,构建响应// IO通信class EndPoint{public:EndPoint(int sock):_sock(sock){}void RecvHttpRequest();void BuildHttpResponse();void SendHttpResponse();bool IsStop(){return _stop;}~EndPoint(){if (_sock >= 0) close(_sock);}private:int _sock;bool _stop = false;HttpRequest _http_request;HttpResponse _http_response;};

设计回调

设计一个回调,龚任务类使用,该回调主要是处理HTTP请求和响应,顺序调用EndPoint类中三个方法,调用完读取协议方法后,需要对stop标志位进行判断,正常读取就继续构建响应,错误就不构建响应。

代码实现:

class CallBack{public:void operator()(int sock){HandlerRequest(sock);}void HandlerRequest(int sock){LOG(INFO, "Handler Request Begin");EndPoint* ep = new EndPoint(sock);ep->RecvHttpRequest();if (!ep->IsStop()){LOG(INFO, "Recv No Error, Begin Build And Send");ep->BuildHttpResponse();ep->SendHttpResponse();}else{LOG(WARNING, "Recv Error, Stop Build And Send");}delete ep;LOG(INFO, "Handler Request End");close(sock);}};

读取请求

读取协议的主要几个步骤:

  1. 读取请求行
  2. 读取请求报头
  3. 分析请求行
  4. 分析请求报头
  5. 读取请求正文

逻辑: 1或2读取失败,后续三个步骤都可以不执行,如果成功就继续执行后三个步骤

代码实现:

void RecvHttpRequest(){if (RecvHttpRequestLine() && RecvHttpRequestHeader()){ParseHttpRequestLine();ParseHttpRequestHeader();// 读取正文RecvHttpRequestBody();}}
读取请求行

请求行只有一行,可以调用一次工具类中的ReadLine接口进行读取,而且还能够处理行分割,统一转为\n。如果读取是发生错误,就将停止标志位设置为true,并且返回false

代码实现:

bool RecvHttpRequestLine(){std::string& line = _http_request._request_line;if (line.size() > 0) line.pop_back();//去掉结尾的'\n'符号else_stop = true;return !_stop;}
读取请求报头

请求报头中有很多行,报头和报文是以空行进行分割的,所以我们只需要一行一行读取,直到读取到空行就可以停止读取。读取到的每一行全部放入大vector容器中,进行存放,这里也同样需要进行错误处理,读取失败就将停止标志位设置为true,并且返回false

代码实现:

bool RecvHttpRequestHeader(){while (1){std::string line;if (Util::ReadLine(_sock, line) <= 0){_stop = true;break;}if (line == "\n"){// 空行_http_request._blank = line; break;}// 去掉换行line.pop_back();_http_request._request_header.push_back(line);}return !_stop;}
分析请求报头

请求行分为请求方法、请求uri和HTTP版本三个部分,三者都在status_line存放,且都是空格分割,这里我们可以使用streamstring这个对象对字符串进行转换,提取出三个部分,如下:

std::stringstream ss(_http_request._request_line);ss >> _http_request._method >> _http_request._uri >> _http_request._version;

为了方便处理,这里对请求方法字符串大小统一处理,都转为大写,这里选择使用transform这个函数进行转换

代码实现:

void ParseHttpRequestLine(){// 解析 Get / HTTP/1.1std::stringstream ss(_http_request._request_line);ss >> _http_request._method >> _http_request._uri >> _http_request._version;auto& method = _http_request._method; // 将请求方法统一转化为大写std::transform(method.begin(), method.end(), method.begin(), toupper); }
分析请求正文

分析请求正文就是将vector中提取出字符串,进行’key: value分析,存入一个哈希表中。需要知道的是,解析每一个字符串时,分隔符是: ,所以这里调用工具类中的字符串切割方法进行分割字符串

代码实现:

void ParseHttpRequestHeader(){std::string key;std::string value;for (auto& s : _http_request._request_header){// key: value if (Util::CutString(s, key, value, SEP))_http_request._http_header_kv.insert({key, value});}}
读取请求正文

需要知道的是,只有请求方法是POST时,才需要读取请求正文,所以我们需要先判断是否需要读取请求报头,如果需要就提取出Content-Length字段,并对请求类中该成员进行填充,判断代码如下:

bool IsNeedRecvHttpRequestBody(){auto& method = _http_request._method;if (method == "POST"){auto& header_kv = _http_request._http_header_kv;auto iter = header_kv.find("Content-Length");if (iter != header_kv.end()){_http_request._content_length = stoi(iter->second);return true;}}return false;}

正式读取请求正文时,也需要进行读取错误处理判断,如果读取出错,也需要将停止标志位设置为true

代码实现:

void RecvHttpRequestBody(){if (IsNeedRecvHttpRequestBody()){char ch;int content_length = _http_request._content_length;auto& http_request_body = _http_request._request_body;while (content_length){ssize_t sz = recv(_sock, &ch, 1, 0);if (sz > 0){http_request_body += ch;content_length--;}else if (sz == 0){// 客户端发送数据大小与Content-Length字段描述的不符_stop = true;break;}else{// error_http_response._status_code = SERVER_ERROR;break;}}}LOG(INFO, _http_request._request_body);} 

构建响应

主体

该项目定义了一下几个状态码:

#define OK 200#define BAD_REQUEST 400#define FORBIDDEN 403#define NOT_FOUND 404#define SERVER_ERROR 500

主要还是常见的几个错误码,如果后期改善项目,还可以继续增加

构建响应的几个步骤:

  1. 判断请求方法,不是GET且不是POST方法都不做处理,将错误码设置为BAD_REQUSET,然后跳转到最后,构建对应的错误请求响应
  2. 如果是GET请求方法,且带有参数('” />#define WEB_ROOT “webroot”path = WEB_ROOT + path;

    ​ 如果发现该路径是一个未指明具体资源的一个目录,不是一个明确的文件(例如:wwwroot/a/),就需要在路径后面添加该目录下的默认资源页面进行返 回,如下:

    #define HOMT_PAGE "index.html"if (_path[_path.size()-1] == '/'){_path += HOME_PAGE;}

    ​ 为了判断文件的属性,这里用到一个系统调用stat,它可以根据路径找到文件并提炼出文件属性,可以曲儿文件是否存在,函数原 型如下:

    int stat(const char *pathname, struct stat *buf);

    ​ 返回值为0代表成功,找不到文件返回-1,如果文件存在,就判断是目录还是可执行程序,还是普通文件,如果是目录,就在目录路 径添加默认文件,如果是普通文件,路径不作处理,只需要提取出文件大小填充响应类的body_size字段,如果是可执行程序,启 动cgi机制即可。可执行程序可以根据S_IXUSRS_IXGRPS_IXOTH三个标志位进行判断,只有满足一个,就代表是可执行程序, 代码如下:

    struct stat st;if (stat(_path.c_str(), &st) == 0){// 资源存在,判断是否是一个目录if (S_ISDIR(st.st_mode)){// 是一个目录,添加默认首页_path += "/";_path += HOME_PAGE;// 对st进行更新,获取文件大小stat(_path.c_str(), &st);}_http_response._body_size = st.st_size; // 判断请求是否是一个可执行程序// 拥有者具有可执行,所属组具有,其他人具有都能够证明是一个可执行程序if((st.st_mode & S_IXUSR) ||(st.st_mode & S_IXGRP) ||(st.st_mode & S_IXOTH)){_http_request._cgi = true;}}else{// 资源不存在LOG(WARNING, _path+" Not Found");_http_response._status_code = NOT_FOUND;goto END;}
    1. 接下来就是分析资源的后缀,如果不是可执行程序,都可以提取出后缀,以便后序根据根据判断文本类型,代码如下:
    // 提取后缀,默认为 ".html"pos = _path.rfind('.');if (pos != std::string::npos){_http_response._suffix = _path.substr(pos);}
    1. 根据cgi机制启动标准位判断是否需要启动,如果需要,就执行cgi的方法,否则执行非cgi处理的方法
    2. 细节处理完之后,最后根据状态码等内容统一构建一个响应

    代码实现:

    void BuildHttpResponse(){auto& method = _http_request._method;auto& _path = _http_request._uri_path;auto& query = _http_request._uri_query;size_t pos = 0;if (method != "GET" && method != "POST"){// 非法请求LOG(WARNING, "method is not right");_http_response._status_code = BAD_REQUEST;goto END;}if (method == "GET"){// 是否带参 uri=path?if (_http_request._uri.find("?") != std::string::npos){// 找到?,说明带有参数Util::CutString(_http_request._uri, _path, query, "?"); //std::cout << _path << ":" << query << std::endl;// webroot/test_cgi_http_request._cgi = true;}else{_path = _http_request._uri;}}else if (method == "POST"){// 使用cgi模式_http_request._cgi = true;_path = _http_request._uri;}//std::cout << __LINE__ << ": " + _path <<std::endl;// path拼接前缀 webroot_path = WEB_ROOT + _path;// 未知名具体文件资源时,默认返回当前目录下的首页 if (_path[_path.size()-1] == '/'){_path += HOME_PAGE;}//std::cout << "debug: path:" + _path + " query:" + query << std::endl;struct stat st;if (stat(_path.c_str(), &st) == 0){// 资源存在,判断是否是一个目录if (S_ISDIR(st.st_mode)){// 是一个目录,添加默认首页_path += "/";_path += HOME_PAGE;// 对st进行更新,获取文件大小stat(_path.c_str(), &st);}_http_response._body_size = st.st_size; // 判断请求是否是一个可执行程序// 拥有者具有可执行,所属组具有,其他人具有都能够证明是一个可执行程序if((st.st_mode & S_IXUSR) ||(st.st_mode & S_IXGRP) ||(st.st_mode & S_IXOTH)){_http_request._cgi = true;}}else{// 资源不存在LOG(WARNING, _path+" Not Found");_http_response._status_code = NOT_FOUND;goto END;}// 提取后缀,默认为 ".html"pos = _path.rfind('.');if (pos != std::string::npos){_http_response._suffix = _path.substr(pos);}if (_http_request._cgi){// cgi模式 ProcessCgi 做数据处理_http_response._status_code = ProcessCgi();}else{// 非cgi模式,NoProcessCgi不做数据处理,直接返回静态网页// 构建http响应,返回网页_http_response._status_code = ProcessNonCgi();}END:// 统一处理,构建响应BuildHttpResponseHelper();return;}
    非cgi处理

    该处理只需要进行打开资源文件的操作即可,如果文件打开失败,把状态码设置为NOT_FOUND,否则设置为OK

    代码实现:

    int ProcessNonCgi(){// 打开文件_http_response._fd = open(_http_request._uri_path.c_str(), O_RDONLY);if (_http_response._fd >= 0){return OK;}return NOT_FOUND;}
    cgi机制处理

    什么是CGI?

    CGI(Common Gateway Interface) 是WWW技术中最重要的技术之一,是Web 服务器运行时外部程序的规范,按CGI 编写的程序可以扩展服务器功能。CGI 应用程序能与浏览器进行交互,还可通过数据API与数据库服务器等外部数据源进行通信,从数据库服务器中获取数据。格式化为HTML文档后,发送给浏览器,也可以将从浏览器获得的数据放到数据库中。几乎所有服务器都支持CGI,可用任何语言编写CGI。使用命令行参数或环境变量表示服务器的详细请求,服务器与浏览器通信采用标准输入输出方式,是标准CGi的做法。

    没有CGI的服务器只能够给浏览器发送一些网页资源,如果浏览器上传资源的话,该服务器就无法处理,HTTP为我们提供了CGI机制,可以供我们在平台上根据需求搭建CGI程序。

    能调用cgi机制,一定是启动了cgi机制,也就是使用POST方法、GET方法带参数或访问可执行程序:

    • GET方法带参数
    • POST方法
    • 访问资源是一个可执行程序

    cgi程序由我们自己进行编写,可以使用任何语言,我们只需要调用该程序处理请求即可。如何用一个程序抵用另一个程序,这对大家来说应该是不陌生的——程序替换,我们可以通过创建子进程,如何让子进程进行程序替换,去执行对应的cgi程序,为了让cgi程序能够将请求处理结果返回个父进程,这里需要让父子进程进行通信。进程间通信的方式有很多种,我们这里选择使用匿名管道,因为管道通信时单向的,因为需要双向通信,所以这里采用创建两个匿名管道的方法进行双向通信,创建两个管道:out[2],in[2],父进程使用out[1]作为写端,in[0]作为读端,子进程使用out[0]作为读端,in[1]作为写端,如下:

    父进程想cgi传参可以有两种:往管道里写和导环境变量。如果是GET请求,因为参数是比较短的,所以这里我们可以采取导环境变量的方式;如果是POST请求,因为POST的参数在正文中,正文相比GET命令行参数肯定会大很多,所以这里采用往管道里写的方式传参,这里还需要导入Content-Length的大小,导进环境变量,让cgi能够得知。同时我们需要让cgI知道请求方法是什么,所以这个也同样需要通过导环境变量的方式让cgi能够读取到,所以总结如下:

    • GET:需导环境变量METHODQUERY_STRING
    • POST:正文从管道写,需导环境变量METHODCONTENT_LENGTH

    这里还有一个问题:cgi如何得知管道的读端和写端是多少?

    程序替换后,进程的代码和数据会进行替换,但进程的数据结构是不变的。子进程的文件描述符表和替换前是一样的,这些是都不变的,所以这里我们可以在程序替换前,将子进程的管道读端和写端进行重定向,把子进程的读端重定向到标准输入,写端重定向到标准输出中,这样程序替换后,cgi只需要用标准输入和标准输出进行读写管道即可,整个cgi布局如下图:

    可以看出的是,cgi程序本质上是使用标准输入和标准输出与浏览器进行交互,完全可以回来中间一整套通信细节。同时需要注意父进程等待子进程,要根据子进程退出情况设置相应的退出码。

    代码实现:

    int ProcessCgi(){auto& method = _http_request._method;auto& path = _http_request._uri_path;auto& query = _http_request._uri_query;// GET url提交参数auto& body = _http_request._request_body;// POST 正文请求int content_length = _http_request._content_length;auto& code = _http_response._status_code;// 对于父进程 input是读入,output是写入 int input[2];int output[2];// 创建两个管道if (pipe(input) < 0){LOG(ERROR, "create output pipe error!");return SERVER_ERROR;}if (pipe(output) < 0){LOG(ERROR, "create output pipe error!");return SERVER_ERROR;}pid_t pid = fork();if (pid == 0){// 子进程close(input[0]);// 关闭读,用来写close(output[1]);// 关闭写,用来读 // 导入环境变量,便于程序替换后方便识别是何种请求方式std::string method_env = "METHOD=" + method;putenv((char*)method_env.c_str());if (method == "GET"){std::string query_env = "QUERY_STRING=" + query;putenv((char*)query_env.c_str());}else if (method == "POST"){// 将正文长度导入环境变量std::string body_size_env = "CONTENT_LENGTH=" + std::to_string(content_length);putenv((char*)body_size_env.c_str());}// 对标准输入和标准输出进行重定向dup2(input[1], 1);dup2(output[0], 0);// 程序替换execlexecl(path.c_str(), path.c_str(), nullptr);// 失败就退出exit(5);}else if (pid < 0){// 创建失败LOG(ERROR, "fork error!");return SERVER_ERROR;}else{// 父进程close(input[1]);// 关闭写,用来读 close(output[0]);// 关闭读,用来写 if (method == "POST"){int size = 0;int total = 0;// 将参数写进管道while ((size = write(output[1], body.c_str()+total, body.size()-total)>0)){total += size; }}// 读取管道char ch;while (read(input[0], &ch, 1) > 0){_http_response._response_body += ch;}int status;pid_t ret = waitpid(pid, &status, 0);//LOG(INFO, "code="+std::to_string(code));if (ret > 0){if (WIFEXITED(status)){// 正常退出if (WEXITSTATUS(status) == 0){// 退出码正常code = OK;}else{// 退出码不正常 // 5:程序替换失败// 6: 请求方法不对code = WEXITSTATUS(status) == 5 " />: BAD_REQUEST;LOG(INFO, "code="+std::to_string(code));}}else{// 异常退出code = SERVER_ERROR;LOG(INFO, "code="+std::to_string(code));}}else{// 等待失败code = SERVER_ERROR;LOG(INFO, "code="+std::to_string(code));}//LOG(INFO, "code="+std::to_string(code));close(input[0]);close(output[1]);}return code;}
    根据状态码构建响应

    不管状态码如何,响应行基本是一样的,所以可以先对响应行进行构建,如何根据不同的状态码去构建不同的响应报头和响应正文,如下:

    void BuildHttpResponseHelper(){auto& code = _http_response._status_code;// 版本 状态码 状态描述_http_response._status_line += HTTP_VERSION;_http_response._status_line += " " + std::to_string(code);_http_response._status_line += " " + Code2Desc(code);_http_response._status_line += LINE_END;// 行分割符switch(code){case OK:BuildOkResponseHeader();break;case BAD_REQUEST:HandlerError(PAGE_400);break;case FORBIDDEN:HandlerError(PAGE_403);break;case NOT_FOUND:HandlerError(PAGE_404);break;case SERVER_ERROR:HandlerError(PAGE_500);break;case BAD_GATEWAY:HandlerError(PAGE_504);break;default:break;}}
    构建正确响应

    正确响应的响应报头必须填写的两个字段是Content-LengthContent-TypeContent-Length这个字段需对于启用cgi和不启用cgi两种机制是不同的。如果是cgi机制,正文被读取到了response_body中了,所以该字段就是response_body的大小,如果不是cgi机制,该字段就是请求资源文件的大小,这个在获取文件属性的时候已经进行了填充,所以读取content_length成员变量即可

    这里补充一个根据文件后缀获取文本类型的函数:

    static std::string Suffix2Desc(const std::string& suffix){static std::unordered_map<std::string, std::string> suffix2desc = {{".html", "text/html"},{".css", "text/css"},{".xml", "text/xml"},{".js", "application/x-javascript"},{".jpg", "image/jpeg"}};if (suffix2desc.find(suffix) != suffix2desc.end()){// 找到了return suffix2desc[suffix];}return "text/html";}

    如果需要补充,可以查询该工具——Content-Type

    代码实现如下:

    void BuildOkResponseHeader(){std::string content_type_string = "Content-Type: ";content_type_string += Suffix2Desc(_http_response._suffix) + LINE_END;_http_response._response_header.push_back(content_type_string);std::string content_length_string = "Content-Length: ";if (_http_request._cgi){// POST GET带参,根据响应正文的大小获取大小content_length_string += std::to_string(_http_response._response_body.size()) + LINE_END;}else{//非cgi,获取页面, 根据body_size获取页面大小content_length_string += std::to_string(_http_response._body_size) + LINE_END;}_http_response._response_header.push_back(content_length_string); }
    构建错误响应

    错误响应的正文统一返回对应错误码的html格式的文件,如下(宏变量定义):

    #define PAGE_400 "400.html"#define PAGE_403 "403.html"#define PAGE_404 "404.html"#define PAGE_500 "500.html"#define PAGE_504 "504.html"

    代码实现:

    void HandlerError(std::string page){// 错误处理统一通过页面返回_http_request._cgi = false;// 打开文件// 错误页面统一放在wwwroot目录下page = WEB_ROOT + std::string("/") + page;LOG(INFO, "HandlerError:"+page);_http_response._fd = open(page.c_str(), O_RDONLY);if (_http_response._fd > 0){struct stat st;stat(page.c_str(), &st);_http_response._body_size = st.st_size;std::string content_type_string = "Content-Type: ";content_type_string += Suffix2Desc(".html") + LINE_END;_http_response._response_header.push_back(content_type_string);std::string content_length_string = "Content-Length: ";content_length_string += std::to_string(_http_response._body_size) + LINE_END;_http_response._response_header.push_back(content_length_string);}}

    发送响应

    主要有以下几个步骤:

    1. 发送响应行
    send(_sock, _http_response._status_line.c_str(), _http_response._status_line.size(), 0);
    1. 发送报头
    for (auto& iter : _http_response._response_header){send(_sock, iter.c_str(), iter.size(), 0);}
    1. 发送空行
    send(_sock, _http_response._blank.c_str(), _http_response._blank.size(), 0);
    1. 发送正文

    ​ 如果启动了cgi机制,正文内容就放在了response_body中,如果不是cgi机制,就需要传送资源文件给客户端,如果是用writeread将文件先读出来,再写入客户端套接字中,需要经过用户层,sendfile这个接口可以在内核层完成一个文件到一个文件的拷贝,不经过用户层,效率比前者高,如下:

    sendfile

    功能: 把一个文件描述符的内容拷贝给另一个文件描述符,在内核层完成该操作,不经过用户层,比read和write的效率高

    #include  ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);

    参数:

    • out_fd: 要被写入的文件描述符
    • in_fd: 要被读取的文件描述符
    • offset: 偏移量,可以记录读取文件的位置
    • count: 要拷贝内容的大小

    返回值: 成功返回已经写入的字节数,失败返回-1

    代码实现:

    void SendHttpResponse(){// 发送状态行send(_sock, _http_response._status_line.c_str(), _http_response._status_line.size(), 0);// 发送报头for (auto& iter : _http_response._response_header){send(_sock, iter.c_str(), iter.size(), 0);}// 发送空行send(_sock, _http_response._blank.c_str(), _http_response._blank.size(), 0);// 发送正文 使用sendfile,将一个文件的内容拷贝给另一个文件,不经过用户层,在内核区进行拷贝if (_http_request._cgi){auto& body = _http_response._response_body;int size = 0;int total = 0;while ((size = send(_sock, body.c_str()+total, body.size()-total, 0))>0){total += size;}} sendfile(_sock, _http_response._fd, nullptr, _http_response._body_size);// 关闭文件close(_http_response._fd);}}

    部署cgi程序

    简单的计算程序

    该cgi程序处理两个参数的请求,并且算出加减乘除的结果,以html文本进行返回。

    代码实现:

    bool GetQueryString(std::string& query){std::string method = getenv("METHOD");std::cerr << "method: " + method << std::endl;if (method == "GET"){query = getenv("QUERY_STRING");//cerr << "query_string: " + query << endl;return true;}else if (method == "POST"){int content_length = atoi(getenv("CONTENT_LENGTH"));//std::cerr << "debug: content_length:" << content_length << std::endl;char ch;while (content_length){read(0, &ch, 1);query += ch;content_length--;}std::cerr << "query_string: " + query << std::endl;return true;}else{return false;}}void CutString(const std::string& in, const std::string sep, std::string& out1, std::string& out2){size_t pos = in.find(sep);if (pos != std::string::npos){out1 = in.substr(0, pos);out2 = in.substr(pos+sep.size());}}int main(){std::string query;if (!GetQueryString(query)){exit(7);// 请求方法出错}std::string s1;std::string s2;CutString(query, "&", s1, s2);std::string name1;std::string value1;CutString(s1, "=", name1, value1);std::string name2;std::string value2;CutString(s2, "=", name2, value2);if (value1.size() == 0 || value2.size() == 0){exit(7);}// 写入管道//std::cout << name1 + ":" + value1 << std::endl;//std::cout << name2 + ":" + value2 << std::endl;int num1 = stoi(value1), num2 = stoi(value2);if (num2 == 0){std::cout << "" << std::endl;std::cout << "

    除零错误

    "
    << std::endl;}else{std::cout << "" << std::endl;std::cout << "

    计数结果如下:


    "
    << std::endl;std::cout << "

    " << num1 << "+" << num2 << "=" << (double)num1+num2 <<"


    "
    << std::endl;std::cout << "

    " << num1 << "-" << num2 << "=" << (double)num1-num2 <<"


    "
    << std::endl;std::cout << "

    " << num1 << "*" << num2 << "=" << (double)num1*num2 <<"


    "
    << std::endl;std::cout << "

    " << num1 << "/" << num2 << "=" << (double)num1/num2 <<"


    "
    << std::endl;}std::cerr << name1 + ":" + value1 << std::endl;std::cerr << name2 + ":" + value2 << std::endl;return 0;}

    测试: 使用浏览器提交表单的方式进行测试

    用户数据存储程序

    我们设计一个mysql_cgi程序,该程序连接MySQL,可以将用户注册的信息进行存储和查询,所以这里可以设计一个前端注册界面和登录界面,如下:

    使用C语言连接数据库,把用户数据放入数据库,方便登录进行查询,下面是插入数据的代码:

    bool implement(std::string& sql){MYSQL* mfp = mysql_init(nullptr);// 设置编码格式mysql_set_character_set(mfp, "utf8");if (mysql_real_connect(mfp, "127.0.0.1", "http_server", "llyscysygr", "http_server", 3306, nullptr, 0) == nullptr){std::cerr << "connect mysql error!" << std::endl;return false;}std::cerr << "connect mysql success!" << std::endl;int ret = mysql_query(mfp, sql.c_str());mysql_close(mfp);return ret == 0;}bool Insert(std::string& name, std::string& value){std::string sql;sql = "insert into user values('" + name + "','" + value + "');";return implement(sql);}

    用户登录时和注册是要进行数据库查询代码:

    int Select(std::string& name, std::string& pwd){std::string sql;sql = "select * from user where name='" + name + "';";std::cerr << sql << std::endl;MYSQL* mfp = mysql_init(nullptr);// 设置编码格式mysql_set_character_set(mfp, "utf8");if (mysql_real_connect(mfp, "127.0.0.1", "http_server", "llyscysygr", "http_server", 3306, nullptr, 0) == nullptr){std::cerr << "connect mysql error!" << std::endl;return -1;}if (mysql_query(mfp, sql.c_str())){return -1;}// 获取查询结果MYSQL_RES* sqlres = mysql_store_result(mfp);if (sqlres == nullptr) return 1;my_ulonglong num = mysql_num_rows(sqlres);//uint32_t col = mysql_num_fields(sqlres);//std::cerr << col << std::endl;std::cerr << num << std::endl;if (num == 0){// 账户不存在return 1;}MYSQL_ROW line = mysql_fetch_row(sqlres);std::cerr << line[0] <<":" << line[1] << std::endl;//std::cerr << pwd << std::endl;if (line[1] != pwd){//密码不正确return 2;}mysql_close(mfp);mysql_free_result(sqlres);return 0;}

    需要对前端表单提交过来的数据进行解码,也就是Decode,所以这里有一点对编码后的字符串进行解码的代码如下:

    std::string UrlDecode(const std::string& szToDecode){std::string result;int hex = 0;for (size_t i = 0; i < szToDecode.length(); ++i){switch (szToDecode[i]){case '+':result += ' ';break;case '%':if (isxdigit(szToDecode[i + 1]) && isxdigit(szToDecode[i + 2])){std::string hexStr = szToDecode.substr(i + 1, 2);hex = strtol(hexStr.c_str(), 0, 16);//字母和数字[0-9a-zA-Z]、一些特殊符号[$-_.+!*'(),] 、以及某些保留字[$&+,/:;=" />//可以不经过编码直接用于URLif (!((hex >= 48 && hex <= 57) || //0-9(hex >=97 && hex <= 122) || //a-z(hex >=65 && hex <= 90) ||//A-Z//一些特殊符号及保留字[$-_.+!*'(),][$&+,/:;=?@]hex == 0x21 || hex == 0x24 || hex == 0x26 || hex == 0x27 || hex == 0x28 || hex == 0x29|| hex == 0x2a || hex == 0x2b|| hex == 0x2c || hex == 0x2d || hex == 0x2e || hex == 0x2f|| hex == 0x3A || hex == 0x3B|| hex == 0x3D || hex == 0x3f || hex == 0x40 || hex == 0x5f)){result += char(hex);i += 2;}else result += '%';}else {result += '%';}break;default:result += szToDecode[i];break;}}return result;}

    以上都是该程序核心部分代码,其它的代码都是一些逻辑,可以在我的gitee进行查看。

    简单的逻辑:

    1. 用户注册号账号可以进行登录,如果注册的账号已经存在需要返回错误页面,正确返回登录页面
    2. 用户登录成功返回html+css+js编写的简单的计算器页面,密码错误返回密码错误的页面

    效果演示:

    用户注册成功:

    用户登录:

    后台数据库:

    Postman测试

    GET方法不带参数

    GET方法带参数

    POST方法

    项目总结

    遇到的问题和解决的方法

    1. 读取协议报头的行分隔符需要做统一处理,在判断行分隔符是\r还是\r\n时,不能够直接调用recv继续读取下一个字符,否则会将接受缓冲区的字符拿走,这时候需要使用MSG_PEEK选项进行窥探下一个字符,使用该选项不会将接受缓冲区的字符拿走,十分地友好
    2. 调用cgi程序之前,需要根据不同的方法导入不同的环境变量,且还要让cgi程序知道两个管道的读端和写端的文件描述符刚开始想通过导入环境变量的方式让cgi调用getenv知道,但是发现直接到环境变量不太友好,对端不但要通过环境变量的方式获取参数,还要通过环境变量获取文件描述符,这样有点麻烦。最后想出用dup2系统调用对两个文件描述符进行重定向,重定向到标准输入和标准输出,这样cgi程序可以直接通过标准输入和标准输出进行读写管道
    3. 发送响应正文时,如果要返回网页资源,开始想通过read先进行读文件,读到自己定义的一个缓冲区中,然后调用write将缓冲区中的内容写入sock中。这种方法感觉十分地麻烦,每次都要开一个缓冲区,开销大,效率低。后来发现sendfile这个接口可以在内核完成一个文件描述符到另一个文件描述符的拷贝,效率很高
    4. 服务器在写入时,客户端关闭连接会导致服务器崩溃。这是因为操作系统给服务器进程发送了SIGPIPE信号,导致服务器崩溃,这个bug开始并没有考虑到,后面意识到了将该信号设置为SIG_IGN,忽略该信号,解决了问题