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

大家好,我是阿浪

本文是我自己的源码拆解笔记和思考,代码都是简化示意,不是逐行照搬。目的是让刚开始学框架源码的朋友,也能看懂一个 Web 框架到底由哪些"零件"组成。

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

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

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

  • 一个框架的项目结构为什么要这么分?
  • 应用是怎么启动的?
  • 中间件为什么叫"洋葱模型"?
  • 插件系统到底插在哪?
  • 路由怎么把 /users/:id 变成参数?
  • 状态管理为什么要搞订阅和选择器?
  • 构建、测试、性能优化这些工程化东西,为什么也是框架的一部分?

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

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

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

  • core:框架核心,应用、中间件、插件、生命周期都在这;
  • 其他包:可能是工具、适配器、测试辅助之类;
  • 根目录统一管理依赖、构建、测试。

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

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

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

json 复制代码
{
  "pipeline": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": ["dist/**"],
      "cache": true
    },
    "test": {
      "dependsOn": ["build"],
      "cache": true
    }
  }
}

这里 ​^build 的意思是:我依赖的包先构建,我再构建。 小白可以记一句话:Turborepo 管的是"谁先做、谁不用重复做"。

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

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

  1. 注册插件;
  2. 注册中间件;
  3. 启动前钩子;
  4. 组合中间件;
  5. 监听端口;
  6. 启动后钩子。

简化一下,大概是这样:

ts 复制代码
class Application {
  private middlewares: Middleware[] = []
  private plugins: Plugin[] = []

  use(plugin: Plugin) {
    this.plugins.push(plugin)
    plugin.install(this)
    return this
  }

  middleware(fn: Middleware) {
    this.middlewares.push(fn)
    return this
  }

  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 框架里最值得小白先搞懂的东西。

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

  • 看看包裹信息;
  • 加个标签;
  • 记录时间;
  • 判断要不要直接拦截;
  • 或者放行给下一层。

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

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

ts 复制代码
type Middleware = (ctx: any, next: () => Promise<void>) => Promise<void>

function compose(middlewares: Middleware[]) {
  return function (ctx: any) {
    let index = -1

    function dispatch(i: number): Promise<void> {
      if (i <= index) {
        return Promise.reject(new Error('next() 被重复调用了'))
      }
      index = i

      const fn = middlewares[i]
      if (!fn) return Promise.resolve()

      try {
        return Promise.resolve(fn(ctx, () => dispatch(i + 1)))
      } catch (err) {
        return Promise.reject(err)
      }
    }

    return dispatch(0)
  }
}

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

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

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

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

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

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

简化接口:

ts 复制代码
interface Plugin {
  name: string
  install(app: Application): void
  beforeStart?(app: Application): Promise<void> | void
  afterStart?(app: Application): Promise<void> | void
}

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

ts 复制代码
const dbPlugin: Plugin = {
  name: 'db',
  install(app) {
    app.db = createPool()
  },
  async beforeStart(app) {
    await app.db.connect()
  },
}

app.use(dbPlugin)

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

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

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

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

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

简化实现:

ts 复制代码
function pathToRegex(path: string) {
  const pattern = path.replace(/:[^/]+/g, '([^/]+)')
  return new RegExp(`^${pattern}$`)
}

const re = pathToRegex('/users/:id')
const match = '/users/42'.match(re)

console.log(match?.[1]) // 42

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

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

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

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

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

简化代码:

ts 复制代码
class Store<T> {
  private state: T
  private listeners = new Set<(s: T) => void>()

  constructor(initial: T) {
    this.state = initial
  }

  getState() {
    return this.state
  }

  setState(next: T) {
    if (Object.is(next, this.state)) return
    this.state = next
    this.listeners.forEach(fn => fn(this.state))
  }

  subscribe(fn: (s: T) => void) {
    this.listeners.add(fn)
    return () => this.listeners.delete(fn)
  }
}

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

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

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

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

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

TypeScript 配置里常见几个东西:

ts 复制代码
{
  "compilerOptions": {
    "strict": true,
    "composite": true,
    "declaration": true
  }
}
  • strict:把类型检查拧紧,早发现问题;
  • composite:支持项目引用;
  • declaration:生成类型声明文件。

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

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

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

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

1. 缓存中间件

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

简化示意:

ts 复制代码
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() 重复调用会不会报错?
  • 插件生命周期有没有按顺序执行?

路由测试简化示意:

ts 复制代码
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 这类框架真正值得学的,不是某一行业务代码,而是它怎么把"启动、请求、扩展、状态、构建、测试"这些事拆开,再用清晰的边界重新拼起来。看懂这套拼法,你再看别的框架,也会轻松很多。

扫一扫关注阿浪微信公众号

相关推荐
沛东的认知和实践1 小时前
模型会自己伪造工具结果:一次 Agent 评测暴露的 Harness 工程问题——从一句 `count=0`,看模型输出、工具执行和上下文权威的边界
llm·agent
用户70988330008491 小时前
Cloudflare 开源 security-audit-skill:让 Coding Agent 变成安全审计员
agent
weixin_435208162 小时前
大模型面试宝典—大模型(LLMs)基础面
人工智能·职场和发展·agent
死磕音视频3 小时前
Codex CLI 入门:在终端里让 AI 读懂项目并完成一次小修改
openai·ai编程·命令行·codex
灵动的艺术4 小时前
如一 Agent 架构解读(二十一):SubAgent 体系——封装、闭环与可交互
人工智能·agent·如一
绝望的蛋炒饭4 小时前
长会话的模型选型:缓存命中率如何决定真实成本
人工智能·后端·agent
Topskys4 小时前
RSI递归自我改进
agent
JGrigg4 小时前
DeepSeek 的“思考过程”到底是什么?
agent
小林ixn5 小时前
别再 MySQL + Milvus 双写了:PostgreSQL + pgvector 一张表搞定 AI 长期记忆
数据库·postgresql·agent