mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-28 21:56:36 +08:00
Compare commits
159 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c3606bc6f6 | |||
| 2b3be6f3a3 | |||
| 11c8e5c7f3 | |||
| 4d52a5b097 | |||
| b1626d068f | |||
| 079fa7ee53 | |||
| 3867841a2f | |||
| 805eea5bd6 | |||
| aad059ab6e | |||
| 96abbf180d | |||
| fff4f425ae | |||
| e1753f868e | |||
| a49d07e2c2 | |||
| 1fe2763034 | |||
| bf1e77a865 | |||
| 65bc7d9b81 | |||
| 1d4533f183 | |||
| a2404a50ef | |||
| 71a715a64b | |||
| 501c754352 | |||
| a0324dc65c | |||
| bbd7f72c2d | |||
| 81739f9cb4 | |||
| 2b69f4d8d7 | |||
| b56f27632a | |||
| fc733d0295 | |||
| 453f7e5d90 | |||
| 63e3b85294 | |||
| e66dea9090 | |||
| 451ce52592 | |||
| bbf79199aa | |||
| 40232d86ba | |||
| 55db1c01ec | |||
| 3528323b50 | |||
| 2cb339258c | |||
| 63007fc8c7 | |||
| 4f8e7e66e3 | |||
| ed1efd3d54 | |||
| efd8268a5d | |||
| 0dd2cf9e80 | |||
| be5d067af9 | |||
| 3e5f3cc562 | |||
| d73aa5f9f0 | |||
| c9fc9c0eea | |||
| cdc7474d7e | |||
| 983cce3e80 | |||
| 76e9d5b0e7 | |||
| 6b75706b8e | |||
| 59fd8cda7b | |||
| cb94081ebd | |||
| 3de0a54d47 | |||
| e7b8fb2f99 | |||
| 454542c1d0 | |||
| 580c51a73a | |||
| 6b7df5a6b5 | |||
| 497edfd564 | |||
| d7d510ec97 | |||
| f4ec58c0e6 | |||
| 2ba28417b3 | |||
| 3f97193280 | |||
| 2aa0a70762 | |||
| 55c1fcd5f9 | |||
| e5d2e4b6d5 | |||
| 4f360cc7a9 | |||
| da43de56ac | |||
| f538670e1f | |||
| 2f60329886 | |||
| c76c5a697b | |||
| 9609bec8b0 | |||
| 9b89d3c630 | |||
| e87f445218 | |||
| 40eee778ac | |||
| 6c128e0be5 | |||
| 7d03154a8a | |||
| 7f8e257d33 | |||
| 4d78bc1c38 | |||
| 1813bbdba3 | |||
| aa4faddade | |||
| ab70633e9f | |||
| e1b439d6a5 | |||
| 4962bf90d1 | |||
| c455be3002 | |||
| ab0e5fecf2 | |||
| f5c9da03f4 | |||
| a16be014d4 | |||
| c85373ff47 | |||
| d7b8f44f90 | |||
| 85321888e0 | |||
| 65c02ef7a5 | |||
| 63a24da9ee | |||
| e5f6b0ad90 | |||
| 111d2900d7 | |||
| 73d8173018 | |||
| 4ecec2cf1b | |||
| 86fad02c41 | |||
| 600a7acdfb | |||
| 288b74d104 | |||
| ce28f63659 | |||
| d0414b402a | |||
| 699e95f12c | |||
| 4e3d79c001 | |||
| 5aaaf8f197 | |||
| b76f707c8b | |||
| f1f6bb858a | |||
| 305d609d0d | |||
| 5a8722ff07 | |||
| 64fbaa7ef1 | |||
| fa689aedbc | |||
| f960511cc0 | |||
| 465440fa5b | |||
| a4dd5ca9e1 | |||
| a9e4237bbf | |||
| 75d1fcf345 | |||
| f1577bf092 | |||
| 01ed2c5e36 | |||
| 80696c12fa | |||
| 3d4d99081e | |||
| 0639855653 | |||
| 0524ae1da4 | |||
| adee4f7b27 | |||
| 1d0f2d6342 | |||
| e3f603f72a | |||
| 3b010bb15e | |||
| f530cd4025 | |||
| 08d28c2c8e | |||
| 34a0896ff8 | |||
| 0c22e76f4b | |||
| bd2183c8bb | |||
| 9df2437e47 | |||
| e8c414aa12 | |||
| 7e8aa5fa0f | |||
| 7e518987de | |||
| 61484090f9 | |||
| 94b74d72f6 | |||
| 6487ce666d | |||
| c4be34b214 | |||
| fda727cf53 | |||
| f083da20f2 | |||
| 9797fcdb2f | |||
| 93ec3096f3 | |||
| 0e34301c92 | |||
| 12b5271f92 | |||
| 8ee966434d | |||
| 6882481a56 | |||
| b675038bba | |||
| d17ec9d17d | |||
| 8758f9a061 | |||
| 7d93d3d2a1 | |||
| 074edf17a1 | |||
| 6faf525af0 | |||
| a6fc2b7737 | |||
| ca21ff3a5b | |||
| 7d71f1e4e1 | |||
| 734fe45baa | |||
| ef22ecc5dc | |||
| 6738abdec1 | |||
| d17d8457f3 | |||
| 16f34928c9 | |||
| 3328d3d121 |
@@ -7,7 +7,7 @@ description: "Wavelet 项目专用:当新增或修改 ClickHouse 批量写入
|
||||
|
||||
开始前阅读根目录 `AGENTS.md`。ClickHouse 是辅助 OLAP 存储,**厌恶高频单条写入**(过多小 part);写入路径必须优先批量或异步聚合。
|
||||
|
||||
DDL 与表结构变更见 `database-migration` 技能;本技能只覆盖**运行时写入架构**。
|
||||
DDL 与表结构变更见 `database-migration` 技能。日志/分析用途表的判定、三库回落与切换见 `logstore` 技能。本技能只覆盖**运行时写入架构**。
|
||||
|
||||
## 分层职责
|
||||
|
||||
@@ -17,7 +17,7 @@ DDL 与表结构变更见 `database-migration` 技能;本技能只覆盖**运
|
||||
| 批量框架 | `internal/infra/persistence/batchwriter/` | 泛型队列 + 按条数/时间 flush + 非阻塞入队 + 优雅停机;**各业务域独立实例** |
|
||||
| Model | `internal/model/analytics/` | 列定义、`TableName()`、`BatchInsertSQL()`(及可选 `InsertColumns()`) |
|
||||
| Repository | `internal/repository/analytics/` | `BatchInsert*` / `BatchInsertNodeAccessLogs` 等;`PrepareBatch` + 多行 `Append` + 一次 `Send` |
|
||||
| Apps | `internal/apps/<domain>/` | 采集、入队、背压;`FlushFunc` 只调 repository,不写 SQL、不 `PrepareBatch` |
|
||||
| Apps | `internal/apps/<domain>/` | 采集、入队、背压;`FlushFunc` 只调 logstore / repository,不写 SQL、不 `PrepareBatch` |
|
||||
| 装配 | `internal/platform/bootstrap/bootstrap.go` | 进程启动时调用 `Writer.Start`;初始化时需调用 `lifecycle.OnShutdown` 挂载停机钩子 |
|
||||
| 生命周期 | `internal/platform/lifecycle/lifecycle.go` | 统一协调全局并发优雅停机,业务包无需在 `bootstrap.go` 中硬编码 `Stop` 逻辑 |
|
||||
|
||||
@@ -37,6 +37,7 @@ writer.Stop(stopCtx) // close 队列 + drain + 最终 flush
|
||||
|
||||
- `QueueSize`: 10_000
|
||||
- `MaxBatchSize`: 1_000
|
||||
- `MinBatchSize`: 50(未达阈值则跳过按时间 flush,除非设了 `MaxFlushWait`)
|
||||
- `FlushInterval`: 1s
|
||||
|
||||
各域可独立覆盖;可观测低频指标可用更小 `MaxBatchSize`(如 100)与更长 `FlushInterval`(如 2–5s),但**不要**退化为逐条 `Send`。
|
||||
@@ -49,7 +50,8 @@ writer.Stop(stopCtx) // close 队列 + drain + 最终 flush
|
||||
### FlushFunc 规范
|
||||
|
||||
- 签名:`func(ctx context.Context, items []T) error`
|
||||
- 内部调用 `internal/repository/analytics` 的 `BatchInsert*`(传入 `[]analyticsmodel.X`)
|
||||
- **日志/分析用途表**:`logstore.Active(ctx)` 再调对应 `BatchInsert*`。禁止 apps 直连 `analyticsrepo` 或 `db.ChConn`。
|
||||
- 仅 CH、无需主库回落的分析表:才直接调 `repository/analytics` 的 `BatchInsert*`。
|
||||
- 在 flush 边界记录一次错误日志,不要把 DB 驱动错误直接暴露给 HTTP 客户端
|
||||
- `Start` 使用 `context.WithoutCancel(parent)`,避免请求 ctx 取消中断后台 flush
|
||||
|
||||
@@ -57,11 +59,11 @@ writer.Stop(stopCtx) // close 队列 + drain + 最终 flush
|
||||
|
||||
每个业务域拥有自己的 `Writer`、配置与 `FlushFunc`:
|
||||
|
||||
| 域 | 表 | 现状 | 目标形态 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| 管理端审计 | `w_user_access_logs` | `risk_control` → `batchwriter` + `analyticsrepo.BatchInsert` | 已接入 |
|
||||
| 边缘访问日志 | `of_node_access_logs` | `openflare/chwriter` 异步 flush | 已接入 |
|
||||
| 可观测时序 | `of_node_metric_snapshots` 等 5 表 | `openflare/chwriter` 五表独立 writer + 进程内短 TTL 去重 | 已接入 |
|
||||
| 域 | 表 | 写入路径 |
|
||||
| :--- | :--- | :--- |
|
||||
| 管理端审计 | `w_user_access_logs` | `risk_control` → `batchwriter` → `logstore.Active` |
|
||||
| 边缘访问日志 | `of_node_access_logs` | `openflare/chwriter` → `logstore.Active` |
|
||||
| 可观测时序 | `of_node_metric_snapshots` 等 | `openflare/chwriter` 分表 writer + 进程内短 TTL 去重 → `logstore.Active` |
|
||||
|
||||
**不要**把 audit、access log、observability 并入同一 channel。
|
||||
|
||||
@@ -73,8 +75,9 @@ writer.Stop(stopCtx) // close 队列 + drain + 最终 flush
|
||||
- `len(items)==0` 直接返回
|
||||
- `db.ChConn == nil` 返回明确错误
|
||||
- 一次 `PrepareBatch` → 循环 `Append` → 一次 `Send`
|
||||
4. **Writer 胶水**(`internal/apps/<domain>/` 或 `internal/repository/analytics/<domain>_writer.go`):
|
||||
4. **Writer 胶水**(`internal/apps/<domain>/`):
|
||||
- `New` + `Start`,并在初始化逻辑内通过 `lifecycle.OnShutdown("your_writer_name", Stop)` 注册停机回调
|
||||
- 日志表的 `FlushFunc` 调 `logstore.Active`(见 `logstore` skill)
|
||||
- 业务路径 `TryEnqueue`;HTTP 背压用 `IsFull()`
|
||||
5. **测试**:
|
||||
- repository:mock `ChConn` 验证 `BatchInsertSQL` 与 append 列数
|
||||
@@ -123,14 +126,9 @@ var globalChan chan any
|
||||
|
||||
```go
|
||||
// internal/platform/bootstrap/bootstrap.go(示意)
|
||||
var userAccessLogWriter *batchwriter.Writer[*analytics.UserAccessLog]
|
||||
|
||||
func RegisterAPI(ctx context.Context) {
|
||||
// ...
|
||||
if config.Config.ClickHouse.Enabled {
|
||||
initUserAccessLogWriter(ctx) // Start writer
|
||||
risk_control.BindWriter(userAccessLogWriter) // 或逐步替换 InitLogWriter
|
||||
}
|
||||
// 日志 writer 不依赖 clickhouse.enabled:flush 时由 logstore 选库
|
||||
risk_control.InitLogWriter(ctx)
|
||||
}
|
||||
```
|
||||
|
||||
@@ -149,7 +147,8 @@ make code-check
|
||||
- flush 按 `MaxBatchSize` 与 `FlushInterval` 触发
|
||||
- `Stop` 能 drain 队列内剩余项
|
||||
- repository 层无 goroutine、无 channel
|
||||
- `clickhouse.enabled: false` 时不 `Start` writer、不入队
|
||||
- 日志表:`clickhouse.enabled: false` 时 writer 仍 `Start`,flush 走主库 logstore
|
||||
- 仅 CH 的分析表:未启用 CH 时不要 `Start`、不要入队
|
||||
|
||||
## 相关文件速查
|
||||
|
||||
@@ -157,7 +156,8 @@ make code-check
|
||||
- 连接:`internal/infra/persistence/clickhouse.go`
|
||||
- 审计写入:`internal/apps/risk_control/logics.go`
|
||||
- OpenFlare 写入胶水:`internal/apps/openflare/chwriter/writer.go`
|
||||
- 节点访问日志 repository:`internal/repository/analytics/node_access_log_writer.go`
|
||||
- 可观测 repository:`internal/repository/analytics/node_observability_writer.go`
|
||||
- 日志抽象:`internal/repository/logstore`
|
||||
- 节点访问日志 CH 实现:`internal/repository/analytics/node_access_log_writer.go`
|
||||
- 可观测 CH 实现:`internal/repository/analytics/node_observability_writer.go`
|
||||
- 生命周期管理器:`internal/platform/lifecycle/lifecycle.go`
|
||||
- Bootstrap:`internal/platform/bootstrap/bootstrap.go`
|
||||
@@ -81,7 +81,7 @@ make code-check
|
||||
ClickHouse 是**辅助 OLAP 存储**,与 PostgreSQL/SQLite 主库**完全独立**的迁移与访问管线:
|
||||
|
||||
- 主库(PG/SQLite):业务事务数据、`goose_db_version`、双方言 SQL。
|
||||
- 分析库(ClickHouse):访问日志、统计聚合等分析型数据、`goose_clickhouse_version`、单方言 SQL。
|
||||
- 分析库(ClickHouse):分析型数据、`goose_clickhouse_version`、单方言 SQL。日志用途表还必须在主库建回落并走 `logstore`(见该 skill);CH 目录仍只放 CH DDL。
|
||||
|
||||
**不要**把 ClickHouse 表结构混入 PG/SQLite 迁移目录,也**不要**在 `support-files/`、`internal/apps/` 或 `internal/repository/` 中手写 DDL。
|
||||
|
||||
@@ -118,7 +118,7 @@ ClickHouse 是**辅助 OLAP 存储**,与 PostgreSQL/SQLite 主库**完全独
|
||||
1. **Model**:在 `internal/model/analytics/` 定义 struct,`gorm:"column:..."` 与 DDL 列名一一对应;实现 `TableName()`,批量写入表可提供 `InsertColumns()` / `BatchInsertSQL()`。
|
||||
2. **Goose SQL**:在 `internal/infra/persistence/migrator/goose/clickhouse/` 新增递增版本文件(格式同主库,如 `YYYYMMDDNNNN_create_xxx.sql`),编写 `-- +goose Up` / `-- +goose Down`。
|
||||
3. **Repository**:在 `internal/repository/analytics/` 实现 `BatchInsert*`(`db.ChConn` 一次 `PrepareBatch` + 多行 `Append` + 一次 `Send`)与查询(`db.ChDB`);连接未初始化时返回明确错误,**不要**在 handler 写 SQL,**不要**在 repository 内维护 channel/goroutine。
|
||||
4. **Apps**:在 `internal/apps/<domain>/` 编排采集与入队;高频写入通过 `internal/infra/persistence/batchwriter` 各域独立实例异步 flush(详见 `clickhouse-batchwriter` 技能),`FlushFunc` 只调 repository `BatchInsert*`;管理端统计 API 只读 repository,不触达 DDL。
|
||||
4. **Apps**:在 `internal/apps/<domain>/` 编排采集与入队;高频写入通过 `internal/infra/persistence/batchwriter` 各域独立实例异步 flush(详见 `clickhouse-batchwriter` 技能)。**日志/分析用途表**还要同时建 PG/SQLite 回落并接入 `logstore`(见 `logstore` 技能),`FlushFunc` 调 `logstore.Active` 而不是 `analyticsrepo`;普通业务分析表仍只读 repository。
|
||||
|
||||
### ClickHouse 验证
|
||||
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
---
|
||||
name: go-code-review
|
||||
description: Use when reviewing Go code or checking code against community style standards. Also use proactively before submitting a Go PR or when reviewing any Go code changes, even if the user doesn't explicitly request a style review. Does not cover language-specific syntax — delegates to specialized skills.
|
||||
license: Apache-2.0
|
||||
compatibility: Web server example in references uses slog (Go 1.21+)
|
||||
metadata:
|
||||
sources: "Go Wiki CodeReviewComments, Uber Style Guide"
|
||||
allowed-tools: Bash(bash:*)
|
||||
---
|
||||
|
||||
# Go 代码审查清单
|
||||
|
||||
## 审查流程
|
||||
|
||||
> 使用 `assets/review-template.md` 格式化代码审查输出,确保结构与"必须修复 / 建议修复 / 吹毛求疵"的严重程度分组保持一致。
|
||||
|
||||
1. 运行 `gofmt -d .` 和 `go vet ./...` 先捕获机械性问题
|
||||
2. 逐文件阅读 diff;对于每个文件,按以下类别顺序检查
|
||||
3. 标记问题时需要包含具体行号引用和规则名称
|
||||
4. 审查完所有文件后,重新阅读标记项以确认它们是真实的问题
|
||||
5. 按严重程度分组汇总发现(必须修复、建议修复、吹毛求疵)
|
||||
|
||||
> **验证**:完成审查后,再次阅读 diff 以验证每个标记的问题都是真实的。删除任何无法用具体行号引用的发现。
|
||||
|
||||
---
|
||||
|
||||
## 格式化
|
||||
|
||||
- [ ] **gofmt**:代码已使用 `gofmt` 或 `goimports` 格式化 → [go-linting](../go-linting/SKILL.md)
|
||||
|
||||
---
|
||||
|
||||
## 文档
|
||||
|
||||
- [ ] **注释句子**:注释是完整的句子,以被描述的名称开头,以句号结尾 → [go-documentation](../go-documentation/SKILL.md)
|
||||
- [ ] **文档注释**:所有导出名称都有文档注释;非平凡的未导出声明也应有 → [go-documentation](../go-documentation/SKILL.md)
|
||||
- [ ] **包注释**:包注释出现在 package 子句附近,无空行 → [go-documentation](../go-documentation/SKILL.md)
|
||||
- [ ] **命名结果参数**:仅当它们能澄清含义时使用(例如,多个相同类型返回值),而不仅仅是为了启用裸返回 → [go-documentation](../go-documentation/SKILL.md)
|
||||
|
||||
---
|
||||
|
||||
## 错误处理
|
||||
|
||||
- [ ] **处理错误**:不使用 `_` 丢弃错误;处理、返回或(在特殊情况下)panic → [go-error-handling](../go-error-handling/SKILL.md)
|
||||
- [ ] **错误字符串**:小写开头,无标点(除非以专有名词/首字母缩略词开头) → [go-error-handling](../go-error-handling/SKILL.md)
|
||||
- [ ] **带内错误**:不使用魔术值(-1、""、nil);使用带 error 或 ok bool 的多返回值 → [go-error-handling](../go-error-handling/SKILL.md)
|
||||
- [ ] **错误流缩进**:先处理错误并返回;保持正常路径的缩进最小化 → [go-error-handling](../go-error-handling/SKILL.md)
|
||||
|
||||
---
|
||||
|
||||
## 命名
|
||||
|
||||
- [ ] **MixedCaps**:使用 `MixedCaps` 或 `mixedCaps`,不使用下划线;未导出使用 `maxLength` 而非 `MAX_LENGTH` → [go-naming](../go-naming/SKILL.md)
|
||||
- [ ] **首字母缩略词**:保持一致的大小写:`URL`/`url`、`ID`/`id`、`HTTP`/`http`(例如 `ServeHTTP`、`xmlHTTPRequest`) → [go-naming](../go-naming/SKILL.md)
|
||||
- [ ] **变量名**:有限作用域用短名称(`i`、`r`、`c`);更广作用域用较长名称 → [go-naming](../go-naming/SKILL.md)
|
||||
- [ ] **接收器名称**:类型的一两个字母缩写(`c` 代表 `Client`);不使用 `this`、`self`、`me`;各方法之间保持一致 → [go-naming](../go-naming/SKILL.md)
|
||||
- [ ] **包名**:不重复(使用 `chubby.File` 而非 `chubby.ChubbyFile`);避免 `util`、`common`、`misc` → [go-packages](../go-packages/SKILL.md)
|
||||
- [ ] **避免内置名称**:不遮蔽 `error`、`string`、`len`、`cap`、`append`、`copy`、`new`、`make` → [go-declarations](../go-declarations/SKILL.md)
|
||||
|
||||
---
|
||||
|
||||
## 并发
|
||||
|
||||
- [ ] **Goroutine 生命周期**:明确 goroutine 何时/是否退出;如不明显则添加文档 → [go-concurrency](../go-concurrency/SKILL.md)
|
||||
- [ ] **同步函数**:优先同步而非异步;让调用者在需要时添加并发 → [go-concurrency](../go-concurrency/SKILL.md)
|
||||
- [ ] **Context**:作为第一个参数;不放在 struct 中;不自定义 Context 类型;即使认为不需要也应传递 → [go-context](../go-context/SKILL.md)
|
||||
|
||||
---
|
||||
|
||||
## 接口
|
||||
|
||||
- [ ] **接口位置**:在消费方包中定义,而非实现方;生产者返回具体类型 → [go-interfaces](../go-interfaces/SKILL.md)
|
||||
- [ ] **不提前定义接口**:不在使用前定义;不在实现方"为了 mock"而定义 → [go-interfaces](../go-interfaces/SKILL.md)
|
||||
- [ ] **接收器类型**:如果会修改状态、有 sync 字段或体积大,使用指针;小的不可变类型使用值;不要混用 → [go-interfaces](../go-interfaces/SKILL.md)
|
||||
|
||||
---
|
||||
|
||||
## 数据结构
|
||||
|
||||
- [ ] **空切片**:优先使用 `var t []string`(nil)而非 `t := []string{}`(非 nil 零长度) → [go-data-structures](../go-data-structures/SKILL.md)
|
||||
- [ ] **复制**:小心复制含指针/切片字段的结构体;不按值复制 `*T` 方法的接收器 → [go-data-structures](../go-data-structures/SKILL.md)
|
||||
|
||||
---
|
||||
|
||||
## 安全性
|
||||
|
||||
- [ ] **加密随机数**:密钥使用 `crypto/rand`,不使用 `math/rand` → [go-defensive](../go-defensive/SKILL.md)
|
||||
- [ ] **不 panic**:常规错误处理使用 error 返回;仅在真正特殊的情况下 panic → [go-defensive](../go-defensive/SKILL.md)
|
||||
|
||||
---
|
||||
|
||||
## 声明与初始化
|
||||
|
||||
- [ ] **分组相似的**:相关的 `var`/`const`/`type` 放在括号块中;不相关的分开 → [go-declarations](../go-declarations/SKILL.md)
|
||||
- [ ] **var vs :=**:有意使用零值时用 `var`;显式赋值时用 `:=` → [go-declarations](../go-declarations/SKILL.md)
|
||||
- [ ] **缩小作用域**:将声明移到使用位置附近;使用 if-init 限制变量作用域 → [go-declarations](../go-declarations/SKILL.md)
|
||||
- [ ] **Struct 初始化**:始终使用字段名;省略零值字段;零值 struct 使用 `var` → [go-declarations](../go-declarations/SKILL.md)
|
||||
- [ ] **使用 `any`**:新代码中优先使用 `any` 而非 `interface{}` → [go-declarations](../go-declarations/SKILL.md)
|
||||
|
||||
---
|
||||
|
||||
## 函数
|
||||
|
||||
- [ ] **文件排序**:类型 → 构造函数 → 导出方法 → 未导出方法 → 工具函数 → [go-functions](../go-functions/SKILL.md)
|
||||
- [ ] **签名格式化**:换行时所有参数各占一行并带尾逗号 → [go-functions](../go-functions/SKILL.md)
|
||||
- [ ] **裸参数**:为含义不明确的 bool/int 参数添加 `/* name */` 注释,或使用自定义类型 → [go-functions](../go-functions/SKILL.md)
|
||||
- [ ] **Printf 命名**:接受格式字符串的函数以 `f` 结尾,以便 `go vet` 检查 → [go-functions](../go-functions/SKILL.md)
|
||||
|
||||
---
|
||||
|
||||
## 风格
|
||||
|
||||
- [ ] **行长度**:无硬性限制,但避免令人不适的长行;按语义断行,而非任意长度 → [go-style-core](../go-style-core/SKILL.md)
|
||||
- [ ] **裸返回**:仅在短函数中使用;中/大函数使用显式返回 → [go-style-core](../go-style-core/SKILL.md)
|
||||
- [ ] **传值**:不要仅为节省字节而使用指针;小的固定大小类型传 `string` 而非 `*string` → [go-performance](../go-performance/SKILL.md)
|
||||
- [ ] **字符串拼接**:简单拼接用 `+`;格式化用 `fmt.Sprintf`;循环中用 `strings.Builder` → [go-performance](../go-performance/SKILL.md)
|
||||
|
||||
---
|
||||
|
||||
## 日志
|
||||
|
||||
- [ ] **使用 slog**:新代码使用 `log/slog`,不使用 `log` 或 `fmt.Println` 进行运维日志记录 → [go-logging](../go-logging/SKILL.md)
|
||||
- [ ] **结构化字段**:日志消息使用静态字符串加键值属性,不使用 fmt.Sprintf → [go-logging](../go-logging/SKILL.md)
|
||||
- [ ] **适当的级别**:Debug 用于开发者追踪,Info 用于重要事件,Warn 用于可恢复的问题,Error 用于故障 → [go-logging](../go-logging/SKILL.md)
|
||||
- [ ] **日志中无敏感信息**:PII、凭证和令牌永远不记录在日志中 → [go-logging](../go-logging/SKILL.md)
|
||||
|
||||
---
|
||||
|
||||
## 导入
|
||||
|
||||
- [ ] **导入分组**:标准库优先,然后空行,再外部包 → [go-packages](../go-packages/SKILL.md)
|
||||
- [ ] **导入重命名**:除非冲突否则避免重命名;冲突时重命名本地/项目特定的导入 → [go-packages](../go-packages/SKILL.md)
|
||||
- [ ] **空白导入**:`import _ "pkg"` 仅在 main 包或测试中使用 → [go-packages](../go-packages/SKILL.md)
|
||||
- [ ] **点导入**:仅在测试中用于解决循环依赖 → [go-packages](../go-packages/SKILL.md)
|
||||
|
||||
---
|
||||
|
||||
## 泛型
|
||||
|
||||
- [ ] **何时使用**:仅当多个类型共享相同逻辑且接口不足时 → [go-generics](../go-generics/SKILL.md)
|
||||
- [ ] **类型别名**:使用定义创建新类型;别名仅用于包迁移 → [go-generics](../go-generics/SKILL.md)
|
||||
|
||||
---
|
||||
|
||||
## 测试
|
||||
|
||||
- [ ] **示例**:包含可运行的 `Example` 函数或演示用法的测试 → [go-documentation](../go-documentation/SKILL.md)
|
||||
- [ ] **有用的测试失败信息**:消息包含出了什么错、输入、实际值和期望值;顺序为 `got != want` → [go-testing](../go-testing/SKILL.md)
|
||||
- [ ] **TestMain**:仅当所有测试都需要带清理的公共设置时使用;优先使用作用域化的 helper → [go-testing](../go-testing/SKILL.md)
|
||||
- [ ] **真实传输**:优先使用 `httptest.NewServer` + 真实客户端而非 mock HTTP → [go-testing](../go-testing/SKILL.md)
|
||||
|
||||
---
|
||||
|
||||
## 自动化检查
|
||||
|
||||
运行自动化预审查检查:
|
||||
|
||||
```bash
|
||||
bash scripts/pre-review.sh ./... # 文本输出
|
||||
bash scripts/pre-review.sh --json ./... # 结构化 JSON 输出
|
||||
```
|
||||
|
||||
或手动:`gofmt -l <path> && go vet ./... && golangci-lint run ./...`
|
||||
|
||||
在进入上述清单之前修复所有问题。有关 linter 设置和配置,请参阅 [go-linting](../go-linting/SKILL.md)。
|
||||
|
||||
---
|
||||
|
||||
## 综合示例
|
||||
|
||||
> 在构建生产级 HTTP 服务器并希望验证代码是否正确应用了并发、错误处理、context、文档和命名规范时,阅读 [references/WEB-SERVER.md](references/WEB-SERVER.md)。
|
||||
|
||||
---
|
||||
|
||||
## 相关 Skill
|
||||
|
||||
- **风格基础**:在解决格式化争议或应用"清晰 > 简单 > 简洁"优先级时,请参阅 [go-style-core](../go-style-core/SKILL.md)
|
||||
- **Linting 设置**:在配置 golangci-lint 或将自动化检查添加到 CI 时,请参阅 [go-linting](../go-linting/SKILL.md)
|
||||
- **错误策略**:在审查错误包装、哨兵错误或 handle-once 模式时,请参阅 [go-error-handling](../go-error-handling/SKILL.md)
|
||||
- **命名规范**:在评估标识符名称、接收器名称或包-符号重复时,请参阅 [go-naming](../go-naming/SKILL.md)
|
||||
- **测试模式**:在审查表驱动结构、失败消息或 helper 使用的测试代码时,请参阅 [go-testing](../go-testing/SKILL.md)
|
||||
- **并发安全**:在审查 goroutine 生命周期、channel 使用或互斥锁放置时,请参阅 [go-concurrency](../go-concurrency/SKILL.md)
|
||||
- **日志实践**:在审查日志使用、结构化日志或 slog 配置时,请参阅 [go-logging](../go-logging/SKILL.md)
|
||||
@@ -1,23 +0,0 @@
|
||||
# Code Review: [PR Title]
|
||||
|
||||
## Summary
|
||||
[Brief description of the changes]
|
||||
|
||||
## Findings
|
||||
|
||||
### Must Fix
|
||||
- [ ] [file:line] Description of critical issue
|
||||
|
||||
### Should Fix
|
||||
- [ ] [file:line] Description of recommended improvement
|
||||
|
||||
### Nits
|
||||
- [ ] [file:line] Description of minor suggestion
|
||||
|
||||
## Automated Checks
|
||||
- [ ] `gofmt -d .` — clean
|
||||
- [ ] `go vet ./...` — clean
|
||||
- [ ] `golangci-lint run` — clean
|
||||
|
||||
## Skills Applied
|
||||
[List of go-* skills referenced during review]
|
||||
@@ -1,119 +0,0 @@
|
||||
# Web 服务器:Skill 的综合应用
|
||||
|
||||
本示例展示 Go skill 如何在真实的 HTTP 服务器中协同应用。每个部分
|
||||
引用相关的 skill 以获取详细指导。
|
||||
|
||||
## 结构
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"time"
|
||||
)
|
||||
|
||||
// --- 接口(go-interfaces) ---
|
||||
|
||||
// Store 定义了数据访问边界。定义在消费方包中,
|
||||
// 而非实现方包中。
|
||||
type Store interface {
|
||||
GetUser(ctx context.Context, id string) (*User, error)
|
||||
}
|
||||
|
||||
// --- 类型与构造函数(go-naming、go-declarations) ---
|
||||
|
||||
// Server 处理用户 API 的 HTTP 请求。
|
||||
type Server struct {
|
||||
store Store
|
||||
router *http.ServeMux
|
||||
}
|
||||
|
||||
// NewServer 使用给定的依赖创建 Server。
|
||||
// 调用者必须调用 Shutdown 来释放资源。
|
||||
func NewServer(store Store) *Server {
|
||||
s := &Server{store: store}
|
||||
s.router = http.NewServeMux()
|
||||
s.router.HandleFunc("GET /users/{id}", s.handleGetUser)
|
||||
return s
|
||||
}
|
||||
|
||||
// --- 错误处理(go-error-handling) ---
|
||||
|
||||
// 领域错误作为哨兵 —— 使用 errors.Is 进行检查。
|
||||
var ErrNotFound = errors.New("not found")
|
||||
|
||||
// --- HTTP 处理器(go-control-flow、go-context、go-error-handling) ---
|
||||
|
||||
func (s *Server) handleGetUser(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context() // go-context:从 request 派生
|
||||
id := r.PathValue("id")
|
||||
|
||||
user, err := s.store.GetUser(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) { // go-error-handling:errors.Is
|
||||
http.Error(w, "user not found", http.StatusNotFound)
|
||||
return // go-control-flow:提前返回
|
||||
}
|
||||
// HTTP 处理器是"记录或返回"规则的例外:在服务端记录详细信息,向客户端返回脱敏错误。
|
||||
slog.Error("GetUser failed", "id", id, "err", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(user)
|
||||
}
|
||||
|
||||
// --- 优雅关闭(go-concurrency、go-defensive) ---
|
||||
|
||||
func main() {
|
||||
store := NewDBStore(os.Getenv("DATABASE_URL"))
|
||||
srv := NewServer(store)
|
||||
|
||||
httpSrv := &http.Server{
|
||||
Addr: ":8080",
|
||||
Handler: srv.router,
|
||||
ReadTimeout: 5 * time.Second, // go-defensive:使用 time.Duration
|
||||
WriteTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
// go-concurrency:goroutine 生命周期清晰
|
||||
go func() {
|
||||
sigCh := make(chan os.Signal, 1) // go-concurrency:channel 大小为 1
|
||||
signal.Notify(sigCh, os.Interrupt)
|
||||
<-sigCh
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel() // go-defensive:defer 清理
|
||||
httpSrv.Shutdown(ctx)
|
||||
}()
|
||||
|
||||
slog.Info("starting server", "addr", httpSrv.Addr)
|
||||
if err := httpSrv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
|
||||
slog.Error("server error", "err", err)
|
||||
os.Exit(1) // go-packages:仅在 main 中退出
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 应用的 Skill
|
||||
|
||||
| 领域 | Skill | 演示内容 |
|
||||
|------|-------|----------|
|
||||
| 接口在消费方 | [go-interfaces](../../go-interfaces/SKILL.md) | `Store` 在使用处定义 |
|
||||
| 命名 | [go-naming](../../go-naming/SKILL.md) | MixedCaps、接收器缩写、清晰的函数名 |
|
||||
| 错误处理 | [go-error-handling](../../go-error-handling/SKILL.md) | 哨兵错误、`errors.Is`、记录或返回 |
|
||||
| Context | [go-context](../../go-context/SKILL.md) | 从 request 派生,逐层传递 |
|
||||
| 控制流 | [go-control-flow](../../go-control-flow/SKILL.md) | 错误情况的提前返回 |
|
||||
| 并发 | [go-concurrency](../../go-concurrency/SKILL.md) | 清晰的 goroutine 生命周期、channel 大小 |
|
||||
| 防御性 | [go-defensive](../../go-defensive/SKILL.md) | `defer cancel()`、`time.Duration`、优雅关闭 |
|
||||
| 包管理 | [go-packages](../../go-packages/SKILL.md) | 仅在 `main()` 中退出 |
|
||||
| 日志 | [go-error-handling](../../go-error-handling/SKILL.md) | 结构化 slog,错误只处理一次 |
|
||||
@@ -1,246 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="1.0.0"
|
||||
SCRIPT_NAME="$(basename "$0")"
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
$SCRIPT_NAME v$VERSION — Run automated pre-review checks on Go code
|
||||
|
||||
USAGE
|
||||
bash $SCRIPT_NAME [options] [path]
|
||||
|
||||
DESCRIPTION
|
||||
Runs gofmt, go vet, and golangci-lint against the target path and
|
||||
reports any findings. Use before manual code review to catch
|
||||
mechanical issues early.
|
||||
|
||||
Exits 0 if all checks pass, 1 if issues found, 2 on error.
|
||||
|
||||
OPTIONS
|
||||
-h, --help Show this help message
|
||||
-v, --version Show version
|
||||
--json Output results as JSON
|
||||
--force Run even if golangci-lint is not installed (skip it)
|
||||
--limit N Max items reported per section (0 = unlimited, default: 0)
|
||||
|
||||
ARGUMENTS
|
||||
path Package pattern to check (default: ./...)
|
||||
|
||||
EXAMPLES
|
||||
bash $SCRIPT_NAME
|
||||
bash $SCRIPT_NAME ./pkg/...
|
||||
bash $SCRIPT_NAME --json ./cmd/server/...
|
||||
bash $SCRIPT_NAME --force ./...
|
||||
bash $SCRIPT_NAME --json --limit 10 ./...
|
||||
EOF
|
||||
}
|
||||
|
||||
json_escape() {
|
||||
local s="$1"
|
||||
s="${s//\\/\\\\}"
|
||||
s="${s//\"/\\\"}"
|
||||
s="${s//$'\t'/\\t}"
|
||||
s="${s//$'\r'/}"
|
||||
s="${s//$'\n'/\\n}"
|
||||
printf '%s' "$s"
|
||||
}
|
||||
|
||||
JSON_OUTPUT=false
|
||||
FORCE=false
|
||||
LIMIT=0
|
||||
TARGET=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-h|--help) usage; exit 0 ;;
|
||||
-v|--version) echo "$SCRIPT_NAME v$VERSION"; exit 0 ;;
|
||||
--json) JSON_OUTPUT=true; shift ;;
|
||||
--force) FORCE=true; shift ;;
|
||||
--limit) LIMIT="${2:?error: --limit requires a number}"; shift 2 ;;
|
||||
-*) echo "error: unknown option: $1" >&2; usage >&2; exit 2 ;;
|
||||
*) TARGET="$1"; shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
TARGET="${TARGET:-./...}"
|
||||
|
||||
if ! command -v go &>/dev/null; then
|
||||
echo "error: go is not installed or not in PATH" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if ! command -v gofmt &>/dev/null; then
|
||||
echo "error: gofmt is not installed or not in PATH" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
GOFMT_STATUS="pass"
|
||||
GOFMT_FINDINGS=()
|
||||
GOFMT_DIR="${TARGET%%/...}"
|
||||
GOFMT_DIR="${GOFMT_DIR:-.}"
|
||||
UNFORMATTED=$(gofmt -l "$GOFMT_DIR" 2>&1) || true
|
||||
if [[ -n "$UNFORMATTED" ]]; then
|
||||
GOFMT_STATUS="fail"
|
||||
while IFS= read -r f; do
|
||||
[[ -n "$f" ]] && GOFMT_FINDINGS+=("$f")
|
||||
done <<< "$UNFORMATTED"
|
||||
fi
|
||||
|
||||
GOVET_STATUS="pass"
|
||||
GOVET_OUTPUT=""
|
||||
if ! GOVET_OUTPUT=$(go vet "$TARGET" 2>&1); then
|
||||
GOVET_STATUS="fail"
|
||||
fi
|
||||
|
||||
LINT_STATUS="skip"
|
||||
LINT_OUTPUT=""
|
||||
if command -v golangci-lint &>/dev/null; then
|
||||
LINT_STATUS="pass"
|
||||
if ! LINT_OUTPUT=$(golangci-lint run "$TARGET" 2>&1); then
|
||||
LINT_STATUS="fail"
|
||||
fi
|
||||
elif ! $FORCE; then
|
||||
echo "error: golangci-lint not installed (use --force to skip)" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
FAILED=0
|
||||
[[ "$GOFMT_STATUS" == "fail" ]] && FAILED=1
|
||||
[[ "$GOVET_STATUS" == "fail" ]] && FAILED=1
|
||||
[[ "$LINT_STATUS" == "fail" ]] && FAILED=1
|
||||
|
||||
if $JSON_OUTPUT; then
|
||||
GOFMT_TRUNCATED=false
|
||||
GOFMT_DISPLAY=("${GOFMT_FINDINGS[@]+"${GOFMT_FINDINGS[@]}"}")
|
||||
if [[ $LIMIT -gt 0 && ${#GOFMT_DISPLAY[@]} -gt $LIMIT ]]; then
|
||||
GOFMT_DISPLAY=("${GOFMT_FINDINGS[@]:0:$LIMIT}")
|
||||
GOFMT_TRUNCATED=true
|
||||
fi
|
||||
|
||||
GOFMT_JSON="["
|
||||
first=true
|
||||
for f in "${GOFMT_DISPLAY[@]+"${GOFMT_DISPLAY[@]}"}"; do
|
||||
$first || GOFMT_JSON+=","
|
||||
first=false
|
||||
GOFMT_JSON+="\"$(json_escape "$f")\""
|
||||
done
|
||||
GOFMT_JSON+="]"
|
||||
|
||||
GOVET_TRUNCATED=false
|
||||
GOVET_DISPLAY="$GOVET_OUTPUT"
|
||||
if [[ $LIMIT -gt 0 && -n "$GOVET_OUTPUT" ]]; then
|
||||
GOVET_ARR=()
|
||||
while IFS= read -r line; do
|
||||
GOVET_ARR+=("$line")
|
||||
done <<< "$GOVET_OUTPUT"
|
||||
if [[ ${#GOVET_ARR[@]} -gt $LIMIT ]]; then
|
||||
GOVET_DISPLAY=""
|
||||
for (( i=0; i<LIMIT; i++ )); do
|
||||
[[ -n "$GOVET_DISPLAY" ]] && GOVET_DISPLAY+=$'\n'
|
||||
GOVET_DISPLAY+="${GOVET_ARR[$i]}"
|
||||
done
|
||||
GOVET_TRUNCATED=true
|
||||
fi
|
||||
fi
|
||||
GOVET_ESC="$(json_escape "$GOVET_DISPLAY")"
|
||||
|
||||
LINT_TRUNCATED=false
|
||||
LINT_DISPLAY="$LINT_OUTPUT"
|
||||
if [[ $LIMIT -gt 0 && -n "$LINT_OUTPUT" ]]; then
|
||||
LINT_ARR=()
|
||||
while IFS= read -r line; do
|
||||
LINT_ARR+=("$line")
|
||||
done <<< "$LINT_OUTPUT"
|
||||
if [[ ${#LINT_ARR[@]} -gt $LIMIT ]]; then
|
||||
LINT_DISPLAY=""
|
||||
for (( i=0; i<LIMIT; i++ )); do
|
||||
[[ -n "$LINT_DISPLAY" ]] && LINT_DISPLAY+=$'\n'
|
||||
LINT_DISPLAY+="${LINT_ARR[$i]}"
|
||||
done
|
||||
LINT_TRUNCATED=true
|
||||
fi
|
||||
fi
|
||||
LINT_ESC="$(json_escape "$LINT_DISPLAY")"
|
||||
|
||||
GOFMT_TRUNC=""
|
||||
$GOFMT_TRUNCATED && GOFMT_TRUNC=',"truncated":true'
|
||||
GOVET_TRUNC=""
|
||||
$GOVET_TRUNCATED && GOVET_TRUNC=',"truncated":true'
|
||||
LINT_TRUNC=""
|
||||
$LINT_TRUNCATED && LINT_TRUNC=',"truncated":true'
|
||||
|
||||
cat <<EOF
|
||||
{"gofmt":{"status":"$GOFMT_STATUS","files":$GOFMT_JSON$GOFMT_TRUNC},"govet":{"status":"$GOVET_STATUS","output":"$GOVET_ESC"$GOVET_TRUNC},"golangci_lint":{"status":"$LINT_STATUS","output":"$LINT_ESC"$LINT_TRUNC},"passed":$( [[ $FAILED -eq 0 ]] && echo true || echo false )}
|
||||
EOF
|
||||
else
|
||||
echo "=== gofmt ==="
|
||||
if [[ "$GOFMT_STATUS" == "fail" ]]; then
|
||||
echo "Unformatted files:"
|
||||
GOFMT_COUNT=0
|
||||
for f in "${GOFMT_FINDINGS[@]}"; do
|
||||
GOFMT_COUNT=$((GOFMT_COUNT + 1))
|
||||
if [[ $LIMIT -gt 0 && $GOFMT_COUNT -gt $LIMIT ]]; then
|
||||
echo " ... ($(( ${#GOFMT_FINDINGS[@]} - LIMIT )) more items truncated)"
|
||||
break
|
||||
fi
|
||||
echo " $f"
|
||||
done
|
||||
else
|
||||
echo "OK"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== go vet ==="
|
||||
if [[ "$GOVET_STATUS" == "fail" ]]; then
|
||||
if [[ $LIMIT -gt 0 ]]; then
|
||||
GOVET_ARR=()
|
||||
while IFS= read -r line; do
|
||||
GOVET_ARR+=("$line")
|
||||
done <<< "$GOVET_OUTPUT"
|
||||
for (( i=0; i<${#GOVET_ARR[@]} && i<LIMIT; i++ )); do
|
||||
echo "${GOVET_ARR[$i]}"
|
||||
done
|
||||
if [[ ${#GOVET_ARR[@]} -gt $LIMIT ]]; then
|
||||
echo "... ($(( ${#GOVET_ARR[@]} - LIMIT )) more items truncated)"
|
||||
fi
|
||||
else
|
||||
echo "$GOVET_OUTPUT"
|
||||
fi
|
||||
else
|
||||
echo "OK"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== golangci-lint ==="
|
||||
if [[ "$LINT_STATUS" == "skip" ]]; then
|
||||
echo "Skipped (not installed)"
|
||||
elif [[ "$LINT_STATUS" == "fail" ]]; then
|
||||
if [[ $LIMIT -gt 0 ]]; then
|
||||
LINT_ARR=()
|
||||
while IFS= read -r line; do
|
||||
LINT_ARR+=("$line")
|
||||
done <<< "$LINT_OUTPUT"
|
||||
for (( i=0; i<${#LINT_ARR[@]} && i<LIMIT; i++ )); do
|
||||
echo "${LINT_ARR[$i]}"
|
||||
done
|
||||
if [[ ${#LINT_ARR[@]} -gt $LIMIT ]]; then
|
||||
echo "... ($(( ${#LINT_ARR[@]} - LIMIT )) more items truncated)"
|
||||
fi
|
||||
else
|
||||
echo "$LINT_OUTPUT"
|
||||
fi
|
||||
else
|
||||
echo "OK"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
if [[ $FAILED -eq 1 ]]; then
|
||||
echo "Pre-review checks FAILED — fix issues before manual review."
|
||||
else
|
||||
echo "All pre-review checks passed."
|
||||
fi
|
||||
fi
|
||||
|
||||
exit $FAILED
|
||||
@@ -1,191 +0,0 @@
|
||||
---
|
||||
name: go-concurrency
|
||||
description: Use when writing concurrent Go code — goroutines, channels, mutexes, or thread-safety guarantees. Also use when parallelizing work, fixing data races, or protecting shared state, even if the user doesn't explicitly mention concurrency primitives. Does not cover context.Context patterns (see go-context).
|
||||
license: Apache-2.0
|
||||
compatibility: Requires go.uber.org/atomic for atomic operation wrappers
|
||||
metadata:
|
||||
sources: "Effective Go, Google Style Guide, Uber Style Guide"
|
||||
---
|
||||
|
||||
# Go 并发
|
||||
|
||||
## Goroutine 生命周期
|
||||
|
||||
> **规范**:当你启动 goroutine 时,要明确它们何时或是否退出。
|
||||
|
||||
Goroutine 可能因阻塞在 channel 的发送/接收上而泄漏。GC **不会终止**被阻塞的 goroutine,即使没有其他 goroutine 持有对该 channel 的引用。即使不泄漏的在途 goroutine 也会导致 panic(在已关闭的 channel 上发送)、数据竞争、内存问题和资源泄漏。
|
||||
|
||||
### 核心规则
|
||||
|
||||
1. **每个 goroutine 都需要停止机制** —— 可预测的结束时间、取消信号,或两者兼有
|
||||
2. **代码必须能够等待** goroutine 完成
|
||||
3. **不在 `init()` 中启动 goroutine** —— 改为暴露生命周期方法(`Close`、`Stop`、`Shutdown`)
|
||||
4. **保持同步作用域化** —— 限制在函数作用域内,将逻辑分解为同步函数
|
||||
|
||||
```go
|
||||
// 好:使用 WaitGroup 明确生命周期
|
||||
var wg sync.WaitGroup
|
||||
for item := range queue {
|
||||
wg.Add(1)
|
||||
go func() { defer wg.Done(); process(ctx, item) }()
|
||||
}
|
||||
wg.Wait()
|
||||
```
|
||||
|
||||
```go
|
||||
// 不好:无法停止或等待
|
||||
go func() { for { flush(); time.Sleep(delay) } }()
|
||||
```
|
||||
|
||||
使用 [go.uber.org/goleak](https://pkg.go.dev/go.uber.org/goleak) **检测泄漏**。
|
||||
|
||||
> **原则**:永远不要在不知道 goroutine 将如何停止的情况下启动它。
|
||||
|
||||
> 在实现 stop/done channel 模式、goroutine 等待策略或
|
||||
> 生命周期管理的 worker 时,阅读 [references/GOROUTINE-PATTERNS.md](references/GOROUTINE-PATTERNS.md)。
|
||||
|
||||
---
|
||||
|
||||
## 通过通信共享
|
||||
|
||||
> "不要通过共享内存来通信;而是通过通信来共享内存。"
|
||||
|
||||
这是 Go 并发设计的基础原则。使用 **channel** 进行所有权转移和协调 —— 当一个 goroutine 生产值,另一个消费它时使用。当多个 goroutine 访问共享状态且 channel 会增加不必要的复杂性时,使用 **互斥锁**。
|
||||
|
||||
**默认使用 channel。** 当问题本质上是保护共享数据结构(例如缓存或计数器)而非在 goroutine 之间传递数据时,退回到 `sync.Mutex` / `sync.RWMutex`。
|
||||
|
||||
---
|
||||
|
||||
## 同步函数
|
||||
|
||||
> **规范**:优先使用同步函数而非异步函数。
|
||||
|
||||
| 优势 | 原因 |
|
||||
|---|---|
|
||||
| 局部化 goroutine | 生命周期更容易推理 |
|
||||
| 避免泄漏和竞争 | 更容易防止资源泄漏和数据竞争 |
|
||||
| 更容易测试 | 直接检查输入/输出,无需轮询 |
|
||||
| 调用方灵活性 | 调用方在需要时添加并发 |
|
||||
|
||||
> **建议**:在调用方移除不必要的并发是相当困难的(有时是不可能的)。让调用方在需要时添加并发。
|
||||
|
||||
> 在编写同步优先的 API(调用方可以将其包装在 goroutine 中)时,
|
||||
> 阅读 [references/GOROUTINE-PATTERNS.md](references/GOROUTINE-PATTERNS.md)。
|
||||
|
||||
---
|
||||
|
||||
## 零值互斥锁
|
||||
|
||||
`sync.Mutex` 和 `sync.RWMutex` 的零值是有效的 —— 几乎不需要互斥锁的指针。
|
||||
|
||||
```go
|
||||
// 好:零值有效 // 不好:不必要的指针
|
||||
var mu sync.Mutex mu := new(sync.Mutex)
|
||||
```
|
||||
|
||||
**不要嵌入互斥锁** —— 使用命名的 `mu` 字段,使 `Lock`/`Unlock` 保持为实现细节,而非导出的 API。
|
||||
|
||||
> 在实现互斥锁保护的 struct 或决定如何组织互斥锁字段时,
|
||||
> 阅读 [references/SYNC-PRIMITIVES.md](references/SYNC-PRIMITIVES.md)。
|
||||
|
||||
---
|
||||
|
||||
## Channel 方向
|
||||
|
||||
> **规范**:尽可能指定 channel 方向。
|
||||
|
||||
方向可以防止错误(编译器会捕获对仅接收 channel 的关闭操作),传达所有权,并且具有自文档化效果。
|
||||
|
||||
```go
|
||||
func produce(out chan<- int) { /* 仅发送 */ }
|
||||
func consume(in <-chan int) { /* 仅接收 */ }
|
||||
func transform(in <-chan int, out chan<- int) { /* 双向 */ }
|
||||
```
|
||||
|
||||
### Channel 大小:一或零
|
||||
|
||||
Channel 的大小应为 **零**(无缓冲)或 **一**。其他任何大小都需要给出理由:
|
||||
|
||||
- 大小是如何确定的
|
||||
- 什么机制防止 channel 在负载下填满
|
||||
- 当写入者阻塞时会发生什么
|
||||
|
||||
```go
|
||||
c := make(chan int) // 无缓冲 —— 好
|
||||
c := make(chan int, 1) // 大小为 1 —— 好
|
||||
c := make(chan int, 64) // 任意大小 —— 需要给出理由
|
||||
```
|
||||
|
||||
> 在审查详细的 channel 方向示例及易出错模式时,
|
||||
> 阅读 [references/SYNC-PRIMITIVES.md](references/SYNC-PRIMITIVES.md)。
|
||||
|
||||
---
|
||||
|
||||
## 原子操作
|
||||
|
||||
使用 `atomic.Bool`、`atomic.Int64` 等(Go 1.19 起标准库 `sync/atomic` 提供,或 [go.uber.org/atomic](https://pkg.go.dev/go.uber.org/atomic))进行类型安全的原子操作。原始的 `int32`/`int64` 字段容易在某些代码路径上忘记原子访问。
|
||||
|
||||
```go
|
||||
// 好:类型安全 // 不好:容易忘记
|
||||
var running atomic.Bool var running int32 // 原子操作
|
||||
running.Store(true) atomic.StoreInt32(&running, 1)
|
||||
running.Load() running == 1 // 竞争!
|
||||
```
|
||||
|
||||
> 在 sync/atomic 和 go.uber.org/atomic 之间选择,或在 struct 中实现原子
|
||||
> 状态标志时,阅读 [references/SYNC-PRIMITIVES.md](references/SYNC-PRIMITIVES.md)。
|
||||
|
||||
---
|
||||
|
||||
## 并发文档
|
||||
|
||||
> **建议**:当线程安全性从操作类型不明显时,添加文档说明。
|
||||
|
||||
Go 用户假设只读操作可以安全地并发使用,而修改操作则不行。在以下情况添加并发文档:
|
||||
|
||||
1. **读取与修改不明确** —— 例如,会修改 LRU 状态的 `Lookup`
|
||||
2. **API 提供同步** —— 例如,线程安全的客户端
|
||||
3. **接口有并发要求** —— 在类型定义中添加文档
|
||||
|
||||
---
|
||||
|
||||
## Context 使用
|
||||
|
||||
> 有关 context.Context 的指导(参数位置、struct 存储、自定义
|
||||
> 类型、派生模式),请参阅专门的
|
||||
> [go-context](../go-context/SKILL.md) skill。
|
||||
|
||||
---
|
||||
|
||||
## 使用 Channel 的缓冲池
|
||||
|
||||
使用有缓冲 channel 作为空闲列表来复用已分配的缓冲区。这种"泄漏缓冲"模式使用带 `default` 的 `select` 进行非阻塞操作。
|
||||
|
||||
> 在实现带可复用缓冲区的 worker pool 或在基于 channel 的池和
|
||||
> `sync.Pool` 之间选择时,阅读 [references/BUFFER-POOLING.md](references/BUFFER-POOLING.md)。
|
||||
|
||||
---
|
||||
|
||||
## 高级模式
|
||||
|
||||
> 在实现使用 channel 的 channel 进行请求-响应多路复用,或
|
||||
> 跨核心的 CPU 密集型并行计算时,阅读 [references/ADVANCED-PATTERNS.md](references/ADVANCED-PATTERNS.md)。
|
||||
|
||||
---
|
||||
|
||||
## 相关 Skill
|
||||
|
||||
- **Context 传播**:在通过 goroutine 传递取消、截止时间或请求作用域值时,请参阅 [go-context](../go-context/SKILL.md)
|
||||
- **错误处理**:在从 goroutine 传播错误或使用 errgroup 时,请参阅 [go-error-handling](../go-error-handling/SKILL.md)
|
||||
- **防御性加固**:在 API 边界保护共享状态或使用 defer 清理时,请参阅 [go-defensive](../go-defensive/SKILL.md)
|
||||
- **接口设计**:在为包含 sync 原语的类型选择接收器类型时,请参阅 [go-interfaces](../go-interfaces/SKILL.md)
|
||||
|
||||
### 外部资源
|
||||
|
||||
- [永远不要在不知道 goroutine 将如何停止的情况下启动它](https://dave.cheney.net/2016/12/22/never-start-a-goroutine-without-knowing-how-it-will-stop)
|
||||
—— Dave Cheney
|
||||
- [重新思考经典并发模式](https://www.youtube.com/watch?v=5zXAHh5tJqQ) —— Bryan Mills
|
||||
(GopherCon 2018)
|
||||
- [Go 程序何时结束](https://changelog.com/gotime/165) —— Go Time 播客
|
||||
- [go.uber.org/goleak](https://pkg.go.dev/go.uber.org/goleak) —— 用于测试的 Goroutine 泄漏检测器
|
||||
- [go.uber.org/atomic](https://pkg.go.dev/go.uber.org/atomic) —— 类型安全的原子操作
|
||||
@@ -1,132 +0,0 @@
|
||||
# 高级并发模式
|
||||
|
||||
来自 Effective Go 的高级并发模式详细参考。这些模式适用于特定场景 —— 在需要请求/响应多路复用或 CPU 密集型并行化时使用。
|
||||
|
||||
---
|
||||
|
||||
## Channel 的 Channel
|
||||
|
||||
> **来源**:Effective Go
|
||||
|
||||
Channel 是一等公民值,可以像其他值一样被分配和传递。一个强大的模式是在请求结构体中嵌入 **回复 channel**,让每个客户端提供自己的应答路径:
|
||||
|
||||
```go
|
||||
type Request struct {
|
||||
args []int
|
||||
f func([]int) int
|
||||
resultChan chan int
|
||||
}
|
||||
```
|
||||
|
||||
客户端发送一个包含函数、参数和接收结果 channel 的请求:
|
||||
|
||||
```go
|
||||
request := &Request{[]int{3, 4, 5}, sum, make(chan int)}
|
||||
clientRequests <- request
|
||||
fmt.Printf("answer: %d\n", <-request.resultChan)
|
||||
```
|
||||
|
||||
服务端处理器从队列中读取请求,并将结果发送回每个请求的回复 channel:
|
||||
|
||||
```go
|
||||
func handle(queue chan *Request) {
|
||||
for req := range queue {
|
||||
req.resultChan <- req.f(req.args)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
这个模式构成了限速、并行、非阻塞 RPC 系统的基础,无需任何互斥锁。
|
||||
|
||||
---
|
||||
|
||||
## CPU 密集型并行化
|
||||
|
||||
> **来源**:Effective Go(现代化版本)
|
||||
|
||||
当计算可以分解为独立的部分时,使用 `sync.WaitGroup` 等待完成,将其并行化到多个 CPU 核心上:
|
||||
|
||||
```go
|
||||
type Vector []float64
|
||||
|
||||
func (v Vector) DoSome(i, n int, u Vector) {
|
||||
for ; i < n; i++ {
|
||||
v[i] += u.Op(v[i])
|
||||
}
|
||||
}
|
||||
|
||||
func (v Vector) DoAll(u Vector) {
|
||||
numCPU := runtime.NumCPU()
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(numCPU)
|
||||
for i := 0; i < numCPU; i++ {
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
v.DoSome(i*len(v)/numCPU, (i+1)*len(v)/numCPU, u)
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
```
|
||||
|
||||
使用 `runtime.NumCPU()` 获取硬件核心数,或使用 `runtime.GOMAXPROCS(0)` 以遵循用户的资源配置。
|
||||
|
||||
> **重要**:不要混淆并发(将程序组织为独立执行的组件)和并行(在多个 CPU 上同时执行计算)。Go 是一门并发语言;并非所有并行化问题都适合它的模型。
|
||||
|
||||
---
|
||||
|
||||
## 常见错误
|
||||
|
||||
### 忘记通知完成
|
||||
|
||||
如果 goroutine 从未调用 `wg.Done()`(或从未在 done channel 上发送),等待的 goroutine 将永远阻塞:
|
||||
|
||||
```go
|
||||
// 不好:缺少 wg.Done —— 死锁
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
doWork()
|
||||
}()
|
||||
wg.Wait()
|
||||
|
||||
// 好:始终 defer wg.Done
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
doWork()
|
||||
}()
|
||||
wg.Wait()
|
||||
```
|
||||
|
||||
### 无限制的 goroutine 创建
|
||||
|
||||
为每个工作项无限制地启动 goroutine 可能会耗尽内存或压垮下游资源。使用信号量来限制并发数:
|
||||
|
||||
```go
|
||||
// 不好:一次性创建 len(items) 个 goroutine
|
||||
var wg sync.WaitGroup
|
||||
for _, item := range items {
|
||||
wg.Add(1)
|
||||
go func(it Item) {
|
||||
defer wg.Done()
|
||||
process(it)
|
||||
}(item)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// 好:信号量将并发限制为 maxWorkers
|
||||
var wg sync.WaitGroup
|
||||
sem := make(chan struct{}, maxWorkers)
|
||||
for _, item := range items {
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func(it Item) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
process(it)
|
||||
}(item)
|
||||
}
|
||||
wg.Wait()
|
||||
```
|
||||
@@ -1,73 +0,0 @@
|
||||
# 使用 Channel 的缓冲池
|
||||
|
||||
使用有缓冲 channel 作为空闲列表来复用已分配的缓冲区,避免重复分配。这种"泄漏缓冲"模式使用带 `default` 的 `select` 进行非阻塞操作。
|
||||
|
||||
> **来源**:Effective Go
|
||||
|
||||
```go
|
||||
var freeList = make(chan *Buffer, 100) // 有缓冲 channel 作为空闲列表
|
||||
|
||||
// 客户端:从空闲列表获取缓冲区或分配新的
|
||||
func getBuffer() *Buffer {
|
||||
select {
|
||||
case b := <-freeList:
|
||||
return b // 复用已有缓冲区
|
||||
default:
|
||||
return new(Buffer) // 空闲列表为空;分配新缓冲区
|
||||
}
|
||||
}
|
||||
|
||||
// 服务端:如有空间则将缓冲区归还空闲列表,否则丢弃
|
||||
func putBuffer(b *Buffer) {
|
||||
b.Reset() // 为重用做准备
|
||||
select {
|
||||
case freeList <- b:
|
||||
// 缓冲区已归还空闲列表
|
||||
default:
|
||||
// 空闲列表已满;丢弃缓冲区(GC 会回收)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 工作原理
|
||||
|
||||
1. **非阻塞接收**:客户端尝试从 `freeList` 获取缓冲区。如果为空,`default` 分支运行并分配新缓冲区。
|
||||
2. **非阻塞发送**:服务端尝试归还缓冲区。如果 `freeList` 已满,`default` 分支运行,缓冲区被丢弃等待垃圾回收。
|
||||
3. **有限内存**:channel 容量(100)限制了池中缓冲区的数量,防止无限增长。
|
||||
|
||||
当分配成本较高且缓冲区复用有益,但你不希望在池空或池满时出现阻塞行为时,这种模式非常有用。
|
||||
|
||||
## 何时使用
|
||||
|
||||
- 高频分配相似大小的对象
|
||||
- 分配开销影响性能的代码路径
|
||||
- 需要限制内存使用量的场景
|
||||
|
||||
## 生产环境替代方案
|
||||
|
||||
对于生产代码,考虑使用 `sync.Pool`,它提供类似功能并与垃圾收集器有更好的集成:
|
||||
|
||||
```go
|
||||
var bufferPool = sync.Pool{
|
||||
New: func() any {
|
||||
return new(Buffer)
|
||||
},
|
||||
}
|
||||
|
||||
func getBuffer() *Buffer {
|
||||
return bufferPool.Get().(*Buffer)
|
||||
}
|
||||
|
||||
func putBuffer(b *Buffer) {
|
||||
b.Reset()
|
||||
bufferPool.Put(b)
|
||||
}
|
||||
```
|
||||
|
||||
`sync.Pool` 的优势:
|
||||
- 垃圾收集期间自动清理
|
||||
- 无需管理池大小
|
||||
- 天生线程安全
|
||||
- 高并发下性能更好
|
||||
|
||||
基于 channel 的方式对于理解 Go 的并发原语以及需要更多控制池行为的场景仍然很有价值。
|
||||
@@ -1,126 +0,0 @@
|
||||
# Goroutine 生命周期模式
|
||||
|
||||
管理 goroutine 生命周期的详细模式 —— 确保每个 goroutine 都有清晰的启动/停止机制并防止资源泄漏。
|
||||
|
||||
---
|
||||
|
||||
## 使生命周期清晰
|
||||
|
||||
> WaitGroup 示例和作用域规则在父 skill(SKILL.md § Goroutine 生命周期,核心规则)中。本参考涵盖:stop/done channel 模式、等待策略、init() 生命周期示例和同步 API 设计。
|
||||
|
||||
---
|
||||
|
||||
## Stop/Done Channel 模式
|
||||
|
||||
每个 goroutine 必须有可预测的停止机制。使用 stop channel 通知关闭,使用 done channel 确认退出:
|
||||
|
||||
```go
|
||||
var (
|
||||
stop = make(chan struct{}) // 通知 goroutine 停止
|
||||
done = make(chan struct{}) // 通知我们 goroutine 已退出
|
||||
)
|
||||
go func() {
|
||||
defer close(done)
|
||||
ticker := time.NewTicker(delay)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
flush()
|
||||
case <-stop:
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// 关闭时:
|
||||
close(stop) // 通知 goroutine 停止
|
||||
<-done // 并等待它退出
|
||||
```
|
||||
|
||||
在已关闭的 channel 上发送会 panic —— 始终使用 `close()` 来发信号,不要直接发送:
|
||||
|
||||
```go
|
||||
ch := make(chan int)
|
||||
close(ch)
|
||||
ch <- 13 // panic: 在已关闭的 channel 上发送
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 等待 Goroutine
|
||||
|
||||
> 多 goroutine 的 `sync.WaitGroup` 模式在父 skill 中(SKILL.md § Goroutine 生命周期)。以下是单 goroutine 的 done-channel 替代方案。
|
||||
|
||||
为单个 goroutine 使用 done channel:
|
||||
|
||||
```go
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
// 工作...
|
||||
}()
|
||||
<-done // 等待 goroutine 完成
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 不在 init() 中使用 Goroutine
|
||||
|
||||
> 核心规则在父 skill 中(SKILL.md § 核心规则,规则 3)。以下是展示生命周期管理的扩展示例。
|
||||
|
||||
```go
|
||||
// 不好:创建了不可控的后台 goroutine
|
||||
func init() {
|
||||
go doWork()
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
// 好:显式的生命周期管理
|
||||
type Worker struct {
|
||||
stop chan struct{}
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func NewWorker() *Worker {
|
||||
w := &Worker{
|
||||
stop: make(chan struct{}),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
go w.doWork()
|
||||
return w
|
||||
}
|
||||
|
||||
func (w *Worker) Shutdown() {
|
||||
close(w.stop)
|
||||
<-w.done
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 优先使用同步函数
|
||||
|
||||
> 理由和优势表在父 skill 中(SKILL.md § 同步函数)。以下是具体的代码示例。
|
||||
|
||||
```go
|
||||
// 好:同步函数 - 调用方控制并发
|
||||
func ProcessItems(items []Item) ([]Result, error) {
|
||||
var results []Result
|
||||
for _, item := range items {
|
||||
result, err := processItem(item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results = append(results, result)
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// 调用方可以在需要时添加并发:
|
||||
go func() {
|
||||
results, err := ProcessItems(items)
|
||||
// 处理结果
|
||||
}()
|
||||
```
|
||||
@@ -1,110 +0,0 @@
|
||||
# 同步原语模式
|
||||
|
||||
互斥锁和原子操作的详细模式 —— 涵盖互斥锁嵌入陷阱和类型安全的原子访问。
|
||||
|
||||
---
|
||||
|
||||
## 不要嵌入互斥锁
|
||||
|
||||
如果你通过指针使用结构体,互斥锁应该是非指针字段。不要在结构体中嵌入互斥锁,即使该结构体未被导出。
|
||||
|
||||
```go
|
||||
// 不好:嵌入的互斥锁将 Lock/Unlock 暴露为 API 的一部分
|
||||
type SMap struct {
|
||||
sync.Mutex // Lock() 和 Unlock() 成为 SMap 的方法
|
||||
data map[string]string
|
||||
}
|
||||
|
||||
func (m *SMap) Get(k string) string {
|
||||
m.Lock()
|
||||
defer m.Unlock()
|
||||
return m.data[k]
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
// 好:命名字段使互斥锁保持为实现细节
|
||||
type SMap struct {
|
||||
mu sync.Mutex
|
||||
data map[string]string
|
||||
}
|
||||
|
||||
func (m *SMap) Get(k string) string {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.data[k]
|
||||
}
|
||||
```
|
||||
|
||||
在不好的示例中,`Lock` 和 `Unlock` 方法无意中成为了导出 API 的一部分。在好的示例中,互斥锁是对调用方隐藏的实现细节。
|
||||
|
||||
---
|
||||
|
||||
## 原子操作:完整示例
|
||||
|
||||
标准 `sync/atomic` 包操作原始类型(`int32`、`int64` 等),容易忘记一致地使用原子操作。
|
||||
|
||||
```go
|
||||
// 不好:容易忘记原子操作
|
||||
type foo struct {
|
||||
running int32 // 原子操作
|
||||
}
|
||||
|
||||
func (f *foo) start() {
|
||||
if atomic.SwapInt32(&f.running, 1) == 1 {
|
||||
return // 已在运行
|
||||
}
|
||||
// 启动 Foo
|
||||
}
|
||||
|
||||
func (f *foo) isRunning() bool {
|
||||
return f.running == 1 // 竞争!忘记使用 atomic.LoadInt32
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
// 好:类型安全的原子操作
|
||||
type foo struct {
|
||||
running atomic.Bool
|
||||
}
|
||||
|
||||
func (f *foo) start() {
|
||||
if f.running.Swap(true) {
|
||||
return // 已在运行
|
||||
}
|
||||
// 启动 Foo
|
||||
}
|
||||
|
||||
func (f *foo) isRunning() bool {
|
||||
return f.running.Load() // 不可能意外地非原子读取
|
||||
}
|
||||
```
|
||||
|
||||
`atomic.Bool`、`atomic.Int64` 等类型(Go 1.19 起在标准库 `sync/atomic` 中可用,或通过 [go.uber.org/atomic](https://pkg.go.dev/go.uber.org/atomic))通过隐藏底层类型来增加类型安全性。
|
||||
|
||||
---
|
||||
|
||||
## Channel 方向示例
|
||||
|
||||
指定方向可以防止意外误用:
|
||||
|
||||
```go
|
||||
// 好:指定方向 - 清晰的所有权
|
||||
func sum(values <-chan int) int {
|
||||
total := 0
|
||||
for v := range values {
|
||||
total += v
|
||||
}
|
||||
return total
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
// 不好:未指定方向 - 允许意外误用
|
||||
func sum(values chan int) (out int) {
|
||||
for v := range values {
|
||||
out += v
|
||||
}
|
||||
close(values) // 漏洞!这能通过编译但不应该发生。
|
||||
}
|
||||
```
|
||||
@@ -1,122 +0,0 @@
|
||||
---
|
||||
name: go-context
|
||||
description: 在 Go 中使用 context.Context 时使用 — 包括函数签名中的位置、传播取消和截止时间、以及在 context 中存储值与使用参数的对比。也适用于取消长时间运行的操作、设置超时或传递请求作用域数据,即使未直接提及 context.Context。不涵盖 goroutine 生命周期或 sync 原语(参见 go-concurrency)。
|
||||
license: Apache-2.0
|
||||
compatibility: 需要 Go 1.7+(context 在 Go 1.7 中移入标准库)
|
||||
metadata:
|
||||
sources: "Go Wiki CodeReviewComments"
|
||||
---
|
||||
|
||||
# Go Context 用法
|
||||
|
||||
## Context 作为第一个参数
|
||||
|
||||
使用 Context 的函数应将其作为**第一个参数**:
|
||||
|
||||
```go
|
||||
func F(ctx context.Context, /* 其他参数 */) error
|
||||
func ProcessRequest(ctx context.Context, req *Request) (*Response, error)
|
||||
```
|
||||
|
||||
这是 Go 中的一个强约定,使 context 的传递在代码库中可见且一致。
|
||||
|
||||
---
|
||||
|
||||
## 不要在结构体中存储 Context
|
||||
|
||||
不要在结构体类型中添加 Context 成员。相反,将 `ctx` 作为参数传递给每个需要它的方法:
|
||||
|
||||
```go
|
||||
// 不好:Context 存储在结构体中
|
||||
type Worker struct {
|
||||
ctx context.Context // 不要这样做
|
||||
}
|
||||
|
||||
// 好:Context 传递给方法
|
||||
type Worker struct{ /* ... */ }
|
||||
|
||||
func (w *Worker) Process(ctx context.Context) error {
|
||||
// Context 显式传递 — 生命周期清晰
|
||||
}
|
||||
```
|
||||
|
||||
**例外**:签名必须匹配标准库或第三方库中接口的方法可能需要变通处理。
|
||||
|
||||
---
|
||||
|
||||
## 不要创建自定义 Context 类型
|
||||
|
||||
不要创建自定义的 Context 类型或在函数签名中使用 `context.Context` 以外的接口:
|
||||
|
||||
```go
|
||||
// 不好:自定义 context 类型
|
||||
type MyContext interface {
|
||||
context.Context
|
||||
GetUserID() string
|
||||
}
|
||||
|
||||
// 好:使用标准 context.Context 并提取值
|
||||
func Process(ctx context.Context) error {
|
||||
userID := GetUserID(ctx)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 应用数据放在哪里
|
||||
|
||||
按以下优先级顺序考虑:
|
||||
|
||||
1. **函数参数** — 最明确且类型安全
|
||||
2. **接收者** — 适用于属于该类型的数据
|
||||
3. **全局变量** — 适用于真正的全局配置(谨慎使用)
|
||||
4. **Context 值** — 仅用于请求作用域数据
|
||||
|
||||
Context 值适用于:
|
||||
- 请求 ID 和追踪 ID
|
||||
- 随请求流动的认证/授权信息
|
||||
- 截止时间和取消信号
|
||||
|
||||
Context 值**不适用**于:
|
||||
- 可选的函数参数
|
||||
- 可以显式传递的数据
|
||||
- 不随请求变化的配置
|
||||
|
||||
---
|
||||
|
||||
## 常见模式
|
||||
|
||||
> 在派生 context(WithTimeout、WithCancel、WithDeadline)、在循环或 HTTP 处理器中检查取消、使用带类型键的 context 值、或需要快速参考表时,阅读 [references/PATTERNS.md](references/PATTERNS.md)。
|
||||
|
||||
### 派生 Context
|
||||
|
||||
创建派生 context 后,始终立即 `defer cancel()`:
|
||||
|
||||
```go
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
```
|
||||
|
||||
### 检查取消
|
||||
|
||||
```go
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
// 执行工作
|
||||
}
|
||||
```
|
||||
|
||||
### Context 不可变性
|
||||
|
||||
Context 是不可变的 — 将同一个 `ctx` 传递给共享相同截止时间和取消信号的多个并发调用是安全的。
|
||||
|
||||
---
|
||||
|
||||
## 相关技能
|
||||
|
||||
- **Goroutine 协调**:在使用 context 进行 goroutine 取消、基于 select 的超时或 errgroup 时,参见 [go-concurrency](../go-concurrency/SKILL.md)
|
||||
- **错误处理**:在决定如何包装或返回 `ctx.Err()` 取消错误时,参见 [go-error-handling](../go-error-handling/SKILL.md)
|
||||
- **接口设计**:在设计接受 context 并结合接口的 API 时,参见 [go-interfaces](../go-interfaces/SKILL.md)
|
||||
- **请求作用域日志**:在将 logger 注入 context 或将请求 ID 添加到结构化日志输出时,参见 [go-logging](../go-logging/SKILL.md)
|
||||
@@ -1,227 +0,0 @@
|
||||
# Context 模式
|
||||
|
||||
派生、检查和传播 `context.Context` 的常见模式。
|
||||
|
||||
---
|
||||
|
||||
## Context 不可变性
|
||||
|
||||
Context 是不可变的。将同一个 `ctx` 传递给共享相同截止时间、取消信号、凭据和父级追踪的多个调用是安全的:
|
||||
|
||||
```go
|
||||
// 安全:同一个 context 传递给顺序调用
|
||||
func ProcessBatch(ctx context.Context, items []Item) error {
|
||||
for _, item := range items {
|
||||
if err := process(ctx, item); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 安全:同一个 context 传递给并发调用
|
||||
func ProcessConcurrently(ctx context.Context, a, b *Data) error {
|
||||
g, ctx := errgroup.WithContext(ctx)
|
||||
g.Go(func() error { return processA(ctx, a) })
|
||||
g.Go(func() error { return processB(ctx, b) })
|
||||
return g.Wait()
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 何时使用 context.Background()
|
||||
|
||||
仅在**从不特定于请求**的函数中使用 `context.Background()`:
|
||||
|
||||
```go
|
||||
func main() {
|
||||
ctx := context.Background()
|
||||
if err := run(ctx); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func startBackgroundWorker() {
|
||||
ctx := context.Background()
|
||||
go worker(ctx)
|
||||
}
|
||||
```
|
||||
|
||||
**默认传递 Context**,即使你认为不需要。只有在有充分理由说明传递 context 是错误做法时,才直接使用 `context.Background()`:
|
||||
|
||||
```go
|
||||
func LoadConfig(ctx context.Context) (*Config, error) {
|
||||
// 即使现在不使用 ctx,接受它可以在未来添加功能时
|
||||
// 不需要修改 API
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 派生 Context
|
||||
|
||||
```go
|
||||
// 添加超时 — 持续时间结束后触发取消
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// 添加取消 — 调用者控制何时取消
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
// 添加截止时间 — 在指定的墙钟时间触发取消
|
||||
ctx, cancel := context.WithDeadline(ctx, time.Now().Add(time.Hour))
|
||||
defer cancel()
|
||||
|
||||
// 添加值(谨慎使用 — 仅用于请求作用域数据)
|
||||
ctx = context.WithValue(ctx, requestIDKey, reqID)
|
||||
```
|
||||
|
||||
创建派生 context 后,**始终立即 `defer cancel()`**。这确保即使函数提前返回,资源也会被释放。
|
||||
|
||||
### 嵌套派生
|
||||
|
||||
派生的 context 形成树状结构。取消父级会取消所有子级:
|
||||
|
||||
```go
|
||||
func handleRequest(ctx context.Context) error {
|
||||
// 整个请求的父级超时
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// 数据库调用的更短超时
|
||||
dbCtx, dbCancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer dbCancel()
|
||||
|
||||
data, err := queryDB(dbCtx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 父级 context 的剩余时间适用于此处
|
||||
return sendResponse(ctx, data)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 检查取消
|
||||
|
||||
### 在长时间运行的循环中
|
||||
|
||||
```go
|
||||
func LongRunningOperation(ctx context.Context) error {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
// 执行工作
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 在高开销操作之前
|
||||
|
||||
在开始无法中断的工作之前检查取消:
|
||||
|
||||
```go
|
||||
func ProcessItems(ctx context.Context, items []Item) error {
|
||||
for _, item := range items {
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
if err := expensiveProcess(item); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
### 区分取消原因
|
||||
|
||||
```go
|
||||
if err := ctx.Err(); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, context.Canceled):
|
||||
// 调用者显式取消(例如客户端断开连接)
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
// 超时或截止时间已过
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 在 HTTP 处理器中遵守取消
|
||||
|
||||
```go
|
||||
func handler(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
result, err := slowOperation(ctx)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
// 客户端已断开连接 — 无需写入
|
||||
return
|
||||
}
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(result)
|
||||
}
|
||||
```
|
||||
|
||||
`r.Context()` 在以下情况被取消:
|
||||
- 客户端关闭连接
|
||||
- `http.Server` 的 `ReadTimeout` 或 `WriteTimeout` 触发
|
||||
- `ServeHTTP` 方法返回
|
||||
|
||||
---
|
||||
|
||||
## Context 值的最佳实践
|
||||
|
||||
### 使用未导出的键类型
|
||||
|
||||
```go
|
||||
type contextKey struct{}
|
||||
|
||||
var userIDKey contextKey
|
||||
|
||||
func WithUserID(ctx context.Context, id string) context.Context {
|
||||
return context.WithValue(ctx, userIDKey, id)
|
||||
}
|
||||
|
||||
func UserIDFromContext(ctx context.Context) (string, bool) {
|
||||
id, ok := ctx.Value(userIDKey).(string)
|
||||
return id, ok
|
||||
}
|
||||
```
|
||||
|
||||
使用未导出的结构体类型作为键可以防止与其他包的键发生冲突 — 即使它们使用相同的 string 或 int 值。
|
||||
|
||||
### 提供访问器函数
|
||||
|
||||
始终将 `context.WithValue` 和 `ctx.Value` 包装在类型化的辅助函数中(如上所示),而不是暴露键。这提供了类型安全性和一个可以修改实现的单一位置。
|
||||
|
||||
---
|
||||
|
||||
## 快速参考
|
||||
|
||||
| 模式 | 指导 |
|
||||
|------|------|
|
||||
| 参数位置 | 始终第一个:`func F(ctx context.Context, ...)` |
|
||||
| 结构体存储 | 不要存储在结构体中;传递给方法 |
|
||||
| 自定义类型 | 不要创建;使用 `context.Context` 接口 |
|
||||
| 应用数据 | 优先选择 参数 > 接收者 > 全局变量 > context 值 |
|
||||
| 请求作用域数据 | 适用于 context 值 |
|
||||
| 共享 context | 安全 — context 是不可变的 |
|
||||
| `context.Background()` | 仅用于非请求特定的代码 |
|
||||
| 默认行为 | 即使认为不需要也要传递 context |
|
||||
| `defer cancel()` | 在 `WithTimeout`/`WithCancel`/`WithDeadline` 之后始终立即 defer |
|
||||
| 值键 | 使用未导出的结构体类型,提供访问器函数 |
|
||||
| 取消检查 | 在高开销操作前使用 `ctx.Err()`;在循环中使用 `select` 监听 `ctx.Done()` |
|
||||
@@ -1,193 +0,0 @@
|
||||
---
|
||||
name: go-control-flow
|
||||
description: Use when writing conditionals, loops, or switch statements in Go — including if with initialization, early returns, for loop forms, range, switch, type switches, and blank identifier patterns. Also use when writing a simple if/else or for loop, even if the user doesn't mention guard clauses or variable scoping. Does not cover error flow patterns (see go-error-handling).
|
||||
license: Apache-2.0
|
||||
metadata:
|
||||
sources: "Effective Go, Google Style Guide"
|
||||
---
|
||||
|
||||
# Go 控制流
|
||||
|
||||
> 在使用 switch 语句、类型 switch 或带标签的 break 时,阅读 [references/SWITCH-PATTERNS.md](references/SWITCH-PATTERNS.md)
|
||||
|
||||
> 在使用 `_`、空白标识符导入或编译时接口检查时,阅读 [references/BLANK-IDENTIFIER.md](references/BLANK-IDENTIFIER.md)
|
||||
|
||||
---
|
||||
|
||||
## 带初始化的 If
|
||||
|
||||
`if` 和 `switch` 接受可选的初始化语句。使用它将变量限定在条件块作用域内:
|
||||
|
||||
```go
|
||||
if err := file.Chmod(0664); err != nil {
|
||||
log.Print(err)
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
如果需要在 `if` 之后超出几行的范围使用该变量,请单独声明并使用标准 `if`:
|
||||
|
||||
```go
|
||||
x, err := f()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 大量使用 x 的代码
|
||||
```
|
||||
|
||||
## 缩进错误流(守卫子句)
|
||||
|
||||
当 `if` 主体以 `break`、`continue`、`goto` 或 `return` 结尾时,省略不必要的 `else`。保持成功路径不缩进:
|
||||
|
||||
```go
|
||||
f, err := os.Open(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
d, err := f.Stat()
|
||||
if err != nil {
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
codeUsing(f, d)
|
||||
```
|
||||
|
||||
当 `if` 已经返回时,绝不要将正常流程埋在 `else` 中。
|
||||
|
||||
---
|
||||
|
||||
## 重新声明和重新赋值
|
||||
|
||||
`:=` 短声明允许在同一作用域中重新声明变量:
|
||||
|
||||
```go
|
||||
f, err := os.Open(name) // 声明 f 和 err
|
||||
d, err := f.Stat() // 声明 d,重新赋值 err
|
||||
```
|
||||
|
||||
变量 `v` 即使已经声明过,也可以出现在 `:=` 声明中,前提是:
|
||||
|
||||
1. 声明在与现有 `v` **相同的作用域**中
|
||||
2. 值**可赋值**给 `v`
|
||||
3. 声明中至少创建了**一个其他新变量**
|
||||
|
||||
### 变量遮蔽
|
||||
|
||||
**警告**:如果 `v` 在外层作用域中声明,`:=` 会创建一个**新的**遮蔽变量 — 这是常见的 bug 来源:
|
||||
|
||||
```go
|
||||
// Bug:if 块内的 ctx 遮蔽了外层的 ctx
|
||||
if *shortenDeadlines {
|
||||
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||
defer cancel()
|
||||
}
|
||||
// 此处的 ctx 仍然是原始的 — 被遮蔽的 ctx 没有逃逸
|
||||
|
||||
// 修复:使用 = 而不是 :=
|
||||
var cancel func()
|
||||
ctx, cancel = context.WithTimeout(ctx, 3*time.Second)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## For 循环
|
||||
|
||||
Go 的 `for` 是唯一的循环结构,统一了 `while`、`do-while` 和 C 风格的 `for`:
|
||||
|
||||
```go
|
||||
// 仅条件(Go 的 "while")
|
||||
for x > 0 {
|
||||
x = process(x)
|
||||
}
|
||||
|
||||
// 无限循环
|
||||
for {
|
||||
if done() { break }
|
||||
}
|
||||
|
||||
// C 风格的三组件形式
|
||||
for i := 0; i < n; i++ { ... }
|
||||
```
|
||||
|
||||
### Range
|
||||
|
||||
`range` 遍历切片、映射、字符串和通道:
|
||||
|
||||
```go
|
||||
for i, v := range slice { ... } // 索引 + 值
|
||||
for k, v := range myMap { ... } // 键 + 值(顺序不确定)
|
||||
for i, r := range "héllo" { ... } // 字节索引 + rune(不是字节)
|
||||
for v := range ch { ... } // 接收直到通道关闭
|
||||
```
|
||||
|
||||
**关键规则:**
|
||||
- 对字符串 range 产生 **rune**,不是字节 — `i` 是字节偏移量
|
||||
- 对映射 range 的顺序**不确定** — 不要依赖它
|
||||
- 使用 `_` 丢弃索引或值:`for _, v := range slice`
|
||||
|
||||
### 并行赋值
|
||||
|
||||
Go 没有逗号运算符。使用并行赋值处理多个循环变量:
|
||||
|
||||
```go
|
||||
for i, j := 0, len(a)-1; i < j; i, j = i+1, j-1 {
|
||||
a[i], a[j] = a[j], a[i]
|
||||
}
|
||||
```
|
||||
|
||||
`++` 和 `--` 是语句,不是表达式 — 它们不能出现在并行赋值中。
|
||||
|
||||
---
|
||||
|
||||
## Switch:带标签的 Break
|
||||
|
||||
`for` 循环内 `switch` 中的 `break` 只会中断 switch。使用带标签的 `break` 退出外层循环:
|
||||
|
||||
```go
|
||||
Loop:
|
||||
for _, v := range items {
|
||||
switch v.Type {
|
||||
case "done":
|
||||
break Loop // 中断 for 循环
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
关于类型 switch,参见 **go-interfaces**:类型 Switch。
|
||||
|
||||
---
|
||||
|
||||
## 空白标识符
|
||||
|
||||
**绝不要随意丢弃错误** — 空指针解引用 panic 可能随之而来。
|
||||
|
||||
在编译时验证接口实现:`var _ io.Writer = (*MyType)(nil)`。
|
||||
参见 **go-interfaces** 中的接口满足检查模式。
|
||||
|
||||
---
|
||||
|
||||
## 快速参考
|
||||
|
||||
| 模式 | Go 惯用法 |
|
||||
|------|-----------|
|
||||
| If 初始化 | `if err := f(); err != nil { }` |
|
||||
| 提前返回 | 当 if 主体返回时省略 `else` |
|
||||
| 重新声明 | `:=` 在相同作用域 + 新变量时重新赋值 |
|
||||
| 遮蔽陷阱 | `:=` 在内层作用域创建新变量 |
|
||||
| 并行赋值 | `i, j = i+1, j-1` |
|
||||
| 无表达式 switch | `switch { case cond: }` |
|
||||
| 逗号 case | `case 'a', 'b', 'c':` |
|
||||
| 无 fallthrough | 默认行为(需要时显式使用 `fallthrough`) |
|
||||
| 从 switch 中跳出循环 | `break Label` |
|
||||
| 丢弃值 | `_, err := f()` |
|
||||
| 副作用导入 | `import _ "pkg"` |
|
||||
| 接口检查 | `var _ Interface = (*Type)(nil)` |
|
||||
|
||||
---
|
||||
|
||||
## 相关技能
|
||||
|
||||
- **错误流程**:在构建守卫子句、提前返回或错误优先模式时,参见 [go-error-handling](../go-error-handling/SKILL.md)
|
||||
- **类型 switch**:在使用类型 switch、comma-ok 惯用法或接口满足检查时,参见 [go-interfaces](../go-interfaces/SKILL.md)
|
||||
- **减少嵌套**:在减少嵌套深度或解决格式问题时,参见 [go-style-core](../go-style-core/SKILL.md)
|
||||
- **变量作用域**:在使用 if 初始化、`:=` 重新声明或减少变量作用域时,参见 [go-declarations](../go-declarations/SKILL.md)
|
||||
@@ -1,71 +0,0 @@
|
||||
# 空白标识符模式
|
||||
|
||||
空白标识符 `_` 在 Go 中有多种用途:丢弃不需要的值、为副作用导入包、以及在编译时验证接口实现。
|
||||
|
||||
---
|
||||
|
||||
## 多重赋值
|
||||
|
||||
使用 `_` 丢弃多值表达式中不需要的值:
|
||||
|
||||
```go
|
||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||
fmt.Printf("%s does not exist\n", path)
|
||||
}
|
||||
```
|
||||
|
||||
### 绝不要随意丢弃错误
|
||||
|
||||
静默丢弃错误会引发空指针 panic:
|
||||
|
||||
```go
|
||||
// 不好:忽略错误会在路径不存在时崩溃
|
||||
fi, _ := os.Stat(path)
|
||||
if fi.IsDir() { ... } // 空指针解引用
|
||||
```
|
||||
|
||||
如果确实不需要错误,请记录原因:
|
||||
|
||||
```go
|
||||
_ = logger.Sync() // 尽力刷新;错误不可操作
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 副作用导入
|
||||
|
||||
使用空白标识符仅为了 `init()` 副作用而导入包:
|
||||
|
||||
```go
|
||||
import _ "net/http/pprof" // 注册 HTTP 处理器
|
||||
import _ "image/png" // 注册 PNG 解码器
|
||||
```
|
||||
|
||||
这通常用于注册驱动、编解码器或调试处理器,它们在 `init()` 期间将自己注册到注册表中。
|
||||
|
||||
---
|
||||
|
||||
## 接口实现检查
|
||||
|
||||
在编译时验证类型是否实现了接口,方法是将 nil 指针赋值给接口类型的空白标识符变量:
|
||||
|
||||
```go
|
||||
var _ io.Writer = (*MyType)(nil)
|
||||
```
|
||||
|
||||
如果 `*MyType` 不满足 `io.Writer`,这会产生编译错误,在运行时之前捕获缺失的方法。
|
||||
|
||||
**何时使用**:将此检查放在定义该类型的同一文件中,通常在类型声明之后。当类型必须满足另一个包中定义的接口时特别有用。
|
||||
|
||||
参见 [go-interfaces](../../go-interfaces/SKILL.md):接口满足检查,获取关于何时何地使用此模式的完整指导。
|
||||
|
||||
---
|
||||
|
||||
## 快速参考
|
||||
|
||||
| 模式 | 语法 |
|
||||
|------|------|
|
||||
| 丢弃值 | `_, err := f()` |
|
||||
| 在 if 初始化中丢弃 | `if _, err := f(); err != nil { }` |
|
||||
| 副作用导入 | `import _ "pkg"` |
|
||||
| 接口检查 | `var _ Interface = (*Type)(nil)` |
|
||||
@@ -1,109 +0,0 @@
|
||||
# Switch 模式
|
||||
|
||||
Go `switch` 语句的详细模式,包括无表达式 switch、逗号 case、break 行为和带标签的 break。
|
||||
|
||||
---
|
||||
|
||||
## 无自动 Fallthrough
|
||||
|
||||
Go `switch` 的 case 默认**不会** fall through(与 C/Java 不同)。每个 case 主体隐式地 break。仅在明确需要时使用 `fallthrough` — 这在惯用 Go 中很少见。
|
||||
|
||||
```go
|
||||
switch n {
|
||||
case 1:
|
||||
fmt.Println("one")
|
||||
// 无 fallthrough — 下一个 case 不会执行
|
||||
case 2:
|
||||
fmt.Println("two")
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 无表达式 Switch
|
||||
|
||||
没有表达式的 `switch` 对 `true` 进行 switch。在将单个变量与多个条件进行比较时,用它来替代 if-else-if 链:
|
||||
|
||||
```go
|
||||
func unhex(c byte) byte {
|
||||
switch {
|
||||
case '0' <= c && c <= '9':
|
||||
return c - '0'
|
||||
case 'a' <= c && c <= 'f':
|
||||
return c - 'a' + 10
|
||||
case 'A' <= c && c <= 'F':
|
||||
return c - 'A' + 10
|
||||
}
|
||||
return 0
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 逗号分隔的 Case
|
||||
|
||||
多个值可以使用逗号共享一个 case 主体 — 不需要 `fallthrough`:
|
||||
|
||||
```go
|
||||
func shouldEscape(c byte) bool {
|
||||
switch c {
|
||||
case ' ', '?', '&', '=', '#', '+', '%':
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 带标签的 Break
|
||||
|
||||
`switch` 中的 `break` 仅终止 switch,**不会**终止外层的 `for` 循环。使用标签来跳出循环:
|
||||
|
||||
```go
|
||||
Loop:
|
||||
for n := 0; n < len(src); n += size {
|
||||
switch {
|
||||
case src[n] < sizeOne:
|
||||
break // 仅中断 switch
|
||||
case src[n] < sizeTwo:
|
||||
if n+1 >= len(src) {
|
||||
break Loop // 跳出 for 循环
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
另一个常见模式 — 从 switch 内部中断 range 循环:
|
||||
|
||||
```go
|
||||
Loop:
|
||||
for _, v := range items {
|
||||
switch v.Type {
|
||||
case "done":
|
||||
break Loop // 中断 for 循环
|
||||
case "skip":
|
||||
break // 仅中断 switch
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**经验法则**:当 `for` 循环内有 `switch` 且需要从 case 中退出循环时,始终使用带标签的 break。
|
||||
|
||||
---
|
||||
|
||||
## 类型 Switch
|
||||
|
||||
关于类型 switch(`switch v := x.(type)`),参见 [go-interfaces](../../go-interfaces/SKILL.md):类型 Switch。
|
||||
|
||||
---
|
||||
|
||||
## 快速参考
|
||||
|
||||
| 模式 | 语法 |
|
||||
|------|------|
|
||||
| 无表达式 switch | `switch { case cond: }` |
|
||||
| 逗号 case | `case 'a', 'b', 'c':` |
|
||||
| 无 fallthrough | 默认行为;需要时使用 `fallthrough` 关键字 |
|
||||
| 仅中断 switch | case 内使用 `break` |
|
||||
| 中断外层循环 | 使用带标签的 `for` 和 `break Label` |
|
||||
@@ -1,140 +0,0 @@
|
||||
---
|
||||
name: go-data-structures
|
||||
description: Use when working with Go slices, maps, or arrays — choosing between new and make, using append, declaring empty slices (nil vs literal for JSON), implementing sets with maps, and copying data at boundaries. Also use when building or manipulating collections, even if the user doesn't ask about allocation idioms. Does not cover concurrent data structure safety (see go-concurrency).
|
||||
license: Apache-2.0
|
||||
metadata:
|
||||
sources: "Effective Go, Google Style Guide, Uber Style Guide, Go Wiki CodeReviewComments"
|
||||
---
|
||||
|
||||
# Go 数据结构
|
||||
|
||||
---
|
||||
|
||||
## 选择数据结构
|
||||
|
||||
```
|
||||
你需要什么?
|
||||
├─ 有序的元素集合
|
||||
│ ├─ 编译时已知固定大小 → 数组 [N]T
|
||||
│ └─ 动态大小 → 切片 []T
|
||||
│ ├─ 知道大概的大小?→ make([]T, 0, capacity)
|
||||
│ └─ 未知大小或需要 nil 安全的 JSON?→ var s []T (nil)
|
||||
├─ 键值查找
|
||||
│ └─ 映射 map[K]V
|
||||
│ ├─ 知道大概的大小?→ make(map[K]V, capacity)
|
||||
│ └─ 需要集合?→ map[T]struct{}(零大小值)
|
||||
└─ 需要传递给函数?
|
||||
└─ 如果调用者可能会修改它,则在边界处复制
|
||||
```
|
||||
|
||||
> **此技能不适用的场景**:对于数据结构的并发访问(互斥锁、原子操作),参见 [go-concurrency](../go-concurrency/SKILL.md)。对于 API 边界处的防御性复制,参见 [go-defensive](../go-defensive/SKILL.md)。对于为性能预分配容量,参见 [go-performance](../go-performance/SKILL.md)。
|
||||
|
||||
---
|
||||
|
||||
## 切片
|
||||
|
||||
### append 函数
|
||||
|
||||
**始终赋值结果** — 底层数组可能会改变:
|
||||
|
||||
```go
|
||||
x := []int{1, 2, 3}
|
||||
x = append(x, 4, 5, 6)
|
||||
|
||||
// 将切片追加到切片
|
||||
x = append(x, y...) // 注意 ...
|
||||
```
|
||||
|
||||
### 二维切片
|
||||
|
||||
**独立的内部切片**(可以独立增长/缩小):
|
||||
|
||||
```go
|
||||
picture := make([][]uint8, YSize)
|
||||
for i := range picture {
|
||||
picture[i] = make([]uint8, XSize)
|
||||
}
|
||||
```
|
||||
|
||||
**单次分配**(对于固定大小更高效):
|
||||
|
||||
```go
|
||||
picture := make([][]uint8, YSize)
|
||||
pixels := make([]uint8, XSize*YSize)
|
||||
for i := range picture {
|
||||
picture[i], pixels = pixels[:XSize], pixels[XSize:]
|
||||
}
|
||||
```
|
||||
|
||||
> 在调试意外的切片行为、跨 goroutine 共享切片或处理切片头时,阅读 [references/SLICES.md](references/SLICES.md)。
|
||||
|
||||
### 声明空切片
|
||||
|
||||
优先使用 nil 切片而非空字面量:
|
||||
|
||||
```go
|
||||
// 好:nil 切片
|
||||
var t []string
|
||||
|
||||
// 避免:非 nil 但零长度
|
||||
t := []string{}
|
||||
```
|
||||
|
||||
两者的 `len` 和 `cap` 都是零,但 nil 切片是首选风格。
|
||||
|
||||
**JSON 例外**:nil 切片编码为 `null`,而 `[]string{}` 编码为 `[]`。当需要 JSON 数组时使用非 nil。
|
||||
|
||||
在设计接口时,避免区分 nil 和非 nil 的零长度切片。
|
||||
|
||||
---
|
||||
|
||||
## 映射
|
||||
|
||||
### 实现集合
|
||||
|
||||
使用 `map[T]bool` — 惯用且阅读自然:
|
||||
|
||||
```go
|
||||
attended := map[string]bool{"Ann": true, "Joe": true}
|
||||
if attended[person] { // 不在映射中则为 false
|
||||
fmt.Println(person, "was at the meeting")
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 复制
|
||||
|
||||
从另一个包复制结构体时要小心。如果类型的方法定义在指针类型(`*T`)上,复制值可能导致别名 bug。
|
||||
|
||||
**通用规则:** 如果类型 `T` 的方法与指针类型 `*T` 关联,则不要复制 `T` 的值。这适用于 `bytes.Buffer`、`sync.Mutex`、`sync.WaitGroup` 以及包含它们的类型。
|
||||
|
||||
```go
|
||||
// 不好:复制互斥锁
|
||||
var mu sync.Mutex
|
||||
mu2 := mu // 几乎总是 bug
|
||||
|
||||
// 好:通过指针传递
|
||||
func increment(sc *SafeCounter) {
|
||||
sc.mu.Lock()
|
||||
sc.count++
|
||||
sc.mu.Unlock()
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 快速参考
|
||||
|
||||
| 主题 | 关键点 |
|
||||
|------|--------|
|
||||
| 切片 | 始终赋值 `append` 结果;`nil` 切片优于 `[]T{}` |
|
||||
| 集合 | `map[T]bool` 是惯用写法 |
|
||||
| 复制 | 如果方法在 `*T` 上则不要复制 `T`;注意别名问题 |
|
||||
|
||||
## 相关技能
|
||||
|
||||
- **防御性复制**:在 API 边界处复制切片或映射以防止修改时,参见 [go-defensive](../go-defensive/SKILL.md)
|
||||
- **容量提示**:为已知工作负载预分配切片或映射容量时,参见 [go-performance](../go-performance/SKILL.md)
|
||||
- **迭代模式**:在对切片、映射或通道使用 range 循环时,参见 [go-control-flow](../go-control-flow/SKILL.md)
|
||||
- **声明风格**:在 `new`、`make`、`var` 和复合字面量之间选择时,参见 [go-declarations](../go-declarations/SKILL.md)
|
||||
@@ -1,146 +0,0 @@
|
||||
# Go 切片内部原理
|
||||
|
||||
> **来源**:Effective Go
|
||||
|
||||
---
|
||||
|
||||
## 三项描述符
|
||||
|
||||
切片是一个运行时数据结构,包含三个组件:
|
||||
|
||||
- **指针**:第一个可访问元素的地址
|
||||
- **长度**:元素数量(`len(s)`)
|
||||
- **容量**:到底层数组末尾的最大元素数(`cap(s)`)
|
||||
|
||||
```go
|
||||
arr := [5]int{10, 20, 30, 40, 50}
|
||||
s := arr[1:4] // s = [20, 30, 40]
|
||||
// 指针:&arr[1],长度:3,容量:4
|
||||
```
|
||||
|
||||
`nil` 切片的三项均为零/nil。
|
||||
|
||||
---
|
||||
|
||||
## 切片引用底层数组
|
||||
|
||||
切片不存储数据 — 它们描述数组的一部分:
|
||||
|
||||
```go
|
||||
data := [4]int{1, 2, 3, 4}
|
||||
a := data[0:2] // [1, 2]
|
||||
b := data[1:3] // [2, 3]
|
||||
|
||||
b[0] = 99
|
||||
fmt.Println(a) // [1, 99] - 两者都看到变化
|
||||
fmt.Println(data) // [1, 99, 3, 4]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 切片运算符
|
||||
|
||||
`s[lo:hi]` 创建从索引 `lo` 到 `hi-1` 的切片:
|
||||
|
||||
```go
|
||||
s := []int{0, 1, 2, 3, 4, 5}
|
||||
s[2:4] // [2, 3]
|
||||
s[:3] // [0, 1, 2]
|
||||
s[3:] // [3, 4, 5]
|
||||
```
|
||||
|
||||
三索引形式 `s[lo:hi:max]` 将容量限制为 `max-lo`。
|
||||
|
||||
---
|
||||
|
||||
## 为什么 append 必须返回切片
|
||||
|
||||
切片头是**按值传递**的。函数可以修改元素但无法改变调用者的切片头:
|
||||
|
||||
```go
|
||||
func Append(slice, data []byte) []byte {
|
||||
l := len(slice)
|
||||
if l+len(data) > cap(slice) {
|
||||
newSlice := make([]byte, (l+len(data))*2)
|
||||
copy(newSlice, slice)
|
||||
slice = newSlice // 只改变局部变量
|
||||
}
|
||||
slice = slice[0 : l+len(data)]
|
||||
copy(slice[l:], data)
|
||||
return slice // 调用者必须接收新的切片头
|
||||
}
|
||||
```
|
||||
|
||||
当发生重新分配时,`slice` 指向新数组。调用者的原始引用仍指向旧数组 — 返回使调用者能够更新其引用。
|
||||
|
||||
---
|
||||
|
||||
## copy 函数
|
||||
|
||||
`copy(dst, src)` 复制元素并返回复制的数量:
|
||||
|
||||
```go
|
||||
src := []int{1, 2, 3, 4, 5}
|
||||
dst := make([]int, 3)
|
||||
n := copy(dst, src) // n=3, dst=[1,2,3]
|
||||
```
|
||||
|
||||
正确处理重叠切片。复制 `min(len(dst), len(src))` 个元素 — 不会发生重新分配。
|
||||
|
||||
---
|
||||
|
||||
## 切片常见陷阱
|
||||
|
||||
### 1. 共享底层数组
|
||||
|
||||
```go
|
||||
original := []int{1, 2, 3, 4, 5}
|
||||
subset := original[1:3]
|
||||
subset[0] = 99
|
||||
fmt.Println(original) // [1, 99, 3, 4, 5] - 被修改了!
|
||||
|
||||
// 修复:创建独立副本
|
||||
subset := make([]int, 2)
|
||||
copy(subset, original[1:3])
|
||||
```
|
||||
|
||||
### 2. append 可能重新分配也可能不
|
||||
|
||||
```go
|
||||
a := make([]int, 3, 5) // len=3, cap=5
|
||||
b := a[0:3]
|
||||
a = append(a, 4) // 在容量内 - 仍然共享
|
||||
a = append(a, 5, 6) // 超出容量 - 现在独立
|
||||
```
|
||||
|
||||
### 3. 大底层数组导致内存泄漏
|
||||
|
||||
```go
|
||||
// 不好:小切片将整个文件保留在内存中
|
||||
func getHeader(file []byte) []byte { return file[:100] }
|
||||
|
||||
// 好:复制以释放大数组
|
||||
func getHeader(file []byte) []byte {
|
||||
header := make([]byte, 100)
|
||||
copy(header, file)
|
||||
return header
|
||||
}
|
||||
```
|
||||
|
||||
### 4. nil vs 空切片
|
||||
|
||||
```go
|
||||
var nilSlice []int // nil, len=0, cap=0
|
||||
emptySlice := []int{} // 非 nil, len=0, cap=0
|
||||
// 两者在 len、cap、append、range 中表现相同
|
||||
// 未初始化状态优先使用 nil
|
||||
```
|
||||
|
||||
## 快速参考
|
||||
|
||||
| 操作 | 行为 |
|
||||
|------|------|
|
||||
| `s[lo:hi]` | 从 lo 到 hi-1 的切片 |
|
||||
| `s[lo:hi:max]` | 容量限制为 max-lo 的切片 |
|
||||
| `append(s, x...)` | 返回新切片;可能重新分配 |
|
||||
| `copy(dst, src)` | 返回复制数量;不重新分配 |
|
||||
@@ -1,188 +0,0 @@
|
||||
---
|
||||
name: go-defensive
|
||||
description: Use when hardening Go code at API boundaries — copying slices/maps, verifying interface compliance, using defer for cleanup, time.Time/time.Duration, or avoiding mutable globals. Also use when reviewing for robustness concerns like missing cleanup or unsafe crypto usage, even if the user doesn't mention "defensive programming." Does not cover error handling strategy (see go-error-handling).
|
||||
license: Apache-2.0
|
||||
compatibility: Uses crypto/rand.Text (Go 1.24+) in examples
|
||||
metadata:
|
||||
sources: "Effective Go, Uber Style Guide, Go Wiki CodeReviewComments"
|
||||
---
|
||||
|
||||
# Go 防御性编程模式
|
||||
|
||||
## 防御性检查清单优先级
|
||||
|
||||
在加固 API 边界代码时,按以下顺序检查:
|
||||
|
||||
```
|
||||
正在审查 API 边界?
|
||||
├─ 1. 错误处理 → 返回错误;不要 panic(参见 go-error-handling)
|
||||
├─ 2. 输入验证 → 复制从调用者接收的切片/map
|
||||
├─ 3. 输出安全 → 在返回给调用者之前复制切片/map
|
||||
├─ 4. 资源清理 → 使用 defer 进行 Close/Unlock/Cancel
|
||||
├─ 5. 接口检查 → var _ Interface = (*Type)(nil) 编译时验证
|
||||
├─ 6. 时间正确性 → 使用 time.Time 和 time.Duration,不要用 int/float
|
||||
├─ 7. 枚举安全 → iota 从 1 开始,使零值无效
|
||||
└─ 8. 加密安全 → 用 crypto/rand 生成密钥,绝不用 math/rand
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 快速参考
|
||||
|
||||
| 模式 | 规则 | 详情 |
|
||||
|------|------|------|
|
||||
| 边界复制 | 在接收和返回时复制切片/map | [BOUNDARY-COPYING.md](references/BOUNDARY-COPYING.md) |
|
||||
| Defer 清理 | 在 `os.Open` 之后立即 `defer f.Close()` | 见下文 |
|
||||
| 接口检查 | `var _ I = (*T)(nil)` | 参见 go-interfaces |
|
||||
| 时间类型 | `time.Time` / `time.Duration`,绝不用原始 int | [TIME-ENUMS-TAGS.md](references/TIME-ENUMS-TAGS.md) |
|
||||
| 枚举起始值 | `iota + 1` 使零值 = 无效 | 见下文 |
|
||||
| 加密随机数 | 用 `crypto/rand` 生成密钥,绝不用 `math/rand` | 见下文 |
|
||||
| Must 函数 | 仅在初始化时使用;失败时 panic | [MUST-FUNCTIONS.md](references/MUST-FUNCTIONS.md) |
|
||||
| Panic/recover | 绝不跨包暴露 panic | [PANIC-RECOVER.md](references/PANIC-RECOVER.md) |
|
||||
| 可变全局变量 | 用依赖注入替代 | 见下文 |
|
||||
|
||||
---
|
||||
|
||||
## 验证接口合规性
|
||||
|
||||
使用编译时检查来验证接口实现。完整模式请参见 **go-interfaces**:接口满足检查。
|
||||
|
||||
```go
|
||||
var _ http.Handler = (*Handler)(nil)
|
||||
```
|
||||
|
||||
## 在边界处复制切片和 Map
|
||||
|
||||
切片和 map 包含指向底层数据的指针。在 API 边界处复制,以防止意外修改。
|
||||
|
||||
```go
|
||||
// 接收:复制传入的切片
|
||||
d.trips = make([]Trip, len(trips))
|
||||
copy(d.trips, trips)
|
||||
|
||||
// 返回:在返回之前复制 map
|
||||
result := make(map[string]int, len(s.counters))
|
||||
for k, v := range s.counters { result[k] = v }
|
||||
```
|
||||
|
||||
> 在 API 边界处复制切片或 map,或决定何时需要防御性复制、何时可以跳过时,请阅读 [references/BOUNDARY-COPYING.md](references/BOUNDARY-COPYING.md)。
|
||||
|
||||
## 使用 Defer 清理资源
|
||||
|
||||
使用 `defer` 清理资源(文件、锁)。避免在多个返回路径中遗漏清理。
|
||||
|
||||
```go
|
||||
p.Lock()
|
||||
defer p.Unlock()
|
||||
|
||||
if p.count < 10 {
|
||||
return p.count
|
||||
}
|
||||
p.count++
|
||||
return p.count
|
||||
```
|
||||
|
||||
Defer 的开销可以忽略不计。在 `os.Open` 之后立即放置 `defer f.Close()` 以提高清晰度。延迟函数的参数在 `defer` 执行时求值,而非在函数运行时。多个 defer 按 LIFO 顺序执行。
|
||||
|
||||
## 结构体字段标签
|
||||
|
||||
> **建议**:始终为需要序列化或反序列化的结构体添加显式字段标签。
|
||||
|
||||
```go
|
||||
type User struct {
|
||||
Name string `json:"name" yaml:"name"`
|
||||
Email string `json:"email" yaml:"email"`
|
||||
}
|
||||
```
|
||||
|
||||
字段标签是**序列化契约**——重命名结构体字段而不更新标签会悄然破坏线格式兼容性。对于任何跨越序列化边界的类型,应将标签视为公共 API 的一部分。
|
||||
|
||||
## 枚举从 1 开始
|
||||
|
||||
枚举从非零值开始,以区分未初始化的值和有效值。
|
||||
|
||||
```go
|
||||
const (
|
||||
Add Operation = iota + 1 // Add=1,零值 = 未初始化
|
||||
Subtract
|
||||
Multiply
|
||||
)
|
||||
```
|
||||
|
||||
**例外**:当零值是合理的默认值时(例如 `LogToStdout = iota`)。
|
||||
|
||||
## 时间、结构体标签和嵌入
|
||||
|
||||
> 在使用 `time.Time`/`time.Duration` 代替原始 int、为序列化结构体添加字段标签,或决定是否在公共结构体中嵌入类型时,请阅读 [references/TIME-ENUMS-TAGS.md](references/TIME-ENUMS-TAGS.md)。
|
||||
|
||||
## 避免可变全局变量
|
||||
|
||||
通过注入依赖代替修改包级变量。这使代码可以在不需要全局 save/restore 的情况下进行测试。
|
||||
|
||||
```go
|
||||
type signer struct {
|
||||
now func() time.Time // 注入的;测试中用固定时间替换
|
||||
}
|
||||
|
||||
func newSigner() *signer {
|
||||
return &signer{now: time.Now}
|
||||
}
|
||||
```
|
||||
|
||||
> 在决定全局变量是否合适、设计 New() + Default() 包状态模式,或用依赖注入替代可变全局变量时,请阅读 [references/GLOBAL-STATE.md](references/GLOBAL-STATE.md)。
|
||||
|
||||
## 加密随机数
|
||||
|
||||
不要使用 `math/rand` 或 `math/rand/v2` 生成密钥——这是一个**安全问题**。时间种子的生成器输出是可预测的。
|
||||
|
||||
```go
|
||||
import "crypto/rand"
|
||||
|
||||
func Key() string { return rand.Text() }
|
||||
```
|
||||
|
||||
对于文本输出,直接使用 `crypto/rand.Text`,或用 `encoding/hex` 或 `encoding/base64` 编码随机字节。
|
||||
|
||||
---
|
||||
|
||||
## Panic 与 Recover
|
||||
|
||||
仅在真正不可恢复的情况下使用 `panic`。库函数应避免 panic。
|
||||
|
||||
```go
|
||||
func safelyDo(work *Work) {
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
log.Println("work failed:", err)
|
||||
}
|
||||
}()
|
||||
do(work)
|
||||
}
|
||||
```
|
||||
|
||||
**关键规则:**
|
||||
- 绝不跨包边界暴露 panic——始终转换为 error
|
||||
- 如果库确实无法在 `init()` 中完成初始化,可以接受 panic
|
||||
- 使用 recover 隔离服务器 goroutine 处理器中的 panic
|
||||
|
||||
> 在编写 HTTP 服务器中的 panic 恢复、在解析器中使用 panic 作为内部控制流机制,或在 log.Fatal 和 panic 之间做选择时,请阅读 [references/PANIC-RECOVER.md](references/PANIC-RECOVER.md)。
|
||||
|
||||
## Must 函数
|
||||
|
||||
`Must` 函数在出错时 panic——**仅**在程序初始化阶段使用,因为失败意味着程序无法运行。
|
||||
|
||||
```go
|
||||
var validID = regexp.MustCompile(`^[a-z][a-z0-9-]{0,62}$`)
|
||||
var tmpl = template.Must(template.ParseFiles("index.html"))
|
||||
```
|
||||
|
||||
> 在编写自定义 Must 函数、决定 Must 是否适用于特定调用点,或将可能失败的初始化包装在 panic 辅助函数中时,请阅读 [references/MUST-FUNCTIONS.md](references/MUST-FUNCTIONS.md)。
|
||||
|
||||
---
|
||||
|
||||
## 相关技能
|
||||
|
||||
- **错误处理**:在选择返回错误还是 panic,或在边界处包装错误时,参见 [go-error-handling](../go-error-handling/SKILL.md)
|
||||
- **并发安全**:在使用互斥锁、原子操作或通道保护共享状态时,参见 [go-concurrency](../go-concurrency/SKILL.md)
|
||||
- **接口检查**:在添加编译时接口满足检查(`var _ I = (*T)(nil)`)时,参见 [go-interfaces](../go-interfaces/SKILL.md)
|
||||
- **数据结构复制**:在处理切片/map 内部结构或指针别名时,参见 [go-data-structures](../go-data-structures/SKILL.md)
|
||||
@@ -1,101 +0,0 @@
|
||||
# 在 API 边界处复制切片和 Map
|
||||
|
||||
> **来源**:Uber 风格指南
|
||||
|
||||
切片和 map 包含对其底层数据的引用。在 API 边界处复制它们,以防止调用者修改内部状态(反之亦然)。
|
||||
|
||||
## 接收切片和 Map
|
||||
|
||||
当函数存储调用者传入的切片或 map 时,始终进行防御性复制。调用者保留原始引用,可以在函数返回后修改它。
|
||||
|
||||
### 切片
|
||||
|
||||
**不好**
|
||||
```go
|
||||
func (d *Driver) SetTrips(trips []Trip) {
|
||||
d.trips = trips // 调用者仍然可以修改 d.trips
|
||||
}
|
||||
```
|
||||
|
||||
**好**
|
||||
```go
|
||||
func (d *Driver) SetTrips(trips []Trip) {
|
||||
d.trips = make([]Trip, len(trips))
|
||||
copy(d.trips, trips)
|
||||
}
|
||||
```
|
||||
|
||||
### Map
|
||||
|
||||
**不好**
|
||||
```go
|
||||
func (s *Server) SetConfig(cfg map[string]string) {
|
||||
s.config = cfg // 调用者仍然可以修改 s.config
|
||||
}
|
||||
```
|
||||
|
||||
**好**
|
||||
```go
|
||||
func (s *Server) SetConfig(cfg map[string]string) {
|
||||
s.config = make(map[string]string, len(cfg))
|
||||
for k, v := range cfg {
|
||||
s.config[k] = v
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 返回切片和 Map
|
||||
|
||||
返回内部切片或 map 时,返回副本以防止调用者修改你的内部状态。
|
||||
|
||||
### 返回 Map
|
||||
|
||||
**不好**
|
||||
```go
|
||||
func (s *Stats) Snapshot() map[string]int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.counters // 暴露了内部状态!
|
||||
}
|
||||
```
|
||||
|
||||
**好**
|
||||
```go
|
||||
func (s *Stats) Snapshot() map[string]int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
result := make(map[string]int, len(s.counters))
|
||||
for k, v := range s.counters {
|
||||
result[k] = v
|
||||
}
|
||||
return result
|
||||
}
|
||||
```
|
||||
|
||||
### 返回切片
|
||||
|
||||
**不好**
|
||||
```go
|
||||
func (q *Queue) Items() []Item {
|
||||
return q.items // 调用者可以追加、修改或重新切片
|
||||
}
|
||||
```
|
||||
|
||||
**好**
|
||||
```go
|
||||
func (q *Queue) Items() []Item {
|
||||
result := make([]Item, len(q.items))
|
||||
copy(result, q.items)
|
||||
return result
|
||||
}
|
||||
```
|
||||
|
||||
## 何时不需要复制
|
||||
|
||||
防御性复制有开销。在以下情况下可以跳过:
|
||||
|
||||
- 数据**按约定是不可变的**,并且有清晰的文档说明
|
||||
- 切片/map 是**为调用者新创建的**(不在内部存储)
|
||||
- 性能分析表明复制在热路径中是瓶颈
|
||||
|
||||
如有疑问,就复制。与共享引用导致的 bug 相比,开销通常可以忽略不计。
|
||||
@@ -1,144 +0,0 @@
|
||||
# 全局状态模式
|
||||
|
||||
> **来源**:Google 风格指南, Effective Go
|
||||
|
||||
全局状态使程序更难以测试、推理和维护。依赖注入是首选替代方案,但某些全局状态在谨慎使用时是可以接受的。
|
||||
|
||||
## 何时可以接受全局状态
|
||||
|
||||
并非所有包级变量都有害。当全局状态是**真正进程级别的**且**不值得注入**时,它是合适的:
|
||||
|
||||
- **默认实例**——`http.DefaultClient`、`log.Default()`、`flag.CommandLine`
|
||||
- **一次编译的值**——包级别的 `regexp.MustCompile(...)`
|
||||
- **注册表**——`database/sql.Register`、`image.RegisterFormat`
|
||||
- **单例基础设施**——进程级别的指标收集器或追踪导出器
|
||||
|
||||
## 全局变量的试金石测试
|
||||
|
||||
在添加包级变量之前,请问自己:
|
||||
|
||||
1. **它是否真正是进程级别的?** 如果两个 goroutine 或测试可能需要不同的值,它不应该是全局的
|
||||
2. **它是否妨碍了测试?** 如果测试必须保存/恢复变量,或因此无法并行运行,应改为注入
|
||||
3. **它可以是常量吗?** 如果值在初始化后永远不会改变,优先使用 `const` 或未导出的只初始化一次的 `var`
|
||||
4. **它是否携带可变状态?** 可变全局变量是最危险的——仅在有完善文档、并发安全的单例情况下才可接受
|
||||
|
||||
## 包状态 API 模式:New() + Default()
|
||||
|
||||
标准库模式同时提供可定制的构造器和便捷的默认值。这使调用者可以在简单场景下使用默认值,在测试或特殊行为需求下注入自定义实例。
|
||||
|
||||
**好**
|
||||
```go
|
||||
package mylog
|
||||
|
||||
type Logger struct {
|
||||
prefix string
|
||||
out io.Writer
|
||||
}
|
||||
|
||||
func New(prefix string, out io.Writer) *Logger {
|
||||
return &Logger{prefix: prefix, out: out}
|
||||
}
|
||||
|
||||
var defaultLogger = New("", os.Stderr)
|
||||
|
||||
func Default() *Logger { return defaultLogger }
|
||||
|
||||
func (l *Logger) Info(msg string) {
|
||||
fmt.Fprintf(l.out, "%s%s\n", l.prefix, msg)
|
||||
}
|
||||
|
||||
// 包级便捷函数委托给默认实例。
|
||||
func Info(msg string) { defaultLogger.Info(msg) }
|
||||
```
|
||||
|
||||
```go
|
||||
// 调用者在简单场景下使用默认值
|
||||
mylog.Info("starting server")
|
||||
|
||||
// 测试或特殊代码创建自定义实例
|
||||
logger := mylog.New("[test] ", &buf)
|
||||
logger.Info("test message")
|
||||
```
|
||||
|
||||
此模式的标准库示例:
|
||||
- `log.New()` + `log.Default()` + `log.Println()`
|
||||
- `http.NewServeMux()` + `http.DefaultServeMux`
|
||||
- `flag.NewFlagSet()` + `flag.CommandLine`
|
||||
|
||||
## 依赖注入作为首选替代方案
|
||||
|
||||
当代码需要可配置行为时,通过构造器参数或结构体字段接受依赖,而非读取包级变量。
|
||||
|
||||
**不好**
|
||||
```go
|
||||
var db *sql.DB
|
||||
|
||||
func GetUser(id int) (*User, error) {
|
||||
return db.QueryRow("SELECT ...", id) // 依赖全局变量
|
||||
}
|
||||
```
|
||||
|
||||
**好**
|
||||
```go
|
||||
type UserStore struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func NewUserStore(db *sql.DB) *UserStore {
|
||||
return &UserStore{db: db}
|
||||
}
|
||||
|
||||
func (s *UserStore) GetUser(id int) (*User, error) {
|
||||
return s.db.QueryRow("SELECT ...", id)
|
||||
}
|
||||
```
|
||||
|
||||
注入的好处:
|
||||
- 测试可以提供 mock 或内存实现
|
||||
- 多个实例可以共存(例如,只读副本与主库)
|
||||
- 依赖在构造器签名中是显式的
|
||||
|
||||
## 注入时间
|
||||
|
||||
一个常见场景:替换 `time.Now` 以实现确定性测试。
|
||||
|
||||
**不好**
|
||||
```go
|
||||
func IsExpired(expiry time.Time) bool {
|
||||
return time.Now().After(expiry) // 不可测试
|
||||
}
|
||||
```
|
||||
|
||||
**好**
|
||||
```go
|
||||
type Checker struct {
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewChecker() *Checker {
|
||||
return &Checker{now: time.Now}
|
||||
}
|
||||
|
||||
func (c *Checker) IsExpired(expiry time.Time) bool {
|
||||
return c.now().After(expiry)
|
||||
}
|
||||
```
|
||||
|
||||
测试用固定函数替换 `now`:
|
||||
|
||||
```go
|
||||
c := &Checker{now: func() time.Time {
|
||||
return time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
}}
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
| 场景 | 方法 |
|
||||
|------|------|
|
||||
| 进程级单例(日志、指标) | 默认实例 + `New()` 构造器 |
|
||||
| 一次编译的正则或模板 | 包级 `var` 配合 `MustCompile` |
|
||||
| 注册表(数据库驱动、编解码器) | 包级 `Register()` 函数 |
|
||||
| 可配置行为 | 通过构造器进行依赖注入 |
|
||||
| 时间相关逻辑 | 注入 `func() time.Time` |
|
||||
| 测试需要变化的任何东西 | 不要使用全局状态 |
|
||||
@@ -1,92 +0,0 @@
|
||||
# Must 函数
|
||||
|
||||
> **来源**:Uber 风格指南, Go 标准库约定
|
||||
|
||||
`Must` 函数包装一个可能失败的函数,在出错时 panic。**仅**在程序初始化阶段使用,因为失败意味着程序无法运行。
|
||||
|
||||
## 标准库示例
|
||||
|
||||
```go
|
||||
// regexp.MustCompile 在模式无效时 panic
|
||||
var validID = regexp.MustCompile(`^[a-z][a-z0-9-]{0,62}$`)
|
||||
|
||||
// template.Must 在模板解析失败时 panic
|
||||
var tmpl = template.Must(template.ParseFiles("index.html"))
|
||||
```
|
||||
|
||||
这些是安全的,因为它们在包初始化时运行——如果失败,程序无法正确运行。
|
||||
|
||||
## 何时使用 Must
|
||||
|
||||
```
|
||||
这是在程序初始化期间调用的吗(包级 var、init、main 设置)?
|
||||
├─ 是 → 失败是否不可恢复(配置、正则、模板)?
|
||||
│ ├─ 是 → 使用 Must 是合适的
|
||||
│ └─ 否 → 改为返回 error
|
||||
└─ 否 → 绝不使用 Must——返回 error
|
||||
```
|
||||
|
||||
### 适当的使用场景
|
||||
|
||||
- **包级 `var`**:编译正则表达式、解析模板、加载必需的配置
|
||||
- **`init()` 或 `main()` 早期**:设置程序运行所必需的资源
|
||||
- **测试辅助函数**:测试中优先使用 `t.Fatal`,但 Must 在测试 fixture 中是可以接受的
|
||||
|
||||
### 绝不使用 Must 的场景
|
||||
|
||||
- 运行时请求处理
|
||||
- 用户提供的输入
|
||||
- 可能合理失败的网络或文件操作
|
||||
- 程序启动后调用的任何代码
|
||||
|
||||
## 编写 Must 函数
|
||||
|
||||
遵循命名约定 `MustX`,其中 `X` 是可能失败的函数名:
|
||||
|
||||
```go
|
||||
func MustParseConfig(path string) *Config {
|
||||
cfg, err := ParseConfig(path)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("parsing config %s: %v", path, err))
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
```
|
||||
|
||||
### 指南
|
||||
|
||||
- **命名**:`Must` 前缀 + 可能失败的函数名(例如 `MustParse`、`MustNew`、`MustCompile`)
|
||||
- **Panic 消息**:包含输入和错误信息以便调试
|
||||
- **文档**:始终记录函数在出错时会 panic
|
||||
|
||||
```go
|
||||
// MustParseConfig 解析路径处的配置文件。
|
||||
// 如果文件无法读取或包含无效配置,则会 panic。
|
||||
func MustParseConfig(path string) *Config { ... }
|
||||
```
|
||||
|
||||
### 泛型 Must 辅助函数
|
||||
|
||||
对于一次性使用,泛型 Must 辅助函数可以避免样板代码:
|
||||
|
||||
```go
|
||||
func Must[T any](v T, err error) T {
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// 在包级别使用
|
||||
var cfg = Must(ParseConfig("app.yaml"))
|
||||
```
|
||||
|
||||
## 与 Panic/Recover 的关系
|
||||
|
||||
Must 函数是对 `panic` 的受控使用。它们应该:
|
||||
|
||||
- 仅在初始化期间运行(因此不需要 recover)
|
||||
- 产生清晰、可操作的 panic 消息
|
||||
- 绝不在可以返回 error 的场景中使用
|
||||
|
||||
完整的 panic/recover 模式请参见 [PANIC-RECOVER.md](PANIC-RECOVER.md)。
|
||||
@@ -1,161 +0,0 @@
|
||||
# Panic 与 Recover 模式
|
||||
|
||||
> **来源**:Effective Go
|
||||
|
||||
## Panic 指南
|
||||
|
||||
`panic` 创建一个运行时错误来停止程序。仅在真正不可恢复的情况下使用。
|
||||
|
||||
### 何时 Panic
|
||||
|
||||
真正的库函数应**避免 panic**。如果问题可以被掩盖或绕过,让程序继续运行,而不是让整个程序崩溃。
|
||||
|
||||
```go
|
||||
// 可接受:真正不可能的情况
|
||||
func CubeRoot(x float64) float64 {
|
||||
z := x/3
|
||||
for i := 0; i < 1e6; i++ {
|
||||
prevz := z
|
||||
z -= (z*z*z-x) / (3*z*z)
|
||||
if veryClose(z, prevz) {
|
||||
return z
|
||||
}
|
||||
}
|
||||
// 百万次迭代仍未收敛;出了问题。
|
||||
panic(fmt.Sprintf("CubeRoot(%g) did not converge", x))
|
||||
}
|
||||
```
|
||||
|
||||
### 初始化中的 Panic
|
||||
|
||||
例外:如果库在 `init()` 期间确实无法完成初始化,panic 可能是合理的:
|
||||
|
||||
```go
|
||||
var user = os.Getenv("USER")
|
||||
|
||||
func init() {
|
||||
if user == "" {
|
||||
panic("no value for $USER")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 何时 Panic 是可接受的
|
||||
|
||||
除了初始化之外,panic 在以下窄泛场景中是可接受的:
|
||||
|
||||
1. **API 误用**——类似于核心语言对越界访问的 panic。`reflect` 包使用了这种方法。
|
||||
2. **带有匹配 `recover` 的内部实现细节**在包边界处。Panic 简化了深层嵌套的控制流,而公共 API 仍然返回 error(下方的 Parse/parseInt 模式)。
|
||||
3. **`panic("unreachable")`** 在 `log.Fatal` 之后,当编译器无法检测到不可达代码时。
|
||||
|
||||
#### Parse/parseInt 模式
|
||||
|
||||
在内部使用 panic 来回退复杂的递归,但始终在包边界处转换为 error:
|
||||
|
||||
```go
|
||||
func parseInt(in string) int {
|
||||
n, err := strconv.Atoi(in)
|
||||
if err != nil {
|
||||
panic(&syntaxError{"not a valid integer"})
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func Parse(in string) (_ *Node, err error) {
|
||||
defer func() {
|
||||
if p := recover(); p != nil {
|
||||
sErr, ok := p.(*syntaxError)
|
||||
if !ok {
|
||||
panic(p) // 不是我们的——重新 panic
|
||||
}
|
||||
err = fmt.Errorf("syntax error: %v", sErr.msg)
|
||||
}
|
||||
}()
|
||||
// ... 内部调用 parseInt
|
||||
}
|
||||
```
|
||||
|
||||
**关键**:类型检查 `p.(*syntaxError)` 确保只捕获*我们的* panic。意外的 panic(nil 指针等)正常传播。
|
||||
|
||||
---
|
||||
|
||||
## Recover 模式
|
||||
|
||||
`recover` 重新获得对正在 panic 的 goroutine 的控制。它只在延迟函数中有效。
|
||||
|
||||
### 基本恢复模式
|
||||
|
||||
```go
|
||||
func safelyDo(work *Work) {
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
log.Println("work failed:", err)
|
||||
}
|
||||
}()
|
||||
do(work)
|
||||
}
|
||||
```
|
||||
|
||||
### 服务器 Goroutine 保护
|
||||
|
||||
在服务器中将 panic 隔离到各个 goroutine:
|
||||
|
||||
```go
|
||||
func server(workChan <-chan *Work) {
|
||||
for work := range workChan {
|
||||
go safelyDo(work) // 每个 worker 都受保护
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
如果 `do(work)` panic,结果会被记录,goroutine 干净退出而不影响其他 goroutine。
|
||||
|
||||
### 包内部的 Panic/Recover
|
||||
|
||||
在内部使用 panic 但在 API 边界处转换为 error:
|
||||
|
||||
```go
|
||||
// Error 是一个解析错误类型
|
||||
type Error string
|
||||
func (e Error) Error() string { return string(e) }
|
||||
|
||||
// 内部:使用 Error 类型 panic
|
||||
func (regexp *Regexp) error(err string) {
|
||||
panic(Error(err))
|
||||
}
|
||||
|
||||
// 外部 API:将 panic 转换为 error 返回
|
||||
func Compile(str string) (regexp *Regexp, err error) {
|
||||
regexp = new(Regexp)
|
||||
defer func() {
|
||||
if e := recover(); e != nil {
|
||||
regexp = nil
|
||||
err = e.(Error) // 如果不是我们的 Error 类型则重新 panic
|
||||
}
|
||||
}()
|
||||
return regexp.doParse(str), nil
|
||||
}
|
||||
```
|
||||
|
||||
**要点:**
|
||||
|
||||
- 延迟函数可以修改命名返回值
|
||||
- 类型断言 `e.(Error)` 对意外错误类型重新 panic
|
||||
- 绝不向客户端暴露 panic——始终在 API 边界处转换
|
||||
|
||||
---
|
||||
|
||||
## 快速参考
|
||||
|
||||
| 模式 | 描述 |
|
||||
|------|------|
|
||||
| 基本恢复 | `defer func() { if err := recover(); err != nil { ... } }()` |
|
||||
| 服务器保护 | 将每个 goroutine 处理器包装在 safelyDo 中 |
|
||||
| 包内部 | 内部 panic,在 API 边界处 recover 并返回 error |
|
||||
| 类型安全恢复 | 使用类型断言对意外错误重新 panic |
|
||||
|
||||
## 何时使用
|
||||
|
||||
- **Panic**:仅用于真正不可恢复的情况或初始化失败
|
||||
- **Recover**:服务器处理器、包内部错误简化
|
||||
- **绝不**:跨包边界暴露 panic——始终转换为 error
|
||||
@@ -1,111 +0,0 @@
|
||||
# 时间、结构体标签和嵌入模式
|
||||
|
||||
## 使用 time.Time 和 time.Duration
|
||||
|
||||
始终使用 `time` 包。避免使用原始 `int` 表示时间值。
|
||||
|
||||
### 时间点
|
||||
|
||||
**不好**
|
||||
```go
|
||||
func isActive(now, start, stop int) bool {
|
||||
return start <= now && now < stop
|
||||
}
|
||||
```
|
||||
|
||||
**好**
|
||||
```go
|
||||
func isActive(now, start, stop time.Time) bool {
|
||||
return (start.Before(now) || start.Equal(now)) && now.Before(stop)
|
||||
}
|
||||
```
|
||||
|
||||
### 时长
|
||||
|
||||
**不好**
|
||||
```go
|
||||
func poll(delay int) {
|
||||
time.Sleep(time.Duration(delay) * time.Millisecond)
|
||||
}
|
||||
poll(10) // 秒?毫秒?
|
||||
```
|
||||
|
||||
**好**
|
||||
```go
|
||||
func poll(delay time.Duration) {
|
||||
time.Sleep(delay)
|
||||
}
|
||||
poll(10 * time.Second)
|
||||
```
|
||||
|
||||
### JSON 字段
|
||||
|
||||
当无法使用 `time.Duration` 时,在字段名中包含单位:
|
||||
|
||||
**不好**
|
||||
```go
|
||||
type Config struct {
|
||||
Interval int `json:"interval"`
|
||||
}
|
||||
```
|
||||
|
||||
**好**
|
||||
```go
|
||||
type Config struct {
|
||||
IntervalMillis int `json:"intervalMillis"`
|
||||
}
|
||||
```
|
||||
|
||||
## 避免在公共结构体中嵌入类型
|
||||
|
||||
嵌入类型会泄露实现细节并阻碍类型演进。
|
||||
|
||||
**不好**
|
||||
```go
|
||||
type ConcreteList struct {
|
||||
*AbstractList
|
||||
}
|
||||
```
|
||||
|
||||
**好**
|
||||
```go
|
||||
type ConcreteList struct {
|
||||
list *AbstractList
|
||||
}
|
||||
|
||||
func (l *ConcreteList) Add(e Entity) {
|
||||
l.list.Add(e)
|
||||
}
|
||||
|
||||
func (l *ConcreteList) Remove(e Entity) {
|
||||
l.list.Remove(e)
|
||||
}
|
||||
```
|
||||
|
||||
嵌入的问题:
|
||||
- 向嵌入接口添加方法是破坏性变更
|
||||
- 从嵌入结构体移除方法是破坏性变更
|
||||
- 替换嵌入类型是破坏性变更
|
||||
|
||||
## 在序列化结构体中使用字段标签
|
||||
|
||||
始终为 JSON、YAML 等使用显式字段标签。
|
||||
|
||||
**不好**
|
||||
```go
|
||||
type Stock struct {
|
||||
Price int
|
||||
Name string
|
||||
}
|
||||
```
|
||||
|
||||
**好**
|
||||
```go
|
||||
type Stock struct {
|
||||
Price int `json:"price"`
|
||||
Name string `json:"name"`
|
||||
// 可以安全地将 Name 重命名为 Symbol
|
||||
}
|
||||
```
|
||||
|
||||
标签使序列化契约显式化,并可以安全地进行重构。
|
||||
@@ -1,167 +0,0 @@
|
||||
---
|
||||
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)
|
||||
@@ -1,61 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
# 文档约定参考
|
||||
|
||||
## 参数和配置
|
||||
|
||||
> **建议**:记录容易出错或非显而易见的参数,而非所有参数。
|
||||
|
||||
```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)
|
||||
}
|
||||
```
|
||||
@@ -1,107 +0,0 @@
|
||||
# 包注释和示例参考
|
||||
|
||||
## 包注释
|
||||
|
||||
> **规范**:每个包必须有且仅有一个包注释。
|
||||
|
||||
```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)
|
||||
// ...
|
||||
}
|
||||
```
|
||||
@@ -1,85 +0,0 @@
|
||||
# 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 格式化是否正确渲染。
|
||||
@@ -1,298 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="1.0.0"
|
||||
SCRIPT_NAME="$(basename "$0")"
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
$SCRIPT_NAME v$VERSION — Check for missing doc comments on exported Go symbols
|
||||
|
||||
USAGE
|
||||
bash $SCRIPT_NAME [options] [path]
|
||||
|
||||
DESCRIPTION
|
||||
Scans Go source files for exported functions, types, methods, constants,
|
||||
and variables that lack doc comments. Go convention requires all exported
|
||||
symbols to have a doc comment starting with the symbol name.
|
||||
|
||||
Exits 0 if all exports are documented, 1 if undocumented exports found,
|
||||
2 on error.
|
||||
|
||||
OPTIONS
|
||||
-h, --help Show this help message
|
||||
-v, --version Show version
|
||||
--json Output results as JSON
|
||||
--strict Also check unexported types/functions with 5+ lines
|
||||
--limit N Show at most N results (default: all)
|
||||
|
||||
ARGUMENTS
|
||||
path Directory or file to check (default: ./...)
|
||||
|
||||
EXAMPLES
|
||||
bash $SCRIPT_NAME
|
||||
bash $SCRIPT_NAME ./pkg/api
|
||||
bash $SCRIPT_NAME --json .
|
||||
bash $SCRIPT_NAME --strict ./internal/server
|
||||
EOF
|
||||
}
|
||||
|
||||
JSON_OUTPUT=false
|
||||
STRICT=false
|
||||
LIMIT=0
|
||||
TARGET=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-h|--help) usage; exit 0 ;;
|
||||
-v|--version) echo "$SCRIPT_NAME v$VERSION"; exit 0 ;;
|
||||
--json) JSON_OUTPUT=true; shift ;;
|
||||
--strict) STRICT=true; shift ;;
|
||||
--limit) LIMIT="${2:?error: --limit requires a number}"; shift 2 ;;
|
||||
-*) echo "error: unknown option: $1" >&2; usage >&2; exit 2 ;;
|
||||
*) TARGET="$1"; shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
TARGET="${TARGET:-./...}"
|
||||
|
||||
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
|
||||
@@ -1,168 +0,0 @@
|
||||
---
|
||||
name: go-error-handling
|
||||
description: Use when writing Go code that returns, wraps, or handles errors — choosing between sentinel errors, custom types, and fmt.Errorf (%w vs %v), structuring error flow, or deciding whether to log or return. Also use when propagating errors across package boundaries or using errors.Is/As, even if the user doesn't ask about error strategy. Does not cover panic/recover patterns (see go-defensive).
|
||||
license: Apache-2.0
|
||||
compatibility: Requires Go 1.13+ for errors.Is/errors.As and fmt.Errorf %w wrapping. Structured logging examples use slog (Go 1.21+).
|
||||
metadata:
|
||||
sources: "Google Style Guide, Uber Style Guide"
|
||||
allowed-tools: Bash(bash:*)
|
||||
---
|
||||
|
||||
# Go 错误处理
|
||||
|
||||
## 可用脚本
|
||||
|
||||
- **`scripts/check-errors.sh`** — 检测错误处理反模式:对 `err.Error()` 进行字符串比较、没有上下文的裸 `return err`、以及日志并返回违规。运行 `bash scripts/check-errors.sh --help` 查看选项。
|
||||
|
||||
在 Go 中,[错误是值](https://go.dev/blog/errors-are-values) — 它们由代码创建,也由代码消费。
|
||||
|
||||
## 选择错误策略
|
||||
|
||||
1. 系统边界(RPC、IPC、存储)?→ 使用 `%v` 包装以避免泄露内部细节
|
||||
2. 调用者需要匹配特定条件?→ 哨兵或类型化错误,使用 `%w` 包装
|
||||
3. 调用者只需要调试上下文?→ `fmt.Errorf("...: %w", err)`
|
||||
4. 叶子函数,无需包装?→ 直接返回错误
|
||||
|
||||
**默认**:使用 `%w` 包装,并将其放在格式字符串的末尾。
|
||||
|
||||
---
|
||||
|
||||
## 核心规则
|
||||
|
||||
### 永不返回具体错误类型
|
||||
|
||||
**永不从导出函数返回具体错误类型** — 具体的 `nil` 指针可能变成非 nil 接口:
|
||||
|
||||
```go
|
||||
// 不好:具体类型可能导致微妙的 bug
|
||||
func Bad() *os.PathError { /*...*/ }
|
||||
|
||||
// 好:始终返回 error 接口
|
||||
func Good() error { /*...*/ }
|
||||
```
|
||||
|
||||
### 错误字符串
|
||||
|
||||
错误字符串**不应**大写,也**不应**以标点符号结尾。例外:导出名称、专有名词或缩写。
|
||||
|
||||
```go
|
||||
// 不好
|
||||
err := fmt.Errorf("Something bad happened.")
|
||||
|
||||
// 好
|
||||
err := fmt.Errorf("something bad happened")
|
||||
```
|
||||
|
||||
对于显示的消息(日志、测试失败、API 响应),大写是适当的。
|
||||
|
||||
### 出错时的返回值
|
||||
|
||||
当函数返回错误时,调用者必须将所有非错误返回值视为未指定,除非有明确文档说明。
|
||||
|
||||
**提示**:接受 `context.Context` 的函数通常应返回 `error`,以便调用者判断上下文是否被取消。
|
||||
|
||||
---
|
||||
|
||||
## 处理错误
|
||||
|
||||
遇到错误时,做出**深思熟虑的选择** — 不要用 `_` 丢弃:
|
||||
|
||||
1. **立即处理** — 解决错误并继续
|
||||
2. **返回给调用者** — 可选择用上下文包装
|
||||
3. **在特殊情况下** — `log.Fatal` 或 `panic`
|
||||
|
||||
有意忽略时:添加注释说明原因。
|
||||
|
||||
```go
|
||||
n, _ := b.Write(p) // 永不返回非 nil 错误
|
||||
```
|
||||
|
||||
对于相关的并发操作,使用 [`errgroup`](https://pkg.go.dev/golang.org/x/sync/errgroup):
|
||||
|
||||
```go
|
||||
g, ctx := errgroup.WithContext(ctx)
|
||||
g.Go(func() error { return task1(ctx) })
|
||||
g.Go(func() error { return task2(ctx) })
|
||||
if err := g.Wait(); err != nil { return err }
|
||||
```
|
||||
|
||||
### 避免带内错误
|
||||
|
||||
不要返回 `-1`、`nil` 或空字符串来表示错误。使用多返回值:
|
||||
|
||||
```go
|
||||
// 不好:带内错误值
|
||||
func Lookup(key string) int // 缺失时返回 -1
|
||||
|
||||
// 好:显式的 error 或 ok 值
|
||||
func Lookup(key string) (string, bool)
|
||||
```
|
||||
|
||||
这可以防止调用者写出 `Parse(Lookup(key))` — 它会导致编译时错误,因为 `Lookup(key)` 有 2 个输出。
|
||||
|
||||
---
|
||||
|
||||
## 错误流程
|
||||
|
||||
在正常代码之前处理错误。提前返回使正常路径保持无缩进:
|
||||
|
||||
```go
|
||||
// 好:错误优先,正常代码无缩进
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 正常代码
|
||||
```
|
||||
|
||||
**错误只处理一次** — 记录日志或返回,不要两者都做:
|
||||
|
||||
```
|
||||
遇到错误?
|
||||
├─ 调用者可以采取行动?→ 返回(通过 %w 附带上下文)
|
||||
├─ 在调用链顶部?→ 记录日志并处理
|
||||
└─ 都不是?→ 以适当级别记录日志,继续执行
|
||||
```
|
||||
|
||||
> 在组织复杂的错误流程、决定记录日志还是返回、实现一次处理模式、或选择结构化日志级别时,请阅读 [references/ERROR-FLOW.md](references/ERROR-FLOW.md)。
|
||||
|
||||
---
|
||||
|
||||
## 错误类型
|
||||
|
||||
> **建议**:推荐的最佳实践。
|
||||
|
||||
| 调用者需要匹配? | 消息类型 | 使用方式 |
|
||||
|-----------------|---------|---------|
|
||||
| 否 | 静态 | `errors.New("message")` |
|
||||
| 否 | 动态 | `fmt.Errorf("msg: %v", val)` |
|
||||
| 是 | 静态 | `var ErrFoo = errors.New("...")` |
|
||||
| 是 | 动态 | 自定义 `error` 类型 |
|
||||
|
||||
**默认**:使用 `fmt.Errorf("...: %w", err)` 包装。升级为哨兵以使用 `errors.Is()`,升级为自定义类型以使用 `errors.As()`。
|
||||
|
||||
> 在定义哨兵错误、创建自定义错误类型、或为包 API 选择错误策略时,请阅读 [references/ERROR-TYPES.md](references/ERROR-TYPES.md)。
|
||||
|
||||
---
|
||||
|
||||
## 错误包装
|
||||
|
||||
> **建议**:推荐的最佳实践。
|
||||
|
||||
- **使用 `%v`**:在系统边界、用于日志记录、隐藏内部细节
|
||||
- **使用 `%w`**:保留错误链以供 `errors.Is`/`errors.As` 使用
|
||||
|
||||
**关键规则**:将 `%w` 放在末尾。添加调用者没有的上下文。如果注释没有增加信息,直接返回 `err`。
|
||||
|
||||
> 在决定使用 %v 还是 %w、跨包边界包装错误、或添加上下文信息时,请阅读 [references/WRAPPING.md](references/WRAPPING.md)。
|
||||
|
||||
> **验证**:实现错误处理后,运行 `bash scripts/check-errors.sh` 检测常见的反模式。然后运行 `go vet ./...` 捕获其他问题。
|
||||
|
||||
---
|
||||
|
||||
## 相关技能
|
||||
|
||||
- **错误命名**:在命名哨兵错误(`ErrFoo`)或自定义错误类型时,参见 [go-naming](../go-naming/SKILL.md)
|
||||
- **测试错误**:在使用 `errors.Is`/`errors.As` 测试错误语义或编写错误检查辅助函数时,参见 [go-testing](../go-testing/SKILL.md)
|
||||
- **Panic 处理**:在决定 panic 还是返回错误、或编写 recover 守卫时,参见 [go-defensive](../go-defensive/SKILL.md)
|
||||
- **守卫子句**:在组织提前返回的错误流程或减少嵌套时,参见 [go-control-flow](../go-control-flow/SKILL.md)
|
||||
- **日志决策**:在选择日志级别、配置结构化日志、或决定日志消息中包含什么上下文时,参见 [go-logging](../go-logging/SKILL.md)
|
||||
@@ -1,153 +0,0 @@
|
||||
# 错误流程模式
|
||||
|
||||
错误流程、一次处理原则和日志决策的详细模式。
|
||||
|
||||
## 缩进错误流程
|
||||
|
||||
在继续正常代码之前先处理错误。这通过使读者能够快速找到正常路径来提高可读性。
|
||||
|
||||
```go
|
||||
// 好:错误处理优先,正常代码无缩进
|
||||
if err != nil {
|
||||
// 错误处理
|
||||
return // 或 continue 等
|
||||
}
|
||||
// 正常代码
|
||||
```
|
||||
|
||||
```go
|
||||
// 不好:正常代码隐藏在 else 子句中
|
||||
if err != nil {
|
||||
// 错误处理
|
||||
} else {
|
||||
// 正常代码因缩进看起来不自然
|
||||
}
|
||||
```
|
||||
|
||||
### 避免对长期使用的变量使用 if 初始化语句
|
||||
|
||||
如果变量在多行中使用,将声明移出:
|
||||
|
||||
```go
|
||||
// 好:声明与错误检查分开
|
||||
x, err := f()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 大量使用 x 的代码
|
||||
// 跨越多行
|
||||
```
|
||||
|
||||
```go
|
||||
// 不好:变量作用域限制在 else 块中,难以阅读
|
||||
if x, err := f(); err != nil {
|
||||
return err
|
||||
} else {
|
||||
// 大量使用 x 的代码
|
||||
// 跨越多行
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 错误只处理一次
|
||||
|
||||
当调用者收到错误时,应该**只处理一次**。选择一种响应方式:
|
||||
|
||||
1. **返回错误**(包装或原文)让调用者处理
|
||||
2. **记录日志并优雅降级**(不返回错误)
|
||||
3. **匹配并处理**特定错误情况,返回其他错误
|
||||
|
||||
**如果返回了错误,就不要自己记录日志** — 让调用者处理。对同一错误既记录日志又返回是最常见的"一次处理"违规,导致重复噪音,因为调用栈上层的调用者也会处理该错误。
|
||||
|
||||
```go
|
||||
// 不好:既记录日志又返回 — 导致日志噪音
|
||||
u, err := getUser(id)
|
||||
if err != nil {
|
||||
log.Printf("Could not get user %q: %v", id, err)
|
||||
return err // 调用者也会记录这个!
|
||||
}
|
||||
|
||||
// 好:包装并返回 — 让调用者决定如何处理
|
||||
u, err := getUser(id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get user %q: %w", id, err)
|
||||
}
|
||||
|
||||
// 好:记录日志并优雅降级(不返回错误)
|
||||
if err := emitMetrics(); err != nil {
|
||||
// 写入指标失败不应影响应用程序
|
||||
log.Printf("Could not emit metrics: %v", err)
|
||||
}
|
||||
// 继续执行...
|
||||
|
||||
// 好:匹配特定错误,返回其他错误
|
||||
tz, err := getUserTimeZone(id)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrUserNotFound) {
|
||||
// 用户不存在,使用 UTC
|
||||
tz = time.UTC
|
||||
} else {
|
||||
return fmt.Errorf("get user %q: %w", id, err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 记录日志 vs 返回错误
|
||||
|
||||
> 错误只处理一次 — 记录日志或返回,不要两者都做。
|
||||
|
||||
### 决策流程
|
||||
|
||||
```
|
||||
遇到错误?
|
||||
├─ 调用者可以采取行动?→ 返回错误(通过 %w 附带上下文)
|
||||
├─ 在调用链顶部?→ 记录日志并处理(返回 HTTP 状态码、退出等)
|
||||
└─ 都不是?→ 以适当级别记录日志并继续
|
||||
```
|
||||
|
||||
### 不要既记录日志又返回
|
||||
|
||||
```go
|
||||
// 不好:错误既被记录又被返回 — 在日志中出现两次
|
||||
func process(ctx context.Context, id string) error {
|
||||
result, err := fetch(ctx, id)
|
||||
if err != nil {
|
||||
log.Printf("failed to fetch %s: %v", id, err)
|
||||
return fmt.Errorf("fetching %s: %w", id, err)
|
||||
}
|
||||
return handle(result)
|
||||
}
|
||||
|
||||
// 好:带上下文返回 — 让调用者决定是否记录日志
|
||||
func process(ctx context.Context, id string) error {
|
||||
result, err := fetch(ctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("fetching %s: %w", id, err)
|
||||
}
|
||||
return handle(result)
|
||||
}
|
||||
```
|
||||
|
||||
### 结构化日志
|
||||
|
||||
在生产代码中,优先使用结构化日志(Go 1.21+ 的 `slog`,或 `log/slog` 兼容库)而非 `log.Printf`:
|
||||
|
||||
```go
|
||||
// 好:结构化字段可被机器解析
|
||||
slog.Error("fetch failed", "id", id, "err", err)
|
||||
|
||||
// 避免:非结构化的字符串插值
|
||||
log.Printf("fetch failed for %s: %v", id, err)
|
||||
```
|
||||
|
||||
### 日志级别
|
||||
|
||||
| 级别 | 使用场景 |
|
||||
|------|---------|
|
||||
| Error | 需要关注的可操作故障 |
|
||||
| Warn | 不需要立即处理的降级行为 |
|
||||
| Info | 关键生命周期事件(启动、关闭、配置加载) |
|
||||
| Debug | 开发期间有用的诊断细节 |
|
||||
@@ -1,151 +0,0 @@
|
||||
# 错误类型参考
|
||||
|
||||
本参考涵盖结构化错误类型、哨兵错误,以及如何为你的用例选择正确的错误类型。
|
||||
|
||||
---
|
||||
|
||||
## 错误结构
|
||||
|
||||
> 错误类型决策表在父技能中(SKILL.md § 错误类型)。
|
||||
> 本参考涵盖:扩展的代码示例、哨兵错误、使用 `errors.Is`/`errors.As` 进行错误检查,以及结构化错误类型。
|
||||
|
||||
**关键考虑因素**:
|
||||
|
||||
- 调用者是否需要使用 `errors.Is` 或 `errors.As` 来匹配错误?
|
||||
- 错误消息是静态的还是需要运行时值?
|
||||
- 导出的错误变量/类型将成为公共 API 的一部分
|
||||
|
||||
```go
|
||||
// 无需匹配,静态消息
|
||||
func Open() error {
|
||||
return errors.New("could not open")
|
||||
}
|
||||
|
||||
// 需要匹配,静态消息 - 导出哨兵
|
||||
var ErrCouldNotOpen = errors.New("could not open")
|
||||
|
||||
func Open() error {
|
||||
return ErrCouldNotOpen
|
||||
}
|
||||
|
||||
// 需要匹配,动态消息 - 使用自定义类型
|
||||
type NotFoundError struct {
|
||||
File string
|
||||
}
|
||||
|
||||
func (e *NotFoundError) Error() string {
|
||||
return fmt.Sprintf("file %q not found", e.File)
|
||||
}
|
||||
|
||||
func Open(file string) error {
|
||||
return &NotFoundError{File: file}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 哨兵错误
|
||||
|
||||
最简单的结构化错误是无参数化的全局值:
|
||||
|
||||
```go
|
||||
// 好:用于程序化检查的哨兵错误
|
||||
var (
|
||||
// ErrDuplicate 在该动物已被见过时发生。
|
||||
ErrDuplicate = errors.New("duplicate")
|
||||
|
||||
// ErrMarsupial 因为我们不支持有袋类动物。
|
||||
ErrMarsupial = errors.New("marsupials are not supported")
|
||||
)
|
||||
|
||||
func process(animal Animal) error {
|
||||
switch {
|
||||
case seen[animal]:
|
||||
return ErrDuplicate
|
||||
case marsupial(animal):
|
||||
return ErrMarsupial
|
||||
}
|
||||
seen[animal] = true
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 检查错误
|
||||
|
||||
对于直接比较(当错误未被包装时):
|
||||
|
||||
```go
|
||||
// 好:与哨兵直接比较
|
||||
switch err := process(an); err {
|
||||
case ErrDuplicate:
|
||||
return fmt.Errorf("feed %q: %v", an, err)
|
||||
case ErrMarsupial:
|
||||
alternate := an.BackupAnimal()
|
||||
return handlePet(alternate)
|
||||
}
|
||||
```
|
||||
|
||||
当错误可能被包装时,使用 `errors.Is`:
|
||||
|
||||
```go
|
||||
// 好:适用于被包装的错误
|
||||
switch err := process(an); {
|
||||
case errors.Is(err, ErrDuplicate):
|
||||
return fmt.Errorf("feed %q: %v", an, err)
|
||||
case errors.Is(err, ErrMarsupial):
|
||||
// 尝试恢复...
|
||||
}
|
||||
```
|
||||
|
||||
**绝不**基于字符串内容匹配错误:
|
||||
|
||||
```go
|
||||
// 不好:脆弱的字符串匹配
|
||||
if regexp.MatchString(`duplicate`, err.Error()) {...}
|
||||
if regexp.MatchString(`marsupial`, err.Error()) {...}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 结构化错误类型
|
||||
|
||||
对于需要额外程序化信息的错误,使用结构体类型:
|
||||
|
||||
```go
|
||||
// 好:具有可访问字段的结构化错误
|
||||
type PathError struct {
|
||||
Op string
|
||||
Path string
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *PathError) Error() string {
|
||||
return e.Op + " " + e.Path + ": " + e.Err.Error()
|
||||
}
|
||||
|
||||
func (e *PathError) Unwrap() error { return e.Err }
|
||||
```
|
||||
|
||||
调用者可以使用 `errors.As` 提取结构化错误:
|
||||
|
||||
```go
|
||||
var pathErr *os.PathError
|
||||
if errors.As(err, &pathErr) {
|
||||
fmt.Println("Failed path:", pathErr.Path)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 快速参考
|
||||
|
||||
| 场景 | 错误类型 |
|
||||
|------|---------|
|
||||
| 无需匹配,静态消息 | `errors.New("message")` |
|
||||
| 无需匹配,动态消息 | `fmt.Errorf("msg: %v", val)` |
|
||||
| 需要匹配,静态消息 | `var ErrFoo = errors.New(...)` |
|
||||
| 需要匹配,动态消息 | 自定义结构体类型 |
|
||||
| 检查哨兵错误 | `errors.Is(err, ErrFoo)` |
|
||||
| 提取结构化错误 | `errors.As(err, &target)` |
|
||||
@@ -1,174 +0,0 @@
|
||||
# 错误包装参考
|
||||
|
||||
本参考涵盖使用 `%v` vs `%w` 的错误包装、放置约定、向错误添加上下文以及日志最佳实践。
|
||||
|
||||
---
|
||||
|
||||
## 包装错误:%v vs %w
|
||||
|
||||
> **建议**:推荐的最佳实践。
|
||||
|
||||
`%v` 和 `%w` 的选择会显著影响错误的传播和检查方式。
|
||||
|
||||
### 使用 %v 进行简单注释
|
||||
|
||||
当你需要以下操作时使用 `%v`:
|
||||
|
||||
- 添加上下文但不保留错误链以供程序化检查
|
||||
- 创建全新的、独立的错误(特别是在 RPC/IPC 等系统边界)
|
||||
- 向人类记录或显示错误
|
||||
|
||||
```go
|
||||
// 好:%v 在系统边界 — 隐藏内部细节
|
||||
func (s *Server) SuggestFortune(ctx context.Context, req *pb.Request) (*pb.Response, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("couldn't find fortune database: %v", err)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 使用 %w 保留错误链
|
||||
|
||||
当你需要调用者以编程方式检查底层错误时使用 `%w`:
|
||||
|
||||
```go
|
||||
// 好:%w 保留错误链以供 errors.Is/errors.As 使用
|
||||
func (s *Server) internalFunction(ctx context.Context) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("couldn't find remote file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 调用者现在可以检查:
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
// 处理未找到的情况
|
||||
}
|
||||
```
|
||||
|
||||
### 何时使用哪种
|
||||
|
||||
**使用 %w 的场景**:
|
||||
- 在添加上下文的同时保留原始错误以供程序化检查
|
||||
- 你明确记录并测试了所暴露的底层错误
|
||||
|
||||
**使用 %v 的场景**:
|
||||
- 在系统边界(RPC、IPC、存储)转换为规范错误空间
|
||||
- 向人类记录日志或显示
|
||||
- 创建隐藏实现细节的独立错误
|
||||
|
||||
---
|
||||
|
||||
## %w 的放置位置
|
||||
|
||||
> **建议**:推荐的最佳实践。
|
||||
|
||||
将 `%w` 放在错误字符串的**末尾**,使错误文本反映错误链结构:
|
||||
|
||||
```go
|
||||
// 好:%w 在末尾 — 从最新到最旧打印
|
||||
err1 := fmt.Errorf("err1")
|
||||
err2 := fmt.Errorf("err2: %w", err1)
|
||||
err3 := fmt.Errorf("err3: %w", err2)
|
||||
fmt.Println(err3) // err3: err2: err1
|
||||
```
|
||||
|
||||
```go
|
||||
// 不好:%w 在开头 — 从最旧到最新打印(令人困惑)
|
||||
err1 := fmt.Errorf("err1")
|
||||
err2 := fmt.Errorf("%w: err2", err1)
|
||||
err3 := fmt.Errorf("%w: err3", err2)
|
||||
fmt.Println(err3) // err1: err2: err3
|
||||
```
|
||||
|
||||
```go
|
||||
// 不好:%w 在中间 — 不连贯的顺序
|
||||
err1 := fmt.Errorf("err1")
|
||||
err2 := fmt.Errorf("err2-1 %w err2-2", err1)
|
||||
err3 := fmt.Errorf("err3-1 %w err3-2", err2)
|
||||
fmt.Println(err3) // err3-1 err2-1 err1 err2-2 err3-2
|
||||
```
|
||||
|
||||
**模式**:使用 `context message: %w` 的形式
|
||||
|
||||
---
|
||||
|
||||
## 向错误添加信息
|
||||
|
||||
> **建议**:推荐的最佳实践。
|
||||
|
||||
### 添加上下文,而非冗余
|
||||
|
||||
添加你拥有但调用者/被调用者可能没有的信息。避免重复底层错误已提供的信息:
|
||||
|
||||
```go
|
||||
// 好:添加有意义的上下文
|
||||
if err := os.Open("settings.txt"); err != nil {
|
||||
return fmt.Errorf("launch codes unavailable: %v", err)
|
||||
}
|
||||
// 输出:launch codes unavailable: open settings.txt: no such file or directory
|
||||
```
|
||||
|
||||
```go
|
||||
// 不好:重复了文件名
|
||||
if err := os.Open("settings.txt"); err != nil {
|
||||
return fmt.Errorf("could not open settings.txt: %v", err)
|
||||
}
|
||||
// 输出:could not open settings.txt: open settings.txt: no such file or directory
|
||||
```
|
||||
|
||||
### 不要无目的地注释
|
||||
|
||||
如果注释仅表示失败而没有添加信息,直接返回错误:
|
||||
|
||||
```go
|
||||
// 不好:注释没有增加信息
|
||||
return fmt.Errorf("failed: %v", err)
|
||||
|
||||
// 好:直接返回错误
|
||||
return err
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 记录错误日志
|
||||
|
||||
> **建议**:推荐的最佳实践。
|
||||
|
||||
当需要记录错误时,使用 `log/slog`(Go 1.21+)配合结构化键值对和适当的日志级别:
|
||||
|
||||
- **`slog.Error`**:保留用于需要调查的可操作问题。
|
||||
- **`slog.Warn`**:用于可能需要关注但不可立即操作的问题。
|
||||
- **`slog.Debug`**:用于开发追踪 — 仅在 handler 级别设为 `LevelDebug` 时才输出。
|
||||
|
||||
```go
|
||||
// 好:使用适当级别的结构化日志
|
||||
for _, q := range queries {
|
||||
slog.Debug("handling query", "query", q)
|
||||
q.Run()
|
||||
}
|
||||
|
||||
// 好:在级别检查后保护昂贵的格式化操作
|
||||
if slog.Default().Enabled(context.Background(), slog.LevelDebug) {
|
||||
slog.Debug("query plan", "explain", q.Explain())
|
||||
}
|
||||
|
||||
// 不好:即使禁用了 debug 日志也会执行昂贵的调用
|
||||
slog.Debug("query plan", "explain", q.Explain())
|
||||
```
|
||||
|
||||
### 保护敏感信息
|
||||
|
||||
注意日志消息中的 PII(个人身份信息)。许多日志接收器不适合存放敏感用户数据。
|
||||
|
||||
---
|
||||
|
||||
## 快速参考
|
||||
|
||||
| 模式 | 指导 |
|
||||
|------|------|
|
||||
| `%v` | 在系统边界使用、用于日志记录、隐藏细节 |
|
||||
| `%w` | 保留错误链以供程序化检查 |
|
||||
| `%w` 放置 | 始终在末尾:`"context: %w"` |
|
||||
| 添加上下文 | 添加新信息,不要重复现有信息 |
|
||||
| 空注释 | 直接返回 `err` 而非 `fmt.Errorf("failed: %v", err)` |
|
||||
| 日志 | 不要既记录日志又返回;使用适当的日志级别 |
|
||||
@@ -1,266 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="1.0.0"
|
||||
SCRIPT_NAME="$(basename "$0")"
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
$SCRIPT_NAME v$VERSION — Check Go code for common error handling anti-patterns
|
||||
|
||||
USAGE
|
||||
bash $SCRIPT_NAME [options] [path]
|
||||
|
||||
DESCRIPTION
|
||||
Scans Go source files for error handling anti-patterns:
|
||||
- err.Error() used in string comparison (should use errors.Is/As)
|
||||
- Bare 'return err' without wrapping context
|
||||
- Errors that are both logged and returned (handle once)
|
||||
|
||||
Exits 0 if no issues found, 1 if anti-patterns detected, 2 on error.
|
||||
|
||||
OPTIONS
|
||||
-h, --help Show this help message
|
||||
-v, --version Show version
|
||||
--json Output results as JSON
|
||||
--no-bare-return Skip the bare 'return err' check (high false-positive rate)
|
||||
--limit N Show at most N results (default: all)
|
||||
|
||||
ARGUMENTS
|
||||
path Directory or file to check (default: current directory)
|
||||
|
||||
EXAMPLES
|
||||
bash $SCRIPT_NAME
|
||||
bash $SCRIPT_NAME ./pkg/api
|
||||
bash $SCRIPT_NAME --json .
|
||||
bash $SCRIPT_NAME --no-bare-return ./internal
|
||||
EOF
|
||||
}
|
||||
|
||||
JSON_OUTPUT=false
|
||||
CHECK_BARE_RETURN=true
|
||||
LIMIT=0
|
||||
TARGET=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-h|--help) usage; exit 0 ;;
|
||||
-v|--version) echo "$SCRIPT_NAME v$VERSION"; exit 0 ;;
|
||||
--json) JSON_OUTPUT=true; shift ;;
|
||||
--no-bare-return) CHECK_BARE_RETURN=false; shift ;;
|
||||
--limit) LIMIT="${2:?error: --limit requires a number}"; shift 2 ;;
|
||||
-*) echo "error: unknown option: $1" >&2; usage >&2; exit 2 ;;
|
||||
*) TARGET="$1"; shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
TARGET="${TARGET:-.}"
|
||||
|
||||
json_escape() {
|
||||
local s="$1"
|
||||
s="${s//\\/\\\\}"
|
||||
s="${s//\"/\\\"}"
|
||||
s="${s//$'\t'/\\t}"
|
||||
s="${s//$'\r'/}"
|
||||
s="${s//$'\n'/\\n}"
|
||||
printf '%s' "$s"
|
||||
}
|
||||
|
||||
find_go_files() {
|
||||
local t="$1"
|
||||
if [[ -f "$t" ]]; then
|
||||
echo "$t"
|
||||
elif [[ -d "$t" ]]; then
|
||||
find "$t" -name '*.go' ! -name '*_test.go' ! -path '*/vendor/*' ! -path '*/.git/*' 2>/dev/null
|
||||
else
|
||||
local dir="${t%%/...}"
|
||||
dir="${dir:-.}"
|
||||
if [[ -d "$dir" ]]; then
|
||||
find "$dir" -name '*.go' ! -name '*_test.go' ! -path '*/vendor/*' ! -path '*/.git/*' 2>/dev/null
|
||||
else
|
||||
echo "error: path not found: $t" >&2
|
||||
exit 2
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
FINDINGS=()
|
||||
|
||||
add_finding() {
|
||||
local file="$1" line="$2" rule="$3" message="$4"
|
||||
FINDINGS+=("${file}:${line}|${rule}|${message}")
|
||||
}
|
||||
|
||||
# Rule 1: err.Error() in string comparison
|
||||
check_string_error_comparison() {
|
||||
local file="$1"
|
||||
local line_num=0
|
||||
while IFS= read -r line; do
|
||||
line_num=$((line_num + 1))
|
||||
|
||||
# Pattern: err.Error() == "..." or err.Error() != "..."
|
||||
pat='\.Error\(\)[[:space:]]*(==|!=)[[:space:]]*\"'
|
||||
if [[ "$line" =~ $pat ]]; then
|
||||
add_finding "$file" "$line_num" "string-error-compare" \
|
||||
"comparing err.Error() to string; use errors.Is() or errors.As() instead"
|
||||
fi
|
||||
|
||||
# Pattern: strings.Contains(err.Error(), "...")
|
||||
pat_contains='strings\.Contains\(.*\.Error\(\)'
|
||||
if [[ "$line" =~ $pat_contains ]]; then
|
||||
add_finding "$file" "$line_num" "string-error-compare" \
|
||||
"using strings.Contains on err.Error(); use errors.Is() or errors.As() instead"
|
||||
fi
|
||||
|
||||
# Pattern: "..." == err.Error()
|
||||
pat='\"[^\"]*\"[[:space:]]*(==|!=)[[:space:]]*[a-zA-Z_][a-zA-Z0-9_]*\.Error\(\)'
|
||||
if [[ "$line" =~ $pat ]]; then
|
||||
add_finding "$file" "$line_num" "string-error-compare" \
|
||||
"comparing string to err.Error(); use errors.Is() or errors.As() instead"
|
||||
fi
|
||||
done < "$file"
|
||||
}
|
||||
|
||||
# Rule 2: Bare return err (no wrapping)
|
||||
check_bare_return_err() {
|
||||
local file="$1"
|
||||
local line_num=0
|
||||
local in_error_block=false
|
||||
|
||||
while IFS= read -r line; do
|
||||
line_num=$((line_num + 1))
|
||||
|
||||
# Detect if err != nil { block
|
||||
pat='if[[:space:]]+(.*err[[:space:]]*(!=|==)[[:space:]]*nil|err[[:space:]]*:=)'
|
||||
if [[ "$line" =~ $pat ]]; then
|
||||
in_error_block=true
|
||||
fi
|
||||
|
||||
# Check for bare "return err" that is not wrapped
|
||||
pat='^[[:space:]]*return[[:space:]]+(.*,)?[[:space:]]*err[[:space:]]*$'
|
||||
if $in_error_block && [[ "$line" =~ $pat ]]; then
|
||||
# Exclude single-line functions and main error handlers
|
||||
# Only flag if the return is just "err" (not fmt.Errorf wrapped)
|
||||
local trimmed
|
||||
trimmed=$(echo "$line" | sed 's/^[[:space:]]*//')
|
||||
if [[ "$trimmed" == "return err" ]]; then
|
||||
add_finding "$file" "$line_num" "bare-return-err" \
|
||||
"bare 'return err' without wrapping context; consider fmt.Errorf('...: %w', err)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Reset error block tracking on closing brace at same indentation
|
||||
pat_close='^[[:space:]]*\}[[:space:]]*$'
|
||||
if $in_error_block && [[ "$line" =~ $pat_close ]]; then
|
||||
in_error_block=false
|
||||
fi
|
||||
done < "$file"
|
||||
}
|
||||
|
||||
# Rule 3: Log-and-return (handle errors once)
|
||||
check_log_and_return() {
|
||||
local file="$1"
|
||||
local line_num=0
|
||||
local prev_lines=()
|
||||
|
||||
while IFS= read -r line; do
|
||||
line_num=$((line_num + 1))
|
||||
prev_lines+=("$line")
|
||||
|
||||
# Keep a small window to detect log followed by return err
|
||||
if [[ ${#prev_lines[@]} -gt 5 ]]; then
|
||||
prev_lines=("${prev_lines[@]:1}")
|
||||
fi
|
||||
|
||||
# Check if current line is 'return ... err' and a recent line logged the error
|
||||
pat='^[[:space:]]*return[[:space:]]+(.*,)?[[:space:]]*err'
|
||||
if [[ "$line" =~ $pat ]]; then
|
||||
local window_size=${#prev_lines[@]}
|
||||
for ((i=0; i<window_size-1; i++)); do
|
||||
local prev="${prev_lines[$i]}"
|
||||
# Match log.Print/Printf/Println/Error/Errorf/Warn/Warnf with err
|
||||
pat_log1='(log\.|logger\.|slog\.)[a-zA-Z]*\(.*[^a-zA-Z]err[^a-zA-Z]'
|
||||
pat_log2='(log\.|logger\.|slog\.)[a-zA-Z]*\(err[,\)]'
|
||||
if [[ "$prev" =~ $pat_log1 ]] || \
|
||||
[[ "$prev" =~ $pat_log2 ]]; then
|
||||
local log_line=$((line_num - window_size + 1 + i))
|
||||
add_finding "$file" "$log_line" "log-and-return" \
|
||||
"error is both logged (line $log_line) and returned (line $line_num); handle errors once"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
done < "$file"
|
||||
}
|
||||
|
||||
FILES=()
|
||||
while IFS= read -r f; do
|
||||
[[ -n "$f" ]] && FILES+=("$f")
|
||||
done < <(find_go_files "$TARGET")
|
||||
|
||||
if [[ ${#FILES[@]} -eq 0 ]]; then
|
||||
if $JSON_OUTPUT; then
|
||||
echo '{"findings":[],"count":0,"status":"no_go_files"}'
|
||||
else
|
||||
echo "No Go files found in: $TARGET"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
for file in "${FILES[@]}"; do
|
||||
check_string_error_comparison "$file"
|
||||
if $CHECK_BARE_RETURN; then
|
||||
check_bare_return_err "$file"
|
||||
fi
|
||||
check_log_and_return "$file"
|
||||
done
|
||||
|
||||
# Truncation
|
||||
TOTAL=${#FINDINGS[@]}
|
||||
TRUNCATED=false
|
||||
if [[ $LIMIT -gt 0 && $TOTAL -gt $LIMIT ]]; then
|
||||
FINDINGS=("${FINDINGS[@]:0:$LIMIT}")
|
||||
TRUNCATED=true
|
||||
fi
|
||||
|
||||
if $JSON_OUTPUT; then
|
||||
echo "{"
|
||||
echo ' "findings": ['
|
||||
first=true
|
||||
for entry in "${FINDINGS[@]+"${FINDINGS[@]}"}"; do
|
||||
IFS='|' read -r location rule message <<< "$entry"
|
||||
file="${location%%:*}"
|
||||
line="${location#*:}"
|
||||
$first || echo ","
|
||||
first=false
|
||||
printf ' {"file":"%s","line":%s,"rule":"%s","message":"%s"}' \
|
||||
"$(json_escape "$file")" "$line" "$(json_escape "$rule")" "$(json_escape "$message")"
|
||||
done
|
||||
echo ""
|
||||
echo " ],"
|
||||
printf ' "total": %d,\n' "$TOTAL"
|
||||
printf ' "truncated": %s\n' "$TRUNCATED"
|
||||
echo "}"
|
||||
else
|
||||
if [[ $TOTAL -eq 0 ]]; then
|
||||
echo "No error handling anti-patterns found."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Error handling anti-patterns found:"
|
||||
echo ""
|
||||
for entry in "${FINDINGS[@]}"; do
|
||||
IFS='|' read -r location rule message <<< "$entry"
|
||||
printf " %s [%s] %s\n" "$location" "$rule" "$message"
|
||||
done
|
||||
if $TRUNCATED; then
|
||||
echo " ... and $((TOTAL - LIMIT)) more (use --limit to adjust)"
|
||||
fi
|
||||
echo ""
|
||||
echo "Total: $TOTAL finding(s)"
|
||||
fi
|
||||
|
||||
if [[ $TOTAL -gt 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
@@ -1,210 +0,0 @@
|
||||
---
|
||||
name: go-functional-options
|
||||
description: Use when designing a Go constructor or factory function with optional configuration — especially with 3+ optional parameters or extensible APIs. Also use when building a New* function that takes many settings, even if they don't mention "functional options" by name. Does not cover general function design (see go-functions).
|
||||
license: Apache-2.0
|
||||
metadata:
|
||||
sources: "Uber Style Guide"
|
||||
---
|
||||
|
||||
# 函数式选项模式
|
||||
|
||||
函数式选项是一种模式,你声明一个不透明的 `Option` 类型,在内部结构体中记录信息。构造函数接受可变数量的这些选项并将其应用于配置结果。
|
||||
|
||||
## 何时使用
|
||||
|
||||
在以下情况使用函数式选项:
|
||||
|
||||
- 构造函数或公共 API 上有 **3 个以上可选参数**
|
||||
- **可扩展 API**,可能随时间增加新选项
|
||||
- **良好的调用者体验**很重要(无需传递默认值)
|
||||
|
||||
## 模式
|
||||
|
||||
### 核心组件
|
||||
|
||||
1. **未导出的 `options` 结构体** - 保存所有配置
|
||||
2. **导出的 `Option` 接口** - 带有未导出的 `apply` 方法
|
||||
3. **Option 类型** - 实现接口
|
||||
4. **`With*` 构造函数** - 创建选项
|
||||
|
||||
### Option 接口
|
||||
|
||||
```go
|
||||
type Option interface {
|
||||
apply(*options)
|
||||
}
|
||||
```
|
||||
|
||||
未导出的 `apply` 方法确保只能使用来自本包的选项。
|
||||
|
||||
## 完整实现
|
||||
|
||||
```go
|
||||
package db
|
||||
|
||||
import "go.uber.org/zap"
|
||||
|
||||
// options 保存打开连接的所有配置。
|
||||
type options struct {
|
||||
cache bool
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// Option 配置我们如何打开连接。
|
||||
type Option interface {
|
||||
apply(*options)
|
||||
}
|
||||
|
||||
// cacheOption 为缓存设置实现 Option(简单类型别名)。
|
||||
type cacheOption bool
|
||||
|
||||
func (c cacheOption) apply(opts *options) {
|
||||
opts.cache = bool(c)
|
||||
}
|
||||
|
||||
// WithCache 启用或禁用缓存。
|
||||
func WithCache(c bool) Option {
|
||||
return cacheOption(c)
|
||||
}
|
||||
|
||||
// loggerOption 为日志设置实现 Option(用于指针的结构体)。
|
||||
type loggerOption struct {
|
||||
Log *zap.Logger
|
||||
}
|
||||
|
||||
func (l loggerOption) apply(opts *options) {
|
||||
opts.logger = l.Log
|
||||
}
|
||||
|
||||
// WithLogger 设置连接的日志记录器。
|
||||
func WithLogger(log *zap.Logger) Option {
|
||||
return loggerOption{Log: log}
|
||||
}
|
||||
|
||||
// Open 创建一个连接。
|
||||
func Open(addr string, opts ...Option) (*Connection, error) {
|
||||
// 从默认值开始
|
||||
options := options{
|
||||
cache: defaultCache,
|
||||
logger: zap.NewNop(),
|
||||
}
|
||||
|
||||
// 应用所有提供的选项
|
||||
for _, o := range opts {
|
||||
o.apply(&options)
|
||||
}
|
||||
|
||||
// 使用 options.cache 和 options.logger...
|
||||
return &Connection{}, nil
|
||||
}
|
||||
```
|
||||
|
||||
## 使用示例
|
||||
|
||||
### 不使用函数式选项(不好)
|
||||
|
||||
```go
|
||||
// 调用者必须始终提供所有参数,即使是默认值
|
||||
db.Open(addr, db.DefaultCache, zap.NewNop())
|
||||
db.Open(addr, db.DefaultCache, log)
|
||||
db.Open(addr, false /* cache */, zap.NewNop())
|
||||
db.Open(addr, false /* cache */, log)
|
||||
```
|
||||
|
||||
### 使用函数式选项(好)
|
||||
|
||||
```go
|
||||
// 只在需要时提供选项
|
||||
db.Open(addr)
|
||||
db.Open(addr, db.WithLogger(log))
|
||||
db.Open(addr, db.WithCache(false))
|
||||
db.Open(
|
||||
addr,
|
||||
db.WithCache(false),
|
||||
db.WithLogger(log),
|
||||
)
|
||||
```
|
||||
|
||||
## 比较:函数式选项 vs 配置结构体
|
||||
|
||||
| 方面 | 函数式选项 | 配置结构体 |
|
||||
|------|-----------|-----------|
|
||||
| **可扩展性** | 添加新的 `With*` 函数 | 添加新字段(可能破坏兼容性) |
|
||||
| **默认值** | 内置于构造函数 | 零值或单独的默认值 |
|
||||
| **调用者体验** | 只指定不同的部分 | 必须构造整个结构体 |
|
||||
| **可测试性** | 选项可比较 | 结构体比较 |
|
||||
| **复杂性** | 更多样板代码 | 更简单的设置 |
|
||||
|
||||
**优先使用配置结构体的场景**:少于 3 个选项、选项很少变化、所有选项通常一起指定、或仅用于内部 API。
|
||||
|
||||
> 在决定使用函数式选项还是配置结构体、设计具有适当默认值的配置结构体 API、或评估复杂构造函数的混合方法时,请阅读 [references/OPTIONS-VS-STRUCTS.md](references/OPTIONS-VS-STRUCTS.md)。
|
||||
|
||||
## 为什么不使用闭包?
|
||||
|
||||
另一种实现使用闭包:
|
||||
|
||||
```go
|
||||
// 闭包方法(不推荐)
|
||||
type Option func(*options)
|
||||
|
||||
func WithCache(c bool) Option {
|
||||
return func(o *options) { o.cache = c }
|
||||
}
|
||||
```
|
||||
|
||||
优先使用接口方法,因为:
|
||||
|
||||
1. **可测试性** - 选项可以在测试和 mock 中进行比较
|
||||
2. **可调试性** - 选项可以实现 `fmt.Stringer`
|
||||
3. **灵活性** - 选项可以实现额外的接口
|
||||
4. **可见性** - 选项类型在文档中可见
|
||||
|
||||
## 快速参考
|
||||
|
||||
```go
|
||||
// 1. 带有默认值的未导出 options 结构体
|
||||
type options struct {
|
||||
field1 Type1
|
||||
field2 Type2
|
||||
}
|
||||
|
||||
// 2. 导出的 Option 接口,未导出的方法
|
||||
type Option interface {
|
||||
apply(*options)
|
||||
}
|
||||
|
||||
// 3. Option 类型 + apply + With* 构造函数
|
||||
type field1Option Type1
|
||||
|
||||
func (o field1Option) apply(opts *options) { opts.field1 = Type1(o) }
|
||||
func WithField1(v Type1) Option { return field1Option(v) }
|
||||
|
||||
// 4. 构造函数在默认值之上应用选项
|
||||
func New(required string, opts ...Option) (*Thing, error) {
|
||||
o := options{field1: defaultField1, field2: defaultField2}
|
||||
for _, opt := range opts {
|
||||
opt.apply(&o)
|
||||
}
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### 检查清单
|
||||
|
||||
- [ ] `options` 结构体未导出
|
||||
- [ ] `Option` 接口有未导出的 `apply` 方法
|
||||
- [ ] 每个选项有 `With*` 构造函数
|
||||
- [ ] 默认值在应用选项之前设置
|
||||
- [ ] 必需参数与 `...Option` 分开
|
||||
|
||||
## 相关技能
|
||||
|
||||
- **接口设计**:在设计 `Option` 接口或选择接口与闭包方法时,参见 [go-interfaces](../go-interfaces/SKILL.md)
|
||||
- **命名约定**:在命名 `With*` 构造函数、选项类型或未导出的 options 结构体时,参见 [go-naming](../go-naming/SKILL.md)
|
||||
- **函数设计**:在组织文件中的构造函数或格式化可变参数签名时,参见 [go-functions](../go-functions/SKILL.md)
|
||||
- **文档**:在记录 `Option` 类型、`With*` 函数或构造函数行为时,参见 [go-documentation](../go-documentation/SKILL.md)
|
||||
|
||||
### 外部资源
|
||||
|
||||
- [Self-referential functions and the design of options](https://commandcenter.blogspot.com/2014/01/self-referential-functions-and-design.html) - Rob Pike
|
||||
- [Functional options for friendly APIs](https://dave.cheney.net/2014/10/17/functional-options-for-friendly-apis) - Dave Cheney
|
||||
@@ -1,129 +0,0 @@
|
||||
# 函数式选项 vs 配置结构体
|
||||
|
||||
> **来源**:Google 风格指南, Uber 风格指南
|
||||
|
||||
函数式选项和配置结构体解决相同的问题 — 构造函数的可选配置 — 但它们有不同的权衡。根据 API 受众、可扩展性需求和复杂性预算来选择。
|
||||
|
||||
## 决策框架
|
||||
|
||||
```
|
||||
需要可选配置?
|
||||
├─ 内部或仅测试 API?
|
||||
│ └─ 配置结构体(更简单,更少样板代码)
|
||||
├─ 具有 3 个以上选项的公共 API?
|
||||
│ └─ 函数式选项(可扩展,向后兼容)
|
||||
├─ 选项需要校验或有相互依赖?
|
||||
│ └─ 函数式选项(在 apply 或构造函数中校验)
|
||||
├─ 所有选项通常一起指定?
|
||||
│ └─ 配置结构体(一个字面量,无需 With* 仪式)
|
||||
└─ 选项可能随时间增长?
|
||||
└─ 函数式选项(添加 With* 不会破坏调用者)
|
||||
```
|
||||
|
||||
## 配置结构体模式
|
||||
|
||||
配置结构体将可选参数分组为传递给构造函数的单个结构体。零值作为默认值,或提供 `DefaultConfig()`。
|
||||
|
||||
**好**
|
||||
```go
|
||||
type Config struct {
|
||||
Timeout time.Duration // 零 = 无超时
|
||||
MaxRetry int // 零 = 无重试
|
||||
Logger *log.Logger // nil = 丢弃
|
||||
}
|
||||
|
||||
func NewClient(addr string, cfg Config) *Client {
|
||||
if cfg.Logger == nil {
|
||||
cfg.Logger = log.New(io.Discard, "", 0)
|
||||
}
|
||||
return &Client{addr: addr, cfg: cfg}
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
c := NewClient("localhost:8080", Config{
|
||||
Timeout: 5 * time.Second,
|
||||
MaxRetry: 3,
|
||||
})
|
||||
```
|
||||
|
||||
**不好** — 在公共 API 中依赖未导出的配置字段:
|
||||
```go
|
||||
type config struct { // 未导出:调用者无法构造
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
func NewClient(addr string, cfg config) *Client { ... }
|
||||
```
|
||||
|
||||
### 当零值不适用时
|
||||
|
||||
如果零是一个有效的非默认值(例如,超时为 0 表示"无超时",但期望的默认值是 30s),使用指针字段或哨兵值:
|
||||
|
||||
```go
|
||||
type Config struct {
|
||||
Timeout *time.Duration // nil = 使用默认值(30s),零 = 无超时
|
||||
}
|
||||
```
|
||||
|
||||
## 比较
|
||||
|
||||
| 方面 | 函数式选项 | 配置结构体 |
|
||||
|------|-----------|-----------|
|
||||
| **样板代码** | 高(每个选项需要类型 + apply + With*) | 低(一个结构体) |
|
||||
| **可扩展性** | 添加 `With*` — 无破坏性变更 | 添加字段 — 无破坏性变更 |
|
||||
| **向后兼容** | 对公共 API 极好 | 好(新字段获得零值) |
|
||||
| **默认值** | 内置于构造函数 | 零值或 `DefaultConfig()` |
|
||||
| **校验** | 在 `apply` 或构造函数循环中 | 在接收到结构体后的构造函数中 |
|
||||
| **可发现性** | `With*` 函数出现在 godoc 中 | 所有字段在一个结构体中可见 |
|
||||
| **可测试性** | 比较选项或测试构造函数输出 | 比较结构体字面量 |
|
||||
| **调用者体验** | 只指定与默认值不同的部分 | 必须构造结构体字面量 |
|
||||
| **零值歧义** | 无 — 未设置的选项不应用 | 可能需要指针字段 |
|
||||
|
||||
## 何时优先使用配置结构体
|
||||
|
||||
- **内部 API** — 更少的仪式,在调用处更易读
|
||||
- **少量选项(1-3 个)** — 函数式选项的开销不值得
|
||||
- **所有选项通常一起设置** — 可变参数风格没有好处
|
||||
- **不需要校验** — 简单的字段赋值即可
|
||||
- **选项是数据而非行为** — 结构体字段自然映射
|
||||
|
||||
```go
|
||||
srv := NewServer(Config{
|
||||
Port: 8080,
|
||||
TLSCert: "/path/to/cert.pem",
|
||||
TLSKey: "/path/to/key.pem",
|
||||
})
|
||||
```
|
||||
|
||||
## 何时优先使用函数式选项
|
||||
|
||||
- **公共/库 API** — 调用者不应跟踪内部配置的演变
|
||||
- **3 个以上选项**,每个都是可选的
|
||||
- **复杂默认值** — 默认值计算依赖于其他选项
|
||||
- **按选项校验** — 在 apply 时拒绝无效值
|
||||
- **选项可能增长** — 新的 `With*` 函数是纯粹增量的
|
||||
|
||||
```go
|
||||
srv := NewServer(
|
||||
WithPort(8080),
|
||||
WithTLS("/path/to/cert.pem", "/path/to/key.pem"),
|
||||
WithLogger(logger),
|
||||
)
|
||||
```
|
||||
|
||||
## 混合方法
|
||||
|
||||
对于同时需要便利性和可扩展性的 API,接受配置结构体用于常见设置,函数式选项用于高级覆盖:
|
||||
|
||||
```go
|
||||
func NewServer(cfg Config, opts ...Option) *Server {
|
||||
s := &Server{cfg: cfg}
|
||||
for _, o := range opts {
|
||||
o.apply(&s.cfg)
|
||||
}
|
||||
return s
|
||||
}
|
||||
```
|
||||
|
||||
谨慎使用 — 它增加了复杂性。每个 API 优先使用一种方法。
|
||||
@@ -1,107 +0,0 @@
|
||||
---
|
||||
name: go-functions
|
||||
description: Use when organizing functions within a Go file, formatting function signatures, designing return values, or following Printf-style naming conventions. Also use when a user is adding or refactoring any Go function, even if they don't mention function design or signature formatting. Does not cover functional options constructors (see go-functional-options).
|
||||
license: Apache-2.0
|
||||
metadata:
|
||||
sources: "Effective Go, Google Style Guide, Uber Style Guide"
|
||||
---
|
||||
|
||||
# Go 函数设计
|
||||
|
||||
> **本技能不适用的场景**:对于函数选项构造函数(`WithTimeout`、`WithLogger`),参见 [go-functional-options](../go-functional-options/SKILL.md)。对于错误返回约定,参见 [go-error-handling](../go-error-handling/SKILL.md)。对于函数和方法的命名,参见 [go-naming](../go-naming/SKILL.md)。
|
||||
|
||||
---
|
||||
|
||||
## 函数分组与排序
|
||||
|
||||
按以下规则组织文件中的函数:
|
||||
|
||||
1. 函数按**大致调用顺序**排序
|
||||
2. 函数**按接收者分组**
|
||||
3. **导出**函数排在最前面,位于 `struct`/`const`/`var` 定义之后
|
||||
4. `NewXxx`/`newXxx` 构造函数紧跟在类型定义之后
|
||||
5. 普通工具函数排在文件末尾
|
||||
|
||||
```go
|
||||
type something struct{ ... }
|
||||
|
||||
func newSomething() *something { return &something{} }
|
||||
|
||||
func (s *something) Cost() int { return calcCost(s.weights) }
|
||||
|
||||
func (s *something) Stop() { ... }
|
||||
|
||||
func calcCost(n []int) int { ... }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 函数签名
|
||||
|
||||
> 在格式化多行签名、包装返回值、缩短调用点或用自定义类型替换裸 bool 参数时,阅读 [references/SIGNATURES.md](references/SIGNATURES.md)。
|
||||
|
||||
尽量将签名保持在一行内。当必须换行时,将**所有参数放在各自的行上**并加尾随逗号:
|
||||
|
||||
```go
|
||||
func (r *SomeType) SomeLongFunctionName(
|
||||
foo1, foo2, foo3 string,
|
||||
foo4, foo5, foo6 int,
|
||||
) {
|
||||
foo7 := bar(foo1)
|
||||
}
|
||||
```
|
||||
|
||||
为含义不明确的参数添加 `/* name */` 注释,或者更好的做法是用自定义类型替换裸 `bool` 参数。
|
||||
|
||||
---
|
||||
|
||||
## 接口指针
|
||||
|
||||
几乎不需要指向接口的指针。将接口作为值传递——底层数据仍然可以是指针。
|
||||
|
||||
```go
|
||||
// 不好:接口指针
|
||||
func process(r *io.Reader) { ... }
|
||||
|
||||
// 好:传递接口值
|
||||
func process(r io.Reader) { ... }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Printf 与 Stringer
|
||||
|
||||
> 在使用 %v/%s/%d 之外的 Printf 动词、实现 fmt.Stringer 或 fmt.GoStringer、编写自定义 Format() 方法或调试 String() 方法中的无限递归时,阅读 [references/PRINTF-STRINGER.md](references/PRINTF-STRINGER.md)。
|
||||
|
||||
### Printf 风格函数名
|
||||
|
||||
接受格式字符串的函数应以 `f` 结尾,以便 `go vet` 支持。在 `Printf` 调用之外使用格式字符串时,将其声明为 `const`。
|
||||
|
||||
在格式化日志或错误消息中的字符串时,优先使用 `%q` 而非手动加引号的 `%s`——它能安全地转义特殊字符并加上引号:
|
||||
|
||||
```go
|
||||
return fmt.Errorf("unknown key %q", key) // 输出:unknown key "foo\nbar"
|
||||
```
|
||||
|
||||
设计具有 3 个以上可选参数的构造函数时,参见 **go-functional-options**。
|
||||
|
||||
---
|
||||
|
||||
## 快速参考
|
||||
|
||||
| 主题 | 规则 |
|
||||
|------|------|
|
||||
| 文件排序 | 类型 -> 构造函数 -> 导出 -> 未导出 -> 工具函数 |
|
||||
| 签名换行 | 所有参数各占一行,加尾随逗号 |
|
||||
| 裸参数 | 添加 `/* name */` 注释或使用自定义类型 |
|
||||
| 接口指针 | 几乎不需要;按值传递接口 |
|
||||
| Printf 函数名 | 以 `f` 结尾以支持 `go vet` |
|
||||
|
||||
---
|
||||
|
||||
## 相关技能
|
||||
|
||||
- **错误返回**:在设计错误返回模式或在多返回值函数中包装错误时,参见 [go-error-handling](../go-error-handling/SKILL.md)
|
||||
- **命名约定**:在为函数、方法命名或选择 getter/setter 模式时,参见 [go-naming](../go-naming/SKILL.md)
|
||||
- **函数选项**:在设计具有 3 个以上可选参数的构造函数时,参见 [go-functional-options](../go-functional-options/SKILL.md)
|
||||
- **格式化原则**:在决定行长度、裸返回或签名格式时,参见 [go-style-core](../go-style-core/SKILL.md)
|
||||
@@ -1,264 +0,0 @@
|
||||
# Printf、Stringer 与自定义格式化
|
||||
|
||||
Go 的 `fmt` 打印动词、`Stringer` 和 `GoStringer` 接口、自定义 `Format()` 方法以及常见陷阱的深度参考。
|
||||
|
||||
---
|
||||
|
||||
## Printf 动词
|
||||
|
||||
### 通用动词
|
||||
|
||||
| 动词 | 用途 |
|
||||
|------|------|
|
||||
| `%v` | 默认格式(结构体字段、切片元素) |
|
||||
| `%+v` | 带字段名的结构体:`{Name:alice Age:30}` |
|
||||
| `%#v` | Go 语法表示:`main.User{Name:"alice", Age:30}` |
|
||||
| `%T` | 值的类型:`main.User` |
|
||||
| `%%` | 字面百分号 |
|
||||
|
||||
### 字符串与字节动词
|
||||
|
||||
| 动词 | 用途 |
|
||||
|------|------|
|
||||
| `%s` | 纯字符串或字节切片 |
|
||||
| `%q` | 带 Go 语法转义的引号字符串:`"hello\n"` |
|
||||
| `%x` | 十六进制编码,小写:`68656c6c6f` |
|
||||
| `%X` | 十六进制编码,大写:`68656C6C6F` |
|
||||
|
||||
### 整数动词
|
||||
|
||||
| 动词 | 用途 |
|
||||
|------|------|
|
||||
| `%d` | 十进制整数 |
|
||||
| `%b` | 二进制 |
|
||||
| `%o` | 八进制 |
|
||||
| `%O` | 带 `0o` 前缀的八进制 |
|
||||
| `%x` | 十六进制,小写 |
|
||||
| `%X` | 十六进制,大写 |
|
||||
|
||||
### 浮点数动词
|
||||
|
||||
| 动词 | 用途 |
|
||||
|------|------|
|
||||
| `%f` | 小数点,无指数:`123.456` |
|
||||
| `%e` | 科学计数法:`1.23456e+02` |
|
||||
| `%g` | 紧凑格式:大指数用 `%e`,否则用 `%f` |
|
||||
|
||||
### 宽度与精度
|
||||
|
||||
```go
|
||||
fmt.Sprintf("%10d", 42) // " 42" (宽度 10,右对齐)
|
||||
fmt.Sprintf("%-10d", 42) // "42 " (宽度 10,左对齐)
|
||||
fmt.Sprintf("%.2f", 3.14159) // "3.14" (2 位小数)
|
||||
fmt.Sprintf("%010d", 42) // "0000000042" (零填充)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 使用 `%q` 输出字符串
|
||||
|
||||
`%q` 动词在双引号内打印字符串,使空字符串和控制字符可见:
|
||||
|
||||
```go
|
||||
fmt.Printf("value %q looks like English text", someText)
|
||||
|
||||
// 不好:手动添加引号
|
||||
fmt.Printf("value \"%s\" looks like English text", someText)
|
||||
```
|
||||
|
||||
在面向人类的输出中,如果值可能为空或包含控制字符,优先使用 `%q`。
|
||||
|
||||
---
|
||||
|
||||
## Printf 之外的格式字符串
|
||||
|
||||
在 `Printf` 风格调用之外声明格式字符串时,使用 `const`。这样 `go vet` 可以进行静态分析:
|
||||
|
||||
```go
|
||||
// 不好:变量格式字符串——go vet 无法检查
|
||||
msg := "unexpected values %v, %v\n"
|
||||
fmt.Printf(msg, 1, 2)
|
||||
|
||||
// 好:常量格式字符串——go vet 可以验证
|
||||
const msg = "unexpected values %v, %v\n"
|
||||
fmt.Printf(msg, 1, 2)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Printf 风格函数的命名
|
||||
|
||||
接受格式字符串的函数应以 `f` 结尾。这样 `go vet` 可以自动检查格式字符串:
|
||||
|
||||
```go
|
||||
func Wrapf(err error, format string, args ...any) error
|
||||
```
|
||||
|
||||
如果使用非标准名称,需要告知 `go vet`:
|
||||
|
||||
```bash
|
||||
go vet -printfuncs=wrapf,statusf
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## `fmt.Stringer` 接口
|
||||
|
||||
实现 `fmt.Stringer` 来控制类型在 `%v` 和 `%s` 下的显示方式:
|
||||
|
||||
```go
|
||||
type fmt.Stringer interface {
|
||||
String() string
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
type Point struct{ X, Y int }
|
||||
|
||||
func (p Point) String() string {
|
||||
return fmt.Sprintf("(%d, %d)", p.X, p.Y)
|
||||
}
|
||||
|
||||
// fmt.Println(Point{1, 2}) → "(1, 2)"
|
||||
// fmt.Sprintf("point: %v", p) → "point: (1, 2)"
|
||||
// fmt.Sprintf("point: %s", p) → "point: (1, 2)"
|
||||
```
|
||||
|
||||
### 何时实现 Stringer
|
||||
|
||||
- 类型将出现在日志消息或面向用户的输出中
|
||||
- 默认的 `%v` 输出(仅字段值)不够有意义
|
||||
- 需要一种区别于序列化的、对人类友好的表示
|
||||
|
||||
---
|
||||
|
||||
## `fmt.GoStringer` 接口
|
||||
|
||||
实现 `fmt.GoStringer` 来控制 `%#v` 输出。这对于默认 Go 语法表示具有误导性或过于冗长的类型很有用:
|
||||
|
||||
```go
|
||||
type fmt.GoStringer interface {
|
||||
GoString() string
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
type Color struct{ R, G, B uint8 }
|
||||
|
||||
func (c Color) GoString() string {
|
||||
return fmt.Sprintf("Color(%#02x, %#02x, %#02x)", c.R, c.G, c.B)
|
||||
}
|
||||
|
||||
// fmt.Sprintf("%#v", Color{255, 128, 0})
|
||||
// → "Color(0xff, 0x80, 0x00)" 而非 "main.Color{R:0xff, G:0x80, B:0x00}"
|
||||
```
|
||||
|
||||
`GoString()` 的输出应该是有效的 Go 语法或接近有效语法——它用于调试,而非面向用户的显示。
|
||||
|
||||
---
|
||||
|
||||
## 使用 `fmt.Formatter` 自定义格式化
|
||||
|
||||
要完全控制所有格式动词,实现 `fmt.Formatter`:
|
||||
|
||||
```go
|
||||
type fmt.Formatter interface {
|
||||
Format(f fmt.State, verb rune)
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
type Point struct{ X, Y int }
|
||||
|
||||
func (p Point) Format(f fmt.State, verb rune) {
|
||||
switch verb {
|
||||
case 'v':
|
||||
if f.Flag('#') {
|
||||
// %#v——Go 语法表示
|
||||
fmt.Fprintf(f, "Point{X: %d, Y: %d}", p.X, p.Y)
|
||||
return
|
||||
}
|
||||
if f.Flag('+') {
|
||||
// %+v——带字段名的详细格式
|
||||
fmt.Fprintf(f, "X:%d Y:%d", p.X, p.Y)
|
||||
return
|
||||
}
|
||||
// %v——默认
|
||||
fmt.Fprintf(f, "(%d, %d)", p.X, p.Y)
|
||||
case 's':
|
||||
fmt.Fprintf(f, "(%d, %d)", p.X, p.Y)
|
||||
case 'q':
|
||||
fmt.Fprintf(f, "%q", p.String())
|
||||
default:
|
||||
fmt.Fprintf(f, "%%!%c(Point=%d,%d)", verb, p.X, p.Y)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `fmt.State` 方法
|
||||
|
||||
| 方法 | 返回值 |
|
||||
|------|--------|
|
||||
| `Flag(c int) bool` | 标志(`+`、`-`、`#`、`0`、` `)是否设置 |
|
||||
| `Width() (int, bool)` | 宽度值以及是否指定了宽度 |
|
||||
| `Precision() (int, bool)` | 精度值以及是否指定了精度 |
|
||||
| `Write(b []byte) (int, error)` | 写入输出字节 |
|
||||
|
||||
仅在 `String()` 不够用时才实现 `fmt.Formatter`——很少需要这样做。常见原因:需要为 `%v`、`%+v`、`%#v` 提供不同输出,或者需要遵循宽度/精度标志。
|
||||
|
||||
---
|
||||
|
||||
## 无限递归陷阱
|
||||
|
||||
**在 `String()` 方法内部对接收者使用 `%s` 或 `%v` 调用 `fmt.Sprintf` 会导致无限递归:**
|
||||
|
||||
```go
|
||||
type MyString string
|
||||
|
||||
// BUG:无限递归——Sprintf 调用 String(),String() 又调用 Sprintf...
|
||||
func (m MyString) String() string {
|
||||
return fmt.Sprintf("MyString: %s", m) // 崩溃:栈溢出
|
||||
}
|
||||
```
|
||||
|
||||
修复方法——将接收者转换为其底层类型以打破方法集:
|
||||
|
||||
```go
|
||||
func (m MyString) String() string {
|
||||
return fmt.Sprintf("MyString: %s", string(m)) // 安全:string 没有 String()
|
||||
}
|
||||
```
|
||||
|
||||
此陷阱还适用于:
|
||||
- 底层类型为 string、[]byte 或另一个 Stringer 的类型
|
||||
- 任何使用 `%s` 或 `%v` 格式化 `self` 的 `String()` 方法
|
||||
- 使用 `%#v` 格式化 `self` 的 `GoString()` 方法
|
||||
|
||||
```go
|
||||
type IPAddr [4]byte
|
||||
|
||||
// BUG:%v 调用 String(),无限递归
|
||||
func (ip IPAddr) String() string {
|
||||
return fmt.Sprintf("%v.%v.%v.%v", ip[0], ip[1], ip[2], ip[3])
|
||||
// 这里安全——ip[0] 是 byte(uint8),没有 String() 方法。
|
||||
// 但如果 ip 是一个包装了 Stringer 的命名类型,就会递归。
|
||||
}
|
||||
```
|
||||
|
||||
**经验法则**:在 `String()` 内部,永远不要将接收者(或重新转换为自身类型的接收者)传递给 `%s` 或 `%v` 动词。先转换为底层原始类型。
|
||||
|
||||
---
|
||||
|
||||
## 快速参考
|
||||
|
||||
| 主题 | 规则 |
|
||||
|------|------|
|
||||
| `%q` | 用于人类可读的字符串输出 |
|
||||
| `%+v` | 带字段名的结构体 |
|
||||
| `%#v` | Go 语法表示;通过 `GoStringer` 自定义 |
|
||||
| 格式字符串存储 | 在 Printf 调用之外声明为 `const` |
|
||||
| Printf 函数名 | 以 `f` 结尾以支持 `go vet` |
|
||||
| `Stringer` | 实现 `String() string` 用于 `%v`/`%s` 输出 |
|
||||
| `GoStringer` | 实现 `GoString() string` 用于 `%#v` 输出 |
|
||||
| `Formatter` | 实现 `Format(fmt.State, rune)` 以完全控制动词 |
|
||||
| 递归陷阱 | 永远不要在 `String()` 内部使用 `Sprintf("%s", receiver)`;转换为底层类型 |
|
||||
@@ -1,168 +0,0 @@
|
||||
# 函数签名
|
||||
|
||||
格式化 Go 函数签名、避免裸参数以及保持调用点可读性的详细规则。
|
||||
|
||||
---
|
||||
|
||||
## 单行 vs 多行
|
||||
|
||||
当签名能轻松放在一行时保持单行。当必须换行时,将**所有参数放在各自的行上**并加尾随逗号:
|
||||
|
||||
**不好**——部分换行使对齐变得脆弱:
|
||||
|
||||
```go
|
||||
func (r *SomeType) SomeLongFunctionName(foo1, foo2, foo3 string,
|
||||
foo4, foo5, foo6 int) {
|
||||
foo7 := bar(foo1)
|
||||
}
|
||||
```
|
||||
|
||||
**好**——完全换行,尾随逗号:
|
||||
|
||||
```go
|
||||
func (r *SomeType) SomeLongFunctionName(
|
||||
foo1, foo2, foo3 string,
|
||||
foo4, foo5, foo6 int,
|
||||
) {
|
||||
foo7 := bar(foo1)
|
||||
}
|
||||
```
|
||||
|
||||
### 返回值
|
||||
|
||||
当返回值也需要换行时,遵循相同的模式:
|
||||
|
||||
```go
|
||||
func (r *SomeType) LongName(
|
||||
foo1, foo2, foo3 string,
|
||||
foo4, foo5, foo6 int,
|
||||
) (
|
||||
*Result,
|
||||
error,
|
||||
) {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
对于更简单的情况,命名返回值可以与参数右括号在同一行:
|
||||
|
||||
```go
|
||||
func (r *SomeType) LongName(
|
||||
foo1, foo2, foo3 string,
|
||||
) (result *Result, err error) {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 缩短调用点
|
||||
|
||||
提取局部变量,而不是将函数调用拆分到多行:
|
||||
|
||||
```go
|
||||
// 不好:过长的内联调用
|
||||
result := foo.Call(
|
||||
somePackage.ComplexFunction(arg1, arg2),
|
||||
anotherPackage.Transform(data),
|
||||
defaultOptions,
|
||||
)
|
||||
|
||||
// 好:提取局部变量以提高清晰度
|
||||
transformed := anotherPackage.Transform(data)
|
||||
computed := somePackage.ComplexFunction(arg1, arg2)
|
||||
result := foo.Call(computed, transformed, defaultOptions)
|
||||
```
|
||||
|
||||
这提高了可读性,并使中间值可用于调试。
|
||||
|
||||
---
|
||||
|
||||
## 避免裸参数
|
||||
|
||||
函数调用中的裸参数会降低可读性。为含义不明确的参数添加 C 风格注释:
|
||||
|
||||
```go
|
||||
// 不好:这些布尔值是什么意思?
|
||||
printInfo("foo", true, true)
|
||||
|
||||
// 好:内联注释说明了意图
|
||||
printInfo("foo", true /* isLocal */, true /* done */)
|
||||
```
|
||||
|
||||
更好的做法是用自定义类型替换裸 `bool` 参数:
|
||||
|
||||
```go
|
||||
type Region int
|
||||
|
||||
const (
|
||||
UnknownRegion Region = iota
|
||||
Local
|
||||
)
|
||||
|
||||
type Status int
|
||||
|
||||
const (
|
||||
Pending Status = iota
|
||||
Done
|
||||
)
|
||||
|
||||
func printInfo(name string, region Region, status Status)
|
||||
```
|
||||
|
||||
### 何时使用每种方法
|
||||
|
||||
| 方法 | 时机 |
|
||||
|------|------|
|
||||
| C 风格注释 | 快速修复;调用点少;无法修改的第三方 API |
|
||||
| 自定义类型 | 多个调用点;公开 API;多个 bool/int 参数 |
|
||||
| 函数选项 | 3 个以上可选参数;参见 [go-functional-options](../../go-functional-options/SKILL.md) |
|
||||
|
||||
---
|
||||
|
||||
## 分组相关参数
|
||||
|
||||
当函数接受多个相同类型的参数时,将它们分组:
|
||||
|
||||
```go
|
||||
// 可接受:将同类型参数分组
|
||||
func Copy(dst, src string) error
|
||||
|
||||
// 可接受:尽管类型相同,但含义不同时分开声明
|
||||
func Move(source string, destination string) error
|
||||
```
|
||||
|
||||
当参数名称能清楚表明角色时使用分组;当不能清楚表明时使用分开声明。
|
||||
|
||||
---
|
||||
|
||||
## 方法接收者的位置
|
||||
|
||||
接收者放在函数名之前,格式类似于参数:
|
||||
|
||||
```go
|
||||
// 短接收者——放在同一行
|
||||
func (s *Server) Start(ctx context.Context) error { ... }
|
||||
|
||||
// 长接收者类型——如果整行过长则考虑换行
|
||||
func (h *ComplicatedHandler) ServeHTTP(
|
||||
w http.ResponseWriter,
|
||||
r *http.Request,
|
||||
) { ... }
|
||||
```
|
||||
|
||||
参见 [go-naming](../../go-naming/SKILL.md) 了解接收者命名约定(简短的一到两个字母缩写)。
|
||||
|
||||
---
|
||||
|
||||
## 快速参考
|
||||
|
||||
| 主题 | 规则 |
|
||||
|------|------|
|
||||
| 单行 | 能放下时保持一行 |
|
||||
| 多行 | 所有参数各占一行,尾随逗号 |
|
||||
| 返回值换行 | 与参数相同的模式 |
|
||||
| 调用点 | 提取局部变量而不是拆分调用 |
|
||||
| 裸 bool | 添加 `/* name */` 注释或使用自定义类型 |
|
||||
| 分组参数 | 当名称能清楚表明角色时将同类型分组 |
|
||||
| 接收者 | 在函数名之前;简短缩写 |
|
||||
@@ -1,173 +0,0 @@
|
||||
---
|
||||
name: go-generics
|
||||
description: Use when deciding whether to use Go generics, writing generic functions or types, choosing constraints, or picking between type aliases and type definitions. Also use when a user is writing a utility function that could work with multiple types, even if they don't mention generics explicitly. Does not cover interface design without generics (see go-interfaces).
|
||||
license: Apache-2.0
|
||||
compatibility: Requires Go 1.18+ (generics were introduced in Go 1.18)
|
||||
metadata:
|
||||
sources: "Google Style Guide"
|
||||
---
|
||||
|
||||
# Go 泛型与类型参数
|
||||
|
||||
---
|
||||
|
||||
## 何时使用泛型
|
||||
|
||||
从具体类型开始。只在出现第二种类型时才进行泛化。
|
||||
|
||||
### 优先使用泛型的场景
|
||||
|
||||
- 多种类型共享相同的逻辑(排序、过滤、map/reduce)
|
||||
- 否则需要依赖 `any` 和大量的类型切换
|
||||
- 正在构建可复用的数据结构(并发安全的集合、有序映射)
|
||||
|
||||
### 避免使用泛型的场景
|
||||
|
||||
- 实践中只有一种类型被实例化
|
||||
- 接口已经能清晰地表达共享行为
|
||||
- 泛型代码比特定类型的替代方案更难阅读
|
||||
|
||||
> "写代码,不要设计类型。"—— Robert Griesemer 和 Ian Lance Taylor
|
||||
|
||||
### 决策流程
|
||||
|
||||
```
|
||||
多种类型是否共享相同的逻辑?
|
||||
├─ 否 → 使用具体类型
|
||||
├─ 是 → 它们是否共享一个有用的接口?
|
||||
│ ├─ 是 → 使用接口
|
||||
│ └─ 否 → 使用泛型
|
||||
```
|
||||
|
||||
**不好:**
|
||||
|
||||
```go
|
||||
// 过早使用泛型:只会被 int 调用
|
||||
func Sum[T constraints.Integer | constraints.Float](vals []T) T {
|
||||
var total T
|
||||
for _, v := range vals {
|
||||
total += v
|
||||
}
|
||||
return total
|
||||
}
|
||||
```
|
||||
|
||||
**好:**
|
||||
|
||||
```go
|
||||
func SumInts(vals []int) int {
|
||||
var total int
|
||||
for _, v := range vals {
|
||||
total += v
|
||||
}
|
||||
return total
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 类型参数命名
|
||||
|
||||
| 名称 | 典型用途 |
|
||||
|------|----------|
|
||||
| `T` | 通用类型参数 |
|
||||
| `K` | 映射键类型 |
|
||||
| `V` | 映射值类型 |
|
||||
| `E` | 元素/项目类型 |
|
||||
|
||||
对于复杂约束,可以使用简短的描述性名称:
|
||||
|
||||
```go
|
||||
func Marshal[Opts encoding.MarshalOptions](v any, opts Opts) ([]byte, error)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 类型别名 vs 类型定义
|
||||
|
||||
类型别名(`type Old = new.Name`)很少使用——仅用于包迁移或渐进式 API 重构。
|
||||
|
||||
---
|
||||
|
||||
## 约束组合
|
||||
|
||||
使用 `~`(底层类型)和 `|`(联合)组合约束:
|
||||
|
||||
```go
|
||||
type Numeric interface {
|
||||
~int | ~int8 | ~int16 | ~int32 | ~int64 |
|
||||
~float32 | ~float64
|
||||
}
|
||||
|
||||
func Sum[T Numeric](vals []T) T {
|
||||
var total T
|
||||
for _, v := range vals {
|
||||
total += v
|
||||
}
|
||||
return total
|
||||
}
|
||||
```
|
||||
|
||||
使用 `constraints` 包或 `cmp` 包(Go 1.21+)中的标准约束如 `cmp.Ordered`,而不是自己编写。
|
||||
|
||||
> 在编写自定义类型约束、使用 ~ 和 | 组合约束或调试类型推断问题时,阅读 [references/CONSTRAINTS.md](references/CONSTRAINTS.md)。
|
||||
|
||||
---
|
||||
|
||||
## 常见陷阱
|
||||
|
||||
### 不要包装标准库类型
|
||||
|
||||
```go
|
||||
// 不好:泛型包装器增加了复杂度但没有价值
|
||||
type Set[T comparable] struct {
|
||||
m map[T]struct{}
|
||||
}
|
||||
|
||||
// 更好:当用法简单时直接使用 map[T]struct{}
|
||||
seen := map[string]struct{}{}
|
||||
```
|
||||
|
||||
泛型在消除**多个调用点**之间的重复时才能证明其复杂度的合理性。单次使用的泛型只是多余的间接层。
|
||||
|
||||
### 不要为接口满足而使用泛型
|
||||
|
||||
```go
|
||||
// 不好:T 仅用于满足接口——直接使用接口即可
|
||||
func Process[T io.Reader](r T) error { ... }
|
||||
|
||||
// 好:直接接受接口
|
||||
func Process(r io.Reader) error { ... }
|
||||
```
|
||||
|
||||
### 避免过度约束
|
||||
|
||||
```go
|
||||
// 不好:约束比需要的更严格
|
||||
func Contains[T interface{ ~int | ~string }](slice []T, target T) bool { ... }
|
||||
|
||||
// 好:comparable 就足够了
|
||||
func Contains[T comparable](slice []T, target T) bool { ... }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 快速参考
|
||||
|
||||
| 主题 | 指导 |
|
||||
|------|------|
|
||||
| 何时使用泛型 | 仅在多种类型共享相同逻辑且接口不够用时 |
|
||||
| 起点 | 先写具体代码;之后再泛化 |
|
||||
| 命名 | 单个大写字母(`T`、`K`、`V`、`E`) |
|
||||
| 类型别名 | 相同类型,替代名称;仅用于迁移 |
|
||||
| 约束组合 | 使用 `~` 表示底层类型,`|` 表示联合;优先使用 `cmp.Ordered` 而非自定义 |
|
||||
| 常见陷阱 | 不要对单次使用的代码或接口已足够时使用泛型 |
|
||||
|
||||
---
|
||||
|
||||
## 相关技能
|
||||
|
||||
- **接口 vs 泛型**:在决定接口是否已经能表达共享行为而无需泛型时,参见 [go-interfaces](../go-interfaces/SKILL.md)
|
||||
- **类型声明**:在定义新类型、类型别名或在类型定义和别名之间选择时,参见 [go-declarations](../go-declarations/SKILL.md)
|
||||
- **文档化泛型 API**:在为泛型函数编写文档注释和可运行示例时,参见 [go-documentation](../go-documentation/SKILL.md)
|
||||
- **命名类型参数**:在为类型参数或约束接口选择名称时,参见 [go-naming](../go-naming/SKILL.md)
|
||||
@@ -1,169 +0,0 @@
|
||||
# Go 泛型中的类型约束
|
||||
|
||||
> **来源**:Google Go 风格指南、Go 语言规范
|
||||
|
||||
约束定义了类型参数支持的操作。选择满足函数需求的最窄约束——不要更多。
|
||||
|
||||
---
|
||||
|
||||
## 内置约束
|
||||
|
||||
> **规范**:在自行编写约束之前,优先使用标准约束。
|
||||
|
||||
| 约束 | 含义 |
|
||||
|------|------|
|
||||
| `any` | `interface{}` 的别名;对类型没有要求 |
|
||||
| `comparable` | 支持 `==` 和 `!=`;映射键所必需 |
|
||||
| `cmp.Ordered` | 支持 `<`、`<=`、`>=`、`>`(Go 1.21+,替代 `constraints.Ordered`) |
|
||||
|
||||
在新代码中优先使用 `cmp.Ordered`(来自 `cmp` 包),而不是已弃用的 `golang.org/x/exp/constraints.Ordered`。
|
||||
|
||||
---
|
||||
|
||||
## `~` 运算符(底层类型)
|
||||
|
||||
> **建议**:当你想接受基于原始类型构建的命名类型时使用 `~`。
|
||||
|
||||
`~T` 语法匹配任何**底层类型**为 `T` 的类型。没有 `~` 时,只有精确的类型匹配。
|
||||
|
||||
```go
|
||||
type Celsius float64
|
||||
|
||||
type ExactFloat interface{ float64 } // 拒绝 Celsius
|
||||
type AnyFloat64 interface{ ~float64 } // 接受 Celsius
|
||||
```
|
||||
|
||||
当调用者可能基于基础类型定义命名类型时使用 `~`。仅在需要限制为精确的内置类型时才省略 `~`。
|
||||
|
||||
---
|
||||
|
||||
## 组合与编写约束
|
||||
|
||||
> **建议**:仅在没有标准约束适用时才定义自定义约束。
|
||||
|
||||
使用 `|` 组合类型并嵌入约束来组合它们:
|
||||
|
||||
```go
|
||||
type Numeric interface {
|
||||
~int | ~int8 | ~int16 | ~int32 | ~int64 |
|
||||
~float32 | ~float64
|
||||
}
|
||||
|
||||
type Addable interface {
|
||||
Numeric | ~string // 数字和字符串拼接
|
||||
}
|
||||
```
|
||||
|
||||
约束可以同时要求方法和类型元素:
|
||||
|
||||
```go
|
||||
type Stringer interface {
|
||||
comparable
|
||||
String() string
|
||||
}
|
||||
```
|
||||
|
||||
满足 `Stringer` 的类型必须是可比较的 **并且** 具有 `String()` 方法。
|
||||
|
||||
---
|
||||
|
||||
## 避免过度约束
|
||||
|
||||
> **规范**:使用支持所执行操作的最小约束。
|
||||
|
||||
**不好**
|
||||
```go
|
||||
// 只使用了 == 但限制为 int 和 string
|
||||
func Contains[T interface{ ~int | ~string }](s []T, v T) bool { ... }
|
||||
```
|
||||
|
||||
**好**
|
||||
```go
|
||||
// comparable 是 == 的最小约束
|
||||
func Contains[T comparable](s []T, v T) bool { ... }
|
||||
```
|
||||
|
||||
过度约束限制了复用,并迫使调用者绕过实现中根本不需要的限制。
|
||||
|
||||
## 类型推断
|
||||
|
||||
> **建议**:当类型明确时让编译器推断类型参数。
|
||||
|
||||
编译器从函数参数推断类型参数:
|
||||
|
||||
```go
|
||||
result := slices.Contains[string](names, "alice") // 显式——不必要
|
||||
result := slices.Contains(names, "alice") // 推断——推荐
|
||||
```
|
||||
|
||||
仅在以下情况下才显式提供类型参数:没有可用于推断的函数参数、推断的类型不正确(例如无类型常量提升为错误的类型),或者将类型显式展示出来有助于可读性。
|
||||
|
||||
---
|
||||
|
||||
## 常见陷阱
|
||||
|
||||
### 接口已足够时不要使用泛型
|
||||
|
||||
> **规范**:来自 Google 风格指南——当类型共享一个有用的统一接口时,优先使用接口。
|
||||
|
||||
**不好**
|
||||
```go
|
||||
// T 仅用于满足 io.Reader——直接使用接口即可
|
||||
func Process[T io.Reader](r T) error { ... }
|
||||
```
|
||||
|
||||
**好**
|
||||
```go
|
||||
func Process(r io.Reader) error { ... }
|
||||
```
|
||||
|
||||
如果约束是单个已有接口,直接接受该接口。
|
||||
|
||||
### 不要泛型地包装标准库类型
|
||||
|
||||
> **建议**:单次使用的泛型只是多余的间接层。
|
||||
|
||||
**不好**
|
||||
```go
|
||||
type Set[T comparable] struct{ m map[T]struct{} } // 永远只是 Set[string]
|
||||
```
|
||||
|
||||
**好**
|
||||
```go
|
||||
seen := map[string]struct{}{} // 对于单次实例化直接使用 map
|
||||
```
|
||||
|
||||
泛型在消除**多个调用点**之间的重复时才能证明其复杂度的合理性。如果只使用一种类型,从具体类型开始。
|
||||
|
||||
### 方法集与类型约束
|
||||
|
||||
你只能调用约束允许的操作:
|
||||
|
||||
**不好**
|
||||
```go
|
||||
func Stringify[T any](v T) string {
|
||||
return v.String() // 编译错误:any 没有 String()
|
||||
}
|
||||
```
|
||||
|
||||
**好**
|
||||
```go
|
||||
func Stringify[T fmt.Stringer](v T) string {
|
||||
return v.String()
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 快速参考
|
||||
|
||||
| 主题 | 指导 |
|
||||
|------|------|
|
||||
| 默认约束 | `any`——不需要对 T 进行任何操作时使用 |
|
||||
| 相等性检查 | `comparable`——`==`、`!=` 和映射键所必需 |
|
||||
| 排序 | `cmp.Ordered`(Go 1.21+)用于 `<`、`>` 比较 |
|
||||
| 命名类型 | 使用 `~T` 接受底层类型为 T 的类型 |
|
||||
| 联合类型 | 使用 `\|` 组合——例如 `~int \| ~float64` |
|
||||
| 自定义约束 | 定义为包含类型元素和/或方法的接口 |
|
||||
| 类型推断 | 当编译器可以推断时省略类型参数 |
|
||||
| 最小约束 | 使用函数实际需要的最窄约束 |
|
||||
@@ -1,151 +0,0 @@
|
||||
---
|
||||
name: go-interfaces
|
||||
description: Use when defining or implementing Go interfaces, designing abstractions, creating mockable boundaries for testing, or composing types through embedding. Also use when deciding whether to accept an interface or return a concrete type, or using type assertions or type switches, even if the user doesn't explicitly mention interfaces. Does not cover generics-based polymorphism (see go-generics).
|
||||
license: Apache-2.0
|
||||
metadata:
|
||||
sources: "Effective Go, Google Style Guide, Uber Style Guide"
|
||||
allowed-tools: Bash(bash:*)
|
||||
---
|
||||
|
||||
# Go 接口与组合
|
||||
|
||||
## 可用脚本
|
||||
|
||||
- **`scripts/check-interface-compliance.sh`**——查找缺少编译时合规性检查(`var _ I = (*T)(nil)`)的导出接口。运行 `bash scripts/check-interface-compliance.sh --help` 查看选项。
|
||||
|
||||
---
|
||||
|
||||
## 接受接口,返回具体类型
|
||||
|
||||
接口属于**消费**值的包,而不是**实现**值的包。从构造函数返回具体类型(通常是指针或结构体),这样可以在不重构的情况下添加新方法。
|
||||
|
||||
```go
|
||||
// 好:消费者定义自己需要的接口
|
||||
package consumer
|
||||
|
||||
type Thinger interface { Thing() bool }
|
||||
|
||||
func Foo(t Thinger) string { ... }
|
||||
```
|
||||
|
||||
```go
|
||||
// 好:生产者返回具体类型
|
||||
package producer
|
||||
|
||||
type Thinger struct{ ... }
|
||||
func (t Thinger) Thing() bool { ... }
|
||||
func NewThinger() Thinger { return Thinger{ ... } }
|
||||
```
|
||||
|
||||
```go
|
||||
// 不好:生产者定义并返回自己的接口
|
||||
package producer
|
||||
|
||||
type Thinger interface { Thing() bool }
|
||||
type defaultThinger struct{ ... }
|
||||
func NewThinger() Thinger { return defaultThinger{ ... } }
|
||||
```
|
||||
|
||||
**不要在接口被使用之前定义它。** 如果没有现实的使用示例,很难判断接口是否真的有必要。
|
||||
|
||||
---
|
||||
|
||||
## 通用性:隐藏实现,暴露接口
|
||||
|
||||
如果一个类型仅用于实现某个接口,且没有该接口之外的导出方法,则从构造函数返回接口以隐藏实现:
|
||||
|
||||
```go
|
||||
func NewHash() hash.Hash32 {
|
||||
return &myHash{} // 未导出的类型
|
||||
}
|
||||
```
|
||||
|
||||
好处:实现可以在不影响调用者的情况下更改,替换算法只需更改构造函数调用。
|
||||
|
||||
---
|
||||
|
||||
## 类型断言:Comma-Ok 模式
|
||||
|
||||
不进行检查的话,失败的断言会导致运行时 panic。始终使用 comma-ok 模式进行安全测试:
|
||||
|
||||
```go
|
||||
str, ok := value.(string)
|
||||
if ok {
|
||||
fmt.Printf("string value is: %q\n", str)
|
||||
}
|
||||
```
|
||||
|
||||
检查值是否实现了某个接口:
|
||||
|
||||
```go
|
||||
if _, ok := val.(json.Marshaler); ok {
|
||||
fmt.Printf("value %v implements json.Marshaler\n", val)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 类型切换
|
||||
|
||||
重用变量名是惯用做法(`t := t.(type)`)——变量在每个 case 分支中拥有正确的类型。当 case 列出多个类型(`case int, int64:`)时,变量拥有接口类型。
|
||||
|
||||
---
|
||||
|
||||
## 嵌入
|
||||
|
||||
避免在公开结构体中嵌入类型——内部类型的完整方法集将成为你公开 API 的一部分。改用未导出的字段。
|
||||
|
||||
> 在使用结构体嵌入进行组合、重写嵌入方法、解决名称冲突、应用 HandlerFunc 适配器模式或决定是否在公开 API 类型中使用嵌入时,阅读 [references/EMBEDDING.md](references/EMBEDDING.md)。
|
||||
|
||||
---
|
||||
|
||||
## 接口满足检查
|
||||
|
||||
使用空标识符赋值在编译时验证类型是否实现了接口:
|
||||
|
||||
```go
|
||||
var _ json.Marshaler = (*RawMessage)(nil)
|
||||
```
|
||||
|
||||
如果 `*RawMessage` 没有实现 `json.Marshaler`,这会导致编译错误。
|
||||
|
||||
在以下情况下使用此模式:
|
||||
- 没有能自动验证接口的静态转换
|
||||
- 类型必须满足接口才能正确运行(例如自定义 JSON 序列化)
|
||||
- 接口更改应该导致编译失败,而不是静默降级
|
||||
|
||||
**不要**为每个接口都添加这些检查——仅在没有其他静态转换能捕获错误时才使用。
|
||||
|
||||
> **验证**:在定义接口或实现后,运行 `bash scripts/check-interface-compliance.sh` 验证所有具体类型都有编译时的 `var _ I = (*T)(nil)` 检查。
|
||||
|
||||
---
|
||||
|
||||
## 接收者类型
|
||||
|
||||
如果不确定,使用指针接收者。不要在单个类型上混合接收者类型——如果任何方法需要指针,则所有方法都使用指针。仅在小型不可变类型(`Point`、`time.Time`)或基本类型上使用值接收者。
|
||||
|
||||
> 在为新类型决定使用指针接收者还是值接收者时,特别是对于包含 sync 原语或大型结构体的类型,阅读 [references/RECEIVER-TYPE.md](references/RECEIVER-TYPE.md)。
|
||||
|
||||
---
|
||||
|
||||
## 快速参考
|
||||
|
||||
| 概念 | 模式 | 说明 |
|
||||
|------|------|------|
|
||||
| 消费者拥有接口 | 在使用处定义接口 | 不在实现包中 |
|
||||
| 安全类型断言 | `v, ok := x.(Type)` | 返回零值 + false |
|
||||
| 类型切换 | `switch v := x.(type)` | 变量在每个 case 中拥有正确类型 |
|
||||
| 接口嵌入 | `type RW interface { Reader; Writer }` | 方法的并集 |
|
||||
| 结构体嵌入 | `type S struct { *T }` | 提升 T 的方法 |
|
||||
| 接口检查 | `var _ I = (*T)(nil)` | 编译时验证 |
|
||||
| 通用性 | 从构造函数返回接口 | 隐藏实现 |
|
||||
|
||||
---
|
||||
|
||||
## 相关技能
|
||||
|
||||
- **接口命名**:在为接口命名(`-er` 后缀约定)或选择接收者名称时,参见 [go-naming](../go-naming/SKILL.md)
|
||||
- **错误类型**:在实现 `error` 接口、自定义错误类型或 `errors.As` 匹配时,参见 [go-error-handling](../go-error-handling/SKILL.md)
|
||||
- **泛型 vs 接口**:在决定是否需要泛型或接口是否已足够时,参见 [go-generics](../go-generics/SKILL.md)
|
||||
- **函数选项**:在使用基于接口的 Option 模式实现灵活构造函数时,参见 [go-functional-options](../go-functional-options/SKILL.md)
|
||||
- **编译时检查**:在 API 边界添加 `var _ I = (*T)(nil)` 满足检查时,参见 [go-defensive](../go-defensive/SKILL.md)
|
||||
@@ -1,138 +0,0 @@
|
||||
# Go 中的嵌入模式
|
||||
|
||||
> **来源**:Effective Go、Uber 风格指南
|
||||
|
||||
Go 使用嵌入来实现组合而非继承。嵌入将内部类型的方法提升到外部类型,自动满足接口。
|
||||
|
||||
## 接口嵌入
|
||||
|
||||
通过嵌入来组合接口:
|
||||
|
||||
```go
|
||||
type ReadWriter interface {
|
||||
Reader
|
||||
Writer
|
||||
}
|
||||
```
|
||||
|
||||
`ReadWriter` 既能做 `Reader` 能做的事,*也能*做 `Writer` 能做的事。接口中只能嵌入接口。
|
||||
|
||||
## 结构体嵌入
|
||||
|
||||
嵌入将内部类型的方法提升到外部类型,无需显式转发。
|
||||
|
||||
```go
|
||||
type ReadWriter struct {
|
||||
*Reader // *bufio.Reader
|
||||
*Writer // *bufio.Writer
|
||||
}
|
||||
```
|
||||
|
||||
通过嵌入,`bufio.ReadWriter` 自动满足 `io.Reader`、`io.Writer` 和 `io.ReadWriter`。
|
||||
|
||||
混合使用嵌入字段和命名字段:
|
||||
|
||||
```go
|
||||
type Job struct {
|
||||
Command string
|
||||
*log.Logger
|
||||
}
|
||||
|
||||
job.Println("starting now...")
|
||||
job.Logger.SetPrefix("Job: ")
|
||||
```
|
||||
|
||||
## 方法重写
|
||||
|
||||
在外部类型上定义方法以重写提升的方法:
|
||||
|
||||
```go
|
||||
func (job *Job) Printf(format string, args ...any) {
|
||||
job.Logger.Printf("%q: %s", job.Command, fmt.Sprintf(format, args...))
|
||||
}
|
||||
```
|
||||
|
||||
外部方法优先——对 `job.Printf(...)` 的调用会调用外部方法,而嵌入方法仍可通过 `job.Logger.Printf(...)` 访问。
|
||||
|
||||
## 嵌入 vs 子类化
|
||||
|
||||
当调用嵌入方法时,接收者是**内部**类型,而非外部类型。嵌入类型不知道自己被嵌入——不存在类似于 `this` 或 `super` 的引用指向包含它的类型。
|
||||
|
||||
```go
|
||||
type Base struct{}
|
||||
func (b *Base) Name() string { return "Base" }
|
||||
|
||||
type Derived struct{ Base }
|
||||
|
||||
d := Derived{}
|
||||
d.Name() // 返回 "Base",而非 "Derived"
|
||||
```
|
||||
|
||||
## 名称冲突解决
|
||||
|
||||
1. **外部隐藏内部**——外部类型上的字段或方法会遮蔽嵌入类型在同名位置提升的字段或方法
|
||||
2. **同级冲突是错误**——如果两个同深度的嵌入类型提升了相同的名称,则为编译错误(除非该名称从未被访问)
|
||||
|
||||
```go
|
||||
type A struct{}
|
||||
func (A) Hello() string { return "A" }
|
||||
|
||||
type B struct{}
|
||||
func (B) Hello() string { return "B" }
|
||||
|
||||
type C struct {
|
||||
A
|
||||
B
|
||||
}
|
||||
|
||||
// c.Hello() // 编译错误:选择器不明确
|
||||
c.A.Hello() // 可以:显式消歧
|
||||
```
|
||||
|
||||
## 不要在公开结构体中嵌入
|
||||
|
||||
嵌入将内部类型的完整方法集暴露为你的公开 API 的一部分。这带来了维护负担:嵌入类型方法的更改会破坏 API 的兼容性保证。
|
||||
|
||||
**不好**
|
||||
```go
|
||||
type SMap struct {
|
||||
sync.Mutex // Lock 和 Unlock 现在是 SMap API 的一部分
|
||||
data map[string]string
|
||||
}
|
||||
```
|
||||
|
||||
**好**
|
||||
```go
|
||||
type SMap struct {
|
||||
mu sync.Mutex // 未导出的字段——实现细节
|
||||
data map[string]string
|
||||
}
|
||||
|
||||
func (m *SMap) Get(k string) string {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.data[k]
|
||||
}
|
||||
```
|
||||
|
||||
例外:在测试类型和 API 稳定性无关紧要的内部结构体中,嵌入是可以接受的。
|
||||
|
||||
## HandlerFunc 适配器模式
|
||||
|
||||
方法可以在任何命名类型上定义,不仅仅是结构体。`http.HandlerFunc` 模式将普通函数转换为接口实现:
|
||||
|
||||
```go
|
||||
type HandlerFunc func(ResponseWriter, *Request)
|
||||
|
||||
func (f HandlerFunc) ServeHTTP(w ResponseWriter, req *Request) {
|
||||
f(w, req)
|
||||
}
|
||||
```
|
||||
|
||||
任何具有正确签名的函数都可以成为 HTTP 处理器:
|
||||
|
||||
```go
|
||||
http.Handle("/args", http.HandlerFunc(ArgServer))
|
||||
```
|
||||
|
||||
这种适配器模式在需要让独立函数满足单方法接口时非常有用。
|
||||
@@ -1,68 +0,0 @@
|
||||
# 接收者类型:指针 vs 值
|
||||
|
||||
> **建议**:Go Wiki CodeReviewComments
|
||||
|
||||
选择在方法上使用值接收者还是指针接收者可能很困难。**如果不确定,使用指针**,但有时值接收者也是合理的。
|
||||
|
||||
## 何时使用指针接收者
|
||||
|
||||
- **方法修改接收者**:接收者必须是指针
|
||||
- **接收者包含 sync.Mutex 或类似类型**:必须使用指针以避免复制
|
||||
- **大型结构体或数组**:指针接收者更高效。如果将所有元素作为参数传递感觉太大,那对值接收者来说也太大了
|
||||
- **并发或被调方法可能修改**:如果更改必须对原始接收者可见,则必须使用指针
|
||||
- **元素是指向可变内容的指针**:优先使用指针接收者使意图更清晰
|
||||
|
||||
## 何时使用值接收者
|
||||
|
||||
- **小型不变的结构体或基本类型**:值接收者以提高效率
|
||||
- **Map、func 或 chan**:不要对它们使用指针
|
||||
- **不重新切片/重新分配的切片**:如果方法不重新切片或重新分配切片,不要使用指针
|
||||
- **没有可变字段的小型值类型**:像 `time.Time` 这样没有可变字段且没有指针的类型适合作为值接收者
|
||||
- **简单基本类型**:`int`、`string` 等
|
||||
|
||||
```go
|
||||
// 值接收者:小型、不可变类型
|
||||
type Point struct {
|
||||
X, Y float64
|
||||
}
|
||||
|
||||
func (p Point) Distance(q Point) float64 {
|
||||
return math.Hypot(q.X-p.X, q.Y-p.Y)
|
||||
}
|
||||
|
||||
// 指针接收者:方法修改接收者
|
||||
func (p *Point) ScaleBy(factor float64) {
|
||||
p.X *= factor
|
||||
p.Y *= factor
|
||||
}
|
||||
|
||||
// 指针接收者:包含 sync.Mutex
|
||||
type Counter struct {
|
||||
mu sync.Mutex
|
||||
count int
|
||||
}
|
||||
|
||||
func (c *Counter) Increment() {
|
||||
c.mu.Lock()
|
||||
c.count++
|
||||
c.mu.Unlock()
|
||||
}
|
||||
```
|
||||
|
||||
## 一致性规则
|
||||
|
||||
**不要混合接收者类型**。为类型上所有可用的方法统一选择指针或结构体类型。如果任何方法需要指针接收者,则所有方法都使用指针接收者。
|
||||
|
||||
```go
|
||||
// 好:一致的指针接收者
|
||||
type Buffer struct {
|
||||
data []byte
|
||||
}
|
||||
|
||||
func (b *Buffer) Write(p []byte) (int, error) { /* ... */ }
|
||||
func (b *Buffer) Read(p []byte) (int, error) { /* ... */ }
|
||||
func (b *Buffer) Len() int { return len(b.data) }
|
||||
|
||||
// 不好:混合接收者类型
|
||||
func (b Buffer) Len() int { return len(b.data) } // 不一致
|
||||
```
|
||||
@@ -1,224 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="1.0.0"
|
||||
SCRIPT_NAME="$(basename "$0")"
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
$SCRIPT_NAME v$VERSION — Check for missing compile-time interface compliance verifications
|
||||
|
||||
USAGE
|
||||
bash $SCRIPT_NAME [options] [path]
|
||||
|
||||
DESCRIPTION
|
||||
Scans Go files for exported interface definitions and checks whether each
|
||||
has a corresponding compile-time compliance assertion like:
|
||||
|
||||
var _ MyInterface = (*MyImpl)(nil)
|
||||
var _ MyInterface = MyImpl{}
|
||||
|
||||
Reports interfaces that lack such compile-time checks. This helps catch
|
||||
interface drift at compile time instead of runtime.
|
||||
|
||||
Exits 0 if all interfaces are verified, 1 if missing checks found, 2 on error.
|
||||
|
||||
OPTIONS
|
||||
-h, --help Show this help message
|
||||
-v, --version Show version
|
||||
--json Output results as JSON
|
||||
--include-test Also scan _test.go files for compliance checks
|
||||
--limit N Show at most N results (default: all)
|
||||
|
||||
ARGUMENTS
|
||||
path Directory to scan (default: current directory)
|
||||
|
||||
EXAMPLES
|
||||
bash $SCRIPT_NAME
|
||||
bash $SCRIPT_NAME ./pkg/storage
|
||||
bash $SCRIPT_NAME --json .
|
||||
bash $SCRIPT_NAME --include-test ./internal
|
||||
EOF
|
||||
}
|
||||
|
||||
JSON_OUTPUT=false
|
||||
INCLUDE_TEST=false
|
||||
LIMIT=0
|
||||
TARGET=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-h|--help) usage; exit 0 ;;
|
||||
-v|--version) echo "$SCRIPT_NAME v$VERSION"; exit 0 ;;
|
||||
--json) JSON_OUTPUT=true; shift ;;
|
||||
--include-test) INCLUDE_TEST=true; shift ;;
|
||||
--limit) LIMIT="${2:?error: --limit requires a number}"; shift 2 ;;
|
||||
-*) echo "error: unknown option: $1" >&2; usage >&2; exit 2 ;;
|
||||
*) TARGET="$1"; shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
TARGET="${TARGET:-.}"
|
||||
|
||||
if [[ ! -d "$TARGET" && ! -f "$TARGET" ]]; then
|
||||
# Handle ./... patterns
|
||||
dir="${TARGET%%/...}"
|
||||
dir="${dir:-.}"
|
||||
if [[ ! -d "$dir" ]]; then
|
||||
echo "error: path not found: $TARGET" >&2
|
||||
exit 2
|
||||
fi
|
||||
TARGET="$dir"
|
||||
fi
|
||||
|
||||
json_escape() {
|
||||
local s="$1"
|
||||
s="${s//\\/\\\\}"
|
||||
s="${s//\"/\\\"}"
|
||||
s="${s//$'\t'/\\t}"
|
||||
s="${s//$'\r'/}"
|
||||
s="${s//$'\n'/\\n}"
|
||||
printf '%s' "$s"
|
||||
}
|
||||
|
||||
# Collect all Go source files
|
||||
find_go_files() {
|
||||
local t="$1"
|
||||
if $INCLUDE_TEST; then
|
||||
find "$t" -name '*.go' ! -path '*/vendor/*' ! -path '*/.git/*' 2>/dev/null
|
||||
else
|
||||
find "$t" -name '*.go' ! -name '*_test.go' ! -path '*/vendor/*' ! -path '*/.git/*' 2>/dev/null
|
||||
fi
|
||||
}
|
||||
|
||||
# Collect all Go files (including tests) for checking compliance vars
|
||||
find_all_go_files() {
|
||||
find "$1" -name '*.go' ! -path '*/vendor/*' ! -path '*/.git/*' 2>/dev/null
|
||||
}
|
||||
|
||||
# Step 1: Find all exported interface definitions
|
||||
IFACE_NAMES=()
|
||||
IFACE_LOCATIONS=()
|
||||
|
||||
while IFS= read -r file; do
|
||||
[[ -n "$file" ]] || continue
|
||||
line_num=0
|
||||
while IFS= read -r line; do
|
||||
line_num=$((line_num + 1))
|
||||
# Match: type ExportedName interface {
|
||||
pat='^[[:space:]]*type[[:space:]]+([A-Z][a-zA-Z0-9]*)[[:space:]]+interface[[:space:]]*\{'
|
||||
if [[ "$line" =~ $pat ]]; then
|
||||
iface_name="${BASH_REMATCH[1]}"
|
||||
IFACE_NAMES+=("$iface_name")
|
||||
IFACE_LOCATIONS+=("$file:$line_num")
|
||||
fi
|
||||
done < "$file"
|
||||
done < <(find_go_files "$TARGET")
|
||||
|
||||
if [[ ${#IFACE_NAMES[@]} -eq 0 ]]; then
|
||||
if $JSON_OUTPUT; then
|
||||
echo '{"interfaces":[],"missing":[],"count_interfaces":0,"count_missing":0}'
|
||||
else
|
||||
echo "No exported interfaces found in: $TARGET"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Step 2: Scan all Go files (including tests) for compliance checks
|
||||
# Pattern: var _ InterfaceName = ...
|
||||
ALL_GO_FILES=()
|
||||
while IFS= read -r f; do
|
||||
[[ -n "$f" ]] && ALL_GO_FILES+=("$f")
|
||||
done < <(find_all_go_files "$TARGET")
|
||||
|
||||
MISSING=()
|
||||
|
||||
for ((i=0; i<${#IFACE_NAMES[@]}; i++)); do
|
||||
iface_name="${IFACE_NAMES[$i]}"
|
||||
location="${IFACE_LOCATIONS[$i]}"
|
||||
# Look for: var _ InterfaceName = (various patterns)
|
||||
if ! grep -qlE "var[[:space:]]+_[[:space:]]+${iface_name}[[:space:]]*=" \
|
||||
"${ALL_GO_FILES[@]}" 2>/dev/null; then
|
||||
MISSING+=("${iface_name}|${location}")
|
||||
fi
|
||||
done
|
||||
|
||||
# Sort for stable output
|
||||
IFS=$'\n' MISSING=($(sort <<<"${MISSING[*]}")); unset IFS
|
||||
|
||||
# Truncation
|
||||
TOTAL=${#MISSING[@]}
|
||||
TRUNCATED=false
|
||||
if [[ $LIMIT -gt 0 && $TOTAL -gt $LIMIT ]]; then
|
||||
MISSING=("${MISSING[@]:0:$LIMIT}")
|
||||
TRUNCATED=true
|
||||
fi
|
||||
|
||||
# Output results
|
||||
if $JSON_OUTPUT; then
|
||||
echo "{"
|
||||
echo ' "interfaces": ['
|
||||
first=true
|
||||
SORTED_INDICES=()
|
||||
for ((i=0; i<${#IFACE_NAMES[@]}; i++)); do
|
||||
SORTED_INDICES+=("$i|${IFACE_NAMES[$i]}")
|
||||
done
|
||||
IFS=$'\n' SORTED_INDICES=($(sort -t'|' -k2 <<<"${SORTED_INDICES[*]}")); unset IFS
|
||||
|
||||
for entry in "${SORTED_INDICES[@]}"; do
|
||||
i="${entry%%|*}"
|
||||
iface_name="${IFACE_NAMES[$i]}"
|
||||
location="${IFACE_LOCATIONS[$i]}"
|
||||
file="${location%%:*}"
|
||||
line="${location#*:}"
|
||||
$first || echo ","
|
||||
first=false
|
||||
printf ' {"name":"%s","file":"%s","line":%s}' "$(json_escape "$iface_name")" "$(json_escape "$file")" "$line"
|
||||
done
|
||||
echo ""
|
||||
echo " ],"
|
||||
echo ' "missing": ['
|
||||
first=true
|
||||
for entry in "${MISSING[@]+"${MISSING[@]}"}"; do
|
||||
IFS='|' read -r name location <<< "$entry"
|
||||
file="${location%%:*}"
|
||||
line="${location#*:}"
|
||||
$first || echo ","
|
||||
first=false
|
||||
printf ' {"name":"%s","file":"%s","line":%s}' "$(json_escape "$name")" "$(json_escape "$file")" "$line"
|
||||
done
|
||||
echo ""
|
||||
echo " ],"
|
||||
printf ' "count_interfaces": %d,\n' "${#IFACE_NAMES[@]}"
|
||||
printf ' "count_missing": %d,\n' "$TOTAL"
|
||||
printf ' "truncated": %s\n' "$TRUNCATED"
|
||||
echo "}"
|
||||
else
|
||||
echo "Exported interfaces found: ${#IFACE_NAMES[@]}"
|
||||
echo ""
|
||||
|
||||
if [[ $TOTAL -eq 0 ]]; then
|
||||
echo "All interfaces have compile-time compliance checks."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Missing compile-time compliance checks:"
|
||||
echo ""
|
||||
for entry in "${MISSING[@]}"; do
|
||||
IFS='|' read -r name location <<< "$entry"
|
||||
printf " %s interface '%s' has no 'var _ %s = ...' assertion\n" "$location" "$name" "$name"
|
||||
done
|
||||
if $TRUNCATED; then
|
||||
echo " ... and $((TOTAL - LIMIT)) more (use --limit to adjust)"
|
||||
fi
|
||||
echo ""
|
||||
echo "Add compile-time checks like:"
|
||||
echo " var _ MyInterface = (*MyImpl)(nil)"
|
||||
echo ""
|
||||
echo "Total: $TOTAL interface(s) missing verification"
|
||||
fi
|
||||
|
||||
if [[ $TOTAL -gt 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
@@ -1,209 +0,0 @@
|
||||
---
|
||||
name: go-linting
|
||||
description: Use when setting up linting for a Go project, configuring golangci-lint, or adding Go checks to a CI/CD pipeline. Also use when starting a new Go project and deciding which linters to enable, even if the user only asks about "code quality" or "static analysis" without mentioning specific linter names. Does not cover code review process (see go-code-review).
|
||||
license: Apache-2.0
|
||||
metadata:
|
||||
sources: "Uber Style Guide"
|
||||
allowed-tools: Bash(bash:*)
|
||||
---
|
||||
|
||||
# Go Lint
|
||||
|
||||
## 核心原则
|
||||
|
||||
比任何"推荐"的 linter 集合更重要的是:**在整个代码库中一致地进行 lint**。
|
||||
|
||||
一致的 lint 有助于捕获常见问题,并在不过度限制的情况下建立高标准的代码质量。
|
||||
|
||||
---
|
||||
|
||||
## 设置步骤
|
||||
|
||||
1. 使用下面的配置创建 `.golangci.yml`
|
||||
2. 运行 `golangci-lint run ./...`
|
||||
3. 如果出现错误,按类别逐一修复(先格式化,再 vet,再风格)
|
||||
4. 重新运行直到通过
|
||||
|
||||
---
|
||||
|
||||
## 最低推荐 Linter
|
||||
|
||||
这些 linter 能捕获最常见的问题,同时保持高质量标准:
|
||||
|
||||
| Linter | 用途 |
|
||||
|--------|------|
|
||||
| [errcheck](https://github.com/kisielk/errcheck) | 确保错误被处理 |
|
||||
| [goimports](https://pkg.go.dev/golang.org/x/tools/cmd/goimports) | 格式化代码和管理导入 |
|
||||
| [revive](https://github.com/mgechev/revive) | 常见风格错误(golint 的现代替代品) |
|
||||
| [govet](https://pkg.go.dev/cmd/vet) | 分析代码中的常见错误 |
|
||||
| [staticcheck](https://staticcheck.dev) | 各种静态分析检查 |
|
||||
|
||||
> **注意**:`revive` 是现已弃用的 `golint` 的现代、更快的替代品。
|
||||
|
||||
---
|
||||
|
||||
## Lint 运行器:golangci-lint
|
||||
|
||||
使用 [golangci-lint](https://github.com/golangci/golangci-lint) 作为你的 lint 运行器。参见 uber-go/guide 的 [示例 .golangci.yml](https://github.com/uber-go/guide/blob/master/.golangci.yml)。
|
||||
|
||||
---
|
||||
|
||||
## 示例配置
|
||||
|
||||
> 在创建新的 `.golangci.yml` 或将现有配置与推荐基线进行比较时,参见 `assets/golangci.yml`。
|
||||
|
||||
在项目根目录创建 `.golangci.yml`:
|
||||
|
||||
```yaml
|
||||
linters:
|
||||
enable:
|
||||
- errcheck
|
||||
- goimports
|
||||
- revive
|
||||
- govet
|
||||
- staticcheck
|
||||
|
||||
linters-settings:
|
||||
goimports:
|
||||
local-prefixes: github.com/your-org/your-repo
|
||||
revive:
|
||||
rules:
|
||||
- name: blank-imports
|
||||
- name: context-as-argument
|
||||
- name: error-return
|
||||
- name: error-strings
|
||||
- name: exported
|
||||
|
||||
run:
|
||||
timeout: 5m
|
||||
```
|
||||
|
||||
### 运行
|
||||
|
||||
```bash
|
||||
# 安装
|
||||
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
|
||||
|
||||
# 运行所有 linter
|
||||
golangci-lint run
|
||||
|
||||
# 对特定路径运行
|
||||
golangci-lint run ./pkg/...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 额外推荐的 Linter
|
||||
|
||||
除了最低集合之外,在生产项目中可以考虑以下 linter:
|
||||
|
||||
| Linter | 用途 | 何时启用 |
|
||||
|--------|------|----------|
|
||||
| [gosec](https://github.com/securego/gosec) | 安全漏洞检测 | 处理用户输入的服务始终启用 |
|
||||
| [ineffassign](https://github.com/gordonklaus/ineffassign) | 检测无效赋值 | 始终——捕获死代码 |
|
||||
| [misspell](https://github.com/client9/misspell) | 纠正注释/字符串中的常见拼写错误 | 始终 |
|
||||
| [gocyclo](https://github.com/fzipp/gocyclo) | 圈复杂度阈值 | 当函数超过约 15 的复杂度时 |
|
||||
| [exhaustive](https://github.com/nishanths/exhaustive) | 确保 switch 覆盖所有枚举值 | 使用 iota 枚举时 |
|
||||
| [bodyclose](https://github.com/timakin/bodyclose) | 检测未关闭的 HTTP 响应体 | HTTP 客户端代码始终启用 |
|
||||
|
||||
---
|
||||
|
||||
## Nolint 指令
|
||||
|
||||
在抑制 lint 发现时,始终说明原因:
|
||||
|
||||
```go
|
||||
//nolint:errcheck // 即发即忘的日志;错误不可操作
|
||||
_ = logger.Sync()
|
||||
```
|
||||
|
||||
规则:
|
||||
- 使用 `//nolint:lintername`——永远不要使用裸 `//nolint`
|
||||
- 将注释放在与发现相同的行
|
||||
- 在 `//` 之后包含理由说明
|
||||
|
||||
---
|
||||
|
||||
## CI/CD 集成
|
||||
|
||||
### GitHub Actions
|
||||
|
||||
```yaml
|
||||
# .github/workflows/lint.yml
|
||||
name: Lint
|
||||
on: [push, pull_request]
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: stable
|
||||
- uses: golangci/golangci-lint-action@v6
|
||||
with:
|
||||
version: latest
|
||||
```
|
||||
|
||||
### Pre-commit Hook
|
||||
|
||||
```bash
|
||||
#!/bin/sh
|
||||
# .git/hooks/pre-commit
|
||||
golangci-lint run --new-from-rev=HEAD~1
|
||||
```
|
||||
|
||||
使用 `--new-from-rev` 只对更改的代码进行 lint,保持快速反馈循环。
|
||||
|
||||
---
|
||||
|
||||
## 可用脚本
|
||||
|
||||
- **`scripts/setup-lint.sh`**——生成 `.golangci.yml` 并运行初始 lint
|
||||
|
||||
```bash
|
||||
bash scripts/setup-lint.sh github.com/your-org/your-repo
|
||||
bash scripts/setup-lint.sh --force github.com/your-org/your-repo # 覆盖现有配置
|
||||
bash scripts/setup-lint.sh --dry-run # 预览配置
|
||||
bash scripts/setup-lint.sh --json # 结构化输出
|
||||
```
|
||||
|
||||
> **验证**:在生成 `.golangci.yml` 后,运行 `golangci-lint run ./...` 验证配置有效并产生预期输出。如果因配置错误而失败,修复后重试。
|
||||
|
||||
> `scripts/setup-lint.sh` 生成**最低**配置(5 个核心 linter)。
|
||||
> 对于已有项目,使用 `assets/golangci.yml` 作为起点——
|
||||
> 它增加了 gosec、ineffassign、misspell、gocyclo 和 bodyclose。
|
||||
|
||||
---
|
||||
|
||||
## 快速参考
|
||||
|
||||
| 任务 | 命令/操作 |
|
||||
|------|-----------|
|
||||
| 安装 golangci-lint | `go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest` |
|
||||
| 运行 linter | `golangci-lint run` |
|
||||
| 对路径运行 | `golangci-lint run ./pkg/...` |
|
||||
| 配置文件 | 项目根目录的 `.golangci.yml` |
|
||||
| CI 集成 | 在管道中运行 `golangci-lint run` |
|
||||
| Nolint 指令 | `//nolint:name // 原因`——永远不要使用裸 `//nolint` |
|
||||
| CI 集成 | 使用 `golangci/golangci-lint-action` 用于 GitHub Actions |
|
||||
| Pre-commit | `golangci-lint run --new-from-rev=HEAD~1` |
|
||||
|
||||
### Linter 选择指南
|
||||
|
||||
| 当你需要... | 使用 |
|
||||
|-------------|------|
|
||||
| 错误处理覆盖率 | errcheck |
|
||||
| 导入格式化 | goimports |
|
||||
| 风格一致性 | revive |
|
||||
| Bug 检测 | govet、staticcheck |
|
||||
| 以上全部 | golangci-lint 配合配置 |
|
||||
|
||||
---
|
||||
|
||||
## 相关技能
|
||||
|
||||
- **风格基础**:在解决 linter 执行的风格问题(格式化、嵌套、命名)时,参见 [go-style-core](../go-style-core/SKILL.md)
|
||||
- **代码审查**:在将 linter 输出与手动审查清单结合使用时,参见 [go-code-review](../go-code-review/SKILL.md)
|
||||
- **错误处理**:在 errcheck 标记未处理的错误并需要决定如何处理时,参见 [go-error-handling](../go-error-handling/SKILL.md)
|
||||
- **测试**:在 CI 管道中将 linter 与测试一起运行时,参见 [go-testing](../go-testing/SKILL.md)
|
||||
@@ -1,31 +0,0 @@
|
||||
run:
|
||||
timeout: 5m
|
||||
|
||||
linters:
|
||||
enable:
|
||||
# Minimum recommended
|
||||
- errcheck
|
||||
- goimports
|
||||
- revive
|
||||
- govet
|
||||
- staticcheck
|
||||
# Additional recommended
|
||||
- gosec
|
||||
- ineffassign
|
||||
- misspell
|
||||
- gocyclo
|
||||
- bodyclose
|
||||
|
||||
linters-settings:
|
||||
goimports:
|
||||
local-prefixes: "" # Set to your module path
|
||||
revive:
|
||||
rules:
|
||||
- name: exported
|
||||
gocyclo:
|
||||
min-complexity: 15
|
||||
|
||||
issues:
|
||||
exclude-use-default: false
|
||||
max-issues-per-linter: 0
|
||||
max-same-issues: 0
|
||||
@@ -1,172 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="1.0.0"
|
||||
SCRIPT_NAME="$(basename "$0")"
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
$SCRIPT_NAME v$VERSION — Generate .golangci.yml and run initial lint
|
||||
|
||||
USAGE
|
||||
bash $SCRIPT_NAME [options] [local-prefix]
|
||||
|
||||
DESCRIPTION
|
||||
Creates a .golangci.yml with a curated set of linters (errcheck,
|
||||
goimports, revive, govet, staticcheck) and runs golangci-lint.
|
||||
If local-prefix is provided, configures goimports to group local
|
||||
imports separately.
|
||||
|
||||
Exits 0 if lint passes, 1 if lint issues found, 2 on error.
|
||||
|
||||
OPTIONS
|
||||
-h, --help Show this help message
|
||||
-v, --version Show version
|
||||
--json Output results as JSON
|
||||
--force Overwrite existing .golangci.yml
|
||||
--dry-run Print generated config to stdout without writing
|
||||
--limit N Max lint issue lines in JSON output (default: 50, 0 = unlimited)
|
||||
|
||||
ARGUMENTS
|
||||
local-prefix Module path prefix for goimports grouping
|
||||
(e.g., github.com/myorg/myrepo)
|
||||
|
||||
EXAMPLES
|
||||
bash $SCRIPT_NAME
|
||||
bash $SCRIPT_NAME github.com/myorg/myrepo
|
||||
bash $SCRIPT_NAME --force github.com/myorg/myrepo
|
||||
bash $SCRIPT_NAME --dry-run github.com/myorg/myrepo
|
||||
bash $SCRIPT_NAME --json
|
||||
bash $SCRIPT_NAME --json --limit 20
|
||||
EOF
|
||||
}
|
||||
|
||||
json_escape() {
|
||||
local s="$1"
|
||||
s="${s//\\/\\\\}"
|
||||
s="${s//\"/\\\"}"
|
||||
s="${s//$'\t'/\\t}"
|
||||
s="${s//$'\r'/}"
|
||||
s="${s//$'\n'/\\n}"
|
||||
printf '%s' "$s"
|
||||
}
|
||||
|
||||
JSON_OUTPUT=false
|
||||
FORCE=false
|
||||
DRY_RUN=false
|
||||
LIMIT=50
|
||||
LOCAL_PREFIX=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-h|--help) usage; exit 0 ;;
|
||||
-v|--version) echo "$SCRIPT_NAME v$VERSION"; exit 0 ;;
|
||||
--json) JSON_OUTPUT=true; shift ;;
|
||||
--force) FORCE=true; shift ;;
|
||||
--dry-run) DRY_RUN=true; shift ;;
|
||||
--limit) LIMIT="${2:?error: --limit requires a number}"; shift 2 ;;
|
||||
-*) echo "error: unknown option: $1" >&2; usage >&2; exit 2 ;;
|
||||
*) LOCAL_PREFIX="$1"; shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
generate_config() {
|
||||
cat <<'YAML'
|
||||
linters:
|
||||
enable:
|
||||
- errcheck
|
||||
- goimports
|
||||
- revive
|
||||
- govet
|
||||
- staticcheck
|
||||
|
||||
linters-settings:
|
||||
YAML
|
||||
|
||||
if [[ -n "$LOCAL_PREFIX" ]]; then
|
||||
cat <<YAML
|
||||
goimports:
|
||||
local-prefixes: ${LOCAL_PREFIX}
|
||||
YAML
|
||||
fi
|
||||
|
||||
cat <<'YAML'
|
||||
revive:
|
||||
rules:
|
||||
- name: blank-imports
|
||||
- name: context-as-argument
|
||||
- name: error-return
|
||||
- name: error-strings
|
||||
- name: exported
|
||||
|
||||
run:
|
||||
timeout: 5m
|
||||
YAML
|
||||
}
|
||||
|
||||
if $DRY_RUN; then
|
||||
generate_config
|
||||
exit 0
|
||||
fi
|
||||
|
||||
CONFIG_PATH=".golangci.yml"
|
||||
|
||||
if [[ -f "$CONFIG_PATH" ]] && ! $FORCE; then
|
||||
echo "error: $CONFIG_PATH already exists (use --force to overwrite)" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
generate_config > "$CONFIG_PATH"
|
||||
|
||||
LINT_OUTPUT=""
|
||||
LINT_EXIT=0
|
||||
if ! command -v golangci-lint &>/dev/null; then
|
||||
echo "error: golangci-lint is not installed" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
LINT_OUTPUT=$(golangci-lint run ./... 2>&1) || LINT_EXIT=$?
|
||||
|
||||
if $JSON_OUTPUT; then
|
||||
LINT_TRUNCATED=false
|
||||
LINT_DISPLAY="$LINT_OUTPUT"
|
||||
if [[ $LIMIT -gt 0 && -n "$LINT_OUTPUT" ]]; then
|
||||
LINT_ARR=()
|
||||
while IFS= read -r line; do
|
||||
LINT_ARR+=("$line")
|
||||
done <<< "$LINT_OUTPUT"
|
||||
if [[ ${#LINT_ARR[@]} -gt $LIMIT ]]; then
|
||||
LINT_DISPLAY=""
|
||||
for (( i=0; i<LIMIT; i++ )); do
|
||||
[[ -n "$LINT_DISPLAY" ]] && LINT_DISPLAY+=$'\n'
|
||||
LINT_DISPLAY+="${LINT_ARR[$i]}"
|
||||
done
|
||||
LINT_TRUNCATED=true
|
||||
fi
|
||||
fi
|
||||
LINT_ESC="$(json_escape "$LINT_DISPLAY")"
|
||||
CONFIG_ESC="$(json_escape "$CONFIG_PATH")"
|
||||
PREFIX_ESC="$(json_escape "$LOCAL_PREFIX")"
|
||||
CREATED=true
|
||||
HAS_ISSUES=$( [[ $LINT_EXIT -ne 0 ]] && echo true || echo false )
|
||||
TRUNC_FIELD=""
|
||||
$LINT_TRUNCATED && TRUNC_FIELD=',"truncated":true'
|
||||
cat <<EOF
|
||||
{"config_path":"$CONFIG_ESC","local_prefix":"$PREFIX_ESC","created":$CREATED,"lint_issues":$HAS_ISSUES,"lint_output":"$LINT_ESC"$TRUNC_FIELD}
|
||||
EOF
|
||||
else
|
||||
echo "Created $CONFIG_PATH"
|
||||
if [[ $LINT_EXIT -ne 0 ]]; then
|
||||
echo ""
|
||||
echo "$LINT_OUTPUT"
|
||||
echo ""
|
||||
echo "Lint issues found — fix them category by category (formatting first, then vet, then style)."
|
||||
else
|
||||
echo "golangci-lint: all clean."
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ $LINT_EXIT -ne 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
@@ -1,179 +0,0 @@
|
||||
---
|
||||
name: go-style-core
|
||||
description: Use when working with Go formatting, line length, nesting, naked returns, semicolons, or core style principles. Also use when a style question isn't covered by a more specific skill, even if the user doesn't reference a specific style rule. Does not cover domain-specific patterns like error handling, naming, or testing (see specialized skills). Acts as fallback when no more specific style skill applies.
|
||||
license: Apache-2.0
|
||||
metadata:
|
||||
sources: "Effective Go, Google Style Guide, Uber Style Guide, Go Wiki CodeReviewComments"
|
||||
---
|
||||
|
||||
# Go 风格核心原则
|
||||
|
||||
## 风格原则(优先级顺序)
|
||||
|
||||
编写可读 Go 代码时,按以下重要性顺序应用这些原则:
|
||||
|
||||
### 优先级顺序
|
||||
|
||||
1. **清晰性** — 读者能否在没有额外上下文的情况下理解代码?
|
||||
2. **简洁性** — 这是否是实现目标的最简单方式?
|
||||
3. **精炼性** — 每一行是否都有其存在的价值?
|
||||
4. **可维护性** — 后续修改是否容易?
|
||||
5. **一致性** — 是否与周围代码和项目约定保持一致?
|
||||
|
||||
> 在解决清晰性、简洁性和精炼性之间的冲突时,或需要具体示例了解每个原则在实际 Go 代码中的应用时,请阅读 [references/PRINCIPLES.md](references/PRINCIPLES.md)。
|
||||
|
||||
---
|
||||
|
||||
## 格式化
|
||||
|
||||
运行 `gofmt` — 没有例外。**没有严格的行长度限制**,但 Uber 建议软限制为 99 个字符。按语义换行,而非按长度 — 选择重构而非仅仅换行。
|
||||
|
||||
> 在配置 gofmt、决定换行策略、应用 MixedCaps 规则或解决局部一致性问题时,请阅读 [references/FORMATTING.md](references/FORMATTING.md)。
|
||||
|
||||
---
|
||||
|
||||
## 减少嵌套
|
||||
|
||||
优先处理错误情况和特殊条件。提前返回或继续循环,使"正常路径"保持无缩进。
|
||||
|
||||
```go
|
||||
// 不好:深度嵌套
|
||||
for _, v := range data {
|
||||
if v.F1 == 1 {
|
||||
v = process(v)
|
||||
if err := v.Call(); err == nil {
|
||||
v.Send()
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
log.Printf("Invalid v: %v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// 好:扁平结构,提前返回
|
||||
for _, v := range data {
|
||||
if v.F1 != 1 {
|
||||
log.Printf("Invalid v: %v", v)
|
||||
continue
|
||||
}
|
||||
|
||||
v = process(v)
|
||||
if err := v.Call(); err != nil {
|
||||
return err
|
||||
}
|
||||
v.Send()
|
||||
}
|
||||
```
|
||||
|
||||
### 不必要的 Else
|
||||
|
||||
如果变量在 if 的两个分支中都被赋值,使用默认值 + 覆盖模式。
|
||||
|
||||
```go
|
||||
// 不好:在两个分支中都赋值
|
||||
var a int
|
||||
if b {
|
||||
a = 100
|
||||
} else {
|
||||
a = 10
|
||||
}
|
||||
|
||||
// 好:默认值 + 覆盖
|
||||
a := 10
|
||||
if b {
|
||||
a = 100
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 裸返回
|
||||
|
||||
没有参数的 `return` 语句会返回命名返回值。这被称为"裸"返回。
|
||||
|
||||
```go
|
||||
func split(sum int) (x, y int) {
|
||||
x = sum * 4 / 9
|
||||
y = sum - x
|
||||
return // 返回 x, y
|
||||
}
|
||||
```
|
||||
|
||||
### 裸返回的使用指南
|
||||
|
||||
- **在小型函数中可以使用**:裸返回在只有几行的函数中是没问题的
|
||||
- **在中大型函数中要明确**:一旦函数增长到中等大小,为了清晰起见应明确指定返回值
|
||||
- **不要仅为了裸返回而命名返回值**:文档的清晰性始终比节省一两行更重要
|
||||
|
||||
```go
|
||||
// 好:小型函数,裸返回很清晰
|
||||
func minMax(a, b int) (min, max int) {
|
||||
if a < b {
|
||||
min, max = a, b
|
||||
} else {
|
||||
min, max = b, a
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// 好:较大的函数,显式返回
|
||||
func processData(data []byte) (result []byte, err error) {
|
||||
result = make([]byte, 0, len(data))
|
||||
|
||||
for _, b := range data {
|
||||
if b == 0 {
|
||||
return nil, errors.New("null byte in data")
|
||||
}
|
||||
result = append(result, transform(b))
|
||||
}
|
||||
|
||||
return result, nil // 显式返回:在较长的函数中更清晰
|
||||
}
|
||||
```
|
||||
|
||||
关于命名返回参数的指导,请参阅 **go-documentation**。
|
||||
|
||||
---
|
||||
|
||||
## 分号
|
||||
|
||||
Go 的词法分析器会在任何最后一个 token 是标识符、字面量或以下关键字之一的行后自动插入分号:`break continue fallthrough return ++ -- ) }`。
|
||||
|
||||
这意味着 **左花括号必须与控制结构在同一行**:
|
||||
|
||||
```go
|
||||
// 好:花括号在同一行
|
||||
if i < f() {
|
||||
g()
|
||||
}
|
||||
|
||||
// 不好:花括号在下一行 — 词法分析器会在 f() 后插入分号
|
||||
if i < f() // 错误!
|
||||
{ // 错误!
|
||||
g()
|
||||
}
|
||||
```
|
||||
|
||||
在惯用 Go 中,显式分号仅出现在 `for` 循环子句中和用于分隔单行上的多个语句。
|
||||
|
||||
---
|
||||
|
||||
## 快速参考
|
||||
|
||||
| 原则 | 核心问题 |
|
||||
|------|----------|
|
||||
| 清晰性 | 读者能否理解代码的意图和原因? |
|
||||
| 简洁性 | 这是否是最简单的方法? |
|
||||
| 精炼性 | 信噪比是否高? |
|
||||
| 可维护性 | 后续能否安全地修改? |
|
||||
| 一致性 | 是否与周围代码保持一致? |
|
||||
|
||||
## 相关 Skill
|
||||
|
||||
- **命名约定**:在应用 MixedCaps、选择标识符名称或解决命名争议时,请参阅 [go-naming](../go-naming/SKILL.md)
|
||||
- **错误流程**:在构建错误优先的守卫子句或通过提前返回减少嵌套时,请参阅 [go-error-handling](../go-error-handling/SKILL.md)
|
||||
- **文档**:在编写文档注释、命名返回参数或包级别文档时,请参阅 [go-documentation](../go-documentation/SKILL.md)
|
||||
- **Linting 执行**:在使用 golangci-lint 自动化风格检查或配置 CI 时,请参阅 [go-linting](../go-linting/SKILL.md)
|
||||
- **代码审查**:在系统性代码审查中应用风格原则时,请参阅 [go-code-review](../go-code-review/SKILL.md)
|
||||
- **日志风格**:在审查日志实践、在 log 和 slog 之间选择或组织日志输出时,请参阅 [go-logging](../go-logging/SKILL.md)
|
||||
@@ -1,95 +0,0 @@
|
||||
# 格式化参考
|
||||
|
||||
## gofmt 是必须的
|
||||
|
||||
所有 Go 源文件 **必须** 符合 `gofmt` 的输出。没有例外。
|
||||
|
||||
```bash
|
||||
# 格式化一个文件
|
||||
gofmt -w myfile.go
|
||||
|
||||
# 格式化目录下所有文件
|
||||
gofmt -w .
|
||||
```
|
||||
|
||||
其他格式化工具:
|
||||
|
||||
| 工具 | 用途 |
|
||||
|------|------|
|
||||
| `gofmt` | 标准格式化工具(必须使用) |
|
||||
| `goimports` | gofmt + import 管理 |
|
||||
| `gofumpt` | gofmt 的更严格超集 |
|
||||
|
||||
---
|
||||
|
||||
## 括号
|
||||
|
||||
Go 比 C 和 Java 需要更少的括号。控制结构(`if`、`for`、`switch`)的语法中不需要括号。运算符优先级层次更短更清晰,所以 `x<<8 + y<<16` 的含义就如空格所暗示的那样 — 不像其他语言。
|
||||
|
||||
---
|
||||
|
||||
## MixedCaps(驼峰命名)
|
||||
|
||||
Go 使用 `MixedCaps` 或 `mixedCaps`,从不使用下划线:
|
||||
|
||||
```go
|
||||
// 好
|
||||
MaxLength // 导出常量
|
||||
maxLength // 非导出常量
|
||||
userID // 变量
|
||||
|
||||
// 不好
|
||||
MAX_LENGTH // 不使用 snake_case
|
||||
max_length // 不使用下划线
|
||||
```
|
||||
|
||||
例外:
|
||||
- 测试函数名可以使用下划线:`TestFoo_Bar`
|
||||
- 与 OS/cgo 交互的生成代码
|
||||
|
||||
---
|
||||
|
||||
## 行长度
|
||||
|
||||
Go 中 **没有严格的行长度限制**,但避免过长的行。Uber 建议软限制为 99 个字符。
|
||||
|
||||
指导原则:
|
||||
- 如果一行感觉太长,**重构** 而非仅仅换行
|
||||
- 不要在缩进变化之前换行(函数声明、条件语句)
|
||||
- 不要将长字符串(URL)拆分成多行
|
||||
- 换行时,将所有参数放在各自的行上
|
||||
- 如果已经尽可能短了,就让它保持长行
|
||||
|
||||
**按语义换行,而非按长度**:
|
||||
|
||||
不要仅仅为了保持短行而添加换行符,当长行更具可读性时(例如,重复性的行)。因为你所写的内容而换行,而非因为行长度。
|
||||
|
||||
长行通常与长名称相关。如果你发现行太长,考虑名称是否可以更短。去掉长名称往往比换行更有帮助。
|
||||
|
||||
这个建议同样适用于函数长度 — 没有"函数永远不超过 N 行"的规则,但确实存在太长的情况。解决方案是改变函数的边界在哪里,而非计算行数。
|
||||
|
||||
```go
|
||||
// 不好:随意的行中断
|
||||
func (s *Store) GetUser(ctx context.Context,
|
||||
id string) (*User, error) {
|
||||
|
||||
// 好:所有参数各占一行
|
||||
func (s *Store) GetUser(
|
||||
ctx context.Context,
|
||||
id string,
|
||||
) (*User, error) {
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 局部一致性
|
||||
|
||||
当风格指南未做规定时,与附近代码保持一致:
|
||||
|
||||
**有效的** 局部选择:
|
||||
- 错误格式化使用 `%s` 还是 `%v`
|
||||
- 带缓冲 channel 还是 mutex
|
||||
|
||||
**无效的** 局部覆盖:
|
||||
- 行长度限制
|
||||
- 基于断言的测试库
|
||||
@@ -1,89 +0,0 @@
|
||||
# 风格原则参考
|
||||
|
||||
## 1. 清晰性
|
||||
|
||||
代码的目的和原理必须对读者清晰。
|
||||
|
||||
- **做什么**:使用描述性名称、有帮助的注释和高效的组织
|
||||
- **为什么**:添加解释原理的注释,特别是对于微妙的细节
|
||||
- 从读者的角度审视清晰性,而非作者的角度
|
||||
- 代码应该易于阅读,而非易于编写
|
||||
|
||||
```go
|
||||
// 好:目的清晰
|
||||
func (c *Config) WriteTo(w io.Writer) (int64, error)
|
||||
|
||||
// 不好:不清晰,重复了接收者
|
||||
func (c *Config) WriteConfigTo(w io.Writer) (int64, error)
|
||||
```
|
||||
|
||||
## 2. 简洁性
|
||||
|
||||
代码应该以最简单的方式实现目标。
|
||||
|
||||
简洁的代码:
|
||||
- 从头到尾容易阅读
|
||||
- 不假定读者有先验知识
|
||||
- 没有不必要的抽象层次
|
||||
- 注释解释"为什么",而非"做什么"
|
||||
- 可能与"巧妙"的代码互斥
|
||||
|
||||
### 最少机制
|
||||
|
||||
当有几种方式表达同一个想法时,优先使用最标准的工具:
|
||||
|
||||
1. 核心语言结构(channel、slice、map、loop、struct)
|
||||
2. 标准库(HTTP 客户端、模板引擎)
|
||||
3. 第三方库 — 仅在 (1) 和 (2) 不够用时使用
|
||||
|
||||
## 3. 精炼性
|
||||
|
||||
代码应该有高信噪比。
|
||||
|
||||
- 避免重复代码
|
||||
- 避免多余的语法
|
||||
- 避免不必要的抽象
|
||||
- 使用表驱动测试提取公共代码
|
||||
|
||||
```go
|
||||
// 好:常见惯用法,信号量高
|
||||
if err := doSomething(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 好:为异常情况增强信号
|
||||
if err := doSomething(); err == nil { // 如果没有错误
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## 4. 可维护性
|
||||
|
||||
代码被修改的次数远多于被编写的次数。
|
||||
|
||||
可维护的代码:
|
||||
- 对于未来的程序员来说容易正确修改
|
||||
- API 能够优雅地扩展
|
||||
- 使用可预测的名称(相同概念 = 相同名称)
|
||||
- 最小化依赖
|
||||
- 具有全面的测试和清晰的诊断信息
|
||||
|
||||
```go
|
||||
// 不好:关键细节被隐藏
|
||||
if user, err = db.UserByID(userID); err != nil { // = vs :=
|
||||
|
||||
// 好:显式且清晰
|
||||
u, err := db.UserByID(userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid origin user: %s", err)
|
||||
}
|
||||
user = u
|
||||
```
|
||||
|
||||
## 5. 一致性
|
||||
|
||||
代码的外观和行为应该与代码库中的类似代码一致。
|
||||
|
||||
- 包级别的一致性最重要
|
||||
- 当出现平局时,优先保持一致性
|
||||
- 绝不为了局部一致性而覆盖有文档记录的风格原则
|
||||
@@ -0,0 +1,75 @@
|
||||
---
|
||||
name: "logstore"
|
||||
description: "OpenFlare / Wavelet:当新增或修改日志/分析用途表(节点访问日志、用户访问日志、可观测时序)、接入 internal/repository/logstore、切换日志主库、实现 PG/SQLite 回落,或判断一张表该走业务主库还是日志库时必须使用。"
|
||||
---
|
||||
|
||||
# 日志用途表开发
|
||||
|
||||
开始前阅读根目录 `AGENTS.md`。DDL 用 `database-migration`;高频写入队列用 `clickhouse-batchwriter`;切换任务用 `new-async-task`。本技能只回答:**这张表是不是日志表,以及如何接入可切换的日志主库。**
|
||||
|
||||
设计背景见 [日志存储解耦](../../../docs/design/logstore.md)。
|
||||
|
||||
## 先判定
|
||||
|
||||
日志表同时满足:
|
||||
|
||||
- 追加写入、几乎不更新单行
|
||||
- 按时间查询/聚合,允许按保留天数删除
|
||||
- 关闭 ClickHouse 后仍要能写、能查
|
||||
- 不参与网站/节点/证书等事务一致性
|
||||
|
||||
**不要**做成日志表:Zone、节点、配置版本、任务执行、上传元数据。这些走主库 `repository`。
|
||||
|
||||
当前日志域:
|
||||
|
||||
| 域 | 接口 | 表 |
|
||||
| :--- | :--- | :--- |
|
||||
| 节点访问日志 | `AccessLogStore` | `of_node_access_logs` |
|
||||
| 可观测 | `ObservabilityStore` | `of_node_metric_snapshots` / `of_node_edge_health` / `of_node_obs_frps` / `of_node_obs_frpc` |
|
||||
| 用户访问审计 | `UserAccessLogStore` | `w_user_access_logs` |
|
||||
|
||||
## 分层
|
||||
|
||||
| 层级 | 路径 | 职责 |
|
||||
| :--- | :--- | :--- |
|
||||
| 抽象 | `internal/repository/logstore` | 接口 + `Active`/`BuildForMigration`;apps **只**面向这里或 `repository` 门面 |
|
||||
| CH 实现 | `logstore/clickhouse_store.go` 委托 `analytics` | 原生批量 + 现有聚合 SQL |
|
||||
| 主库实现 | `logstore/postgres_store.go` | PG(按月分区)与 SQLite(普通表)共用 GORM |
|
||||
| Model | `internal/model/analytics` | 实体与批量 SQL,无 IO |
|
||||
| 入队 | `chwriter` / `risk_control` + `batchwriter` | flush 调 logstore `BatchInsert*`;CH 入队经 hooks |
|
||||
| 切换 | `of_log_db_switch` | 冻结 → `chwriter.Drain` → 逐表复制 → 翻转 |
|
||||
| 约束 | `logstore/imports_test.go` | apps 禁止 import `repository/analytics` |
|
||||
|
||||
`log_database` 只能是「随主库」或 `clickhouse`。`log_database` / `log_db_migration` 受保护。
|
||||
|
||||
## 新增一张日志表
|
||||
|
||||
1. **Model**(`internal/model/analytics`):`TableName` + `InsertColumns` / `BatchInsertSQL`。
|
||||
2. **三套 DDL**:CH `MergeTree` + `toYYYYMM`;PG `PARTITION BY RANGE(时间列)`(主键含分区键);SQLite 普通表。不要在主库建 CH 物化视图,聚合实时算。
|
||||
3. **挂到已有域或新接口**:能进 `AccessLogStore` / `ObservabilityStore` / `UserAccessLogStore` 就不要再拆包。新域才新增接口并放进 `Store`。
|
||||
4. **方法最少集**:`BatchInsert`(含 `ensureWritable`)、业务查询、`ListForMigration`、`MigrationRange`、`DeleteAll`、`DeleteBefore`、`EnsurePartitions`(仅 PG 预建)。
|
||||
5. **双实现**:CH 委托 `analyticsrepo`;GORM 共用一套,方言 SQL 放 `dialect_*.go`。零值 id 用 `idgen.NextUint64ID()`。
|
||||
6. **`buildStore`**:CH / GORM 两分支都挂上。
|
||||
7. **写入**:独立 `batchwriter`;`FlushFunc` → `logstore.Active`。节点日志/可观测走 `SetAccessLogHooks` / `SetObservabilityHooks`,不要让 apps 碰 `ChConn`。
|
||||
8. **切换任务**:`clearTarget` + `copy*` 增加该表;源数据不删,失败不翻转。
|
||||
9. **清理**:访问类走 `log_retention_days_*`;性能指标走 `metric_retention_days`。不要擅自共用错误的 TTL。
|
||||
10. **import-lint**:apps 新增对 `analytics` 或 `infra/persistence`(`batchwriter`/`idgen` 除外)的 import 必须失败。
|
||||
|
||||
## 禁止
|
||||
|
||||
- apps 直连 `analyticsrepo` / `db.ChConn` / `db.ChDB` 做日志读写
|
||||
- 只建 CH、不建主库回落
|
||||
- Handler 内逐条 `PrepareBatch`
|
||||
- 业务表塞进 logstore
|
||||
- 管理端改 `log_database` / `log_db_migration`
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
go test ./internal/repository/logstore ./internal/repository/analytics
|
||||
go test ./internal/apps/openflare/... ./internal/apps/admin/logs ./internal/apps/admin/status
|
||||
make swagger
|
||||
make code-check
|
||||
```
|
||||
|
||||
对照:`of_node_access_logs` 或 `w_user_access_logs` 的 model、三库 goose、`logstore` 双实现、`chwriter`/`risk_control` flush、`LogDBSwitchHandler`。
|
||||
@@ -1,37 +0,0 @@
|
||||
---
|
||||
name: plan
|
||||
description: 项目级技能:规定在开启新方案、新计划或进行任务交接时,必须将计划落库到 docs/plan 文件夹中并使用对应模板。
|
||||
---
|
||||
|
||||
# Plan & Handover Skill
|
||||
|
||||
当你在当前项目中被要求“开启一个新的方案”、“制定开发计划”或者准备“任务交接(Handover)”时,你**必须**遵循本技能的工作流,将计划或方案落库到 `docs/plan/` 目录下。
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **什么时候应当创建实现计划?**
|
||||
> * **必须创建的场景**:新功能开发、涉及多组件的重大架构重构、引入新基础设施依赖,以及存在显著设计决策冲突的**中大型、复杂**需求。
|
||||
> * **绝对不要创建的场景**:改个包名、挪个文件、重命名函数、小修小改修复 Bug 等**轻量级、简单的局部重构**。对于此类改动,应当直接完成并运行单元测试通过后交付,禁止制造冗余的计划文档。
|
||||
|
||||
## 执行工作流 (Workflow)
|
||||
|
||||
### 1. 确定计划类型
|
||||
* **新特性/技术实现计划**:如果你要开发新功能或进行重大重构,你需要创建**实现计划**。
|
||||
* **AI 任务交接计划**:如果当前任务尚未完成但需要记录进度留作以后或其他 AI 代理接手,你需要创建**交接计划**。
|
||||
|
||||
### 2. 读取对应模板
|
||||
在创建计划文档前,必须读取对应的模板内容,并严格按照模板的骨架进行填充:
|
||||
* **实现计划模板**:`docs/plan/implementation-plan-template.md`
|
||||
* **接手计划模板**:`docs/plan/handover-plan-template.md`
|
||||
|
||||
### 3. 落库与命名规范
|
||||
在 `docs/plan/` 目录下创建新的 Markdown 文件进行保存:
|
||||
* **实现计划**命名格式:`docs/plan/YYYYMMDD-[feature-name].md` (例如:`20260605-uptime-kuma-sync.md`)
|
||||
* **接手计划**命名格式:`docs/plan/handover-[task-name].md` (例如:`handover-waf-ip-group.md`)
|
||||
|
||||
### 4. 隔离约束 (极其重要)
|
||||
`docs/plan/` 目录下的文档**仅限内部开发和 AI 代理同步使用**。
|
||||
* **绝对禁止**将新创建的 plan 文档加入到项目的官方导航配置(如 `docs/config.ts` 的 `nav` 或 `sidebar` 导航条中)。
|
||||
* **绝对禁止**通过任何方式将其暴露给文档渲染框架(如 VitePress)对外渲染。
|
||||
|
||||
## 后续动作
|
||||
落库完成后,向用户报告计划已生成在 `docs/plan/` 目录下,并列出文档的核心要点或待决策项(如有),等待用户 Review 或批准后即可推进下一步。
|
||||
@@ -28,16 +28,14 @@ description: "Wavelet 项目专用:根据自上一个正式版本 Tag 以来
|
||||
|
||||
1. 合并重复或相近提交。
|
||||
2. 删除无意义提交,例如格式化、临时调试、无关重构。
|
||||
3. 将内部实现描述改写为用户可理解的变更。
|
||||
4. 每条使用完整中文句子。
|
||||
5. 尽量说明“修复/优化了什么”以及“带来的效果”。
|
||||
6. 不要编造 commit log 中没有的信息。
|
||||
7. 不要加入 token、密钥、私有地址等敏感信息。
|
||||
8. 如果某个分类没有内容,可以省略。
|
||||
3. 将内部实现描述改写为用户可理解的变更, 说明“修复/优化了什么”以及“带来的效果”。
|
||||
4. 不要写技术细节:只描述用户可感知的行为与效果,禁止内部实现描述,例如字段名/表名/SQL(`node_id = ''`)、框架或库名称(shadcn、GORM、OpenResty)、配置或协议细节(RFC3339、ClickHouse/PostgreSQL 差异)、代码机制(`proxy_intercept_errors`、Lua 过滤器、雪花 ID)。数据库名称仅在说明受影响用户范围时使用(如「PostgreSQL 日志库下无数据」)。
|
||||
5. 如果某个分类没有内容,则省略。
|
||||
|
||||
固定使用以下分类:
|
||||
|
||||
```text
|
||||
### ✨ 新功能
|
||||
### 🛠 修复
|
||||
### ⚡️ 优化与改进
|
||||
### 💄 其他/体验
|
||||
@@ -45,19 +43,29 @@ description: "Wavelet 项目专用:根据自上一个正式版本 Tag 以来
|
||||
|
||||
分类规则:
|
||||
|
||||
- 新功能、新能力、新配置、新任务:放入 ### ✨ 新功能
|
||||
- Bug、异常行为、错误逻辑:放入 ### 🛠 修复
|
||||
- 性能、稳定性、接口、架构、兼容性:放入 ### ⚡️ 优化与改进
|
||||
- 日志、文案、UI、文档、开发体验:放入 ### 💄 其他/体验
|
||||
|
||||
「修复/优化」与「新增」的判定(关键):
|
||||
|
||||
- **判定标准是“该功能在上一正式版本中是否已存在”**:
|
||||
- 已存在 → 本次对其 bug 的修正可计入「🛠 修复」,对其行为/性能的改进可计入「⚡️ 优化与改进」;
|
||||
- 不存在(本版本新增)→ 该功能的一切内容——包括开发过程中修的 bug、做的性能优化、补的索引——都只属于新功能开发的一部分,不应该在发布说明中提及。
|
||||
- 禁止把新功能的开发期修复/优化写进「修复」或「优化」:新功能此前版本没有,谈不上“修复/优化了旧行为”。
|
||||
|
||||
示例:
|
||||
|
||||
```
|
||||
chore(release): v3.3.0
|
||||
|
||||
### ✨ 新功能
|
||||
- 新增笔记库快照备份功能,支持定时备份与手动一键恢复(仅说明新增的功能, 禁止提及新功能开发时期的优化修复等内容)。
|
||||
|
||||
### 🛠 修复
|
||||
- 修复了通过 MCP 接口操作时笔记库范围限制未正确生效的问题。
|
||||
- 修复了 MCP 接口返回数据格式不一致的问题。
|
||||
- 修复了 WebSocket 客户端异常断开后僵尸连接未及时清理的问题。
|
||||
- 修复首页「来源分布」卡片在 PostgreSQL/SQLite 日志库下无数据的问题。
|
||||
- 修复源站错误页「仅针对 GET 请求」未真正透传非 GET 响应的问题:POST/PUT 等非 GET 请求现可完整看到源站原始报错内容。
|
||||
|
||||
### ⚡️ 优化与改进
|
||||
- 优化了 WebGUI 登录机制,引入设备令牌自动轮转,减少因 IP 变化产生的冗余令牌。
|
||||
|
||||
Executable
+53
@@ -0,0 +1,53 @@
|
||||
#!/bin/bash
|
||||
# Correctness gate: must pass after every edit. Fails fast on real breakage.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
echo "==> go vet ./..."
|
||||
go vet ./... 2>&1 | tail -20
|
||||
|
||||
echo "==> go build ./..."
|
||||
go build ./... 2>&1 | tail -20
|
||||
|
||||
echo "==> golangci-lint run (repo config)"
|
||||
golangci-lint run 2>&1 | tail -20
|
||||
|
||||
# 全量单测(sqlite + miniredis,纯本地无需外部服务;2026-08-16 起全绿)
|
||||
echo "==> go test ./internal/... ./pkg/..."
|
||||
go test ./internal/... ./pkg/... 2>&1 | grep -E "^--- FAIL|^FAIL" | head -20 || true
|
||||
if go test ./internal/... ./pkg/... > /tmp/auto_gotest.log 2>&1; then
|
||||
:
|
||||
else
|
||||
tail -30 /tmp/auto_gotest.log
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 前端测试(vitest;2026-08-16 起全绿)
|
||||
echo "==> pnpm exec vitest run (frontend)"
|
||||
(cd frontend && node scripts/merge-i18n-fragments.mjs && pnpm exec vitest run --reporter=dot > /tmp/auto_vitest.log 2>&1) || {
|
||||
tail -30 /tmp/auto_vitest.log
|
||||
exit 1
|
||||
}
|
||||
|
||||
# SPDX license 头门禁(repo 自带约定)
|
||||
echo "==> make license-check"
|
||||
make license-check 2>&1 | grep "needs license" | head -10 || true
|
||||
if make license-check > /tmp/auto_license.log 2>&1; then
|
||||
:
|
||||
else
|
||||
tail -15 /tmp/auto_license.log
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 并发密集包 -race 门禁(2026-08-16 全仓 -race 清零后纳入,防回归;
|
||||
# frpc/frps 慢套件不含在此,另做全量周期验证)
|
||||
echo "==> go test -race (concurrency packages)"
|
||||
RACE_PKGS="./internal/apps/oauth/ ./internal/apps/openflare/tls/ ./internal/apps/openflare/uptimekuma/ ./internal/apps/upload/cache/ ./internal/repository/ ./pkg/cache/disk/ ./pkg/logger/ ./internal/infra/persistence/batchwriter/"
|
||||
if go test -race -count=1 $RACE_PKGS > /tmp/auto_race.log 2>&1; then
|
||||
:
|
||||
else
|
||||
grep -E "WARNING: DATA RACE|^--- FAIL|^FAIL" /tmp/auto_race.log | head -20
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "OK: checks passed"
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
# Ideas backlog (代码质量)
|
||||
|
||||
## 已尝试并收尾(2026-08-16 会话,14 个实验,108→8)
|
||||
|
||||
- 生产代码 golangci 扩展集 13 类 linter 全量清理(modernize/perfsprint/
|
||||
errorlint/canonicalheader/usestdlibvars/intrange/wastedassign/errname/
|
||||
forcetypeassert/prealloc/gosec/recvcheck/exhaustive),剩余 8 处全部为
|
||||
有据可查的刻意保留项(telegram %v、3 处嵌套 struct omitempty、
|
||||
3 处 not-found 惯例、1 处 encoding/json 接收者混合)。
|
||||
- 测试代码质量维度(testifylint/usetesting/thelper)25→0。
|
||||
- 前端 eslint/tsc 0。
|
||||
- 修复中积累的工具经验:golangci-lint v2 `--fix` 的 import 管理不可靠,
|
||||
跑完必须 `goimports -w`;`--max-issues-per-linter=0` 才能拿到全量清单
|
||||
(默认 50 + max-same-issues=3 会掩盖重复模式);cyclop 与 exhaustive
|
||||
有张力(显式 case 计入复杂度)。
|
||||
|
||||
## 未来可深化方向(均经评估)
|
||||
|
||||
- 测试可运行性修复:`go test ./internal/...` 目前在 main 上就有失败
|
||||
(无本地 redis、frpc 进程测试 flaky)。修复这些环境问题后,可以把
|
||||
`go test` 加入 checks.sh,解锁 paralleltest/tparallel 维度
|
||||
(t.Parallel 提速 + 正确性,目前因共享状态+不可运行而放弃)。
|
||||
- frontend biome 格式漂移(76 文件):一次性 `make format` 提交,
|
||||
与质量修复分开做,不进基准。
|
||||
- fieldalignment:结构体内存布局优化,但会改变 JSON key 顺序且有
|
||||
位置字面量风险 —— 若做,需按文件人工核对,不进自动基准。
|
||||
- Go 1.26 新特性扫描:`go vet` 新分析器、golangci-lint 新 linter
|
||||
(如 recvcheck 之后的 new receivers 检查)随版本跟进。
|
||||
- 文档/示例代码(docs/、scripts/)质量:目前不在 golangci 范围(tests:false
|
||||
之外还有 scripts 目录),可用同一扩展集扫 scripts/ 下的 main.go。
|
||||
|
||||
## 会话收尾(2026-08-16,run #23 后)
|
||||
|
||||
- 已确认收敛:基准 5 维全下限、-race 全仓清零、双端测试全绿、发布构建可复现、
|
||||
config.example.yaml ↔ model.go 同步无漂移、无 flaky 测试。
|
||||
- 明确评估为不值得做的方向:paralleltest/tparallel(共享全局状态风险)、
|
||||
fieldalignment(JSON key 顺序变化)、biome 格式漂移(纯噪声)、
|
||||
frpc/frps 慢测试注入 backoff(为省 ~40s 改生产时序逻辑,不值)。
|
||||
- 未来如继续:可周期跑 `go test -race ./...` 全量(frpc/frps 慢套件);
|
||||
或前端 a11y 用 axe 做浏览器级审计(超出 eslint 静态规则)。
|
||||
|
||||
## 本会话新增(runs #39-#43)
|
||||
|
||||
已修复:
|
||||
- agent auth_cache negative 缓存无上限 → 10k 上限+过期清理(DoS 防护)
|
||||
- relay/flared 与 agent 三份重复 authenticateAccessToken → 共享 agent 版(负缓存共享,DB 压力下降)
|
||||
- websocket 三 hub:runWritePump 抽取、wsClientCore 嵌入(close/enqueue 单份)、broadcastAgent 合并
|
||||
- frps/frpc TOML 注入 → pkg/protocol/toml.go TOMLQuote 转义全部插值
|
||||
|
||||
评估后不修/暂缓:
|
||||
- cloudflare listMemberItems、config_version snapshot 证书循环的 N+1:管理端小 N 低频,
|
||||
加批量 repo API 属投机优化;若未来组员数量变大再做 ListZoneDomainsByIDs。
|
||||
- fatcontext ×3(oauth/upload/auth_source cache listener):别名赋值误报,非嵌套包装。
|
||||
- objectstore newOSSBackend/newWebDAVBackend 恒 nil error:跨后端工厂签名统一,刻意设计。
|
||||
- edge/updater assetNameForGOOSGOARCH 恒 "linux":跨平台预留参数,刻意泛化。
|
||||
- agent ResolverDirective explicitResolvers 原样插入 nginx conf:管理员配置属可信输入;
|
||||
若未来开放给低权限角色需加格式校验(IP 解析)。
|
||||
- pkg/render/openresty 管理端旋钮(ClientMaxBodySize 等)原样插值:管理员权限范围内。
|
||||
- frontend/settings/profile.tsx(858 行)超 AGENTS.md ~600 行指引:存量组件,拆分属
|
||||
纯重构无质量增益,暂缓;若后续要改该页面功能时顺手拆 components/。
|
||||
|
||||
## Run #44(全仓 -race 扫描)
|
||||
|
||||
- 发现并修复 upload/cache 监听器 DATA RACE:goroutine 读可变全局 db.Redis vs
|
||||
testhelper 清理置 nil。根因修复=启动时捕获 redisClient(oauth×2/repository×2
|
||||
同型监听器一并加固),StopUploadMetaCacheListener 补 done 等待。
|
||||
- 教训:testhelper 不能 import upload/cache(循环依赖);"捕获替代全局读"是
|
||||
无环的根因修法。
|
||||
- 全仓 -race 现为 0 竞争(internal/... + pkg/...);建议周期性重跑。
|
||||
|
||||
## LIKE 转义(本轮已修日志搜索 4 站点;同类遗留)
|
||||
|
||||
- 已修:analytics/node_access_log_filter.go、analytics/access_log_filter.go、
|
||||
logstore/postgres_store.go×2(PG/SQLite 加 ESCAPE '\',CH 用默认反斜杠转义)。
|
||||
新助手 pkg/util/like.go EscapeLike + 单测。
|
||||
- Run #47 已收尾全部 GORM 站点:upload.go keyword、user.go:73/76/188/229
|
||||
(含 OAuth uniqueUsername base 转义——外部输入含 _ 曾误报用户名冲突)、
|
||||
task_execution.go task_type 前缀。均加显式 ESCAPE '\'。
|
||||
- 刻意保留:upload.go:199 `image/%`(系统常量)、config_version.go:65(系统生成)。
|
||||
|
||||
## Run #48(后台 goroutine panic 防护,55db1c01)
|
||||
|
||||
- 全仓 20 处裸 go func() 零 recover → 新增 pkg/util/goroutine.go `Go(fn)`(recover +
|
||||
slog + debug.Stack,runtime.Caller 自动记录调用点无需手写名字),22 个站点全部收口
|
||||
(oauth/upload/system_config/auth_source 的嵌套 ctx-done watcher 也含)。
|
||||
- 教训:脚本括号深度匹配首轮会跳过嵌套内层 goroutine,需跑两轮;新 Go 文件必须先跑
|
||||
scripts/update_go_license.sh(license-check 会拦)。
|
||||
- 已过期记录:go test ./internal/... ./pkg/... 现全过(94 ok)——"main 上测试失败"
|
||||
不再成立。scripts/、docs/ 下 Go 文件用扩展 linter 扫过:0 issues。
|
||||
|
||||
## Run #50(发现型 linter 扫描,全证伪——勿重跑这些维度)
|
||||
|
||||
- errchkjson 12 处:全部为不可能失败的 json.Marshal(纯 string/int/[]string
|
||||
结构体;admin/logs/routers.go:131 与 waf/ip_group_sync.go:255 的 "unsafe type"
|
||||
是传递性保守标记,RawMessage/time.Time 内容来自必然成功的 marshal)。
|
||||
- spancheck 1 处(pkg/trace/trace.go:61):误报,helper 正常返回 span,
|
||||
唯一调用方 internal/infra/task/executor.go:242 有 defer span.End()。
|
||||
- unparam ×2(objectstore oss/webdav 恒 nil error):已在 #43 前评估为跨后端工厂签名统一。
|
||||
- 性能排查:正则全部包级编译(无函数内 MustCompile);包级 map 全为有界静态注册表;
|
||||
task AppendLog 走 DB 非内存累积;push escapeJSONString 用法正确。
|
||||
- 结论:Go 静态可发现的低垂果实已穷尽。剩余方向:frontend axe a11y 浏览器级审计、
|
||||
周期性 -race 重跑(上次 #49 干净)、运维类增长审查。
|
||||
|
||||
## Run #54(认证页 axe a11y 审计+修复,451ce525)
|
||||
|
||||
已修(复扫验证生效):
|
||||
- 布局级全局:sidebar 折叠按钮 aria-label、Sidebar role=navigation(region 18 节点/页清零)、
|
||||
header Kbd 对比度 text-foreground/70、空态/错误/加载 h3→p(heading-order 清零)。
|
||||
- 页面级:dashboard 4 个 Progress aria-label、users 分页 prev/next aria-label、
|
||||
admin/system 无内容 Tabs→aria-pressed 按钮组(aria-valid-attr-value critical 清零)。
|
||||
- / 与 /admin/system 现 axe 0 违规。
|
||||
|
||||
后续可做(页面级批量,工作量大):
|
||||
- admin 数据表格行内操作图标按钮(编辑/删除)与 Switch 开关无 aria-label —— 每张管理表逐个补;
|
||||
- muted 文本对比度(card description、radix tabs trigger、primary 按钮文字)—— shadcn 默认色在浅色主题下 axe 判 fail,改主题变量影响面大需设计确认。
|
||||
- 审计环境复用:后端 :3100 + CONFIG_PATH=/tmp/of-audit/config.yaml(sqlite)、docker redis --network host、
|
||||
pnpm dev --port 3002 WAVELET_BACKEND_URL=:3100;admin 密码 reset-passwd 重置。注意 :3000 是生产实例勿动。
|
||||
|
||||
## Run #54-#55(认证页 a11y 审计,两轮 keep)
|
||||
|
||||
已修复(浏览器 axe 复扫验证):
|
||||
- 全局布局:sidebar 折叠按钮 aria-label、Sidebar role=navigation、header Kbd 对比度、
|
||||
dashboard Progress aria-label、分页 prev/next、空态/加载 h3→p、admin/system Tabs→aria-pressed。
|
||||
- 主题级根因:--primary indigo-500(#6366f1) 白字对比度仅 4.27(AA 需 4.5) → indigo-600
|
||||
oklch(51.1% 0.262 276.966) ≈6.8,一处修复全站 contrast 清零。
|
||||
- 控件名:access-analytics 刷新、events-tab Switch/编辑/删除、openflare-ops Switch/Select/
|
||||
Input(htmlFor)/Textarea、table-browser/sql-console SelectTrigger;heading-order:眉题
|
||||
h4→p(cache-manager/user-detail-sheet)、卡片题 h3→p(task-manager/file-manager)。
|
||||
- 结果:dashboard、admin/system、admin/settings、admin/logs、admin/push、admin/tasks、
|
||||
admin/database、files 共 8 页 axe 0 违规。
|
||||
|
||||
审计方法(可复用):后端 :3100(CONFIG_PATH=/tmp/of-audit/config.yaml,sqlite,
|
||||
api_prefix 必须显式 /api)+ docker redis --network host(本机 bridge NAT 坏)+
|
||||
pnpm dev --port 3002 WAVELET_BACKEND_URL=:3100 + admin 密码经 reset-passwd 重置。
|
||||
axe 注入:eval 建 CDN script → Promise 轮询 window.axe → axe.run。
|
||||
教训:表单页异步渲染,须 wait≥5s 再扫否则漏报 label 规则;Radix SelectValue
|
||||
value='' 时 placeholder 不显示,combobox 无名需 aria-label 兜底。
|
||||
|
||||
## 剩余可做
|
||||
|
||||
- 抽查其余页面(websites/[zoneId]、origins/detail、responses 编辑器等富交互页)
|
||||
——contrast 已由主题修复覆盖,预期只剩个别控件名。
|
||||
- 周期性 go test -race ./... 全量重跑(上次干净为 run #49 后)。
|
||||
|
||||
## Run #56(富交互页抽查,keep,63e3b852)
|
||||
|
||||
- 扫描 11 页:websites/origins/proxy-routes/certificates/dns-accounts 直接 0 违规
|
||||
(indigo-600 主题修复已覆盖全站 contrast)。
|
||||
- 修复 3 处并复扫归零:
|
||||
1. cloudflare/components/sync-tasks-panel.tsx 状态筛选 SelectTrigger 加 aria-label
|
||||
(Radix SelectValue value='' 时 placeholder 不渲染,combobox 无名)。
|
||||
2. components/common/settings/access-token.tsx 安全提示 text-amber-600→amber-700
|
||||
(12px 小字对比度不足)。
|
||||
3. settings/notifications 面包屑页缺 h1 → sr-only h1。教训:h1 不能作为
|
||||
BreadcrumbList 子元素(axe list 规则报 list 语义破坏),须放 <Breadcrumb> 外;
|
||||
BreadcrumbPage 无 asChild 支持。
|
||||
- a11y 维度至此穷尽:累计 14 页 axe 全部 0 违规。
|
||||
|
||||
## Run #59(-shuffle=on 测试顺序随机化扫描,keep,b56f2763)
|
||||
|
||||
- 新维度:`go test -shuffle=on` 抓到 config_version 包测试顺序依赖——
|
||||
TestBuildOpenRestyConfigSnapshotOriginErrorPageDefaults 在 shuffle 下命中
|
||||
Custom 用例留在进程级 RAM 配置缓存的值(GetSystemConfigByGroup 未命中时
|
||||
ram.Set 回填,TTL 跨测试存活;:memory: DB + SetDB 换库不使缓存失效)。
|
||||
- 修复:setupOriginErrorPageSnapshotDB / setupConfigVersionTestDB 换 DB 前后
|
||||
接入既有 ram.ResetForTest()。包内 shuffle×8 + 全仓 shuffle 复扫全过。
|
||||
- 教训:默认源码顺序掩盖顺序依赖;-shuffle=on 是低成本周期扫描手段。
|
||||
全仓 -race(#58 后)同样干净。其余用 SetDB 的测试包如后续 shuffle 复发,
|
||||
同法接入 ResetForTest 即可。
|
||||
@@ -0,0 +1,60 @@
|
||||
{"type":"config","name":"前后端代码质量优化(符合最佳实践)","metricName":"total_issues","metricUnit":"","bestDirection":"lower"}
|
||||
{"run":1,"commit":"305d609","metric":108,"metrics":{"golint_canonicalheader":8,"golint_errname":1,"golint_errorlint":12,"golint_forcetypeassert":3,"golint_gosec":2,"golint_intrange":3,"golint_modernize":37,"golint_nilnil":3,"golint_perfsprint":18,"golint_prealloc":3,"golint_recvcheck":7,"golint_usestdlibvars":3,"golint_wastedassign":7,"golint_total":107,"eslint_problems":1,"eslint_errors":0,"eslint_warnings":1,"tsc_errors":0,"measure_s":36},"status":"checks_failed","description":"基线:总问题 108(golangci 107 + eslint 1)。checks 失败的唯一原因:repo 自带 golangci gate 有 2 个既有 gosec G115 问题(预期内,首次修复后即绿)。","timestamp":1786871594292,"segment":0,"confidence":null,"asi":{"hypothesis":"baseline","next_action_hint":"修复 internal/apps/edge/observability/linux.go 的 2 个 G115 gosec 问题后 checks.sh 才能通过;之后每次迭代即可正常 keep/discard"}}
|
||||
{"run":2,"commit":"f1f6bb8","metric":106,"metrics":{"golint_canonicalheader":8,"golint_errname":1,"golint_errorlint":12,"golint_forcetypeassert":3,"golint_gosec":0,"golint_intrange":3,"golint_modernize":37,"golint_nilnil":3,"golint_perfsprint":18,"golint_prealloc":3,"golint_recvcheck":7,"golint_usestdlibvars":3,"golint_wastedassign":7,"golint_total":105,"eslint_problems":1,"eslint_errors":0,"eslint_warnings":1,"tsc_errors":0,"measure_s":38},"status":"keep","description":"修复 internal/apps/edge/observability/linux.go 的 2 个 gosec G115 整数溢出转换:helper 改为接收 int64 b,用 gosec 认可的饱和乘法模式(uint64 域乘积 + 上界比较),去掉原 //nolint:gosec,语义不变(Bsize 恒为正)。repo 自带 gate 首次全绿。","timestamp":1786872064145,"segment":0,"confidence":null,"asi":{"hypothesis":"修复 gosec G115:multiplyUint64ToInt64 改为 accept int64 b 并采用 gosec 认可的饱和乘法模式","insight":"gosec G115 不接受分支上界证明(a > MaxInt64/b),但接受先算 uint64 乘积再 if v > MaxInt64 饱和的模式,无需 nolint","next_action_hint":"下一步批量清理 modernize(37)/perfsprint(18) 等自动可修复类别,用 golangci-lint --fix 后人工核对 diff"}}
|
||||
{"run":3,"commit":"b76f707","metric":74,"metrics":{"golint_canonicalheader":8,"golint_errname":1,"golint_errorlint":12,"golint_forcetypeassert":3,"golint_gosec":0,"golint_intrange":3,"golint_modernize":5,"golint_nilnil":3,"golint_perfsprint":18,"golint_prealloc":3,"golint_recvcheck":7,"golint_usestdlibvars":3,"golint_wastedassign":7,"golint_total":73,"eslint_problems":1,"eslint_errors":0,"eslint_warnings":1,"tsc_errors":0,"measure_s":38},"status":"keep","description":"modernize 37→5(-32):interface{}→any、内置 max/min、slices/maps 辅助、strings.Cut/SplitSeq、strings.Builder(修复 mail.go O(n²) 拼接)。逐 hunk 核对语义等价;omitzero 冲突修复被自动跳过(wire 格式不变);手动清 4 处遗留 sort import + 2 处 QF1012。","timestamp":1786872502383,"segment":0,"confidence":17,"asi":{"hypothesis":"批量应用 modernize 自动修复(interface{}→any、max/min、slices.Sort/Contains、strings.Cut/SplitSeq、maps.Copy、strings.Builder)","insight":"golangci-lint --fix 会把 omitempty→omitzero 的冲突修复跳过(2个文件保留原 tag,wire 格式不变,好);但 fixer 会遗留未使用的 sort import,需手动清 4 处;mail.go 的 Builder 迁移附带暴露 2 个 QF1012,顺手用 fmt.Fprintf 修复。全部修复语义等价,已逐项核对 diff","next_action_hint":"剩余 modernize=5 应为 omitzero 冲突文件;下轮先处理 perfsprint(18):先看 --fix 是否安全再决定"}}
|
||||
{"run":4,"commit":"699e95f","metric":56,"metrics":{"golint_canonicalheader":8,"golint_errname":1,"golint_errorlint":12,"golint_forcetypeassert":3,"golint_gosec":0,"golint_intrange":3,"golint_modernize":5,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":3,"golint_recvcheck":7,"golint_usestdlibvars":3,"golint_wastedassign":7,"golint_total":55,"eslint_problems":1,"eslint_errors":0,"eslint_warnings":1,"tsc_errors":0,"measure_s":47},"status":"keep","description":"perfsprint 18→0:strconv.Itoa/FormatInt/FormatUint/FormatBool 替代 fmt.Sprintf、无动词 fmt.Errorf→errors.New、纯字符串拼接。全部语义等价(已核对 diff)。修正 fixer 遗留的 import 问题(引入 goimports 统一整理)。","timestamp":1786872884713,"segment":0,"confidence":3.0588235294117645,"asi":{"hypothesis":"perfsprint --fix:%d→strconv.Itoa/FormatInt、%t→FormatBool、%s+const→拼接、无动词 Errorf→errors.New","insight":"重要:golangci-lint v2 fixer 的 import 管理不可靠(删除/添加 import 会出错,53 个文件中 5 处报 undefined)+ 遗留未用 import。已安装 goimports(repo make format 本来就需要它),对改动文件统一 goimports -w 修复。后续只要用 --fix 就要记得跑 goimports -w","next_action_hint":"剩余大头:errorlint(12)、canonicalheader(8)(usestdlibvars 同类)、recvcheck(7)、wastedassign(7)。errorlint 需手工逐处判断;先做 canonicalheader+usestdlibvars(自动可修复但要核对)"}}
|
||||
{"run":5,"commit":"d0414b4","metric":45,"metrics":{"golint_canonicalheader":0,"golint_errname":1,"golint_errorlint":12,"golint_forcetypeassert":3,"golint_gosec":0,"golint_intrange":3,"golint_modernize":5,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":3,"golint_recvcheck":7,"golint_usestdlibvars":0,"golint_wastedassign":7,"golint_total":44,"eslint_problems":1,"eslint_errors":0,"eslint_warnings":1,"tsc_errors":0,"measure_s":38},"status":"keep","description":"canonicalheader 8→0 + usestdlibvars 3→0:header key 改为 Go 规范大小写(wire 格式本就如此,纯代码修正)、HTTP 方法常量替代字符串字面量。","timestamp":1786873098921,"segment":0,"confidence":2.1724137931034484,"asi":{"hypothesis":"canonicalheader+usestdlibvars --fix:Header key 统一规范大小写、GET/OPTIONS 等方法常量","insight":"GitHub header 修正前后的 wire 格式完全一致(Go 在 Set 时本来就会规范化),纯代码层面修正,零行为风险;下次遇到同类 100% 安全","next_action_hint":"剩余:errorlint(12) 需逐处人工判断(其中 3 处 err != context.Canceled、2 处 %v wrap、若干 ==/类型断言);recvcheck(7) 是模型接收者一致性;wastedassign(7) 删 TODO 赋值;intrange(3)/modernize(5)/nilnil(3)/prealloc(3)/forcetypeassert(3)/errname(1)/eslint(1)"}}
|
||||
{"run":6,"commit":"ce28f63","metric":38,"metrics":{"golint_canonicalheader":0,"golint_errname":1,"golint_errorlint":12,"golint_forcetypeassert":3,"golint_gosec":0,"golint_intrange":3,"golint_modernize":5,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":3,"golint_recvcheck":7,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":37,"eslint_problems":1,"eslint_errors":0,"eslint_warnings":1,"tsc_errors":0,"measure_s":47},"status":"keep","description":"wastedassign 7→0:删除 7 处死初始化(snapshot.go 三连、push 三件套 content、format.go numStr),改 var 声明,零行为变化。","timestamp":1786873485497,"segment":0,"confidence":2.978723404255319,"asi":{"hypothesis":"wastedassign 7→0:删除 7 处死初始化(x := \"\" 后所有分支都赋值)改为 var 声明","insight":"replace 工具会归一化 replacement_text 的前导空白;对需要缩进的编辑直接用 sed/gofmt -w 处理更稳","next_action_hint":"剩余:errorlint(12)、recvcheck(7)、modernize(5)、intrange(3)、nilnil(3)、prealloc(3)、forcetypeassert(3)、errname(1)、eslint(1)"}}
|
||||
{"run":7,"commit":"288b74d","metric":33,"metrics":{"golint_canonicalheader":0,"golint_errname":1,"golint_errorlint":12,"golint_forcetypeassert":3,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":3,"golint_recvcheck":7,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":32,"eslint_problems":1,"eslint_errors":0,"eslint_warnings":1,"tsc_errors":0,"measure_s":45},"status":"keep","description":"intrange 3→0 + modernize 5→3:for i:=0;i<len/N;i++ → range len/N(8 处);time.Time 字段 omitempty→omitzero(wire 输出一致);SplitSeq;min() 简化。刻意保留 lark.go omitzero(会改变 wire 行为)。","timestamp":1786873629461,"segment":0,"confidence":4.166666666666667,"asi":{"hypothesis":"intrange(3) + modernize 剩余(2 个 time.Time omitempty→omitzero + SplitSeq + min)","insight":"lark.go larkTextContent omitempty→omitzero 会改变 wire(普通 struct 无 IsZero,当前恒序列化,改后零值省略)—— 判定为行为变化,故意保留;time.Time 字段 omitempty/omitzero 输出一致,可安全替换","next_action_hint":"剩余:errorlint(12) 大头(3 处 != context.Canceled 需确认 runner 是否 wrap;%v→%w 2 处;若干 ==err / 类型断言);recvcheck(7);forcetypeassert(3);nilnil(3);prealloc(3);errname(1);eslint(1)"}}
|
||||
{"run":8,"commit":"86fad02","metric":22,"metrics":{"golint_canonicalheader":0,"golint_errname":1,"golint_errorlint":1,"golint_forcetypeassert":3,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":3,"golint_recvcheck":7,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":21,"eslint_problems":1,"eslint_errors":0,"eslint_warnings":1,"tsc_errors":0,"measure_s":46},"status":"keep","description":"errorlint 12→1:3 处 cmd 入口 err!=context.Canceled→errors.Is(防御性,当前 runner 不 wrap 语义不变);2 处 strconv.NumError 断言、1 处 viper 断言、2 处 ==io.EOF、2 处 ==redis.Nil、1 处 ==gorm.ErrRecordNotFound→errors.As/Is;8 处 %v→%w 保留错误链。刻意保留 telegram.go 单处 %v(原始错误仅作上下文文本,wrap 会改变 errors.Is 匹配语义)。","timestamp":1786873923775,"segment":0,"confidence":4.195121951219512,"asi":{"hypothesis":"errorlint 12→1:errors.Is/As 替代 ==/类型断言(防御 wrap),%v→%w 保留错误链","insight":"errorlint 结果在并行分析时一度不稳定(可能文件缓存竞争),多跑一次确认;telegram.go 的 %v 是刻意保留原始 HTML 错误为文本(只 wrap fallbackErr),判定为合理例外,不计为负债。错误链保留(%w)对多错误组合消息(manager.go、restart_unix.go、service.go)是净收益,调用方无 Is 匹配这些次要错误","next_action_hint":"剩余:recvcheck(7)、forcetypeassert(3)、nilnil(3)、prealloc(3)、modernize(3=lark omitzero 刻意保留)、errname(1)、eslint(1)"}}
|
||||
{"run":9,"commit":"4ecec2c","metric":15,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":7,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":14,"eslint_problems":1,"eslint_errors":0,"eslint_warnings":1,"tsc_errors":0,"measure_s":43},"status":"keep","description":"forcetypeassert 6→0(缓存 list 断言、relay/flared 中间件契约断言、图片压缩 flight 断言,全部带检查+安全失败路径);errname 1→0;prealloc 2 处(另 1 处与 repo mnd 冲突,用命名常量解决)。nilnil 保留(not-found/可选结果惯例,含接口契约注释)。","timestamp":1786874283774,"segment":0,"confidence":4.043478260869565,"asi":{"hypothesis":"forcetypeassert(6处) → 带检查断言(middleware 契约破坏时 Abort 401/返回错误);errname runtimeInitErr→errRuntimeInit;prealloc 2 处(uptimekuma、postgres replicas)","insight":"prealloc 与 repo mnd 门禁冲突(magic number 3):用命名常量 baseTracingOptionCount 同时满足两者;nilnil 5 处判定为合法 not-found/可选结果惯例(含接口注释契约 + 测试断言),全部保留;用 --max-issues-per-linter=0 拿全量清单避免被默认 50 截断误导","next_action_hint":"剩余:recvcheck(7) 接收者一致性(需逐模型判断)、eslint(1) exhaustive-deps、modernize(3=lark omitzero 刻意保留+2 处待查)、nilnil(3 刻意保留)、errorlint(1 刻意保留)"}}
|
||||
{"run":10,"commit":"73d8173","metric":9,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":1,"eslint_errors":0,"eslint_warnings":1,"tsc_errors":0,"measure_s":45},"status":"keep","description":"recvcheck 7→1:6 个 GORM 模型 TableName 改为指针接收者(GORM 源码确认 reflect.New 判定 Tabler,兼容;模型单测通过)。MillisecondDuration 刻意保留(encoding/json 要求 Marshal 值/Unmarshal 指针的混合)。","timestamp":1786874445733,"segment":0,"confidence":4.304347826086956,"asi":{"hypothesis":"recvcheck 7→1:GORM 模型 TableName 值接收者→指针接收者,与其它方法一致","insight":"GORM schema.Parse 用 reflect.New(modelType) 判定 Tabler,指针接收者 TableName 完全兼容(已读 gorm 源码确认 + 模型单测通过);仓库中 (Model{}).TableName() 字面量调用都在未改的类型上,无破坏。MillisecondDuration 保留:MarshalJSON 值接收者是 json 对不可寻址值的行为保障,UnmarshalJSON 必须指针 —— 混合是 encoding/json 硬性要求","next_action_hint":"剩余:modernize(3,含 lark omitzero 刻意保留 + 2 处待查)、nilnil(3 刻意保留)、eslint(1 exhaustive-deps)、errorlint(1 刻意保留)。下一步查 modernize 剩余 2 处并修 eslint 的 hook 依赖"}}
|
||||
{"run":11,"commit":"111d290","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":38},"status":"keep","description":"eslint 1→0:pages-source-card useEffect 补 t 依赖(next-intl 稳定引用)。modernize 补 1 处 time.Time omitzero。剩余 8 全部为刻意保留项。","timestamp":1786874578893,"segment":0,"confidence":4.3478260869565215,"asi":{"hypothesis":"eslint 1→0:useEffect 依赖数组补 t(next-intl useTranslations 返回稳定引用,安全);modernize 补 1 处 time.Time omitempty→omitzero(输出一致)","insight":"modernize 剩余 3 处全部是嵌套 struct omitempty(client.go Release/Asset、lark.go Content)→ omitzero 会改变 wire,全部刻意保留。至此所有可安全修复的类别清零,剩余 8 个全部是有据可查的刻意保留项","next_action_hint":"剩余 8 全部刻意保留(errorlint 1 telegram、modernize 3 嵌套struct、nilnil 3 not-found、recvcheck 1 json)。下一轮做深化方向:测试代码质量(tests:false 之外)、或 golangci 附加 linter(gocritic 更多检查)作为新基准段"}}
|
||||
{"run":12,"commit":"e5f6b0a","metric":33,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":20,"golint_test_thelper":3,"golint_test_usetesting":2,"golint_test_total":25,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":37},"status":"keep","description":"基准扩展(文档化):新增测试代码质量维度 25 处(testifylint 20 + thelper 3 + usetesting 2),生产代码 8 处刻意保留不变。新基线 total=33。","timestamp":1786874744438,"segment":0,"confidence":4.878048780487805,"asi":{"hypothesis":"扩展基准到测试代码质量维度(testifylint 20 + thelper 3 + usetesting 2 = 25)","insight":"刻意排除 paralleltest/tparallel(共享 DB/redis 状态 + 本环境无法跑测试,t.Parallel 有风险)—— 这是范围扩展(抬高门槛),不是 gaming;基准定义已写入 prompt.md","next_action_hint":"修 25 处测试问题:float-compare 3(InDelta)、require-error 3、encoded-compare 1(JSONEq)、empty 3、contains 1、error-is-as 3、len 3、go-require-in-handler 2、t.Helper 3、os.MkdirTemp→t.TempDir 2"}}
|
||||
{"run":13,"commit":"63a24da","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":39},"status":"keep","description":"测试代码质量 25→0:assert↔require 一致性(fail-fast)、float 精确比较→InDelta、Equal(\"\",x)→Empty、Equal(len)→Len、errors.Is/As→ErrorIs/ErrorAs、JSON 字符串→JSONEq、handler goroutine 内 require→assert(真健壮性修复)、t.Helper()、os.MkdirTemp→t.TempDir()(符合 repo AGENTS 约束)。","timestamp":1786875177918,"segment":0,"confidence":4.3478260869565215,"asi":{"hypothesis":"修完测试代码质量维度 25 处(testifylint 20 + thelper 3 + usetesting 2)","insight":"批量修复过程揭示 testifylint 默认 max-same-issues=3 会掩盖重复模式(len(entries) 出现 4+ 次、float64(3) 4 次),需 --max-issues-per-linter=0 反复收敛;全部修复语义中性(assert↔require 仅 fail-fast 差异,InDelta/JSONEq/Empty/Len/ErrorIs 等价断言,t.Helper/t.TempDir 纯改善)。go-require 类(handler 内 require→assert)是真正的健壮性修复","next_action_hint":"测试维度清零。生产代码剩余 8 全部刻意保留。可选深化:gocritic 更多子检查/staticcheck 更多(SA 系列)扫描、或 biome check 格式一次性提交、或前端 a11y(eslint jsx-a11y 已含于 next core-web-vitals 默认关闭项)"}}
|
||||
{"run":14,"commit":"65c02ef","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":36},"status":"keep","description":"基准扩展 exhaustive(文档化)+ 12→0:枚举 switch 补显式 case(全部与现有 default 行为等价,fail-explicit 防未来枚举静默落入 default);source_tasks.go 为控制复杂度合并两个等价校验条件。","timestamp":1786875548060,"segment":0,"confidence":4.25531914893617,"asi":{"hypothesis":"基准扩展 exhaustive(12 处枚举 switch 显式化)+ 全量修复","insight":"12 处全部是 default 已正确处理、缺显式 case 的类型;补显式 case 仅为 fail-explicit(未来枚举新增不会静默落入 default)。source_tasks 补 case 后 Execute 复杂度 20→21 触发 cyclop,合并两个 ActionInvalid 条件(逻辑等价)降回 19。cyclop 与 exhaustive 的张力:显式 case 也计入复杂度","next_action_hint":"剩余 8 全为刻意保留。可再深化:sloglint 全量、govet 附加分析器、或前端 jsx-a11y/next 规则已有覆盖。也可将剩余 8 处文档化后收尾总结"}}
|
||||
{"run":15,"commit":"d7b8f44","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":37},"status":"keep","description":"修复 geoip/runtime.go 真死代码:ensureServerMMDB 的 os.Stat 错误被 if-init 遮蔽,`err != nil && !os.IsNotExist(err)` 恒为 false(外层 err 恒 nil),防御检查从未生效;改为显式捕获 statErr,stat 非 not-exist 错误现在正确返回。基准新增第 4 维度 govet nilness+unusedwrite(文档化扩展),当前 0。","timestamp":1786875949461,"segment":0,"confidence":4.166666666666667,"asi":{"hypothesis":"govet nilness 真实死代码 bug:ensureServerMMDB 的 stat 错误被 if-init 遮蔽,!os.IsNotExist(err) 恒为死条件(外层 err 恒 nil)","insight":"修复:显式捕获 statErr,使防御检查生效(stat 权限错误现在立即返回,不再静默吞掉后走 WriteFile 失败)。顺带基准扩展第 4 维度 govet nilness+unusedwrite(文档化,survey 过 fatcontext/containedctx/unparam/gocritic+29 检查:unparam 有 6+ 处真实死结果但需签名改动,留待下轮)","next_action_hint":"下轮候选:unparam(6+ 处 always-nil/never-used 结果,含 getSQLiteOverview/getPostgresOverview/getStatus 等,需改签名+调用方,churn 中等但都是真实死代码);或 fatcontext/containedctx(3+3 处,需逐处判断是否真反模式)"}}
|
||||
{"run":16,"commit":"c85373f","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":43},"status":"keep","description":"unparam 死代码清理 12→2(保留 2 处 objectstore 构造函数统一签名):移除 10 处恒 nil error / 从未使用的结果(getPoWConfigForRoute 的恒 nil *PoWConfig、getSQLiteOverview/getPostgresOverview/getStatus/loadKumaConfig/filterExpectedRoutes 的恒 nil error、rawJSONString/parsePositiveInt 的弃用 bool、buildProxyRoute 的弃用 []ZoneDomain、getLocked 的恒 nil error),同步简化 12+ 处调用方与死错误检查。9 个受影响包测试通过。metric 持平 8(改进在基准之外)。","timestamp":1786876191447,"segment":0,"confidence":5.128205128205129,"asi":{"hypothesis":"unparam 死代码清理:10 处 always-nil error / never-used 结果从签名移除","insight":"移除后调用方同步简化(db_manage 的 err 检查、option routers 的 AbortBadRequestOnError 成为死代码一并删)。getPoWConfigForRoute 的 *PoWConfig 结果恒 nil 且从未被用 —— 真死代码。保留 2 处 objectstore 构造函数 (X, error):factory switch 统一签名(newS3Backend/newLocalBackend 等可能真实报错),unparam 在此为接口一致性误报。全部 9 个受影响包测试通过。metric 持平 8(改进在基准之外,诚实记录)","next_action_hint":"下一候选:fatcontext(3 处嵌套 context 闭包,多为 slog/otel ctx 传递,需逐处判断是否真反模式) 或 containedctx(3 处 struct 含 ctx 字段,含 webdav/uptimekuma client —— 重构风险中等);或收尾把 unparam 加入基准(2 处已知保留)"}}
|
||||
{"run":17,"commit":"a16be01","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":40},"status":"keep","description":"修复 frpc 进程生命周期真 bug(agent 生产代码):exec.CommandContext 默认只杀直接子进程,被杀 shell 的孤儿 sleep 继续持有 stderr 管道,cmd.Wait() 阻塞到其自然退出(Stop/重启可挂起秒级)。改 Setpgid 进程组 + Kill(-pid) 整组击杀。连带修复两个测试 bug(Manager 拥有 Cmd 的并发 Wait 竞态 → Signal(0) 探测;ssl_renew 用 miniredis 替代 init() 创建的真实 redis 客户端)。go test ./internal/... ./pkg/... 全绿,checks.sh 升级为真实测试门禁。","timestamp":1786877266517,"segment":0,"confidence":7.142857142857143,"asi":{"hypothesis":"frpc 进程生命周期真 bug:exec.CommandContext 只杀直接子进程,孤儿孙进程持有 stderr 管道导致 cmd.Wait 阻塞到其自然退出(实测脚本 sleep 5 时 Stop 挂起 5s)","insight":"修复:Setpgid 独立进程组 + cmd.Cancel 覆盖为 Kill(-pid,SIGKILL) 整组击杀(经隔离复现 + 临时插桩定位,4 次假设检验收敛)。连带修复两个测试 bug:TestStopCancelsRunningProcesses 对 Manager 拥有的 Cmd 并发 Wait(与 os/exec ctxResult 通道竞争永久挂起)改为 Signal(0) 探测;ssl_renew 测试改用 miniredis(task 包 init() 创建真实 redis 客户端,违反 repo 无 init 装配约束)。成果:go test ./internal/... ./pkg/... 从 3 个失败→全绿(81+13 包),checks.sh 升级为真实测试门禁。metric 持平 8(改进在基准之外,但价值最高的一轮)","next_action_hint":"测试全绿后可解锁:paralleltest/tparallel 维度(t.Parallel 提速)——需先评估共享状态(miniredis/sqlite 每测试独立,风险低);或探索 relay/frps 同构代码是否有同样的 group-kill 问题(frps/manager 结构相同,值得检查)"}}
|
||||
{"run":18,"commit":"f5c9da0","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":39},"status":"keep","description":"前端测试套件 44 失败→全绿:10 个测试文件补 NextIntlClientProvider 包装(含 React19 createElement 类型修复、.ts→.tsx 重命名);修复真实 i18n ICU bug(githubUrlInvalid 的 {owner}/{repo} 未转义导致生产渲染成 key,zh/en + fragment 4 文件同步转义);更新 2 处过期测试期望。vitest 116/116 + tsc + eslint 全绿,checks.sh 增加前端测试门禁。","timestamp":1786878501539,"segment":0,"confidence":9.523809523809524,"asi":{"hypothesis":"前端测试可运行性:next-intl 迁移后 44/116 测试失败(缺 NextIntlClientProvider + 3 处真实断言问题)","insight":"修复三类:(1) 10 个测试文件的 render 助手缺 NextIntlClientProvider(createElement 与 JSX 混用踩 React19 类型坑,.ts 文件不能写 JSX → 重命名为 .tsx);(2) 真实 i18n bug:githubUrlInvalid 消息的 {owner}/{repo} 被 ICU 当占位符,t() 无参调用渲染成 key —— 需 '{' 单引号转义('{}' 内层转义不够,必须整体引号包裹 '{owner}'),4 个消息文件(zh/en + fragment 源)同步修复,check:i18n 通过;(3) 2 处测试期望过期(唯一访问者→查询窗口独立访客、检查间隔→检查间隔(分钟),以消息文件为准)。成果:116/116 vitest + tsc/eslint 全绿,checks.sh 增加前端测试门禁","next_action_hint":"前端测试全绿后可把 vitest 失败数纳入基准(当前不在基准内);或检查 app/(main) 目录下 3 个自带 .test.tsx(waf editor 系列)是否也符合新约定"}}
|
||||
{"run":19,"commit":"c455be3","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"vitest_failed":0,"vitest_total":116,"measure_s":62},"status":"keep","description":"基准扩展第 5 维度(文档化):前端 vitest 失败数纳入 total_issues(vitest_failed=0, total=116)。5 维全部处于下限,total=8 不变。","timestamp":1786878719509,"segment":0,"confidence":14.285714285714286,"asi":{"hypothesis":"基准扩展第 5 维度:前端 vitest 失败数(全绿后纳入防回归,文档化范围扩展非作弊)","insight":"measure_s 从 39s 升到 62s(vitest ~20s + eslint 冷启动),可接受。5 个维度全部在其下限:生产 8(全刻意保留)+ 测试 0 + govet 0 + eslint/tsc 0 + vitest 0","next_action_hint":"基准已 5 维全下限。后续可深化:paralleltest(现在测试可跑,但共享全局状态风险仍在,低优先);或 frontend biome 格式一次性提交(不进基准);或前端组件更深规则(jsx-a11y 已在 next core-web-vitals 覆盖)。也可认为会话到达稳定收尾点,更新 prompt/ideas 后总结"}}
|
||||
{"run":20,"commit":"4962bf9","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"vitest_failed":0,"vitest_total":116,"measure_s":86},"status":"keep","description":"两处真实质量修复:(1) 过期 swagger 文档重新生成(status_2xx/4xx/5xx_count 字段随 a4dd5ca9 加入后未同步 docs,违反 repo 约定,swag init 后差异仅真实新增字段);(2) generate-themes.js 输出补尾换行,themes.json 构建可复现(此前每次 build 弄脏工作树)。验证 next build 成功、musttag/tagalign 调查无真实问题。","timestamp":1786879144888,"segment":0,"confidence":25,"asi":{"hypothesis":"验证生产构建 + 修两处真实质量问题:swagger 文档过期(status_2xx/4xx/5xx_count 新增字段未重新生成)与 themes.json 构建不可复现(generate-themes.js 缺尾换行,每次 build 弄脏工作树)","insight":"next build 成功(无构建问题);musttag 3 处与 tagalign 均判定为非问题(持久化 round-trip 自洽/调试日志/纯格式)。swagger 差异仅 27 行且全部真实(a4dd5ca9 状态码拆分字段)。generate-themes.js 补 '\\n' 后 themes.json 再生与提交版完全一致,构建可复现。metric 持平 8(改进在基准之外)","next_action_hint":"会话已 5 维全下限 + 构建可复现 + 双端测试全绿。收尾候选:更新 prompt/ideas 记录本轮成果后总结;或继续验证 swag 生成的 docs.go 在 CI 中的可复现性"}}
|
||||
{"run":21,"commit":"e1b439d","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"vitest_failed":0,"vitest_total":116,"measure_s":74},"status":"keep","description":"全仓 go test -race 扫描(93 包)→ 全绿。修复 6 类数据竞争:frpc/frps 测试的锁外读与并发 Wait;oauth/repository 4 个 Pub/Sub 监听器 goroutine 读可变包变量(局部捕获 + done 通道等待);oauth 测试换 db.Redis 前停监听器;【真实生产 bug】tls 响应快照与异步续签 goroutine 并发写 cert 竞争(先快照再起 goroutine);upload/cache 监听器 goroutine 内读 db.Redis(调用方捕获)。","timestamp":1786881379399,"segment":0,"confidence":100,"asi":{"hypothesis":"全仓 -race 扫描:发现并修复 6 类数据竞争,含 1 个真实生产竞争","insight":"修复清单:(1) frpc 测试助手锁外读 proc.Status;(2) frps 测试对 Manager 拥有的 Cmd 并发 Wait(与 frpc 同类);(3) oauth/repository 4 个 Pub/Sub 监听器 goroutine 内读可变包变量 userListenerCtx 等 → 局部捕获 + stop 增加 done 通道等待;(4) oauth 测试 setupTestRouter 换 db.Redis 前先停各层监听器;(5)【真实生产 bug】tls logics 的 sanitizeCertificateForResponse 整体拷贝 cert 与异步续签 goroutine 字段写入并发 → 先快照再起 goroutine;(6) upload/cache 监听器 goroutine 内读 db.Redis → 调用方捕获。成果:93 包 go test -race 全绿。基准 5 维全下限不变(改进在基准外,但 tls 竞争是生产级真实问题)","next_action_hint":"可考虑把 -race 纳入周期性验证(不进每次 checks,全仓 ~3 分钟);或在 prompt/ideas 记录本轮成果"}}
|
||||
{"run":22,"commit":"ab70633","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"vitest_failed":0,"vitest_total":116,"measure_s":68},"status":"keep","description":"checks.sh 新增并发密集包 -race 门禁(8 个快速包,全仓 -race 清零后纳入防回归;frpc/frps 慢套件留作周期全量验证)。核查 7 处 t.Skip 均为合法环境门控。","timestamp":1786881700477,"segment":0,"confidence":200,"asi":{"hypothesis":"把 -race 门禁纳入 checks.sh(并发密集包子集,全仓 -race 清零后防回归)","insight":"选 8 个快速并发包(oauth/tls/uptimekuma/upload-cache/repository/disk-cache/logger/batchwriter,约 20s),排除 frpc/frps 慢套件(15s backoff + 25s supervisor,另做周期全量验证)。checks 总耗时 ~60s 可接受。顺带核查 7 处 t.Skip 均为合法环境门控(PG/CH 集成、symlink 平台差异),无隐藏损坏。metric 持平 8","next_action_hint":"会话已达全面收敛:5 维基准全下限 + 全仓 -race 清零 + 双端测试全绿 + 构建可复现 + -race 门禁入 checks。可收尾:更新 prompt/ideas 后总结"}}
|
||||
{"run":23,"commit":"aa4fadd","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"vitest_failed":0,"vitest_total":116,"measure_s":81},"status":"keep","description":"补齐 131 个 .go 文件的 SPDX license 头(repo 自带 make license 约定,早于约定新增的文件含 2 个生产文件;纯注释插入零行为影响),make license-check 转绿。go mod tidy -diff 确认干净。","timestamp":1786882164158,"segment":0,"confidence":null,"asi":{"hypothesis":"跑 repo 自带门禁 make license-check:发现 131 个 .go 文件缺 SPDX 头(早于 license 约定,含 2 个生产文件与多份本会话早期改过的文件)","insight":"用 repo 自带 scripts/update_go_license.sh(make license)自动补头(391 行纯注释插入),license-check 转绿;go mod tidy -diff 干净。gofmt/build/全测试/-race 均通过。metric 持平 8","next_action_hint":"会话全维度收敛。收尾:把 license-check 加入 checks.sh 防再犯;更新 prompt/ideas 最终状态"}}
|
||||
{"run":24,"commit":"4d78bc1","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"vitest_failed":0,"vitest_total":116,"measure_s":83},"status":"keep","description":"收敛验证轮:3× 时序敏感包测试无 flake;config.example.yaml ↔ model.go 同步确认无漂移(scheduler 空结构、replicas 注释化、queues[].name 嵌套均为误报);make build-embedded 发布路径构建成功且工作树干净(bin/、frontend/out 已 gitignore)。更新 prompt/ideas 记录会话终态。metric 持平 8。","timestamp":1786882694298,"segment":0,"confidence":null,"asi":{"hypothesis":"会话收敛验证:跑 3× 时序敏感包(无 flake)、config.example.yaml↔model.go 同步(3 个疑似缺失均为误报:scheduler 空结构/replicas 注释化/queues[].name 嵌套)、make build-embedded 发布路径(成功且树干净)","insight":"全部为确认性结果,无新问题。诚实记录:本轮零代码改动(上一轮已修 license),metric 持平 8。会话正式收敛:基准 5 维全下限 + 全仓 -race 清零 + 双端测试全绿 + 发布可复现 + config/文档同步。prompt/ideas 已更新终态","next_action_hint":"会话已收敛。若继续:周期全量 -race、前端 axe 浏览器级 a11y 审计(ideas.md 已记录)"}}
|
||||
{"run":25,"commit":"7f8e257","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"vitest_failed":0,"vitest_total":118,"measure_s":71},"status":"keep","description":"前端真实 a11y 审计:新增 axe-core(devDep)+ tests/a11y.test.tsx,对登录页与注册页渲染完整表单后运行 axe 结构性规则(label/button-name/heading-order/landmark/aria),两页均零违规。摸清并处理了渲染依赖(UserProvider 会话检查、publicConfigQuery 门控、configBool 字符串语义)。vitest 116→118 全绿。","timestamp":1786884595141,"segment":0,"confidence":null,"asi":{"hypothesis":"前端真实 a11y 审计:axe-core(jsdom 结构性规则)覆盖登录/注册页,超出 eslint 静态 jsx-a11y 的动态可访问性验证","insight":"新增 tests/a11y.test.tsx(2 测试)+ axe-core devDependency。调试中摸清登录/注册页渲染依赖链(UserProvider 挂载跳查 getUserInfo、LoginForm/RegisterForm 门控 publicConfigQuery、configBool 期望字符串 'true' 而非布尔 —— mock 需给字符串)。两页均零 axe 违规(color-contrast 因 jsdom 无布局引擎禁用,文档化)。vitest 116→118,checks 全绿。metric 持平 8","next_action_hint":"可扩展 axe 到更多页面(如登录 OTP 态、设置页),或收尾。axe 依赖仅 devDependency,不进基准计数(vitest_failed 已含新测试)"}}
|
||||
{"run":26,"commit":"7d03154","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"vitest_failed":0,"vitest_total":120,"measure_s":71},"status":"keep","description":"axe a11y 审计扩展到登录 OTP 验证表单(input-otp 分段输入,FieldLabel htmlFor 正确关联,零违规)与人机验证小部件手动模式(零违规)。环境修复:tests/setup.ts 加 ResizeObserver mock(input-otp 依赖,jsdom 未内置)。vitest 118→120 全绿。","timestamp":1786885103995,"segment":0,"confidence":null,"asi":{"hypothesis":"把 axe a11y 审计扩展到登录/注册外的关键表单状态:登录 OTP 验证(input-otp 分段输入是经典 a11y 雷区)与人机验证小部件(CapWidget 手动模式)","insight":"新增 2 个 axe 测试(vitest 118→120)。OTPForm 验证通过 —— FieldLabel htmlFor='otp-verification' 与 InputOTP id 正确关联,分段输入零违规;CapWidget 手动模式零违规。环境修复:input-otp 依赖 ResizeObserver,jsdom 未内置 → tests/setup.ts 加 ResizeObserver mock(全局,其它测试无害)。渲染上下文修正:裸组件需 <main> landmark 包裹(region 规则,真实页面由 AuthShell 提供)。scripts/ Go 代码用扩展 linter 集扫描为 0 问题(死路,未纳入基准)。metric 持平 8","next_action_hint":"a11y 覆盖已达:登录/注册页/OTP/CapWidget 全零违规。剩余页面(设置页、admin)需较重 mock。会话可收尾,或周期跑全量 -race 验证无 flake"}}
|
||||
{"run":27,"commit":"6c128e0","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"vitest_failed":0,"vitest_total":121,"measure_s":71},"status":"keep","description":"axe a11y 审计扩展到最复杂认证路径:注册页开启人机验证(CapWidget 自动求解→已通过状态 + 完整表单),mock getCapToken 避免 jsdom 无 Worker 环境限制。零违规。vitest 120→121 全绿。","timestamp":1786885536618,"segment":0,"confidence":null,"asi":{"hypothesis":"axe 审计覆盖最复杂的认证渲染路径:注册页开启人机验证(CapWidget 自动求解→已通过状态 + 完整表单)","insight":"新增第 5 个 axe 测试(vitest 120→121):mock @/lib/cap-solver 的 getCapToken 直接 resolve token(避免 jsdom 无 Web Worker),config 开 cap_login_enabled/cap_auto_solve,注册页渲染出 CAPTCHA 已通过态 + 表单全字段 → 零违规。vi.mock('@/lib/cap-solver') 对其它测试无害(仅 capEnabled 时渲染 CapWidget)。metric 持平 8","next_action_hint":"axe 覆盖已达 5 个认证表单态(登录/注册/OTP/验证小部件手动/注册+验证)。剩余:设置页与 admin 页需较重 mock。可收尾,或周期跑全量 -race 验证无 flake"}}
|
||||
{"run":28,"commit":"40eee77","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_vetx_total":0,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"vitest_failed":0,"vitest_total":121,"measure_s":0},"status":"keep","description":"前端显式 any 类型清理 2→0:Slot children?: any → ReactNode | MotionValue 联合(motion 真实类型),顺带修复潜在崩溃(原代码在 isValidElement 前访问 children.type,缺失时 TypeError,现无效 children 返回 null,hooks 无条件合规);useControlledState Rest extends any[] → unknown[]。两处 eslint-disable 注释删除。tsc/eslint/vitest 121 全绿。","timestamp":1786886086713,"segment":0,"confidence":null,"asi":{"hypothesis":"前端显式 any 类型清理:全仓 grep 仅 2 处 any —— Slot children?: any 与 useControlledState 的 Rest extends any[],均为真实类型缺陷","insight":"全前端 any 计数 2→0。slot.tsx:children?: any → React.ReactNode | MotionValue<string> | MotionValue<number>(motion HTMLMotionProps 的真实 children 类型);顺带修复潜在崩溃 —— 原代码在 isValidElement 检查前就访问 children.type,children 缺失时 TypeError,改为 isValidChild/childrenType 先计算(hooks 无条件,rules-of-hooks 合规),无效 children 返回 null。use-controlled-state.tsx:Rest extends any[] → unknown[]。两处 eslint-disable no-explicit-any 注释随之删除(无抑制注释)。tsc/eslint/vitest 121/checks.sh 全绿。benchmark 无关(metric 持平 8)。注意:run #28 的 run_experiment 被用户中断(aborted),但代码修复已通过全部门禁验证","next_action_hint":"用户要求合并到 main 并推送"}}
|
||||
{"run":29,"commit":"511bed8","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":63,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":124},"status":"keep","description":"修复 2 个新增 unconvert 问题(linux.go 中 int64(stat.Bsize) 恒等转换,Statfs_t.Bsize 在 Linux 上本就是 int64),删除多余转换零行为变化;total 10→8 回到 5 维全下限。","timestamp":1786894372432,"segment":0,"confidence":null,"asi":{"category":"unconvert","hypothesis":"会话恢复后 measure 显示 total=10,出现 2 个新的 unconvert 问题(internal/apps/edge/observability/linux.go:261-262 的 int64(stat.Bsize) 恒等转换,Linux Statfs_t.Bsize 本就是 int64)。删除多余转换,零行为变化","finding":"unconvert 是 repo 自带配置启用的 linter,此前 baseline 无此问题,最近用户提交/Go 版本变化后新增;修复后 5 维回到全下限 8","next_action_hint":"会话恢复点确认:total=8(5 维全下限,8 项均为有据可查的刻意保留)。下一轮候选:静态检查新维度(staticcheck SA 系列在 repo 配置中已启用且为 0)、或把 docs/ 下 vitepress 站点的构建纳入 measure 防回归(docs build 不属质量计数,不进基准)"}}
|
||||
{"run":30,"commit":"d49c7e1","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":183,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126},"status":"checks_failed","description":"Agent 发现 Token 比较改为 SHA-256 后恒定时间 Compare,堵住未授权节点注册口的计时侧信道。checks 在 -race 阶段超时(包本身已单独跑绿)。","timestamp":1787667218065,"segment":0,"confidence":null,"asi":{"hypothesis":"discovery token 用 != 比较,未授权 /agent/nodes/register 可被计时;改 SHA-256 + ConstantTimeCompare","rollback_reason":"checks.sh 在 go test -race 阶段 300s 超时(包单独跑全绿,预算不够)","next_action_hint":"同一修复用 checks_timeout_seconds=600 重跑"}}
|
||||
{"run":31,"commit":"69055a9","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":71,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"未授权 Agent 注册口的 discovery token 改为 SHA-256 后恒定时间比较,堵住计时侧信道;空 token / 末字节翻转用例同步补上。metric 持平 8。","timestamp":1787667401636,"segment":0,"confidence":null,"asi":{"hypothesis":"discovery token 用 != 比较,未授权 /agent/nodes/register 可被计时;改 SHA-256 + ConstantTimeCompare","finding":"公开面注册口 ValidateDiscoveryToken 是入侵入口;管理员已登录操作不在范围内。checks 全绿。","next_action_hint":"下一轮可查边缘 Token 比较(agent/relay/flared 走 DB 查找,计时面更弱)或登录口限流"}}
|
||||
{"run":32,"commit":"fb62802","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":81,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"公开登录/注册邮箱验证码比较改为 SHA-256 后恒定时间 Compare,堵住未授权口的计时侧信道。metric 持平 8。","timestamp":1787667660993,"segment":0,"confidence":null,"asi":{"hypothesis":"verifyEmailCode 用 != 比较 6 位码,公开登录/注册口可被计时","finding":"公开面验证码比较已改恒定时间;冷却仍在,不改限流策略。","next_action_hint":"下一轮可查边缘节点 access_token 比较(DB 查找,计时面更弱)或登录失败锁定"}}
|
||||
{"run":33,"commit":"b8bf82b","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":99,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"未授权登录口补哑 bcrypt 比较,用户不存在与密码错误耗时对齐;禁用账号不再返回不同文案,堵住用户枚举。metric 持平 8。","timestamp":1787668237322,"segment":0,"confidence":null,"asi":{"hypothesis":"未授权 /user/login 在用户不存在时跳过 bcrypt,且禁用账号返回不同文案,可枚举用户","finding":"DummyCheckPassword 启动时生成哑哈希,gosec 不报警;禁用账号改统一错误文案。管理员已登录不在范围内。","next_action_hint":"下一轮可查边缘节点 access_token 明文比较,或公开 CAP challenge 滥用"}}
|
||||
{"run":34,"commit":"380a42a","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":90,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"登录/注册/OAuth 回调统一走 SetLoginSession,保存前清空 Redis 会话 ID,堵住未授权会话固定。metric 持平 8。","timestamp":1787669059138,"segment":0,"confidence":null,"asi":{"hypothesis":"生产 Redis 会话在登录时复用同一 ID,未授权方可固定会话 cookie","finding":"SetLoginSession 先 Clear 再把 gorilla session.ID 置空,Save 时 redistore 生成新 ID;明文改密标记经 extras 写回。","next_action_hint":"下一轮可查边缘节点 access_token 明文比较,或公开 CAP challenge 滥用"}}
|
||||
{"run":35,"commit":"dfda2d3","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":75,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"去掉公开 CAP 口硬编码默认密钥;SessionSecret 为空时拒绝签发/核销,防止未授权伪造 PoW。metric 持平 8。","timestamp":1787669542055,"segment":0,"confidence":null,"asi":{"hypothesis":"公开 /api/cap/challenge 在 SessionSecret 为空时用硬编码默认密钥,未授权方可伪造 PoW","finding":"GetDefaultManager 无密钥时返回 nil;Challenge/Redeem 拒绝,VerifyMiddleware 在 CAP 开启时同样拒绝。测试自行设置密钥。","next_action_hint":"下一轮可查公开 OAuth state 洪水或边缘节点 access_token 明文比较"}}
|
||||
{"run":36,"commit":"7fa9e46","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":86,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"注册开关读取失败时改为关闭,堵住配置缺失时未授权开注册;OAuth 自动注册同样 fail-closed。metric 持平 8。","timestamp":1787669960693,"segment":0,"confidence":null,"asi":{"hypothesis":"registration_enabled/password_register_enabled 读取失败默认 true,和种子 false 相反,配置缺失时未授权开注册","finding":"密码注册与 OAuth 自动注册均 fail-closed;测试改为显式开启注册并正确失效缓存。","next_action_hint":"下一轮可查 OIDC 开关 fail-open(种子默认 true,风险较低)或公开 OAuth state 洪水"}}
|
||||
{"run":37,"commit":"0290c93","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":95,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"公开 OAuth 登录/授权入口按会话限制 10 分钟内最多 20 个 state,堵住未授权 Redis 洪水。metric 持平 8。","timestamp":1787670327304,"segment":0,"confidence":null,"asi":{"hypothesis":"公开 /oauth/login 与 /oauth/{source}/authorize 每次请求都往 Redis 写 10 分钟 state,无上限","finding":"按 sessionHash 计数,10 分钟内最多 20 个;超出返回业务错误。mock Redis 补 Incr/Expire。","next_action_hint":"下一轮可查边缘节点 access_token 明文比较,或公开 CAP challenge 洪水"}}
|
||||
{"run":38,"commit":"c0a82f8","metric":8,"metrics":{"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_usestdlibvars":0,"golint_wastedassign":0,"golint_total":8,"eslint_problems":0,"eslint_errors":0,"eslint_warnings":0,"tsc_errors":0,"measure_s":85,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_usetesting":0,"golint_test_total":0,"golint_exhaustive":0,"golint_vetx_total":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"公开密码登录口按 IP 限制 10 分钟内最多 20 次失败,堵住未授权爆破。metric 持平 8。","timestamp":1787670665553,"segment":0,"confidence":null,"asi":{"hypothesis":"公开 /user/login 失败无 IP 限流,未授权方可无限爆破","finding":"按 ClientIP 计数,10 分钟 20 次失败后拒绝;成功清零。管理员已登录不在范围内。","next_action_hint":"下一轮可查公开 CAP challenge 洪水或边缘节点 access_token 明文比较"}}
|
||||
{"run":39,"commit":"be5d067","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":76,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"auth_cache negative 缓存加上限防 DoS + relay/flared 删除重复 authenticateAccessToken 改用 agent 共享缓存版","timestamp":1787708052241,"segment":0,"confidence":null,"asi":{"hypothesis":"negative cache 无上限可被伪造 token 撑爆内存;relay/flared 与 agent 三份重复的 authenticateAccessToken","next_action_hint":"继续扫其他无界缓存/限流缺口","result":"metric 持平 8(8 个均为 deliberate keeper),安全修复不计入 metric","security":"negative cache 加 10k 上限+过期清理;relay/flared 复用 agent.AuthenticateAccessToken(共享 2min 正/10min 负缓存,DB 压力下降)"}}
|
||||
{"run":40,"commit":"0dd2cf9","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":77,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"websocket 三 hub 去重:抽 runWritePump 共享写泵 + 合并 agent 广播函数为 broadcastAgent","timestamp":1787708370650,"segment":0,"confidence":null,"asi":{"hypothesis":"三份 hub 的 writePump 完全重复(仅日志前缀不同),readPump 已有 runReadPump 抽取先例;BroadcastWAFIPGroups/BroadcastActiveConfig 复制粘贴","next_action_hint":"close() 3 份小重复可再合并但收益低;继续找其他模块的重复/无界增长","result":"metric 持平 8,全测试绿","refactor":"新增 websocket/write_pump.go runWritePump(对齐 runReadPump 模式),agent/relay/flared writePump 改委托;agent_hub 抽 broadcastAgent 合并两个广播函数"}}
|
||||
{"run":41,"commit":"efd8268","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":75,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"websocket 三 client 结构体去重:嵌入共享 wsClientCore(close/enqueue 单份实现)","timestamp":1787708975609,"segment":0,"confidence":null,"asi":{"hypothesis":"agentClient/relayClient/flaredClient 字段与 close/enqueue 完全相同,用组合(嵌入 wsClientCore)消除三份重复","next_action_hint":"代码库经 40 轮已高度收敛;后续可周期性跑 go test -race 全量","result":"metric 持平 8,全测试绿;净减 ~60 行重复代码","refactor":"新增 websocket/client_core.go:wsClientCore(nodeID/conn/send/done/once) + 共享 close/enqueue;三个 client 结构体改为嵌入"}}
|
||||
{"run":42,"commit":"ed1efd3","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":77,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"补 wsClientCore 并发测试 + close() 防 nil conn 守卫","timestamp":1787709222794,"segment":0,"confidence":null,"asi":{"hypothesis":"wsClientCore 并发语义(close 幂等、enqueue 不阻塞/关后拒绝)无测试覆盖","next_action_hint":"websocket 包已有基础并发测试;继续其他模块扫描","result":"metric 持平 8;测试还暴露 close 未防 nil conn 的防御缺口,已补守卫","refactor":"新增 websocket/client_core_test.go 3 个 -race 测试;client_core.go close() 增加 nil conn 守卫"}}
|
||||
{"run":43,"commit":"4f8e7e6","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":77,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"修复 frps/frpc TOML 配置注入:新增 protocol.TOMLQuote 并在两处配置渲染全部使用","timestamp":1787709693698,"segment":0,"confidence":null,"asi":{"hypothesis":"frps/frpc TOML 配置用裸 Fprintf 拼接,token/password/域名含引号、反斜杠、换行时会破坏配置或注入键","next_action_hint":"检查其他配置生成点是否有同类注入面(nginx/openresty 配置)","result":"metric 回到 8;frpc 慢套件 16.8s 全绿;mnd 曾短暂+1(Grow 魔法数),删除微优化后消除","security":"新增 pkg/protocol/toml.go TOMLQuote 转义助手 + toml_test.go;relay/frps renderConfig 与 flared/frpc buildFrpcToml 全部插值改为转义输出"}}
|
||||
{"run":44,"commit":"63007fc","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":92,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"全仓 race 扫描发现 upload/cache 监听器 DATA RACE:捕获 redis 客户端消除全局读竞争 + Stop 等待 done + 同型监听器(oauth×2/repository×2)加固","timestamp":1787711092906,"segment":0,"confidence":null,"asi":{"hypothesis":"全仓 go test -race 可能暴露并发 bug(此前仅局部验证)","next_action_hint":"继续扫其他模块;可考虑把 -race 纳入周期性检查","result":"发现并修复 1 个真实 DATA RACE;修复后全仓 -race 0 竞争,metric 持平 8","root_cause":"upload/cache 监听器 goroutine 读可变全局 db.Redis,与 testhelper 清理置 nil 竞争;testhelper 导入 upload/cache 有循环依赖,故用启动时捕获客户端的根因修复(oauth/repository 同型监听器一并加固),并补 StopUploadMetaCacheListener 同步等待 done"}}
|
||||
{"run":45,"commit":"63007fc","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":70,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"discard","description":"探索轮:索引对齐/前端请求瀑布/BasicAuth 注入面三假设均证伪,无代码变更","timestamp":1787711474404,"segment":0,"confidence":null,"asi":{"hypothesis":"SQLite 迁移缺 PG 同款索引;前端存在串行请求瀑布;nginx BasicAuth 密码有注入面","next_action_hint":"代码库已高度收敛;下轮可考虑 observability 查询构造器审计或周期性重跑 -race","rollback_reason":"纯探索无代码变更,无需回滚","result":"三个假设均无产出:①索引对比(修正提取正则后)PG/SQLite 完全对齐,SQLite 仅多 legacy w_* 冗余索引;②前端 await Service 均在事件处理器非渲染期;③BasicAuth 密码经 base64 编码(字母表无元字符)无注入面","lessons":"grep 提取 SQL 时注意 IF NOT EXISTS 变体,否则产生假缺口"}}
|
||||
{"run":46,"commit":"2cb3392","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":106,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"LIKE 过滤器转义修复:日志搜索含 %/_ 的输入不再被当通配符;pkg/util 新增 EscapeLike 共享助手 + 单测","timestamp":1787712116152,"segment":0,"confidence":null,"asi":{"hypothesis":"日志搜索 LIKE 过滤器不转义 %/_/\\,含下划线的路径/主机名搜索结果错误","next_action_hint":"同类遗留站点(upload/user/task_execution GORM 搜索)已记 ideas.md,可作后续轮次","result":"修复 4 个站点:analytics 两处 CH 过滤器 + logstore postgres_store 两处(PG/SQLite 加 ESCAPE '\\')。新增 pkg/util/like.go EscapeLike + 单测。metric 持平 8,全部测试通过","scope_decision":"GORM 实体搜索站(upload keyword、user username/email)同 bug 类但低风险且可能依赖现有通配语义,本轮不动"}}
|
||||
{"run":47,"commit":"3528323","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":102,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"GORM 实体搜索 LIKE 转义收尾:6 站点复用 EscapeLike + 显式 ESCAPE 子句,含 OAuth 用户名冲突误报修复","timestamp":1787712555794,"segment":0,"confidence":null,"asi":{"hypothesis":"GORM 实体搜索站与 #46 日志搜索同 bug 类:LIKE 模式不转义通配符","next_action_hint":"LIKE 类已全部收尾;下轮可考虑 ideas.md 的测试可运行性方向或周期性全仓 -race 重跑","result":"6 站点修复(upload keyword、user username/email 前缀+contains、OAuth uniqueUsername base、task_type 前缀),PG/SQLite 加显式 ESCAPE。系统常量模式刻意保留(upload.go:199 image/%)。metric 持平 8,测试全绿","scope_decision":"uniqueUsername 的 base 来自 OAuth 用户信息属外部输入,含 _ 会误报用户名冲突——虽是系统生成后缀模式也需转义 base 本身"}}
|
||||
{"run":48,"commit":"55db1c0","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":112,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"后台 goroutine panic 防护:新增 pkg/util.Go 共享助手(recover+调用点日志),全仓 22 个裸 go func() 站点统一收口","timestamp":1787713583118,"segment":0,"confidence":null,"asi":{"hypothesis":"全仓 20 处后台 goroutine 裸跑零 recover,任一 panic 击穿 gin handler 级恢复直接崩溃进程","next_action_hint":"goroutine 收口完成;下轮可周期性 go test -race ./... 全量重跑(上次 #44)","result":"pkg/util.Go(fn) 共享助手(runtime.Caller 自动记录调用点 + slog + debug.Stack),22 个站点全部收口(含嵌套 watcher)。脚本转换两轮(首轮漏嵌套内层)。首次 checks_failed 因新文件缺 SPDX 头,update_go_license.sh 修复后全绿。metric 持平 8"}}
|
||||
{"run":49,"commit":"40232d8","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":75,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"修复 frpc restartProcess 发布未初始化 exec.Cmd 的数据竞争:proc.Cmd/Status 改为 Start 成功后加锁发布","timestamp":1787714358791,"segment":0,"confidence":null,"asi":{"hypothesis":"周期性全仓 go test -race ./... 重跑(上次 #44 后又改了 repository/logstore/goroutine 站点)能抓出新数据竞争","next_action_hint":"-race 全仓清零;下轮候选:frontend axe a11y 审计,或 Go 1.26 新 linter 扫描","result":"全仓 -race 抓到 1 个真实 race:frpc/manager.go restartProcess 在 cmd.Start() 前就发布 proc.Cmd+Status=running(Start 中 cmd.Process 未赋值),测试读句柄与之竞争。修复=Start 成功后再加锁发布(manager.go:219-220 移入 err==nil 分支)。frpc 包 -race 连续 3 次通过。其余全仓 -race 干净"}}
|
||||
{"run":50,"commit":"40232d8","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":70,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"discard","description":"扩展 linter 发现扫描 + 热路径性能排查:errchkjson/unparam/spancheck 等 9 个新维度,全部核实为不可失败/刻意设计/误报","timestamp":1787714798689,"segment":0,"confidence":null,"asi":{"hypothesis":"基准外发现型 linter(errchkjson/unparam/spancheck/exptostd/durationcheck/makezero/reassign/asasalint/bidichk)+ 热路径性能 grep 能找到真实缺陷","next_action_hint":"发现型 linter 已穷尽;下轮候选:frontend axe a11y 浏览器级审计,或任务执行日志/DB 增长类运维审查","result":"全部证伪:errchkjson 12 处均核实为不可能失败的 marshal(纯 string/int/[]string 结构体;2 处 unsafe 标记是传递性保守);spancheck 1 处误报(唯一调用方 executor.go:242 有 defer span.End());unparam×2 为已评估的工厂签名设计;正则全在包级编译无热路径重编译;包级 map 全为有界静态注册表;AppendLog 走 DB 无内存累积。escapeJSONString 用法正确。无代码变更"}}
|
||||
{"run":51,"commit":"bbf7919","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":72,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"discard","description":"运行时资源审计:HTTP 客户端超时覆盖 + 查询热路径索引覆盖,两项全部干净无缺陷","timestamp":1787715135724,"segment":0,"confidence":null,"asi":{"hypothesis":"运行时资源审计:出站 HTTP 客户端超时覆盖 + LIKE/精确匹配热路径的 DB 索引支撑","next_action_hint":"两项审计干净。剩余:frontend axe a11y(需起前端+浏览器)、周期性 -race 重跑、uploads LOWER(file_name) contains 若成为性能痛点需改前缀语义+表达式索引","result":"全部干净:15 个 http.Client 中 14 个显式 Timeout,唯一无 Timeout 的 agent/nginx checkStubStatus 走 NewRequestWithContext+WithTimeout 边界;users.username 全部精确匹配热路径由 UNIQUE 内联索引覆盖(PG+SQLite 均确认),email/task_type/logstore 过滤列均已有索引;uploads LOWER(file_name) contains 不可用 b-tree 但属管理端低频,改语义才有收益故不动"}}
|
||||
{"run":52,"commit":"bbf7919","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":71,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"discard","description":"SQL 注入面 + Go 运行时陷阱模式 + react-hooks 依赖三重审计,全部干净无缺陷","timestamp":1787715503278,"segment":0,"confidence":null,"asi":{"hypothesis":"原始 SQL 拼接注入面 + 经典 Go 运行时陷阱(time.After 循环泄漏/defer-in-loop/context.Background 丢失取消)+ 前端 react-hooks 依赖正确性","next_action_hint":"静态+运行时审计维度已穷尽。剩余唯一大项:frontend axe a11y 浏览器级审计(需起前端 dev server + agent_browser)","result":"全部干净:db_manage SQL 控制台为管理端允许例外且表名双引号转义正确、analytics Sprintf 均内部常量表名+参数化占位符;time.After 仅 3 处且均为 select 单次等待/有界重试;defer 均在函数级非循环内;19 处 context.Background() 全部为后台监听器(WithCancel)/重启路径/自带超时的清理任务,无请求 ctx 丢弃;react-hooks/exhaustive-deps 全仓零违规(CLI 临时规则,未改配置)"}}
|
||||
{"run":53,"commit":"bbf7919","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":72,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"discard","description":"前端 axe a11y 浏览器审计:唯一违规为无后端环境产物,无代码缺陷","timestamp":1787716027952,"segment":0,"confidence":null,"asi":{"hypothesis":"前端 axe-core 浏览器级 a11y 审计(最后一个未探索大维度)","next_action_hint":"a11y 维度已探索但受登录墙限制:完整审计需起后端+种子账号登录。若未来重跑:起 Go 后端 + admin 登录后逐页 axe.run","result":"agent-browser 0.34.0 已装好可复用。axe 审计覆盖所有无认证可达页面(/login、/register、/docs/* 全被登录墙拦截):唯一违规 page-has-heading-one 是环境产物——后端未启动时页面卡在 session-check/publicConfig-pending 态只渲染 Spinner,真实表单的 AuthHeading h1 未渲染;瞬态态用 h3 属可接受的瞬态层级。无代码缺陷。已认证页面需后端才能审计"}}
|
||||
{"run":54,"commit":"451ce52","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":93,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"认证页 axe a11y 审计+修复:7 处布局级真实违规全修,复扫验证 dashboard/admin/system 归零;基准 total_issues 保持 8 不变(纯质量收益)","timestamp":1787718397798,"segment":0,"confidence":null,"asi":{"hypothesis":"认证页 axe a11y 审计(起后端+登录突破登录墙):修复布局级真实违规","next_action_hint":"已验证 / 与 /admin/system 归零。剩余页面级:admin 表格行内操作按钮/Switch 无 aria-label、muted 文本对比度——需逐表补标签,工作量大已归档 ideas.md","result":"修复 7 处全局问题并复扫验证:sidebar 折叠按钮 aria-label、Sidebar role=navigation(region 违规 18 节点/页清零)、header Kbd 对比度 text-foreground/70(每页 1 处)、dashboard 4 个 Progress aria-label、分页按钮 aria-label、空态/错误/加载 h3→p(heading-order 清零)、admin/system 无内容 Tabs 改 aria-pressed 按钮组(aria-valid-attr-value critical 清零)。dashboard 与 admin/system 现 0 违规","setup":"审计环境:后端 go run . api @:3100(CONFIG_PATH=/tmp/of-audit/config.yaml,sqlite+redis host 网络 docker)、前端 pnpm dev --port 3002(WAVELET_BACKEND_URL=:3100)、admin 密码经 reset-passwd 重置"}}
|
||||
{"run":55,"commit":"e66dea9","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":85,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"a11y 收尾:主题级对比度根因修复(indigo-500→600)+12 处控件 accessible name+4 处 heading-order,7 页复扫全 0 违规;基准 total_issues 保持 8","timestamp":1787719908229,"segment":0,"confidence":null,"asi":{"hypothesis":"页面级 a11y 批量收尾:主题级 color-contrast 根因 + 表格/表单控件 accessible name","next_action_hint":"7 页复扫全 0 违规。剩余:其余页面(websites/origins/cloudflare 等仅扫过 contrast 已由主题修复覆盖)可抽查;-race 周期重跑","result":"根因1:--primary indigo-500(#6366f1) 对 #fafafa 仅 4.27 → 改 indigo-600 oklch(51.1% 0.262 276.966)(~6.8 AA),全站 contrast 清零(一处主题修复覆盖所有页面)。修复 12 处控件名:access-analytics 刷新按钮、events-tab Switch/edit/delete、openflare-ops ToggleRow Switch+geoip/kuma Select+FieldInput Input htmlFor+discovery Textarea、table-browser/sql-console SelectTrigger;heading-order:cache-manager/user-detail-sheet h4→p、task-manager h3→p、file-manager noFiles h3→p;新增 admin.logs.analytics.refresh i18n 键(en/zh)+merge-i18n-fragments。教训:settings 表单异步渲染,早前扫描漏报 label 违规需 wait 5s 后再 axe.run;Radix SelectValue value='' 时 placeholder 不显示致 combobox 无名,须 aria-label 兜底","setup":"审计环境同 run#54:后端:3100(sqlite) + docker redis host 网络 + pnpm dev --port 3002"}}
|
||||
{"run":56,"commit":"63e3b85","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":85,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"富交互页 a11y 抽查收尾:8+3 页扫描,修复 cloudflare 筛选器无名/access-token amber 对比度/notifications 缺 h1 共 3 处,全部复扫归零;基准 total_issues 保持 8","timestamp":1787720716912,"segment":0,"confidence":null,"asi":{"hypothesis":"富交互页抽查(websites/origins/proxy-routes/certificates/cloudflare/dns-accounts/settings 子页)","next_action_hint":"11 页扫描全部归零,a11y 维度已穷尽。剩余:周期性 -race 重跑;审计环境复用法在 ideas.md","result":"websites/origins/proxy-routes/certificates/dns-accounts 5 页直接 0 违规(主题修复覆盖);3 处新发现全修复并复扫验证:cloudflare 同步面板状态筛选 SelectTrigger 加 aria-label(statusPlaceholder);access-token 安全提示 amber-600→amber-700(12px 小字对比度 4.5 不达标);notifications 面包屑页加 sr-only h1——教训:h1 不能放 BreadcrumbList 内(破坏 list 语义 axe list 规则),BreadcrumbPage 无 asChild 需放 Breadcrumb 外","setup":"审计环境同前:后端:3100 + docker redis host 网络 + pnpm dev --port 3002"}}
|
||||
{"run":57,"commit":"453f7e5","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":95,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"周期性 -race 重跑抓到真实 bug:wsClientCore.enqueue close 后 select 随机选择致契约违反;确定性先查 done 修复+测试循环加固+gofmt 存量漂移清理","timestamp":1787721299485,"segment":0,"confidence":null,"asi":{"hypothesis":"周期性全仓 -race 重跑(上次干净为 run #49)","next_action_hint":"websocket 包 -race 10×count=1 全过。教训已记录:select 多 case 同时就绪时随机选择,closed 检查须独立 select 先行;replace 工具锚点选错会级联破坏文件,小文件直接 write 重写更安全","root_cause":"enqueue 把 closed 检查与发送合并在同一个 select,两 case 同时就绪时 Go 随机选择,close 后约 50% 概率仍投递成功——违反 fail-fast 契约且测试 flaky。修复=独立 select 确定性先查 done;测试加固为循环 50 次","result":"抓到真实 bug:wsClientCore.enqueue close 后非确定返回 true(TestWSClientCoreEnqueueFailsAfterClose 必失败)。调用方 agent_hub×3 语义无影响(false=丢弃本就正确)。顺带修 3 个 hub 文件存量 gofmt 漂移","scope_note":"-race 重跑仅 websocket 包 1 个 FAIL,其余 internal/... pkg/... 全部通过"}}
|
||||
{"run":58,"commit":"fc733d0","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":77,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"#57 enqueue 修复的同型残留收口:SendFlaredPong/SendRelayPong 合并 select 随机选择 bug,委托 client.enqueue 去重修复","timestamp":1787721635717,"segment":0,"confidence":null,"asi":{"hypothesis":"#57 修复 enqueue 后,grep 全 hub 同型合并 select——发现 SendFlaredPong/SendRelayPong 残留相同 bug","lesson":"修一个 bug 后应 grep 所有同型调用点(本会话 run #44/#46/#57 三次都是同型残留收口模式);委托共享 enqueue 是去重+根因一步到位","next_action_hint":"websocket 并发面已全清。下轮可做:周期性全仓 -race 或 go test -count=10 稳定性抽查","root_cause":"SendFlaredPong (flared_hub.go) 与 SendRelayPong (relay_hub.go) 把 case <-client.done 与 case client.send <- 合并同一 select,两 case 同时就绪时 Go 随机选择,close 后仍可能投递成功。修复=委托 client.enqueue(内含确定性先查 done),同时消除重复代码"}}
|
||||
{"run":59,"commit":"b56f276","metric":8,"metrics":{"eslint_errors":0,"eslint_problems":0,"eslint_warnings":0,"golint_canonicalheader":0,"golint_errname":0,"golint_errorlint":1,"golint_exhaustive":0,"golint_forcetypeassert":0,"golint_gosec":0,"golint_intrange":0,"golint_modernize":3,"golint_nilnil":3,"golint_perfsprint":0,"golint_prealloc":0,"golint_recvcheck":1,"golint_test_testifylint":0,"golint_test_thelper":0,"golint_test_total":0,"golint_test_usetesting":0,"golint_total":8,"golint_usestdlibvars":0,"golint_vetx_total":0,"golint_wastedassign":0,"measure_s":66,"tsc_errors":0,"vitest_failed":0,"vitest_total":126},"status":"keep","description":"#59 -shuffle=on 扫描抓到测试顺序依赖:config_version RAM 配置缓存跨测试污染,setup/cleanup 接入 ram.ResetForTest() 修复","timestamp":1787722520315,"segment":0,"confidence":null,"asi":{"hypothesis":"-shuffle=on 测试顺序随机化扫描(未查过的维度),暴露测试间共享状态依赖","lesson":"repository 读配置会写进程级 RAM 缓存(ram.Set,TTL 跨测试存活);测试用 :memory: DB + SetDB 换库时缓存不随之失效。默认源码顺序下 Defaults 先跑掩盖了问题。-shuffle=on 是暴露此类顺序依赖的低成本手段,可周期重跑","next_action_hint":"全仓 shuffle 已干净。下轮候选:-count 多轮稳定性、或从 ideas.md 剩余条目挑;明确不做清单见 ideas.md","root_cause":"TestBuildOpenRestyConfigSnapshotOriginErrorPageDefaults 在 shuffle 下命中 Custom 用例留在进程级 RAM 配置缓存的 enabled=false/[\"522\",\"500-502\"](GetSystemConfigByGroup 未命中时 ram.Set 回填)。修复=两个测试 setup(setupOriginErrorPageSnapshotDB/setupConfigVersionTestDB)接入既有 ram.ResetForTest():换 DB 前后各清一次"}}
|
||||
Executable
+90
@@ -0,0 +1,90 @@
|
||||
#!/bin/bash
|
||||
# Benchmark: total code-quality issues across backend + frontend (lower is better).
|
||||
# Fixed linter set — see .auto/prompt.md. Never tune this file to game counts.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
start=$(date +%s)
|
||||
|
||||
# ---------- Backend: golangci-lint, repo config + fixed best-practice extras ----------
|
||||
EXTRA_LINTERS="errorlint,errname,nilnil,forcetypeassert,copyloopvar,intrange,mirror,perfsprint,prealloc,usestdlibvars,modernize,sloglint,canonicalheader,nosprintfhostport,recvcheck,wastedassign,exhaustive"
|
||||
golang_out=$(golangci-lint run --enable="$EXTRA_LINTERS" 2>&1 || true)
|
||||
|
||||
golang_total=0
|
||||
while IFS= read -r line; do
|
||||
if [[ "$line" =~ ^\*\ ([a-zA-Z0-9_]+):\ ([0-9]+)$ ]]; then
|
||||
name="${BASH_REMATCH[1]}"
|
||||
n="${BASH_REMATCH[2]}"
|
||||
golang_total=$((golang_total + n))
|
||||
echo "METRIC golint_${name}=$n"
|
||||
fi
|
||||
done <<< "$golang_out"
|
||||
echo "METRIC golint_total=$golang_total"
|
||||
|
||||
# ---------- Backend: test-code quality (tests excluded from repo config; safe linters only) ----------
|
||||
test_out=$(golangci-lint run --tests=true --enable=testifylint,usetesting,thelper --enable-only=testifylint,usetesting,thelper 2>&1 || true)
|
||||
golang_test_total=0
|
||||
while IFS= read -r line; do
|
||||
if [[ "$line" =~ ^\*\ ([a-zA-Z0-9_]+):\ ([0-9]+)$ ]]; then
|
||||
name="${BASH_REMATCH[1]}"
|
||||
n="${BASH_REMATCH[2]}"
|
||||
golang_test_total=$((golang_test_total + n))
|
||||
echo "METRIC golint_test_${name}=$n"
|
||||
fi
|
||||
done <<< "$test_out"
|
||||
echo "METRIC golint_test_total=$golang_test_total"
|
||||
|
||||
# ---------- Backend: govet extra analyzers (dead code / nil deref — real-bug finders) ----------
|
||||
cat > /tmp/govetx.yml <<'EOF'
|
||||
version: "2"
|
||||
linters:
|
||||
default: none
|
||||
enable:
|
||||
- govet
|
||||
settings:
|
||||
govet:
|
||||
enable:
|
||||
- nilness
|
||||
- unusedwrite
|
||||
EOF
|
||||
vetx_out=$(golangci-lint run --config /tmp/govetx.yml --max-issues-per-linter=0 2>&1 || true)
|
||||
rm -f /tmp/govetx.yml
|
||||
golang_vetx_total=0
|
||||
while IFS= read -r line; do
|
||||
if [[ "$line" =~ ^\*\ ([a-zA-Z0-9_]+):\ ([0-9]+)$ ]]; then
|
||||
name="${BASH_REMATCH[1]}"
|
||||
n="${BASH_REMATCH[2]}"
|
||||
golang_vetx_total=$((golang_vetx_total + n))
|
||||
echo "METRIC golint_vetx_${name}=$n"
|
||||
fi
|
||||
done <<< "$vetx_out"
|
||||
echo "METRIC golint_vetx_total=$golang_vetx_total"
|
||||
|
||||
# ---------- Frontend: eslint (repo gate) ----------
|
||||
cd frontend
|
||||
eslint_out=$(pnpm exec eslint . --max-warnings 0 2>&1 || true)
|
||||
eslint_problems=0; eslint_errors=0; eslint_warnings=0
|
||||
if [[ "$eslint_out" =~ ([0-9]+)\ problems? ]]; then eslint_problems="${BASH_REMATCH[1]}"; fi
|
||||
if [[ "$eslint_out" =~ \(([0-9]+)\ errors?, ]]; then eslint_errors="${BASH_REMATCH[1]}"; fi
|
||||
if [[ "$eslint_out" =~ ,\ ([0-9]+)\ warnings? ]]; then eslint_warnings="${BASH_REMATCH[1]}"; fi
|
||||
echo "METRIC eslint_problems=$eslint_problems"
|
||||
echo "METRIC eslint_errors=$eslint_errors"
|
||||
echo "METRIC eslint_warnings=$eslint_warnings"
|
||||
|
||||
# ---------- Frontend: tsc (repo gate) ----------
|
||||
tsc_out=$(pnpm exec tsc --noEmit --jsx preserve 2>&1 || true)
|
||||
tsc_errors=$(grep -cE "error TS" <<< "$tsc_out" || true)
|
||||
echo "METRIC tsc_errors=$tsc_errors"
|
||||
|
||||
# ---------- Frontend: vitest (2026-08-16 起全绿,纳入基准防回归) ----------
|
||||
vitest_out=$(pnpm exec vitest run --reporter=dot 2>&1 || true)
|
||||
vitest_failed=0; vitest_total=0
|
||||
if [[ "$vitest_out" =~ ([0-9]+)\ failed ]]; then vitest_failed="${BASH_REMATCH[1]}"; fi
|
||||
if [[ "$vitest_out" =~ Tests[[:space:]]+([0-9]+)\ passed ]]; then vitest_total="${BASH_REMATCH[1]}"; fi
|
||||
if [[ "$vitest_out" =~ Tests[[:space:]]+([0-9]+) ]]; then vitest_total="${BASH_REMATCH[1]}"; fi
|
||||
echo "METRIC vitest_failed=$vitest_failed"
|
||||
echo "METRIC vitest_total=$vitest_total"
|
||||
|
||||
end=$(date +%s)
|
||||
total=$((golang_total + golang_test_total + golang_vetx_total + eslint_problems + tsc_errors + vitest_failed))
|
||||
echo "METRIC total_issues=$total"
|
||||
echo "METRIC measure_s=$((end - start))"
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
# Autoresearch: 前后端代码质量符合最佳代码实践
|
||||
|
||||
## Objective
|
||||
|
||||
Improve backend (Go) and frontend (Next.js/TS) code quality so the codebase
|
||||
conforms to best practices. NOT a performance task. Each experiment is a code
|
||||
change that removes real, lint-diagnosed code-quality issues (dead assignments,
|
||||
error-wrapping bugs, non-idiomatic loops, mixed receivers, unsafe error
|
||||
comparisons, unnecessary string fmt, etc.) without changing behavior.
|
||||
|
||||
Genuine quality work only: fix code, never weaken the checks. Do NOT edit
|
||||
`.golangci.yml`, eslint/biome config, or add `nolint`/`eslint-disable`
|
||||
comments to reduce counts. Do NOT reformat code that isn't part of a fix
|
||||
(no formatted-only churn).
|
||||
|
||||
## Metrics
|
||||
|
||||
- **Primary**: `total_issues` (unitless, lower is better) = backend golangci
|
||||
issues (extended linter set below) + frontend eslint problems + tsc errors.
|
||||
- **Secondary**: per-linter counts (`golint_modernize`, `golint_perfsprint`,
|
||||
`golint_errorlint`, `golint_gosec`, `golint_canonicalheader`,
|
||||
`golint_recvcheck`, `golint_wastedassign`, `golint_usestdlibvars`,
|
||||
`golint_intrange`, `golint_forcetypeassert`, `golint_nilnil`,
|
||||
`golint_prealloc`, `golint_errname`, `golint_sloglint`,
|
||||
`golint_copyloopvar`, `golint_mirror`, `golint_nosprintfhostport`),
|
||||
`eslint_problems`, `eslint_errors`, `eslint_warnings`, `tsc_errors`,
|
||||
`measure_s` (benchmark wall time).
|
||||
|
||||
## How to Run
|
||||
|
||||
`./.auto/measure.sh` — outputs `METRIC name=value` lines. Parsed by
|
||||
run_experiment automatically.
|
||||
|
||||
Correctness gate: `./.auto/checks.sh` runs `go vet ./...`, `go build ./...`,
|
||||
and the repo's own `golangci-lint run` (repo config, tests excluded) — all
|
||||
must pass. Note: `go test ./...` is NOT in checks.sh — several tests fail on
|
||||
main today for environmental reasons (no local redis; flaky frpc process
|
||||
tests). Don't "fix" those unless cheap and clearly unrelated to redis/flaky.
|
||||
|
||||
## Benchmark Definition (fixed — never change mid-session)
|
||||
|
||||
Backend: `golangci-lint run --enable=errorlint,errname,nilnil,forcetypeassert,
|
||||
copyloopvar,intrange,mirror,perfsprint,prealloc,usestdlibvars,modernize,
|
||||
sloglint,canonicalheader,nosprintfhostport,recvcheck,wastedassign`
|
||||
(repo `.golangci.yml` linters stay active too; `tests: false` as configured).
|
||||
|
||||
Frontend: `pnpm exec eslint . --max-warnings 0` (repo gate) +
|
||||
`pnpm exec tsc --noEmit --jsx preserve` (repo gate).
|
||||
|
||||
Test-code dimension (added 2026-08-16, run #12+, documented scope extension —
|
||||
raising the bar, not gaming): `golangci-lint run --tests=true
|
||||
--enable=testifylint,usetesting,thelper --enable-only=testifylint,usetesting,thelper`
|
||||
counts test-file quality. DELIBERATELY excludes paralleltest/tparallel
|
||||
(t.Parallel advice is unsafe here: many suites share DB/redis state and tests
|
||||
cannot be run in this env) and gocritic extras (noise). Fix test issues only
|
||||
when compile-safe (go vet compiles tests) and semantically neutral.
|
||||
|
||||
Frontend vitest dimension (added run #19, after suite went green in run #18):
|
||||
`pnpm exec vitest run --reporter=dot` — `vitest_failed` counts into total.
|
||||
The suite is fully runnable locally (jsdom + mocks; no external services).
|
||||
Do not add/remove linters or change settings to make the number go down.
|
||||
|
||||
## Files in Scope
|
||||
|
||||
Backend (Go): `cmd/`, `internal/`, `pkg/`. Anything lint-flagged in the
|
||||
extended set above. Note: module name in go.mod is `github.com/Rain-kl/Wavelet`.
|
||||
|
||||
Frontend (TS/React): `frontend/app/`, `frontend/components/`, `frontend/lib/`,
|
||||
`frontend/contexts/`, `frontend/hooks/`, `frontend/types/`, frontend scripts.
|
||||
|
||||
Infra: `frontend/pnpm-workspace.yaml` — approved @parcel/watcher + @swc/core
|
||||
builds (fixes `make code-check` under pnpm 11; ERR_PNPM_IGNORED_BUILDS
|
||||
otherwise). Already committed in setup.
|
||||
|
||||
## Off Limits
|
||||
|
||||
- `.golangci.yml`, `eslint.config.mjs`, `biome.json` — never touch to reduce counts.
|
||||
- No `//nolint` / `eslint-disable` comments to silence checks.
|
||||
- No reformat-only commits (biome/gofmt churn without a fix).
|
||||
- No behavior changes: refactors must compile (checks.sh gate) and keep tests
|
||||
semantics identical. Re-run checks.sh after every edit.
|
||||
- `frontend/node_modules`, `frontend/bun.lock` (untracked, not ours).
|
||||
- Do not run `go test` suites that need redis/network to declare success.
|
||||
|
||||
## Constraints
|
||||
|
||||
- Backend conventions (AGENTS.md): apps → repository → model layering;
|
||||
`pkg/util/` must not import Gin/GORM/sessions; no `db.DB` in model;
|
||||
response.Abort* for API errors; Chinese docs for content changes
|
||||
(code-quality fixes are not content changes — no doc sync needed unless
|
||||
behavior/UX changes; changelog only for user-visible changes, typically
|
||||
none here).
|
||||
- Frontend: run `pnpm exec biome format --write` only on files you edit
|
||||
(repo `make format` uses biome); keep component placement rules.
|
||||
- `golangci-lint --fix` is allowed and preferred for safe fixes
|
||||
(modernize/intrange/perfsprint/usestdlibvars/canonicalheader/mirror/
|
||||
copyloopvar/sloglint/errname) — review the resulting diff before keeping.
|
||||
For no-fix linters (errorlint wrapping, wastedassign, recvcheck, nilnil,
|
||||
prealloc, forcetypeassert) edit by hand.
|
||||
|
||||
## Workflow per iteration
|
||||
|
||||
1. Read current measure output: which categories remain, where.
|
||||
2. Pick ONE category (or a coherent set of similar fixes), locate files, fix
|
||||
by hand or with golangci-lint --fix scoped to that category.
|
||||
3. `./.auto/measure.sh` → if total dropped → `./.auto/checks.sh` → log keep.
|
||||
If flat/worse → discard or adjust.
|
||||
|
||||
## What's Been Tried
|
||||
|
||||
- Setup commit `ee6974d` (autoresearch/code-quality-2026-08-16): branch,
|
||||
.auto/ session files, frontend/pnpm-workspace.yaml build approvals.
|
||||
- Baseline (before any code fix): total_issues = 108
|
||||
(golangci 107 = modernize 37, perfsprint 18, errorlint 12, canonicalheader 8,
|
||||
recvcheck 7, wastedassign 7, usestdlibvars 3, intrange 3, forcetypeassert 3,
|
||||
nilnil 3, prealloc 3, errname 1, gosec 2; eslint 1 warning
|
||||
[react-hooks/exhaustive-deps in
|
||||
app/(main)/pages/detail/components/pages-source-card.tsx:275]; tsc 0).
|
||||
- Environment notes: golangci-lint 2.12.2 warm cache ~3s; eslint cold ~27s
|
||||
(ignore stderr pnpm noise); go vet+go build ~15-30s after edits.
|
||||
|
||||
### 最终状态(run #23,提交 aa4fadda,本会话收敛点)
|
||||
|
||||
基准 5 维全下限 total=8(全为刻意保留);后端 94 包 + 前端 vitest 116 全绿;
|
||||
`go test -race ./internal/... ./pkg/...` 93 包零警告;`make build-embedded`
|
||||
(发布路径)成功且工作树干净;`make license-check` / `go mod tidy -diff` /
|
||||
`go test -count=3`(时序敏感包)全部通过。checks.sh 门禁:vet + build +
|
||||
golangci + 单测 + vitest + 并发包 -race + license-check。
|
||||
|
||||
### Session result (14 experiments, commits f1f6bb85→65c02ef7)
|
||||
|
||||
108 → **8** (-92.6%) across 3 benchmark dimensions, all remaining 8 are
|
||||
deliberate, documented keepers (see below). Never weakened a check; never
|
||||
added nolint/eslint-disable; benchmark extensions were transparently
|
||||
documented (test-code dimension run #12, exhaustive run #14).
|
||||
|
||||
Fixed (zero behavior change, each reviewed):
|
||||
- gosec 2→0 (saturating multiply pattern gosec accepts without nolint)
|
||||
- modernize 37→5→3 (any, max/min, slices/maps, strings.Cut/SplitSeq,
|
||||
strings.Builder; omitted omitted-lark: nested struct omitzero = wire change)
|
||||
- perfsprint 18→0, canonicalheader 8→0, usestdlibvars 3→0, intrange 3→0,
|
||||
wastedassign 7→0, errname 1→0, forcetypeassert 6→0, prealloc 2→0
|
||||
- errorlint 12→1 (errors.Is/As, %v→%w chains)
|
||||
- recvcheck 7→1 (GORM TableName → pointer receiver; verified gorm source uses
|
||||
reflect.New, tests pass)
|
||||
- eslint 1→0 (exhaustive-deps: add stable `t` to dep array)
|
||||
- test dimension 25→0 (testifylint 20, thelper 3, usetesting 2)
|
||||
- exhaustive 12→0 (explicit enum cases = fail-explicit)
|
||||
|
||||
Deliberate keepers (8) — do NOT "fix" without new evidence:
|
||||
- errorlint 1: pkg/push/telegram.go %v — wrapping the original error would
|
||||
change errors.Is matching semantics; it's intentionally textual context.
|
||||
- modernize 3: nested-struct omitempty (client.go Release/Asset,
|
||||
lark.go Content) — omitzero would CHANGE wire output (plain structs
|
||||
serialize always today).
|
||||
- nilnil 3: not-found/optional-result conventions — postgres_store.go
|
||||
ClickHouseOperationalStats (interface contract, documented in comment),
|
||||
openflare_apply_log.go GetLatestOpenFlareApplyLogByNodeID (tested),
|
||||
github_source_action.go guarded outcome (callers check != nil).
|
||||
- recvcheck 1: MillisecondDuration — encoding/json requires Marshal value
|
||||
receiver + Unmarshal pointer receiver.
|
||||
|
||||
Surveyed and rejected (noise/risk, do not add):
|
||||
- fieldalignment (~100+): JSON key order change + positional literal risk.
|
||||
- sloglint full / gocritic extras: 0 findings.
|
||||
- paralleltest/tparallel: t.Parallel advice unsafe (shared DB/redis state;
|
||||
tests not runnable in this env).
|
||||
- biome format drift (76 files): pure formatting noise; repo's make format
|
||||
covers it.
|
||||
@@ -1,19 +0,0 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[*.{json,yml,yaml}]
|
||||
indent_size = 2
|
||||
|
||||
[*.md]
|
||||
insert_final_newline = false
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.{js,ts,css,html,jsx,tsx,vue}]
|
||||
indent_size = 2
|
||||
+1
-1
@@ -56,7 +56,7 @@ REDIS_MAINT_NOTIFICATIONS=false
|
||||
|
||||
# ─── ClickHouse(必需)────────────────────────────────────────────────────────
|
||||
# CLICKHOUSE_HOST 设置后会自动启用;测试环境可显式 CLICKHOUSE_ENABLED=true 做 live 联调
|
||||
CLICKHOUSE_ENABLED=true
|
||||
CLICKHOUSE_ENABLED=false
|
||||
# compose 内:clickhouse:9000;本机连映射端口:127.0.0.1:9000
|
||||
CLICKHOUSE_HOST=clickhouse:9000
|
||||
CLICKHOUSE_USERNAME=default
|
||||
|
||||
@@ -298,9 +298,7 @@ jobs:
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Fetch embedded GeoIP database
|
||||
run: bash scripts/fetch-agent-geoip-mmdb.sh
|
||||
|
||||
# GeoIP MMDB is not embedded; Docker images COPY mmdb files, bare binaries seed via download on first start.
|
||||
- name: Build Agent
|
||||
env:
|
||||
CGO_ENABLED: 0
|
||||
|
||||
+7
-2
@@ -77,8 +77,13 @@ profile.cov
|
||||
.grok
|
||||
/.gomodcache/
|
||||
*.mmdb
|
||||
!internal/apps/agent/geoipdata/GeoLite2-Country.mmdb
|
||||
!internal/apps/agent/geoipdata/GeoLite2-City.mmdb
|
||||
# Server control-plane MaxMind Country seed (Country only; Agent does not embed)
|
||||
!internal/apps/openflare/geoip/data/GeoLite2-Country.mmdb
|
||||
|
||||
/.superpowers/
|
||||
/.worktrees/
|
||||
/.pi-subagents/
|
||||
|
||||
# i18n 生成物(由 scripts/merge-i18n-fragments.mjs 从 fragments 生成)
|
||||
frontend/messages/zh-CN.json
|
||||
frontend/messages/en.json
|
||||
|
||||
@@ -1,372 +1,237 @@
|
||||
# AGENTS.md
|
||||
|
||||
本文件是 OpenFlare 的 AI 接手入口,不承载详细设计、规范和计划。接手项目时,请根据以下分层文档指引进行阅读与开发:
|
||||
Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed.
|
||||
|
||||
### 1. 开发指导规范 (AI & Developer Guidelines)
|
||||
**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment.
|
||||
|
||||
* **必须阅读**:
|
||||
* **[docs/plan/index.md](./docs/plan/index.md)**:查看正在进行的开发实现计划(Implementation Plan)与 AI 代理交接文档(Handover),接手项目时优先检查。
|
||||
## 1. Think Before Coding
|
||||
|
||||
### 2. 系统设计与架构 (Design Docs)
|
||||
**Don't assume. Don't hide confusion. Surface tradeoffs.**
|
||||
|
||||
* **[docs/design/index.md](./docs/design/index.md)**:理解产品范围、系统边界、核心对象及长期约束,以及[仓库结构](./docs/design/index.md#仓库结构)。
|
||||
* **[docs/design/architecture.md](./docs/design/architecture.md)**:理解 Server、Agent、OpenResty 与前端的职责边界与网络拓扑。
|
||||
* **[docs/design/agent-design.md](./docs/design/agent-design.md)**:理解 Agent 设计原则、与 Server 交互时序、OpenResty 管控与配置发布回滚模型。
|
||||
Before implementing:
|
||||
- State your assumptions explicitly. If uncertain, ask.
|
||||
- If multiple interpretations exist, present them - don't pick silently.
|
||||
- If a simpler approach exists, say so. Push back when warranted.
|
||||
- If something is unclear, stop. Name what's confusing. Ask.
|
||||
|
||||
## 2. Simplicity First
|
||||
|
||||
**Minimum code that solves the problem. Nothing speculative.**
|
||||
|
||||
- No features beyond what was asked.
|
||||
- No abstractions for single-use code.
|
||||
- No "flexibility" or "configurability" that wasn't requested.
|
||||
- No error handling for impossible scenarios.
|
||||
- If you write 200 lines and it could be 50, rewrite it.
|
||||
|
||||
Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
|
||||
|
||||
## 3. Surgical Changes
|
||||
|
||||
**Touch only what you must. Clean up only your own mess.**
|
||||
|
||||
When editing existing code:
|
||||
- Don't "improve" adjacent code, comments, or formatting.
|
||||
- Don't refactor things that aren't broken.
|
||||
- Match existing style, even if you'd do it differently.
|
||||
- If you notice unrelated dead code, mention it - don't delete it.
|
||||
|
||||
When your changes create orphans:
|
||||
- Remove imports/variables/functions that YOUR changes made unused.
|
||||
- Don't remove pre-existing dead code unless asked.
|
||||
|
||||
The test: Every changed line should trace directly to the user's request.
|
||||
|
||||
## 4. Goal-Driven Execution
|
||||
|
||||
**Define success criteria. Loop until verified.**
|
||||
|
||||
Transform tasks into verifiable goals:
|
||||
- "Add validation" → "Write tests for invalid inputs, then make them pass"
|
||||
- "Fix the bug" → "Write a test that reproduces it, then make it pass"
|
||||
- "Refactor X" → "Ensure tests pass before and after"
|
||||
|
||||
For multi-step tasks, state a brief plan:
|
||||
```
|
||||
1. [Step] → verify: [check]
|
||||
2. [Step] → verify: [check]
|
||||
3. [Step] → verify: [check]
|
||||
```
|
||||
|
||||
Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.
|
||||
|
||||
---
|
||||
|
||||
## Git 提交规范指南
|
||||
**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes.
|
||||
|
||||
### 提交信息基本格式
|
||||
|
||||
每次提交更改时,应当使用以下提交格式:
|
||||
|
||||
```text
|
||||
<type>(<scope>): <subject>
|
||||
|
||||
<body>
|
||||
```
|
||||
|
||||
* **Type**: 提交类型(例如 `feat`, `fix`, `refactor`, `perf`, `docs`, `chore` 等)。
|
||||
* **Scope** (可选): 影响的范围(例如 `api`, `frontend`, `auth`, `mcp` 等)。
|
||||
* **Subject**: 简短的一句话描述变更。
|
||||
* **Body** (可选): 详细的说明,多行叙述。
|
||||
|
||||
## 务必阅读匹配的 Skill
|
||||
## Skills(匹配任务时必读)
|
||||
|
||||
| Skill | 何时使用 |
|
||||
| :--- | :--- |
|
||||
| `new-api` | 添加或修改自定义业务 API、Handler、服务层逻辑、自定义路由注册 |
|
||||
| `new-async-task` | 添加或修改 Asynq 任务、定时任务、TaskHandler、任务元数据 |
|
||||
| `new-setting` | 添加或修改系统/业务/公开设置、`/admin/system` 参数或 `/admin/settings` 图形化设置 |
|
||||
| `database-migration` | 数据库表结构变更、goose SQL 迁移(PG/SQLite/ClickHouse)、seed 数据 |
|
||||
| `clickhouse-batchwriter` | ClickHouse 批量写入、`internal/infra/persistence/batchwriter` 接入、分析表异步 flush、背压与写入路径改造 |
|
||||
| `file-upload` | 业务上传文件、Worker 程序化摄取、`upload.Ingest` 策略选型、文件访问与 `w_uploads` / 统计排查 |
|
||||
| `cache-framework` | 新增或修改业务缓存(RAM/Redis/DB 三层读路径)、缓存失效、多节点 pub/sub 同步、评估高频读是否应接入缓存 |
|
||||
| `push-notification` | 系统通知推送事件、统一触发器投递、带消息推送的业务功能 |
|
||||
| `release-guide` | 根据自上一正式版本 Tag 以来的提交整理 Version Bump 提交信息以触发双语 Release |
|
||||
| `shadcn` | 添加、修改或组合 shadcn/ui 组件 |
|
||||
| `new-api` | 业务 API、Handler、服务层、路由注册 |
|
||||
| `new-async-task` | Asynq 任务、定时任务、TaskHandler、任务元数据 |
|
||||
| `new-setting` | 系统/业务/公开设置、`/admin/system`、`/admin/settings` |
|
||||
| `database-migration` | 表结构、goose 迁移(PG/SQLite/ClickHouse)、seed |
|
||||
| `logstore` | 日志/分析用途表、`internal/repository/logstore`、切换日志主库、PG/SQLite 回落 |
|
||||
| `clickhouse-batchwriter` | CH 批量写入、batchwriter、分析表 flush/背压 |
|
||||
| `file-upload` | 上传/摄取、`upload.Ingest`、文件访问、`w_uploads` |
|
||||
| `cache-framework` | 业务缓存(RAM/Redis/DB)、失效、多节点同步 |
|
||||
| `push-notification` | 通知推送事件、统一触发器、带推送的业务 |
|
||||
| `release-guide` | Version Bump 提交信息(触发双语 Release) |
|
||||
| `shadcn` | 添加/修改/组合 shadcn/ui 组件 |
|
||||
|
||||
## 硬性约束
|
||||
|
||||
## 严格遵循事项 (Guardrails)
|
||||
- 禁止删除 `frontend/node_modules`。
|
||||
- `pkg/util/` 保持纯净:禁止导入 Gin、GORM、sessions 等 HTTP/Web/DB 框架(会话选项在 `internal/apps/oauth/session.go`)。
|
||||
- 测试临时目录只用 `t.TempDir()`,禁止硬编码相对路径写源码树。
|
||||
- HTTP 路由仅在 `internal/router/router.go` 注册;`Serve()` 只挂路由与中间件,禁止进程级初始化(如 `SyncEvents`、`InitLogWriter`)。
|
||||
- API 变更后:`make swagger`;开发完成:`make code-check`;提交前:`make format`。
|
||||
- 缓存/文件管理复用平台实现,业务包禁止自建缓存目录或旁路存储后端。
|
||||
- 文件摄取走 `upload.Ingest`(`PolicyCreate` / `PolicyDedupNewRecord` / `PolicyResolveExisting`);删除走 `upload.Remove` / `upload.RemoveOwned`。禁止业务直接 `repository.CreateUpload` / `SoftDeleteUpload` 或 `db.Create(&model.Upload{})`。
|
||||
- **分层**:`apps → repository → model`,`repository → infra/persistence`;禁止 `model → repository`。
|
||||
- `model`:实体、表名、配置 key、查询 DTO、无 IO 规则。禁止 `db.DB` / Redis / CH;禁止 `import repository`。GORM hook 仅可 mutate 自身字段,禁止在 hook 内再查 DB/缓存。
|
||||
- `repository`:唯一持久化入口。apps/logics 禁止为业务 CRUD 直调 `db.DB`(管理端 SQL 控制台、infra 内部等例外保留)。禁止新增 `model.Get/List/Create/...` 类数据访问 API。
|
||||
- 日志/分析表(节点访问日志、用户访问日志、可观测时序)走 `internal/repository/logstore`,禁止 apps 直连 `repository/analytics` 或 `db.ChConn`/`db.ChDB`。判定与接入步骤见 `logstore` skill。
|
||||
- 跨模块集成(任务 Handler、推送事件、域监听、完成钩子)禁止 `init()` 注册;经 `internal/platform/bootstrap` 在 `internal/cmd` 入口显式装配。
|
||||
- 核心业务(如 `oauth`、`user`)禁止直接 import push/custom_events;经 `internal/listener` 发域事件,push 在 bootstrap 订阅。
|
||||
- 依赖任务/推送注册的测试须显式 `bootstrap.RegisterTasks()` / `RegisterPushDomainEvents()` 等,不依赖 `init()`。
|
||||
- API 错误必须 `response.Abort*` + `ErrorHandlerMiddleware`;禁止 Handler 直接 `c.JSON(..., response.Err(...))` 或用 HTTP 200 表示失败。
|
||||
|
||||
- 切勿删除 `frontend/node_modules`
|
||||
- 保持 `pkg/util/` 绝对纯净且不引入任何框架。禁止从 `pkg/util/` 及其子包中导入 Gin、GORM、sessions 等 HTTP/Web/数据库相关框架包(例如,Web 会话选项已收敛至 `internal/apps/oauth/session.go`)。
|
||||
- 编写测试用例时,禁止使用硬编码的相对路径(如 `"uploads/test_cache"`)在源码目录下创建临时测试目录,必须统一使用 Go 内置的 `t.TempDir()` 以避免污染源码目录。
|
||||
- 所有 HTTP 路由仅在 `internal/router/router.go` 中注册。
|
||||
- 当 API Handler 发生变化时,更新 Swagger 文档(运行 `make swagger`)。
|
||||
- 在完成代码开发后必须运行 `make code-check`, 并修复报错。
|
||||
- 在完成代码开发后或者 git 提交前必须运行 `make format` 格式化代码。
|
||||
- 需要缓存或文件管理能力时,必须复用现有平台实现,禁止在业务包中自行创建缓存目录、直接管理缓存文件或重复封装存储后端。
|
||||
- 文件摄取必须通过 `upload.Ingest`(`upload.PolicyCreate` / `PolicyDedupNewRecord` / `PolicyResolveExisting`);删除必须通过 `upload.Remove` 或 `upload.RemoveOwned`。禁止业务模块直接调用 `repository.CreateUpload` / `repository.SoftDeleteUpload`,禁止 `db.Create(&model.Upload{})` 旁路写 `w_uploads`。
|
||||
- **`internal/model` 与 `internal/repository` 分层(硬规则)**:
|
||||
- `internal/model/`:仅 GORM 实体、表名、配置 key、查询 DTO、无 IO 领域规则(如密码哈希校验、字段规范化)。**禁止**在 model 中调用 `db.DB` / Redis / ClickHouse,**禁止** `import internal/repository`。
|
||||
- 实体上允许仅 mutate 自身字段的 GORM hook(如 `AfterFind(*gorm.DB)`),**禁止**在 hook 内再发起 DB/缓存查询。
|
||||
- `internal/repository/`:唯一持久化入口(CRUD、事务、缓存、分析查询)。apps / logics / task 框架通过 repository 访问数据,**禁止**在 Handler 内直接写复杂 SQL。
|
||||
- apps 不得为业务 CRUD 直接调用 `db.DB`;必须走 repository(管理端 SQL 控制台、infra 内部实现等例外可保留)。
|
||||
- 依赖方向只能是 `apps → repository → model` 与 `repository → infra/persistence`;**禁止** `model → repository`。
|
||||
- 新增代码不得再增加 `model.Get/List/Create/Update/Delete*(ctx…)` 类数据访问 API;存量迁移按域收敛至 repository。
|
||||
- 禁止在 `init()` 中注册跨模块集成(任务 Handler、推送内置事件、域事件监听器、任务完成钩子)。统一通过 `internal/platform/bootstrap` 在 `internal/cmd` 入口显式装配。
|
||||
- `internal/router/router.go` 的 `Serve()` 仅负责 HTTP 路由与中间件,禁止在其中执行 `SyncEvents`、`InitLogWriter` 等进程级运行时初始化。
|
||||
- 核心业务模块(如 `oauth`、`user`)禁止直接 `import` `internal/apps/admin/push` 或 `custom_events` 触发通知;应通过 `internal/listener` 发射域事件,由 push 模块在 bootstrap 阶段订阅。
|
||||
- 编写依赖任务注册或推送事件同步的测试时,必须在测试 setup 中显式调用 `bootstrap.RegisterTasks()`、`bootstrap.RegisterPushDomainEvents()` 等,不得依赖 `init()` 副作用。
|
||||
- API 错误响应必须通过 `response.Abort*` 中断请求,由 `ErrorHandlerMiddleware` 统一写出 JSON;禁止 `c.JSON(http.StatusOK, response.Err(...))` 及 Handler 直接 `c.JSON(status, response.Err(...))`。
|
||||
1. **设计先行**:
|
||||
* 开发新功能或重要特性时,必须在 `docs/design/` 下创建/更新对应的设计文档,理清架构与核心决策。
|
||||
* 新增的设计文档应同步更新至 `docs/design/architecture.md` 及在 `docs/config.ts` 中注册侧边栏路由。
|
||||
* 若实现内容超出产品边界,必须先修改设计文档,再编码实现。
|
||||
3. **开发计划与交接**:
|
||||
* 正在进行的开发计划或 AI 接手交接发生变化时,在 `docs/plan/` 下更新对应的开发计划或接手文档,并使用相应模板初始化。
|
||||
4. **文档与变更日志**:
|
||||
* 当相关内容发生变化时,同步更新对应的**中文文档**(不要同步英文文档)。
|
||||
* 代码或配置变更完成后,必须在 [`docs/changelog/index.md`](./docs/changelog/index.md) 的 `[Unreleased]` 区块补充对应变更条目。
|
||||
* **纯文档变更(如 `docs/` 下的 Markdown 文档、README 等)不需要写入 changelog。**
|
||||
* 更新 changelog 时遵循以下书写规则:
|
||||
1. 合并重复或相近的变更,不按提交逐条罗列。
|
||||
2. 不记录格式化、临时调试、无关重构等对用户无意义的变更。
|
||||
3. 使用用户可理解的表述,不描述内部实现细节。
|
||||
4. 每条均使用完整中文句子,并尽量说明修复或优化的内容及其带来的效果。
|
||||
5. 仅基于实际变更撰写,不编造提交或代码中不存在的信息。
|
||||
6. 不记录 Token、密钥、私有地址等敏感信息;没有内容的分类可以省略。
|
||||
### 文档与 Changelog
|
||||
|
||||
## 项目介绍
|
||||
- 内容变更同步**中文文档**(不同步英文)。
|
||||
- 代码/配置变更写入 [`docs/changelog/index.md`](./docs/changelog/index.md) 的 `[Unreleased]`;纯文档变更不写 changelog。
|
||||
- Changelog:合并相近项;不记格式化/调试/无关重构;用户可读完整中文句;说明效果;不编造;不写密钥等敏感信息;空分类可省略。
|
||||
|
||||
### 技术栈
|
||||
## 技术栈
|
||||
|
||||
- 后端:Go 1.25+、Gin、GORM、PostgreSQL、可选 ClickHouse、Redis、Asynq、Cobra、Viper、Swaggo、OpenTelemetry、Zap、AWS SDK v2、Snowflake IDs。
|
||||
- 前端:Next.js App Router、TypeScript、Tailwind CSS、pnpm、shadcn/ui。
|
||||
- **后端**:Go 1.25+、Gin、GORM、PostgreSQL、可选 ClickHouse、Redis、Asynq、Cobra、Viper、Swaggo、OTel、Zap、AWS SDK v2、Snowflake IDs
|
||||
- **前端**:Next.js App Router、TypeScript、Tailwind、pnpm、shadcn/ui
|
||||
|
||||
### 目录结构与平台能力
|
||||
## Git
|
||||
|
||||
顶层目录:
|
||||
Conventional Commits:`<type>(<scope>): <subject>`(例:`feat(auth): support email login`)。
|
||||
|
||||
- `main.go`:程序入口,委派给 `internal/cmd`。
|
||||
- `config.example.yaml`:已提交的配置模板。在添加配置字段时保持更新。
|
||||
- `config.yaml`:本地运行时的配置文件。不要将其作为已提交的源码提交。
|
||||
- `docker/`:集成的、仅前端的和仅后端的 Dockerfile。
|
||||
- `docs/`:自动生成的 Swagger 文档。请勿手动编辑生成的文件。
|
||||
- `frontend/`:Next.js 应用。
|
||||
- `internal/`:私有 Go 后端代码。
|
||||
- `pkg/`:公共 Go 库/工具包(留作扩展或存放不依赖特定业务的通用代码)。
|
||||
- `scripts/`:本地和 CI 辅助脚本。
|
||||
- `support-files/`:部署 and 数据库辅助文件。
|
||||
- `bin/`:本地编译生成的二进制可执行文件。
|
||||
- `data/`:本地运行时数据文件目录(如 PostgreSQL、Redis 数据等)。
|
||||
- `uploads/`:本地文件上传存储目录。
|
||||
---
|
||||
|
||||
后端目录:
|
||||
## 后端
|
||||
|
||||
- `internal/cmd/`:用于 API、worker、scheduler、root init 的 Cobra 命令。进程启动时在此调用 `bootstrap.Register*` 与 `bootstrap.Init`,再启动 router / worker / scheduler。
|
||||
- `internal/platform/bootstrap/`:应用装配根(composition root)。集中注册任务 Handler、推送域事件订阅、任务完成监听器,并执行 `SyncEvents`、ClickHouse 访问日志写入等进程级初始化;所有注册函数使用 `sync.Once` 保证幂等。
|
||||
- `internal/infra/config/`:Viper 加载和配置结构体。运行时代码应使用 `config.Config.<Section>.<Field>`。
|
||||
- `internal/router/`:唯一的 HTTP 路由注册点。
|
||||
- `internal/apps/`:按功能(Feature-based)组织的 HTTP Handler、中间件、内部服务与模块逻辑。移除全局 service 层,模块内部业务逻辑(如验证码业务逻辑管理器 `internal/apps/cap/manager.go`)均收敛于各自模块中;管理端模块位于 `internal/apps/admin/`。
|
||||
- `internal/apps/upload/`:上传记录、文件访问控制、本地/S3 文件响应、下载及图片 WebP 压缩。业务应复用 `upload.Ingest` / `upload.Remove` 与 `GET /f/:id` 文件服务,不直接操作底层 storage 或旁路写 `w_uploads`。
|
||||
- `internal/model/`:GORM 实体、表映射、配置 key、查询 DTO 与无 IO 领域规则;不含数据库访问。
|
||||
- `internal/repository/`:数据访问层(平台与业务域 CRUD、缓存、ClickHouse 分析读写);唯一持久化入口。
|
||||
- `internal/infra/persistence/`:PostgreSQL、Redis、ClickHouse、GORM 日志、ID 生成和 goose SQL 迁移的布线。
|
||||
- `internal/infra/diskcache/`:平台级磁盘字节缓存,通过 `diskcache.GetGlobalCache()` 提供 TTL、最大空间限制、LRU 淘汰、清空、状态统计和配置热更新。写入时使用 `DefaultExpiration`(全局默认 TTL)、正数 `time.Duration`(业务 TTL)或 `NoExpiration`(无 TTL,仍受空间限制和 LRU 淘汰)。
|
||||
- `internal/infra/objectstore/`:S3 兼容对象存储适配,提供对象上传、读取、删除、CDN/代理读取及远端对象本地缓存。
|
||||
- `internal/infra/task/`:Asynq 任务框架;参见 `new-async-task` 了解变更。
|
||||
- `internal/shared/`:共享的通用模型及响应(如 `internal/shared/response`)、绑定(bind)、常量以及通用错误。
|
||||
- `pkg/util/`:纯底层无副作用的系统工具(Crypto/Password/UUID、格式化、网络、版本比较等)。
|
||||
- `internal/listener/`:域事件分发层。核心域(auth、user 等)在此定义并发射事件(如 `EmitAdminLoggedIn`);运维模块(push、webhook 等)在 bootstrap 阶段订阅,实现跨模块解耦。
|
||||
- `internal/otel_trace/`:链路追踪(tracing)助手。
|
||||
- `internal/testhelper/`:后端测试共享辅助能力。
|
||||
- `internal/buildinfo/`:暴露在发布/构建工作流中注入的元数据(如版本号、编译时间等)。
|
||||
### 命名
|
||||
|
||||
公共底层包 (`pkg/`):
|
||||
- `pkg/cache/disk/`:纯底层的通用本地磁盘缓存引擎。
|
||||
- `pkg/cap/`:底层的通用验证码验证和生成库。
|
||||
- `pkg/httppool/`:管理全局共享且经过优化的 HTTP 传输客户端及连接池,集成 OTel 链路追踪。
|
||||
- `pkg/logger/`:Zap 和 OTel 日志助手。
|
||||
- `pkg/push/`:推送渠道客户端集成(Lark/Telegram/Email)。
|
||||
- `pkg/mail/`:邮件发送客户端。
|
||||
- `pkg/trace/`:OpenTelemetry 链路追踪配置。
|
||||
| 类别 | 规则 | 例 |
|
||||
|------|------|-----|
|
||||
| 包/文件 | 小写蛇形 | `auth_source`、`postgres_logger.go` |
|
||||
| 导出/未导出标识符 | PascalCase / camelCase | — |
|
||||
| 请求/响应结构体 | camelCase + 后缀 | `listUsersRequest` |
|
||||
| 错误文案常量 | camelCase 字符串 `const`(非包级 `error`) | `errBindParamsFailed` |
|
||||
| YAML 键 | 小写蛇形 | — |
|
||||
|
||||
前端目录:
|
||||
### Handler
|
||||
|
||||
- `frontend/app/`:App Router 页面、路由组、根布局、全局配置。
|
||||
- `frontend/components/ui/`:shadcn/ui 基础组件。
|
||||
- `frontend/components/common/`:跨页面的业务组件。
|
||||
- `frontend/components/layout/`:Header、Sidebar、Footer 等应用布局组件。
|
||||
- `frontend/components/auth/`、`home/`、`animate-ui/`、`providers/`:特定作用域的 UI 组件。
|
||||
- `frontend/lib/services/`:基于 `BaseService` 的类型化 API 服务,按业务域拆分并由 `services` 对象统一导出。
|
||||
- `frontend/contexts/`、`hooks/`、`lib/`、`types/`、`public/`:共享状态、Hook、客户端与实用工具、TypeScript 类型、静态资产。
|
||||
- `frontend/scripts/`:前端构建和维护脚本。
|
||||
- `frontend/.next/`、`frontend/out/`、`frontend/node_modules/`:本地生成或安装的产物,不作为业务源码编辑。
|
||||
- 命名:动词 + 名词(`ListUsers`);绑定用 `ShouldBindQuery` / `ShouldBindJSON`。
|
||||
- 每个 HTTP API 需完整 Swagger 注释;API 变更后 `make swagger`。
|
||||
- Handler:绑定 → 调 logic → 映射为 `Abort*` 或 `response.OK`。
|
||||
- `logics.go`:接受 `context.Context`,返回结果/error;**禁止**依赖 `*gin.Context`、调用 `Abort*` / `c.JSON`。参考 `internal/apps/user/logics.go`。
|
||||
|
||||
### API 响应
|
||||
|
||||
## 开发要求
|
||||
信封:`{ "error_msg": "", "data": ... }`。成功 `error_msg` 空、`data` 为载荷;失败 `data` 为 `null`。分页:`data: { total, results }`。
|
||||
|
||||
### 后端规则
|
||||
|
||||
命名规范:
|
||||
|
||||
- Go 包和文件使用小写蛇形命名(lowercase snake case):如 `auth_source`、`postgres_logger.go`。
|
||||
- 导出的 Go 标识符使用 PascalCase;未导出的标识符使用 camelCase。
|
||||
- 请求/响应结构体使用 camelCase 并带有后缀,例如 `listUsersRequest` 和 `listUsersResponse`。
|
||||
- 错误消息常量是 camelCase 字符串 `const`值,而不是包级别的 `error` 值。
|
||||
- YAML 配置键使用小写蛇形命名(lowercase snake case)。
|
||||
|
||||
Handler 规范:
|
||||
|
||||
- Handler 命名为 动词 + 名词,例如 `ListUsers`。
|
||||
- 使用 `ShouldBindQuery` 或 `ShouldBindJSON` 进行绑定。
|
||||
- 每个 HTTP API 都需要有完整的 Swagger 注释;在 API 变更后运行 `make swagger`。
|
||||
|
||||
#### API 响应信封(统一格式)
|
||||
|
||||
所有 JSON API 响应的外层结构**必须**为:
|
||||
|
||||
```json
|
||||
{ "error_msg": "", "data": ... }
|
||||
```
|
||||
|
||||
- 成功时:`error_msg` 为空字符串,`data` 承载业务载荷。
|
||||
- 失败时:`data` 为 `null`,`error_msg` 为用户可见的错误说明。
|
||||
- 分页响应在 `data` 下使用 `{ "total": 0, "results": [] }`。
|
||||
|
||||
#### 成功响应(唯一写法)
|
||||
|
||||
成功时**始终**使用 HTTP `200`,由 Handler 直接写出 JSON:
|
||||
**成功**(始终 HTTP 200):
|
||||
|
||||
```go
|
||||
import (
|
||||
"net/http"
|
||||
"github.com/Rain-kl/Wavelet/internal/shared/response"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// 有数据
|
||||
c.JSON(http.StatusOK, response.OK(data))
|
||||
|
||||
// 无数据(data 为 null)
|
||||
c.JSON(http.StatusOK, response.OKNil())
|
||||
```
|
||||
|
||||
#### 失败响应(中断请求,禁止直接写错误 JSON)
|
||||
**失败**:仅用 `response.Abort*`(挂 `c.Errors` 并 `Abort`,由 `ErrorHandlerMiddleware` 统一写出并记 OTel),阅读/internal/shared/response/abort.go使用已有函数
|
||||
|
||||
失败时**禁止**在 Handler / 中间件中直接调用 `c.JSON(..., response.Err(msg))`,也**禁止**用 HTTP `200` 携带非空 `error_msg` 表示失败。
|
||||
中间件同规则(`oauth.LoginRequired` → Unauthorized;`admin.LoginAdminRequired` → NotFound;`cap.VerifyMiddleware` → Unauthorized)。
|
||||
|
||||
统一通过 `internal/shared/response` 的 **Abort 系列函数**中断请求。这些函数会将 `*response.APIError` 挂载到 Gin 的 `c.Errors` 链并 `c.Abort()`;请求结束后由全局 `response.ErrorHandlerMiddleware()`(在 `internal/router/middlewares.go` 中注册)统一写出 JSON,并记录到 OpenTelemetry Trace/Jaeger。
|
||||
- 用户可见错误:模块内 `errs.go` 的 camelCase 字符串常量;禁止向客户端暴露驱动错误/堆栈。
|
||||
- `response.Err` 仅供中间件构造 JSON,业务禁止用于 `c.JSON`。
|
||||
|
||||
**推荐使用的便捷函数(优先于手写状态码):**
|
||||
**禁止**:`c.JSON(200, response.Err(...))`;Handler 直接 `c.JSON(4xx/5xx, response.Err(...))`;手写 `gin.H` 错误体;在 `logics.go` 里 `Abort*`。
|
||||
|
||||
| 函数 | HTTP 状态码 | 典型场景 |
|
||||
|------|-------------|----------|
|
||||
| `response.AbortBadRequest(c, msg)` | 400 | 参数绑定失败、字段校验、业务规则拒绝(如密码错误、重复注册) |
|
||||
| `response.AbortUnauthorized(c, msg)` | 401 | 未登录、Session/Token 失效(`oauth.LoginRequired()`) |
|
||||
| `response.AbortForbidden(c, msg)` | 403 | 已登录但无权访问(如 Token 不允许访问的端点) |
|
||||
| `response.AbortNotFound(c, msg)` | 404 | 资源不存在;管理员中间件对非管理员隐藏端点时也使用此码 |
|
||||
| `response.AbortConflict(c, msg)` | 409 | 资源冲突(如唯一键重复) |
|
||||
| `response.AbortTooManyRequests(c, msg)` | 429 | 限流、频率限制 |
|
||||
| `response.AbortInternal(c, msg)` | 500 | 对用户返回通用提示;底层错误须先记录日志 |
|
||||
| `response.AbortWithError(c, code, msg)` | 自定义 | 上表未覆盖的状态码时使用 |
|
||||
Swagger:`@Success 200` 用具体类型或 `response.Any`;每个可能 Abort 状态声明 `@Failure`。
|
||||
|
||||
**标准 Handler 模板:**
|
||||
### 日志
|
||||
|
||||
```go
|
||||
func CreateWidget(c *gin.Context) {
|
||||
var req createWidgetRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.AbortBadRequest(c, errBindParamsFailed)
|
||||
return
|
||||
}
|
||||
- 运行时错误(DB/Redis/第三方/IO)在 Handler 或 logic 边界用 `pkg/logger`(带 `ctx`)记录,再返回安全 Abort/业务错误。
|
||||
- 吞错、转通用响应、worker 忽略前必须先记日志。
|
||||
- 禁止 `_ = err` 静默丢弃重要错误;best-effort 可忽略时加简短注释。
|
||||
- 只在处理/抑制边界记一次,避免重复刷日志。
|
||||
|
||||
widget, err := createWidgetLogic(c.Request.Context(), req)
|
||||
if err != nil {
|
||||
// 底层错误已记录日志时,向用户返回安全文案
|
||||
response.AbortBadRequest(c, err.Error()) // 或按语义选用 AbortConflict / AbortInternal 等
|
||||
return
|
||||
}
|
||||
### 路由与装配
|
||||
|
||||
c.JSON(http.StatusOK, response.OK(widget))
|
||||
}
|
||||
```
|
||||
- `router.go` 只做高层分发,禁止直接挂业务 Handler。归属与开发步骤见 `new-api` skill。
|
||||
- 跨模块副作用:在 `bootstrap` 增 `Register*`,于对应 `internal/cmd/*.go` 调用(`RegisterAPI` / `RegisterWorker` / `RegisterAll`)。
|
||||
- API/`all` 模式:`bootstrap.Init` 须在 `RegisterPushDomainEvents()` **之后**调用,保证 `SyncEvents` 同步内置推送元数据。
|
||||
|
||||
**中间件**与 Handler 遵循同一规则。参考 `oauth.LoginRequired()` → `AbortUnauthorized`,`admin.LoginAdminRequired()` → `AbortNotFound`,`cap.VerifyMiddleware` → `AbortUnauthorized`。
|
||||
### 中间件
|
||||
|
||||
#### 错误消息定义
|
||||
- 全局:`gin.Recovery()`、`otelgin`、日志、session。
|
||||
- 登录组:`oauth.LoginRequired()`;管理组:`admin.LoginAdminRequired()`。
|
||||
|
||||
- 面向用户的错误文案定义为模块内 **camelCase 字符串常量**(放在 `errs.go`),例如 `errBindParamsFailed = "参数绑定失败"`。
|
||||
- Handler / 中间件向 Abort 函数传入这些常量或经校验的安全字符串;**禁止**将数据库驱动错误、堆栈信息等内部细节直接暴露给客户端。
|
||||
- `response.Err(msg)` 仅供 `ErrorHandlerMiddleware` 内部构造 JSON,**业务代码不得直接用于 `c.JSON`**。
|
||||
### 配置
|
||||
|
||||
#### `logics.go` 与 Handler 的分工
|
||||
- 运行时只读 `config.Config`,禁止 `os.Getenv()`。
|
||||
- 新增配置同步 `config.example.yaml` 与 `internal/infra/config/model.go`。
|
||||
|
||||
- `logics.go` 接受 `context.Context`,返回 `(result, error)` 或带状态的业务结果结构体(参考 `internal/apps/user/logics.go` 的 `LoginEmailVerificationResult`)。
|
||||
- `logics.go` **不得**依赖 `*gin.Context`,**不得**调用 `response.Abort*` 或 `c.JSON`。
|
||||
- Handler 负责:绑定参数 → 调用 logic → 将 logic 错误/状态映射为对应的 `Abort*` 或 `response.OK`。
|
||||
### 数据库
|
||||
|
||||
#### 日志与内部错误
|
||||
- 持久化只经 `repository`(或 analytics);复杂查询不进 Handler;编排在 logics。
|
||||
- repository 内用 `db.DB(ctx)`(链路追踪)。
|
||||
- 迁移:`internal/infra/persistence/migrator/goose/` SQL;禁止 GORM AutoMigrate。
|
||||
- 不建物理外键,关系字段加显式索引。
|
||||
- 列默认值与 Go 零值(`nil`/`0`/`false`/`""`)一致。
|
||||
|
||||
- 数据库、Redis、第三方 API、文件 I/O 等**运行时错误**:在 Handler 或 logic 边界用 `pkg/logger` 记录(带 `ctx`),再向用户返回安全的 `AbortInternal` 或语义匹配的业务错误常量。
|
||||
- 任何关键错误在被吞掉、转换为通用响应,或由后台 worker 忽略之前,都必须通过 `pkg/logger` 打印日志。
|
||||
- 禁止用 `_ = ...` 静默丢弃重要错误。如果某个错误因为 best-effort 操作或确认无害而需要忽略,必须添加简短注释说明原因。
|
||||
- 避免重复刷日志:在真正处理或抑制错误的边界记录一次,然后 `Abort*` 或成功返回。
|
||||
---
|
||||
|
||||
#### 禁止写法(反模式)
|
||||
## 前端
|
||||
|
||||
```go
|
||||
// ❌ 禁止:HTTP 200 表示失败
|
||||
c.JSON(http.StatusOK, response.Err("密码错误"))
|
||||
- Next.js:以 `node_modules/next/dist/docs/` 为准(训练数据可能过时)。
|
||||
- 示例:`frontend/app/(main)/admin/demo`。
|
||||
|
||||
// ❌ 禁止:Handler 直接写错误 JSON,绕过 ErrorHandlerMiddleware 与 OTel 记录
|
||||
c.JSON(http.StatusBadRequest, response.Err("参数错误"))
|
||||
### 样式
|
||||
|
||||
// ❌ 禁止:gin.H 手写错误体
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error_msg": "...", "data": nil})
|
||||
- shadcn 用 `variant` + CSS 变量;业务 `className` 不硬编码颜色/背景/阴影。
|
||||
- 变体不足时扩展组件 variant,不写一次性颜色。
|
||||
|
||||
// ❌ 禁止:logics.go 中中断 HTTP 请求
|
||||
func doSomething(c *gin.Context) { response.AbortBadRequest(c, "...") }
|
||||
```
|
||||
### 页面结构
|
||||
|
||||
#### Swagger 注释约定
|
||||
- 根容器全宽 `w-full`;禁止页面级 `max-w-*`(主布局负责宽度)。
|
||||
- 外层间距:`py-6` 或 `py-6 px-1`。
|
||||
- 标题行:`flex items-center gap-2`(有右侧操作则加 `justify-between`)。
|
||||
- 图标:Lucide 直接放标题容器,`size-5 text-primary`;禁止背景卡片/边框包裹。
|
||||
- 标题:仅 `h1 className="text-2xl font-semibold tracking-tight"`。
|
||||
- 多 Tab:各 Tab 独立文件;`page.tsx` 只管 Tabs 状态与触发器;禁止 `page.tsx` 仅转发同名空壳。
|
||||
- 单文件 > ~600 行或状态过重时拆局部 `components/`;跨页复用放 `frontend/components/common/`。标杆:`/admin/database`。
|
||||
|
||||
- `@Success 200` 的 `data` 使用具体类型或 `response.Any`。
|
||||
- 对每个可能返回的 Abort 状态码声明 `@Failure`,例如 `@Failure 400 {object} response.Any "参数错误"`、`@Failure 401 {object} response.Any "未登录"`。
|
||||
### 组件放置
|
||||
|
||||
路由与模块:
|
||||
| 类型 | 路径 |
|
||||
|------|------|
|
||||
| 跨页业务 | `frontend/components/common/` |
|
||||
| shadcn 原语 | `frontend/components/ui/` |
|
||||
| 路由专属 | 邻近 feature 目录 |
|
||||
|
||||
- 仅在 `internal/router/router.go` 中作为统一高层入口进行路由分发委派,不允许在 `router.go` 中直接挂载业务 Handler。
|
||||
- 关于所有的路由归属划分、接口开发隔离防线以及详细的注册和开发步骤,请直接阅读并严格遵循 [new-api](.agents/skills/new-api/SKILL.md) 技能。
|
||||
|
||||
应用装配与跨模块集成:
|
||||
|
||||
- 新增跨模块副作用(任务注册、推送订阅、后台监听器)时,在 `internal/platform/bootstrap/bootstrap.go` 增加 `Register*` 函数,并在对应 `internal/cmd/*.go` 入口调用;参考现有 `RegisterAPI` / `RegisterWorker` / `RegisterAll` 分工。
|
||||
- `bootstrap.Init` 必须在 `RegisterPushDomainEvents()` 之后调用(API/`all` 模式),以确保 `SyncEvents` 能同步内置推送事件元数据。
|
||||
- Handler 与业务逻辑分离:HTTP Handler 负责绑定与响应;可复用逻辑放入 `logics.go`(接受 `context.Context`,不依赖 `*gin.Context`),便于 Worker 与单元测试复用。参考 `internal/apps/user/logics.go`。
|
||||
|
||||
中间件:
|
||||
|
||||
- 全局中间件属于路由设置:`gin.Recovery()`、`otelgin.Middleware()`、日志中间件 and session 中间件。
|
||||
- 对于登录路由组,使用 `oauth.LoginRequired()`。
|
||||
- 对于管理路由组,使用 `admin.LoginAdminRequired()`。
|
||||
|
||||
配置管理:
|
||||
|
||||
- 运行时代码从 `config.Config` 中读取配置,绝对不要直接从 `os.Getenv()` 中读取。
|
||||
- 当添加配置时,同时更新 `config.example.yaml` and `internal/infra/config/model.go`。
|
||||
|
||||
数据库操作:
|
||||
|
||||
- **持久化只通过 `internal/repository`**(或 analytics 子包)。apps / logics 不要直接 `db.DB(ctx).Where...` 拼复杂查询;简单事务编排可在 logics 中调用多个 repository 方法。
|
||||
- repository 内管理员/业务查询应使用 `db.DB(ctx)` 以获得链路追踪感知的 DB 访问。
|
||||
- 不要在 Handler 中放置 SQL;复杂查询放 `internal/repository/`,业务编排放 `internal/apps/<module>/logics.go`(或 `service.go`)。
|
||||
- `internal/model` 只定义实体与无 IO 规则,不访问数据库。
|
||||
- 在 `internal/infra/persistence/migrator/goose/` 下使用 goose SQL 迁移;不要添加基于 GORM AutoMigrate 的 Schema 升级。
|
||||
- 不要创建物理数据库外键。改为关系字段添加显式索引。
|
||||
- 数据库默认值必须与 Go 模型零值(`nil`、`0`、`false`、`""`)匹配,以避免意外的插入。
|
||||
|
||||
### 前端规则
|
||||
|
||||
在进行任何 Next.js 工作之前,请在 `node_modules/next/dist/docs/` 中找到并阅读相关文档。您的训练数据已过时 —— 这些文档是唯一的真理来源。
|
||||
|
||||
请直接查看并参考项目提供的示例和 Demo 代码:[frontend/app/(main)/admin/demo](frontend/app/(main)/admin/demo)。
|
||||
|
||||
样式规范:
|
||||
|
||||
- shadcn/ui 基础组件应该使用它们的 `variant` 系统和全局 CSS 变量。当组件的变体(variant)应该拥有某种外观时,不要在业务 `className` 中硬编码颜色、背景或阴影。
|
||||
- 如果现有的变体不足以满足需求,请扩展 shadcn/ui 组件的变体,而不是硬编码一次性的颜色。
|
||||
|
||||
页面标题栏规范 (新人开发与重构必读):
|
||||
|
||||
- **容器与对齐机制**:
|
||||
- 标题容器统一使用 `flex items-center gap-2`。如果右侧有操作按钮(如“新增”、“刷新”),请使用 `justify-between` 布局让操作区与标题双向分布。
|
||||
- 为了确保所有页面在进入/切换时,顶部的呼吸感和视觉高度完全一致,页面最外层容器**必须**统一使用 `py-6 px-1` 或 `py-6` 进行上边距对齐。
|
||||
- **图标标准**:图标作为视觉辅助点缀,**必须**直接嵌套在标题容器中,直接使用 Lucide 图标组件,样式大小限制为 `size-5 text-primary`。**严禁**为图标包裹任何背景小卡片、圆角边框或额外的修饰容器。
|
||||
- **标题文字标准**:标题文字使用且仅使用 `h1 className="text-2xl font-semibold tracking-tight"`。不要自行定义字号、字量(如使用 `font-bold`)或添加任何渐变色,保持整个系统的字形规范化。
|
||||
- **Tabs 模块化与文件拆分规范**:凡是带有多个 Tab 页切换的复杂页面,**禁止**将所有 Tab 的渲染逻辑堆积在同一个主文件内。每个 Tab 的具体渲染内容必须单独拆分为独立的 React 组件文件(如 `tabs/events-tab.tsx`)。主页面文件应该仅用于导入子组件、注册 Tabs 触发器以及管理 Tab 的切换激活状态。这有利于防止单文件过大(避免单文件行数超过 600 行限制),并大幅度提高代码的可读性与编译维护效率。
|
||||
- **扁平化结构与避免冗余中间件**:为了消除无意义的“中间代理文件”,所有作为路由物理入口的 Tabs 状态维护、骨架及外层布局代码,**必须**直接定义在 Next.js 的 `app/` 页面文件(即 `page.tsx`)中。禁止在 `page.tsx` 中仅写一个单纯的 `<AnotherComponent />` 转发,而在外部新建一个同名中转容器。
|
||||
- **复杂度驱动的组件拆分规范**:组件的拆分不应局限于“跨页面复用”。当一个路由页面的复杂度变高时(如渲染逻辑膨胀、存在大型嵌套弹窗或多层状态管理,如单文件代码行数超过 600 行),必须主动将其拆分为子组件以维持单文件的高可读性与低耦合度。拆分时遵循就近原则:特定于该路由且不复用的子组件应放置在最邻近该路由的特征目录(Feature Folder,如 `components/` 局部文件夹)中;只有真正具备跨页面复用价值的通用业务/基础 UI 组件才应存放在全局 `components/` 共享目录下。
|
||||
- **最佳实践标杆案例(数据管理 `/admin/database`)**:
|
||||
该页面由于整合了“运行状态概览”、“物理表网格浏览器”、“磁盘缓存管理”和“SQL 交互控台”多个复杂大区块,重构前单文件接近 1000 行。
|
||||
重构后,主页面 `page.tsx` 仅做高级页面骨架与排版排布,维护全局刷新机制与终端视图切换;而“数据表浏览器 (`table-browser.tsx`)”、“缓存管理 (`cache-manager.tsx`)”与“SQL 终端 (`sql-console.tsx`)”等独立高状态密度区块均被抽离为局部子组件,存放在 `frontend/app/(main)/admin/database/components/`。这保证了代码结构层次清晰、单文件小巧好维护。所有复杂页面的新开发和重构必须遵循此模式。
|
||||
|
||||
页面宽度:
|
||||
|
||||
- 页面根容器必须支持全宽。使用 `w-full`。
|
||||
- 不要硬编码页面级的最大宽度,如 `max-w-6xl` 或 `max-w-4xl`;主布局(main layout)拥有正常/全宽的限制。
|
||||
|
||||
组件放置:
|
||||
|
||||
- 跨页面的业务组件属于 `frontend/components/common/`。
|
||||
- shadcn/ui 原生组件(primitives)属于 `frontend/components/ui/`。
|
||||
- 特定于路由/页面的组件放在最邻近的特征(feature)目录中。
|
||||
|
||||
服务类(Services):
|
||||
|
||||
- 前端 API 访问通过服务类和导出的 `services` 对象进行。
|
||||
- 新增服务结构如下:
|
||||
### Services
|
||||
|
||||
```text
|
||||
frontend/lib/services/<service-name>/
|
||||
frontend/lib/services/<name>/
|
||||
types.ts
|
||||
<service-name>.service.ts
|
||||
<name>.service.ts
|
||||
index.ts
|
||||
```
|
||||
|
||||
- 服务类继承 `BaseService`,定义 `basePath`,并暴露有类型的静态方法。
|
||||
- **防止回调 `this` 上下文丢失(核心规范)**:在传递服务类的静态方法作为组件事件回调(如 `onClick`)或 React Query 的 `mutationFn`/`queryFn` 时,**禁止直接传递静态方法引用**(如 `mutationFn: DnsAccountService.create`),必须使用箭头函数包裹以防止 `this` 上下文丢失导致运行时崩溃(如 `mutationFn: (payload) => DnsAccountService.create(payload)`)。
|
||||
- 在 `frontend/lib/services/index.ts` 中注册新服务。
|
||||
- 继承 `BaseService`,定义 `basePath`,有类型静态方法;在 `frontend/lib/services/index.ts` 注册。
|
||||
- 回调/`mutationFn`/`queryFn` **禁止**直接传静态方法引用(丢 `this`);用箭头:`(p) => XxxService.create(p)`。
|
||||
|
||||
### 国际化 (i18n)
|
||||
|
||||
- 使用 `next-intl`(无 URL locale 前缀 / provider 模式),兼容 `NEXT_STANDALONE_EXPORT`。
|
||||
- 语言:`zh-CN`、`en`;默认 `zh-CN`。优先级:cookie `NEXT_LOCALE` → 浏览器语言 → 默认。
|
||||
- 文案放在 `frontend/messages/fragments`。参考已有代码,按模块拆文件夹,en.json 和 zh-CN.json 是 ci 生成的(node scripts/merge-i18n-fragments.mjs),禁止手动修改。
|
||||
- 禁止在页面/组件里直接写文案,文案必须支持 i18
|
||||
|
||||
@@ -45,7 +45,7 @@ code-check:
|
||||
exit 1; \
|
||||
fi
|
||||
golangci-lint run
|
||||
cd frontend && pnpm tsc --noEmit --jsx preserve && npx eslint . --max-warnings 0
|
||||
cd frontend && node scripts/merge-i18n-fragments.mjs && pnpm tsc --noEmit --jsx preserve && npx eslint . --max-warnings 0
|
||||
|
||||
build-backend:
|
||||
@echo "==> Building backend version=$(VERSION) build_date=$(BUILD_DATE)..."
|
||||
|
||||
-225
@@ -1,225 +0,0 @@
|
||||
<div align="center">
|
||||
|
||||
# OpenFlare
|
||||
|
||||
**[English](./README.en.md) | [📖 中文](./README.md)**
|
||||
|
||||
OpenFlare is an open-source CDN orchestration and edge security platform. It supports reverse proxies, centralized configuration synchronization, secure intranet penetration (Tunnels), dynamic WAF protection, and anti-CC challenges.
|
||||
|
||||
</div>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/LICENSE">
|
||||
<img src="https://img.shields.io/github/license/Rain-kl/OpenFlare?color=brightgreen" alt="license">
|
||||
</a>
|
||||
<a href="https://github.com/Rain-kl/OpenFlare/releases/latest">
|
||||
<img src="https://img.shields.io/github/v/release/Rain-kl/OpenFlare?color=brightgreen&include_prereleases" alt="release">
|
||||
</a>
|
||||
<a href="https://github.com/Rain-kl/OpenFlare/pkgs/container/openflare">
|
||||
<img src="https://img.shields.io/badge/GHCR-ghcr.io%2Frain--kl%2Fopenflare-brightgreen" alt="ghcr">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
> [!WARNING]
|
||||
> After logging in for the first time with the `root` user, make sure to change the default password `123456`.
|
||||
>
|
||||
> The BETA version is a temporary product for the development and testing phase. It may contain unknown issues and should not be used in production environments.
|
||||
|
||||
## Documentation
|
||||
|
||||
**https://open-flare.pages.dev**
|
||||
|
||||
Quick links:
|
||||
|
||||
* [Quick Start](https://open-flare.pages.dev/en/guide/quick-start)
|
||||
* [Deployment Guide](https://open-flare.pages.dev/en/deployment/deployment)
|
||||
* [Configuration Reference](https://open-flare.pages.dev/reference/configuration)
|
||||
* [System Design](https://open-flare.pages.dev/design/)
|
||||
|
||||
## Core Features
|
||||
|
||||
* **Reverse Proxy Management**: Website rules as the aggregation boundary, supporting multi-domain binding and multi-upstream load balancing with unified management of all OpenResty node configurations.
|
||||
* **Immutable Config Version Control**: Full-snapshot publish model based on version numbers (`YYYYMMDD-NNN`), with pre-publish diff preview, a single globally active version, and one-click sub-second rollback.
|
||||
* **Secure Intranet Penetration (Tunnels)**: An open-source alternative to Cloudflare Tunnels. Securely expose local intranet Web services to the public network via Relay and OpenFlared clients — no public IP or open inbound ports required.
|
||||
* **Edge WAF Safety Protection**: Provides global and custom rule groups, supporting manual/automatic/subscription IP groups, MaxMind GeoIP country-level access control, Checksum-based differential IP group sync (no Nginx reload), and custom block responses.
|
||||
* **Anti-CC & Human-Machine Challenge (PoW)**: Built-in high-performance client-side cryptographic Proof of Work challenges (similar to Turnstile) to block and intercept botnets and scrapers at the gateway edge in seconds.
|
||||
* **Pages Static Hosting**: Upload pre-built ZIP packages directly; edge Agents pull and serve them via local OpenResty, with SPA Fallback and built-in API reverse proxy configuration.
|
||||
* **Automated TLS Certificate Management**: Supports dynamic certificate upload, automatic multi-domain certificate matching and binding, and ACME-based automatic issuance and renewal via Let's Encrypt.
|
||||
* **Uptime Kuma Monitoring Sync**: Integrates with Uptime Kuma to automatically sync the monitoring site list using differential updates, providing real-time awareness of node availability and service health.
|
||||
* **SSO Single Sign-On**: Supports GitHub OAuth and standard OIDC protocol for seamless integration with enterprise identity providers.
|
||||
* **Unified Observability**: Aggregates node request metrics, real-time access log details, host/Nginx resource snapshots, health events, and a re-upload buffer for network fluctuations.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Launch Server
|
||||
|
||||
```yaml
|
||||
services:
|
||||
openflare:
|
||||
image: ghcr.io/rain-kl/openflare:latest
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
environment:
|
||||
TZ: ${TZ:-Asia/Shanghai}
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- openflare_uploads:/app/uploads
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
clickhouse:
|
||||
condition: service_healthy
|
||||
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: ${DB_NAME:-openflare}
|
||||
POSTGRES_USER: ${DB_USERNAME:-openflare}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-replace-with-strong-password}
|
||||
volumes:
|
||||
- openflare_postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME:-openflare} -d ${DB_NAME:-openflare}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
redis:
|
||||
image: valkey/valkey:8.0-alpine
|
||||
restart: unless-stopped
|
||||
command: ["valkey-server", "--appendonly", "yes"]
|
||||
volumes:
|
||||
- openflare_redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "valkey-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 5s
|
||||
|
||||
clickhouse:
|
||||
image: clickhouse/clickhouse-server:25.3-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
CLICKHOUSE_DB: ${CLICKHOUSE_NAME:-openflare}
|
||||
CLICKHOUSE_USER: ${CLICKHOUSE_USERNAME:-default}
|
||||
CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-replace-with-clickhouse-password}
|
||||
CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: 1
|
||||
TZ: ${TZ:-Asia/Shanghai}
|
||||
volumes:
|
||||
- openflare_clickhouse_data:/var/lib/clickhouse
|
||||
healthcheck:
|
||||
test: ["CMD", "clickhouse-client", "--user", "${CLICKHOUSE_USERNAME:-default}", "--password", "${CLICKHOUSE_PASSWORD:-replace-with-clickhouse-password}", "--query", "SELECT 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 15s
|
||||
|
||||
volumes:
|
||||
openflare_uploads:
|
||||
openflare_postgres_data:
|
||||
openflare_redis_data:
|
||||
openflare_clickhouse_data:
|
||||
```
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Access at: `http://localhost:3000`
|
||||
|
||||
Default credentials:
|
||||
|
||||
* Username: `root`
|
||||
* Password: `123456`
|
||||
|
||||
### 2. Install Agent
|
||||
|
||||
Before installing an Agent, please install OpenResty on the target node first, or use the Agent Docker image with OpenResty built-in.
|
||||
|
||||
You can copy the installation command from **Node Management -> Details -> Node Info -> Node Token & Deployment** in the control panel, or directly use the scripts below:
|
||||
|
||||
#### Docker Deployment
|
||||
|
||||
For Docker deployment, you can directly run the Agent image:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/rain-kl/openflare-agent:latest
|
||||
docker rm -f openflare-agent 2>/dev/null || true
|
||||
docker run -d --name openflare-agent --restart unless-stopped \
|
||||
-p 80:80 -p 443:443/tcp -p 443:443/udp \
|
||||
-e OPENFLARE_SERVER_URL=http://your-server:3000 \
|
||||
-e OPENFLARE_AGENT_TOKEN=YOUR_AGENT_TOKEN \
|
||||
ghcr.io/rain-kl/openflare-agent:latest
|
||||
```
|
||||
|
||||
#### Local Installation
|
||||
|
||||
Using `discovery_token` to register:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh | bash -s -- \
|
||||
--server-url http://your-server:3000 \
|
||||
--discovery-token YOUR_DISCOVERY_TOKEN
|
||||
```
|
||||
|
||||
Using node-specific `agent_token`:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh | bash -s -- \
|
||||
--server-url http://your-server:3000 \
|
||||
--agent-token YOUR_AGENT_TOKEN
|
||||
```
|
||||
|
||||
The installation script defaults to `/opt/openflare-agent`, creates a `openflare-agent.service`, automatically searches for `openresty`, and can be executed repeatedly to reinstall or upgrade the Agent.
|
||||
|
||||
### 3. Uninstall Agent
|
||||
|
||||
To completely uninstall the Agent and clear local data, run:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/uninstall-agent.sh | bash
|
||||
```
|
||||
|
||||
The uninstallation script will stop and remove the `openflare-agent.service`, and delete the entire `/opt/openflare-agent` directory. It will not delete the local OpenResty installation.
|
||||
|
||||
### 4. Publish Your First Configuration
|
||||
|
||||
1. Log in to the management panel and add a reverse proxy rule.
|
||||
2. View the preview or change summary before publishing.
|
||||
3. Activate the new version.
|
||||
4. Agents will receive the configuration and apply it via WebSocket notification or subsequent heartbeats.
|
||||
|
||||
The version number format is fixed as `YYYYMMDD-NNN`. Historical versions are immutable, and rollback is achieved by reactivating an older version.
|
||||
|
||||
## UI Preview
|
||||
|
||||
### Dashboard Overview
|
||||
|
||||

|
||||
|
||||
### Node Details
|
||||
|
||||

|
||||
|
||||
### Proxy Configuration
|
||||
|
||||

|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under [Apache License 2.0](./LICENSE).
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=Rain-kl%2FOpenFlare&type=date&legend=bottom-right">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=Rain-kl/OpenFlare&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=Rain-kl/OpenFlare&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=Rain-kl/OpenFlare&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
# OpenFlare
|
||||
|
||||
**[📖 中文](./README.md) | [English](./README.en.md)**
|
||||
**[English](./README.md) | [简体中文](./README.zh-CN.md)**
|
||||
|
||||
OpenFlare 是开源 CDN 编排与边缘安全平台。它支持反向代理、集中式配置同步、内网穿透(Tunnels)、动态 WAF 防护以及防 CC 挑战。
|
||||
OpenFlare is an open-source CDN orchestration and edge security platform. It supports reverse proxy, centralized configuration synchronization, in-network tunneling (Tunnels), dynamic WAF protection, and CC defense challenges.
|
||||
|
||||
</div>
|
||||
|
||||
@@ -21,62 +21,66 @@ OpenFlare 是开源 CDN 编排与边缘安全平台。它支持反向代理、
|
||||
</p>
|
||||
|
||||
> [!WARNING]
|
||||
> 使用 `admin` 用户初次登录系统后,务必修改默认密码 `12345678`。
|
||||
> After the first login with the `admin` user, you must change the default password `12345678`.
|
||||
>
|
||||
> BETA 版本为开发测试阶段的临时产物,可能存在未知问题,请勿在生产环境使用。
|
||||
> The BETA version is a temporary product in the development and testing stage and may have unknown issues. It should not be used in production environments.
|
||||
|
||||
## 文档
|
||||
## Documentation
|
||||
|
||||
**https://open-flare.pages.dev**
|
||||
**https://openflare.fyrn.link**
|
||||
|
||||
常用入口:
|
||||
Common entry points:
|
||||
|
||||
* [快速开始](https://open-flare.pages.dev/guide/quick-start)
|
||||
* [部署说明](https://open-flare.pages.dev/deployment/deployment)
|
||||
* [配置项参考](https://open-flare.pages.dev/reference/configuration)
|
||||
* [系统设计](https://open-flare.pages.dev/design/)
|
||||
* [Quick Start](https://openflare.fyrn.link/guide/quick-start)
|
||||
* [Deployment Guide](https://openflare.fyrn.link/deployment/deployment)
|
||||
* [Configuration Reference](https://openflare.fyrn.link/reference/configuration)
|
||||
* [System Design](https://openflare.fyrn.link/design/)
|
||||
|
||||
## 核心能力
|
||||
## Core Capabilities
|
||||
|
||||
* **反代配置管理**:以网站规则为聚合边界,支持多域名绑定与多上游负载均衡,统一管理所有 OpenResty 节点的反代配置。
|
||||
* **安全内网穿透(Tunnels)**:开源版的 Cloudflare Tunnels。无须公网 IP 或暴露入向端口,通过 Relay 中继节点与 OpenFlared 客户端安全反向穿透内网 Web 服务至公网。
|
||||
* **边缘 WAF 安全防护**:提供全局与自定义规则组,支持手动/自动/订阅型 IP 组、MaxMind GeoIP 国家级地域准入、IP 组成员 Checksum 差分同步(无需 Nginx 重载)以及自定义拦截响应。
|
||||
* **防 CC 与人机挑战(PoW)**:内置高性能客户端密码学 Proof of Work 挑战(类似 Turnstile),在网关边缘秒级拦截并阻断僵尸网络与爬虫。
|
||||
* **Pages 静态托管**:支持上传或从受限 Remote URL、公开 GitHub Release asset 同步预构建产物;GitHub latest 可定时检查并可选自动发布。所有来源统一生成不可变部署,由边缘 Agent 拉取并通过 OpenResty 本地提供服务,支持回滚、SPA Fallback 与 API 反向代理。
|
||||
* **TLS 证书自动化**:支持证书动态上传、多域名证书自动匹配绑定,以及通过 ACME 协议向 Let's Encrypt 自动申请与续期证书。
|
||||
* **Uptime Kuma 监控同步**:与 Uptime Kuma 集成,自动差分同步监控站点列表,实时感知节点存活与服务可用状态。
|
||||
* **SSO 单点登录**:支持 GitHub OAuth 与标准 OIDC 协议,无缝接入企业身份提供商实现统一登录。
|
||||
* **统一观测**:聚合节点请求指标、实时访问日志明细、宿主机与 Nginx 资源快照、健康事件以及网络波动补传缓冲。
|
||||
* **Reverse Proxy Configuration Management**: Uses website rules as the aggregation boundary, supports multi-domain binding and multi-upstream load balancing, and centrally manages reverse proxy configurations for all OpenResty nodes.
|
||||
* **Secure In-Network Tunneling (Tunnels)**: Open-source version of Cloudflare Tunnels. No public IP or exposed inbound ports are required. Securely reverse-proxy internal web services to the public internet through Relay relay nodes and OpenFlared clients.
|
||||
* **Edge WAF Security Protection**: Provides global and custom rule groups, supports manual/auto/subscription-type IP groups, MaxMind GeoIP national-level geographic access control, IP group member Checksum differential synchronization (no Nginx reload required), and custom blocking responses.
|
||||
* **CC Defense and Human-Computer Challenge (PoW)**: Built-in high-performance client-side cryptography Proof of Work challenge (similar to Turnstile). Secures high-speed interception and blocking of zombie networks and crawlers at the gateway edge.
|
||||
* **Pages Static Hosting**: Supports uploading or synchronizing pre-built artifacts from restricted Remote URLs or public GitHub Release assets. GitHub latest can be checked periodically and optionally auto-published. All sources are unified to generate immutable deployments, pulled by the edge Agent and served locally by OpenResty, supporting rollbacks, SPA Fallback, and API reverse proxy.
|
||||
* **TLS Certificate Automation**: Supports dynamic certificate uploads, automatic multi-domain certificate matching and binding, and automatic issuance and renewal of certificates from Let's Encrypt via the ACME protocol.
|
||||
* **Uptime Kuma Monitoring Synchronization**: Integrated with Uptime Kuma to automatically perform differential synchronization of monitoring site lists, real-time awareness of node availability and service status.
|
||||
* **SSO Single Sign-On**: Supports GitHub OAuth and standard OIDC protocol for seamless integration with enterprise identity providers to achieve unified login.
|
||||
* **Unified Observability**: Aggregates node request metrics, real-time access log details, host and Nginx resource snapshots, health events, and network fluctuation replenishment buffers.
|
||||
|
||||
## 界面预览
|
||||
## Interface Preview
|
||||
|
||||
### 仪表盘总览
|
||||
### Dashboard Overview
|
||||
|
||||

|
||||
|
||||
### 节点详情
|
||||
### Access Logs
|
||||
|
||||

|
||||

|
||||
|
||||
### 配置新增
|
||||
### WAF Protection
|
||||
|
||||

|
||||

|
||||
|
||||
## 快速开始
|
||||
## Quick Start
|
||||
|
||||
### 1. 启动 Server
|
||||
### Hardware Configuration Recommendations
|
||||
|
||||
使用 docker-compose
|
||||
| Component | Minimum Hardware Requirements | Recommended Hardware Requirements | Notes |
|
||||
|------------------------|-----------------------------------|-----------------------------------|-------|
|
||||
| **Server Control Plane** | 1 CPU core / 2 GB RAM / 20 GB disk | 2 CPU cores / 4 GB RAM / 50 GB+ disk | Disk usage should be expanded reasonably based on access log retention duration and concurrent traffic |
|
||||
| **Agent Data Plane** | 1 CPU core / 512 MB RAM / 2 GB disk | 2 CPU cores / 2 GB RAM / 10 GB+ disk | Expanded based on OpenResty concurrent proxy connections and WAF interception processing |
|
||||
| **Relay Relay Node** | 1 CPU core / 1 GB RAM / 5 GB disk | 2 CPU cores / 2 GB RAM / 20 GB disk | frps transmission relay throughput is mainly limited by bandwidth and CPU throughput |
|
||||
| **OpenFlared Client** | 1 CPU core / 256 MB RAM / 1 GB disk | 1 CPU core / 512 MB RAM / 5 GB disk | Runs independently on the internal network with extremely low resource consumption; only network throughput needs to be guaranteed |
|
||||
|
||||
### 1. Start the Server
|
||||
|
||||
Use `docker-compose`:
|
||||
|
||||
```bash
|
||||
# 下载环境变量模板并创建 .env 文件
|
||||
# Download environment variable template and create .env file
|
||||
curl -o .env.example https://raw.githubusercontent.com/Rain-kl/OpenFlare/refs/heads/main/.env.example
|
||||
cp .env.example .env
|
||||
|
||||
# ClickHouse 服务端:curl performance.xml 到 ./config/clickhouse,并以单文件方式挂载到 config.d
|
||||
mkdir -p ./config/clickhouse
|
||||
curl -fsSL -o ./config/clickhouse/performance.xml \
|
||||
https://raw.githubusercontent.com/Rain-kl/OpenFlare/refs/heads/main/config/clickhouse/performance.xml
|
||||
```
|
||||
|
||||
```yaml
|
||||
@@ -96,8 +100,6 @@ services:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
clickhouse:
|
||||
condition: service_healthy
|
||||
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
@@ -127,54 +129,30 @@ services:
|
||||
retries: 5
|
||||
start_period: 5s
|
||||
|
||||
clickhouse:
|
||||
image: clickhouse/clickhouse-server:25.3-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
CLICKHOUSE_DB: ${CLICKHOUSE_NAME:-openflare}
|
||||
CLICKHOUSE_USER: ${CLICKHOUSE_USERNAME:-default}
|
||||
CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-replace-with-clickhouse-password}
|
||||
CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: 1
|
||||
TZ: ${TZ:-Asia/Shanghai}
|
||||
ulimits:
|
||||
nofile:
|
||||
soft: 262144
|
||||
hard: 262144
|
||||
volumes:
|
||||
- openflare_clickhouse_data:/var/lib/clickhouse
|
||||
- ./config/clickhouse/performance.xml:/etc/clickhouse-server/config.d/performance.xml:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "clickhouse-client", "--user", "${CLICKHOUSE_USERNAME:-default}", "--password", "${CLICKHOUSE_PASSWORD:-replace-with-clickhouse-password}", "--query", "SELECT 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 15s
|
||||
|
||||
volumes:
|
||||
openflare_uploads:
|
||||
openflare_postgres_data:
|
||||
openflare_redis_data:
|
||||
openflare_clickhouse_data:
|
||||
```
|
||||
|
||||
详细部署说明见 [部署文档](https://open-flare.pages.dev/deployment/deployment)。
|
||||
See the [deployment documentation](https://openflare.fyrn.link/deployment/deployment) for details.
|
||||
|
||||
访问地址:`http://localhost:3000`
|
||||
Access address: `http://localhost:3000`
|
||||
|
||||
默认账号:
|
||||
Default account:
|
||||
|
||||
* 用户名:`admin`
|
||||
* 密码:`12345678`
|
||||
* Username: `admin`
|
||||
* Password: `12345678`
|
||||
|
||||
### 2. 安装 Agent
|
||||
### 2. Install Agent
|
||||
|
||||
安装 Agent 前请先在节点上安装 OpenResty,或改用内置 OpenResty 的 Agent Docker 镜像。
|
||||
Before installing the Agent, first install OpenResty on the node or use the built-in OpenResty Agent Docker image.
|
||||
|
||||
你可以在控制面板的节点管理->详情->节点信息->节点标识与部署复制安装命令,或直接使用下面的脚本:
|
||||
You can copy the installation command from the control panel's **Nodes Management -> Details -> Node Information -> Node ID and Deployment**, or use the script below:
|
||||
|
||||
#### Docker 部署
|
||||
#### Docker Deployment
|
||||
|
||||
Docker 部署可直接运行 Agent 镜像:
|
||||
Docker deployment can directly run the Agent image:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/rain-kl/openflare-agent:latest
|
||||
@@ -187,9 +165,9 @@ docker run -d --name openflare-agent --restart unless-stopped \
|
||||
ghcr.io/rain-kl/openflare-agent:latest
|
||||
```
|
||||
|
||||
## 开源协议
|
||||
## Open Source License
|
||||
|
||||
本项目采用 [Apache License 2.0](./LICENSE) 开源。
|
||||
This project is licensed under the [Apache License 2.0](./LICENSE).
|
||||
|
||||
## Star History
|
||||
|
||||
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
<div align="center">
|
||||
|
||||
# OpenFlare
|
||||
|
||||
**[English](./README.md) | [简体中文](./README.zh-CN.md)**
|
||||
|
||||
OpenFlare 是开源 CDN 编排与边缘安全平台。它支持反向代理、集中式配置同步、内网穿透(Tunnels)、动态 WAF 防护以及防 CC 挑战。
|
||||
|
||||
</div>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/LICENSE">
|
||||
<img src="https://img.shields.io/github/license/Rain-kl/OpenFlare?color=brightgreen" alt="license">
|
||||
</a>
|
||||
<a href="https://github.com/Rain-kl/OpenFlare/releases/latest">
|
||||
<img src="https://img.shields.io/github/v/release/Rain-kl/OpenFlare?color=brightgreen&include_prereleases" alt="release">
|
||||
</a>
|
||||
<a href="https://github.com/Rain-kl/OpenFlare/pkgs/container/openflare">
|
||||
<img src="https://img.shields.io/badge/GHCR-ghcr.io%2Frain--kl%2Fopenflare-brightgreen" alt="ghcr">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
> [!WARNING]
|
||||
> 使用 `admin` 用户初次登录系统后,务必修改默认密码 `12345678`。
|
||||
>
|
||||
> BETA 版本为开发测试阶段的临时产物,可能存在未知问题,请勿在生产环境使用。
|
||||
|
||||
## 文档
|
||||
|
||||
**https://openflare.fyrn.link**
|
||||
|
||||
常用入口:
|
||||
|
||||
* [快速开始](https://openflare.fyrn.link/guide/quick-start)
|
||||
* [部署说明](https://openflare.fyrn.link/deployment/deployment)
|
||||
* [配置项参考](https://openflare.fyrn.link/reference/configuration)
|
||||
* [系统设计](https://openflare.fyrn.link/design/)
|
||||
|
||||
## 核心能力
|
||||
|
||||
* **反代配置管理**:以网站规则为聚合边界,支持多域名绑定与多上游负载均衡,统一管理所有 OpenResty 节点的反代配置。
|
||||
* **安全内网穿透(Tunnels)**:开源版的 Cloudflare Tunnels。无须公网 IP 或暴露入向端口,通过 Relay 中继节点与 OpenFlared 客户端安全反向穿透内网 Web 服务至公网。
|
||||
* **边缘 WAF 安全防护**:提供全局与自定义规则组,支持手动/自动/订阅型 IP 组、MaxMind GeoIP 国家级地域准入、IP 组成员 Checksum 差分同步(无需 Nginx 重载)以及自定义拦截响应。
|
||||
* **防 CC 与人机挑战(PoW)**:内置高性能客户端密码学 Proof of Work 挑战(类似 Turnstile),在网关边缘秒级拦截并阻断僵尸网络与爬虫。
|
||||
* **Pages 静态托管**:支持上传或从受限 Remote URL、公开 GitHub Release asset 同步预构建产物;GitHub latest 可定时检查并可选自动发布。所有来源统一生成不可变部署,由边缘 Agent 拉取并通过 OpenResty 本地提供服务,支持回滚、SPA Fallback 与 API 反向代理。
|
||||
* **TLS 证书自动化**:支持证书动态上传、多域名证书自动匹配绑定,以及通过 ACME 协议向 Let's Encrypt 自动申请与续期证书。
|
||||
* **Uptime Kuma 监控同步**:与 Uptime Kuma 集成,自动差分同步监控站点列表,实时感知节点存活与服务可用状态。
|
||||
* **SSO 单点登录**:支持 GitHub OAuth 与标准 OIDC 协议,无缝接入企业身份提供商实现统一登录。
|
||||
* **统一观测**:聚合节点请求指标、实时访问日志明细、宿主机与 Nginx 资源快照、健康事件以及网络波动补传缓冲。
|
||||
|
||||
## 界面预览
|
||||
|
||||
### 仪表盘总览
|
||||
|
||||

|
||||
|
||||
### 访问日志
|
||||
|
||||

|
||||
|
||||
### WAF 防护
|
||||
|
||||

|
||||
|
||||
## 快速开始
|
||||
|
||||
### 硬件配置推荐
|
||||
|
||||
| 组件 | 最低硬件配额 | 推荐硬件配额 | 说明 |
|
||||
| --- |-------------------------------| --- | --- |
|
||||
| **Server 控制面** | 1 核 CPU / 2 GB 内存 / 20 GB 磁盘 | 2 核 CPU / 4 GB 内存 / 50 GB+ 磁盘 | 磁盘用量需根据访问日志留存时长与并发流量合理扩容 |
|
||||
| **Agent 数据面** | 1 核 CPU / 512 MB 内存 / 2 GB 磁盘 | 2 核 CPU / 2 GB 内存 / 10 GB+ 磁盘 | 根据 OpenResty 的并发代理连接量与 WAF 拦截处理扩容 |
|
||||
| **Relay 中继节点**| 1 核 CPU / 1 GB 内存 / 5 GB 磁盘 | 2 核 CPU / 2 GB 内存 / 20 GB 磁盘 | frps 传输中继吞吐量主要受带宽与 CPU 吞吐能力限制 |
|
||||
| **OpenFlared 客户端**| 1 核 CPU / 256 MB 内存 / 1 GB 磁盘 | 1 核 CPU / 512 MB 内存 / 5 GB 磁盘 | 独立运行于内网,自身资源占用极小,保障网络吞吐即可 |
|
||||
|
||||
### 1. 启动 Server
|
||||
|
||||
使用 docker-compose
|
||||
|
||||
```bash
|
||||
# 下载环境变量模板并创建 .env 文件
|
||||
curl -o .env.example https://raw.githubusercontent.com/Rain-kl/OpenFlare/refs/heads/main/.env.example
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
```yaml
|
||||
services:
|
||||
openflare:
|
||||
image: ghcr.io/rain-kl/openflare:latest
|
||||
restart: unless-stopped
|
||||
env_file: .env
|
||||
environment:
|
||||
TZ: ${TZ:-Asia/Shanghai}
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
- openflare_uploads:/app/uploads
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: ${DB_NAME:-openflare}
|
||||
POSTGRES_USER: ${DB_USERNAME:-openflare}
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-replace-with-strong-password}
|
||||
volumes:
|
||||
- openflare_postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME:-openflare} -d ${DB_NAME:-openflare}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
redis:
|
||||
image: valkey/valkey:8.0-alpine
|
||||
restart: unless-stopped
|
||||
command: ["valkey-server", "--appendonly", "yes"]
|
||||
volumes:
|
||||
- openflare_redis_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "valkey-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 5s
|
||||
|
||||
volumes:
|
||||
openflare_uploads:
|
||||
openflare_postgres_data:
|
||||
openflare_redis_data:
|
||||
```
|
||||
|
||||
详细部署说明见 [部署文档](https://openflare.fyrn.link/deployment/deployment)。
|
||||
|
||||
访问地址:`http://localhost:3000`
|
||||
|
||||
默认账号:
|
||||
|
||||
* 用户名:`admin`
|
||||
* 密码:`12345678`
|
||||
|
||||
### 2. 安装 Agent
|
||||
|
||||
安装 Agent 前请先在节点上安装 OpenResty,或改用内置 OpenResty 的 Agent Docker 镜像。
|
||||
|
||||
你可以在控制面板的节点管理->详情->节点信息->节点标识与部署复制安装命令,或直接使用下面的脚本:
|
||||
|
||||
#### Docker 部署
|
||||
|
||||
Docker 部署可直接运行 Agent 镜像:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/rain-kl/openflare-agent:latest
|
||||
docker rm -f openflare-agent 2>/dev/null || true
|
||||
docker run -d --name openflare-agent --restart unless-stopped \
|
||||
-p 80:80 -p 443:443/tcp -p 443:443/udp \
|
||||
-v openflare-agent-pages:/data/var/lib/openflare/pages \
|
||||
-e OPENFLARE_SERVER_URL=http://your-server:3000 \
|
||||
-e OPENFLARE_AGENT_TOKEN=YOUR_AGENT_TOKEN \
|
||||
ghcr.io/rain-kl/openflare-agent:latest
|
||||
```
|
||||
|
||||
## 开源协议
|
||||
|
||||
本项目采用 [Apache License 2.0](./LICENSE) 开源。
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=Rain-kl%2FOpenFlare&type=date&legend=bottom-right">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=Rain-kl/OpenFlare&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=Rain-kl/OpenFlare&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=Rain-kl/OpenFlare&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
+5
-1
@@ -1,8 +1,12 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Command agent runs the OpenFlare edge agent daemon.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"log/slog"
|
||||
"os"
|
||||
@@ -132,7 +136,7 @@ func main() {
|
||||
go geoIPUpdater.Run(ctx)
|
||||
slog.Info("agent process started")
|
||||
|
||||
if err = runner.Run(ctx); err != nil && err != context.Canceled {
|
||||
if err = runner.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
|
||||
slog.Error("agent process exited with error", "error", err)
|
||||
stop()
|
||||
os.Exit(1)
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
|
||||
+5
-1
@@ -1,8 +1,12 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Command flared runs the OpenFlare tunnel client daemon.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"log/slog"
|
||||
"os"
|
||||
@@ -63,7 +67,7 @@ func main() {
|
||||
|
||||
slog.Info("flared process started")
|
||||
|
||||
if err := runner.Run(ctx); err != nil && err != context.Canceled {
|
||||
if err := runner.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
|
||||
slog.Error("flared process exited with error", "error", err)
|
||||
stop()
|
||||
os.Exit(1)
|
||||
|
||||
+5
-1
@@ -1,8 +1,12 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Command relay runs the OpenFlare relay node daemon.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"log/slog"
|
||||
"os"
|
||||
@@ -63,7 +67,7 @@ func main() {
|
||||
|
||||
slog.Info("relay process started")
|
||||
|
||||
if err := runner.Run(ctx); err != nil && err != context.Canceled {
|
||||
if err := runner.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
|
||||
slog.Error("relay process exited with error", "error", err)
|
||||
stop()
|
||||
os.Exit(1)
|
||||
|
||||
+4
-2
@@ -99,10 +99,12 @@ otel:
|
||||
tracer_name: "github.com/Rain-kl/OpenFlare" # Global tracer instrumentation name
|
||||
|
||||
|
||||
# ─── ClickHouse (required) ──────────────────────────────────────────────────────
|
||||
# ─── ClickHouse (optional) ─────────────────────────────────────────────────────
|
||||
# Analytics / observability OLAP store. Telemetry writes are best-effort (async batch).
|
||||
# 默认关闭:缺失本配置块或 enabled: false 时不启用 ClickHouse,日志/指标由主库承担;
|
||||
# 设置 CLICKHOUSE_HOST 或 CLICKHOUSE_ENABLED=true 可经环境变量启用。
|
||||
clickhouse:
|
||||
enabled: true
|
||||
enabled: false
|
||||
hosts:
|
||||
- "127.0.0.1:9000" # compose 内应用可用 clickhouse:9000(经 CLICKHOUSE_HOST)
|
||||
username: "default"
|
||||
|
||||
+1
-1
@@ -110,7 +110,7 @@ services:
|
||||
- ./data/agent/:/data
|
||||
environment:
|
||||
OPENFLARE_SERVER_URL: "http://host.docker.internal:3000"
|
||||
OPENFLARE_AGENT_TOKEN: "af2fb112f36a0055ec25dd164c908fea"
|
||||
OPENFLARE_AGENT_TOKEN: "7c7c4c13df0f3a77866bcd8cde492610"
|
||||
LOG_LEVEL: "debug"
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
|
||||
+17
-8
@@ -1,4 +1,5 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
# Agent image: slim binary + MMDB files on disk (not embedded in the binary).
|
||||
ARG VERSION=dev
|
||||
|
||||
FROM golang:1.25-alpine AS builder
|
||||
@@ -17,20 +18,25 @@ RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
go mod download
|
||||
|
||||
COPY . .
|
||||
RUN apk add --no-cache bash curl \
|
||||
&& bash scripts/fetch-agent-geoip-mmdb.sh
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
--mount=type=cache,target=/root/.cache/go-build \
|
||||
go build -trimpath -ldflags "-s -w -X 'github.com/Rain-kl/Wavelet/internal/apps/agent/config.Version=$VERSION'" -o /build/bin/openflare-agent ./cmd/agent/main.go
|
||||
|
||||
FROM openresty/openresty:alpine
|
||||
# Fetch MMDB into dist/geoip for COPY into the runtime image (not go:embed).
|
||||
RUN apk add --no-cache bash curl \
|
||||
&& bash scripts/fetch-agent-geoip-mmdb.sh
|
||||
|
||||
RUN apk add --no-cache ca-certificates tzdata perl libmaxminddb su-exec libcap \
|
||||
FROM openresty/openresty:alpine-slim
|
||||
|
||||
RUN apk add --no-cache ca-certificates tzdata libmaxminddb su-exec libcap \
|
||||
&& ln -sf /usr/lib/libmaxminddb.so.0 /usr/lib/libmaxminddb.so \
|
||||
&& apk add --no-cache --virtual .build-deps perl curl \
|
||||
&& opm get anjia0532/lua-resty-maxminddb \
|
||||
&& apk del .build-deps \
|
||||
&& rm -rf /root/.opm \
|
||||
&& addgroup -S openflare \
|
||||
&& adduser -S -G openflare -H -h /data -s /sbin/nologin openflare \
|
||||
&& mkdir -p /etc/openflare /data \
|
||||
&& mkdir -p /etc/openflare /data/etc/openflare \
|
||||
&& chown -R openflare:openflare /etc/openflare /data \
|
||||
&& setcap 'cap_net_bind_service=+ep' /usr/local/openresty/nginx/sbin/nginx
|
||||
|
||||
@@ -38,9 +44,12 @@ ENV OPENFLARE_OPENRESTY_PATH=openresty \
|
||||
OPENFLARE_DATA_DIR=/data
|
||||
|
||||
COPY --from=builder /build/bin/openflare-agent /usr/local/bin/openflare-agent
|
||||
COPY scripts/agent-entrypoint.sh /usr/local/bin/openflare-agent-entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/openflare-agent-entrypoint.sh
|
||||
# Default agent paths: data_dir/etc/openflare/GeoLite2-*.mmdb
|
||||
COPY --chown=openflare:openflare --chmod=644 --from=builder /build/dist/geoip/GeoLite2-Country.mmdb /data/etc/openflare/GeoLite2-Country.mmdb
|
||||
COPY --chown=openflare:openflare --chmod=644 --from=builder /build/dist/geoip/GeoLite2-City.mmdb /data/etc/openflare/GeoLite2-City.mmdb
|
||||
|
||||
COPY --chmod=755 scripts/agent-entrypoint.sh /usr/local/bin/openflare-agent-entrypoint.sh
|
||||
|
||||
EXPOSE 80 443 18081
|
||||
ENTRYPOINT ["/usr/local/bin/openflare-agent-entrypoint.sh"]
|
||||
CMD ["-config", "/etc/openflare/agent.json"]
|
||||
CMD ["-config", "/etc/openflare/agent.json"]
|
||||
|
||||
@@ -14,7 +14,8 @@ export default defineConfig({
|
||||
'components/**',
|
||||
'snippets/**',
|
||||
'plan/**',
|
||||
'guideline/**'
|
||||
'guideline/**',
|
||||
'superpowers/**'
|
||||
],
|
||||
|
||||
markdown: {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 67 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 77 KiB |
+100
-7
@@ -8,34 +8,127 @@ sidebar: false
|
||||
|
||||
格式基于 [Keep a Changelog](http://keepachangelog.com/),版本号遵循 [语义化版本](http://semver.org/)。
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 新增
|
||||
- Cloudflare 指向分组:支持将已添加的域名在不同分组之间移动,自动排队更新远程 DNS 指向。
|
||||
- Cloudflare 指向分组:详情页支持批量勾选域名进行批量移动与批量移出操作。
|
||||
- WAF IP 组:在查看 IP 组弹窗中新增即时搜索功能,支持快速过滤和定位 IP 地址。
|
||||
|
||||
## [v3.5.5] - 2026-09-19
|
||||
|
||||
### 🛠 修复
|
||||
- 修复 Cloudflare 指向分组引用的节点已被删除时,分组列表/详情接口整体返回「Cloudflare 资源不存在」的问题;现会跳过缺失节点并继续返回其余分组。
|
||||
- 修复静态导出部署下访问 Cloudflare 指向分组详情(`/cloudflare/groups/{id}`,id 不为 1)会跳回首页并触发 React hydration 报错的问题。
|
||||
|
||||
### ⚡️ 优化与改进
|
||||
- 优化 openflare-agent Docker 镜像体积:精简运行时依赖并消除离线 IP 库冗余层,镜像总体积从 500MB+ 缩减至约 150MB。
|
||||
|
||||
## [v3.5.4] - 2026-08-29
|
||||
|
||||
### ✨ 新功能
|
||||
- 控制台接入中英双语(next-intl,无 URL 语言前缀):默认中文,可在顶栏或「外观设置」切换;选择写入 cookie 后刷新生效。
|
||||
|
||||
### 🛠 修复
|
||||
- 修复在网站列表中删除已加入 Cloudflare 指向分组的域名后,访问 Cloudflare 指向分组详情报错「Cloudflare 资源不存在」的问题。
|
||||
- 修复自定义 Webhook 推送在企业微信/钉钉返回 HTTP 200 但 `errcode` 非零时仍记为成功的问题;任务日志会记录上游响应体。
|
||||
- 修复 OpenTelemetry Resource 绑定 semconv schema 版本导致 SDK 升级后可能无法启动的问题。
|
||||
- 修复静态导出(build:embed)部署下切换语言无效的问题:此前页面在构建时固定为默认中文,运行时不再读取 `NEXT_LOCALE`;现在客户端会按 cookie/浏览器语言重新解析并切换界面语言与 `html lang`。
|
||||
- 修复 frpc 子进程在被杀后孤儿进程继续持有管道导致退出阻塞的问题。
|
||||
|
||||
### 💄 其他/体验
|
||||
- 前端使用 `next/font` 自托管 Inter 字体,并忽略浏览器扩展改写 `body` 属性引起的 hydration 警告。
|
||||
|
||||
## 重大变更
|
||||
|
||||
> [!IMPORTANT]
|
||||
>
|
||||
> 3.1.2 版本更新了 CLickHouse 部署配置。
|
||||
>3.5.1 版本解耦了日志存储,ClickHouse 变为可选项,如果想切换数据库, 点击 「任务管理」 -> 「切换日志数据库」任务,按提示迁移数据并切换主库。
|
||||
>
|
||||
> 3.0.0 版本为 Wavelet 平台迁移与架构重构版本,涉及数据库表结构、环境变量以及前后端底层架构的重大变更。请务必在升级前备份数据库,并且更新到 V2.3.4。
|
||||
> 目前已知的兼容性问题:
|
||||
>
|
||||
> - Pages 无法迁移, 升级前请先手动下载并备份 Pages 静态站点的 ZIP 包,升级后重新创建。
|
||||
> - 性能调优参数重置, 升级后请重新配置
|
||||
|
||||
## [unreleased]
|
||||
|
||||
## [v3.5.3] - 2026-08-13
|
||||
|
||||
### 新增
|
||||
- 访问日志「日志明细」支持按 HTTP 状态码筛选,可直接输入任意状态码。
|
||||
- 访问日志「日志明细」支持自定义时间范围筛选,可按起止时间检索日志。
|
||||
- 首页看板改版:24 小时请求趋势拆分展示请求总量与 2xx/4xx/5xx 状态码类请求量并独占一行;移除宿主机磁盘指标,24 小时容量趋势(CPU/内存)并入业务流量卡片展示。
|
||||
|
||||
### 🛠 修复
|
||||
- 修复首页「来源分布」卡片在 PostgreSQL/SQLite 日志库下无数据的问题。
|
||||
- 修复源站错误页「仅针对 GET 请求」未真正透传非 GET 响应的问题:POST/PUT 等非 GET 请求现可完整看到源站原始报错内容。
|
||||
|
||||
## [v3.5.2] - 2026-08-09
|
||||
|
||||
### 🛠 修复
|
||||
- 修复 PostgreSQL 作为日志库时节点访问日志/可观测指标/用户访问日志批量写入失败的问题,现可正常写入。
|
||||
|
||||
## [v3.5.1] - 2026-08-09
|
||||
|
||||
### 新增
|
||||
- 日志存储解耦:ClickHouse 变为可选项,不启用时由 PostgreSQL/SQLite 承担全部日志功能;新增「切换日志数据库」任务支持 PostgreSQL/SQLite 与 ClickHouse 间数据迁移(迁移期间冻结日志写入,成功后自动切换主库并保留源数据);`log_database` / `log_db_migration` 设为受保护配置;ClickHouse 改为默认关闭。
|
||||
- 新增 PostgreSQL/SQLite 日志存储实现:节点访问日志按月分区,统计查询合并为单次扫描、IP 汇总归属地取查询窗口内最新记录、WAF 按 IP 聚合减少扫描次数,并新增 `logged_at` 前导索引与主机名小写表达式索引;过期清理直接删除完全过期的整月分区,启动时兜底预建当月及未来 2 个月分区。
|
||||
- 性能指标与访问日志的保留时长解耦:新增三库共用的 `metric_retention_days` 配置(默认 3 天),每日垃圾清理按独立短留存清理指标快照。
|
||||
|
||||
### 🛠 修复
|
||||
- 修复 UptimeKuma 同步调试日志泄露凭据:Socket.IO 事件日志不再打印 payload 内容(仅记录长度),避免凭据进入日志。
|
||||
- 修复日志保留天数配置继承旧键导致的误删风险:`log_retention_days_*` 不再继承 `database_auto_cleanup_retention_days`,统一默认 30 天。
|
||||
|
||||
### ⚡️ 优化与改进
|
||||
- 系统定期垃圾清理由每 2 小时改为每日执行一次(凌晨 3 点,Asia/Shanghai),降低非必要高频扫描。
|
||||
|
||||
### 💄 其他/体验
|
||||
- 服务工作者(SW)注入挑战页改为前台无感知:不再显示「加载中…」文案,页面空白,仅通过浏览器控制台输出 `[sw-challenge]` 调试信息,注入过程不打扰访客。
|
||||
- 用户访问日志(`w_user_access_logs`)记录禁用:不再采集与写入新的用户访问日志,存量数据与管理端访问日志统计页面保留。
|
||||
|
||||
|
||||
## [v3.5.0] - 2026-08-08
|
||||
|
||||
### 🛠 修复
|
||||
- 修复 PoW 挑战页潜在 XSS 风险,状态与错误文案改用纯文本渲染,并限制跳转 URL 仅允许 http/https 协议。
|
||||
- 修复邮件发送的邮件头注入风险,写入邮件头前自动清除 CR/LF 换行符(CWE-93)。
|
||||
- 修复 UptimeKuma 同步调试日志泄露凭据问题,输出日志前对密码和 Token 等敏感字段打码。
|
||||
|
||||
### ⚡️ 优化与改进
|
||||
- 新增 Service Worker 离线兜底功能,为启用 HTTPS 的网站自动下发 Service Worker 并缓存离线页,域名不可达时展示离线兜底页面。
|
||||
- 重构响应页面设置,将源站错误页与 Service Worker 离线页整合至统一的「响应页面」(/responses)标签页,并增加 URL 查询参数 tab 状态同步。
|
||||
|
||||
### 💄 其他/体验
|
||||
- 新增离线页内置预制模板套件(「极简白底」、「线框拓扑」、「包豪斯」),与源站错误页模板风格保持一致,支持编辑界面一键加载与预览。
|
||||
|
||||
## [v3.4.5] - 2026-08-08
|
||||
|
||||
### 改进
|
||||
|
||||
- 源站错误页支持「仅针对 GET 请求」:开启后仅对 GET 的匹配错误状态码返回自定义错误页,其它 HTTP 方法透传源站响应。
|
||||
- 升级后端 Go 依赖至最新稳定版(Gin、GORM、OpenTelemetry、ClickHouse 驱动、AWS SDK、Redis 客户端等),并完成升级兼容性适配:OpenTelemetry 资源 schema 与语义约定版本对齐,ClickHouse 驱动新增格式查询/插入接口的测试替身补齐。
|
||||
- 升级前端 npm 依赖至最新稳定版(Next.js 16.3、React 19.2、recharts 3、react-day-picker 10、lucide-react 1.x、Tailwind CSS 4.3 等),适配图表/日历组件 API 变化,并将 ESLint 配置迁移为 eslint-config-next 16 的 flat config。
|
||||
|
||||
- Agent 不再将 GeoLite2 Country/City MMDB 嵌入二进制:Docker 镜像在默认数据目录 COPY 数据库文件,裸二进制首次启动时按需下载,显著减小 Agent 包体积;OpenResty 仍从磁盘路径读取 MMDB。Server 控制面仍仅内嵌 Country MMDB(不含 City),供可选 MaxMind 提供方离线初始化。
|
||||
|
||||
## [v3.4.4] - 2026-08-06
|
||||
|
||||
### 新增
|
||||
|
||||
- 新增全局源站错误页:可在「网站管理 → 错误页」配置开关、触发状态码(支持 `500-599` 区间与单码)与自定义 HTML;默认启用 OpenFlare 极简错误页并保持真实 HTTP 状态码,修改后随配置版本发布下发到边缘,关闭后恢复透传。
|
||||
- 源站错误页支持「仅针对 GET 请求」:开启后仅对 GET 的匹配错误状态码返回自定义错误页,其它 HTTP 方法透传源站响应。
|
||||
- 新增 Cloudflare DNS 指向管理:可复用现有 Cloudflare DNS 账号或配置独立 Token,按分组将 ZoneDomain 的单条 A 记录异步同步到边缘节点 IPv4,并支持成员橙云、同步状态与节点 IP 变更联动。
|
||||
|
||||
### 修复
|
||||
|
||||
- 修复源站错误页在边缘返回 HTTP 200、页面状态码显示异常(如 0)的问题:错误响应现在正确透传上游状态码,并在页面中展示真实状态码。
|
||||
- 修复 Agent 在配置已对齐但磁盘校验和不一致时,Pages 等对账成功后仍保留 `LastError` 的问题,避免偶发网络失败被健康事件长期显示为「活动中」且无法自动恢复。
|
||||
|
||||
### 改进
|
||||
|
||||
- 删除、撤销与未保存离开等确认操作统一改用页面内 AlertDialog,不再使用浏览器原生 `confirm` 弹窗,交互风格与系统其余对话框保持一致。
|
||||
- Cloudflare 分组添加域名成员时支持按顶级域分层展示、搜索筛选与批量勾选,可一次加入多个域名并排队同步。
|
||||
- Cloudflare 首页展示域名同步(sync_member)与分组同步(sync_group)任务执行记录,可筛选状态、查看详情与失败重试。
|
||||
- Cloudflare 域名/分组同步任务日志补充域名、分组、生效节点 IP、橙云状态及逐域名进度等关键信息,便于排查同步结果。
|
||||
- Cloudflare 域名同步与分组同步任务改为可在任务管理中调度的标准任务类型,并提供成员 ID / 分组 ID 参数表单。
|
||||
- Cloudflare 首页直接提供指向分组管理,并为分组详情增加自动刷新与手动刷新,减少页面跳转并及时展示同步状态。
|
||||
- 统一数据访问分层:业务持久化经 `internal/repository`,`internal/model` 仅保留实体与无 IO 领域规则,避免双轨 CRUD 与职责混淆。
|
||||
- 构建检查增加 `internal/model` 禁止直接访问数据库/Redis 的架构守卫,并收敛 model 与 repository 的错误文案定义边界。
|
||||
|
||||
## [v3.4.3] - 2026-07-24
|
||||
|
||||
|
||||
@@ -138,6 +138,7 @@ function sidebarDesign(): DefaultTheme.SidebarItem[] {
|
||||
{ text: '边缘可观测与业务流量统计', link: 'observability-design' },
|
||||
{ text: '观测数据传输模型', link: 'observability-transport-model' },
|
||||
{ text: '观测上报协议与表结构', link: 'observability-data-model' },
|
||||
{ text: '日志存储解耦', link: 'logstore' },
|
||||
{ text: 'Uptime Kuma 监控同步设计', link: 'kuma-design' },
|
||||
{ text: '登录验证码设计', link: 'login-captcha' }
|
||||
]
|
||||
|
||||
+11
-84
@@ -20,7 +20,7 @@ OpenFlare Agent 运行在代理节点侧。它不会接收远程 shell 指令,
|
||||
|
||||
## 一键安装
|
||||
|
||||
### 交互式安装 (推荐)
|
||||
### 交互式安装(推荐)
|
||||
|
||||
如果在不传递任何参数的情况下运行安装脚本,脚本将进入交互模式。您将可以通过向导选择安装方式(本地运行 / Docker 容器运行),并配置 Server 地址与认证 Token(若选择 Docker 方式且本地没有 Docker,脚本还会询问并智能安装 Docker):
|
||||
|
||||
@@ -28,7 +28,7 @@ OpenFlare Agent 运行在代理节点侧。它不会接收远程 shell 指令,
|
||||
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-agent.sh | bash
|
||||
```
|
||||
|
||||
### 自动化 (非交互式) 安装
|
||||
### 自动化(非交互式)安装
|
||||
|
||||
如果在执行脚本时附加了任何参数,脚本将进入自动化安装模式,不需要任何交互。
|
||||
|
||||
@@ -115,7 +115,7 @@ curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/inst
|
||||
}
|
||||
```
|
||||
|
||||
如果不配置 `openresty_path`,Agent 默认调用 `openresty`。完整字段见 [配置项参考](../reference/configuration.md#agent-配置字段)。
|
||||
如果不配置 `openresty_path`,Agent 默认调用 `openresty`。完整字段见 [配置项参考](../reference/configuration.md#agent-命令行参数与配置字段)。
|
||||
|
||||
## Docker 运行
|
||||
|
||||
@@ -136,58 +136,9 @@ docker run -d --name openflare-agent --restart unless-stopped \
|
||||
> **Pages 持久化**
|
||||
> 默认将 Pages 部署目录挂载到 Docker 命名卷 `openflare-agent-pages`(容器内路径 `/data/var/lib/openflare/pages`)。重建或升级 Agent 容器时无需重新拉取静态站点包。
|
||||
|
||||
> [!NOTE]
|
||||
> **非 Root 安全加固运行**
|
||||
> Agent 容器内部已完成安全加固,在启动后会统一以低权限非 root 用户 `openflare` 运行。
|
||||
> 容器已内置了 `cap_net_bind_service` 内核能力,使得低权限进程依然能够正常监听宿主机的 `80` 和 `443` 特权端口。
|
||||
> 同时,OpenResty 运行时所需的各种临时路径(包括 PID 路径、各类临时缓存目录如 `client_body_temp_path`、`proxy_temp_path` 等)都由 Agent 控制器动态渲染并自动重定向至容器内的 `/data` 目录,彻底避免在非 root 权限运行时写入默认系统路径而导致的权限拒绝错误(Permission Denied)。
|
||||
> 具体物理缓存写入路径为:
|
||||
> * 临时缓存目录:`/data/var/cache/nginx`
|
||||
> * 代理缓存目录:`/data/var/cache/openflare_proxy`
|
||||
|
||||
## 启动与验证
|
||||
|
||||
systemd 环境:
|
||||
|
||||
```bash
|
||||
systemctl status openflare-agent
|
||||
journalctl -u openflare-agent -f
|
||||
```
|
||||
|
||||
手动启动:
|
||||
|
||||
```bash
|
||||
/opt/openflare-agent/openflare-agent -config /opt/openflare-agent/agent.json
|
||||
```
|
||||
|
||||
源码运行:
|
||||
|
||||
```bash
|
||||
|
||||
export LOG_LEVEL='info'
|
||||
go run ./cmd/agent -config /path/to/agent.json
|
||||
```
|
||||
|
||||
编译后二进制运行:
|
||||
|
||||
```bash
|
||||
|
||||
go build -o openflare-agent ./cmd/agent
|
||||
export LOG_LEVEL='info'
|
||||
./openflare-agent -config /path/to/agent.json
|
||||
```
|
||||
|
||||
在管理端确认:
|
||||
|
||||
| 位置 | 期望结果 |
|
||||
| --- | --- |
|
||||
| 节点列表 | 节点在线 |
|
||||
| 节点详情 | 能看到心跳时间、当前版本和基础资源信息 |
|
||||
| 应用记录 | 发布配置后出现应用结果 |
|
||||
|
||||
## 卸载
|
||||
|
||||
### 交互式卸载 (推荐)
|
||||
### 交互式卸载(推荐)
|
||||
|
||||
如果在不传递任何参数的情况下运行卸载脚本,脚本将进入交互模式。您可以通过提示菜单选择卸载方式(本地卸载 / Docker 容器卸载):
|
||||
|
||||
@@ -195,38 +146,14 @@ export LOG_LEVEL='info'
|
||||
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/uninstall-agent.sh | bash
|
||||
```
|
||||
|
||||
### 自动化 (非交互式) 卸载
|
||||
### Docker 容器卸载
|
||||
|
||||
使用命令行传参进行无人值守卸载。
|
||||
|
||||
本地卸载(默认):
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/uninstall-agent.sh | bash -s -- --install-dir /opt/openflare-agent
|
||||
```
|
||||
|
||||
Docker 容器卸载:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/uninstall-agent.sh | bash -s -- --docker
|
||||
```
|
||||
|
||||
支持参数:
|
||||
|
||||
| 参数 | 说明 |
|
||||
| --- | --- |
|
||||
| `--install-dir` | 安装目录,默认 `/opt/openflare-agent`(仅本地卸载生效) |
|
||||
| `--service-name` | systemd 服务名,默认 `openflare-agent`(仅本地卸载生效) |
|
||||
| `--docker` | 使用 Docker 容器方式卸载 |
|
||||
| `--method` | 卸载方式,可选 `local` 或 `docker`(默认 `local`) |
|
||||
|
||||
本地卸载只会移除 Agent 服务、进程和安装目录,不会删除本机 OpenResty。Docker 卸载会停止并删除 `openflare-agent` 容器,交互模式下还可以选择是否清理对应的 Docker 镜像。
|
||||
停止并删除 `openflare-agent` 容器即可
|
||||
|
||||
## 常见问题
|
||||
|
||||
| 现象 | 处理步骤 |
|
||||
| --- | --- |
|
||||
| `agent_token 和 discovery_token 不能同时为空` | 检查 `agent.json` 至少配置了一个 Token |
|
||||
| 节点一直离线 | 在 Agent 节点执行 `curl -I http://your-server:3000`,确认 Server 地址可达 |
|
||||
| OpenResty 没有启动 | 查看 `journalctl -u openflare-agent`,确认 `openresty_path` 可执行,80/443 端口未被占用,且运行用户(如 `openflare`)对数据目录具有读写权限 |
|
||||
| 发布后重复失败 | Agent 会阻断同一 `version + checksum` 的重复应用;需要修正配置后重新发布,或激活旧版本回滚 |
|
||||
| 现象 | 处理步骤 |
|
||||
| --- |---------------------------------------------------------------------------------------------------------|
|
||||
| `agent_token 和 discovery_token 不能同时为空` | 检查 `agent.json` 至少配置了一个 Token |
|
||||
| 节点一直离线 | 在 Agent 节点执行 `curl -I http://your-server:3000`,确认 Server 地址可达 |
|
||||
| 发布后重复失败 | Agent 会阻断同一 `version + checksum` 的重复应用;在节点详情页点击「强制同步」,或重新发布新版本 |
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
你会学到:OpenFlare 的推荐部署方式、Server 与 Agent 的运行要求、源码启动方式、联调步骤、升级与卸载入口。
|
||||
|
||||
生产环境建议使用 PostgreSQL 作为 Server 数据库,并通过 `config.yaml` 或环境变量配置 `APP_SESSION_SECRET` 等参数。完整 Docker Compose 部署还需 Redis 与 ClickHouse(见仓库根目录 `docker-compose.yaml`)。Agent 部署方式推荐为 Docker 部署(即直接使用内置 OpenResty 的 Agent 镜像);亦支持通过安装脚本或手动本地运行。
|
||||
生产环境建议使用 PostgreSQL 作为 Server 数据库,并通过 `config.yaml` 或环境变量配置 `APP_SESSION_SECRET` 等参数。完整 Docker Compose 部署需要 Redis;ClickHouse 可选,用于海量访问日志与观测时序(见仓库根目录 `docker-compose.yaml`)。Agent 支持 Docker 部署与本地安装脚本两种方式,Docker 镜像已内置 OpenResty 二进制。日志库判定与切换见 [日志存储解耦](../design/logstore.md)。
|
||||
|
||||
## 部署拓扑
|
||||
|
||||
@@ -47,33 +47,13 @@ Internal Service (192.168.x.x)
|
||||
|
||||
## 前置条件
|
||||
|
||||
Server:
|
||||
|
||||
| 项目 | 要求 |
|
||||
| --- | --- |
|
||||
| Go | `1.25+`,仅源码运行需要 |
|
||||
| Node.js | `18+`,仅源码构建管理端需要 |
|
||||
| 数据库 | 可写 SQLite 文件目录,或可访问的 PostgreSQL 实例 |
|
||||
| 端口 | 默认监听 `3000` |
|
||||
|
||||
Agent:
|
||||
|
||||
| 项目 | 要求 |
|
||||
| --- | --- |
|
||||
| 系统 | 安装脚本支持 Linux 和 macOS;systemd 服务仅在 Linux + systemd 环境创建 |
|
||||
| 架构 | `amd64` 或 `arm64` |
|
||||
| OpenResty | 本地部署需要可执行 `openresty`,或通过 `--openresty-path` 指定路径 |
|
||||
| Docker | 仅 Docker 部署 Agent 镜像时需要 |
|
||||
| 网络 | Agent 节点必须能访问 Server 地址 |
|
||||
| GeoIP | WAF 地域规则使用 Agent 本地 MaxMind mmdb;Agent 内置初始库并会定期更新 |
|
||||
|
||||
### 硬件配置推荐
|
||||
|
||||
| 组件 | 最低硬件配额 | 推荐硬件配额 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| **Server 控制面** | 1 核 CPU / 1 GB 内存 / 10 GB 磁盘 | 2 核 CPU / 4 GB 内存 / 50 GB+ 磁盘 | 磁盘用量需根据访问日志留存时长与并发流量合理扩容 |
|
||||
| 组件 | 参考配置(入门) | 参考配置(生产) | 说明 |
|
||||
| --- |-------------------------------| --- | --- |
|
||||
| **Server 控制面** | 1 核 CPU / 2 GB 内存 / 20 GB 磁盘 | 2 核 CPU / 4 GB 内存 / 50 GB+ 磁盘 | 磁盘用量需根据访问日志留存时长与并发流量合理扩容 |
|
||||
| **Agent 数据面** | 1 核 CPU / 512 MB 内存 / 2 GB 磁盘 | 2 核 CPU / 2 GB 内存 / 10 GB+ 磁盘 | 根据 OpenResty 的并发代理连接量与 WAF 拦截处理扩容 |
|
||||
| **Relay 中继节点**| 1 核 CPU / 1 GB 内存 / 5 GB 磁盘 | 2 核 CPU / 2 GB 内存 / 20 GB 磁盘 | frps 传输中继吞吐量主要受带宽与 CPU 吞吐能力限制 |
|
||||
| **Relay 中继节点**| 1 核 CPU / 1 GB 内存 / 5 GB 磁盘 | 2 核 CPU / 2 GB 内存 / 20 GB 磁盘 | frps 传输中继吞吐量主要受带宽与 CPU 吞吐能力限制 |
|
||||
| **OpenFlared 客户端**| 1 核 CPU / 256 MB 内存 / 1 GB 磁盘 | 1 核 CPU / 512 MB 内存 / 5 GB 磁盘 | 独立运行于内网,自身资源占用极小,保障网络吞吐即可 |
|
||||
|
||||
## Docker Compose 部署 Server
|
||||
@@ -169,41 +149,3 @@ curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/inst
|
||||
systemctl status openflare-agent
|
||||
journalctl -u openflare-agent -f
|
||||
```
|
||||
|
||||
## 手动运行 Agent
|
||||
|
||||
源码运行:
|
||||
|
||||
```bash
|
||||
|
||||
export LOG_LEVEL='info'
|
||||
go run ./cmd/agent -config /path/to/agent.json
|
||||
```
|
||||
|
||||
编译后二进制运行:
|
||||
|
||||
```bash
|
||||
|
||||
go build -o openflare-agent ./cmd/agent
|
||||
export LOG_LEVEL='info'
|
||||
./openflare-agent -config /path/to/agent.json
|
||||
```
|
||||
|
||||
最小 `agent.json` 示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"server_url": "http://127.0.0.1:3000",
|
||||
"agent_token": "replace-with-node-auth-token",
|
||||
"data_dir": "./data",
|
||||
"openresty_path": "openresty",
|
||||
"heartbeat_interval": 3000,
|
||||
"request_timeout": 10000
|
||||
}
|
||||
```
|
||||
|
||||
未配置 `openresty_path` 时,Agent 默认调用 `openresty`。
|
||||
|
||||
默认情况下,Agent 在 HTTP 心跳成功后会尝试升级为 WebSocket。升级成功时,Server 发布或激活配置会立即通知 Agent;如果 WebSocket 无法建立或意外断开,Agent 会自动退回 HTTP 心跳同步。
|
||||
|
||||
WAF 地域规则依赖 Agent 本地 `GeoLite2-Country.mmdb`。Agent 启动时会在 `data_dir/etc/openflare/GeoLite2-Country.mmdb` 初始化内置数据库,并按配置周期尝试更新;更新失败只记录警告,不影响配置同步与 OpenResty reload。
|
||||
|
||||
@@ -8,10 +8,10 @@
|
||||
|
||||
## 前置条件
|
||||
|
||||
1. **获取 Tunnel Token**:在 OpenFlare 管理端的「内网穿透」或「隧道管理」页面中,创建一个新的隧道实例,系统会自动生成唯一的 `tunnel_id` 与 `tunnel_token`(形如 `tun-<32hex>`)。
|
||||
1. **获取 Tunnel Token**:在管理端「节点管理」中新增一个类型为 **Tunnel** 的节点,保存后进入节点详情页即可查看该节点专属的接入 Token。
|
||||
2. **网络出方向权限**:内网服务器无需任何公网入方向 IP 或端口映射,但必须能够通过网络访问公网上的 **OpenFlare Server 地址** 以及对应的 **TunnelRelay 节点中继端口 (默认 7000)**。
|
||||
3. **软件依赖**(仅限宿主机直接部署):
|
||||
- 本地需有可执行的 `frpc` 二进制文件(建议版本为 `v0.61.0+` 或最新稳定版 `v0.69.0`),或通过参数显式指定路径。
|
||||
- 本地需有可执行的 `frpc` 二进制文件,或通过参数显式指定路径。
|
||||
|
||||
---
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
|
||||
---
|
||||
|
||||
## Docker 运行(推荐)
|
||||
## Docker 运行
|
||||
|
||||
Docker 部署是内网运行最简单也最安全的方式。官方的 `openflared` 镜像已经内置了客户端控制器以及 `frpc v0.69.0` 二进制运行时,无需额外搭建环境。
|
||||
|
||||
@@ -51,40 +51,6 @@ docker run -d --name openflared --restart unless-stopped \
|
||||
|
||||
---
|
||||
|
||||
## 宿主机手动运行
|
||||
|
||||
如果您需要直接在内网的 Linux/macOS/Windows 宿主机上独立运行:
|
||||
|
||||
### 1. 编译二进制
|
||||
|
||||
```bash
|
||||
go build -o bin/flared ./cmd/flared
|
||||
```
|
||||
|
||||
### 2. 准备 `flared.json`
|
||||
|
||||
在程序同级目录下创建 `flared.json` 配置文件:
|
||||
|
||||
```json
|
||||
{
|
||||
"server_url": "http://your-server-ip:3000",
|
||||
"tunnel_token": "your-tunnel-auth-token",
|
||||
"frpc_path": "/usr/local/bin/frpc",
|
||||
"data_dir": "./data",
|
||||
"heartbeat_interval": "10s",
|
||||
"sync_interval": "30s"
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 运行服务
|
||||
|
||||
```bash
|
||||
export LOG_LEVEL='info'
|
||||
./flared -config ./flared.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 启动与验证
|
||||
|
||||
### 1. 自动同步逻辑
|
||||
@@ -92,8 +58,8 @@ export LOG_LEVEL='info'
|
||||
启动成功后,OpenFlared 将执行以下工作流:
|
||||
- **心跳与配置获取**:周期性向 Server 的 `/api/v1/tunnel/heartbeat` 和 `/api/v1/tunnel/config/active` 接口发起同步,验证 Token 并检测配置版本。
|
||||
- **文件渲染**:当检测到配置版本(或校验和 Checksum)变化时,会自动拉取该隧道的完整路由规则。如果绑定了多个中继 Relay,将为每个 Relay 分别在 `data_dir` 下渲染出 `frpc_{relayNodeID}.toml`。
|
||||
- **热重载或重启**:拉起对应的 `frpc` 子进程,或在配置文件发生改变时执行 `frpc reload` / 重启动作,以确保流量映射保持最新。
|
||||
- **异常自恢复**:如果本地 `frpc` 隧道进程异常退出,主控程序会在 5 秒的退避惩罚后自动尝试重新启动。
|
||||
- **配置变更重启**:当配置或校验和变化时,重新拉起对应的 `frpc` 子进程,以确保流量映射保持最新。
|
||||
- **异常自恢复**:如果本地 `frpc` 隧道进程异常退出,主控程序会按指数退避(初始 1 秒,上限 60 秒)自动重启。
|
||||
|
||||
### 2. 查看日志与连接状态
|
||||
|
||||
@@ -113,6 +79,6 @@ frpc process missing, starting {"relay_id": "..."}
|
||||
|
||||
### 3. 管理端确认
|
||||
|
||||
打开管理后台的 **「内网穿透」** 页面:
|
||||
- 查看对应隧道的在线状态,此时应当绿灯显示 **「在线」**。
|
||||
- 您可以清晰地看到该隧道目前连接了哪些中继节点,以及各内网服务的穿透路由详情。
|
||||
打开管理后台的 **「节点管理」**,进入对应 Tunnel 节点的详情页:
|
||||
- 查看节点在线状态与 flared 运行状态(WebSocket 已连接 / 运行中 / 离线)。
|
||||
- 查看当前应用版本与最近一次应用记录。
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# 部署 Relay (Tunnel 中继)
|
||||
# 部署 Relay(Tunnel 中继)
|
||||
|
||||
你会学到:TunnelRelay 节点的职责、`openflare-relay` 的配置项与环境变量、使用 Docker 运行 Relay 的方法,以及如何通过源码手动构建并部署 Relay。
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
- 必须确保 `bindPort`(frpc 连接端口,默认 `7000`)可被公网/内网客户端访问。
|
||||
- 必须确保 `vhostHTTPPort`(HTTP Vhost 端口,默认 `8080`)处于空闲状态,Agent 将在此端口上与 frps 进行流量传递。
|
||||
3. **软件依赖**(仅限宿主机直接部署):
|
||||
- 本地需有可执行的 `frps` 二进制文件(建议版本为 `v0.61.0+` 或最新稳定版 `v0.69.0`),或通过参数显式指定路径。
|
||||
- 本地需有可执行的 `frps` 二进制文件,或通过参数显式指定路径。
|
||||
|
||||
---
|
||||
|
||||
@@ -40,9 +40,9 @@
|
||||
|
||||
---
|
||||
|
||||
## Docker 运行(推荐)
|
||||
## Docker 运行
|
||||
|
||||
Docker 运行是 TunnelRelay 节点最便捷的部署方案。官方镜像内置了 `openflare-relay` 控制器与 `frps v0.69.0` 运行时,开箱即用。
|
||||
Docker 运行是 TunnelRelay 节点最便捷的部署方案。官方镜像内置了 `openflare-relay` 控制器与 `frps` 运行时,开箱即用。
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/rain-kl/openflare-relay:latest
|
||||
@@ -67,39 +67,6 @@ docker run -d --name openflare-relay --restart unless-stopped \
|
||||
|
||||
---
|
||||
|
||||
## 宿主机手动运行
|
||||
|
||||
如果您倾向于在物理机或虚拟机上直接运行:
|
||||
|
||||
### 1. 编译二进制
|
||||
|
||||
```bash
|
||||
go build -o bin/openflare-relay ./cmd/relay
|
||||
```
|
||||
|
||||
### 2. 准备 `relay.json`
|
||||
|
||||
在程序同级目录下创建 `relay.json` 配置文件:
|
||||
|
||||
```json
|
||||
{
|
||||
"server_url": "http://127.0.0.1:3000",
|
||||
"agent_token": "your-relay-node-agent-token",
|
||||
"frps_path": "/usr/local/bin/frps",
|
||||
"data_dir": "./data",
|
||||
"heartbeat_interval": "10s",
|
||||
"request_timeout": "10s"
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 运行服务
|
||||
|
||||
```bash
|
||||
export LOG_LEVEL='info'
|
||||
./openflare-relay -config ./relay.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 启动与验证
|
||||
|
||||
@@ -110,11 +77,6 @@ export LOG_LEVEL='info'
|
||||
docker logs -f openflare-relay
|
||||
```
|
||||
|
||||
如果是在 Linux 上通过 Systemd 托管的,可执行:
|
||||
```bash
|
||||
journalctl -u openflare-relay -f
|
||||
```
|
||||
|
||||
### 2. 验证运行状态
|
||||
|
||||
启动成功后,Relay 将进行以下工作:
|
||||
@@ -122,7 +84,7 @@ journalctl -u openflare-relay -f
|
||||
- 从控制面获取最新的 frps 基础配置(包括 `bindPort`、`vhostHTTPPort` 与自动生成的隧道认证凭证 `auth_token`)。
|
||||
- 在本地自动渲染出 `data/frps.toml` 配置文件。
|
||||
- 自动拉起子进程 `frps -c data/frps.toml`。
|
||||
- 如果进程意外崩溃,Relay 将在 2 秒后自动拉起它。
|
||||
- 如果进程意外退出,Relay 会按指数退避(初始 1 秒,上限 60 秒)自动重启 frps。
|
||||
|
||||
### 3. 管理端确认
|
||||
|
||||
|
||||
+16
-156
@@ -6,7 +6,8 @@ OpenFlare Server 是 Gin + GORM 单体控制面,负责管理端 UI、管理 AP
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **关于外部依赖**:
|
||||
> OpenFlare 系统内建了对后台异步任务(Asynq 框架)及海量节点日志分析与度量指标(观测面板)的支持。因此,**无论采用何种部署模式,系统都必须依赖 Redis(或 Valkey)与 ClickHouse 的运行**。各个部署方案的主要差异在于主关系型数据库的选择(SQLite vs PostgreSQL)以及是否启用链路追踪服务(Jaeger)。
|
||||
> OpenFlare 系统内建了对后台异步任务(Asynq 框架)的支持。因此,**无论采用何种部署模式,系统都必须依赖 Redis(或 Valkey)**。各个部署方案的主要差异在于主关系型数据库的选择(SQLite vs PostgreSQL)以及是否启用链路追踪服务(Jaeger)。
|
||||
> 若业务流量过大,建议使用 ClickHouse 存储日志。
|
||||
|
||||
> [!TIP]
|
||||
> **ClickHouse 服务端性能配置(推荐挂载)**
|
||||
@@ -33,15 +34,15 @@ volumes:
|
||||
|
||||
---
|
||||
|
||||
## 方式一:Docker 部署 (推荐)
|
||||
## 方式一:Docker 部署(推荐)
|
||||
|
||||
使用 Docker 部署可以免去本地配置 Go 与 Node.js 前端构建环境的麻烦。根据你的服务器硬件配置及业务需求,你可以选择以下三种方案之一:
|
||||
|
||||
### 1. 快速启动 (SQLite + Redis + ClickHouse)
|
||||
### 1. 快速启动(SQLite + Redis)
|
||||
|
||||
> **适用场景**:测试体验、轻量化单机部署。
|
||||
>
|
||||
> **特点**:主关系型数据库使用内建的 SQLite 文件
|
||||
> **特点**:主关系型数据库使用 SQLite
|
||||
|
||||
创建 `docker-compose.yaml` 文件:
|
||||
|
||||
@@ -65,13 +66,9 @@ services:
|
||||
SQLITE_PATH: "/data/openflare.db"
|
||||
REDIS_ENABLED: "true"
|
||||
REDIS_ADDR: "redis:6379"
|
||||
CLICKHOUSE_ENABLED: "true"
|
||||
CLICKHOUSE_HOST: "clickhouse:9000"
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
clickhouse:
|
||||
condition: service_healthy
|
||||
|
||||
redis:
|
||||
image: valkey/valkey:8.0-alpine
|
||||
@@ -84,47 +81,13 @@ services:
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
clickhouse:
|
||||
image: clickhouse/clickhouse-server:25.3-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
CLICKHOUSE_DB: openflare
|
||||
CLICKHOUSE_USER: default
|
||||
CLICKHOUSE_PASSWORD: 123456
|
||||
CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: 1
|
||||
TZ: Asia/Shanghai
|
||||
ulimits:
|
||||
nofile:
|
||||
soft: 262144
|
||||
hard: 262144
|
||||
volumes:
|
||||
- ./data/clickhouse_data:/var/lib/clickhouse
|
||||
- ./config/clickhouse/performance.xml:/etc/clickhouse-server/config.d/performance.xml:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "clickhouse-client", "--user", "default", "--password", "123456", "--query", "SELECT 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 15s
|
||||
```
|
||||
|
||||
运行启动命令:
|
||||
|
||||
```bash
|
||||
mkdir -p ./config/clickhouse
|
||||
curl -fsSL -o ./config/clickhouse/performance.xml \
|
||||
https://raw.githubusercontent.com/Rain-kl/OpenFlare/refs/heads/main/config/clickhouse/performance.xml
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. 生产推荐 (PostgreSQL + Redis + ClickHouse)
|
||||
### 2. 小流量业务场景(PostgreSQL + Redis)
|
||||
|
||||
> **适用场景**:生产环境、多节点集群管理、高并发高可用要求。
|
||||
>
|
||||
> **特点**:完全分层架构。启用专用的 PostgreSQL 服务作为主关系数据库,Redis 负责高并发分布式锁、会话缓存与异步队列,ClickHouse 承载海量日志异步 Flush 与观测指标。
|
||||
> **适用场景**:生产环境、业务流量中小,PostgreSQL 不会成为日志记录的瓶颈。
|
||||
|
||||
创建 `docker-compose.yaml` 文件:
|
||||
|
||||
@@ -145,8 +108,6 @@ services:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
clickhouse:
|
||||
condition: service_healthy
|
||||
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
@@ -176,56 +137,29 @@ services:
|
||||
retries: 5
|
||||
start_period: 5s
|
||||
|
||||
clickhouse:
|
||||
image: clickhouse/clickhouse-server:25.3-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
CLICKHOUSE_DB: ${CLICKHOUSE_NAME:-openflare}
|
||||
CLICKHOUSE_USER: ${CLICKHOUSE_USERNAME:-default}
|
||||
CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-replace-with-clickhouse-password}
|
||||
CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: 1
|
||||
TZ: ${TZ:-Asia/Shanghai}
|
||||
ulimits:
|
||||
nofile:
|
||||
soft: 262144
|
||||
hard: 262144
|
||||
volumes:
|
||||
- openflare_clickhouse_data:/var/lib/clickhouse
|
||||
- ./config/clickhouse/performance.xml:/etc/clickhouse-server/config.d/performance.xml:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "clickhouse-client", "--user", "${CLICKHOUSE_USERNAME:-default}", "--password", "${CLICKHOUSE_PASSWORD:-replace-with-clickhouse-password}", "--query", "SELECT 1"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 15s
|
||||
|
||||
volumes:
|
||||
openflare_uploads:
|
||||
openflare_postgres_data:
|
||||
openflare_redis_data:
|
||||
openflare_clickhouse_data:
|
||||
```
|
||||
|
||||
创建对应的 `.env` 文件来配置系统环境变量(可复制并修改根目录下的 `.env.example`):
|
||||
|
||||
```bash
|
||||
mkdir -p ./config/clickhouse
|
||||
curl -fsSL -o ./config/clickhouse/performance.xml \
|
||||
https://raw.githubusercontent.com/Rain-kl/OpenFlare/refs/heads/main/config/clickhouse/performance.xml
|
||||
curl -o .env.example https://raw.githubusercontent.com/Rain-kl/OpenFlare/refs/heads/main/.env.example
|
||||
cp .env.example .env
|
||||
# 编辑 .env 文件,填入对应的数据库、Redis、ClickHouse 连接地址、密码与 APP_SESSION_SECRET
|
||||
# 编辑 .env 文件,填入对应的数据库、Redis、密码与 APP_SESSION_SECRET
|
||||
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. 进阶版 (含 Jaeger 链路追踪的完整编排)
|
||||
### 3. 进阶版(含 Jaeger 链路追踪的完整编排)
|
||||
|
||||
> **适用场景**:开发者调试、系统深度性能诊断、高级可观测性追溯。
|
||||
> **适用场景**:大流量场景,需要进行链路性能指标追踪。
|
||||
>
|
||||
> **特点**:在“生产推荐”全家桶的基础上,联动拉起 Jaeger 作为 OpenTelemetry (OTel) 链路追踪的后端,收集 Server 运行时各个 API 请求的 Span Trace 信息。
|
||||
> **特点**:在“生产推荐”全家桶的基础上,使用 ClickHouse 存储日志,联动 Jaeger 作为 OpenTelemetry (OTel) 链路追踪的后端。
|
||||
|
||||
创建 `docker-compose.yaml` 文件:
|
||||
|
||||
@@ -241,7 +175,7 @@ services:
|
||||
TZ: ${TZ:-Asia/Shanghai}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: "http://jaeger:4317"
|
||||
OTEL_EXPORTER_OTLP_INSECURE: "true"
|
||||
OTEL_SAMPLING_RATE: "1.0" # 本地调试建议设为 1.0 以采样所有 Trace
|
||||
OTEL_SAMPLING_RATE: "1.0" # 采样率,1.0 表示采样全部 Trace
|
||||
ports:
|
||||
- "3000:3000"
|
||||
volumes:
|
||||
@@ -340,64 +274,6 @@ docker compose up -d
|
||||
|
||||
---
|
||||
|
||||
## 方式二:本地部署 (源码/二进制启动)
|
||||
|
||||
如果你不希望使用 Docker,也可以直接在本地或虚拟机上从源码构建和运行 Server。由于后台异步任务和可观测指标分析为系统核心防线,**本地部署时依然需要连接外部 Redis 与 ClickHouse 实例**。
|
||||
|
||||
### 前置条件
|
||||
|
||||
| 项目 | 要求 |
|
||||
| --- | --- |
|
||||
| Go | `1.25+` |
|
||||
| Node.js | `18+` |
|
||||
| pnpm | 推荐通过 `corepack enable` 使用项目声明的 pnpm |
|
||||
| 外部服务 | 必须在本地或远端运行 Redis (Valkey) 和 ClickHouse 实例;ClickHouse 建议挂载仓库提供的 `performance.xml`(见上文「ClickHouse 服务端性能配置」) |
|
||||
|
||||
### 1. 构建管理端前端
|
||||
|
||||
Go Server 运行时需要嵌入前端静态资源。编译 Go 二进制前需要先构建前端静态产物并输出到 Go 服务目录:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
corepack enable
|
||||
pnpm install
|
||||
pnpm build:embed
|
||||
cd ..
|
||||
```
|
||||
|
||||
> **常用前端代码检查命令**:
|
||||
> * `pnpm lint`
|
||||
> * `pnpm typecheck`
|
||||
|
||||
### 2. 使用 SQLite 启动
|
||||
|
||||
关系数据库存储在本地 SQLite 文件,但依然需要提供 Redis 和 ClickHouse 连接配置:
|
||||
|
||||
```bash
|
||||
cp config.example.yaml config.yaml
|
||||
# 编辑 config.yaml:
|
||||
# 1. 设置 app.session_secret 为一个随机的长字符串
|
||||
# 2. 将 database.enabled 设为 false 以启用内置 SQLite
|
||||
# 3. 将 redis.addrs 与 clickhouse.hosts 修改为你的本地/局域网服务连接信息
|
||||
|
||||
# 启动 Server(默认融合模式)
|
||||
go run main.go all
|
||||
```
|
||||
|
||||
### 3. 使用 PostgreSQL 启动
|
||||
|
||||
```bash
|
||||
cp config.example.yaml config.yaml
|
||||
# 编辑 config.yaml:
|
||||
# 1. 设置 app.session_secret
|
||||
# 2. 将 database.enabled 设为 true,并完整设置 database.*、redis.*、clickhouse.* 字段连接参数
|
||||
|
||||
# 启动 Server(默认融合模式)
|
||||
go run main.go all
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 首次登录
|
||||
|
||||
Server 默认监听 `3000` 端口,启动成功后可以使用浏览器访问:`http://localhost:3000`。
|
||||
@@ -413,28 +289,12 @@ Server 默认监听 `3000` 端口,启动成功后可以使用浏览器访问
|
||||
|
||||
---
|
||||
|
||||
## 常用运维指南
|
||||
|
||||
### 1. 命令行子服务分进程启动
|
||||
## 分布式部署
|
||||
|
||||
在大型生产部署中,你可以选择将 Server 按职责拆分为多个进程运行:
|
||||
|
||||
```bash
|
||||
go run main.go api # 仅启动管理端与节点通信的 API 服务
|
||||
go run main.go worker # 仅启动后台任务的 Worker 服务
|
||||
go run main.go scheduler # 仅启动定时任务的 Scheduler 服务
|
||||
go run main.go all # 融合模式(在一进程内运行上述所有服务,默认)
|
||||
```
|
||||
|
||||
### 2. 状态验证
|
||||
|
||||
```bash
|
||||
# 验证编译是否通过
|
||||
go build ./...
|
||||
|
||||
# 运行内部单元测试
|
||||
go test ./internal/apps/openflare/... -count=1
|
||||
|
||||
# 检查服务健康状态
|
||||
curl http://127.0.0.1:3000/api/v1/d/status
|
||||
go run main.go api # 仅启动管理端与节点通信的 API 服务
|
||||
go run main.go worker # 仅启动后台任务的 Worker 服务
|
||||
go run main.go scheduler # 仅启动定时任务的 Scheduler 服务
|
||||
```
|
||||
|
||||
@@ -17,4 +17,4 @@ docker compose up
|
||||
|
||||
## Agent 升级
|
||||
|
||||
Agent 是完全无状态的,升级时直接拉取最新镜像重建容器即可。具体部署命令与安装方式请参考 **[接入 Agent](./agent.md)**。
|
||||
Agent 本地仅缓存运行配置与状态文件,不保存业务数据;升级时直接拉取最新镜像重建容器即可。具体部署命令与安装方式请参考 **[接入 Agent](./agent.md)**。
|
||||
|
||||
@@ -92,12 +92,12 @@ sequenceDiagram
|
||||
Agent 对数据面 OpenResty 的管控实现了端到端的闭环,包含配置落地、语法验证、平滑重载和异常状态捕获:
|
||||
|
||||
### 1. 配置文件的落地组织
|
||||
同步成功后,Agent 会将配置按照特定的物理结构写入到本地 `/etc/nginx/openflare-lua/` 目录下(或配置指定的 `LuaDir`):
|
||||
同步成功后,Agent 将配置写入 `data_dir` 下(默认相对路径 `etc/nginx/`、`etc/openflare/`、`var/lib/openflare/`,具体以 `agent.json` 中 `main_config_path`、`route_config_path`、`cert_dir`、`lua_dir`、`runtime_config_dir`、`pages_dir` 等字段为准):
|
||||
* `nginx.conf`:主配置文件(替换相关占位符,配置性能参数、Shared Dictionaries 及全局 Server)。
|
||||
* `routes.conf`:路由配置文件(由 Agent 生成,包含所有代理网站的 Server 块、证书路径、缓存及速率限制指令)。
|
||||
* `conf.d/openflare_routes.conf`:路由配置文件(由 Agent 生成,包含所有代理网站的 Server 块、证书路径、缓存及速率限制指令)。
|
||||
* `certs/`:证书存放目录(文件命名为 `{cert_id}.crt` 和 `{cert_id}.key`)。
|
||||
* `waf/` 与 `pow/`:WAF 及防 CC 挑战所需的专用 Lua 运行时脚本。
|
||||
* `waf_config.json` 与 `waf_ip_groups.json`:WAF 过滤引擎所需的结构化规则配置文件。
|
||||
* `lua/waf/` 与 `lua/pow/`:WAF 及防 CC 挑战所需的专用 Lua 运行时脚本。
|
||||
* `etc/openflare/waf_config.json` 与 `waf_ip_groups.json`:WAF 过滤引擎所需的结构化规则配置文件。
|
||||
* `pages_dir`:Pages 静态站点部署目录,默认位于 `data_dir/var/lib/openflare/pages`。当激活配置引用 Pages **项目**时,Agent 按 `project_id` 请求控制面「最新激活包」(hash + package),以流式方式写入临时文件并执行实际响应上限与 SHA-256 校验,再安全解压到 `projects/{project_id}/releases/{hash}`。解压后会复核文件数与总字节,绝对防御上限为 2 GiB 包、1,000 个文件、单文件及总量 8 GiB;随后原子切换 `current` 并**立即删除同项目其它历史 release**(仅保留最新)。项目内切换激活无需重发主配置;多项目对账时单项目失败不阻塞其它项目。
|
||||
|
||||
### 2. 精细化的重载动作
|
||||
@@ -105,13 +105,13 @@ Agent 对数据面 OpenResty 的管控实现了端到端的闭环,包含配置
|
||||
2. **写入并替换占位符**:将最新拉取的模板写入,自动将模板中的绝对路径占位符(如 `__OPENFLARE_LUA_DIR__`、`__OPENFLARE_PAGES_DIR__`)替换为本地实际运行路径。
|
||||
3. **语法校验**:调用 `openresty -t -c <temp_nginx.conf>` 进行严格的语法测试。
|
||||
4. **平滑重载**:若校验通过,将新配置移至正式路径,执行 `openresty -s reload`。若 OpenResty 处于未启动状态,则使用当前配置拉起进程。
|
||||
5. **捕获异常**:校验或重载失败时,Agent 会截获标准错误输出(stderr),提取前 2000 个字符的详细报错信息。
|
||||
5. **捕获异常**:校验或重载失败时,Agent 截获命令标准输出(stderr/stdout)作为失败详情上报。
|
||||
|
||||
---
|
||||
|
||||
## 发布与配置应用模型
|
||||
|
||||
OpenFlare 摒弃了动态 Patch 节点配置的落后方式,采用 **不可变配置版本发布模型**。
|
||||
OpenFlare 采用 **不可变配置版本发布模型**,而非对节点配置进行在线动态 Patch。
|
||||
|
||||
```text
|
||||
修改规则 -> 预览 / 查看 diff -> 发布 -> 生成完整配置版本 -> 激活版本 -> Agent 拉取 -> 本地应用 -> 上报结果
|
||||
|
||||
@@ -145,7 +145,7 @@ OpenResty access.log(业务事实)
|
||||
|
|
||||
| Agent tail 增量明细(不 sum/count/uniq)
|
||||
v
|
||||
Server 入库 ClickHouse
|
||||
Server 经 logstore 入库(当前日志主库:PostgreSQL / SQLite / ClickHouse)
|
||||
|
|
||||
+---> 全局聚合 --> 看板「已提供数据 / 请求 / UV」
|
||||
+---> host∈Zone --> Zone「已提供数据」等(同一套语义)
|
||||
@@ -206,19 +206,3 @@ OpenResty 健康与连接数 --> 边缘健康(瞬时,不作 24h 业务总量
|
||||
| Pages artifact 与仓库构建分离 | 现有来源只导入预构建产物;未来 checkout/build 由 Server 隔离 executor 完成并复用 artifact pipeline,Agent 不执行第三方构建 |
|
||||
|
||||
---
|
||||
|
||||
## 贡献者阅读建议
|
||||
|
||||
修改系统架构或开发新功能前,请按以下顺序阅读:
|
||||
|
||||
1. **[产品边界](./index.md)**:了解 OpenFlare 核心定位与不允许逾越的设计边界。
|
||||
2. **[Agent 与发布模型](./agent-design.md)**:理解版本快照同步及失败回滚的安全兜底逻辑。
|
||||
3. **细分领域设计**:
|
||||
* Zone 与域名相关开发:阅读 [Zone 与域名资源设计](./zone-design.md)。
|
||||
* Cloudflare DNS 指向开发:阅读 [Cloudflare DNS 指向设计](./cloudflare-pointing.md)。
|
||||
* 穿透相关开发:阅读 [内网穿透隧道设计](./tunnel-design.md)。
|
||||
* WAF 相关开发:阅读 [WAF 设计](./waf-design.md) 与 [WAF 可编排规则设计](./waf-orchestration-design.md)。
|
||||
* Pages 托管开发:阅读 [Pages 静态托管设计](./pages-design.md)。
|
||||
* 监控同步开发:阅读 [Uptime Kuma 监控同步设计](./kuma-design.md)。
|
||||
* 看板/访问日志/节点指标开发:阅读 [观测数据传输模型](./observability-transport-model.md) 与 [边缘可观测与业务流量统计](./observability-design.md)。
|
||||
4. **[仓库结构](./index.md#仓库结构)**:明确各个物理目录分层职责,避免堆砌和重复开发。
|
||||
|
||||
@@ -187,8 +187,7 @@ OpenFlare 库表为 Source of Truth。每个成员期望:
|
||||
| 可选域名 | `GET /domains/available` |
|
||||
|
||||
* 成功 `response.OK`;失败 `response.Abort*`;**永不**在 JSON 中返回 Token。
|
||||
* Handler 与 `logics.go` 分离;CF 客户端可 mock 接口。
|
||||
* 变更后维护 Swagger(`make swagger`)。
|
||||
* Handler 与 `logics.go` 分离;CF 客户端以接口抽象便于替换。
|
||||
|
||||
## 前端
|
||||
|
||||
@@ -207,18 +206,9 @@ OpenFlare 库表为 Source of Truth。每个成员期望:
|
||||
* 典型:未配置 Token、Token 无效、节点无 IP、CF 无 Zone、同名多 A、限流。
|
||||
* Token 仅服务端解密使用;响应与日志禁止明文 Token。
|
||||
|
||||
## 数据迁移与测试
|
||||
## 数据迁移
|
||||
|
||||
* goose 双方言(PG/SQLite)新建三张表;默认值与 Go 零值一致。
|
||||
* 单测:Token 解析、reconcile 0/1/多条、橙云只初始化新成员、移出删远端(mock)、节点 IP 变更入队。
|
||||
* 禁止单测打真实 Cloudflare。
|
||||
|
||||
## 文档与边界同步
|
||||
|
||||
* 更新 [Zone 与域名资源设计](./zone-design.md):Zone 仍不内建权威 DNS;可选本模块负责 CF A 指向。
|
||||
* 更新 [系统架构](./architecture.md) 核心对象与阅读建议。
|
||||
* 更新 [产品边界](./index.md) 能力表。
|
||||
* 实现完成后写入 `docs/changelog/index.md` 的 `[Unreleased]`(纯设计文档变更不写 changelog)。
|
||||
|
||||
## 关键决策摘要
|
||||
|
||||
|
||||
@@ -204,7 +204,6 @@ access.log cache_status=$upstream_cache_status
|
||||
| 模型/默认 | 创建路由默认 `cache_policy=static`;读写时 `url`→`all` |
|
||||
| 快照 | `config_version` 快照规范化 |
|
||||
| UI | `proxy-routes/detail/components/cache-section.tsx` |
|
||||
| 测试 | `pkg/render/openresty/render_test.go` 等 |
|
||||
|
||||
---
|
||||
|
||||
@@ -235,21 +234,7 @@ access.log cache_status=$upstream_cache_status
|
||||
|
||||
---
|
||||
|
||||
## 7. 验证要点
|
||||
|
||||
* 渲染:无 Cookie/Auth/请求 Cache-Control 旁路;含 `proxy_cache_valid` 三行;`proxy_no_cache` 含 `$upstream_http_set_cookie`。
|
||||
* 单测:内置表含 `css`/`js`/`map`/`mjs`,**不含** `html`/`json`。
|
||||
* 手动:
|
||||
* 带 session Cookie 请求 `/a.js` → 第二次 `HIT`;
|
||||
* `/index.html` + `static` → 未缓存;
|
||||
* 源站对 eligible 路径返回 `Set-Cookie` → 不入库(持续 MISS/不 HIT);
|
||||
* 源站 `Cache-Control: private` → 不入库。
|
||||
* 观测:access log 三态与原始 `cache_status` 一致。
|
||||
* 生效:配置版本发布并节点应用后验证。
|
||||
|
||||
---
|
||||
|
||||
## 8. 决策矩阵(防漏判)
|
||||
## 7. 决策矩阵(防漏判)
|
||||
|
||||
| 场景 | CF | OpenFlare(本设计) |
|
||||
| --- | --- | --- |
|
||||
@@ -264,18 +249,7 @@ access.log cache_status=$upstream_cache_status
|
||||
|
||||
---
|
||||
|
||||
## 9. 后续路线图
|
||||
|
||||
1. Auth 完整 RFC/CF 条件缓存(Lua)
|
||||
2. 强制 Edge TTL / `proxy_ignore_headers`(Cache Rules 级)
|
||||
3. Purge API
|
||||
4. Cache Rules(有序规则 + 动作)
|
||||
5. 全局默认可缓存扩展名可配置;可选对齐 CF 更长扩展名表
|
||||
6. HEAD→GET
|
||||
|
||||
---
|
||||
|
||||
## 10. 决策记录
|
||||
## 8. 决策记录
|
||||
|
||||
| 决策 | 选择 | 原因 |
|
||||
| --- | --- | --- |
|
||||
|
||||
@@ -32,6 +32,8 @@ OpenFlare 适合需要统一管理多台 OpenResty 代理节点的团队,具
|
||||
| **Pages 静态托管** | 支持上传或从 Remote URL、公开 GitHub Release 同步预构建产物;GitHub latest 可定时检查并可选自动发布。不可变部署由边缘节点拉取并由 OpenResty 本地服务,支持回滚、API 反代与 SPA Fallback | [Pages 静态托管设计](./pages-design.md) / [Pages 使用指南](../guide/pages-usage.md) |
|
||||
| **TLS 证书自动续期** | 将证书显式绑定到 Zone 域名,并通过 ACME 协议向 Let's Encrypt 申请/续期证书 | [Zone 与域名资源设计](./zone-design.md) |
|
||||
| **多节点监控与观测** | 访问日志为业务流量唯一真相;Agent 只上报明细与主机读数,Server 统一聚合;与 Zone/看板对账 | [观测数据传输模型](./observability-transport-model.md) / [边缘可观测与业务流量统计](./observability-design.md) / [上报协议与表结构](./observability-data-model.md) / [系统架构](./architecture.md) |
|
||||
| **日志存储** | 访问日志与可观测时序走可切换日志主库(随业务主库或 ClickHouse);关闭 ClickHouse 后仍可写可查 | [日志存储解耦](./logstore.md) |
|
||||
| **控制台双语** | 无 URL 前缀的 zh-CN / en,cookie `NEXT_LOCALE` 优先,兼容静态导出 | [前端 i18n 设计](../superpowers/specs/2026-07-24-frontend-i18n-design.md) |
|
||||
|
||||
---
|
||||
|
||||
@@ -62,7 +64,7 @@ OpenFlare 适合需要统一管理多台 OpenResty 代理节点的团队,具
|
||||
### 5. 系统与版本边界
|
||||
* **全局单一激活版本**:所有节点拉取并消费同一份全局激活配置。不进行按节点分组的差异化配置发布。
|
||||
* **单租户架构**:OpenFlare 仅供单团队在受信任的内部网络部署使用。采用单租户设计,不支持细粒度的多用户角色或多租户资源隔离。
|
||||
* **外部基础设施依赖性**:Server 虽支持 SQLite 作为本地轻量关系数据库,但**系统必须强制依赖外部 Redis(或 Valkey)及 ClickHouse 实例**。Redis 用于处理分布式协调、后台异步队列(Asynq 框架)及系统级全局缓存;ClickHouse 用于接收海量节点访问日志与基础观测的异步 Flush。系统不支持完全脱离这两个组件运行。
|
||||
* **外部基础设施依赖性**:Server **必须依赖**外部 Redis(或 Valkey),用于分布式协调、Asynq 队列与系统缓存。关系库为 PostgreSQL,或关闭 `database.enabled` 时使用 SQLite。ClickHouse **可选**:不启用时,访问日志与可观测时序由当前日志主库(随业务主库)承接;启用后可通过「切换日志数据库」任务迁到 ClickHouse。系统不支持脱离 Redis 运行。详情见 [日志存储解耦](./logstore.md)。
|
||||
|
||||
---
|
||||
|
||||
@@ -98,7 +100,7 @@ OpenFlare 已收敛为**单 monorepo**(Go 模块 `github.com/Rain-kl/Wavelet`
|
||||
| `internal/apps/openflare/{agent,relay,flared}/` | **Server 侧**边缘协议处理器(鉴权、心跳、WS) |
|
||||
| `internal/model/` | GORM 实体 / DTO / 无 IO 领域规则(`openflare_*.go` + 平台模型);**不含** DB 访问 |
|
||||
| `internal/infra/persistence/migrator/goose/` | goose SQL 迁移(PostgreSQL / SQLite / ClickHouse) |
|
||||
| `internal/repository/` | 数据访问层(平台 + OpenFlare 业务 CRUD、缓存、ClickHouse 分析读写);**唯一**持久化入口 |
|
||||
| `internal/repository/` | 数据访问层(平台 + OpenFlare 业务 CRUD、缓存、`logstore` 日志读写);**唯一**持久化入口 |
|
||||
| `internal/infra/task/` | Asynq 异步任务(Worker + Scheduler) |
|
||||
| `internal/infra/config/` | Viper 配置加载 |
|
||||
| `internal/shared/` | 统一 API 响应封装(`response/`) |
|
||||
@@ -191,6 +193,7 @@ OpenFlare 已收敛为**单 monorepo**(Go 模块 `github.com/Rain-kl/Wavelet`
|
||||
## 文档维护原则
|
||||
|
||||
* 产品范围或系统边界变化:更新本文档([产品边界](./index.md))。
|
||||
* 日志存储、日志表判定或切换协议变化:更新 [日志存储解耦](./logstore.md)。
|
||||
* 系统结构、组件分工变化:更新 [系统架构](./architecture.md)。
|
||||
* 发布、同步、回滚与 Agent 模型变化:更新 [Agent 与发布模型](./agent-design.md)。
|
||||
* 部署方式变化:更新 [部署说明](../deployment/deployment.md) 与 README。
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
在多节点的网关架构中,监控系统的状态与反向代理路由的状态通常是相互脱节的:
|
||||
1. **录入开销大**:每当网关控制面新增或下线一个站点,管理员都必须在监控系统(如 Uptime Kuma)中重复配置对应的探测地址与告警策略。
|
||||
2. **数据不一致**:当代理路由域名发生变更或切换 HTTPS 时,容易遗漏修改监控参数,导致监控系统误报或漏报。
|
||||
3. **环境污染隐患**:如果简单的在监控中执行全量“删除-重建”同步,不仅会清空监控系统中的历史统计指标和 SLA 曲线,还会影响到用户在此监控实例上自行配置的、与网关无关的其他监控任务。
|
||||
3. **环境污染隐患**:若在监控中执行全量“删除-重建”同步,会清空监控系统中的历史统计指标与 SLA 曲线,还会影响用户在此监控实例上自行配置的、与网关无关的其他监控任务。
|
||||
|
||||
为了解决这些痛点,OpenFlare 引入了基于客户端/服务器模式的 **Uptime Kuma 自动监控同步机制**,实现网关站点路由定义与可用性监测系统的强一致、低开销以及零污染同步。
|
||||
|
||||
@@ -106,4 +106,4 @@ stateDiagram-v2
|
||||
* Server 周期性(每 1 分钟)通过后台的 Cron Job 探测是否达到配置的同步间隔(`UptimeKumaSyncInterval`)。
|
||||
* 任务内部设计了互斥锁(Mutex Locking)。如果前一次同步请求因为网络延迟等原因尚未结束,下一次调度将自动跳过,防止并发多个 Socket.IO 连接对 Uptime Kuma 实例造成 DDOS 冲击。
|
||||
2. **WebSocket 状态监听**:
|
||||
* 同步程序利用 Socket.IO 的事件监听机制,在连接建立后,必须等到监听到 `monitorList` 事件的完整列表推送后,才允许向下执行差分算法,以规避因为数据加载不完整导致误删监控项的边界情况。
|
||||
* 同步程序利用 Socket.IO 的事件监听机制,在连接建立后,必须等到监听到 `monitorList` 事件的完整列表推送后,才允许向下执行差分算法,避免因数据加载不完整导致误删监控项。
|
||||
|
||||
@@ -7,14 +7,14 @@
|
||||
## 1. 业务背景与产品范围
|
||||
|
||||
### 背景与痛点
|
||||
根据我们的系统安全分析,OpenFlare 的登录端点 `/api/user/login` 虽然配置了基于 IP 的限流限制,但由于缺少用户维度的防护机制,攻击者可使用代理池绕过 IP 限制对高权限账户(如 `root`)实施撞库和暴力破解。同时,对于系统登录页面,标准的视觉验证码对用户体验和无障碍不够友好。
|
||||
OpenFlare 的登录端点 `/api/v1/user/login` 缺少用户维度的防护机制,攻击者可使用代理池对高权限账户(如 `root`)实施撞库和暴力破解。同时,标准的视觉验证码对登录页用户体验和无障碍不够友好。
|
||||
|
||||
### 产品范围与技术选型
|
||||
* **技术选型**:Cap (Proof-of-Work 驱动的无感无图像验证码解决方案)。
|
||||
- **核心原理**:客户端(Widget/网页)从服务器获取工作量证明 (PoW) 的难题,使用浏览器后台计算求解并将答案回传。服务器验证答案的正确性,完成人机识别。
|
||||
- **优势**:无感、无图像验证、不依赖任何外部第三方 API 节点(私密)、包极小。
|
||||
* **接入范围**:控制面 Server 登录 API(`/api/user/login`)以及前端登录页面。
|
||||
* **配置粒度**:支持管理员通过控制台 Option 表随时开启/关闭验证码(`CapLoginEnabled`)。
|
||||
* **接入范围**:控制面 Server 登录 API(`/api/v1/user/login`)以及前端登录页面。
|
||||
* **配置粒度**:支持管理员通过控制台 Option 表随时开启/关闭验证码(`cap_login_enabled`)。
|
||||
|
||||
---
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
* 暴露 `POST /api/cap/challenge` 接口,为客户端分发 PoW 难题和签名的 JWT Token。
|
||||
* 暴露 `POST /api/cap/redeem` 接口,校验客户端提交的 PoW 解答并核发带有失效时间的登录凭证(Redeem Token)。
|
||||
* 将 Redeem Token 与对应过期时间保存在内存缓存/Redis 缓存中。
|
||||
* 在 `POST /api/user/login` 接口中,若启用了验证码保护,先校验并消耗(单次失效)对应的 `cap-token`。
|
||||
* 在 `POST /api/v1/user/login` 接口中,若启用了验证码保护,先校验并消耗(单次失效)对应的 `cap-token`。
|
||||
|
||||
### 2.2 验证流时序图
|
||||
```mermaid
|
||||
@@ -51,7 +51,7 @@ sequenceDiagram
|
||||
Server->>Browser: 返回 {success: false, reason}
|
||||
end
|
||||
User->>Browser: 输入账号密码,点击登录
|
||||
Browser->>Server: POST /api/user/login (在 HTTP 请求头中携带 X-Cap-Token)
|
||||
Browser->>Server: POST /api/v1/user/login (在 HTTP 请求头中携带 X-Cap-Token)
|
||||
alt CapLoginEnabled = true
|
||||
Server->>Server: Middleware (CapAuth) 校验并消费 X-Cap-Token
|
||||
alt token 合法且未过期且未被消费
|
||||
@@ -80,7 +80,7 @@ sequenceDiagram
|
||||
"error_msg": "",
|
||||
"data": {
|
||||
"challenge": {
|
||||
"c": 50,
|
||||
"c": 1,
|
||||
"s": 32,
|
||||
"d": 4
|
||||
},
|
||||
@@ -108,7 +108,7 @@ sequenceDiagram
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. 登录接口 (POST /api/user/login)
|
||||
#### 3. 登录接口 (POST /api/v1/user/login)
|
||||
* **请求负载保持不变**:
|
||||
```json
|
||||
{
|
||||
@@ -124,4 +124,4 @@ sequenceDiagram
|
||||
1. **JWT 临时状态绑定**:难题在生成时就被签入 JWT payload,包含过期时间限制(10 分钟)。
|
||||
2. **Replay 拦截(Nonce 消耗)**:当客户端调用 `/redeem` 提交解答时,后端在缓存中标记该 JWT Signature 已使用。重复提交相同的解密包将返回 `already_redeemed`。
|
||||
3. **Redeem 一次性核销(单次失效)**:当客户端登录并提交 `cap-token` 时,后端在检验到合法性后立即从缓存中删除该 Key,防止黑客提取历史正确的 `cap-token` 进行重放登录。
|
||||
4. **验证机制无感化**:通过调整 `c (难题数)=50`,`d (难度)=4`,普通用户在桌面端和移动端只需 0.5 秒至 1.5 秒即可静默解出,极大地兼顾了用户体验和反爬效果。
|
||||
4. **验证机制无感化**:通过调整 `c (难题数)`、`d (难度)` 等参数平衡求解耗时与反爬强度,用户在后台静默解出,不打断登录流程。
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
# 日志存储解耦
|
||||
|
||||
你会学到:哪些表属于日志用途、为什么不能绑死 ClickHouse,以及新增一张日志表时必须走哪条代码路径。
|
||||
|
||||
观测字段与上报协议仍以 [观测上报协议与表结构](./observability-data-model.md) 为准;本文只约定**存到哪、怎么切库**。
|
||||
|
||||
---
|
||||
|
||||
## 1. 目标
|
||||
|
||||
* **ClickHouse 可选**:不启用时,PostgreSQL(或关闭主库时的 SQLite)完整承接写入、查询、聚合与清理。
|
||||
* **上层不碰底层库**:apps 只面向 `internal/repository/logstore`(或 `repository` 门面)。`repository/analytics` 与 `db.ChConn` / `db.ChDB` 仅供 logstore 的 ClickHouse 实现使用。
|
||||
* **可切换**:任务管理里的「切换日志数据库」在 PostgreSQL/SQLite 与 ClickHouse 之间复制数据并翻转主库;迁移期间冻结写入,成功才切换,源数据不删。
|
||||
|
||||
---
|
||||
|
||||
## 2. 什么算日志表
|
||||
|
||||
同时满足才进 logstore:
|
||||
|
||||
* 追加写入,几乎不更新单行
|
||||
* 按时间查询或聚合,允许按保留天数删除
|
||||
* 关闭 ClickHouse 后仍要能写、能查
|
||||
* 不参与网站 / 节点 / 证书等事务一致性
|
||||
|
||||
**不要**做成日志表:Zone、节点、配置版本、任务执行、上传元数据。这些走业务主库 `repository`。
|
||||
|
||||
当前日志域:
|
||||
|
||||
| 域 | 接口 | 表 |
|
||||
| --- | --- | --- |
|
||||
| 节点访问日志 | `AccessLogStore` | `of_node_access_logs` |
|
||||
| 可观测时序 | `ObservabilityStore` | `of_node_metric_snapshots` / `of_node_edge_health` / `of_node_obs_frps` / `of_node_obs_frpc` |
|
||||
| 用户访问审计 | `UserAccessLogStore` | `w_user_access_logs` |
|
||||
|
||||
ClickHouse 上的小时级物化视图(如 `of_access_log_hourly`)只服务 CH 查询加速。PostgreSQL / SQLite **不建**同构聚合表,查询时从原始日志实时聚合。
|
||||
|
||||
---
|
||||
|
||||
## 3. 分层
|
||||
|
||||
| 层级 | 路径 | 职责 |
|
||||
| --- | --- | --- |
|
||||
| 抽象 | `internal/repository/logstore` | 接口 + `Active` / `BuildForMigration`;按 `log_database` 选实现 |
|
||||
| CH 实现 | `logstore/clickhouse_store.go` | 委托 `repository/analytics`(原生批量 + 现有聚合 SQL) |
|
||||
| 主库实现 | `logstore/postgres_store.go` | PostgreSQL(高频表按月分区)与 SQLite(普通表)共用 GORM |
|
||||
| Model | `internal/model/analytics` | 实体与批量 SQL,无 IO |
|
||||
| 入队 | `chwriter` / `risk_control` + `batchwriter` | `FlushFunc` 调 `logstore.Active`;节点日志 / 可观测经 hooks 入队 |
|
||||
| 约束 | `logstore/imports_test.go` | apps 禁止 import `repository/analytics` |
|
||||
|
||||
`log_database` 只有两种合法状态:**随业务主库**(`postgres` 或 `sqlite`)或 **`clickhouse`**。不存在「主库 PostgreSQL + 日志 SQLite」。`log_database` / `log_db_migration` 受保护,管理端不可改。
|
||||
|
||||
启动时:`log_database=clickhouse` 但 ClickHouse 未启用会拒绝启动,须先重新启用 ClickHouse 并切回主库后再关掉。
|
||||
|
||||
---
|
||||
|
||||
## 4. 切换协议
|
||||
|
||||
任务类型 `of_log_db_switch`(管理端名称「切换日志数据库」),参数 `target`。
|
||||
|
||||
1. 校验目标合法且不等于当前库。
|
||||
2. 写 `log_db_migration=migrating`,排空在途 batchwriter(`Drain`,不要 `Stop` writer)。此后写入返回明确错误(HTTP 503),不排队积压。
|
||||
3. 清空目标日志表后按 id 分页复制;复制前对 PostgreSQL 目标 `EnsurePartitions`。
|
||||
4. 全部成功才写 `log_database=target` 并清除迁移标记;失败清除标记,写入继续走源库。
|
||||
5. 源数据不删;重试前重新清空目标以保证幂等。
|
||||
|
||||
不要另起切换协议,也不要在任务里直连 `analyticsrepo`。
|
||||
|
||||
---
|
||||
|
||||
## 5. 新增一张日志表
|
||||
|
||||
列名必须在 ClickHouse / PostgreSQL / SQLite 三套 goose 迁移中一致。要点:
|
||||
|
||||
* 高频表:CH 用 `MergeTree` + `toYYYYMM`;PG 用 `PARTITION BY RANGE(时间列)`,主键含分区键;SQLite 普通表 + 索引。
|
||||
* ID 用 snowflake `uint64`,迁移时原样保留。
|
||||
* 写入走独立 `batchwriter`;flush 调 `logstore.Active`,不要 `analyticsrepo.BatchInsert`。
|
||||
* 切换任务的 `copy*` 必须覆盖新表;清理走已有 `log_retention_days_*` 或 `metric_retention_days`,不要用错 TTL。
|
||||
|
||||
运行时配置见 [配置项参考 · 日志存储](../reference/configuration.md#8-日志存储log-database)。
|
||||
|
||||
---
|
||||
|
||||
## 6. 相关文档
|
||||
|
||||
* 观测字段与上报协议:[观测上报协议与表结构](./observability-data-model.md)
|
||||
@@ -264,7 +264,7 @@ Agent:tail access.log → 解析 JSON 行 → 原样字段上报(可截断 p
|
||||
#### 边界
|
||||
|
||||
* Pages 静态 / 无 `proxy_cache` 的 location:多为空或 `-` → **未使用缓存**,不得标成「命中」。
|
||||
* 第一期只做明细可见;命中率看板、hourly 维度可后续用同一列聚合。
|
||||
* 明细详情展示缓存状态;命中率看板与 hourly 维度可基于同一列扩展。
|
||||
|
||||
**单次心跳条数建议:**
|
||||
|
||||
@@ -302,10 +302,10 @@ Agent:tail access.log → 解析 JSON 行 → 原样字段上报(可截断 p
|
||||
|
||||
写入关系库健康事件表(现有模型即可),不进访问日志湖。
|
||||
|
||||
### 3.8 Go 协议草图(目标)
|
||||
### 3.8 Go 协议结构
|
||||
|
||||
```go
|
||||
// pkg/protocol/agent.go(目标形态,实现时替换旧类型)
|
||||
// pkg/protocol/agent.go(当前实现)
|
||||
|
||||
type NodePayload struct {
|
||||
SchemaVersion int `json:"schema_version,omitempty"`
|
||||
@@ -447,7 +447,7 @@ type BufferedFacts struct {
|
||||
|
||||
---
|
||||
|
||||
## 5. 表结构(目标 DDL)
|
||||
## 5. 表结构(DDL)
|
||||
|
||||
> 引擎与 TTL 与现网一致倾向:访问日志 90 天,指标 30 天。
|
||||
> `id` 使用控制面 Snowflake/唯一 UInt64。
|
||||
@@ -556,7 +556,6 @@ GROUP BY node_id, hour, host;
|
||||
2. 即便存每小时 UV,对多小时窗口 **相加会严重高估**(同一 IP 跨小时重复计)。
|
||||
3. 产品「24h 独立访客」只认整窗 `uniqExact`;趋势图主序列是请求量/错误/字节,分时 UV 非主指标。
|
||||
|
||||
可选未来:若需要分时 UV 曲线,再单独加 `AggregatingMergeTree` 状态表或查询时对明细做 `uniqExact` 按小时 group(成本更高,不阻塞当前看板)。
|
||||
### 5.3 L3 事实表:`of_node_metric_snapshots`(保留,语义明确)
|
||||
|
||||
```sql
|
||||
@@ -761,18 +760,7 @@ Agent 解析:
|
||||
|
||||
---
|
||||
|
||||
## 10. 实现检查清单
|
||||
|
||||
- [x] `pkg/protocol`:仅 v2 字段,无兼容别名
|
||||
- [x] Agent:只组 `host_metrics` / `edge_health` / `access_logs` / `buffered`
|
||||
- [x] Server:无 request_reports / openresty 吞吐;健康当前态 PG、时序 CH
|
||||
- [x] CH migration:`request_length`、`request_time_ms`、`of_node_edge_health`、`of_access_log_hourly`、hourly 回填
|
||||
- [x] 看板/Zone API 统一读 access log 聚合
|
||||
- [x] UV:整窗 uniqExact;Zone 曲线标明分桶 UV;小时趋势不绘 UV
|
||||
|
||||
---
|
||||
|
||||
## 11. 修订记录
|
||||
## 10. 修订记录
|
||||
|
||||
| 日期 | 说明 |
|
||||
| --- | --- |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 边缘可观测与业务流量统计重构设计
|
||||
|
||||
你会学到:当前观测链路为何出现「看板 OpenResty 出站」与「Zone 已提供数据」不一致、字段与聚合为何冗余,以及目标架构如何让 **Agent 只上报事实、Server 只解释事实**,业务流量以访问日志为唯一真相源。
|
||||
你会学到:本次重构要解决的问题(「看板 OpenResty 出站」与「Zone 已提供数据」不一致、字段与聚合冗余),以及目标架构如何让 **Agent 只上报事实、Server 只解释事实**,业务流量以访问日志为唯一真相源。
|
||||
|
||||
---
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
### 2.1 产品约束(继承)
|
||||
|
||||
* 单租户、全局单激活配置;观测不引入多租户计费隔离。
|
||||
* ClickHouse 为访问日志与时序观测的强制分析存储。
|
||||
* 访问日志与时序观测走可切换日志主库(默认 ClickHouse,可切换 PostgreSQL/SQLite),见 [日志存储解耦](./logstore.md)。
|
||||
* Agent 无入向控制、Pull 模型;离线期间本地 OpenResty 继续服务,观测可本地缓冲后补传。
|
||||
|
||||
### 2.2 工程约束
|
||||
@@ -98,9 +98,9 @@ Server = 入库 + 聚合 + 归属 + 趋势 + 对账
|
||||
|
||||
---
|
||||
|
||||
## 4. 现状问题(基线)
|
||||
## 4. 重构前的问题(基线)
|
||||
|
||||
### 4.1 当前数据流(冗余)
|
||||
### 4.1 重构前数据流(冗余)
|
||||
|
||||
```text
|
||||
一次 HTTP 请求
|
||||
@@ -307,11 +307,10 @@ Agent 职责:
|
||||
|
||||
### 7.4 OpenResty 本地观测
|
||||
|
||||
**收敛后建议:**
|
||||
收敛后的状态:
|
||||
|
||||
* 保留:健康检查、`stub_status` 当前连接。
|
||||
* 删除主路径依赖:`log.lua` 中对 request/status/domain/rx/tx 的 shared dict 业务计数,以及 `/openflare/observability` 作为 TrafficReport 来源。
|
||||
* 若短期内保留 endpoint 供调试,不得再写入 Server 权威分析表。
|
||||
* 主路径不再依赖 `log.lua` 的 shared dict 业务计数;`/openflare/observability` 只返回健康与连接快照,不作为业务报表来源。
|
||||
|
||||
### 7.5 与 Agent 设计文档的关系
|
||||
|
||||
@@ -479,7 +478,7 @@ bytes_sent (= $body_bytes_sent), request_length
|
||||
### 11.4 健康状态权威
|
||||
|
||||
* **当前态**:PG `openresty_status` / `openresty_message`。
|
||||
* **时序**:CH `of_node_edge_health`(status + connections;无 message)。
|
||||
* **时序**:日志主库 `of_node_edge_health`(status + connections;无 message)。
|
||||
|
||||
### 11.5 UV
|
||||
|
||||
@@ -497,37 +496,7 @@ bytes_sent (= $body_bytes_sent), request_length
|
||||
|
||||
---
|
||||
|
||||
## 13. 验证标准
|
||||
|
||||
### 13.1 对账
|
||||
|
||||
在仅有单一 Zone 产生流量的环境:
|
||||
|
||||
```text
|
||||
看板「已提供数据」(24h) ≈ Zone「已提供的数据总计」(24h)
|
||||
误差仅来自时间窗对齐(整点截断)与未计入 Host
|
||||
```
|
||||
|
||||
多 Zone 时:
|
||||
|
||||
```text
|
||||
sum(各 Zone 已提供) + sum(未归属 Host) = 全局已提供
|
||||
```
|
||||
|
||||
### 13.2 回归
|
||||
|
||||
* Agent 单测:只解析与 offset,不出现业务 sum 断言为「上报契约」。
|
||||
* Server:Zone stats 与 dashboard business traffic 共用聚合测例。
|
||||
* 前端:文案快照/测试中不再出现业务含义的「OpenResty 出站」与「已提供数据」双卡片。
|
||||
|
||||
### 13.3 性能
|
||||
|
||||
* 24h 看板聚合 P95 可接受(必要时 hourly MV)。
|
||||
* 心跳 payload 体积:明细批量有上限;超限拆缓冲,不在 Agent 做摘要替代。
|
||||
|
||||
---
|
||||
|
||||
## 14. 风险与权衡
|
||||
## 13. 风险与权衡
|
||||
|
||||
| 风险 | 缓解 |
|
||||
| --- | --- |
|
||||
@@ -543,7 +512,7 @@ sum(各 Zone 已提供) + sum(未归属 Host) = 全局已提供
|
||||
|
||||
---
|
||||
|
||||
## 15. 关键决策摘要
|
||||
## 14. 关键决策摘要
|
||||
|
||||
| 决策 | 选择 | 否决方案 |
|
||||
| --- | --- | --- |
|
||||
@@ -556,7 +525,7 @@ sum(各 Zone 已提供) + sum(未归属 Host) = 全局已提供
|
||||
|
||||
---
|
||||
|
||||
## 16. 文档与代码映射(落地时)
|
||||
## 15. 文档与代码映射
|
||||
|
||||
| 区域 | 主要路径 |
|
||||
| --- | --- |
|
||||
@@ -568,8 +537,6 @@ sum(各 Zone 已提供) + sum(未归属 Host) = 全局已提供
|
||||
| 看板 | `internal/apps/openflare/dashboard/`、`internal/apps/openflare/observability/analytics.go` |
|
||||
| 前端 | `frontend/app/(main)/page.tsx`、`components/dashboard/*`、`websites/.../zone-overview.tsx` |
|
||||
|
||||
实现计划见:`docs/plan/20260717-observability-redesign.md`。
|
||||
|
||||
**推荐阅读顺序:**
|
||||
|
||||
1. **[观测数据传输模型](./observability-transport-model.md)**(最新:传什么、从哪采、频率、示例 JSON)
|
||||
@@ -577,7 +544,7 @@ sum(各 Zone 已提供) + sum(未归属 Host) = 全局已提供
|
||||
|
||||
---
|
||||
|
||||
## 17. 修订记录
|
||||
## 16. 修订记录
|
||||
|
||||
| 日期 | 说明 |
|
||||
| --- | --- |
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user