☰
codegraph:AST解析构建代码依赖图,快速读懂陌生代码库
2026/10/15 12:01:32 网站建设 项目流程

接手一个四万多行的老服务时,我意识到人的眼睛根本拼不出完整调用关系。那个项目的文档只有一句话,代码里到处是包了一层又包一层的封装,我在 IDE 里从入口函数跳到调用处,再跳回定义,来回二十几次,脑子里还是一团乱麻。后来我花两个周末写了 codegraph,一个把代码库里的函数、类、模块调用关系抽取成结构化数据,再渲染成可交互依赖图的命令行工具,配套的 graphify 子命令专门负责把扫描结果变成网页版可视化图谱。这篇文章把整个工具的设计思路、实现管线和实战用法完整拆开讲,适合想快速摸清陌生代码库的开发者,也适合准备自己写代码分析工具的人参考。

1. 为什么写 codegraph:读代码这件事,痛点比想象多

1.1 四万行代码,没有注释,还全是装饰器

那次接手的是个内部服务,功能不算复杂,但代码组织形式非常绕。业务逻辑不直接写在入口模块里,而是经过三层封装:路由、服务层、数据访问层,每一层都往上抛异常、往下调接口,再用装饰器把鉴权、日志、缓存动作全部挂在函数头上。

IDE 自带的功能在这种场景下只能算勉强可用。点一下函数名,IDE 能显示“谁调用了它”,但那是基于文本索引的,结果散成一长串列表,没有层级,没有分组,也看不出调用链的长度和方向。我试图用全局搜索一个个找,结果搜到一个方法名,出来三十几个匹配项,大部分是测试代码和相似命名的业务函数,逐个排除完,半天已经过去了。

真正让我崩溃的是排查一个生产问题。某个接口超时,我需要知道请求从入口到数据库调用中间经过了哪些函数。最终确认至少有 5 层封装、3 个中间件、2 个装饰器参与,而这些都是我在日志里翻出来的,代码里没有任何一条完整路径被直接写出来。

1.2 现有工具差在哪一块拼图

不是没有工具能解决这个问题。IDE 的 Call Hierarchy 能看单点的调用树,但对整个系统的全局结构无能为力;Sourcegraph 这类代码搜索平台能做跨仓库分析,但要搭服务、维护索引,对一个个人开发者或者小团队来说明显重了;还有 Doxygen 之类的文档生成器,确实能生成调用图,但配置复杂、输出样式老旧,而且对动态语言的支持往往停留在“能跑通,但不准”的水平。

我想要的其实很具体:一个本地命令行工具,扫一下目录,能输出一份机器可读的依赖图数据文件,同时能把它渲染成可以交互的网页图。它不需要理解业务语义,只需要把“谁声明了什么、谁调用了谁、谁导入了谁”这些静态关系提取出来,准确率和速度都要够用。

1.3 codegraph 的设计目标

所以 codegraph 从一开始就定了四条原则:

  • 本地优先:不依赖云端服务,不要求代码上传到第三方,离线可用。
  • 多语言支持:不能只做一个语言的玩具,要能覆盖我日常用的几门语言,且新增语言成本低。
  • 结构化输出:中间产物必须是标准 JSON 格式的节点和边,方便自己用脚本二次分析。
  • 可视化与查询分离:scan 命令负责扫描建图,graphify 命令负责渲染,query 命令负责问答,各管一段,互不耦合。

这个拆分帮我省了很多事。后面做 CI 集成的时候,我完全不需要关心图怎么画,只需要在流水线里跑 scan 和 query,拿到结果就够用。

2. 技术底座:AST 解析与符号抽取

2.1 用正则硬吃代码,算清楚账就不香了

普通人听到“解析代码”,第一反应是正则匹配。我早期也这么干过,用正则去找函数定义和调用,结果被现实狠狠教育了一轮。

问题出在代码的歧义性上。拿 Python 来说,foo(a, bar())到底是个函数调用还是别的什么,正则里得写一堆复杂的嵌套匹配才能勉强分清。更麻烦的是变量名和函数名同名的场景:

def process(data): result = apply_hook(data) hook = load_hook("main") hook.process(result) # 这里的 process 是方法调用 return result

