← 返回 Skill 列表
extension
分类: 开发与工程API Key 暂未确认

节制工程减负法

节制工程:什么时候该写防御性代码、什么时候不该写,以及如何把一轮构建测试耗时从几十分钟压到几十秒的实战守则。Use when deciding whether to add defensive code or when build-test cycles are too slow to trim. Triggers include 节制工程、防御性代码、构建提速、减负守则。如需质量工具包,淘宝搜索「AI灵匣」店铺获取完整版。

person作者: nuhabvtyhubOpenAPI

节制工程

这份技能回答两个问题:这段"安全"代码值不值得写,以及这一轮为什么这么慢。

它不是"少写代码"的口号。下面每一条都有明确的判据和明确的例外。

怎么用这份技能:它是判据表,不是待办清单。你不需要从头读到尾——按你正要做的事 跳到对应那一节,用那一节的判据做一次决定,然后继续。判据给出"不写"的结论时,就真的 不写,并且在回复里用一句话说明为什么不写(这一句是给用户的,不是给自己的)。

本文引用的实测数字都来自同一份真实对话记录:一个约 20k 行的 Java / Gradle 工程,模型是 GPT-5.6,一次长会话。它说明这类行为真实存在且量级很大,不是跨模型 或跨工程的基准统计,别当基准线引用。


一、校验只在信任边界做一次

信任边界只有四处:

  1. 对外公开的 API(别人的代码会调用它)
  2. 用户命令入口 / 事件回调(人或外部系统直接触发)
  3. 配置文件、数据库、网络返回值的解析出口
  4. 跨进程 / 跨机器边界

越过这条线,数据就是可信的。 私有方法、同模块内部调用、构造函数里已经校验过的 字段——不再校验。

反例(真实代码,那份记录里的工程有 585 条这样的)

private static List<MainEntry> immutableMainEntries(List<MainEntry> source) {
    Objects.requireNonNull(source, "mainEntries");   // ← 私有静态方法
    ...
}

private ConfigSnapshot currentSnapshot() {
    return Objects.requireNonNull(snapshotSupplier.get(), "config snapshot");
}

这两个方法都是 private,调用方全在同一个类里。这条校验唯一能捕获的是"我自己刚写的 代码有 bug"——而那种 bug 本来就会在下一行抛 NPE,抛的位置还更接近现场。

同一个错误在别的语言里长什么样

// 反例:未导出函数,调用方全在包内
func (s *store) merge(items []Item) []Item {
    if items == nil {                       // ← 这个 nil 是从哪来的?
        return nil
    }
    ...
}
// Go 里更常见的变体:对自己刚构造的东西做 nil 检查,或者给 range 加 len() > 0 守卫
// (range 一个 nil slice 本来就是零次迭代,这个守卫什么也没做)
// 反例:内部函数入口重复校验一个上游已经用类型收窄过的值
function formatRow(row: Row): string {
  if (!row) throw new Error("row is required");        // ← Row 不可空,这行永远不成立
  if (!row.id) throw new Error("row.id is required");  // ← 上游 parseRows 已经校验过
  ...
}
// 真正该校验的地方是 parseRows —— 也就是 JSON 进来的那一处(边界 3)
# 反例:私有方法里的断言式校验
def _apply(self, entries: list[Entry]) -> None:
    assert entries is not None, "entries"    # ← 调用方全在类内
    ...
# 注意 assert 在 python -O 下会被整个去掉,用它做真正的边界校验本身就是错的

判据

问一句:这个 null / nil / undefined 是从哪来的?

  • 从外部来(用户输入、配置文件、网络、反序列化) → 校验,而且要给出人话错误。
  • 从我自己的代码来 → 不校验。真的担心,用类型表达(不可空类型 / 值对象 / 构造期 校验 / TypeScript 的 strictNullChecks),别用运行时检查。

例外

  • 公开库的 API 边界:校验是契约文档的一部分,写。
  • 反射 / 序列化框架构造出来的对象:绕过了构造函数,写。
  • 跨语言 / FFI / 手工内存边界:写。

二、不要用 catch 把根因盖住

try {
    return codec.decode(item);
} catch (Exception e) {
    return false;              // ← 这里发生了什么?永远不会有人知道
}

