用 AI Agent 一步步写一个 C++ Linux 文件服务器:FastStore M0~M6-C 总结

#C++#Linux#epoll#HTTP#FastStore#AI Agent

FastStore 是我用来学习 Linux 网络编程和 C++ 工程实践的一个项目。它现在仍然只是一个基础版本的 C++17 Linux HTTP 文件服务器,没有完整网盘产品所需要的账号、权限、目录管理和分布式存储,也没有刻意包装成“生产级系统”。对我来说,它更重要的价值是提供了一条足够真实的主线:一个连接怎样进入服务器,一个请求怎样被增量解析,一段文件数据怎样在非阻塞 I/O 中向前流动,异常发生后资源又怎样被正确回收。

在开始这个项目以前,我已经接触过 socket、epoll、HTTP、RAII 等概念,但“知道一个概念”和“让多个概念在同一个长期运行的程序里正确协作”差别很大。书上的示例通常集中解释一个 API,项目则会迫使我面对跨事件状态、部分读写、文件系统边界、错误恢复和测试设计。FastStore 从 M0 演进到 M6-C 的过程,就是我把这些零散知识逐渐连接起来的过程。

这也是一次 AI Agent 协作开发的实验。我让 Agent 参与具体实现、测试和阶段报告,但没有把架构边界、验收标准和代码理解一起外包出去。很多时候,Agent 能很快生成“看起来完整”的代码,而真正需要我投入精力的,恰恰是确认状态是否闭合、异常路径是否安全、测试是否真的覆盖了我关心的问题。

1. 为什么开始 FastStore

我最初并不是想做一个功能齐全的网盘,而是想找一个能够持续承载 Linux 网络编程知识的练习载体。只写 echo server 时,连接建立、读取、写回都很短,很多工程问题不会暴露出来。一旦加入 HTTP 和文件传输,请求可能被拆成多个 TCP 片段,响应可能无法一次发送完,上传中途可能断开,下载速度也可能受到客户端接收能力的限制。程序必须记住“上一轮做到哪里”,下一次事件到来时才能继续。

这个项目因此同时覆盖了网络、协议、文件 I/O 和 C++ 资源管理。我希望完成的转变,是从“能看懂 Linux 网络编程书籍里的流程”走到“能实现一个可运行、可测试、能从错误中恢复的 Server”。这里的重点不是代码量,而是每一个阶段都能回答几个问题:状态属于谁,资源由谁关闭,操作只完成一部分时保存什么,外部输入不可信时在哪一层拒绝,以及如何证明实现没有只在理想路径上工作。

2. M0:先搭项目骨架,而不是直接写 main.cpp

M0 没有急着进入 socket API,而是先建立 CMake 和基本目录:include/ 放公开接口,src/ 放实现,tests/ 放测试,data/ 作为文件服务的受控根目录。对一个小项目来说,这些结构看上去有些“提前”,但它很快就体现了价值。Parser、Router、Connection、FileService 各自有了明确位置,测试也不需要和服务器入口混在一起。

过去做练习时,我经常把所有逻辑塞进一个 main.cpp,因为这样启动最快。但单文件方便的是第一天,而不是第十天。随着功能增加,如果模块边界没有先出现,后面很容易把网络事件、协议判断和业务操作写成一段无法单独验证的流程。M0 给我的第一个提醒是:工程结构不是为了让目录看起来专业,而是为了限制依赖方向,让后续每一次修改都更容易定位。

CMake 在这里也不只是“把文件编译起来”。它让主程序和测试共享同一组核心实现,并为 Debug、Release 等构建方式提供统一入口。后来做 robustness test 和 benchmark 时,这个早期决定避免了许多临时编译命令,也让我第一次更认真地把项目当成一个持续演进的工程,而不是一次性的代码片段。

3. M1:non-blocking 与 epoll

M1 建立了最基本的事件循环,主链路可以概括为:

socket
  ↓
bind
  ↓
listen
  ↓
epoll_create1
  ↓
epoll_wait
  ↓
accept4 / recv

这一阶段最重要的不是记住函数参数,而是区分 listening socket 与 client socket。前者代表服务器的监听入口,可读事件意味着可能有连接等待接受;后者代表一条已经建立的通信连接,可读事件意味着内核接收缓冲区中可能有数据、对端可能关闭,或者连接出现错误。它们都能被 epoll 观察,但事件到来后的处理完全不同。

