一个 nginx 工程师,重新设计了 AI 写的项目架构(完结篇)

5 阅读18分钟

十篇技术文章,三十多次提交。该停下来看看了。

做了什么

回头看,做的事情只有一件:一层一层地把复杂度放到它该在的地方。

骨架先理(第二篇)——命名、文件前缀、构建分离。看起来琐碎,但它定义了项目的身份。

然后修问题(第三篇)——fetch 不支持并发,bench 把失败算成成功。修问题本身就带来了架构改进:同步阻塞变成异步事件循环。

接下来是自下而上的重构:

  • 事件引擎(第五篇):四步封装,epoll_wait() 从散落两处变成只在一个地方
  • 工作引擎(第六篇):红黑树定时器 + epoll 组装成 js_engine_t,每个线程一个
  • 连接层(第七篇):从 conn 里拆掉 HTTP,引入 buffer,让 conn 不知道协议
  • HTTP 层(第八篇):请求类型统一,conn 拥有输出 buffer,连接操作归位
  • 结构体(第九篇):js_http_peer_tjs_fetch_tjs_thread_t——用 struct 表达领域概念
  • fetch 封装(第十篇):拆文件、搬行为、切依赖——loop 变成 85 行纯调度器

中间穿插了两篇想法。第四篇:好的架构就是刚刚好。第一篇:AI 写代码像盖楼,前几层盖得又快又稳,但图纸得人来画。

架构对比

说了这么多,不如看一眼。

AI 写的第一版:

               jsb.h(318 行,所有声明)
                      │
   ┌──────────────────┼──────────────────┐
   │                  │                  │
fetch.c          worker.c         http_client.c
544 行            239 行             227 行
同步阻塞          事件分发            conn + HTTP
+ Headers         + conn 处理         混在一起
+ Response        + HTTP 解析
+ fetch()         + 统计

              event_loop.c
           45 行,裸 epoll 封装

一个头文件装所有声明。fetch 是同步阻塞的,三个类混在一个文件里。worker 同时做事件分发、连接处理、HTTP 解析和统计。conn 和 HTTP 绑死。event_loop 只是 epoll 的薄封装,不是真正的事件引擎。

每个文件都知道其他文件的事。改一处可能影响所有地方。

现在:

┌─────────────────────────────────────────────────────┐
│  应用层                                              │
│  js_worker.c    js_fetch.c    js_loop.c    js_cli.c  │
│  (bench C路径)   (JS fetch)    (调度器)     (CLI)     │
├─────────────────────────────────────────────────────┤
│  HTTP 层                                             │
│  js_http.h    js_http_parser.c                       │
│  js_headers.c    js_response.c                       │
├─────────────────────────────────────────────────────┤
│  连接层                                              │
│  js_conn.h + js_conn.c    js_buf.h                   │
├─────────────────────────────────────────────────────┤
│  引擎层                                              │
│  js_engine.h + js_engine.c                           │
│  js_epoll.h + js_epoll.c    js_timer.h + js_timer.c  │
├─────────────────────────────────────────────────────┤
│  基础设施                                            │
│  js_unix.h    js_clang.h    js_time.h    js_rbtree.h │
└─────────────────────────────────────────────────────┘

依赖方向:↓ 只往下,不往上

32 个文件,5 层。每层只依赖下面的层,不知道上面的层。引擎不知道连接,连接不知道 HTTP,loop 不知道 fetch。改任何一层,不影响其他层。

从 13 个文件、1 个头文件、没有分层,到 32 个文件、12 个头文件、5 层架构。代码总量差不多——从 2800 行到 3900 行,多出来的主要是基础设施(红黑树、定时器、buffer)。但每个文件变小了,每个模块只做一件事。

对架构的理解

十次重构之后,对架构有了一个比以前更清晰的认识。不是书上学的理论,是在真实代码里反复验证的体会。

如果只能用一句话概括:架构就是管理复杂度。

AI 写的第一版 fetch 是个 400 多行的函数:DNS 解析、创建独立事件循环、网络读写、HTTP 解析、构造 Promise——五种不同的复杂度搅在同一个函数里。每一段单独看都没问题。但想让 fetch 支持并发的时候,改不动——因为它自己管着一个独立的事件循环,不接入全局的事件引擎。要改并发,就得同时动 I/O 模型、Promise 机制、事件循环。因为它们没有被分开,所以只能一起改。

重构之后,fetch 只负责创建连接和注册事件。事件循环、连接管理、Promise 调度各在各的模块里。想改并发?只动事件循环,其他模块不用碰。

复杂度不会消失,但它可以被隔离。 做到了,改一处不用担心另一处。做不到,动哪里都怕。这是架构要解决的根本问题。

反复用到的几个原则

