夜雨聆风学习资料网

ARTICLE · 1247515

我拆完 OpenClaw 源码后,终于明白一个 Web 框架是怎么“长”出来的

我拆完 OpenClaw 源码后,终于明白一个 Web 框架是怎么“长”出来的
大家好,我是阿浪
本文是我自己的源码拆解笔记和思考,代码都是简化示意,不是逐行照搬。目的是让刚开始学框架源码的朋友,也能看懂一个 Web 框架到底由哪些“零件”组成。

很多人一听到“读框架源码”,第一反应就是:
别了吧,肯定一大堆抽象类、泛型、设计模式,我连入口都找不到。

我以前也这么想。但这次把 OpenClaw 这套 TypeScript Web 框架拆下来,我最大的感受是:
框架没那么神秘,它更像一套搭得很讲究的积木。你只要先找到“地基”,再顺着请求走一遍,很多模块自己就串起来了。

这篇文章我不打算一上来就甩一堆源码。咱们按小白的视角,一块一块看:

  • 一个框架的项目结构为什么要这么分?

  • 应用是怎么启动的?

  • 中间件为什么叫“洋葱模型”?

  • 插件系统到底插在哪?

  • 路由怎么把 /users/:id 变成参数?

  • 状态管理为什么要搞订阅和选择器?

  • 构建、测试、性能优化这些工程化东西,为什么也是框架的一部分?

你慢慢看,最后会发现:框架的核心不是“写了很多代码”,而是“把边界划清楚了”。

一、先别急着看代码,先看它怎么分层的

OpenClaw 用的是 Monorepo,也就是把多个包放在一个大仓库里管理。你可以把它想成一个大院子,里面有几个房间:

  • core:框架核心,应用、中间件、插件、生命周期都在这;

  • 其他包:可能是工具、适配器、测试辅助之类;

  • 根目录统一管理依赖、构建、测试。

我自己的理解是:Monorepo 不是为了“看起来高级”,而是为了改一个底层包时,上层包能立刻跟着验证。
如果每个包都拆成独立仓库,改一个类型定义,你可能要发版、升级、再联调,来回折腾。放在一个院子里,至少“串门”方便。

构建上它用了 Turborepo。这个东西你可以理解成:
只给脏衣服洗,干净的衣服不重复洗。
哪个包变了,就构建哪个包;没变的包,直接拿缓存。

简化后的配置大概长这样:

{"pipeline":{"build":{"dependsOn":["^build"],"outputs":["dist/**"],"cache":true},"test":{"dependsOn":["build"],"cache":true}}}
这里 ^build 的意思是:我依赖的包先构建,我再构建。
小白可以记一句话:Turborepo 管的是“谁先做、谁不用重复做”。

二、应用启动:其实就是一条流水线

框架的入口通常是一个 Application 类。你不用把它想得太玄,它主要干几件事:

  1. 注册插件;

  2. 注册中间件;

  3. 启动前钩子;

  4. 组合中间件;

  5. 监听端口;

  6. 启动后钩子。

简化一下,大概是这样:

classApplication {private middlewares: Middleware[] = []private plugins: Plugin[] = []  use(plugin: Plugin) {this.plugins.push(plugin)    plugin.install(this)returnthis  }  middleware(fn: Middleware) {this.middlewares.push(fn)returnthis  }  async start(port: number) {for (const plugin of this.plugins) {      await plugin.beforeStart?.(this)    }const handler = compose(this.middlewares)    createServer((req, res) => {      handler({ req, res })    }).listen(port)for (const plugin of this.plugins) {      await plugin.afterStart?.(this)    }  }}

这段代码不复杂,但它体现了一个很重要的思想:
启动不是“一把梭”,而是有顺序的生命周期。

我自己的看法是:生命周期钩子是框架给插件留的“时间窗口”。
数据库插件要在服务监听前连上数据库,日志插件可能要在启动后打一条“服务已启动”。如果没有这些钩子,插件就只能到处乱插,最后核心代码会被污染。

三、中间件:为什么叫“洋葱模型”?

中间件是 Web 框架里最值得小白先搞懂的东西。

你可以把一次请求想成一个快递包裹。
包裹从最外层进来,经过一层层流水线,每一层都可以:

  • 看看包裹信息;

  • 加个标签;

  • 记录时间;

  • 判断要不要直接拦截;

  • 或者放行给下一层。

等包裹处理完,再一层层往回走。
这就是“洋葱模型”:进去的时候一层层进,出来的时候一层层出。

简化后的组合函数大概这样:

