关于 go-defensive-coding
go-defensive-coding 是面向 Go 开发者的实践参考技能,适用于编写或审查代码。它聚焦容易导致故障的语言行为与资源处理模式,并提供操作模式和核对清单。
主要功能
- 01讲解类型化 nil 接口陷阱
- 02说明 nil map、slice 和 channel 行为
- 03介绍 slice 别名与防御性复制
- 04涵盖数值转换和比较安全
- 05讲解资源生命周期与零值设计
- 06列出 Go 工具及审查要点
适用场景
- 01在修改代码时加固 Go 实现
- 02审查代码中的崩溃或数据损坏风险
- 03检查数值转换和共享 slice
- 04审计延迟清理与资源处理
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ eduardo-sl/ go-agent-skills --skill go-defensive-coding安装后在 Claude Code 中输入/go-defensive-coding确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
references/copies-and-lifecycle.mdnil-and-aliasing.mdnumeric-safety.md详细介绍
Go 防御性编码
Go 没有异常,类型系统也没有空值安全机制。下面的每个陷阱都能顺利编译、通过评审,却会在生产环境中出错。
按需加载的详细参考资料:
references/nil-and-aliasing.md— 完整的带类型 nil 规则、切片别名场景和内存保留说明。references/numeric-safety.md— 转换范围检查、溢出检测、浮点数和时间比较。references/copies-and-lifecycle.md—copylocks/noCopy、循环中的defer、检查延迟执行的Close错误、可用的零值。
只有在下文内容不足时才阅读参考文件。
工作模式
- 加固 — 编写或修改代码时,随时应用所有规则。
- 评审 — 审查现有代码时,按严重程度报告问题(🔴 panic 或数据损坏,🟡 潜在错误,🟢 风格),并注明文件:行号。
1. 带类型 nil 的接口陷阱
非 nil 接口可以持有 nil 指针。这是 Go 中导致“看似不可能”的 nil 检查的最常见原因。
type NotFoundError struct{ ID string }
func (e *NotFoundError) Error() string { return "not found: " + e.ID }
// ❌ Bad — returns a non-nil error even on success
func find(id string) error {
var err *NotFoundError // typed nil
if id == "" {
err = &NotFoundError{ID: id}
}
return err // interface is (type=*NotFoundError, value=nil) — NOT nil
}
// ✅ Good — return the untyped nil literal
func find(id string) error {
if id == "" {
return &NotFoundError{ID: id}
}
return nil
}
规则:
- 绝不要声明具体的 error/指针变量并将其作为接口返回。成功时应显式返回
nil。 - 绝不要将可能为 nil 的具体指针存入
error、io.Reader或任何接口类型的结构体字段。 - 如果由于此陷阱,与
nil的比较永远不可能为真,staticcheck会报告SA4023。没有任何工具能捕获所有情况。
2. nil Map、切片和通道的行为
记住这张表——其中一半是安全的,另一半会 panic 或挂起。
| 操作 | nil map | nil 切片 | nil 通道 |
|---|---|---|---|
| 读取 / 接收 | 零值 | 索引会 panic | 永久阻塞 |
| 写入 / 发送 | 会 panic | append 可用 | 永久阻塞 |
len / cap | 0 | 0 | 0 |
range | 零次迭代 | 零次迭代 | 永久阻塞 |
close | 不适用 | 不适用 | 会 panic |
// ✅ A nil slice is a valid empty slice — do not guard append
var out []string
out = append(out, "a")
// ❌ A nil map is read-only
var m map[string]int
m["k"] = 1 // panic: assignment to entry in nil map
// ✅ Initialise every map before writing
m := make(map[string]int)
返回 nil 切片,而不是 []T{}。当字段使用 omitempty 时,对于 encoding/json,两者编码为 JSON 的结果相同,且 nil 切片不需要分配内存。只有在文档明确说明调用方会向其写入时,才返回非 nil 的空 map。
3. 切片别名
切片是一个视图。只要容量允许,append 就会通过该视图写入,从而修改调用方仍持有的数据。
a := []int{1, 2, 3, 4}
b := a[:2]
b = append(b, 99) // ❌ overwrites a[2]; a is now [1 2 99 4]
// ✅ Full slice expression caps the view — append must reallocate
b := a[:2:2]
b = append(b, 99) // a is untouched
每当你将子切片交给自己无法控制的代码,或者结构体字段持有较大缓冲区的子切片时,都应遵循此规则。
子切片还会使整个底层数组保持存活。要释放大型缓冲区,请复制所需内容:head := slices.Clone(buf[:64])。
4. 在边界处防御性复制
切片和 map 是引用类型。存储或返回它们而不进行复制,会向外暴露一个可变句柄,让外部代码访问你的内部数据。
type Config struct{ hosts []string }
// ❌ Bad — caller can mutate our state, both ways
func NewConfig(hosts []string) *Config { return &Config{hosts: hosts} }
func (c *Config) Hosts() []string { return c.hosts }
// ✅ Good — copy in, copy out
func NewConfig(hosts []string) *Config {
return &Config{hosts: slices.Clone(hosts)}
}
func (c *Config) Hosts() []string { return slices.Clone(c.hosts) }
对 map 使用 maps.Clone。两者都是浅复制——[]*User 的克隆仍会共享被指向的用户对象。
当值会在调用结束后继续保留,或会暴露给调用方时,应进行复制。如果你只在函数内部读取切片,就不要复制;那只会浪费分配。
防止意外复制
持有 sync.Mutex、sync.WaitGroup 或 atomic.Int64 的结构体绝不能复制——值接收器或按值传递的参数会产生一把独立的锁,而两份数据都会认为自己处于同步状态。go vet 的 copylocks 可以捕获这些情况;对于没有锁的不可复制类型,可嵌入 noCopy 标记,让 vet 也能检查它们。示例:
references/copies-and-lifecycle.md。
5. 数值转换和比较
Go 的数值转换永远不会 panic,而是会截断数值。
// ❌ Silent corruption when the value does not fit
count := int32(int64Total)
// ✅ Range-check before narrowing
if int64Total > math.MaxInt32 || int64Total < math.MinInt32 {
return fmt.Errorf("total %d out of int32 range", int64Total)
}
count := int32(int64Total)
int → uint 也有同样的问题(负数会回绕为巨大的值),将 len() 的结果赋给定宽类型时也是如此。gosec 会将这些情况报告为 G115。
绝不要使用 == 比较浮点数——应与容差值比较。绝不要使用 == 比较 time.Time——Equal 比较的是时间点,而 == 还会比较单调时钟读数和时区。示例:
references/numeric-safety.md。
整数除以零会 panic;浮点数除以零会得到 ±Inf 或 NaN,而且 NaN != NaN。对于来自输入的除数,应进行检查。
6. 资源生命周期
defer在函数返回时运行,而不是在循环体结束时运行。在循环中使用defer f.Close()会让每个文件都一直保持打开状态,直到循环结束——应为每次迭代 单独创建函数作用域。resp.Body.Close、rows.Close、mu.Unlock和tx.Rollback也同样如此。- 对写入过数据的对象,检查延迟调用的
Close返回的错误:关闭时刷新失败会导致 无声的数据丢失。如果函数体执行成功,请将该错误赋给命名的err返回值。
示例:references/copies-and-lifecycle.md。
7. 零值与初始化安全
设计类型时应确保零值可用,这样就不会忘记调用构造函数。
sync.Mutex 的零值已就绪,nil map 可以读取;在首次写入时,于锁的保护下延迟
初始化 map。示例:references/copies-and-lifecycle.md。
如果延迟初始化必须恰好发生一次且可能存在竞争,请使用 sync.Once。
避免使用 init()。它会在 main 之前运行,无法妥善处理失败,无法单独测试,
而且跨文件的执行顺序取决于文件名。请改用显式的 New... 构造函数,并让它返回错误。
使用工具强制检查
运行以下工具,不要只靠阅读检查。如果某个工具未安装,请跳过并注明。
go vet ./... # copylocks, lostcancel, printf
golangci-lint run # errcheck, bodyclose, makezero, sqlclosecheck
staticcheck ./... # SA4023 typed-nil comparisons
gosec -include=G115,G104 ./... # integer overflow, unhandled errors
go test -race ./... # aliasing bugs often surface as races
相关的 golangci-lint 检查器:errcheck、bodyclose、sqlclosecheck、
rowserrcheck、makezero、nilerr、exhaustive,以及启用了 nilness 分析器的
govet——go vet 本身不会运行 nilness。
验证清单
- 没有函数在成功路径上将具体指针类型作为接口返回
- 每个 map 在首次写入前都已初始化
- 跨包边界传递的子切片使用完整切片表达式
a[:n:n] - 存储在结构体中或从结构体返回的切片和 map 都经过克隆
- 不会按值传递或接收持有互斥锁或原子变量的类型
- 每次缩窄数值类型的转换都经过范围检查,或注明其值有界
- 不对浮点数或
time.Time使用== - 对输入推导出的除数都会检查是否为零
- 循环体中没有未置于外层函数作用域内的
defer - 对写入过数据的资源调用延迟
Close时会报告其错误 go vet、golangci-lint和go test -race均通过