mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 05:56:38 +08:00
Compare commits
287 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ae3b792e16 | |||
| b66cf3ae9c | |||
| a8fcf6087a | |||
| 30acdb91e8 | |||
| 124ce9bebb | |||
| 635c1760ad | |||
| 69d39d906f | |||
| 09ec9d0af3 | |||
| 7ca6dbe272 | |||
| ef97ca5c7e | |||
| cc86370e50 | |||
| 60afdf7c7b | |||
| 9b1ef1cf7f | |||
| 8ea4c7e13e | |||
| a4db79e9bd | |||
| 8b1eb9ca0d | |||
| e36db8a56c | |||
| 37d5a87c9b | |||
| e52592b16d | |||
| 6a53619dd2 | |||
| b9b42e3174 | |||
| 20830d31bd | |||
| 284eec54f7 | |||
| a3ad0d2c97 | |||
| 92322c7a22 | |||
| f499645cdc | |||
| 776c6b397c | |||
| db17965b1a | |||
| ae35b4e733 | |||
| c6d22549ce | |||
| 9f15f42b47 | |||
| a4588b05e0 | |||
| 1625cfb2fb | |||
| 9c1369186f | |||
| fbbb75095f | |||
| 290427d5fa | |||
| 72dbfac3fc | |||
| 018f237384 | |||
| ef28cdc3ad | |||
| b1f2241d0a | |||
| fae83ab0fa | |||
| b498117f32 | |||
| ef4e9c9edc | |||
| 5fd056f99f | |||
| 08fac67f2a | |||
| 9cac25696a | |||
| a938a5e67f | |||
| e0eb4e5975 | |||
| 08e61ce833 | |||
| c293a255d0 | |||
| 1855b48028 | |||
| 2310e1d12a | |||
| f5ee19405f | |||
| de605834b7 | |||
| 5a6ffd600a | |||
| bdbc42c22d | |||
| 84626f9613 | |||
| 932f0c65b9 | |||
| 206f8b59c9 | |||
| a25a570973 | |||
| 681de3b8cc | |||
| a4f6c2ae34 | |||
| 6e6bce6a03 | |||
| ef5d5b46af | |||
| a1f6459c09 | |||
| 280bb63cbd | |||
| f52c8db21a | |||
| d490030b75 | |||
| 200525a1ab | |||
| cac6e88bc0 | |||
| 080be1e03a | |||
| d0a9958711 | |||
| a98d266278 | |||
| 00593742c3 | |||
| 13e9fead35 | |||
| 83b5475d69 | |||
| 990e3a6f51 | |||
| 1cad0c5c55 | |||
| 9c6c697d95 | |||
| 410ff14795 | |||
| 1f391e9ec2 | |||
| 6fa7034172 | |||
| e766066a75 | |||
| 2648f3f3b7 | |||
| 1b2e083aec | |||
| dd991909af | |||
| e5b3a60f73 | |||
| beae8d2dd9 | |||
| 3d25a377cb | |||
| 9af84c8ed6 | |||
| 13f823e5e3 | |||
| dac1c979d1 | |||
| de8a21a49f | |||
| 03fa5d948d | |||
| fcf17db2e7 | |||
| 22c2ad5c73 | |||
| 1135347a96 | |||
| b659f47b62 | |||
| 6066eb114b | |||
| 50c45db561 | |||
| eb99628cd6 | |||
| f826807cf1 | |||
| 333e45572a | |||
| 3a05e7f09c | |||
| 92dc6a59c0 | |||
| a27113feb2 | |||
| a44043262e | |||
| 9550fa6ff3 | |||
| d3ba767087 | |||
| ca21e0eca7 | |||
| 6a8f9a7aea | |||
| e057cd5de5 | |||
| 82155b8599 | |||
| f129a9cfca | |||
| 9fa38dcfdc | |||
| 9515f0811b | |||
| c6df73e522 | |||
| bfa00ae6ac | |||
| 6a913701b9 | |||
| ff60f1c699 | |||
| e50e600bc8 | |||
| 0adc5e8189 | |||
| dfcbdfe47c | |||
| 5016103774 | |||
| 363d2cb4cc | |||
| 34de1639f5 | |||
| 7f00ed6a65 | |||
| 3e8e879661 | |||
| 239711cea7 | |||
| 953af7d8db | |||
| b3ed94342c | |||
| 84ae4ec27e | |||
| 1425fd1dbb | |||
| 2a3b9f8a5f | |||
| de58b118b4 | |||
| cb018b3b60 | |||
| aee457093d | |||
| 5698169f20 | |||
| 922f764241 | |||
| 5b13d5b464 | |||
| 8b19ffed90 | |||
| dbabe8b8d7 | |||
| 1d37242a8b | |||
| ce9423f877 | |||
| 66e0a69666 | |||
| 26a1e71a27 | |||
| acd430836f | |||
| bc18800b58 | |||
| 9a18bea324 | |||
| 4bf8a4806e | |||
| 9453af2aa0 | |||
| b262880189 | |||
| 04280a7b11 | |||
| 40e83a832f | |||
| e5c661f957 | |||
| 49bd850c8b | |||
| 9a6bb04bf5 | |||
| 0df4712831 | |||
| 30aa89f686 | |||
| 26e12594a2 | |||
| eb999eba09 | |||
| f6f8c25930 | |||
| 7b379863b4 | |||
| 5412c385dc | |||
| 895788974c | |||
| f48426dbf8 | |||
| 50d21b431b | |||
| 3228574a8c | |||
| 752101612e | |||
| c9642fb5e2 | |||
| 179a23f1a0 | |||
| 55831efd44 | |||
| c916f566d9 | |||
| 407c1edf74 | |||
| 654d7fd646 | |||
| 16604e5f86 | |||
| 6a1f89936e | |||
| d9df78d2c7 | |||
| 63e3ade7f4 | |||
| 1fe5118029 | |||
| 62fcd245f2 | |||
| 01b80ac376 | |||
| 50533e1837 | |||
| 7c125ccef2 | |||
| 46760d4286 | |||
| 5915b31519 | |||
| 7da4b72d24 | |||
| f14875a8de | |||
| 1eef5336e3 | |||
| 533f783268 | |||
| 6b93320404 | |||
| ad97ca7df1 | |||
| 0221d4de14 | |||
| e8e0326879 | |||
| 2a3a17b6fe | |||
| eb9f6023f0 | |||
| 5e0de01c4b | |||
| 312bc7d4d5 | |||
| 786fe71778 | |||
| b5a1707898 | |||
| 616242fad8 | |||
| 6cbc368dc1 | |||
| c1a1904a67 | |||
| 3023d47eec | |||
| 5dedff1324 | |||
| 8964054fd8 | |||
| 5241055030 | |||
| 983228227e | |||
| bff9e8811d | |||
| 3ebead0d15 | |||
| e6a6182a0c | |||
| db163034c0 | |||
| 69edb1252b | |||
| 15b3c83625 | |||
| 57944398e6 | |||
| 3ed4dec4a3 | |||
| d05acd804f | |||
| df1961c42d | |||
| 1b5d37a8e8 | |||
| ebf644adb7 | |||
| 7aeaa7b0d2 | |||
| 32e4a32462 | |||
| 05ef5ed7bd | |||
| 0ead6348f6 | |||
| 949c021d45 | |||
| 1c76c7158a | |||
| 673061265c | |||
| 50f39a6983 | |||
| 055005688a | |||
| b1c161b255 | |||
| 6b3c0217f0 | |||
| 40e8a7cfa3 | |||
| bff09241d3 | |||
| 73b220de3c | |||
| 92664273ed | |||
| d705f2ff64 | |||
| 8801b3976c | |||
| c6eea8111d | |||
| e06f76436e | |||
| b05d26c9c6 | |||
| 4ac9857fe8 | |||
| f428839602 | |||
| 31f0fb4ceb | |||
| e100441e8a | |||
| d7521dc49a | |||
| d4d214d074 | |||
| 61bc569bd8 | |||
| b41457553d | |||
| 7a62a78fad | |||
| 2ae55da52e | |||
| b0023787c5 | |||
| 7579d3865f | |||
| d14d222ede | |||
| ddda7c44ef | |||
| 957116d983 | |||
| 356b3df81e | |||
| db0d503bb4 | |||
| 2cce8a3175 | |||
| a998f02f2b | |||
| 31253eb23d | |||
| cd3d0c9f82 | |||
| 02f458856d | |||
| d99bd5231a | |||
| 0f65202659 | |||
| b96624a251 | |||
| 3aff95d256 | |||
| 85f91b1ed7 | |||
| e3ef6c9d27 | |||
| c1fbf73f7e | |||
| c6cc8c3305 | |||
| 5c7c995a95 | |||
| b03d2d7ea6 | |||
| f136c9abdb | |||
| 72c74803be | |||
| db68a130ce | |||
| 62bd5d09d4 | |||
| 47c9bc53fa | |||
| 474e3da3b7 | |||
| 72d72810b2 | |||
| 9d0f9f0576 | |||
| 4b72419a96 | |||
| 53a4c92176 | |||
| 589ae08318 | |||
| 70a13dc107 | |||
| 360a26f109 | |||
| 48d414e197 | |||
| 8a782525de |
@@ -1,183 +0,0 @@
|
||||
---
|
||||
name: go-code-review
|
||||
description: Use when reviewing Go code or checking code against community style standards. Also use proactively before submitting a Go PR or when reviewing any Go code changes, even if the user doesn't explicitly request a style review. Does not cover language-specific syntax — delegates to specialized skills.
|
||||
license: Apache-2.0
|
||||
compatibility: Web server example in references uses slog (Go 1.21+)
|
||||
metadata:
|
||||
sources: "Go Wiki CodeReviewComments, Uber Style Guide"
|
||||
allowed-tools: Bash(bash:*)
|
||||
---
|
||||
|
||||
# Go 代码审查清单
|
||||
|
||||
## 审查流程
|
||||
|
||||
> 使用 `assets/review-template.md` 格式化代码审查输出,确保结构与"必须修复 / 建议修复 / 吹毛求疵"的严重程度分组保持一致。
|
||||
|
||||
1. 运行 `gofmt -d .` 和 `go vet ./...` 先捕获机械性问题
|
||||
2. 逐文件阅读 diff;对于每个文件,按以下类别顺序检查
|
||||
3. 标记问题时需要包含具体行号引用和规则名称
|
||||
4. 审查完所有文件后,重新阅读标记项以确认它们是真实的问题
|
||||
5. 按严重程度分组汇总发现(必须修复、建议修复、吹毛求疵)
|
||||
|
||||
> **验证**:完成审查后,再次阅读 diff 以验证每个标记的问题都是真实的。删除任何无法用具体行号引用的发现。
|
||||
|
||||
---
|
||||
|
||||
## 格式化
|
||||
|
||||
- [ ] **gofmt**:代码已使用 `gofmt` 或 `goimports` 格式化 → [go-linting](../go-linting/SKILL.md)
|
||||
|
||||
---
|
||||
|
||||
## 文档
|
||||
|
||||
- [ ] **注释句子**:注释是完整的句子,以被描述的名称开头,以句号结尾 → [go-documentation](../go-documentation/SKILL.md)
|
||||
- [ ] **文档注释**:所有导出名称都有文档注释;非平凡的未导出声明也应有 → [go-documentation](../go-documentation/SKILL.md)
|
||||
- [ ] **包注释**:包注释出现在 package 子句附近,无空行 → [go-documentation](../go-documentation/SKILL.md)
|
||||
- [ ] **命名结果参数**:仅当它们能澄清含义时使用(例如,多个相同类型返回值),而不仅仅是为了启用裸返回 → [go-documentation](../go-documentation/SKILL.md)
|
||||
|
||||
---
|
||||
|
||||
## 错误处理
|
||||
|
||||
- [ ] **处理错误**:不使用 `_` 丢弃错误;处理、返回或(在特殊情况下)panic → [go-error-handling](../go-error-handling/SKILL.md)
|
||||
- [ ] **错误字符串**:小写开头,无标点(除非以专有名词/首字母缩略词开头) → [go-error-handling](../go-error-handling/SKILL.md)
|
||||
- [ ] **带内错误**:不使用魔术值(-1、""、nil);使用带 error 或 ok bool 的多返回值 → [go-error-handling](../go-error-handling/SKILL.md)
|
||||
- [ ] **错误流缩进**:先处理错误并返回;保持正常路径的缩进最小化 → [go-error-handling](../go-error-handling/SKILL.md)
|
||||
|
||||
---
|
||||
|
||||
## 命名
|
||||
|
||||
- [ ] **MixedCaps**:使用 `MixedCaps` 或 `mixedCaps`,不使用下划线;未导出使用 `maxLength` 而非 `MAX_LENGTH` → [go-naming](../go-naming/SKILL.md)
|
||||
- [ ] **首字母缩略词**:保持一致的大小写:`URL`/`url`、`ID`/`id`、`HTTP`/`http`(例如 `ServeHTTP`、`xmlHTTPRequest`) → [go-naming](../go-naming/SKILL.md)
|
||||
- [ ] **变量名**:有限作用域用短名称(`i`、`r`、`c`);更广作用域用较长名称 → [go-naming](../go-naming/SKILL.md)
|
||||
- [ ] **接收器名称**:类型的一两个字母缩写(`c` 代表 `Client`);不使用 `this`、`self`、`me`;各方法之间保持一致 → [go-naming](../go-naming/SKILL.md)
|
||||
- [ ] **包名**:不重复(使用 `chubby.File` 而非 `chubby.ChubbyFile`);避免 `util`、`common`、`misc` → [go-packages](../go-packages/SKILL.md)
|
||||
- [ ] **避免内置名称**:不遮蔽 `error`、`string`、`len`、`cap`、`append`、`copy`、`new`、`make` → [go-declarations](../go-declarations/SKILL.md)
|
||||
|
||||
---
|
||||
|
||||
## 并发
|
||||
|
||||
- [ ] **Goroutine 生命周期**:明确 goroutine 何时/是否退出;如不明显则添加文档 → [go-concurrency](../go-concurrency/SKILL.md)
|
||||
- [ ] **同步函数**:优先同步而非异步;让调用者在需要时添加并发 → [go-concurrency](../go-concurrency/SKILL.md)
|
||||
- [ ] **Context**:作为第一个参数;不放在 struct 中;不自定义 Context 类型;即使认为不需要也应传递 → [go-context](../go-context/SKILL.md)
|
||||
|
||||
---
|
||||
|
||||
## 接口
|
||||
|
||||
- [ ] **接口位置**:在消费方包中定义,而非实现方;生产者返回具体类型 → [go-interfaces](../go-interfaces/SKILL.md)
|
||||
- [ ] **不提前定义接口**:不在使用前定义;不在实现方"为了 mock"而定义 → [go-interfaces](../go-interfaces/SKILL.md)
|
||||
- [ ] **接收器类型**:如果会修改状态、有 sync 字段或体积大,使用指针;小的不可变类型使用值;不要混用 → [go-interfaces](../go-interfaces/SKILL.md)
|
||||
|
||||
---
|
||||
|
||||
## 数据结构
|
||||
|
||||
- [ ] **空切片**:优先使用 `var t []string`(nil)而非 `t := []string{}`(非 nil 零长度) → [go-data-structures](../go-data-structures/SKILL.md)
|
||||
- [ ] **复制**:小心复制含指针/切片字段的结构体;不按值复制 `*T` 方法的接收器 → [go-data-structures](../go-data-structures/SKILL.md)
|
||||
|
||||
---
|
||||
|
||||
## 安全性
|
||||
|
||||
- [ ] **加密随机数**:密钥使用 `crypto/rand`,不使用 `math/rand` → [go-defensive](../go-defensive/SKILL.md)
|
||||
- [ ] **不 panic**:常规错误处理使用 error 返回;仅在真正特殊的情况下 panic → [go-defensive](../go-defensive/SKILL.md)
|
||||
|
||||
---
|
||||
|
||||
## 声明与初始化
|
||||
|
||||
- [ ] **分组相似的**:相关的 `var`/`const`/`type` 放在括号块中;不相关的分开 → [go-declarations](../go-declarations/SKILL.md)
|
||||
- [ ] **var vs :=**:有意使用零值时用 `var`;显式赋值时用 `:=` → [go-declarations](../go-declarations/SKILL.md)
|
||||
- [ ] **缩小作用域**:将声明移到使用位置附近;使用 if-init 限制变量作用域 → [go-declarations](../go-declarations/SKILL.md)
|
||||
- [ ] **Struct 初始化**:始终使用字段名;省略零值字段;零值 struct 使用 `var` → [go-declarations](../go-declarations/SKILL.md)
|
||||
- [ ] **使用 `any`**:新代码中优先使用 `any` 而非 `interface{}` → [go-declarations](../go-declarations/SKILL.md)
|
||||
|
||||
---
|
||||
|
||||
## 函数
|
||||
|
||||
- [ ] **文件排序**:类型 → 构造函数 → 导出方法 → 未导出方法 → 工具函数 → [go-functions](../go-functions/SKILL.md)
|
||||
- [ ] **签名格式化**:换行时所有参数各占一行并带尾逗号 → [go-functions](../go-functions/SKILL.md)
|
||||
- [ ] **裸参数**:为含义不明确的 bool/int 参数添加 `/* name */` 注释,或使用自定义类型 → [go-functions](../go-functions/SKILL.md)
|
||||
- [ ] **Printf 命名**:接受格式字符串的函数以 `f` 结尾,以便 `go vet` 检查 → [go-functions](../go-functions/SKILL.md)
|
||||
|
||||
---
|
||||
|
||||
## 风格
|
||||
|
||||
- [ ] **行长度**:无硬性限制,但避免令人不适的长行;按语义断行,而非任意长度 → [go-style-core](../go-style-core/SKILL.md)
|
||||
- [ ] **裸返回**:仅在短函数中使用;中/大函数使用显式返回 → [go-style-core](../go-style-core/SKILL.md)
|
||||
- [ ] **传值**:不要仅为节省字节而使用指针;小的固定大小类型传 `string` 而非 `*string` → [go-performance](../go-performance/SKILL.md)
|
||||
- [ ] **字符串拼接**:简单拼接用 `+`;格式化用 `fmt.Sprintf`;循环中用 `strings.Builder` → [go-performance](../go-performance/SKILL.md)
|
||||
|
||||
---
|
||||
|
||||
## 日志
|
||||
|
||||
- [ ] **使用 slog**:新代码使用 `log/slog`,不使用 `log` 或 `fmt.Println` 进行运维日志记录 → [go-logging](../go-logging/SKILL.md)
|
||||
- [ ] **结构化字段**:日志消息使用静态字符串加键值属性,不使用 fmt.Sprintf → [go-logging](../go-logging/SKILL.md)
|
||||
- [ ] **适当的级别**:Debug 用于开发者追踪,Info 用于重要事件,Warn 用于可恢复的问题,Error 用于故障 → [go-logging](../go-logging/SKILL.md)
|
||||
- [ ] **日志中无敏感信息**:PII、凭证和令牌永远不记录在日志中 → [go-logging](../go-logging/SKILL.md)
|
||||
|
||||
---
|
||||
|
||||
## 导入
|
||||
|
||||
- [ ] **导入分组**:标准库优先,然后空行,再外部包 → [go-packages](../go-packages/SKILL.md)
|
||||
- [ ] **导入重命名**:除非冲突否则避免重命名;冲突时重命名本地/项目特定的导入 → [go-packages](../go-packages/SKILL.md)
|
||||
- [ ] **空白导入**:`import _ "pkg"` 仅在 main 包或测试中使用 → [go-packages](../go-packages/SKILL.md)
|
||||
- [ ] **点导入**:仅在测试中用于解决循环依赖 → [go-packages](../go-packages/SKILL.md)
|
||||
|
||||
---
|
||||
|
||||
## 泛型
|
||||
|
||||
- [ ] **何时使用**:仅当多个类型共享相同逻辑且接口不足时 → [go-generics](../go-generics/SKILL.md)
|
||||
- [ ] **类型别名**:使用定义创建新类型;别名仅用于包迁移 → [go-generics](../go-generics/SKILL.md)
|
||||
|
||||
---
|
||||
|
||||
## 测试
|
||||
|
||||
- [ ] **示例**:包含可运行的 `Example` 函数或演示用法的测试 → [go-documentation](../go-documentation/SKILL.md)
|
||||
- [ ] **有用的测试失败信息**:消息包含出了什么错、输入、实际值和期望值;顺序为 `got != want` → [go-testing](../go-testing/SKILL.md)
|
||||
- [ ] **TestMain**:仅当所有测试都需要带清理的公共设置时使用;优先使用作用域化的 helper → [go-testing](../go-testing/SKILL.md)
|
||||
- [ ] **真实传输**:优先使用 `httptest.NewServer` + 真实客户端而非 mock HTTP → [go-testing](../go-testing/SKILL.md)
|
||||
|
||||
---
|
||||
|
||||
## 自动化检查
|
||||
|
||||
运行自动化预审查检查:
|
||||
|
||||
```bash
|
||||
bash scripts/pre-review.sh ./... # 文本输出
|
||||
bash scripts/pre-review.sh --json ./... # 结构化 JSON 输出
|
||||
```
|
||||
|
||||
或手动:`gofmt -l <path> && go vet ./... && golangci-lint run ./...`
|
||||
|
||||
在进入上述清单之前修复所有问题。有关 linter 设置和配置,请参阅 [go-linting](../go-linting/SKILL.md)。
|
||||
|
||||
---
|
||||
|
||||
## 综合示例
|
||||
|
||||
> 在构建生产级 HTTP 服务器并希望验证代码是否正确应用了并发、错误处理、context、文档和命名规范时,阅读 [references/WEB-SERVER.md](references/WEB-SERVER.md)。
|
||||
|
||||
---
|
||||
|
||||
## 相关 Skill
|
||||
|
||||
- **风格基础**:在解决格式化争议或应用"清晰 > 简单 > 简洁"优先级时,请参阅 [go-style-core](../go-style-core/SKILL.md)
|
||||
- **Linting 设置**:在配置 golangci-lint 或将自动化检查添加到 CI 时,请参阅 [go-linting](../go-linting/SKILL.md)
|
||||
- **错误策略**:在审查错误包装、哨兵错误或 handle-once 模式时,请参阅 [go-error-handling](../go-error-handling/SKILL.md)
|
||||
- **命名规范**:在评估标识符名称、接收器名称或包-符号重复时,请参阅 [go-naming](../go-naming/SKILL.md)
|
||||
- **测试模式**:在审查表驱动结构、失败消息或 helper 使用的测试代码时,请参阅 [go-testing](../go-testing/SKILL.md)
|
||||
- **并发安全**:在审查 goroutine 生命周期、channel 使用或互斥锁放置时,请参阅 [go-concurrency](../go-concurrency/SKILL.md)
|
||||
- **日志实践**:在审查日志使用、结构化日志或 slog 配置时,请参阅 [go-logging](../go-logging/SKILL.md)
|
||||
@@ -1,23 +0,0 @@
|
||||
# Code Review: [PR Title]
|
||||
|
||||
## Summary
|
||||
[Brief description of the changes]
|
||||
|
||||
## Findings
|
||||
|
||||
### Must Fix
|
||||
- [ ] [file:line] Description of critical issue
|
||||
|
||||
### Should Fix
|
||||
- [ ] [file:line] Description of recommended improvement
|
||||
|
||||
### Nits
|
||||
- [ ] [file:line] Description of minor suggestion
|
||||
|
||||
## Automated Checks
|
||||
- [ ] `gofmt -d .` — clean
|
||||
- [ ] `go vet ./...` — clean
|
||||
- [ ] `golangci-lint run` — clean
|
||||
|
||||
## Skills Applied
|
||||
[List of go-* skills referenced during review]
|
||||
@@ -1,119 +0,0 @@
|
||||
# Web 服务器:Skill 的综合应用
|
||||
|
||||
本示例展示 Go skill 如何在真实的 HTTP 服务器中协同应用。每个部分
|
||||
引用相关的 skill 以获取详细指导。
|
||||
|
||||
## 结构
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"time"
|
||||
)
|
||||
|
||||
// --- 接口(go-interfaces) ---
|
||||
|
||||
// Store 定义了数据访问边界。定义在消费方包中,
|
||||
// 而非实现方包中。
|
||||
type Store interface {
|
||||
GetUser(ctx context.Context, id string) (*User, error)
|
||||
}
|
||||
|
||||
// --- 类型与构造函数(go-naming、go-declarations) ---
|
||||
|
||||
// Server 处理用户 API 的 HTTP 请求。
|
||||
type Server struct {
|
||||
store Store
|
||||
router *http.ServeMux
|
||||
}
|
||||
|
||||
// NewServer 使用给定的依赖创建 Server。
|
||||
// 调用者必须调用 Shutdown 来释放资源。
|
||||
func NewServer(store Store) *Server {
|
||||
s := &Server{store: store}
|
||||
s.router = http.NewServeMux()
|
||||
s.router.HandleFunc("GET /users/{id}", s.handleGetUser)
|
||||
return s
|
||||
}
|
||||
|
||||
// --- 错误处理(go-error-handling) ---
|
||||
|
||||
// 领域错误作为哨兵 —— 使用 errors.Is 进行检查。
|
||||
var ErrNotFound = errors.New("not found")
|
||||
|
||||
// --- HTTP 处理器(go-control-flow、go-context、go-error-handling) ---
|
||||
|
||||
func (s *Server) handleGetUser(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context() // go-context:从 request 派生
|
||||
id := r.PathValue("id")
|
||||
|
||||
user, err := s.store.GetUser(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) { // go-error-handling:errors.Is
|
||||
http.Error(w, "user not found", http.StatusNotFound)
|
||||
return // go-control-flow:提前返回
|
||||
}
|
||||
// HTTP 处理器是"记录或返回"规则的例外:在服务端记录详细信息,向客户端返回脱敏错误。
|
||||
slog.Error("GetUser failed", "id", id, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(user)
|
||||
}
|
||||
|
||||
// --- 优雅关闭(go-concurrency、go-defensive) ---
|
||||
|
||||
func main() {
|
||||
store := NewDBStore(os.Getenv("DATABASE_URL"))
|
||||
srv := NewServer(store)
|
||||
|
||||
httpSrv := &http.Server{
|
||||
Addr: ":8080",
|
||||
Handler: srv.router,
|
||||
ReadTimeout: 5 * time.Second, // go-defensive:使用 time.Duration
|
||||
WriteTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
// go-concurrency:goroutine 生命周期清晰
|
||||
go func() {
|
||||
sigCh := make(chan os.Signal, 1) // go-concurrency:channel 大小为 1
|
||||
signal.Notify(sigCh, os.Interrupt)
|
||||
<-sigCh
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel() // go-defensive:defer 清理
|
||||
httpSrv.Shutdown(ctx)
|
||||
}()
|
||||
|
||||
slog.Info("starting server", "addr", httpSrv.Addr)
|
||||
if err := httpSrv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
|
||||
slog.Error("server error", "err", err)
|
||||
os.Exit(1) // go-packages:仅在 main 中退出
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 应用的 Skill
|
||||
|
||||
| 领域 | Skill | 演示内容 |
|
||||
|------|-------|----------|
|
||||
| 接口在消费方 | [go-interfaces](../../go-interfaces/SKILL.md) | `Store` 在使用处定义 |
|
||||
| 命名 | [go-naming](../../go-naming/SKILL.md) | MixedCaps、接收器缩写、清晰的函数名 |
|
||||
| 错误处理 | [go-error-handling](../../go-error-handling/SKILL.md) | 哨兵错误、`errors.Is`、记录或返回 |
|
||||
| Context | [go-context](../../go-context/SKILL.md) | 从 request 派生,逐层传递 |
|
||||
| 控制流 | [go-control-flow](../../go-control-flow/SKILL.md) | 错误情况的提前返回 |
|
||||
| 并发 | [go-concurrency](../../go-concurrency/SKILL.md) | 清晰的 goroutine 生命周期、channel 大小 |
|
||||
| 防御性 | [go-defensive](../../go-defensive/SKILL.md) | `defer cancel()`、`time.Duration`、优雅关闭 |
|
||||
| 包管理 | [go-packages](../../go-packages/SKILL.md) | 仅在 `main()` 中退出 |
|
||||
| 日志 | [go-error-handling](../../go-error-handling/SKILL.md) | 结构化 slog,错误只处理一次 |
|
||||
@@ -1,246 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="1.0.0"
|
||||
SCRIPT_NAME="$(basename "$0")"
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
$SCRIPT_NAME v$VERSION — Run automated pre-review checks on Go code
|
||||
|
||||
USAGE
|
||||
bash $SCRIPT_NAME [options] [path]
|
||||
|
||||
DESCRIPTION
|
||||
Runs gofmt, go vet, and golangci-lint against the target path and
|
||||
reports any findings. Use before manual code review to catch
|
||||
mechanical issues early.
|
||||
|
||||
Exits 0 if all checks pass, 1 if issues found, 2 on error.
|
||||
|
||||
OPTIONS
|
||||
-h, --help Show this help message
|
||||
-v, --version Show version
|
||||
--json Output results as JSON
|
||||
--force Run even if golangci-lint is not installed (skip it)
|
||||
--limit N Max items reported per section (0 = unlimited, default: 0)
|
||||
|
||||
ARGUMENTS
|
||||
path Package pattern to check (default: ./...)
|
||||
|
||||
EXAMPLES
|
||||
bash $SCRIPT_NAME
|
||||
bash $SCRIPT_NAME ./pkg/...
|
||||
bash $SCRIPT_NAME --json ./cmd/server/...
|
||||
bash $SCRIPT_NAME --force ./...
|
||||
bash $SCRIPT_NAME --json --limit 10 ./...
|
||||
EOF
|
||||
}
|
||||
|
||||
json_escape() {
|
||||
local s="$1"
|
||||
s="${s//\\/\\\\}"
|
||||
s="${s//\"/\\\"}"
|
||||
s="${s//$'\t'/\\t}"
|
||||
s="${s//$'\r'/}"
|
||||
s="${s//$'\n'/\\n}"
|
||||
printf '%s' "$s"
|
||||
}
|
||||
|
||||
JSON_OUTPUT=false
|
||||
FORCE=false
|
||||
LIMIT=0
|
||||
TARGET=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-h|--help) usage; exit 0 ;;
|
||||
-v|--version) echo "$SCRIPT_NAME v$VERSION"; exit 0 ;;
|
||||
--json) JSON_OUTPUT=true; shift ;;
|
||||
--force) FORCE=true; shift ;;
|
||||
--limit) LIMIT="${2:?error: --limit requires a number}"; shift 2 ;;
|
||||
-*) echo "error: unknown option: $1" >&2; usage >&2; exit 2 ;;
|
||||
*) TARGET="$1"; shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
TARGET="${TARGET:-./...}"
|
||||
|
||||
if ! command -v go &>/dev/null; then
|
||||
echo "error: go is not installed or not in PATH" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if ! command -v gofmt &>/dev/null; then
|
||||
echo "error: gofmt is not installed or not in PATH" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
GOFMT_STATUS="pass"
|
||||
GOFMT_FINDINGS=()
|
||||
GOFMT_DIR="${TARGET%%/...}"
|
||||
GOFMT_DIR="${GOFMT_DIR:-.}"
|
||||
UNFORMATTED=$(gofmt -l "$GOFMT_DIR" 2>&1) || true
|
||||
if [[ -n "$UNFORMATTED" ]]; then
|
||||
GOFMT_STATUS="fail"
|
||||
while IFS= read -r f; do
|
||||
[[ -n "$f" ]] && GOFMT_FINDINGS+=("$f")
|
||||
done <<< "$UNFORMATTED"
|
||||
fi
|
||||
|
||||
GOVET_STATUS="pass"
|
||||
GOVET_OUTPUT=""
|
||||
if ! GOVET_OUTPUT=$(go vet "$TARGET" 2>&1); then
|
||||
GOVET_STATUS="fail"
|
||||
fi
|
||||
|
||||
LINT_STATUS="skip"
|
||||
LINT_OUTPUT=""
|
||||
if command -v golangci-lint &>/dev/null; then
|
||||
LINT_STATUS="pass"
|
||||
if ! LINT_OUTPUT=$(golangci-lint run "$TARGET" 2>&1); then
|
||||
LINT_STATUS="fail"
|
||||
fi
|
||||
elif ! $FORCE; then
|
||||
echo "error: golangci-lint not installed (use --force to skip)" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
FAILED=0
|
||||
[[ "$GOFMT_STATUS" == "fail" ]] && FAILED=1
|
||||
[[ "$GOVET_STATUS" == "fail" ]] && FAILED=1
|
||||
[[ "$LINT_STATUS" == "fail" ]] && FAILED=1
|
||||
|
||||
if $JSON_OUTPUT; then
|
||||
GOFMT_TRUNCATED=false
|
||||
GOFMT_DISPLAY=("${GOFMT_FINDINGS[@]+"${GOFMT_FINDINGS[@]}"}")
|
||||
if [[ $LIMIT -gt 0 && ${#GOFMT_DISPLAY[@]} -gt $LIMIT ]]; then
|
||||
GOFMT_DISPLAY=("${GOFMT_FINDINGS[@]:0:$LIMIT}")
|
||||
GOFMT_TRUNCATED=true
|
||||
fi
|
||||
|
||||
GOFMT_JSON="["
|
||||
first=true
|
||||
for f in "${GOFMT_DISPLAY[@]+"${GOFMT_DISPLAY[@]}"}"; do
|
||||
$first || GOFMT_JSON+=","
|
||||
first=false
|
||||
GOFMT_JSON+="\"$(json_escape "$f")\""
|
||||
done
|
||||
GOFMT_JSON+="]"
|
||||
|
||||
GOVET_TRUNCATED=false
|
||||
GOVET_DISPLAY="$GOVET_OUTPUT"
|
||||
if [[ $LIMIT -gt 0 && -n "$GOVET_OUTPUT" ]]; then
|
||||
GOVET_ARR=()
|
||||
while IFS= read -r line; do
|
||||
GOVET_ARR+=("$line")
|
||||
done <<< "$GOVET_OUTPUT"
|
||||
if [[ ${#GOVET_ARR[@]} -gt $LIMIT ]]; then
|
||||
GOVET_DISPLAY=""
|
||||
for (( i=0; i<LIMIT; i++ )); do
|
||||
[[ -n "$GOVET_DISPLAY" ]] && GOVET_DISPLAY+=$'\n'
|
||||
GOVET_DISPLAY+="${GOVET_ARR[$i]}"
|
||||
done
|
||||
GOVET_TRUNCATED=true
|
||||
fi
|
||||
fi
|
||||
GOVET_ESC="$(json_escape "$GOVET_DISPLAY")"
|
||||
|
||||
LINT_TRUNCATED=false
|
||||
LINT_DISPLAY="$LINT_OUTPUT"
|
||||
if [[ $LIMIT -gt 0 && -n "$LINT_OUTPUT" ]]; then
|
||||
LINT_ARR=()
|
||||
while IFS= read -r line; do
|
||||
LINT_ARR+=("$line")
|
||||
done <<< "$LINT_OUTPUT"
|
||||
if [[ ${#LINT_ARR[@]} -gt $LIMIT ]]; then
|
||||
LINT_DISPLAY=""
|
||||
for (( i=0; i<LIMIT; i++ )); do
|
||||
[[ -n "$LINT_DISPLAY" ]] && LINT_DISPLAY+=$'\n'
|
||||
LINT_DISPLAY+="${LINT_ARR[$i]}"
|
||||
done
|
||||
LINT_TRUNCATED=true
|
||||
fi
|
||||
fi
|
||||
LINT_ESC="$(json_escape "$LINT_DISPLAY")"
|
||||
|
||||
GOFMT_TRUNC=""
|
||||
$GOFMT_TRUNCATED && GOFMT_TRUNC=',"truncated":true'
|
||||
GOVET_TRUNC=""
|
||||
$GOVET_TRUNCATED && GOVET_TRUNC=',"truncated":true'
|
||||
LINT_TRUNC=""
|
||||
$LINT_TRUNCATED && LINT_TRUNC=',"truncated":true'
|
||||
|
||||
cat <<EOF
|
||||
{"gofmt":{"status":"$GOFMT_STATUS","files":$GOFMT_JSON$GOFMT_TRUNC},"govet":{"status":"$GOVET_STATUS","output":"$GOVET_ESC"$GOVET_TRUNC},"golangci_lint":{"status":"$LINT_STATUS","output":"$LINT_ESC"$LINT_TRUNC},"passed":$( [[ $FAILED -eq 0 ]] && echo true || echo false )}
|
||||
EOF
|
||||
else
|
||||
echo "=== gofmt ==="
|
||||
if [[ "$GOFMT_STATUS" == "fail" ]]; then
|
||||
echo "Unformatted files:"
|
||||
GOFMT_COUNT=0
|
||||
for f in "${GOFMT_FINDINGS[@]}"; do
|
||||
GOFMT_COUNT=$((GOFMT_COUNT + 1))
|
||||
if [[ $LIMIT -gt 0 && $GOFMT_COUNT -gt $LIMIT ]]; then
|
||||
echo " ... ($(( ${#GOFMT_FINDINGS[@]} - LIMIT )) more items truncated)"
|
||||
break
|
||||
fi
|
||||
echo " $f"
|
||||
done
|
||||
else
|
||||
echo "OK"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== go vet ==="
|
||||
if [[ "$GOVET_STATUS" == "fail" ]]; then
|
||||
if [[ $LIMIT -gt 0 ]]; then
|
||||
GOVET_ARR=()
|
||||
while IFS= read -r line; do
|
||||
GOVET_ARR+=("$line")
|
||||
done <<< "$GOVET_OUTPUT"
|
||||
for (( i=0; i<${#GOVET_ARR[@]} && i<LIMIT; i++ )); do
|
||||
echo "${GOVET_ARR[$i]}"
|
||||
done
|
||||
if [[ ${#GOVET_ARR[@]} -gt $LIMIT ]]; then
|
||||
echo "... ($(( ${#GOVET_ARR[@]} - LIMIT )) more items truncated)"
|
||||
fi
|
||||
else
|
||||
echo "$GOVET_OUTPUT"
|
||||
fi
|
||||
else
|
||||
echo "OK"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== golangci-lint ==="
|
||||
if [[ "$LINT_STATUS" == "skip" ]]; then
|
||||
echo "Skipped (not installed)"
|
||||
elif [[ "$LINT_STATUS" == "fail" ]]; then
|
||||
if [[ $LIMIT -gt 0 ]]; then
|
||||
LINT_ARR=()
|
||||
while IFS= read -r line; do
|
||||
LINT_ARR+=("$line")
|
||||
done <<< "$LINT_OUTPUT"
|
||||
for (( i=0; i<${#LINT_ARR[@]} && i<LIMIT; i++ )); do
|
||||
echo "${LINT_ARR[$i]}"
|
||||
done
|
||||
if [[ ${#LINT_ARR[@]} -gt $LIMIT ]]; then
|
||||
echo "... ($(( ${#LINT_ARR[@]} - LIMIT )) more items truncated)"
|
||||
fi
|
||||
else
|
||||
echo "$LINT_OUTPUT"
|
||||
fi
|
||||
else
|
||||
echo "OK"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
if [[ $FAILED -eq 1 ]]; then
|
||||
echo "Pre-review checks FAILED — fix issues before manual review."
|
||||
else
|
||||
echo "All pre-review checks passed."
|
||||
fi
|
||||
fi
|
||||
|
||||
exit $FAILED
|
||||
@@ -1,191 +0,0 @@
|
||||
---
|
||||
name: go-concurrency
|
||||
description: Use when writing concurrent Go code — goroutines, channels, mutexes, or thread-safety guarantees. Also use when parallelizing work, fixing data races, or protecting shared state, even if the user doesn't explicitly mention concurrency primitives. Does not cover context.Context patterns (see go-context).
|
||||
license: Apache-2.0
|
||||
compatibility: Requires go.uber.org/atomic for atomic operation wrappers
|
||||
metadata:
|
||||
sources: "Effective Go, Google Style Guide, Uber Style Guide"
|
||||
---
|
||||
|
||||
# Go 并发
|
||||
|
||||
## Goroutine 生命周期
|
||||
|
||||
> **规范**:当你启动 goroutine 时,要明确它们何时或是否退出。
|
||||
|
||||
Goroutine 可能因阻塞在 channel 的发送/接收上而泄漏。GC **不会终止**被阻塞的 goroutine,即使没有其他 goroutine 持有对该 channel 的引用。即使不泄漏的在途 goroutine 也会导致 panic(在已关闭的 channel 上发送)、数据竞争、内存问题和资源泄漏。
|
||||
|
||||
### 核心规则
|
||||
|
||||
1. **每个 goroutine 都需要停止机制** —— 可预测的结束时间、取消信号,或两者兼有
|
||||
2. **代码必须能够等待** goroutine 完成
|
||||
3. **不在 `init()` 中启动 goroutine** —— 改为暴露生命周期方法(`Close`、`Stop`、`Shutdown`)
|
||||
4. **保持同步作用域化** —— 限制在函数作用域内,将逻辑分解为同步函数
|
||||
|
||||
```go
|
||||
// 好:使用 WaitGroup 明确生命周期
|
||||
var wg sync.WaitGroup
|
||||
for item := range queue {
|
||||
wg.Add(1)
|
||||
go func() { defer wg.Done(); process(ctx, item) }()
|
||||
}
|
||||
wg.Wait()
|
||||
```
|
||||
|
||||
```go
|
||||
// 不好:无法停止或等待
|
||||
go func() { for { flush(); time.Sleep(delay) } }()
|
||||
```
|
||||
|
||||
使用 [go.uber.org/goleak](https://pkg.go.dev/go.uber.org/goleak) **检测泄漏**。
|
||||
|
||||
> **原则**:永远不要在不知道 goroutine 将如何停止的情况下启动它。
|
||||
|
||||
> 在实现 stop/done channel 模式、goroutine 等待策略或
|
||||
> 生命周期管理的 worker 时,阅读 [references/GOROUTINE-PATTERNS.md](references/GOROUTINE-PATTERNS.md)。
|
||||
|
||||
---
|
||||
|
||||
## 通过通信共享
|
||||
|
||||
> "不要通过共享内存来通信;而是通过通信来共享内存。"
|
||||
|
||||
这是 Go 并发设计的基础原则。使用 **channel** 进行所有权转移和协调 —— 当一个 goroutine 生产值,另一个消费它时使用。当多个 goroutine 访问共享状态且 channel 会增加不必要的复杂性时,使用 **互斥锁**。
|
||||
|
||||
**默认使用 channel。** 当问题本质上是保护共享数据结构(例如缓存或计数器)而非在 goroutine 之间传递数据时,退回到 `sync.Mutex` / `sync.RWMutex`。
|
||||
|
||||
---
|
||||
|
||||
## 同步函数
|
||||
|
||||
> **规范**:优先使用同步函数而非异步函数。
|
||||
|
||||
| 优势 | 原因 |
|
||||
|---|---|
|
||||
| 局部化 goroutine | 生命周期更容易推理 |
|
||||
| 避免泄漏和竞争 | 更容易防止资源泄漏和数据竞争 |
|
||||
| 更容易测试 | 直接检查输入/输出,无需轮询 |
|
||||
| 调用方灵活性 | 调用方在需要时添加并发 |
|
||||
|
||||
> **建议**:在调用方移除不必要的并发是相当困难的(有时是不可能的)。让调用方在需要时添加并发。
|
||||
|
||||
> 在编写同步优先的 API(调用方可以将其包装在 goroutine 中)时,
|
||||
> 阅读 [references/GOROUTINE-PATTERNS.md](references/GOROUTINE-PATTERNS.md)。
|
||||
|
||||
---
|
||||
|
||||
## 零值互斥锁
|
||||
|
||||
`sync.Mutex` 和 `sync.RWMutex` 的零值是有效的 —— 几乎不需要互斥锁的指针。
|
||||
|
||||
```go
|
||||
// 好:零值有效 // 不好:不必要的指针
|
||||
var mu sync.Mutex mu := new(sync.Mutex)
|
||||
```
|
||||
|
||||
**不要嵌入互斥锁** —— 使用命名的 `mu` 字段,使 `Lock`/`Unlock` 保持为实现细节,而非导出的 API。
|
||||
|
||||
> 在实现互斥锁保护的 struct 或决定如何组织互斥锁字段时,
|
||||
> 阅读 [references/SYNC-PRIMITIVES.md](references/SYNC-PRIMITIVES.md)。
|
||||
|
||||
---
|
||||
|
||||
## Channel 方向
|
||||
|
||||
> **规范**:尽可能指定 channel 方向。
|
||||
|
||||
方向可以防止错误(编译器会捕获对仅接收 channel 的关闭操作),传达所有权,并且具有自文档化效果。
|
||||
|
||||
```go
|
||||
func produce(out chan<- int) { /* 仅发送 */ }
|
||||
func consume(in <-chan int) { /* 仅接收 */ }
|
||||
func transform(in <-chan int, out chan<- int) { /* 双向 */ }
|
||||
```
|
||||
|
||||
### Channel 大小:一或零
|
||||
|
||||
Channel 的大小应为 **零**(无缓冲)或 **一**。其他任何大小都需要给出理由:
|
||||
|
||||
- 大小是如何确定的
|
||||
- 什么机制防止 channel 在负载下填满
|
||||
- 当写入者阻塞时会发生什么
|
||||
|
||||
```go
|
||||
c := make(chan int) // 无缓冲 —— 好
|
||||
c := make(chan int, 1) // 大小为 1 —— 好
|
||||
c := make(chan int, 64) // 任意大小 —— 需要给出理由
|
||||
```
|
||||
|
||||
> 在审查详细的 channel 方向示例及易出错模式时,
|
||||
> 阅读 [references/SYNC-PRIMITIVES.md](references/SYNC-PRIMITIVES.md)。
|
||||
|
||||
---
|
||||
|
||||
## 原子操作
|
||||
|
||||
使用 `atomic.Bool`、`atomic.Int64` 等(Go 1.19 起标准库 `sync/atomic` 提供,或 [go.uber.org/atomic](https://pkg.go.dev/go.uber.org/atomic))进行类型安全的原子操作。原始的 `int32`/`int64` 字段容易在某些代码路径上忘记原子访问。
|
||||
|
||||
```go
|
||||
// 好:类型安全 // 不好:容易忘记
|
||||
var running atomic.Bool var running int32 // 原子操作
|
||||
running.Store(true) atomic.StoreInt32(&running, 1)
|
||||
running.Load() running == 1 // 竞争!
|
||||
```
|
||||
|
||||
> 在 sync/atomic 和 go.uber.org/atomic 之间选择,或在 struct 中实现原子
|
||||
> 状态标志时,阅读 [references/SYNC-PRIMITIVES.md](references/SYNC-PRIMITIVES.md)。
|
||||
|
||||
---
|
||||
|
||||
## 并发文档
|
||||
|
||||
> **建议**:当线程安全性从操作类型不明显时,添加文档说明。
|
||||
|
||||
Go 用户假设只读操作可以安全地并发使用,而修改操作则不行。在以下情况添加并发文档:
|
||||
|
||||
1. **读取与修改不明确** —— 例如,会修改 LRU 状态的 `Lookup`
|
||||
2. **API 提供同步** —— 例如,线程安全的客户端
|
||||
3. **接口有并发要求** —— 在类型定义中添加文档
|
||||
|
||||
---
|
||||
|
||||
## Context 使用
|
||||
|
||||
> 有关 context.Context 的指导(参数位置、struct 存储、自定义
|
||||
> 类型、派生模式),请参阅专门的
|
||||
> [go-context](../go-context/SKILL.md) skill。
|
||||
|
||||
---
|
||||
|
||||
## 使用 Channel 的缓冲池
|
||||
|
||||
使用有缓冲 channel 作为空闲列表来复用已分配的缓冲区。这种"泄漏缓冲"模式使用带 `default` 的 `select` 进行非阻塞操作。
|
||||
|
||||
> 在实现带可复用缓冲区的 worker pool 或在基于 channel 的池和
|
||||
> `sync.Pool` 之间选择时,阅读 [references/BUFFER-POOLING.md](references/BUFFER-POOLING.md)。
|
||||
|
||||
---
|
||||
|
||||
## 高级模式
|
||||
|
||||
> 在实现使用 channel 的 channel 进行请求-响应多路复用,或
|
||||
> 跨核心的 CPU 密集型并行计算时,阅读 [references/ADVANCED-PATTERNS.md](references/ADVANCED-PATTERNS.md)。
|
||||
|
||||
---
|
||||
|
||||
## 相关 Skill
|
||||
|
||||
- **Context 传播**:在通过 goroutine 传递取消、截止时间或请求作用域值时,请参阅 [go-context](../go-context/SKILL.md)
|
||||
- **错误处理**:在从 goroutine 传播错误或使用 errgroup 时,请参阅 [go-error-handling](../go-error-handling/SKILL.md)
|
||||
- **防御性加固**:在 API 边界保护共享状态或使用 defer 清理时,请参阅 [go-defensive](../go-defensive/SKILL.md)
|
||||
- **接口设计**:在为包含 sync 原语的类型选择接收器类型时,请参阅 [go-interfaces](../go-interfaces/SKILL.md)
|
||||
|
||||
### 外部资源
|
||||
|
||||
- [永远不要在不知道 goroutine 将如何停止的情况下启动它](https://dave.cheney.net/2016/12/22/never-start-a-goroutine-without-knowing-how-it-will-stop)
|
||||
—— Dave Cheney
|
||||
- [重新思考经典并发模式](https://www.youtube.com/watch?v=5zXAHh5tJqQ) —— Bryan Mills
|
||||
(GopherCon 2018)
|
||||
- [Go 程序何时结束](https://changelog.com/gotime/165) —— Go Time 播客
|
||||
- [go.uber.org/goleak](https://pkg.go.dev/go.uber.org/goleak) —— 用于测试的 Goroutine 泄漏检测器
|
||||
- [go.uber.org/atomic](https://pkg.go.dev/go.uber.org/atomic) —— 类型安全的原子操作
|
||||
@@ -1,132 +0,0 @@
|
||||
# 高级并发模式
|
||||
|
||||
来自 Effective Go 的高级并发模式详细参考。这些模式适用于特定场景 —— 在需要请求/响应多路复用或 CPU 密集型并行化时使用。
|
||||
|
||||
---
|
||||
|
||||
## Channel 的 Channel
|
||||
|
||||
> **来源**:Effective Go
|
||||
|
||||
Channel 是一等公民值,可以像其他值一样被分配和传递。一个强大的模式是在请求结构体中嵌入 **回复 channel**,让每个客户端提供自己的应答路径:
|
||||
|
||||
```go
|
||||
type Request struct {
|
||||
args []int
|
||||
f func([]int) int
|
||||
resultChan chan int
|
||||
}
|
||||
```
|
||||
|
||||
客户端发送一个包含函数、参数和接收结果 channel 的请求:
|
||||
|
||||
```go
|
||||
request := &Request{[]int{3, 4, 5}, sum, make(chan int)}
|
||||
clientRequests <- request
|
||||
fmt.Printf("answer: %d\n", <-request.resultChan)
|
||||
```
|
||||
|
||||
服务端处理器从队列中读取请求,并将结果发送回每个请求的回复 channel:
|
||||
|
||||
```go
|
||||
func handle(queue chan *Request) {
|
||||
for req := range queue {
|
||||
req.resultChan <- req.f(req.args)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
这个模式构成了限速、并行、非阻塞 RPC 系统的基础,无需任何互斥锁。
|
||||
|
||||
---
|
||||
|
||||
## CPU 密集型并行化
|
||||
|
||||
> **来源**:Effective Go(现代化版本)
|
||||
|
||||
当计算可以分解为独立的部分时,使用 `sync.WaitGroup` 等待完成,将其并行化到多个 CPU 核心上:
|
||||
|
||||
```go
|
||||
type Vector []float64
|
||||
|
||||
func (v Vector) DoSome(i, n int, u Vector) {
|
||||
for ; i < n; i++ {
|
||||
v[i] += u.Op(v[i])
|
||||
}
|
||||
}
|
||||
|
||||
func (v Vector) DoAll(u Vector) {
|
||||
numCPU := runtime.NumCPU()
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(numCPU)
|
||||
for i := 0; i < numCPU; i++ {
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
v.DoSome(i*len(v)/numCPU, (i+1)*len(v)/numCPU, u)
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
```
|
||||
|
||||
使用 `runtime.NumCPU()` 获取硬件核心数,或使用 `runtime.GOMAXPROCS(0)` 以遵循用户的资源配置。
|
||||
|
||||
> **重要**:不要混淆并发(将程序组织为独立执行的组件)和并行(在多个 CPU 上同时执行计算)。Go 是一门并发语言;并非所有并行化问题都适合它的模型。
|
||||
|
||||
---
|
||||
|
||||
## 常见错误
|
||||
|
||||
### 忘记通知完成
|
||||
|
||||
如果 goroutine 从未调用 `wg.Done()`(或从未在 done channel 上发送),等待的 goroutine 将永远阻塞:
|
||||
|
||||
```go
|
||||
// 不好:缺少 wg.Done —— 死锁
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
doWork()
|
||||
}()
|
||||
wg.Wait()
|
||||
|
||||
// 好:始终 defer wg.Done
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
doWork()
|
||||
}()
|
||||
wg.Wait()
|
||||
```
|
||||
|
||||
### 无限制的 goroutine 创建
|
||||
|
||||
为每个工作项无限制地启动 goroutine 可能会耗尽内存或压垮下游资源。使用信号量来限制并发数:
|
||||
|
||||
```go
|
||||
// 不好:一次性创建 len(items) 个 goroutine
|
||||
var wg sync.WaitGroup
|
||||
for _, item := range items {
|
||||
wg.Add(1)
|
||||
go func(it Item) {
|
||||
defer wg.Done()
|
||||
process(it)
|
||||
}(item)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// 好:信号量将并发限制为 maxWorkers
|
||||
var wg sync.WaitGroup
|
||||
sem := make(chan struct{}, maxWorkers)
|
||||
for _, item := range items {
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func(it Item) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
process(it)
|
||||
}(item)
|
||||
}
|
||||
wg.Wait()
|
||||
```
|
||||
@@ -1,73 +0,0 @@
|
||||
# 使用 Channel 的缓冲池
|
||||
|
||||
使用有缓冲 channel 作为空闲列表来复用已分配的缓冲区,避免重复分配。这种"泄漏缓冲"模式使用带 `default` 的 `select` 进行非阻塞操作。
|
||||
|
||||
> **来源**:Effective Go
|
||||
|
||||
```go
|
||||
var freeList = make(chan *Buffer, 100) // 有缓冲 channel 作为空闲列表
|
||||
|
||||
// 客户端:从空闲列表获取缓冲区或分配新的
|
||||
func getBuffer() *Buffer {
|
||||
select {
|
||||
case b := <-freeList:
|
||||
return b // 复用已有缓冲区
|
||||
default:
|
||||
return new(Buffer) // 空闲列表为空;分配新缓冲区
|
||||
}
|
||||
}
|
||||
|
||||
// 服务端:如有空间则将缓冲区归还空闲列表,否则丢弃
|
||||
func putBuffer(b *Buffer) {
|
||||
b.Reset() // 为重用做准备
|
||||
select {
|
||||
case freeList <- b:
|
||||
// 缓冲区已归还空闲列表
|
||||
default:
|
||||
// 空闲列表已满;丢弃缓冲区(GC 会回收)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 工作原理
|
||||
|
||||
1. **非阻塞接收**:客户端尝试从 `freeList` 获取缓冲区。如果为空,`default` 分支运行并分配新缓冲区。
|
||||
2. **非阻塞发送**:服务端尝试归还缓冲区。如果 `freeList` 已满,`default` 分支运行,缓冲区被丢弃等待垃圾回收。
|
||||
3. **有限内存**:channel 容量(100)限制了池中缓冲区的数量,防止无限增长。
|
||||
|
||||
当分配成本较高且缓冲区复用有益,但你不希望在池空或池满时出现阻塞行为时,这种模式非常有用。
|
||||
|
||||
## 何时使用
|
||||
|
||||
- 高频分配相似大小的对象
|
||||
- 分配开销影响性能的代码路径
|
||||
- 需要限制内存使用量的场景
|
||||
|
||||
## 生产环境替代方案
|
||||
|
||||
对于生产代码,考虑使用 `sync.Pool`,它提供类似功能并与垃圾收集器有更好的集成:
|
||||
|
||||
```go
|
||||
var bufferPool = sync.Pool{
|
||||
New: func() any {
|
||||
return new(Buffer)
|
||||
},
|
||||
}
|
||||
|
||||
func getBuffer() *Buffer {
|
||||
return bufferPool.Get().(*Buffer)
|
||||
}
|
||||
|
||||
func putBuffer(b *Buffer) {
|
||||
b.Reset()
|
||||
bufferPool.Put(b)
|
||||
}
|
||||
```
|
||||
|
||||
`sync.Pool` 的优势:
|
||||
- 垃圾收集期间自动清理
|
||||
- 无需管理池大小
|
||||
- 天生线程安全
|
||||
- 高并发下性能更好
|
||||
|
||||
基于 channel 的方式对于理解 Go 的并发原语以及需要更多控制池行为的场景仍然很有价值。
|
||||
@@ -1,126 +0,0 @@
|
||||
# Goroutine 生命周期模式
|
||||
|
||||
管理 goroutine 生命周期的详细模式 —— 确保每个 goroutine 都有清晰的启动/停止机制并防止资源泄漏。
|
||||
|
||||
---
|
||||
|
||||
## 使生命周期清晰
|
||||
|
||||
> WaitGroup 示例和作用域规则在父 skill(SKILL.md § Goroutine 生命周期,核心规则)中。本参考涵盖:stop/done channel 模式、等待策略、init() 生命周期示例和同步 API 设计。
|
||||
|
||||
---
|
||||
|
||||
## Stop/Done Channel 模式
|
||||
|
||||
每个 goroutine 必须有可预测的停止机制。使用 stop channel 通知关闭,使用 done channel 确认退出:
|
||||
|
||||
```go
|
||||
var (
|
||||
stop = make(chan struct{}) // 通知 goroutine 停止
|
||||
done = make(chan struct{}) // 通知我们 goroutine 已退出
|
||||
)
|
||||
go func() {
|
||||
defer close(done)
|
||||
ticker := time.NewTicker(delay)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
flush()
|
||||
case <-stop:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// 关闭时:
|
||||
close(stop) // 通知 goroutine 停止
|
||||
<-done // 并等待它退出
|
||||
```
|
||||
|
||||
在已关闭的 channel 上发送会 panic —— 始终使用 `close()` 来发信号,不要直接发送:
|
||||
|
||||
```go
|
||||
ch := make(chan int)
|
||||
close(ch)
|
||||
ch <- 13 // panic: 在已关闭的 channel 上发送
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 等待 Goroutine
|
||||
|
||||
> 多 goroutine 的 `sync.WaitGroup` 模式在父 skill 中(SKILL.md § Goroutine 生命周期)。以下是单 goroutine 的 done-channel 替代方案。
|
||||
|
||||
为单个 goroutine 使用 done channel:
|
||||
|
||||
```go
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
// 工作...
|
||||
}()
|
||||
<-done // 等待 goroutine 完成
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 不在 init() 中使用 Goroutine
|
||||
|
||||
> 核心规则在父 skill 中(SKILL.md § 核心规则,规则 3)。以下是展示生命周期管理的扩展示例。
|
||||
|
||||
```go
|
||||
// 不好:创建了不可控的后台 goroutine
|
||||
func init() {
|
||||
go doWork()
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
// 好:显式的生命周期管理
|
||||
type Worker struct {
|
||||
stop chan struct{}
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func NewWorker() *Worker {
|
||||
w := &Worker{
|
||||
stop: make(chan struct{}),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
go w.doWork()
|
||||
return w
|
||||
}
|
||||
|
||||
func (w *Worker) Shutdown() {
|
||||
close(w.stop)
|
||||
<-w.done
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 优先使用同步函数
|
||||
|
||||
> 理由和优势表在父 skill 中(SKILL.md § 同步函数)。以下是具体的代码示例。
|
||||
|
||||
```go
|
||||
// 好:同步函数 - 调用方控制并发
|
||||
func ProcessItems(items []Item) ([]Result, error) {
|
||||
var results []Result
|
||||
for _, item := range items {
|
||||
result, err := processItem(item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results = append(results, result)
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// 调用方可以在需要时添加并发:
|
||||
go func() {
|
||||
results, err := ProcessItems(items)
|
||||
// 处理结果
|
||||
}()
|
||||
```
|
||||
@@ -1,110 +0,0 @@
|
||||
# 同步原语模式
|
||||
|
||||
互斥锁和原子操作的详细模式 —— 涵盖互斥锁嵌入陷阱和类型安全的原子访问。
|
||||
|
||||
---
|
||||
|
||||
## 不要嵌入互斥锁
|
||||
|
||||
如果你通过指针使用结构体,互斥锁应该是非指针字段。不要在结构体中嵌入互斥锁,即使该结构体未被导出。
|
||||
|
||||
```go
|
||||
// 不好:嵌入的互斥锁将 Lock/Unlock 暴露为 API 的一部分
|
||||
type SMap struct {
|
||||
sync.Mutex // Lock() 和 Unlock() 成为 SMap 的方法
|
||||
data map[string]string
|
||||
}
|
||||
|
||||
func (m *SMap) Get(k string) string {
|
||||
m.Lock()
|
||||
defer m.Unlock()
|
||||
return m.data[k]
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
// 好:命名字段使互斥锁保持为实现细节
|
||||
type SMap struct {
|
||||
mu sync.Mutex
|
||||
data map[string]string
|
||||
}
|
||||
|
||||
func (m *SMap) Get(k string) string {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.data[k]
|
||||
}
|
||||
```
|
||||
|
||||
在不好的示例中,`Lock` 和 `Unlock` 方法无意中成为了导出 API 的一部分。在好的示例中,互斥锁是对调用方隐藏的实现细节。
|
||||
|
||||
---
|
||||
|
||||
## 原子操作:完整示例
|
||||
|
||||
标准 `sync/atomic` 包操作原始类型(`int32`、`int64` 等),容易忘记一致地使用原子操作。
|
||||
|
||||
```go
|
||||
// 不好:容易忘记原子操作
|
||||
type foo struct {
|
||||
running int32 // 原子操作
|
||||
}
|
||||
|
||||
func (f *foo) start() {
|
||||
if atomic.SwapInt32(&f.running, 1) == 1 {
|
||||
return // 已在运行
|
||||
}
|
||||
// 启动 Foo
|
||||
}
|
||||
|
||||
func (f *foo) isRunning() bool {
|
||||
return f.running == 1 // 竞争!忘记使用 atomic.LoadInt32
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
// 好:类型安全的原子操作
|
||||
type foo struct {
|
||||
running atomic.Bool
|
||||
}
|
||||
|
||||
func (f *foo) start() {
|
||||
if f.running.Swap(true) {
|
||||
return // 已在运行
|
||||
}
|
||||
// 启动 Foo
|
||||
}
|
||||
|
||||
func (f *foo) isRunning() bool {
|
||||
return f.running.Load() // 不可能意外地非原子读取
|
||||
}
|
||||
```
|
||||
|
||||
`atomic.Bool`、`atomic.Int64` 等类型(Go 1.19 起在标准库 `sync/atomic` 中可用,或通过 [go.uber.org/atomic](https://pkg.go.dev/go.uber.org/atomic))通过隐藏底层类型来增加类型安全性。
|
||||
|
||||
---
|
||||
|
||||
## Channel 方向示例
|
||||
|
||||
指定方向可以防止意外误用:
|
||||
|
||||
```go
|
||||
// 好:指定方向 - 清晰的所有权
|
||||
func sum(values <-chan int) int {
|
||||
total := 0
|
||||
for v := range values {
|
||||
total += v
|
||||
}
|
||||
return total
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
// 不好:未指定方向 - 允许意外误用
|
||||
func sum(values chan int) (out int) {
|
||||
for v := range values {
|
||||
out += v
|
||||
}
|
||||
close(values) // 漏洞!这能通过编译但不应该发生。
|
||||
}
|
||||
```
|
||||
@@ -1,122 +0,0 @@
|
||||
---
|
||||
name: go-context
|
||||
description: 在 Go 中使用 context.Context 时使用 — 包括函数签名中的位置、传播取消和截止时间、以及在 context 中存储值与使用参数的对比。也适用于取消长时间运行的操作、设置超时或传递请求作用域数据,即使未直接提及 context.Context。不涵盖 goroutine 生命周期或 sync 原语(参见 go-concurrency)。
|
||||
license: Apache-2.0
|
||||
compatibility: 需要 Go 1.7+(context 在 Go 1.7 中移入标准库)
|
||||
metadata:
|
||||
sources: "Go Wiki CodeReviewComments"
|
||||
---
|
||||
|
||||
# Go Context 用法
|
||||
|
||||
## Context 作为第一个参数
|
||||
|
||||
使用 Context 的函数应将其作为**第一个参数**:
|
||||
|
||||
```go
|
||||
func F(ctx context.Context, /* 其他参数 */) error
|
||||
func ProcessRequest(ctx context.Context, req *Request) (*Response, error)
|
||||
```
|
||||
|
||||
这是 Go 中的一个强约定,使 context 的传递在代码库中可见且一致。
|
||||
|
||||
---
|
||||
|
||||
## 不要在结构体中存储 Context
|
||||
|
||||
不要在结构体类型中添加 Context 成员。相反,将 `ctx` 作为参数传递给每个需要它的方法:
|
||||
|
||||
```go
|
||||
// 不好:Context 存储在结构体中
|
||||
type Worker struct {
|
||||
ctx context.Context // 不要这样做
|
||||
}
|
||||
|
||||
// 好:Context 传递给方法
|
||||
type Worker struct{ /* ... */ }
|
||||
|
||||
func (w *Worker) Process(ctx context.Context) error {
|
||||
// Context 显式传递 — 生命周期清晰
|
||||
}
|
||||
```
|
||||
|
||||
**例外**:签名必须匹配标准库或第三方库中接口的方法可能需要变通处理。
|
||||
|
||||
---
|
||||
|
||||
## 不要创建自定义 Context 类型
|
||||
|
||||
不要创建自定义的 Context 类型或在函数签名中使用 `context.Context` 以外的接口:
|
||||
|
||||
```go
|
||||
// 不好:自定义 context 类型
|
||||
type MyContext interface {
|
||||
context.Context
|
||||
GetUserID() string
|
||||
}
|
||||
|
||||
// 好:使用标准 context.Context 并提取值
|
||||
func Process(ctx context.Context) error {
|
||||
userID := GetUserID(ctx)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 应用数据放在哪里
|
||||
|
||||
按以下优先级顺序考虑:
|
||||
|
||||
1. **函数参数** — 最明确且类型安全
|
||||
2. **接收者** — 适用于属于该类型的数据
|
||||
3. **全局变量** — 适用于真正的全局配置(谨慎使用)
|
||||
4. **Context 值** — 仅用于请求作用域数据
|
||||
|
||||
Context 值适用于:
|
||||
- 请求 ID 和追踪 ID
|
||||
- 随请求流动的认证/授权信息
|
||||
- 截止时间和取消信号
|
||||
|
||||
Context 值**不适用**于:
|
||||
- 可选的函数参数
|
||||
- 可以显式传递的数据
|
||||
- 不随请求变化的配置
|
||||
|
||||
---
|
||||
|
||||
## 常见模式
|
||||
|
||||
> 在派生 context(WithTimeout、WithCancel、WithDeadline)、在循环或 HTTP 处理器中检查取消、使用带类型键的 context 值、或需要快速参考表时,阅读 [references/PATTERNS.md](references/PATTERNS.md)。
|
||||
|
||||
### 派生 Context
|
||||
|
||||
创建派生 context 后,始终立即 `defer cancel()`:
|
||||
|
||||
```go
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
```
|
||||
|
||||
### 检查取消
|
||||
|
||||
```go
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
// 执行工作
|
||||
}
|
||||
```
|
||||
|
||||
### Context 不可变性
|
||||
|
||||
Context 是不可变的 — 将同一个 `ctx` 传递给共享相同截止时间和取消信号的多个并发调用是安全的。
|
||||
|
||||
---
|
||||
|
||||
## 相关技能
|
||||
|
||||
- **Goroutine 协调**:在使用 context 进行 goroutine 取消、基于 select 的超时或 errgroup 时,参见 [go-concurrency](../go-concurrency/SKILL.md)
|
||||
- **错误处理**:在决定如何包装或返回 `ctx.Err()` 取消错误时,参见 [go-error-handling](../go-error-handling/SKILL.md)
|
||||
- **接口设计**:在设计接受 context 并结合接口的 API 时,参见 [go-interfaces](../go-interfaces/SKILL.md)
|
||||
- **请求作用域日志**:在将 logger 注入 context 或将请求 ID 添加到结构化日志输出时,参见 [go-logging](../go-logging/SKILL.md)
|
||||
@@ -1,227 +0,0 @@
|
||||
# Context 模式
|
||||
|
||||
派生、检查和传播 `context.Context` 的常见模式。
|
||||
|
||||
---
|
||||
|
||||
## Context 不可变性
|
||||
|
||||
Context 是不可变的。将同一个 `ctx` 传递给共享相同截止时间、取消信号、凭据和父级追踪的多个调用是安全的:
|
||||
|
||||
```go
|
||||
// 安全:同一个 context 传递给顺序调用
|
||||
func ProcessBatch(ctx context.Context, items []Item) error {
|
||||
for _, item := range items {
|
||||
if err := process(ctx, item); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 安全:同一个 context 传递给并发调用
|
||||
func ProcessConcurrently(ctx context.Context, a, b *Data) error {
|
||||
g, ctx := errgroup.WithContext(ctx)
|
||||
g.Go(func() error { return processA(ctx, a) })
|
||||
g.Go(func() error { return processB(ctx, b) })
|
||||
return g.Wait()
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 何时使用 context.Background()
|
||||
|
||||
仅在**从不特定于请求**的函数中使用 `context.Background()`:
|
||||
|
||||
```go
|
||||
func main() {
|
||||
ctx := context.Background()
|
||||
if err := run(ctx); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func startBackgroundWorker() {
|
||||
ctx := context.Background()
|
||||
go worker(ctx)
|
||||
}
|
||||
```
|
||||
|
||||
**默认传递 Context**,即使你认为不需要。只有在有充分理由说明传递 context 是错误做法时,才直接使用 `context.Background()`:
|
||||
|
||||
```go
|
||||
func LoadConfig(ctx context.Context) (*Config, error) {
|
||||
// 即使现在不使用 ctx,接受它可以在未来添加功能时
|
||||
// 不需要修改 API
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 派生 Context
|
||||
|
||||
```go
|
||||
// 添加超时 — 持续时间结束后触发取消
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// 添加取消 — 调用者控制何时取消
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
// 添加截止时间 — 在指定的墙钟时间触发取消
|
||||
ctx, cancel := context.WithDeadline(ctx, time.Now().Add(time.Hour))
|
||||
defer cancel()
|
||||
|
||||
// 添加值(谨慎使用 — 仅用于请求作用域数据)
|
||||
ctx = context.WithValue(ctx, requestIDKey, reqID)
|
||||
```
|
||||
|
||||
创建派生 context 后,**始终立即 `defer cancel()`**。这确保即使函数提前返回,资源也会被释放。
|
||||
|
||||
### 嵌套派生
|
||||
|
||||
派生的 context 形成树状结构。取消父级会取消所有子级:
|
||||
|
||||
```go
|
||||
func handleRequest(ctx context.Context) error {
|
||||
// 整个请求的父级超时
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// 数据库调用的更短超时
|
||||
dbCtx, dbCancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer dbCancel()
|
||||
|
||||
data, err := queryDB(dbCtx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 父级 context 的剩余时间适用于此处
|
||||
return sendResponse(ctx, data)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 检查取消
|
||||
|
||||
### 在长时间运行的循环中
|
||||
|
||||
```go
|
||||
func LongRunningOperation(ctx context.Context) error {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
// 执行工作
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 在高开销操作之前
|
||||
|
||||
在开始无法中断的工作之前检查取消:
|
||||
|
||||
```go
|
||||
func ProcessItems(ctx context.Context, items []Item) error {
|
||||
for _, item := range items {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
if err := expensiveProcess(item); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
### 区分取消原因
|
||||
|
||||
```go
|
||||
if err := ctx.Err(); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, context.Canceled):
|
||||
// 调用者显式取消(例如客户端断开连接)
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
// 超时或截止时间已过
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 在 HTTP 处理器中遵守取消
|
||||
|
||||
```go
|
||||
func handler(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
result, err := slowOperation(ctx)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
// 客户端已断开连接 — 无需写入
|
||||
return
|
||||
}
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(result)
|
||||
}
|
||||
```
|
||||
|
||||
`r.Context()` 在以下情况被取消:
|
||||
- 客户端关闭连接
|
||||
- `http.Server` 的 `ReadTimeout` 或 `WriteTimeout` 触发
|
||||
- `ServeHTTP` 方法返回
|
||||
|
||||
---
|
||||
|
||||
## Context 值的最佳实践
|
||||
|
||||
### 使用未导出的键类型
|
||||
|
||||
```go
|
||||
type contextKey struct{}
|
||||
|
||||
var userIDKey contextKey
|
||||
|
||||
func WithUserID(ctx context.Context, id string) context.Context {
|
||||
return context.WithValue(ctx, userIDKey, id)
|
||||
}
|
||||
|
||||
func UserIDFromContext(ctx context.Context) (string, bool) {
|
||||
id, ok := ctx.Value(userIDKey).(string)
|
||||
return id, ok
|
||||
}
|
||||
```
|
||||
|
||||
使用未导出的结构体类型作为键可以防止与其他包的键发生冲突 — 即使它们使用相同的 string 或 int 值。
|
||||
|
||||
### 提供访问器函数
|
||||
|
||||
始终将 `context.WithValue` 和 `ctx.Value` 包装在类型化的辅助函数中(如上所示),而不是暴露键。这提供了类型安全性和一个可以修改实现的单一位置。
|
||||
|
||||
---
|
||||
|
||||
## 快速参考
|
||||
|
||||
| 模式 | 指导 |
|
||||
|------|------|
|
||||
| 参数位置 | 始终第一个:`func F(ctx context.Context, ...)` |
|
||||
| 结构体存储 | 不要存储在结构体中;传递给方法 |
|
||||
| 自定义类型 | 不要创建;使用 `context.Context` 接口 |
|
||||
| 应用数据 | 优先选择 参数 > 接收者 > 全局变量 > context 值 |
|
||||
| 请求作用域数据 | 适用于 context 值 |
|
||||
| 共享 context | 安全 — context 是不可变的 |
|
||||
| `context.Background()` | 仅用于非请求特定的代码 |
|
||||
| 默认行为 | 即使认为不需要也要传递 context |
|
||||
| `defer cancel()` | 在 `WithTimeout`/`WithCancel`/`WithDeadline` 之后始终立即 defer |
|
||||
| 值键 | 使用未导出的结构体类型,提供访问器函数 |
|
||||
| 取消检查 | 在高开销操作前使用 `ctx.Err()`;在循环中使用 `select` 监听 `ctx.Done()` |
|
||||
@@ -1,193 +0,0 @@
|
||||
---
|
||||
name: go-control-flow
|
||||
description: Use when writing conditionals, loops, or switch statements in Go — including if with initialization, early returns, for loop forms, range, switch, type switches, and blank identifier patterns. Also use when writing a simple if/else or for loop, even if the user doesn't mention guard clauses or variable scoping. Does not cover error flow patterns (see go-error-handling).
|
||||
license: Apache-2.0
|
||||
metadata:
|
||||
sources: "Effective Go, Google Style Guide"
|
||||
---
|
||||
|
||||
# Go 控制流
|
||||
|
||||
> 在使用 switch 语句、类型 switch 或带标签的 break 时,阅读 [references/SWITCH-PATTERNS.md](references/SWITCH-PATTERNS.md)
|
||||
|
||||
> 在使用 `_`、空白标识符导入或编译时接口检查时,阅读 [references/BLANK-IDENTIFIER.md](references/BLANK-IDENTIFIER.md)
|
||||
|
||||
---
|
||||
|
||||
## 带初始化的 If
|
||||
|
||||
`if` 和 `switch` 接受可选的初始化语句。使用它将变量限定在条件块作用域内:
|
||||
|
||||
```go
|
||||
if err := file.Chmod(0664); err != nil {
|
||||
log.Print(err)
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
如果需要在 `if` 之后超出几行的范围使用该变量,请单独声明并使用标准 `if`:
|
||||
|
||||
```go
|
||||
x, err := f()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 大量使用 x 的代码
|
||||
```
|
||||
|
||||
## 缩进错误流(守卫子句)
|
||||
|
||||
当 `if` 主体以 `break`、`continue`、`goto` 或 `return` 结尾时,省略不必要的 `else`。保持成功路径不缩进:
|
||||
|
||||
```go
|
||||
f, err := os.Open(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
d, err := f.Stat()
|
||||
if err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
codeUsing(f, d)
|
||||
```
|
||||
|
||||
当 `if` 已经返回时,绝不要将正常流程埋在 `else` 中。
|
||||
|
||||
---
|
||||
|
||||
## 重新声明和重新赋值
|
||||
|
||||
`:=` 短声明允许在同一作用域中重新声明变量:
|
||||
|
||||
```go
|
||||
f, err := os.Open(name) // 声明 f 和 err
|
||||
d, err := f.Stat() // 声明 d,重新赋值 err
|
||||
```
|
||||
|
||||
变量 `v` 即使已经声明过,也可以出现在 `:=` 声明中,前提是:
|
||||
|
||||
1. 声明在与现有 `v` **相同的作用域**中
|
||||
2. 值**可赋值**给 `v`
|
||||
3. 声明中至少创建了**一个其他新变量**
|
||||
|
||||
### 变量遮蔽
|
||||
|
||||
**警告**:如果 `v` 在外层作用域中声明,`:=` 会创建一个**新的**遮蔽变量 — 这是常见的 bug 来源:
|
||||
|
||||
```go
|
||||
// Bug:if 块内的 ctx 遮蔽了外层的 ctx
|
||||
if *shortenDeadlines {
|
||||
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||
defer cancel()
|
||||
}
|
||||
// 此处的 ctx 仍然是原始的 — 被遮蔽的 ctx 没有逃逸
|
||||
|
||||
// 修复:使用 = 而不是 :=
|
||||
var cancel func()
|
||||
ctx, cancel = context.WithTimeout(ctx, 3*time.Second)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## For 循环
|
||||
|
||||
Go 的 `for` 是唯一的循环结构,统一了 `while`、`do-while` 和 C 风格的 `for`:
|
||||
|
||||
```go
|
||||
// 仅条件(Go 的 "while")
|
||||
for x > 0 {
|
||||
x = process(x)
|
||||
}
|
||||
|
||||
// 无限循环
|
||||
for {
|
||||
if done() { break }
|
||||
}
|
||||
|
||||
// C 风格的三组件形式
|
||||
for i := 0; i < n; i++ { ... }
|
||||
```
|
||||
|
||||
### Range
|
||||
|
||||
`range` 遍历切片、映射、字符串和通道:
|
||||
|
||||
```go
|
||||
for i, v := range slice { ... } // 索引 + 值
|
||||
for k, v := range myMap { ... } // 键 + 值(顺序不确定)
|
||||
for i, r := range "héllo" { ... } // 字节索引 + rune(不是字节)
|
||||
for v := range ch { ... } // 接收直到通道关闭
|
||||
```
|
||||
|
||||
**关键规则:**
|
||||
- 对字符串 range 产生 **rune**,不是字节 — `i` 是字节偏移量
|
||||
- 对映射 range 的顺序**不确定** — 不要依赖它
|
||||
- 使用 `_` 丢弃索引或值:`for _, v := range slice`
|
||||
|
||||
### 并行赋值
|
||||
|
||||
Go 没有逗号运算符。使用并行赋值处理多个循环变量:
|
||||
|
||||
```go
|
||||
for i, j := 0, len(a)-1; i < j; i, j = i+1, j-1 {
|
||||
a[i], a[j] = a[j], a[i]
|
||||
}
|
||||
```
|
||||
|
||||
`++` 和 `--` 是语句,不是表达式 — 它们不能出现在并行赋值中。
|
||||
|
||||
---
|
||||
|
||||
## Switch:带标签的 Break
|
||||
|
||||
`for` 循环内 `switch` 中的 `break` 只会中断 switch。使用带标签的 `break` 退出外层循环:
|
||||
|
||||
```go
|
||||
Loop:
|
||||
for _, v := range items {
|
||||
switch v.Type {
|
||||
case "done":
|
||||
break Loop // 中断 for 循环
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
关于类型 switch,参见 **go-interfaces**:类型 Switch。
|
||||
|
||||
---
|
||||
|
||||
## 空白标识符
|
||||
|
||||
**绝不要随意丢弃错误** — 空指针解引用 panic 可能随之而来。
|
||||
|
||||
在编译时验证接口实现:`var _ io.Writer = (*MyType)(nil)`。
|
||||
参见 **go-interfaces** 中的接口满足检查模式。
|
||||
|
||||
---
|
||||
|
||||
## 快速参考
|
||||
|
||||
| 模式 | Go 惯用法 |
|
||||
|------|-----------|
|
||||
| If 初始化 | `if err := f(); err != nil { }` |
|
||||
| 提前返回 | 当 if 主体返回时省略 `else` |
|
||||
| 重新声明 | `:=` 在相同作用域 + 新变量时重新赋值 |
|
||||
| 遮蔽陷阱 | `:=` 在内层作用域创建新变量 |
|
||||
| 并行赋值 | `i, j = i+1, j-1` |
|
||||
| 无表达式 switch | `switch { case cond: }` |
|
||||
| 逗号 case | `case 'a', 'b', 'c':` |
|
||||
| 无 fallthrough | 默认行为(需要时显式使用 `fallthrough`) |
|
||||
| 从 switch 中跳出循环 | `break Label` |
|
||||
| 丢弃值 | `_, err := f()` |
|
||||
| 副作用导入 | `import _ "pkg"` |
|
||||
| 接口检查 | `var _ Interface = (*Type)(nil)` |
|
||||
|
||||
---
|
||||
|
||||
## 相关技能
|
||||
|
||||
- **错误流程**:在构建守卫子句、提前返回或错误优先模式时,参见 [go-error-handling](../go-error-handling/SKILL.md)
|
||||
- **类型 switch**:在使用类型 switch、comma-ok 惯用法或接口满足检查时,参见 [go-interfaces](../go-interfaces/SKILL.md)
|
||||
- **减少嵌套**:在减少嵌套深度或解决格式问题时,参见 [go-style-core](../go-style-core/SKILL.md)
|
||||
- **变量作用域**:在使用 if 初始化、`:=` 重新声明或减少变量作用域时,参见 [go-declarations](../go-declarations/SKILL.md)
|
||||
@@ -1,71 +0,0 @@
|
||||
# 空白标识符模式
|
||||
|
||||
空白标识符 `_` 在 Go 中有多种用途:丢弃不需要的值、为副作用导入包、以及在编译时验证接口实现。
|
||||
|
||||
---
|
||||
|
||||
## 多重赋值
|
||||
|
||||
使用 `_` 丢弃多值表达式中不需要的值:
|
||||
|
||||
```go
|
||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||
fmt.Printf("%s does not exist\n", path)
|
||||
}
|
||||
```
|
||||
|
||||
### 绝不要随意丢弃错误
|
||||
|
||||
静默丢弃错误会引发空指针 panic:
|
||||
|
||||
```go
|
||||
// 不好:忽略错误会在路径不存在时崩溃
|
||||
fi, _ := os.Stat(path)
|
||||
if fi.IsDir() { ... } // 空指针解引用
|
||||
```
|
||||
|
||||
如果确实不需要错误,请记录原因:
|
||||
|
||||
```go
|
||||
_ = logger.Sync() // 尽力刷新;错误不可操作
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 副作用导入
|
||||
|
||||
使用空白标识符仅为了 `init()` 副作用而导入包:
|
||||
|
||||
```go
|
||||
import _ "net/http/pprof" // 注册 HTTP 处理器
|
||||
import _ "image/png" // 注册 PNG 解码器
|
||||
```
|
||||
|
||||
这通常用于注册驱动、编解码器或调试处理器,它们在 `init()` 期间将自己注册到注册表中。
|
||||
|
||||
---
|
||||
|
||||
## 接口实现检查
|
||||
|
||||
在编译时验证类型是否实现了接口,方法是将 nil 指针赋值给接口类型的空白标识符变量:
|
||||
|
||||
```go
|
||||
var _ io.Writer = (*MyType)(nil)
|
||||
```
|
||||
|
||||
如果 `*MyType` 不满足 `io.Writer`,这会产生编译错误,在运行时之前捕获缺失的方法。
|
||||
|
||||
**何时使用**:将此检查放在定义该类型的同一文件中,通常在类型声明之后。当类型必须满足另一个包中定义的接口时特别有用。
|
||||
|
||||
参见 [go-interfaces](../../go-interfaces/SKILL.md):接口满足检查,获取关于何时何地使用此模式的完整指导。
|
||||
|
||||
---
|
||||
|
||||
## 快速参考
|
||||
|
||||
| 模式 | 语法 |
|
||||
|------|------|
|
||||
| 丢弃值 | `_, err := f()` |
|
||||
| 在 if 初始化中丢弃 | `if _, err := f(); err != nil { }` |
|
||||
| 副作用导入 | `import _ "pkg"` |
|
||||
| 接口检查 | `var _ Interface = (*Type)(nil)` |
|
||||
@@ -1,109 +0,0 @@
|
||||
# Switch 模式
|
||||
|
||||
Go `switch` 语句的详细模式,包括无表达式 switch、逗号 case、break 行为和带标签的 break。
|
||||
|
||||
---
|
||||
|
||||
## 无自动 Fallthrough
|
||||
|
||||
Go `switch` 的 case 默认**不会** fall through(与 C/Java 不同)。每个 case 主体隐式地 break。仅在明确需要时使用 `fallthrough` — 这在惯用 Go 中很少见。
|
||||
|
||||
```go
|
||||
switch n {
|
||||
case 1:
|
||||
fmt.Println("one")
|
||||
// 无 fallthrough — 下一个 case 不会执行
|
||||
case 2:
|
||||
fmt.Println("two")
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 无表达式 Switch
|
||||
|
||||
没有表达式的 `switch` 对 `true` 进行 switch。在将单个变量与多个条件进行比较时,用它来替代 if-else-if 链:
|
||||
|
||||
```go
|
||||
func unhex(c byte) byte {
|
||||
switch {
|
||||
case '0' <= c && c <= '9':
|
||||
return c - '0'
|
||||
case 'a' <= c && c <= 'f':
|
||||
return c - 'a' + 10
|
||||
case 'A' <= c && c <= 'F':
|
||||
return c - 'A' + 10
|
||||
}
|
||||
return 0
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 逗号分隔的 Case
|
||||
|
||||
多个值可以使用逗号共享一个 case 主体 — 不需要 `fallthrough`:
|
||||
|
||||
```go
|
||||
func shouldEscape(c byte) bool {
|
||||
switch c {
|
||||
case ' ', '?', '&', '=', '#', '+', '%':
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 带标签的 Break
|
||||
|
||||
`switch` 中的 `break` 仅终止 switch,**不会**终止外层的 `for` 循环。使用标签来跳出循环:
|
||||
|
||||
```go
|
||||
Loop:
|
||||
for n := 0; n < len(src); n += size {
|
||||
switch {
|
||||
case src[n] < sizeOne:
|
||||
break // 仅中断 switch
|
||||
case src[n] < sizeTwo:
|
||||
if n+1 >= len(src) {
|
||||
break Loop // 跳出 for 循环
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
另一个常见模式 — 从 switch 内部中断 range 循环:
|
||||
|
||||
```go
|
||||
Loop:
|
||||
for _, v := range items {
|
||||
switch v.Type {
|
||||
case "done":
|
||||
break Loop // 中断 for 循环
|
||||
case "skip":
|
||||
break // 仅中断 switch
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**经验法则**:当 `for` 循环内有 `switch` 且需要从 case 中退出循环时,始终使用带标签的 break。
|
||||
|
||||
---
|
||||
|
||||
## 类型 Switch
|
||||
|
||||
关于类型 switch(`switch v := x.(type)`),参见 [go-interfaces](../../go-interfaces/SKILL.md):类型 Switch。
|
||||
|
||||
---
|
||||
|
||||
## 快速参考
|
||||
|
||||
| 模式 | 语法 |
|
||||
|------|------|
|
||||
| 无表达式 switch | `switch { case cond: }` |
|
||||
| 逗号 case | `case 'a', 'b', 'c':` |
|
||||
| 无 fallthrough | 默认行为;需要时使用 `fallthrough` 关键字 |
|
||||
| 仅中断 switch | case 内使用 `break` |
|
||||
| 中断外层循环 | 使用带标签的 `for` 和 `break Label` |
|
||||
@@ -1,140 +0,0 @@
|
||||
---
|
||||
name: go-data-structures
|
||||
description: Use when working with Go slices, maps, or arrays — choosing between new and make, using append, declaring empty slices (nil vs literal for JSON), implementing sets with maps, and copying data at boundaries. Also use when building or manipulating collections, even if the user doesn't ask about allocation idioms. Does not cover concurrent data structure safety (see go-concurrency).
|
||||
license: Apache-2.0
|
||||
metadata:
|
||||
sources: "Effective Go, Google Style Guide, Uber Style Guide, Go Wiki CodeReviewComments"
|
||||
---
|
||||
|
||||
# Go 数据结构
|
||||
|
||||
---
|
||||
|
||||
## 选择数据结构
|
||||
|
||||
```
|
||||
你需要什么?
|
||||
├─ 有序的元素集合
|
||||
│ ├─ 编译时已知固定大小 → 数组 [N]T
|
||||
│ └─ 动态大小 → 切片 []T
|
||||
│ ├─ 知道大概的大小?→ make([]T, 0, capacity)
|
||||
│ └─ 未知大小或需要 nil 安全的 JSON?→ var s []T (nil)
|
||||
├─ 键值查找
|
||||
│ └─ 映射 map[K]V
|
||||
│ ├─ 知道大概的大小?→ make(map[K]V, capacity)
|
||||
│ └─ 需要集合?→ map[T]struct{}(零大小值)
|
||||
└─ 需要传递给函数?
|
||||
└─ 如果调用者可能会修改它,则在边界处复制
|
||||
```
|
||||
|
||||
> **此技能不适用的场景**:对于数据结构的并发访问(互斥锁、原子操作),参见 [go-concurrency](../go-concurrency/SKILL.md)。对于 API 边界处的防御性复制,参见 [go-defensive](../go-defensive/SKILL.md)。对于为性能预分配容量,参见 [go-performance](../go-performance/SKILL.md)。
|
||||
|
||||
---
|
||||
|
||||
## 切片
|
||||
|
||||
### append 函数
|
||||
|
||||
**始终赋值结果** — 底层数组可能会改变:
|
||||
|
||||
```go
|
||||
x := []int{1, 2, 3}
|
||||
x = append(x, 4, 5, 6)
|
||||
|
||||
// 将切片追加到切片
|
||||
x = append(x, y...) // 注意 ...
|
||||
```
|
||||
|
||||
### 二维切片
|
||||
|
||||
**独立的内部切片**(可以独立增长/缩小):
|
||||
|
||||
```go
|
||||
picture := make([][]uint8, YSize)
|
||||
for i := range picture {
|
||||
picture[i] = make([]uint8, XSize)
|
||||
}
|
||||
```
|
||||
|
||||
**单次分配**(对于固定大小更高效):
|
||||
|
||||
```go
|
||||
picture := make([][]uint8, YSize)
|
||||
pixels := make([]uint8, XSize*YSize)
|
||||
for i := range picture {
|
||||
picture[i], pixels = pixels[:XSize], pixels[XSize:]
|
||||
}
|
||||
```
|
||||
|
||||
> 在调试意外的切片行为、跨 goroutine 共享切片或处理切片头时,阅读 [references/SLICES.md](references/SLICES.md)。
|
||||
|
||||
### 声明空切片
|
||||
|
||||
优先使用 nil 切片而非空字面量:
|
||||
|
||||
```go
|
||||
// 好:nil 切片
|
||||
var t []string
|
||||
|
||||
// 避免:非 nil 但零长度
|
||||
t := []string{}
|
||||
```
|
||||
|
||||
两者的 `len` 和 `cap` 都是零,但 nil 切片是首选风格。
|
||||
|
||||
**JSON 例外**:nil 切片编码为 `null`,而 `[]string{}` 编码为 `[]`。当需要 JSON 数组时使用非 nil。
|
||||
|
||||
在设计接口时,避免区分 nil 和非 nil 的零长度切片。
|
||||
|
||||
---
|
||||
|
||||
## 映射
|
||||
|
||||
### 实现集合
|
||||
|
||||
使用 `map[T]bool` — 惯用且阅读自然:
|
||||
|
||||
```go
|
||||
attended := map[string]bool{"Ann": true, "Joe": true}
|
||||
if attended[person] { // 不在映射中则为 false
|
||||
fmt.Println(person, "was at the meeting")
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 复制
|
||||
|
||||
从另一个包复制结构体时要小心。如果类型的方法定义在指针类型(`*T`)上,复制值可能导致别名 bug。
|
||||
|
||||
**通用规则:** 如果类型 `T` 的方法与指针类型 `*T` 关联,则不要复制 `T` 的值。这适用于 `bytes.Buffer`、`sync.Mutex`、`sync.WaitGroup` 以及包含它们的类型。
|
||||
|
||||
```go
|
||||
// 不好:复制互斥锁
|
||||
var mu sync.Mutex
|
||||
mu2 := mu // 几乎总是 bug
|
||||
|
||||
// 好:通过指针传递
|
||||
func increment(sc *SafeCounter) {
|
||||
sc.mu.Lock()
|
||||
sc.count++
|
||||
sc.mu.Unlock()
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 快速参考
|
||||
|
||||
| 主题 | 关键点 |
|
||||
|------|--------|
|
||||
| 切片 | 始终赋值 `append` 结果;`nil` 切片优于 `[]T{}` |
|
||||
| 集合 | `map[T]bool` 是惯用写法 |
|
||||
| 复制 | 如果方法在 `*T` 上则不要复制 `T`;注意别名问题 |
|
||||
|
||||
## 相关技能
|
||||
|
||||
- **防御性复制**:在 API 边界处复制切片或映射以防止修改时,参见 [go-defensive](../go-defensive/SKILL.md)
|
||||
- **容量提示**:为已知工作负载预分配切片或映射容量时,参见 [go-performance](../go-performance/SKILL.md)
|
||||
- **迭代模式**:在对切片、映射或通道使用 range 循环时,参见 [go-control-flow](../go-control-flow/SKILL.md)
|
||||
- **声明风格**:在 `new`、`make`、`var` 和复合字面量之间选择时,参见 [go-declarations](../go-declarations/SKILL.md)
|
||||
@@ -1,146 +0,0 @@
|
||||
# Go 切片内部原理
|
||||
|
||||
> **来源**:Effective Go
|
||||
|
||||
---
|
||||
|
||||
## 三项描述符
|
||||
|
||||
切片是一个运行时数据结构,包含三个组件:
|
||||
|
||||
- **指针**:第一个可访问元素的地址
|
||||
- **长度**:元素数量(`len(s)`)
|
||||
- **容量**:到底层数组末尾的最大元素数(`cap(s)`)
|
||||
|
||||
```go
|
||||
arr := [5]int{10, 20, 30, 40, 50}
|
||||
s := arr[1:4] // s = [20, 30, 40]
|
||||
// 指针:&arr[1],长度:3,容量:4
|
||||
```
|
||||
|
||||
`nil` 切片的三项均为零/nil。
|
||||
|
||||
---
|
||||
|
||||
## 切片引用底层数组
|
||||
|
||||
切片不存储数据 — 它们描述数组的一部分:
|
||||
|
||||
```go
|
||||
data := [4]int{1, 2, 3, 4}
|
||||
a := data[0:2] // [1, 2]
|
||||
b := data[1:3] // [2, 3]
|
||||
|
||||
b[0] = 99
|
||||
fmt.Println(a) // [1, 99] - 两者都看到变化
|
||||
fmt.Println(data) // [1, 99, 3, 4]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 切片运算符
|
||||
|
||||
`s[lo:hi]` 创建从索引 `lo` 到 `hi-1` 的切片:
|
||||
|
||||
```go
|
||||
s := []int{0, 1, 2, 3, 4, 5}
|
||||
s[2:4] // [2, 3]
|
||||
s[:3] // [0, 1, 2]
|
||||
s[3:] // [3, 4, 5]
|
||||
```
|
||||
|
||||
三索引形式 `s[lo:hi:max]` 将容量限制为 `max-lo`。
|
||||
|
||||
---
|
||||
|
||||
## 为什么 append 必须返回切片
|
||||
|
||||
切片头是**按值传递**的。函数可以修改元素但无法改变调用者的切片头:
|
||||
|
||||
```go
|
||||
func Append(slice, data []byte) []byte {
|
||||
l := len(slice)
|
||||
if l+len(data) > cap(slice) {
|
||||
newSlice := make([]byte, (l+len(data))*2)
|
||||
copy(newSlice, slice)
|
||||
slice = newSlice // 只改变局部变量
|
||||
}
|
||||
slice = slice[0 : l+len(data)]
|
||||
copy(slice[l:], data)
|
||||
return slice // 调用者必须接收新的切片头
|
||||
}
|
||||
```
|
||||
|
||||
当发生重新分配时,`slice` 指向新数组。调用者的原始引用仍指向旧数组 — 返回使调用者能够更新其引用。
|
||||
|
||||
---
|
||||
|
||||
## copy 函数
|
||||
|
||||
`copy(dst, src)` 复制元素并返回复制的数量:
|
||||
|
||||
```go
|
||||
src := []int{1, 2, 3, 4, 5}
|
||||
dst := make([]int, 3)
|
||||
n := copy(dst, src) // n=3, dst=[1,2,3]
|
||||
```
|
||||
|
||||
正确处理重叠切片。复制 `min(len(dst), len(src))` 个元素 — 不会发生重新分配。
|
||||
|
||||
---
|
||||
|
||||
## 切片常见陷阱
|
||||
|
||||
### 1. 共享底层数组
|
||||
|
||||
```go
|
||||
original := []int{1, 2, 3, 4, 5}
|
||||
subset := original[1:3]
|
||||
subset[0] = 99
|
||||
fmt.Println(original) // [1, 99, 3, 4, 5] - 被修改了!
|
||||
|
||||
// 修复:创建独立副本
|
||||
subset := make([]int, 2)
|
||||
copy(subset, original[1:3])
|
||||
```
|
||||
|
||||
### 2. append 可能重新分配也可能不
|
||||
|
||||
```go
|
||||
a := make([]int, 3, 5) // len=3, cap=5
|
||||
b := a[0:3]
|
||||
a = append(a, 4) // 在容量内 - 仍然共享
|
||||
a = append(a, 5, 6) // 超出容量 - 现在独立
|
||||
```
|
||||
|
||||
### 3. 大底层数组导致内存泄漏
|
||||
|
||||
```go
|
||||
// 不好:小切片将整个文件保留在内存中
|
||||
func getHeader(file []byte) []byte { return file[:100] }
|
||||
|
||||
// 好:复制以释放大数组
|
||||
func getHeader(file []byte) []byte {
|
||||
header := make([]byte, 100)
|
||||
copy(header, file)
|
||||
return header
|
||||
}
|
||||
```
|
||||
|
||||
### 4. nil vs 空切片
|
||||
|
||||
```go
|
||||
var nilSlice []int // nil, len=0, cap=0
|
||||
emptySlice := []int{} // 非 nil, len=0, cap=0
|
||||
// 两者在 len、cap、append、range 中表现相同
|
||||
// 未初始化状态优先使用 nil
|
||||
```
|
||||
|
||||
## 快速参考
|
||||
|
||||
| 操作 | 行为 |
|
||||
|------|------|
|
||||
| `s[lo:hi]` | 从 lo 到 hi-1 的切片 |
|
||||
| `s[lo:hi:max]` | 容量限制为 max-lo 的切片 |
|
||||
| `append(s, x...)` | 返回新切片;可能重新分配 |
|
||||
| `copy(dst, src)` | 返回复制数量;不重新分配 |
|
||||
@@ -1,188 +0,0 @@
|
||||
---
|
||||
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)
|
||||
@@ -1,101 +0,0 @@
|
||||
# 在 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 相比,开销通常可以忽略不计。
|
||||
@@ -1,144 +0,0 @@
|
||||
# 全局状态模式
|
||||
|
||||
> **来源**: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` |
|
||||
| 测试需要变化的任何东西 | 不要使用全局状态 |
|
||||
@@ -1,92 +0,0 @@
|
||||
# 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)。
|
||||
@@ -1,161 +0,0 @@
|
||||
# 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
|
||||
@@ -1,111 +0,0 @@
|
||||
# 时间、结构体标签和嵌入模式
|
||||
|
||||
## 使用 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
|
||||
}
|
||||
```
|
||||
|
||||
标签使序列化契约显式化,并可以安全地进行重构。
|
||||
@@ -1,168 +0,0 @@
|
||||
---
|
||||
name: go-error-handling
|
||||
description: Use when writing Go code that returns, wraps, or handles errors — choosing between sentinel errors, custom types, and fmt.Errorf (%w vs %v), structuring error flow, or deciding whether to log or return. Also use when propagating errors across package boundaries or using errors.Is/As, even if the user doesn't ask about error strategy. Does not cover panic/recover patterns (see go-defensive).
|
||||
license: Apache-2.0
|
||||
compatibility: Requires Go 1.13+ for errors.Is/errors.As and fmt.Errorf %w wrapping. Structured logging examples use slog (Go 1.21+).
|
||||
metadata:
|
||||
sources: "Google Style Guide, Uber Style Guide"
|
||||
allowed-tools: Bash(bash:*)
|
||||
---
|
||||
|
||||
# Go 错误处理
|
||||
|
||||
## 可用脚本
|
||||
|
||||
- **`scripts/check-errors.sh`** — 检测错误处理反模式:对 `err.Error()` 进行字符串比较、没有上下文的裸 `return err`、以及日志并返回违规。运行 `bash scripts/check-errors.sh --help` 查看选项。
|
||||
|
||||
在 Go 中,[错误是值](https://go.dev/blog/errors-are-values) — 它们由代码创建,也由代码消费。
|
||||
|
||||
## 选择错误策略
|
||||
|
||||
1. 系统边界(RPC、IPC、存储)?→ 使用 `%v` 包装以避免泄露内部细节
|
||||
2. 调用者需要匹配特定条件?→ 哨兵或类型化错误,使用 `%w` 包装
|
||||
3. 调用者只需要调试上下文?→ `fmt.Errorf("...: %w", err)`
|
||||
4. 叶子函数,无需包装?→ 直接返回错误
|
||||
|
||||
**默认**:使用 `%w` 包装,并将其放在格式字符串的末尾。
|
||||
|
||||
---
|
||||
|
||||
## 核心规则
|
||||
|
||||
### 永不返回具体错误类型
|
||||
|
||||
**永不从导出函数返回具体错误类型** — 具体的 `nil` 指针可能变成非 nil 接口:
|
||||
|
||||
```go
|
||||
// 不好:具体类型可能导致微妙的 bug
|
||||
func Bad() *os.PathError { /*...*/ }
|
||||
|
||||
// 好:始终返回 error 接口
|
||||
func Good() error { /*...*/ }
|
||||
```
|
||||
|
||||
### 错误字符串
|
||||
|
||||
错误字符串**不应**大写,也**不应**以标点符号结尾。例外:导出名称、专有名词或缩写。
|
||||
|
||||
```go
|
||||
// 不好
|
||||
err := fmt.Errorf("Something bad happened.")
|
||||
|
||||
// 好
|
||||
err := fmt.Errorf("something bad happened")
|
||||
```
|
||||
|
||||
对于显示的消息(日志、测试失败、API 响应),大写是适当的。
|
||||
|
||||
### 出错时的返回值
|
||||
|
||||
当函数返回错误时,调用者必须将所有非错误返回值视为未指定,除非有明确文档说明。
|
||||
|
||||
**提示**:接受 `context.Context` 的函数通常应返回 `error`,以便调用者判断上下文是否被取消。
|
||||
|
||||
---
|
||||
|
||||
## 处理错误
|
||||
|
||||
遇到错误时,做出**深思熟虑的选择** — 不要用 `_` 丢弃:
|
||||
|
||||
1. **立即处理** — 解决错误并继续
|
||||
2. **返回给调用者** — 可选择用上下文包装
|
||||
3. **在特殊情况下** — `log.Fatal` 或 `panic`
|
||||
|
||||
有意忽略时:添加注释说明原因。
|
||||
|
||||
```go
|
||||
n, _ := b.Write(p) // 永不返回非 nil 错误
|
||||
```
|
||||
|
||||
对于相关的并发操作,使用 [`errgroup`](https://pkg.go.dev/golang.org/x/sync/errgroup):
|
||||
|
||||
```go
|
||||
g, ctx := errgroup.WithContext(ctx)
|
||||
g.Go(func() error { return task1(ctx) })
|
||||
g.Go(func() error { return task2(ctx) })
|
||||
if err := g.Wait(); err != nil { return err }
|
||||
```
|
||||
|
||||
### 避免带内错误
|
||||
|
||||
不要返回 `-1`、`nil` 或空字符串来表示错误。使用多返回值:
|
||||
|
||||
```go
|
||||
// 不好:带内错误值
|
||||
func Lookup(key string) int // 缺失时返回 -1
|
||||
|
||||
// 好:显式的 error 或 ok 值
|
||||
func Lookup(key string) (string, bool)
|
||||
```
|
||||
|
||||
这可以防止调用者写出 `Parse(Lookup(key))` — 它会导致编译时错误,因为 `Lookup(key)` 有 2 个输出。
|
||||
|
||||
---
|
||||
|
||||
## 错误流程
|
||||
|
||||
在正常代码之前处理错误。提前返回使正常路径保持无缩进:
|
||||
|
||||
```go
|
||||
// 好:错误优先,正常代码无缩进
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 正常代码
|
||||
```
|
||||
|
||||
**错误只处理一次** — 记录日志或返回,不要两者都做:
|
||||
|
||||
```
|
||||
遇到错误?
|
||||
├─ 调用者可以采取行动?→ 返回(通过 %w 附带上下文)
|
||||
├─ 在调用链顶部?→ 记录日志并处理
|
||||
└─ 都不是?→ 以适当级别记录日志,继续执行
|
||||
```
|
||||
|
||||
> 在组织复杂的错误流程、决定记录日志还是返回、实现一次处理模式、或选择结构化日志级别时,请阅读 [references/ERROR-FLOW.md](references/ERROR-FLOW.md)。
|
||||
|
||||
---
|
||||
|
||||
## 错误类型
|
||||
|
||||
> **建议**:推荐的最佳实践。
|
||||
|
||||
| 调用者需要匹配? | 消息类型 | 使用方式 |
|
||||
|-----------------|---------|---------|
|
||||
| 否 | 静态 | `errors.New("message")` |
|
||||
| 否 | 动态 | `fmt.Errorf("msg: %v", val)` |
|
||||
| 是 | 静态 | `var ErrFoo = errors.New("...")` |
|
||||
| 是 | 动态 | 自定义 `error` 类型 |
|
||||
|
||||
**默认**:使用 `fmt.Errorf("...: %w", err)` 包装。升级为哨兵以使用 `errors.Is()`,升级为自定义类型以使用 `errors.As()`。
|
||||
|
||||
> 在定义哨兵错误、创建自定义错误类型、或为包 API 选择错误策略时,请阅读 [references/ERROR-TYPES.md](references/ERROR-TYPES.md)。
|
||||
|
||||
---
|
||||
|
||||
## 错误包装
|
||||
|
||||
> **建议**:推荐的最佳实践。
|
||||
|
||||
- **使用 `%v`**:在系统边界、用于日志记录、隐藏内部细节
|
||||
- **使用 `%w`**:保留错误链以供 `errors.Is`/`errors.As` 使用
|
||||
|
||||
**关键规则**:将 `%w` 放在末尾。添加调用者没有的上下文。如果注释没有增加信息,直接返回 `err`。
|
||||
|
||||
> 在决定使用 %v 还是 %w、跨包边界包装错误、或添加上下文信息时,请阅读 [references/WRAPPING.md](references/WRAPPING.md)。
|
||||
|
||||
> **验证**:实现错误处理后,运行 `bash scripts/check-errors.sh` 检测常见的反模式。然后运行 `go vet ./...` 捕获其他问题。
|
||||
|
||||
---
|
||||
|
||||
## 相关技能
|
||||
|
||||
- **错误命名**:在命名哨兵错误(`ErrFoo`)或自定义错误类型时,参见 [go-naming](../go-naming/SKILL.md)
|
||||
- **测试错误**:在使用 `errors.Is`/`errors.As` 测试错误语义或编写错误检查辅助函数时,参见 [go-testing](../go-testing/SKILL.md)
|
||||
- **Panic 处理**:在决定 panic 还是返回错误、或编写 recover 守卫时,参见 [go-defensive](../go-defensive/SKILL.md)
|
||||
- **守卫子句**:在组织提前返回的错误流程或减少嵌套时,参见 [go-control-flow](../go-control-flow/SKILL.md)
|
||||
- **日志决策**:在选择日志级别、配置结构化日志、或决定日志消息中包含什么上下文时,参见 [go-logging](../go-logging/SKILL.md)
|
||||
@@ -1,153 +0,0 @@
|
||||
# 错误流程模式
|
||||
|
||||
错误流程、一次处理原则和日志决策的详细模式。
|
||||
|
||||
## 缩进错误流程
|
||||
|
||||
在继续正常代码之前先处理错误。这通过使读者能够快速找到正常路径来提高可读性。
|
||||
|
||||
```go
|
||||
// 好:错误处理优先,正常代码无缩进
|
||||
if err != nil {
|
||||
// 错误处理
|
||||
return // 或 continue 等
|
||||
}
|
||||
// 正常代码
|
||||
```
|
||||
|
||||
```go
|
||||
// 不好:正常代码隐藏在 else 子句中
|
||||
if err != nil {
|
||||
// 错误处理
|
||||
} else {
|
||||
// 正常代码因缩进看起来不自然
|
||||
}
|
||||
```
|
||||
|
||||
### 避免对长期使用的变量使用 if 初始化语句
|
||||
|
||||
如果变量在多行中使用,将声明移出:
|
||||
|
||||
```go
|
||||
// 好:声明与错误检查分开
|
||||
x, err := f()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 大量使用 x 的代码
|
||||
// 跨越多行
|
||||
```
|
||||
|
||||
```go
|
||||
// 不好:变量作用域限制在 else 块中,难以阅读
|
||||
if x, err := f(); err != nil {
|
||||
return err
|
||||
} else {
|
||||
// 大量使用 x 的代码
|
||||
// 跨越多行
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 错误只处理一次
|
||||
|
||||
当调用者收到错误时,应该**只处理一次**。选择一种响应方式:
|
||||
|
||||
1. **返回错误**(包装或原文)让调用者处理
|
||||
2. **记录日志并优雅降级**(不返回错误)
|
||||
3. **匹配并处理**特定错误情况,返回其他错误
|
||||
|
||||
**如果返回了错误,就不要自己记录日志** — 让调用者处理。对同一错误既记录日志又返回是最常见的"一次处理"违规,导致重复噪音,因为调用栈上层的调用者也会处理该错误。
|
||||
|
||||
```go
|
||||
// 不好:既记录日志又返回 — 导致日志噪音
|
||||
u, err := getUser(id)
|
||||
if err != nil {
|
||||
log.Printf("Could not get user %q: %v", id, err)
|
||||
return err // 调用者也会记录这个!
|
||||
}
|
||||
|
||||
// 好:包装并返回 — 让调用者决定如何处理
|
||||
u, err := getUser(id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get user %q: %w", id, err)
|
||||
}
|
||||
|
||||
// 好:记录日志并优雅降级(不返回错误)
|
||||
if err := emitMetrics(); err != nil {
|
||||
// 写入指标失败不应影响应用程序
|
||||
log.Printf("Could not emit metrics: %v", err)
|
||||
}
|
||||
// 继续执行...
|
||||
|
||||
// 好:匹配特定错误,返回其他错误
|
||||
tz, err := getUserTimeZone(id)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrUserNotFound) {
|
||||
// 用户不存在,使用 UTC
|
||||
tz = time.UTC
|
||||
} else {
|
||||
return fmt.Errorf("get user %q: %w", id, err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 记录日志 vs 返回错误
|
||||
|
||||
> 错误只处理一次 — 记录日志或返回,不要两者都做。
|
||||
|
||||
### 决策流程
|
||||
|
||||
```
|
||||
遇到错误?
|
||||
├─ 调用者可以采取行动?→ 返回错误(通过 %w 附带上下文)
|
||||
├─ 在调用链顶部?→ 记录日志并处理(返回 HTTP 状态码、退出等)
|
||||
└─ 都不是?→ 以适当级别记录日志并继续
|
||||
```
|
||||
|
||||
### 不要既记录日志又返回
|
||||
|
||||
```go
|
||||
// 不好:错误既被记录又被返回 — 在日志中出现两次
|
||||
func process(ctx context.Context, id string) error {
|
||||
result, err := fetch(ctx, id)
|
||||
if err != nil {
|
||||
log.Printf("failed to fetch %s: %v", id, err)
|
||||
return fmt.Errorf("fetching %s: %w", id, err)
|
||||
}
|
||||
return handle(result)
|
||||
}
|
||||
|
||||
// 好:带上下文返回 — 让调用者决定是否记录日志
|
||||
func process(ctx context.Context, id string) error {
|
||||
result, err := fetch(ctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetching %s: %w", id, err)
|
||||
}
|
||||
return handle(result)
|
||||
}
|
||||
```
|
||||
|
||||
### 结构化日志
|
||||
|
||||
在生产代码中,优先使用结构化日志(Go 1.21+ 的 `slog`,或 `log/slog` 兼容库)而非 `log.Printf`:
|
||||
|
||||
```go
|
||||
// 好:结构化字段可被机器解析
|
||||
slog.Error("fetch failed", "id", id, "err", err)
|
||||
|
||||
// 避免:非结构化的字符串插值
|
||||
log.Printf("fetch failed for %s: %v", id, err)
|
||||
```
|
||||
|
||||
### 日志级别
|
||||
|
||||
| 级别 | 使用场景 |
|
||||
|------|---------|
|
||||
| Error | 需要关注的可操作故障 |
|
||||
| Warn | 不需要立即处理的降级行为 |
|
||||
| Info | 关键生命周期事件(启动、关闭、配置加载) |
|
||||
| Debug | 开发期间有用的诊断细节 |
|
||||
@@ -1,151 +0,0 @@
|
||||
# 错误类型参考
|
||||
|
||||
本参考涵盖结构化错误类型、哨兵错误,以及如何为你的用例选择正确的错误类型。
|
||||
|
||||
---
|
||||
|
||||
## 错误结构
|
||||
|
||||
> 错误类型决策表在父技能中(SKILL.md § 错误类型)。
|
||||
> 本参考涵盖:扩展的代码示例、哨兵错误、使用 `errors.Is`/`errors.As` 进行错误检查,以及结构化错误类型。
|
||||
|
||||
**关键考虑因素**:
|
||||
|
||||
- 调用者是否需要使用 `errors.Is` 或 `errors.As` 来匹配错误?
|
||||
- 错误消息是静态的还是需要运行时值?
|
||||
- 导出的错误变量/类型将成为公共 API 的一部分
|
||||
|
||||
```go
|
||||
// 无需匹配,静态消息
|
||||
func Open() error {
|
||||
return errors.New("could not open")
|
||||
}
|
||||
|
||||
// 需要匹配,静态消息 - 导出哨兵
|
||||
var ErrCouldNotOpen = errors.New("could not open")
|
||||
|
||||
func Open() error {
|
||||
return ErrCouldNotOpen
|
||||
}
|
||||
|
||||
// 需要匹配,动态消息 - 使用自定义类型
|
||||
type NotFoundError struct {
|
||||
File string
|
||||
}
|
||||
|
||||
func (e *NotFoundError) Error() string {
|
||||
return fmt.Sprintf("file %q not found", e.File)
|
||||
}
|
||||
|
||||
func Open(file string) error {
|
||||
return &NotFoundError{File: file}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 哨兵错误
|
||||
|
||||
最简单的结构化错误是无参数化的全局值:
|
||||
|
||||
```go
|
||||
// 好:用于程序化检查的哨兵错误
|
||||
var (
|
||||
// ErrDuplicate 在该动物已被见过时发生。
|
||||
ErrDuplicate = errors.New("duplicate")
|
||||
|
||||
// ErrMarsupial 因为我们不支持有袋类动物。
|
||||
ErrMarsupial = errors.New("marsupials are not supported")
|
||||
)
|
||||
|
||||
func process(animal Animal) error {
|
||||
switch {
|
||||
case seen[animal]:
|
||||
return ErrDuplicate
|
||||
case marsupial(animal):
|
||||
return ErrMarsupial
|
||||
}
|
||||
seen[animal] = true
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 检查错误
|
||||
|
||||
对于直接比较(当错误未被包装时):
|
||||
|
||||
```go
|
||||
// 好:与哨兵直接比较
|
||||
switch err := process(an); err {
|
||||
case ErrDuplicate:
|
||||
return fmt.Errorf("feed %q: %v", an, err)
|
||||
case ErrMarsupial:
|
||||
alternate := an.BackupAnimal()
|
||||
return handlePet(alternate)
|
||||
}
|
||||
```
|
||||
|
||||
当错误可能被包装时,使用 `errors.Is`:
|
||||
|
||||
```go
|
||||
// 好:适用于被包装的错误
|
||||
switch err := process(an); {
|
||||
case errors.Is(err, ErrDuplicate):
|
||||
return fmt.Errorf("feed %q: %v", an, err)
|
||||
case errors.Is(err, ErrMarsupial):
|
||||
// 尝试恢复...
|
||||
}
|
||||
```
|
||||
|
||||
**绝不**基于字符串内容匹配错误:
|
||||
|
||||
```go
|
||||
// 不好:脆弱的字符串匹配
|
||||
if regexp.MatchString(`duplicate`, err.Error()) {...}
|
||||
if regexp.MatchString(`marsupial`, err.Error()) {...}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 结构化错误类型
|
||||
|
||||
对于需要额外程序化信息的错误,使用结构体类型:
|
||||
|
||||
```go
|
||||
// 好:具有可访问字段的结构化错误
|
||||
type PathError struct {
|
||||
Op string
|
||||
Path string
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *PathError) Error() string {
|
||||
return e.Op + " " + e.Path + ": " + e.Err.Error()
|
||||
}
|
||||
|
||||
func (e *PathError) Unwrap() error { return e.Err }
|
||||
```
|
||||
|
||||
调用者可以使用 `errors.As` 提取结构化错误:
|
||||
|
||||
```go
|
||||
var pathErr *os.PathError
|
||||
if errors.As(err, &pathErr) {
|
||||
fmt.Println("Failed path:", pathErr.Path)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 快速参考
|
||||
|
||||
| 场景 | 错误类型 |
|
||||
|------|---------|
|
||||
| 无需匹配,静态消息 | `errors.New("message")` |
|
||||
| 无需匹配,动态消息 | `fmt.Errorf("msg: %v", val)` |
|
||||
| 需要匹配,静态消息 | `var ErrFoo = errors.New(...)` |
|
||||
| 需要匹配,动态消息 | 自定义结构体类型 |
|
||||
| 检查哨兵错误 | `errors.Is(err, ErrFoo)` |
|
||||
| 提取结构化错误 | `errors.As(err, &target)` |
|
||||
@@ -1,174 +0,0 @@
|
||||
# 错误包装参考
|
||||
|
||||
本参考涵盖使用 `%v` vs `%w` 的错误包装、放置约定、向错误添加上下文以及日志最佳实践。
|
||||
|
||||
---
|
||||
|
||||
## 包装错误:%v vs %w
|
||||
|
||||
> **建议**:推荐的最佳实践。
|
||||
|
||||
`%v` 和 `%w` 的选择会显著影响错误的传播和检查方式。
|
||||
|
||||
### 使用 %v 进行简单注释
|
||||
|
||||
当你需要以下操作时使用 `%v`:
|
||||
|
||||
- 添加上下文但不保留错误链以供程序化检查
|
||||
- 创建全新的、独立的错误(特别是在 RPC/IPC 等系统边界)
|
||||
- 向人类记录或显示错误
|
||||
|
||||
```go
|
||||
// 好:%v 在系统边界 — 隐藏内部细节
|
||||
func (s *Server) SuggestFortune(ctx context.Context, req *pb.Request) (*pb.Response, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("couldn't find fortune database: %v", err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 使用 %w 保留错误链
|
||||
|
||||
当你需要调用者以编程方式检查底层错误时使用 `%w`:
|
||||
|
||||
```go
|
||||
// 好:%w 保留错误链以供 errors.Is/errors.As 使用
|
||||
func (s *Server) internalFunction(ctx context.Context) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("couldn't find remote file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 调用者现在可以检查:
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
// 处理未找到的情况
|
||||
}
|
||||
```
|
||||
|
||||
### 何时使用哪种
|
||||
|
||||
**使用 %w 的场景**:
|
||||
- 在添加上下文的同时保留原始错误以供程序化检查
|
||||
- 你明确记录并测试了所暴露的底层错误
|
||||
|
||||
**使用 %v 的场景**:
|
||||
- 在系统边界(RPC、IPC、存储)转换为规范错误空间
|
||||
- 向人类记录日志或显示
|
||||
- 创建隐藏实现细节的独立错误
|
||||
|
||||
---
|
||||
|
||||
## %w 的放置位置
|
||||
|
||||
> **建议**:推荐的最佳实践。
|
||||
|
||||
将 `%w` 放在错误字符串的**末尾**,使错误文本反映错误链结构:
|
||||
|
||||
```go
|
||||
// 好:%w 在末尾 — 从最新到最旧打印
|
||||
err1 := fmt.Errorf("err1")
|
||||
err2 := fmt.Errorf("err2: %w", err1)
|
||||
err3 := fmt.Errorf("err3: %w", err2)
|
||||
fmt.Println(err3) // err3: err2: err1
|
||||
```
|
||||
|
||||
```go
|
||||
// 不好:%w 在开头 — 从最旧到最新打印(令人困惑)
|
||||
err1 := fmt.Errorf("err1")
|
||||
err2 := fmt.Errorf("%w: err2", err1)
|
||||
err3 := fmt.Errorf("%w: err3", err2)
|
||||
fmt.Println(err3) // err1: err2: err3
|
||||
```
|
||||
|
||||
```go
|
||||
// 不好:%w 在中间 — 不连贯的顺序
|
||||
err1 := fmt.Errorf("err1")
|
||||
err2 := fmt.Errorf("err2-1 %w err2-2", err1)
|
||||
err3 := fmt.Errorf("err3-1 %w err3-2", err2)
|
||||
fmt.Println(err3) // err3-1 err2-1 err1 err2-2 err3-2
|
||||
```
|
||||
|
||||
**模式**:使用 `context message: %w` 的形式
|
||||
|
||||
---
|
||||
|
||||
## 向错误添加信息
|
||||
|
||||
> **建议**:推荐的最佳实践。
|
||||
|
||||
### 添加上下文,而非冗余
|
||||
|
||||
添加你拥有但调用者/被调用者可能没有的信息。避免重复底层错误已提供的信息:
|
||||
|
||||
```go
|
||||
// 好:添加有意义的上下文
|
||||
if err := os.Open("settings.txt"); err != nil {
|
||||
return fmt.Errorf("launch codes unavailable: %v", err)
|
||||
}
|
||||
// 输出:launch codes unavailable: open settings.txt: no such file or directory
|
||||
```
|
||||
|
||||
```go
|
||||
// 不好:重复了文件名
|
||||
if err := os.Open("settings.txt"); err != nil {
|
||||
return fmt.Errorf("could not open settings.txt: %v", err)
|
||||
}
|
||||
// 输出:could not open settings.txt: open settings.txt: no such file or directory
|
||||
```
|
||||
|
||||
### 不要无目的地注释
|
||||
|
||||
如果注释仅表示失败而没有添加信息,直接返回错误:
|
||||
|
||||
```go
|
||||
// 不好:注释没有增加信息
|
||||
return fmt.Errorf("failed: %v", err)
|
||||
|
||||
// 好:直接返回错误
|
||||
return err
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 记录错误日志
|
||||
|
||||
> **建议**:推荐的最佳实践。
|
||||
|
||||
当需要记录错误时,使用 `log/slog`(Go 1.21+)配合结构化键值对和适当的日志级别:
|
||||
|
||||
- **`slog.Error`**:保留用于需要调查的可操作问题。
|
||||
- **`slog.Warn`**:用于可能需要关注但不可立即操作的问题。
|
||||
- **`slog.Debug`**:用于开发追踪 — 仅在 handler 级别设为 `LevelDebug` 时才输出。
|
||||
|
||||
```go
|
||||
// 好:使用适当级别的结构化日志
|
||||
for _, q := range queries {
|
||||
slog.Debug("handling query", "query", q)
|
||||
q.Run()
|
||||
}
|
||||
|
||||
// 好:在级别检查后保护昂贵的格式化操作
|
||||
if slog.Default().Enabled(context.Background(), slog.LevelDebug) {
|
||||
slog.Debug("query plan", "explain", q.Explain())
|
||||
}
|
||||
|
||||
// 不好:即使禁用了 debug 日志也会执行昂贵的调用
|
||||
slog.Debug("query plan", "explain", q.Explain())
|
||||
```
|
||||
|
||||
### 保护敏感信息
|
||||
|
||||
注意日志消息中的 PII(个人身份信息)。许多日志接收器不适合存放敏感用户数据。
|
||||
|
||||
---
|
||||
|
||||
## 快速参考
|
||||
|
||||
| 模式 | 指导 |
|
||||
|------|------|
|
||||
| `%v` | 在系统边界使用、用于日志记录、隐藏细节 |
|
||||
| `%w` | 保留错误链以供程序化检查 |
|
||||
| `%w` 放置 | 始终在末尾:`"context: %w"` |
|
||||
| 添加上下文 | 添加新信息,不要重复现有信息 |
|
||||
| 空注释 | 直接返回 `err` 而非 `fmt.Errorf("failed: %v", err)` |
|
||||
| 日志 | 不要既记录日志又返回;使用适当的日志级别 |
|
||||
@@ -1,266 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="1.0.0"
|
||||
SCRIPT_NAME="$(basename "$0")"
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
$SCRIPT_NAME v$VERSION — Check Go code for common error handling anti-patterns
|
||||
|
||||
USAGE
|
||||
bash $SCRIPT_NAME [options] [path]
|
||||
|
||||
DESCRIPTION
|
||||
Scans Go source files for error handling anti-patterns:
|
||||
- err.Error() used in string comparison (should use errors.Is/As)
|
||||
- Bare 'return err' without wrapping context
|
||||
- Errors that are both logged and returned (handle once)
|
||||
|
||||
Exits 0 if no issues found, 1 if anti-patterns detected, 2 on error.
|
||||
|
||||
OPTIONS
|
||||
-h, --help Show this help message
|
||||
-v, --version Show version
|
||||
--json Output results as JSON
|
||||
--no-bare-return Skip the bare 'return err' check (high false-positive rate)
|
||||
--limit N Show at most N results (default: all)
|
||||
|
||||
ARGUMENTS
|
||||
path Directory or file to check (default: current directory)
|
||||
|
||||
EXAMPLES
|
||||
bash $SCRIPT_NAME
|
||||
bash $SCRIPT_NAME ./pkg/api
|
||||
bash $SCRIPT_NAME --json .
|
||||
bash $SCRIPT_NAME --no-bare-return ./internal
|
||||
EOF
|
||||
}
|
||||
|
||||
JSON_OUTPUT=false
|
||||
CHECK_BARE_RETURN=true
|
||||
LIMIT=0
|
||||
TARGET=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-h|--help) usage; exit 0 ;;
|
||||
-v|--version) echo "$SCRIPT_NAME v$VERSION"; exit 0 ;;
|
||||
--json) JSON_OUTPUT=true; shift ;;
|
||||
--no-bare-return) CHECK_BARE_RETURN=false; shift ;;
|
||||
--limit) LIMIT="${2:?error: --limit requires a number}"; shift 2 ;;
|
||||
-*) echo "error: unknown option: $1" >&2; usage >&2; exit 2 ;;
|
||||
*) TARGET="$1"; shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
TARGET="${TARGET:-.}"
|
||||
|
||||
json_escape() {
|
||||
local s="$1"
|
||||
s="${s//\\/\\\\}"
|
||||
s="${s//\"/\\\"}"
|
||||
s="${s//$'\t'/\\t}"
|
||||
s="${s//$'\r'/}"
|
||||
s="${s//$'\n'/\\n}"
|
||||
printf '%s' "$s"
|
||||
}
|
||||
|
||||
find_go_files() {
|
||||
local t="$1"
|
||||
if [[ -f "$t" ]]; then
|
||||
echo "$t"
|
||||
elif [[ -d "$t" ]]; then
|
||||
find "$t" -name '*.go' ! -name '*_test.go' ! -path '*/vendor/*' ! -path '*/.git/*' 2>/dev/null
|
||||
else
|
||||
local dir="${t%%/...}"
|
||||
dir="${dir:-.}"
|
||||
if [[ -d "$dir" ]]; then
|
||||
find "$dir" -name '*.go' ! -name '*_test.go' ! -path '*/vendor/*' ! -path '*/.git/*' 2>/dev/null
|
||||
else
|
||||
echo "error: path not found: $t" >&2
|
||||
exit 2
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
FINDINGS=()
|
||||
|
||||
add_finding() {
|
||||
local file="$1" line="$2" rule="$3" message="$4"
|
||||
FINDINGS+=("${file}:${line}|${rule}|${message}")
|
||||
}
|
||||
|
||||
# Rule 1: err.Error() in string comparison
|
||||
check_string_error_comparison() {
|
||||
local file="$1"
|
||||
local line_num=0
|
||||
while IFS= read -r line; do
|
||||
line_num=$((line_num + 1))
|
||||
|
||||
# Pattern: err.Error() == "..." or err.Error() != "..."
|
||||
pat='\.Error\(\)[[:space:]]*(==|!=)[[:space:]]*\"'
|
||||
if [[ "$line" =~ $pat ]]; then
|
||||
add_finding "$file" "$line_num" "string-error-compare" \
|
||||
"comparing err.Error() to string; use errors.Is() or errors.As() instead"
|
||||
fi
|
||||
|
||||
# Pattern: strings.Contains(err.Error(), "...")
|
||||
pat_contains='strings\.Contains\(.*\.Error\(\)'
|
||||
if [[ "$line" =~ $pat_contains ]]; then
|
||||
add_finding "$file" "$line_num" "string-error-compare" \
|
||||
"using strings.Contains on err.Error(); use errors.Is() or errors.As() instead"
|
||||
fi
|
||||
|
||||
# Pattern: "..." == err.Error()
|
||||
pat='\"[^\"]*\"[[:space:]]*(==|!=)[[:space:]]*[a-zA-Z_][a-zA-Z0-9_]*\.Error\(\)'
|
||||
if [[ "$line" =~ $pat ]]; then
|
||||
add_finding "$file" "$line_num" "string-error-compare" \
|
||||
"comparing string to err.Error(); use errors.Is() or errors.As() instead"
|
||||
fi
|
||||
done < "$file"
|
||||
}
|
||||
|
||||
# Rule 2: Bare return err (no wrapping)
|
||||
check_bare_return_err() {
|
||||
local file="$1"
|
||||
local line_num=0
|
||||
local in_error_block=false
|
||||
|
||||
while IFS= read -r line; do
|
||||
line_num=$((line_num + 1))
|
||||
|
||||
# Detect if err != nil { block
|
||||
pat='if[[:space:]]+(.*err[[:space:]]*(!=|==)[[:space:]]*nil|err[[:space:]]*:=)'
|
||||
if [[ "$line" =~ $pat ]]; then
|
||||
in_error_block=true
|
||||
fi
|
||||
|
||||
# Check for bare "return err" that is not wrapped
|
||||
pat='^[[:space:]]*return[[:space:]]+(.*,)?[[:space:]]*err[[:space:]]*$'
|
||||
if $in_error_block && [[ "$line" =~ $pat ]]; then
|
||||
# Exclude single-line functions and main error handlers
|
||||
# Only flag if the return is just "err" (not fmt.Errorf wrapped)
|
||||
local trimmed
|
||||
trimmed=$(echo "$line" | sed 's/^[[:space:]]*//')
|
||||
if [[ "$trimmed" == "return err" ]]; then
|
||||
add_finding "$file" "$line_num" "bare-return-err" \
|
||||
"bare 'return err' without wrapping context; consider fmt.Errorf('...: %w', err)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Reset error block tracking on closing brace at same indentation
|
||||
pat_close='^[[:space:]]*\}[[:space:]]*$'
|
||||
if $in_error_block && [[ "$line" =~ $pat_close ]]; then
|
||||
in_error_block=false
|
||||
fi
|
||||
done < "$file"
|
||||
}
|
||||
|
||||
# Rule 3: Log-and-return (handle errors once)
|
||||
check_log_and_return() {
|
||||
local file="$1"
|
||||
local line_num=0
|
||||
local prev_lines=()
|
||||
|
||||
while IFS= read -r line; do
|
||||
line_num=$((line_num + 1))
|
||||
prev_lines+=("$line")
|
||||
|
||||
# Keep a small window to detect log followed by return err
|
||||
if [[ ${#prev_lines[@]} -gt 5 ]]; then
|
||||
prev_lines=("${prev_lines[@]:1}")
|
||||
fi
|
||||
|
||||
# Check if current line is 'return ... err' and a recent line logged the error
|
||||
pat='^[[:space:]]*return[[:space:]]+(.*,)?[[:space:]]*err'
|
||||
if [[ "$line" =~ $pat ]]; then
|
||||
local window_size=${#prev_lines[@]}
|
||||
for ((i=0; i<window_size-1; i++)); do
|
||||
local prev="${prev_lines[$i]}"
|
||||
# Match log.Print/Printf/Println/Error/Errorf/Warn/Warnf with err
|
||||
pat_log1='(log\.|logger\.|slog\.)[a-zA-Z]*\(.*[^a-zA-Z]err[^a-zA-Z]'
|
||||
pat_log2='(log\.|logger\.|slog\.)[a-zA-Z]*\(err[,\)]'
|
||||
if [[ "$prev" =~ $pat_log1 ]] || \
|
||||
[[ "$prev" =~ $pat_log2 ]]; then
|
||||
local log_line=$((line_num - window_size + 1 + i))
|
||||
add_finding "$file" "$log_line" "log-and-return" \
|
||||
"error is both logged (line $log_line) and returned (line $line_num); handle errors once"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
done < "$file"
|
||||
}
|
||||
|
||||
FILES=()
|
||||
while IFS= read -r f; do
|
||||
[[ -n "$f" ]] && FILES+=("$f")
|
||||
done < <(find_go_files "$TARGET")
|
||||
|
||||
if [[ ${#FILES[@]} -eq 0 ]]; then
|
||||
if $JSON_OUTPUT; then
|
||||
echo '{"findings":[],"count":0,"status":"no_go_files"}'
|
||||
else
|
||||
echo "No Go files found in: $TARGET"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
for file in "${FILES[@]}"; do
|
||||
check_string_error_comparison "$file"
|
||||
if $CHECK_BARE_RETURN; then
|
||||
check_bare_return_err "$file"
|
||||
fi
|
||||
check_log_and_return "$file"
|
||||
done
|
||||
|
||||
# Truncation
|
||||
TOTAL=${#FINDINGS[@]}
|
||||
TRUNCATED=false
|
||||
if [[ $LIMIT -gt 0 && $TOTAL -gt $LIMIT ]]; then
|
||||
FINDINGS=("${FINDINGS[@]:0:$LIMIT}")
|
||||
TRUNCATED=true
|
||||
fi
|
||||
|
||||
if $JSON_OUTPUT; then
|
||||
echo "{"
|
||||
echo ' "findings": ['
|
||||
first=true
|
||||
for entry in "${FINDINGS[@]+"${FINDINGS[@]}"}"; do
|
||||
IFS='|' read -r location rule message <<< "$entry"
|
||||
file="${location%%:*}"
|
||||
line="${location#*:}"
|
||||
$first || echo ","
|
||||
first=false
|
||||
printf ' {"file":"%s","line":%s,"rule":"%s","message":"%s"}' \
|
||||
"$(json_escape "$file")" "$line" "$(json_escape "$rule")" "$(json_escape "$message")"
|
||||
done
|
||||
echo ""
|
||||
echo " ],"
|
||||
printf ' "total": %d,\n' "$TOTAL"
|
||||
printf ' "truncated": %s\n' "$TRUNCATED"
|
||||
echo "}"
|
||||
else
|
||||
if [[ $TOTAL -eq 0 ]]; then
|
||||
echo "No error handling anti-patterns found."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Error handling anti-patterns found:"
|
||||
echo ""
|
||||
for entry in "${FINDINGS[@]}"; do
|
||||
IFS='|' read -r location rule message <<< "$entry"
|
||||
printf " %s [%s] %s\n" "$location" "$rule" "$message"
|
||||
done
|
||||
if $TRUNCATED; then
|
||||
echo " ... and $((TOTAL - LIMIT)) more (use --limit to adjust)"
|
||||
fi
|
||||
echo ""
|
||||
echo "Total: $TOTAL finding(s)"
|
||||
fi
|
||||
|
||||
if [[ $TOTAL -gt 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
@@ -1,210 +0,0 @@
|
||||
---
|
||||
name: go-functional-options
|
||||
description: Use when designing a Go constructor or factory function with optional configuration — especially with 3+ optional parameters or extensible APIs. Also use when building a New* function that takes many settings, even if they don't mention "functional options" by name. Does not cover general function design (see go-functions).
|
||||
license: Apache-2.0
|
||||
metadata:
|
||||
sources: "Uber Style Guide"
|
||||
---
|
||||
|
||||
# 函数式选项模式
|
||||
|
||||
函数式选项是一种模式,你声明一个不透明的 `Option` 类型,在内部结构体中记录信息。构造函数接受可变数量的这些选项并将其应用于配置结果。
|
||||
|
||||
## 何时使用
|
||||
|
||||
在以下情况使用函数式选项:
|
||||
|
||||
- 构造函数或公共 API 上有 **3 个以上可选参数**
|
||||
- **可扩展 API**,可能随时间增加新选项
|
||||
- **良好的调用者体验**很重要(无需传递默认值)
|
||||
|
||||
## 模式
|
||||
|
||||
### 核心组件
|
||||
|
||||
1. **未导出的 `options` 结构体** - 保存所有配置
|
||||
2. **导出的 `Option` 接口** - 带有未导出的 `apply` 方法
|
||||
3. **Option 类型** - 实现接口
|
||||
4. **`With*` 构造函数** - 创建选项
|
||||
|
||||
### Option 接口
|
||||
|
||||
```go
|
||||
type Option interface {
|
||||
apply(*options)
|
||||
}
|
||||
```
|
||||
|
||||
未导出的 `apply` 方法确保只能使用来自本包的选项。
|
||||
|
||||
## 完整实现
|
||||
|
||||
```go
|
||||
package db
|
||||
|
||||
import "go.uber.org/zap"
|
||||
|
||||
// options 保存打开连接的所有配置。
|
||||
type options struct {
|
||||
cache bool
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// Option 配置我们如何打开连接。
|
||||
type Option interface {
|
||||
apply(*options)
|
||||
}
|
||||
|
||||
// cacheOption 为缓存设置实现 Option(简单类型别名)。
|
||||
type cacheOption bool
|
||||
|
||||
func (c cacheOption) apply(opts *options) {
|
||||
opts.cache = bool(c)
|
||||
}
|
||||
|
||||
// WithCache 启用或禁用缓存。
|
||||
func WithCache(c bool) Option {
|
||||
return cacheOption(c)
|
||||
}
|
||||
|
||||
// loggerOption 为日志设置实现 Option(用于指针的结构体)。
|
||||
type loggerOption struct {
|
||||
Log *zap.Logger
|
||||
}
|
||||
|
||||
func (l loggerOption) apply(opts *options) {
|
||||
opts.logger = l.Log
|
||||
}
|
||||
|
||||
// WithLogger 设置连接的日志记录器。
|
||||
func WithLogger(log *zap.Logger) Option {
|
||||
return loggerOption{Log: log}
|
||||
}
|
||||
|
||||
// Open 创建一个连接。
|
||||
func Open(addr string, opts ...Option) (*Connection, error) {
|
||||
// 从默认值开始
|
||||
options := options{
|
||||
cache: defaultCache,
|
||||
logger: zap.NewNop(),
|
||||
}
|
||||
|
||||
// 应用所有提供的选项
|
||||
for _, o := range opts {
|
||||
o.apply(&options)
|
||||
}
|
||||
|
||||
// 使用 options.cache 和 options.logger...
|
||||
return &Connection{}, nil
|
||||
}
|
||||
```
|
||||
|
||||
## 使用示例
|
||||
|
||||
### 不使用函数式选项(不好)
|
||||
|
||||
```go
|
||||
// 调用者必须始终提供所有参数,即使是默认值
|
||||
db.Open(addr, db.DefaultCache, zap.NewNop())
|
||||
db.Open(addr, db.DefaultCache, log)
|
||||
db.Open(addr, false /* cache */, zap.NewNop())
|
||||
db.Open(addr, false /* cache */, log)
|
||||
```
|
||||
|
||||
### 使用函数式选项(好)
|
||||
|
||||
```go
|
||||
// 只在需要时提供选项
|
||||
db.Open(addr)
|
||||
db.Open(addr, db.WithLogger(log))
|
||||
db.Open(addr, db.WithCache(false))
|
||||
db.Open(
|
||||
addr,
|
||||
db.WithCache(false),
|
||||
db.WithLogger(log),
|
||||
)
|
||||
```
|
||||
|
||||
## 比较:函数式选项 vs 配置结构体
|
||||
|
||||
| 方面 | 函数式选项 | 配置结构体 |
|
||||
|------|-----------|-----------|
|
||||
| **可扩展性** | 添加新的 `With*` 函数 | 添加新字段(可能破坏兼容性) |
|
||||
| **默认值** | 内置于构造函数 | 零值或单独的默认值 |
|
||||
| **调用者体验** | 只指定不同的部分 | 必须构造整个结构体 |
|
||||
| **可测试性** | 选项可比较 | 结构体比较 |
|
||||
| **复杂性** | 更多样板代码 | 更简单的设置 |
|
||||
|
||||
**优先使用配置结构体的场景**:少于 3 个选项、选项很少变化、所有选项通常一起指定、或仅用于内部 API。
|
||||
|
||||
> 在决定使用函数式选项还是配置结构体、设计具有适当默认值的配置结构体 API、或评估复杂构造函数的混合方法时,请阅读 [references/OPTIONS-VS-STRUCTS.md](references/OPTIONS-VS-STRUCTS.md)。
|
||||
|
||||
## 为什么不使用闭包?
|
||||
|
||||
另一种实现使用闭包:
|
||||
|
||||
```go
|
||||
// 闭包方法(不推荐)
|
||||
type Option func(*options)
|
||||
|
||||
func WithCache(c bool) Option {
|
||||
return func(o *options) { o.cache = c }
|
||||
}
|
||||
```
|
||||
|
||||
优先使用接口方法,因为:
|
||||
|
||||
1. **可测试性** - 选项可以在测试和 mock 中进行比较
|
||||
2. **可调试性** - 选项可以实现 `fmt.Stringer`
|
||||
3. **灵活性** - 选项可以实现额外的接口
|
||||
4. **可见性** - 选项类型在文档中可见
|
||||
|
||||
## 快速参考
|
||||
|
||||
```go
|
||||
// 1. 带有默认值的未导出 options 结构体
|
||||
type options struct {
|
||||
field1 Type1
|
||||
field2 Type2
|
||||
}
|
||||
|
||||
// 2. 导出的 Option 接口,未导出的方法
|
||||
type Option interface {
|
||||
apply(*options)
|
||||
}
|
||||
|
||||
// 3. Option 类型 + apply + With* 构造函数
|
||||
type field1Option Type1
|
||||
|
||||
func (o field1Option) apply(opts *options) { opts.field1 = Type1(o) }
|
||||
func WithField1(v Type1) Option { return field1Option(v) }
|
||||
|
||||
// 4. 构造函数在默认值之上应用选项
|
||||
func New(required string, opts ...Option) (*Thing, error) {
|
||||
o := options{field1: defaultField1, field2: defaultField2}
|
||||
for _, opt := range opts {
|
||||
opt.apply(&o)
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### 检查清单
|
||||
|
||||
- [ ] `options` 结构体未导出
|
||||
- [ ] `Option` 接口有未导出的 `apply` 方法
|
||||
- [ ] 每个选项有 `With*` 构造函数
|
||||
- [ ] 默认值在应用选项之前设置
|
||||
- [ ] 必需参数与 `...Option` 分开
|
||||
|
||||
## 相关技能
|
||||
|
||||
- **接口设计**:在设计 `Option` 接口或选择接口与闭包方法时,参见 [go-interfaces](../go-interfaces/SKILL.md)
|
||||
- **命名约定**:在命名 `With*` 构造函数、选项类型或未导出的 options 结构体时,参见 [go-naming](../go-naming/SKILL.md)
|
||||
- **函数设计**:在组织文件中的构造函数或格式化可变参数签名时,参见 [go-functions](../go-functions/SKILL.md)
|
||||
- **文档**:在记录 `Option` 类型、`With*` 函数或构造函数行为时,参见 [go-documentation](../go-documentation/SKILL.md)
|
||||
|
||||
### 外部资源
|
||||
|
||||
- [Self-referential functions and the design of options](https://commandcenter.blogspot.com/2014/01/self-referential-functions-and-design.html) - Rob Pike
|
||||
- [Functional options for friendly APIs](https://dave.cheney.net/2014/10/17/functional-options-for-friendly-apis) - Dave Cheney
|
||||
@@ -1,129 +0,0 @@
|
||||
# 函数式选项 vs 配置结构体
|
||||
|
||||
> **来源**:Google 风格指南, Uber 风格指南
|
||||
|
||||
函数式选项和配置结构体解决相同的问题 — 构造函数的可选配置 — 但它们有不同的权衡。根据 API 受众、可扩展性需求和复杂性预算来选择。
|
||||
|
||||
## 决策框架
|
||||
|
||||
```
|
||||
需要可选配置?
|
||||
├─ 内部或仅测试 API?
|
||||
│ └─ 配置结构体(更简单,更少样板代码)
|
||||
├─ 具有 3 个以上选项的公共 API?
|
||||
│ └─ 函数式选项(可扩展,向后兼容)
|
||||
├─ 选项需要校验或有相互依赖?
|
||||
│ └─ 函数式选项(在 apply 或构造函数中校验)
|
||||
├─ 所有选项通常一起指定?
|
||||
│ └─ 配置结构体(一个字面量,无需 With* 仪式)
|
||||
└─ 选项可能随时间增长?
|
||||
└─ 函数式选项(添加 With* 不会破坏调用者)
|
||||
```
|
||||
|
||||
## 配置结构体模式
|
||||
|
||||
配置结构体将可选参数分组为传递给构造函数的单个结构体。零值作为默认值,或提供 `DefaultConfig()`。
|
||||
|
||||
**好**
|
||||
```go
|
||||
type Config struct {
|
||||
Timeout time.Duration // 零 = 无超时
|
||||
MaxRetry int // 零 = 无重试
|
||||
Logger *log.Logger // nil = 丢弃
|
||||
}
|
||||
|
||||
func NewClient(addr string, cfg Config) *Client {
|
||||
if cfg.Logger == nil {
|
||||
cfg.Logger = log.New(io.Discard, "", 0)
|
||||
}
|
||||
return &Client{addr: addr, cfg: cfg}
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
c := NewClient("localhost:8080", Config{
|
||||
Timeout: 5 * time.Second,
|
||||
MaxRetry: 3,
|
||||
})
|
||||
```
|
||||
|
||||
**不好** — 在公共 API 中依赖未导出的配置字段:
|
||||
```go
|
||||
type config struct { // 未导出:调用者无法构造
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
func NewClient(addr string, cfg config) *Client { ... }
|
||||
```
|
||||
|
||||
### 当零值不适用时
|
||||
|
||||
如果零是一个有效的非默认值(例如,超时为 0 表示"无超时",但期望的默认值是 30s),使用指针字段或哨兵值:
|
||||
|
||||
```go
|
||||
type Config struct {
|
||||
Timeout *time.Duration // nil = 使用默认值(30s),零 = 无超时
|
||||
}
|
||||
```
|
||||
|
||||
## 比较
|
||||
|
||||
| 方面 | 函数式选项 | 配置结构体 |
|
||||
|------|-----------|-----------|
|
||||
| **样板代码** | 高(每个选项需要类型 + apply + With*) | 低(一个结构体) |
|
||||
| **可扩展性** | 添加 `With*` — 无破坏性变更 | 添加字段 — 无破坏性变更 |
|
||||
| **向后兼容** | 对公共 API 极好 | 好(新字段获得零值) |
|
||||
| **默认值** | 内置于构造函数 | 零值或 `DefaultConfig()` |
|
||||
| **校验** | 在 `apply` 或构造函数循环中 | 在接收到结构体后的构造函数中 |
|
||||
| **可发现性** | `With*` 函数出现在 godoc 中 | 所有字段在一个结构体中可见 |
|
||||
| **可测试性** | 比较选项或测试构造函数输出 | 比较结构体字面量 |
|
||||
| **调用者体验** | 只指定与默认值不同的部分 | 必须构造结构体字面量 |
|
||||
| **零值歧义** | 无 — 未设置的选项不应用 | 可能需要指针字段 |
|
||||
|
||||
## 何时优先使用配置结构体
|
||||
|
||||
- **内部 API** — 更少的仪式,在调用处更易读
|
||||
- **少量选项(1-3 个)** — 函数式选项的开销不值得
|
||||
- **所有选项通常一起设置** — 可变参数风格没有好处
|
||||
- **不需要校验** — 简单的字段赋值即可
|
||||
- **选项是数据而非行为** — 结构体字段自然映射
|
||||
|
||||
```go
|
||||
srv := NewServer(Config{
|
||||
Port: 8080,
|
||||
TLSCert: "/path/to/cert.pem",
|
||||
TLSKey: "/path/to/key.pem",
|
||||
})
|
||||
```
|
||||
|
||||
## 何时优先使用函数式选项
|
||||
|
||||
- **公共/库 API** — 调用者不应跟踪内部配置的演变
|
||||
- **3 个以上选项**,每个都是可选的
|
||||
- **复杂默认值** — 默认值计算依赖于其他选项
|
||||
- **按选项校验** — 在 apply 时拒绝无效值
|
||||
- **选项可能增长** — 新的 `With*` 函数是纯粹增量的
|
||||
|
||||
```go
|
||||
srv := NewServer(
|
||||
WithPort(8080),
|
||||
WithTLS("/path/to/cert.pem", "/path/to/key.pem"),
|
||||
WithLogger(logger),
|
||||
)
|
||||
```
|
||||
|
||||
## 混合方法
|
||||
|
||||
对于同时需要便利性和可扩展性的 API,接受配置结构体用于常见设置,函数式选项用于高级覆盖:
|
||||
|
||||
```go
|
||||
func NewServer(cfg Config, opts ...Option) *Server {
|
||||
s := &Server{cfg: cfg}
|
||||
for _, o := range opts {
|
||||
o.apply(&s.cfg)
|
||||
}
|
||||
return s
|
||||
}
|
||||
```
|
||||
|
||||
谨慎使用 — 它增加了复杂性。每个 API 优先使用一种方法。
|
||||
@@ -1,107 +0,0 @@
|
||||
---
|
||||
name: go-functions
|
||||
description: Use when organizing functions within a Go file, formatting function signatures, designing return values, or following Printf-style naming conventions. Also use when a user is adding or refactoring any Go function, even if they don't mention function design or signature formatting. Does not cover functional options constructors (see go-functional-options).
|
||||
license: Apache-2.0
|
||||
metadata:
|
||||
sources: "Effective Go, Google Style Guide, Uber Style Guide"
|
||||
---
|
||||
|
||||
# Go 函数设计
|
||||
|
||||
> **本技能不适用的场景**:对于函数选项构造函数(`WithTimeout`、`WithLogger`),参见 [go-functional-options](../go-functional-options/SKILL.md)。对于错误返回约定,参见 [go-error-handling](../go-error-handling/SKILL.md)。对于函数和方法的命名,参见 [go-naming](../go-naming/SKILL.md)。
|
||||
|
||||
---
|
||||
|
||||
## 函数分组与排序
|
||||
|
||||
按以下规则组织文件中的函数:
|
||||
|
||||
1. 函数按**大致调用顺序**排序
|
||||
2. 函数**按接收者分组**
|
||||
3. **导出**函数排在最前面,位于 `struct`/`const`/`var` 定义之后
|
||||
4. `NewXxx`/`newXxx` 构造函数紧跟在类型定义之后
|
||||
5. 普通工具函数排在文件末尾
|
||||
|
||||
```go
|
||||
type something struct{ ... }
|
||||
|
||||
func newSomething() *something { return &something{} }
|
||||
|
||||
func (s *something) Cost() int { return calcCost(s.weights) }
|
||||
|
||||
func (s *something) Stop() { ... }
|
||||
|
||||
func calcCost(n []int) int { ... }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 函数签名
|
||||
|
||||
> 在格式化多行签名、包装返回值、缩短调用点或用自定义类型替换裸 bool 参数时,阅读 [references/SIGNATURES.md](references/SIGNATURES.md)。
|
||||
|
||||
尽量将签名保持在一行内。当必须换行时,将**所有参数放在各自的行上**并加尾随逗号:
|
||||
|
||||
```go
|
||||
func (r *SomeType) SomeLongFunctionName(
|
||||
foo1, foo2, foo3 string,
|
||||
foo4, foo5, foo6 int,
|
||||
) {
|
||||
foo7 := bar(foo1)
|
||||
}
|
||||
```
|
||||
|
||||
为含义不明确的参数添加 `/* name */` 注释,或者更好的做法是用自定义类型替换裸 `bool` 参数。
|
||||
|
||||
---
|
||||
|
||||
## 接口指针
|
||||
|
||||
几乎不需要指向接口的指针。将接口作为值传递——底层数据仍然可以是指针。
|
||||
|
||||
```go
|
||||
// 不好:接口指针
|
||||
func process(r *io.Reader) { ... }
|
||||
|
||||
// 好:传递接口值
|
||||
func process(r io.Reader) { ... }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Printf 与 Stringer
|
||||
|
||||
> 在使用 %v/%s/%d 之外的 Printf 动词、实现 fmt.Stringer 或 fmt.GoStringer、编写自定义 Format() 方法或调试 String() 方法中的无限递归时,阅读 [references/PRINTF-STRINGER.md](references/PRINTF-STRINGER.md)。
|
||||
|
||||
### Printf 风格函数名
|
||||
|
||||
接受格式字符串的函数应以 `f` 结尾,以便 `go vet` 支持。在 `Printf` 调用之外使用格式字符串时,将其声明为 `const`。
|
||||
|
||||
在格式化日志或错误消息中的字符串时,优先使用 `%q` 而非手动加引号的 `%s`——它能安全地转义特殊字符并加上引号:
|
||||
|
||||
```go
|
||||
return fmt.Errorf("unknown key %q", key) // 输出:unknown key "foo\nbar"
|
||||
```
|
||||
|
||||
设计具有 3 个以上可选参数的构造函数时,参见 **go-functional-options**。
|
||||
|
||||
---
|
||||
|
||||
## 快速参考
|
||||
|
||||
| 主题 | 规则 |
|
||||
|------|------|
|
||||
| 文件排序 | 类型 -> 构造函数 -> 导出 -> 未导出 -> 工具函数 |
|
||||
| 签名换行 | 所有参数各占一行,加尾随逗号 |
|
||||
| 裸参数 | 添加 `/* name */` 注释或使用自定义类型 |
|
||||
| 接口指针 | 几乎不需要;按值传递接口 |
|
||||
| Printf 函数名 | 以 `f` 结尾以支持 `go vet` |
|
||||
|
||||
---
|
||||
|
||||
## 相关技能
|
||||
|
||||
- **错误返回**:在设计错误返回模式或在多返回值函数中包装错误时,参见 [go-error-handling](../go-error-handling/SKILL.md)
|
||||
- **命名约定**:在为函数、方法命名或选择 getter/setter 模式时,参见 [go-naming](../go-naming/SKILL.md)
|
||||
- **函数选项**:在设计具有 3 个以上可选参数的构造函数时,参见 [go-functional-options](../go-functional-options/SKILL.md)
|
||||
- **格式化原则**:在决定行长度、裸返回或签名格式时,参见 [go-style-core](../go-style-core/SKILL.md)
|
||||
@@ -1,264 +0,0 @@
|
||||
# Printf、Stringer 与自定义格式化
|
||||
|
||||
Go 的 `fmt` 打印动词、`Stringer` 和 `GoStringer` 接口、自定义 `Format()` 方法以及常见陷阱的深度参考。
|
||||
|
||||
---
|
||||
|
||||
## Printf 动词
|
||||
|
||||
### 通用动词
|
||||
|
||||
| 动词 | 用途 |
|
||||
|------|------|
|
||||
| `%v` | 默认格式(结构体字段、切片元素) |
|
||||
| `%+v` | 带字段名的结构体:`{Name:alice Age:30}` |
|
||||
| `%#v` | Go 语法表示:`main.User{Name:"alice", Age:30}` |
|
||||
| `%T` | 值的类型:`main.User` |
|
||||
| `%%` | 字面百分号 |
|
||||
|
||||
### 字符串与字节动词
|
||||
|
||||
| 动词 | 用途 |
|
||||
|------|------|
|
||||
| `%s` | 纯字符串或字节切片 |
|
||||
| `%q` | 带 Go 语法转义的引号字符串:`"hello\n"` |
|
||||
| `%x` | 十六进制编码,小写:`68656c6c6f` |
|
||||
| `%X` | 十六进制编码,大写:`68656C6C6F` |
|
||||
|
||||
### 整数动词
|
||||
|
||||
| 动词 | 用途 |
|
||||
|------|------|
|
||||
| `%d` | 十进制整数 |
|
||||
| `%b` | 二进制 |
|
||||
| `%o` | 八进制 |
|
||||
| `%O` | 带 `0o` 前缀的八进制 |
|
||||
| `%x` | 十六进制,小写 |
|
||||
| `%X` | 十六进制,大写 |
|
||||
|
||||
### 浮点数动词
|
||||
|
||||
| 动词 | 用途 |
|
||||
|------|------|
|
||||
| `%f` | 小数点,无指数:`123.456` |
|
||||
| `%e` | 科学计数法:`1.23456e+02` |
|
||||
| `%g` | 紧凑格式:大指数用 `%e`,否则用 `%f` |
|
||||
|
||||
### 宽度与精度
|
||||
|
||||
```go
|
||||
fmt.Sprintf("%10d", 42) // " 42" (宽度 10,右对齐)
|
||||
fmt.Sprintf("%-10d", 42) // "42 " (宽度 10,左对齐)
|
||||
fmt.Sprintf("%.2f", 3.14159) // "3.14" (2 位小数)
|
||||
fmt.Sprintf("%010d", 42) // "0000000042" (零填充)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 使用 `%q` 输出字符串
|
||||
|
||||
`%q` 动词在双引号内打印字符串,使空字符串和控制字符可见:
|
||||
|
||||
```go
|
||||
fmt.Printf("value %q looks like English text", someText)
|
||||
|
||||
// 不好:手动添加引号
|
||||
fmt.Printf("value \"%s\" looks like English text", someText)
|
||||
```
|
||||
|
||||
在面向人类的输出中,如果值可能为空或包含控制字符,优先使用 `%q`。
|
||||
|
||||
---
|
||||
|
||||
## Printf 之外的格式字符串
|
||||
|
||||
在 `Printf` 风格调用之外声明格式字符串时,使用 `const`。这样 `go vet` 可以进行静态分析:
|
||||
|
||||
```go
|
||||
// 不好:变量格式字符串——go vet 无法检查
|
||||
msg := "unexpected values %v, %v\n"
|
||||
fmt.Printf(msg, 1, 2)
|
||||
|
||||
// 好:常量格式字符串——go vet 可以验证
|
||||
const msg = "unexpected values %v, %v\n"
|
||||
fmt.Printf(msg, 1, 2)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Printf 风格函数的命名
|
||||
|
||||
接受格式字符串的函数应以 `f` 结尾。这样 `go vet` 可以自动检查格式字符串:
|
||||
|
||||
```go
|
||||
func Wrapf(err error, format string, args ...any) error
|
||||
```
|
||||
|
||||
如果使用非标准名称,需要告知 `go vet`:
|
||||
|
||||
```bash
|
||||
go vet -printfuncs=wrapf,statusf
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `fmt.Stringer` 接口
|
||||
|
||||
实现 `fmt.Stringer` 来控制类型在 `%v` 和 `%s` 下的显示方式:
|
||||
|
||||
```go
|
||||
type fmt.Stringer interface {
|
||||
String() string
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
type Point struct{ X, Y int }
|
||||
|
||||
func (p Point) String() string {
|
||||
return fmt.Sprintf("(%d, %d)", p.X, p.Y)
|
||||
}
|
||||
|
||||
// fmt.Println(Point{1, 2}) → "(1, 2)"
|
||||
// fmt.Sprintf("point: %v", p) → "point: (1, 2)"
|
||||
// fmt.Sprintf("point: %s", p) → "point: (1, 2)"
|
||||
```
|
||||
|
||||
### 何时实现 Stringer
|
||||
|
||||
- 类型将出现在日志消息或面向用户的输出中
|
||||
- 默认的 `%v` 输出(仅字段值)不够有意义
|
||||
- 需要一种区别于序列化的、对人类友好的表示
|
||||
|
||||
---
|
||||
|
||||
## `fmt.GoStringer` 接口
|
||||
|
||||
实现 `fmt.GoStringer` 来控制 `%#v` 输出。这对于默认 Go 语法表示具有误导性或过于冗长的类型很有用:
|
||||
|
||||
```go
|
||||
type fmt.GoStringer interface {
|
||||
GoString() string
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
type Color struct{ R, G, B uint8 }
|
||||
|
||||
func (c Color) GoString() string {
|
||||
return fmt.Sprintf("Color(%#02x, %#02x, %#02x)", c.R, c.G, c.B)
|
||||
}
|
||||
|
||||
// fmt.Sprintf("%#v", Color{255, 128, 0})
|
||||
// → "Color(0xff, 0x80, 0x00)" 而非 "main.Color{R:0xff, G:0x80, B:0x00}"
|
||||
```
|
||||
|
||||
`GoString()` 的输出应该是有效的 Go 语法或接近有效语法——它用于调试,而非面向用户的显示。
|
||||
|
||||
---
|
||||
|
||||
## 使用 `fmt.Formatter` 自定义格式化
|
||||
|
||||
要完全控制所有格式动词,实现 `fmt.Formatter`:
|
||||
|
||||
```go
|
||||
type fmt.Formatter interface {
|
||||
Format(f fmt.State, verb rune)
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
type Point struct{ X, Y int }
|
||||
|
||||
func (p Point) Format(f fmt.State, verb rune) {
|
||||
switch verb {
|
||||
case 'v':
|
||||
if f.Flag('#') {
|
||||
// %#v——Go 语法表示
|
||||
fmt.Fprintf(f, "Point{X: %d, Y: %d}", p.X, p.Y)
|
||||
return
|
||||
}
|
||||
if f.Flag('+') {
|
||||
// %+v——带字段名的详细格式
|
||||
fmt.Fprintf(f, "X:%d Y:%d", p.X, p.Y)
|
||||
return
|
||||
}
|
||||
// %v——默认
|
||||
fmt.Fprintf(f, "(%d, %d)", p.X, p.Y)
|
||||
case 's':
|
||||
fmt.Fprintf(f, "(%d, %d)", p.X, p.Y)
|
||||
case 'q':
|
||||
fmt.Fprintf(f, "%q", p.String())
|
||||
default:
|
||||
fmt.Fprintf(f, "%%!%c(Point=%d,%d)", verb, p.X, p.Y)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `fmt.State` 方法
|
||||
|
||||
| 方法 | 返回值 |
|
||||
|------|--------|
|
||||
| `Flag(c int) bool` | 标志(`+`、`-`、`#`、`0`、` `)是否设置 |
|
||||
| `Width() (int, bool)` | 宽度值以及是否指定了宽度 |
|
||||
| `Precision() (int, bool)` | 精度值以及是否指定了精度 |
|
||||
| `Write(b []byte) (int, error)` | 写入输出字节 |
|
||||
|
||||
仅在 `String()` 不够用时才实现 `fmt.Formatter`——很少需要这样做。常见原因:需要为 `%v`、`%+v`、`%#v` 提供不同输出,或者需要遵循宽度/精度标志。
|
||||
|
||||
---
|
||||
|
||||
## 无限递归陷阱
|
||||
|
||||
**在 `String()` 方法内部对接收者使用 `%s` 或 `%v` 调用 `fmt.Sprintf` 会导致无限递归:**
|
||||
|
||||
```go
|
||||
type MyString string
|
||||
|
||||
// BUG:无限递归——Sprintf 调用 String(),String() 又调用 Sprintf...
|
||||
func (m MyString) String() string {
|
||||
return fmt.Sprintf("MyString: %s", m) // 崩溃:栈溢出
|
||||
}
|
||||
```
|
||||
|
||||
修复方法——将接收者转换为其底层类型以打破方法集:
|
||||
|
||||
```go
|
||||
func (m MyString) String() string {
|
||||
return fmt.Sprintf("MyString: %s", string(m)) // 安全:string 没有 String()
|
||||
}
|
||||
```
|
||||
|
||||
此陷阱还适用于:
|
||||
- 底层类型为 string、[]byte 或另一个 Stringer 的类型
|
||||
- 任何使用 `%s` 或 `%v` 格式化 `self` 的 `String()` 方法
|
||||
- 使用 `%#v` 格式化 `self` 的 `GoString()` 方法
|
||||
|
||||
```go
|
||||
type IPAddr [4]byte
|
||||
|
||||
// BUG:%v 调用 String(),无限递归
|
||||
func (ip IPAddr) String() string {
|
||||
return fmt.Sprintf("%v.%v.%v.%v", ip[0], ip[1], ip[2], ip[3])
|
||||
// 这里安全——ip[0] 是 byte(uint8),没有 String() 方法。
|
||||
// 但如果 ip 是一个包装了 Stringer 的命名类型,就会递归。
|
||||
}
|
||||
```
|
||||
|
||||
**经验法则**:在 `String()` 内部,永远不要将接收者(或重新转换为自身类型的接收者)传递给 `%s` 或 `%v` 动词。先转换为底层原始类型。
|
||||
|
||||
---
|
||||
|
||||
## 快速参考
|
||||
|
||||
| 主题 | 规则 |
|
||||
|------|------|
|
||||
| `%q` | 用于人类可读的字符串输出 |
|
||||
| `%+v` | 带字段名的结构体 |
|
||||
| `%#v` | Go 语法表示;通过 `GoStringer` 自定义 |
|
||||
| 格式字符串存储 | 在 Printf 调用之外声明为 `const` |
|
||||
| Printf 函数名 | 以 `f` 结尾以支持 `go vet` |
|
||||
| `Stringer` | 实现 `String() string` 用于 `%v`/`%s` 输出 |
|
||||
| `GoStringer` | 实现 `GoString() string` 用于 `%#v` 输出 |
|
||||
| `Formatter` | 实现 `Format(fmt.State, rune)` 以完全控制动词 |
|
||||
| 递归陷阱 | 永远不要在 `String()` 内部使用 `Sprintf("%s", receiver)`;转换为底层类型 |
|
||||
@@ -1,168 +0,0 @@
|
||||
# 函数签名
|
||||
|
||||
格式化 Go 函数签名、避免裸参数以及保持调用点可读性的详细规则。
|
||||
|
||||
---
|
||||
|
||||
## 单行 vs 多行
|
||||
|
||||
当签名能轻松放在一行时保持单行。当必须换行时,将**所有参数放在各自的行上**并加尾随逗号:
|
||||
|
||||
**不好**——部分换行使对齐变得脆弱:
|
||||
|
||||
```go
|
||||
func (r *SomeType) SomeLongFunctionName(foo1, foo2, foo3 string,
|
||||
foo4, foo5, foo6 int) {
|
||||
foo7 := bar(foo1)
|
||||
}
|
||||
```
|
||||
|
||||
**好**——完全换行,尾随逗号:
|
||||
|
||||
```go
|
||||
func (r *SomeType) SomeLongFunctionName(
|
||||
foo1, foo2, foo3 string,
|
||||
foo4, foo5, foo6 int,
|
||||
) {
|
||||
foo7 := bar(foo1)
|
||||
}
|
||||
```
|
||||
|
||||
### 返回值
|
||||
|
||||
当返回值也需要换行时,遵循相同的模式:
|
||||
|
||||
```go
|
||||
func (r *SomeType) LongName(
|
||||
foo1, foo2, foo3 string,
|
||||
foo4, foo5, foo6 int,
|
||||
) (
|
||||
*Result,
|
||||
error,
|
||||
) {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
对于更简单的情况,命名返回值可以与参数右括号在同一行:
|
||||
|
||||
```go
|
||||
func (r *SomeType) LongName(
|
||||
foo1, foo2, foo3 string,
|
||||
) (result *Result, err error) {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 缩短调用点
|
||||
|
||||
提取局部变量,而不是将函数调用拆分到多行:
|
||||
|
||||
```go
|
||||
// 不好:过长的内联调用
|
||||
result := foo.Call(
|
||||
somePackage.ComplexFunction(arg1, arg2),
|
||||
anotherPackage.Transform(data),
|
||||
defaultOptions,
|
||||
)
|
||||
|
||||
// 好:提取局部变量以提高清晰度
|
||||
transformed := anotherPackage.Transform(data)
|
||||
computed := somePackage.ComplexFunction(arg1, arg2)
|
||||
result := foo.Call(computed, transformed, defaultOptions)
|
||||
```
|
||||
|
||||
这提高了可读性,并使中间值可用于调试。
|
||||
|
||||
---
|
||||
|
||||
## 避免裸参数
|
||||
|
||||
函数调用中的裸参数会降低可读性。为含义不明确的参数添加 C 风格注释:
|
||||
|
||||
```go
|
||||
// 不好:这些布尔值是什么意思?
|
||||
printInfo("foo", true, true)
|
||||
|
||||
// 好:内联注释说明了意图
|
||||
printInfo("foo", true /* isLocal */, true /* done */)
|
||||
```
|
||||
|
||||
更好的做法是用自定义类型替换裸 `bool` 参数:
|
||||
|
||||
```go
|
||||
type Region int
|
||||
|
||||
const (
|
||||
UnknownRegion Region = iota
|
||||
Local
|
||||
)
|
||||
|
||||
type Status int
|
||||
|
||||
const (
|
||||
Pending Status = iota
|
||||
Done
|
||||
)
|
||||
|
||||
func printInfo(name string, region Region, status Status)
|
||||
```
|
||||
|
||||
### 何时使用每种方法
|
||||
|
||||
| 方法 | 时机 |
|
||||
|------|------|
|
||||
| C 风格注释 | 快速修复;调用点少;无法修改的第三方 API |
|
||||
| 自定义类型 | 多个调用点;公开 API;多个 bool/int 参数 |
|
||||
| 函数选项 | 3 个以上可选参数;参见 [go-functional-options](../../go-functional-options/SKILL.md) |
|
||||
|
||||
---
|
||||
|
||||
## 分组相关参数
|
||||
|
||||
当函数接受多个相同类型的参数时,将它们分组:
|
||||
|
||||
```go
|
||||
// 可接受:将同类型参数分组
|
||||
func Copy(dst, src string) error
|
||||
|
||||
// 可接受:尽管类型相同,但含义不同时分开声明
|
||||
func Move(source string, destination string) error
|
||||
```
|
||||
|
||||
当参数名称能清楚表明角色时使用分组;当不能清楚表明时使用分开声明。
|
||||
|
||||
---
|
||||
|
||||
## 方法接收者的位置
|
||||
|
||||
接收者放在函数名之前,格式类似于参数:
|
||||
|
||||
```go
|
||||
// 短接收者——放在同一行
|
||||
func (s *Server) Start(ctx context.Context) error { ... }
|
||||
|
||||
// 长接收者类型——如果整行过长则考虑换行
|
||||
func (h *ComplicatedHandler) ServeHTTP(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
) { ... }
|
||||
```
|
||||
|
||||
参见 [go-naming](../../go-naming/SKILL.md) 了解接收者命名约定(简短的一到两个字母缩写)。
|
||||
|
||||
---
|
||||
|
||||
## 快速参考
|
||||
|
||||
| 主题 | 规则 |
|
||||
|------|------|
|
||||
| 单行 | 能放下时保持一行 |
|
||||
| 多行 | 所有参数各占一行,尾随逗号 |
|
||||
| 返回值换行 | 与参数相同的模式 |
|
||||
| 调用点 | 提取局部变量而不是拆分调用 |
|
||||
| 裸 bool | 添加 `/* name */` 注释或使用自定义类型 |
|
||||
| 分组参数 | 当名称能清楚表明角色时将同类型分组 |
|
||||
| 接收者 | 在函数名之前;简短缩写 |
|
||||
@@ -1,173 +0,0 @@
|
||||
---
|
||||
name: go-generics
|
||||
description: Use when deciding whether to use Go generics, writing generic functions or types, choosing constraints, or picking between type aliases and type definitions. Also use when a user is writing a utility function that could work with multiple types, even if they don't mention generics explicitly. Does not cover interface design without generics (see go-interfaces).
|
||||
license: Apache-2.0
|
||||
compatibility: Requires Go 1.18+ (generics were introduced in Go 1.18)
|
||||
metadata:
|
||||
sources: "Google Style Guide"
|
||||
---
|
||||
|
||||
# Go 泛型与类型参数
|
||||
|
||||
---
|
||||
|
||||
## 何时使用泛型
|
||||
|
||||
从具体类型开始。只在出现第二种类型时才进行泛化。
|
||||
|
||||
### 优先使用泛型的场景
|
||||
|
||||
- 多种类型共享相同的逻辑(排序、过滤、map/reduce)
|
||||
- 否则需要依赖 `any` 和大量的类型切换
|
||||
- 正在构建可复用的数据结构(并发安全的集合、有序映射)
|
||||
|
||||
### 避免使用泛型的场景
|
||||
|
||||
- 实践中只有一种类型被实例化
|
||||
- 接口已经能清晰地表达共享行为
|
||||
- 泛型代码比特定类型的替代方案更难阅读
|
||||
|
||||
> "写代码,不要设计类型。"—— Robert Griesemer 和 Ian Lance Taylor
|
||||
|
||||
### 决策流程
|
||||
|
||||
```
|
||||
多种类型是否共享相同的逻辑?
|
||||
├─ 否 → 使用具体类型
|
||||
├─ 是 → 它们是否共享一个有用的接口?
|
||||
│ ├─ 是 → 使用接口
|
||||
│ └─ 否 → 使用泛型
|
||||
```
|
||||
|
||||
**不好:**
|
||||
|
||||
```go
|
||||
// 过早使用泛型:只会被 int 调用
|
||||
func Sum[T constraints.Integer | constraints.Float](vals []T) T {
|
||||
var total T
|
||||
for _, v := range vals {
|
||||
total += v
|
||||
}
|
||||
return total
|
||||
}
|
||||
```
|
||||
|
||||
**好:**
|
||||
|
||||
```go
|
||||
func SumInts(vals []int) int {
|
||||
var total int
|
||||
for _, v := range vals {
|
||||
total += v
|
||||
}
|
||||
return total
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 类型参数命名
|
||||
|
||||
| 名称 | 典型用途 |
|
||||
|------|----------|
|
||||
| `T` | 通用类型参数 |
|
||||
| `K` | 映射键类型 |
|
||||
| `V` | 映射值类型 |
|
||||
| `E` | 元素/项目类型 |
|
||||
|
||||
对于复杂约束,可以使用简短的描述性名称:
|
||||
|
||||
```go
|
||||
func Marshal[Opts encoding.MarshalOptions](v any, opts Opts) ([]byte, error)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 类型别名 vs 类型定义
|
||||
|
||||
类型别名(`type Old = new.Name`)很少使用——仅用于包迁移或渐进式 API 重构。
|
||||
|
||||
---
|
||||
|
||||
## 约束组合
|
||||
|
||||
使用 `~`(底层类型)和 `|`(联合)组合约束:
|
||||
|
||||
```go
|
||||
type Numeric interface {
|
||||
~int | ~int8 | ~int16 | ~int32 | ~int64 |
|
||||
~float32 | ~float64
|
||||
}
|
||||
|
||||
func Sum[T Numeric](vals []T) T {
|
||||
var total T
|
||||
for _, v := range vals {
|
||||
total += v
|
||||
}
|
||||
return total
|
||||
}
|
||||
```
|
||||
|
||||
使用 `constraints` 包或 `cmp` 包(Go 1.21+)中的标准约束如 `cmp.Ordered`,而不是自己编写。
|
||||
|
||||
> 在编写自定义类型约束、使用 ~ 和 | 组合约束或调试类型推断问题时,阅读 [references/CONSTRAINTS.md](references/CONSTRAINTS.md)。
|
||||
|
||||
---
|
||||
|
||||
## 常见陷阱
|
||||
|
||||
### 不要包装标准库类型
|
||||
|
||||
```go
|
||||
// 不好:泛型包装器增加了复杂度但没有价值
|
||||
type Set[T comparable] struct {
|
||||
m map[T]struct{}
|
||||
}
|
||||
|
||||
// 更好:当用法简单时直接使用 map[T]struct{}
|
||||
seen := map[string]struct{}{}
|
||||
```
|
||||
|
||||
泛型在消除**多个调用点**之间的重复时才能证明其复杂度的合理性。单次使用的泛型只是多余的间接层。
|
||||
|
||||
### 不要为接口满足而使用泛型
|
||||
|
||||
```go
|
||||
// 不好:T 仅用于满足接口——直接使用接口即可
|
||||
func Process[T io.Reader](r T) error { ... }
|
||||
|
||||
// 好:直接接受接口
|
||||
func Process(r io.Reader) error { ... }
|
||||
```
|
||||
|
||||
### 避免过度约束
|
||||
|
||||
```go
|
||||
// 不好:约束比需要的更严格
|
||||
func Contains[T interface{ ~int | ~string }](slice []T, target T) bool { ... }
|
||||
|
||||
// 好:comparable 就足够了
|
||||
func Contains[T comparable](slice []T, target T) bool { ... }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 快速参考
|
||||
|
||||
| 主题 | 指导 |
|
||||
|------|------|
|
||||
| 何时使用泛型 | 仅在多种类型共享相同逻辑且接口不够用时 |
|
||||
| 起点 | 先写具体代码;之后再泛化 |
|
||||
| 命名 | 单个大写字母(`T`、`K`、`V`、`E`) |
|
||||
| 类型别名 | 相同类型,替代名称;仅用于迁移 |
|
||||
| 约束组合 | 使用 `~` 表示底层类型,`|` 表示联合;优先使用 `cmp.Ordered` 而非自定义 |
|
||||
| 常见陷阱 | 不要对单次使用的代码或接口已足够时使用泛型 |
|
||||
|
||||
---
|
||||
|
||||
## 相关技能
|
||||
|
||||
- **接口 vs 泛型**:在决定接口是否已经能表达共享行为而无需泛型时,参见 [go-interfaces](../go-interfaces/SKILL.md)
|
||||
- **类型声明**:在定义新类型、类型别名或在类型定义和别名之间选择时,参见 [go-declarations](../go-declarations/SKILL.md)
|
||||
- **文档化泛型 API**:在为泛型函数编写文档注释和可运行示例时,参见 [go-documentation](../go-documentation/SKILL.md)
|
||||
- **命名类型参数**:在为类型参数或约束接口选择名称时,参见 [go-naming](../go-naming/SKILL.md)
|
||||
@@ -1,169 +0,0 @@
|
||||
# Go 泛型中的类型约束
|
||||
|
||||
> **来源**:Google Go 风格指南、Go 语言规范
|
||||
|
||||
约束定义了类型参数支持的操作。选择满足函数需求的最窄约束——不要更多。
|
||||
|
||||
---
|
||||
|
||||
## 内置约束
|
||||
|
||||
> **规范**:在自行编写约束之前,优先使用标准约束。
|
||||
|
||||
| 约束 | 含义 |
|
||||
|------|------|
|
||||
| `any` | `interface{}` 的别名;对类型没有要求 |
|
||||
| `comparable` | 支持 `==` 和 `!=`;映射键所必需 |
|
||||
| `cmp.Ordered` | 支持 `<`、`<=`、`>=`、`>`(Go 1.21+,替代 `constraints.Ordered`) |
|
||||
|
||||
在新代码中优先使用 `cmp.Ordered`(来自 `cmp` 包),而不是已弃用的 `golang.org/x/exp/constraints.Ordered`。
|
||||
|
||||
---
|
||||
|
||||
## `~` 运算符(底层类型)
|
||||
|
||||
> **建议**:当你想接受基于原始类型构建的命名类型时使用 `~`。
|
||||
|
||||
`~T` 语法匹配任何**底层类型**为 `T` 的类型。没有 `~` 时,只有精确的类型匹配。
|
||||
|
||||
```go
|
||||
type Celsius float64
|
||||
|
||||
type ExactFloat interface{ float64 } // 拒绝 Celsius
|
||||
type AnyFloat64 interface{ ~float64 } // 接受 Celsius
|
||||
```
|
||||
|
||||
当调用者可能基于基础类型定义命名类型时使用 `~`。仅在需要限制为精确的内置类型时才省略 `~`。
|
||||
|
||||
---
|
||||
|
||||
## 组合与编写约束
|
||||
|
||||
> **建议**:仅在没有标准约束适用时才定义自定义约束。
|
||||
|
||||
使用 `|` 组合类型并嵌入约束来组合它们:
|
||||
|
||||
```go
|
||||
type Numeric interface {
|
||||
~int | ~int8 | ~int16 | ~int32 | ~int64 |
|
||||
~float32 | ~float64
|
||||
}
|
||||
|
||||
type Addable interface {
|
||||
Numeric | ~string // 数字和字符串拼接
|
||||
}
|
||||
```
|
||||
|
||||
约束可以同时要求方法和类型元素:
|
||||
|
||||
```go
|
||||
type Stringer interface {
|
||||
comparable
|
||||
String() string
|
||||
}
|
||||
```
|
||||
|
||||
满足 `Stringer` 的类型必须是可比较的 **并且** 具有 `String()` 方法。
|
||||
|
||||
---
|
||||
|
||||
## 避免过度约束
|
||||
|
||||
> **规范**:使用支持所执行操作的最小约束。
|
||||
|
||||
**不好**
|
||||
```go
|
||||
// 只使用了 == 但限制为 int 和 string
|
||||
func Contains[T interface{ ~int | ~string }](s []T, v T) bool { ... }
|
||||
```
|
||||
|
||||
**好**
|
||||
```go
|
||||
// comparable 是 == 的最小约束
|
||||
func Contains[T comparable](s []T, v T) bool { ... }
|
||||
```
|
||||
|
||||
过度约束限制了复用,并迫使调用者绕过实现中根本不需要的限制。
|
||||
|
||||
## 类型推断
|
||||
|
||||
> **建议**:当类型明确时让编译器推断类型参数。
|
||||
|
||||
编译器从函数参数推断类型参数:
|
||||
|
||||
```go
|
||||
result := slices.Contains[string](names, "alice") // 显式——不必要
|
||||
result := slices.Contains(names, "alice") // 推断——推荐
|
||||
```
|
||||
|
||||
仅在以下情况下才显式提供类型参数:没有可用于推断的函数参数、推断的类型不正确(例如无类型常量提升为错误的类型),或者将类型显式展示出来有助于可读性。
|
||||
|
||||
---
|
||||
|
||||
## 常见陷阱
|
||||
|
||||
### 接口已足够时不要使用泛型
|
||||
|
||||
> **规范**:来自 Google 风格指南——当类型共享一个有用的统一接口时,优先使用接口。
|
||||
|
||||
**不好**
|
||||
```go
|
||||
// T 仅用于满足 io.Reader——直接使用接口即可
|
||||
func Process[T io.Reader](r T) error { ... }
|
||||
```
|
||||
|
||||
**好**
|
||||
```go
|
||||
func Process(r io.Reader) error { ... }
|
||||
```
|
||||
|
||||
如果约束是单个已有接口,直接接受该接口。
|
||||
|
||||
### 不要泛型地包装标准库类型
|
||||
|
||||
> **建议**:单次使用的泛型只是多余的间接层。
|
||||
|
||||
**不好**
|
||||
```go
|
||||
type Set[T comparable] struct{ m map[T]struct{} } // 永远只是 Set[string]
|
||||
```
|
||||
|
||||
**好**
|
||||
```go
|
||||
seen := map[string]struct{}{} // 对于单次实例化直接使用 map
|
||||
```
|
||||
|
||||
泛型在消除**多个调用点**之间的重复时才能证明其复杂度的合理性。如果只使用一种类型,从具体类型开始。
|
||||
|
||||
### 方法集与类型约束
|
||||
|
||||
你只能调用约束允许的操作:
|
||||
|
||||
**不好**
|
||||
```go
|
||||
func Stringify[T any](v T) string {
|
||||
return v.String() // 编译错误:any 没有 String()
|
||||
}
|
||||
```
|
||||
|
||||
**好**
|
||||
```go
|
||||
func Stringify[T fmt.Stringer](v T) string {
|
||||
return v.String()
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 快速参考
|
||||
|
||||
| 主题 | 指导 |
|
||||
|------|------|
|
||||
| 默认约束 | `any`——不需要对 T 进行任何操作时使用 |
|
||||
| 相等性检查 | `comparable`——`==`、`!=` 和映射键所必需 |
|
||||
| 排序 | `cmp.Ordered`(Go 1.21+)用于 `<`、`>` 比较 |
|
||||
| 命名类型 | 使用 `~T` 接受底层类型为 T 的类型 |
|
||||
| 联合类型 | 使用 `\|` 组合——例如 `~int \| ~float64` |
|
||||
| 自定义约束 | 定义为包含类型元素和/或方法的接口 |
|
||||
| 类型推断 | 当编译器可以推断时省略类型参数 |
|
||||
| 最小约束 | 使用函数实际需要的最窄约束 |
|
||||
@@ -1,151 +0,0 @@
|
||||
---
|
||||
name: go-interfaces
|
||||
description: Use when defining or implementing Go interfaces, designing abstractions, creating mockable boundaries for testing, or composing types through embedding. Also use when deciding whether to accept an interface or return a concrete type, or using type assertions or type switches, even if the user doesn't explicitly mention interfaces. Does not cover generics-based polymorphism (see go-generics).
|
||||
license: Apache-2.0
|
||||
metadata:
|
||||
sources: "Effective Go, Google Style Guide, Uber Style Guide"
|
||||
allowed-tools: Bash(bash:*)
|
||||
---
|
||||
|
||||
# Go 接口与组合
|
||||
|
||||
## 可用脚本
|
||||
|
||||
- **`scripts/check-interface-compliance.sh`**——查找缺少编译时合规性检查(`var _ I = (*T)(nil)`)的导出接口。运行 `bash scripts/check-interface-compliance.sh --help` 查看选项。
|
||||
|
||||
---
|
||||
|
||||
## 接受接口,返回具体类型
|
||||
|
||||
接口属于**消费**值的包,而不是**实现**值的包。从构造函数返回具体类型(通常是指针或结构体),这样可以在不重构的情况下添加新方法。
|
||||
|
||||
```go
|
||||
// 好:消费者定义自己需要的接口
|
||||
package consumer
|
||||
|
||||
type Thinger interface { Thing() bool }
|
||||
|
||||
func Foo(t Thinger) string { ... }
|
||||
```
|
||||
|
||||
```go
|
||||
// 好:生产者返回具体类型
|
||||
package producer
|
||||
|
||||
type Thinger struct{ ... }
|
||||
func (t Thinger) Thing() bool { ... }
|
||||
func NewThinger() Thinger { return Thinger{ ... } }
|
||||
```
|
||||
|
||||
```go
|
||||
// 不好:生产者定义并返回自己的接口
|
||||
package producer
|
||||
|
||||
type Thinger interface { Thing() bool }
|
||||
type defaultThinger struct{ ... }
|
||||
func NewThinger() Thinger { return defaultThinger{ ... } }
|
||||
```
|
||||
|
||||
**不要在接口被使用之前定义它。** 如果没有现实的使用示例,很难判断接口是否真的有必要。
|
||||
|
||||
---
|
||||
|
||||
## 通用性:隐藏实现,暴露接口
|
||||
|
||||
如果一个类型仅用于实现某个接口,且没有该接口之外的导出方法,则从构造函数返回接口以隐藏实现:
|
||||
|
||||
```go
|
||||
func NewHash() hash.Hash32 {
|
||||
return &myHash{} // 未导出的类型
|
||||
}
|
||||
```
|
||||
|
||||
好处:实现可以在不影响调用者的情况下更改,替换算法只需更改构造函数调用。
|
||||
|
||||
---
|
||||
|
||||
## 类型断言:Comma-Ok 模式
|
||||
|
||||
不进行检查的话,失败的断言会导致运行时 panic。始终使用 comma-ok 模式进行安全测试:
|
||||
|
||||
```go
|
||||
str, ok := value.(string)
|
||||
if ok {
|
||||
fmt.Printf("string value is: %q\n", str)
|
||||
}
|
||||
```
|
||||
|
||||
检查值是否实现了某个接口:
|
||||
|
||||
```go
|
||||
if _, ok := val.(json.Marshaler); ok {
|
||||
fmt.Printf("value %v implements json.Marshaler\n", val)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 类型切换
|
||||
|
||||
重用变量名是惯用做法(`t := t.(type)`)——变量在每个 case 分支中拥有正确的类型。当 case 列出多个类型(`case int, int64:`)时,变量拥有接口类型。
|
||||
|
||||
---
|
||||
|
||||
## 嵌入
|
||||
|
||||
避免在公开结构体中嵌入类型——内部类型的完整方法集将成为你公开 API 的一部分。改用未导出的字段。
|
||||
|
||||
> 在使用结构体嵌入进行组合、重写嵌入方法、解决名称冲突、应用 HandlerFunc 适配器模式或决定是否在公开 API 类型中使用嵌入时,阅读 [references/EMBEDDING.md](references/EMBEDDING.md)。
|
||||
|
||||
---
|
||||
|
||||
## 接口满足检查
|
||||
|
||||
使用空标识符赋值在编译时验证类型是否实现了接口:
|
||||
|
||||
```go
|
||||
var _ json.Marshaler = (*RawMessage)(nil)
|
||||
```
|
||||
|
||||
如果 `*RawMessage` 没有实现 `json.Marshaler`,这会导致编译错误。
|
||||
|
||||
在以下情况下使用此模式:
|
||||
- 没有能自动验证接口的静态转换
|
||||
- 类型必须满足接口才能正确运行(例如自定义 JSON 序列化)
|
||||
- 接口更改应该导致编译失败,而不是静默降级
|
||||
|
||||
**不要**为每个接口都添加这些检查——仅在没有其他静态转换能捕获错误时才使用。
|
||||
|
||||
> **验证**:在定义接口或实现后,运行 `bash scripts/check-interface-compliance.sh` 验证所有具体类型都有编译时的 `var _ I = (*T)(nil)` 检查。
|
||||
|
||||
---
|
||||
|
||||
## 接收者类型
|
||||
|
||||
如果不确定,使用指针接收者。不要在单个类型上混合接收者类型——如果任何方法需要指针,则所有方法都使用指针。仅在小型不可变类型(`Point`、`time.Time`)或基本类型上使用值接收者。
|
||||
|
||||
> 在为新类型决定使用指针接收者还是值接收者时,特别是对于包含 sync 原语或大型结构体的类型,阅读 [references/RECEIVER-TYPE.md](references/RECEIVER-TYPE.md)。
|
||||
|
||||
---
|
||||
|
||||
## 快速参考
|
||||
|
||||
| 概念 | 模式 | 说明 |
|
||||
|------|------|------|
|
||||
| 消费者拥有接口 | 在使用处定义接口 | 不在实现包中 |
|
||||
| 安全类型断言 | `v, ok := x.(Type)` | 返回零值 + false |
|
||||
| 类型切换 | `switch v := x.(type)` | 变量在每个 case 中拥有正确类型 |
|
||||
| 接口嵌入 | `type RW interface { Reader; Writer }` | 方法的并集 |
|
||||
| 结构体嵌入 | `type S struct { *T }` | 提升 T 的方法 |
|
||||
| 接口检查 | `var _ I = (*T)(nil)` | 编译时验证 |
|
||||
| 通用性 | 从构造函数返回接口 | 隐藏实现 |
|
||||
|
||||
---
|
||||
|
||||
## 相关技能
|
||||
|
||||
- **接口命名**:在为接口命名(`-er` 后缀约定)或选择接收者名称时,参见 [go-naming](../go-naming/SKILL.md)
|
||||
- **错误类型**:在实现 `error` 接口、自定义错误类型或 `errors.As` 匹配时,参见 [go-error-handling](../go-error-handling/SKILL.md)
|
||||
- **泛型 vs 接口**:在决定是否需要泛型或接口是否已足够时,参见 [go-generics](../go-generics/SKILL.md)
|
||||
- **函数选项**:在使用基于接口的 Option 模式实现灵活构造函数时,参见 [go-functional-options](../go-functional-options/SKILL.md)
|
||||
- **编译时检查**:在 API 边界添加 `var _ I = (*T)(nil)` 满足检查时,参见 [go-defensive](../go-defensive/SKILL.md)
|
||||
@@ -1,138 +0,0 @@
|
||||
# Go 中的嵌入模式
|
||||
|
||||
> **来源**:Effective Go、Uber 风格指南
|
||||
|
||||
Go 使用嵌入来实现组合而非继承。嵌入将内部类型的方法提升到外部类型,自动满足接口。
|
||||
|
||||
## 接口嵌入
|
||||
|
||||
通过嵌入来组合接口:
|
||||
|
||||
```go
|
||||
type ReadWriter interface {
|
||||
Reader
|
||||
Writer
|
||||
}
|
||||
```
|
||||
|
||||
`ReadWriter` 既能做 `Reader` 能做的事,*也能*做 `Writer` 能做的事。接口中只能嵌入接口。
|
||||
|
||||
## 结构体嵌入
|
||||
|
||||
嵌入将内部类型的方法提升到外部类型,无需显式转发。
|
||||
|
||||
```go
|
||||
type ReadWriter struct {
|
||||
*Reader // *bufio.Reader
|
||||
*Writer // *bufio.Writer
|
||||
}
|
||||
```
|
||||
|
||||
通过嵌入,`bufio.ReadWriter` 自动满足 `io.Reader`、`io.Writer` 和 `io.ReadWriter`。
|
||||
|
||||
混合使用嵌入字段和命名字段:
|
||||
|
||||
```go
|
||||
type Job struct {
|
||||
Command string
|
||||
*log.Logger
|
||||
}
|
||||
|
||||
job.Println("starting now...")
|
||||
job.Logger.SetPrefix("Job: ")
|
||||
```
|
||||
|
||||
## 方法重写
|
||||
|
||||
在外部类型上定义方法以重写提升的方法:
|
||||
|
||||
```go
|
||||
func (job *Job) Printf(format string, args ...any) {
|
||||
job.Logger.Printf("%q: %s", job.Command, fmt.Sprintf(format, args...))
|
||||
}
|
||||
```
|
||||
|
||||
外部方法优先——对 `job.Printf(...)` 的调用会调用外部方法,而嵌入方法仍可通过 `job.Logger.Printf(...)` 访问。
|
||||
|
||||
## 嵌入 vs 子类化
|
||||
|
||||
当调用嵌入方法时,接收者是**内部**类型,而非外部类型。嵌入类型不知道自己被嵌入——不存在类似于 `this` 或 `super` 的引用指向包含它的类型。
|
||||
|
||||
```go
|
||||
type Base struct{}
|
||||
func (b *Base) Name() string { return "Base" }
|
||||
|
||||
type Derived struct{ Base }
|
||||
|
||||
d := Derived{}
|
||||
d.Name() // 返回 "Base",而非 "Derived"
|
||||
```
|
||||
|
||||
## 名称冲突解决
|
||||
|
||||
1. **外部隐藏内部**——外部类型上的字段或方法会遮蔽嵌入类型在同名位置提升的字段或方法
|
||||
2. **同级冲突是错误**——如果两个同深度的嵌入类型提升了相同的名称,则为编译错误(除非该名称从未被访问)
|
||||
|
||||
```go
|
||||
type A struct{}
|
||||
func (A) Hello() string { return "A" }
|
||||
|
||||
type B struct{}
|
||||
func (B) Hello() string { return "B" }
|
||||
|
||||
type C struct {
|
||||
A
|
||||
B
|
||||
}
|
||||
|
||||
// c.Hello() // 编译错误:选择器不明确
|
||||
c.A.Hello() // 可以:显式消歧
|
||||
```
|
||||
|
||||
## 不要在公开结构体中嵌入
|
||||
|
||||
嵌入将内部类型的完整方法集暴露为你的公开 API 的一部分。这带来了维护负担:嵌入类型方法的更改会破坏 API 的兼容性保证。
|
||||
|
||||
**不好**
|
||||
```go
|
||||
type SMap struct {
|
||||
sync.Mutex // Lock 和 Unlock 现在是 SMap API 的一部分
|
||||
data map[string]string
|
||||
}
|
||||
```
|
||||
|
||||
**好**
|
||||
```go
|
||||
type SMap struct {
|
||||
mu sync.Mutex // 未导出的字段——实现细节
|
||||
data map[string]string
|
||||
}
|
||||
|
||||
func (m *SMap) Get(k string) string {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.data[k]
|
||||
}
|
||||
```
|
||||
|
||||
例外:在测试类型和 API 稳定性无关紧要的内部结构体中,嵌入是可以接受的。
|
||||
|
||||
## HandlerFunc 适配器模式
|
||||
|
||||
方法可以在任何命名类型上定义,不仅仅是结构体。`http.HandlerFunc` 模式将普通函数转换为接口实现:
|
||||
|
||||
```go
|
||||
type HandlerFunc func(ResponseWriter, *Request)
|
||||
|
||||
func (f HandlerFunc) ServeHTTP(w ResponseWriter, req *Request) {
|
||||
f(w, req)
|
||||
}
|
||||
```
|
||||
|
||||
任何具有正确签名的函数都可以成为 HTTP 处理器:
|
||||
|
||||
```go
|
||||
http.Handle("/args", http.HandlerFunc(ArgServer))
|
||||
```
|
||||
|
||||
这种适配器模式在需要让独立函数满足单方法接口时非常有用。
|
||||
@@ -1,68 +0,0 @@
|
||||
# 接收者类型:指针 vs 值
|
||||
|
||||
> **建议**:Go Wiki CodeReviewComments
|
||||
|
||||
选择在方法上使用值接收者还是指针接收者可能很困难。**如果不确定,使用指针**,但有时值接收者也是合理的。
|
||||
|
||||
## 何时使用指针接收者
|
||||
|
||||
- **方法修改接收者**:接收者必须是指针
|
||||
- **接收者包含 sync.Mutex 或类似类型**:必须使用指针以避免复制
|
||||
- **大型结构体或数组**:指针接收者更高效。如果将所有元素作为参数传递感觉太大,那对值接收者来说也太大了
|
||||
- **并发或被调方法可能修改**:如果更改必须对原始接收者可见,则必须使用指针
|
||||
- **元素是指向可变内容的指针**:优先使用指针接收者使意图更清晰
|
||||
|
||||
## 何时使用值接收者
|
||||
|
||||
- **小型不变的结构体或基本类型**:值接收者以提高效率
|
||||
- **Map、func 或 chan**:不要对它们使用指针
|
||||
- **不重新切片/重新分配的切片**:如果方法不重新切片或重新分配切片,不要使用指针
|
||||
- **没有可变字段的小型值类型**:像 `time.Time` 这样没有可变字段且没有指针的类型适合作为值接收者
|
||||
- **简单基本类型**:`int`、`string` 等
|
||||
|
||||
```go
|
||||
// 值接收者:小型、不可变类型
|
||||
type Point struct {
|
||||
X, Y float64
|
||||
}
|
||||
|
||||
func (p Point) Distance(q Point) float64 {
|
||||
return math.Hypot(q.X-p.X, q.Y-p.Y)
|
||||
}
|
||||
|
||||
// 指针接收者:方法修改接收者
|
||||
func (p *Point) ScaleBy(factor float64) {
|
||||
p.X *= factor
|
||||
p.Y *= factor
|
||||
}
|
||||
|
||||
// 指针接收者:包含 sync.Mutex
|
||||
type Counter struct {
|
||||
mu sync.Mutex
|
||||
count int
|
||||
}
|
||||
|
||||
func (c *Counter) Increment() {
|
||||
c.mu.Lock()
|
||||
c.count++
|
||||
c.mu.Unlock()
|
||||
}
|
||||
```
|
||||
|
||||
## 一致性规则
|
||||
|
||||
**不要混合接收者类型**。为类型上所有可用的方法统一选择指针或结构体类型。如果任何方法需要指针接收者,则所有方法都使用指针接收者。
|
||||
|
||||
```go
|
||||
// 好:一致的指针接收者
|
||||
type Buffer struct {
|
||||
data []byte
|
||||
}
|
||||
|
||||
func (b *Buffer) Write(p []byte) (int, error) { /* ... */ }
|
||||
func (b *Buffer) Read(p []byte) (int, error) { /* ... */ }
|
||||
func (b *Buffer) Len() int { return len(b.data) }
|
||||
|
||||
// 不好:混合接收者类型
|
||||
func (b Buffer) Len() int { return len(b.data) } // 不一致
|
||||
```
|
||||
@@ -1,224 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="1.0.0"
|
||||
SCRIPT_NAME="$(basename "$0")"
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
$SCRIPT_NAME v$VERSION — Check for missing compile-time interface compliance verifications
|
||||
|
||||
USAGE
|
||||
bash $SCRIPT_NAME [options] [path]
|
||||
|
||||
DESCRIPTION
|
||||
Scans Go files for exported interface definitions and checks whether each
|
||||
has a corresponding compile-time compliance assertion like:
|
||||
|
||||
var _ MyInterface = (*MyImpl)(nil)
|
||||
var _ MyInterface = MyImpl{}
|
||||
|
||||
Reports interfaces that lack such compile-time checks. This helps catch
|
||||
interface drift at compile time instead of runtime.
|
||||
|
||||
Exits 0 if all interfaces are verified, 1 if missing checks found, 2 on error.
|
||||
|
||||
OPTIONS
|
||||
-h, --help Show this help message
|
||||
-v, --version Show version
|
||||
--json Output results as JSON
|
||||
--include-test Also scan _test.go files for compliance checks
|
||||
--limit N Show at most N results (default: all)
|
||||
|
||||
ARGUMENTS
|
||||
path Directory to scan (default: current directory)
|
||||
|
||||
EXAMPLES
|
||||
bash $SCRIPT_NAME
|
||||
bash $SCRIPT_NAME ./pkg/storage
|
||||
bash $SCRIPT_NAME --json .
|
||||
bash $SCRIPT_NAME --include-test ./internal
|
||||
EOF
|
||||
}
|
||||
|
||||
JSON_OUTPUT=false
|
||||
INCLUDE_TEST=false
|
||||
LIMIT=0
|
||||
TARGET=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-h|--help) usage; exit 0 ;;
|
||||
-v|--version) echo "$SCRIPT_NAME v$VERSION"; exit 0 ;;
|
||||
--json) JSON_OUTPUT=true; shift ;;
|
||||
--include-test) INCLUDE_TEST=true; shift ;;
|
||||
--limit) LIMIT="${2:?error: --limit requires a number}"; shift 2 ;;
|
||||
-*) echo "error: unknown option: $1" >&2; usage >&2; exit 2 ;;
|
||||
*) TARGET="$1"; shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
TARGET="${TARGET:-.}"
|
||||
|
||||
if [[ ! -d "$TARGET" && ! -f "$TARGET" ]]; then
|
||||
# Handle ./... patterns
|
||||
dir="${TARGET%%/...}"
|
||||
dir="${dir:-.}"
|
||||
if [[ ! -d "$dir" ]]; then
|
||||
echo "error: path not found: $TARGET" >&2
|
||||
exit 2
|
||||
fi
|
||||
TARGET="$dir"
|
||||
fi
|
||||
|
||||
json_escape() {
|
||||
local s="$1"
|
||||
s="${s//\\/\\\\}"
|
||||
s="${s//\"/\\\"}"
|
||||
s="${s//$'\t'/\\t}"
|
||||
s="${s//$'\r'/}"
|
||||
s="${s//$'\n'/\\n}"
|
||||
printf '%s' "$s"
|
||||
}
|
||||
|
||||
# Collect all Go source files
|
||||
find_go_files() {
|
||||
local t="$1"
|
||||
if $INCLUDE_TEST; then
|
||||
find "$t" -name '*.go' ! -path '*/vendor/*' ! -path '*/.git/*' 2>/dev/null
|
||||
else
|
||||
find "$t" -name '*.go' ! -name '*_test.go' ! -path '*/vendor/*' ! -path '*/.git/*' 2>/dev/null
|
||||
fi
|
||||
}
|
||||
|
||||
# Collect all Go files (including tests) for checking compliance vars
|
||||
find_all_go_files() {
|
||||
find "$1" -name '*.go' ! -path '*/vendor/*' ! -path '*/.git/*' 2>/dev/null
|
||||
}
|
||||
|
||||
# Step 1: Find all exported interface definitions
|
||||
IFACE_NAMES=()
|
||||
IFACE_LOCATIONS=()
|
||||
|
||||
while IFS= read -r file; do
|
||||
[[ -n "$file" ]] || continue
|
||||
line_num=0
|
||||
while IFS= read -r line; do
|
||||
line_num=$((line_num + 1))
|
||||
# Match: type ExportedName interface {
|
||||
pat='^[[:space:]]*type[[:space:]]+([A-Z][a-zA-Z0-9]*)[[:space:]]+interface[[:space:]]*\{'
|
||||
if [[ "$line" =~ $pat ]]; then
|
||||
iface_name="${BASH_REMATCH[1]}"
|
||||
IFACE_NAMES+=("$iface_name")
|
||||
IFACE_LOCATIONS+=("$file:$line_num")
|
||||
fi
|
||||
done < "$file"
|
||||
done < <(find_go_files "$TARGET")
|
||||
|
||||
if [[ ${#IFACE_NAMES[@]} -eq 0 ]]; then
|
||||
if $JSON_OUTPUT; then
|
||||
echo '{"interfaces":[],"missing":[],"count_interfaces":0,"count_missing":0}'
|
||||
else
|
||||
echo "No exported interfaces found in: $TARGET"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Step 2: Scan all Go files (including tests) for compliance checks
|
||||
# Pattern: var _ InterfaceName = ...
|
||||
ALL_GO_FILES=()
|
||||
while IFS= read -r f; do
|
||||
[[ -n "$f" ]] && ALL_GO_FILES+=("$f")
|
||||
done < <(find_all_go_files "$TARGET")
|
||||
|
||||
MISSING=()
|
||||
|
||||
for ((i=0; i<${#IFACE_NAMES[@]}; i++)); do
|
||||
iface_name="${IFACE_NAMES[$i]}"
|
||||
location="${IFACE_LOCATIONS[$i]}"
|
||||
# Look for: var _ InterfaceName = (various patterns)
|
||||
if ! grep -qlE "var[[:space:]]+_[[:space:]]+${iface_name}[[:space:]]*=" \
|
||||
"${ALL_GO_FILES[@]}" 2>/dev/null; then
|
||||
MISSING+=("${iface_name}|${location}")
|
||||
fi
|
||||
done
|
||||
|
||||
# Sort for stable output
|
||||
IFS=$'\n' MISSING=($(sort <<<"${MISSING[*]}")); unset IFS
|
||||
|
||||
# Truncation
|
||||
TOTAL=${#MISSING[@]}
|
||||
TRUNCATED=false
|
||||
if [[ $LIMIT -gt 0 && $TOTAL -gt $LIMIT ]]; then
|
||||
MISSING=("${MISSING[@]:0:$LIMIT}")
|
||||
TRUNCATED=true
|
||||
fi
|
||||
|
||||
# Output results
|
||||
if $JSON_OUTPUT; then
|
||||
echo "{"
|
||||
echo ' "interfaces": ['
|
||||
first=true
|
||||
SORTED_INDICES=()
|
||||
for ((i=0; i<${#IFACE_NAMES[@]}; i++)); do
|
||||
SORTED_INDICES+=("$i|${IFACE_NAMES[$i]}")
|
||||
done
|
||||
IFS=$'\n' SORTED_INDICES=($(sort -t'|' -k2 <<<"${SORTED_INDICES[*]}")); unset IFS
|
||||
|
||||
for entry in "${SORTED_INDICES[@]}"; do
|
||||
i="${entry%%|*}"
|
||||
iface_name="${IFACE_NAMES[$i]}"
|
||||
location="${IFACE_LOCATIONS[$i]}"
|
||||
file="${location%%:*}"
|
||||
line="${location#*:}"
|
||||
$first || echo ","
|
||||
first=false
|
||||
printf ' {"name":"%s","file":"%s","line":%s}' "$(json_escape "$iface_name")" "$(json_escape "$file")" "$line"
|
||||
done
|
||||
echo ""
|
||||
echo " ],"
|
||||
echo ' "missing": ['
|
||||
first=true
|
||||
for entry in "${MISSING[@]+"${MISSING[@]}"}"; do
|
||||
IFS='|' read -r name location <<< "$entry"
|
||||
file="${location%%:*}"
|
||||
line="${location#*:}"
|
||||
$first || echo ","
|
||||
first=false
|
||||
printf ' {"name":"%s","file":"%s","line":%s}' "$(json_escape "$name")" "$(json_escape "$file")" "$line"
|
||||
done
|
||||
echo ""
|
||||
echo " ],"
|
||||
printf ' "count_interfaces": %d,\n' "${#IFACE_NAMES[@]}"
|
||||
printf ' "count_missing": %d,\n' "$TOTAL"
|
||||
printf ' "truncated": %s\n' "$TRUNCATED"
|
||||
echo "}"
|
||||
else
|
||||
echo "Exported interfaces found: ${#IFACE_NAMES[@]}"
|
||||
echo ""
|
||||
|
||||
if [[ $TOTAL -eq 0 ]]; then
|
||||
echo "All interfaces have compile-time compliance checks."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Missing compile-time compliance checks:"
|
||||
echo ""
|
||||
for entry in "${MISSING[@]}"; do
|
||||
IFS='|' read -r name location <<< "$entry"
|
||||
printf " %s interface '%s' has no 'var _ %s = ...' assertion\n" "$location" "$name" "$name"
|
||||
done
|
||||
if $TRUNCATED; then
|
||||
echo " ... and $((TOTAL - LIMIT)) more (use --limit to adjust)"
|
||||
fi
|
||||
echo ""
|
||||
echo "Add compile-time checks like:"
|
||||
echo " var _ MyInterface = (*MyImpl)(nil)"
|
||||
echo ""
|
||||
echo "Total: $TOTAL interface(s) missing verification"
|
||||
fi
|
||||
|
||||
if [[ $TOTAL -gt 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
@@ -1,209 +0,0 @@
|
||||
---
|
||||
name: go-linting
|
||||
description: Use when setting up linting for a Go project, configuring golangci-lint, or adding Go checks to a CI/CD pipeline. Also use when starting a new Go project and deciding which linters to enable, even if the user only asks about "code quality" or "static analysis" without mentioning specific linter names. Does not cover code review process (see go-code-review).
|
||||
license: Apache-2.0
|
||||
metadata:
|
||||
sources: "Uber Style Guide"
|
||||
allowed-tools: Bash(bash:*)
|
||||
---
|
||||
|
||||
# Go Lint
|
||||
|
||||
## 核心原则
|
||||
|
||||
比任何"推荐"的 linter 集合更重要的是:**在整个代码库中一致地进行 lint**。
|
||||
|
||||
一致的 lint 有助于捕获常见问题,并在不过度限制的情况下建立高标准的代码质量。
|
||||
|
||||
---
|
||||
|
||||
## 设置步骤
|
||||
|
||||
1. 使用下面的配置创建 `.golangci.yml`
|
||||
2. 运行 `golangci-lint run ./...`
|
||||
3. 如果出现错误,按类别逐一修复(先格式化,再 vet,再风格)
|
||||
4. 重新运行直到通过
|
||||
|
||||
---
|
||||
|
||||
## 最低推荐 Linter
|
||||
|
||||
这些 linter 能捕获最常见的问题,同时保持高质量标准:
|
||||
|
||||
| Linter | 用途 |
|
||||
|--------|------|
|
||||
| [errcheck](https://github.com/kisielk/errcheck) | 确保错误被处理 |
|
||||
| [goimports](https://pkg.go.dev/golang.org/x/tools/cmd/goimports) | 格式化代码和管理导入 |
|
||||
| [revive](https://github.com/mgechev/revive) | 常见风格错误(golint 的现代替代品) |
|
||||
| [govet](https://pkg.go.dev/cmd/vet) | 分析代码中的常见错误 |
|
||||
| [staticcheck](https://staticcheck.dev) | 各种静态分析检查 |
|
||||
|
||||
> **注意**:`revive` 是现已弃用的 `golint` 的现代、更快的替代品。
|
||||
|
||||
---
|
||||
|
||||
## Lint 运行器:golangci-lint
|
||||
|
||||
使用 [golangci-lint](https://github.com/golangci/golangci-lint) 作为你的 lint 运行器。参见 uber-go/guide 的 [示例 .golangci.yml](https://github.com/uber-go/guide/blob/master/.golangci.yml)。
|
||||
|
||||
---
|
||||
|
||||
## 示例配置
|
||||
|
||||
> 在创建新的 `.golangci.yml` 或将现有配置与推荐基线进行比较时,参见 `assets/golangci.yml`。
|
||||
|
||||
在项目根目录创建 `.golangci.yml`:
|
||||
|
||||
```yaml
|
||||
linters:
|
||||
enable:
|
||||
- errcheck
|
||||
- goimports
|
||||
- revive
|
||||
- govet
|
||||
- staticcheck
|
||||
|
||||
linters-settings:
|
||||
goimports:
|
||||
local-prefixes: github.com/your-org/your-repo
|
||||
revive:
|
||||
rules:
|
||||
- name: blank-imports
|
||||
- name: context-as-argument
|
||||
- name: error-return
|
||||
- name: error-strings
|
||||
- name: exported
|
||||
|
||||
run:
|
||||
timeout: 5m
|
||||
```
|
||||
|
||||
### 运行
|
||||
|
||||
```bash
|
||||
# 安装
|
||||
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
|
||||
|
||||
# 运行所有 linter
|
||||
golangci-lint run
|
||||
|
||||
# 对特定路径运行
|
||||
golangci-lint run ./pkg/...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 额外推荐的 Linter
|
||||
|
||||
除了最低集合之外,在生产项目中可以考虑以下 linter:
|
||||
|
||||
| Linter | 用途 | 何时启用 |
|
||||
|--------|------|----------|
|
||||
| [gosec](https://github.com/securego/gosec) | 安全漏洞检测 | 处理用户输入的服务始终启用 |
|
||||
| [ineffassign](https://github.com/gordonklaus/ineffassign) | 检测无效赋值 | 始终——捕获死代码 |
|
||||
| [misspell](https://github.com/client9/misspell) | 纠正注释/字符串中的常见拼写错误 | 始终 |
|
||||
| [gocyclo](https://github.com/fzipp/gocyclo) | 圈复杂度阈值 | 当函数超过约 15 的复杂度时 |
|
||||
| [exhaustive](https://github.com/nishanths/exhaustive) | 确保 switch 覆盖所有枚举值 | 使用 iota 枚举时 |
|
||||
| [bodyclose](https://github.com/timakin/bodyclose) | 检测未关闭的 HTTP 响应体 | HTTP 客户端代码始终启用 |
|
||||
|
||||
---
|
||||
|
||||
## Nolint 指令
|
||||
|
||||
在抑制 lint 发现时,始终说明原因:
|
||||
|
||||
```go
|
||||
//nolint:errcheck // 即发即忘的日志;错误不可操作
|
||||
_ = logger.Sync()
|
||||
```
|
||||
|
||||
规则:
|
||||
- 使用 `//nolint:lintername`——永远不要使用裸 `//nolint`
|
||||
- 将注释放在与发现相同的行
|
||||
- 在 `//` 之后包含理由说明
|
||||
|
||||
---
|
||||
|
||||
## CI/CD 集成
|
||||
|
||||
### GitHub Actions
|
||||
|
||||
```yaml
|
||||
# .github/workflows/lint.yml
|
||||
name: Lint
|
||||
on: [push, pull_request]
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: stable
|
||||
- uses: golangci/golangci-lint-action@v6
|
||||
with:
|
||||
version: latest
|
||||
```
|
||||
|
||||
### Pre-commit Hook
|
||||
|
||||
```bash
|
||||
#!/bin/sh
|
||||
# .git/hooks/pre-commit
|
||||
golangci-lint run --new-from-rev=HEAD~1
|
||||
```
|
||||
|
||||
使用 `--new-from-rev` 只对更改的代码进行 lint,保持快速反馈循环。
|
||||
|
||||
---
|
||||
|
||||
## 可用脚本
|
||||
|
||||
- **`scripts/setup-lint.sh`**——生成 `.golangci.yml` 并运行初始 lint
|
||||
|
||||
```bash
|
||||
bash scripts/setup-lint.sh github.com/your-org/your-repo
|
||||
bash scripts/setup-lint.sh --force github.com/your-org/your-repo # 覆盖现有配置
|
||||
bash scripts/setup-lint.sh --dry-run # 预览配置
|
||||
bash scripts/setup-lint.sh --json # 结构化输出
|
||||
```
|
||||
|
||||
> **验证**:在生成 `.golangci.yml` 后,运行 `golangci-lint run ./...` 验证配置有效并产生预期输出。如果因配置错误而失败,修复后重试。
|
||||
|
||||
> `scripts/setup-lint.sh` 生成**最低**配置(5 个核心 linter)。
|
||||
> 对于已有项目,使用 `assets/golangci.yml` 作为起点——
|
||||
> 它增加了 gosec、ineffassign、misspell、gocyclo 和 bodyclose。
|
||||
|
||||
---
|
||||
|
||||
## 快速参考
|
||||
|
||||
| 任务 | 命令/操作 |
|
||||
|------|-----------|
|
||||
| 安装 golangci-lint | `go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest` |
|
||||
| 运行 linter | `golangci-lint run` |
|
||||
| 对路径运行 | `golangci-lint run ./pkg/...` |
|
||||
| 配置文件 | 项目根目录的 `.golangci.yml` |
|
||||
| CI 集成 | 在管道中运行 `golangci-lint run` |
|
||||
| Nolint 指令 | `//nolint:name // 原因`——永远不要使用裸 `//nolint` |
|
||||
| CI 集成 | 使用 `golangci/golangci-lint-action` 用于 GitHub Actions |
|
||||
| Pre-commit | `golangci-lint run --new-from-rev=HEAD~1` |
|
||||
|
||||
### Linter 选择指南
|
||||
|
||||
| 当你需要... | 使用 |
|
||||
|-------------|------|
|
||||
| 错误处理覆盖率 | errcheck |
|
||||
| 导入格式化 | goimports |
|
||||
| 风格一致性 | revive |
|
||||
| Bug 检测 | govet、staticcheck |
|
||||
| 以上全部 | golangci-lint 配合配置 |
|
||||
|
||||
---
|
||||
|
||||
## 相关技能
|
||||
|
||||
- **风格基础**:在解决 linter 执行的风格问题(格式化、嵌套、命名)时,参见 [go-style-core](../go-style-core/SKILL.md)
|
||||
- **代码审查**:在将 linter 输出与手动审查清单结合使用时,参见 [go-code-review](../go-code-review/SKILL.md)
|
||||
- **错误处理**:在 errcheck 标记未处理的错误并需要决定如何处理时,参见 [go-error-handling](../go-error-handling/SKILL.md)
|
||||
- **测试**:在 CI 管道中将 linter 与测试一起运行时,参见 [go-testing](../go-testing/SKILL.md)
|
||||
@@ -1,31 +0,0 @@
|
||||
run:
|
||||
timeout: 5m
|
||||
|
||||
linters:
|
||||
enable:
|
||||
# Minimum recommended
|
||||
- errcheck
|
||||
- goimports
|
||||
- revive
|
||||
- govet
|
||||
- staticcheck
|
||||
# Additional recommended
|
||||
- gosec
|
||||
- ineffassign
|
||||
- misspell
|
||||
- gocyclo
|
||||
- bodyclose
|
||||
|
||||
linters-settings:
|
||||
goimports:
|
||||
local-prefixes: "" # Set to your module path
|
||||
revive:
|
||||
rules:
|
||||
- name: exported
|
||||
gocyclo:
|
||||
min-complexity: 15
|
||||
|
||||
issues:
|
||||
exclude-use-default: false
|
||||
max-issues-per-linter: 0
|
||||
max-same-issues: 0
|
||||
@@ -1,172 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="1.0.0"
|
||||
SCRIPT_NAME="$(basename "$0")"
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
$SCRIPT_NAME v$VERSION — Generate .golangci.yml and run initial lint
|
||||
|
||||
USAGE
|
||||
bash $SCRIPT_NAME [options] [local-prefix]
|
||||
|
||||
DESCRIPTION
|
||||
Creates a .golangci.yml with a curated set of linters (errcheck,
|
||||
goimports, revive, govet, staticcheck) and runs golangci-lint.
|
||||
If local-prefix is provided, configures goimports to group local
|
||||
imports separately.
|
||||
|
||||
Exits 0 if lint passes, 1 if lint issues found, 2 on error.
|
||||
|
||||
OPTIONS
|
||||
-h, --help Show this help message
|
||||
-v, --version Show version
|
||||
--json Output results as JSON
|
||||
--force Overwrite existing .golangci.yml
|
||||
--dry-run Print generated config to stdout without writing
|
||||
--limit N Max lint issue lines in JSON output (default: 50, 0 = unlimited)
|
||||
|
||||
ARGUMENTS
|
||||
local-prefix Module path prefix for goimports grouping
|
||||
(e.g., github.com/myorg/myrepo)
|
||||
|
||||
EXAMPLES
|
||||
bash $SCRIPT_NAME
|
||||
bash $SCRIPT_NAME github.com/myorg/myrepo
|
||||
bash $SCRIPT_NAME --force github.com/myorg/myrepo
|
||||
bash $SCRIPT_NAME --dry-run github.com/myorg/myrepo
|
||||
bash $SCRIPT_NAME --json
|
||||
bash $SCRIPT_NAME --json --limit 20
|
||||
EOF
|
||||
}
|
||||
|
||||
json_escape() {
|
||||
local s="$1"
|
||||
s="${s//\\/\\\\}"
|
||||
s="${s//\"/\\\"}"
|
||||
s="${s//$'\t'/\\t}"
|
||||
s="${s//$'\r'/}"
|
||||
s="${s//$'\n'/\\n}"
|
||||
printf '%s' "$s"
|
||||
}
|
||||
|
||||
JSON_OUTPUT=false
|
||||
FORCE=false
|
||||
DRY_RUN=false
|
||||
LIMIT=50
|
||||
LOCAL_PREFIX=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-h|--help) usage; exit 0 ;;
|
||||
-v|--version) echo "$SCRIPT_NAME v$VERSION"; exit 0 ;;
|
||||
--json) JSON_OUTPUT=true; shift ;;
|
||||
--force) FORCE=true; shift ;;
|
||||
--dry-run) DRY_RUN=true; shift ;;
|
||||
--limit) LIMIT="${2:?error: --limit requires a number}"; shift 2 ;;
|
||||
-*) echo "error: unknown option: $1" >&2; usage >&2; exit 2 ;;
|
||||
*) LOCAL_PREFIX="$1"; shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
generate_config() {
|
||||
cat <<'YAML'
|
||||
linters:
|
||||
enable:
|
||||
- errcheck
|
||||
- goimports
|
||||
- revive
|
||||
- govet
|
||||
- staticcheck
|
||||
|
||||
linters-settings:
|
||||
YAML
|
||||
|
||||
if [[ -n "$LOCAL_PREFIX" ]]; then
|
||||
cat <<YAML
|
||||
goimports:
|
||||
local-prefixes: ${LOCAL_PREFIX}
|
||||
YAML
|
||||
fi
|
||||
|
||||
cat <<'YAML'
|
||||
revive:
|
||||
rules:
|
||||
- name: blank-imports
|
||||
- name: context-as-argument
|
||||
- name: error-return
|
||||
- name: error-strings
|
||||
- name: exported
|
||||
|
||||
run:
|
||||
timeout: 5m
|
||||
YAML
|
||||
}
|
||||
|
||||
if $DRY_RUN; then
|
||||
generate_config
|
||||
exit 0
|
||||
fi
|
||||
|
||||
CONFIG_PATH=".golangci.yml"
|
||||
|
||||
if [[ -f "$CONFIG_PATH" ]] && ! $FORCE; then
|
||||
echo "error: $CONFIG_PATH already exists (use --force to overwrite)" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
generate_config > "$CONFIG_PATH"
|
||||
|
||||
LINT_OUTPUT=""
|
||||
LINT_EXIT=0
|
||||
if ! command -v golangci-lint &>/dev/null; then
|
||||
echo "error: golangci-lint is not installed" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
LINT_OUTPUT=$(golangci-lint run ./... 2>&1) || LINT_EXIT=$?
|
||||
|
||||
if $JSON_OUTPUT; then
|
||||
LINT_TRUNCATED=false
|
||||
LINT_DISPLAY="$LINT_OUTPUT"
|
||||
if [[ $LIMIT -gt 0 && -n "$LINT_OUTPUT" ]]; then
|
||||
LINT_ARR=()
|
||||
while IFS= read -r line; do
|
||||
LINT_ARR+=("$line")
|
||||
done <<< "$LINT_OUTPUT"
|
||||
if [[ ${#LINT_ARR[@]} -gt $LIMIT ]]; then
|
||||
LINT_DISPLAY=""
|
||||
for (( i=0; i<LIMIT; i++ )); do
|
||||
[[ -n "$LINT_DISPLAY" ]] && LINT_DISPLAY+=$'\n'
|
||||
LINT_DISPLAY+="${LINT_ARR[$i]}"
|
||||
done
|
||||
LINT_TRUNCATED=true
|
||||
fi
|
||||
fi
|
||||
LINT_ESC="$(json_escape "$LINT_DISPLAY")"
|
||||
CONFIG_ESC="$(json_escape "$CONFIG_PATH")"
|
||||
PREFIX_ESC="$(json_escape "$LOCAL_PREFIX")"
|
||||
CREATED=true
|
||||
HAS_ISSUES=$( [[ $LINT_EXIT -ne 0 ]] && echo true || echo false )
|
||||
TRUNC_FIELD=""
|
||||
$LINT_TRUNCATED && TRUNC_FIELD=',"truncated":true'
|
||||
cat <<EOF
|
||||
{"config_path":"$CONFIG_ESC","local_prefix":"$PREFIX_ESC","created":$CREATED,"lint_issues":$HAS_ISSUES,"lint_output":"$LINT_ESC"$TRUNC_FIELD}
|
||||
EOF
|
||||
else
|
||||
echo "Created $CONFIG_PATH"
|
||||
if [[ $LINT_EXIT -ne 0 ]]; then
|
||||
echo ""
|
||||
echo "$LINT_OUTPUT"
|
||||
echo ""
|
||||
echo "Lint issues found — fix them category by category (formatting first, then vet, then style)."
|
||||
else
|
||||
echo "golangci-lint: all clean."
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ $LINT_EXIT -ne 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
@@ -1,138 +0,0 @@
|
||||
---
|
||||
name: go-packages
|
||||
description: Use when creating Go packages, organizing imports, managing dependencies, or deciding how to structure Go code into packages. Also use when starting a new Go project or splitting a growing codebase into packages, even if the user doesn't explicitly ask about package organization. Does not cover naming individual identifiers (see go-naming).
|
||||
license: Apache-2.0
|
||||
metadata:
|
||||
sources: "Google Style Guide, Uber Style Guide, Go Wiki CodeReviewComments"
|
||||
---
|
||||
|
||||
# Go 包和 Import
|
||||
|
||||
> **本技能不适用的场景**:对于包内单个标识符的命名,参见 [go-naming](../go-naming/SKILL.md)。对于单文件中函数的组织,参见 [go-functions](../go-functions/SKILL.md)。对于强制执行 import 规则的 linter 配置,参见 [go-linting](../go-linting/SKILL.md)。
|
||||
|
||||
## 包组织
|
||||
|
||||
### 避免 Util 包
|
||||
|
||||
包名应描述包提供的内容。避免使用 `util`、`helper`、`common` 等泛化名称——它们会模糊含义并导致 import 冲突。
|
||||
|
||||
```go
|
||||
// 好:有意义的包名
|
||||
db := spannertest.NewDatabaseFromFile(...)
|
||||
_, err := f.Seek(0, io.SeekStart)
|
||||
|
||||
// 不好:模糊的名称遮蔽含义
|
||||
db := test.NewDatabaseFromFile(...)
|
||||
_, err := f.Seek(0, common.SeekStart)
|
||||
```
|
||||
|
||||
泛化名称可以作为名称的*一部分*(例如 `stringutil`),但不应成为整个包名。
|
||||
|
||||
### Package Size
|
||||
|
||||
| 问题 | 操作 |
|
||||
|------|------|
|
||||
| 你能用一句话描述它的用途吗? | 不能 → 按职责拆分 |
|
||||
| 文件中从未共享未导出的符号? | 这些文件可以是独立的包 |
|
||||
| 不同的用户群体使用不同部分? | 按用户边界拆分 |
|
||||
| Godoc 页面过于庞大? | 拆分以提高可发现性 |
|
||||
|
||||
**不要拆分**的原因仅仅是文件很长、创建只有单一类型的包,或会产生循环依赖。
|
||||
|
||||
> 在决定是否拆分或合并包、组织包内文件或构建 CLI 程序时,阅读 [references/PACKAGE-SIZE.md](references/PACKAGE-SIZE.md)。
|
||||
|
||||
---
|
||||
|
||||
## Import
|
||||
|
||||
Import 按组组织,组之间用空行分隔。标准库包始终放在第一组。使用
|
||||
[goimports](https://pkg.go.dev/golang.org/x/tools/cmd/goimports) 自动管理。
|
||||
|
||||
```go
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/foo/bar"
|
||||
"rsc.io/goversion/version"
|
||||
)
|
||||
```
|
||||
|
||||
**快速规则:**
|
||||
|
||||
| 规则 | 指导 |
|
||||
|------|------|
|
||||
| 分组 | 标准库优先,然后是外部包。扩展分组:标准库 → 其他 → proto → 副作用 |
|
||||
| 重命名 | 除非冲突,否则避免重命名。重命名最本地的 import。Proto 包加 `pb` 后缀 |
|
||||
| 空白 import(`import _`) | 仅在 `main` 包或测试中使用 |
|
||||
| 点 import(`import .`) | 永不使用,除非用于循环依赖的测试文件 |
|
||||
|
||||
> 在组织扩展分组的 import、重命名 proto 包或决定使用空白/点 import 时,阅读 [references/IMPORTS.md](references/IMPORTS.md)。
|
||||
|
||||
---
|
||||
|
||||
## 避免 init()
|
||||
|
||||
尽可能避免 `init()`。当不可避免时,它必须是:
|
||||
|
||||
1. 完全确定性的
|
||||
2. 不依赖于其他 `init()` 的执行顺序
|
||||
3. 不依赖环境状态(环境变量、工作目录、参数)
|
||||
4. 不进行 I/O(文件系统、网络、系统调用)
|
||||
|
||||
**可接受的使用场景**:无法用单个赋值完成的复杂表达式、可插拔钩子(例如 `database/sql` 方言)、确定性预计算。
|
||||
|
||||
> 在需要将 init() 重构为显式函数或理解可接受的 init() 使用场景时,阅读 [references/PACKAGE-SIZE.md](references/PACKAGE-SIZE.md)。
|
||||
|
||||
---
|
||||
|
||||
## Main 中的退出
|
||||
|
||||
仅在 `main()` 中调用 `os.Exit` 或 `log.Fatal*`。所有其他函数应返回 error。
|
||||
|
||||
**原因**:不明显的控制流、不可测试、`defer` 语句被跳过。
|
||||
|
||||
**最佳实践**:使用 `run()` 模式——将逻辑提取到
|
||||
`func run() error` 中,在 `main()` 中调用并使用单一退出点:
|
||||
|
||||
```go
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> 在实现 run() 模式、构建 CLI 子命令或选择 flag 命名约定时,阅读 [references/PACKAGE-SIZE.md](references/PACKAGE-SIZE.md)。
|
||||
|
||||
---
|
||||
|
||||
## 命令行 Flag
|
||||
|
||||
> **建议**:仅在 `package main` 中定义 flag。
|
||||
|
||||
- Flag 名称使用 `snake_case`:`--output_dir` 而非 `--outputDir`
|
||||
- 库应通过参数接收配置,而非直接读取 flag——
|
||||
这使它们可测试且可复用
|
||||
- 优先使用标准 `flag` 包;仅在需要 POSIX 约定
|
||||
(双破折号、单字符快捷方式)时使用 `pflag`
|
||||
|
||||
```go
|
||||
// 好:Flag 在 main 中定义,作为参数传递给库
|
||||
func main() {
|
||||
outputDir := flag.String("output_dir", ".", "directory for output files")
|
||||
flag.Parse()
|
||||
if err := mylib.Generate(*outputDir); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 相关技能
|
||||
|
||||
- **包命名**:在选择包名、避免名称重复或命名导出符号时,参见 [go-naming](../go-naming/SKILL.md)
|
||||
- **跨包的错误处理**:在使用 `%w` vs `%v` 在包边界包装错误时,参见 [go-error-handling](../go-error-handling/SKILL.md)
|
||||
- **Import linting**:在配置 goimports local-prefixes 或强制执行 import 分组时,参见 [go-linting](../go-linting/SKILL.md)
|
||||
- **全局状态**:在用显式初始化替换 `init()` 或避免可变全局变量时,参见 [go-defensive](../go-defensive/SKILL.md)
|
||||
@@ -1,110 +0,0 @@
|
||||
# Import 组织
|
||||
|
||||
Go import 组织的详细规则和示例。
|
||||
|
||||
## Import 分组
|
||||
|
||||
Import 按组组织,组之间用空行分隔。标准库包始终放在第一组。
|
||||
|
||||
**最小分组(Uber):** 标准库,然后其他所有。
|
||||
|
||||
**扩展分组(Google):** 标准库 → 其他 → protocol buffers → 副作用。
|
||||
|
||||
```go
|
||||
// 好:标准库与外部包分开
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"go.uber.org/atomic"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
```
|
||||
|
||||
```go
|
||||
// 好:完整分组,包含 proto 和副作用
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/dsnet/compress/flate"
|
||||
"golang.org/x/text/encoding"
|
||||
|
||||
foopb "myproj/foo/proto/proto"
|
||||
|
||||
_ "myproj/rpc/protocols/dial"
|
||||
)
|
||||
```
|
||||
|
||||
## Import 重命名
|
||||
|
||||
避免重命名 import,除非为了避免名称冲突;好的包名不需要重命名。
|
||||
在发生冲突时,**优先重命名最本地的或项目特定的 import**。
|
||||
|
||||
**必须重命名:** 与其他 import 冲突、生成的 protocol buffer 包
|
||||
(删除下划线,添加 `pb` 后缀)。
|
||||
|
||||
**可以重命名:** 无意义的名称(例如 `v1`)、与本地变量冲突。
|
||||
|
||||
```go
|
||||
// 好:Proto 包用 pb 后缀重命名
|
||||
import (
|
||||
foosvcpb "path/to/package/foo_service_go_proto"
|
||||
)
|
||||
|
||||
// 好:当需要 url 变量时使用 urlpkg
|
||||
import (
|
||||
urlpkg "net/url"
|
||||
)
|
||||
|
||||
func parseEndpoint(url string) (*urlpkg.URL, error) {
|
||||
return urlpkg.Parse(url)
|
||||
}
|
||||
```
|
||||
|
||||
## 空白 Import(`import _`)
|
||||
|
||||
仅为副作用而导入的包(使用 `import _ "pkg"`)
|
||||
应仅在程序的主包(main)或需要它们的测试中导入。
|
||||
|
||||
```go
|
||||
// 好:在主包中使用空白 import
|
||||
package main
|
||||
|
||||
import (
|
||||
_ "time/tzdata"
|
||||
_ "image/jpeg"
|
||||
)
|
||||
```
|
||||
|
||||
## 点 Import(`import .`)
|
||||
|
||||
**不要**使用点 import。它们使程序难以阅读,因为不清楚
|
||||
`Quux` 这样的名称是当前包中的顶层标识符还是导入包中的。
|
||||
|
||||
**例外:** `import .` 形式在由于循环依赖而无法成为被测试包的一部分的测试文件中可能有用:
|
||||
|
||||
```go
|
||||
package foo_test
|
||||
|
||||
import (
|
||||
"bar/testutil" // 也导入了 "foo"
|
||||
. "foo"
|
||||
)
|
||||
```
|
||||
|
||||
在这种情况下,测试文件不能是 `foo` 包,因为它使用了
|
||||
`bar/testutil`,而后者导入了 `foo`。因此 `import .` 形式让文件
|
||||
假装是 `foo` 包的一部分,即使实际上不是。
|
||||
|
||||
**除了这一种情况外,不要在程序中使用 `import .`。**
|
||||
|
||||
```go
|
||||
// 不好:点 import 隐藏了来源
|
||||
import . "foo"
|
||||
var myThing = Bar() // Bar 来自哪里?
|
||||
|
||||
// 好:显式限定
|
||||
import "foo"
|
||||
var myThing = foo.Bar()
|
||||
```
|
||||
@@ -1,214 +0,0 @@
|
||||
# 包大小、程序结构和 CLI
|
||||
|
||||
关于包拆分、避免 init()、run() 模式和 CLI 结构的详细指南。
|
||||
|
||||
## 何时拆分包
|
||||
|
||||
```
|
||||
包是否变得太大?
|
||||
├─ 你能用一句话描述它的用途吗?
|
||||
│ ├─ 不能 → 按职责拆分
|
||||
│ └─ 能 → 保留,但检查以下内容
|
||||
├─ 包中的文件是否从未导入彼此的未导出符号?
|
||||
│ └─ 是 → 这些文件可以是独立的包
|
||||
├─ 包是否有不同的用户群体使用不同部分?
|
||||
│ └─ 是 → 按用户边界拆分
|
||||
└─ godoc 页面是否过于庞大?
|
||||
└─ 是 → 拆分以提高可发现性
|
||||
```
|
||||
|
||||
### 何时不应拆分
|
||||
|
||||
- 不要仅因为文件很长就拆分——聚焦的包中的大文件是可以的
|
||||
- 不要创建只包含一个类型或函数的包
|
||||
- 如果会产生循环依赖则不要拆分
|
||||
- 避免将内部辅助工具拆分到 `util` 或 `internal/helpers` 包中
|
||||
|
||||
### 何时合并包
|
||||
|
||||
- 如果客户端代码很可能需要两个类型交互,保持它们在一起
|
||||
- 如果类型有紧密耦合的实现
|
||||
- 如果用户需要同时导入两个包才能有意义地使用其中任何一个
|
||||
|
||||
### 文件组织
|
||||
|
||||
Go 中没有"一个类型一个文件"的惯例。文件应该足够聚焦以便知道哪个文件包含什么内容,且足够小以便轻松查找。
|
||||
|
||||
---
|
||||
|
||||
## 避免 init()
|
||||
|
||||
优先使用显式函数而非 `init()`:
|
||||
|
||||
```go
|
||||
// 不好:init() 带有 I/O 和环境依赖
|
||||
var _config Config
|
||||
|
||||
func init() {
|
||||
cwd, _ := os.Getwd()
|
||||
raw, _ := os.ReadFile(path.Join(cwd, "config.yaml"))
|
||||
yaml.Unmarshal(raw, &_config)
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
// 好:用于加载配置的显式函数
|
||||
func loadConfig() (Config, error) {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
raw, err := os.ReadFile(path.Join(cwd, "config.yaml"))
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
var config Config
|
||||
if err := yaml.Unmarshal(raw, &config); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
```
|
||||
|
||||
**init() 的可接受使用场景:**
|
||||
- 无法用单个赋值完成的复杂表达式
|
||||
- 可插拔钩子(例如 `database/sql` 方言、编码注册表)
|
||||
- 确定性预计算
|
||||
|
||||
---
|
||||
|
||||
## Main 中的退出
|
||||
|
||||
仅在 `main()` 中调用 `os.Exit` 或 `log.Fatal*`。所有其他函数应
|
||||
返回 error 来表示失败。
|
||||
|
||||
**为什么这很重要:**
|
||||
- 不明显的控制流:任何函数都可以退出程序
|
||||
- 难以测试:退出程序的函数也会退出测试
|
||||
- 跳过的清理:`defer` 语句会被跳过
|
||||
|
||||
```go
|
||||
// 不好:在辅助函数中使用 log.Fatal
|
||||
func readFile(path string) string {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
log.Fatal(err) // 退出程序,跳过 defer
|
||||
}
|
||||
b, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
// 好:返回 error,让 main() 决定是否退出
|
||||
func main() {
|
||||
body, err := readFile(path)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Println(body)
|
||||
}
|
||||
|
||||
func readFile(path string) (string, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
b, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
```
|
||||
|
||||
### run() 模式
|
||||
|
||||
优先在 `main()` 中**最多调用一次** `os.Exit` 或 `log.Fatal`。将
|
||||
业务逻辑提取到返回 error 的独立函数中。
|
||||
|
||||
```go
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
args := os.Args[1:]
|
||||
if len(args) != 1 {
|
||||
return errors.New("missing file")
|
||||
}
|
||||
|
||||
f, err := os.Open(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close() // 将始终执行
|
||||
|
||||
b, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 处理 b...
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
**`run()` 模式的优势:**
|
||||
- 简短的 `main()` 函数,单一退出点
|
||||
- 所有业务逻辑都可测试
|
||||
- `defer` 语句始终执行
|
||||
|
||||
---
|
||||
|
||||
## 命令行接口
|
||||
|
||||
### Flag 命名
|
||||
|
||||
使用小写、连字符分隔的 flag 名称:
|
||||
|
||||
```go
|
||||
// 好
|
||||
flag.String("output-dir", ".", "directory for output files")
|
||||
flag.Bool("dry-run", false, "print actions without executing")
|
||||
|
||||
// 不好
|
||||
flag.String("outputDir", ".", "") // camelCase
|
||||
flag.String("output_dir", ".", "") // 下划线
|
||||
```
|
||||
|
||||
### 子命令
|
||||
|
||||
对于带有子命令的复杂 CLI,为每个子命令使用 `flag.NewFlagSet`:
|
||||
|
||||
```go
|
||||
func main() {
|
||||
serveCmd := flag.NewFlagSet("serve", flag.ExitOnError)
|
||||
port := serveCmd.Int("port", 8080, "listen port")
|
||||
|
||||
migrateCmd := flag.NewFlagSet("migrate", flag.ExitOnError)
|
||||
dryRun := migrateCmd.Bool("dry-run", false, "preview changes")
|
||||
|
||||
switch os.Args[1] {
|
||||
case "serve":
|
||||
serveCmd.Parse(os.Args[2:])
|
||||
runServe(*port)
|
||||
case "migrate":
|
||||
migrateCmd.Parse(os.Args[2:])
|
||||
runMigrate(*dryRun)
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1])
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
对于更大的 CLI,考虑使用 `cobra` 或 `urfave/cli` 等库。仅从
|
||||
`main()` 退出。
|
||||
@@ -1,152 +0,0 @@
|
||||
---
|
||||
name: go-performance
|
||||
description: Use when optimizing Go code, investigating slow performance, or writing performance-critical sections. Also use when a user mentions slow Go code, string concatenation in loops, or asks about benchmarking, even if the user doesn't explicitly mention performance patterns. Does not cover concurrent performance patterns (see go-concurrency).
|
||||
license: Apache-2.0
|
||||
metadata:
|
||||
sources: "Uber Style Guide, Google Style Guide, Go Wiki CodeReviewComments"
|
||||
allowed-tools: Bash(bash:*)
|
||||
---
|
||||
|
||||
# Go 性能模式
|
||||
|
||||
## 可用脚本
|
||||
|
||||
- **`scripts/bench-compare.sh`** — 运行 Go 基准测试 N 次,并可选通过 benchstat 进行基线比较。支持保存结果以供未来比较。运行 `bash scripts/bench-compare.sh --help` 查看选项。
|
||||
|
||||
性能特定的指南仅适用于**热点路径**。不要过早优化——将这些模式集中在最重要的地方。
|
||||
|
||||
---
|
||||
|
||||
## 优先使用 strconv 而非 fmt
|
||||
|
||||
在基本类型和字符串之间转换时,`strconv` 比 `fmt` 更快:
|
||||
|
||||
```go
|
||||
s := strconv.Itoa(rand.Int()) // 比 fmt.Sprint() 快约 2 倍
|
||||
```
|
||||
|
||||
| 方式 | 速度 | 分配次数 |
|
||||
|------|------|---------|
|
||||
| `fmt.Sprint` | 143 ns/op | 2 allocs/op |
|
||||
| `strconv.Itoa` | 64.2 ns/op | 1 allocs/op |
|
||||
|
||||
> 在 strconv 和 fmt 之间选择类型转换方式时,或需要完整的转换对照表时,阅读 [references/STRING-OPTIMIZATION.md](references/STRING-OPTIMIZATION.md)。
|
||||
|
||||
---
|
||||
|
||||
## 避免重复的字符串到字节转换
|
||||
|
||||
将固定字符串在循环外转换为 `[]byte` 一次:
|
||||
|
||||
```go
|
||||
data := []byte("Hello world")
|
||||
for i := 0; i < b.N; i++ {
|
||||
w.Write(data) // 比每次迭代 []byte("...") 快约 7 倍
|
||||
}
|
||||
```
|
||||
|
||||
> 在优化热点循环中的重复字节转换时,阅读 [references/STRING-OPTIMIZATION.md](references/STRING-OPTIMIZATION.md)。
|
||||
|
||||
---
|
||||
|
||||
## 优先指定容器容量
|
||||
|
||||
尽可能指定容器容量,以便预先分配内存。这可以最大程度减少后续添加元素时因复制和调整大小而产生的分配。
|
||||
|
||||
### Map 容量提示
|
||||
|
||||
使用 `make()` 初始化 map 时提供容量提示:
|
||||
|
||||
```go
|
||||
m := make(map[string]os.DirEntry, len(files))
|
||||
```
|
||||
|
||||
**注意**:与 slice 不同,map 的容量提示不保证完整的预分配——它只是近似计算所需的哈希桶数量。
|
||||
|
||||
### Slice 容量
|
||||
|
||||
使用 `make()` 初始化 slice 时提供容量提示,特别是在追加时:
|
||||
|
||||
```go
|
||||
data := make([]int, 0, size)
|
||||
```
|
||||
|
||||
与 map 不同,slice 容量**不是提示**——编译器会精确分配那么多内存。后续的 `append()` 操作在达到容量之前不会产生任何分配。
|
||||
|
||||
| 方式 | 时间(1 亿次迭代) |
|
||||
|------|------------------------|
|
||||
| 无容量 | 2.48s |
|
||||
| 指定容量 | 0.21s |
|
||||
|
||||
指定容量的版本**快约 12 倍**,因为追加期间零重新分配。
|
||||
|
||||
---
|
||||
|
||||
## 传值
|
||||
|
||||
不要仅为了节省几个字节就将指针作为函数参数传递。如果函数在整个函数体中仅通过 `*x` 引用其参数 `x`,则该参数不应该是`指针。
|
||||
|
||||
```go
|
||||
func process(s string) { // 不是 *string —— string 是小的固定大小头部
|
||||
fmt.Println(s)
|
||||
}
|
||||
```
|
||||
|
||||
**常见的按值传递类型**:`string`、`io.Reader`、小结构体。
|
||||
|
||||
**例外**:
|
||||
- 复制代价高的大结构体
|
||||
- 未来可能增长的小结构体
|
||||
|
||||
---
|
||||
|
||||
## 字符串拼接
|
||||
|
||||
根据复杂度选择正确的策略:
|
||||
|
||||
| 方法 | 最佳用途 |
|
||||
|------|---------|
|
||||
| `+` | 少量字符串,简单拼接 |
|
||||
| `fmt.Sprintf` | 混合类型的格式化输出 |
|
||||
| `strings.Builder` | 循环/逐段构建 |
|
||||
| `strings.Join` | 连接 slice |
|
||||
| 反引号字面量 | 常量多行文本 |
|
||||
|
||||
> 在选择字符串拼接策略、在循环中使用 strings.Builder 或在 fmt.Sprintf 和手动拼接之间做决定时,阅读 [references/STRING-OPTIMIZATION.md](references/STRING-OPTIMIZATION.md)。
|
||||
|
||||
---
|
||||
|
||||
## 基准测试和性能分析
|
||||
|
||||
在优化前后始终要进行测量。使用 Go 内置的基准测试框架和性能分析工具。
|
||||
|
||||
```bash
|
||||
go test -bench=. -benchmem -count=10 ./...
|
||||
```
|
||||
|
||||
> 在编写基准测试、使用 benchstat 比较结果、使用 pprof 进行性能分析或解读基准测试输出时,阅读 [references/BENCHMARKS.md](references/BENCHMARKS.md)。
|
||||
|
||||
> **验证**:在应用优化后,运行 `bash scripts/bench-compare.sh` 测量实际影响。只保留有可衡量改进的优化。
|
||||
|
||||
---
|
||||
|
||||
## 快速参考
|
||||
|
||||
| 模式 | 不好 | 好 | 改进 |
|
||||
|------|-----|------|-------------|
|
||||
| 整数转字符串 | `fmt.Sprint(n)` | `strconv.Itoa(n)` | 快约 2 倍 |
|
||||
| 重复 `[]byte` | 循环中 `[]byte("str")` | 在循环外转换一次 | 快约 7 倍 |
|
||||
| Map 初始化 | `make(map[K]V)` | `make(map[K]V, size)` | 更少分配 |
|
||||
| Slice 初始化 | `make([]T, 0)` | `make([]T, 0, cap)` | 快约 12 倍 |
|
||||
| 小型固定大小参数 | `*string`、`*io.Reader` | `string`、`io.Reader` | 无间接引用 |
|
||||
| 简单字符串连接 | `s1 + " " + s2` | (已经很好) | 对少量字符串使用 `+` |
|
||||
| 循环构建字符串 | 重复 `+=` | `strings.Builder` | O(n) vs O(n²) |
|
||||
|
||||
---
|
||||
|
||||
## 相关技能
|
||||
|
||||
- **数据结构**:在 slice、map 和数组之间选择或理解分配语义时,参见 [go-data-structures](../go-data-structures/SKILL.md)
|
||||
- **声明模式**:在使用 `make` 配合容量提示或初始化 map 和 slice 时,参见 [go-declarations](../go-declarations/SKILL.md)
|
||||
- **并发**:在跨 goroutine 并行化工作或使用 sync.Pool 复用缓冲区时,参见 [go-concurrency](../go-concurrency/SKILL.md)
|
||||
- **风格原则**:在判断优化是否值得牺牲可读性时,参见 [go-style-core](../go-style-core/SKILL.md)
|
||||
@@ -1,281 +0,0 @@
|
||||
# 基准测试方法
|
||||
|
||||
## 编写基准测试
|
||||
|
||||
Go 基准测试使用 `testing.B` 类型,位于 `_test.go` 文件中。
|
||||
基准测试函数名必须以 `Benchmark` 开头。
|
||||
|
||||
```go
|
||||
func BenchmarkStrconv(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
s := strconv.Itoa(rand.Int())
|
||||
_ = s
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkFmtSprint(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
s := fmt.Sprint(rand.Int())
|
||||
_ = s
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
关键规则:
|
||||
- 使用 `b.N` 作为循环边界——框架会调整它以获得稳定的计时
|
||||
- 将结果赋值给变量(或 `_`),防止编译器优化掉调用
|
||||
- 在不需要测量的昂贵设置之后使用 `b.ResetTimer()`
|
||||
- 使用 `b.ReportAllocs()` 或 `-benchmem` 标志跟踪分配情况
|
||||
|
||||
### 子基准测试
|
||||
|
||||
```go
|
||||
func BenchmarkConvert(b *testing.B) {
|
||||
for _, size := range []int{10, 100, 1000} {
|
||||
b.Run(fmt.Sprintf("size=%d", size), func(b *testing.B) {
|
||||
data := make([]byte, size)
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = string(data)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 运行基准测试
|
||||
|
||||
```bash
|
||||
# 运行包中的所有基准测试
|
||||
go test -bench=. ./...
|
||||
|
||||
# 运行特定基准测试并显示内存统计
|
||||
go test -bench=BenchmarkStrconv -benchmem ./...
|
||||
|
||||
# 多次运行以获得统计显著性
|
||||
go test -bench=. -benchmem -count=10 ./...
|
||||
```
|
||||
|
||||
`-benchmem` 标志报告每次操作的分配次数。`-count` 标志将每个基准测试运行 N 次以获得统计显著性。
|
||||
|
||||
---
|
||||
|
||||
## 解读结果
|
||||
|
||||
```
|
||||
BenchmarkStrconv-8 18705042 64.2 ns/op 16 B/op 1 allocs/op
|
||||
BenchmarkFmtSprint-8 8249536 143.0 ns/op 16 B/op 2 allocs/op
|
||||
```
|
||||
|
||||
| 字段 | 含义 |
|
||||
|------|------|
|
||||
| `-8` | GOMAXPROCS |
|
||||
| `18705042` | 迭代次数 |
|
||||
| `64.2 ns/op` | 每次操作时间 |
|
||||
| `16 B/op` | 每次操作分配的字节数 |
|
||||
| `1 allocs/op` | 每次操作的堆分配次数 |
|
||||
|
||||
---
|
||||
|
||||
## 使用 benchstat 进行比较
|
||||
|
||||
`benchstat` 对基准测试结果进行统计比较。安装它并将基准测试输出保存到文件:
|
||||
|
||||
```bash
|
||||
# 安装 benchstat
|
||||
go install golang.org/x/perf/cmd/benchstat@latest
|
||||
|
||||
# 运行基准测试并保存结果
|
||||
go test -bench=. -benchmem -count=10 ./... > old.txt
|
||||
|
||||
# 进行修改后再次运行
|
||||
go test -bench=. -benchmem -count=10 ./... > new.txt
|
||||
|
||||
# 比较结果
|
||||
benchstat old.txt new.txt
|
||||
```
|
||||
|
||||
### 解读 benchstat 输出
|
||||
|
||||
```
|
||||
name old time/op new time/op delta
|
||||
Strconv-8 64.2ns ± 2% 61.8ns ± 1% -3.74% (p=0.001 n=10+10)
|
||||
```
|
||||
|
||||
- **delta**:变化百分比(负数 = 更快)
|
||||
- **p-value**:统计显著性(p < 0.05 为显著)
|
||||
- **n**:使用的有效样本数量
|
||||
|
||||
提示:
|
||||
- 始终使用 `-count=10` 或更高以获得可靠结果
|
||||
- 小的 p 值确认变化是真实的,而非噪声
|
||||
- 如果 benchstat 显示 `~`(波浪号),则差异不具有统计显著性
|
||||
|
||||
---
|
||||
|
||||
## 来自性能模式的基准测试示例
|
||||
|
||||
### strconv vs fmt
|
||||
|
||||
| 方式 | 速度 | 分配次数 |
|
||||
|------|------|---------|
|
||||
| `fmt.Sprint` | 143 ns/op | 2 allocs/op |
|
||||
| `strconv.Itoa` | 64.2 ns/op | 1 allocs/op |
|
||||
|
||||
### 重复字节转换
|
||||
|
||||
```go
|
||||
func BenchmarkRepeatedConversion(b *testing.B) {
|
||||
var buf bytes.Buffer
|
||||
for i := 0; i < b.N; i++ {
|
||||
buf.Write([]byte("Hello world"))
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkSingleConversion(b *testing.B) {
|
||||
var buf bytes.Buffer
|
||||
data := []byte("Hello world")
|
||||
for i := 0; i < b.N; i++ {
|
||||
buf.Write(data)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 方式 | 速度 |
|
||||
|------|------|
|
||||
| 重复转换 | 22.2 ns/op |
|
||||
| 单次转换 | 3.25 ns/op |
|
||||
|
||||
### Slice 容量
|
||||
|
||||
```go
|
||||
func BenchmarkNoCapacity(b *testing.B) {
|
||||
for n := 0; n < b.N; n++ {
|
||||
data := make([]int, 0)
|
||||
for k := 0; k < 1000; k++ {
|
||||
data = append(data, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkWithCapacity(b *testing.B) {
|
||||
for n := 0; n < b.N; n++ {
|
||||
data := make([]int, 0, 1000)
|
||||
for k := 0; k < 1000; k++ {
|
||||
data = append(data, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 方式 | 时间(1 亿次迭代) |
|
||||
|------|------------------------|
|
||||
| 无容量 | 2.48s |
|
||||
| 指定容量 | 0.21s |
|
||||
|
||||
---
|
||||
|
||||
## 使用 pprof 进行性能分析
|
||||
|
||||
使用 `pprof` 在优化前识别瓶颈。基准测试衡量改进效果;pprof 找到需要改进的地方。
|
||||
|
||||
### CPU 性能分析
|
||||
|
||||
```bash
|
||||
# 从基准测试生成 CPU 分析文件
|
||||
go test -bench=BenchmarkHotPath -cpuprofile=cpu.prof ./...
|
||||
|
||||
# 使用 pprof 分析
|
||||
go tool pprof cpu.prof
|
||||
```
|
||||
|
||||
常用 pprof 命令:
|
||||
|
||||
```
|
||||
(pprof) top10 # 按 CPU 时间排列的前 10 个函数
|
||||
(pprof) list funcName # 某个函数的带注释源码
|
||||
(pprof) web # 浏览器中的交互式图表
|
||||
```
|
||||
|
||||
### 内存性能分析
|
||||
|
||||
```bash
|
||||
# 生成内存分析文件
|
||||
go test -bench=BenchmarkHotPath -memprofile=mem.prof ./...
|
||||
|
||||
# 分析分配情况
|
||||
go tool pprof -alloc_space mem.prof
|
||||
```
|
||||
|
||||
### 运行中服务的 HTTP 性能分析
|
||||
|
||||
```go
|
||||
import _ "net/http/pprof"
|
||||
|
||||
func main() {
|
||||
go func() {
|
||||
log.Println(http.ListenAndServe("localhost:6060", nil))
|
||||
}()
|
||||
// ... 应用程序代码 ...
|
||||
}
|
||||
```
|
||||
|
||||
通过 `http://localhost:6060/debug/pprof/` 访问性能分析数据。
|
||||
|
||||
### 性能分析工作流
|
||||
|
||||
1. 对疑似热点路径进行**基准测试**
|
||||
2. 使用 pprof **分析**以确认时间花在了哪里
|
||||
3. 使用本技能中的模式进行**优化**
|
||||
4. **重新基准测试**以用 benchstat 验证改进
|
||||
5. **重新分析**以检查是否出现新的瓶颈
|
||||
|
||||
---
|
||||
|
||||
## 常见错误
|
||||
|
||||
### 忽略 b.N
|
||||
|
||||
测试框架会调整 `b.N` 以获得稳定的计时。使用固定迭代次数会产生无意义的结果:
|
||||
|
||||
```go
|
||||
// 不好:忽略 b.N —— 基准测试框架无法校准
|
||||
func BenchmarkFixed(b *testing.B) {
|
||||
for i := 0; i < 1000; i++ {
|
||||
doWork()
|
||||
}
|
||||
}
|
||||
|
||||
// 好:使用 b.N 作为循环边界
|
||||
func BenchmarkCorrect(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
doWork()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 未防止编译器优化消除
|
||||
|
||||
如果函数调用的结果未被使用,编译器可能会完全优化掉该调用。将结果赋值给包级变量:
|
||||
|
||||
```go
|
||||
// 不好:编译器可能会优化掉调用
|
||||
func BenchmarkElided(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
expensiveFunc()
|
||||
}
|
||||
}
|
||||
|
||||
// 好:赋值给包级变量以防止优化消除
|
||||
var benchResult int
|
||||
|
||||
func BenchmarkKept(b *testing.B) {
|
||||
var r int
|
||||
for i := 0; i < b.N; i++ {
|
||||
r = expensiveFunc()
|
||||
}
|
||||
benchResult = r
|
||||
}
|
||||
```
|
||||
@@ -1,134 +0,0 @@
|
||||
# 字符串优化模式
|
||||
|
||||
## strconv vs fmt
|
||||
|
||||
在基本类型和字符串之间转换时,`strconv` 比 `fmt` 更快,因为 `fmt` 使用反射并处理任意类型。
|
||||
|
||||
**不好:**
|
||||
|
||||
```go
|
||||
for i := 0; i < b.N; i++ {
|
||||
s := fmt.Sprint(rand.Int())
|
||||
}
|
||||
```
|
||||
|
||||
**好:**
|
||||
|
||||
```go
|
||||
for i := 0; i < b.N; i++ {
|
||||
s := strconv.Itoa(rand.Int())
|
||||
}
|
||||
```
|
||||
|
||||
**基准测试比较:**
|
||||
|
||||
| 方式 | 速度 | 分配次数 |
|
||||
|------|------|---------|
|
||||
| `fmt.Sprint` | 143 ns/op | 2 allocs/op |
|
||||
| `strconv.Itoa` | 64.2 ns/op | 1 allocs/op |
|
||||
|
||||
常用转换:
|
||||
|
||||
| 任务 | `fmt` | `strconv` |
|
||||
|------|-------|-----------|
|
||||
| Int → string | `fmt.Sprint(n)` | `strconv.Itoa(n)` |
|
||||
| Int64 → string | `fmt.Sprint(n)` | `strconv.FormatInt(n, 10)` |
|
||||
| Float → string | `fmt.Sprint(f)` | `strconv.FormatFloat(f, 'f', -1, 64)` |
|
||||
| String → int | — | `strconv.Atoi(s)` |
|
||||
| Bool → string | `fmt.Sprint(b)` | `strconv.FormatBool(b)` |
|
||||
|
||||
---
|
||||
|
||||
## 重复的字符串到字节转换
|
||||
|
||||
不要重复从固定字符串创建字节切片。应该只转换一次并保存结果。
|
||||
|
||||
**不好:**
|
||||
|
||||
```go
|
||||
for i := 0; i < b.N; i++ {
|
||||
w.Write([]byte("Hello world"))
|
||||
}
|
||||
```
|
||||
|
||||
**好:**
|
||||
|
||||
```go
|
||||
data := []byte("Hello world")
|
||||
for i := 0; i < b.N; i++ {
|
||||
w.Write(data)
|
||||
}
|
||||
```
|
||||
|
||||
**基准测试比较:**
|
||||
|
||||
| 方式 | 速度 |
|
||||
|------|------|
|
||||
| 重复转换 | 22.2 ns/op |
|
||||
| 单次转换 | 3.25 ns/op |
|
||||
|
||||
好的版本**快约 7 倍**,因为它避免了每次迭代都分配新的字节切片。
|
||||
|
||||
---
|
||||
|
||||
## 字符串拼接
|
||||
|
||||
根据复杂度选择正确的字符串构建策略。
|
||||
|
||||
### 简单场景使用 `+`
|
||||
|
||||
```go
|
||||
key := "projectid: " + p
|
||||
```
|
||||
|
||||
`+` 运算符对于少量、固定数量的字符串是高效的。编译器通常可以优化相邻的字符串字面量。
|
||||
|
||||
### 格式化使用 `fmt.Sprintf`
|
||||
|
||||
```go
|
||||
// 好:清晰的格式化
|
||||
str := fmt.Sprintf("%s [%s:%d]-> %s", src, qos, mtu, dst)
|
||||
|
||||
// 不好:使用 + 手动转换
|
||||
str := src.String() + " [" + qos.String() + ":" + strconv.Itoa(mtu) + "]-> " + dst.String()
|
||||
```
|
||||
|
||||
当写入 `io.Writer` 时,直接使用 `fmt.Fprintf` 而不是先用 `fmt.Sprintf` 构建临时字符串。
|
||||
|
||||
### 逐段构建使用 `strings.Builder`
|
||||
|
||||
`strings.Builder` 花费摊销线性时间,而重复使用 `+` 或
|
||||
`fmt.Sprintf` 在构建大字符串时花费二次时间:
|
||||
|
||||
```go
|
||||
b := new(strings.Builder)
|
||||
for i, d := range digitsOfPi {
|
||||
fmt.Fprintf(b, "the %d digit of pi is: %d\n", i, d)
|
||||
}
|
||||
str := b.String()
|
||||
```
|
||||
|
||||
### 常量多行字符串使用反引号
|
||||
|
||||
```go
|
||||
// 好:原始字符串字面量
|
||||
usage := `Usage:
|
||||
|
||||
custom_tool [args]`
|
||||
|
||||
// 不好:使用转义序列拼接
|
||||
usage := "" +
|
||||
"Usage:\n" +
|
||||
"\n" +
|
||||
"custom_tool [args]"
|
||||
```
|
||||
|
||||
### 策略总结
|
||||
|
||||
| 方法 | 最佳用途 | 性能 |
|
||||
|------|---------|------|
|
||||
| `+` | 少量字符串,简单拼接 | 小 n 时 O(n) |
|
||||
| `fmt.Sprintf` | 格式化输出 | 较慢,但更清晰 |
|
||||
| `strings.Builder` | 循环/逐段构建 | 摊销 O(n) |
|
||||
| `strings.Join` | 连接 slice | O(n) |
|
||||
| 反引号字面量 | 常量多行文本 | 零开销 |
|
||||
@@ -1,252 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="1.1.0"
|
||||
SCRIPT_NAME="$(basename "$0")"
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
$SCRIPT_NAME v$VERSION — Run Go benchmarks with optional comparison
|
||||
|
||||
USAGE
|
||||
bash $SCRIPT_NAME [options] [package]
|
||||
|
||||
DESCRIPTION
|
||||
Wrapper around 'go test -bench' that runs benchmarks multiple times and
|
||||
optionally compares results against a saved baseline using benchstat.
|
||||
|
||||
Results can be saved to a file for future comparison. If benchstat is
|
||||
installed and a baseline is provided, a statistical comparison is shown.
|
||||
|
||||
EXIT CODES
|
||||
0 Benchmarks ran successfully
|
||||
1 go test failed (compilation error, test failure, no benchmarks found)
|
||||
2 Usage error (missing arguments, bad flags, file exists without --force)
|
||||
|
||||
OPTIONS
|
||||
-h, --help Show this help message
|
||||
-v, --version Show version
|
||||
-n, --count N Number of benchmark iterations (default: 5)
|
||||
-b, --baseline FILE Compare results against this baseline file
|
||||
-s, --save FILE Save benchmark results to this file
|
||||
-f, --filter REGEX Benchmark filter regex (default: ".")
|
||||
--json Output metadata as JSON (human output goes to stderr)
|
||||
--benchmem Include memory allocation stats (default: on)
|
||||
--no-benchmem Disable memory allocation stats
|
||||
--force Allow --save to overwrite existing files
|
||||
--limit N Max benchmark result lines to include (default: 0 = all)
|
||||
|
||||
ARGUMENTS
|
||||
package Go package to benchmark (default: ./...)
|
||||
|
||||
EXAMPLES
|
||||
bash $SCRIPT_NAME
|
||||
bash $SCRIPT_NAME -n 10 ./pkg/parser
|
||||
bash $SCRIPT_NAME --save baseline.txt ./...
|
||||
bash $SCRIPT_NAME --baseline baseline.txt --save current.txt ./...
|
||||
bash $SCRIPT_NAME --filter BenchmarkSort -n 3
|
||||
bash $SCRIPT_NAME --json --limit 5 ./...
|
||||
bash $SCRIPT_NAME --save results.txt --force ./...
|
||||
EOF
|
||||
}
|
||||
|
||||
json_escape() {
|
||||
local s="$1"
|
||||
s="${s//\\/\\\\}"
|
||||
s="${s//\"/\\\"}"
|
||||
s="${s//$'\t'/\\t}"
|
||||
s="${s//$'\r'/}"
|
||||
s="${s//$'\n'/\\n}"
|
||||
printf '%s' "$s"
|
||||
}
|
||||
|
||||
# Print human-readable output: stdout in text mode, stderr in JSON mode.
|
||||
log() {
|
||||
if $JSON_OUTPUT; then
|
||||
echo "$@" >&2
|
||||
else
|
||||
echo "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
COUNT=5
|
||||
BASELINE=""
|
||||
SAVE=""
|
||||
FILTER="."
|
||||
PACKAGE=""
|
||||
JSON_OUTPUT=false
|
||||
BENCHMEM=true
|
||||
FORCE=false
|
||||
LIMIT=0
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-h|--help) usage; exit 0 ;;
|
||||
-v|--version) echo "$SCRIPT_NAME v$VERSION"; exit 0 ;;
|
||||
-n|--count) COUNT="${2:?error: --count requires a number}"; shift 2 ;;
|
||||
-b|--baseline) BASELINE="${2:?error: --baseline requires a file path}"; shift 2 ;;
|
||||
-s|--save) SAVE="${2:?error: --save requires a file path}"; shift 2 ;;
|
||||
-f|--filter) FILTER="${2:?error: --filter requires a regex}"; shift 2 ;;
|
||||
--json) JSON_OUTPUT=true; shift ;;
|
||||
--benchmem) BENCHMEM=true; shift ;;
|
||||
--no-benchmem) BENCHMEM=false; shift ;;
|
||||
--force) FORCE=true; shift ;;
|
||||
--limit) LIMIT="${2:?error: --limit requires a number}"; shift 2 ;;
|
||||
-*) echo "error: unknown option: $1" >&2; usage >&2; exit 2 ;;
|
||||
*) PACKAGE="$1"; shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
PACKAGE="${PACKAGE:-./...}"
|
||||
|
||||
if ! command -v go &>/dev/null; then
|
||||
echo "error: 'go' command not found in PATH" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if ! [[ "$COUNT" =~ ^[1-9][0-9]*$ ]]; then
|
||||
echo "error: --count must be a positive integer, got: $COUNT" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if ! [[ "$LIMIT" =~ ^[0-9]+$ ]]; then
|
||||
echo "error: --limit must be a non-negative integer, got: $LIMIT" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [[ -n "$BASELINE" && ! -f "$BASELINE" ]]; then
|
||||
echo "error: baseline file not found: $BASELINE" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [[ -n "$SAVE" && -f "$SAVE" ]] && ! $FORCE; then
|
||||
echo "error: save target already exists: $SAVE (use --force to overwrite)" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
HAS_BENCHSTAT=false
|
||||
if command -v benchstat &>/dev/null; then
|
||||
HAS_BENCHSTAT=true
|
||||
fi
|
||||
|
||||
BENCH_ARGS=(-bench "$FILTER" -count "$COUNT" -run '^$')
|
||||
if $BENCHMEM; then
|
||||
BENCH_ARGS+=(-benchmem)
|
||||
fi
|
||||
|
||||
TMPFILE=$(mktemp "${TMPDIR:-/tmp}/bench-XXXXXX.txt")
|
||||
trap 'rm -f "$TMPFILE"' EXIT
|
||||
|
||||
log "Running benchmarks: go test ${BENCH_ARGS[*]} $PACKAGE"
|
||||
log "Iterations: $COUNT"
|
||||
log ""
|
||||
|
||||
GO_EXIT=0
|
||||
if $JSON_OUTPUT; then
|
||||
go test "${BENCH_ARGS[@]}" "$PACKAGE" 2>&1 | tee "$TMPFILE" >&2 || GO_EXIT=$?
|
||||
else
|
||||
go test "${BENCH_ARGS[@]}" "$PACKAGE" 2>&1 | tee "$TMPFILE" || GO_EXIT=$?
|
||||
fi
|
||||
|
||||
BENCH_COUNT=$(grep -cE '^Benchmark' "$TMPFILE" || true)
|
||||
|
||||
TRUNCATED=false
|
||||
if [[ $LIMIT -gt 0 && $BENCH_COUNT -gt $LIMIT ]]; then
|
||||
TRUNCATED=true
|
||||
fi
|
||||
|
||||
if ! $JSON_OUTPUT && $TRUNCATED; then
|
||||
log ""
|
||||
log "Note: $BENCH_COUNT benchmark results found, showing first $LIMIT (--limit $LIMIT)"
|
||||
fi
|
||||
|
||||
if [[ -n "$SAVE" ]]; then
|
||||
cp "$TMPFILE" "$SAVE"
|
||||
log ""
|
||||
log "Results saved to: $SAVE"
|
||||
fi
|
||||
|
||||
if [[ -n "$BASELINE" ]]; then
|
||||
log ""
|
||||
log "=== Comparison with baseline: $BASELINE ==="
|
||||
log ""
|
||||
if $HAS_BENCHSTAT; then
|
||||
if $JSON_OUTPUT; then
|
||||
benchstat "$BASELINE" "$TMPFILE" >&2 || true
|
||||
else
|
||||
benchstat "$BASELINE" "$TMPFILE" || true
|
||||
fi
|
||||
else
|
||||
log "note: install benchstat for statistical comparison:"
|
||||
log " go install golang.org/x/perf/cmd/benchstat@latest"
|
||||
log ""
|
||||
log "--- Baseline ---"
|
||||
if $JSON_OUTPUT; then
|
||||
grep -E '^Benchmark' "$BASELINE" >&2 || true
|
||||
else
|
||||
grep -E '^Benchmark' "$BASELINE" || true
|
||||
fi
|
||||
log ""
|
||||
log "--- Current ---"
|
||||
if $JSON_OUTPUT; then
|
||||
grep -E '^Benchmark' "$TMPFILE" >&2 || true
|
||||
else
|
||||
grep -E '^Benchmark' "$TMPFILE" || true
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
FINAL_EXIT=0
|
||||
if [[ $GO_EXIT -ne 0 ]]; then
|
||||
FINAL_EXIT=1
|
||||
if ! $JSON_OUTPUT; then
|
||||
log ""
|
||||
log "error: go test exited with code $GO_EXIT"
|
||||
fi
|
||||
elif [[ $BENCH_COUNT -eq 0 ]]; then
|
||||
FINAL_EXIT=1
|
||||
if ! $JSON_OUTPUT; then
|
||||
log ""
|
||||
log "error: no benchmarks found matching filter: $FILTER"
|
||||
fi
|
||||
fi
|
||||
|
||||
if $JSON_OUTPUT; then
|
||||
BENCH_OUTPUT=$(<"$TMPFILE")
|
||||
if $TRUNCATED; then
|
||||
limited=""
|
||||
bench_seen=0
|
||||
while IFS= read -r line; do
|
||||
if [[ "$line" =~ ^Benchmark ]]; then
|
||||
bench_seen=$((bench_seen + 1))
|
||||
if [[ $bench_seen -le $LIMIT ]]; then
|
||||
limited+="$line"$'\n'
|
||||
fi
|
||||
else
|
||||
limited+="$line"$'\n'
|
||||
fi
|
||||
done < "$TMPFILE"
|
||||
BENCH_OUTPUT="$limited"
|
||||
fi
|
||||
|
||||
escaped_package=$(json_escape "$PACKAGE")
|
||||
escaped_filter=$(json_escape "$FILTER")
|
||||
escaped_baseline=$(json_escape "$BASELINE")
|
||||
escaped_save=$(json_escape "$SAVE")
|
||||
escaped_output=$(json_escape "$BENCH_OUTPUT")
|
||||
|
||||
printf '{"count":%d,' "$COUNT"
|
||||
printf '"package":"%s",' "$escaped_package"
|
||||
printf '"filter":"%s",' "$escaped_filter"
|
||||
printf '"benchmarks_found":%d,' "$BENCH_COUNT"
|
||||
printf '"baseline":"%s",' "$escaped_baseline"
|
||||
printf '"save":"%s",' "$escaped_save"
|
||||
printf '"exit_code":%d,' "$GO_EXIT"
|
||||
printf '"output":"%s"' "$escaped_output"
|
||||
if $TRUNCATED; then
|
||||
printf ',"truncated":true'
|
||||
fi
|
||||
printf '}\n'
|
||||
fi
|
||||
|
||||
exit $FINAL_EXIT
|
||||
@@ -1,179 +0,0 @@
|
||||
---
|
||||
name: go-style-core
|
||||
description: Use when working with Go formatting, line length, nesting, naked returns, semicolons, or core style principles. Also use when a style question isn't covered by a more specific skill, even if the user doesn't reference a specific style rule. Does not cover domain-specific patterns like error handling, naming, or testing (see specialized skills). Acts as fallback when no more specific style skill applies.
|
||||
license: Apache-2.0
|
||||
metadata:
|
||||
sources: "Effective Go, Google Style Guide, Uber Style Guide, Go Wiki CodeReviewComments"
|
||||
---
|
||||
|
||||
# Go 风格核心原则
|
||||
|
||||
## 风格原则(优先级顺序)
|
||||
|
||||
编写可读 Go 代码时,按以下重要性顺序应用这些原则:
|
||||
|
||||
### 优先级顺序
|
||||
|
||||
1. **清晰性** — 读者能否在没有额外上下文的情况下理解代码?
|
||||
2. **简洁性** — 这是否是实现目标的最简单方式?
|
||||
3. **精炼性** — 每一行是否都有其存在的价值?
|
||||
4. **可维护性** — 后续修改是否容易?
|
||||
5. **一致性** — 是否与周围代码和项目约定保持一致?
|
||||
|
||||
> 在解决清晰性、简洁性和精炼性之间的冲突时,或需要具体示例了解每个原则在实际 Go 代码中的应用时,请阅读 [references/PRINCIPLES.md](references/PRINCIPLES.md)。
|
||||
|
||||
---
|
||||
|
||||
## 格式化
|
||||
|
||||
运行 `gofmt` — 没有例外。**没有严格的行长度限制**,但 Uber 建议软限制为 99 个字符。按语义换行,而非按长度 — 选择重构而非仅仅换行。
|
||||
|
||||
> 在配置 gofmt、决定换行策略、应用 MixedCaps 规则或解决局部一致性问题时,请阅读 [references/FORMATTING.md](references/FORMATTING.md)。
|
||||
|
||||
---
|
||||
|
||||
## 减少嵌套
|
||||
|
||||
优先处理错误情况和特殊条件。提前返回或继续循环,使"正常路径"保持无缩进。
|
||||
|
||||
```go
|
||||
// 不好:深度嵌套
|
||||
for _, v := range data {
|
||||
if v.F1 == 1 {
|
||||
v = process(v)
|
||||
if err := v.Call(); err == nil {
|
||||
v.Send()
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
log.Printf("Invalid v: %v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// 好:扁平结构,提前返回
|
||||
for _, v := range data {
|
||||
if v.F1 != 1 {
|
||||
log.Printf("Invalid v: %v", v)
|
||||
continue
|
||||
}
|
||||
|
||||
v = process(v)
|
||||
if err := v.Call(); err != nil {
|
||||
return err
|
||||
}
|
||||
v.Send()
|
||||
}
|
||||
```
|
||||
|
||||
### 不必要的 Else
|
||||
|
||||
如果变量在 if 的两个分支中都被赋值,使用默认值 + 覆盖模式。
|
||||
|
||||
```go
|
||||
// 不好:在两个分支中都赋值
|
||||
var a int
|
||||
if b {
|
||||
a = 100
|
||||
} else {
|
||||
a = 10
|
||||
}
|
||||
|
||||
// 好:默认值 + 覆盖
|
||||
a := 10
|
||||
if b {
|
||||
a = 100
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 裸返回
|
||||
|
||||
没有参数的 `return` 语句会返回命名返回值。这被称为"裸"返回。
|
||||
|
||||
```go
|
||||
func split(sum int) (x, y int) {
|
||||
x = sum * 4 / 9
|
||||
y = sum - x
|
||||
return // 返回 x, y
|
||||
}
|
||||
```
|
||||
|
||||
### 裸返回的使用指南
|
||||
|
||||
- **在小型函数中可以使用**:裸返回在只有几行的函数中是没问题的
|
||||
- **在中大型函数中要明确**:一旦函数增长到中等大小,为了清晰起见应明确指定返回值
|
||||
- **不要仅为了裸返回而命名返回值**:文档的清晰性始终比节省一两行更重要
|
||||
|
||||
```go
|
||||
// 好:小型函数,裸返回很清晰
|
||||
func minMax(a, b int) (min, max int) {
|
||||
if a < b {
|
||||
min, max = a, b
|
||||
} else {
|
||||
min, max = b, a
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 好:较大的函数,显式返回
|
||||
func processData(data []byte) (result []byte, err error) {
|
||||
result = make([]byte, 0, len(data))
|
||||
|
||||
for _, b := range data {
|
||||
if b == 0 {
|
||||
return nil, errors.New("null byte in data")
|
||||
}
|
||||
result = append(result, transform(b))
|
||||
}
|
||||
|
||||
return result, nil // 显式返回:在较长的函数中更清晰
|
||||
}
|
||||
```
|
||||
|
||||
关于命名返回参数的指导,请参阅 **go-documentation**。
|
||||
|
||||
---
|
||||
|
||||
## 分号
|
||||
|
||||
Go 的词法分析器会在任何最后一个 token 是标识符、字面量或以下关键字之一的行后自动插入分号:`break continue fallthrough return ++ -- ) }`。
|
||||
|
||||
这意味着 **左花括号必须与控制结构在同一行**:
|
||||
|
||||
```go
|
||||
// 好:花括号在同一行
|
||||
if i < f() {
|
||||
g()
|
||||
}
|
||||
|
||||
// 不好:花括号在下一行 — 词法分析器会在 f() 后插入分号
|
||||
if i < f() // 错误!
|
||||
{ // 错误!
|
||||
g()
|
||||
}
|
||||
```
|
||||
|
||||
在惯用 Go 中,显式分号仅出现在 `for` 循环子句中和用于分隔单行上的多个语句。
|
||||
|
||||
---
|
||||
|
||||
## 快速参考
|
||||
|
||||
| 原则 | 核心问题 |
|
||||
|------|----------|
|
||||
| 清晰性 | 读者能否理解代码的意图和原因? |
|
||||
| 简洁性 | 这是否是最简单的方法? |
|
||||
| 精炼性 | 信噪比是否高? |
|
||||
| 可维护性 | 后续能否安全地修改? |
|
||||
| 一致性 | 是否与周围代码保持一致? |
|
||||
|
||||
## 相关 Skill
|
||||
|
||||
- **命名约定**:在应用 MixedCaps、选择标识符名称或解决命名争议时,请参阅 [go-naming](../go-naming/SKILL.md)
|
||||
- **错误流程**:在构建错误优先的守卫子句或通过提前返回减少嵌套时,请参阅 [go-error-handling](../go-error-handling/SKILL.md)
|
||||
- **文档**:在编写文档注释、命名返回参数或包级别文档时,请参阅 [go-documentation](../go-documentation/SKILL.md)
|
||||
- **Linting 执行**:在使用 golangci-lint 自动化风格检查或配置 CI 时,请参阅 [go-linting](../go-linting/SKILL.md)
|
||||
- **代码审查**:在系统性代码审查中应用风格原则时,请参阅 [go-code-review](../go-code-review/SKILL.md)
|
||||
- **日志风格**:在审查日志实践、在 log 和 slog 之间选择或组织日志输出时,请参阅 [go-logging](../go-logging/SKILL.md)
|
||||
@@ -1,95 +0,0 @@
|
||||
# 格式化参考
|
||||
|
||||
## gofmt 是必须的
|
||||
|
||||
所有 Go 源文件 **必须** 符合 `gofmt` 的输出。没有例外。
|
||||
|
||||
```bash
|
||||
# 格式化一个文件
|
||||
gofmt -w myfile.go
|
||||
|
||||
# 格式化目录下所有文件
|
||||
gofmt -w .
|
||||
```
|
||||
|
||||
其他格式化工具:
|
||||
|
||||
| 工具 | 用途 |
|
||||
|------|------|
|
||||
| `gofmt` | 标准格式化工具(必须使用) |
|
||||
| `goimports` | gofmt + import 管理 |
|
||||
| `gofumpt` | gofmt 的更严格超集 |
|
||||
|
||||
---
|
||||
|
||||
## 括号
|
||||
|
||||
Go 比 C 和 Java 需要更少的括号。控制结构(`if`、`for`、`switch`)的语法中不需要括号。运算符优先级层次更短更清晰,所以 `x<<8 + y<<16` 的含义就如空格所暗示的那样 — 不像其他语言。
|
||||
|
||||
---
|
||||
|
||||
## MixedCaps(驼峰命名)
|
||||
|
||||
Go 使用 `MixedCaps` 或 `mixedCaps`,从不使用下划线:
|
||||
|
||||
```go
|
||||
// 好
|
||||
MaxLength // 导出常量
|
||||
maxLength // 非导出常量
|
||||
userID // 变量
|
||||
|
||||
// 不好
|
||||
MAX_LENGTH // 不使用 snake_case
|
||||
max_length // 不使用下划线
|
||||
```
|
||||
|
||||
例外:
|
||||
- 测试函数名可以使用下划线:`TestFoo_Bar`
|
||||
- 与 OS/cgo 交互的生成代码
|
||||
|
||||
---
|
||||
|
||||
## 行长度
|
||||
|
||||
Go 中 **没有严格的行长度限制**,但避免过长的行。Uber 建议软限制为 99 个字符。
|
||||
|
||||
指导原则:
|
||||
- 如果一行感觉太长,**重构** 而非仅仅换行
|
||||
- 不要在缩进变化之前换行(函数声明、条件语句)
|
||||
- 不要将长字符串(URL)拆分成多行
|
||||
- 换行时,将所有参数放在各自的行上
|
||||
- 如果已经尽可能短了,就让它保持长行
|
||||
|
||||
**按语义换行,而非按长度**:
|
||||
|
||||
不要仅仅为了保持短行而添加换行符,当长行更具可读性时(例如,重复性的行)。因为你所写的内容而换行,而非因为行长度。
|
||||
|
||||
长行通常与长名称相关。如果你发现行太长,考虑名称是否可以更短。去掉长名称往往比换行更有帮助。
|
||||
|
||||
这个建议同样适用于函数长度 — 没有"函数永远不超过 N 行"的规则,但确实存在太长的情况。解决方案是改变函数的边界在哪里,而非计算行数。
|
||||
|
||||
```go
|
||||
// 不好:随意的行中断
|
||||
func (s *Store) GetUser(ctx context.Context,
|
||||
id string) (*User, error) {
|
||||
|
||||
// 好:所有参数各占一行
|
||||
func (s *Store) GetUser(
|
||||
ctx context.Context,
|
||||
id string,
|
||||
) (*User, error) {
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 局部一致性
|
||||
|
||||
当风格指南未做规定时,与附近代码保持一致:
|
||||
|
||||
**有效的** 局部选择:
|
||||
- 错误格式化使用 `%s` 还是 `%v`
|
||||
- 带缓冲 channel 还是 mutex
|
||||
|
||||
**无效的** 局部覆盖:
|
||||
- 行长度限制
|
||||
- 基于断言的测试库
|
||||
@@ -1,89 +0,0 @@
|
||||
# 风格原则参考
|
||||
|
||||
## 1. 清晰性
|
||||
|
||||
代码的目的和原理必须对读者清晰。
|
||||
|
||||
- **做什么**:使用描述性名称、有帮助的注释和高效的组织
|
||||
- **为什么**:添加解释原理的注释,特别是对于微妙的细节
|
||||
- 从读者的角度审视清晰性,而非作者的角度
|
||||
- 代码应该易于阅读,而非易于编写
|
||||
|
||||
```go
|
||||
// 好:目的清晰
|
||||
func (c *Config) WriteTo(w io.Writer) (int64, error)
|
||||
|
||||
// 不好:不清晰,重复了接收者
|
||||
func (c *Config) WriteConfigTo(w io.Writer) (int64, error)
|
||||
```
|
||||
|
||||
## 2. 简洁性
|
||||
|
||||
代码应该以最简单的方式实现目标。
|
||||
|
||||
简洁的代码:
|
||||
- 从头到尾容易阅读
|
||||
- 不假定读者有先验知识
|
||||
- 没有不必要的抽象层次
|
||||
- 注释解释"为什么",而非"做什么"
|
||||
- 可能与"巧妙"的代码互斥
|
||||
|
||||
### 最少机制
|
||||
|
||||
当有几种方式表达同一个想法时,优先使用最标准的工具:
|
||||
|
||||
1. 核心语言结构(channel、slice、map、loop、struct)
|
||||
2. 标准库(HTTP 客户端、模板引擎)
|
||||
3. 第三方库 — 仅在 (1) 和 (2) 不够用时使用
|
||||
|
||||
## 3. 精炼性
|
||||
|
||||
代码应该有高信噪比。
|
||||
|
||||
- 避免重复代码
|
||||
- 避免多余的语法
|
||||
- 避免不必要的抽象
|
||||
- 使用表驱动测试提取公共代码
|
||||
|
||||
```go
|
||||
// 好:常见惯用法,信号量高
|
||||
if err := doSomething(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 好:为异常情况增强信号
|
||||
if err := doSomething(); err == nil { // 如果没有错误
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## 4. 可维护性
|
||||
|
||||
代码被修改的次数远多于被编写的次数。
|
||||
|
||||
可维护的代码:
|
||||
- 对于未来的程序员来说容易正确修改
|
||||
- API 能够优雅地扩展
|
||||
- 使用可预测的名称(相同概念 = 相同名称)
|
||||
- 最小化依赖
|
||||
- 具有全面的测试和清晰的诊断信息
|
||||
|
||||
```go
|
||||
// 不好:关键细节被隐藏
|
||||
if user, err = db.UserByID(userID); err != nil { // = vs :=
|
||||
|
||||
// 好:显式且清晰
|
||||
u, err := db.UserByID(userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid origin user: %s", err)
|
||||
}
|
||||
user = u
|
||||
```
|
||||
|
||||
## 5. 一致性
|
||||
|
||||
代码的外观和行为应该与代码库中的类似代码一致。
|
||||
|
||||
- 包级别的一致性最重要
|
||||
- 当出现平局时,优先保持一致性
|
||||
- 绝不为了局部一致性而覆盖有文档记录的风格原则
|
||||
@@ -1,146 +0,0 @@
|
||||
---
|
||||
name: "new-api"
|
||||
description: "Wavelet 项目专用:当新增或修改自定义业务 API、新增业务路由、新增 service 层核心逻辑时必须使用。本技能指导包职责划分、推荐文件结构、路由解耦、Swagger 文档生成与质量门禁验证。"
|
||||
---
|
||||
|
||||
# 新增业务 API 开发与路由注册规范
|
||||
|
||||
本技能是 Wavelet 项目接口开发与路由注册的唯一指导规范。在开发任何新接口前,请严格按照本指南进行架构决策与路由注册。
|
||||
|
||||
---
|
||||
|
||||
## 核心路由准则与防线 (Routing Governance & Guardrails)
|
||||
|
||||
Wavelet 后端路由采用了**严格的框架层与业务层隔离机制**。请牢记以下开发原则:
|
||||
|
||||
1. **禁止修改框架级路由文件**:
|
||||
- 以下文件属于系统框架/平台级接口,**禁止为了添加自定义业务接口而进行任何修改**:
|
||||
- `internal/router/router.go`(核心入口委派)
|
||||
- `internal/router/root/default.go`(公开文件服务、robots.txt、Swagger 及 /api/health 路由)
|
||||
- `internal/router/root/frontend.go`(前端静态服务)
|
||||
- `internal/router/v1/v1.go`(V1 分发层协调器)
|
||||
- `internal/router/v1/admin.go`(框架管理员端管理接口)
|
||||
- `internal/router/v1/user.go`(框架普通用户端基础接口、OAuth及公开接口)
|
||||
2. **仅允许在 `custom.go` 中注册业务接口**:
|
||||
- 所有的自定义/业务相关接口注册,有且仅有以下两个合法的承载点:
|
||||
- [internal/router/root/custom.go](file:///Users/ryan/DEV/Go/Wavelet/internal/router/root/custom.go)(用于挂载到根路径的特殊业务接口)
|
||||
- [internal/router/v1/custom.go](file:///Users/ryan/DEV/Go/Wavelet/internal/router/v1/custom.go)(用于挂载在 API V1 下的标准自定义业务接口)
|
||||
|
||||
---
|
||||
|
||||
## 路由归属判定表 (Where should I register my new API?)
|
||||
|
||||
根据接口的**访问路径特征**和**访问身份/限制条件**,决定将新开发的 API 挂载至何处:
|
||||
|
||||
| 目标 API 路径特征 | 访问身份/条件限制 | 对应的路由注册入口 | 是否允许修改 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **`/my-custom-path`** (挂载在根路径下的特殊业务接口) | 自定义控制 | `root/custom.go` 中的 `RegisterCustomRootRoutes` | **允许修改 (业务自定义入口)** |
|
||||
| **`/api/v1/custom/...`** (API v1 下的定制业务接口) | 自定义控制 | `v1/custom.go` 中的 `RegisterCustomRoutes` | **允许修改 (业务自定义入口)** |
|
||||
| **`/api/v1/admin/...`** (系统管理员管理端接口) | 需要管理员登录 (`admin.LoginAdminRequired()`) | `v1/admin.go` | **禁止修改 (仅限系统框架路由)** |
|
||||
| **`/api/v1/user/...`** (框架普通用户基础接口) | 需要普通用户登录 (`oauth.LoginRequired()`) | `v1/user.go` | **禁止修改 (仅限系统框架路由)** |
|
||||
| **`/api/v1/public/...`** (Captcha、Config 等系统公开接口) | 所有人 (无条件 / 公开) | `v1/user.go` | **禁止修改 (仅限系统框架路由)** |
|
||||
| **`GET /f/:id`**, **`GET /robots.txt`**, **`GET /api/health`** (系统级默认及公开接口) | 所有人 (无条件 / 公开) | `root/default.go` | **禁止修改 (仅限系统框架路由)** |
|
||||
|
||||
---
|
||||
|
||||
## 两个自定义路由包的用法与区别 (Root Custom vs V1 Custom)
|
||||
|
||||
### 1. 根路径自定义包:`root/custom.go`
|
||||
|
||||
* **适用场景**:适用于需要**直接挂载在主域名根路径下**的特殊自定义业务接口(如第三方 Webhook 回调、特定的短链接重定向、外部数据接口等,不需要 `/api/v1` 前缀)。
|
||||
* **用法示例**:
|
||||
在 [root/custom.go](file:///Users/ryan/DEV/Go/Wavelet/internal/router/root/custom.go) 中实现:
|
||||
```go
|
||||
package root
|
||||
|
||||
import (
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/custom"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// RegisterCustomRootRoutes registers custom business routes that belong to the root path.
|
||||
func RegisterCustomRootRoutes(r *gin.Engine) {
|
||||
// 挂载到根路径下,如 GET /my-custom-webhook
|
||||
r.GET("/my-custom-webhook", custom.HandleRootWebhook)
|
||||
}
|
||||
```
|
||||
*(注:该函数已由 `root.go` 自动加载,你无需修改任何其他核心文件。)*
|
||||
|
||||
### 2. V1 API 自定义包:`v1/custom.go`
|
||||
|
||||
* **适用场景**:适用于普通的**自定义业务 API**,需要规范挂载在标准 API V1 路径下(即自动带有 `/api/v1/custom/...` 前缀,可选择性配置用户/管理员登录中间件)。
|
||||
* **用法示例**:
|
||||
在 [v1/custom.go](file:///Users/ryan/DEV/Go/Wavelet/internal/router/v1/custom.go) 中实现:
|
||||
```go
|
||||
package v1
|
||||
|
||||
import (
|
||||
"github.com/Rain-kl/Wavelet/internal/apps/custom"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// RegisterCustomRoutes registers standard custom API routes under /api/v1.
|
||||
func RegisterCustomRoutes(apiV1Router *gin.RouterGroup) {
|
||||
customRouter := apiV1Router.Group("/custom")
|
||||
{
|
||||
// 挂载到 /api/v1/custom 下,例如:POST /api/v1/custom/action
|
||||
customRouter.POST("/action", custom.DoActionHandler)
|
||||
}
|
||||
}
|
||||
```
|
||||
*(注:该函数已由 `v1/v1.go` 自动加载,你无需修改任何其他核心文件。)*
|
||||
|
||||
---
|
||||
|
||||
## 建议创建/修改的文件结构 (Recommended Directory Structure)
|
||||
|
||||
当新增一套定制的业务接口(例如名为 `custom` 的业务模块)时,建议采用以下标准文件结构:
|
||||
|
||||
```text
|
||||
internal/
|
||||
├── router/
|
||||
│ ├── root/
|
||||
│ │ └── custom.go # [修改] 若为根路径 API,在此处注册,将路由委派给 apps/custom
|
||||
│ └── v1/
|
||||
│ └── custom.go # [修改] 若为 v1 API,在此处注册,将路由委派给 apps/custom
|
||||
└── apps/
|
||||
└── custom/
|
||||
├── routers.go # [新建] HTTP Handlers (Gin),负责参数绑定、校验与响应
|
||||
├── logics.go # [新建] 业务逻辑层:承载模块内闭环的纯 Go 业务逻辑,不依赖 gin.Context
|
||||
└── errs.go # [新建] 存放模块特有的业务错误常量定义(可选)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 核心开发步骤 (Step-by-Step Flow)
|
||||
|
||||
### 步骤 1:数据库定义与迁移
|
||||
如果自定义功能涉及新表或字段,请参考 [database-migration](../database-migration/SKILL.md) 技能,在 `internal/db/migrator/goose/` 目录下编写迁移文件并在 `internal/model/` 中定义 GORM 数据模型。
|
||||
|
||||
### 步骤 2:在模块内实现业务逻辑 (`logics.go` / `service.go`)
|
||||
业务逻辑逻辑应当实现于 `internal/apps/custom/` 目录下:
|
||||
- **优先使用纯函数(`logics.go`)**:定义接收 `context.Context` 且不依赖 `*gin.Context` 的函数,易于单元测试与 Worker 复用。参考 `internal/apps/user/logics.go`。
|
||||
- **有状态服务(`service.go`)**:若需注入依赖(如 DB 连接、外部客户端等),可定义 Service 结构体和构造函数。
|
||||
- **跨模块副作用(推送、任务监听等)**:核心业务代码通过 `internal/listener` 发射域事件,禁止直接 `import` push 模块;装配在 `internal/bootstrap` 完成(参见 `push-notification` skill)。
|
||||
|
||||
### 步骤 3:编写 HTTP Handler (`routers.go`)
|
||||
在 `internal/apps/custom/routers.go` 中编写 Handler:
|
||||
- 负责请求参数绑定与校验(使用 `ShouldBindJSON`/`ShouldBindQuery`)。
|
||||
- 负责提取 Session / 用户身份。
|
||||
- 调用业务逻辑层,并使用 `github.com/Rain-kl/Wavelet/internal/common/response` 统一返回响应:
|
||||
- 成功时返回:`response.OK(data)` 或 `response.OKNil()`
|
||||
- 失败时返回:`response.Err(msg)`
|
||||
- 编写规范的 Swagger 注释。
|
||||
|
||||
### 步骤 4:在自定义包中注册路由并委派
|
||||
根据 **路由归属判定表**,在 [root/custom.go](file:///Users/ryan/DEV/Go/Wavelet/internal/router/root/custom.go) 或 [v1/custom.go](file:///Users/ryan/DEV/Go/Wavelet/internal/router/v1/custom.go) 中编写注册代码,将路由路径绑定到步骤 3 中编写的 Handler。
|
||||
|
||||
---
|
||||
|
||||
## 质量验证门禁 (Quality Gates)
|
||||
|
||||
每次新增或修改接口后,必须运行并验证以下各项:
|
||||
1. **自动授权许可**:`make license`(新增 Go 文件时自动添加许可头)
|
||||
2. **重新生成 Swagger 文档**:`make swagger`(若有 Swagger 注释修改)
|
||||
3. **静态代码及风格检查**:`make code-check`(确保通过 golangci-lint 和前端 TS 检查)
|
||||
4. **自动化单元测试**:`go test ./...`(确保所有测试 100% 通过)
|
||||
@@ -1,58 +0,0 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package references
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/service"
|
||||
"github.com/Rain-kl/Wavelet/internal/util"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// customRequest 客户端请求体 DTO
|
||||
type customRequest struct {
|
||||
Payload string `json:"payload" binding:"required,min=1,max=100"`
|
||||
}
|
||||
|
||||
// customResponse API 响应体 DTO
|
||||
type customResponse struct {
|
||||
Result string `json:"result"`
|
||||
}
|
||||
|
||||
// HandleCustomBusiness 示例 API Handler
|
||||
// @Summary 示例定制业务接口
|
||||
// @Description 接收数据载荷,调用 Service 执行核心逻辑,并返回统一格式的 JSON 结果。
|
||||
// @Tags custom
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body customRequest true "业务请求参数"
|
||||
// @Success 200 {object} util.ResponseAny{data=customResponse} "操作成功"
|
||||
// @Router /api/v1/custom/business [post]
|
||||
func HandleCustomBusiness(c *gin.Context) {
|
||||
// 1. 参数绑定与校验
|
||||
var req customRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, util.Err("参数校验失败:载荷不能为空且在 1-100 字符内"))
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 模拟获取当前上下文与已登录用户(例如从 Session 中提取)
|
||||
// 通常结合 oauth.LoginRequired() 等中间件使用
|
||||
userID := int64(9527)
|
||||
|
||||
// 3. 实例化业务 Service 并调用核心逻辑
|
||||
// 注意传入 c.Request.Context() 以正确传递 OpenTelemetry Tracing 等上下文信息
|
||||
svc := service.NewCustomService()
|
||||
resText, err := svc.ProcessBusinessData(c.Request.Context(), userID, req.Payload)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// 4. 返回符合外层形状规范 { "error_msg": "", "data": ... } 的统一成功响应
|
||||
c.JSON(http.StatusOK, util.OK(customResponse{
|
||||
Result: resText,
|
||||
}))
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package references
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/pkg/logger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// ProcessLocalBusiness 示例的模块内部闭环业务逻辑
|
||||
// 1. 存放在 apps/custom/logics.go 下,遵循纯 Go 规范,不强依赖 gin.Context,以便逻辑清晰和便于单元测试。
|
||||
// 2. 用于当前应用模块内的简单业务或通用过程。
|
||||
func ProcessLocalBusiness(ctx context.Context, userID int64, param string) (string, error) {
|
||||
if param == "" {
|
||||
return "", errors.New("param cannot be empty")
|
||||
}
|
||||
|
||||
logger.Info(ctx, "processing local business inside apps/custom/logics",
|
||||
zap.Int64("user_id", userID),
|
||||
zap.String("param", param),
|
||||
)
|
||||
|
||||
// 执行轻量级、无需跨模块/多入口复用的本地计算或模型操作
|
||||
result := fmt.Sprintf("Processed local logic for user %d: %s", userID, param)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package references
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/pkg/logger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// CustomService 示例业务 Service 结构体(通常放在 internal/apps/custom/service.go 中)
|
||||
type CustomService struct {
|
||||
// 这里可以注入数据库连接、配置对象或者其他基础服务的客户端
|
||||
// 例如:db *gorm.DB
|
||||
}
|
||||
|
||||
// NewCustomService 创建 CustomService 实例的构造函数
|
||||
func NewCustomService() *CustomService {
|
||||
return &CustomService{}
|
||||
}
|
||||
|
||||
// ProcessBusinessData 演示核心业务处理逻辑的 Service 方法
|
||||
// 1. 首位参数必须是 context.Context,以传播链路追踪 (OTel) 和超时控制。
|
||||
// 2. 方法签名应该只包含纯 Go 的参数与返回值,禁止导入 Gin 或与 HTTP 相关的协议依赖。
|
||||
// 3. 将可能发生的核心异常通过 error 返回给上层,而不是在这一层转换成 HTTP 状态码。
|
||||
func (s *CustomService) ProcessBusinessData(ctx context.Context, userID int64, payload string) (string, error) {
|
||||
if payload == "" {
|
||||
return "", errors.New("payload cannot be empty")
|
||||
}
|
||||
|
||||
// 模拟执行业务逻辑...
|
||||
logger.Info(ctx, "processing custom business data in service",
|
||||
zap.Int64("user_id", userID),
|
||||
zap.String("payload", payload),
|
||||
)
|
||||
|
||||
// 这里可以包含数据库读写、事务控制、或者远程 API 调用等复杂逻辑。
|
||||
result := fmt.Sprintf("Success processed data for user %d: %s", userID, payload)
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
---
|
||||
name: plan
|
||||
description: 项目级技能:规定在开启新方案、新计划或进行任务交接时,必须将计划落库到 docs/plan 文件夹中并使用对应模板。
|
||||
---
|
||||
|
||||
# Plan & Handover Skill
|
||||
|
||||
当你在当前项目中被要求“开启一个新的方案”、“制定开发计划”或者准备“任务交接(Handover)”时,你**必须**遵循本技能的工作流,将计划或方案落库到 `docs/plan/` 目录下。
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **什么时候应当创建实现计划?**
|
||||
> * **必须创建的场景**:新功能开发、涉及多组件的重大架构重构、引入新基础设施依赖,以及存在显著设计决策冲突的**中大型、复杂**需求。
|
||||
> * **绝对不要创建的场景**:改个包名、挪个文件、重命名函数、小修小改修复 Bug 等**轻量级、简单的局部重构**。对于此类改动,应当直接完成并运行单元测试通过后交付,禁止制造冗余的计划文档。
|
||||
|
||||
## 执行工作流 (Workflow)
|
||||
|
||||
### 1. 确定计划类型
|
||||
* **新特性/技术实现计划**:如果你要开发新功能或进行重大重构,你需要创建**实现计划**。
|
||||
* **AI 任务交接计划**:如果当前任务尚未完成但需要记录进度留作以后或其他 AI 代理接手,你需要创建**交接计划**。
|
||||
|
||||
### 2. 读取对应模板
|
||||
在创建计划文档前,必须读取对应的模板内容,并严格按照模板的骨架进行填充:
|
||||
* **实现计划模板**:`docs/plan/implementation-plan-template.md`
|
||||
* **接手计划模板**:`docs/plan/handover-plan-template.md`
|
||||
|
||||
### 3. 落库与命名规范
|
||||
在 `docs/plan/` 目录下创建新的 Markdown 文件进行保存:
|
||||
* **实现计划**命名格式:`docs/plan/YYYYMMDD-[feature-name].md` (例如:`20260605-uptime-kuma-sync.md`)
|
||||
* **接手计划**命名格式:`docs/plan/handover-[task-name].md` (例如:`handover-waf-ip-group.md`)
|
||||
|
||||
### 4. 隔离约束 (极其重要)
|
||||
`docs/plan/` 目录下的文档**仅限内部开发和 AI 代理同步使用**。
|
||||
* **绝对禁止**将新创建的 plan 文档加入到项目的官方导航配置(如 `docs/config.ts` 的 `nav` 或 `sidebar` 导航条中)。
|
||||
* **绝对禁止**通过任何方式将其暴露给文档渲染框架(如 VitePress)对外渲染。
|
||||
|
||||
## 后续动作
|
||||
落库完成后,向用户报告计划已生成在 `docs/plan/` 目录下,并列出文档的核心要点或待决策项(如有),等待用户 Review 或批准后即可推进下一步。
|
||||
@@ -125,7 +125,7 @@ func startThingCacheInvalidationListener() {
|
||||
| Auth Source | `repository/auth_source_cache.go` | Otter | Redis JSON | `oauth:auth_source_invalidation` ✅ |
|
||||
| OAuth 用户/Token | `apps/oauth/cache.go` | 自研 map | Redis JSON | ❌ 无 pub/sub(历史债) |
|
||||
| 推送渠道 | `repository/push_channel.go` | 无 | Redis JSON | ❌ 仅 Redis Del |
|
||||
| Storage 驱动 | `internal/storage/storage.go` | RWMutex 快照 | — | `storage:config_invalidation` ✅ |
|
||||
| Storage 驱动 | `internal/infra/objectstore/storage.go` | RWMutex 快照 | — | `storage:config_invalidation` ✅ |
|
||||
|
||||
## 新增缓存工作流
|
||||
|
||||
@@ -210,7 +210,7 @@ make code-check
|
||||
## 相关文件
|
||||
|
||||
- L1 引擎:`pkg/cache/ram/cache.go`
|
||||
- DB/Redis 助手:`internal/db/redis.go`(`GetJSON`, `SetJSON`, `HGetJSON`, `PrefixedKey`)
|
||||
- DB/Redis 助手:`internal/infra/persistence/redis.go`(`GetJSON`, `SetJSON`, `HGetJSON`, `PrefixedKey`)
|
||||
- 金标准:`internal/repository/system_config_cache.go`
|
||||
- 上传元数据:`internal/apps/upload/cache/meta_cache.go`
|
||||
- Auth Source:`internal/repository/auth_source_cache.go`
|
||||
+35
-41
@@ -1,25 +1,25 @@
|
||||
---
|
||||
name: "clickhouse-batchwriter"
|
||||
description: "Wavelet 项目专用:当新增或修改 ClickHouse 批量写入、接入 internal/db/batchwriter、将业务域异步 flush 到分析表、迁移 risk_control/节点访问日志/可观测时序写入、或评估 async_insert 与背压策略时必须使用。本技能指导分层职责、各域独立 Writer 实例、repository 批量 API 与禁止写法。"
|
||||
description: "Wavelet 项目专用:当新增或修改 ClickHouse 批量写入、接入 internal/infra/persistence/batchwriter、将业务域异步 flush 到分析表、迁移 risk_control/节点访问日志/可观测时序写入、或评估 async_insert 与背压策略时必须使用。本技能指导分层职责、各域独立 Writer 实例、repository 批量 API 与禁止写法。"
|
||||
---
|
||||
|
||||
# ClickHouse 批量写入开发
|
||||
|
||||
开始前阅读根目录 `AGENTS.md`。ClickHouse 是辅助 OLAP 存储,**厌恶高频单条写入**(过多小 part);写入路径必须优先批量或异步聚合。
|
||||
|
||||
DDL 与表结构变更见 `database-migration` 技能;本技能只覆盖**运行时写入架构**。
|
||||
DDL 与表结构变更见 `database-migration` 技能。日志/分析用途表的判定、三库回落与切换见 `logstore` 技能。本技能只覆盖**运行时写入架构**。
|
||||
|
||||
## 分层职责
|
||||
|
||||
| 层级 | 路径 | 职责 |
|
||||
| :--- | :--- | :--- |
|
||||
| 连接 | `internal/db/clickhouse.go` | `ChConn`(原生批量写)、`ChDB`(GORM 查询);禁止在业务包直接 `clickhouse.Open` |
|
||||
| 批量框架 | `internal/db/batchwriter/` | 泛型队列 + 按条数/时间 flush + 非阻塞入队 + 优雅停机;**各业务域独立实例** |
|
||||
| 连接 | `internal/infra/persistence/clickhouse.go` | `ChConn`(原生批量写)、`ChDB`(GORM 查询);禁止在业务包直接 `clickhouse.Open` |
|
||||
| 批量框架 | `internal/infra/persistence/batchwriter/` | 泛型队列 + 按条数/时间 flush + 非阻塞入队 + 优雅停机;**各业务域独立实例** |
|
||||
| Model | `internal/model/analytics/` | 列定义、`TableName()`、`BatchInsertSQL()`(及可选 `InsertColumns()`) |
|
||||
| Repository | `internal/repository/analytics/` | `BatchInsert*` / `BatchInsertNodeAccessLogs` 等;`PrepareBatch` + 多行 `Append` + 一次 `Send` |
|
||||
| Apps | `internal/apps/<domain>/` | 采集、入队、背压;`FlushFunc` 只调 repository,不写 SQL、不 `PrepareBatch` |
|
||||
| 装配 | `internal/bootstrap/bootstrap.go` | 进程启动时调用 `Writer.Start`;初始化时需调用 `lifecycle.OnShutdown` 挂载停机钩子 |
|
||||
| 生命周期 | `internal/lifecycle/lifecycle.go` | 统一协调全局并发优雅停机,业务包无需在 `bootstrap.go` 中硬编码 `Stop` 逻辑 |
|
||||
| Apps | `internal/apps/<domain>/` | 采集、入队、背压;`FlushFunc` 只调 logstore / repository,不写 SQL、不 `PrepareBatch` |
|
||||
| 装配 | `internal/platform/bootstrap/bootstrap.go` | 进程启动时调用 `Writer.Start`;初始化时需调用 `lifecycle.OnShutdown` 挂载停机钩子 |
|
||||
| 生命周期 | `internal/platform/lifecycle/lifecycle.go` | 统一协调全局并发优雅停机,业务包无需在 `bootstrap.go` 中硬编码 `Stop` 逻辑 |
|
||||
|
||||
**禁止**在 Handler / middleware 内直接 `db.ChConn.PrepareBatch`;**禁止**在 repository 内启动 goroutine 或维护全局 channel(队列生命周期由 apps + bootstrap 或专用 writer 包负责)。
|
||||
|
||||
@@ -37,6 +37,7 @@ writer.Stop(stopCtx) // close 队列 + drain + 最终 flush
|
||||
|
||||
- `QueueSize`: 10_000
|
||||
- `MaxBatchSize`: 1_000
|
||||
- `MinBatchSize`: 50(未达阈值则跳过按时间 flush,除非设了 `MaxFlushWait`)
|
||||
- `FlushInterval`: 1s
|
||||
|
||||
各域可独立覆盖;可观测低频指标可用更小 `MaxBatchSize`(如 100)与更长 `FlushInterval`(如 2–5s),但**不要**退化为逐条 `Send`。
|
||||
@@ -49,7 +50,8 @@ writer.Stop(stopCtx) // close 队列 + drain + 最终 flush
|
||||
### FlushFunc 规范
|
||||
|
||||
- 签名:`func(ctx context.Context, items []T) error`
|
||||
- 内部调用 `internal/repository/analytics` 的 `BatchInsert*`(传入 `[]analyticsmodel.X`)
|
||||
- **日志/分析用途表**:`logstore.Active(ctx)` 再调对应 `BatchInsert*`。禁止 apps 直连 `analyticsrepo` 或 `db.ChConn`。
|
||||
- 仅 CH、无需主库回落的分析表:才直接调 `repository/analytics` 的 `BatchInsert*`。
|
||||
- 在 flush 边界记录一次错误日志,不要把 DB 驱动错误直接暴露给 HTTP 客户端
|
||||
- `Start` 使用 `context.WithoutCancel(parent)`,避免请求 ctx 取消中断后台 flush
|
||||
|
||||
@@ -57,28 +59,27 @@ writer.Stop(stopCtx) // close 队列 + drain + 最终 flush
|
||||
|
||||
每个业务域拥有自己的 `Writer`、配置与 `FlushFunc`:
|
||||
|
||||
| 域 | 表 | 现状 | 目标形态 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| 管理端审计 | `w_user_access_logs` | `risk_control` → `batchwriter` + `analyticsrepo.BatchInsert` | 已接入 |
|
||||
| 边缘访问日志 | `of_node_access_logs` | `openflare/chwriter` 异步 flush | 已接入 |
|
||||
| 可观测时序 | `of_node_metric_snapshots` 等 5 表 | `openflare/chwriter` 五表独立 writer + 进程内短 TTL 去重 | 已接入 |
|
||||
| 域 | 表 | 写入路径 |
|
||||
| :--- | :--- | :--- |
|
||||
| 管理端审计 | `w_user_access_logs` | `risk_control` → `batchwriter` → `logstore.Active` |
|
||||
|
||||
**不要**把 audit、access log、observability 并入同一 channel。
|
||||
**不要**把不同日志域并入同一 channel。新日志表先按 `logstore` skill 判定,再为本域建独立 writer。
|
||||
|
||||
## 新增 ClickHouse 写入工作流
|
||||
|
||||
1. **Model**:在 `internal/model/analytics/` 定义 struct 与 `BatchInsertSQL()`(列顺序与 goose DDL 一致)。
|
||||
2. **Goose DDL**:在 `internal/db/migrator/goose/clickhouse/` 新增迁移(见 `database-migration`)。
|
||||
2. **Goose DDL**:在 `internal/infra/persistence/migrator/goose/clickhouse/` 新增迁移(见 `database-migration`)。
|
||||
3. **Repository**:实现 `BatchInsertX(ctx, []analyticsmodel.X) error`:
|
||||
- `len(items)==0` 直接返回
|
||||
- `db.ChConn == nil` 返回明确错误
|
||||
- 一次 `PrepareBatch` → 循环 `Append` → 一次 `Send`
|
||||
4. **Writer 胶水**(`internal/apps/<domain>/` 或 `internal/repository/analytics/<domain>_writer.go`):
|
||||
- `len(items)==0` 直接返回
|
||||
- `db.ChConn == nil` 返回明确错误
|
||||
- 一次 `PrepareBatch` → 循环 `Append` → 一次 `Send`
|
||||
4. **Writer 胶水**(`internal/apps/<domain>/`):
|
||||
- `New` + `Start`,并在初始化逻辑内通过 `lifecycle.OnShutdown("your_writer_name", Stop)` 注册停机回调
|
||||
- 日志表的 `FlushFunc` 调 `logstore.Active`(见 `logstore` skill)
|
||||
- 业务路径 `TryEnqueue`;HTTP 背压用 `IsFull()`
|
||||
5. **测试**:
|
||||
- repository:mock `ChConn` 验证 `BatchInsertSQL` 与 append 列数
|
||||
- batchwriter:`go test ./internal/db/batchwriter`
|
||||
- repository:mock `ChConn` 验证 `BatchInsertSQL` 与 append 列数
|
||||
- batchwriter:`go test ./internal/infra/persistence/batchwriter`
|
||||
6. 运行 `make code-check`;有 API 变更时 `make swagger`。
|
||||
|
||||
## 背压与丢弃策略
|
||||
@@ -86,8 +87,7 @@ writer.Stop(stopCtx) // close 队列 + drain + 最终 flush
|
||||
| 场景 | 推荐策略 |
|
||||
| :--- | :--- |
|
||||
| 管理端 API 审计 | 队列满 → `IsFull()` 触发 429(见 `risk_control` middleware) |
|
||||
| Agent 心跳指标 | 队列满 → `WithDropHandler` 记 warn;不阻塞心跳响应 |
|
||||
| 边缘 access log | 优先扩大队列与 batch;必要时丢弃最旧或采样 |
|
||||
| 可丢弃的高频日志 | 队列满 → `WithDropHandler` 记 warn;不阻塞请求 |
|
||||
|
||||
## 禁止写法
|
||||
|
||||
@@ -110,7 +110,7 @@ var globalChan chan any
|
||||
|
||||
## async_insert(补充,非主方案)
|
||||
|
||||
可在 `internal/db/clickhouse.go` 的 `Settings` 增加服务端异步写入作为第二层防护:
|
||||
可在 `internal/infra/persistence/clickhouse.go` 的 `Settings` 增加服务端异步写入作为第二层防护:
|
||||
|
||||
```go
|
||||
"async_insert": 1,
|
||||
@@ -122,15 +122,10 @@ var globalChan chan any
|
||||
## Bootstrap 装配示例
|
||||
|
||||
```go
|
||||
// internal/bootstrap/bootstrap.go(示意)
|
||||
var userAccessLogWriter *batchwriter.Writer[*analytics.UserAccessLog]
|
||||
|
||||
// internal/platform/bootstrap/bootstrap.go(示意)
|
||||
func RegisterAPI(ctx context.Context) {
|
||||
// ...
|
||||
if config.Config.ClickHouse.Enabled {
|
||||
initUserAccessLogWriter(ctx) // Start writer
|
||||
risk_control.BindWriter(userAccessLogWriter) // 或逐步替换 InitLogWriter
|
||||
}
|
||||
// 日志 writer 不依赖 clickhouse.enabled:flush 时由 logstore 选库
|
||||
risk_control.InitLogWriter(ctx)
|
||||
}
|
||||
```
|
||||
|
||||
@@ -141,7 +136,7 @@ func RegisterAPI(ctx context.Context) {
|
||||
## 验证清单
|
||||
|
||||
```bash
|
||||
go test ./internal/db/batchwriter
|
||||
go test ./internal/infra/persistence/batchwriter
|
||||
go test ./internal/repository/analytics
|
||||
make code-check
|
||||
```
|
||||
@@ -149,15 +144,14 @@ make code-check
|
||||
- flush 按 `MaxBatchSize` 与 `FlushInterval` 触发
|
||||
- `Stop` 能 drain 队列内剩余项
|
||||
- repository 层无 goroutine、无 channel
|
||||
- `clickhouse.enabled: false` 时不 `Start` writer、不入队
|
||||
- 日志表:`clickhouse.enabled: false` 时 writer 仍 `Start`,flush 走主库 logstore
|
||||
- 仅 CH 的分析表:未启用 CH 时不要 `Start`、不要入队
|
||||
|
||||
## 相关文件速查
|
||||
|
||||
- 框架:`internal/db/batchwriter/{config,writer,errs}.go`
|
||||
- 连接:`internal/db/clickhouse.go`
|
||||
- 框架:`internal/infra/persistence/batchwriter/{config,writer,errs}.go`
|
||||
- 连接:`internal/infra/persistence/clickhouse.go`
|
||||
- 审计写入:`internal/apps/risk_control/logics.go`
|
||||
- OpenFlare 写入胶水:`internal/apps/openflare/chwriter/writer.go`
|
||||
- 节点访问日志 repository:`internal/repository/analytics/node_access_log_writer.go`
|
||||
- 可观测 repository:`internal/repository/analytics/node_observability_writer.go`
|
||||
- 生命周期管理器:`internal/lifecycle/lifecycle.go`
|
||||
- Bootstrap:`internal/bootstrap/bootstrap.go`
|
||||
- 日志抽象:`internal/repository/logstore`
|
||||
- 生命周期管理器:`internal/platform/lifecycle/lifecycle.go`
|
||||
- Bootstrap:`internal/platform/bootstrap/bootstrap.go`
|
||||
+11
-11
@@ -1,17 +1,17 @@
|
||||
---
|
||||
name: "database-migration"
|
||||
description: "Wavelet 项目专用:当新增或修改数据库表结构、索引、初始化数据、系统配置 seed、模板 seed、默认管理员、goose SQL 迁移、internal/db/migrator、ClickHouse 分析库 DDL 或数据库升级流程时必须使用。本技能指导在 internal/db/migrator/goose 下编写 PostgreSQL/SQLite 双方言 SQL 迁移,以及在 goose/clickhouse 下编写 ClickHouse 单方言分析表迁移,并完成验证。"
|
||||
description: "Wavelet 项目专用:当新增或修改数据库表结构、索引、初始化数据、系统配置 seed、模板 seed、默认管理员、goose SQL 迁移、internal/infra/persistence/migrator、ClickHouse 分析库 DDL 或数据库升级流程时必须使用。本技能指导在 internal/infra/persistence/migrator/goose 下编写 PostgreSQL/SQLite 双方言 SQL 迁移,以及在 goose/clickhouse 下编写 ClickHouse 单方言分析表迁移,并完成验证。"
|
||||
---
|
||||
|
||||
# Wavelet 数据库升级操作指南
|
||||
|
||||
Wavelet 使用 `github.com/pressly/goose/v3` 执行 SQL 迁移。迁移入口是 `internal/db/migrator.Migrate()`,SQL 文件嵌入在二进制中。
|
||||
Wavelet 使用 `github.com/pressly/goose/v3` 执行 SQL 迁移。迁移入口是 `internal/infra/persistence/migrator.Migrate()`,SQL 文件嵌入在二进制中。
|
||||
|
||||
## 基本规则
|
||||
|
||||
- SQL 迁移文件放在:
|
||||
- `internal/db/migrator/goose/postgres/`
|
||||
- `internal/db/migrator/goose/sqlite/`
|
||||
- `internal/infra/persistence/migrator/goose/postgres/`
|
||||
- `internal/infra/persistence/migrator/goose/sqlite/`
|
||||
- PostgreSQL 和 SQLite 必须使用同一个版本号、同一个语义文件名。
|
||||
- 迁移文件使用 goose SQL 标记:
|
||||
|
||||
@@ -51,7 +51,7 @@ Wavelet 使用 `github.com/pressly/goose/v3` 执行 SQL 迁移。迁移入口是
|
||||
7. 至少运行:
|
||||
|
||||
```bash
|
||||
go test ./internal/db/migrator
|
||||
go test ./internal/infra/persistence/migrator
|
||||
go test ./internal/model ./internal/apps/config ./internal/apps/admin/system_config
|
||||
make code-check
|
||||
```
|
||||
@@ -81,7 +81,7 @@ make code-check
|
||||
ClickHouse 是**辅助 OLAP 存储**,与 PostgreSQL/SQLite 主库**完全独立**的迁移与访问管线:
|
||||
|
||||
- 主库(PG/SQLite):业务事务数据、`goose_db_version`、双方言 SQL。
|
||||
- 分析库(ClickHouse):访问日志、统计聚合等分析型数据、`goose_clickhouse_version`、单方言 SQL。
|
||||
- 分析库(ClickHouse):分析型数据、`goose_clickhouse_version`、单方言 SQL。日志用途表还必须在主库建回落并走 `logstore`(见该 skill);CH 目录仍只放 CH DDL。
|
||||
|
||||
**不要**把 ClickHouse 表结构混入 PG/SQLite 迁移目录,也**不要**在 `support-files/`、`internal/apps/` 或 `internal/repository/` 中手写 DDL。
|
||||
|
||||
@@ -89,10 +89,10 @@ ClickHouse 是**辅助 OLAP 存储**,与 PostgreSQL/SQLite 主库**完全独
|
||||
|
||||
| 路径 | 职责 |
|
||||
| :--- | :--- |
|
||||
| `internal/db/migrator/goose/clickhouse/` | **唯一** ClickHouse DDL 来源(goose SQL,嵌入二进制) |
|
||||
| `internal/infra/persistence/migrator/goose/clickhouse/` | **唯一** ClickHouse DDL 来源(goose SQL,嵌入二进制) |
|
||||
| `internal/model/analytics/` | 分析表 Go model,列名须与 goose DDL 一致 |
|
||||
| `internal/repository/analytics/` | 所有 ClickHouse 读写(批量写入、查询、聚合) |
|
||||
| `internal/db/clickhouse.go` | 连接初始化(`ChConn` 原生批量、`ChDB` GORM 查询) |
|
||||
| `internal/infra/persistence/clickhouse.go` | 连接初始化(`ChConn` 原生批量、`chDB` GORM 查询) |
|
||||
|
||||
### 迁移入口与版本表
|
||||
|
||||
@@ -116,16 +116,16 @@ ClickHouse 是**辅助 OLAP 存储**,与 PostgreSQL/SQLite 主库**完全独
|
||||
按以下顺序落地,避免列名或类型漂移:
|
||||
|
||||
1. **Model**:在 `internal/model/analytics/` 定义 struct,`gorm:"column:..."` 与 DDL 列名一一对应;实现 `TableName()`,批量写入表可提供 `InsertColumns()` / `BatchInsertSQL()`。
|
||||
2. **Goose SQL**:在 `internal/db/migrator/goose/clickhouse/` 新增递增版本文件(格式同主库,如 `YYYYMMDDNNNN_create_xxx.sql`),编写 `-- +goose Up` / `-- +goose Down`。
|
||||
2. **Goose SQL**:在 `internal/infra/persistence/migrator/goose/clickhouse/` 新增递增版本文件(格式同主库,如 `YYYYMMDDNNNN_create_xxx.sql`),编写 `-- +goose Up` / `-- +goose Down`。
|
||||
3. **Repository**:在 `internal/repository/analytics/` 实现 `BatchInsert*`(`db.ChConn` 一次 `PrepareBatch` + 多行 `Append` + 一次 `Send`)与查询(`db.ChDB`);连接未初始化时返回明确错误,**不要**在 handler 写 SQL,**不要**在 repository 内维护 channel/goroutine。
|
||||
4. **Apps**:在 `internal/apps/<domain>/` 编排采集与入队;高频写入通过 `internal/db/batchwriter` 各域独立实例异步 flush(详见 `clickhouse-batchwriter` 技能),`FlushFunc` 只调 repository `BatchInsert*`;管理端统计 API 只读 repository,不触达 DDL。
|
||||
4. **Apps**:在 `internal/apps/<domain>/` 编排采集与入队;高频写入通过 `internal/infra/persistence/batchwriter` 各域独立实例异步 flush(详见 `clickhouse-batchwriter` 技能)。**日志/分析用途表**还要同时建 PG/SQLite 回落并接入 `logstore`(见 `logstore` 技能),`FlushFunc` 调 `logstore.Active` 而不是 `analyticsrepo`;普通业务分析表仍只读 repository。
|
||||
|
||||
### ClickHouse 验证
|
||||
|
||||
至少运行:
|
||||
|
||||
```bash
|
||||
go test ./internal/db/migrator
|
||||
go test ./internal/infra/persistence/migrator
|
||||
go test ./internal/repository/analytics
|
||||
make code-check
|
||||
```
|
||||
@@ -15,7 +15,7 @@ Wavelet 将「对象存储」与「上传业务」分为两层,**禁止混用
|
||||
|
||||
| 层级 | 包路径 | 职责 | 业务是否直接调用 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **对象存储引擎** | `internal/storage` | `Backend` 接口:`Put` / `Get` / `Delete` / `Test`;按配置切换 Local / S3 / R2 / OSS / WebDAV | **禁止**(仅 upload 域内部使用) |
|
||||
| **对象存储引擎** | `internal/infra/objectstore` | `Backend` 接口:`Put` / `Get` / `Delete` / `Test`;按配置切换 Local / S3 / R2 / OSS / WebDAV | **禁止**(仅 upload 域内部使用) |
|
||||
| **上传域服务** | `internal/apps/upload` | `w_uploads` 记录、权限、秒传、统计、文件服务、`upload.Ingest` | **必须** |
|
||||
| **上传 HTTP 入口** | `internal/apps/upload/handler` | `POST /api/v1/upload` 等 multipart 接口 | 前端 / 用户侧上传 |
|
||||
| **文件访问** | `internal/apps/upload/filesrv` | `GET /f/:id` 流式响应、访问控制、图片 WebP 压缩 | 展示 / 下载 |
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user