项目自主实现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协议用行的方式来陈列协议内容,其中不同的浏览器下行分隔符的表示方式是不一样的,一般有下面三种:
- \\r\\n
- \\r
- \\n
所以为了方便分析协议,我们可以在读取协议的每一行时候都将其行分隔符进行统一处理,统一转为\\n
的形式。所以这里设计了一个ReadLine
的方法进行处理。
思路如下:
- 该函数从sock中读取一行协议内容,然后将行分割符进行处理,然后返回,所以这里使用两个参数:sock、out转换之后的一行)
int ReadLine(int sock, std::string& out);
- 逐个读取字符,如果不是
\\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';
- 最后处理完上面两种情况之后,接下来检验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服务器