节制工程
这份技能回答两个问题:这段"安全"代码值不值得写,以及这一轮为什么这么慢。
它不是"少写代码"的口号。下面每一条都有明确的判据和明确的例外。
怎么用这份技能:它是判据表,不是待办清单。你不需要从头读到尾——按你正要做的事 跳到对应那一节,用那一节的判据做一次决定,然后继续。判据给出"不写"的结论时,就真的 不写,并且在回复里用一句话说明为什么不写(这一句是给用户的,不是给自己的)。
本文引用的实测数字都来自同一份真实对话记录:一个约 20k 行的 Java / Gradle 工程,模型是 GPT-5.6,一次长会话。它说明这类行为真实存在且量级很大,不是跨模型 或跨工程的基准统计,别当基准线引用。
一、校验只在信任边界做一次
信任边界只有四处:
- 对外公开的 API(别人的代码会调用它)
- 用户命令入口 / 事件回调(人或外部系统直接触发)
- 配置文件、数据库、网络返回值的解析出口
- 跨进程 / 跨机器边界
越过这条线,数据就是可信的。 私有方法、同模块内部调用、构造函数里已经校验过的 字段——不再校验。
反例(真实代码,那份记录里的工程有 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>只有一个实参时,它只是让签名更难读。
六、写"安全代码"之前的三问
- 这个失败真的会发生吗?——有日志?有复现步骤?还是只是想象?
- 发生了用户会怎样?——数据错乱不可恢复,还是看到一条报错?
- 不写它的代价是什么?——一句话说出来。
三问里有一问答不上,就不要写。
答得上、决定要写的时候,把这三句写进提交说明或回复里——它同时也是给评审者的理由。
七、一轮的耗时是你的责任
这一节是纯粹的工程成本,和代码质量无关,但它决定了用户等你多久。
实测
那份记录里: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 工具。
没有那个插件,这份技能也完整可用——它覆盖了全部守则和判据,而且比插件里的守则 详细。两者的分工是:插件负责「每轮都提醒」,技能负责「需要时给出完整判据」。