Bootstrap

使用c++手把手实现一个简单的http服务器

一、http相关知识

(1)HTTP的五大特点如下

  • 客户端服务器模式(CS,BS): 在一条通信线路上必定有一端是客户端,另一端是服务器端,请求从客户端发出,服务器响应请求并返回。
  • 简单快速: 客户端向服务器请求服务时,只需传送请求方法和请求资源路径,不需要发送额外过多的数据,并且由于HTTP协议结构较为简单,使得HTTP服务器的程序规模小,因此通信速度很快。
  • 灵活: HTTP协议对数据对象没有要求,允许传输任意类型的数据对象,对于正在传输的数据类型,HTTP协议将通过报头中的Content-Type属性加以标记。
  • 无连接: 每次连接都只会对一个请求进行处理,当服务器对客户端的请求处理完毕并收到客户端的应答后,就会直接断开连接。HTTP协议采用这种方式可以大大节省传输时间,提高传输效率。
  • 无状态: HTTP协议自身不对请求和响应之间的通信状态进行保存,每个请求都是独立的,这是为了让HTTP能更快地处理大量事务,确保协议的可伸缩性而特意设计的。

 (2)HTTP的协议格式

  • 请求协议格式如下。

  1. 请求行:[请求方法] + [URI] + [HTTP版本]。
  2. 请求报头:请求的属性,这些属性都是以key: value的形式按行陈列的。
  3. 空行:遇到空行表示请求报头结束。
  4. 请求正文:请求正文允许为空字符串,如果请求正文存在,则在请求报头中会有一个Content-Length属性来标识请求正文的长度。
  • 响应协议格式如下。

  1. 状态行:[HTTP版本] + [状态码] + [状态码描述]。
  2. 响应报头:响应的属性,这些属性都是以key: value的形式按行陈列的。
  3. 空行:遇到空行表示响应报头结束。
  4. 响应正文:响应正文允许为空字符串,如果响应正文存在,则在响应报头中会有一个Content-Length属性来标识响应正文的长度。

  (3)HTTP状态码

HTTP状态码是用来表示服务器HTTP响应状态的3位数字代码,通过状态码可以知道服务器端是否正确的处理了请求,以及请求处理错误的原因。

常见的状态码如下:

HTTP常见的Header如下:

  • Content-Type:数据类型(text/html等)。
  • Content-Length:正文的长度。
  • Host:客户端告知服务器,所请求的资源是在哪个主机的哪个端口上。
  • User-Agent:声明用户的操作系统和浏览器的版本信息。
  • Referer:当前页面是哪个页面跳转过来的。
  • Location:搭配3XX状态码使用,告诉客户端接下来要去哪里访问。
  • Cookie:用户在客户端存储少量信息,通常用于实现会话(session)的功能。

二、服务器代码设计

(1)日志类设计

服务器在运作时会产生一些日志,这些日志会记录下服务器运行过程中产生的一些事件。

本项目中的日志格式如下:

日志说明:

  • 日志级别: 分为四个等级,从低到高依次是INFO、WARNING、ERROR、FATAL。
  • 时间戳: 事件产生的时间。
  • 日志信息: 事件产生的日志信息。
  • 错误文件名称: 事件在哪一个文件产生。
  • 行数: 事件在对应文件的哪一行产生。

日志级别说明:

  • INFO: 表示正常的日志输出,一切按预期运行。
  • WARNING: 表示警告,该事件不影响服务器运行,但存在风险。
  • ERROR: 表示发生了某种错误,但该事件不影响服务器继续运行。
  • FATAL: 表示发生了致命的错误,该事件将导致服务器停止运行。

我们可以针对日志编写一个输出日志的Log函数,该函数的参数就包括日志级别、日志信息、错误文件名称、错误的行数。如下:

#include "Log.h"
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;
}

日志类头文件编写如下:

#pragma once
#define INFO    1
#define WARNING 2
#define ERROR   3
#define FATAL   4
#define INTERNAL_SERVER_ERROR 500
#define OK 200
#define  BAD_REQUEST 400
#define NOT_FOUND 404
#include <iostream>
#define LOG(level, message) Log(#level, message, __FILE__, __LINE__)
void Log(std::string level, std::string message, std::string file_name, int line);

(2)套接字代码相关编写

套接字相关的代码封装到TcpServer类中,在初始化TcpServer对象时完成套接字的创建、绑定和监听动作,并向外提供一个Sock接口用于获取监听套接字。

此外将TcpServer设置成单例模式:

  1. 将TcpServer类的构造函数设置为私有,并将拷贝构造和拷贝赋值函数设置为私有或删除,防止外部创建或拷贝对象。
  2. 提供一个指向单例对象的static指针,并在类外将其初始化为nullptr。
  3. 提供一个全局访问点获取单例对象,在单例对象第一次被获取的时候就创建这个单例对象并进行初始化。

TcpServer类头文件设计如下:

#define BACKLOG 5
#include <pthread.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <unistd.h>
#include <netinet/in.h>								                                        
#include "Log.h"
#include <arpa/inet.h>
//TCP服务器
class TcpServer {
private:
	int _port;              //端口号
	int _listen_sock;       //监听套接字
	static TcpServer* _svr; //指向单例对象的static指针
private:
	//构造函数私有
	TcpServer(int port);
	//将拷贝构造函数和拷贝赋值函数私有或删除(防拷贝)
	TcpServer(const TcpServer&) = delete;
	TcpServer* operator=(const TcpServer&) = delete;

public:
	//获取单例对象
	static TcpServer* GetInstance(int port);

	//初始化服务器
	void InitServer();

	//创建套接字
	void Socket();

	//绑定
	void Bind();

	//监听
	void Listen();

	//获取监听套接字
	int Sock();

	~TcpServer();
};


 TcpServer类源文件设计如下:

#include "TcpServer.h"
#include <string.h>
//构造函数私有
TcpServer::TcpServer(int port)
	:_port(port)
	, _listen_sock(-1)
{}

