tinyWebServer 项目 写在前面:
学习了C++很久一直很想找个项目来做做看一看,验证一下自己目前的学习进度,于是在网上搜了一下,几乎每一篇博客都会有这个项目的推荐,也有人说这个项目做的人很多,不可以写在简历上。但是我觉得做的人很多就代表着很经典,咱们应该尝试着做一下。
刚开始看这个项目的代码的时候完全不知道如何下手,看也看不懂,搞的我都快怀疑自己的能力了。后来想了一下,确实不应该这么着急,可以提前了解一下项目的知识再重新来做一下。
于是就先做了三个小项目,每个项目都挺经典的,而且项目的代码量也很小。
线程池的项目:(主要学习线程池的概念和用法)
tinyhttpd 项目:(主要HTTP协议)
chatRoom 项目:(主要学习socket和IO多路复用)
学习完基础知识之后,就开始尝试学学习这个项目
介绍 这个项目在github上面有很多的版本
先说一下我自己的(嘿嘿嘿):
cmake版本:https://github.com/zqzhang2023/zzqStudy/tree/main/project/5_tinyWebServer
makefile版本:https://github.com/zqzhang2023/zzqStudy/tree/main/project/5_TinyWebServer_makefile
初始的应该是这个:https://github.com/qinguoyi/TinyWebServer
但是这个确实有些久远,有些语法可能目前不太常使用了, 在这个最初的版本后面有贴C++ 11 实现的版本
也就是这个:https://github.com/markparticle/WebServer
这个C++ 11 的版本看着很舒心,于是我就选择了这个版本开始学习
介绍:(这个是官方git上的原话,我直接拿过来了)
Linux下C++轻量级Web服务器,助力初学者快速实践网络编程,搭建属于自己的服务器.
使用 线程池 + 非阻塞socket + epoll(ET和LT均实现) + 事件处理(Reactor和模拟Proactor均实现) 的并发模型
使用状态机解析HTTP请求报文,支持解析GET和POST请求
访问服务器数据库实现web端用户注册、登录功能,可以请求服务器图片和视频文件
实现同步/异步日志系统,记录服务器运行状态
经Webbench压力测试可以实现上万的并发连接数据交换
快速运行 image 我的运行环境是Linux Ubuntu 20.04,Mysql,C++ 11
使用的版本是:https://github.com/markparticle/WebServer
1.首先要安装数据库(这个可以找一下教程,网上有很多,而且安装确实不难)
2.然后按照网站是给出的步骤来,建立数据库和创建表格添加数据
// 建立yourdb库 create database yourdb; // 创建user表 USE yourdb; CREATE TABLE user( username char(50) NULL, password char(50) NULL )ENGINE=InnoDB; // 添加数据 INSERT INTO user(username, password) VALUES('name', 'password');
3.修改一下 code/main.cpp 里面的参数
#include <unistd.h> #include "include/webserver.h" int main () { WebServer server ( 1316 , 3 , 60000 , 3306 , "用户(一般是root)" , "密码" , "数据库名" , 12 , 8 , true , 1 , 1024 ) ; server.Start (); }
4.在根目录下 执行 make
5.在根目录下 执行 sudo ./bin/server (这里要用sudo哈,要不然可能出现权限问题)
下面是运行成功图(这个是后面截的,我修改了一部分):
项目文件作用 简单介绍一下各个文件的作用,官方给的是这个,但是咱们主要看code文件里面的东西
.
├── code 源代码的目录
│ ├── buffer
│ ├── config
│ ├── http
│ ├── log
│ ├── timer
│ ├── pool
│ ├── server
│ └── main.cpp
├── test 单元测试
│ ├── Makefile
│ └── test.cpp
├── resources 静态资源
│ ├── index.html
│ ├── image
│ ├── video
│ ├── js
│ └── css
├── bin 可执行文件
│ └── server
├── log 日志文件
├── webbench-1.5 压力测试
├── build
│ └── Makefile
├── Makefile
├── LICENSE
└── readme.md
code文件夹目录:
code/
├── buffer/
│ ├── buffer.cpp
│ ├── buffer.h 缓冲区的实现
│ └── readme.md
├── http/
│ ├── httpconn.cpp
│ ├── httpconn.h 综合httprequest与httpresponse
│ ├── httprequest.cpp
│ ├── httprequest.h 处理http请求
│ ├── httpresponse.cpp
│ ├── httpresponse.h 处理http响应
│ └── readme.md
├── log/
│ ├── blockqueue.h 阻塞队列(生产者消费者模式)
│ ├── log.cpp
│ ├── log.h 日志系统
│ └── readme.md
├── pool/
│ ├── readme.md
│ ├── sqlconnpool.cpp
│ ├── sqlconnpool.h mysql链接池
│ └── threadpool.h 线程池
├── server/
│ ├── epoller.cpp
│ ├── epoller.h 对epoll接口的封装
│ ├── readme.md
│ ├── webserver.cpp
│ └── webserver.h 最后的实现,综合了前面所有东西
├── timer/
│ ├── heap_timer.cpp
│ ├── heap_timer.h 小根堆计时器
│ └── readme.md
└── main.cpp 入口文件
下面就开始复现该项目,这个是别人的思路:
通过采用从局部到整体的设计思想。先使用单一线程完成串行的HTTP连接建立、HTTP消息处理和HTTP应答发送,然后围绕高并发这个核心扩展多个模块。
首先就是日志模块和缓冲区模块的一个设计,这里优先实现是为了下面各个模块的调试方便,记录各个模块运行的状况和打印输出模块运作情况来排除明显的BUG。
然后是引入I/O多路复用实现单线程下也能在一次系统调用中同时监听多个文件描述符,再进一步搭配线程池实现多客户多任务并行处理,这是高并发的核心部分。
在这个基础上做一点优化,在应用层实现了心跳机制,通过定时器实现非活跃连接的一个检测和中断,减少系统资源(内存)不必要的浪费。最后将所有模块整合起来,实现一个单Reactor多线程的网络设计模式,将多个模块串联起来实现完整高并发的服务器程序。
线程安全这块是通过不断将锁的粒度降低,平衡性能和安全。一开始采用粒度较粗的锁实现互斥(通常是整个操作),然后慢慢分析将一些不共享的代码移出临界区,慢慢调整慢慢优化。
最后加入数据库部分,因为这一部分比较独立,采用RAII机制创建连接池,拿出连接来使用。在HTTP中加入数据库信息验证的功能。
我确实水平有限,没有按上面的思路实现,我的实现方法是:过两遍
项目复现流程 建立复现的流程:
buffer 缓冲区的实现,这个和其不依赖其他模块,但是其他模块很依赖这个
log 日志系统,这个只依赖buffer模块,而且实现这个之后咱们就可以通过日志来调试了
pool 线程池和数据库连接池模块,这个模块比较独立,也是只依赖log和buffer
http 处理http请求与响应,依赖buffer log pool
内部建议实现顺序:
httprequest(http请求的处理)
httpresponse(根据http请求来生成响应)
httpconn(将上面两个连接在一起,包括请求的接收以及响应的发送等)
timer 小根堆计时器,是一个工具,只依赖log,还是因为调试才依赖的,很独立
server 最后的综合实现
内部建议实现顺序:
epoller 对IO多路复用技术 epoll进行了一个封装
webserver 最后对所有模块进行综合
项目复现 按照项目复现流程开始复现
因为我已经提前看了一遍代码了,确实不知道如何从0开始写介绍,所以针对每个模块,我主要介绍一下我当时闭环模糊的点
buffer 这是一个缓冲区模块模块,设计的非常的巧妙,通过两个指针来切分整个 vector ,我搜索的资料是这个设计的思路来源于 陈硕大佬的muduo网络库。
注意buffer的内容,要不然很容易搞迷糊,通过两个指针把buffer分成了三个区域
|————-A———|————B———–|———–C————-|
A区域:左侧是Buffer[0] 右侧是readPos_ 这里表示prependable区域(正常情况下应该是读区域和写区域,读区域最开始的index应该是0,数据读除去之后 readPos_++ 就形成了这样一个区域,所以这个区域被称为备用区域,后续可以用这个区域来扩容写空间)
B区域:左侧是readPos_ 右侧是writePos_ 这篇区域表示可以被读出去的区域
C区域:左侧是writePos_ 右侧是readPos_ 这片区域表示便是可以写进来的区域
那么我们模拟一下过程,假设一个buffer为1024这么大,那么初始的readPos_,writePos_均为0,现在进行模拟(记得在脑海里面想象一下过程)
现在有一个socketfd要往buffer里面写东西,咱们调用他的read函数,然后把得到东西写入buffer中,假设使用了64字节那么大 (现在就是字节存进取,writePos_后移动64个位置)(现在写区域还剩下1024-64)
服务器读取内容,假设第一次读取32个字节(假设哈,这里是模拟,不是真实的),那么readPos_向后移动32个位置,这个时候就形成了3个区域了,其中A区域,也就是0-31这些地方的内容已经被读出去了,不再需要了
现在服务器要把返回的东西写进来buffer,假设要写进来的东西是1024-64 + 16 比 还剩下的1024 - 64 多 16个字节,那么咱们就需要查找一下A区域的大小,咱们发现,A区域还有32个字节,可以支持,那么现在就需要合并一下A与C区域
合并方法,移动B区域,将B区域移动到最前面,readPos_变成 0 writePos_变成 B.size B区域的大小
如果发现合并区域之后还是不行的话,那么就要给buffer多分配空间了
代码(其实项目里面有,但是我这里加了注释可能比较清楚一些):
#ifndef BUFFER_H #define BUFFER_H #include <cstring> #include <iostream> #include <unistd.h> #include <sys/uio.h> #include <vector> #include <atomic> #include <assert.h> class Buffer {private : std::vector<char > buffer_; std::atomic<std::size_t > readPos_; std::atomic<std::size_t > writePos_; char * BeginPtr_ () ; const char * BeginPtr_ () const ; void MakeSpace_ (size_t len) ; public : Buffer (int initBuffSize = 1024 ); ~Buffer () = default ; size_t WritableBytes () const ; size_t ReadableBytes () const ; size_t PrependableBytes () const ; const char * Peek () const ; void EnsureWriteable (size_t len) ; void HasWritten (size_t len) ; void Retrieve (size_t len) ; void RetrieveUntil (const char * end) ; void RetrieveAll () ; std::string RetrieveAllToStr () ; const char * BeginWriteConst () const ; char * BeginWrite () ; void Append (const std::string& str) ; void Append (const char * str,size_t len) ; void Append (const void * data, size_t len) ; void Append (const Buffer& buff) ; ssize_t ReadFd (int fd, int * Errno) ; ssize_t WriteFd (int fd, int * Errno) ; }; #endif
#include "buffer.h" Buffer::Buffer (int initBuffSize):buffer_ (initBuffSize),readPos_ (0 ),writePos_ (0 ){ assert (initBuffSize > 0 ); } size_t Buffer::WritableBytes () const { return buffer_.size () - writePos_; } size_t Buffer::ReadableBytes () const { return writePos_ - readPos_; } size_t Buffer::PrependableBytes () const { return readPos_; } const char * Buffer::Peek () const { return &buffer_[readPos_]; } void Buffer::EnsureWriteable (size_t len) { if (len > WritableBytes ()){ MakeSpace_ (len); } assert (len < WritableBytes ()); } void Buffer::HasWritten (size_t len) { writePos_ += len; } void Buffer::Retrieve (size_t len) { readPos_ += len; } void Buffer::RetrieveUntil (const char * end) { assert (Peek () <= end); Retrieve (end - Peek ()); } void Buffer::RetrieveAll () { bzero (&buffer_[0 ], buffer_.size ()); readPos_ = writePos_ = 0 ; } std::string Buffer::RetrieveAllToStr () { std::string str (Peek(),ReadableBytes()) ; RetrieveAll (); return str; } const char * Buffer::BeginWriteConst () const { return &buffer_[writePos_]; } char * Buffer::BeginWrite () { return &buffer_[writePos_]; } void Buffer::Append (const char * str, size_t len) { assert (str); EnsureWriteable (len); std::copy (str, str + len, BeginWrite ()); HasWritten (len); } void Buffer::Append (const std::string& str) { Append (str.c_str (),str.size ()); } void Buffer::Append (const void * data, size_t len) { Append (static_cast <const char *>(data), len); } void Buffer::Append (const Buffer& buff) { Append (buff.Peek (), buff.ReadableBytes ()); } char * Buffer::BeginPtr_ () { return &buffer_[0 ]; } const char * Buffer::BeginPtr_ () const { return &buffer_[0 ]; } void Buffer::MakeSpace_ (size_t len) { if (WritableBytes () + PrependableBytes () < len){ buffer_.resize (writePos_ + len + 1 ); }else { size_t readable = ReadableBytes (); std::copy (BeginPtr_ () + readPos_, BeginPtr_ () + writePos_, BeginPtr_ ()); readPos_ = 0 ; writePos_ = readable; assert (readable == ReadableBytes ()); } } ssize_t Buffer::WriteFd (int fd,int * Errno) { ssize_t len = write (fd,Peek (),ReadableBytes ()); if (len < 0 ){ *Errno = errno; return len; } Retrieve (len); return len; } ssize_t Buffer::ReadFd (int fd,int * Errno) { char buff[65535 ]; struct iovec iov[2 ]; size_t writeable = WritableBytes (); iov[0 ].iov_base = BeginWrite (); iov[0 ].iov_len = writeable; iov[1 ].iov_base = buff; iov[1 ].iov_len = sizeof (buff); ssize_t len = readv (fd, iov, 2 ); if (len < 0 ){ *Errno = errno; }else if (static_cast <size_t >(len) <= writeable){ writePos_ += len; }else { writePos_ = buffer_.size (); Append (buff, static_cast <size_t >(len - writeable)); } return len; }
log 日志系统
1.使用单例模式来实现,目的是保证一个类只有一个实例,并提供一个他的全局访问点,该实例被所有程序模块共享。
2.异步日志,日志写入的时候需要写入文件,因此需要进行IO操作,IO操作如果放在主线程的话就会很大的阻塞线程,因此需要再创建一个线程来进行这个IO操作
3.日志的分级与分文件
Debug,调试代码时的输出,在系统实际运行时,一般不使用。
Warn,这种警告与调试时终端的warning类似,同样是调试代码时使用。
Info,报告系统当前的状态,当前执行的流程或接收的信息等。
Erro,输出系统的错误信息
4.分文件情况
按天分,日志写入前会判断当前today是否为创建日志的时间,若为创建日志时间,则写入日志,否则按当前时间创建新的log文件,更新创建时间和行数。
按行分,日志写入前会判断行数是否超过最大行限制,若超过,则在当前日志的末尾加lineCount / MAX_LOG_LINES为后缀创建新的log文件。
blockqueue 这是一个安全队列,咱们普通的队列在进行多线程操作的时候会出现互斥,必须得先加锁,再进行队列操作才可以,现在将这些加锁的操作封装到队列之中,这就是安全队列。在其他多线程的地方调用安全队列的方法的的时候不需要加锁,因为内部已经加锁了
代码:
#ifndef BLOCKQUEUE_H #define BLOCKQUEUE_H #include <deque> #include <condition_variable> #include <mutex> #include <sys/time.h> using namespace std;template <typename T>class BlockQueue {private : deque<T> deq_; mutex mtx_; bool isClose_; size_t capacity_; condition_variable condConsumer_; condition_variable condProducer_; public : explicit BlockQueue (size_t maxsize = 1000 ) ; ~BlockQueue (); bool empty () ; bool full () ; void push_back (const T& item) ; void push_front (const T& item) ; bool pop (T& item) ; bool pop (T& item, int timeout) ; void clear () ; T front () ; T back () ; size_t capacity () ; size_t size () ; void flush () ; void Close () ; }; template <typename T>BlockQueue<T>::BlockQueue (size_t maxsize):capacity_ (maxsize){ assert (maxsize > 0 ); isClose_ = false ; } template <typename T>BlockQueue<T>::~BlockQueue (){ Close (); } template <typename T>void BlockQueue<T>::clear (){ lock_guard<mutex> locker (mtx_) ; deq_.clear (); } template <typename T>void BlockQueue<T>::Close (){ clear (); isClose_ = true ; condConsumer_.notify_all (); condProducer_.notify_all (); } template <typename T>bool BlockQueue<T>::empty (){ lock_guard<mutex> locker (mtx_) ; return deq_.empty (); } template <typename T>bool BlockQueue<T>::full (){ lock_guard<mutex> locker (mtx_) ; return deq_.size () >= capacity_; } template <typename T>void BlockQueue<T>::push_back (const T& item){ unique_lock<mutex> locker (mtx_) ; condProducer_.wait (locker,[this ]{ return !(this ->deq_.size () >= this ->capacity_)||this ->isClose_; }); if (isClose_) { return ; } deq_.push_back (item); condConsumer_.notify_one (); } template <typename T>void BlockQueue<T>::push_front (const T& item){ unique_lock<mutex> locker (mtx_) ; condProducer_.wait (locker,[this ]{ return !(this ->deq_.size () >= this ->capacity_)||this ->isClose_; }); if (isClose_) { return ; } deq_.push_front (item); condConsumer_.notify_one (); } template <typename T>bool BlockQueue<T>::pop (T& item) { unique_lock<mutex> locker (mtx_) ; while (deq_.empty ()) { condConsumer_.wait (locker); if (isClose_){ return false ; } } item = deq_.front (); deq_.pop_front (); condProducer_.notify_one (); return true ; } template <typename T>bool BlockQueue<T>::pop (T &item, int timeout) { unique_lock<std::mutex> locker (mtx_) ; while (deq_.empty ()){ if (condConsumer_.wait_for (locker, std::chrono::seconds (timeout)) == std::cv_status::timeout){ return false ; } if (isClose_){ return false ; } } item = deq_.front (); deq_.pop_front (); condProducer_.notify_one (); return true ; } template <typename T>T BlockQueue<T>::front () { lock_guard<std::mutex> locker (mtx_) ; return deq_.front (); } template <typename T>T BlockQueue<T>::back () { lock_guard<std::mutex> locker (mtx_) ; return deq_.back (); } template <typename T>size_t BlockQueue<T>::capacity () { lock_guard<std::mutex> locker (mtx_) ; return capacity_; } template <typename T>size_t BlockQueue<T>::size () { lock_guard<std::mutex> locker (mtx_) ; return deq_.size (); } template <typename T>void BlockQueue<T>::flush () { condConsumer_.notify_one (); } #endif
log (下面是我复现的时候的一些想法)
日志系统对整个项目来说非常重要,涉及到一些错误调试操作
单例模式:确保整个程序中只有一个日志实例,在这样一个“庞大”的系统之中,无法避免的的要全局的访问日志系统,如果使用多个日志实例的话,那么就需要考虑很多很多的同步的问题,因此单例模式是最优解
这里要注意,这个项目是C++11实现的 C++11标准 (§6.7 [stmt.dcl] 第4段) 如果控制流在变量初始化时并发进入声明,并发线程必须等待初始化完成。 也就是说 静态局部变量 是线程安全的
同步与异步:这里又涉及到了同步与异步的概念,这个可以搜索了解一下,这里简单来说,就是:同步,在写入文件的时候直接在主线程里面写;异步:重新申请一个线程,在线程里面写,不会阻塞主线程
咱们来理一下整个流程
1.向外接口是 LOG_INFO (以LOG_INFO为例哈,其他的还有LOG_DEBUG啥的)看下面的宏定义。首先会获取实例,然后经过判断调用write
2.在写的时候先判断一下对应的文件(比如日期,比如文件的行数是不是超过最大的行数),如果有问题的话会重新生成一个新的日志文件
3.然后会组合日志的语句,存储到buffer之中。比如:一行日志:2025-04-11 10:58:46.397812 [info] : Client23 in, userCount:6 这里需要生成前面的2025-04-11 10:58:46.397812 [info]还要组合后面的内容
4.最后才是写进去,这里要注意,写的时候就涉及到同步和异步了
同步:直接在主线程里面写就行了,也就是说直接写,并不需要啥多余的操作,因此这里的IO操作就会对主线程造成阻塞
异步:通过申请一个新的线程来写。具体操作:设置一个阻塞队列,典型的生产者-消费者模型。咱们申请的线程会一直读取这个队列里面的的内容(也就是pop),如果为空的话就会线程就会阻塞,但是只要有值push进去的话,就会重新唤醒线程
因此,在异步的情况下,直接将buffer之中的内容push到队列之中就行了,push之后会唤醒线程从而执行写的操作
还有一个小点,就是可变参数,这个不解释其实也能的懂,但是这边还是记录一下把
正常的调用比如:LOG_INFO(“11111”) 这种是没有可变参数的
带有可变参数的比如:LOG_INFO(“%s,%s,%d”,str1,str2,int1) 这样就是有可变参数的
代码:
#ifndef LOG_H #define LOG_H #include <mutex> #include <string> #include <thread> #include <sys/time.h> #include <string.h> #include <stdarg.h> #include <assert.h> #include <sys/stat.h> #include "blockqueue.h" #include "../buffer/buffer.h" class Log {private : static const int LOG_PATH_LEN = 256 ; static const int LOG_NAME_LEN = 256 ; static const int MAX_LINES = 50000 ; const char * path_; const char * suffix_; int MAX_LINES_; int lineCount_; int toDay_; bool isOpen_; Buffer buff_; int level_; bool isAsync_; FILE* fp_; std::unique_ptr<BlockQueue<std::string>> deque_; std::unique_ptr<std::thread> writeThread_; std::mutex mtx_; Log (); void AppendLogLevelTitle_ (int level) ; virtual ~Log (); void AsyncWrite_ () ; public : void init (int level, const char * path = "./log" , const char * suffix = ".log" ,int maxQueueCapacity = 1024 ) ; static Log* Instance () ; static void FlushLogThread () ; void write (int level,const char * format,...) ; void flush () ; int GetLevel () ; void SetLevel (int level) ; bool IsOpen () { return isOpen_; } }; #define LOG_BASE(level, format, ...) \ do {\ Log* log = Log::Instance();\ if (log->IsOpen() && log->GetLevel() <= level) {\ log->write(level, format, ##__VA_ARGS__); \ log->flush();\ }\ } while(0); #define LOG_DEBUG(format, ...) do {LOG_BASE(0, format, ##__VA_ARGS__)} while(0); #define LOG_INFO(format, ...) do {LOG_BASE(1, format, ##__VA_ARGS__)} while(0); #define LOG_WARN(format, ...) do {LOG_BASE(2, format, ##__VA_ARGS__)} while(0); #define LOG_ERROR(format, ...) do {LOG_BASE(3, format, ##__VA_ARGS__)} while(0); #endif
#include "log.h" Log::Log (){ fp_ = nullptr ; deque_ = nullptr ; writeThread_ = nullptr ; lineCount_ = 0 ; toDay_ = 0 ; isAsync_ = false ; } Log::~Log (){ while (deque_->empty ()){ deque_->flush (); } deque_->Close (); if (writeThread_ && writeThread_->joinable ()){ writeThread_->join (); } if (fp_){ lock_guard<mutex> locker (mtx_) ; flush (); fclose (fp_); } } void Log::flush () { if (isAsync_){ deque_->flush (); } fflush (fp_); } Log* Log::Instance () { static Log log; return &log; } void Log::FlushLogThread () { Log::Instance ()->AsyncWrite_ (); } void Log::AsyncWrite_ () { string str = "" ; while (deque_->pop (str)){ lock_guard<mutex> locker (mtx_) ; fputs (str.c_str (), fp_); } } void Log::init (int level, const char * path, const char * suffix, int maxQueCapacity) { isOpen_ = true ; level_ = level; path_ = path; suffix_ = suffix; if (maxQueCapacity){ isAsync_ = true ; if (!deque_){ unique_ptr<BlockQueue<std::string>> newQue (new BlockQueue<std::string>); deque_ = move (newQue); unique_ptr<thread> newThread (new thread(FlushLogThread)) ; writeThread_ = move (newThread); } }else { isAsync_ = false ; } lineCount_ = 0 ; time_t timer = time (nullptr ); struct tm *sysTime = localtime (&timer); struct tm t = *sysTime; path_ = path; suffix_ = suffix; char fileName[LOG_NAME_LEN] = {0 }; snprintf (fileName, LOG_NAME_LEN - 1 , "%s/%04d_%02d_%02d%s" , path_, t.tm_year + 1900 , t.tm_mon + 1 , t.tm_mday, suffix_); toDay_ = t.tm_mday; { lock_guard<mutex> locker (mtx_) ; buff_.RetrieveAll (); if (fp_) { flush (); fclose (fp_); } fp_ = fopen (fileName, "a" ); if (fp_ == nullptr ){ mkdir (path_, 0777 ); fp_ = fopen (fileName, "a" ); } assert (fp_ != nullptr ); } } void Log::write (int level, const char *format, ...) { struct timeval now = {0 , 0 }; gettimeofday (&now, nullptr ); time_t tSec = now.tv_sec; struct tm *sysTime = localtime (&tSec); struct tm t = *sysTime; va_list vaList; if (toDay_ != t.tm_mday || (lineCount_ && (lineCount_ % MAX_LINES == 0 ))){ unique_lock<mutex> locker (mtx_) ; locker.unlock (); char newFile[LOG_NAME_LEN]; char tail[36 ] = {0 }; snprintf (tail, 36 , "%04d_%02d_%02d" , t.tm_year + 1900 , t.tm_mon + 1 , t.tm_mday); if (toDay_!=t.tm_mday){ snprintf (newFile, LOG_NAME_LEN - 72 , "%s/%s%s" , path_, tail, suffix_); toDay_ = t.tm_mday; lineCount_ = 0 ; }else { snprintf (newFile, LOG_NAME_LEN - 72 , "%s/%s-%d%s" , path_, tail, (lineCount_ / MAX_LINES), suffix_); } locker.lock (); flush (); fclose (fp_); fp_ = fopen (newFile, "a" ); assert (fp_ != nullptr ); } { unique_lock<mutex> locker (mtx_) ; lineCount_++; int n = snprintf (buff_.BeginWrite (), 128 , "%d-%02d-%02d %02d:%02d:%02d.%06ld " , t.tm_year + 1900 , t.tm_mon + 1 , t.tm_mday, t.tm_hour, t.tm_min, t.tm_sec, now.tv_usec); buff_.HasWritten (n); AppendLogLevelTitle_ (level); va_start (vaList, format); int m = vsnprintf (buff_.BeginWrite (), buff_.WritableBytes (), format, vaList); va_end (vaList); buff_.HasWritten (m); buff_.Append ("\n\0" , 2 ); if (isAsync_ && deque_ && !deque_->full ()){ deque_->push_back (buff_.RetrieveAllToStr ()); }else { fputs (buff_.Peek (), fp_); } buff_.RetrieveAll (); } } void Log::AppendLogLevelTitle_ (int level) { switch (level) { case 0 : buff_.Append ("[debug]: " , 9 ); break ; case 1 : buff_.Append ("[info] : " , 9 ); break ; case 2 : buff_.Append ("[warn] : " , 9 ); break ; case 3 : buff_.Append ("[error]: " , 9 ); break ; default : buff_.Append ("[info] : " , 9 ); break ; } } int Log::GetLevel () { lock_guard<mutex> locker (mtx_) ; return level_; } void Log::SetLevel (int level) { lock_guard<mutex> locker (mtx_) ; level_ = level; }
pool 1.线程池
我在博客 https://blog.zqzhang2025.com/2025/04/13/ThreadPool/ 里面也做了一些介绍,现在再介绍一下
线程池是一种用于优化线程管理和任务调度的并发编程机制,其核心在于复用线程、控制资源使用,并提升系统性能。说白了就是,为了防止这么构造线程的浪费太多的时间,先把线程创建好,放在那等着,有任务提交的话就直接处理,省去了构造线程的过程
2.数据库连接池
这里仿照了线程池的一些思路
服务器在运行的时候肯定不只一个用户连接,每个用户连接的时候都需要进行数据库操作,在连接的时候要对数据库连接进行初始化,那样就非常的耗时,因此引入了 数据库连接池
先进行数据库连接,把连接好的标识放入一个池子里面(这里是queue),然后用户来的时候只需要把这些连接好的标识非配给他就好了
因此:数据库连接池就需要使用 单例模式 了,为什么?
整个系统就只需要一个池子,不需要多个池子,也就是说不需要多个实例
如果想要更多的池子的话,把的第一个池子扩大一些不就好了吗
因此单例模式是最为合适的
3.RAII
这个就是说,由系统来管理资源的申请和释放。智能指针就是用这个来思路来实现的,咱们不需要手动释放资源了,系统会帮忙管理。
再说白了:其实就是再单独设计一个class,用来申请资源,比如在构造的时候new 一个 地址空间,在析构的时候delete一下,设计一个向外的接口,可以获取这个地址空间的指针。那么咱们在用的时候就不需要考虑这个地址空间的释放问题了,因为随着这个class生命周期的结束,就会自动释放这个空间。
这个可以看一下智能指针的实现的思路就理解了
我在这里介绍了 https://blog.zqzhang2025.com/2025/04/13/C++pointer/
threadpool 这里的实现比较简单了,因为task只需要接收void类型的函数,就是说没有返回值的函数,我之前实现的theadpool(https://blog.zqzhang2025.com/2025/04/13/ThreadPool/)是综合考虑到返回值和可变参数等问题的
这里的比较简单
#ifndef THREADPOOL_H #define THREADPOOL_H #include <queue> #include <mutex> #include <condition_variable> #include <functional> #include <thread> #include <assert.h> class ThreadPool {private : struct Pool { std::mutex mtx_; std::condition_variable cond_; bool isClosed; std::queue<std::function<void ()>> tasks; }; std::shared_ptr<Pool> pool_; public : ThreadPool () = default ; ThreadPool (ThreadPool&&) = default ; explicit ThreadPool (int threadCount = 8 ) : pool_(std::make_shared<Pool>()) { assert (threadCount > 0 ); for (int i=0 ;i<threadCount;i++){ std::thread ([this ]{ std::unique_lock<std::mutex> locker (pool_->mtx_); while (true ){ if (!pool_->tasks.empty ()){ auto task = std::move (pool_->tasks.front ()); pool_->tasks.pop (); locker.unlock (); task (); locker.lock (); }else if (pool_->isClosed){ break ; }else { pool_->cond_.wait (locker); } } }).detach (); } } ~ThreadPool () { if (pool_){ std::unique_lock<std::mutex> locker (pool_->mtx_) ; pool_->isClosed = true ; } pool_->cond_.notify_all (); } template <typename T> void AddTask (T&& task) { std::unique_lock<std::mutex> locker (pool_->mtx_) ; pool_->tasks.emplace (std::forward<T>(task)); pool_->cond_.notify_one (); } }; #endif
sqlconnpool 在看这个之前记得先了解一下C++数据库的基本操作哈,其实就是几个函数,挺简单了,去搜一下大概10分钟就业能了解了。
数据库的连接 数据库的查询 查询结果获取 数据库的插入 等
#ifndef SQLCONNPOOL_H #define SQLCONNPOOL_H #include <mysql/mysql.h> #include <string> #include <queue> #include <mutex> #include <semaphore.h> #include <thread> #include "../log/log.h" class SqlConnPool {private : int MAX_CONN_; std::queue<MYSQL* > connQue_; std::mutex mtx_; sem_t semId_; SqlConnPool () = default ; ~SqlConnPool () { ClosePool (); } public : static SqlConnPool* Instance () ; MYSQL* GetConn () ; void FreeConn (MYSQL* conn) ; int GetFreeConnCount () ; void Init (const char * host, uint16_t port, const char * user,const char * pwd, const char * dbName, int connSize) ; void ClosePool () ; }; class SqlConnRAII {private : MYSQL *sql_; SqlConnPool* connpool_; public : SqlConnRAII (MYSQL** sql, SqlConnPool *connpool){ assert (connpool); *sql = connpool->GetConn (); sql_ = *sql; connpool_ = connpool; } ~SqlConnRAII () { if (sql_) { connpool_->FreeConn (sql_); } } }; #endif
#include "sqlconnpool.h" SqlConnPool* SqlConnPool::Instance () { static SqlConnPool pool; return &pool; } void SqlConnPool::Init (const char * host, uint16_t port, const char * user,const char * pwd, const char * dbName, int connSize = 10 ) { assert (connSize > 0 ); for (int i=0 ;i<connSize;i++){ MYSQL* conn = nullptr ; conn = mysql_init (conn); if (!conn){ LOG_ERROR ("MySql init error!" ); assert (conn); } conn = mysql_real_connect (conn, host, user, pwd, dbName, port, nullptr , 0 ); if (!conn){ LOG_ERROR ("MySql Connect error!" ); } connQue_.emplace (conn); } MAX_CONN_ = connSize; sem_init (&semId_, 0 , MAX_CONN_); } MYSQL* SqlConnPool::GetConn () { MYSQL* conn = nullptr ; if (connQue_.empty ()){ LOG_WARN ("SqlConnPool busy!" ); return nullptr ; } sem_wait (&semId_); lock_guard<mutex> locker (mtx_) ; conn = connQue_.front (); connQue_.pop (); return conn; } void SqlConnPool::FreeConn (MYSQL* conn) { assert (conn); lock_guard<mutex> locker (mtx_) ; connQue_.push (conn); sem_post (&semId_); } void SqlConnPool::ClosePool () { lock_guard<mutex> locker (mtx_) ; while (!connQue_.empty ()) { auto conn = connQue_.front (); connQue_.pop (); mysql_close (conn); } mysql_library_end (); } int SqlConnPool::GetFreeConnCount () { lock_guard<mutex> locker (mtx_) ; return connQue_.size (); }
http 这里主要是针对HTTP的一些操作,注意学习一下HTTP报本的一些基本结构,包括请求报文以及响应报文
我在 https://blog.zqzhang2025.com/2025/04/13/tinyhttpd/ 里面做了一些基本介绍
在这里再说一下 报文结构
那么我们举个例子:
以下是百度的请求包
> GET / HTTP/1.1\r\n
> Accept:text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,/;q=0.8,application/signed-exchange;v=b3;q=0.9\r\n
> Accept-Encoding: gzip, deflate, br\r\n
> Accept-Language: zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6\r\n
> Connection: keep-alive\r\n
> Host: www.baidu.com\r\n
> Sec-Fetch-Dest: document\r\n
> Sec-Fetch-Mode: navigate\r\n
> Sec-Fetch-Site: none\r\n
> Sec-Fetch-User: ?1\r\n
> Upgrade-Insecure-Requests: 1\r\n
> User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/101.0.4951.41 Safari/537.36 Edg/101.0.1210.32\r\n
> sec-ch-ua: " Not A;Brand";v=“99”, “Chromium”;v=“101”, “Microsoft Edge”;v=“101”\r\n
> sec-ch-ua-mobile: ?0\r\n
> sec-ch-ua-platform: “Windows”\r\n
上面只包括请求行、请求头和空行,请求数据为空。请求方法是GET,协议版本是HTTP/1.1;请求头是键值对的形式。(注意哈,上面的\r\n是我自己添加的哈,去掉才是正常的,我添加上只是为了表示,每一行的结尾是\r\n)
响应报文:
HTTP/1.1 200 OK\r\n Date: Fri, 22 May 2009 06:07:21 GMT\r\n Content-Type: text/html; charset=UTF-8\r\n \r\n <html > <head > </head > <body > </body > </html >
httprequest 这个class主要是针对http请求的,所以看这部分代码之前务必要先了解HTTP请求,了解请求报文的基本结构
注意这里不做逻辑处理哈,比如一个get请求一个页面,这里只是找到这个html,不会返回给客户,只有post的登陆和注册会在这做逻辑处理,但是也是只处理,相当于处理出来一个结果,保存下来。到这里就这个class的任务就结束了,剩下的响应就交给httpresponse了
这里有个很重要的概念:有限状态机
我们再来看一下请求报文。第一行为请求行,往后为请求头,再后面就是请求数据了
因此一般是按照顺序来解析这个请求报文
先解析请求行 获取 请求方法,版本号 以及请求的url等
再解析请求头
最后看有没有请求体,有的话就需要解析了
因此这个有限状态机就是这样的:
来看一下代码吧:
#ifndef HTTP_REQUEST_H #define HTTP_REQUEST_H #include <unordered_map> #include <unordered_set> #include <string> #include <regex> #include <errno.h> #include <mysql/mysql.h> #include "../buffer/buffer.h" #include "../log/log.h" #include "../pool/sqlconnpool.h" class HttpRequest {public : enum PARSE_STATE { REQUEST_LINE, HEADERS, BODY, FINISH, }; HttpRequest () { Init (); } ~HttpRequest () = default ; void Init () ; bool parse (Buffer& buff) ; std::string path () const ; std::string& path () ; std::string method () const ; std::string version () const ; std::string GetPost (const std::string& key) const ; std::string GetPost (const char * key) const ; bool IsKeepAlive () const ; private : bool ParseRequestLine_ (const std::string& line) ; void ParseHeader_ (const std::string& line) ; void ParseBody_ (const std::string& line) ; void ParsePath_ () ; void ParsePost_ () ; void ParseFromUrlencoded_ () ; static bool UserVerify (const std::string& name, const std::string& pwd, bool isLogin) ; PARSE_STATE state_; std::string method_,path_,version_,body_; std::unordered_map<std::string, std::string> header_; std::unordered_map<std::string, std::string> post_; static const std::unordered_set<std::string> DEFAULT_HTML; static const std::unordered_map<std::string, int > DEFAULT_HTML_TAG; static int ConverHex (char ch) ; }; #endif
#include "httprequest.h" using namespace std;const unordered_set<string> HttpRequest::DEFAULT_HTML { "/index" , "/register" , "/login" , "/welcome" , "/video" , "/picture" , }; const unordered_map<string, int > HttpRequest::DEFAULT_HTML_TAG { {"/login.html" , 1 }, {"/register.html" , 0 } }; void HttpRequest::Init () { state_ = REQUEST_LINE; method_ = path_ = version_= body_ = "" ; header_.clear (); post_.clear (); } bool HttpRequest::parse (Buffer& buff) { const char END[] = "\r\n" ; if (buff.ReadableBytes () == 0 ){ return false ; } while (buff.ReadableBytes () && state_!= FINISH){ const char * lineend = search (buff.Peek (), buff.BeginWriteConst (), END, END+2 ); string line (buff.Peek(),lineend) ; switch (state_){ case REQUEST_LINE:{ bool res = ParseRequestLine_ (line); if (!res){ return false ; } ParsePath_ (); break ; } case HEADERS:{ ParseHeader_ (line); if (buff.ReadableBytes () <= 2 ) { state_ = FINISH; } break ; } case BODY:{ ParseBody_ (line); break ; } default :{ break ; } } if (lineend == buff.BeginWrite ()) { buff.RetrieveAll (); break ; } buff.RetrieveUntil (lineend + 2 ); } LOG_DEBUG ("[%s], [%s], [%s]" , method_.c_str (), path_.c_str (), version_.c_str ()); return true ; } bool HttpRequest::ParseRequestLine_ (const string& line) { regex patten ("^([^ ]*) ([^ ]*) HTTP/([^ ]*)$" ) ; smatch Match; if (regex_match (line, Match, patten)) { method_ = Match[1 ]; path_ = Match[2 ]; version_ = Match[3 ]; state_ = HEADERS; return true ; } LOG_ERROR ("RequestLine Error" ); return false ; } void HttpRequest::ParsePath_ () { if (path_ == "/" ) { path_ = "/index.html" ; } else { if (DEFAULT_HTML.find (path_) != DEFAULT_HTML.end ()) { path_ += ".html" ; } } } void HttpRequest::ParseHeader_ (const string& line) { regex patten ("^([^:]*): ?(.*)$" ) ; smatch Match; if (regex_match (line, Match, patten)) { header_[Match[1 ]] = Match[2 ]; } else { state_ = BODY; } } void HttpRequest::ParseBody_ (const string& line) { body_ = line; ParsePost_ (); state_ = FINISH; LOG_DEBUG ("Body:%s, len:%d" , line.c_str (), line.size ()); } int HttpRequest::ConverHex (char ch) { if (ch >= 'A' && ch <= 'F' ) return ch - 'A' + 10 ; if (ch >= 'a' && ch <= 'f' ) return ch - 'a' + 10 ; if (ch >= '0' && ch <= '9' ) return ch - '0' ; return 0 ; } void HttpRequest::ParsePost_ () { if (method_ == "POST" && header_["Content-Type" ] == "application/x-www-form-urlencoded" ) { ParseFromUrlencoded_ (); if (DEFAULT_HTML_TAG.count (path_)){ int tag = DEFAULT_HTML_TAG.find (path_)->second; LOG_DEBUG ("Tag:%d" , tag); if (tag==0 ||tag==1 ){ bool isLogin = (tag == 1 ); if (UserVerify (post_["username" ], post_["password" ], isLogin)) { path_ = "/welcome.html" ; } else { path_ = "/error.html" ; } } } } } void HttpRequest::ParseFromUrlencoded_ () { if (body_.size () == 0 ) { return ; } string key, value; int num = 0 ; int n = body_.size (); int i = 0 , j = 0 ; for (; i < n; i++) { char ch = body_[i]; switch (ch) { case '=' : key = body_.substr (j, i - j); j = i + 1 ; break ; case '+' : body_[i] = ' ' ; break ; case '%' : num = ConverHex (body_[i + 1 ]) * 16 + ConverHex (body_[i + 2 ]); body_[i + 2 ] = num % 10 + '0' ; body_[i + 1 ] = num / 10 + '0' ; i += 2 ; break ; case '&' : value = body_.substr (j, i - j); j = i + 1 ; post_[key] = value; LOG_DEBUG ("%s = %s" , key.c_str (), value.c_str ()); break ; default : break ; } } assert (j <= i); if (post_.count (key) == 0 && j < i) { value = body_.substr (j, i - j); post_[key] = value; } } bool HttpRequest::UserVerify (const string &name, const string &pwd, bool isLogin) { if (name == "" || pwd == "" ) { return false ; } LOG_INFO ("Verify name:%s pwd:%s" , name.c_str (), pwd.c_str ()); MYSQL* sql; SqlConnRAII (&sql, SqlConnPool::Instance ()); assert (sql); bool flag = false ; unsigned int j = 0 ; char order[256 ] = { 0 }; MYSQL_FIELD *fields = nullptr ; MYSQL_RES *res = nullptr ; if (!isLogin){ flag = true ; } snprintf (order, 256 , "SELECT username, password FROM user WHERE username='%s' LIMIT 1" , name.c_str ()); LOG_DEBUG ("%s" , order); if (mysql_query (sql,order)){ mysql_free_result (res); return false ; } res = mysql_store_result (sql); j = mysql_num_fields (res); fields = mysql_fetch_fields (res); while (MYSQL_ROW row = mysql_fetch_row (res)){ LOG_DEBUG ("MYSQL ROW: %s %s" , row[0 ], row[1 ]); string password (row[1 ]) ; if (isLogin){ if (pwd == password) { flag = true ; }else { flag = false ; LOG_INFO ("pwd error!" ); } }else { flag = false ; LOG_INFO ("user used!" ); } } mysql_free_result (res); if (!isLogin && flag == true ) { LOG_DEBUG ("regirster!" ); bzero (order, 256 ); snprintf (order, 256 ,"INSERT INTO user(username, password) VALUES('%s','%s')" , name.c_str (), pwd.c_str ()); LOG_DEBUG ( "%s" , order); if (mysql_query (sql, order)) { LOG_DEBUG ( "Insert error!" ); flag = false ; } flag = true ; } LOG_DEBUG ( "UserVerify success!!" ); return flag; } std::string HttpRequest::path () const { return path_; } std::string& HttpRequest::path () { return path_; } std::string HttpRequest::method () const { return method_; } std::string HttpRequest::version () const { return version_; } std::string HttpRequest::GetPost (const std::string& key) const { assert (key != "" ); if (post_.count (key) == 1 ) { return post_.find (key)->second; } return "" ; } std::string HttpRequest::GetPost (const char * key) const { assert (key != nullptr ); if (post_.count (key) == 1 ) { return post_.find (key)->second; } return "" ; } bool HttpRequest::IsKeepAlive () const { if (header_.count ("Connection" ) == 1 ) { return header_.find ("Connection" )->second == "keep-alive" && version_ == "1.1" ; } return false ; }
httpresponse 这里就很常规了,和之前的httprequest类似,主要是组合响应报文,并不会将响应报文返回给客户端,这里一定要注意一下响应报文的格式
这里一定要注意,组合响应报文,并不会将响应报文返回给客户端
我举个例子:
比如httprequest解析了一个GET请求的,请求的是一个index.html的文件
那么httpresponse任务就是组合响应报文,比如响应报文的状态行 响应头 以及 添加响应内容
其中涉及到一些判断,比如状态行,如果发现没有index.html那个就是404 如果发现有但是咱们没有操作的权限就是403等。
这里会把状态行以及响应头放在一个buffer里面方便后面httpconn取
响应内容 是一个文件,那么就会把这个文件放到内存映射里面,这里不会放在buffer里面哈,毕竟文件很大,就是映射到内存,方便后面httpconn取
简单介绍一下内存映射,比如一个文件里面存储abcd,内存映射就是把文件映射到内存里面,分配一个连续的空间存储abcd,给一个首地址,指向a,那么后续咱们要拿就直接操作这个地址就行了。
这个就是简单理解哈,想要搞懂可以取网上搜一下,有很多的介绍。
代码:
#ifndef HTTP_RESPONSE_H #define HTTP_RESPONSE_H #include <unordered_map> #include <fcntl.h> #include <unistd.h> #include <sys/stat.h> #include <sys/mman.h> #include "../buffer/buffer.h" #include "../log/log.h" class HttpResponse {private : int code_; bool isKeepAlive_; std::string path_; std::string srcDir_; char * mmFile_; struct stat mmFileStat_; static const std::unordered_map<std::string, std::string> SUFFIX_TYPE; static const std::unordered_map<int , std::string> CODE_STATUS; static const std::unordered_map<int , std::string> CODE_PATH; void AddStateLine_ (Buffer &buff) ; void AddHeader_ (Buffer &buff) ; void AddContent_ (Buffer &buff) ; void ErrorHtml_ () ; std::string GetFileType_ () ; public : HttpResponse (); ~HttpResponse (); void Init (const std::string& srcDir, std::string& path, bool isKeepAlive = false , int code = -1 ) ; void MakeResponse (Buffer& buff) ; char * File () ; void UnmapFile () ; size_t FileLen () const ; void ErrorContent (Buffer& buff, std::string message) ; int Code () const { return code_; } }; #endif
#include "httpresponse.h" using namespace std;const unordered_map<string, string> HttpResponse::SUFFIX_TYPE = { { ".html" , "text/html" }, { ".xml" , "text/xml" }, { ".xhtml" , "application/xhtml+xml" }, { ".txt" , "text/plain" }, { ".rtf" , "application/rtf" }, { ".pdf" , "application/pdf" }, { ".word" , "application/nsword" }, { ".png" , "image/png" }, { ".gif" , "image/gif" }, { ".jpg" , "image/jpeg" }, { ".jpeg" , "image/jpeg" }, { ".au" , "audio/basic" }, { ".mpeg" , "video/mpeg" }, { ".mpg" , "video/mpeg" }, { ".avi" , "video/x-msvideo" }, { ".gz" , "application/x-gzip" }, { ".tar" , "application/x-tar" }, { ".css" , "text/css " }, { ".js" , "text/javascript " }, }; const unordered_map<int , string> HttpResponse::CODE_STATUS = { { 200 , "OK" }, { 400 , "Bad Request" }, { 403 , "Forbidden" }, { 404 , "Not Found" }, }; const unordered_map<int , string> HttpResponse::CODE_PATH = { { 400 , "/400.html" }, { 403 , "/403.html" }, { 404 , "/404.html" }, }; HttpResponse::HttpResponse () { code_ = -1 ; path_ = srcDir_ = "" ; isKeepAlive_ = false ; mmFile_ = nullptr ; mmFileStat_ = { 0 }; }; HttpResponse::~HttpResponse () { UnmapFile (); } void HttpResponse::Init (const string& srcDir, string& path, bool isKeepAlive, int code) { assert (srcDir != "" ); if (mmFile_){ UnmapFile (); } code_ = code; isKeepAlive_ = isKeepAlive; path_ = path; srcDir_ = srcDir; mmFile_ = nullptr ; mmFileStat_ = { 0 }; } void HttpResponse::MakeResponse (Buffer& buff) { if (stat ((srcDir_ + path_).data (), &mmFileStat_) < 0 || S_ISDIR (mmFileStat_.st_mode)) { code_ = 404 ; }else if (!(mmFileStat_.st_mode & S_IROTH)){ code_ = 403 ; }else if (code_ == -1 ){ code_ = 200 ; } ErrorHtml_ (); AddStateLine_ (buff); AddHeader_ (buff); AddContent_ (buff); } char * HttpResponse::File () { return mmFile_; } size_t HttpResponse::FileLen () const { return mmFileStat_.st_size; } void HttpResponse::ErrorHtml_ () { if (CODE_PATH.count (code_) == 1 ) { path_ = CODE_PATH.find (code_)->second; stat ((srcDir_ + path_).data (), &mmFileStat_); } } void HttpResponse::AddStateLine_ (Buffer& buff) { string status; if (CODE_STATUS.count (code_) == 1 ) { status = CODE_STATUS.find (code_)->second; }else { code_ = 400 ; status = CODE_STATUS.find (400 )->second; } buff.Append ("HTTP/1.1 " + to_string (code_) + " " + status + "\r\n" ); } void HttpResponse::AddHeader_ (Buffer& buff) { buff.Append ("Connection: " ); if (isKeepAlive_){ buff.Append ("keep-alive\r\n" ); buff.Append ("keep-alive: max=6, timeout=120\r\n" ); }else { buff.Append ("close\r\n" ); } buff.Append ("Content-type: " + GetFileType_ () + "\r\n" ); } void HttpResponse::AddContent_ (Buffer& buff) { int srcFd = open ((srcDir_ + path_).data (), O_RDONLY); if (srcFd < 0 ) { ErrorContent (buff, "File NotFound!" ); return ; } LOG_DEBUG ("file path %s" , (srcDir_ + path_).data ()); int * mmRet = (int *)mmap (0 , mmFileStat_.st_size, PROT_READ, MAP_PRIVATE, srcFd, 0 ); if (*mmRet == -1 ) { ErrorContent (buff, "File NotFound!" ); return ; } mmFile_ = (char *)mmRet; close (srcFd); buff.Append ("Content-length: " + to_string (mmFileStat_.st_size) + "\r\n\r\n" ); } void HttpResponse::UnmapFile () { if (mmFile_) { munmap (mmFile_, mmFileStat_.st_size); mmFile_ = nullptr ; } } string HttpResponse::GetFileType_ () { string::size_type idx = path_.find_last_of ('.' ); if (idx == string::npos) { return "text/plain" ; } string suffix = path_.substr (idx); if (SUFFIX_TYPE.count (suffix)==1 ){ return SUFFIX_TYPE.find (suffix)->second; } return "text/plain" ; } void HttpResponse::ErrorContent (Buffer& buff, string message) { string body; string status; body += "<html><title>Error</title>" ; body += "<body bgcolor=\"ffffff\">" ; if (CODE_STATUS.count (code_) == 1 ) { status = CODE_STATUS.find (code_)->second; } else { status = "Bad Request" ; } body += to_string (code_) + " : " + status + "\n" ; body += "<p>" + message + "</p>" ; body += "<hr><em>TinyWebServer</em></body></html>" ; buff.Append ("Content-length: " + to_string (body.size ()) + "\r\n\r\n" ); buff.Append (body); }
httpconn 这里是综合了前面 httprequest 与 httpresponse
什么意思呢?就是向外面提供一些接口,这个类里面会存储客户端的文件描述符(fd),以及相应的客户端的地址(sockaddr_in),里面实现了读fd内容的操作,向fd里面写的操作,等。
httprequest 负责解析 请求内容
httpresponse 负责生成 响应内容
整体大概的流程:
相当于将 请求内容 读取到 readBuff_ 之中 然后 httprequest 负责解析 后面 httpresponse 负责生成响应的内容,写到 writeBuff_ 里面 然后再写到 fd 里面
1.这里简单介绍一下ET和LT,简单解释一下:
相当于通知我们有数据来了
LT模式下如果缓冲区里面有数据就会一直不停的通知,一直到咱们把缓冲区里面的数据全部读完才会停止通知。因此LT模式下支持分次读或者写,因为如果咱们一次没读完,后面还会不停的继续通知咱们。这样虽然有效,但是确实会浪费系统资源,因为要不停通知
ET模式下只有缓冲区里面来数据了才会通知一次,也只会通知一次。因此,咱们在进行读写的时候必须要保证一次性读完才可以。
代码:
#ifndef HTTP_CONN_H #define HTTP_CONN_H #include <sys/types.h> #include <sys/uio.h> #include <arpa/inet.h> #include <stdlib.h> #include <errno.h> #include "../log/log.h" #include "../buffer/buffer.h" #include "httprequest.h" #include "httpresponse.h" class HttpConn {private : int fd_; struct sockaddr_in addr_; bool isClose_; int iovCnt_; struct iovec iov_[2 ]; Buffer readBuff_; Buffer writeBuff_; HttpRequest request_; HttpResponse response_; public : HttpConn (); ~HttpConn (); void init (int sockFd, const sockaddr_in& addr) ; ssize_t read (int * saveErrno) ; ssize_t write (int * saveErrno) ; void Close () ; int GetFd () const ; int GetPort () const ; const char * GetIP () const ; sockaddr_in GetAddr () const ; bool process () ; int ToWriteBytes () { return iov_[0 ].iov_len + iov_[1 ].iov_len; } bool IsKeepAlive () const { return request_.IsKeepAlive (); } static bool isET; static const char * srcDir; static std::atomic<int > userCount; }; #endif
#include "httpconn.h" using namespace std;const char * HttpConn::srcDir; std::atomic<int > HttpConn::userCount; bool HttpConn::isET; HttpConn::HttpConn () { fd_ = -1 ; addr_ = { 0 }; isClose_ = true ; } HttpConn::~HttpConn () { Close (); }; void HttpConn::init (int fd, const sockaddr_in& addr) { assert (fd > 0 ); userCount++; addr_ = addr; fd_ = fd; writeBuff_.RetrieveAll (); readBuff_.RetrieveAll (); isClose_ = false ; LOG_INFO ("Client[%d](%s:%d) in, userCount:%d" , fd_, GetIP (), GetPort (), (int )userCount); } void HttpConn::Close () { response_.UnmapFile (); if (isClose_ == false ){ isClose_ = true ; userCount--; close (fd_); LOG_INFO ("Client[%d](%s:%d) quit, UserCount:%d" , fd_, GetIP (), GetPort (), (int )userCount); } } int HttpConn::GetFd () const { return fd_; }; struct sockaddr_in HttpConn::GetAddr () const { return addr_; } const char * HttpConn::GetIP () const { return inet_ntoa (addr_.sin_addr); } int HttpConn::GetPort () const { return addr_.sin_port; } ssize_t HttpConn::read (int * saveErrno) { ssize_t len = -1 ; do { len = readBuff_.ReadFd (fd_, saveErrno); if (len <= 0 ) { break ; } }while (isET); return len; } ssize_t HttpConn::write (int * saveErrno) { ssize_t len = -1 ; do { len = writev (fd_,iov_,iovCnt_); if (len <= 0 ) { *saveErrno = errno; break ; } if (iov_[0 ].iov_len + iov_[1 ].iov_len == 0 ) { break ; }else if (static_cast <size_t >(len) > iov_[0 ].iov_len){ iov_[1 ].iov_base = (uint8_t *) iov_[1 ].iov_base + (len - iov_[0 ].iov_len); iov_[1 ].iov_len -= (len - iov_[0 ].iov_len); if (iov_[0 ].iov_len) { writeBuff_.RetrieveAll (); iov_[0 ].iov_len = 0 ; } }else { iov_[0 ].iov_base = (uint8_t *)iov_[0 ].iov_base + len; iov_[0 ].iov_len -= len; writeBuff_.Retrieve (len); } }while (isET || ToWriteBytes () > 10240 ); return len; } bool HttpConn::process () { request_.Init (); if (readBuff_.ReadableBytes () <= 0 ) { return false ; }else if (request_.parse (readBuff_)){ LOG_DEBUG ("%s" , request_.path ().c_str ()); response_.Init (srcDir, request_.path (), request_.IsKeepAlive (), 200 ); }else { response_.Init (srcDir, request_.path (), false , 400 ); } response_.MakeResponse (writeBuff_); iov_[0 ].iov_base = const_cast <char *>(writeBuff_.Peek ()); iov_[0 ].iov_len = writeBuff_.ReadableBytes (); iovCnt_ = 1 ; if (response_.FileLen () > 0 && response_.File ()) { iov_[1 ].iov_base = response_.File (); iov_[1 ].iov_len = response_.FileLen (); iovCnt_ = 2 ; } LOG_DEBUG ("filesize:%d, %d to %d" , response_.FileLen () , iovCnt_, ToWriteBytes ()); return true ; }
timer 先说一下为什么要有这个,有些socket连接很长事件不进行操作,还白白占用一个连接,这就很不合理,因此设置一个计时器,当超过一定时间之后就断开连接
通过小顶堆来实现,放在最前面的永远是最早就要超时的,对于超时的就调用回调函数来处理(一般情况下这里的回调函数就是断开连接了)
时钟操作,小根堆的实现,小根堆的下沉与上浮,小根堆的删除与添加
下面是代码:(务必注意一下回调函数是怎么搞的以及回调函数是什么时候调用的)
#ifndef HEAP_TIMER_H #define HEAP_TIMER_H #include <queue> #include <unordered_map> #include <time.h> #include <algorithm> #include <arpa/inet.h> #include <functional> #include <assert.h> #include <chrono> #include "../log/log.h" typedef std::function<void ()> TimeoutCallBack;typedef std::chrono::high_resolution_clock Clock;typedef std::chrono::milliseconds MS;typedef Clock::time_point TimeStamp;struct TimerNode { int id; TimeStamp expires; TimeoutCallBack cb; bool operator <(const TimerNode& t) { return expires < t.expires; } bool operator >(const TimerNode& t) { return expires > t.expires; } }; class HeapTimer {private : std::vector<TimerNode> heap_; std::unordered_map<int , size_t > ref_; void del_ (size_t i) ; void siftup_ (size_t i) ; bool siftdown_ (size_t i, size_t n) ; void SwapNode_ (size_t i, size_t j) ; public : HeapTimer () { heap_.reserve (64 ); } ~HeapTimer () { clear (); } void adjust (int id, int newExpires) ; void add (int id, int timeOut, const TimeoutCallBack& cb) ; void doWork (int id) ; void clear () ; void tick () ; void pop () ; int GetNextTick () ; }; #endif
#include "heaptimer.h" void HeapTimer::SwapNode_ (size_t i, size_t j) { assert (i >= 0 && i <heap_.size ()); assert (j >= 0 && j <heap_.size ()); swap (heap_[i], heap_[j]); ref_[heap_[i].id] = i; ref_[heap_[j].id] = j; } void HeapTimer::siftup_ (size_t i) { assert (i >= 0 && i < heap_.size ()); int parent = (i-1 )/2 ; while (parent >= 0 ){ if (heap_[parent] > heap_[i]){ SwapNode_ (i, parent); i = parent; parent = (i-1 )/2 ; }else { break ; } } } bool HeapTimer::siftdown_ (size_t i, size_t n) { assert (i >= 0 && i < heap_.size ()); assert (n >= 0 && n <= heap_.size ()); auto index = i; auto child = 2 * index + 1 ; while (child < n){ if (child+1 < n && heap_[child+1 ] < heap_[child]) { child++; } if (heap_[child] < heap_[index]) { SwapNode_ (index, child); index = child; child = 2 * child + 1 ; }else { break ; } } return index > i; } void HeapTimer::del_ (size_t index) { assert (index >= 0 && index < heap_.size ()); size_t tmp = index; size_t n = heap_.size () - 1 ; assert (tmp <= n); if (index < heap_.size ()-1 ){ SwapNode_ (tmp, heap_.size ()-1 ); if (!siftdown_ (tmp, n)){ siftup_ (tmp); } } ref_.erase (heap_.back ().id); heap_.pop_back (); } void HeapTimer::adjust (int id, int newExpires) { assert (!heap_.empty () && ref_.count (id)); heap_[ref_[id]].expires = Clock::now () + MS (newExpires); size_t i = ref_[id]; if (!siftdown_ (i, heap_.size ())) { siftup_ (i); } } void HeapTimer::add (int id, int timeOut, const TimeoutCallBack& cb) { assert (id >= 0 ); if (ref_.count (id)){ int tmp = ref_[id]; heap_[tmp].expires = Clock::now () + MS (timeOut); heap_[tmp].cb = cb; if (!siftdown_ (tmp, heap_.size ())) { siftup_ (tmp); } }else { size_t n = heap_.size (); ref_[id] = n; heap_.push_back ({id, Clock::now () + MS (timeOut), cb}); siftup_ (n); } } void HeapTimer::doWork (int id) { if (heap_.empty () || ref_.count (id) == 0 ) { return ; } size_t i = ref_[id]; auto node = heap_[i]; node.cb (); del_ (i); } void HeapTimer::tick () { if (heap_.empty ()) { return ; } while (!heap_.empty ()){ TimerNode node = heap_.front (); if (std::chrono::duration_cast <MS>(node.expires - Clock::now ()).count () > 0 ) { break ; } node.cb (); pop (); } } void HeapTimer::pop () { assert (!heap_.empty ()); del_ (0 ); } void HeapTimer::clear () { ref_.clear (); heap_.clear (); } int HeapTimer::GetNextTick () { tick (); int res = -1 ; if (!heap_.empty ()) { res = std::chrono::duration_cast <MS>(heap_.front ().expires - Clock::now ()).count (); if (res < 0 ) { res = 0 ; } } return res; }
server 这里就是最后把所有的模块综合在一起的地方了
epoller 这里要注意IO多路复用的概念
我在 https://blog.zqzhang2025.com/2025/04/15/chatRoom/ 做了一些基本的介绍
这里就说一下为什么要有这个技术
socket 中的许多操作会阻塞进程,比如服务端的accept 还有两端的 recv 等操作,如果要接受多个客户端,就不能阻塞进程。
有一种方法就是 多线程操作,每个线程接收一个,但是这种方法会严重的浪费内存空间,高并发的程序中 上千个客户端,就需要申请上千个线程
而且每一次切换线程 可能会需要 进行上下文操作,这就会很大的限制运行速度
因此引入了IO多路复用的技术,相当于咱们把咱们的socket连接之后的文件描述符交给epoll托管,每当有事件发生的时候,epoll就会向咱们发送通知,咱们可以获取那些发生事件的fd,从而执行操作。
(这里还是建议先学习一下IO多路复用的概念,以及epoll的基本操作,学习完之后就会发现这个epoller特别简单,就是把epoll的命令封装一下)
#ifndef EPOLLER_H #define EPOLLER_H #include <sys/epoll.h> #include <unistd.h> #include <assert.h> #include <vector> #include <errno.h> class Epoller {public : explicit Epoller (int maxEvent = 1024 ) ; ~Epoller (); bool AddFd (int fd, uint32_t events) ; bool ModFd (int fd, uint32_t events) ; bool DelFd (int fd) ; int Wait (int timeoutMs = -1 ) ; int GetEventFd (size_t i) const ; uint32_t GetEvents (size_t i) const ; private : int epollFd_; std::vector<struct epoll_event> events_; }; #endif
#include "epoller.h" Epoller::Epoller (int maxEvent):epollFd_ (epoll_create (512 )), events_ (maxEvent){ assert (epollFd_ >= 0 && events_.size () > 0 ); } Epoller::~Epoller () { close (epollFd_); } bool Epoller::AddFd (int fd, uint32_t events) { if (fd < 0 ) return false ; epoll_event ev = {0 }; ev.data.fd = fd; ev.events = events; return 0 == epoll_ctl (epollFd_, EPOLL_CTL_ADD, fd, &ev); } bool Epoller::ModFd (int fd, uint32_t events) { if (fd < 0 ) return false ; epoll_event ev = {0 }; ev.data.fd = fd; ev.events = events; return 0 == epoll_ctl (epollFd_, EPOLL_CTL_MOD, fd, &ev); } bool Epoller::DelFd (int fd) { if (fd < 0 ) return false ; return 0 == epoll_ctl (epollFd_, EPOLL_CTL_DEL, fd, 0 ); } int Epoller::Wait (int timeoutMs) { return epoll_wait (epollFd_, &events_[0 ], static_cast <int >(events_.size ()), timeoutMs); } int Epoller::GetEventFd (size_t i) const { assert (i < events_.size () && i >= 0 ); return events_[i].data.fd; } uint32_t Epoller::GetEvents (size_t i) const { assert (i < events_.size () && i >= 0 ); return events_[i].events; }
webserver 最后的所有模块的封装,前面咱们已经把所有的功能实现了,这里就吧前面所有的功能组合在一起,拼接成咱们的服务器。
初始化任务:
初始化日志系统(log模块)
初始化数据库连接池 (pool/sqlconnpool模块)
辅助功能的初始化(HeapTimer,ThreadPool,Epoller)
开启服务器,socket套接字创建,绑定,监听
获取资源的位置(resources文件夹的位置)
服务器运行的函数(Start函数流程,也就是那个主循环的流程)
开始的时候先让timer tick一下并获取下一次超时的时间,tick会这里就是处理那些长时间不干活的client
调用epoller的Wait,设置超时事件为上面获取的下一次超时的时间(这里挺巧妙的,可以想一下为啥)
后面正常执行的话,就会获取那些就绪client的fd,然后分事件执行就好
如果是服务器收到消息,那么一定是来新的连接了 那么 accept 一下,添加新客户就好
如果是异常事件就直接断开连接就好
如果是读事件就 为线程池添加读任务,线程池会自动分配线程来执行读任务
如果是写事件就 为线程池添加读任务,线程池会自动分配线程来执行写任务
这里要注意读写的整体的过程,也就是webserver设计的核心:
(这段来源于 https://blog.csdn.net/weixin_51322383/article/details/130545172 )
浏览器向服务器发出request的时候,epoll会接收到EPOLL_IN读事件,此时调用OnRead去解析,将fd(浏览器)的request内容放到读缓冲区,并且把响应报文写到写缓冲区,这个时候调用OnProcess()是为了把该事件变为EPOLL_OUT,让epoll下一次检测到写事件,把写缓冲区的内容写到fd。当EPOLL_OUT写完后,整个流程就结束了,此时需要再次把他置回原来的EPOLL_IN去检测新的读事件到来。
理解了这段话就理解了整个webserver的设计
这里我还是想再解释解释epoll的机制,虽然在 https://blog.zqzhang2025.com/2025/04/15/chatRoom/ 里面已经解释过了,这里就当成补充吧。
1.socket收发信息
这个大家应该都知道,我再废话说一遍
对于socket中的recv和send 这些函数,都有响应的缓冲区
比如:recv,外面的数据来了,会先放在内核之中的读缓冲区里面,然后咱们调用recv才能拿到数据
相应的send的时候,咱们会把数据发送到内核的写缓冲区里面,然后内核会调用网络操作把东西给发出去
2.epoll的触发
前面也提到了epoll的触发有两种模式:水平触发(LT)与边缘触发(ET)这里就详细的解释一下怎么触发的
水平触发(LT)
对于读(EPOLLIN)事件:当内核之中的读缓冲区存在数据的时候,就会一直触发,就是不停的向用户通知,还有东西可以读。
对于写(EPOLLOUT)事件:当内核之中的写缓冲区还可以写,也就是说写缓冲区还没满的时候就会不停的触发
所以对于LT,并不会要求一次必须要把事情处理完,比如读的话,如果这次没读完,下次还可以继续读,因为epoll会一直通知我们读事件
边缘触发(ET)
对于读(EPOLLIN)事件:内核之中的读缓冲区状态变化的时候才会触发,比如:有新的数据来了,就会触发一次,但也只会触发这一次,如果这次你读不完数据也没办法,后续也不会触发。
对于写(EPOLLOUT)事件:内核之中的写缓冲区状态变化的时候才会触发,这个状态变化指的是 不可写->可写,就是缓冲区满->缓冲区不满。如果缓冲区满了的话会出现errno事件,事件为EAGAIN。
对于写(EPOLLOUT)事件:还有另外一种情况,就是第一次绑定EPOLLOUT事件的时候会触发,这个时候写缓冲区肯定是可写的,因此先触发一次,让用户先写。
这样的话就很好理解了吧
再把上面那句话拷贝下来看一看,是不是就理解了
浏览器向服务器发出request的时候,epoll会接收到EPOLL_IN读事件,此时调用OnRead去解析,将fd(浏览器)的request内容放到读缓冲区(这里不是内核里面的哈,指的是咱们写的那个buffer),并且把响应报文写到写缓冲区这里不是内核里面的哈,指的是咱们写的那个buffer),这个时候调用OnProcess()是为了把该事件变为EPOLL_OUT,让epoll下一次检测到写事件,把写缓冲区的内容写到fd。当EPOLL_OUT写完后,整个流程就结束了,此时需要再次把他置回原来的EPOLL_IN去检测新的读事件到来。
代码:
#ifndef HTTP_CONN_H #define HTTP_CONN_H #include <sys/types.h> #include <sys/uio.h> #include <arpa/inet.h> #include <stdlib.h> #include <errno.h> #include "../log/log.h" #include "../buffer/buffer.h" #include "httprequest.h" #include "httpresponse.h" class HttpConn {private : int fd_; struct sockaddr_in addr_; bool isClose_; int iovCnt_; struct iovec iov_[2 ]; Buffer readBuff_; Buffer writeBuff_; HttpRequest request_; HttpResponse response_; public : HttpConn (); ~HttpConn (); void init (int sockFd, const sockaddr_in& addr) ; ssize_t read (int * saveErrno) ; ssize_t write (int * saveErrno) ; void Close () ; int GetFd () const ; int GetPort () const ; const char * GetIP () const ; sockaddr_in GetAddr () const ; bool process () ; int ToWriteBytes () { return iov_[0 ].iov_len + iov_[1 ].iov_len; } bool IsKeepAlive () const { return request_.IsKeepAlive (); } static bool isET; static const char * srcDir; static std::atomic<int > userCount; }; #endif
#include "httpconn.h" using namespace std;const char * HttpConn::srcDir; std::atomic<int > HttpConn::userCount; bool HttpConn::isET; HttpConn::HttpConn () { fd_ = -1 ; addr_ = { 0 }; isClose_ = true ; } HttpConn::~HttpConn () { Close (); }; void HttpConn::init (int fd, const sockaddr_in& addr) { assert (fd > 0 ); userCount++; addr_ = addr; fd_ = fd; writeBuff_.RetrieveAll (); readBuff_.RetrieveAll (); isClose_ = false ; LOG_INFO ("Client[%d](%s:%d) in, userCount:%d" , fd_, GetIP (), GetPort (), (int )userCount); } void HttpConn::Close () { response_.UnmapFile (); if (isClose_ == false ){ isClose_ = true ; userCount--; close (fd_); LOG_INFO ("Client[%d](%s:%d) quit, UserCount:%d" , fd_, GetIP (), GetPort (), (int )userCount); } } int HttpConn::GetFd () const { return fd_; }; struct sockaddr_in HttpConn::GetAddr () const { return addr_; } const char * HttpConn::GetIP () const { return inet_ntoa (addr_.sin_addr); } int HttpConn::GetPort () const { return addr_.sin_port; } ssize_t HttpConn::read (int * saveErrno) { ssize_t len = -1 ; do { len = readBuff_.ReadFd (fd_, saveErrno); if (len <= 0 ) { break ; } }while (isET); return len; } ssize_t HttpConn::write (int * saveErrno) { ssize_t len = -1 ; do { len = writev (fd_,iov_,iovCnt_); if (len <= 0 ) { *saveErrno = errno; break ; } if (iov_[0 ].iov_len + iov_[1 ].iov_len == 0 ) { break ; }else if (static_cast <size_t >(len) > iov_[0 ].iov_len){ iov_[1 ].iov_base = (uint8_t *) iov_[1 ].iov_base + (len - iov_[0 ].iov_len); iov_[1 ].iov_len -= (len - iov_[0 ].iov_len); if (iov_[0 ].iov_len) { writeBuff_.RetrieveAll (); iov_[0 ].iov_len = 0 ; } }else { iov_[0 ].iov_base = (uint8_t *)iov_[0 ].iov_base + len; iov_[0 ].iov_len -= len; writeBuff_.Retrieve (len); } }while (isET || ToWriteBytes () > 10240 ); return len; } bool HttpConn::process () { request_.Init (); if (readBuff_.ReadableBytes () <= 0 ) { return false ; }else if (request_.parse (readBuff_)){ LOG_DEBUG ("%s" , request_.path ().c_str ()); response_.Init (srcDir, request_.path (), request_.IsKeepAlive (), 200 ); }else { response_.Init (srcDir, request_.path (), false , 400 ); } response_.MakeResponse (writeBuff_); iov_[0 ].iov_base = const_cast <char *>(writeBuff_.Peek ()); iov_[0 ].iov_len = writeBuff_.ReadableBytes (); iovCnt_ = 1 ; if (response_.FileLen () > 0 && response_.File ()) { iov_[1 ].iov_base = response_.File (); iov_[1 ].iov_len = response_.FileLen (); iovCnt_ = 2 ; } LOG_DEBUG ("filesize:%d, %d to %d" , response_.FileLen () , iovCnt_, ToWriteBytes ()); return true ; }
github链接 我平时比较喜欢用cmake,感觉这个比较方便一些,因此就整了两个版本,一个是cmake版本一个是makefile版本(原来的是makefile版本)
cmake 版本运行:https://github.com/zqzhang2023/zzqStudy/tree/main/project/5_tinyWebServer
(刚开始记得先把build里面东西给删除掉)
cd build
cmake ..
make
cd ../
sudo ./main
http://127.0.0.1:1316/
makefile版本:https://github.com/zqzhang2023/zzqStudy/tree/main/project/5_TinyWebServer_makefile
make
sudo ../bin/server
参考: https://blog.csdn.net/weixin_51322383/article/details/130464403
https://blog.csdn.net/qq_44184756/article/details/130140778
友情链接 我自己的学习用的github仓库:
https://github.com/zqzhang2023/zzqStudy
我的个人博客:
https://blog.zqzhang2025.com/
结束 里面有很多都是我自己的理解,作为初学者,文章里面肯定会有一些错误。大家看的时候记得加上自己的理解去看,如果发现我那里理解有问题,非常欢迎大家能够指出来,咱们共同进步。非常感谢大家。