typeMiddleware = (ctx: any, next: () => Promise<void>) =>Promise<void>functioncompose(middlewares: Middleware[]) {returnfunction (ctx: any) {let index = -1functiondispatch(i: number): Promise<void> {if (i <= index) {returnPromise.reject(newError('next() 被重复调用了'))      }      index = iconst fn = middlewares[i]if (!fn) returnPromise.resolve()try {returnPromise.resolve(fn(ctx, () =>dispatch(i + 1)))      } catch (err) {returnPromise.reject(err)      }    }returndispatch(0)  }}

这段代码里有两个点很关键:

第一,next() 本质上就是“调用下一层”。
第二,它防了 next() 重复调用。这个防护看起来小,但很实用。因为中间件写多了,真有人会在一个分支里调两次 next(),最后请求链路就乱了。

我自己的理解是:中间件不是越多越好。
每加一层,请求链路就多一次函数调用,也多一次出错机会。日志、错误处理、鉴权、限流这些适合放中间件;业务逻辑就别硬塞进去了,不然洋葱会变成“千层饼”。

四、插件系统:给框架装“外设”

插件系统我最喜欢用一个比喻:插座。

框架核心提供标准插座,插件就是外设。
数据库插件、日志插件、配置插件,只要遵守 install 这个约定,就能把自己的能力挂到应用上。

简化接口:

interfacePlugin {name: stringinstall(app: Application): void  beforeStart?(app: Application): Promise<void> | void  afterStart?(app: Application): Promise<void> | void}

一个数据库插件可以简化成:

constdbPlugin: Plugin = {name: 'db',install(app) {    app.db = createPool()  },asyncbeforeStart(app) {await app.db.connect()  },}app.use(dbPlugin)

我自己的想法是:插件系统的价值,不是“能装很多东西”,而是“核心不用知道太多东西”。
如果每个功能都写进核心,核心会越来越胖。插件把“可选能力”挪出去,核心只保留最稳定的约定,这样框架才容易长大。

但插件也不能乱写。我的建议是:
插件尽量只依赖应用暴露的接口,不要偷偷改核心内部状态。
不然插件一多,排查问题会非常痛苦。

五、路由:门牌号 + 正则匹配

路由说白了就是:给不同的 URL 分配不同的处理函数。

/users/:id 这种写法,:id 是动态参数。框架需要把路径转成正则,匹配时把参数抠出来。

简化实现:

functionpathToRegex(path: string) {const pattern = path.replace(/:[^/]+/g, '([^/]+)')returnnewRegExp(`^${pattern}$`)}const re = pathToRegex('/users/:id')const match = '/users/42'.match(re)console.log(match?.[1]) // 42

这段代码很短,但小白要理解一个点:
路由匹配的本质是“字符串规则 + 参数提取”。

嵌套路由也不神秘。
比如 /api 下面挂一个子路由,本质上就是把子路由的处理链挂到父路由下面。你可以理解成:
先找到小区大门,再找楼栋门牌。

我自己的看法是:路由正则适合入门理解,但真实框架要考虑性能、优先级、静态路径和动态路径的冲突。
所以看源码时别只盯着“能不能匹配”,还要看它怎么组织路由表、怎么处理嵌套、怎么避免每次都从头扫一遍。

六、状态管理:一个小广播站

OpenClaw 里的 Store 用了发布订阅模式。
你可以把它想成小区广播:状态一变,订阅过的人都能收到通知。

简化代码:

classStore<T> {privatestate: Tprivate listeners = newSet<(s: T) =>void>()constructor(initial: T) {this.state = initial  }getState() {returnthis.state  }setState(next: T) {if (Object.is(next, this.state)) returnthis.state = nextthis.listeners.forEach(fn =>fn(this.state))  }subscribe(fn: (s: T) => void) {this.listeners.add(fn)return() =>this.listeners.delete(fn)  }}

这里有个细节:状态没变就不通知。
别小看这一行,它能省掉很多无意义的更新。

还有 createSelector,它的作用是缓存计算结果。
上一次的状态和结果都记着,如果状态没变,就直接返回上次结果。

我自己的理解是:状态管理最难的不是“存”,而是“什么时候通知、通知谁、要不要重算”。
Store 负责广播,Selector 负责算得省一点。两者配合,才不会一改状态就全量重算。

七、工程化:框架的“后勤部队”

很多小白看源码只看运行时,忽略了构建和类型配置。
但我拆完这套东西后,反而觉得工程化特别重要。

TypeScript 配置里常见几个东西:

{"compilerOptions":{"strict":true,"composite":true,"declaration":true}}
  • strict:把类型检查拧紧,早发现问题;

  • composite:支持项目引用;

  • declaration:生成类型声明文件。