我以前对 non-blocking 的直觉是“调用不会卡住”,这没有错,却不够完整。更准确的理解是:一次操作不保证完成我想完成的全部工作。accept4() 可能连续取出多个已完成连接,所以监听 fd 就绪后要循环 accept,直到返回 EAGAIN;recv() 也要持续读取当前能够立即取得的数据,直到暂时无数据可读。

EAGAIN 不是业务完成,也不是致命错误。它表示当前非阻塞 I/O 暂时无法继续,本轮立即可处理的数据已经耗尽。此时应用程序要保存已有状态,回到事件循环,等待下一次就绪通知。这个语义后来贯穿了请求接收、响应发送和文件下载。

epoll 本身也不会替应用程序读取数据。EPOLLIN 只是在说“这个 fd 当前有可读条件”,真正的 accept、recv、错误判断和状态推进仍由应用程序完成。我逐渐把两者的关系理解为:

epoll:现在有事情可以做
应用程序:一直做到当前暂时不能继续,再保存状态返回

这个模型比“epoll 帮我处理连接”准确得多,也为后续 Connection 状态机奠定了基础。

4. M2:Connection、RAII 与资源所有权

当客户端 fd 只是散落在流程中的裸 int 时,很难回答它究竟由谁关闭。正常响应结束要关闭,解析失败要关闭,写文件失败要关闭,客户端重置连接也要关闭;错误分支一多,手动维护很容易遗漏。M2 引入 Connection,并让 Server 保存连接对象:

Server
└── unordered_map<int, Connection>

每个 Connection 成为对应 client fd 的唯一所有者。它禁止复制,允许移动,析构时关闭 fd。这样一来,connections_.erase(fd) 不只是从容器里删掉一个记录,它还会触发对象析构,让资源生命周期在同一个位置结束。

这一阶段也让我重新理解了 std::move。它本身不会搬走文件描述符,只是把表达式转换为允许移动的值类别;真正定义“旧对象失去什么、新对象得到什么”的,是 move constructor 和 move assignment。对于 fd 这样的资源,移动后必须让源对象进入安全、不可重复关闭的状态,否则所谓移动仍然可能导致 double close。

RAII 常被概括为“自动释放资源”,但项目里更明显的收益是异常路径更容易正确。只要所有权明确绑定到对象生命周期,后续逻辑无论从哪个错误分支退出,都不必重复编写一套 close 清单。RAII 并不会自动设计好所有权,但它迫使我先回答“谁拥有资源”,再让编译器和析构机制帮助维持这个约定。

5. M3:TCP 是字节流,因此 HTTP 必须增量解析

TCP 不提供 HTTP message boundary。一次 recv() 可能只拿到请求行的一半,也可能同时拿到完整 header 和一部分 body。例如一个 PUT 请求完全可能这样到达:

第一次:PUT /files/a HTTP/1.1\r\nHost: lo
第二次:calhost\r\nContent-Length: 5\r\n\r\nhe
第三次:llo

如果把“一次 recv”当成“一次请求”,测试数据稍微分片,解析就会失败。M3 中 Parser 因此采用增量输入,并返回 kIncomplete、kHeadersComplete 或 kError。它寻找 \r\n\r\n 来确定 header 结束位置,通过 consumed_bytes 告诉 Connection 已经消费了多少字节。header 与 body 如果在同一次读取中到达,分隔符之后的字节不能丢失,而应立即进入后续 body 处理。

当前 Parser 只负责 request line 和 header metadata,不缓存完整文件 body。这条边界很重要:解析器判断请求在 HTTP 语法和协议约束上是否成立,Connection 则保存跨事件的上传状态,并把后续 body chunk 流式交给文件层。如果 Parser 为了“方便”把完整 body 全部收齐,文件大小就会直接变成单连接内存占用,Streaming PUT 也失去意义。

这一阶段加入的协议约束包括 HTTP/1.1 必须提供 Host、PUT 必须提供有效的 Content-Length、不支持 Transfer-Encoding,并对 request line、header 和上传大小设置上限。当前上传限制为 64 MiB。限制并不是为了宣称协议实现完整,而是明确服务器现在接受什么、不接受什么,让错误能够在一致的位置被拒绝。

Parser 与 Router 的职责也需要分开。Parser 判断报文是否合法,Router 决定一个合法请求的方法和路径是否被业务支持。例如一个格式正确的 POST 可以顺利通过语法解析,再由 Router 判断为 Unsupported 并返回 405。若 Parser 直接把“不支持的业务方法”当成格式错误,协议层和业务层就会纠缠,测试也难以表达真实原因。

