mcpField
G
go-defensive-coding

go-defensive-coding

github.com/eduardo-sl开发8382 次安装2 周前更新
代码仓库

关于 go-defensive-coding

go-defensive-coding 是面向 Go 开发者的实践参考技能,适用于编写或审查代码。它聚焦容易导致故障的语言行为与资源处理模式,并提供操作模式和核对清单。

主要功能

  1. 01讲解类型化 nil 接口陷阱
  2. 02说明 nil map、slice 和 channel 行为
  3. 03介绍 slice 别名与防御性复制
  4. 04涵盖数值转换和比较安全
  5. 05讲解资源生命周期与零值设计
  6. 06列出 Go 工具及审查要点

适用场景

  1. 01在修改代码时加固 Go 实现
  2. 02审查代码中的崩溃或数据损坏风险
  3. 03检查数值转换和共享 slice
  4. 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.md
nil-and-aliasing.md
numeric-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 mapnil 切片nil 通道
读取 / 接收零值索引会 panic永久阻塞
写入 / 发送会 panicappend 可用永久阻塞
len / cap000
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。

验证清单

  1. 没有函数在成功路径上将具体指针类型作为接口返回
  2. 每个 map 在首次写入前都已初始化
  3. 跨包边界传递的子切片使用完整切片表达式 a[:n:n]
  4. 存储在结构体中或从结构体返回的切片和 map 都经过克隆
  5. 不会按值传递或接收持有互斥锁或原子变量的类型
  6. 每次缩窄数值类型的转换都经过范围检查,或注明其值有界
  7. 不对浮点数或 time.Time 使用 ==
  8. 对输入推导出的除数都会检查是否为零
  9. 循环体中没有未置于外层函数作用域内的 defer
  10. 对写入过数据的资源调用延迟 Close 时会报告其错误
  11. go vet、golangci-lint 和 go test -race 均通过

评论

支持 Markdown