第一行的process是函数定义,第四行的process是对象方法调用,第五行还有一个字典访问和字符串。正则想要区分这些,要么写出让后来者骂娘的超长模式,要么接受频繁的误报漏报。字符串里的伪调用、注释里的历史代码、被 eval 的模板片段更是灾难。

AST 解析不存在这个问题。它一次性把源码转成语法树,函数定义在树里是一个function_definition节点,方法调用是一个call节点,注释和字符串天然不会和代码混淆。我们要做的只是在树上做结构化遍历,把感兴趣的信息摘出来。

2.2 选 tree-sitter 而不是别的解析器

做 AST 解析时有过几个候选方案,最终选了 tree-sitter。理由有三点。

第一,它对语法错误容忍度极高。真实项目代码里经常有未完成的临时改动、平台相关的条件编译块、老旧的语法残留,很多解析器碰到这些就直接罢工报错,tree-sitter 会尽量构造一棵“带错误节点”的语法树,其余部分不受影响。这一点在扫描别人遗留代码时极其重要。

第二,它支持几十种语言,而且每种语言的语法定义是独立模块。我想支持新语言时,不需要动 codegraph 的主逻辑,只需要挂一个新的 parser 和一套语言相关的提取规则。

第三,它的增量解析能力很强。虽然 codegraph 第一版没用到增量特性,但后续做文件监听、增量扫描时可以直接复用同一套基础能力,不用换解析引擎。

作为对比,Python 自带的ast模块只能解析 Python,想解析 JavaScript 就得另找方案,等于每种语言写一套工具。ANTLR 功能很完整,但学习曲线和生成代码的体量都不适合做一个轻量工具。

2.3 从语法树到图数据:符号抽取规则

图数据由两个基本元素组成:节点和边。codegraph 的节点定义比较保守,只保留静态分析能确认的实体。

节点的kind分为:module、class、function、method、variable。边分为六类:call(函数或方法调用)、import(模块导入)、inherit(类继承)、decorate(装饰器/注解)、assign(变量赋值指向)、reference(符号引用)。

拿一段简单 Python 代码举例,tree-sitter 的遍历逻辑大致是这样的:

import tree_sitter_python as tsp from tree_sitter import Language, Parser parser = Parser(Language(tsp.language())) tree = parser.parse(source.encode("utf-8")) root = tree.root_node for node in root.children: if node.type == "function_definition": name_node = node.child_by_field_name("name") print("found function:", name_node.text_bytes.decode()) # 递归遍历函数体内的 call 节点

这段代码只是示意,实际实现里会用递归函数完整遍历整个语法树,维护一个调用栈来记录当前所在的函数作用域。每遇到一个call节点,就尝试解析目标函数名,结合当前作用域拼出完整的限定名,记录一条边。

为了避免把同一个函数在多次调用时生成大量重复节点,codegraph 对每个限定名做去重合并。后面建图时,module::handler::process不管在多少个地方被调用,都只是同一个节点。

3. 核心管线:codegraph scan 从零到图

3.1 文件遍历与语言分派

scan 命令的第一步是遍历目录。这里要处理的坑比想象中多:node_modules、虚拟环境目录、编译产物、测试夹具,这些都是纯噪音,如果不排除,最后的图会被无关代码撑爆。

codegraph 的规则是白名单加黑名单混合。默认忽略.git、node_modules、__pycache__、venv、dist、build等目录,同时支持用户在配置文件里手工指定跳过路径。配置文件是项目根目录的codegraph.yaml:

source_dir: ./src languages: [python, javascript, typescript, go] ignore_paths: - tests/fixtures - legacy/third_party

文件遍历和语法解析是分开的。遍历阶段只收集文件路径和语言类型,解析阶段才真正读文件内容。这种两阶段设计让我能方便地测试一个新文件映射规则,不必每次都全量解析代码。

3.2 一个符号实例的生命周期

代码里会大量出现同名函数,比如每个模块里都可能有init(),每个类里都可能有_parse()。为了区分它们,codegraph 必须生成符号的完整限定名(FQN)。

规则是这样的:模块名取相对源代码根目录的路径,把路径分隔符换成::,再加上符号名。比如src/api/client.py里的class Client内部方法call(),完整限定名就是api::client::Client::call。