6. M4-A:安全的 FileService

把用户提供的文件名直接拼成 root + "/" + filename,并不能构成可靠的文件系统安全边界。路径遍历、符号链接、目录、FIFO 等对象都会让“看起来只是读写一个文件”的操作产生不同语义。M4-A 将数据目录作为一个已经打开的 directory fd,文件操作围绕 openat、fstatat、unlinkat 和 renameat 展开。

输入首先经过 basename validation。包含 ..、/、反斜杠或 NUL 的名字不能进入后续文件操作。即使名字通过检查,也要确认目标类型:普通文件、目录、symlink、FIFO 不能被混为一谈。特别是 symlink,如果跟随它访问,操作范围可能逃离预期的 data 目录;FIFO 则可能带来意料之外的阻塞语义。

这一阶段让我更具体地理解“外部输入永远不可信”。它不只意味着检查字符串是否为空,而是要考虑字符串最终进入了哪个系统调用、系统调用会解释出什么对象,以及对象类型是否符合业务假设。FileService 的价值不是封装几个 API 名称,而是把这些文件系统约束集中在明确边界内,让 Router 和 Connection 不必各自拼路径、各自猜测安全条件。

7. M4-B:Streaming PUT

Streaming PUT 把解析到的 Content-Length 转换为一段可持续推进的上传状态:

PUT
  ↓
读取 Content-Length
  ↓
创建临时文件
  ↓
接收 body chunk
  ↓
写入临时文件
  ↓
更新 received_body_bytes
  ↓
received == expected
  ↓
rename 提交
  ↓
201 Created

Connection 保存 expected_body_bytes 与 received_body_bytes。每次收到数据时,真正属于当前请求 body 的长度是 min(readable, remaining)。这里不能简单把缓冲区中所有数据都写入文件,因为 readable 可能超过剩余 body 长度;即使当前版本关闭连接而不处理下一条 keep-alive 请求,这个边界仍然必须由协议长度决定,而不能由某一次 recv 的偶然结果决定。

流式处理意味着服务器只需要一个固定大小的网络缓冲区和写入缓冲,而不需要先把整个上传文件放入内存。文件大小因此不直接等于单连接内存占用。更重要的是,状态可以跨越多次 EPOLLIN:本轮读到多少就写多少,遇到 EAGAIN 保存计数器,下一轮继续。

上传没有直接写最终文件,而是先写临时文件,完整接收并确认写入成功后再通过 rename 提交。如果客户端传到一半断开,临时文件可以清理,原有正式文件不会变成一个无法分辨的残缺版本。temp + rename 不是为了增加步骤,而是把“正在构建的结果”和“已经完成的结果”区分开。做完这一阶段后,我开始把提交动作看作状态机的一部分,而不只是最后一次文件 API 调用。

8. M5-A:HTTP Response、EPOLLOUT 与 partial write

非阻塞发送同样不保证一次完成。假设响应还有 100 bytes,send() 返回 40 并不表示出错,而是内核当前只接受了 40 bytes。Connection 必须把 response_offset 更新为 40,保留剩余数据,等下一次可写事件再从正确位置继续。

EPOLLOUT 表示 fd 当前存在发送空间,不表示整个 response 能一次写完。send() 返回 EAGAIN 时,也只是当前暂时无法继续,状态必须留下来。这里开始能明显感受到 backpressure:应用程序准备好了数据,但下游 socket 的发送能力决定了状态推进速度。

另一个容易踩坑的地方是永久监听 EPOLLOUT。大多数连接在大多数时间都是可写的,如果无条件关注它,事件循环会不断被没有实际工作的可写事件唤醒。FastStore 只在确实存在待发送数据时注册或保留 EPOLLOUT,发送完成后取消关注。事件兴趣集合本身因此也是 Connection 状态的外部表现,而不是固定配置。

9. M5-B:Streaming GET

下载链路比发送一段内存响应多了一层文件读取:

GET
  ↓
FileService::openRead
  ↓
确定 Content-Length
  ↓
发送 response header
  ↓
读取一个文件 chunk
  ↓
发送 chunk
  ↓
处理 partial send
  ↓
保存 offset,等待下一次 EPOLLOUT