catch → return null / false / Optional.empty()、或者"记一条 warn 然后继续"——这些都 不是容错。它们把一次清晰的崩溃换成一个很难查的错误值,代价由后面每一次调试支付。 一个星期后有人报"某个属性偶尔没生效",你会在十七个返回 false 的地方之间来回找。

各语言里的同一个反模式

// 反例:吞掉 error,只留一个零值
data, err := loadConfig(path)
if err != nil {
    return Config{}, nil       // ← 调用方拿到一份"空但成功"的配置
}
// 也别用 _ = json.Unmarshal(...) 把错误显式丢掉——那和 catch 忽略是同一件事
// 反例:catch 里 return 一个假的成功值
try {
  return await res.json();
} catch {
  return {};                   // ← 上游 500 和上游返回空对象,从此无法区分
}
# 反例:宽异常 + pass
try:
    return parse(raw)
except Exception:
    pass                       # ← 之后一切正常,只是结果是 None

判据

只有当你能对调用方说清楚「失败是一条正常的业务路径,它应该这样处理」时才捕获:

// 好:失败是正常路径,而且调用方知道该怎么办
try {
    return Optional.of(parseAmount(raw));
} catch (NumberFormatException e) {
    return Optional.empty();   // 契约:非数字输入 → 空,调用方会提示用户重填
}

其他情况一律让它抛,抛到能真正处理它的那一层:命令入口、事件回调、请求边界。 在那一层做一次统一转换(转成用户看得懂的话 + 一条带堆栈的日志),全工程只需要一处。

Go 没有异常,对应的做法是把 error 一路带上去(fmt.Errorf("...: %w", err) 包上 上下文再返回),而不是在中间某一层把它变成零值。

反模式:捕获一个你还没见过的异常

如果你说不出这个异常在什么情况下会抛,那你也一定写不出正确的处理——最后必然写成 "记日志然后继续"。不要 catch。

反模式:捕获范围过宽

catch (Exception) / except Exception / 空 catch {} 会连编程错误(空指针、 下标越界、类型错误)一起吞掉。要捕就捕你说得出名字的那一个。


三、只有一个实现的抽象不是抽象

接口、抽象类、工厂、策略、Builder、适配层——这些只有在已经有第二个实现时才有意义。

public interface StrengthenTargetGateway { ... }          // 唯一实现:BukkitStrengthenTargetGateway
public final class StrengthenMutationPlanner { ... }      // 唯一调用点:AccessoryStrengthenService

为「将来可能要换」而抽的缝,将来八成不在那个位置。真要换的时候再抽——那时你才知道 该沿哪条缝抽,而且改起来比现在便宜(IDE 的 Extract Interface 是一次按键)。

同样的东西在别处的样子:TypeScript 里给一个只有一种实现的东西写 interface Foo + class FooImpl;Python 里为一个类写一个 ABC 基类;Go 里在定义方先写好接口再写 唯一的实现结构体。

Go 特别说明:Go 的惯例是「在消费方按需要声明小接口」,不是在实现方预先定义。 如果只有一个消费者、而且它只是直接调用具体类型,那个接口不用写。

例外

  • 为了测试而抽的接口:有理由,但先看看能不能用构造注入一个函数(Supplier<T> / Function<A,B> / 一个函数类型字段)代替一整个接口 + 一个假实现类。
  • 已经明确知道第二个实现马上要来(下一个迭代就写):可以抽。
  • 语言/框架强制要求(依赖注入容器、插件 SPI、dyn Trait):按框架来。

四、修 bug 先改根因

加兜底等于承认没找到根因。

如果一个问题你修了两次还没好,不要加第三层保护。回去把完整调用路径读一遍——从入口 到出错那一行,每一步的数据长什么样。这一步通常比再写一层保护快得多,而且是一次性的。

唯一允许兜底的场景:根因在我们改不动的第三方代码里。这时兜底必须带注释写明 谁家的问题 + 什么条件下能删:

// 兜底:上游库 <名字> <版本> 在<具体条件>下会返回一个 amount=0 的对象(见其 issue #NNN)。
// 升到 <修复版本> 之后这段可以删。
if (stack.getAmount() <= 0) { return MaterialCheck.unavailable(); }