//获取单例对象
TcpServer* TcpServer::GetInstance(int port)
{
	static pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER; //定义静态的互斥锁
	if (_svr == nullptr) {
		pthread_mutex_lock(&mtx); //加锁
		if (_svr == nullptr) {
			//创建单例TCP服务器对象并初始化
			_svr = new TcpServer(port);
			_svr->InitServer();
		}
		pthread_mutex_unlock(&mtx); //解锁
	}
	return _svr; //返回单例对象
}

//初始化服务器
void TcpServer::InitServer()
{
	Socket(); //创建套接字
	Bind();   //绑定
	Listen(); //监听
	LOG(INFO, "tcp_server init ... success");
}
//创建套接字
void TcpServer::Socket()
{
	 
	_listen_sock = socket(AF_INET, SOCK_STREAM, 0);
	if (_listen_sock < 0) { //创建套接字失败
		LOG(FATAL, "socket error!");
		exit(1);
	}
	//设置端口复用
	int opt = 1;
	setsockopt(_listen_sock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
	LOG(INFO, "create socket ... success");
}
//绑定
void TcpServer::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) 
	{ //绑定失败
		perror("error bind:");
		LOG(FATAL, "bind error!");
		exit(2);
	}
	LOG(INFO, "bind socket ... success");
}
//监听
void TcpServer::Listen()
{
	if (listen(_listen_sock, BACKLOG) < 0) { //监听失败
		LOG(FATAL, "listen error!");
		exit(3);
	}
	LOG(INFO, "listen socket ... success");
}
//获取监听套接字
int TcpServer::Sock()
{
	return _listen_sock;
}
TcpServer::~TcpServer()
{
	if (_listen_sock >= 0) { //关闭监听套接字
		close(_listen_sock);
	}
}
//单例对象指针初始化为nullptr
TcpServer * TcpServer::_svr = nullptr;

(3)Http服务器类设计

可以将HTTP服务器封装成一个HttpServer类,在构造HttpServer对象时传入一个端口号,之后就可以调用Loop让服务器运行起来了。服务器运行起来后要做的就是,先获取单例对象TcpServer中的监听套接字,然后不断从监听套接字中获取新连接,每当获取到一个新连接后就创建一个新线程为该连接提供服务。

Http头文件设计如下:

#define PORT 8081
#include "TcpServer.h"
#include "CallBack.h"
#include "Log.h"
#include <iostream>
#include <sstream>
#include <sys/socket.h>
//HTTP服务器
class HttpServer {
private:
	int _port; //端口号
public:
	HttpServer(int port);

	//启动服务器
	void Loop();

	~HttpServer();
};

Http源文件设计如下:

#include "HttpServer.h"
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <unistd.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <pthread.h>
HttpServer::HttpServer(int port)
	:_port(port)
{}

//启动服务器
void HttpServer::Loop()
{
	LOG(INFO, "loop begin");
	TcpServer * tsvr = TcpServer::GetInstance(_port); //获取TCP服务器单例对象
	int listen_sock = tsvr->Sock(); //获取监听套接字
	while (true)
	{
		struct sockaddr_in peer;
		memset(&peer, 0, sizeof(peer));
		socklen_t len = sizeof(peer);
		int sock = accept(listen_sock, (struct sockaddr*)&peer, &len); //获取新连接(阻塞到一直有客户端请求连接)
		if (sock < 0) {
			continue; //获取失败,继续获取
		}

		//打印客户端相关信息
		std::string client_ip = inet_ntoa(peer.sin_addr);
		int client_port = ntohs(peer.sin_port);
		LOG(INFO, "get a new link: [" + client_ip + ":" + std::to_string(client_port) + "]");

		//创建新线程处理新连接发起的HTTP请求
		int * p = new int(sock);
		pthread_t tid;
		pthread_create(&tid, nullptr, CallBack::HandlerRequest, (void*)p);
		pthread_detach(tid); //线程分离
	}
}
HttpServer::~HttpServer()
{
}

每次服务器与一个客户端建立好了连接,就创建一条线程去处理连接。新线程创建后可以将新线程分离,分离后主线程继续获取新连接,而新线程则处理新连接发来的HTTP请求,代码中的HandlerRequest函数就是新线程处理新连接时需要执行的回调函数。

(4)连接处理回调类编写

主要用来处理客户端的连接请求

Callback头文件设计如下:

#include "Log.h"
#include "EndPoint.h"
#include <sys/types.h>
#include <sys/socket.h>
#include <unistd.h>
#include <netinet/in.h>
class CallBack {
public:
	static void* HandlerRequest(void* arg);
};

Callback源文件设计如下:

#include "CallBack.h"

void* CallBack::HandlerRequest(void* arg)
{
	LOG(INFO, "handler request begin");
	int sock = *(int*)arg;

	EndPoint * ep = new EndPoint(sock);
	ep->RecvHttpRequest();    //读取请求
	ep->HandlerHttpRequest(); //处理请求
	ep->BuildHttpResponse();  //构建响应
	ep->SendHttpResponse();   //发送响应

	close(sock); //关闭与该客户端建立的套接字
	delete ep;
	LOG(INFO, "handler request end");
	return nullptr;
}

(5)Http请求类设计

将HTTP请求封装成一个类,这个类当中包括HTTP请求的内容、HTTP请求的解析结果以及是否需要使用CGI模式的标志位。后续处理请求时就可以定义一个HTTP请求类,读取到的HTTP请求的数据就存储在这个类当中,解析HTTP请求后得到的数据也存储在这个类当中。

头文件设计如下:

#include <iostream>
#include <vector>
#include <unordered_map>

//HTTP请求
class HttpRequest {
public:
	//HTTP请求内容
	std::string _request_line;                //请求行
	std::vector<std::string> _request_header; //请求报头
	std::string _blank;                       //空行
	std::string _request_body;                //请求正文

	//解析结果
	std::string _method;       //请求方法
	std::string _uri;          //URI
	std::string _version;      //版本号
	std::unordered_map<std::string, std::string> _header_kv; //请求报头中的键值对
	int _content_length;       //正文长度
	std::string _path;         //请求资源的路径
	std::string _query_string; //uri中携带的参数