十篇文章用到的架构手法其实不多。但每一个都反复出现,反复生效。

单一职责——一个模块只做一件事。 AI 写的 worker 模块同时做事件分发、连接处理、HTTP 解析和统计——四件事搅在 239 行里。重构之后,事件分发、连接处理、HTTP 解析、统计各回各家。每个模块只做一件事,改任何一件都不影响其他的。同样的问题在第十篇再次出现:loop 是调度器,但 224 行里超过一半在做 fetch 的活——HTTP 解析、Promise resolve/reject、连接清理。把行为搬回 fetch 模块之后,loop 变成 85 行纯调度。

判断该不该拆,有一个简单标准:各自独立变化的分开,一起变化的放一起。 读字节和解析 HTTP 会各自独立变化——你完全可能换一种协议解析,或者换一种读取策略——所以该分。创建、销毁、读写全是连接操作,总是一起变——所以该合到一个模块。

聚合——相关的东西放在一起。 这是单一职责的另一面。拆开不相关的,同时也要把相关的聚合起来。

第六篇引入工作引擎,把事件分发和定时器组合成一个整体——它们散着的时候,"事件循环的超时应该由最近的定时器决定"这个关系只存在于程序员脑子里。聚合之后,关系变成代码本身表达的。第九篇引入 HttpPeer,把响应解析器和计时字段从连接对象和局部变量里收拢到一起——连接层立刻变干净了。fetch 把连接、响应解析器、超时定时器、Promise 回调聚合在一起——loop 从 200 多行变成 85 行。

聚合不是随便建个类把东西装进去。关键是识别出"这些东西属于同一个概念"。HttpPeer 表达的是"一次 HTTP 交互的对端",Thread 表达的是"一个工作线程的完整上下文",Engine 表达的是"一个线程的完整工作引擎"。好的类型命名本身就在解释系统。 当你发现几个变量总是一起创建、一起传递、一起销毁,它们很可能应该被聚合到一个对象里。

定义边界——用抽象划出一条线。 第五篇的事件引擎是最好的例子。连接是事件,定时器也是事件——底层实现完全不同,但对事件引擎来说,它们都是同一个东西:一个 Event 对象,有处理函数,仅此而已。分发循环不需要知道事件类型,只管调 handler。将来加信号、管道、UDP,分发代码一个字不用改。

抽象的价值在于:边界以上所有东西一视同仁,边界以下各自不同。 事件引擎只跟边界以上打交道,所以它不随事件类型的增加而修改。这是系统能扩展的根本原因。

第十篇也用了同样的手法。loop 不应该知道 pending 操作是 fetch 还是 WebSocket——给 pending 加一个 destroy 接口,loop 清理时只管调 destroy,不管对面是谁。Go 里是 interface,Java 里是抽象类,C 里是函数指针——形式不同,本质一样。

隐藏复杂度——但不是转移复杂度。 这个区分很重要。AI 的连接对象里嵌着 HTTP 响应解析器,连接层的读函数里直接调 HTTP 解析,keepalive 判断去读 HTTP 头。表面上看,HTTP 的逻辑"藏在"了连接模块里。但这不是隐藏,是转移——HTTP 的复杂度被塞进了一个不该承担它的地方。连接层不能离开 HTTP 独立存在,改 HTTP 解析要动连接代码。两层的复杂度不是加在一起,是乘在一起。

重构之后,连接对象里没有任何 HTTP 字段。连接层只做传输:建连接、读字节、写字节。连接不知道 HTTP 的存在,就像事件引擎不知道连接的存在一样。真正的隐藏,是让每个模块只承担自己该承担的复杂度。

依赖只往一个方向走。 五层架构,每层只依赖下面的层,不知道上面的层。引擎不知道连接,连接不知道 HTTP,loop 不知道 fetch。改下层不影响上层——第六篇改了引擎的内部实现,上层的连接逻辑完全不受影响。如果基础设施"知道"上层的存在,它就不再是基础设施,而是上层的一部分。

消除不合理的依赖同样重要。第五篇把 epoll 文件描述符从每次传递的参数变成线程级别的基础设施——接口少一个参数,不只是少打几个字,是从源头消除了传错的可能。

刚好足够,不多不少。 AI 的代码里,设计不足随处可见:定时器三套实现,请求概念拆成两个类型,数据重复、所有权不清。但过度设计的诱惑也无处不在——AI 在项目第一天就建议"按职责拆分模块",可当时只有一个公共接口文件,没有通用组件,拆了反而是过度设计。后来引入红黑树和定时器模块,它们不属于任何业务逻辑——这时候拆才是刚好需要。"刚好"不是一种设计能力,是一种时机判断。 同一个动作,做早了是过度设计,做晚了是设计不足。