两样都要有,而且必须是你真的核实过的——版本号、条件、issue 号要么写真的,要么 就别写。编造一条上游缺陷比不写注释更糟:它会误导后来的人去读一个不存在的 issue, 也会让那个第三方项目背上一个它没有的锅。核实不了就照实写:

// 兜底:<库名> 在某些情况下会返回 0 值,尚未定位到确切触发条件,也没有对应的上游
// issue。删除条件:定位到根因之后。

没有这两句的兜底会永远留在代码里。


五、改动量要与行为变化成比例

一个 bug 的修复通常是几行。如果你正准备提交一个新增几百行而行为只变了一点的改动, 那是重写不是修复。

动手前的一句话

每次打算新增 50 行以上时,先报:

预计新增 N 行,其中 M 行是校验和兜底;不写它的代价是 ______。

这句话本身就是过滤器:M/N 超过一半时,你多半在写一个自己也不确定需要的东西。

「安全子系统」的特别警告

超时调度、重试、熔断、租约、审计追踪、风险事实、单终态句柄——这类东西成本是永久的 (每个新功能都要接一遍,每次调试都要穿过一层),而收益取决于那次失败是否真的会发生。

那份记录里的真实案例:一个给饰品升级用的 Minecraft 插件,operation/ 包写了 1786 行 这样的设施——租约、追踪、风险事实、单终态句柄、超时调度、不确定终止通知格式化器。而这个 插件的核心业务逻辑(强化 + 升星 + 重铸 + 祝福)加起来才 2500 行左右。

判据:指出一次真实发生过的故障(有日志、有复现步骤),否则不写。

同类但更隐蔽的几种

  • 为不存在的并发写锁:确认它真的会被多个线程/协程碰到,再加。
  • 为不存在的规模写缓存:先测量,别猜。
  • 配置化一个从来只有一个取值的参数:等第二个取值真的出现。
  • 提前泛型化:<T> 只有一个实参时,它只是让签名更难读。

六、写"安全代码"之前的三问

  1. 这个失败真的会发生吗?——有日志?有复现步骤?还是只是想象?
  2. 发生了用户会怎样?——数据错乱不可恢复,还是看到一条报错?
  3. 不写它的代价是什么?——一句话说出来。

三问里有一问答不上,就不要写。

答得上、决定要写的时候,把这三句写进提交说明或回复里——它同时也是给评审者的理由。


七、一轮的耗时是你的责任

这一节是纯粹的工程成本,和代码质量无关,但它决定了用户等你多久。

实测

那份记录里:570 次 Gradle 调用 = 247 分钟(4.1 小时)。其中:

  • 235 次带 --no-daemon(41%)——每次都付一遍 JVM 冷启动 + 全量配置阶段
  • 55 次带 clean——每次丢掉全部增量编译产物
  • 12 条命令跑到 600 秒超时被杀掉——那 120 分钟是纯粹的浪费

规则

| | 做 | 不做 | |---|---|---| | 进程复用 | 让守护进程活着 | Gradle --no-daemon;Maven 每次联网解析;重建 venv / node_modules | | 增量 | 保留构建缓存 | clean、--rerun-tasks、go clean -cache、cargo clean | | 范围 | 只跑受影响的用例 | 改一行跑全量测试套件 | | 顺序 | 先编译,再测试 | 用测试套件去查编译错误(几十秒的事变成几分钟) | | 频率 | 全量验证一轮最多一次,放最后 | 每改一处跑一次 | | 超时 | 换个更小的目标 | 把 timeout 调大再跑一遍 |

各构建系统的"只跑这一个"

# Gradle
./gradlew :core:compileJava                              # 先编译
./gradlew :core:test --tests com.example.FooTest         # 再单测

# Maven
mvn -o -q -DskipTests compile
mvn -o -q -Dtest=FooTest#shouldBar test

# Go
go build ./internal/foo/...
go test ./internal/foo/ -run '^TestBar$' -count=1

# Node / TypeScript
npx tsc --noEmit -p tsconfig.json
npx vitest run src/foo.test.ts -t 'bar'

# Rust
cargo check -p mycrate
cargo test -p mycrate bar -- --nocapture

# Python
pytest tests/test_foo.py::test_bar -x -q