为了区分不同进度,Connection 需要维护 expected_file_bytes、loaded_file_bytes 和 sent_file_bytes。expected 表示整个响应预期发送的文件长度,loaded 表示已经从文件读入用户态缓冲区的累计长度,sent 表示已经成功交给 socket 的累计长度。这三个值不能合并,因为读取文件和写入网络的速度并不一致。

例如当前 chunk 已经加载 64 KiB,但 socket 只接受了 20 KiB,此时 loaded != sent,剩余 44 KiB 必须继续保存在原缓冲区中。只要当前 chunk 尚未发送完,就不能读取下一 chunk 覆盖它。否则文件读取进度看起来向前了,网络端实际收到的数据却会缺失或错位。

这让我第一次在自己的代码里真正理解 backpressure。下游 socket 发不动时,上游文件读取也必须停下来。Streaming GET 不只是“循环 read 和 send”,而是两个速率不同的阶段通过有限缓冲区连接起来,并由状态机保证数据顺序。它同样让内存占用保持有界:无论文件有多大,Connection 都只保存当前正在传输的 chunk 和必要计数器。

10. M6-A:Robustness Testing

在基本 GET、PUT、DELETE 跑通后,M6-A 没有立即加新功能,而是集中测试异常路径。测试覆盖 malformed HTTP、非法路径、不完整 PUT、RST、half-close、symlink、目录、FIFO、大量连接建立与断开,以及连接结束后的 fd recovery。

这部分改变了我对“正确”的判断。以前写练习时,只要正常请求得到正常响应,我就容易认为功能完成了。但服务器面对的输入不会只走 happy path:客户端可能发到一半消失,文件类型可能不符合预期,请求头可能缺失,系统调用也可能只完成部分工作。异常路径不是附加功能,而是服务器日常运行路径的一部分。

测试目标也不只是“返回了某个错误码”。更重要的问题是错误之后会发生什么:临时文件是否残留,fd 是否泄漏,Connection 是否还留在 map 中,事件循环是否继续工作,下一位客户端是否仍能得到服务。connection churn 和 fd recovery 尤其能暴露单次测试看不到的问题,因为少量泄漏往往不会马上让进程失败,却会在长时间运行后积累成资源耗尽。

11. M6-B:Benchmark Infrastructure

M6-B 没有把重点放在一个好看的 QPS 数字上,而是先建立可重复的性能实验基础。Debug 与 Release 必须分开,workload 和文件大小要记录,client 与 server 是否竞争同一组 CPU 资源要明确,page cache 是否已经预热也会改变结果。除了压测工具的输出,还要保留 raw data,并采样 CPU 和 RSS,才能在后续比较中知道环境究竟发生了什么。

我以前容易把 benchmark 理解为“跑一次 wrk,然后截图”。实际做下来,性能测试更像实验设计。参数不受控时,两次数字的差异可能来自编译优化、缓存状态、日志量或机器负载,而不是代码改变。一个结果只有在条件可说明、过程可重复、原始数据可追踪时才有解释价值。

当前阶段的 benchmark infrastructure 主要是为后续演进建立基线,而不是证明 FastStore 达到了某种生产性能。克制地记录限制和环境,比只挑最好的一次结果更有意义。

12. M6-C:quiet logging

压测过程中,INFO 日志会产生大量终端输出。它既干扰观察,也可能让日志 I/O 参与到性能结果中。M6-C 因此增加 FASTSTORE_QUIET=1:进程启动时读取一次配置,把结果保存为布尔状态;热路径只需要做一次 bool 判断,避免每条日志都重复解析环境变量。

quiet 模式只关闭 INFO,ERROR 仍然保留。性能测试需要减少无关噪声,但不能因此把真正的故障一起隐藏。更重要的是,这个配置不能偷偷改变请求处理、文件传输或错误恢复逻辑。benchmark 配置应该只影响观测开销,而不是让被测系统换成另一套业务行为。

这看起来是一个很小的阶段,却让我意识到工程中的性能工作经常从测量条件开始。没有稳定的观测方式,再复杂的优化也可能只是在比较噪声。

13. AI Agent 在项目中的作用

FastStore 也是我逐渐形成 AI Agent 协作流程的地方。现在更稳定的节奏大致是:

明确需求
  ↓
限制本阶段 scope
  ↓
人工确定架构与边界
  ↓
编写 Agent Prompt
  ↓
Agent 实现并运行 build / test
  ↓
阅读 completion report
  ↓
暂不 commit
  ↓
源码 Review,理解关键 10%~20%
  ↓
