实战项目(上):设计一个高性能Web服务器——需求分析、架构设计、模块划分、核心代码实现
终于到了实战环节。说实话,前面讲了那么多网络编程的理论、IO模型、内存管理,都是为了这一刻。我个人习惯是,学完一个知识点,必须亲手做个东西把它串起来。否则你学再多,过两个月也就忘光了。
这一章,我们开始动手做一个真正的高性能Web服务器。不是那种玩具级的“Hello World”服务器,而是能扛住高并发、支持静态文件服务、具备基础路由能力的生产级原型。嗯,我在公司内部做技术分享时,经常拿这个项目当案例,效果还不错。
一、需求分析:我们要做什么?
先别急着写代码。你想想看,如果连需求都没搞清楚,写出来的东西大概率是废的。我见过太多人上来就撸代码,最后发现方向偏了,又得重来。
我们这个Web服务器,核心需求其实就几条:
- 支持HTTP/1.1协议:能解析GET、POST请求,返回对应的响应。
- 静态文件服务:用户访问
/index.html,服务器能从磁盘读取并返回。 - 高并发能力:使用Reactor模型 + 非阻塞IO,支撑数千并发连接。
- 基础路由:能根据URL路径分发到不同的处理逻辑。
- 日志记录:记录请求日志,方便排查问题。
说白了,这就是一个简化版的Nginx。当然,我们不会做到Nginx那么复杂,但核心思想是一样的。
核心目标:用C++实现一个基于Reactor模式、支持高并发的轻量级Web服务器。
二、架构设计:选什么模型?
架构设计是灵魂。选错了模型,后面再怎么优化也白搭。我个人比较推崇的是单线程Reactor + 线程池的组合。为什么?
- 单线程Reactor负责监听事件、分发请求。它不处理业务逻辑,只做调度。
- 线程池负责处理实际的业务逻辑,比如解析HTTP请求、读取文件、构造响应。
这样做的好处很明显:Reactor线程不会阻塞,能快速响应新连接。而耗时的IO操作(比如读磁盘)交给线程池,不影响主循环。
我曾经在一个项目中用过纯多线程模型,每个连接一个线程。结果连接数一上去,线程切换开销直接让CPU飙到100%。后来改成Reactor + 线程池,同样的硬件,吞吐量翻了3倍。嗯,血的教训。
下面是整体架构图:
三、模块划分:拆解成可落地的部分
架构定好了,接下来就是模块划分。我习惯把项目拆成几个独立的模块,每个模块职责单一,方便测试和维护。下面是我们的模块列表:
| 模块名 | 职责 | 核心类/文件 |
|---|---|---|
| 网络模块 | 封装epoll/kqueue,管理事件循环 | EventLoop, Channel, Poller |
| 连接模块 | 管理TCP连接,读写缓冲区 | Connection, Buffer |
| HTTP模块 | 解析HTTP请求,构造HTTP响应 | HttpRequest, HttpResponse, HttpParser |
| 路由模块 | 根据URL路径分发到处理函数 | Router, Handler |
| 线程池模块 | 管理工作线程,执行耗时任务 | ThreadPool, Task |
| 日志模块 | 记录请求日志和错误日志 | Logger |
每个模块的代码量控制在200-500行之间。太少了说明功能没做全,太多了说明职责不单一。这是我多年总结的经验。
四、核心代码实现:从EventLoop开始
好,我们开始写代码。先从最底层的EventLoop开始。它是整个服务器的发动机,负责循环监听事件并分发。
// EventLoop.h
#pragma once
#include <vector>
#include <memory>
#include <functional>
class Channel;
class Poller;
class EventLoop {
public:
EventLoop();
~EventLoop();
void loop(); // 启动事件循环
void quit(); // 退出事件循环
void updateChannel(Channel* ch); // 注册/更新事件
void removeChannel(Channel* ch); // 移除事件
private:
std::unique_ptr<Poller> poller_;
bool quit_;
};
EventLoop的核心就是那个 loop() 方法。它内部会调用 poller->poll() 获取就绪事件,然后逐个调用 Channel 的回调函数。说白了,就是一个死循环,不断问操作系统:“有事件没?有的话告诉我。”
小提示:EventLoop的 loop() 必须在创建它的线程中调用。这是Reactor模型的基本约束——一个EventLoop绑定一个线程。
接下来是Channel。它是对文件描述符(fd)和事件的封装。每个Channel持有一个fd,以及该fd感兴趣的事件类型(比如可读、可写)。
// Channel.h
#pragma once
#include <functional>
class EventLoop;
class Channel {
public:
Channel(EventLoop* loop, int fd);
~Channel();
void handleEvent(); // 处理就绪事件
void setReadCallback(std::function<void()> cb);
void setWriteCallback(std::function<void()> cb);
void enableReading(); // 开启可读事件监听
void enableWriting(); // 开启可写事件监听
int fd() const { return fd_; }
int events() const { return events_; }
void setRevents(int revents) { revents_ = revents; }
private:
EventLoop* loop_;
int fd_;
int events_; // 感兴趣的事件
int revents_; // 实际发生的事件
std::function<void()> readCallback_;
std::function<void()> writeCallback_;
};
Channel本身不调用epoll,它只是数据的载体。真正的epoll操作在Poller里。Poller负责把Channel注册到epoll实例,并在事件发生时把就绪的Channel返回给EventLoop。
// Poller.h
#pragma once
#include <vector>
#include <map>
#include <sys/epoll.h>
class Channel;
class Poller {
public:
Poller();
~Poller();
void updateChannel(Channel* ch);
void removeChannel(Channel* ch);
std::vector<Channel*> poll(int timeoutMs);
private:
int epollFd_;
std::vector<struct epoll_event> events_;
std::map<int, Channel*> channels_;
};
这里有个细节:poll() 返回的是 std::vector<Channel*>,而不是原始的epoll_event。这样做的好处是,上层代码不用关心epoll的细节,拿到Channel直接调用 handleEvent() 就行。
注意:epoll的 EPOLLET(边缘触发)模式需要小心使用。我曾在项目中因为没处理好ET模式下的数据读取,导致丢包。建议新手先用水平触发(LT),稳定后再考虑ET优化。
五、连接管理:Connection与Buffer
有了EventLoop和Channel,接下来就是管理具体的TCP连接了。每个连接对应一个Connection对象,它内部封装了socket fd、输入输出缓冲区。
// Connection.h
#pragma once
#include <memory>
#include <functional>
#include "Buffer.h"
#include "Channel.h"
class EventLoop;
class Connection : public std::enable_shared_from_this<Connection> {
public:
Connection(EventLoop* loop, int fd);
~Connection();
void send(const std::string& msg); // 发送数据
void close(); // 关闭连接
void setMessageCallback(std::function<void(const std::string&)> cb);
private:
void handleRead(); // 可读事件回调
void handleWrite(); // 可写事件回调
EventLoop* loop_;
std::unique_ptr<Channel> channel_;
Buffer inputBuffer_;
Buffer outputBuffer_;
std::function<void(const std::string&)> messageCallback_;
};
Buffer的设计也很关键。不能每次读写都动态分配内存,那样性能太差。我一般用vector作为底层存储,预留一定的空间,避免频繁扩容。
// Buffer.h
#pragma once
#include <vector>
#include <string>
class Buffer {
public:
Buffer() : buffer_(kInitialSize), readIndex_(0), writeIndex_(0) {}
size_t readableBytes() const { return writeIndex_ - readIndex_; }
size_t writableBytes() const { return buffer_.size() - writeIndex_; }
void append(const std::string& data);
std::string retrieveAsString(size_t len);
void retrieveAll();
char* beginWrite() { return &buffer_[writeIndex_]; }
const char* beginRead() const { return &buffer_[readIndex_]; }
private:
static const size_t kInitialSize = 1024;
std::vector<char> buffer_;
size_t readIndex_;
size_t writeIndex_;
};
Buffer用两个指针(readIndex_和writeIndex_)来管理读写位置,而不是频繁地移动数据。只有当可读空间不足时,才做一次整理。这种设计在muduo库中也有体现,实践证明非常高效。
六、HTTP解析:从字节流到结构化请求
连接层搞定了,接下来就是HTTP协议解析。这部分其实挺繁琐的,因为HTTP协议有很多细节:请求行、头部字段、空行、消息体……
我建议不要自己从头写解析器,容易踩坑。可以用状态机的方式,逐字节解析。下面是一个简化的状态机示例:
enum class HttpParseState {
kParseRequestLine,
kParseHeaders,
kParseBody,
kParseDone
};
class HttpParser {
public:
HttpParseState parse(const std::string& data);
private:
HttpParseState state_;
std::string method_;
std::string path_;
std::string version_;
std::map<std::string, std::string> headers_;
std::string body_;
};
状态机的核心逻辑是:从 kParseRequestLine 开始,读到换行符后切换到 kParseHeaders,再读到空行后切换到 kParseBody,最后到 kParseDone。每个状态只做一件事,逻辑清晰,不容易出错。
关键点:HTTP解析一定要处理粘包和半包问题。TCP是流式协议,一次recv可能只收到半个请求,也可能收到多个请求。所以解析器必须能保存中间状态,等数据到齐了再继续解析。
七、路由与静态文件服务
路由模块负责把URL映射到处理函数。最简单的实现就是一个map:
class Router {
public:
using Handler = std::function<std::string(const HttpRequest&)>;
void addRoute(const std::string& path, Handler handler);
std::string dispatch(const HttpRequest& req);
private:
std::map<std::string, Handler> routes_;
};
静态文件服务是路由的一种特殊形式。当用户访问 /static/index.html 时,服务器从磁盘读取文件并返回。这里要注意的是,读取文件是阻塞操作,必须放到线程池中执行,不能阻塞EventLoop。
// 静态文件处理函数
std::string serveStaticFile(const std::string& filePath) {
std::ifstream file(filePath, std::ios::binary);
if (!file) {
return "HTTP/1.1 404 Not Found\r\n\r\n";
}
std::string content((std::istreambuf_iterator<char>(file)),
std::istreambuf_iterator<char>());
return "HTTP/1.1 200 OK\r\nContent-Length: " +
std::to_string(content.size()) + "\r\n\r\n" + content;
}
嗯,这里有个坑:文件路径一定要做安全检查,防止路径穿越攻击。比如用户请求 /../../../etc/passwd,你不能真的去读那个文件。我一般会把请求路径限制在某个根目录下,超出就返回403。
八、线程池:别让主线程卡住
最后是线程池。它的实现其实不复杂,核心就是一个任务队列 + 一组工作线程。工作线程从队列里取任务执行,执行完继续取下一个。
class ThreadPool {
public:
ThreadPool(size_t numThreads);
~ThreadPool();
void start();
void stop();
void addTask(std::function<void()> task);
private:
void workerLoop();
std::vector<std::thread> threads_;
std::queue<std::function<void()>> tasks_;
std::mutex mutex_;
std::condition_variable cond_;
bool running_;
};
线程池的大小怎么定?我一般根据CPU核心数来设置,通常是 std::thread::hardware_concurrency()。如果任务主要是IO密集型(比如读文件),可以适当增加线程数。如果是CPU密集型,线程数等于核心数就够了。
经验之谈:线程池的任务队列最好用无锁队列(比如moodycamel::ConcurrentQueue)来优化性能。不过对于初学者,先用互斥锁+条件变量实现,理解原理后再优化也不迟。
九、总结:把碎片拼成整体
好了,核心模块都讲完了。你可能会觉得,每个模块单独看都不难,但怎么把它们拼起来?其实很简单:
- 启动EventLoop,创建监听socket,注册到Poller。
- 新连接到来时,创建Connection对象,绑定Channel。
- Connection收到数据后,调用HttpParser解析。
- 解析完成后,Router根据路径分发到处理函数。
- 处理函数(比如读文件)通过线程池异步执行。
- 结果写回Connection的输出缓冲区,触发可写事件发送。
这个流程我画过无数遍,每次给新人讲都这么画。你把它理解成一个流水线:数据从网卡进来,经过解析、路由、处理,再返回给客户端。每个环节都是独立的,互不干扰。
下一章,我们会把代码跑起来,做压力测试,看看性能到底怎么样。还会讨论一些优化技巧,比如零拷贝、连接复用、超时处理等。嗯,到时候你会发现,真正的挑战才刚刚开始。
公众号:蓝海资料掘金营,微信deep3321