# .NET
dotnet build --no-restore
dotnet test --no-build --filter FullyQualifiedName~FooTests.ShouldBar

模块名 / 测试名当然要换成这个工程真实的。不确定这个工程的最小回路是什么,就先问 一句或者读一眼构建脚本,别默认跑根目录的全量任务。

不要做的两件验证

  • 不要用哈希/校验和证明产物变了。 对一个 jar 跑 Get-FileHash / sha256sum 不产生任何新信息——构建输出和时间戳已经说明了。那份记录里有 20 条这样的命令。
  • 不要为了确认而重读刚写过的文件。 编辑工具已经回了 diff。那份记录里 读:改 = 8381:1746(每改一次读 4.8 次),单个文件被重复读了 659 次。 除非有具体理由怀疑写错了(比如写入报了错、或者要看它和别处的交互),否则直接往下走。

八、可以说"不用改"

如果最好的答案是删掉一段代码、或者什么都不做,就直接这么说。产出代码不是目标, 把问题解决掉才是。

同理,如果一个需求会让代码显著变复杂而收益很小,说出来让用户决定——而不是默默地 把复杂度吃下去。

反过来也一样:不要在用户没问的地方顺手"加固"。用户让你修 A,你顺带给 B 加了三层 校验,那三层校验没有人评审过,也没有人要求过。


附:审计一份已经长歪的代码

按这个顺序做,先量再删:

第一步 · 量(一次搜索做完,不要逐个文件读)。按语言挑对应的几条:

# Java / Kotlin
rg -c 'Objects\.requireNonNull|Preconditions\.|checkNotNull|^\s*require\(' --glob '!**/test/**'
rg -c 'throw new IllegalArgumentException'                                  --glob '!**/test/**'
rg -c -U 'catch \([^)]*\) \{[^}]*return (null|false|Optional\.empty\(\))'
rg -c 'List\.copyOf|Collections\.unmodifiable|new ArrayList<>\('

# Go
rg -c -U 'if err != nil \{[^}]*return [^}]*(nil|false|""|\{\})\s*$' --glob '!**/*_test.go'
rg -c '_ = ' --glob '!**/*_test.go'          # 显式丢弃的返回值(多数是被吞的 error)

# TypeScript / JavaScript
rg -c -U 'catch \([^)]*\)?\s*\{[^}]*return (null|undefined|\{\}|\[\]|false)' --glob '!**/*.test.*'
rg -c 'if \(!\w+\) (throw|return)'                                          --glob '!**/*.test.*'

# Python
rg -c -U 'except [\w.]*( as \w+)?:\s*(pass|return None)'
rg -c '^\s*assert '                          --glob '!**/test_*'

这些是起点,不是定式——按这个工程的实际写法调整。算出每千行的密度,列出密度 最高的 5 个文件。参考值:健康的业务代码大约是每 200–500 行一条运行时校验;每 20–40 行 一条就是明显偏高。

第二步 · 判(只看那 5 个文件,逐条归档):

  • 删——调用方全在模块内部的校验;对内部调用方的防御性拷贝;防"还没发生过的失败"的设施
  • 上移——同一个校验在多层重复出现,只保留最外层那一次
  • 留——真正在信任边界上的,说明它守的是哪条边界

第三步 · 报——给一张表:文件 / 行号 / 现状 / 判定 / 预计减少行数,末尾给总计。 先出表,让用户挑,不要直接开删。

第四步 · 改——用户挑完之后再动手,一次一个文件,每个文件改完就编译一次(不是跑 全量测试)。删除校验最常见的翻车是删掉了唯一在守边界的那一条,编译期发现不了, 所以边界上的那几条要在第二步就标清楚。


配套(可选)

Vantaloom 有一个同名插件 lean-mode,把这里的守则做成每一轮都生效的压力:一份 压缩到 718 字节的守则贴在每轮上下文末端、工具结果尾注(本轮第一条失败的命令 / 命令数 超阈值 / 同一文件反复改),以及一个把「值不值得」变成确定性裁决的 lean_review 工具。

没有那个插件,这份技能也完整可用——它覆盖了全部守则和判据,而且比插件里的守则 详细。两者的分工是:插件负责「每轮都提醒」,技能负责「需要时给出完整判据」。