实战项目(上):设计一个高性能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倍。嗯,血的教训。

下面是整体架构图:

高性能Web服务器架构图 主Reactor (EventLoop) 新连接 新连接 任务队列 工作线程1 工作线程2 工作线程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)来优化性能。不过对于初学者,先用互斥锁+条件变量实现,理解原理后再优化也不迟。

九、总结:把碎片拼成整体

好了,核心模块都讲完了。你可能会觉得,每个模块单独看都不难,但怎么把它们拼起来?其实很简单:

  1. 启动EventLoop,创建监听socket,注册到Poller。
  2. 新连接到来时,创建Connection对象,绑定Channel。
  3. Connection收到数据后,调用HttpParser解析。
  4. 解析完成后,Router根据路径分发到处理函数。
  5. 处理函数(比如读文件)通过线程池异步执行。
  6. 结果写回Connection的输出缓冲区,触发可写事件发送。

这个流程我画过无数遍,每次给新人讲都这么画。你把它理解成一个流水线:数据从网卡进来,经过解析、路由、处理,再返回给客户端。每个环节都是独立的,互不干扰。

下一章,我们会把代码跑起来,做压力测试,看看性能到底怎么样。还会讨论一些优化技巧,比如零拷贝、连接复用、超时处理等。嗯,到时候你会发现,真正的挑战才刚刚开始。


公众号:蓝海资料掘金营,微信deep3321