From 8801b3976c75e057ad2258a6616b1e38ec1e8845 Mon Sep 17 00:00:00 2001 From: ryan Date: Tue, 9 Jun 2026 13:59:45 +0800 Subject: [PATCH] go dev skill go dev skill go dev skill shadcn skill --- .agent/skills/go-code-review/SKILL.md | 183 ++++ .../go-code-review/assets/review-template.md | 23 + .../go-code-review/references/WEB-SERVER.md | 119 +++ .../go-code-review/scripts/pre-review.sh | 246 ++++++ .agent/skills/go-concurrency/SKILL.md | 191 ++++ .../references/ADVANCED-PATTERNS.md | 132 +++ .../references/BUFFER-POOLING.md | 73 ++ .../references/GOROUTINE-PATTERNS.md | 126 +++ .../references/SYNC-PRIMITIVES.md | 110 +++ .agent/skills/go-context/SKILL.md | 122 +++ .../skills/go-context/references/PATTERNS.md | 227 +++++ .agent/skills/go-control-flow/SKILL.md | 193 ++++ .../references/BLANK-IDENTIFIER.md | 71 ++ .../references/SWITCH-PATTERNS.md | 109 +++ .agent/skills/go-data-structures/SKILL.md | 140 +++ .../go-data-structures/references/SLICES.md | 146 +++ .agent/skills/go-defensive/SKILL.md | 188 ++++ .../references/BOUNDARY-COPYING.md | 101 +++ .../go-defensive/references/GLOBAL-STATE.md | 144 +++ .../go-defensive/references/MUST-FUNCTIONS.md | 92 ++ .../go-defensive/references/PANIC-RECOVER.md | 161 ++++ .../references/TIME-ENUMS-TAGS.md | 111 +++ .agent/skills/go-documentation/SKILL.md | 167 ++++ .../go-documentation/assets/doc-template.go | 61 ++ .../references/CONVENTIONS.md | 239 +++++ .../go-documentation/references/EXAMPLES.md | 107 +++ .../go-documentation/references/FORMATTING.md | 85 ++ .../go-documentation/scripts/check-docs.sh | 298 +++++++ .agent/skills/go-error-handling/SKILL.md | 168 ++++ .../references/ERROR-FLOW.md | 153 ++++ .../references/ERROR-TYPES.md | 151 ++++ .../go-error-handling/references/WRAPPING.md | 174 ++++ .../go-error-handling/scripts/check-errors.sh | 266 ++++++ .agent/skills/go-functional-options/SKILL.md | 210 +++++ .../references/OPTIONS-VS-STRUCTS.md | 129 +++ .agent/skills/go-functions/SKILL.md | 107 +++ .../references/PRINTF-STRINGER.md | 264 ++++++ .../go-functions/references/SIGNATURES.md | 168 ++++ .agent/skills/go-generics/SKILL.md | 173 ++++ .../go-generics/references/CONSTRAINTS.md | 169 ++++ .agent/skills/go-interfaces/SKILL.md | 151 ++++ .../go-interfaces/references/EMBEDDING.md | 138 +++ .../go-interfaces/references/RECEIVER-TYPE.md | 68 ++ .../scripts/check-interface-compliance.sh | 224 +++++ .agent/skills/go-linting/SKILL.md | 209 +++++ .agent/skills/go-linting/assets/golangci.yml | 31 + .../skills/go-linting/scripts/setup-lint.sh | 172 ++++ .agent/skills/go-logging/SKILL.md | 187 ++++ .../references/LEVELS-AND-CONTEXT.md | 244 +++++ .../go-logging/references/LOGGING-PATTERNS.md | 314 +++++++ .agent/skills/go-packages/SKILL.md | 138 +++ .../skills/go-packages/references/IMPORTS.md | 110 +++ .../go-packages/references/PACKAGE-SIZE.md | 214 +++++ .agent/skills/go-performance/SKILL.md | 152 ++++ .../go-performance/references/BENCHMARKS.md | 281 ++++++ .../references/STRING-OPTIMIZATION.md | 134 +++ .../go-performance/scripts/bench-compare.sh | 252 ++++++ .agent/skills/go-style-core/SKILL.md | 179 ++++ .../go-style-core/references/FORMATTING.md | 95 ++ .../go-style-core/references/PRINCIPLES.md | 89 ++ .agent/skills/go-testing/SKILL.md | 168 ++++ .../go-testing/assets/table-test-template.go | 30 + .../go-testing/references/INTEGRATION.md | 144 +++ .../references/TABLE-DRIVEN-TESTS.md | 154 ++++ .../go-testing/references/TEST-HELPERS.md | 131 +++ .../references/TEST-ORGANIZATION.md | 167 ++++ .../go-testing/references/VALIDATION-APIS.md | 108 +++ .../go-testing/scripts/gen-table-test.sh | 163 ++++ .agent/{ => skills}/new-async-task/SKILL.md | 14 + .agent/skills/shadcn/SKILL.md | 267 ++++++ .agent/skills/shadcn/agents/openai.yml | 5 + .agent/skills/shadcn/assets/shadcn-small.png | Bin 0 -> 1049 bytes .agent/skills/shadcn/assets/shadcn.png | Bin 0 -> 3852 bytes .agent/skills/shadcn/cli.md | 290 ++++++ .agent/skills/shadcn/customization.md | 209 +++++ .agent/skills/shadcn/evals/evals.json | 47 + .agent/skills/shadcn/mcp.md | 105 +++ .agent/skills/shadcn/registry.md | 277 ++++++ .agent/skills/shadcn/rules/base-vs-radix.md | 306 +++++++ .agent/skills/shadcn/rules/composition.md | 195 ++++ .agent/skills/shadcn/rules/forms.md | 192 ++++ .agent/skills/shadcn/rules/icons.md | 101 +++ .agent/skills/shadcn/rules/styling.md | 162 ++++ .claude | 1 + .codex | 1 + Agents.md | 832 +++++------------- CLAUDE.md | 646 -------------- Claude.md | 1 + 88 files changed, 12944 insertions(+), 1251 deletions(-) create mode 100644 .agent/skills/go-code-review/SKILL.md create mode 100644 .agent/skills/go-code-review/assets/review-template.md create mode 100644 .agent/skills/go-code-review/references/WEB-SERVER.md create mode 100755 .agent/skills/go-code-review/scripts/pre-review.sh create mode 100644 .agent/skills/go-concurrency/SKILL.md create mode 100644 .agent/skills/go-concurrency/references/ADVANCED-PATTERNS.md create mode 100644 .agent/skills/go-concurrency/references/BUFFER-POOLING.md create mode 100644 .agent/skills/go-concurrency/references/GOROUTINE-PATTERNS.md create mode 100644 .agent/skills/go-concurrency/references/SYNC-PRIMITIVES.md create mode 100644 .agent/skills/go-context/SKILL.md create mode 100644 .agent/skills/go-context/references/PATTERNS.md create mode 100644 .agent/skills/go-control-flow/SKILL.md create mode 100644 .agent/skills/go-control-flow/references/BLANK-IDENTIFIER.md create mode 100644 .agent/skills/go-control-flow/references/SWITCH-PATTERNS.md create mode 100644 .agent/skills/go-data-structures/SKILL.md create mode 100644 .agent/skills/go-data-structures/references/SLICES.md create mode 100644 .agent/skills/go-defensive/SKILL.md create mode 100644 .agent/skills/go-defensive/references/BOUNDARY-COPYING.md create mode 100644 .agent/skills/go-defensive/references/GLOBAL-STATE.md create mode 100644 .agent/skills/go-defensive/references/MUST-FUNCTIONS.md create mode 100644 .agent/skills/go-defensive/references/PANIC-RECOVER.md create mode 100644 .agent/skills/go-defensive/references/TIME-ENUMS-TAGS.md create mode 100644 .agent/skills/go-documentation/SKILL.md create mode 100644 .agent/skills/go-documentation/assets/doc-template.go create mode 100644 .agent/skills/go-documentation/references/CONVENTIONS.md create mode 100644 .agent/skills/go-documentation/references/EXAMPLES.md create mode 100644 .agent/skills/go-documentation/references/FORMATTING.md create mode 100755 .agent/skills/go-documentation/scripts/check-docs.sh create mode 100644 .agent/skills/go-error-handling/SKILL.md create mode 100644 .agent/skills/go-error-handling/references/ERROR-FLOW.md create mode 100644 .agent/skills/go-error-handling/references/ERROR-TYPES.md create mode 100644 .agent/skills/go-error-handling/references/WRAPPING.md create mode 100755 .agent/skills/go-error-handling/scripts/check-errors.sh create mode 100644 .agent/skills/go-functional-options/SKILL.md create mode 100644 .agent/skills/go-functional-options/references/OPTIONS-VS-STRUCTS.md create mode 100644 .agent/skills/go-functions/SKILL.md create mode 100644 .agent/skills/go-functions/references/PRINTF-STRINGER.md create mode 100644 .agent/skills/go-functions/references/SIGNATURES.md create mode 100644 .agent/skills/go-generics/SKILL.md create mode 100644 .agent/skills/go-generics/references/CONSTRAINTS.md create mode 100644 .agent/skills/go-interfaces/SKILL.md create mode 100644 .agent/skills/go-interfaces/references/EMBEDDING.md create mode 100644 .agent/skills/go-interfaces/references/RECEIVER-TYPE.md create mode 100755 .agent/skills/go-interfaces/scripts/check-interface-compliance.sh create mode 100644 .agent/skills/go-linting/SKILL.md create mode 100644 .agent/skills/go-linting/assets/golangci.yml create mode 100755 .agent/skills/go-linting/scripts/setup-lint.sh create mode 100644 .agent/skills/go-logging/SKILL.md create mode 100644 .agent/skills/go-logging/references/LEVELS-AND-CONTEXT.md create mode 100644 .agent/skills/go-logging/references/LOGGING-PATTERNS.md create mode 100644 .agent/skills/go-packages/SKILL.md create mode 100644 .agent/skills/go-packages/references/IMPORTS.md create mode 100644 .agent/skills/go-packages/references/PACKAGE-SIZE.md create mode 100644 .agent/skills/go-performance/SKILL.md create mode 100644 .agent/skills/go-performance/references/BENCHMARKS.md create mode 100644 .agent/skills/go-performance/references/STRING-OPTIMIZATION.md create mode 100755 .agent/skills/go-performance/scripts/bench-compare.sh create mode 100644 .agent/skills/go-style-core/SKILL.md create mode 100644 .agent/skills/go-style-core/references/FORMATTING.md create mode 100644 .agent/skills/go-style-core/references/PRINCIPLES.md create mode 100644 .agent/skills/go-testing/SKILL.md create mode 100644 .agent/skills/go-testing/assets/table-test-template.go create mode 100644 .agent/skills/go-testing/references/INTEGRATION.md create mode 100644 .agent/skills/go-testing/references/TABLE-DRIVEN-TESTS.md create mode 100644 .agent/skills/go-testing/references/TEST-HELPERS.md create mode 100644 .agent/skills/go-testing/references/TEST-ORGANIZATION.md create mode 100644 .agent/skills/go-testing/references/VALIDATION-APIS.md create mode 100755 .agent/skills/go-testing/scripts/gen-table-test.sh rename .agent/{ => skills}/new-async-task/SKILL.md (95%) create mode 100644 .agent/skills/shadcn/SKILL.md create mode 100644 .agent/skills/shadcn/agents/openai.yml create mode 100644 .agent/skills/shadcn/assets/shadcn-small.png create mode 100644 .agent/skills/shadcn/assets/shadcn.png create mode 100644 .agent/skills/shadcn/cli.md create mode 100644 .agent/skills/shadcn/customization.md create mode 100644 .agent/skills/shadcn/evals/evals.json create mode 100644 .agent/skills/shadcn/mcp.md create mode 100644 .agent/skills/shadcn/registry.md create mode 100644 .agent/skills/shadcn/rules/base-vs-radix.md create mode 100644 .agent/skills/shadcn/rules/composition.md create mode 100644 .agent/skills/shadcn/rules/forms.md create mode 100644 .agent/skills/shadcn/rules/icons.md create mode 100644 .agent/skills/shadcn/rules/styling.md create mode 120000 .claude create mode 120000 .codex delete mode 100644 CLAUDE.md create mode 120000 Claude.md diff --git a/.agent/skills/go-code-review/SKILL.md b/.agent/skills/go-code-review/SKILL.md new file mode 100644 index 00000000..d941b930 --- /dev/null +++ b/.agent/skills/go-code-review/SKILL.md @@ -0,0 +1,183 @@ +--- +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 && 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) diff --git a/.agent/skills/go-code-review/assets/review-template.md b/.agent/skills/go-code-review/assets/review-template.md new file mode 100644 index 00000000..8a9783ef --- /dev/null +++ b/.agent/skills/go-code-review/assets/review-template.md @@ -0,0 +1,23 @@ +# 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] diff --git a/.agent/skills/go-code-review/references/WEB-SERVER.md b/.agent/skills/go-code-review/references/WEB-SERVER.md new file mode 100644 index 00000000..1d219e98 --- /dev/null +++ b/.agent/skills/go-code-review/references/WEB-SERVER.md @@ -0,0 +1,119 @@ +# 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,错误只处理一次 | diff --git a/.agent/skills/go-code-review/scripts/pre-review.sh b/.agent/skills/go-code-review/scripts/pre-review.sh new file mode 100755 index 00000000..88bbe51d --- /dev/null +++ b/.agent/skills/go-code-review/scripts/pre-review.sh @@ -0,0 +1,246 @@ +#!/usr/bin/env bash +set -euo pipefail + +VERSION="1.0.0" +SCRIPT_NAME="$(basename "$0")" + +usage() { + cat <&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 **规范**:当你启动 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) —— 类型安全的原子操作 diff --git a/.agent/skills/go-concurrency/references/ADVANCED-PATTERNS.md b/.agent/skills/go-concurrency/references/ADVANCED-PATTERNS.md new file mode 100644 index 00000000..ade47cc5 --- /dev/null +++ b/.agent/skills/go-concurrency/references/ADVANCED-PATTERNS.md @@ -0,0 +1,132 @@ +# 高级并发模式 + +来自 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() +``` diff --git a/.agent/skills/go-concurrency/references/BUFFER-POOLING.md b/.agent/skills/go-concurrency/references/BUFFER-POOLING.md new file mode 100644 index 00000000..ea2bcda3 --- /dev/null +++ b/.agent/skills/go-concurrency/references/BUFFER-POOLING.md @@ -0,0 +1,73 @@ +# 使用 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 的并发原语以及需要更多控制池行为的场景仍然很有价值。 diff --git a/.agent/skills/go-concurrency/references/GOROUTINE-PATTERNS.md b/.agent/skills/go-concurrency/references/GOROUTINE-PATTERNS.md new file mode 100644 index 00000000..ba297b7f --- /dev/null +++ b/.agent/skills/go-concurrency/references/GOROUTINE-PATTERNS.md @@ -0,0 +1,126 @@ +# 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) + // 处理结果 +}() +``` diff --git a/.agent/skills/go-concurrency/references/SYNC-PRIMITIVES.md b/.agent/skills/go-concurrency/references/SYNC-PRIMITIVES.md new file mode 100644 index 00000000..20c41146 --- /dev/null +++ b/.agent/skills/go-concurrency/references/SYNC-PRIMITIVES.md @@ -0,0 +1,110 @@ +# 同步原语模式 + +互斥锁和原子操作的详细模式 —— 涵盖互斥锁嵌入陷阱和类型安全的原子访问。 + +--- + +## 不要嵌入互斥锁 + +如果你通过指针使用结构体,互斥锁应该是非指针字段。不要在结构体中嵌入互斥锁,即使该结构体未被导出。 + +```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) // 漏洞!这能通过编译但不应该发生。 +} +``` diff --git a/.agent/skills/go-context/SKILL.md b/.agent/skills/go-context/SKILL.md new file mode 100644 index 00000000..6c8387cf --- /dev/null +++ b/.agent/skills/go-context/SKILL.md @@ -0,0 +1,122 @@ +--- +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) diff --git a/.agent/skills/go-context/references/PATTERNS.md b/.agent/skills/go-context/references/PATTERNS.md new file mode 100644 index 00000000..fd542a18 --- /dev/null +++ b/.agent/skills/go-context/references/PATTERNS.md @@ -0,0 +1,227 @@ +# 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()` | diff --git a/.agent/skills/go-control-flow/SKILL.md b/.agent/skills/go-control-flow/SKILL.md new file mode 100644 index 00000000..279fb171 --- /dev/null +++ b/.agent/skills/go-control-flow/SKILL.md @@ -0,0 +1,193 @@ +--- +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) diff --git a/.agent/skills/go-control-flow/references/BLANK-IDENTIFIER.md b/.agent/skills/go-control-flow/references/BLANK-IDENTIFIER.md new file mode 100644 index 00000000..9d84354b --- /dev/null +++ b/.agent/skills/go-control-flow/references/BLANK-IDENTIFIER.md @@ -0,0 +1,71 @@ +# 空白标识符模式 + +空白标识符 `_` 在 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)` | diff --git a/.agent/skills/go-control-flow/references/SWITCH-PATTERNS.md b/.agent/skills/go-control-flow/references/SWITCH-PATTERNS.md new file mode 100644 index 00000000..74ae6429 --- /dev/null +++ b/.agent/skills/go-control-flow/references/SWITCH-PATTERNS.md @@ -0,0 +1,109 @@ +# 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` | diff --git a/.agent/skills/go-data-structures/SKILL.md b/.agent/skills/go-data-structures/SKILL.md new file mode 100644 index 00000000..a3c7d427 --- /dev/null +++ b/.agent/skills/go-data-structures/SKILL.md @@ -0,0 +1,140 @@ +--- +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) diff --git a/.agent/skills/go-data-structures/references/SLICES.md b/.agent/skills/go-data-structures/references/SLICES.md new file mode 100644 index 00000000..c4aea940 --- /dev/null +++ b/.agent/skills/go-data-structures/references/SLICES.md @@ -0,0 +1,146 @@ +# 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)` | 返回复制数量;不重新分配 | diff --git a/.agent/skills/go-defensive/SKILL.md b/.agent/skills/go-defensive/SKILL.md new file mode 100644 index 00000000..269d36e3 --- /dev/null +++ b/.agent/skills/go-defensive/SKILL.md @@ -0,0 +1,188 @@ +--- +name: go-defensive +description: Use when hardening Go code at API boundaries — copying slices/maps, verifying interface compliance, using defer for cleanup, time.Time/time.Duration, or avoiding mutable globals. Also use when reviewing for robustness concerns like missing cleanup or unsafe crypto usage, even if the user doesn't mention "defensive programming." Does not cover error handling strategy (see go-error-handling). +license: Apache-2.0 +compatibility: Uses crypto/rand.Text (Go 1.24+) in examples +metadata: + sources: "Effective Go, Uber Style Guide, Go Wiki CodeReviewComments" +--- + +# Go 防御性编程模式 + +## 防御性检查清单优先级 + +在加固 API 边界代码时,按以下顺序检查: + +``` +正在审查 API 边界? +├─ 1. 错误处理 → 返回错误;不要 panic(参见 go-error-handling) +├─ 2. 输入验证 → 复制从调用者接收的切片/map +├─ 3. 输出安全 → 在返回给调用者之前复制切片/map +├─ 4. 资源清理 → 使用 defer 进行 Close/Unlock/Cancel +├─ 5. 接口检查 → var _ Interface = (*Type)(nil) 编译时验证 +├─ 6. 时间正确性 → 使用 time.Time 和 time.Duration,不要用 int/float +├─ 7. 枚举安全 → iota 从 1 开始,使零值无效 +└─ 8. 加密安全 → 用 crypto/rand 生成密钥,绝不用 math/rand +``` + +--- + +## 快速参考 + +| 模式 | 规则 | 详情 | +|------|------|------| +| 边界复制 | 在接收和返回时复制切片/map | [BOUNDARY-COPYING.md](references/BOUNDARY-COPYING.md) | +| Defer 清理 | 在 `os.Open` 之后立即 `defer f.Close()` | 见下文 | +| 接口检查 | `var _ I = (*T)(nil)` | 参见 go-interfaces | +| 时间类型 | `time.Time` / `time.Duration`,绝不用原始 int | [TIME-ENUMS-TAGS.md](references/TIME-ENUMS-TAGS.md) | +| 枚举起始值 | `iota + 1` 使零值 = 无效 | 见下文 | +| 加密随机数 | 用 `crypto/rand` 生成密钥,绝不用 `math/rand` | 见下文 | +| Must 函数 | 仅在初始化时使用;失败时 panic | [MUST-FUNCTIONS.md](references/MUST-FUNCTIONS.md) | +| Panic/recover | 绝不跨包暴露 panic | [PANIC-RECOVER.md](references/PANIC-RECOVER.md) | +| 可变全局变量 | 用依赖注入替代 | 见下文 | + +--- + +## 验证接口合规性 + +使用编译时检查来验证接口实现。完整模式请参见 **go-interfaces**:接口满足检查。 + +```go +var _ http.Handler = (*Handler)(nil) +``` + +## 在边界处复制切片和 Map + +切片和 map 包含指向底层数据的指针。在 API 边界处复制,以防止意外修改。 + +```go +// 接收:复制传入的切片 +d.trips = make([]Trip, len(trips)) +copy(d.trips, trips) + +// 返回:在返回之前复制 map +result := make(map[string]int, len(s.counters)) +for k, v := range s.counters { result[k] = v } +``` + +> 在 API 边界处复制切片或 map,或决定何时需要防御性复制、何时可以跳过时,请阅读 [references/BOUNDARY-COPYING.md](references/BOUNDARY-COPYING.md)。 + +## 使用 Defer 清理资源 + +使用 `defer` 清理资源(文件、锁)。避免在多个返回路径中遗漏清理。 + +```go +p.Lock() +defer p.Unlock() + +if p.count < 10 { + return p.count +} +p.count++ +return p.count +``` + +Defer 的开销可以忽略不计。在 `os.Open` 之后立即放置 `defer f.Close()` 以提高清晰度。延迟函数的参数在 `defer` 执行时求值,而非在函数运行时。多个 defer 按 LIFO 顺序执行。 + +## 结构体字段标签 + +> **建议**:始终为需要序列化或反序列化的结构体添加显式字段标签。 + +```go +type User struct { + Name string `json:"name" yaml:"name"` + Email string `json:"email" yaml:"email"` +} +``` + +字段标签是**序列化契约**——重命名结构体字段而不更新标签会悄然破坏线格式兼容性。对于任何跨越序列化边界的类型,应将标签视为公共 API 的一部分。 + +## 枚举从 1 开始 + +枚举从非零值开始,以区分未初始化的值和有效值。 + +```go +const ( + Add Operation = iota + 1 // Add=1,零值 = 未初始化 + Subtract + Multiply +) +``` + +**例外**:当零值是合理的默认值时(例如 `LogToStdout = iota`)。 + +## 时间、结构体标签和嵌入 + +> 在使用 `time.Time`/`time.Duration` 代替原始 int、为序列化结构体添加字段标签,或决定是否在公共结构体中嵌入类型时,请阅读 [references/TIME-ENUMS-TAGS.md](references/TIME-ENUMS-TAGS.md)。 + +## 避免可变全局变量 + +通过注入依赖代替修改包级变量。这使代码可以在不需要全局 save/restore 的情况下进行测试。 + +```go +type signer struct { + now func() time.Time // 注入的;测试中用固定时间替换 +} + +func newSigner() *signer { + return &signer{now: time.Now} +} +``` + +> 在决定全局变量是否合适、设计 New() + Default() 包状态模式,或用依赖注入替代可变全局变量时,请阅读 [references/GLOBAL-STATE.md](references/GLOBAL-STATE.md)。 + +## 加密随机数 + +不要使用 `math/rand` 或 `math/rand/v2` 生成密钥——这是一个**安全问题**。时间种子的生成器输出是可预测的。 + +```go +import "crypto/rand" + +func Key() string { return rand.Text() } +``` + +对于文本输出,直接使用 `crypto/rand.Text`,或用 `encoding/hex` 或 `encoding/base64` 编码随机字节。 + +--- + +## Panic 与 Recover + +仅在真正不可恢复的情况下使用 `panic`。库函数应避免 panic。 + +```go +func safelyDo(work *Work) { + defer func() { + if err := recover(); err != nil { + log.Println("work failed:", err) + } + }() + do(work) +} +``` + +**关键规则:** +- 绝不跨包边界暴露 panic——始终转换为 error +- 如果库确实无法在 `init()` 中完成初始化,可以接受 panic +- 使用 recover 隔离服务器 goroutine 处理器中的 panic + +> 在编写 HTTP 服务器中的 panic 恢复、在解析器中使用 panic 作为内部控制流机制,或在 log.Fatal 和 panic 之间做选择时,请阅读 [references/PANIC-RECOVER.md](references/PANIC-RECOVER.md)。 + +## Must 函数 + +`Must` 函数在出错时 panic——**仅**在程序初始化阶段使用,因为失败意味着程序无法运行。 + +```go +var validID = regexp.MustCompile(`^[a-z][a-z0-9-]{0,62}$`) +var tmpl = template.Must(template.ParseFiles("index.html")) +``` + +> 在编写自定义 Must 函数、决定 Must 是否适用于特定调用点,或将可能失败的初始化包装在 panic 辅助函数中时,请阅读 [references/MUST-FUNCTIONS.md](references/MUST-FUNCTIONS.md)。 + +--- + +## 相关技能 + +- **错误处理**:在选择返回错误还是 panic,或在边界处包装错误时,参见 [go-error-handling](../go-error-handling/SKILL.md) +- **并发安全**:在使用互斥锁、原子操作或通道保护共享状态时,参见 [go-concurrency](../go-concurrency/SKILL.md) +- **接口检查**:在添加编译时接口满足检查(`var _ I = (*T)(nil)`)时,参见 [go-interfaces](../go-interfaces/SKILL.md) +- **数据结构复制**:在处理切片/map 内部结构或指针别名时,参见 [go-data-structures](../go-data-structures/SKILL.md) diff --git a/.agent/skills/go-defensive/references/BOUNDARY-COPYING.md b/.agent/skills/go-defensive/references/BOUNDARY-COPYING.md new file mode 100644 index 00000000..cbb77a7e --- /dev/null +++ b/.agent/skills/go-defensive/references/BOUNDARY-COPYING.md @@ -0,0 +1,101 @@ +# 在 API 边界处复制切片和 Map + +> **来源**:Uber 风格指南 + +切片和 map 包含对其底层数据的引用。在 API 边界处复制它们,以防止调用者修改内部状态(反之亦然)。 + +## 接收切片和 Map + +当函数存储调用者传入的切片或 map 时,始终进行防御性复制。调用者保留原始引用,可以在函数返回后修改它。 + +### 切片 + +**不好** +```go +func (d *Driver) SetTrips(trips []Trip) { + d.trips = trips // 调用者仍然可以修改 d.trips +} +``` + +**好** +```go +func (d *Driver) SetTrips(trips []Trip) { + d.trips = make([]Trip, len(trips)) + copy(d.trips, trips) +} +``` + +### Map + +**不好** +```go +func (s *Server) SetConfig(cfg map[string]string) { + s.config = cfg // 调用者仍然可以修改 s.config +} +``` + +**好** +```go +func (s *Server) SetConfig(cfg map[string]string) { + s.config = make(map[string]string, len(cfg)) + for k, v := range cfg { + s.config[k] = v + } +} +``` + +## 返回切片和 Map + +返回内部切片或 map 时,返回副本以防止调用者修改你的内部状态。 + +### 返回 Map + +**不好** +```go +func (s *Stats) Snapshot() map[string]int { + s.mu.Lock() + defer s.mu.Unlock() + return s.counters // 暴露了内部状态! +} +``` + +**好** +```go +func (s *Stats) Snapshot() map[string]int { + s.mu.Lock() + defer s.mu.Unlock() + result := make(map[string]int, len(s.counters)) + for k, v := range s.counters { + result[k] = v + } + return result +} +``` + +### 返回切片 + +**不好** +```go +func (q *Queue) Items() []Item { + return q.items // 调用者可以追加、修改或重新切片 +} +``` + +**好** +```go +func (q *Queue) Items() []Item { + result := make([]Item, len(q.items)) + copy(result, q.items) + return result +} +``` + +## 何时不需要复制 + +防御性复制有开销。在以下情况下可以跳过: + +- 数据**按约定是不可变的**,并且有清晰的文档说明 +- 切片/map 是**为调用者新创建的**(不在内部存储) +- 性能分析表明复制在热路径中是瓶颈 + +如有疑问,就复制。与共享引用导致的 bug 相比,开销通常可以忽略不计。 diff --git a/.agent/skills/go-defensive/references/GLOBAL-STATE.md b/.agent/skills/go-defensive/references/GLOBAL-STATE.md new file mode 100644 index 00000000..d8c84ff2 --- /dev/null +++ b/.agent/skills/go-defensive/references/GLOBAL-STATE.md @@ -0,0 +1,144 @@ +# 全局状态模式 + +> **来源**:Google 风格指南, Effective Go + +全局状态使程序更难以测试、推理和维护。依赖注入是首选替代方案,但某些全局状态在谨慎使用时是可以接受的。 + +## 何时可以接受全局状态 + +并非所有包级变量都有害。当全局状态是**真正进程级别的**且**不值得注入**时,它是合适的: + +- **默认实例**——`http.DefaultClient`、`log.Default()`、`flag.CommandLine` +- **一次编译的值**——包级别的 `regexp.MustCompile(...)` +- **注册表**——`database/sql.Register`、`image.RegisterFormat` +- **单例基础设施**——进程级别的指标收集器或追踪导出器 + +## 全局变量的试金石测试 + +在添加包级变量之前,请问自己: + +1. **它是否真正是进程级别的?** 如果两个 goroutine 或测试可能需要不同的值,它不应该是全局的 +2. **它是否妨碍了测试?** 如果测试必须保存/恢复变量,或因此无法并行运行,应改为注入 +3. **它可以是常量吗?** 如果值在初始化后永远不会改变,优先使用 `const` 或未导出的只初始化一次的 `var` +4. **它是否携带可变状态?** 可变全局变量是最危险的——仅在有完善文档、并发安全的单例情况下才可接受 + +## 包状态 API 模式:New() + Default() + +标准库模式同时提供可定制的构造器和便捷的默认值。这使调用者可以在简单场景下使用默认值,在测试或特殊行为需求下注入自定义实例。 + +**好** +```go +package mylog + +type Logger struct { + prefix string + out io.Writer +} + +func New(prefix string, out io.Writer) *Logger { + return &Logger{prefix: prefix, out: out} +} + +var defaultLogger = New("", os.Stderr) + +func Default() *Logger { return defaultLogger } + +func (l *Logger) Info(msg string) { + fmt.Fprintf(l.out, "%s%s\n", l.prefix, msg) +} + +// 包级便捷函数委托给默认实例。 +func Info(msg string) { defaultLogger.Info(msg) } +``` + +```go +// 调用者在简单场景下使用默认值 +mylog.Info("starting server") + +// 测试或特殊代码创建自定义实例 +logger := mylog.New("[test] ", &buf) +logger.Info("test message") +``` + +此模式的标准库示例: +- `log.New()` + `log.Default()` + `log.Println()` +- `http.NewServeMux()` + `http.DefaultServeMux` +- `flag.NewFlagSet()` + `flag.CommandLine` + +## 依赖注入作为首选替代方案 + +当代码需要可配置行为时,通过构造器参数或结构体字段接受依赖,而非读取包级变量。 + +**不好** +```go +var db *sql.DB + +func GetUser(id int) (*User, error) { + return db.QueryRow("SELECT ...", id) // 依赖全局变量 +} +``` + +**好** +```go +type UserStore struct { + db *sql.DB +} + +func NewUserStore(db *sql.DB) *UserStore { + return &UserStore{db: db} +} + +func (s *UserStore) GetUser(id int) (*User, error) { + return s.db.QueryRow("SELECT ...", id) +} +``` + +注入的好处: +- 测试可以提供 mock 或内存实现 +- 多个实例可以共存(例如,只读副本与主库) +- 依赖在构造器签名中是显式的 + +## 注入时间 + +一个常见场景:替换 `time.Now` 以实现确定性测试。 + +**不好** +```go +func IsExpired(expiry time.Time) bool { + return time.Now().After(expiry) // 不可测试 +} +``` + +**好** +```go +type Checker struct { + now func() time.Time +} + +func NewChecker() *Checker { + return &Checker{now: time.Now} +} + +func (c *Checker) IsExpired(expiry time.Time) bool { + return c.now().After(expiry) +} +``` + +测试用固定函数替换 `now`: + +```go +c := &Checker{now: func() time.Time { + return time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC) +}} +``` + +## 总结 + +| 场景 | 方法 | +|------|------| +| 进程级单例(日志、指标) | 默认实例 + `New()` 构造器 | +| 一次编译的正则或模板 | 包级 `var` 配合 `MustCompile` | +| 注册表(数据库驱动、编解码器) | 包级 `Register()` 函数 | +| 可配置行为 | 通过构造器进行依赖注入 | +| 时间相关逻辑 | 注入 `func() time.Time` | +| 测试需要变化的任何东西 | 不要使用全局状态 | diff --git a/.agent/skills/go-defensive/references/MUST-FUNCTIONS.md b/.agent/skills/go-defensive/references/MUST-FUNCTIONS.md new file mode 100644 index 00000000..d0592a09 --- /dev/null +++ b/.agent/skills/go-defensive/references/MUST-FUNCTIONS.md @@ -0,0 +1,92 @@ +# Must 函数 + +> **来源**:Uber 风格指南, Go 标准库约定 + +`Must` 函数包装一个可能失败的函数,在出错时 panic。**仅**在程序初始化阶段使用,因为失败意味着程序无法运行。 + +## 标准库示例 + +```go +// regexp.MustCompile 在模式无效时 panic +var validID = regexp.MustCompile(`^[a-z][a-z0-9-]{0,62}$`) + +// template.Must 在模板解析失败时 panic +var tmpl = template.Must(template.ParseFiles("index.html")) +``` + +这些是安全的,因为它们在包初始化时运行——如果失败,程序无法正确运行。 + +## 何时使用 Must + +``` +这是在程序初始化期间调用的吗(包级 var、init、main 设置)? +├─ 是 → 失败是否不可恢复(配置、正则、模板)? +│ ├─ 是 → 使用 Must 是合适的 +│ └─ 否 → 改为返回 error +└─ 否 → 绝不使用 Must——返回 error +``` + +### 适当的使用场景 + +- **包级 `var`**:编译正则表达式、解析模板、加载必需的配置 +- **`init()` 或 `main()` 早期**:设置程序运行所必需的资源 +- **测试辅助函数**:测试中优先使用 `t.Fatal`,但 Must 在测试 fixture 中是可以接受的 + +### 绝不使用 Must 的场景 + +- 运行时请求处理 +- 用户提供的输入 +- 可能合理失败的网络或文件操作 +- 程序启动后调用的任何代码 + +## 编写 Must 函数 + +遵循命名约定 `MustX`,其中 `X` 是可能失败的函数名: + +```go +func MustParseConfig(path string) *Config { + cfg, err := ParseConfig(path) + if err != nil { + panic(fmt.Sprintf("parsing config %s: %v", path, err)) + } + return cfg +} +``` + +### 指南 + +- **命名**:`Must` 前缀 + 可能失败的函数名(例如 `MustParse`、`MustNew`、`MustCompile`) +- **Panic 消息**:包含输入和错误信息以便调试 +- **文档**:始终记录函数在出错时会 panic + +```go +// MustParseConfig 解析路径处的配置文件。 +// 如果文件无法读取或包含无效配置,则会 panic。 +func MustParseConfig(path string) *Config { ... } +``` + +### 泛型 Must 辅助函数 + +对于一次性使用,泛型 Must 辅助函数可以避免样板代码: + +```go +func Must[T any](v T, err error) T { + if err != nil { + panic(err) + } + return v +} + +// 在包级别使用 +var cfg = Must(ParseConfig("app.yaml")) +``` + +## 与 Panic/Recover 的关系 + +Must 函数是对 `panic` 的受控使用。它们应该: + +- 仅在初始化期间运行(因此不需要 recover) +- 产生清晰、可操作的 panic 消息 +- 绝不在可以返回 error 的场景中使用 + +完整的 panic/recover 模式请参见 [PANIC-RECOVER.md](PANIC-RECOVER.md)。 diff --git a/.agent/skills/go-defensive/references/PANIC-RECOVER.md b/.agent/skills/go-defensive/references/PANIC-RECOVER.md new file mode 100644 index 00000000..89f9379a --- /dev/null +++ b/.agent/skills/go-defensive/references/PANIC-RECOVER.md @@ -0,0 +1,161 @@ +# Panic 与 Recover 模式 + +> **来源**:Effective Go + +## Panic 指南 + +`panic` 创建一个运行时错误来停止程序。仅在真正不可恢复的情况下使用。 + +### 何时 Panic + +真正的库函数应**避免 panic**。如果问题可以被掩盖或绕过,让程序继续运行,而不是让整个程序崩溃。 + +```go +// 可接受:真正不可能的情况 +func CubeRoot(x float64) float64 { + z := x/3 + for i := 0; i < 1e6; i++ { + prevz := z + z -= (z*z*z-x) / (3*z*z) + if veryClose(z, prevz) { + return z + } + } + // 百万次迭代仍未收敛;出了问题。 + panic(fmt.Sprintf("CubeRoot(%g) did not converge", x)) +} +``` + +### 初始化中的 Panic + +例外:如果库在 `init()` 期间确实无法完成初始化,panic 可能是合理的: + +```go +var user = os.Getenv("USER") + +func init() { + if user == "" { + panic("no value for $USER") + } +} +``` + +### 何时 Panic 是可接受的 + +除了初始化之外,panic 在以下窄泛场景中是可接受的: + +1. **API 误用**——类似于核心语言对越界访问的 panic。`reflect` 包使用了这种方法。 +2. **带有匹配 `recover` 的内部实现细节**在包边界处。Panic 简化了深层嵌套的控制流,而公共 API 仍然返回 error(下方的 Parse/parseInt 模式)。 +3. **`panic("unreachable")`** 在 `log.Fatal` 之后,当编译器无法检测到不可达代码时。 + +#### Parse/parseInt 模式 + +在内部使用 panic 来回退复杂的递归,但始终在包边界处转换为 error: + +```go +func parseInt(in string) int { + n, err := strconv.Atoi(in) + if err != nil { + panic(&syntaxError{"not a valid integer"}) + } + return n +} + +func Parse(in string) (_ *Node, err error) { + defer func() { + if p := recover(); p != nil { + sErr, ok := p.(*syntaxError) + if !ok { + panic(p) // 不是我们的——重新 panic + } + err = fmt.Errorf("syntax error: %v", sErr.msg) + } + }() + // ... 内部调用 parseInt +} +``` + +**关键**:类型检查 `p.(*syntaxError)` 确保只捕获*我们的* panic。意外的 panic(nil 指针等)正常传播。 + +--- + +## Recover 模式 + +`recover` 重新获得对正在 panic 的 goroutine 的控制。它只在延迟函数中有效。 + +### 基本恢复模式 + +```go +func safelyDo(work *Work) { + defer func() { + if err := recover(); err != nil { + log.Println("work failed:", err) + } + }() + do(work) +} +``` + +### 服务器 Goroutine 保护 + +在服务器中将 panic 隔离到各个 goroutine: + +```go +func server(workChan <-chan *Work) { + for work := range workChan { + go safelyDo(work) // 每个 worker 都受保护 + } +} +``` + +如果 `do(work)` panic,结果会被记录,goroutine 干净退出而不影响其他 goroutine。 + +### 包内部的 Panic/Recover + +在内部使用 panic 但在 API 边界处转换为 error: + +```go +// Error 是一个解析错误类型 +type Error string +func (e Error) Error() string { return string(e) } + +// 内部:使用 Error 类型 panic +func (regexp *Regexp) error(err string) { + panic(Error(err)) +} + +// 外部 API:将 panic 转换为 error 返回 +func Compile(str string) (regexp *Regexp, err error) { + regexp = new(Regexp) + defer func() { + if e := recover(); e != nil { + regexp = nil + err = e.(Error) // 如果不是我们的 Error 类型则重新 panic + } + }() + return regexp.doParse(str), nil +} +``` + +**要点:** + +- 延迟函数可以修改命名返回值 +- 类型断言 `e.(Error)` 对意外错误类型重新 panic +- 绝不向客户端暴露 panic——始终在 API 边界处转换 + +--- + +## 快速参考 + +| 模式 | 描述 | +|------|------| +| 基本恢复 | `defer func() { if err := recover(); err != nil { ... } }()` | +| 服务器保护 | 将每个 goroutine 处理器包装在 safelyDo 中 | +| 包内部 | 内部 panic,在 API 边界处 recover 并返回 error | +| 类型安全恢复 | 使用类型断言对意外错误重新 panic | + +## 何时使用 + +- **Panic**:仅用于真正不可恢复的情况或初始化失败 +- **Recover**:服务器处理器、包内部错误简化 +- **绝不**:跨包边界暴露 panic——始终转换为 error diff --git a/.agent/skills/go-defensive/references/TIME-ENUMS-TAGS.md b/.agent/skills/go-defensive/references/TIME-ENUMS-TAGS.md new file mode 100644 index 00000000..5738347e --- /dev/null +++ b/.agent/skills/go-defensive/references/TIME-ENUMS-TAGS.md @@ -0,0 +1,111 @@ +# 时间、结构体标签和嵌入模式 + +## 使用 time.Time 和 time.Duration + +始终使用 `time` 包。避免使用原始 `int` 表示时间值。 + +### 时间点 + +**不好** +```go +func isActive(now, start, stop int) bool { + return start <= now && now < stop +} +``` + +**好** +```go +func isActive(now, start, stop time.Time) bool { + return (start.Before(now) || start.Equal(now)) && now.Before(stop) +} +``` + +### 时长 + +**不好** +```go +func poll(delay int) { + time.Sleep(time.Duration(delay) * time.Millisecond) +} +poll(10) // 秒?毫秒? +``` + +**好** +```go +func poll(delay time.Duration) { + time.Sleep(delay) +} +poll(10 * time.Second) +``` + +### JSON 字段 + +当无法使用 `time.Duration` 时,在字段名中包含单位: + +**不好** +```go +type Config struct { + Interval int `json:"interval"` +} +``` + +**好** +```go +type Config struct { + IntervalMillis int `json:"intervalMillis"` +} +``` + +## 避免在公共结构体中嵌入类型 + +嵌入类型会泄露实现细节并阻碍类型演进。 + +**不好** +```go +type ConcreteList struct { + *AbstractList +} +``` + +**好** +```go +type ConcreteList struct { + list *AbstractList +} + +func (l *ConcreteList) Add(e Entity) { + l.list.Add(e) +} + +func (l *ConcreteList) Remove(e Entity) { + l.list.Remove(e) +} +``` + +嵌入的问题: +- 向嵌入接口添加方法是破坏性变更 +- 从嵌入结构体移除方法是破坏性变更 +- 替换嵌入类型是破坏性变更 + +## 在序列化结构体中使用字段标签 + +始终为 JSON、YAML 等使用显式字段标签。 + +**不好** +```go +type Stock struct { + Price int + Name string +} +``` + +**好** +```go +type Stock struct { + Price int `json:"price"` + Name string `json:"name"` + // 可以安全地将 Name 重命名为 Symbol +} +``` + +标签使序列化契约显式化,并可以安全地进行重构。 diff --git a/.agent/skills/go-documentation/SKILL.md b/.agent/skills/go-documentation/SKILL.md new file mode 100644 index 00000000..648ddd09 --- /dev/null +++ b/.agent/skills/go-documentation/SKILL.md @@ -0,0 +1,167 @@ +--- +name: go-documentation +description: 在编写或审查 Go 包、类型、函数或方法的文档时使用。在创建新的导出类型、函数或包时也应主动使用,即使用户没有明确询问文档问题。不涵盖未导出符号的代码注释(参见 go-style-core)。 +license: Apache-2.0 +metadata: + sources: "Google 风格指南" +allowed-tools: Bash(bash:*) +--- + +# Go 文档 + +## 可用脚本 + +- **`scripts/check-docs.sh`** — 报告缺少文档注释的导出函数、类型、方法、常量和包。运行 `bash scripts/check-docs.sh --help` 查看选项。 + +> 在为新包或导出类型编写文档注释并需要所有文档约定的完整参考时,请参阅 `assets/doc-template.go`。 + +--- + +## 文档注释 + +> **规范**:所有顶层导出名称必须有文档注释。 + +### 基本规则 + +1. 以被描述对象的名称开头 +2. 冠词("a"、"an"、"the")可以放在名称前面 +3. 使用完整句子(首字母大写,带标点符号) + +```go +// A Request represents a request to run a command. +type Request struct { ... + +// Encode writes the JSON encoding of req to w. +func Encode(w io.Writer, req *Request) { ... +``` + +行为不明显的未导出类型/函数也应有文档注释。 + +> **验证**:添加文档注释后,运行 `bash scripts/check-docs.sh` 验证是否有导出符号缺少文档。修复所有缺失后再继续。 + +--- + +## 注释语句 + +> **规范**:文档注释必须是完整的句子。 + +- 首字母大写,以标点符号结尾 +- 例外:如果含义清晰,可以以小写标识符开头 +- 结构体字段的行尾注释可以是短语 + +--- + +## 注释行长度 + +> **建议**:目标约 80 列,但不设硬性限制。 + +根据标点符号换行。不要拆分长 URL。 + +--- + +## 结构体文档 + +使用段落注释对字段分组。标记可选字段及默认值: + +```go +type Options struct { + // 通用设置: + Name string + Group *FooGroup + + // 自定义设置: + LargeGroupThreshold int // 可选;默认值:10 +} +``` + +--- + +## 包注释 + +> **规范**:每个包必须有且仅有一个包注释。 + +```go +// Package math provides basic constants and mathematical functions. +package math +``` + +- 对于 `main` 包,使用二进制名称:`// The seed_generator command ...` +- 对于较长的包注释,使用 `doc.go` 文件 + +> 在编写包级文档、main 包注释、doc.go 文件或可运行示例时,请阅读 [references/EXAMPLES.md](references/EXAMPLES.md)。 + +--- + +## 文档编写要点 + +> **建议**:记录非显而易见的行为,显而易见的行为无需记录。 + +| 主题 | 何时记录... | 何时跳过... | +|------|------------|------------| +| 参数 | 非显而易见的行为、边界情况 | 只是重复类型签名 | +| 上下文 | 行为与标准取消不同 | 标准 `ctx.Err()` 返回 | +| 并发 | 线程安全性不明确(例如,看似读取但内部修改) | 只读安全、修改不安全 | +| 清理 | 始终记录资源释放要求 | — | +| 错误 | 哨兵值、错误类型(使用 `*PathError`) | — | +| 命名返回值 | 多个同类型参数、面向操作命名 | 类型本身已足够清晰 | + +关键原则: + +- 上下文取消返回 `ctx.Err()` 是隐含的 — 不要重复说明 +- 只读操作默认线程安全;修改操作默认不安全 — 不要重复说明 +- 始终记录清理要求(例如,`Call Stop to release resources`) +- 在错误类型文档中使用指针(`*PathError`),以确保 `errors.Is`/`errors.As` 正确使用 +- 不要仅为启用裸返回而命名返回值 — 清晰性 > 简洁性 + +> 在记录参数行为、上下文取消、并发安全性、清理要求、错误返回或函数文档注释中的命名返回参数时,请阅读 [references/CONVENTIONS.md](references/CONVENTIONS.md)。 + +--- + +## 可运行示例 + +> **建议**:在测试文件(`*_test.go`)中提供可运行示例。 + +```go +func ExampleConfig_WriteTo() { + cfg := &Config{Name: "example"} + cfg.WriteTo(os.Stdout) + // Output: + // {"name": "example"} +} +``` + +示例会出现在 Godoc 中,附加到对应的文档元素上。 + +> 在编写可运行 Example 函数、选择示例命名约定(Example vs ExampleType_Method)或添加包级 doc.go 文件时,请阅读 [references/EXAMPLES.md](references/EXAMPLES.md)。 + +--- + +## Godoc 格式化 + +> 在格式化 godoc 标题、链接、列表或代码块,使用信号增强来标记弃用通知,或在本地预览文档输出时,请阅读 [references/FORMATTING.md](references/FORMATTING.md)。 + +--- + +## 快速参考 + +| 主题 | 关键规则 | +|------|---------| +| 文档注释 | 以名称开头,使用完整句子 | +| 行长度 | 约 80 字符,优先考虑可读性 | +| 包注释 | 每个包一个,放在 `package` 声明之前 | +| 参数 | 仅记录非显而易见的行为 | +| 上下文 | 记录与隐含行为不同的例外情况 | +| 并发 | 记录线程安全性不明确的情况 | +| 清理 | 始终记录资源释放要求 | +| 错误 | 记录哨兵值和类型(注意指针) | +| 示例 | 在测试文件中使用可运行示例 | +| 格式化 | 空行分隔段落,缩进表示代码 | + +--- + +## 相关技能 + +- **命名约定**:在为文档注释描述的标识符选择名称时,参见 [go-naming](../go-naming/SKILL.md) +- **测试示例**:在编写出现在 godoc 中的可运行 `Example` 测试函数时,参见 [go-testing](../go-testing/SKILL.md) +- **Lint 强制执行**:在使用 revive 或其他 linter 强制执行文档注释存在性时,参见 [go-linting](../go-linting/SKILL.md) +- **风格原则**:在平衡文档详细程度与清晰简洁时,参见 [go-style-core](../go-style-core/SKILL.md) diff --git a/.agent/skills/go-documentation/assets/doc-template.go b/.agent/skills/go-documentation/assets/doc-template.go new file mode 100644 index 00000000..a2c54b85 --- /dev/null +++ b/.agent/skills/go-documentation/assets/doc-template.go @@ -0,0 +1,61 @@ +// Package example demonstrates proper Go documentation conventions. +// +// This package shows how to write doc comments for packages, types, +// functions, methods, and constants following Google Go Style Guide +// conventions. +// +// # Getting Started +// +// Create a new Widget with [NewWidget]: +// +// w := example.NewWidget("name") +// defer w.Close() +package example + +import "errors" + +// ErrNotFound is returned when a requested item does not exist. +var ErrNotFound = errors.New("example: not found") + +// MaxRetries is the default number of retry attempts. +const MaxRetries = 3 + +// Widget processes items with configurable options. +// +// A zero-value Widget is not valid; use [NewWidget] to create one. +// Widget is safe for concurrent use. +// +// # Cleanup +// +// Call [Widget.Close] when done to release resources. +type Widget struct { + name string +} + +// NewWidget creates a Widget with the given name. +// +// Name must be non-empty; NewWidget panics otherwise. +func NewWidget(name string) *Widget { + if name == "" { + panic("example: name must be non-empty") + } + return &Widget{name: name} +} + +// Process handles the given input and returns the result. +// +// Process returns [ErrNotFound] if the input references +// a missing item. +func (w *Widget) Process(input string) (string, error) { + return input, nil +} + +// Close releases resources held by the Widget. +func (w *Widget) Close() error { + return nil +} + +// Deprecated: Use [NewWidget] with functional options instead. +func NewWidgetLegacy(name string) *Widget { + return NewWidget(name) +} diff --git a/.agent/skills/go-documentation/references/CONVENTIONS.md b/.agent/skills/go-documentation/references/CONVENTIONS.md new file mode 100644 index 00000000..7990dcce --- /dev/null +++ b/.agent/skills/go-documentation/references/CONVENTIONS.md @@ -0,0 +1,239 @@ +# 文档约定参考 + +## 参数和配置 + +> **建议**:记录容易出错或非显而易见的参数,而非所有参数。 + +```go +// 不好:重复了显而易见的信息 +// Sprintf formats according to a format specifier and returns the resulting string. +// +// format is the format, and data is the interpolation data. +func Sprintf(format string, data ...any) string + +// 好:记录了非显而易见的行为 +// Sprintf formats according to a format specifier and returns the resulting string. +// +// The provided data is used to interpolate the format string. If the data does +// not match the expected format verbs or the amount of data does not satisfy +// the format specification, the function will inline warnings about formatting +// errors into the output string. +func Sprintf(format string, data ...any) string +``` + +--- + +## 上下文 + +> **建议**:不要重复隐含的上下文行为;记录例外情况。 + +上下文取消被隐含地认为会中断函数并返回 `ctx.Err()`。不要记录这一点。 + +```go +// 不好:重复了隐含的行为 +// Run executes the worker's run loop. +// +// The method will process work until the context is cancelled. +func (Worker) Run(ctx context.Context) error + +// 好:只记录关键信息 +// Run executes the worker's run loop. +func (Worker) Run(ctx context.Context) error +``` + +**当行为不同时记录:** + +```go +// 好:非标准的取消行为 +// Run executes the worker's run loop. +// +// If the context is cancelled, Run returns a nil error. +func (Worker) Run(ctx context.Context) error + +// 好:特殊的上下文要求 +// NewReceiver starts receiving messages sent to the specified queue. +// The context should not have a deadline. +func NewReceiver(ctx context.Context) *Receiver +``` + +--- + +## 并发 + +> **建议**:记录非显而易见的线程安全特性。 + +只读操作被认为是安全的;修改操作被认为是不安全的。不要重复说明这一点。 + +**何时记录:** + +```go +// 不明确的操作(看似只读但内部有修改) +// Lookup returns the data associated with the key from the cache. +// +// This operation is not safe for concurrent use. +func (*Cache) Lookup(key string) (data []byte, ok bool) + +// API 提供同步机制 +// NewFortuneTellerClient returns an *rpc.Client for the FortuneTeller service. +// It is safe for simultaneous use by multiple goroutines. +func NewFortuneTellerClient(cc *rpc.ClientConn) *FortuneTellerClient + +// 接口有并发要求 +// A Watcher reports the health of some entity (usually a backend service). +// +// Watcher methods are safe for simultaneous use by multiple goroutines. +type Watcher interface { + Watch(changed chan<- bool) (unwatch func()) + Health() error +} +``` + +--- + +## 清理 + +> **建议**:始终记录显式清理要求。 + +```go +// 好: +// NewTicker returns a new Ticker containing a channel that will send the +// current time on the channel after each tick. +// +// Call Stop to release the Ticker's associated resources when done. +func NewTicker(d Duration) *Ticker + +// 好:展示如何清理 +// Get issues a GET to the specified URL. +// +// When err is nil, resp always contains a non-nil resp.Body. +// Caller should close resp.Body when done reading from it. +// +// resp, err := http.Get("http://example.com/") +// if err != nil { +// // handle error +// } +// defer resp.Body.Close() +// body, err := io.ReadAll(resp.Body) +func (c *Client) Get(url string) (resp *Response, err error) +``` + +--- + +## 错误 + +> **建议**:记录重要的错误哨兵值和类型。 + +```go +// 好:记录哨兵值 +// Read reads up to len(b) bytes from the File and stores them in b. +// +// At end of file, Read returns 0, io.EOF. +func (*File) Read(b []byte) (n int, err error) + +// 好:记录错误类型(包含指针接收者) +// Chdir changes the current working directory to the named directory. +// +// If there is an error, it will be of type *PathError. +func Chdir(dir string) error +``` + +注意使用 `*PathError`(而非 `PathError`)可以确保 `errors.Is` 和 `errors.As` 的正确使用。 + +对于包级别的错误约定,在包注释中记录。 + +--- + +## 命名返回参数 + +> **建议**:在类型本身不够清晰时用于文档说明。 + +```go +// 好:多个同类型参数 +func (n *Node) Children() (left, right *Node, err error) + +// 好:面向操作的名称阐明了用法 +// The caller must arrange for the returned cancel function to be called. +func WithTimeout(parent Context, d time.Duration) (ctx Context, cancel func()) + +// 不好:类型已经很清晰,命名没有增加信息 +func (n *Node) Parent1() (node *Node) +func (n *Node) Parent2() (node *Node, err error) + +// 好:类型已足够 +func (n *Node) Parent1() *Node +func (n *Node) Parent2() (*Node, error) +``` + +不要仅为启用裸返回而命名返回值。清晰性 > 简洁性。 + +--- + +## 弃用通知 + +> **建议**:使用 `// Deprecated:` 注释标记符号为已弃用。 + +`Deprecated:` 段落必须出现在文档注释中紧接在符号之前。应说明使用什么替代。 + +**标准格式:** + +``` +// Deprecated: Use NewThing instead. +``` + +Godoc 会以特殊的视觉样式渲染 `Deprecated:` 注释,使其容易被发现。 + +**函数弃用:** + +```go +// EstimateSize returns an approximate byte count. +// +// Deprecated: Use [Size] instead, which returns an exact count. +func EstimateSize(r io.Reader) (int64, error) +``` + +**类型弃用:** + +```go +// LegacyClient talks to the v1 API. +// +// Deprecated: Use [Client] instead, which supports v2. +type LegacyClient struct{ /* ... */ } +``` + +**包弃用** — 在包文档注释中添加 `Deprecated:`: + +```go +// Package old provides the original implementation. +// +// Deprecated: Use package example/new instead. +package old +``` + +始终建议具体的替代方案,让调用者知道迁移目标。 + +--- + +## 注释语句 — 详细说明 + +> **规范**:文档注释必须是完整的句子。 + +- 首字母大写,以标点符号结尾 +- 例外:如果含义清晰,可以以小写标识符开头 +- 结构体字段的行尾注释可以是短语: + +```go +// 好: +// A Server handles serving quotes from Shakespeare. +type Server struct { + // BaseDir points to the base directory for Shakespeare's works. + // + // Expected structure: + // {BaseDir}/manifest.json + // {BaseDir}/{name}/{name}-part{number}.txt + BaseDir string + + WelcomeMessage string // 用户登录时显示 + ProtocolVersion string // 与传入请求进行校验 + PageLength int // 每页行数(可选;默认值:20) +} +``` diff --git a/.agent/skills/go-documentation/references/EXAMPLES.md b/.agent/skills/go-documentation/references/EXAMPLES.md new file mode 100644 index 00000000..44f33e39 --- /dev/null +++ b/.agent/skills/go-documentation/references/EXAMPLES.md @@ -0,0 +1,107 @@ +# 包注释和示例参考 + +## 包注释 + +> **规范**:每个包必须有且仅有一个包注释。 + +```go +// 好: +// Package math provides basic constants and mathematical functions. +// +// This package does not guarantee bit-identical results across architectures. +package math +``` + +### Main 包 + +使用二进制名称(与 BUILD 文件匹配): + +```go +// 好: +// The seed_generator command is a utility that generates a Finch seed file +// from a set of JSON study configs. +package main +``` + +有效格式:`Binary seed_generator`、`Command seed_generator`、`The seed_generator command`、`Seed_generator ...` + +### doc.go + +- 对于较长的包注释,使用仅包含包注释和 `package` 声明的 `doc.go` 文件 +- 放在 import 之后的维护者注释不会出现在 Godoc 中 +- 保持 doc.go 文件专注于面向用户的文档 + +```go +// Package complex provides advanced mathematical operations for +// complex number arithmetic, including polar form conversion, +// matrix operations, and numerical integration. +// +// Basic usage +// +// Create a complex number and perform operations: +// +// z := complex.New(3, 4) +// magnitude := z.Abs() // 5.0 +// conjugate := z.Conj() // (3, -4) +// +// Matrix operations +// +// The package supports complex-valued matrices: +// +// m := complex.NewMatrix(2, 2) +// m.Set(0, 0, complex.New(1, 0)) +// det := m.Det() +package complex +``` + +--- + +## 可运行示例 + +> **建议**:提供可运行示例来展示包的用法。 + +将示例放在测试文件(`*_test.go`)中: + +```go +// 好: +func ExampleConfig_WriteTo() { + cfg := &Config{ + Name: "example", + } + if err := cfg.WriteTo(os.Stdout); err != nil { + log.Exitf("Failed to write config: %s", err) + } + // Output: + // { + // "name": "example" + // } +} +``` + +示例会出现在 Godoc 中,附加到对应的文档元素上。 + +### 命名约定 + +| 函数名称 | 文档对象 | +|----------|---------| +| `Example()` | 包级别示例 | +| `ExampleFoo()` | 函数 `Foo` | +| `ExampleBar_Baz()` | 方法 `Bar.Baz` | +| `ExampleFoo_suffix()` | `Foo` 示例的命名变体 | + +### 技巧 + +- 使用 `// Output:` 注释使示例可通过 `go test` 进行测试和验证 +- 保持示例专注于展示一个概念 +- 使用真实但精简的数据 +- 对于复杂的设置,使用 `testMain` 或辅助函数保持示例主体简洁 +- 同一符号的多个示例使用小写 `_suffix`: + +```go +func ExampleNewClient_withTimeout() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + client := NewClient(ctx) + // ... +} +``` diff --git a/.agent/skills/go-documentation/references/FORMATTING.md b/.agent/skills/go-documentation/references/FORMATTING.md new file mode 100644 index 00000000..1a5e22f0 --- /dev/null +++ b/.agent/skills/go-documentation/references/FORMATTING.md @@ -0,0 +1,85 @@ +# Godoc 格式化参考 + +## Godoc 格式化 + +> **建议**:使用 godoc 语法编写格式良好的文档。 + +**段落** - 用空行分隔: + +```go +// 好: +// LoadConfig reads a configuration out of the named file. +// +// See some/shortlink for config file format details. +``` + +**逐字/代码块** - 额外缩进两个空格: + +```go +// 好: +// Update runs the function in an atomic transaction. +// +// This is typically used with an anonymous TransactionFunc: +// +// if err := db.Update(func(state *State) { state.Foo = bar }); err != nil { +// //... +// } +``` + +**列表和表格** - 使用逐字格式: + +```go +// 好: +// LoadConfig treats the following keys in special ways: +// "import" will make this configuration inherit from the named file. +// "env" if present will be populated with the system environment. +``` + +**标题** - 单行,首字母大写,无标点(括号/逗号除外),后跟段落: + +```go +// 好: +// Using headings +// +// Headings come with autogenerated anchor tags for easy linking. +``` + +--- + +## 信号增强 + +> **建议**:添加注释以突出不寻常或容易被忽略的模式。 + +以下两种情况很难区分: + +```go +if err := doSomething(); err != nil { // 常见 + // ... +} + +if err := doSomething(); err == nil { // 不寻常! + // ... +} +``` + +添加注释来增强信号: + +```go +// 好: +if err := doSomething(); err == nil { // 如果没有错误 + // ... +} +``` + +--- + +## 文档预览 + +> **建议**:在代码审查之前和期间预览文档。 + +```bash +go install golang.org/x/pkgsite/cmd/pkgsite@latest +pkgsite +``` + +这可以验证 godoc 格式化是否正确渲染。 diff --git a/.agent/skills/go-documentation/scripts/check-docs.sh b/.agent/skills/go-documentation/scripts/check-docs.sh new file mode 100755 index 00000000..6351d2b2 --- /dev/null +++ b/.agent/skills/go-documentation/scripts/check-docs.sh @@ -0,0 +1,298 @@ +#!/usr/bin/env bash +set -euo pipefail + +VERSION="1.0.0" +SCRIPT_NAME="$(basename "$0")" + +usage() { + cat <&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 +} + +MISSING=() + +add_missing() { + local file="$1" line="$2" kind="$3" name="$4" + MISSING+=("${file}:${line}|${kind}|${name}") +} + +check_file() { + local file="$1" + local prev_line="" + local prev_prev_line="" + local line_num=0 + + local in_grouped_block=false + local grouped_kind="" + + local re_method='^func[[:space:]]+\([^)]+\)[[:space:]]+([A-Z][a-zA-Z0-9]*)\(' + local re_func='^func[[:space:]]+([A-Z][a-zA-Z0-9]*)\(' + local re_unexported_func='^func[[:space:]]+([a-z][a-zA-Z0-9]*)\(' + local re_grouped_open='^(const|var|type)[[:space:]]*\($' + local re_exported_type='^type[[:space:]]+([A-Z][a-zA-Z0-9]*)[[:space:]]' + local re_unexported_type='^type[[:space:]]+([a-z][a-zA-Z0-9]*)[[:space:]]' + local re_exported_const='^const[[:space:]]+([A-Z][a-zA-Z0-9]*)[[:space:]]' + local re_exported_var='^var[[:space:]]+([A-Z][a-zA-Z0-9]*)[[:space:]]' + local re_grouped_exported='^[[:space:]]+([A-Z][a-zA-Z0-9]*)' + local re_grouped_unexported='^[[:space:]]+([a-z][a-zA-Z0-9]*)' + + while IFS= read -r line; do + line_num=$((line_num + 1)) + + # Check exported function/method declarations + if [[ "$line" =~ ^func[[:space:]] ]]; then + local name="" + local kind="" + # Method: func (r *Type) Name( + if [[ "$line" =~ $re_method ]]; then + name="${BASH_REMATCH[1]}" + kind="method" + # Function: func Name( + elif [[ "$line" =~ $re_func ]]; then + name="${BASH_REMATCH[1]}" + kind="function" + fi + + if [[ -n "$name" ]]; then + if ! is_documented "$prev_line" "$prev_prev_line"; then + add_missing "$file" "$line_num" "$kind" "$name" + fi + fi + + # Strict mode: also check unexported functions + if $STRICT && [[ -z "$name" ]] && [[ "$line" =~ $re_unexported_func ]]; then + name="${BASH_REMATCH[1]}" + if ! is_documented "$prev_line" "$prev_prev_line"; then + add_missing "$file" "$line_num" "function" "$name" + fi + fi + fi + + # Check exported type declarations + if [[ "$line" =~ $re_exported_type ]]; then + local name="${BASH_REMATCH[1]}" + if ! is_documented "$prev_line" "$prev_prev_line"; then + add_missing "$file" "$line_num" "type" "$name" + fi + fi + + # Strict mode: also check unexported type declarations + if $STRICT && [[ "$line" =~ $re_unexported_type ]]; then + local name="${BASH_REMATCH[1]}" + if ! is_documented "$prev_line" "$prev_prev_line"; then + add_missing "$file" "$line_num" "type" "$name" + fi + fi + + # Check exported const (single-line, not in block) + if [[ "$line" =~ $re_exported_const ]]; then + local name="${BASH_REMATCH[1]}" + if ! is_documented "$prev_line" "$prev_prev_line"; then + add_missing "$file" "$line_num" "const" "$name" + fi + fi + + # Check exported var (single-line, not blank identifier) + if [[ "$line" =~ $re_exported_var ]]; then + local name="${BASH_REMATCH[1]}" + if ! is_documented "$prev_line" "$prev_prev_line"; then + add_missing "$file" "$line_num" "var" "$name" + fi + fi + + # Check package comment + if [[ "$line" =~ ^package[[:space:]]+ ]]; then + if ! is_documented "$prev_line" "$prev_prev_line"; then + local pkg_name + pkg_name=$(echo "$line" | sed 's/^package[[:space:]]*//;s/[[:space:]]*$//') + add_missing "$file" "$line_num" "package" "$pkg_name" + fi + fi + + # Track grouped declaration blocks: const ( ... ), var ( ... ), type ( ... ) + if [[ "$line" =~ $re_grouped_open ]]; then + in_grouped_block=true + grouped_kind="${BASH_REMATCH[1]}" + fi + if $in_grouped_block && [[ "$line" =~ ^\)[[:space:]]*$ ]]; then + in_grouped_block=false + grouped_kind="" + fi + if $in_grouped_block && [[ -n "$grouped_kind" ]]; then + # Check for exported names inside grouped block + if [[ "$line" =~ $re_grouped_exported ]]; then + local gname="${BASH_REMATCH[1]}" + if ! is_documented "$prev_line" "$prev_prev_line"; then + add_missing "$file" "$line_num" "$grouped_kind" "$gname" + fi + fi + # Strict: also check unexported names in grouped blocks + if $STRICT && [[ "$line" =~ $re_grouped_unexported ]]; then + local gname="${BASH_REMATCH[1]}" + if ! is_documented "$prev_line" "$prev_prev_line"; then + add_missing "$file" "$line_num" "$grouped_kind" "$gname" + fi + fi + fi + + prev_prev_line="$prev_line" + prev_line="$line" + done < "$file" +} + +is_documented() { + local prev="$1" + local prev_prev="$2" + # Previous line is a comment (// or end of block comment */) + if [[ "$prev" =~ ^[[:space:]]*//.* ]] || [[ "$prev" =~ \*/[[:space:]]*$ ]]; then + return 0 + fi + # Previous line might be empty but line before is comment (allow one blank line) + if [[ -z "${prev// /}" ]] && [[ "$prev_prev" =~ ^[[:space:]]*//.* ]]; then + return 0 + fi + return 1 +} + +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 '{"missing":[],"count":0,"status":"no_go_files"}' + else + echo "No Go files found in: $TARGET" + fi + exit 0 +fi + +for file in "${FILES[@]}"; do + check_file "$file" +done + +# Truncation +TOTAL=${#MISSING[@]} +TRUNCATED=false +if [[ $LIMIT -gt 0 && $TOTAL -gt $LIMIT ]]; then + MISSING=("${MISSING[@]:0:$LIMIT}") + TRUNCATED=true +fi + +if $JSON_OUTPUT; then + echo "{" + echo ' "missing": [' + first=true + for entry in "${MISSING[@]+"${MISSING[@]}"}"; do + IFS='|' read -r location kind name <<< "$entry" + file="${location%%:*}" + line="${location#*:}" + $first || echo "," + first=false + printf ' {"file":"%s","line":%s,"kind":"%s","name":"%s"}' \ + "$(json_escape "$file")" "$line" "$(json_escape "$kind")" "$(json_escape "$name")" + done + echo "" + echo " ]," + printf ' "total": %d,\n' "$TOTAL" + printf ' "truncated": %s\n' "$TRUNCATED" + echo "}" +else + if [[ $TOTAL -eq 0 ]]; then + echo "All exported symbols are documented." + exit 0 + fi + + echo "Undocumented exported symbols:" + echo "" + for entry in "${MISSING[@]}"; do + IFS='|' read -r location kind name <<< "$entry" + printf " %s [%s] %s\n" "$location" "$kind" "$name" + done + if $TRUNCATED; then + echo " ... and $((TOTAL - LIMIT)) more (use --limit to adjust)" + fi + echo "" + echo "Total: $TOTAL undocumented symbol(s)" +fi + +if [[ $TOTAL -gt 0 ]]; then + exit 1 +fi +exit 0 diff --git a/.agent/skills/go-error-handling/SKILL.md b/.agent/skills/go-error-handling/SKILL.md new file mode 100644 index 00000000..3759e7b0 --- /dev/null +++ b/.agent/skills/go-error-handling/SKILL.md @@ -0,0 +1,168 @@ +--- +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) diff --git a/.agent/skills/go-error-handling/references/ERROR-FLOW.md b/.agent/skills/go-error-handling/references/ERROR-FLOW.md new file mode 100644 index 00000000..69d663fd --- /dev/null +++ b/.agent/skills/go-error-handling/references/ERROR-FLOW.md @@ -0,0 +1,153 @@ +# 错误流程模式 + +错误流程、一次处理原则和日志决策的详细模式。 + +## 缩进错误流程 + +在继续正常代码之前先处理错误。这通过使读者能够快速找到正常路径来提高可读性。 + +```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 | 开发期间有用的诊断细节 | diff --git a/.agent/skills/go-error-handling/references/ERROR-TYPES.md b/.agent/skills/go-error-handling/references/ERROR-TYPES.md new file mode 100644 index 00000000..db5a985e --- /dev/null +++ b/.agent/skills/go-error-handling/references/ERROR-TYPES.md @@ -0,0 +1,151 @@ +# 错误类型参考 + +本参考涵盖结构化错误类型、哨兵错误,以及如何为你的用例选择正确的错误类型。 + +--- + +## 错误结构 + +> 错误类型决策表在父技能中(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)` | diff --git a/.agent/skills/go-error-handling/references/WRAPPING.md b/.agent/skills/go-error-handling/references/WRAPPING.md new file mode 100644 index 00000000..1305848b --- /dev/null +++ b/.agent/skills/go-error-handling/references/WRAPPING.md @@ -0,0 +1,174 @@ +# 错误包装参考 + +本参考涵盖使用 `%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)` | +| 日志 | 不要既记录日志又返回;使用适当的日志级别 | diff --git a/.agent/skills/go-error-handling/scripts/check-errors.sh b/.agent/skills/go-error-handling/scripts/check-errors.sh new file mode 100755 index 00000000..bc90b0df --- /dev/null +++ b/.agent/skills/go-error-handling/scripts/check-errors.sh @@ -0,0 +1,266 @@ +#!/usr/bin/env bash +set -euo pipefail + +VERSION="1.0.0" +SCRIPT_NAME="$(basename "$0")" + +usage() { + cat <&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 在决定使用函数式选项还是配置结构体、设计具有适当默认值的配置结构体 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 diff --git a/.agent/skills/go-functional-options/references/OPTIONS-VS-STRUCTS.md b/.agent/skills/go-functional-options/references/OPTIONS-VS-STRUCTS.md new file mode 100644 index 00000000..a6e473a1 --- /dev/null +++ b/.agent/skills/go-functional-options/references/OPTIONS-VS-STRUCTS.md @@ -0,0 +1,129 @@ +# 函数式选项 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 优先使用一种方法。 diff --git a/.agent/skills/go-functions/SKILL.md b/.agent/skills/go-functions/SKILL.md new file mode 100644 index 00000000..8287dc09 --- /dev/null +++ b/.agent/skills/go-functions/SKILL.md @@ -0,0 +1,107 @@ +--- +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) diff --git a/.agent/skills/go-functions/references/PRINTF-STRINGER.md b/.agent/skills/go-functions/references/PRINTF-STRINGER.md new file mode 100644 index 00000000..805d9a2d --- /dev/null +++ b/.agent/skills/go-functions/references/PRINTF-STRINGER.md @@ -0,0 +1,264 @@ +# 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)`;转换为底层类型 | diff --git a/.agent/skills/go-functions/references/SIGNATURES.md b/.agent/skills/go-functions/references/SIGNATURES.md new file mode 100644 index 00000000..8a2ea59f --- /dev/null +++ b/.agent/skills/go-functions/references/SIGNATURES.md @@ -0,0 +1,168 @@ +# 函数签名 + +格式化 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 */` 注释或使用自定义类型 | +| 分组参数 | 当名称能清楚表明角色时将同类型分组 | +| 接收者 | 在函数名之前;简短缩写 | diff --git a/.agent/skills/go-generics/SKILL.md b/.agent/skills/go-generics/SKILL.md new file mode 100644 index 00000000..28aa8b62 --- /dev/null +++ b/.agent/skills/go-generics/SKILL.md @@ -0,0 +1,173 @@ +--- +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) diff --git a/.agent/skills/go-generics/references/CONSTRAINTS.md b/.agent/skills/go-generics/references/CONSTRAINTS.md new file mode 100644 index 00000000..825866e9 --- /dev/null +++ b/.agent/skills/go-generics/references/CONSTRAINTS.md @@ -0,0 +1,169 @@ +# 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` | +| 自定义约束 | 定义为包含类型元素和/或方法的接口 | +| 类型推断 | 当编译器可以推断时省略类型参数 | +| 最小约束 | 使用函数实际需要的最窄约束 | diff --git a/.agent/skills/go-interfaces/SKILL.md b/.agent/skills/go-interfaces/SKILL.md new file mode 100644 index 00000000..93c3a944 --- /dev/null +++ b/.agent/skills/go-interfaces/SKILL.md @@ -0,0 +1,151 @@ +--- +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) diff --git a/.agent/skills/go-interfaces/references/EMBEDDING.md b/.agent/skills/go-interfaces/references/EMBEDDING.md new file mode 100644 index 00000000..1bd5ba3d --- /dev/null +++ b/.agent/skills/go-interfaces/references/EMBEDDING.md @@ -0,0 +1,138 @@ +# 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)) +``` + +这种适配器模式在需要让独立函数满足单方法接口时非常有用。 diff --git a/.agent/skills/go-interfaces/references/RECEIVER-TYPE.md b/.agent/skills/go-interfaces/references/RECEIVER-TYPE.md new file mode 100644 index 00000000..6130cb50 --- /dev/null +++ b/.agent/skills/go-interfaces/references/RECEIVER-TYPE.md @@ -0,0 +1,68 @@ +# 接收者类型:指针 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) } // 不一致 +``` diff --git a/.agent/skills/go-interfaces/scripts/check-interface-compliance.sh b/.agent/skills/go-interfaces/scripts/check-interface-compliance.sh new file mode 100755 index 00000000..50ad2d7d --- /dev/null +++ b/.agent/skills/go-interfaces/scripts/check-interface-compliance.sh @@ -0,0 +1,224 @@ +#!/usr/bin/env bash +set -euo pipefail + +VERSION="1.0.0" +SCRIPT_NAME="$(basename "$0")" + +usage() { + cat <&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 diff --git a/.agent/skills/go-linting/SKILL.md b/.agent/skills/go-linting/SKILL.md new file mode 100644 index 00000000..774675ba --- /dev/null +++ b/.agent/skills/go-linting/SKILL.md @@ -0,0 +1,209 @@ +--- +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) diff --git a/.agent/skills/go-linting/assets/golangci.yml b/.agent/skills/go-linting/assets/golangci.yml new file mode 100644 index 00000000..841a9457 --- /dev/null +++ b/.agent/skills/go-linting/assets/golangci.yml @@ -0,0 +1,31 @@ +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 diff --git a/.agent/skills/go-linting/scripts/setup-lint.sh b/.agent/skills/go-linting/scripts/setup-lint.sh new file mode 100755 index 00000000..9deff85d --- /dev/null +++ b/.agent/skills/go-linting/scripts/setup-lint.sh @@ -0,0 +1,172 @@ +#!/usr/bin/env bash +set -euo pipefail + +VERSION="1.0.0" +SCRIPT_NAME="$(basename "$0")" + +usage() { + cat <&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 <&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 **规范**:在新的 Go 代码中使用 `log/slog`。 + +`slog` 是结构化的、分级别的,并且在标准库中(Go 1.21+)。它涵盖了绝大多数生产日志需求。 + +``` +选择哪个日志器? +├─ 新的生产代码 → log/slog +├─ 简单 CLI / 一次性 → log(标准库) +└─ 有性能瓶颈 → zerolog 或 zap(先做基准测试) +``` + +除非性能分析显示 `slog` 在热路径中是瓶颈,否则不要引入第三方日志库。引入时,保持相同的结构化键值风格。 + +> 在设置 slog handler、配置 JSON/文本输出或从 log.Printf 迁移到 slog 时,阅读 [references/LOGGING-PATTERNS.md](references/LOGGING-PATTERNS.md)。 + +--- + +## 结构化日志 + +> **规范**:始终使用键值对。永远不要将值插值到消息字符串中。 + +消息是描述发生了什么的**静态描述**。动态数据放在键值属性中: + +```go +// 好:静态消息,结构化字段 +slog.Info("order placed", "order_id", orderID, "total", total) + +// 不好:动态数据嵌入到消息字符串中 +slog.Info(fmt.Sprintf("order %d placed for $%.2f", orderID, total)) +``` + +### 键名 + +> **建议**:日志属性键使用 `snake_case`。 + +键应为小写、下划线分隔,并在整个代码库中保持一致:`user_id`、`request_id`、`elapsed_ms`。 + +### 类型化属性 + +对于性能关键路径,使用类型化构造函数以避免分配: + +```go +slog.LogAttrs(ctx, slog.LevelInfo, "request handled", + slog.String("method", r.Method), + slog.Int("status", code), + slog.Duration("elapsed", elapsed), +) +``` + +> 在优化日志性能或使用 Enabled() 进行预检查时,阅读 [references/LEVELS-AND-CONTEXT.md](references/LEVELS-AND-CONTEXT.md)。 + +--- + +## 日志级别 + +> **建议**:一致地遵循这些级别语义。 + +| 级别 | 何时使用 | 生产默认 | +|------|----------|----------| +| Debug | 仅开发人员的诊断,跟踪内部状态 | 禁用 | +| Info | 重要的生命周期事件:启动、关闭、配置加载 | 启用 | +| Warn | 意外但可恢复:使用了弃用功能、重试成功 | 启用 | +| Error | 操作失败,需要运维人员关注 | 启用 | + +**经验法则**: +- 如果没有人需要对其采取行动,那就不是 Error——使用 Warn 或 Info +- 如果只在连接调试器时才有用,那就是 Debug +- `slog.Error` 应始终包含 `"err"` 属性 + +```go +slog.Error("payment failed", "err", err, "order_id", id) +slog.Warn("retry succeeded", "attempt", n, "endpoint", url) +slog.Info("server started", "addr", addr) +slog.Debug("cache lookup", "key", key, "hit", hit) +``` + +> 在 Warn 和 Error 之间选择或定义自定义详细级别时,阅读 [references/LEVELS-AND-CONTEXT.md](references/LEVELS-AND-CONTEXT.md)。 + +--- + +## 请求作用域日志 + +> **建议**:从 context 派生日志器以携带请求作用域字段。 + +使用中间件为日志器添加请求 ID、用户 ID 或跟踪 ID,然后通过 context 或作为显式参数将增强后的日志器传递给下游: + +```go +func middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + logger := slog.With("request_id", requestID(r)) + ctx := context.WithValue(r.Context(), loggerKey, logger) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} +``` + +该请求中所有后续的日志调用都会自动携带 `request_id`。 + +> 在实现日志中间件或通过 context 传递日志器时,阅读 [references/LOGGING-PATTERNS.md](references/LOGGING-PATTERNS.md)。 + +--- + +## 日志或返回,不要同时 + +> **规范**:每个错误恰好处理一次——要么记录它,要么返回它。 + +记录错误然后返回它会导致重复噪音,因为栈上游的调用者也会处理该错误。 + +```go +// 不好:在这里记录,并且栈上游的每个调用者也会记录 +if err != nil { + slog.Error("query failed", "err", err) + return fmt.Errorf("query: %w", err) +} + +// 好:包装并返回——让调用者决定 +if err != nil { + return fmt.Errorf("query: %w", err) +} +``` + +**例外**:HTTP 处理器和其他栈顶边界可以在服务端记录详细错误,同时向客户端返回脱敏消息: + +```go +if err != nil { + slog.Error("checkout failed", "err", err, "user_id", uid) + http.Error(w, "internal error", http.StatusInternalServerError) + return +} +``` + +参见 [go-error-handling](../go-error-handling/SKILL.md) 了解完整的处理一次模式和错误包装指导。 + +--- + +## 不应记录的内容 + +> **规范**:永远不要记录密钥、凭证、PII 或高基数无界数据。 + +- 密码、API 密钥、令牌、会话 ID +- 完整的信用卡号、社会安全号 +- 可能包含用户数据的请求/响应体 +- 无界大小的完整切片或映射 + +> 在决定哪些数据可以安全包含在日志属性中时,阅读 [references/LEVELS-AND-CONTEXT.md](references/LEVELS-AND-CONTEXT.md)。 + +--- + +## 快速参考 + +| 应该 | 不应该 | +|------|--------| +| `slog.Info("msg", "key", val)` | `log.Printf("msg %v", val)` | +| 静态消息 + 结构化字段 | 在消息中使用 `fmt.Sprintf` | +| `snake_case` 键 | camelCase 或不一致的键 | +| 日志或返回错误 | 同时日志和返回同一错误 | +| 从 context 派生日志器 | 每次调用创建新日志器 | +| `slog.Error` 配合 `"err"` 属性 | 用 `slog.Info` 记录错误 | +| 在热路径上预检查 `Enabled()` | 始终分配日志参数 | + +--- + +## 相关技能 + +- **错误处理**:在决定是记录还是返回错误,或了解处理一次模式时,参见 [go-error-handling](../go-error-handling/SKILL.md) +- **上下文传播**:在通过 context 传递请求作用域值(包括日志器)时,参见 [go-context](../go-context/SKILL.md) +- **性能**:在优化热路径日志或减少日志调用中的分配时,参见 [go-performance](../go-performance/SKILL.md) +- **代码审查**:在审查 Go PR 中的日志实践时,参见 [go-code-review](../go-code-review/SKILL.md) diff --git a/.agent/skills/go-logging/references/LEVELS-AND-CONTEXT.md b/.agent/skills/go-logging/references/LEVELS-AND-CONTEXT.md new file mode 100644 index 00000000..d221bf54 --- /dev/null +++ b/.agent/skills/go-logging/references/LEVELS-AND-CONTEXT.md @@ -0,0 +1,244 @@ +# 级别与上下文 + +关于日志级别语义、基于 context 的日志模式、性能考虑以及哪些内容不应出现在日志中的详细指导。 + +## 级别语义 + +### Debug + +仅开发人员的诊断。生产中默认禁用。用于跟踪在开发或故障排查期间有帮助的内部状态: + +```go +slog.Debug("cache lookup", "key", key, "hit", hit) +slog.Debug("parsed config", "fields", len(cfg.Fields)) +slog.Debug("SQL query", "query", q, "args", args) +``` + +**何时使用**:内部状态转换、缓存行为、开发期间的详细请求/响应数据。 + +### Info + +确认系统按预期运行的重要事件。这些应在生产中对理解系统行为有用: + +```go +slog.Info("server started", "addr", addr, "version", version) +slog.Info("config loaded", "path", cfgPath, "env", env) +slog.Info("migration completed", "version", v, "elapsed_ms", elapsed) +slog.Info("user registered", "user_id", uid) +``` + +**何时使用**:启动/关闭、配置变更、重要业务事件、周期性健康摘要。 + +### Warn + +发生了意外的事情,但系统已恢复或优雅降级。运维人员可能想要调查但不需要立即行动: + +```go +slog.Warn("retry succeeded", "attempt", n, "endpoint", url) +slog.Warn("deprecated endpoint called", "path", r.URL.Path, "user_id", uid) +slog.Warn("rate limit approaching", "current", rate, "limit", max) +slog.Warn("fallback to default config", "err", err) +``` + +**何时使用**:最终成功的重试、弃用的代码路径、接近资源限制、回退行为。 + +### Error + +操作失败并需要运维人员关注。系统无法完成请求或任务: + +```go +slog.Error("payment failed", "err", err, "order_id", id, "amount", amt) +slog.Error("database connection lost", "err", err, "host", dbHost) +slog.Error("message processing failed", "err", err, "msg_id", msgID) +``` + +**何时使用**:影响用户的失败操作、丢失的连接、数据完整性问题、未恢复的外部服务故障。 + +**始终包含错误**:`slog.Error` 调用应始终带有包含实际错误值的 `"err"` 属性。 + +### 在 Warn 和 Error 之间选择 + +``` +操作最终是否成功? +├─ 是(经过重试/回退后)→ Warn +└─ 否(调用者收到错误)→ Error + ├─ 需要立即关注 → Error + └─ 可以等到下次审查 → Warn +``` + +--- + +## 自定义详细级别 + +slog 级别是整数。在标准级别之间定义自定义子级别以实现细粒度控制: + +```go +const ( + LevelTrace = slog.Level(-8) // 低于 Debug + LevelNotice = slog.Level(2) // 在 Info 和 Warn 之间 +) + +slog.Log(ctx, LevelTrace, "detailed trace", "span_id", spanID) +``` + +使用 `HandlerOptions.Level` 配合 `slog.LevelVar` 在运行时控制最低级别。 + +--- + +## 基于 Context 的日志 + +### 模式 1:Context 中的日志器 + +在 context 中存储增强后的 `*slog.Logger`。每个中间件层添加自己的字段: + +```go +func authMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + userID := authenticate(r) + logger := loggerFromCtx(r.Context()).With("user_id", userID) + ctx := context.WithValue(r.Context(), loggerKey, logger) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} +``` + +**优点**:简单,与任何 handler 链配合使用。 +**缺点**:需要纪律来始终使用 `loggerFromCtx`。 + +### 模式 2:显式日志器参数 + +将 `*slog.Logger` 作为函数参数与 context 一起传递: + +```go +func processOrder(ctx context.Context, logger *slog.Logger, order *Order) error { + logger.Info("processing order", "order_id", order.ID) + // ... +} +``` + +**优点**:显式依赖,更易测试,无需 context 键。 +**缺点**:每个函数签名中都有额外参数。 + +### 何时使用哪种 + +| 场景 | 推荐 | +|------|------| +| HTTP 处理器 / 中间件链 | Context 中的日志器 | +| 无 HTTP 依赖的库代码 | 显式参数 | +| 后台工作器 / 批处理任务 | 显式参数 | +| 深层调用链(5 层以上) | Context 中的日志器 | + +--- + +## 性能考虑 + +### 使用 Enabled() 预检查 + +当日志级别被禁用时避免分配日志参数: + +```go +// 开销大:参数始终被求值,即使 Debug 被禁用 +slog.Debug("request details", + "headers", fmt.Sprintf("%v", r.Header), + "body", string(bodyBytes), +) + +// 更好:禁用时完全跳过 +if slog.Default().Enabled(ctx, slog.LevelDebug) { + slog.Debug("request details", + "headers", fmt.Sprintf("%v", r.Header), + "body", string(bodyBytes), + ) +} +``` + +当参数构造开销大(格式化、序列化或读取数据)时,这很重要。对于简单属性(`slog.String`、`slog.Int`),开销可以忽略不计。 + +### 在热路径上使用 LogAttrs + +`slog.LogAttrs` 避免了便捷方法(`slog.Info` 等)产生的 `[]any` 分配: + +```go +// 标准——为键值对分配一个 []any +slog.Info("request handled", "method", r.Method, "status", code) + +// 更快——类型化属性,无 []any 分配 +slog.LogAttrs(ctx, slog.LevelInfo, "request handled", + slog.String("method", r.Method), + slog.Int("status", code), +) +``` + +### 避免在紧凑循环中记录日志 + +如果循环处理数千个项目,记录摘要而不是每次迭代: + +```go +// 不好:10k 项目批次中每个项目一条日志 +for _, item := range items { + slog.Debug("processing item", "id", item.ID) + process(item) +} + +// 好:记录摘要 +slog.Info("batch started", "count", len(items)) +processed, failed := processBatch(items) +slog.Info("batch completed", "processed", processed, "failed", failed) +``` + +--- + +## 不应记录的内容 + +### 密钥和凭证 + +永远不要记录: +- 密码、API 密钥、令牌(OAuth、JWT、会话) +- 私钥、证书 +- 包含凭证的数据库连接字符串 + +```go +// 不好 +slog.Info("connecting", "dsn", dsn) // 可能包含密码 + +// 好 +slog.Info("connecting", "host", dbHost, "database", dbName) +``` + +### 个人身份信息(PII) + +除非调试所需且你的保留策略允许,否则避免记录: +- 电子邮件地址、电话号码 +- 完整姓名、物理地址 +- IP 地址(在某些司法管辖区) +- 信用卡号、社会安全号 + +如果必须记录用户标识符,使用不透明 ID 而非 PII。 + +### 高基数无界数据 + +不要记录完整的请求体、Info 级别的完整栈跟踪或无界集合: + +```go +// 不好:无界数据 +slog.Info("received", "body", string(requestBody)) +slog.Info("users loaded", "users", users) // 可能有 10 万条记录 + +// 好:有界摘要 +slog.Info("received", "content_length", len(requestBody), "content_type", ct) +slog.Info("users loaded", "count", len(users)) +``` + +### 决策表 + +| 数据类型 | 记录吗? | 替代方案 | +|----------|----------|----------| +| 请求 ID / 跟踪 ID | 是 | — | +| 用户 ID(不透明的) | 是 | — | +| HTTP 方法、路径、状态 | 是 | — | +| 错误消息 | 是 | — | +| 密码 / 令牌 | **永不** | 记录令牌前缀或 "已脱敏" | +| 完整请求体 | **否** | 记录内容长度和类型 | +| PII(邮箱、姓名) | **避免** | 记录不透明用户 ID | +| 大型集合 | **否** | 记录数量或摘要 | +| 栈跟踪 | 仅 Debug | 使用 `slog.Debug` | diff --git a/.agent/skills/go-logging/references/LOGGING-PATTERNS.md b/.agent/skills/go-logging/references/LOGGING-PATTERNS.md new file mode 100644 index 00000000..e665bc35 --- /dev/null +++ b/.agent/skills/go-logging/references/LOGGING-PATTERNS.md @@ -0,0 +1,314 @@ +# 日志模式 + +关于 slog 设置、handler 配置、测试、HTTP 中间件以及从旧版 `log` 包迁移的详细模式。 + +## 设置 slog + +### 基本配置 + +```go +package main + +import ( + "log/slog" + "os" +) + +func main() { + // JSON handler 用于生产(机器可解析) + logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelInfo, + })) + slog.SetDefault(logger) + + slog.Info("server started", "addr", ":8080") + // 输出:{"time":"...","level":"INFO","msg":"server started","addr":":8080"} +} +``` + +### 用于开发的 Text Handler + +```go +// 本地开发的人类可读输出 +logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ + Level: slog.LevelDebug, +})) +slog.SetDefault(logger) +// 输出:time=... level=DEBUG msg="cache lookup" key=user:42 hit=true +``` + +### 动态级别控制 + +使用 `slog.LevelVar` 在运行时更改最低级别(例如通过管理端点或信号处理器): + +```go +var programLevel = new(slog.LevelVar) // 默认 Info + +func init() { + logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ + Level: programLevel, + })) + slog.SetDefault(logger) +} + +// 从管理端点或信号处理器调用 +func enableDebug() { + programLevel.Set(slog.LevelDebug) +} +``` + +--- + +## 自定义 Handler 模式 + +### 添加源位置 + +```go +logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ + AddSource: true, + Level: slog.LevelInfo, +})) +// 输出包含:"source":{"function":"main.handleRequest","file":"server.go","line":42} +``` + +### 使用默认属性包装 Handler + +使用 `slog.Handler` 中间件向每条日志记录注入字段: + +```go +type contextHandler struct { + inner slog.Handler + attrs []slog.Attr +} + +func (h *contextHandler) Enabled(ctx context.Context, level slog.Level) bool { + return h.inner.Enabled(ctx, level) +} + +func (h *contextHandler) Handle(ctx context.Context, r slog.Record) error { + r.AddAttrs(h.attrs...) + return h.inner.Handle(ctx, r) +} + +func (h *contextHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + return &contextHandler{inner: h.inner.WithAttrs(attrs), attrs: h.attrs} +} + +func (h *contextHandler) WithGroup(name string) slog.Handler { + return &contextHandler{inner: h.inner.WithGroup(name), attrs: h.attrs} +} +``` + +### 多 Handler(扇出) + +写入多个目标(例如 stdout + 文件): + +```go +type multiHandler struct { + handlers []slog.Handler +} + +func (m *multiHandler) Enabled(ctx context.Context, level slog.Level) bool { + for _, h := range m.handlers { + if h.Enabled(ctx, level) { + return true + } + } + return false +} + +func (m *multiHandler) Handle(ctx context.Context, r slog.Record) error { + var errs []error + for _, h := range m.handlers { + if h.Enabled(ctx, r.Level) { + if err := h.Handle(ctx, r); err != nil { + errs = append(errs, err) + } + } + } + return errors.Join(errs...) +} + +func (m *multiHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + handlers := make([]slog.Handler, len(m.handlers)) + for i, h := range m.handlers { + handlers[i] = h.WithAttrs(attrs) + } + return &multiHandler{handlers: handlers} +} + +func (m *multiHandler) WithGroup(name string) slog.Handler { + handlers := make([]slog.Handler, len(m.handlers)) + for i, h := range m.handlers { + handlers[i] = h.WithGroup(name) + } + return &multiHandler{handlers: handlers} +} +``` + +--- + +## 使用 slogtest 测试 + +Go 1.22+ 提供了 `testing/slogtest` 来验证 handler 实现: + +```go +package myhandler_test + +import ( + "testing" + "testing/slogtest" +) + +func TestHandler(t *testing.T) { + // newHandler 返回你的自定义 slog.Handler 和一个 + // 将输出解析为 []map[string]any 的函数用于验证。 + results := func(t *testing.T) map[string]any { + // 在此解析你的 handler 输出 + } + + h := NewMyHandler(buf, nil) + slogtest.Run(t, func(t *testing.T) slog.Handler { return h }, results) +} +``` + +### 在测试中捕获日志 + +对于断言日志输出的单元测试,写入 buffer: + +```go +func TestOrderProcessing(t *testing.T) { + var buf bytes.Buffer + logger := slog.New(slog.NewJSONHandler(&buf, nil)) + + processOrder(logger, order) + + if !strings.Contains(buf.String(), `"order_id"`) { + t.Error("expected order_id in log output") + } +} +``` + +--- + +## HTTP 请求日志中间件 + +一个完整的中间件,记录每个请求的计时、状态和请求作用域字段: + +```go +func loggingMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + reqID := r.Header.Get("X-Request-ID") + if reqID == "" { + reqID = uuid.NewString() + } + + logger := slog.With( + "request_id", reqID, + "method", r.Method, + "path", r.URL.Path, + ) + + // 包装 response writer 以捕获状态码 + rw := &responseWriter{ResponseWriter: w, status: http.StatusOK} + + // 将日志器存入 context 供下游 handler 使用 + ctx := context.WithValue(r.Context(), loggerKey, logger) + next.ServeHTTP(rw, r.WithContext(ctx)) + + logger.Info("request completed", + "status", rw.status, + "elapsed_ms", time.Since(start).Milliseconds(), + ) + }) +} + +type responseWriter struct { + http.ResponseWriter + status int +} + +func (rw *responseWriter) WriteHeader(code int) { + rw.status = code + rw.ResponseWriter.WriteHeader(code) +} +``` + +### 从 Context 获取日志器 + +```go +type ctxKey struct{} + +var loggerKey = ctxKey{} + +func loggerFromCtx(ctx context.Context) *slog.Logger { + if l, ok := ctx.Value(loggerKey).(*slog.Logger); ok { + return l + } + return slog.Default() +} +``` + +--- + +## 从 log.Printf 迁移到 slog + +### 第 1 步:替换直接调用 + +```go +// 迁移前 +log.Printf("user %s logged in from %s", userID, ip) + +// 迁移后 +slog.Info("user logged in", "user_id", userID, "ip", ip) +``` + +### 第 2 步:替换 main() 中的 log.Fatalf + +```go +// 迁移前 +log.Fatalf("failed to connect: %v", err) + +// 迁移后——slog 没有 Fatal;在 main 中使用 slog + os.Exit +slog.Error("failed to connect", "err", err) +os.Exit(1) +``` + +### 第 3 步:桥接旧代码 + +如果逐步迁移,将标准 `log` 包的输出通过 slog 重定向: + +```go +// 在 main() 中,设置 slog 之后: +slog.SetDefault(logger) + +// 标准 log 包现在通过 slog 的默认 handler 写入。 +// 这是因为 slog.SetDefault 也会更新 log.Default()。 +``` + +### 第 4 步:替换日志器参数 + +```go +// 迁移前:传递 *log.Logger +func NewServer(addr string, logger *log.Logger) *Server + +// 迁移后:显式传递 *slog.Logger +func NewServer(addr string, logger *slog.Logger) *Server + +// 或从 handler 中的 context 派生 +func (s *Server) handleRequest(ctx context.Context) { + logger := loggerFromCtx(ctx) + logger.Info("handling request") +} +``` + +### 迁移清单 + +| 步骤 | 更改什么 | 验证 | +|------|----------|------| +| 1 | `log.Printf` → `slog.Info/Warn/Error` | `rg 'log\.Printf'` 返回 0 个匹配 | +| 2 | `log.Fatalf` → `slog.Error` + `os.Exit(1)` 在 main 中 | 仅在 `main()` 中 | +| 3 | 在 main 中尽早设置 `slog.SetDefault` | 旧版 `log` 调用通过 slog 路由 | +| 4 | `*log.Logger` 参数 → `*slog.Logger` | 所有构造函数已更新 | +| 5 | 移除已替换处的 `"log"` 导入 | `goimports` 会自动处理 | diff --git a/.agent/skills/go-packages/SKILL.md b/.agent/skills/go-packages/SKILL.md new file mode 100644 index 00000000..e776f669 --- /dev/null +++ b/.agent/skills/go-packages/SKILL.md @@ -0,0 +1,138 @@ +--- +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) diff --git a/.agent/skills/go-packages/references/IMPORTS.md b/.agent/skills/go-packages/references/IMPORTS.md new file mode 100644 index 00000000..eb468c65 --- /dev/null +++ b/.agent/skills/go-packages/references/IMPORTS.md @@ -0,0 +1,110 @@ +# 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() +``` diff --git a/.agent/skills/go-packages/references/PACKAGE-SIZE.md b/.agent/skills/go-packages/references/PACKAGE-SIZE.md new file mode 100644 index 00000000..58717a0f --- /dev/null +++ b/.agent/skills/go-packages/references/PACKAGE-SIZE.md @@ -0,0 +1,214 @@ +# 包大小、程序结构和 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()` 退出。 diff --git a/.agent/skills/go-performance/SKILL.md b/.agent/skills/go-performance/SKILL.md new file mode 100644 index 00000000..7a89f9a1 --- /dev/null +++ b/.agent/skills/go-performance/SKILL.md @@ -0,0 +1,152 @@ +--- +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) diff --git a/.agent/skills/go-performance/references/BENCHMARKS.md b/.agent/skills/go-performance/references/BENCHMARKS.md new file mode 100644 index 00000000..46d19199 --- /dev/null +++ b/.agent/skills/go-performance/references/BENCHMARKS.md @@ -0,0 +1,281 @@ +# 基准测试方法 + +## 编写基准测试 + +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 +} +``` diff --git a/.agent/skills/go-performance/references/STRING-OPTIMIZATION.md b/.agent/skills/go-performance/references/STRING-OPTIMIZATION.md new file mode 100644 index 00000000..eca3bb77 --- /dev/null +++ b/.agent/skills/go-performance/references/STRING-OPTIMIZATION.md @@ -0,0 +1,134 @@ +# 字符串优化模式 + +## 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) | +| 反引号字面量 | 常量多行文本 | 零开销 | diff --git a/.agent/skills/go-performance/scripts/bench-compare.sh b/.agent/skills/go-performance/scripts/bench-compare.sh new file mode 100755 index 00000000..47f03925 --- /dev/null +++ b/.agent/skills/go-performance/scripts/bench-compare.sh @@ -0,0 +1,252 @@ +#!/usr/bin/env bash +set -euo pipefail + +VERSION="1.1.0" +SCRIPT_NAME="$(basename "$0")" + +usage() { + cat <&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 diff --git a/.agent/skills/go-style-core/SKILL.md b/.agent/skills/go-style-core/SKILL.md new file mode 100644 index 00000000..61ab9259 --- /dev/null +++ b/.agent/skills/go-style-core/SKILL.md @@ -0,0 +1,179 @@ +--- +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) diff --git a/.agent/skills/go-style-core/references/FORMATTING.md b/.agent/skills/go-style-core/references/FORMATTING.md new file mode 100644 index 00000000..9e89f5c4 --- /dev/null +++ b/.agent/skills/go-style-core/references/FORMATTING.md @@ -0,0 +1,95 @@ +# 格式化参考 + +## 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 + +**无效的** 局部覆盖: +- 行长度限制 +- 基于断言的测试库 diff --git a/.agent/skills/go-style-core/references/PRINCIPLES.md b/.agent/skills/go-style-core/references/PRINCIPLES.md new file mode 100644 index 00000000..a278136d --- /dev/null +++ b/.agent/skills/go-style-core/references/PRINCIPLES.md @@ -0,0 +1,89 @@ +# 风格原则参考 + +## 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. 一致性 + +代码的外观和行为应该与代码库中的类似代码一致。 + +- 包级别的一致性最重要 +- 当出现平局时,优先保持一致性 +- 绝不为了局部一致性而覆盖有文档记录的风格原则 diff --git a/.agent/skills/go-testing/SKILL.md b/.agent/skills/go-testing/SKILL.md new file mode 100644 index 00000000..a61034c1 --- /dev/null +++ b/.agent/skills/go-testing/SKILL.md @@ -0,0 +1,168 @@ +--- +name: go-testing +description: Use when writing, reviewing, or improving Go test code — including table-driven tests, subtests, parallel tests, test helpers, test doubles, and assertions with cmp.Diff. Also use when a user asks to write a test for a Go function, even if they don't mention specific patterns like table-driven tests or subtests. Does not cover benchmark performance testing (see go-performance). +license: Apache-2.0 +compatibility: Uses github.com/google/go-cmp for cmp.Diff comparisons +metadata: + sources: "Google Style Guide, Uber Style Guide" +allowed-tools: Bash(bash:*) +--- + +# Go 测试 + +## 快速参考 + +| 模式 | 使用场景 | +|------|----------| +| `t.Error` | 默认 — 报告失败,继续运行 | +| `t.Fatal` | 设置失败或继续运行没有意义 | +| `cmp.Diff` | 比较 struct、slice、map、proto | +| 表驱动 | 多个用例共享相同逻辑 | +| 子测试 | 需要过滤、并行执行或命名 | +| `t.Helper()` | 任何测试辅助函数(作为第一条语句调用) | +| `t.Cleanup()` | 在辅助函数中进行清理,替代 defer | + +--- + +## 有用的测试失败信息 + +> **规范**:测试失败必须在不阅读测试源码的情况下可诊断。 + +每条失败信息必须包含:函数名、输入、实际值(got)和期望值(want)。使用格式 `YourFunc(%v) = %v, want %v`。 + +```go +// 好: +t.Errorf("Add(2, 3) = %d, want %d", got, 5) + +// 不好:缺少函数名和输入 +t.Errorf("got %d, want %d", got, 5) +``` + +始终先打印 got 再打印 want:`got %v, want %v` — 绝不反转。 + +--- + +## 不使用断言库 + +> **规范**:不要使用断言库。对于复杂比较使用 `cmp.Diff`。 + +```go +if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("GetPost() mismatch (-want +got):\n%s", diff) +} +``` + +对于 protocol buffers,添加 `protocmp.Transform()` 作为 cmp 选项。始终在 diff 信息中包含方向键 `(-want +got)`。避免比较 JSON/序列化输出 — 改为语义比较。 + +> 在编写自定义比较辅助函数或领域特定测试工具时,请阅读 [references/TEST-HELPERS.md](references/TEST-HELPERS.md)。 + +--- + +## t.Error vs t.Fatal + +> **规范**:默认使用 `t.Error` 以在一次运行中报告所有失败。仅在无法继续时使用 `t.Fatal`。 + +**选择 `t.Fatal` 的场景:** +- 设置失败(数据库连接、文件加载) +- 下一个断言依赖于上一个断言成功(例如,编码后的解码) + +**绝不在测试 goroutine 以外的 goroutine 中调用 `t.Fatal`/`t.FailNow`** — 改为使用 `t.Error`。 + +> 在编写需要在 t.Error 和 t.Fatal 之间选择的辅助函数时,或需要两者的详细示例时,请阅读 [references/TEST-HELPERS.md](references/TEST-HELPERS.md)。 + +--- + +## 表驱动测试 + +> 在搭建新的表驱动测试并需要标准的 struct、循环和子测试布局时,请参阅 `assets/table-test-template.go`。 + +> **建议**:当多个用例共享相同逻辑时使用表驱动测试。 + +**使用表测试的场景:** 所有用例运行相同的代码路径,没有条件设置、mock 或断言。单个 `shouldErr` bool 是可以接受的。 + +**不使用表测试的场景:** 用例需要复杂设置、条件 mock 或多个分支 — 改为编写单独的测试函数。 + +**关键规则:** +- 当用例跨越多行或有相同类型的相邻字段时,使用字段名 +- 在失败信息中包含输入 — 绝不通过索引标识行 + +> 在编写表驱动测试、子测试或并行测试时,请阅读 [references/TABLE-DRIVEN-TESTS.md](references/TABLE-DRIVEN-TESTS.md)。 + +> **验证**:在生成或修改测试后,运行 `go test -run TestXxx -v` 验证测试能编译并通过。在继续之前修复任何编译错误。 + +--- + +## 测试辅助函数 + +> **规范**:测试辅助函数必须首先调用 `t.Helper()` 并使用 `t.Cleanup()` 进行清理。 + +```go +func setupTestDB(t *testing.T) *sql.DB { + t.Helper() + db, err := sql.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("Could not open database: %v", err) + } + t.Cleanup(func() { db.Close() }) + return db +} +``` + +> 在编写测试辅助函数、清理函数或自定义比较工具时,请阅读 [references/TEST-HELPERS.md](references/TEST-HELPERS.md)。 + +--- + +## 测试错误语义 + +> **建议**:测试错误语义,而非错误消息字符串。 + +```go +// 不好:脆弱的字符串比较 +if err.Error() != "invalid input" { ... } + +// 好:语义检查 +if !errors.Is(err, ErrInvalidInput) { ... } +``` + +对于不需要特定语义的简单存在性检查: + +```go +if gotErr := err != nil; gotErr != tt.wantErr { + t.Errorf("f(%v) error = %v, want error presence = %t", tt.input, err, tt.wantErr) +} +``` + +--- + +## 测试组织 + +> 在使用测试替身、选择测试包位置或规划测试设置范围时,请阅读 [references/TEST-ORGANIZATION.md](references/TEST-ORGANIZATION.md)。 + +> 在设计可重用的测试验证函数时,请阅读 [references/VALIDATION-APIS.md](references/VALIDATION-APIS.md)。 + +--- + +## 集成测试 + +> 在编写 TestMain、验收测试或需要真实 HTTP/RPC 传输层的测试时,请阅读 [references/INTEGRATION.md](references/INTEGRATION.md)。 + +--- + +## 可用脚本 + +- **`scripts/gen-table-test.sh`** — 生成表驱动测试脚手架 + +```bash +bash scripts/gen-table-test.sh ParseConfig config > config/parse_config_test.go +bash scripts/gen-table-test.sh --parallel ParseConfig config # 带 t.Parallel() +bash scripts/gen-table-test.sh --output config/parse_config_test.go ParseConfig config +``` + +--- + +## 相关 Skill + +- **错误测试**:在使用 `errors.Is`/`errors.As` 或哨兵错误测试错误语义时,请参阅 [go-error-handling](../go-error-handling/SKILL.md) +- **接口 mock**:在消费端通过实现接口创建测试替身时,请参阅 [go-interfaces](../go-interfaces/SKILL.md) +- **测试函数命名**:在命名测试函数、子测试或测试辅助工具时,请参阅 [go-naming](../go-naming/SKILL.md) +- **Linter 集成**:在 CI 或 pre-commit hooks 中与测试一起运行 linter 时,请参阅 [go-linting](../go-linting/SKILL.md) diff --git a/.agent/skills/go-testing/assets/table-test-template.go b/.agent/skills/go-testing/assets/table-test-template.go new file mode 100644 index 00000000..08f6fca5 --- /dev/null +++ b/.agent/skills/go-testing/assets/table-test-template.go @@ -0,0 +1,30 @@ +package example_test + +import "testing" + +func TestExample(t *testing.T) { + tests := []struct { + name string + // TODO: add input fields + // TODO: add expected output fields + }{ + { + name: "basic case", + // TODO: fill in + }, + { + name: "edge case", + // TODO: fill in + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // TODO: call function under test + // TODO: compare got vs want + // if diff := cmp.Diff(want, got); diff != "" { + // t.Errorf("Example() mismatch (-want +got):\n%s", diff) + // } + }) + } +} diff --git a/.agent/skills/go-testing/references/INTEGRATION.md b/.agent/skills/go-testing/references/INTEGRATION.md new file mode 100644 index 00000000..006749ba --- /dev/null +++ b/.agent/skills/go-testing/references/INTEGRATION.md @@ -0,0 +1,144 @@ +# Go 测试:集成和高级模式 + +TestMain、验收测试和真实传输层测试的详细参考。 +来源:Google Go Style Guide(最佳实践)。 + +--- + +## TestMain + +> **来源**:Google Go Style Guide(最佳实践) + +当 **包中的所有测试** 都需要共同的设置且需要清理时(例如,共享数据库),使用 `func TestMain(m *testing.M)`。这 **不应该是你的首选** — 尽可能优先使用作用域测试辅助函数或 `t.Cleanup`。 + +```go +var db *sql.DB + +func TestInsert(t *testing.T) { /* 使用 db */ } +func TestSelect(t *testing.T) { /* 使用 db */ } + +func runMain(ctx context.Context, m *testing.M) (code int, err error) { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + d, err := setupDatabase(ctx) + if err != nil { + return 0, err + } + defer d.Close() + db = d + + return m.Run(), nil +} + +func TestMain(m *testing.M) { + code, err := runMain(context.Background(), m) + if err != nil { + log.Fatal(err) + } + // defer 语句在 os.Exit 之后不会执行 + os.Exit(code) +} +``` + +关键点: +- 将设置提取到辅助函数(`runMain`)中,使 `defer` 能正确工作 +- 通过 `log.Fatal` 将失败信息写入 stderr +- 确保各个测试用例保持独立 — 重置它们修改的任何全局状态 + +--- + +## 验收测试 + +> **来源**:Google Go Style Guide(最佳实践) + +验收测试验证实现是否遵循契约,将其视为黑盒。当用户实现你的接口并且你想提供可重用的验证套件时,这种模式很有用。 + +### 结构 + +1. 创建测试辅助包(例如,为 `chess` 包创建 `chesstest`) +2. 导出一个接受被测实现的验证函数: + +```go +// Package chesstest 为 chess.Player 实现提供验收测试。 +package chesstest + +// ExercisePlayer 在单回合中测试 Player 实现。 +// 如果玩家走了正确的一步,返回 nil,否则返回描述违规行为的错误。 +func ExercisePlayer(b *chess.Board, p chess.Player) error { + move := p.Move() + if putsOwnKingIntoCheck(b, move) { + return &IllegalMoveError{Move: move, Reason: "puts own king in check"} + } + return nil +} +``` + +3. 最终用户针对验证函数编写简单测试: + +```go +func TestAcceptance(t *testing.T) { + player := deepblue.New() + if err := chesstest.ExerciseGame(t, chesstest.SimpleGame, player); err != nil { + t.Errorf("Deep Blue player failed acceptance test: %v", err) + } +} +``` + +仅在设置失败时使用 `t.Fatal` — 验证错误应该返回,而非 fatal。 + +--- + +## 使用真实传输层 + +> **来源**:Google Go Style Guide(最佳实践) + +在测试基于 HTTP 或 RPC 的组件集成时,优先使用真实传输层往返而非手动实现的客户端 mock: + +```go +func TestAPIIntegration(t *testing.T) { + // 使用假后端启动测试服务器 + srv := httptest.NewServer(newFakeHandler()) + t.Cleanup(srv.Close) + + // 对测试服务器使用真实 HTTP 客户端 + client := api.NewClient(srv.URL) + result, err := client.GetUser(context.Background(), "user-123") + if err != nil { + t.Fatalf("GetUser() error: %v", err) + } + if result.Name != "Test User" { + t.Errorf("GetUser().Name = %q, want %q", result.Name, "Test User") + } +} +``` + +使用生产客户端配合测试服务器,可以确保测试尽可能多地覆盖真实代码,避免模拟客户端行为的复杂性。 + +--- + +## 常见错误 + +### 在 TestMain 中直接调用 os.Exit + +`os.Exit` 立即终止进程 — defer 的清理函数永远不会执行。将设置/清理提取到辅助函数中,使 `defer` 能正确工作: + +```go +// 不好:defer 不会执行 +func TestMain(m *testing.M) { + setup() + defer cleanup() + os.Exit(m.Run()) // cleanup() 永远不会执行 +} + +// 好:提取到辅助函数中,使 defer 在 os.Exit 之前执行 +func runTests(m *testing.M) int { + setup() + defer cleanup() + return m.Run() +} + +func TestMain(m *testing.M) { + os.Exit(runTests(m)) +} +``` diff --git a/.agent/skills/go-testing/references/TABLE-DRIVEN-TESTS.md b/.agent/skills/go-testing/references/TABLE-DRIVEN-TESTS.md new file mode 100644 index 00000000..ef1e643e --- /dev/null +++ b/.agent/skills/go-testing/references/TABLE-DRIVEN-TESTS.md @@ -0,0 +1,154 @@ +# 表驱动测试、子测试和并行测试 + +在 Go 中组织表驱动测试和子测试的详细参考。 +来源:Google Go Style Guide、Uber Go Style Guide。 + +--- + +## 基本结构 + +```go +func TestCompare(t *testing.T) { + tests := []struct { + a, b string + want int + }{ + {"", "", 0}, + {"a", "", 1}, + {"", "a", -1}, + {"abc", "abc", 0}, + } + for _, tt := range tests { + got := Compare(tt.a, tt.b) + if got != tt.want { + t.Errorf("Compare(%q, %q) = %v, want %v", tt.a, tt.b, got, tt.want) + } + } +} +``` + +--- + +## 最佳实践 + +当测试用例跨越多行或有相同类型的相邻字段时,**使用字段名**: + +```go +tests := []struct { + name string + input string + want int +}{ + {name: "empty", input: "", want: 0}, + {name: "single", input: "a", want: 1}, +} +``` + +**不要通过索引标识行** — 在失败信息中包含输入,而非使用 `Case #%d failed`。 + +--- + +## 避免表测试中的复杂性 + +当测试用例需要复杂设置、条件 mock 或多个分支时,优先使用单独的测试函数而非表测试。 + +```go +// 不好:太多条件字段使测试难以理解 +tests := []struct { + give string + want string + wantErr error + shouldCallX bool + shouldCallY bool + giveXResponse string + giveXErr error + giveYResponse string + giveYErr error +}{...} + +for _, tt := range tests { + t.Run(tt.give, func(t *testing.T) { + if tt.shouldCallX { + xMock.EXPECT().Call().Return(tt.giveXResponse, tt.giveXErr) + } + if tt.shouldCallY { + yMock.EXPECT().Call().Return(tt.giveYResponse, tt.giveYErr) + } + // ... + }) +} + +// 好:单独的专注测试更清晰 +func TestShouldCallX(t *testing.T) { + xMock.EXPECT().Call().Return("XResponse", nil) + got, err := DoComplexThing("inputX", xMock, yMock) + // 断言... +} + +func TestShouldCallYAndFail(t *testing.T) { + yMock.EXPECT().Call().Return("YResponse", nil) + _, err := DoComplexThing("inputY", xMock, yMock) + // 断言错误... +} +``` + +**表测试最适合以下场景:** + +- 所有用例运行相同逻辑(无条件断言) +- 所有用例的设置相同 +- 没有基于测试用例字段的条件 mock +- 所有表字段在所有测试中都被使用 + +如果测试体短且直接,单个 `shouldErr` 字段用于成功/失败检查是可以接受的。 + +--- + +## 子测试 + +使用 `t.Run` 实现更好的组织、过滤和并行执行。 + +### 子测试命名 + +- 使用清晰、简洁的名称:`t.Run("empty_input", ...)`、`t.Run("hu_to_en", ...)` +- 避免冗长的描述或斜杠(斜杠会破坏测试过滤) +- 子测试必须独立 — 不共享状态或执行顺序依赖 + +### 带子测试的表测试 + +```go +func TestTranslate(t *testing.T) { + tests := []struct { + name, srcLang, dstLang, input, want string + }{ + {"hu_en_basic", "hu", "en", "köszönöm", "thank you"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := Translate(tt.srcLang, tt.dstLang, tt.input); got != tt.want { + t.Errorf("Translate(%q, %q, %q) = %q, want %q", + tt.srcLang, tt.dstLang, tt.input, got, tt.want) + } + }) + } +} +``` + +--- + +## 并行测试 + +在表测试中使用 `t.Parallel()` 时,注意循环变量捕获: + +```go +for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + // Go 1.22+:tt 在每次迭代中被正确捕获 + // Go 1.21-:在此处添加 "tt := tt" 来捕获变量 + got := Process(tt.give) + if got != tt.want { + t.Errorf("Process(%q) = %q, want %q", tt.give, got, tt.want) + } + }) +} +``` diff --git a/.agent/skills/go-testing/references/TEST-HELPERS.md b/.agent/skills/go-testing/references/TEST-HELPERS.md new file mode 100644 index 00000000..5bca30db --- /dev/null +++ b/.agent/skills/go-testing/references/TEST-HELPERS.md @@ -0,0 +1,131 @@ +# 测试辅助函数、断言和比较 + +编写测试辅助函数、避免断言库以及在 t.Error 和 t.Fatal 之间选择的详细参考。 +来源:Google Go Style Guide、Uber Go Style Guide。 + +--- + +## 测试辅助函数模式 + +测试辅助函数必须首先调用 `t.Helper()`,使失败指向调用者。 +对设置失败使用 `t.Fatal`,对清理使用 `t.Cleanup`。 + +```go +func mustLoadTestData(t *testing.T, filename string) []byte { + t.Helper() + data, err := os.ReadFile(filename) + if err != nil { + t.Fatalf("Setup failed: could not read %s: %v", filename, err) + } + return data +} + +func setupTestDB(t *testing.T) *sql.DB { + t.Helper() + db, err := sql.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("Could not open database: %v", err) + } + t.Cleanup(func() { db.Close() }) + return db +} +``` + +**关键规则:** +- 将 `t.Helper()` 作为第一条语句调用,将失败归因于调用者 +- 对设置失败使用 `t.Fatal`(不要从辅助函数返回错误) +- 使用 `t.Cleanup()` 进行清理而非 defer — 即使测试调用 `t.FailNow` 它也会执行 + +--- + +## 避免断言库 + +> **规范**:不要创建或使用断言库。 + +断言库会碎片化开发者体验,并且经常产生无用的失败信息。 + +```go +// 不好: +assert.IsNotNil(t, "obj", obj) +assert.StringEq(t, "obj.Type", obj.Type, "blogPost") +assert.IntEq(t, "obj.Comments", obj.Comments, 2) + +// 好:使用 cmp 包和标准比较 +want := BlogPost{ + Type: "blogPost", + Comments: 2, + Body: "Hello, world!", +} +if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("GetPost() mismatch (-want +got):\n%s", diff) +} +``` + +### 领域特定比较 + +对于领域特定比较,返回值或错误而非调用 `t.Error`: + +```go +func postLength(p BlogPost) int { return len(p.Body) } + +func TestBlogPost(t *testing.T) { + post := BlogPost{Body: "Hello"} + if got, want := postLength(post), 5; got != want { + t.Errorf("postLength(post) = %v, want %v", got, want) + } +} +``` + +--- + +## 比较和 Diff + +对于复杂类型,优先使用 `cmp.Equal` 和 `cmp.Diff`。始终在 diff 信息中包含方向键 `(-want +got)`。 + +```go +// struct 比较 +want := &Doc{Type: "blogPost", Authors: []string{"isaac", "albert"}} +if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("AddPost() mismatch (-want +got):\n%s", diff) +} + +// Protocol buffers +if diff := cmp.Diff(want, got, protocmp.Transform()); diff != "" { + t.Errorf("Foo() mismatch (-want +got):\n%s", diff) +} +``` + +**避免不稳定的比较** — 不要比较可能变化的 JSON/序列化输出。改为语义比较。 + +--- + +## t.Error vs t.Fatal:详细指南 + +使用 `t.Error` 保持测试继续运行,在一次运行中报告所有失败: + +```go +// 好:报告所有不匹配 +if diff := cmp.Diff(wantMean, gotMean); diff != "" { + t.Errorf("Mean mismatch (-want +got):\n%s", diff) +} +if diff := cmp.Diff(wantVariance, gotVariance); diff != "" { + t.Errorf("Variance mismatch (-want +got):\n%s", diff) +} +``` + +当后续检查无意义时使用 `t.Fatal`: + +```go +gotEncoded := Encode(input) +if gotEncoded != wantEncoded { + t.Fatalf("Encode(%q) = %q, want %q", input, gotEncoded, wantEncoded) +} +gotDecoded, err := Decode(gotEncoded) +if err != nil { + t.Fatalf("Decode(%q) error: %v", gotEncoded, err) +} +``` + +### 不要从 Goroutine 中调用 t.Fatal + +> **规范**:绝不在测试 goroutine 以外的 goroutine 中调用 `t.Fatal`、`t.Fatalf` 或 `t.FailNow`。改为使用 `t.Error` 并让 goroutine 自然返回。 diff --git a/.agent/skills/go-testing/references/TEST-ORGANIZATION.md b/.agent/skills/go-testing/references/TEST-ORGANIZATION.md new file mode 100644 index 00000000..e3299adb --- /dev/null +++ b/.agent/skills/go-testing/references/TEST-ORGANIZATION.md @@ -0,0 +1,167 @@ +# 测试组织参考 + +来源:Google Go Style Guide(最佳实践、决策)。 + +--- + +## 测试替身类型 + +| 替身 | 用途 | 有状态? | 验证调用? | +|------|------|----------|-----------| +| Stub | 返回预设数据 | 否 | 否 | +| Fake | 可工作但简化的实现 | 是 | 否 | +| Spy | 记录调用以供后续检查 | 是 | 是 | + +**优先使用 fake 而非 mock。** Fake 更具可读性且不需要 mock 框架。仅在验证副作用(例如,分析事件)时使用 spy。 + +```go +// Fake:可工作的内存实现 +type FakeUserStore struct { + users map[string]*User +} + +func (f *FakeUserStore) GetUser(id string) (*User, error) { + u, ok := f.users[id] + if !ok { + return nil, ErrNotFound + } + return u, nil +} + +// Spy:记录调用以供后续断言 +type SpyEmailSender struct{ Sent []string } + +func (s *SpyEmailSender) Send(to, body string) error { + s.Sent = append(s.Sent, to) + return nil +} +``` + +--- + +## 测试替身命名约定 + +> **建议**:为测试替身(stub、fake、spy)遵循一致的命名。 + +**包命名**:在生产代码旁边创建一个 `*test` 包(例如,为 `creditcard` 包创建 `creditcardtest`,为独立的 fake 服务创建 `fakeauthservice`)。 + +```go +// 好:在 creditcardtest 包中 + +// 单个替身 — 使用简单名称 +type Stub struct{} +func (Stub) Charge(*creditcard.Card, money.Money) error { return nil } + +// 多种行为 — 按行为命名 +type AlwaysCharges struct{} +type AlwaysDeclines struct{} + +// 多种类型 — 包含类型名 +type StubService struct{} +type StubStoredValue struct{} +``` + +**局部变量**:为测试替身变量添加替身类型前缀,使调用处更清晰: + +```go +// 好:替身类型立即可见 +spyCC := &creditcardtest.Spy{} +stubDB := &dbtest.Stub{Balance: 100} + +// 不好:模糊 — 这是真实的还是替身? +cc := &creditcardtest.Spy{} +db := &dbtest.Stub{Balance: 100} +``` + +--- + +## 独立测试辅助包 + +当多个包需要相同的替身、辅助函数有足够的逻辑需要自己的测试、或者你想为接口实现者提供验收测试套件时,创建独立的测试辅助包。 + +| 模式 | 使用场景 | 示例 | +|------|----------|------| +| `footest` | `foo` 包的通用测试辅助 | `creditcardtest`、`usertest` | +| `fakeX` | 独立的 fake 服务包 | `fakeauthservice`、`fakestorage` | + +```go +package usertest + +func NewFakeStore(t *testing.T, users ...*user.User) *FakeUserStore { + t.Helper() + store := &FakeUserStore{users: make(map[string]*user.User)} + for _, u := range users { + store.users[u.ID] = u + } + return store +} +``` + +导出接受 `*testing.T` 的构造函数,以便调用 `t.Helper()` 和 `t.Cleanup()`。 + +--- + +## 测试包 + +| 包声明 | 使用场景 | +|--------|----------| +| `package foo` | 同包测试,可以访问非导出标识符 | +| `package foo_test` | 黑盒测试,避免循环依赖 | + +两者都放在同一目录下的 `foo_test.go` 文件中。 + +**使用 `package foo`(白盒)** 当你需要测试非导出函数或内部状态时。 + +**使用 `package foo_test`(黑盒)** 当仅测试公共 API、打破导入循环或验证外部可用性时。 + +```go +package parser_test // 黑盒:仅测试导出的 API + +import "mymodule/parser" + +func TestParse(t *testing.T) { + got, err := parser.Parse("input") + // ... +} +``` + +如果黑盒测试需要非导出符号,在 `package foo`(非 `foo_test`)中创建 `export_test.go` 来暴露它。谨慎使用。 + +--- + +## 设置作用域 + +> **建议**:保持设置仅限于需要它的测试。 + +每个测试中的显式设置更清晰,避免惩罚不相关的测试: + +```go +// 好:在需要它的测试中显式设置 +func TestParseData(t *testing.T) { + data := mustLoadDataset(t) + // ... +} + +func TestUnrelated(t *testing.T) { + // 不需要为数据集加载付出代价 +} +``` + +**避免使用全局 `init` 进行测试设置** — 它会对文件中的每个测试运行,即使是不相关的测试。 + +**子测试设置**:当一组子测试共享设置时,使用带 `t.Run` 的父测试: + +```go +func TestDatabase(t *testing.T) { + db := setupTestDB(t) + + t.Run("Insert", func(t *testing.T) { + // 使用 db + }) + t.Run("Select", func(t *testing.T) { + // 使用 db + }) +} +``` + +这将数据库的生命周期限定在需要它的子测试范围内。仅在万不得已时使用 `TestMain`(参见 [INTEGRATION.md](INTEGRATION.md))。 diff --git a/.agent/skills/go-testing/references/VALIDATION-APIS.md b/.agent/skills/go-testing/references/VALIDATION-APIS.md new file mode 100644 index 00000000..3a618b3c --- /dev/null +++ b/.agent/skills/go-testing/references/VALIDATION-APIS.md @@ -0,0 +1,108 @@ +# 可扩展验证 API + +设计可重用测试验证函数的详细参考,调用者可以将其用于验收测试。来源:Google Go Style Guide(最佳实践)。 + +--- + +## `*test` 包导出模式 + +当你拥有一个由他人实现的接口时,在配套的 `*test` 包中导出一个验证函数。这使实现者无需复制你的测试逻辑即可验证正确性。 + +```go +// Package storagetest 为 storage.Backend 提供验收测试。 +package storagetest + +// Verify 对任何 storage.Backend 运行验证套件。 +// 返回描述第一个违规行为的错误,成功时返回 nil。 +func Verify(b storage.Backend) error { + if err := verifyRoundTrip(b); err != nil { + return fmt.Errorf("round-trip: %w", err) + } + if err := verifyNotFound(b); err != nil { + return fmt.Errorf("not-found: %w", err) + } + return nil +} +``` + +调用者编写一个薄测试来接入他们的实现: + +```go +func TestMyBackend(t *testing.T) { + b := mybackend.New(t) + if err := storagetest.Verify(b); err != nil { + t.Errorf("MyBackend failed acceptance: %v", err) + } +} +``` + +--- + +## 设计可扩展的验证函数 + +**返回错误,而非 `*testing.T` 失败。** 这使验证函数可作为普通 Go 函数使用 — 调用者决定违规是 `t.Error` 还是 `t.Fatal`。 + +```go +// 好:返回错误 — 调用者控制测试流程 +func ExercisePlayer(b *chess.Board, p chess.Player) error { + move := p.Move() + if putsOwnKingIntoCheck(b, move) { + return &IllegalMoveError{Move: move, Reason: "puts own king in check"} + } + return nil +} + +// 不好:调用 t.Fatal — 调用者失去控制 +func ExercisePlayer(t *testing.T, b *chess.Board, p chess.Player) { + t.Helper() + move := p.Move() + if putsOwnKingIntoCheck(b, move) { + t.Fatalf("illegal move: %v puts own king in check", move) + } +} +``` + +**在需要丰富诊断信息时使用自定义错误类型**: + +```go +type IllegalMoveError struct { + Move chess.Move + Reason string +} + +func (e *IllegalMoveError) Error() string { + return fmt.Sprintf("illegal move %v: %s", e.Move, e.Reason) +} +``` + +--- + +## 何时使用验证 API vs 简单辅助函数 + +| 场景 | 使用方式 | +|------|----------| +| 你拥有的接口,由他人实现 | `*test` 包中的验证 API | +| 同一包中跨测试共享设置 | 使用 `t.Helper()` 的测试辅助函数 | +| 在 2-3 个测试中重用的复杂断言 | 返回 `error` 或 `bool` 的辅助函数 | +| 一次性的设置或比较 | 内联测试代码 | + +**验证 API** 在以下场景值得额外的包: +- 多个外部包将实现你的接口 +- 契约有容易被忽略的非显而易见的不变量 +- 你想为"正确行为"提供单一事实来源 + +**简单辅助函数** 在以下场景更好: +- 辅助函数是直接的设置或比较函数 +- 重用是偶然的,不是已发布契约的一部分 + +--- + +## 命名约定 + +使用表示范围的动词命名函数:`Verify`、`Exercise`、`RunConformance`。接受被测接口作为参数 — 绝不在验证包内部构造实现。 + +| 包 | 函数 | 用途 | +|----|------|------| +| `storagetest` | `Verify` | 验证 `storage.Backend` | +| `chesstest` | `ExercisePlayer` | 验证 `chess.Player` | +| `cachetest` | `RunConformance` | `cache.Cache` 的完整一致性套件 | diff --git a/.agent/skills/go-testing/scripts/gen-table-test.sh b/.agent/skills/go-testing/scripts/gen-table-test.sh new file mode 100755 index 00000000..3aaf61ca --- /dev/null +++ b/.agent/skills/go-testing/scripts/gen-table-test.sh @@ -0,0 +1,163 @@ +#!/usr/bin/env bash +set -euo pipefail + +VERSION="1.0.0" +SCRIPT_NAME="$(basename "$0")" + +usage() { + cat < + +DESCRIPTION + Outputs a table-driven test file for the given function and package. + By default writes to stdout; use --output to write to a file. + + Exits 0 on success, 2 on error. + +OPTIONS + -h, --help Show this help message + -v, --version Show version + --output FILE Write to FILE instead of stdout + --force Allow --output to overwrite an existing file + --parallel Include t.Parallel() in generated test + --json Output structured JSON metadata to stdout + +ARGUMENTS + FuncName Name of the function to test (must be exported/uppercase) + package Go package name for the test file + +EXAMPLES + bash $SCRIPT_NAME ParseConfig config + bash $SCRIPT_NAME --parallel ParseConfig config + bash $SCRIPT_NAME --output config/parse_config_test.go ParseConfig config + bash $SCRIPT_NAME --force --output config/parse_config_test.go ParseConfig config + bash $SCRIPT_NAME --json --output config/parse_config_test.go ParseConfig config + bash $SCRIPT_NAME ParseConfig config > config/parse_config_test.go +EOF +} + +json_escape() { + local s="$1" + s="${s//\\/\\\\}" + s="${s//\"/\\\"}" + s="${s//$'\t'/\\t}" + s="${s//$'\r'/}" + s="${s//$'\n'/\\n}" + printf '%s' "$s" +} + +OUTPUT="" +PARALLEL=false +JSON_OUTPUT=false +FORCE=false +POSITIONAL=() + +while [[ $# -gt 0 ]]; do + case "$1" in + -h|--help) usage; exit 0 ;; + -v|--version) echo "$SCRIPT_NAME v$VERSION"; exit 0 ;; + --output) OUTPUT="${2:?error: --output requires a file path}"; shift 2 ;; + --force) FORCE=true; shift ;; + --parallel) PARALLEL=true; shift ;; + --json) JSON_OUTPUT=true; shift ;; + -*) echo "error: unknown option: $1" >&2; usage >&2; exit 2 ;; + *) POSITIONAL+=("$1"); shift ;; + esac +done + +if [[ ${#POSITIONAL[@]} -lt 2 ]]; then + echo "error: FuncName and package are required" >&2 + usage >&2 + exit 2 +fi + +FUNC="${POSITIONAL[0]}" +PKG="${POSITIONAL[1]}" + +if [[ ! "$FUNC" =~ ^[A-Z] ]]; then + echo "error: FuncName '$FUNC' must start with an uppercase letter" >&2 + exit 2 +fi + +generate_test() { + local parallel_top="" parallel_sub="" + if $PARALLEL; then + parallel_top=$'\tt.Parallel()\n' + parallel_sub=$'\t\t\tt.Parallel()\n' + fi + + cat <&2 + exit 2 + fi + if [[ -f "$OUTPUT" ]] && ! $FORCE; then + echo "error: '$OUTPUT' already exists (use --force to overwrite)" >&2 + exit 2 + fi + generate_test > "$OUTPUT" + if $JSON_OUTPUT; then + FUNC_ESC="$(json_escape "$FUNC")" + PKG_ESC="$(json_escape "$PKG")" + OUTPUT_ESC="$(json_escape "$OUTPUT")" + cat <&2 + FUNC_ESC="$(json_escape "$FUNC")" + PKG_ESC="$(json_escape "$PKG")" + cat < `whitelist_only` > `default`。 + +**Admin 任务管理 API**: + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/v1/admin/tasks/types` | 获取可调度任务类型列表 | +| POST | `/api/v1/admin/tasks/dispatch` | 手动下发任务 | +| GET | `/api/v1/admin/tasks/executions` | 分页查询任务执行记录(支持 status / task_type 筛选) | +| GET | `/api/v1/admin/tasks/executions/:id` | 查询单条任务执行详情(含完整 Log) | +| POST | `/api/v1/admin/tasks/executions/:id/retry` | 重试失败任务(校验 Retryable && RetryCount < MaxRetry) | + ## 新增任务的完整步骤 ### 第 1 步:在 constants.go 添加常量和 TaskMeta diff --git a/.agent/skills/shadcn/SKILL.md b/.agent/skills/shadcn/SKILL.md new file mode 100644 index 00000000..dcdb7545 --- /dev/null +++ b/.agent/skills/shadcn/SKILL.md @@ -0,0 +1,267 @@ +--- +name: shadcn +description: Manages shadcn components and projects — adding, searching, fixing, debugging, styling, and composing UI. Provides project context, component docs, and usage examples. Applies when working with shadcn/ui, component registries, presets, --preset codes, or any project with a components.json file. Also triggers for "shadcn init", "create an app with --preset", or "switch to --preset". +user-invocable: false +allowed-tools: Bash(npx shadcn@latest *), Bash(pnpm dlx shadcn@latest *), Bash(bunx --bun shadcn@latest *) +--- + +# shadcn/ui + +A framework for building ui, components and design systems. Components are added as source code to the user's project via the CLI. + +> **IMPORTANT:** Run all CLI commands using the project's package runner: `npx shadcn@latest`, `pnpm dlx shadcn@latest`, or `bunx --bun shadcn@latest` — based on the project's `packageManager`. Examples below use `npx shadcn@latest` but substitute the correct runner for the project. + +## Current Project Context + +```json +!`npx shadcn@latest info --json` +``` + +The JSON above contains the project config and installed components. Use `npx shadcn@latest docs ` to get documentation and example URLs for any component. + +## Principles + +1. **Use existing components first.** Use `npx shadcn@latest search` to check registries before writing custom UI. Check community registries too. +2. **Compose, don't reinvent.** Settings page = Tabs + Card + form controls. Dashboard = Sidebar + Card + Chart + Table. +3. **Use built-in variants before custom styles.** `variant="outline"`, `size="sm"`, etc. +4. **Use semantic colors.** `bg-primary`, `text-muted-foreground` — never raw values like `bg-blue-500`. + +## Critical Rules + +These rules are **always enforced**. Each links to a file with Incorrect/Correct code pairs. + +### Styling & Tailwind → [styling.md](./rules/styling.md) + +- **`className` for layout, not styling.** Never override component colors or typography. +- **No `space-x-*` or `space-y-*`.** Use `flex` with `gap-*`. For vertical stacks, `flex flex-col gap-*`. +- **Use `size-*` when width and height are equal.** `size-10` not `w-10 h-10`. +- **Use `truncate` shorthand.** Not `overflow-hidden text-ellipsis whitespace-nowrap`. +- **No manual `dark:` color overrides.** Use semantic tokens (`bg-background`, `text-muted-foreground`). +- **Use `cn()` for conditional classes.** Don't write manual template literal ternaries. +- **No manual `z-index` on overlay components.** Dialog, Sheet, Popover, etc. handle their own stacking. + +### Forms & Inputs → [forms.md](./rules/forms.md) + +- **Forms use `FieldGroup` + `Field`.** Never use raw `div` with `space-y-*` or `grid gap-*` for form layout. +- **`InputGroup` uses `InputGroupInput`/`InputGroupTextarea`.** Never raw `Input`/`Textarea` inside `InputGroup`. +- **Buttons inside inputs use `InputGroup` + `InputGroupAddon`.** +- **Option sets (2–7 choices) use `ToggleGroup`.** Don't loop `Button` with manual active state. +- **`FieldSet` + `FieldLegend` for grouping related checkboxes/radios.** Don't use a `div` with a heading. +- **Field validation uses `data-invalid` + `aria-invalid`.** `data-invalid` on `Field`, `aria-invalid` on the control. For disabled: `data-disabled` on `Field`, `disabled` on the control. + +### Component Structure → [composition.md](./rules/composition.md) + +- **Items always inside their Group.** `SelectItem` → `SelectGroup`. `DropdownMenuItem` → `DropdownMenuGroup`. `CommandItem` → `CommandGroup`. +- **Use `asChild` (radix) or `render` (base) for custom triggers.** Check `base` field from `npx shadcn@latest info`. → [base-vs-radix.md](./rules/base-vs-radix.md) +- **Dialog, Sheet, and Drawer always need a Title.** `DialogTitle`, `SheetTitle`, `DrawerTitle` required for accessibility. Use `className="sr-only"` if visually hidden. +- **Use full Card composition.** `CardHeader`/`CardTitle`/`CardDescription`/`CardContent`/`CardFooter`. Don't dump everything in `CardContent`. +- **Button has no `isPending`/`isLoading`.** Compose with `Spinner` + `data-icon` + `disabled`. +- **`TabsTrigger` must be inside `TabsList`.** Never render triggers directly in `Tabs`. +- **`Avatar` always needs `AvatarFallback`.** For when the image fails to load. + +### Use Components, Not Custom Markup → [composition.md](./rules/composition.md) + +- **Use existing components before custom markup.** Check if a component exists before writing a styled `div`. +- **Callouts use `Alert`.** Don't build custom styled divs. +- **Empty states use `Empty`.** Don't build custom empty state markup. +- **Toast via `sonner`.** Use `toast()` from `sonner`. +- **Use `Separator`** instead of `
` or `
`. +- **Use `Skeleton`** for loading placeholders. No custom `animate-pulse` divs. +- **Use `Badge`** instead of custom styled spans. + +### Icons → [icons.md](./rules/icons.md) + +- **Icons in `Button` use `data-icon`.** `data-icon="inline-start"` or `data-icon="inline-end"` on the icon. +- **No sizing classes on icons inside components.** Components handle icon sizing via CSS. No `size-4` or `w-4 h-4`. +- **Pass icons as objects, not string keys.** `icon={CheckIcon}`, not a string lookup. + +### CLI + +- **Never decode preset codes or build preset URLs manually.** Use `npx shadcn@latest preset decode `, `preset url `, or `preset open `. For project-aware preset detection, use `npx shadcn@latest preset resolve`. +- **Apply preset codes directly with the CLI.** Use `npx shadcn@latest apply ` for existing projects, or `npx shadcn@latest init --preset ` when initializing. + +## Key Patterns + +These are the most common patterns that differentiate correct shadcn/ui code. For edge cases, see the linked rule files above. + +```tsx +// Form layout: FieldGroup + Field, not div + Label. + + + Email + + + + +// Validation: data-invalid on Field, aria-invalid on the control. + + Email + + Invalid email. + + +// Icons in buttons: data-icon, no sizing classes. + + +// Spacing: gap-*, not space-y-*. +
// correct +
// wrong + +// Equal dimensions: size-*, not w-* h-*. + // correct + // wrong + +// Status colors: Badge variants or semantic tokens, not raw colors. ++20.1% // correct ++20.1% // wrong +``` + +## Component Selection + +| Need | Use | +| -------------------------- | --------------------------------------------------------------------------------------------------- | +| Button/action | `Button` with appropriate variant | +| Form inputs | `Input`, `Select`, `Combobox`, `Switch`, `Checkbox`, `RadioGroup`, `Textarea`, `InputOTP`, `Slider` | +| Toggle between 2–5 options | `ToggleGroup` + `ToggleGroupItem` | +| Data display | `Table`, `Card`, `Badge`, `Avatar` | +| Navigation | `Sidebar`, `NavigationMenu`, `Breadcrumb`, `Tabs`, `Pagination` | +| Overlays | `Dialog` (modal), `Sheet` (side panel), `Drawer` (bottom sheet), `AlertDialog` (confirmation) | +| Feedback | `sonner` (toast), `Alert`, `Progress`, `Skeleton`, `Spinner` | +| Command palette | `Command` inside `Dialog` | +| Charts | `Chart` (wraps Recharts) | +| Layout | `Card`, `Separator`, `Resizable`, `ScrollArea`, `Accordion`, `Collapsible` | +| Empty states | `Empty` | +| Menus | `DropdownMenu`, `ContextMenu`, `Menubar` | +| Tooltips/info | `Tooltip`, `HoverCard`, `Popover` | + +## Key Fields + +The injected project context contains these key fields: + +- **`aliases`** → use the actual alias prefix for imports (e.g. `@/`, `~/`), never hardcode. +- **`isRSC`** → when `true`, components using `useState`, `useEffect`, event handlers, or browser APIs need `"use client"` at the top of the file. Always reference this field when advising on the directive. +- **`tailwindVersion`** → `"v4"` uses `@theme inline` blocks; `"v3"` uses `tailwind.config.js`. +- **`tailwindCssFile`** → the global CSS file where custom CSS variables are defined. Always edit this file, never create a new one. +- **`style`** → component visual treatment (e.g. `nova`, `vega`). +- **`base`** → primitive library (`radix` or `base`). Affects component APIs and available props. +- **`iconLibrary`** → determines icon imports. Use `lucide-react` for `lucide`, `@tabler/icons-react` for `tabler`, etc. Never assume `lucide-react`. +- **`resolvedPaths`** → exact file-system destinations for components, utils, hooks, etc. +- **`framework`** → routing and file conventions (e.g. Next.js App Router vs Vite SPA). +- **`packageManager`** → use this for any non-shadcn dependency installs (e.g. `pnpm add date-fns` vs `npm install date-fns`). +- **`preset`** → resolved preset code and values for the current project. Use `npx shadcn@latest preset resolve --json` when you only need preset information. + +See [cli.md — `info` command](./cli.md) for the full field reference. + +## Component Docs, Examples, and Usage + +Run `npx shadcn@latest docs ` to get the URLs for a component's documentation, examples, and API reference. Fetch these URLs to get the actual content. + +```bash +npx shadcn@latest docs button dialog select +``` + +**When creating, fixing, debugging, or using a component, always run `npx shadcn@latest docs` and fetch the URLs first.** This ensures you're working with the correct API and usage patterns rather than guessing. + +## Workflow + +1. **Get project context** — already injected above. Run `npx shadcn@latest info` again if you need to refresh. +2. **Check installed components first** — before running `add`, always check the `components` list from project context or list the `resolvedPaths.ui` directory. Don't import components that haven't been added, and don't re-add ones already installed. +3. **Find components** — `npx shadcn@latest search`. +4. **Get docs and examples** — run `npx shadcn@latest docs ` to get URLs, then fetch them. Use `npx shadcn@latest view` to browse registry items you haven't installed. To preview changes to installed components, use `npx shadcn@latest add --diff`. +5. **Install or update** — `npx shadcn@latest add`. When updating existing components, use `--dry-run` and `--diff` to preview changes first (see [Updating Components](#updating-components) below). +6. **Fix imports in third-party components** — After adding components from community registries (e.g. `@bundui`, `@magicui`), check the added non-UI files for hardcoded import paths like `@/components/ui/...`. These won't match the project's actual aliases. Use `npx shadcn@latest info` to get the correct `ui` alias (e.g. `@workspace/ui/components`) and rewrite the imports accordingly. The CLI rewrites imports for its own UI files, but third-party registry components may use default paths that don't match the project. +7. **Review added components** — After adding a component or block from any registry, **always read the added files and verify they are correct**. Check for missing sub-components (e.g. `SelectItem` without `SelectGroup`), missing imports, incorrect composition, or violations of the [Critical Rules](#critical-rules). Also replace any icon imports with the project's `iconLibrary` from the project context (e.g. if the registry item uses `lucide-react` but the project uses `hugeicons`, swap the imports and icon names accordingly). Fix all issues before moving on. +8. **Registry must be explicit** — When the user asks to add a block or component, **do not guess the registry**. If no registry is specified (e.g. user says "add a login block" without specifying `@shadcn`, `@tailark`, `owner/repo`, etc.), ask which registry to use. Never default to a registry on behalf of the user. +9. **Switching presets** — Ask the user first: **overwrite**, **partial**, **merge**, or **skip**? + - **Inspect current preset**: `npx shadcn@latest preset resolve`. Use `--json` when you need structured values. + - **Inspect incoming preset**: `npx shadcn@latest preset decode `. Use `preset url ` or `preset open ` to share or open the preset builder. + - **Overwrite**: `npx shadcn@latest apply `. Overwrites detected components, fonts, and CSS variables. + - **Partial**: `npx shadcn@latest apply --only theme,font`. Updates only the selected preset parts without reinstalling UI components. Supported values are `theme` and `font`; comma-separated combinations are allowed. `icon` is intentionally not supported, because icon changes may require full component reinstall and transforms. + - **Merge**: `npx shadcn@latest init --preset --force --no-reinstall`, then run `npx shadcn@latest info` to list installed components, then for each installed component use `--dry-run` and `--diff` to [smart merge](#updating-components) it individually. + - **Skip**: `npx shadcn@latest init --preset --force --no-reinstall`. Only updates config and CSS, leaves components as-is. + - **Important**: Always run preset commands inside the user's project directory. `apply` only works in an existing project with a `components.json` file. The CLI automatically preserves the current base (`base` vs `radix`) from `components.json`. If you must use a scratch/temp directory (e.g. for `--dry-run` comparisons), pass `--base ` explicitly — preset codes do not encode the base. + +## Updating Components + +When the user asks to update a component from upstream while keeping their local changes, use `--dry-run` and `--diff` to intelligently merge. **NEVER fetch raw files from GitHub manually — always use the CLI.** + +1. Run `npx shadcn@latest add --dry-run` to see all files that would be affected. +2. For each file, run `npx shadcn@latest add --diff ` to see what changed upstream vs local. +3. Decide per file based on the diff: + - No local changes → safe to overwrite. + - Has local changes → read the local file, analyze the diff, and apply upstream updates while preserving local modifications. + - User says "just update everything" → use `--overwrite`, but confirm first. +4. **Never use `--overwrite` without the user's explicit approval.** + +## Quick Reference + +```bash +# Create a new project. +npx shadcn@latest init --name my-app --preset base-nova +npx shadcn@latest init --name my-app --preset a2r6bw --template vite + +# Create a monorepo project. +npx shadcn@latest init --name my-app --preset base-nova --monorepo +npx shadcn@latest init --name my-app --preset base-nova --template next --monorepo + +# Initialize existing project. +npx shadcn@latest init --preset base-nova +npx shadcn@latest init --defaults # shortcut: --template=next --preset=nova (base style implied) + +# Apply a preset to an existing project. +npx shadcn@latest apply a2r6bw +npx shadcn@latest apply a2r6bw --only theme +npx shadcn@latest apply a2r6bw --only font +npx shadcn@latest apply a2r6bw --only theme,font + +# Inspect preset codes and project preset state. +npx shadcn@latest preset decode a2r6bw +npx shadcn@latest preset url a2r6bw +npx shadcn@latest preset open a2r6bw +npx shadcn@latest preset resolve +npx shadcn@latest preset resolve --json + +# Add components. +npx shadcn@latest add button card dialog +npx shadcn@latest add @magicui/shimmer-button +npx shadcn@latest add owner/repo/item +npx shadcn@latest add --all + +# Preview changes before adding/updating. +npx shadcn@latest add button --dry-run +npx shadcn@latest add button --diff button.tsx +npx shadcn@latest add @acme/form --view button.tsx +npx shadcn@latest add owner/repo/item --dry-run + +# Search registries. +npx shadcn@latest search @shadcn -q "sidebar" +npx shadcn@latest search @tailark -q "stats" +npx shadcn@latest search owner/repo -q "login" +npx shadcn@latest search # all configured registries +npx shadcn@latest search @shadcn -q "menu" -t ui # filter by item type + +# Get component docs and example URLs. +npx shadcn@latest docs button dialog select + +# View registry item details (for items not yet installed). +npx shadcn@latest view @shadcn/button +npx shadcn@latest view owner/repo/item +``` + +**Named presets:** `nova`, `vega`, `maia`, `lyra`, `mira`, `luma` +**Templates:** `next`, `vite`, `start`, `react-router`, `astro` (all support `--monorepo`) and `laravel` (not supported for monorepo) +**Preset codes:** Version-prefixed base62 strings (e.g. `a2r6bw` or `b0`), from [ui.shadcn.com](https://ui.shadcn.com). + +## Detailed References + +- [rules/forms.md](./rules/forms.md) — FieldGroup, Field, InputGroup, ToggleGroup, FieldSet, validation states +- [rules/composition.md](./rules/composition.md) — Groups, overlays, Card, Tabs, Avatar, Alert, Empty, Toast, Separator, Skeleton, Badge, Button loading +- [rules/icons.md](./rules/icons.md) — data-icon, icon sizing, passing icons as objects +- [rules/styling.md](./rules/styling.md) — Semantic colors, variants, className, spacing, size, truncate, dark mode, cn(), z-index +- [rules/base-vs-radix.md](./rules/base-vs-radix.md) — asChild vs render, Select, ToggleGroup, Slider, Accordion +- [cli.md](./cli.md) — Commands, flags, presets, templates +- [registry.md](./registry.md) — Authoring source registries, `include`, item definitions, dependencies, GitHub registry rules +- [customization.md](./customization.md) — Theming, CSS variables, extending components diff --git a/.agent/skills/shadcn/agents/openai.yml b/.agent/skills/shadcn/agents/openai.yml new file mode 100644 index 00000000..ab636da8 --- /dev/null +++ b/.agent/skills/shadcn/agents/openai.yml @@ -0,0 +1,5 @@ +interface: + display_name: "shadcn/ui" + short_description: "Manages shadcn/ui components — adding, searching, fixing, debugging, styling, and composing UI." + icon_small: "./assets/shadcn-small.png" + icon_large: "./assets/shadcn.png" diff --git a/.agent/skills/shadcn/assets/shadcn-small.png b/.agent/skills/shadcn/assets/shadcn-small.png new file mode 100644 index 0000000000000000000000000000000000000000..547154b97f2298335159c23eec1dac0d147a1246 GIT binary patch literal 1049 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`Ea{HEjtmSN`?>!lvI6-E$sR$z z3=CCj3=9n|3=F@3LJcn%7)lKo7+xhXFj&oCU=S~uvn$XBDAAG{;hE;^%b*2hb1<+n z3NbJPS&Tr)z$nE4G7ZRL@M4sPvx68lplX;H7}_%#SfFa6fHVkr05M1pgl1mAh%j*h z6I`{x0%imor0saV%ts)_S>O>_%)r1c48n{Iv*t(uO^eJ7i71Ki^|4CM&(%vz$xlkv ztH>0+>{umUo3Q%e#RDspr3imfVamB1>jfNYSkzLEl1NlCV?QiN}Sf^&XRs)CuG zfu4bq9hZWFf=y9MnpKdC8&o@xXRDM^Qc_^0uU}qXu2*iXmtT~wZ)j<0sc&GUZ)Btk zRH0j3nOBlnp_^B%3^4>|j!SBBa#3bMNoIbY0?6FNr2NtnTO}osMQ{LdXGvxn!lt}p zsJDO~)CbAv8|oS8!_5Y2wE>A*`4?rT0&NDFZ)a!&R*518wZ}#uWI2*!AU*|)0=;U- zWup%dHajlKxQFb(K%V94;uvBfm>Yb$>yU#$kCbgiC(9RwrlwA(rsW(``(6H=zq@!* zUB_~Trk_lTdxPh$yxDTsVY&L;b+6{tRm^+d+gl^OQcCIBok=%y)Vhx@dR1ceL3r{0 z9=2nMXx8DxEkhL{v?(>~8{qDBkfB!u-iDB`| z-5d%Ae82YI%n8Zg9$mRN?o-|V)V*=5b-R>a>}P2{=pcK^B(&B-W{C#Z!Q%b*ueK>n zK6ymYLRneebCKDSzcqHEHFopo-mkH{zIkt2Z0^086Tko7%N)NxoZYSDM1jSUwPpuS zSeq*wzrC2D(tq4pZ#s9QP@Cd8Q^wW3*2cL_519?*;#4`B;+ei#o;C{Kkamm3Jooa; zE$3K-+c(6ShTjelW_B*W5o zcS6z34IR5C-SIF8m(lui^IOy!-~We?b1?PSGniCYR=ZyR^%<1lJzf1=);T3K0RU@N BSy=!8 literal 0 HcmV?d00001 diff --git a/.agent/skills/shadcn/assets/shadcn.png b/.agent/skills/shadcn/assets/shadcn.png new file mode 100644 index 0000000000000000000000000000000000000000..b7b6814acc25073e5f48099b1fd3f70c47bfb1c3 GIT binary patch literal 3852 zcmY*c2{@Ep*nY=Y1|x&SAZEs{m}!)<%wS9yONAB@M)rMZgo-eODf<#aqEe(qiK0X# zjeV<7Sz@e_J-dH=Uw_~KpZ7ZNbD!tj&;6X|T<3bv^oi2VQ{31CN!jQ|snzFQu@Q;0Du?| zfb$Q>hHZD6F}v@?{A7+^3dW)SVQ8`OjvI|a z{6nF7;ZWA*ClCgH0WJs)HH;bth37*c5IOtVpZ7;)e9lMRL5Xd-wU$;iu|t*(dB|ufCq)@;pdCk(RDoU7f8jS zP&Fw%scw{13LdO|DW$q z9v$>f^8d4#e=GgR%Ptkqr-S~xZFoMo{SXfTK;ub7{gc641VugDjPn7=AJBO!*mUKucc{7ey~li!vybaIkF z8OboYVX%y|2%P`k*m&i}s=eW8)0q&YJ<-thLrc)HZ?6uc7Fd!$(lWSvH;?Y^+|N2q z(lz!>Ip|<{_a4CnLN%GIxW|*Sl|;4c3?=Q>z5fWEEtp(&BzeAccRS{zf9!64+SAA{ zhe+CT7U8Ox1UUnilJqweOM{d}_w6-|yz}dA=-R7yHrxs|(MLR_`U=AC%0V#PpIuxs z`__xodV3Bl2ayP^m8isMu4iD<`=4UURtHBPsaLp81r-f3$(4vZbRPd}*>wTo@@?zp zjl1KkifF5p%-V<}MTk3`()wm7qcMd8<14WE>Y_YwjB;{Zt!@vJbH&pu!Ye!p{1kAj zlRg2pkl)WBbyj>3e<$)@mP?WW}W-c;_(UQ(}=MJLED9F^U*P1KWxu z1h@&s1?M0%ax_P5Qb>kkPKEH=OKGZn z&-@5MD^|kk?Hzkfz8h$8#{$$OjAyr!+uGtp>ns1^oS7Nlv6k$^tzTdIb%jUtI8F3t zL;LuqJ7-@4UI|Q)HN=Ap;dAmuZY4c&XphavC{kl z)mMY6L)S-94;V|YT3TA7U^;uvS0W!$W`JIb@kNAm0@IKVWJ@R_jgX6wlUd5V%pHIn;=Z~px z-B_MFLx+ldzTQUp5C zkXaznhH18TytzOgD%$iMnLiAtjvnHLkM;?P*3ZFb#cbY&ZF*|R;4V4$85DZMQ#T@| zxuMMPg?cl|hNJ$2S&%fLErMQSzCnzc_QGh3x@I8V&Omf&=_n+EaC}tft^`)^W5-=1 z0bbJ#wK8+=zNaz-V{U?NtJp&%IDd9-xIut--L}@gU{E>B&ym$vT2@wEZq- zzDqmDf)Cr-aZ-5KIYF!Elcq_X%?qXsg}Yhp=^bebcZ-Ucd$p+US;P}(@VrR4_fy6!qx?X)%SIi+LRsDR` zQ8+xRd{In4WLlVjoy5ElKt%?Ykr)cH*G}0^tE=$(#0W?l)_9MV89|sem>Y}t&?fGt zo3o0sdsG9?JhC1-KM}e*&zeb*H$7JoD&0z#=2sH725JmGV`WoVb86exi5p^Lm{B5BlZRd6(bKH`QmJ*u8#(j6IH1 zFf;Li5iqsV=V^irlzzwVJ$xXwz?I8y`i+t-g0?n77Ie}-fBp1{)&Dm1Yk^Fb=7rI4 zAD)SQRv9=Xr%G{qI!)}>rf;lzG))%89I~zF)R~&_Ur)%0>4+7bxbBknHT`^+jNFe%p=t<=q}3W1Y-u+ zi-cN{V#+HsEEB8UQsUt1uE991MvN%&V?(0Mcb~82H!ori&rLm~Xyf>i#u_t=rzHA3 zu?NRljnv7YU;T&V#(OVE$+=^@kaXvs%C3f78E(k$IyZ`)N$NYtLMfphBkV|oSI}7N zEpFrKnA*jL!k5P(%)+Sl^e>hI@Y}jqmvb~@RD`9$!9Jpu-Wf2Cq?hfV{ghuo&AG>9 zcvC>&o43J*WfyXuRWV)Y0~9;i68pPyv&~-%Ysp}xCY)|oF5?x{w~aS zfx&5VFU)gcnMk*q%=jkB9w_qQ)x=v#1Q^_3`uyEfKiD4y$vaOR4n4IpvQmyNXK%#R zU|XV~4Bq=Kr7Zp3#hdF5_MgA#yL-jJ4E5*j2?mhMq17{mD#6f?!#-b>@HA&~uECub zF2reQA9f>OHLzqeZhi$%7fW!n^Y3_Y{C0vfQs0?a(Pcz7Opuz;(9n>$6)c9L`((v~ z@R1+M2kRN}mDuKCk=od(UwR?y%hUbEv$dC0BD&Ir;03Mf&|A#ZFtn&*mnkH8>2w0+imbwLLU77q!A=W ztt$OR$b#$dkIAwM*ZWG{5*zUPkBN$42Kxe)AYe9DV+?(A^_|IoJLzRGKlbpb%n=PVmB`nxX< z)_}Tpg&IoX;4<0!ksDY9f>*dl`c}zSl!(+ujG#<&yu3AEKR)zVKTaK88*+ma)}3)G zx460(7TNMTz(t&DUv)`7lj(r=`!?>~Q}XW3w-sipV!{Trr8`zsvu9NEK9)zO0>v3D zOs=r|`OyjAS{Ea@UIRrr2Y7A?5+5}R4RpF)ee5^F-dhvP6MKyO`kNB4jGC{-$x}`x zXmpAxVV=WR{>!zDxoq7#DN+_BWG_PRr?$Sa(XT@zUL`HuM{I4bZSuo97aF&7tL@4P zO59)SaLA2ZMU3dO5>=P)GiqcIBUy4A2h_IC-7+IROG<+Qqczgo-JyHw-!h2%h8#$y zr;y4>z2J1R4+`7o>#OWA2iiS1m8)q6A}u3d7VBk **IMPORTANT:** Always run commands using the project's package runner: `npx shadcn@latest`, `pnpm dlx shadcn@latest`, or `bunx --bun shadcn@latest`. Check `packageManager` from project context to choose the right one. Examples below use `npx shadcn@latest` but substitute the correct runner for the project. + +> **IMPORTANT:** Only use the flags documented below. Do not invent or guess flags — if a flag isn't listed here, it doesn't exist. The CLI auto-detects the package manager from the project's lockfile; there is no `--package-manager` flag. + +## Contents + +- Commands: init, apply, add (dry-run, smart merge), search, view, docs, info, build +- Templates: next, vite, start, react-router, astro +- Presets: named, code, URL formats and fields +- Switching presets + +--- + +## Commands + +### `init` — Initialize or create a project + +```bash +npx shadcn@latest init [components...] [options] +``` + +Initializes shadcn/ui in an existing project or creates a new project (when `--name` is provided). Optionally installs components in the same step. + +| Flag | Short | Description | Default | +| ----------------------- | ----- | --------------------------------------------------------- | ------- | +| `--template