	//CGI相关
	bool _cgi; //是否需要使用CGI模式
public:
	HttpRequest();

	~HttpRequest();
};

 源文件设计如下:

#include "HttpRequest.h"
HttpRequest::HttpRequest()
	:_content_length(0) //默认请求正文长度为0
	, _cgi(false)        //默认不使用CGI模式
{}
HttpRequest::~HttpRequest()
{}

(6)Http响应类设计

HTTP响应也可以封装成一个类,这个类当中包括HTTP响应的内容以及构建HTTP响应所需要的数据。后续构建响应时就可以定义一个HTTP响应类,构建响应需要使用的数据就存储在这个类当中,构建后得到的响应内容也存储在这个类当中。

头文件设计如下:

#include <iostream>
#include <vector>
#define OK 200
#define LINE_END "\r\n"
//HTTP响应
class HttpResponse {
public:
	//HTTP响应内容
	std::string _status_line;                  //状态行
	std::vector<std::string> _response_header; //响应报头
	std::string _blank;                        //空行
	std::string _response_body;                //响应正文(CGI相关)

	//所需数据
	int _status_code;    //状态码
	int _fd;             //响应文件的fd  (非CGI相关)
	int _size;           //响应文件的大小(非CGI相关)
	std::string _suffix; //响应文件的后缀(非CGI相关)
public:
	HttpResponse();

	~HttpResponse();
};

源文件设计如下:

#include "HttpResponse.h"
HttpResponse::HttpResponse()
	:_blank(LINE_END) //设置空行
	, _status_code(OK) //状态码默认为200
	, _fd(-1)          //响应文件的fd初始化为-1
	, _size(0)         //响应文件的大小默认为0
{}
HttpResponse::~HttpResponse()
{}

(7)服务端EndPoint类设计

该类是服务器的核心,用来读取请求,解析请求,处理请求,构建响应,发送响应。

头文件设计如下:

//服务端EndPoint
#include "HttpRequest.h"
#include "HttpResponse.h"
class EndPoint {
private:
	int _sock;                   //通信的套接字
	HttpRequest _http_request;   //HTTP请求
	HttpResponse _http_response; //HTTP响应
public:
	EndPoint(int sock);

	//读取请求
	void RecvHttpRequest();

	//处理请求
	void HandlerHttpRequest();

	//构建响应
	void BuildHttpResponse();

	//发送响应
	void SendHttpResponse();

	~EndPoint();

private:
	//读取请求行
	void RecvHttpRequestLine();

	//读取请求报头和空行
	void RecvHttpRequestHeader();

	//解析请求行
	void ParseHttpRequestLine();

	//解析请求报头
	void ParseHttpRequestHeader();

	//读取请求正文
	void RecvHttpRequestBody();

	//判断是否需要读取请求正文
	bool IsNeedRecvHttpRequestBody();

	//cgi处理程序
	int ProcessCgi();

	int ProcessNonCgi();

	void BuildOkResponse();

	void HandlerError(std::string page);
};

源文件设计如下:

#include "EndPoint.h"
#include "Util.h"
#include <sstream>
#include <algorithm>
#include "Log.h"
#define SEP ":"
#include <sys/types.h>
#include <sys/socket.h>
#include <unistd.h>
#include <netinet/in.h>
#include <sys/wait.h>
#include <sys/stat.h> 
#include <sys/sendfile.h>
#include <fcntl.h> 
#define WEB_ROOT "wwwroot"
#define HOME_PAGE "index.html"
#define HTTP_VERSION "HTTP/1.0"
#define LINE_END "\r\n"

#define PAGE_400 "400.html"
#define PAGE_404 "404.html"
#define PAGE_500 "500.html"
EndPoint::EndPoint(int sock)
	:_sock(sock)
{}

//根据状态码获取状态码描述
static std::string CodeToDesc(int code)
{
	std::string desc;
	switch (code) {
	case 200:
		desc = "OK";
		break;
	case 400:
		desc = "Bad Request";
		break;
	case 404:
		desc = "Not Found";
		break;
	case 500:
		desc = "Internal Server Error";
		break;
	default:
		break;
	}
	return desc;
}

//根据后缀获取资源类型
static std::string SuffixToDesc(const std::string& suffix)
{
	static std::unordered_map<std::string, std::string> suffix_to_desc = {
		{".html", "text/html"},
		{".css", "text/css"},
		{".js", "application/x-javascript"},
		{".jpg", "application/x-jpg"},
		{".xml", "text/xml"}
	};
	auto iter = suffix_to_desc.find(suffix);
	if (iter != suffix_to_desc.end()) {
		return iter->second;
	}
	return "text/html"; //所给后缀未找到则默认该资源为html文件
}

//读取请求
void EndPoint::RecvHttpRequest()
{
	RecvHttpRequestLine();    //读取请求行

	RecvHttpRequestHeader();  //读取请求报头和空行

	ParseHttpRequestLine();   //解析请求行

	ParseHttpRequestHeader(); //解析请求报头

	RecvHttpRequestBody();    //读取请求正文
};

