ByteNoteByteNote

字节笔记本

2026年8月29日

gocyclo:给 Go 函数算圈复杂度

API中转
¥120

圈复杂度听着像教材里的指标,落到 Go 仓库里其实很具体:哪个函数分支太多、单测要覆盖多少条路径、评审时该不该拆。 fzipp/gocyclo 就是干这件事的小命令,扫 .go 文件,给每个函数打一个整数。GitHub 上大约一千六百星,许可证 BSD-3-Clause,当前模块版本是 v0.6.0

它在数什么

McCabe 圈复杂度统计的是「穿过函数的独立路径条数」。gocyclo 的规则写在仓库 README 里,源码 complexity.go 里更细一点:

  • 每个函数从 1 起算
  • 遇到 ifforrange 各加 1
  • switch / select 里每个非 default 的 case 加 1
  • &&|| 各加 1

default 分支不计。数字越大,穷尽路径要写的测试越多,人读起来也更容易绕。它不替你判断对错,只把「这块可能该拆」标出来。

拿一个常见 HTTP handler 看一眼:

go
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+)。安装命令行:

bash
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,适合写自己的扫描脚本。

先扫一遍仓库

在模块根目录:

bash
gocyclo .
gocyclo ./cmd ./internal
gocyclo main.go

不加参数会把扫到的函数全列出来,按复杂度从高到低。每一行四个字段:

<复杂度> <包名> <函数名> <文件:行:列>

方法会带接收者,例如 (*Server).ServeHTTP。行号指向函数声明,方便编辑器跳过去。

只看最复杂的前几名:

bash
gocyclo -top 10 .

顺手看全仓库平均:

bash
gocyclo -avg .
gocyclo -avg-short .

-avg 会在列表末尾打一行 Average: 2.72 这种。-avg-short 只打数字,方便脚本抓。平均值是对所有分析过的函数算的,不是只对打印出来的那几条。所以 -top 10 -avg . 里的平均数,仍然覆盖整个扫描范围。

用阈值卡住 CI

日常最有用的是 -over:只显示复杂度大于 N 的函数。只要集合非空,进程以退出码 1 结束。

bash
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 可以写成:

make
.PHONY: gocyclo
gocyclo:
	gocyclo -over 15 -ignore '_test\.go|\.pb\.go|vendor/|testdata/' .

GitHub Actions 里装一次再跑:

yaml
- 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 生成文件、测试文件,都可以挡掉:

bash
gocyclo -top 20 -ignore '_test\.go|\.pb\.go|_gen\.go|wire_gen\.go|mock_|vendor/|testdata/' .

生成代码往往是巨型 switch,圈复杂度虚高,拿它卡 CI 没有意义。测试文件里的 table-driven 大函数同样容易爆,要不要计入看团队习惯:想管生产代码就忽略 _test.go;想连测试一起管,就把这段从正则里拿掉。

个别函数实在拆不动(协议状态机、外部生成后手改过的解析器),可以在声明上一行加指令:

go
//gocyclo:ignore
func parseWireFormat(b []byte) (*Msg, error) {
    // ...
}

//gocyclo:ignore
var hook = func() {
    // ...
}

函数字面量也能标。这是仓库提供的定向豁免,比把整个文件扔进 -ignore 更干净。加了就要在评审里说清楚为什么留下这个大函数,别当成默认开关。

接到 golangci-lint

已经在用 golangci-lint 的话,不必单独装命令行。它内置同名 linter gocyclo,底层就是这套算法。当前配置格式(v2)类似:

yaml
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

yaml
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

分享: