字节笔记本
2026年8月29日
gocyclo:给 Go 函数算圈复杂度
圈复杂度听着像教材里的指标,落到 Go 仓库里其实很具体:哪个函数分支太多、单测要覆盖多少条路径、评审时该不该拆。 fzipp/gocyclo 就是干这件事的小命令,扫 .go 文件,给每个函数打一个整数。GitHub 上大约一千六百星,许可证 BSD-3-Clause,当前模块版本是 v0.6.0。
它在数什么
McCabe 圈复杂度统计的是「穿过函数的独立路径条数」。gocyclo 的规则写在仓库 README 里,源码 complexity.go 里更细一点:
- 每个函数从 1 起算
- 遇到
if、for、range各加 1 switch/select里每个非 default 的case加 1&&、||各加 1
default 分支不计。数字越大,穷尽路径要写的测试越多,人读起来也更容易绕。它不替你判断对错,只把「这块可能该拆」标出来。
拿一个常见 HTTP handler 看一眼:
func HandleOrder(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
id := r.URL.Query().Get("id")
if id == "" || !valid(id) {
http.Error(w, "id", http.StatusBadRequest)
return
}
order, err := load(id)
if err != nil {
http.Error(w, "load", http.StatusInternalServerError)
return
}
switch order.Status {
case "paid":
fulfill(order)
case "pending":
remind(order)
default:
reject(order)
}
}三个 if、一个 ||、两个非 default 的 case,再加上底数 1,大约是 7。评审里看到 20、30 的函数,多半已经叠了多层状态机,值得单独拆。
安装
需要本机有 Go 工具链(模块要求 Go 1.18+)。安装命令行:
go install github.com/fzipp/gocyclo/cmd/gocyclo@latest二进制会进 $GOPATH/bin(常见是 ~/go/bin)。这个目录不在 PATH 里的话,执行 gocyclo 会提示找不到命令,把该路径加进 PATH,或直接用完整路径调用。
没有 GitHub Release 安装包,走 go install 即可。也可以当库用:import "github.com/fzipp/gocyclo",再调 gocyclo.Analyze,适合写自己的扫描脚本。
先扫一遍仓库
在模块根目录:
gocyclo .
gocyclo ./cmd ./internal
gocyclo main.go不加参数会把扫到的函数全列出来,按复杂度从高到低。每一行四个字段:
<复杂度> <包名> <函数名> <文件:行:列>
方法会带接收者,例如 (*Server).ServeHTTP。行号指向函数声明,方便编辑器跳过去。
只看最复杂的前几名:
gocyclo -top 10 .顺手看全仓库平均:
gocyclo -avg .
gocyclo -avg-short .-avg 会在列表末尾打一行 Average: 2.72 这种。-avg-short 只打数字,方便脚本抓。平均值是对所有分析过的函数算的,不是只对打印出来的那几条。所以 -top 10 -avg . 里的平均数,仍然覆盖整个扫描范围。
用阈值卡住 CI
日常最有用的是 -over:只显示复杂度大于 N 的函数。只要集合非空,进程以退出码 1 结束。
gocyclo -over 15 .退出码 0 表示没有函数超过阈值,1 表示有,适合直接挂在 make check 或 CI 步骤上。注意这里是「大于 N」,gocyclo -over 15 放过复杂度正好是 15 的函数。
阈值别一上来就卡到 10。老仓库第一次跑,可能刷出几十条;先用 -top 20 看分布,再把 -over 设在当前最高值附近,每次重构往下收一档。很多团队落在 15 到 20。golangci-lint 文档里也写过类似建议:默认 30 偏松,推荐 10 到 20。
Makefile 可以写成:
.PHONY: gocyclo
gocyclo:
gocyclo -over 15 -ignore '_test\.go|\.pb\.go|vendor/|testdata/' .GitHub Actions 里装一次再跑:
- name: gocyclo
run: |
go install github.com/fzipp/gocyclo/cmd/gocyclo@latest
gocyclo -over 15 -ignore '_test\.go|\.pb\.go|vendor/|testdata/' .跳过生成文件和测试
-ignore 吃的是正则,匹配的是文件路径。vendor、protobuf、mock、wire 生成文件、测试文件,都可以挡掉:
gocyclo -top 20 -ignore '_test\.go|\.pb\.go|_gen\.go|wire_gen\.go|mock_|vendor/|testdata/' .生成代码往往是巨型 switch,圈复杂度虚高,拿它卡 CI 没有意义。测试文件里的 table-driven 大函数同样容易爆,要不要计入看团队习惯:想管生产代码就忽略 _test.go;想连测试一起管,就把这段从正则里拿掉。
个别函数实在拆不动(协议状态机、外部生成后手改过的解析器),可以在声明上一行加指令:
//gocyclo:ignore
func parseWireFormat(b []byte) (*Msg, error) {
// ...
}
//gocyclo:ignore
var hook = func() {
// ...
}函数字面量也能标。这是仓库提供的定向豁免,比把整个文件扔进 -ignore 更干净。加了就要在评审里说清楚为什么留下这个大函数,别当成默认开关。
接到 golangci-lint
已经在用 golangci-lint 的话,不必单独装命令行。它内置同名 linter gocyclo,底层就是这套算法。当前配置格式(v2)类似:
linters:
enable:
- gocyclo
settings:
gocyclo:
# 达到这个值就报。默认 30,文档建议 10 到 20
min-complexity: 15这里是「大于等于 min-complexity 就报」,和命令行 -over N(严格大于 N)差 1。两边要对齐的话,命令行用 -over 14 去对应 min-complexity: 15,或者统一只走一条路径,避免 PR 里一个红一个绿。
生成文件继续用 golangci-lint 自己的 exclusions,不必再写一份 -ignore:
linters:
exclusions:
paths:
- '\.pb\.go$'
- 'wire_gen\.go$'
- vendor$同仓库里还有 cyclop(圈复杂度,还能卡包平均)和 gocognit(认知复杂度)。gocyclo 只数分支节点,嵌套深度不加权;觉得「三层 if 比三个并列 if 更难读」时,可以再开 gocognit,两套数字一起看。
数字高了怎么拆
gocyclo 不会自动重构。常见拆法很土,但有效:
- 把
switch里每个 case 提成独立函数,handler 只做分发 - 一长串
if err != nil { return }本身加得不多,真正涨数字的是嵌套的业务分支,把校验、取数、落库拆开 &&/||一多,先抽布尔函数,例如if canSettle(order),复杂度从表达式挪到名字上,调用点会好读很多- 协议解码这类天生多 case 的函数,拆完仍然可能高于阈值,用
//gocyclo:ignore并在旁边注释协议版本
先跑 -top 10,从最上面那条开始。平均复杂度掉下来通常比把所有函数压到 10 更有用。
仓库地址:github.com/fzipp/gocyclo,包文档在 pkg.go.dev/github.com/fzipp/gocyclo。