用类型表达领域概念

这一点值得单独说。类型定义不只是存储数据的容器——好的类型定义,本身就是一份领域模型。

第九篇做的就是这件事。请求计时的 start_ns 存在连接对象里,代码能跑,测试能过。但请求计时是 HTTP 层的事,不是传输层的事。问题不是"错了",是"没有意识到可以更好"。 引入 HttpPeer 之后,响应解析器和计时字段归位了——它们属于同一个概念:"一次 HTTP 交互的对端状态"。

更深的例子是 fetch。引入它之前,loop.add() 有六个参数散着传进来:连接、TLS 上下文、JS 上下文、resolve 回调、reject 回调、loop 指针。loop 在函数内部做分配、初始化 HTTP 解析器、设置超时、注册事件——一个事件循环,干着 HTTP 客户端的初始化。 因为代码里没有一个类型表达"一次 fetch 操作是什么"。有了 fetch 之后,六个参数变两个,初始化回到 fetch 模块,超时处理回到 fetch 模块——一个类型的引入,触发了一连串的简化。

数据放在哪个类型里,体现的是你对"谁拥有什么"的理解。 理解对了,数据在对的地方,代码自然简洁。理解缺失,数据散落在不该在的地方,怎么写都别扭。

逐个字段问自己:它描述的是这个类型所代表的概念吗?还是它其实属于别的东西,只是恰好放在了这里?如果答案是后者,要么缺少一个概念,要么概念的边界画错了。好的代码库里,类型定义本身就是最好的架构文档——nginx 的 connection 不知道 HTTP 的存在,Linux 内核的 socket 不知道 TCP 的存在。

怎么检验

说了这么多原则,怎么判断自己做得对不对?几个直观的检验方法,打开代码就能看到:

  • 看类型定义。 每个字段都是自己需要的,还是替别人拿着的?
  • 看函数。 它做的事情是否属于同一层?读字节和解析协议不在同一层。
  • 看依赖方向。 下层有没有引用上层的类型或调用上层的函数?
  • 看模块接口。 一个模块的公开接口能不能不依赖不相关的类型?
  • 看数据的归属。 计时字段在连接里还是在 HTTP 对端里?引擎在 loop 里还是在线程上下文里?

还有一个终极检验:如果加一个新的同类事物,现有代码需要改吗? 加一种新的事件类型,事件分发不用动。加一种新的异步操作(比如 WebSocket),loop 一行不用改。如果需要改,说明抽象的边界没有画对。

重构

如果这个系列有一个核心主题,那就是重构。怎么强调都不过分。

好的架构是设计出来的,但不是一开始就画好蓝图那种设计。 重构本身就是一种设计方式——在实践中不断加深理解,每次做当下最合理的决策。十次重构没有一次是提前计划的。每次只做一件事——发现一个问题,解决它,然后看到下一个问题。想封装事件分发,发现有个依赖挡着;消除了依赖,发现连接耦合在事件循环里;解了耦合,封装才水到渠成。第八篇也是一样——原本想一口气理清 HTTP,但做完请求合并和输出缓冲之后,发现 loop 里还有一堆 fetch 的行为搬不动,只能留到第十篇。

这就是架构演进的正常方式——你对系统的理解是在解决问题的过程中不断加深的。每解决一个问题,才看到之前看不到的下一个问题。

重构有一个反复生效的三步法:建立边界、归还行为、切断依赖。 第十篇封装 fetch 就是这三步——先拆模块让 Headers、Response、fetch 各有边界;再把 loop 里的读写处理、完成/失败逻辑搬回 fetch;最后用接口切断 loop 对 fetch 具体类型的依赖。第五篇封装事件引擎也是:先让事件自带处理函数(边界),再把连接后处理搬回处理函数里(行为),最后把事件分发封装成一个函数(隐藏)。同一个手法,在不同的地方都能生效——它不是技巧,是原则。

重构的勇气来自对代码的理解。 很多人不敢重构,是因为不确定改了会不会坏。理解越深,改的底气越足。而写文章是加深理解最好的方式——要解释一段代码为什么这样写的时候,如果解释不通,说明代码有问题。十篇文章里有好几次重构是在写文章的过程中触发的。读源码的能力也没有捷径——多读好代码,慢慢培养出对"不对劲"的敏感。架构有时候就是一种审美,说不清为什么觉得不舒服,但直觉告诉你有问题。然后去找原因,改掉了,就舒服了。

如果你还不熟悉"重构",建议专门学习一下。Martin Fowler 的《重构》是奠基之作,Joshua Kerievsky 的《重构与模式》可读性更好,把重构手法和设计模式结合在一起讲。重构的核心思想是:在不改变外部行为的前提下,改善代码的内部结构。 功能不变,测试照过,但代码变得更清晰、更容易演进。

