公司动态

Go monorepo 死代码检测:从入口标记到 CI 落地的完整实践

📅 2026/8/30 17:37:37
Go monorepo 死代码检测:从入口标记到 CI 落地的完整实践
在 Go monorepo 里排查 dead code比单仓库麻烦得多。仓库里有几十个 cmd 入口、几百个业务包、跨模块的 internal 引用单靠go vet或者只针对单个 module 的 deadcode 工具很难把不可达代码完整找出来。Deadmono 这个项目的目标很直接detect dead code in Go monorepo把整个仓库的入口集合整理出来再去判断哪些代码真正不可达。适合维护中大型多模块仓库、正在做存量清理或想降低维护成本的技术团队关注。我最关心的不是它报出了多少死代码而是它能否在真实的多模块结构里把误报压住让后续删除动作可以安全推进。下面按我自己的落地思路拆一遍。先讲清楚为什么 monorepo 场景会特殊再讲分析对象、核心流程、误报判断最后说大规模仓库怎么接 CI 和分批清理。1. Monorepo 里的 dead code 为什么比单仓库更难发现1.1 编译通过不等于代码活着很多团队判断一段 Go 代码能不能删第一反应是“编译一下没人引用就删”。这个判断在单模块小仓库里接近有效在 monorepo 里基本不成立。Go 编译器的链接粒度是 package不是 function。go build ./...能通过只代表仓库里每个包自身语法正确、类型检查通过、依赖图完整并不代表每个函数都被真正调用。一个包里的私有函数可以被全仓库无视依然编译成功一个导出函数没有内部调用者同样不影响构建。在 monorepo 里更隐蔽的是两种场景A 模块里的函数没被 A 模块和 B 模块引用但被 C 模块通过 go workspace 或者 replace 指令间接使用。某个函数只被配置文件、计划任务入口、命令行参数分支或者测试数据引用静态文本里搜不到函数名代码里也确实没有直接调用。这些情况下“能编译”和“被使用”是两套完全不同的问题。Deadmono 这类工具的价值就是尝试把“被使用”这件事量化而不是靠编译结果去猜。1.2 常规死代码检测工具在 monorepo 下的盲区单模块场景下大家常用这几类方法golang.org/x/tools/cmd/deadcode从入口开始分析调用图输出不可达函数。go vet里的 unreachable 检查主要看单个函数内是否有不可达语句粒度很小。staticcheck的 U1000 检查能发现未使用的代码元素但对跨模块和反射场景仍然偏保守。这些工具在普通仓库表现不错一旦进入 monorepo会陆续出现盲区。第一个盲区是入口定义不清。单模块仓库通常只有一两个main包而 monorepo 可能有几十个独立 cmd 目录每个目录都可能对应一个发布产物。只分析当前 module 内的 main 包会漏掉其他 module 里的真实入口。第二个盲区是跨模块依赖。A 模块暴露的公开函数是否被 B、C 模块使用单模块工具不会跨 module 去构建完整索引。它会把很多“当前模块内没人调用”的导出函数直接归类为死代码结果误报率非常高。第三个盲区是 library 包。monorepo 里大量代码以 library 形式存在它们不上线、不编译成二进制只被内部其他模块 import。如果工具不理解“凡是导出的公开 API 都不能简单判死”这个规则就会把整个包标记成可清理。Deadmono 这类专门面向 monorepo 的工具至少要处理这些盲区识别全部模块的入口、跨模块构建引用关系、区分“仓库内死代码”和“外部使用者可能调用的公开 API”。1.3 Deadmono 的定位面向多模块入口体系从项目描述和命名看Deadmono 不是要替代go vet和staticcheck而是补上它们在 monorepo 下的空缺。它的核心分析对象不是单个 main 包而是一整套入口体系所有 module 下的main包被内部工具、定时任务、脚本显式运行的命令行入口对外服务的 HTTP/gRPC handler 注册入口需要保留的公开导出 API以包名为单位按规则跳过。从这些入口出发按调用关系标记可达代码。标记结束后仍然没有被任何入口触达的代码才进入候选清理范围。这个思路和单模块 deadcode 工具一致差别在于“入口”不再是程序自动猜的默认值而是可以按仓库实际情况配置。配置越准确误报越少。2. 先确认分析对象仓库结构和入口点2.1 大型仓库里哪些符号真正算“入口”想降低误报第一步不是运行工具而是整理入口清单。我一般会把入口分成四类分别处理。入口类型典型特征处理策略二进制入口cmd/*下的 main 包会构建为独立可执行文件必须标记为入口服务注册入口HTTP handler、gRPC service、消息队列 consumer 的注册函数必须标记为入口内部库的公开 API被其他模块 import 的导出函数、类型、方法按公共 API 保留或单独配置白名单脚本和代码生成入口go generate、CI 脚本调用的工具包、插件加载点按实际调用链分析不能一刀切很多仓库的 dead code 集中在第一类和第二类之间的“灰色地带”。比如某个包的RegisterRoutes函数静态扫描时没有人直接调用它但 main 函数在初始化流程里通过反射或配置文件注册了它。单纯从调用图看这就是死代码从业务上讲它是核心入口。在运行 Deadmono 之前我建议先在仓库里搜一遍这些关键模式func main()出现的目录http.Handle、r.Handle、Register、MustRegister这类注册函数grpc.Register、pb.Register开头的方法go:generate指令附近被调用的工具函数。把这些结果整理成入口清单再让工具基于清单分析会准确很多。2.2 准备环境时先确认 Go 模块边界和分析依赖分析 monorepo 死代码前环境准备看起来简单实际坑不少。首先要确认仓库是怎么组织多模块的。常见有三种单go.mod 大量目录本质是一个大模块。多go.mod分布在子目录各自独立。go.workworkspace 管理多个 module开发时统一构建。Deadmono 要解决的是后两种尤其是多 module 结构。如果仓库只有一个go.mod那用单模块 deadcode 工具可能就够未必需要额外引入项目。如果仓库已经使用go.work先确认分析工具能否识别 workspace 的模块列表。其次要确认 Go 工具链版本。静态分析工具通常紧跟 Go 版本如果仓库里同时存在多个 Go 版本编译出来的代码分析结果可能会因为标准库分析不同而产生差异。我的建议是在准备引入 Deadmono 时先用和 CI 一致的 Go 版本跑分析避免本地和 CI 结果不一致。第三个要注意的是 build tags 和平台差异。monorepo 里常见_linux.go、_windows.go、//go:build条件编译文件。如果没有明确指定平台工具只会分析当前环境的编译路径其他平台独有代码会被当成死代码。这是一个非常典型的误报来源。这部分的处理策略是跑分析时把目标平台设置成仓库实际发布的平台比如GOOSlinux GOARCHamd64如果仓库有多个发布平台就分别跑一遍再合并结果。2.3 资源占用预判全仓分析为什么容易内存紧张Go 静态分析工具最耗资源的阶段是构建调用图和全量类型检查。单仓库几百个包还好monorepo 几千个包时类型检查会消耗大量内存。如果分析工具还需要把每个 module 的依赖索引加载进内存内存增长会更快。以我碰到的仓库规模为例一个包含 800 到 1200 个包的 Go 仓库全量分析时内存可能冲到 4GB 到 8GB。低配置开发机跑起来会有明显卡顿。这不代表工具本身有问题而是全量分析本身就贵。更稳妥的做法是本地先用小范围模块分析不要直接全仓扫描分析前关闭 IDE、浏览器等吃内存的进程用 CI 专用任务跑全量分析本地只做抽样验证如果支持增量分析或缓存优先开启。如果你在自己的机器上跑全仓分析出现 OOM不要急着怪工具。先看是不是构建缓存没开、单次分析的包数量过大、或者模块索引没有复用。这三个原因占了绝大多数情况。3. 核心检测流程入口标记、调用图构建、不可达输出3.1 第一步定义入口集合而不是让工具猜我认为死代码分析工具最大的使用误区就是“直接运行然后相信输出”。除非仓库非常简单否则第一步必须先给工具一份入口列表。按照我前面说的四类入口入口集合大概可以这样组织# 示意格式不代表真实项目命令 deadmono \ -modules ./module-a,./module-b,./module-c \ -entry ./cmd/api/... \ -entry ./cmd/worker/... \ -entry ./internal/server/... \ -exported-api keep这里核心参数有三个模块列表告诉工具分析哪些范围避免把 vendor 或第三方依赖一起分析入口集合所有 main 包、注册函数、被框架调用的初始化点公开 API 策略哪些包下所有导出符号都按保留处理。如果工具本身没有专门参数也要在仓库里建立一个deadcode.ignore或者白名单文件把不应该判死的包路径写进去。这个文件的价值会在第一次误报后体现得很明显。3.2 第二步按包依赖和调用链做可达性标记入口定义清楚后分析器会做两件事构建包依赖图然后沿着调用链做可达性标记。这个过程的原理可以理解成图搜索从每个入口函数开始把入口直接调用的函数加入“已访问”集合递归分析这些函数内部的调用关系跨包调用时进入对应包的函数继续展开遇到接口方法调用、反射调用、go 语句、defer 等特殊结构时采用保守策略最终没有被访问到的函数进入候选死代码列表。伪代码大致是这样的def mark_reachable(entries): visited set() worklist list(entries) while worklist: func worklist.pop() if func in visited: continue visited.add(func) for callee in resolve_calls(func): if callee not in visited: worklist.append(callee) return visited这段逻辑不复杂难点在于resolve_calls怎么处理间接调用。直接函数调用好办接口方法调用需要找到所有实现该接口的类型反射调用在静态分析里基本无法准确求解只能靠过滤和配置处理。这就是为什么 Deadmono 这类工具在 monorepo 里需要“整个仓库一起分析”。如果只看单个模块跨模块调用链会断掉接口实现类也无法完整枚举。分析完成后死代码候选通常是按包分组的。每个候选会包含包路径、函数名、所在文件、行号这些基础信息。有些工具还会输出“为什么不可达”也就是缺少哪条调用链能到达它。3.3 第三步输出报告时一定要带包路径、函数名、引用来源死代码清单有用没用关键在于信息是否完整。我见过有些工具只输出一行函数签名删代码时完全没法定位最后还是要靠grep重新找。一个好的报告至少应该包含这些字段字段作用包路径定位到具体模块函数/类型/方法名明确符号文件路径和行号方便直接跳转符号类别函数、方法、变量、类型、常量候选死代码原因不可达、未使用、被排除入口是否导出判断是否参与公开 API 保留策略拿到报告后不要急着批量删除。先按“导出/未导出”分类再按包维度统计。未导出的私有函数通常是安全候选导出的函数和方法需要再对照入口策略判断。如果工具支持建议把报告导出成 JSON 或者结构化文本方便后续用脚本按模块拆分分发到不同负责人手里。CSV 也可以但字段一多容易乱JSON 更适合做增量比较和 CI 集成。4. 结果里哪些是真死代码哪些是误报4.1 从“不可达”到“可删除”还需要判断条件分析结果给出的是“不可达”不等于“可以删”。中间还隔着一层业务判断。我认为可以进入删除候选的代码至少满足下面几个条件代码从入口不可达且不是公开导出 API搜索整个仓库包括测试文件没有字符串、配置、反射方式引用不在生成代码目录里或者生成后确实没被任何入口使用没有在文档、示例、注释中作为对外使用方式被推荐不是通过插件机制、动态链接、外部进程间接调用。这些条件里前三个可以用脚本自动化检查后两个需要人工判断。Deadmono 能做的是把“不可达候选”过滤出来但最终确认删除仍然需要至少一次人工复核。我一般会建议团队把死代码清单分三个等级高置信度未导出、无引用、无配置引用、不在生成目录可以直接提交删除中置信度导出的函数但仓库内无调用需要确认是否对外发布低置信度涉及反射、动态注册、代码生成、跨仓库依赖不建议直接删除。低置信度这部分不是工具误报而是静态分析本身的边界。任何工具都绕不开反射调用和动态加载的取模问题。4.2 反射、序列化、动态加载是最常见的误报来源Go 的静态分析在遇到界面反射时基本只能靠猜。好在仓库里的反射场景大多数有规律可循。我统计过自己维护的仓库误报主要集中在三类模式第一类reflect.TypeOf或reflect.ValueOf触发的反射调用。某个结构体可能只被反射代码读取静态调用图上完全看不到引用。处理方式是把实现某个接口注册方法、被反射操作的目标类型加入白名单。第二类JSON/序列化相关的类型定义。很多 DTO 类型只是被encoding/json反序列化使用需要满足字段名和小写字段的匹配。它不需要有明确的构造函数却不能被删除。处理方式是所有实现了MarshalJSON、UnmarshalJSON、json.Marshaler、json.Unmarshaler接口的类型默认保留。第三类代码生成器输出。protoc-gen-go生成的*.pb.go、gqlgen 生成的*.generated.go从 main 入口分析时可能找不到调用方但它们会被其他代码 import。如果工具没做生成文件过滤这些文件会大面积进入死代码清单。处理这三类误报的经验是先跑一次分析把报告按文件目录过滤掉生成目录再按接口注册方法排除反射场景最后看剩余清单。大部分高置信度死代码会集中在这时暴露出来。4.3 测试代码和生成代码要不要一起清理测试代码要单独看待。_test.go文件里的函数、类型、变量通常不参与主二进制构建。有些工具默认不分析测试代码这会漏掉一种情况测试文件引用了某个生产代码函数但生产代码函数没有其他调用者。这种函数从生产入口看是死代码从测试入口看是活代码。我的处理建议是默认把测试文件加入分析范围但独立统计。当一个函数只被测试引用其他生产代码都不用时它仍然有删除价值只是需要同时清理测试文件和待删除函数不能只删一边。生成代码则相反。无论有没有被入口引用都不建议完全交给静态分析判断。因为生成代码会随上游定义变化而重新生成手动删除后下一次生成又会回来。正确做法是把生成目录加入忽略列表除非你确定生成配置已经停止输出该文件。5. 大规模仓库落地分批执行、缓存、CI 接入5.1 不要一次扫描整个仓库规模越大越要克制“全仓分析”的冲动。全仓分析问题很多耗时长、内存大、误报多、结果难消化。我建议从三个维度拆分。按模块拆分先跑一个业务模块比如./module-a观察输出数量和误报类型确认配置没问题后再扩大范围。按入口类型拆分第一轮只分析 cmd 二进制入口把最基础的死代码找出来第二轮加入服务注册入口第三轮再加入公开 API 白名单。每一轮都是对配置的校准。按报告置信度拆分处理报告时先处理高置信度清单不要在低置信度清单上花大量时间。分批执行的核心目标是让每一次分析结果都可控。全仓几千个候选死代码一次性抛出来团队根本没法处理最后结果就是没人管下次再跑一次又重复一遍。5.2 增量分析和 CI 门禁怎么设计CI 接入死代码检测最有价值的位置是合并请求检查而不是定时全量扫描。全量扫描适合每周或每月跑一次输出趋势报告。合并请求检查适合只报告本次改动引入的死代码。如果每次提交都跑全量分析几千个包跑下来开发者等结果的时间比写代码还长。增量检查的思路是这样的对比分支和目标分支的代码差异只分析有改动的包及其依赖范围跑完分析后把新增的死代码候选过滤出来如果新增候选数量超过阈值则 CI 失败。这个方案需要一个额外能力能够缓存之前的分析结果。否则每次 PR 都从零开始分析还是慢。缓存粒度可以到包级别包没变就直接读取上一次结果。CI 门禁我不建议直接设成“0 死代码”那样历史存量会阻塞所有新代码提交。更合理的配置是历史死代码候选全部记录但不在 PR 中强制处理新增死代码候选数量超过 0 或某个小阈值时CI 失败定期任务扫描全仓把新增量同步到负责人。这样团队既能逐步清理存量又不会因为历史问题卡住日常开发。5.3 清理动作要配合提交历史和责任人死代码清单真正落地到删除需要配合仓库的提交历史。我推荐三种辅助信息第一最后修改时间。如果一个函数已经被判断为死代码同时在 git 记录里好几个版本没有变动过删除风险相对低。如果最近一次提交就在一两个版本前需要多问一句“是不是正在被改造”。第二代码负责人。按 git blame 或者 CODEOWNERS 文件把清单分给对应负责人。很多人看到“全仓库死代码”不觉得与自己有关按目录拆分后责任人明确推动起来容易很多。第三删除方式。不要一次性把所有候选代码删干净。先按模块提交删除跑一遍测试和构建再继续下一批。这样可以快速定位某个删除是否破坏了隐藏依赖。实际操作里我经常用三步删除法先给候选函数加// Deprecated: 待确认注释合入后观察一两个版本周期确认没有反馈和构建问题后再真正删除删除后跑一次静态分析确认清单里对应项消失。这个方法比“当天扫描、当天删除”稳妥得多尤其适合团队成员多、代码归属不清晰的大仓库。6. 我建议的落地顺序和几个真实坑点6.1 从新的或小规模服务开始第一次使用 Deadmono 时建议选仓库里最独立的服务或模块作为试点。这个模块最好满足三个条件模块数量少依赖关系简单有明确的 main 入口不用处理大量 library 包最近刚做过重构代码里有较多迁移后残留下来的旧函数。这类模块的分析结果最容易验证。删掉一批函数后构建、测试、运行都很快反馈能够快速建立对工具的信任。不要一上来就在最核心的业务模块上跑。核心模块包多、依赖复杂、反射和生成的代码也多跑出来的低置信度候选大量堆积反而很难评估工具到底有没有用。6.2 先处理 cmd 下的单个二进制入口试点模块里我一般会优先看cmd目录下的独立二进制入口。原因是这类入口最接近“程序真实启动点”从它分析出去的调用链最可信。而且删除cmd内部未被调用的私有函数基本不会影响对外 API。具体做法是先扫描cmd/xxx主包及其依赖包只保留主包内部未导出且无引用的函数作为清理目标删除后运行go build ./cmd/xxx和该服务的测试结果稳定后再扩展到其他 cmd 目录。这块跑顺之后再考虑 library 包的导出符号处理逻辑完全不同。6.3 别把误报处理成“删掉再恢复”处理误报时最容易犯的错误是“先删掉试试不行再恢复”。在 monorepo 里这个思路会带来两个问题一是删除代码会影响多个模块的构建。提交 A 删除了函数提交 B 因为缺少依赖而编译失败中间可能牵连一批开发和发布任务。二是恢复困难。函数删掉后如果没有人注意到依赖它的代码可能过了一两个月才被发现。此时 git 历史已经被大量提交推远恢复成本比当时高得多。更合理的做法是先用白名单方式把误报排除。每次确认一个新的误报场景就把“为什么会被误判”记录下来。比如internal/kafka包下的所有类型因为会被消息反序列化使用internal/proto包下所有生成文件忽略扫描实现了cobra.Command的RunE方法的函数全部标记为入口。这些规则积累到一定程度工具输出质量会明显提升。之后再出现的新误报往往是仓库引入了新的反射模式或生成工具这时去补规则就行。死代码清理不是一次性的“大扫除”更像是对代码库做持续维护。工具能做的是把原本靠人肉搜索和猜疑链完成的工作变成可重复的检查项。真正让清理动作安全落地的还是入口定义、误报规则和分批删改这三件事。如果你准备在团队里引入类似方案我建议先从一个小模块开始跑出第一份报告然后花一两个迭代周期校准规则。等规则稳定了再逐步覆盖到整个 monorepo。这样整个过程风险可控结果也能真正沉淀进 CI。