修正并再次测试
  ↓
形成 Git milestone

Agent 很适合承担边界明确的实现工作,例如补齐一个阶段的状态字段、增加测试场景、根据已有接口完成实现,或者整理构建与测试结果。它也能加快我在多个文件之间建立一致修改的速度。但如果 prompt 没有清晰 scope,Agent 同样可能顺手重构无关模块、假设未确认的需求,或者写出正常路径成立、异常路径不闭合的代码。

因此,架构边界、资源所有权、验收条件和 Git milestone 仍然需要由我掌握。completion report 是检查入口,不是正确性证明;build 成功也只说明代码能够构建,不代表协议状态和错误处理一定合理。我会重点阅读那些决定系统行为的少量代码:状态如何转换,计数器何时更新,fd 在哪里转移和关闭,EAGAIN 分支是否保存了足够信息,错误后是否完成清理。

我没有把 Agent 当成“自动完成整个项目”的工具。更接近的方式是,让它提高实现和验证的吞吐量,而我负责确定要做什么、为什么这样做,以及最后是否接受这些改动。只有当关键代码能够被我解释,测试失败时知道从哪一层排查,这次协作才真正转化成了自己的学习。

14. 当前阶段我真正学到的东西

从 M0 到 M6-C,很多原本分散的知识开始形成同一个模型。TCP 是字节流,所以 recv 边界不能被当作 HTTP 边界;non-blocking 不只是“不等待”,还意味着一次操作可能只完成一部分;epoll 只提供就绪通知,应用程序必须主动推进状态,直到当前无法继续。

Connection 状态机的作用,是保存这些跨事件才能完成的工作。解析器可能等待剩余 header,PUT 可能等待剩余 body,普通响应可能保留尚未发送的 offset,GET 则同时保存文件读取和网络发送进度。partial read、partial write 和 EAGAIN 都不是罕见异常,而是非阻塞程序必须按正常路径处理的结果。

Streaming PUT 和 Streaming GET 让我看到有界内存并不是一句抽象要求,而是由明确的 buffer 生命周期、计数器和 backpressure 共同实现。下游不能继续时,上游就不能盲目生产更多数据。RAII 则让网络 fd、文件 fd 和临时资源的所有权进入对象模型,使复杂错误路径更容易保持一致。

文件服务进一步提醒我,外部输入必须在真正使用它的边界被验证。网络层解析正确,不代表文件路径就安全;能打开一个对象,也不代表它是允许操作的普通文件。健壮性测试需要主动制造不完整、非法和突然终止的场景,而 benchmark 需要控制变量、记录环境和保留原始数据。

AI Agent 带来的学习也不是“以后不用读代码”,而是相反:实现速度提高以后,选择什么值得 Review、怎样快速找到关键路径、如何用测试验证边界,变得更加重要。Agent 可以缩短从设计到可运行版本的距离,但不能替代架构判断和最终责任。

15. 当前冻结与下一阶段

FastStore 当前冻结在 M6-C,暂时不进入 M7。现在的版本已经串起 C++17、CMake、TCP、non-blocking socket、epoll LT、HTTP/1.1、RAII、Router、FileService、Streaming PUT、Streaming GET、健壮性测试和 benchmark infrastructure,但它仍然是一个用于学习的基础 HTTP 文件服务器。

ThreadPool、Work Stealing、动态扩缩容、keep-alive、HTTP/2、TLS、Range、sendfile 和 io_uring 都没有在当前阶段实现。我更愿意把它们看作后续演进空间,而不是为了让功能列表更长就提前塞进当前版本。每多加入一层并发或协议能力,原有状态、所有权和测试都会随之变复杂;如果对现有单线程事件循环还没有真正理解,过早增加机制只会让问题更难定位。

接下来我会继续阅读和复盘现有代码,重点确认自己能完整解释 non-blocking、epoll、Connection、HTTP Parser、Streaming PUT、Streaming GET、RAII、backpressure 和 robustness testing 之间的关系。等这些基础足够清晰,再进入 M7 ThreadPool。至于 M7 具体怎样设计,我希望留到下一阶段根据约束重新分析,而不是在这篇总结里提前给出一个未经验证的方案。

M0 到 M6-C 并没有让我“完成一个很厉害的服务器”,但它让我从多个系统调用和语言特性之间,看到了一个服务器怎样依靠明确状态持续向前运行。对现阶段的我来说,这正是 FastStore 最有价值的结果。