wavelet init

This commit is contained in:
ryan
2026-06-18 15:24:48 +08:00
parent d6a7011885
commit 99738bbc17
714 changed files with 139987 additions and 0 deletions
+188
View File
@@ -0,0 +1,188 @@
---
name: go-defensive
description: Use when hardening Go code at API boundaries — copying slices/maps, verifying interface compliance, using defer for cleanup, time.Time/time.Duration, or avoiding mutable globals. Also use when reviewing for robustness concerns like missing cleanup or unsafe crypto usage, even if the user doesn't mention "defensive programming." Does not cover error handling strategy (see go-error-handling).
license: Apache-2.0
compatibility: Uses crypto/rand.Text (Go 1.24+) in examples
metadata:
sources: "Effective Go, Uber Style Guide, Go Wiki CodeReviewComments"
---
# Go 防御性编程模式
## 防御性检查清单优先级
在加固 API 边界代码时,按以下顺序检查:
```
正在审查 API 边界?
├─ 1. 错误处理 → 返回错误;不要 panic(参见 go-error-handling)
├─ 2. 输入验证 → 复制从调用者接收的切片/map
├─ 3. 输出安全 → 在返回给调用者之前复制切片/map
├─ 4. 资源清理 → 使用 defer 进行 Close/Unlock/Cancel
├─ 5. 接口检查 → var _ Interface = (*Type)(nil) 编译时验证
├─ 6. 时间正确性 → 使用 time.Time 和 time.Duration,不要用 int/float
├─ 7. 枚举安全 → iota 从 1 开始,使零值无效
└─ 8. 加密安全 → 用 crypto/rand 生成密钥,绝不用 math/rand
```
---
## 快速参考
| 模式 | 规则 | 详情 |
|------|------|------|
| 边界复制 | 在接收和返回时复制切片/map | [BOUNDARY-COPYING.md](references/BOUNDARY-COPYING.md) |
| Defer 清理 | 在 `os.Open` 之后立即 `defer f.Close()` | 见下文 |
| 接口检查 | `var _ I = (*T)(nil)` | 参见 go-interfaces |
| 时间类型 | `time.Time` / `time.Duration`,绝不用原始 int | [TIME-ENUMS-TAGS.md](references/TIME-ENUMS-TAGS.md) |
| 枚举起始值 | `iota + 1` 使零值 = 无效 | 见下文 |
| 加密随机数 | 用 `crypto/rand` 生成密钥,绝不用 `math/rand` | 见下文 |
| Must 函数 | 仅在初始化时使用;失败时 panic | [MUST-FUNCTIONS.md](references/MUST-FUNCTIONS.md) |
| Panic/recover | 绝不跨包暴露 panic | [PANIC-RECOVER.md](references/PANIC-RECOVER.md) |
| 可变全局变量 | 用依赖注入替代 | 见下文 |
---
## 验证接口合规性
使用编译时检查来验证接口实现。完整模式请参见 **go-interfaces**:接口满足检查。
```go
var _ http.Handler = (*Handler)(nil)
```
## 在边界处复制切片和 Map
切片和 map 包含指向底层数据的指针。在 API 边界处复制,以防止意外修改。
```go
// 接收:复制传入的切片
d.trips = make([]Trip, len(trips))
copy(d.trips, trips)
// 返回:在返回之前复制 map
result := make(map[string]int, len(s.counters))
for k, v := range s.counters { result[k] = v }
```
> 在 API 边界处复制切片或 map,或决定何时需要防御性复制、何时可以跳过时,请阅读 [references/BOUNDARY-COPYING.md](references/BOUNDARY-COPYING.md)。
## 使用 Defer 清理资源
使用 `defer` 清理资源(文件、锁)。避免在多个返回路径中遗漏清理。
```go
p.Lock()
defer p.Unlock()
if p.count < 10 {
return p.count
}
p.count++
return p.count
```
Defer 的开销可以忽略不计。在 `os.Open` 之后立即放置 `defer f.Close()` 以提高清晰度。延迟函数的参数在 `defer` 执行时求值,而非在函数运行时。多个 defer 按 LIFO 顺序执行。
## 结构体字段标签
> **建议**:始终为需要序列化或反序列化的结构体添加显式字段标签。
```go
type User struct {
Name string `json:"name" yaml:"name"`
Email string `json:"email" yaml:"email"`
}
```
字段标签是**序列化契约**——重命名结构体字段而不更新标签会悄然破坏线格式兼容性。对于任何跨越序列化边界的类型,应将标签视为公共 API 的一部分。
## 枚举从 1 开始
枚举从非零值开始,以区分未初始化的值和有效值。
```go
const (
Add Operation = iota + 1 // Add=1,零值 = 未初始化
Subtract
Multiply
)
```
**例外**:当零值是合理的默认值时(例如 `LogToStdout = iota`)。
## 时间、结构体标签和嵌入
> 在使用 `time.Time`/`time.Duration` 代替原始 int、为序列化结构体添加字段标签,或决定是否在公共结构体中嵌入类型时,请阅读 [references/TIME-ENUMS-TAGS.md](references/TIME-ENUMS-TAGS.md)。
## 避免可变全局变量
通过注入依赖代替修改包级变量。这使代码可以在不需要全局 save/restore 的情况下进行测试。
```go
type signer struct {
now func() time.Time // 注入的;测试中用固定时间替换
}
func newSigner() *signer {
return &signer{now: time.Now}
}
```
> 在决定全局变量是否合适、设计 New() + Default() 包状态模式,或用依赖注入替代可变全局变量时,请阅读 [references/GLOBAL-STATE.md](references/GLOBAL-STATE.md)。
## 加密随机数
不要使用 `math/rand` 或 `math/rand/v2` 生成密钥——这是一个**安全问题**。时间种子的生成器输出是可预测的。
```go
import "crypto/rand"
func Key() string { return rand.Text() }
```
对于文本输出,直接使用 `crypto/rand.Text`,或用 `encoding/hex` 或 `encoding/base64` 编码随机字节。
---
## Panic 与 Recover
仅在真正不可恢复的情况下使用 `panic`。库函数应避免 panic。
```go
func safelyDo(work *Work) {
defer func() {
if err := recover(); err != nil {
log.Println("work failed:", err)
}
}()
do(work)
}
```
**关键规则:**
- 绝不跨包边界暴露 panic——始终转换为 error
- 如果库确实无法在 `init()` 中完成初始化,可以接受 panic
- 使用 recover 隔离服务器 goroutine 处理器中的 panic
> 在编写 HTTP 服务器中的 panic 恢复、在解析器中使用 panic 作为内部控制流机制,或在 log.Fatal 和 panic 之间做选择时,请阅读 [references/PANIC-RECOVER.md](references/PANIC-RECOVER.md)。
## Must 函数
`Must` 函数在出错时 panic——**仅**在程序初始化阶段使用,因为失败意味着程序无法运行。
```go
var validID = regexp.MustCompile(`^[a-z][a-z0-9-]{0,62}$`)
var tmpl = template.Must(template.ParseFiles("index.html"))
```
> 在编写自定义 Must 函数、决定 Must 是否适用于特定调用点,或将可能失败的初始化包装在 panic 辅助函数中时,请阅读 [references/MUST-FUNCTIONS.md](references/MUST-FUNCTIONS.md)。
---
## 相关技能
- **错误处理**:在选择返回错误还是 panic,或在边界处包装错误时,参见 [go-error-handling](../go-error-handling/SKILL.md)
- **并发安全**:在使用互斥锁、原子操作或通道保护共享状态时,参见 [go-concurrency](../go-concurrency/SKILL.md)
- **接口检查**:在添加编译时接口满足检查(`var _ I = (*T)(nil)`)时,参见 [go-interfaces](../go-interfaces/SKILL.md)
- **数据结构复制**:在处理切片/map 内部结构或指针别名时,参见 [go-data-structures](../go-data-structures/SKILL.md)
@@ -0,0 +1,101 @@
# 在 API 边界处复制切片和 Map
> **来源**:Uber 风格指南
切片和 map 包含对其底层数据的引用。在 API 边界处复制它们,以防止调用者修改内部状态(反之亦然)。
## 接收切片和 Map
当函数存储调用者传入的切片或 map 时,始终进行防御性复制。调用者保留原始引用,可以在函数返回后修改它。
### 切片
**不好**
```go
func (d *Driver) SetTrips(trips []Trip) {
d.trips = trips // 调用者仍然可以修改 d.trips
}
```
**好**
```go
func (d *Driver) SetTrips(trips []Trip) {
d.trips = make([]Trip, len(trips))
copy(d.trips, trips)
}
```
### Map
**不好**
```go
func (s *Server) SetConfig(cfg map[string]string) {
s.config = cfg // 调用者仍然可以修改 s.config
}
```
**好**
```go
func (s *Server) SetConfig(cfg map[string]string) {
s.config = make(map[string]string, len(cfg))
for k, v := range cfg {
s.config[k] = v
}
}
```
## 返回切片和 Map
返回内部切片或 map 时,返回副本以防止调用者修改你的内部状态。
### 返回 Map
**不好**
```go
func (s *Stats) Snapshot() map[string]int {
s.mu.Lock()
defer s.mu.Unlock()
return s.counters // 暴露了内部状态!
}
```
**好**
```go
func (s *Stats) Snapshot() map[string]int {
s.mu.Lock()
defer s.mu.Unlock()
result := make(map[string]int, len(s.counters))
for k, v := range s.counters {
result[k] = v
}
return result
}
```
### 返回切片
**不好**
```go
func (q *Queue) Items() []Item {
return q.items // 调用者可以追加、修改或重新切片
}
```
**好**
```go
func (q *Queue) Items() []Item {
result := make([]Item, len(q.items))
copy(result, q.items)
return result
}
```
## 何时不需要复制
防御性复制有开销。在以下情况下可以跳过:
- 数据**按约定是不可变的**,并且有清晰的文档说明
- 切片/map 是**为调用者新创建的**(不在内部存储)
- 性能分析表明复制在热路径中是瓶颈
如有疑问,就复制。与共享引用导致的 bug 相比,开销通常可以忽略不计。
@@ -0,0 +1,144 @@
# 全局状态模式
> **来源**:Google 风格指南, Effective Go
全局状态使程序更难以测试、推理和维护。依赖注入是首选替代方案,但某些全局状态在谨慎使用时是可以接受的。
## 何时可以接受全局状态
并非所有包级变量都有害。当全局状态是**真正进程级别的**且**不值得注入**时,它是合适的:
- **默认实例**——`http.DefaultClient`、`log.Default()`、`flag.CommandLine`
- **一次编译的值**——包级别的 `regexp.MustCompile(...)`
- **注册表**——`database/sql.Register`、`image.RegisterFormat`
- **单例基础设施**——进程级别的指标收集器或追踪导出器
## 全局变量的试金石测试
在添加包级变量之前,请问自己:
1. **它是否真正是进程级别的?** 如果两个 goroutine 或测试可能需要不同的值,它不应该是全局的
2. **它是否妨碍了测试?** 如果测试必须保存/恢复变量,或因此无法并行运行,应改为注入
3. **它可以是常量吗?** 如果值在初始化后永远不会改变,优先使用 `const` 或未导出的只初始化一次的 `var`
4. **它是否携带可变状态?** 可变全局变量是最危险的——仅在有完善文档、并发安全的单例情况下才可接受
## 包状态 API 模式:New() + Default()
标准库模式同时提供可定制的构造器和便捷的默认值。这使调用者可以在简单场景下使用默认值,在测试或特殊行为需求下注入自定义实例。
**好**
```go
package mylog
type Logger struct {
prefix string
out io.Writer
}
func New(prefix string, out io.Writer) *Logger {
return &Logger{prefix: prefix, out: out}
}
var defaultLogger = New("", os.Stderr)
func Default() *Logger { return defaultLogger }
func (l *Logger) Info(msg string) {
fmt.Fprintf(l.out, "%s%s\n", l.prefix, msg)
}
// 包级便捷函数委托给默认实例。
func Info(msg string) { defaultLogger.Info(msg) }
```
```go
// 调用者在简单场景下使用默认值
mylog.Info("starting server")
// 测试或特殊代码创建自定义实例
logger := mylog.New("[test] ", &buf)
logger.Info("test message")
```
此模式的标准库示例:
- `log.New()` + `log.Default()` + `log.Println()`
- `http.NewServeMux()` + `http.DefaultServeMux`
- `flag.NewFlagSet()` + `flag.CommandLine`
## 依赖注入作为首选替代方案
当代码需要可配置行为时,通过构造器参数或结构体字段接受依赖,而非读取包级变量。
**不好**
```go
var db *sql.DB
func GetUser(id int) (*User, error) {
return db.QueryRow("SELECT ...", id) // 依赖全局变量
}
```
**好**
```go
type UserStore struct {
db *sql.DB
}
func NewUserStore(db *sql.DB) *UserStore {
return &UserStore{db: db}
}
func (s *UserStore) GetUser(id int) (*User, error) {
return s.db.QueryRow("SELECT ...", id)
}
```
注入的好处:
- 测试可以提供 mock 或内存实现
- 多个实例可以共存(例如,只读副本与主库)
- 依赖在构造器签名中是显式的
## 注入时间
一个常见场景:替换 `time.Now` 以实现确定性测试。
**不好**
```go
func IsExpired(expiry time.Time) bool {
return time.Now().After(expiry) // 不可测试
}
```
**好**
```go
type Checker struct {
now func() time.Time
}
func NewChecker() *Checker {
return &Checker{now: time.Now}
}
func (c *Checker) IsExpired(expiry time.Time) bool {
return c.now().After(expiry)
}
```
测试用固定函数替换 `now`:
```go
c := &Checker{now: func() time.Time {
return time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
}}
```
## 总结
| 场景 | 方法 |
|------|------|
| 进程级单例(日志、指标) | 默认实例 + `New()` 构造器 |
| 一次编译的正则或模板 | 包级 `var` 配合 `MustCompile` |
| 注册表(数据库驱动、编解码器) | 包级 `Register()` 函数 |
| 可配置行为 | 通过构造器进行依赖注入 |
| 时间相关逻辑 | 注入 `func() time.Time` |
| 测试需要变化的任何东西 | 不要使用全局状态 |
@@ -0,0 +1,92 @@
# Must 函数
> **来源**:Uber 风格指南, Go 标准库约定
`Must` 函数包装一个可能失败的函数,在出错时 panic。**仅**在程序初始化阶段使用,因为失败意味着程序无法运行。
## 标准库示例
```go
// regexp.MustCompile 在模式无效时 panic
var validID = regexp.MustCompile(`^[a-z][a-z0-9-]{0,62}$`)
// template.Must 在模板解析失败时 panic
var tmpl = template.Must(template.ParseFiles("index.html"))
```
这些是安全的,因为它们在包初始化时运行——如果失败,程序无法正确运行。
## 何时使用 Must
```
这是在程序初始化期间调用的吗(包级 var、init、main 设置)?
├─ 是 → 失败是否不可恢复(配置、正则、模板)?
│ ├─ 是 → 使用 Must 是合适的
│ └─ 否 → 改为返回 error
└─ 否 → 绝不使用 Must——返回 error
```
### 适当的使用场景
- **包级 `var`**:编译正则表达式、解析模板、加载必需的配置
- **`init()` 或 `main()` 早期**:设置程序运行所必需的资源
- **测试辅助函数**:测试中优先使用 `t.Fatal`,但 Must 在测试 fixture 中是可以接受的
### 绝不使用 Must 的场景
- 运行时请求处理
- 用户提供的输入
- 可能合理失败的网络或文件操作
- 程序启动后调用的任何代码
## 编写 Must 函数
遵循命名约定 `MustX`,其中 `X` 是可能失败的函数名:
```go
func MustParseConfig(path string) *Config {
cfg, err := ParseConfig(path)
if err != nil {
panic(fmt.Sprintf("parsing config %s: %v", path, err))
}
return cfg
}
```
### 指南
- **命名**:`Must` 前缀 + 可能失败的函数名(例如 `MustParse`、`MustNew`、`MustCompile`)
- **Panic 消息**:包含输入和错误信息以便调试
- **文档**:始终记录函数在出错时会 panic
```go
// MustParseConfig 解析路径处的配置文件。
// 如果文件无法读取或包含无效配置,则会 panic。
func MustParseConfig(path string) *Config { ... }
```
### 泛型 Must 辅助函数
对于一次性使用,泛型 Must 辅助函数可以避免样板代码:
```go
func Must[T any](v T, err error) T {
if err != nil {
panic(err)
}
return v
}
// 在包级别使用
var cfg = Must(ParseConfig("app.yaml"))
```
## 与 Panic/Recover 的关系
Must 函数是对 `panic` 的受控使用。它们应该:
- 仅在初始化期间运行(因此不需要 recover)
- 产生清晰、可操作的 panic 消息
- 绝不在可以返回 error 的场景中使用
完整的 panic/recover 模式请参见 [PANIC-RECOVER.md](PANIC-RECOVER.md)。
@@ -0,0 +1,161 @@
# Panic 与 Recover 模式
> **来源**:Effective Go
## Panic 指南
`panic` 创建一个运行时错误来停止程序。仅在真正不可恢复的情况下使用。
### 何时 Panic
真正的库函数应**避免 panic**。如果问题可以被掩盖或绕过,让程序继续运行,而不是让整个程序崩溃。
```go
// 可接受:真正不可能的情况
func CubeRoot(x float64) float64 {
z := x/3
for i := 0; i < 1e6; i++ {
prevz := z
z -= (z*z*z-x) / (3*z*z)
if veryClose(z, prevz) {
return z
}
}
// 百万次迭代仍未收敛;出了问题。
panic(fmt.Sprintf("CubeRoot(%g) did not converge", x))
}
```
### 初始化中的 Panic
例外:如果库在 `init()` 期间确实无法完成初始化,panic 可能是合理的:
```go
var user = os.Getenv("USER")
func init() {
if user == "" {
panic("no value for $USER")
}
}
```
### 何时 Panic 是可接受的
除了初始化之外,panic 在以下窄泛场景中是可接受的:
1. **API 误用**——类似于核心语言对越界访问的 panic。`reflect` 包使用了这种方法。
2. **带有匹配 `recover` 的内部实现细节**在包边界处。Panic 简化了深层嵌套的控制流,而公共 API 仍然返回 error(下方的 Parse/parseInt 模式)。
3. **`panic("unreachable")`** 在 `log.Fatal` 之后,当编译器无法检测到不可达代码时。
#### Parse/parseInt 模式
在内部使用 panic 来回退复杂的递归,但始终在包边界处转换为 error:
```go
func parseInt(in string) int {
n, err := strconv.Atoi(in)
if err != nil {
panic(&syntaxError{"not a valid integer"})
}
return n
}
func Parse(in string) (_ *Node, err error) {
defer func() {
if p := recover(); p != nil {
sErr, ok := p.(*syntaxError)
if !ok {
panic(p) // 不是我们的——重新 panic
}
err = fmt.Errorf("syntax error: %v", sErr.msg)
}
}()
// ... 内部调用 parseInt
}
```
**关键**:类型检查 `p.(*syntaxError)` 确保只捕获*我们的* panic。意外的 panic(nil 指针等)正常传播。
---
## Recover 模式
`recover` 重新获得对正在 panic 的 goroutine 的控制。它只在延迟函数中有效。
### 基本恢复模式
```go
func safelyDo(work *Work) {
defer func() {
if err := recover(); err != nil {
log.Println("work failed:", err)
}
}()
do(work)
}
```
### 服务器 Goroutine 保护
在服务器中将 panic 隔离到各个 goroutine:
```go
func server(workChan <-chan *Work) {
for work := range workChan {
go safelyDo(work) // 每个 worker 都受保护
}
}
```
如果 `do(work)` panic,结果会被记录,goroutine 干净退出而不影响其他 goroutine。
### 包内部的 Panic/Recover
在内部使用 panic 但在 API 边界处转换为 error:
```go
// Error 是一个解析错误类型
type Error string
func (e Error) Error() string { return string(e) }
// 内部:使用 Error 类型 panic
func (regexp *Regexp) error(err string) {
panic(Error(err))
}
// 外部 API:将 panic 转换为 error 返回
func Compile(str string) (regexp *Regexp, err error) {
regexp = new(Regexp)
defer func() {
if e := recover(); e != nil {
regexp = nil
err = e.(Error) // 如果不是我们的 Error 类型则重新 panic
}
}()
return regexp.doParse(str), nil
}
```
**要点:**
- 延迟函数可以修改命名返回值
- 类型断言 `e.(Error)` 对意外错误类型重新 panic
- 绝不向客户端暴露 panic——始终在 API 边界处转换
---
## 快速参考
| 模式 | 描述 |
|------|------|
| 基本恢复 | `defer func() { if err := recover(); err != nil { ... } }()` |
| 服务器保护 | 将每个 goroutine 处理器包装在 safelyDo 中 |
| 包内部 | 内部 panic,在 API 边界处 recover 并返回 error |
| 类型安全恢复 | 使用类型断言对意外错误重新 panic |
## 何时使用
- **Panic**:仅用于真正不可恢复的情况或初始化失败
- **Recover**:服务器处理器、包内部错误简化
- **绝不**:跨包边界暴露 panic——始终转换为 error
@@ -0,0 +1,111 @@
# 时间、结构体标签和嵌入模式
## 使用 time.Time 和 time.Duration
始终使用 `time` 包。避免使用原始 `int` 表示时间值。
### 时间点
**不好**
```go
func isActive(now, start, stop int) bool {
return start <= now && now < stop
}
```
**好**
```go
func isActive(now, start, stop time.Time) bool {
return (start.Before(now) || start.Equal(now)) && now.Before(stop)
}
```
### 时长
**不好**
```go
func poll(delay int) {
time.Sleep(time.Duration(delay) * time.Millisecond)
}
poll(10) // 秒?毫秒?
```
**好**
```go
func poll(delay time.Duration) {
time.Sleep(delay)
}
poll(10 * time.Second)
```
### JSON 字段
当无法使用 `time.Duration` 时,在字段名中包含单位:
**不好**
```go
type Config struct {
Interval int `json:"interval"`
}
```
**好**
```go
type Config struct {
IntervalMillis int `json:"intervalMillis"`
}
```
## 避免在公共结构体中嵌入类型
嵌入类型会泄露实现细节并阻碍类型演进。
**不好**
```go
type ConcreteList struct {
*AbstractList
}
```
**好**
```go
type ConcreteList struct {
list *AbstractList
}
func (l *ConcreteList) Add(e Entity) {
l.list.Add(e)
}
func (l *ConcreteList) Remove(e Entity) {
l.list.Remove(e)
}
```
嵌入的问题:
- 向嵌入接口添加方法是破坏性变更
- 从嵌入结构体移除方法是破坏性变更
- 替换嵌入类型是破坏性变更
## 在序列化结构体中使用字段标签
始终为 JSON、YAML 等使用显式字段标签。
**不好**
```go
type Stock struct {
Price int
Name string
}
```
**好**
```go
type Stock struct {
Price int `json:"price"`
Name string `json:"name"`
// 可以安全地将 Name 重命名为 Symbol
}
```
标签使序列化契约显式化,并可以安全地进行重构。