这层限定名是图里节点的唯一 ID,也是后面 graphify 渲染和 query 搜索的基础。如果没有它,两个同名的parse()就会在图上被合并成一个人,所有调用关系全部串味,分析结果直接废掉。

处理跨文件导入时还要做一层解析。Python 的from utils.http import post会把post映射到utils::http::post,JavaScript 的import { debounce } from "lodash"则映射到外部依赖. codegraph 对项目内部模块做完整解析,对外部包只生成一个笼统的 external 节点,不再往下挖。

3.3 图构建与聚合

节点和边都抽取出来后,建图阶段要做两件事:去重、抽出子图。

去重解决的是循环导入和重复声明的问题。某些模块会被多个入口导入,同一个符号可能在语法树上被访问多次,这些都要在构建时合并成唯一的节点。

子图聚合则是为了控制图的规模。一个中型仓库可能有上万节点,全部摊在一个平面上没法看。codegraph 默认按模块一级聚合,让每个文件成为一个聚合节点,文件内部细节在用户点进去时才展开。这样初始渲染时只显示模块间关系,等到聚焦某个模块再加载函数级节点。

3.4 落盘格式:为什么坚持用 JSON

graph.json 是 codegraph 的核心产出品。结构很简单:

{ "meta": { "scanned_at": "2024-06-01T12:00:00Z", "source_dir": "./src", "language_versions": {"python": "3.12", "go": "1.22"} }, "nodes": [ {"id": "api::client::call", "kind": "method", "file": "api/client.py", "line": 42} ], "edges": [ {"source": "api::client::call", "target": "utils::http::post", "type": "call"} ] }

有人问我为什么不用更紧凑的二进制格式。答案很朴素:JSON 可以被任何语言直接读,可以直接放进 Git 仓库做差异对比,CI 里出了问题也能打开文件人工检查。只有在仓库规模真的大到 JSON 文件上百兆时,才需要考虑切到 Neo4j 或者内存图数据库,第一版没必要上这个复杂度。

4. graphify:把依赖图变成能看的图

4.1 可视化选型:D3 还是 G6

graphify 子命令的目的是把 graph.json 变成一个可交互的网页。这个选型我前后对比了三套方案:D3.js、ECharts graph 模式、AntV G6。

ECharts 的优势是上手快,内置的 graph 类型直接喂categories和links数据就能出图,交互也有缩放拖拽。但它基于 Canvas 渲染,节点多了以后想单独给某个节点加大量定制交互会比较别扭。D3 灵活度拉满,适合深度定制,但所有布局、缩放、力导向参数都要自己调,第一版开发成本高。G6 中间的定位更合适——它本身就是图可视化专门方案,内置了力导向、环形、层级多种布局,节点交互自定义也方便。

codegraph 第一版选择了 G6 来快速跑通,后面把布局计算抽成了独立模块,换来换去也只是换一个渲染适配层。

4.2 布局只用力导向是不够的

依赖图天然是稀疏有向图,力导向布局会让它自动把互相调用频繁的节点聚在一起。但只用一套力导向布局,大仓库出来的图就是一大团乱线,节点互相重叠,边横七竖八,看起来像一堆毛线球。

我的做法是分层加聚合。大体思路是:先按模块聚合出顶层图,走一遍层级布局,把明显的调用层级从入口往下排;用户点击某个模块后,模块内部才切换到力导向布局,显示函数级节点。这样从宏观到微观,两级视图都有合理的空间组织。

调布局参数也踩了挺多坑。力的强度、距离上限、碰撞半径都会影响最终效果,核心经验是“碰撞半径宁可调大一点”。节点间距过小的时候,视觉上虽然密集,但用户根本没法选中他想看的那个函数。间距大了虽然浪费屏幕空间,但交互舒服得多。

4.3 交互设计:点击下钻和链路高亮

graphify 的输出是单个静态 HTML 文件,所有数据内联进去,双击就能打开,不需要起服务。页面上的基本操作包括:

  • 拖拽节点、滚轮缩放、框选放大
  • 点击节点查看详情面板:完整限定名、所在文件、行号、被谁调用、调用了谁
  • 右键节点菜单:展开其全部下游依赖或反查全部上游调用者
  • 输入框支持模糊搜索节点名称,按回车后自动定位并高亮该节点的两度邻域