Project References 你可以理解成“图书馆目录”:
每个包有自己的目录,编译时不用把所有书都翻一遍,只翻需要的部分。

我自己的看法是:框架的工程质量,很大一部分藏在这些“看不见”的配置里。
运行时写得再漂亮,如果构建慢、类型乱、包之间互相绕,维护起来一样难受。

八、性能优化:别为了优化而优化

这类框架常见的优化点有三个:

1. 缓存中间件

以 URL 为 key,把 200 响应缓存起来,命中就直接返回。

简化示意:

const cache = new Map<string, string>()const cacheMiddleware: Middleware = async (ctx, next) => {if (cache.has(ctx.req.url)) {    ctx.res.end(cache.get(ctx.req.url))return  }await next()if (ctx.res.statusCode === 200) {    cache.set(ctx.req.url, ctx.res.body)  }}

但我要泼一盆冷水:缓存中间件不是万能药。
用户相关页面、登录态页面、实时数据页面,都不能随便缓存。缓存 key 怎么设计、过期怎么做、内存会不会涨,都是坑。

2. 路由懒加载

用动态 import() 按需加载控制器。
启动时不用把所有模块都解析一遍,第一次访问再加载。

3. 数据库连接池

连接池就是“复用连接”,不是每次请求都新建数据库连接。
这个对性能影响很直接,但也别把池子开太大,数据库那边也扛不住。

我自己的理解是:性能优化一定要看场景。
先测量,再优化;先找瓶颈,再动手。上来就加缓存,很容易把 bug 也缓存进去。

九、设计模式:别为了模式而模式

这套框架里能看到几种常见模式,但我更想说的是:模式是结果,不是目的。

模式在框架里的感觉我的理解
工厂模式createApplication() 统一创建应用把初始化细节收口,别让调用方到处 new
观察者模式Store 订阅状态变了通知谁,解耦
责任链模式中间件链请求一层层过,每层只管自己的事
策略模式路由匹配策略匹配规则可替换,别写死

我自己的看法是:看源码时不要急着背“这里用了什么模式”。
先问:它解决了什么问题?如果不用这个模式,代码会变成什么样?这样理解才扎实。

十、测试:先测“骨架”,再测“血肉”

小白写测试容易一上来就测业务。
但框架源码更适合先测骨架:

  • 路由能不能正确匹配?

  • 参数能不能正确提取?

  • 中间件顺序对不对?

  • next() 重复调用会不会报错?

  • 插件生命周期有没有按顺序执行?

路由测试简化示意:

const re = pathToRegex('/users/:id')const match='/users/42'.match(re)expect(match?.[1]).toBe('42')

集成测试则可以用请求工具跑完整链路:
发一个请求,看状态码、响应体、中间件有没有生效。

我自己的理解是:测试不是给老师看的,是给你自己改代码时兜底的。
框架这种基础设施,最怕改 A 坏 B。测试就是那张安全网。

十一、最后说说我的整体理解

拆完 OpenClaw 这套架构,我最大的收获不是记住了多少类名,而是理解了几个“框架设计原则”:

第一,核心要小,边界要清。
应用、中间件、插件、路由、状态,各管各的。核心不替插件干活,插件也不乱改核心。

第二,请求链路要可预测。
中间件洋葱模型、生命周期钩子、路由匹配,都是让“请求下一步去哪”变得可预测。可预测,才好排查问题。

第三,工程化不是附属品。
Monorepo、Turborepo、TypeScript 项目引用、测试缓存,这些看起来不写业务,但它们决定了一个框架能不能长期维护。

第四,性能优化要克制。
缓存、懒加载、连接池都很好,但都要看场景。别为了“看起来快”引入新问题。

如果你也是小白,我建议你读这类源码时别从第一行硬啃。
你可以按这个顺序走:

  1. 先找入口:应用怎么创建、怎么启动;

  2. 再跟一次请求:中间件怎么进、怎么出;

  3. 再看扩展点:插件怎么装、路由怎么挂;

  4. 最后看工程化:怎么构建、怎么测试、怎么优化。

你会发现,框架源码不是一座迷宫,而是一张有路标的地图。
先看骨架,再看血肉;先跑通一条请求,再研究每个零件。
这样读,小白也能读得进去。

一句话总结:
OpenClaw 这类框架真正值得学的,不是某一行业务代码,而是它怎么把“启动、请求、扩展、状态、构建、测试”这些事拆开,再用清晰的边界重新拼起来。看懂这套拼法,你再看别的框架,也会轻松很多。

相关学习资料