没有测试,就没有重构的底气。 这个系列的每次改动都遵循同一个流程:改结构,不改行为,用测试验证。测试不告诉你该怎么改,但它告诉你改完之后有没有搞坏东西。第三篇的教训最深刻——AI 写的测试只验证了"有请求数",没验证"请求是否成功",16576 次全部失败却报告 0 错误。测试的价值不在于有没有,而在于覆盖了什么。 单元测试验证模块行为,集成测试验证模块之间的协作,端到端测试验证真实场景。覆盖率不是目的,但它是一个信号——告诉你哪些路径从来没有被验证过。重构越频繁,测试越重要。

AI 与架构

AI 让重构变得前所未有地廉价。以前重构一次,资深工程师可能要花一天。十次重构,两周。很多时候你心里知道代码不够好,但改的成本太高,就忍了——技术债就是这么来的。现在给 AI 讲清楚方向,一次重构几分钟。它不会改了接口忘了调用方,不会 20 处修改漏一处。以前负担不起的架构改进,现在负担得起了。

但 AI 需要的不是长篇大论的 prompt,而是一个准确的判断。第五篇最典型:AI 看出某个参数到处传递不够好,试了几种方案都不对。我给它一句话——"每个线程有且只有一个 epoll 实例"——它立刻知道怎么做了。十几处分散在不同文件里的调用,一次改完,零错误。你的一个架构判断,AI 能帮你落到几十个文件的每一行代码上。判断越准确,杠杆越大。

但 AI 有一个需要警惕的盲区。第三篇发现的那个 bug 最能说明问题:bench async 模式的每次 fetch 都失败了,但统计报告 16576 次请求、0 错误。原因是 worker 没有检查 fetch 是否真的拿到了响应,无条件计入成功。而 AI 写的测试也只看了这个数字。实现和测试是同一个 AI 写的,它们有一致的盲区——实现跳过了错误检查,测试也跳过了错误验证,完美地互相配合,形成了一个一切正常的假象。

所以 review 仍然是最有效的质量保障。这不是新道理——nginx 团队每次提交都有人仔细看,有时候 reviewer 花的时间比写代码的人还多。在 AI 时代这个流程不但没有过时,反而更重要了——因为 AI 写代码的速度远快于人类,如果没有 review,bug 堆积的速度也会远快于人类。

归根到底:代码可以让 AI 写,但架构方向不行——因为它决定了 AI 写的代码是资产还是负债。 好架构的门槛降低了,但架构判断本身无法外包。学会重构,然后让 AI 来做——你负责识别代码中的问题,判断该往哪个方向改;AI 负责把改动落到每一行代码上。这个分工效率极高。

能力怎么来

架构能力没有捷径,只能靠实践。 读书能给你概念框架,但真正的理解只能从动手中来。这个系列的每一条经验——分与合的判断、时机的把握、对"不对劲"的敏感——都不是看文章就能学会的,是在一次次重构中磨出来的。

如果有机会,参与高质量的开源项目,跟专业的人交流。 code review 是最高效的学习方式之一——看别人怎么审你的代码,看高手怎么组织他们的代码。我在 nginx 团队的几年,学到的架构经验比之前十年都多,不是因为读了更多书,是因为每天都在跟顶级工程师的代码和 review 打交道。好的环境会拉高你的标准,而标准决定了你能做到什么程度。

接下来

架构的大框架基本稳定了。后面更多是细节的打磨——接口命名、错误处理、edge case 覆盖、性能优化。这些不那么戏剧性,但同样重要。一个项目从"能用"到"好用",靠的就是这些。

关于 jsbench

jsbench 的定位是可编程的 HTTP 压测工具

市面上的压测工具要么快但不灵活(wrk),要么灵活但慢(k6、Artillery)。jsbench 想要两者兼得:C 引擎的性能,标准 JavaScript 的表达力。 用 JS 写请求逻辑、校验响应、构造动态参数——不需要学私有 DSL,不需要装运行时,开箱即用。

我目前在 nginx 团队工作。对代码质量的追求是一脉相承的——清晰的分层、严格的依赖方向、每个模块只做一件事。 这不只是个能跑的工具,而是一个经过认真设计的系统。

jsbench 会持续维护和发展。如果你对 C 系统编程、事件驱动架构、或者 AI 辅助编程感兴趣,欢迎一起参与:

  • 提 issue、发 PR、讨论设计思路——都欢迎
  • 代码不多(不到 4000 行 C),结构清晰,很适合学习系统编程
  • 架构文章和代码是同步的,可以对照着看

感谢读到这里。


GitHub: github.com/hongzhidao/…

更多文章和后续更新,关注微信公众号:程序员洪志道