自注册、AST发现与中央分发派系:弹性工具运行时架构解析
先说一个我遇到的具体场景。我手里维护着一套代码分析平台最开始只有十几个工具每个新工具加进来就是改一遍注册表、配一次路由、再处理一下启动顺序虽然啰嗦倒也还撑得住。等工具数量突破五十个之后这套玩法开始崩——新工具接入变成纯体力活改的是核心代码承担风险的也是核心代码。有时候某个工具没生效排查半天发现只是清单里漏了一行。后来我把内核重做了一遍才有了这套东西的雏形Hermes Tools Runtime核心思路就三个词——自注册、AST 发现、中央分发派系。这篇文章不打算讲花哨的理论就是把这套设计拆开给你看为什么自注册能取代手工清单、AST 发现到底怎么落地、中央分发派系怎么处理事件以及我在实际接入过程中踩过的那些坑。适合正在做插件系统、静态分析工具链、或者被“工具越来越多维护不过来”折磨的工程师参考。1. 为什么需要一套“工具运行时”三个经典痛点和一套替代思路先说说清楚我讲的工具运行时是什么。它不是某个具体的框架而是一层独立于业务逻辑的基础设施负责发现系统里有哪些工具、把工具安装进注册表、再根据事件把工作派发给合适的工具。你可以把它理解成操作系统里的加载器加调度器只是服务对象从进程换成了代码分析工具。1.1 工具链膨胀之后的死结我有过一段被手工清单折磨得够呛的经历。平台刚起步时工具接入流程很简单在配置文件里加一行、在路由表里挂一个映射、把依赖通过构造器注入进去完事。但随着工具数量增长三个问题开始显性化。第一个是中央清单文件的维护压力。几十个工具共用同一份清单每次提交都在跟别人解决冲突新同事漏改一行工具就静默不生效日志里还什么都看不出来。第二个是路由表的硬编码。新增工具意味着修改主程序的分发逻辑一旦分发逻辑耦合了业务规则改一个工具就可能让另一些工具收到错误的事件。第三个是工具间状态纠缠。没有统一分发边界时工具习惯直接从共享上下文里取别人的中间结果互相之间越缠越深最后谁都拆不动。痛点传统处理方式这套运行时的处理方式工具接入改中央清单一次提交带全量细节新工具自己注册核心代码不动路由分发硬编码 if-else 或集中配置注册时自动构建路由索引工具间耦合直接共享运行时上下文通过事件隔离派系内部协作这张表对应的正是标题里那三个词自注册解决接入问题AST 发现解决工具怎么被找到的问题中央分发派系解决事件路由问题。三者是串起来的缺一个都不完整。1.2 三个概念如何拼成一套完整方案用大楼做类比。这栋楼里有很多工作室工具每个工作室入驻以后在前台登记一下自己的业务范围自注册物业平时会看一眼门口挂的招牌来决定新开的店是不是工作室AST 发现访客进来问问题前台统一接待再按业务范围把电话转给对应工作室中央分发派系。三个环节各自独立拼接起来才是一条完整的链路。选择这种架构还有一个原因让“发现”和“注册”解耦。如果没有 AST 发现自注册会退化成“每个工具自己手动调用注册函数”依然绕不开改入口代码如果没有中央分发派系注册完之后工具也不知道该去哪里接活。当然这套组合不是所有场景的银弹。它的优势区间在“工具数量多、形态相对统一、代码是静态可解析的工程环境”。后面我会专门说到哪些场景最好别用它但如果你也是做静态分析、代码生成或者构建工具链的这套思路大概率值得抄一部分。2. 自注册机制工具进门先自报家门自注册的核心思想是工具模块在被加载的那一刻主动把自己的描述信息和处理能力写进运行时的注册表而不是等外部配置文件来认领它。听起来只是换了个入口实际上差别很大。中央登记模式下注册入口是主程序的代码自注册模式下注册入口是工具自己的模块。2.1 从“中央登记”到“自动签到”我个人比较喜欢一个类比住酒店。中央登记像是前台挨个查房才能知道谁入住了自注册则是每个客人进酒店大厅时自己刷身份证办入住。前台不需要提前知道谁今天会来系统只要能接收登记就行。维度中央清单自注册新增工具需要修改主项目代码独立模块无需改动主项目接入成本随工具数量线性恶化每个工具都是常数复杂度删除工具忘记删清单留下僵尸配置模块移除后注册记录自然消失排错范围每次都要查清单和代码的一致性看注册日志即可定位实际工程里自注册带来的最大收益是并行开发。工具组之间互不知道对方的存在也不需要知道大家只要保证自己的模块在加载时调用 register 就行。2.2 注册表的核心接口与描述符先看注册表接口我用 TypeScript 风格示意。interface ToolRegistry { register(descriptor: ToolDescriptor): void; unregister(toolId: string): void; get(toolId: string): ToolDescriptor | undefined; list(): ToolDescriptor[]; } interface ToolDescriptor { id: string; // 全局限一的工具 ID name: string; // 人类可读名称 version: string; // 语义化版本 eventTypes: string[]; // 工具关心的事件类型 nodeTypes?: string[]; // 工具关心的 AST 节点类型可选 handler: ToolHandler; // 真正干活的函数 priority?: number; // 默认 100越小越先执行 enabled?: boolean; // 默认 true }这里的关键是 ToolDescriptor 里的 eventTypes。分发器不关心工具内部实现只看工具说“我关心哪些事件”。这相当于把工具的能力声明写在了注册表里注册表变成了一本活目录。我见过有人把 handler 直接扔进一个数组完事的写法短期内没问题一旦需要按类型过滤、优先级排序、单独禁用就要全部推翻重来。用描述符而不是裸函数的好处恰恰是给工具添加了可供调度系统读取的元数据。2.3 注册的时机元信息优先handler 按需加载自注册不一定意味着工具模块要在进程启动时全部加载。工程里工具一多启动阶段全部加载的成本会很难看。更合理的做法是分两步走第一步先注册描述符里面带 handler 的加载函数而不是 handler 实例第二步等分发器真正派发事件时才触发加载并执行。register({ id: unused-vars-detector, name: 未使用变量检测, version: 1.2.0, eventTypes: [file:analyzed], nodeTypes: [VariableDeclaration], loadHandler: () import(./tools/unusedVarsDetector).then(m m.createHandler()), priority: 100, });这套延迟加载模式在启动阶段只花注册表的微秒级开销实际执行时才付出模块加载和解析的成本。对代码分析平台这类场景收益非常明显工具数量从十个涨到一百个启动时间也几乎不受影响。2.4 两个容易忽略的自注册细节第一个是重复注册怎么处理。我用 id 做唯一键重复注册时比较 version版本号更高才覆盖否则只记一条告警。这样既能支持工具热更新又不会让两个版本互相覆盖导致行为漂移。第二个是注册和启用的分离。descriptor 里留了 enabled 字段注册表可以整体禁用某个工具但这不等于从注册表里移除它。运维上非常有用某个工具有问题可以先禁用再修复不必重新部署整个运行时。可以在配置中心暴露一个简单开关让运维动作从改代码变成改配置。3. AST 发现让运行时自己从代码里找到工具自注册给接入提供了通道但还有一个问题没解决新工具写的模块到底怎么被发现有些人会直接约定目录结构比如所有工具必须放在 tools 目录下文件名以 .tool.ts 结尾。这种方式能跑但是脆弱。目录一重构就失效文件被复制改名后还可能误伤而且更关键的是文件名这层信息太薄了根本无法把“这个工具关心什么事件、优先级多少”传达给运行时。3.1 为什么是 AST 而不是文件名约定或正则扫描正则扫描比文件约定稍微聪明一点但也只是文本层面的匹配。AST 的优势在于它是带结构和位置的元数据它能告诉你这一段是函数声明、这一段是注释、这两个节点之间存在什么关系。基于 AST 做发现意味着我们可以在“看懂代码结构”的前提下做判断而不是停留在字符串匹配。简单说文件名只能回答“有没有”正则只能回答“像不像”AST 能回答“是什么”。有了这个“是什么”我们才能从源码里提取出工具的描述符这个工具叫什么、关心什么事件、绑定了什么回调全部从结构里来不需要人在旁边再解释一遍。3.2 三种发现策略命名约定、标注注释、结构特征实际落地 AST 发现时我用的是三种策略的混合。发现策略判断依据适合场景主要风险命名约定导出函数名以 createXxxTool 开头或符合工程规范团队规范统一误伤同名普通函数标注注释文件头或节点前的tool注释携带元信息工具需要配置化元数据注释被压缩或剥离结构特征AST 中出现 registerTool({...}) 调用形态参数符合描述符结构自注册字段约定明确对语法糖和别名解析依赖较高我的建议是命名约定做候选集标注注释做精确识别结构特征做兜底。三层各有分工比单一策略可靠得多。工具作者在文件头写清楚tool.id和tool.event发现器看到注释就能直接解析连函数体都不需要深入分析。3.3 一次典型的发现过程给一段示例代码这是工具作者写的部分。/** * tool.id unused-vars-detector * tool.event file:analyzed * tool.node VariableDeclaration */ export function buildUnusedVarsDetector() { return async (ctx: ToolContext) { const bound new Set(ctx.scope.bindings); for (const ref of ctx.scope.references) { if (!bound.has(ref.name)) { ctx.report({ node: ref.node, message: 未使用的变量: ${ref.name} }); } } }; }运行时对它做发现处理的链路大体是六步扫描器拿到源文件路径读取文件内容。解析成 AST用 ESTree、CST 还是其他结构取决于语言解析器。先看文件头部是否带tool块注释命中后进入精确识别。在 AST 里定位到对应导出函数声明验证确实是函数导出而不是普通变量。从注释和签名中抽取描述符元数据比如 id、eventTypes、nodeTypes。把这条候选记录加入待加载队列交给自注册阶段处理。整个过程没有一个步骤需要手写清单。工具作者写完这几十行代码运行时的发现机制就能自动把他纳入体系。实测下来这种“写注释即注册”的体验非常自然工具作者几乎不需要学习额外的框架概念。3.4 AST 发现的边界与失效场景要说清楚一个容易被高估的点AST 发现不是万能的。它只能发现“在源代码结构上留下足够特征”的工具。我碰到过的失效场景不少工具由另一个工具在运行时动态生成字符串拼接导出函数名用了 eval经过压缩混淆的产物这些都很难靠 AST 还原。更现实的问题是跨语言。AST 发现本质依赖一个能够解析目标语言的解析器。如果工具仓库里混着 Java、Python、Kotlin每种语言都要有对应的解析与特征提取实现。所以我在设计里给 AST 发现留了一个口子发现结果必须能被显式清单覆盖。出问题时直接在配置里声明某工具不参与发现用兜底的人工注册保平安。3.5 发现过程的性能优化做全量 AST 扫描的成本可能会劝退很多人但其实有优化空间。第一层是缓存文件哈希只有内容变化了的文件才需要重新解析大部分文件第二次扫描直接命中缓存跳过。第二层是注释预筛选很多工具类文件会在文件头带标记或特定导出名先做低成本的文本预筛命中才走完整 AST 解析能省掉大部分无关文件的解析开销。第三层是并行解析按 CPU 核数对文件分片每片独立解析结果合并。我在模拟项目X的测试数据里一个约五百个源文件的中等规模工程带缓存的增量发现一次大约只有几十毫秒全量冷启动在三百毫秒左右这个量级完全可以接受。关键是把“发现”做成增量作业而不是每次全量重扫。4. 中央分发派系事件进入后的调度中枢自注册和 AST 发现解决了“工具怎么进来”接下来是“事件怎么被处理”。中央分发器dispatch core是这个运行时里最容易被低估的部分它做的事看起来很简单——收到事件找对工具把参数传出去拿回结果——但真要做好细节非常多。4.1 分发器到底在做什么先说“派系”这个词。我给同一种事件感兴趣的所有 handler 的集合起了这个名字。派系不是固定的一组工具而是运行时根据注册表动态计算出来的。每次事件进来分发器会先定位到对应的派系然后在这个派系内按优先级和规则执行 handler。类比的话中央分发器就像一家公司的总机访客只对总机说需求总机根据部门分工转接到对应的人。工具之间不需要互相知道对方存在也不需要自己处理事件订阅关系这让单个工具的代码保持得很干净。4.2 路由表设计事件类型目标节点类型派系路由表是实现派系检索的核心。我的设计是两层 Map第一层按事件类型第二层按 AST 节点类型或通配符。type NodeType string | *; interface HandlerBinding { toolId: string; loadHandler: () PromiseToolHandler; priority: number; timeoutMs: number; enabled: boolean; } class DispatchRouter { private routes new Mapstring, MapNodeType, HandlerBinding[](); addRoute(desc: ToolDescriptor) { for (const eventType of desc.eventTypes) { const nodeMap this.routes.get(eventType) ?? new Map(); const nodeTypes desc.nodeTypes?.length ? desc.nodeTypes : [*]; for (const nt of nodeTypes) { const list nodeMap.get(nt) ?? []; list.push(bindFromDescriptor(desc)); nodeMap.set(nt, list); } this.routes.set(eventType, nodeMap); } } }之所以要把节点类型揉进路由表是因为大量代码分析事件都带有明确的节点上下文。一个file:analyzed事件如果只按事件类型分发那么关注 import 的工具和关注变量的工具都会收到各自判断再过滤空跑很多。有了 nodeTypes 维度分发器能在路由阶段就把无关工具过滤掉一大半。4.3 分发流程的落地实现实际 dispatch 的核心逻辑大概长这样。async function dispatch(event: RuntimeEvent, ctx: ToolContext): Promisevoid { const nodeMap router.lookup(event.type); if (!nodeMap) return; const bindings [ ...(nodeMap.get(event.nodeType) ?? []), ...(nodeMap.get(*) ?? []), ].sort(byPriority); for (const binding of bindings) { if (!binding.enabled) continue; try { const handler await binding.loadHandler(); await withTimeout(handler(ctx, event), binding.timeoutMs); } catch (err) { runtime.logger.error([${binding.toolId}] handler failed, err); } } }注意几个关键选择。一是 lookup 只做索引查询不做遍历扫描路由的性能是 Map 查找级别的。二是 handler 加载放在派发链路内部首次派发的工具会慢一点后续命中缓存不再重复加载。三是整个循环里单个 handler 的异常被捕获、记录不会中断同派系其他工具的链式处理。错误隔离这个点值得多说一句。分析类工具往往在跑批任务里被调用一次事件要过十几个 handler如果其中某个工具抛异常就把整条链路截断其他工具都会平白丢掉这次机会最后排查时还特别容易误判成“后面的工具都没跑”。捕获异常让链路继续往前走最多是一个结果缺失不会影响整体任务完成。4.4 优先级、超时与任务隔离优先级的设计。默认值我设成 100数字小先执行。有的工具场景要求严格顺序比如 import 清理必须在依赖分析之前跑那它的优先级设成 50依赖分析保持默认就能稳定保持顺序。但如果两个工具设置同样的优先级且顺序敏感这属于定义不清分发器只能保证同一优先级下注册表的稳定序不承诺业务顺序。超时也要专门做一层。工具执行外部 IO、网络调用时可能出现长尾不给超时控制一个派系里有几个慢工具事件延迟就会被拉高好几倍。我的做法是为每个 handler binding 配置默认超时比如 500ms超时后丢弃该 handler 的执行结果并记录告警。还有一个取舍是派系之间的执行策略。默认同派系内串行执行保证共享上下文的顺序一致性不同派系之间可以并行。因为不同派系关心的事件不同共享状态很少并行能明显提升批处理吞吐。前提是工具必须声称自己使用的上下文访问级别等于把责任写清楚避免出现隐蔽的共享读写竞态。4.5 避免中央分发器成为新的单点把所有事件都交给一个中央分发器最大的隐患是它自己变成单点。分发器挂了整个工具链停摆。我后来做了三件事缓解这个问题第一路由表快照。分发器启动时把路由表导出成只读快照读多写少的场景下直接查快照减少锁竞争。第二分级派系。一个大的分发器拆成几个子分发器各自管一组派系父级分发器只负责聚合结果和错误。第三把分发器的运行日志单独抽出来每个派系单独记录耗时反过来也能帮助我们定位是哪一步拖慢了全链路。这三件套做完之后中央分发器不再是一个不可替代的总闸门而是更像一个可观测、可降级的前端调度面。5. 从新工具接入到事件路由完整链路拆解为了把前面几章的机制串起来拿一个实际跑过的场景说事。模拟项目X是一个跨平台代码分析工具集里面已经有三类工具在工作工具 A 负责检测循环依赖工具 B 负责格式化 import 声明工具 C 负责标记废弃 API。三者都跑在 Hermes Tools Runtime 之上共享 AST 扫描产生的文件事件和节点事件。5.1 三种工具的差异化路由这三个工具形态各异A 关心的是模块图的边B 关心 import 节点C 关心函数调用节点。如果走传统中央清单模式每次新增一个工具负责核心平台的开发者都得在路由文件里追加分支。而在 Hermes 的架构里工具 A、B、C 各自只是注册表里的一行描述符互不干扰。例如工具 B 注册时会声明register({ id: import-formatter, name: import 格式化, version: 2.0.1, eventTypes: [file:analyzed], nodeTypes: [ImportDeclaration], loadHandler: () import(./tools/importFormatter).then(m m.createHandler()), priority: 100, });它只关心 ImportDeclaration 节点跟 D 关心的 VariableDeclaration 节点完全错开事件进来时各自走各自的路由。5.2 新工具接入的四个动作现在临时需求来了要新增一个未使用变量检测工具 D。在 Hermes 上接入 D整个过程只有四个动作。第一步新建一个独立文件写上带tool标记的头注释。第二步实现导出函数函数返回一个标准的 ToolHandler里面利用上下文里的 scope 信息做引用分析。第三步在模块尾部调用 register 把自己注册进去。第四步本地起一次开发环境让 AST 发现机制扫一遍确认 D 出现在注册表里。这四个动作里没有任何一个是改主项目代码的。D 不关心 B 存在不存在也不关心 C 的优先级比自己高还是低。它只需要在注册表里声明“我关心 file:analyzed 事件里的 VariableDeclaration 节点”就够了。5.3 运行时内部的完整处理时序把这一套完整跑一遍时序列出来是这样。启动阶段运行时发起一次 AST 全量发现扫描源码目录解析命中的候选文件把 D 的模块加入待加载队列接着模块加载执行D 调用 register注册表里出现新版描述符路由表同步生成 D 对应的事件与节点类型索引。运行阶段一次源文件变更触发file:analyzed事件分发器按路由表查到 D 和 B派系内按优先级先跑 B再跑 D各自报告检测结果最后运行时统一收集结果、写日志、返回调用方。这一步之间的衔接之所以顺畅是因为注册阶段已经把所有路由信息算好了事件进来时不需要再动态解析工具代码。代价只是启动登记换取运行时的干净分发。5.4 实测数据与资源开销我在自己搭的基准环境里跑过一组数据受限于测试机器数字只当参考但趋势能说明问题。工具数量从 10 涨到 50 再到 120AST 发现冷启动分别大约 80ms、180ms、300ms增量发现基本在几十毫秒以内事件分发的时间则几乎是平的都在微秒级别完成路由查询主要消耗在 handler 执行本身。这说明这套架构的性能瓶颈不在调度层而在工具实际干活的部分。工具数量AST 发现冷启动增量发现单事件路由耗时10约 80ms约 15ms 0.1ms50约 180ms约 30ms 0.1ms120约 300ms约 45ms 0.1ms路由耗时稳定是设计使然因为路由查询就是两次 Map 查找加一次排序。真正要盯的是长时间运行后的内存占用工具模块按需加载后会留在缓存里如果工具数量上百每个工具都持有 AST 引用内存可能悄悄涨上去。我给运行时加了模块缓存淘汰机制超过 LRU 阈值的 handler 缓存会被回收下次事件再重新加载。6. 踩过的坑和不建议无脑照搬的部分任何架构都有代价这套运行时也一样。下面这些坑是我实际踩过的写出来让你有个预期不要等上线之后才被它们教做人。6.1 AST 发现的误报与漏报先说误报。命名约定策略曾经常把普通函数当成工具。比如有人写了一个buildReportGenerator()函数名字符合buildXxx形态但它的产出是普通业务数据不是工具处理器。跑完发现流程后注册表里多了一条无意义的条目它不会干活但会占用派系的一个位置。这种误报不致命可会污染监控数据排查问题时还会平白多出干扰项。漏报更隐蔽。有的工具实现是动态的入口函数在代码生成器里以模板字符串拼出来AST 发现根本找不到它。处理方式只有一个容忍漏报但要给人工入口。我在发现层特意维护了一张“未发现清单表”把这种特殊工具手工加进去发现机制扫到之后从“候选”升级成“认证”。6.2 并行加载带来的自注册顺序抖动刚上线时我让工具模块并行加载谁先加载谁先注册结果发现一个问题两个优先级相同的工具注册顺序在不同进程里可能不一致。看起来无关紧要但对事件结果有顺序要求的场景比如报告合并输出顺序每次都可能不一样测试用例在 CI 上随机失败。这个现象排查起来很折磨人因为失败用例的报错信息本身是对的只是顺序跟预期不一样。我最初怀疑是缓存问题反复清理之后发现还偶发又开始怀疑测试框架的并发调度。最后是通过给每个报告文件打上注册序号才定位到根本原因并行加载导致两个工具的注册先后不确定而当时的实现里注册序号直接决定了默认顺序。解决思路不复杂注册表只负责收集数据不承诺顺序真正的顺序在 dispatch 阶段通过排序确定。我把排序的 key 改成三元组priority、注册序号、工具 id。注册序号按注册时间递增工具 id 做最后的稳定化锚点。这样既保持人工设置的优先级优先又能让顺序在任何一次重启后都完全可复现。6.3 中央分发器的“过度中心化”风险把太多逻辑塞进分发器之后它可能变成一个重闸门既要过滤事件、又要做权限控制、还要记录指标、还要做负载均衡。我一开始也是这种写完所有逻辑的思路结果分发器本身的测试成了最重的部分任何一处改动都可能影响所有派系。后面我拆了。过滤事件的任务回归路由表本身的索引能力指标记录改成旁路钩子权限控制不下沉到分发器而是放在业务入口处拦截。分发器只保留“查路由、加载 handler、执行链、捕异常”四件事。这个教训是分发器的力量来自简单复杂度一进去崩溃风险也跟着进去。6.4 这套架构不适合哪些场景第一类是不适合强管控场景。如果你的平台是面向外部开发者开放的插件市场必须用签名校验和白名单那么 AST 自动发现带来的便利会变成安全漏洞这个场景应该用显式清单加签名验证而不是自动发现。第二类是不适合动态语言热插拔场景。运行时按 AST 得到的静态结果一旦失效工具能否正常工作就变成概率问题调试门槛很高。第三类是不适合工具数量很少的场景。总共就三五个工具手写清单五分钟搞定搭一套发现器和分发器的成本远大于收益没必要为了架构而架构。6.5 混合模式用显式清单约束自动发现如果看完前面还在犹豫我的建议是不要二选一直接上混合模式。默认开启 AST 发现但允许在配置里通过显式 manifest 覆盖。显式清单里可以声明三条规则强制注册某些工具、强制禁用某些被发现的工具、调节优先级。这层配置不需要写死在新工具里而是放在运行时的配置中心。它的好处在于日常工作是“零配置”新工具写完被发现机制自动纳入出了问题时手工清单又能兜住所有异常情况。两条路都有光不想被自动发现误伤的时候就手工点名不想被手工维护拖累的时候就靠自动发现。这套运行时落地之后我看问题的视角变了不少。过去接一个新工具第一反应是“改哪个配置、动哪段路由”现在更像是在设计一条流水线工具写完了流水线自己把它吸进去。技术上的收获当然有但更深的体会是架构的弹性不是靠功能堆出来的而是靠边界划出来的。自注册划出了工具与运行时的边界AST 发现划出了源码与元数据的边界中央分发派系划出了事件与处理的边界每条边界都消除了一类人工成本。最后再分享一个小技巧。工具头注释里的元信息不要只写给人看尽量写成结构化、能被程序解析的键值对。tool.id、tool.event这种形式看着简单实际上让 AST 发现器的实现省了一大半力气也避免了一个“正则猜猜猜”的义务。先把注释格式定死后端的解析逻辑就可以永远不用改。