OpenTofu 代码复杂度 Lint 治理实践:RFC 20241113 的务实之路(从 160 个违规到重新启用检查)
OpenTofu 代码复杂度 Lint 治理实践:RFC 20241113 的务实之路(从 160 个违规到重新启用检查)
本文以 OpenTofu 仓库中的 RFC 文档 rfc/20241113-pragmatic-complexity-linting.md 为主线,完整讲述 OpenTofu 团队如何务实治理"代码复杂度"类 Lint 违规(cyclop / funlen / gocognit / gocyclo / nestif)的完整方案:为什么要先临时禁用、如何用 160 行违规清单驱动分片重构、以及清零后如何重新启用规则并理性对待
nolint例外。适合关注 Go 工程实践、golangci-lint 配置治理、以及大型 Go 代码库渐进式重构的读者阅读。
背景:代码库继承与"新代码才查 Lint"的折中
OpenTofu 的代码库是从一个更古老的项目(其前身)派生而来的。在 OpenTofu 早期,项目团队曾引入一套较大的 golangci-lint 配置,但这套配置所要求的规则,在旧代码库中几乎没有被遵守过。因此团队做了一个务实决定:只有"自规则引入后发生过改动"的代码,才强制接受 Lint 检查。
这个折中方案的目的在于:
- 在"顺手改到哪、顺手改好哪"(improve code "while we're in the area")的前提下,鼓励代码逐步变好;
- 同时避免对整个存量代码库做一次伤筋动骨的大规模返工(huge retrofit),因为那会带来巨大的 review 负担和合并风险。
但问题在于:这个折中方案对于"代码复杂度"这一类 Lint 规则尤其不适用。复杂度类规则往往是对某个定性指标设定任意上限,例如:
- 函数内的行数 / 语句数(
funlen); - 函数的圈复杂度(cyclomatic complexity)(
gocyclo); - 函数的认知复杂度(cognitive complexity)(
gocognit); - 函数的嵌套层级复杂度(
nestif); - 由圈复杂度和函数长度综合计算出的整体复杂度(
cyclop)。
这类规则覆盖面广、阈值主观,要满足它们往往需要对存量代码做非常"破坏性"的结构改动。在一个针对 bug 修复或小功能开发的 PR 里夹带这种大规模重构,是不合适的:
- 它放大了合并风险;
- 它让 diff 难以被评审;
- 实践中团队往往只能退而求其次,往代码里塞
nolint注释,结果让真正的问题更难被发现和修复。
这正是 RFC 20241113 想要解决的痛点:既要在长期达成"复杂度合规"的目标,又不能拖累其他正在进行的项目进度。
现状盘点:当时有哪些"复杂度类" Lint 在生效
RFC 引用了项目当时使用的 golangci-lint 配置。golangci-lint 官方把以下五个 linter 归类为 "complexity"(复杂度)类别:
| Linter | 度量指标 | 常见默认阈值 | RFC 中实际触发的阈值 |
|---|---|---|---|
cyclop |
综合函数复杂度(圈复杂度 + 函数长度等) | max = 20 | cyclomatic complexity ... max is 20 |
funlen |
函数语句数 / 行数 | statements > 50、lines > 100 | too many statements (51 > 50) / too long (165 > 100) |
gocognit |
认知复杂度 | > 50 | cognitive complexity 51 ... high (> 50) |
gocyclo |
圈复杂度 | > 30 | cyclomatic complexity 48 ... high (> 30) |
nestif |
嵌套 if 的复杂度 | complexity > 5 | complex nested blocks (complexity: 27) |
阈值说明:上表中的"常见默认阈值"仅为帮助读者理解该 linter 的度量语义,实际生效阈值以当时仓库
.golangci.yml中配置为准(该 RFC 写作时,项目启用了一套较大的 linter 集合)。
RFC 特别强调:golangci 对每个不同的代码行最多报告一个问题,因此"160 个问题"实际上是按代码行去重的统计;某些函数实际上同时违反了不止一个 linter。例如 internal/cloud/backend_common.go 中的 confirm 方法,既命中 gocognit(认知复杂度 84),其所在的文件也在 nestif 上有多次命中。
核心方案一:先临时禁用五个复杂度 Linter
RFC 的第一个关键动作是:在与项目其他工作解耦的前提下,临时禁用上述五个复杂度类 Linter,从而把"重构存量代码"和"继续推进其他项目"两条线彻底分开。
具体操作有两步:
-
在
.golangci.yml中用 YAML 注释(#)临时注释掉这五个 linter 的启用行。注释而非删除,是为了:- 明确表达"这只是临时状态";
- 一旦 RFC 工作完成,用完全相同的配置原样重新启用,避免配置漂移。
-
同步从代码库中删除针对这五个 linter 的
nolint注释。目的是保证后续重构不会"漏掉"之前被豁免过的函数——所有历史豁免将被清零,统一暴露在新的检查视野下。
需要澄清的是:临时禁用不等于授权写违规新代码。RFC 明确表示,禁用只是承认"我们的很多工作是对既有函数做局部修改,不应被迫为此承担大规模结构改动的成本和风险"。项目团队仍然应当自觉避免写出复杂度超标的代码。
同时,其他所有 Linter 在整个项目期间保持启用。对于这些 linter 发现的问题,延续既有实践:在后续工作中被动、就地修复(reactively fixing as part of other projects),而不是展开另一轮大规模整改。
参考实现:当前仓库的 .golangci.yml 结构
RFC 20241113 发布之后,仓库的 lint 配置经历了后续演进(详见下文"后续演进"一节),当前仓库根目录的 .golangci.yml 已是 golangci-lint v2 格式,结构如下:
version: "2"
issues:
max-issues-per-linter: 0
max-same-issues: 0
linters:
settings:
staticcheck:
# For now, we will disable some static checks to match golang-ci-lint@v1 functionality.
# These should be addressed once the --new-from-rev work is taken care of.
checks: ["all", "-QF1008", "-ST1003", "-ST1005"]
exclusions:
generated: lax
presets:
- comments
- common-false-positives
- legacy
- std-error-handling
paths:
# 为向后兼容而"冻结"的代码,改动风险高且预计不会有重大维护
- ^internal/ipaddr/
- ^internal/legacy/
- ^internal/states/statefile/version\d+_upgrade\.go$
- ^website/
formatters:
exclusions:
generated: lax
可以观察到几个要点:
issues.max-issues-per-linter: 0与max-same-issues: 0:不限制每个 linter / 同类问题最多报告的数量,也就是"有违规就全部列出",这与 RFC 中"拿到完整问题清单"的思路一致——不过这是 v2 版本全局生效的配置,与 RFC 当时的场景不完全相同;- exclusions.paths:排除了
internal/ipaddr/、internal/legacy/等"冻结代码",这一模式正是 RFC 20250303(Linter Policy)提出的"排除 legacy 文件与包"思路的落地; - 静态检查版本:
Makefile的golangci-linttarget 使用github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.13.1,并分别以GOOS=windows和GOOS=linux各跑一遍(见 Makefile)。
也就是说,在后续的 Linter Policy 落地后,复杂度类 linter 已经从启用集合中被移除(golangci-lint 默认集合不包含它们),而 RFC 20241113 所设想的"临时注释后重新启用"的完整周期,最终以另一种方式被"终结"——这一点在"后续演进"一节详述。
核心方案二:160 行违规清单驱动的渐进式重构
RFC 写作时,通过以下方式统计出了精确的违规清单:
- 临时复制一份
.golangci.yml,把启用集合改成只有上述五个复杂度 linter; - 同时临时移除此前所有为推迟修复而加的
nolint注释; - 运行 golangci-lint,得到结果:全代码库共有 160 行代码至少违反一个复杂度类 linter。
下面是从 RFC 原始清单中节选的若干具有代表性的违规(格式为 文件:行号 linter 具体原因):
internal/backend/local/backend_apply.go:48 funlen Function 'opApply' has too many statements (108 > 50)
internal/backend/remote-state/s3/backend.go:61 funlen Function 'ConfigSchema' is too long (384 > 100)
internal/backend/remote-state/s3/backend.go:464 funlen Function 'PrepareConfig' is too long (167 > 100)
internal/cloud/backend_common.go:466:1 gocognit cognitive complexity 83 of func `(*Remote).confirm` is high (> 50)
internal/cloud/backend_common.go:442:1 gocognit cognitive complexity 84 of func `(*Cloud).confirm` is high (> 50)
internal/command/views/json/diagnostic.go:157:2 nestif `if sourceRefs.Subject != nil` has complex nested blocks (complexity: 56)
internal/legacy/helper/schema/schema.go:474 funlen Function 'Diff' has too many statements (61 > 50)
internal/providercache/installer_test.go:31:1 gocognit cognitive complexity 169 of func `TestEnsureProviderVersions` is high (> 50)
internal/states/statefile/version4.go:40 funlen Function 'prepareStateV4' has too many statements (144 > 50)
从这份清单中可以看到几个值得注意的分布特征:
-
违规大量集中在测试代码(
*_test.go)。例如internal/addrs/map_test.go的TestMap(圈复杂度 21)、internal/tofu/context_apply_test.go:11305的TestContext2Apply_scaleInCBD(圈复杂度 24)、internal/depsfile/locks_file_test.go的TestLoadLocksFromFile(认知复杂度 116)。长测试函数是复杂度 linter 的高发区,这一点在 RFC 后文"重新启用后可能放宽测试代码阈值"的设想中得到了呼应。 -
后端(backend)代码是重灾区。
internal/backend/remote-state/s3/backend.go的ConfigSchema长达 384 行、PrepareConfig167 行;internal/backend/remote-state/azure/backend.go的New165 行。这些函数承担了大量配置 schema 声明与校验逻辑,属于"结构上天然庞大"的典型。 -
嵌套层级极深的
nestif是另一个主要来源。例如internal/command/views/json/diagnostic.go:157的if sourceRefs.Subject != nil嵌套复杂度高达 56,internal/cloud/backend_common.go:83的if b.CLI != nil && (i == 0 || ...)为 29。这类问题往往意味着条件分支逻辑需要被抽取为独立函数。 -
internal/legacy/目录下也有大量命中(如helper/schema的Diff、diffMap、diffSet等)。这也是后续 rfc/20250303-linter-policy.md 直接将该目录整体列入 lint 排除名单(^internal/legacy/)的重要现实依据——"冻结"代码不再强制做复杂度重构。
怎么啃下这 160 行:分片、解耦、先测试后主码
RFC 明确指出:
"虽然 160 个问题多到无法在单个 PR 中解决,但并不多到无法通过多个 PR 逐个攻破——每个 PR 聚焦代码的一个区域,使 diff 易于评审。"
推荐的推进节奏是:
- 按文件(file-by-file)或按包(package-by-package)系统性重构,具体粒度取决于改动严重程度;
- 严格保证不改变 OpenTofu 的可见行为(observable behavior)——这是整个重构的铁律;
- 为了让"行为不变"可被验证,建议把测试代码的修改与主代码的修改分开进行,确保整个过程中所有既有测试始终通过;
- 如果重构过程中发现某个阈值确实"过于苛刻"(再拆分反而更难读),可以先重新加回局部的
nolint注释以继续局部推进,把更广泛的阈值讨论留到项目收尾。
源码佐证:被点名函数如今的样子
RFC 中点名的函数,在当前仓库中仍然存在,可以从源码结构印证"这类函数确实体量庞大"的判断:
internal/cloud/backend_common.go:429定义了func (b *Cloud) confirm(stopCtx context.Context, op *backend.Operation, opts *tofu.InputOpts, r *tfe.Run, keyword string) error,对应 RFC 清单中"gocognit 认知复杂度 84"的那一处(行号与 RFC 中的 442 略有偏移,属后续代码演进导致)。该方法承担 cloud 后端在执行前与用户交互确认的关键流程,条件分支众多。internal/backend/remote-state/s3/backend.go中ConfigSchema()(backend.go)、PrepareConfig(...)(backend.go)、Configure(...)(backend.go)分别对应 RFC 清单中的三条funlen违规(384 行 / 167 行 / 63 条语句)。该文件随后还有verifyAllowedAccountID、getDynamoDBConfig、configureAssumeRole等大量辅助函数——从源码结构看,这些辅助函数正是此前重构"把大函数拆小"留下的痕迹,也说明即便经历了演进,S3 后端的配置解析逻辑依然天然庞大。
收尾:清零后立即重新启用,并理性保留 nolint 例外
RFC 为整个项目设定的终态是:
- 当复杂度类违规数量归零后,立即重新启用先前禁用的五个 linter,让所有新 PR 重新受这些阈值约束;
- 重新启用后,逐一复查仍残留在代码库中的
nolint注释:- 若发现共性因素表明某些阈值过严,则调整对应阈值并移除相关的
nolint注释; - 也可能做出其他妥协,例如为测试代码使用比主代码更宽松的阈值(依据重构中观察到的模式);
- 若发现共性因素表明某些阈值过严,则调整对应阈值并移除相关的
- 明确不是要消灭所有
nolint:有些代码确实在结构或约束上"特殊",此时局部例外优于全局放宽。但每个保留下来的nolint注释必须附上说明理由的注释,以便未来维护者重新评估该例外是否仍然成立。
这一终态设计体现了项目对"规则"与"代码现实"之间张力的成熟处理:规则服务可读性,而不是反过来绑架代码。
后续演进:RFC 被 Linter Policy 取代
在 RFC 20241113 发布之后,OpenTofu 的 lint 治理路线发生了重要转向。仓库中的 rfc/20250303-linter-policy.md 明确记录了这段历史:
- 该 RFC 提出大幅缩减 linter 集合,改为只使用 golangci-lint 的默认启用的 linter 及其默认设置(当时 golangci-lint v1.64.5 的默认集合为:
errcheck、gosimple、govet、ineffassign、staticcheck、unused); - 由于 golangci-lint 的默认集合并不包含任何复杂度类 linter,接受该提案实际上意味着取消(cancel)了复杂度治理项目——"全部复杂度相关 linter 都被移出范围,使本提案(20241113)过时"(原文档第 7 行脚注);
- 该 RFC 还记录了经验教训:复杂度治理提案当时获得不少核心成员口头支持,但接受 RFC 后缺乏足够动力真正执行,"在实践中,使用这些 linter 的成本超过了它们的价值";
- 取而代之的是:主要依赖人工评审——评审者在 code review 时发现某段新代码难以理解,就在评论中指出,团队以个案(case-by-case)方式处理,而不是为全库制定一套强制规则。
需要特别澄清的是:RFC 20250303 文档第 63 行写到的"此前在 A Pragmatic Approach to Linting for Code Complexity 中讨论的复杂度相关 linter"这一链接在原文中即存在笔误(路径少了一个
1),正确文件为 rfc/20241113-pragmatic-complexity-linting.md。
当前仓库的 .golangci.yml(v2 格式)正是这一新政策的落地:启用集合里已无任何复杂度 linter,取而代之的是对冻结代码(internal/legacy/ 等)的显式排除,以及精简后的默认检查集合。这也解释了为什么 search_in_files 在当前代码库中几乎找不到针对 cyclop/funlen/gocognit/gocyclo/nestif 的 nolint 注释——复杂度 linter 已不在启用集合中,自然不再需要这些豁免。
给工程团队的实践启示
抛开 OpenTofu 的具体决策,这份 RFC 及其后续演进对任何治理大型 Go 代码库 lint 规则的团队都有参考价值:
- "新代码才查"是过渡态,不是终态:承认存量代码无法一次性合规的同时,要有一个明确的长期目标与收敛机制,否则豁免会变成永久债务。
- 复杂度类 linter 需要特殊策略:它们与"风格/潜在 bug"类 linter 本质不同——修复成本高、diff 大、review 疲劳明显,容易诱发
nolint泛滥,反而掩盖真实问题。要么为其专门立项分片治理,要么干脆移出强制集合交给人工评审。 - 用完整问题清单驱动重构:RFC 通过临时修改配置、清空
nolint、跑一次全量检查得到了精确的 160 行清单,这让"渐进式重构"有了可追踪的 check-list,也使得每个 PR 都有明确的"下一块该拆谁"。 - 行为不变是可验证的硬约束:把测试改动与主代码改动分开提交、让既有测试全程保持绿色,是"纯重构"不引入回归的工程保障。
nolint可以是理性工具,但必须被辩护:保留的豁免应带理由注释、接受定期复查,而不是成为逃避规则的沉默通道。- 定期重新评估规则本身的成本收益:OpenTofu 最终选择"默认集合 + 人工评审",正是因为实际执行中复杂度 linter 的成本超过了价值。规则治理不是一锤定音,而是一个持续校准的过程。
延伸阅读
- 原 RFC 全文:rfc/20241113-pragmatic-complexity-linting.md
- 取代它的新策略:rfc/20250303-linter-policy.md
- 当前生效的 lint 配置:.golangci.yml
- 本地运行 lint 的目标定义:Makefile
- 复杂度违规重灾区示例:internal/backend/remote-state/s3/backend.go、internal/cloud/backend_common.go
- 项目静态分析辅助脚本:scripts/staticcheck.sh