真正开发时我改进最多的功能是链路高亮。选定一个函数后,它能高亮出从入口到该函数的路径,或者从该函数到底层服务的路径。这种链路对定位性能问题特别有用——可以直观看到一次请求经过了多少层转发、每一层停留在大致哪个模块。

4.4 大仓库渲染优化:首屏不能等三秒

graphify 对着一个 5000 节点的图做全量渲染时,首屏会卡顿到怀疑人生。我踩过这个坑之后做了三件事:

第一,初始渲染只画聚合后的模块图,节点量控制在 500 以内。第二,布局计算放 Web Worker 里,计算期间 UI 线程照常响应,不然整个页面冻结在那里,体验极差。第三,边缘只在用户做邻域展开时按需加载,不在首屏把所有边一次性画出来。

实测一个 5 万行的 Python 仓库,scan 耗时约 10 秒,graphify 生成的最终 HTML 文件在 5MB 左右,浏览器首屏渲染 2 秒内能完成,这在一台普通笔记本上已经可以接受了。

5. 实际使用:我最常用的三个操作

5.1 从拿到源码到看到图,只需要三步

对一个陌生的新仓库,codegraph 最快上手路径是这样:

codegraph scan . --lang python,javascript --output graph.json codegraph graphify --input graph.json --output code-graph.html open code-graph.html

scan 完成后先看一眼统计信息:总节点数、总边数、孤立模块数量。孤立模块数量特别高通常意味着代码里有很多没被主流程引用的闲置模块,这些往往是历史遗留或死代码,值得重点关注。

5.2 找核心模块:看入度,别看出度

一个模块被越多其他模块调用,它在系统里的影响面就越大。codegraph 的命令codegraph query "in-degree(module)>10"直接列出所有入度大于 10 的节点,这就是基础服务层的基础设施模块。

出度大的模块往往是业务编排层,它们负责组装底层能力,但本身很少被别人依赖。用出度找入口模块,用入度找底层模块,往往一分钟就能定位到一个陌生系统的两份关键代码,省下我原先半天的熟悉时间。

5.3 找循环依赖:图论那套终于派上用场

循环依赖在 Python 项目里极其恶心,轻则模块加载顺序错乱,重则ImportError炸在线上。靠人肉去找循环依赖,需要维护一张巨大的依赖矩阵,低效且容易漏。

codegraph 的做法是把依赖图的强连通分量(SCC)算出来。理论上讲,只要有向图中存在一个环,环上的所有节点就属于同一个 SCC。命令codegraph cycles --graph graph.json会输出所有环节点,并按环的长度排序。

第一版跑出结果时,我发现项目里有一个三节点循环:api_client -> core_service -> storage_adaptor -> api_client。这三者都觉得自己在调用“更底层的模块”,实际上组成了一个环,整个链路必须靠运行时惰性加载才能绕过初始化死锁。看到图的那一刻,之前线上偶发导入失败的根因一下就明白了。

5.4 变更影响面分析:改代码前先摸清楚谁能炸

在改一个公共函数之前,把它绑定的调用方全部列出来,把变更风险提前暴露出来。codegraph 的 query 子命令支持这类查找:

codegraph query "callers(utils::parse_json)" --graph graph.json

输出是一棵完整的调用树,列出所有直接或间接调用parse_json的函数。标注了每个调用方的文件位置和触发链深度。有一次我准备给某个底层函数加参数,先跑了这个查询,发现三个不同模块都在直接调用它,其中还有一个是外部接口的入参解析。不是看到图,我根本意识不到一个看起来不起眼的工具函数承接了三条业务链路,那一改肯定要出事。

6. 开发与使用中踩过的坑

6.1 tree-sitter 版本锁定问题

tree-sitter 发展得很快,每个语言 grammar 的语法定义都在不断调整。同一个项目,第一次用某个版本的 Python grammar 扫描,和两个月后升级再扫,出来的节点分布可能有差异,导致 graph.json 在 CI 对比时出现大量无规则的预期外变化。

后来我在 codegraph 的配置里彻底锁住了 grammar 版本,fixture 测试也改为固定解析样本文件,不让线上环境去拉最新语法。这里强烈建议所有基于 tree-sitter 做分析的工具都做版本固化,否则分析结果没有可复现性。

6.2 性能瓶颈不在解析,在文件 IO 和内存往返