//处理请求
void EndPoint::HandlerHttpRequest() 
{
	auto& code = _http_response._status_code;

	if (_http_request._method != "GET" && _http_request._method != "POST") { //非法请求
		LOG(WARNING, "method is not right");
		code = BAD_REQUEST; //设置对应的状态码,并直接返回
		return;
	}

	if (_http_request._method == "GET") {
		size_t pos = _http_request._uri.find('?');
		if (pos != std::string::npos) { //uri中携带参数
			//切割uri,得到客户端请求资源的路径和uri中携带的参数
			Util::CutString(_http_request._uri, _http_request._path, _http_request._query_string, "?");
			_http_request._cgi = true; //上传了参数,需要使用CGI模式
		}
		else { //uri中没有携带参数
			_http_request._path = _http_request._uri; //uri即是客户端请求资源的路径
		}
	}
	else if (_http_request._method == "POST") {
		_http_request._path = _http_request._uri; //uri即是客户端请求资源的路径
		_http_request._cgi = true; //上传了参数,需要使用CGI模式
	}
	else {
		//Do Nothing
	}

	//给请求资源路径拼接web根目录
	std::string path = _http_request._path;
	_http_request._path = WEB_ROOT;
	_http_request._path += path;

	//请求资源路径以/结尾,说明请求的是一个目录
	if (_http_request._path[_http_request._path.size() - 1] == '/') {
		//拼接上该目录下的index.html
		_http_request._path += HOME_PAGE;
	}

	//获取请求资源文件的属性信息
	struct stat st;
	if (stat(_http_request._path.c_str(), &st) == 0) { //属性信息获取成功,说明该资源存在
		if (S_ISDIR(st.st_mode)) { //该资源是一个目录
			_http_request._path += "/"; //需要拼接/,以/结尾的目录前面已经处理过了
			_http_request._path += HOME_PAGE; //拼接上该目录下的index.html
			stat(_http_request._path.c_str(), &st); //需要重新资源文件的属性信息
		}
		else if (st.st_mode & S_IXUSR || st.st_mode & S_IXGRP || st.st_mode & S_IXOTH) { //该资源是一个可执行程序
			_http_request._cgi = true; //需要使用CGI模式
		}
		_http_response._size = st.st_size; //设置请求资源文件的大小
	}
	else { //属性信息获取失败,可以认为该资源不存在
		LOG(WARNING, _http_request._path + " NOT_FOUND");
		code = NOT_FOUND; //设置对应的状态码,并直接返回
		return;
	}

	//获取请求资源文件的后缀
	size_t pos = _http_request._path.rfind('.');
	if (pos == std::string::npos) {
		_http_response._suffix = ".html"; //默认设置
	}
	else {
		_http_response._suffix = _http_request._path.substr(pos);
	}

	//进行CGI或非CGI处理
	if (_http_request._cgi == true) {
		code = ProcessCgi(); //以CGI的方式进行处理
	}
	else {
		code = ProcessNonCgi(); //简单的网页返回,返回静态网页
	}

};

//构建响应
void EndPoint::BuildHttpResponse() 
{
	int code = _http_response._status_code;
	//构建状态行
	auto& status_line = _http_response._status_line;
	status_line += HTTP_VERSION;
	status_line += " ";
	status_line += std::to_string(code);
	status_line += " ";
	status_line += CodeToDesc(code);
	status_line += LINE_END;

	//构建响应报头
	std::string path = WEB_ROOT;
	path += "/";
	switch (code) {
	case OK:
		BuildOkResponse();
		break;
	case NOT_FOUND:
		path += PAGE_404;
		HandlerError(path);
		break;
	case BAD_REQUEST:
		path += PAGE_400;
		HandlerError(path);
		break;
	case INTERNAL_SERVER_ERROR:
		path += PAGE_500;
		HandlerError(path);
		break;
	default:
		break;
	}
};

//发送响应
void EndPoint::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);

	//发送响应正文
	if (_http_request._cgi) {
		auto& response_body = _http_response._response_body;
		const char* start = response_body.c_str();
		size_t size = 0;
		size_t total = 0;
		while (total < response_body.size() && (size = send(_sock, start + total, response_body.size() - total, 0)) > 0) {
			total += size;
		}
	}
	else 
	{
		//通过sendfile文件来发送响应内容
		sendfile(_sock, _http_response._fd, nullptr, _http_response._size);
		//关闭请求的资源文件
		close(_http_response._fd);
	}
};

//读取请求行
void EndPoint::RecvHttpRequestLine()
{
	std::string& line = _http_request._request_line;
	if (Util::ReadLine(_sock, line) > 0)
	{

	}
}

void EndPoint::RecvHttpRequestHeader()
{
	//读取请求报头和空行
	std::string line;
	while (true) {
		line.clear(); //每次读取之前清空line
		Util::ReadLine(_sock, line);
		if (line == "\n") { //读取到了空行
			_http_request._blank = line;
			break;
		}
		//读取到一行请求报头
		line.resize(line.size() - 1);

		//去掉读取上来的\n
		_http_request._request_header.push_back(line);
	}
}

void EndPoint::ParseHttpRequestLine()
{
	//解析请求行
	auto& line = _http_request._request_line;

	//通过stringstream拆分请求行
	std::stringstream ss(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);
}

void EndPoint::ParseHttpRequestHeader()
{
	std::string key;
	std::string value;
	for (auto& iter : _http_request._request_header) {
		//将每行请求报头打散成kv键值对,插入到unordered_map中
		if (Util::CutString(iter, key, value, SEP)) {
			_http_request._header_kv.insert({ key, value });
		}
	}
}

void EndPoint::RecvHttpRequestBody()
{
	if (IsNeedRecvHttpRequestBody()) { //先判断是否需要读取正文
		int content_length = _http_request._content_length;
		auto& body = _http_request._request_body;

		//读取请求正文
		char ch = 0;
		while (content_length) {
			int size = recv(_sock, &ch, 1, 0);
			if (size > 0) {
				body.push_back(ch);
				content_length--;
			}
			else {
				break;
			}
		}
	}
}