一开始我以为扫描一个 5 万行的 Python 项目很慢是 tree-sitter 解析太慢。后来用 profile 工具翻了一下,发现解析本身只占 30% 左右的 CPU 时间,剩下的时间全消耗在文件读取、字符串编码转换、结果对象反复分配上。

我做了两处优化:一是用多进程按文件分片并行解析,每个 worker 只负责自己那批文件;二是解析后的符号信息采用紧凑数组结构存储,减少临时对对象数量,GC 压力立刻降了下来。

优化后的一个直观对比:

仓库规模单进程耗时多进程耗时
Python 1.5 万行7.2s2.1s
JavaScript 3 万行14.5s4.6s
Python 5 万行31s9.8s

机器是八核笔记本。如果项目更大,建议直接把codegraph scan放进一个缓存目录做增量扫描,每次只处理变更文件,速度能再上一个量级。

6.3 布局“好看”和“有用”是两回事

graphify 最开始版本花了很多时间调力导向参数,想让图看起来像一张时尚海报。结果节点一多,力导向布局带来的高频抖动让用户根本没法稳定的选中一个节点。

后来我把绝大部分布局参数固定下来,不再追求动态平衡的美感。节点半径、边宽度、碰撞阈值都采取静态预设值,只在用户对某节点做“展开邻域”操作时才局部计算偏移。这么做图难看了一些,但用起来稳当得多。工具型产品,交互稳定比视觉惊艳优先级高。

6.4 静态分析的边界:假阳性怎么处理

AST 抽取终究是静态分析,对代码的动态执行无能为力。getattr(obj, method_name)()、eval("foo()")、装饰器动态生成新函数、依赖注入容器从字符串加载类,这些场景都会让 codegraph 产生假阳性或者漏报。

我在设计上做了两个妥协。第一,所有边都在数据上标注“静态分析推断”,不假装自己一定能看穿运行时的真相。第二,graphify 的页面里支持手工隐藏边和节点,用于把误报人工消除掉。配合一个manual_override.yaml,用户可以写规则让某个符号在某个范围内强制忽略。诚实处理边界,比硬撑准确率对使用者更有价值。

7. 后续想做的方向,以及你现在就能接着玩的部分

7.1 增量扫描和文件监听

现在的 scan 是全量扫描,对于一个持续演进的大仓库还是偏慢。后续想加一个 daemon 模式,启动后监听文件变化,只对变更文件做增量解析,实时更新内存里的图数据,让 graphify 刷新出的网页始终跟上代码状态。tree-sitter 的增量解析能力天然支持这个方向,改动成本不会太高。

7.2 接入 CI:架构守护规则

分析图最有价值的用法之一是做架构守护。很多团队有分层规范,比如 domain 层不能依赖 infra 层,controller 层只能调 service 层。但没有工具约束时,这类规则全凭开发人员自觉,加了代码 Review 也很难逮到所有越界调用。

codegraph 底层的依赖图数据天然适合做这件事。可以写一个codegraph check子命令,从配置文件读取一组规则:“模块路径 A 不允许依赖模块路径 B”,扫描图上是否有边命中,一旦命中就返回非零退出码,CI 流水线直接失败。这是一个我近期最想落地的功能。

7.3 跨语言统一视角与接口级图

现在 codegraph 的节点只到函数和类。如果把边界再往上提一层,抽象出 service、job、controller 这类业务组件,再把它们之间的 HTTP、RPC、消息队列调用关系解析出来,就能得到一张跨服务接口图。这对微服务架构的全局故障排查会非常有帮助。

如果有朋友想从这个方向自己动手做工具,我的建议很简单:先坚持“输出结构化数据”这个原则,别急着从可视化做起。图数据本身才是最能复用的资产,渲染只是最后一步。做可视化只是让自己看起来好看,做数据才能让别人和别的工具都能用起来。

codegraph 目前的版本已经够我在日常项目里稳定地读代码了。每次接手新项目,scan 一下,打开 graphify 生成的页面,先花五分钟看模块依赖方向,再决定从哪个文件开始读。这个方法帮我把“读懂一个老项目”的时间从几天压缩到半天以内。如果你也有读代码读到脑壳疼的时刻,不妨自己搭一套这样的分析管道,技术含量不高,但回报非常直接。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询