//判断是否需要读取请求正文
bool EndPoint::IsNeedRecvHttpRequestBody()
{
	auto& method = _http_request._method;
	if (method == "POST") { //请求方法为POST则需要读取正文
		auto& header_kv = _http_request._header_kv;
		//通过Content-Length获取请求正文长度
		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;
}

EndPoint::~EndPoint()
{

}

//CGI处理
int EndPoint::ProcessCgi()
{

	int code = OK; //要返回的状态码,默认设置为200

	auto& bin = _http_request._path;      //需要执行的CGI程序
	auto& method = _http_request._method; //请求方法

	//需要传递给CGI程序的参数
	auto& query_string = _http_request._query_string; //GET
	auto& request_body = _http_request._request_body; //POST

	int content_length = _http_request._content_length;  //请求正文的长度
	auto& response_body = _http_response._response_body; //CGI程序的处理结果放到响应正文当中

	//1、创建两个匿名管道(管道命名站在父进程角度)
	//创建从子进程到父进程的通信信道
	int input[2];
	if (pipe(input) < 0) { //管道创建失败,则返回对应的状态码
		LOG(ERROR, "pipe input error!");
		code = INTERNAL_SERVER_ERROR;
		return code;
	}
	//创建从父进程到子进程的通信信道
	int output[2];
	if (pipe(output) < 0) { //管道创建失败,则返回对应的状态码
		LOG(ERROR, "pipe output error!");
		code = INTERNAL_SERVER_ERROR;
		return code;
	}

	//2、创建子进程
	pid_t pid = fork();
	if (pid == 0) { //child
		//子进程关闭两个管道对应的读写端
		close(input[0]);
		close(output[1]);

		//将请求方法通过环境变量传参
		std::string method_env = "METHOD=";
		method_env += method;
		putenv((char*)method_env.c_str());

		if (method == "GET") { //将query_string通过环境变量传参
			std::string query_env = "QUERY_STRING=";
			query_env += query_string;
			putenv((char*)query_env.c_str());
			LOG(INFO, "GET Method, Add Query_String env");
		}
		else if (method == "POST") { //将正文长度通过环境变量传参
			std::string content_length_env = "CONTENT_LENGTH=";
			content_length_env += std::to_string(content_length);
			putenv((char*)content_length_env.c_str());
			LOG(INFO, "POST Method, Add Content_Length env");
		}
		else {
			//Do Nothing
		}

		//3、将子进程的标准输入输出进行重定向
		dup2(output[0], 0); //标准输入重定向到管道的输入
		dup2(input[1], 1);  //标准输出重定向到管道的输出

		//4、将子进程替换为对应的CGI程序
		execl(bin.c_str(), bin.c_str(), nullptr);
		exit(1); //替换失败
	}
	else if (pid < 0) { //创建子进程失败,则返回对应的错误码
		LOG(ERROR, "fork error!");
		code = INTERNAL_SERVER_ERROR;
		return code;
	}
	else { //father
		//父进程关闭两个管道对应的读写端
		close(input[1]);
		close(output[0]);

		if (method == "POST") { //将正文中的参数通过管道传递给CGI程序
			const char* start = request_body.c_str();
			int total = 0;
			int size = 0;
			while (total < content_length && (size = write(output[1], start + total, request_body.size() - total)) > 0) {
				total += size;
			}
		}

		//读取CGI程序的处理结果
		char ch = 0;
		while (read(input[0], &ch, 1) > 0) {
			response_body.push_back(ch);
		} //不会一直读,当另一端关闭后会继续执行下面的代码

		//等待子进程(CGI程序)退出
		int status = 0;
		pid_t ret = waitpid(pid, &status, 0);
		if (ret == pid) {
			if (WIFEXITED(status)) { //正常退出
				if (WEXITSTATUS(status) == 0) { //结果正确
					LOG(INFO, "CGI program exits normally with correct results");
					code = OK;
				}
				else {
					LOG(INFO, "CGI program exits normally with incorrect results");
					code = BAD_REQUEST;
				}
			}
			else {
				LOG(INFO, "CGI program exits abnormally");
				code = INTERNAL_SERVER_ERROR;
			}
		}

		//关闭两个管道对应的文件描述符
		close(input[0]);
		close(output[1]);
	}
	return code; //返回状态码
}

//非CGI处理
int EndPoint::ProcessNonCgi()
{
	//打开客户端请求的资源文件,以供后续发送
	_http_response._fd = open(_http_request._path.c_str(), O_RDONLY);
	if (_http_response._fd >= 0) { //打开文件成功
		return OK;
	}
	return INTERNAL_SERVER_ERROR; //打开文件失败
}

//构建ok响应报头
void EndPoint::BuildOkResponse()
{
	//构建响应报头
	std::string content_type = "Content-Type: ";
	content_type += SuffixToDesc(_http_response._suffix);
	content_type += LINE_END;
	_http_response._response_header.push_back(content_type);

	std::string content_length = "Content-Length: ";
	if (_http_request._cgi) { //以CGI方式请求
		content_length += std::to_string(_http_response._response_body.size());
	}
	else { //以非CGI方式请求
		content_length += std::to_string(_http_response._size);
	}
	content_length += LINE_END;
	_http_response._response_header.push_back(content_length);
}

//处理错误页面
void EndPoint::HandlerError(std::string page)
{
	_http_request._cgi = false; //需要返回对应的错误页面(非CGI返回)

	//打开对应的错误页面文件,以供后续发送
	_http_response._fd = open(page.c_str(), O_RDONLY);
	if (_http_response._fd > 0) { //打开文件成功
		//构建响应报头
		struct stat st;
		stat(page.c_str(), &st); //获取错误页面文件的属性信息

		std::string content_type = "Content-Type: text/html";
		content_type += LINE_END;
		_http_response._response_header.push_back(content_type);

		std::string content_length = "Content-Length: ";
		content_length += std::to_string(st.st_size);
		content_length += LINE_END;
		_http_response._response_header.push_back(content_length);

		_http_response._size = st.st_size; //重新设置响应文件的大小
	}
}

在处理请求中用到CGI处理机制,因为实际对数据的处理与HTTP的关系并不大,而是取决于上层具体的业务场景的 ,因此HTTP不对这些数据做处理。但HTTP提供了CGI机制,上层可以在服务器中部署若干个CGI程序,这些CGI程序可以用任何程序设计语言编写,当HTTP获取到数据后会将其提交给对应CGI程序进行处理,然后再用CGI程序的处理结果构建HTTP响应返回给浏览器。

 

 (8)CGI实现机制

        什么时候需要CGI处理?

(1)只要用户请求服务器时上传了数据,那么服务器就需要使用CGI模式对用户上传的数据进行处理,而如果用户只是单纯的想请求服务器上的某个资源文件则不需要使用CGI模式,此时直接将用户请求的资源文件返回给用户即可。

(2)用户请求的是服务器上的一个可执行程序,说明用户想让服务器运行这个可执行程序,此时也需要使用CGI模式。

        实现步骤

一、创建子进程进行程序替换

服务器获取到新连接后一般会创建一个新线程为其提供服务,而要执行CGI程序一定需要调用exec系列函数进行进程程序替换,但服务器创建的新线程与服务器进程使用的是同一个进程地址空间,如果直接让新线程调用exec系列函数进行进程程序替换,此时服务器进程的代码和数据就会直接被替换掉,相当于HTTP服务器在执行一次CGI程序后就直接退出了,这肯定是不合理的。因此新线程需要先调用fork函数创建子进程,然后让子进程调用exec系列函数进行进程程序替换

二、完成管道通信信道的建立

调用CGI程序的目的是为了让其进行数据处理,因此我们需要通过某种方式将数据交给CGI程序,并且还要能够获取到CGI程序处理数据后的结果,也就是需要进行进程间通信。因为这里的服务器进程和CGI进程是父子进程,因此优先选择使用匿名管道。

由于父进程不仅需要将数据交给子进程,还需要从子进程那里获取数据处理的结果,而管道是半双工通信的,为了实现双向通信于是需要借助两个匿名管道,因此在创建调用fork子进程之前需要先创建两个匿名管道,在创建子进程后还需要父子进程分别关闭两个管道对应的读写端。

三、完成重定向相关的设置

创建用于父子进程间通信的两个匿名管道时,父子进程都是各自用两个变量来记录管道对应读写端的文件描述符的,但是对于子进程来说,当子进程调用exec系列函数进行程序替换后,子进程的代码和数据就被替换成了目标CGI程序的代码和数据,这也就意味着被替换后的CGI程序无法得知管道对应的读写端,这样父子进程之间也就无法进行通信了。

需要注意的是,进程程序替换只替换对应进程的代码和数据,而对于进程的进程控制块、页表、打开的文件等内核数据结构是不做任何替换的。因此子进程进行进程程序替换后,底层创建的两个匿名管道仍然存在,只不过被替换后的CGI程序不知道这两个管道对应的文件描述符罢了。

这时我们可以做一个约定:被替换后的CGI程序,从标准输入读取数据等价于从管道读取数据,向标准输出写入数据等价于向管道写入数据。这样一来,所有的CGI程序都不需要得知管道对应的文件描述符了,当需要读取数据时直接从标准输入中进行读取,而数据处理的结果就直接写入标准输出就行了。

当然,这个约定并不是你说有就有的,要实现这个约定需要在子进程被替换之前进行重定向,将0号文件描述符重定向到对应管道的读端,将1号文件描述符重定向到对应管道的写端

重定向之前:

重定向之后:

四、父子进程交付数据

这时父子进程已经能够通过两个匿名管道进行通信了,接下来就应该讨论父进程如何将数据交给CGI程序,以及CGI程序如何将数据处理结果交给父进程了。

父进程将数据交给CGI程序:

  • 如果请求方法为GET方法,那么用户是通过URL传递参数的,此时可以在子进程进行进程程序替换之前,通过putenv函数将参数导入环境变量,由于环境变量也不受进程程序替换的影响,因此被替换后的CGI程序就可以通过getenv函数来获取对应的参数。
  • 如果请求方法为POST方法,那么用户是通过请求正文传参的,此时父进程直接将请求正文中的数据写入管道传递给CGI程序即可,但是为了让CGI程序知道应该从管道读取多少个参数,父进程还需要通过putenv函数将请求正文的长度导入环境变量。

此时子进程就可以进行进程程序替换了,而父进程需要做如下工作:

  • 如果请求方法为POST方法,则父进程需要将请求正文中的参数写入管道中,以供被替换后的CGI程序进行读取。
  • 然后父进程要做的就是不断调用read函数,从管道中读取CGI程序写入的处理结果,并将其保存到HTTP响应类的response_body当中。
  • 管道中的数据读取完毕后,父进程需要调用waitpid函数等待CGI程序退出,并关闭两个管道对应的文件描述符,防止文件描述符泄露。

 代码如下:

   //CGI处理
        int ProcessCgi()
        {
            int code = OK; //要返回的状态码,默认设置为200

            auto& bin = _http_request._path;      //需要执行的CGI程序
            auto& method = _http_request._method; //请求方法

            //需要传递给CGI程序的参数
            auto& query_string = _http_request._query_string; //GET
            auto& request_body = _http_request._request_body; //POST

            int content_length = _http_request._content_length;  //请求正文的长度
            auto& response_body = _http_response._response_body; //CGI程序的处理结果放到响应正文当中

            //1、创建两个匿名管道(管道命名站在父进程角度)
            //创建从子进程到父进程的通信信道
            int input[2];
            if(pipe(input) < 0){ //管道创建失败,则返回对应的状态码
                LOG(ERROR, "pipe input error!");
                code = INTERNAL_SERVER_ERROR;
                return code;
            }
            //创建从父进程到子进程的通信信道
            int output[2];
            if(pipe(output) < 0){ //管道创建失败,则返回对应的状态码
                LOG(ERROR, "pipe output error!");
                code = INTERNAL_SERVER_ERROR;
                return code;
            }

            //2、创建子进程
            pid_t pid = fork();
            if(pid == 0){ //child
                //子进程关闭两个管道对应的读写端
                close(input[0]);
                close(output[1]);

                //将请求方法通过环境变量传参
                std::string method_env = "METHOD=";
                method_env += method;
                putenv((char*)method_env.c_str());

                if(method == "GET"){ //将query_string通过环境变量传参
                    std::string query_env = "QUERY_STRING=";
                    query_env += query_string;
                    putenv((char*)query_env.c_str());
                    LOG(INFO, "GET Method, Add Query_String env");
                }
                else if(method == "POST"){ //将正文长度通过环境变量传参
                    std::string content_length_env = "CONTENT_LENGTH=";
                    content_length_env += std::to_string(content_length);
                    putenv((char*)content_length_env.c_str());
                    LOG(INFO, "POST Method, Add Content_Length env");
                }
                else{
                    //Do Nothing
                }

                //3、将子进程的标准输入输出进行重定向
                dup2(output[0], 0); //标准输入重定向到管道的输入
                dup2(input[1], 1);  //标准输出重定向到管道的输出

                //4、将子进程替换为对应的CGI程序
                execl(bin.c_str(), bin.c_str(), nullptr);
                exit(1); //替换失败
            }
            else if(pid < 0){ //创建子进程失败,则返回对应的错误码
                LOG(ERROR, "fork error!");
                code = INTERNAL_SERVER_ERROR;
                return code;
            }
            else{ //father
                //父进程关闭两个管道对应的读写端
                close(input[1]);
                close(output[0]);

                if(method == "POST"){ //将正文中的参数通过管道传递给CGI程序
                    const char* start = request_body.c_str();
                    int total = 0;
                    int size = 0;
                    while(total < content_length && (size = write(output[1], start + total, request_body.size() - total)) > 0){
                        total += size;
                    }
                }

                //读取CGI程序的处理结果
                char ch = 0;
                while(read(input[0], &ch, 1) > 0){
                    response_body.push_back(ch);
                } //不会一直读,当另一端关闭后会继续执行下面的代码

                //等待子进程(CGI程序)退出
                int status = 0;
                pid_t ret = waitpid(pid, &status, 0);
                if(ret == pid){
                    if(WIFEXITED(status)){ //正常退出
                        if(WEXITSTATUS(status) == 0){ //结果正确
                            LOG(INFO, "CGI program exits normally with correct results");
                            code = OK;
                        }
                        else{
                            LOG(INFO, "CGI program exits normally with incorrect results");
                            code = BAD_REQUEST;
                        }
                    }
                    else{
                        LOG(INFO, "CGI program exits abnormally");
                        code = INTERNAL_SERVER_ERROR;
                    }
                }

                //关闭两个管道对应的文件描述符
                close(input[0]);
                close(output[1]);
            }
            return code; //返回状态码
        }

(9)线程池的设计

  • 在服务器端预先创建一批线程和一个任务队列,每当获取到一个新连接时就将其封装成一个任务对象放到任务队列当中。
  • 线程池中的若干线程就不断从任务队列中获取任务进行处理,如果任务队列当中没有任务则线程进入休眠状态,当有新任务时再唤醒线程进行任务处理。

任务类设计

当服务器获取到一个新连接后,需要将其封装成一个任务对象放到任务队列当中。任务类中首先需要有一个套接字,也就是与客户端进行通信的套接字,此外还需要有一个回调函数,当线程池中的线程获取到任务后就可以调用这个回调函数进行任务处理。

代码如下:

//任务类
class Task{
    private:
        int _sock;         //通信的套接字
        CallBack _handler; //回调函数
    public:
        Task()
        {}
        Task(int sock)
            :_sock(sock)
        {}
        //处理任务
        void ProcessOn()
        {
            _handler(_sock); //调用回调
        }
        ~Task()
        {}
};

线程池类设计

可以将线程池设计成单例模式:

  1. 将ThreadPool类的构造函数设置为私有,并将拷贝构造和拷贝赋值函数设置为私有或删除,防止外部创建或拷贝对象。
  2. 提供一个指向单例对象的static指针,并在类外将其初始化为nullptr。
  3. 提供一个全局访问点获取单例对象,在单例对象第一次被获取时就创建这个单例对象并进行初始化。

ThreadPool类中的成员变量包括:

  • 任务队列:用于暂时存储未被处理的任务对象。
  • num:表示线程池中线程的个数。
  • 互斥锁:用于保证任务队列在多线程环境下的线程安全。
  • 条件变量:当任务队列中没有任务时,让线程在该条件变量下进行等等,当任务队列中新增任务时,唤醒在该条件变量下进行等待的线程。
  • 指向单例对象的指针:用于指向唯一的单例线程池对象。

ThreadPool类中的成员函数主要包括:

  • 构造函数:完成互斥锁和条件变量的初始化操作。
  • 析构函数:完成互斥锁和条件变量的释放操作。
  • InitThreadPool:初始化线程池时调用,完成线程池中若干线程的创建。
  • PushTask:生产任务时调用,将任务对象放入任务队列,并唤醒在条件变量下等待的一个线程进行处理。
  • PopTask:消费任务时调用,从任务队列中获取一个任务对象。
  • ThreadRoutine:线程池中每个线程的执行例程,完成线程分离后不断检测任务队列中是否有任务,如果有则调用PopTask获取任务进行处理,如果没有则进行休眠直到被唤醒。
  • GetInstance:获取单例线程池对象时调用,如果单例对象未创建则创建并初始化后返回,如果单例对象已经创建则直接返回单例对象。 

代码如下: 

#define NUM 6

//线程池
class ThreadPool{
    private:
        std::queue<Task> _task_queue; //任务队列
        int _num;                     //线程池中线程的个数
        pthread_mutex_t _mutex;       //互斥锁
        pthread_cond_t _cond;         //条件变量
        static ThreadPool* _inst;     //指向单例对象的static指针
    private:
        //构造函数私有
        ThreadPool(int num = NUM)
            :_num(num)
        {
            //初始化互斥锁和条件变量
            pthread_mutex_init(&_mutex, nullptr);
            pthread_cond_init(&_cond, nullptr);
        }
        //将拷贝构造函数和拷贝赋值函数私有或删除(防拷贝)
        ThreadPool(const ThreadPool&)=delete;
        ThreadPool* operator=(const ThreadPool&)=delete;

        //判断任务队列是否为空
        bool IsEmpty()
        {
            return _task_queue.empty();
        }

        //任务队列加锁
        void LockQueue()
        {
            pthread_mutex_lock(&_mutex);
        }
        
        //任务队列解锁
        void UnLockQueue()
        {
            pthread_mutex_unlock(&_mutex);
        }

        //让线程在条件变量下进行等待
        void ThreadWait()
        {
            pthread_cond_wait(&_cond, &_mutex);
        }
        
        //唤醒在条件变量下等待的一个线程
        void ThreadWakeUp()
        {
            pthread_cond_signal(&_cond);
        }

    public:
        //获取单例对象
        static ThreadPool* GetInstance()
        {
            static pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER; //定义静态的互斥锁
            //双检查加锁
            if(_inst == nullptr){
                pthread_mutex_lock(&mtx); //加锁
                if(_inst == nullptr){
                    //创建单例线程池对象并初始化
                    _inst = new ThreadPool();
                    _inst->InitThreadPool();
                }
                pthread_mutex_unlock(&mtx); //解锁
            }
            return _inst; //返回单例对象
        }

        //线程的执行例程
        static void* ThreadRoutine(void* arg)
        {
            pthread_detach(pthread_self()); //线程分离
            ThreadPool* tp = (ThreadPool*)arg;
            while(true){
                tp->LockQueue(); //加锁
                while(tp->IsEmpty()){
                    //任务队列为空,线程进行wait
                    tp->ThreadWait();
                }
                Task task;
                tp->PopTask(task); //获取任务
                tp->UnLockQueue(); //解锁

                task.ProcessOn(); //处理任务
            }
        }
        
        //初始化线程池
        bool InitThreadPool()
        {
            //创建线程池中的若干线程
            pthread_t tid;
            for(int i = 0;i < _num;i++){
                if(pthread_create(&tid, nullptr, ThreadRoutine, this) != 0){
                    LOG(FATAL, "create thread pool error!");
                    return false;
                }
            }
            LOG(INFO, "create thread pool success");
            return true;
        }
        
        //将任务放入任务队列
        void PushTask(const Task& task)
        {
            LockQueue();    //加锁
            _task_queue.push(task); //将任务推入任务队列
            UnLockQueue();  //解锁
            ThreadWakeUp(); //唤醒一个线程进行任务处理
        }

        //从任务队列中拿任务
        void PopTask(Task& task)
        {
            //获取任务
            task = _task_queue.front();
            _task_queue.pop();
        }

        ~ThreadPool()
        {
            //释放互斥锁和条件变量
            pthread_mutex_destroy(&_mutex);
            pthread_cond_destroy(&_cond);
        }
};
//单例对象指针初始化为nullptr
ThreadPool* ThreadPool::_inst = nullptr;

 引入线程池后服务器要做的就是,每当获取到一个新连接时就构建一个任务,然后调用PushTask将其放入任务队列即可。

//构建任务并放入任务队列中
                Task task(sock);
                ThreadPool::GetInstance()->PushTask(task);

三、项目测试

至此HTTP服务器后端逻辑已经全部编写完毕,此时我们要做的就是将对外提供的资源文件放在一个名为wwwroot的目录下,然后将生成的HTTP服务器可执行程序与wwwroot放在同级目录下。比如:

服务器启动界面如下,服务器正在等待连接。

在浏览器页面输入请求地址,服务器返回index.html页面给浏览器

在浏览器页面输入请求地址,请求资源不存在,服务器返回404.html页面给浏览器

GET方法上传数据测试

编写CGI程序

如果用户请求服务器时上传了数据,那么服务器就需要将该数据后交给对应的CGI程序进行处理,因此在测试GET方法上传数据之前,我们需要先编写一个简单的CGI程序。

首先,CGI程序启动后需要先获取父进程传递过来的数据:

  1. 先通过getenv函数获取环境变量中的请求方法。
  2. 如果请求方法为GET方法,则继续通过getenv函数获取父进程传递过来的数据。
  3. 如果请求方法为POST方法,则先通过getenv函数获取父进程传递过来的数据的长度,然后再从0号文件描述符中读取指定长度的数据即可。

 

#include <iostream>
#include <unistd.h>
using namespace std;

//获取参数
bool GetQueryString(std::string & query_string)
{
	bool result = false;
	std::string method = getenv("METHOD"); //获取请求方法
	if (method == "GET") { //GET方法通过环境变量获取参数
		query_string = getenv("QUERY_STRING");
		result = true;
	}
	else if (method == "POST") { //POST方法通过管道获取参数
		int content_length = atoi(getenv("CONTENT_LENGTH"));
		//从管道中读取content_length个参数
		char ch = 0;

		while (content_length) {
			read(0, &ch, 1);
			query_string += ch;
			content_length--;
		}
		result = true;
	}
	else {
		//Do Nothing
		result = false;
	}
	return result;
}

//切割字符串
bool CutString(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());
		return true;
	}
	return false;
}
int main()
{
	std::string query_string;
	GetQueryString(query_string); //获取参数

	//以&为分隔符将两个操作数分开
	std::string str1;
	std::string str2;
	CutString(query_string, "&", str1, str2);

	//以=为分隔符分别获取两个操作数的值
	std::string name1;
	std::string value1;
	CutString(str1, "=", name1, value1);
	std::string name2;
	std::string value2;
	CutString(str2, "=", name2, value2);

	//处理数据
	int x = atoi(value1.c_str());
	int y = atoi(value2.c_str());
	std::cout << "<html>";
	std::cout << "<head><meta charset=\"UTF-8\"></head>";
	std::cout << "<body>";
	std::cout << "<h3>" << x << " + " << y << " = " << x + y << "</h3>";
	std::cout << "<h3>" << x << " - " << y << " = " << x - y << "</h3>";
	std::cout << "<h3>" << x << " * " << y << " = " << x * y << "</h3>";
	std::cout << "<h3>" << x << " / " << y << " = " << x / y << "</h3>"; //除0后cgi程序崩溃,属于异常退出
	std::cout << "</body>";
	std::cout << "</html>";

	return 0;
}

上述代码中,编写了一个简单的CGI程序用来简单测试下用户上传参数的get请求和post请求,GetQueryString函数是用来取出环境变量中的传递的参数,CutString是用来做字符串切割最后将处理结果输出到重定向的标准输出中,服务器从对应的读端读取处理结果即可。

GET请求参数测试

 服务器日志

POST请求测试

 

;