Compare commits

..

244 Commits

Author SHA1 Message Date
ryan 5fd056f99f chore(release): bump version to v1.4.1
### 🛠 修复
- 修复了 Redis 维护通知(maintenance notifications)在启动阶段默认开启协商可能影响启动流程的问题,新增 `maint_notifications` 启动开关并将其默认值调整为禁用,同时统一应用到平台 Redis 与 Asynq 任务客户端。
- 修复了 PostgreSQL SQL 日志级别配置未按预期生效的问题,将常规 SQL 语句输出统一收敛至 debug 级别,避免在生产日志中产生冗余输出。
- 修复了 CI 镜像构建流水线中缺少 IMAGE 环境变量、导致后续步骤无法正确引用镜像地址的问题。
- 修复了前端 prettier 配置缺失的问题。

### ⚡️ 优化与改进
- 在 API、Worker、Scheduler 等进程启动并完成监听器绑定后,统一打印服务 Banner 信息,便于快速识别各进程的运行状态、监听端口与数据库迁移进度。
- 管理员设置页面在浏览器刷新后自动保持当前选中的标签页,通过 URL query 参数持久化并校验非法取值,提升后台操作的连续性。

### 💄 其他/体验
- 新增前端 prettier 配置文件与忽略规则,统一代码格式化规范。
2026-07-13 16:05:55 +08:00
ryan 08fac67f2a feat(startup): print service banner after listener ready 2026-07-13 15:59:29 +08:00
ryan 9cac25696a fix(redis): add maintenance notification startup switch
Default Redis maintenance notification negotiation to disabled and apply the startup-only setting to both platform and Asynq clients.
2026-07-13 15:48:31 +08:00
ryan a938a5e67f fix(db): log SQL statements at debug level 2026-07-13 15:41:04 +08:00
ryan e0eb4e5975 prettier 2026-07-13 15:17:45 +08:00
ryan 08e61ce833 fix(frontend): prettier config 2026-07-13 15:14:47 +08:00
ryan c293a255d0 feat(frontend): persist selected tab on admin settings page refresh
Store the selected tab of the admin settings page in the URL query string under the tab parameter. Defend against invalid values by validating it against the list of known tabs.
2026-06-30 20:52:24 +08:00
ryan 1855b48028 fix ci 2026-06-28 17:31:57 +08:00
ryan 2310e1d12a chore(release): bump version to v1.4.0
### 🛠 修复
- 修复了前端人机验证(Captcha)中请求和响应信封格式不匹配的问题。
- 修复了由于自定义 CSS 变量命名冲突导致用户删除确认按钮在特定主题模式下变黑的问题。
- 修复了由于注册 `reset-passwd` 命令行后触发 Cobra 严格子命令校验、导致原有以参数形式启动的 `all`、`api`、`worker`、`scheduler` 等模式命令失效的 Bug。

### ⚡️ 优化与改进
- 重构了 OAuth 缓存,将其替换为系统标准 RAM 内存缓存并引入了多节点之间的 Pub/Sub 订阅发布缓存同步机制,增强高频鉴权的稳定性。
- 后端新增 `reset-passwd` 命令行工具,支持管理员在后台快速重置指定用户的密码并使该用户的 Token 缓存立即失效。
- 后台用户管理支持在详情中直接编辑个人资料和重置密码,并在列表页新增“邮箱”字段与支持邮箱前缀 LIKE 模糊条件搜索。
- 前端引入本地自托管的 Inter 字体,搭配系统原生“苹方”(PingFang SC)与微软雅黑,极大提升了西文、数字及中文字符在不同操作系统下的显示与排版效果。
- 优化了 OpenTelemetry 链路追踪配置,使用 Schemaless 规范避免 semconv 产生 Schema 版本冲突。

### 💄 其他/体验
- 清理了系统设置及个人中心等页面中所有硬编码的靛蓝(indigo)样式色彩,统一改用标准的 Primary 主题配色,使其完美支持多主题与亮暗色模式切换。
- 移除了用户编辑弹窗和徽章(Badge)上不合规的硬编码前背景色,确保所有基础组件的配色由主题系统统一驱动。
- 优化了前端 UI,微调了侧边栏激活项的字体颜色,并修复了危险动作(Destructive)按钮在特定模式下的色彩对比度。
- 优化了 CI 流程,提高构建流水线的执行效率。
2026-06-28 11:40:59 +08:00
ryan f5ee19405f fix(cmd): register all app modes as subcommands in cobra
Resolve unknown command error when launching all/api/worker/scheduler modes due to Cobra strict subcommand validation triggered by reset-passwd. Subcommands now run database migrations via dynamic PreRun hooks.
2026-06-28 11:37:26 +08:00
ryan de605834b7 style(frontend): remove hardcoded indigo colors in settings components
Replace all manual bg-indigo and text-indigo overrides with standard CSS variables such as bg-primary/10 and text-primary across common settings modules to support theme integration.
2026-06-28 11:30:52 +08:00
ryan 5a6ffd600a feat(frontend): integrate self-hosted Inter Google font
Use next/font/google to download and host Inter locally, injecting it into html root via Tailwind CSS variables and fallback to PingFang SC for Chinese characters.
2026-06-28 11:20:02 +08:00
ryan bdbc42c22d style(frontend): remove hardcoded text and bg color overrides on components
Remove redundant text/bg style overrides on Button and Badge components in UserDetailSheet and UserFilterBar to allow proper default and destructive theme variants.
2026-06-28 11:08:11 +08:00
ryan 84626f9613 fix(frontend): remove custom classes to fix black delete confirmation button
Remove custom className from AlertDialogAction to prevent CSS variables overlap, restoring the default red background with white text styling.
2026-06-28 11:06:51 +08:00
ryan 932f0c65b9 feat(admin): support user profile editing, password resetting, and email column with search
- Add UpdateUser API and logics supporting nickname, email, admin flag modification, and password reset.
- Relocate user delete button and confirmation Alert into the EditUserModal.
- Optimize admin Switch change to trigger instant API request with rollback support.
- Fix missing email field in edit form initialization by fetching full profile metadata.
- Render email column in users list and support email-based filtering in UserFilterBar.
- Remove hardcoded styles and sizes from Switch components to follow global theme.
2026-06-28 11:05:57 +08:00
ryan 206f8b59c9 feat(cmd): add reset-passwd command to reset user password
- Added ./wavelet reset-passwd subcommand to reset user passwords via CLI
- Supported --user flag; if not specified, prompts for username interactively
- Supported --password flag; if not specified, generates a secure random password
- Handled access token deletion and cache invalidation
- Added comprehensive unit tests
2026-06-28 10:50:15 +08:00
ryan a25a570973 fix(frontend): adjust sidebar active text color and fix destructive button contrast
- Update sidebar active menu button text color to use theme dynamic `sidebar-primary` variable instead of hardcoded hex value.

- Add missing `destructive-foreground` variables to default theme config and styles, resolving the black-on-black text contrast issue on confirmation dialog delete buttons.

- Update changelog to track these fixes.
2026-06-28 10:50:15 +08:00
ryan 681de3b8cc refactor(oauth): replace legacy oauth cache with standard ram cache and add pubsub synchronization
- Replaced custom map-based cache in apps/oauth/cache.go with standard pkg/cache/ram framework.
- Implemented Redis Pub/Sub invalidation channels for distributed token and user cache synchronization.
- Created apps/oauth/cache_test.go to verify local cache operations and pub/sub broadcasts.

refactor(cache): generic RAM cache with CoW and unified preheating

Replaced L2 Redis cache and old cache package with process-local generic pkg/cache/ram. Implemented Copy-on-Write for reads, fine-grained locks per type for writes, and unified preheating in bootstrap. Changed cache invalidation to lazy-loading to resolve SQLite deadlocks during transactions.
2026-06-27 14:17:26 +08:00
ryan a4f6c2ae34 fix(frontend): cap envelope mismatch 2026-06-21 11:20:36 +08:00
ryan 6e6bce6a03 Optimize CI 2026-06-20 13:53:54 +08:00
ryan ef5d5b46af chore(release): bump version to v1.3.1
### 🛠 修复
- 修复了风控中间件测试在 ClickHouse 批写架构迁移后无法正确初始化的问题。
- 修复了 OpenTelemetry trace provider 初始化时 semconv Schema URL 版本冲突导致进程无法启动的问题。

### ⚡️ 优化与改进
- 新增 ClickHouse 独立 OLAP 管线,以 goose 迁移作为唯一 schema 来源,并抽取 analytics repository 统一访问日志读写。
- 新增通用 ClickHouse batchwriter 批量写入框架,支持各业务域独立缓冲 flush 管道与默认批处理调优。
- 风控访问日志与管理端日志查询改为经 repository 层批写与查询,移除内联 SQL 与手动 DDL 维护路径。
- OAuth Access Token 与会话校验引入 RAM+Redis 缓存,降低高频鉴权路径的数据库读取压力。
- 上传元数据、Auth Source 与系统配置批量读取接入三层缓存(RAM→Redis→DB),并通过 pub/sub 支持多节点失效同步。
- 上传统计增量更新收敛为单事务写入,减少 ingest/remove 路径的锁竞争与统计偏差风险。
- ClickHouse 迁移与连接初始化增加进程隔离,避免与主库迁移互相阻塞。
- 引入 lifecycle 优雅停机钩子,确保进程退出前 flush 批写缓冲与释放资源。

### 💄 其他/体验
- 新增 cache-framework 与 clickhouse-batchwriter 开发技能,并更新 AGENTS.md Skill 关联索引。
- 登录与 Token 校验路径增加缓存预热,缩短冷启动后首次鉴权延迟。
2026-06-20 11:20:11 +08:00
ryan a1f6459c09 fix(trace): 使用 NewSchemaless 避免 semconv schema 版本冲突
业务 Resource 改为 NewSchemaless 合并,继承 resource.Default() 的 SDK 内置
schema URL,不再硬编码 semconv 版本路径。
2026-06-20 11:20:01 +08:00
ryan 280bb63cbd chore(release): bump version to v1.3.1
### 🛠 修复
- 修复了风控中间件测试在 ClickHouse 批写架构迁移后无法正确初始化的问题。

### ⚡️ 优化与改进
- 新增 ClickHouse 独立 OLAP 管线,以 goose 迁移作为唯一 schema 来源,并抽取 analytics repository 统一访问日志读写。
- 新增通用 ClickHouse batchwriter 批量写入框架,支持各业务域独立缓冲 flush 管道与默认批处理调优。
- 风控访问日志与管理端日志查询改为经 repository 层批写与查询,移除内联 SQL 与手动 DDL 维护路径。
- OAuth Access Token 与会话校验引入 RAM+Redis 缓存,降低高频鉴权路径的数据库读取压力。
- 上传元数据、Auth Source 与系统配置批量读取接入三层缓存(RAM→Redis→DB),并通过 pub/sub 支持多节点失效同步。
- 上传统计增量更新收敛为单事务写入,减少 ingest/remove 路径的锁竞争与统计偏差风险。
- ClickHouse 迁移与连接初始化增加进程隔离,避免与主库迁移互相阻塞。
- 引入 lifecycle 优雅停机钩子,确保进程退出前 flush 批写缓冲与释放资源。

### 💄 其他/体验
- 新增 cache-framework 与 clickhouse-batchwriter 开发技能,并更新 AGENTS.md Skill 关联索引。
- 登录与 Token 校验路径增加缓存预热,缩短冷启动后首次鉴权延迟。
2026-06-20 10:51:27 +08:00
ryan f52c8db21a perf(cache): 三层缓存框架补强
- 新增 cache-framework skill,规范 RAM→Redis→DB 读路径、失效与 pub/sub
- 上传元数据 Otter+Redis 缓存与多节点失效;Auth Source 缓存与 pub/sub
- ListSystemConfigsByKeys 补 Redis 层;上传统计单事务;登录/Token 缓存预热
- cleanup 任务补 upload meta 失效钩子
2026-06-20 10:20:35 +08:00
ryan d490030b75 fix: test 2026-06-20 09:58:37 +08:00
ryan 200525a1ab perf: refactor 2026-06-20 09:54:48 +08:00
ryan cac6e88bc0 perf: refactor 2026-06-20 09:46:29 +08:00
ryan 080be1e03a perf: access token cache 2026-06-20 09:46:29 +08:00
ryan d0a9958711 perf: clickhouse isolation 2026-06-19 21:31:03 +08:00
ryan a98d266278 feat(db): add ClickHouse batchwriter framework and skill
Introduce internal/db/batchwriter as a reusable generic buffered writer
for per-domain ClickHouse flush pipelines, with unit tests and default
batch tuning aligned with audit log ingestion.

Add clickhouse-batchwriter agent skill and cross-references in AGENTS.md
and database-migration. Business layers are not wired yet.
2026-06-19 20:59:07 +08:00
ryan 00593742c3 feat(clickhouse): integrate goose migrations and analytics repository
Add a separate ClickHouse OLAP pipeline with goose/clickhouse DDL as the
sole schema source, model/analytics for ORM mapping, and repository/analytics
for reads (ChDB/GORM) and batch writes (ChConn). Refactor risk_control and
admin/logs to use the repository layer instead of inline SQL. Remove the
manual support-files DDL and document the workflow in database-migration skill.
2026-06-19 12:04:22 +08:00
ryan 13e9fead35 chore(release): bump version to v1.3.0
### 🛠 修复
- 修复了存储驱动切换保存后上传记录未同步指向新后端的问题。
- 修复了 S3 不可达时无法暂存本地存储配置的问题。
- 修复了文件软删除后增量统计未正确扣减的问题。
- 修复了 Snowflake ID 生成异常或出现负值时的不可靠行为,增加重试与集中错误处理。
- 修复了自更新流程中解压文件名不匹配导致更新失败的问题。
- 修复了用户列表按 ID 排序方向不正确的问题。
- 修复了前端服务层循环依赖导致构建失败的问题。

### ⚡️ 优化与改进
- 新增 upload.Ingest 程序化上传域服务,统一对象写入、上传记录与增量统计,支持秒传、Worker 摄取与镜像去重策略。
- 新增文件上传统计全量重建异步任务,可在管理端触发以修复历史统计偏差。
- 存储配置收敛为单一 storage_config 来源,移除逐条上传记录的 storage_driver 冗余字段。
- 抽取 repository 层并瘦身 HTTP Handler,统一 Abort 系列错误响应与链路追踪集成。
- 将进程级初始化从 router 迁至 bootstrap/cmd,任务与推送改为显式装配,消除 init 副作用。
- 认证与用户模块通过 listener 域事件解耦推送,避免核心业务直接依赖通知模块。
- 分离 user 模块 Handler 与 Logic 边界,便于 Worker 与单元测试复用业务逻辑。
- 引入系统配置内存缓存与验证码运行时配置快照,降低高频配置读取的数据库压力。
- 优化上传热路径与管理端增量统计表,文件统计查询由全表扫描降为常数级读取。
- 前端拆分管理端 bundle、并行化鉴权与公共配置加载,并虚拟化日志列表以提升首屏与滚动性能。

### 💄 其他/体验
- 新增 file-upload 开发技能,并将 AGENTS.md Skill 索引整理为分类表格。
- 优化文件管理统计页在 Tab 切换与再次进入时的自动刷新,避免展示过期缓存数据。
- 优化异步任务日志的展示与清理体验。
- 前端管理组件就近归位并拆分服务层目录,提升代码可维护性。
2026-06-18 15:14:38 +08:00
ryan 83b5475d69 fix(frontend): refresh file storage stats on tab and page revisit
Set stats query staleTime to 0 with refetchOnMount always, and unmount
inactive file admin tabs so returning to the stats view triggers a new fetch
instead of serving the 30s React Query cache.
2026-06-18 15:13:09 +08:00
ryan 990e3a6f51 feat(upload): add rebuild stats async task 2026-06-18 15:05:52 +08:00
ryan 1cad0c5c55 feat(upload): add programmatic Ingest service and file-upload skill
Introduce upload/ingest as the single domain entry for storing files,
writing w_uploads records, and maintaining incremental stats. Refactor
HTTP UploadFile and delete handlers to delegate to ingest, fix stats
decrement ordering on Remove, and document usage in the file-upload skill.
2026-06-18 14:57:47 +08:00
ryan 9c6c697d95 fix(user): user id asc 2026-06-18 14:00:21 +08:00
ryan 410ff14795 refactor(storage): drop per-upload storage_driver, use storage_config as single source
Remove w_uploads.storage_driver and route all read/write/delete paths through
storage.Active() backed by storage_config.driver. Block direct driver switches
when uploads exist; require migration task instead. Simplify migration to
cursor-based file_path iteration without per-row driver updates.
2026-06-18 13:56:31 +08:00
ryan 1f391e9ec2 fix(storage): apply driver switch on save and repoint upload records
Saving storage settings now activates the selected driver immediately
instead of staging it until migration. When the driver changes, existing
upload rows are repointed to the new storage_driver so /f/{id} reads use
the correct backend. ForDriver can also open non-active drivers from the
saved multi-backend config.
2026-06-18 13:40:29 +08:00
ryan 6fa7034172 update AGENTS.md 2026-06-18 13:32:44 +08:00
ryan e766066a75 fix(storage): allow staging local config when S3 is unreachable
When switching storage drivers in admin settings, save now validates
connectivity against the selected target backend instead of retesting
the still-active driver. The active driver remains unchanged until
migration completes, so unreachable MinIO no longer blocks saving local
storage settings.
2026-06-18 13:28:56 +08:00
ryan 2648f3f3b7 merge main into repository-context-enabled-background 2026-06-18 12:13:45 +08:00
ryan 1b2e083aec refactor(api): extract repository layer and thin HTTP handlers
Introduce internal/repository for data access and cache-backed system
config reads. Move business logic into logics.go across admin push,
user, template, cache, system_config, and upload/handler packages.

Remove Gin from internal/util by relocating request-scoped helpers to
oauth/gin_context.go. Propagate request context for config lookups in
user flows. Slim model entities and delete model-level DB/cache helpers.

Wire handlers to logics/repository so targeted packages no longer call
db.DB directly. Update admin router tests to use ErrorHandlerMiddleware.
2026-06-18 12:12:49 +08:00
ryan dd991909af test(response): fix AbortWithError router tests and oauth/bootstrap reliability
- Add middleware_test.go covering ErrorHandlerMiddleware and Abort helpers
- Switch router test setups to testhelper.NewTestGinEngine for error JSON
- Fix OAuth provider cache to use mock HTTP client and normalize issuer URLs
- Add ResetInitRuntimeOnceForTest to make bootstrap tests hermetic under -count
- Update admin/task test imports for upload/task package move
2026-06-18 12:05:56 +08:00
ryan e5b3a60f73 merge main into handler-model-logics-user 2026-06-18 10:55:18 +08:00
ryan beae8d2dd9 refactor(frontend): colocate admin components and split service layer
Move route-specific admin components from components/common/admin into
app/(main)/admin/<feature>/components/. Split AdminService god object into
domain services, fix UploadService to use BaseService, add AdminUploadService,
consolidate DB export into DbManageService, and migrate consumers to the
unified services entry.
2026-06-18 10:55:14 +08:00
ryan 3d25a377cb merge: integrate http-handler-abort-error architecture refactor
Resolve user package conflicts by keeping main's service-layer logics and
applying Abort* error handling in routers. Align oauth callback with
listener.EmitAdminLoggedIn from main.
2026-06-18 10:53:03 +08:00
ryan 9af84c8ed6 refactor(api): unify error handling and split upload/oauth god modules
Replace c.JSON(200, response.Err) and middleware gin.H bypasses with
response.Abort* helpers so errors flow through Gin Error chain and
ErrorHandlerMiddleware for OTel trace correlation.

Split oauth/sources.go into domain-focused files and decompose upload
into handler/filesrv/stats/task/cache/storage/util subpackages with a
root facade preserving existing import paths.
2026-06-18 10:51:25 +08:00
ryan 13f823e5e3 test(bootstrap): fix review findings and sync architecture docs
- Register tasks in admin/task test setup after init() removal
- Strengthen bootstrap test: RegisterPushDomainEvents before Init
- Add admin_login auth→push listener integration test
- Update AGENTS.md and push/new-async-task/new-api skills for
  bootstrap composition root, listener domain events, and explicit
  test wiring conventions introduced since 50c45db5
2026-06-18 10:50:45 +08:00
ryan dac1c979d1 chore: remove accidental .grok hook files from merge 2026-06-18 10:39:37 +08:00
ryan de8a21a49f merge main: resolve bootstrap and router init conflicts 2026-06-18 10:38:55 +08:00
ryan 03fa5d948d refactor(bootstrap): move runtime init from router to cmd layer
Extract SyncEvents and InitLogWriter from router.Serve into bootstrap.Init
called from cmd entry points with trace-aware context. Preserve existing
Register* wiring for task and push domain integrations.
2026-06-18 10:38:08 +08:00
ryan fcf17db2e7 merge: replace init registration with bootstrap wiring 2026-06-18 10:34:57 +08:00
ryan 22c2ad5c73 refactor(task): replace init registration with bootstrap wiring
Introduce internal/bootstrap as the composition root with sync.Once
guards for task handler registration and push listener wiring. Replace
the single OnTaskCompleted global hook with multi-subscriber handlers
and remove init()-driven side effects from worker, admin task, and push.
2026-06-18 10:34:49 +08:00
ryan 1135347a96 merge: decouple auth from push via domain events 2026-06-18 10:31:14 +08:00
ryan b659f47b62 merge: refactor(user) separate Handler and Logic layer boundaries 2026-06-18 10:31:12 +08:00
ryan 6066eb114b refactor(auth): decouple auth from push via domain events
Introduce internal/listener as a domain event bus so oauth and user
modules emit AdminLoggedIn without depending on admin/push. Register
push handlers explicitly at the router composition root, replacing
init() side-effect registration and blank imports.
2026-06-18 10:31:11 +08:00
ryan 50c45db561 refactor(user): separate Handler and Logic layer boundaries
Move HTTP handlers out of logics.go and replace gin.Context-coupled
login email verification with context-only processLoginEmailVerification.
Add logics_test.go for pure business logic unit tests.
2026-06-18 10:31:09 +08:00
ryan eb99628cd6 dmux 2026-06-18 10:29:48 +08:00
ryan f826807cf1 feat(task): clean task log 2026-06-17 14:29:38 +08:00
ryan 333e45572a fix(frontend): optimize 2026-06-17 13:58:32 +08:00
ryan 3a05e7f09c refactor(idgen): centralize negative ID handling via panic
Restore NextUint64ID() to uint64-only API so callers need no error
checks. Retry logic stays in idgen; after 3 negative values it panics
instead of returning 0, preventing silent NULL primary keys.
2026-06-17 13:14:17 +08:00
ryan 92dc6a59c0 fix(idgen): retry snowflake generation and error on negative ID
NextUint64ID now retries up to 3 times when Int64() is negative, then
returns an error instead of 0 or Fatalf. All call sites propagate the
error to avoid GORM omitting zero-value primary keys.
2026-06-17 13:11:16 +08:00
ryan a27113feb2 fix(frontend): break service layer circular imports for build
Point service implementations at @/lib/services/core for BaseService
instead of the barrel index, fixing static export prerender failure on
ConfigService initialization.
2026-06-17 12:17:05 +08:00
ryan a44043262e perf(frontend): split admin bundles and tighten data fetching
Add next/dynamic lazy loading for database, logs, and settings heavy
modules; migrate access-logs and task-executions to React Query; parallelize
login auth-sources with public config; consolidate users table tooltips;
enable lazy image decoding; and replace barrel @/lib/services imports with
direct module paths.
2026-06-17 12:12:47 +08:00
ryan 9550fa6ff3 perf(cap): add runtime settings snapshot for dynamic config
Replace per-request GetByKey calls with a singleflight-backed
RuntimeSettings snapshot loaded via ListSystemConfigsByKeys.
Invalidate snapshot on admin cap_* writes and via Redis pub/sub.
Simplify VerifyMiddleware and update PERFORMANCE.md status.
2026-06-17 12:07:02 +08:00
ryan d3ba767087 perf(config): add Otter RAM cache layer for system configs
Introduce pkg/cache/ram on top of existing Redis/DB config reads.
GetByKey now checks local RAM before Redis. Unified invalidation clears
RAM and Redis hash fields on admin writes, with pub/sub for multi-node
RAM eviction. Add tests and wire create/update/migrator write paths.
2026-06-17 11:57:04 +08:00
ryan ca21e0eca7 perf(frontend,config): parallelize auth, virtualize logs, cache public configs
Frontend layout renders immediately; pages gate via RequireAuth and
useAuthRedirect. Login/register skip getUserInfo. Admin log panel uses
useVirtualizer to avoid 2000-row DOM. ListVisibleSystemConfigs caches
visibility=1 configs in Redis with invalidation on create/update.
PERFORMANCE.md marks completed optimizations.
2026-06-17 11:41:24 +08:00
ryan 6a8f9a7aea perf(upload): optimize file hot paths and incremental admin stats
- Use RWMutex for disk cache reads and singleflight for WebP cache misses
- Cache migration read-only state and file access whitelist with pub/sub invalidation
- Add w_upload_stats incremental counters updated on upload/delete
- Add w_uploads composite indexes and goose backfill migrations
- Document performance analysis in docs/PERFORMANCE.md
- Fix RegisterCustomRoutes to accept apiV1Router parameter
2026-06-17 11:33:48 +08:00
ryan e057cd5de5 fix(update): extracted filename mismatch 2026-06-16 10:51:58 +08:00
ryan 82155b8599 chore(release): bump version to v1.2.0
### 🛠 修复
- 修复了异步任务列表在成功状态下允许重试、失败状态下无重试按钮的问题。
- 修复了启用推送事件时,在无可用推送渠道配置下仍尝试触发并阻塞的问题。
- 修复了 WebDAV 存储驱动在 Put/Get/Delete 等操作中由于丢弃 context.Context 导致 HTTP 链路追踪断裂(生成无源 Root Span)的问题。
- 修复了结构化日志在没有 active span 时仍强制打印全零 traceID/spanID 产生的日志冗余噪音。

### ⚡️ 优化与改进
- 实现了全新的系统通知推送机制,支持 Telegram Bot、Lark 机器人及自定义 Webhook 等多种推送渠道。
- 优化了路由结构设计,按照 V1 分类与业务模块实现扁平化的路由解耦。
- 引入了全局 OpenTelemetry 链路追踪(Tracer)框架,集成 Gin, GORM 与 Redis 自动化耗时度量,并补充了统一的全局错误处理中间件。
- 优化了采样器命名,将 ParentBasedErrorAwareSampler 重命名为更契合其真实机制的 ParentBasedRatioSampler。
- 重构并统一了项目架构为基于 Feature 的功能模块化结构,将 internal/util/ 拆分得更加纯净,优化了验证码等公共库提取(pkg/cap)。
- 实现了推送事件与自定义通道的 Redis 缓存机制,极大降低了推送触发时的高频 DB 查询压力。

### 💄 其他/体验
- 优化了前端界面布局,同步系统菜单与侧边栏配置显示。
- 优化了前端自定义通道表格的布局与样式,使其与事件管理 Tab 页面保持一致。
- 更新了项目开发技能手册(Skills),包括新增接口路由规范(new-api)和异步任务开发指南(new-async-task)。
2026-06-16 10:39:22 +08:00
ryan f129a9cfca feat(api): implement global error handler middleware and trace integration
- Introduce APIError type and AbortWithError helper in response package
- Implement errorHandlerMiddleware to record Go errors to Otel Spans and format JSON response
- Register errorHandlerMiddleware globally in router
- Refactor logs analytics handler to use the new unified error pattern
2026-06-16 10:24:14 +08:00
ryan 9fa38dcfdc feat(push): implement Redis caching for push events and custom channels
- Implement cached queries in GetActivePushEventByKey and GetActivePushChannelByName.
- Set TTL for cached items to 24 hours via activePushEventCacheTTL and activePushChannelCacheTTL constants.
- Implement GORM hooks (AfterSave and AfterDelete) on PushEvent and PushChannel to auto-evict Redis caches, guaranteeing cache consistency.
- Evict Redis caches manually inside API handlers for Create/Update/Delete/Toggle event/channel endpoints.
- Update events.go to query models through the new caching methods.
2026-06-16 10:19:06 +08:00
ryan 9515f0811b chore: fix release ci 2026-06-16 10:08:21 +08:00
ryan c6df73e522 tracer 2026-06-16 09:58:15 +08:00
ryan bfa00ae6ac feat(push): validate push channels presence before enabling push event
- Reject toggling an event to enabled in ToggleEvent handler if channels list is empty.
- Add model-level validation in PushEvent.Validate() to prevent enabling events without channels during creation/updates.
- Update TestPushRouters/toggle_event_status unit test to cover this verification.
2026-06-16 08:59:03 +08:00
ryan 6a913701b9 refactor(push): remove legacy push_global_token setting
- Clean up redundant push_global_token INSERT and DELETE statements from 202606140004 migration.
- Add DELETE for push_global_token in 202606160001 migration to wipe out legacy settings on update.
- Adjust expected w_system_configs count in migrator_test.go to 30.
2026-06-16 08:57:35 +08:00
ryan ff60f1c699 refactor(push): clean up legacy push_config and w_push_events hardcoded insertions
- Remove ConfigKeyPushConfig and delete legacy push_config query logic from EventTrigger.Trigger.
- Simplify the push event notification dispatching engine to rely purely on database custom channels.
- Remove w_push_events hardcoded INSERT statement from migration files, letting SyncEvents handle default event registration.
- Rewrite unit tests to use model.PushChannel instead of push_config.
2026-06-16 08:54:32 +08:00
ryan e50e600bc8 docs(skill): update router packaging and categorization guidelines in documentation
- Updated `new-api` skill `SKILL.md` to document the centralized v1.go route registration and domain-driven sub-routing patterns
- Updated `AGENTS.md` instructions with the new router subpackages (`root`, `v1`) and route file layout guidelines
2026-06-16 00:00:53 +08:00
ryan 0adc5e8189 refactor(router): organize root routes and centralize v1 route registration
- Created `internal/router/root` package to register root-level paths
- Moved files serving, robots.txt, and Swagger docs to `root/default.go`
- Registered custom root routes under `root/custom.go`
- Centralized all v1 routes registration in `internal/router/v1/v1.go`
- Simplified `internal/router/router.go` by delegating route registration
- Updated `new-api` skill `SKILL.md` to document the new router structure
2026-06-15 23:42:48 +08:00
ryan dfcbdfe47c docs(skill): update new-api guidelines to reflect router v1 structure
- Updated package structure diagram and routing instructions to point to the new `internal/router/v1` package
- Changed custom routes registration guide to `internal/router/v1/custom.go`
2026-06-15 23:30:45 +08:00
ryan 5016103774 refactor(router): split route registration into v1 package categorizations
- Created `internal/router/v1` subpackage
- Split routes into `admin.go`, `user.go`, `public.go`, and `custom.go`
- Cleaned up imports and helper registration functions from `router.go`
- Removed obsolete `internal/router/custom.go`
2026-06-15 23:30:16 +08:00
ryan 363d2cb4cc refactor(router): merge frontend.go and frontend_embedded.go into one file
- Declared package-level `registerFrontend` variable in `router.go` as a no-op fallback
- Configured `frontend.go` under `embed_frontend` build tag to override `registerFrontend` on package initialization
- Removed separate `frontend_embedded.go`
- Resolved related revive linter warnings
2026-06-15 23:24:09 +08:00
ryan 34de1639f5 feat(frontend): sync menu display config items with sidebar layout
- Added missing routes `/files` and `/admin/push` to menu display management
- Standardized icons, labels and descriptions to match the sidebar layout
- Declared MenuItem/MenuGroup interfaces for type safety
2026-06-15 23:18:04 +08:00
ryan 7f00ed6a65 fix(task): fix manual retry button visibility and validation
- Only show the manual retry button for failed tasks (status === "failed") on the frontend.
- Remove the maximum retry limit validation for manual retries in the task executor.
- Remove obsolete test cases verifying maximum retry limit for manual retries.
2026-06-15 23:10:36 +08:00
ryan 3e8e879661 docs(architecture): update architectural guidelines to reflect feature-based service design
- Updated AGENTS.md, new-api SKILL.md, and new-async-task SKILL.md to remove references to the deleted global internal/service/ package.
- Documented feature-based local logics/services design within internal/apps/<module>/.
- Updated package comments in internal/diskcache to point to pkg/cache/disk.
2026-06-15 17:24:44 +08:00
ryan 239711cea7 refactor(service): adopt feature-based architecture and rename pkg/diskcache
- Moved GORM/Redis CAPTCHA manager from internal/service/cap directly into the cohesive CAPTCHA app folder at internal/apps/cap/.
- Moved background system cleanup handler from internal/service/cleanup.go into internal/apps/upload/cleanup.go.
- Completely removed the global internal/service directory to keep module logic self-contained.
- Renamed the core utility engine pkg/diskcache to pkg/cache/disk to separate underlying utility code from db/config integrations.
- Renamed DiskCache struct in pkg/cache/disk to Cache to resolve revive package-name stuttering warning.
- Regenerated Swagger API documentation and confirmed all tests compile and pass with 0 linter issues.
2026-06-15 16:45:26 +08:00
ryan 953af7d8db refactor(util): move response helper to common/response and session logic to oauth
- Relocated generic HTTP response helpers (Response, OK, Err, etc.) from internal/util/ to a dedicated internal/common/response/ package.
- Renamed ResponseAny to Any to resolve revive stuttering warnings.
- Moved session building options and cookie headers logic from internal/util/ to internal/apps/oauth/.
- Removed all direct imports of Gin/Sessions/HTTP frameworks from internal/util/ to keep general utilities 100% pure.
- Regenerated Swagger API documentation via make swagger.
- All tests and make code-check compile and pass with 0 issues.
2026-06-15 16:39:55 +08:00
ryan b3ed94342c refactor(backend): extract to pkg/cap 2026-06-15 16:39:55 +08:00
ryan 84ae4ec27e refactor(frontend): optimization 2026-06-15 16:39:55 +08:00
ryan 1425fd1dbb feat(frontend): align custom channels table layout and styling with events tab 2026-06-15 16:39:55 +08:00
ryan 2a3b9f8a5f feat(push-task): connect notification module with task module
- Add task_type to w_push_events table and GORM models.
- Implement OnTaskCompleted callback hook in task executor to avoid circular dependencies.
- Implement task listener in push package to trigger notifications on task completion.
- Automatically resolve User objects from payload and results.
- Enhance UI to select task completed events and preview default templates.
- Update Swagger documentation.
2026-06-15 16:39:55 +08:00
ryan de58b118b4 feat(push): add telegram bot push notification channel
Implement TelegramPusher in pkg/push, register config schema in channels_definition.go, add validation in model/push_channel.go, update task routing, and update settings-tab.tsx UI validation.
2026-06-15 15:00:49 +08:00
ryan cb018b3b60 feat(push): implement system notification and push framework 2026-06-14 23:07:08 +08:00
ryan aee457093d chore(release): v1.1.0
### 🛠 修复
- 修复了 CAPTCHA 验证的逻辑和安全性。

### ⚡️ 优化与改进
- 新增了动态存储配置与多后端迁移机制。
- 新增了手动触发存储迁移的 Web GUI 操作界面。
- 实现了存储迁移的并发处理(使用 Group)与上传后 SHA-256 完整性自动校验机制。
- 实现了基于 Redis 的分布式锁与集群多节点缓存失效广播机制,防止迁移任务冲突。
- 实现了 Redis 锁续期守护协程 (watchdog) 防止超长迁移任务锁过期。
- 优化了更新存储配置时的连通性自动校验机制,防止配置错误。
- 优化了存储配置的内存缓存机制,并引入了全局共享连接池以提高 TCP 复用率。
- 优化了上传前已有同名文件的比对逻辑,跳过下载阶段,仅比对 Content-Length 以实现零网络流量跳过。
- 新增了普通用户的独立文件管理 Dashboard 与控制接口。
- 优化了文件管理接口命名空间,将全局管理迁移至管理员级 API Namespace 隔离控制。
- 优化了管理员新建用户接口,增加了邮箱(Email)字段的必填要求。

### 💄 其他/体验
- 重构并优化了文件管理页面,引入了多标签页多维度统计数据大屏。
- 基于 shadcn 原生 UI 组件重构并优化了文件详情和仪表盘组件。
- 抽取后端核心路由注册流程,降低主路由文件圈复杂度,使路由配置更易维护。
2026-06-13 17:13:36 +08:00
ryan 5698169f20 refactor(router): extract route registration into helper methods 2026-06-13 16:29:27 +08:00
ryan 922f764241 feat(user): require email when admin creates a user
- Add `email` as a required field in `createUserRequest`
- Enforce email format verification and database uniqueness checks in the admin user creation handler
- Update the admin user creation frontend modal with validation and form field
- Update the corresponding backend unit tests and regenerate Swagger docs
2026-06-13 16:23:07 +08:00
ryan 5b13d5b464 feat(storage): implement user-group level file management dashboard and APIs
- Expose user-scoped CRUD APIs under `/api/v1/upload` (my files query, stats, rename, delete)
- Update backend handlers and routers with ownership validation checks
- Create a dedicated frontend personal file manager card-list and upload button under `/files`
- Add comprehensive backend test coverage and update API docs
2026-06-13 16:16:43 +08:00
ryan 8b19ffed90 refactor(storage): move file management routes from user to admin namespace
- Remove file list, stats, download, and deletion routes from '/api/v1/upload'
- Move these endpoints under '/api/v1/admin/uploads'
- Remove user-specific filtering from files query and statistics to aggregate system-wide uploads by default
- Allow admins to bypass ownership check when downloading private files
- Update backend unit tests, Swagger documentation, and frontend service client and components
2026-06-13 16:04:21 +08:00
ryan dbabe8b8d7 feat(system_config): validate storage configuration connectivity on update
- Add a live test connectivity check in UpdateSystemConfig before saving the storage configuration.
- Merge masked placeholder secrets from current configuration prior to testing and database storage.
- Extract validation and test logic to validateAndMergeStorageConfig helper to satisfy cyclomatic complexity.
- Add TestUpdateStorageConfigValidation covering successful updates and failed checks.
2026-06-13 15:57:41 +08:00
ryan 1d37242a8b refactor(storage): update Backend.Put to return PutResult and encapsulate bucket mapping
- Update Backend.Put method signature in storage.go to return (PutResult, error).
- Adjust all backend implementations (local, oss, s3, webdav) to return a PutResult enclosing Key and Bucket.
- Refactor storeUploadFile in upload routers.go to extract key/bucket from PutResult, eliminating manual config bucket lookups.
- Remove the unused cfgBucket helper from storage_ops.go.
- Adjust storage_migration_task.go and tests to accommodate the updated method signature.
2026-06-13 15:48:41 +08:00
ryan ce9423f877 feat(upload): implement Redis lock renewal watchdog for storage migration
- Add a watchdog goroutine that periodically extends the Redis lock TTL every 10 minutes to prevent premature lease expiration for long-running migrations.
- Use context.Background with timeout contexts for lock renewal and final deletion to prevent parent context cancellation from aborting lock cleanup.
- Add nolint directives for gosec and contextcheck and define constants for durations to satisfy strict code quality gates.
2026-06-13 15:31:39 +08:00
ryan 66e0a69666 feat(frontend): support manual storage migration triggering
- Modify storage-config-tab.tsx to separate storage configuration saving from task dispatching.
- Save operations now always preserve the currently active storage driver to prevent premature storage engine switching.
- Add a manual '开始迁移' button to trigger storage migration explicitly.
- Display a warning notice to the operator when the selected storage type differs from the active one.
2026-06-13 15:31:39 +08:00
ryan 26a1e71a27 feat(storage): implement Redis distributed lock and cache invalidation broadcasting
- Add Redis distributed lock in Execute of MigrationHandler to prevent concurrent storage migrations.
- Define storage:config_invalidation Redis pub/sub channel to broadcast cache invalidation events.
- Implement background pub/sub listener on all nodes to evict config memory cache concurrently.
- Add integration tests for distributed locking and invalidation propagation using miniredis.
2026-06-13 15:31:39 +08:00
ryan acd430836f feat(upload): implement parallel storage migration and integrity check
- Parallelize storage migration using `errgroup` with a concurrency limit of 10.
- Perform post-copy SHA-256 data integrity validation to prevent silent data corruption.
- Add test case verifying migration with both incorrect and correct hashes.
2026-06-13 15:31:39 +08:00
ryan bc18800b58 perf(storage): add config caching and shared HTTP connection pool
- Implement thread-safe local config caching with a 5s TTL check.
- Reuse backend client singletons in storage.Active and storage.ForDriver.
- Reset in-memory config cache on configuration saves and updates.
- Create internal/httppool package to manage shared HTTP transports.
- Configure WebDAV client and CDN retrieval to reuse the shared pool.
- Add unit tests for httppool and storage caching behaviors.
2026-06-13 15:31:39 +08:00
ryan 9a18bea324 feat(storage): add dynamic storage config and migration
Move storage backend configuration from startup YAML to system_config-backed runtime configuration. Add local, S3-compatible, R2, MinIO, OSS, and WebDAV backend support.

Add a storage migration async task using the existing task dispatch framework. Migration target config is carried in task payload, and maintenance mode is derived from task execution state.

Split upload file management and storage operations, add the admin storage configuration tab, and update migrations and Swagger docs.
2026-06-13 15:31:38 +08:00
ryan 4bf8a4806e refactor(frontend): split files coordinator into separate tab components 2026-06-13 12:53:15 +08:00
ryan 9453af2aa0 refactor(frontend): style files stats tab with native shadcn
refactor(frontend): style files stats tab with native shadcn charts
2026-06-13 12:49:29 +08:00
ryan b262880189 refactor(auth): improve session security, CAPTCHA validation and code hygiene
- Integrate CapWidget with dual-scope capability on the frontend and protect registration/send-email-code endpoints on the backend.
- Set session cookie SameSite mode to Lax.
- Propagate request context through auth source database operations and optimize username uniqueness validation.
- Standardize local error naming to camelCase and resolve references.
- Fix linter rules, missing SheetContent closing tag, and unit tests.
2026-06-13 12:33:01 +08:00
ryan 04280a7b11 feat(upload): refactor file management with statistics and multi-tab layout 2026-06-13 12:32:42 +08:00
ryan 40e83a832f add(skill): code review 2026-06-13 11:33:47 +08:00
ryan e5c661f957 add(skill): code review 2026-06-13 11:28:17 +08:00
ryan 49bd850c8b fix(frontend): repair login session flow
Resolve login-page 401 hangs and redirect races by relying on the shared user state. Keep protected-route redirects intact, clean pending requests without unhandled rejections, and allow the dynamic icon route through the page proxy.
2026-06-13 11:27:30 +08:00
ryan 9a6bb04bf5 fix(frontend): distinguish initial authentication state on login page mount
- Add wasUserPresentRef to detect if the user was already authenticated on initial page load.
- Guard the useEffect redirect block so that it only redirects automatically if the user was already authenticated when mounting.
- Prevent duplicate concurrent router.replace calls from canceling each other when logging in via the form.
2026-06-13 11:09:55 +08:00
ryan 0df4712831 fix(frontend): resolve login redirect loop and clean up info tab
- Prevent infinite session probe requests on the login page by guarding the check with the authenticated user state and using the Latest Ref pattern.
- Decouple useEffect from resolveRedirectTarget by using resolveRedirectTargetRef to avoid searchParams dependency loops.
- Remove the unused '服务连接' Card from the settings info tab and clean up unused imports, queries, and properties.
2026-06-13 11:07:27 +08:00
ryan 30aa89f686 chore(release): v1.0.2
### 🛠 修复
- 修复了修改密码时不会吊销现有会话和访问 Token 的问题,确保密码更改后,所有其它客户端会话和 Access Token 立即失效,防范被盗凭据的持续利用。
- 修复了 WebSocket 连接未校验 Origin 的问题,引入严格的 Origin 允许列表校验,防范跨源 WebSocket 劫持攻击 (CSWSH)。
- 修复了未配置服务地址时 CORS 中间件原样反射 Origin 的问题,严格限制允许跨域访问的源为精确配置的 Origin 列表,增强跨域请求安全性。
- 修复了已认证用户可以通过上传记录 ID 直接越权读取其他用户私有文件的问题,引入了基于文件所有权及权限模式的严格访问控制。
- 修复了 OIDC 策略强制执行不严以及未登录用户能够触发自动绑定导致账户接管的问题,增强了 OAuth 绑定过程中的策略校验。
- 修复了 OAuth 流程中 State 校验不严的问题,在 Session 中强制绑定 State 并在回调时进行一致性验证,防止 CSRF 和账号接管攻击。
- 修复了登录或认证重定向时未对 URL 目标域进行限制的问题,实现了重定向目标 URL 的安全净化与源校验,防范开放重定向与反射型 XSS 漏洞。
2026-06-13 10:31:54 +08:00
ryan 26e12594a2 fix(user): revoke all sessions and access tokens on password change
- Store user password hash in session during login

- Validate password hash compatibility on requests to prevent session reuse

- Revoke all user access tokens and clear session on ChangePassword
2026-06-13 10:27:28 +08:00
ryan eb999eba09 fix(logs): restrict websocket origin to prevent cswsh (LOG-2)
- Restrict WebSocket upgrade to same-origin or configured server_address allowed origins.
- Add comprehensive test suite in utils_test.go to verify origin matching rules.
2026-06-13 10:25:25 +08:00
ryan f6f8c25930 fix(router): restrict CORS origin reflection to allowed hosts (AUTH-ROUTE-5)
- Extract isOriginAllowed helper to match Origin against server_address configurations
- Ensure arbitrary origins are not reflected and credentials are not allowed when server_address is unconfigured or mismatched
- Trim trailing slashes from allowed origins configuration for robust matching
- Add TestCORSMiddleware to cover all CORS matching and rejection scenarios
2026-06-13 10:23:20 +08:00
ryan 7b379863b4 fix(upload): restrict cross-user private file access (UPLOAD-1)
- Add access_mode column to w_uploads table (0 = private, 1 = public) and initialize data in a single migration script
- Enforce strict ownership check for private files during download
- Allow public files to follow whitelisted public-access rules
- Default access_mode to public for avatars and private for generic uploads
- Update frontend service to support optional accessMode parameter
2026-06-13 10:13:41 +08:00
ryan 5412c385dc fix(oauth): remove pending oauth auto-binding and enforce oidc policies
- Complete removal of completePendingOAuthBinding logic to prevent unintended account takeovers (AUTH-ROUTE-1).
- Add strict OIDC policy checks (global switch and source active states) across authorization and callback paths (AUTH-POLICY-1).
- Fix OIDC test cases to properly clear the Redis-backed system config cache using composite keys.
2026-06-13 10:06:49 +08:00
ryan 895788974c fix(oauth): secure OAuth state session binding to prevent account takeover
Bind OAuth state payloads to the initiating session token and user ID.
Verifies session token hash continuity during callback, and validates that
the user ID completing the binding flow matches the user ID that initiated it.
2026-06-13 09:55:07 +08:00
ryan f48426dbf8 fix(frontend): sanitize redirect targets to prevent XSS/open redirect
Sanitize and validate redirect targets from callbackUrl parameter and sessionStorage in login, registration, and OAuth callback flows.
Introduced safeRedirectTarget helper which rejects protocol-relative URLs, non-relative schemes, control characters, backslashes, and encoding bypasses.
2026-06-13 09:51:30 +08:00
ryan 50d21b431b feat(valkey): migrate redis to valkey and fix updater custom prefix selection
Replace redis:7-alpine with valkey:8.0-alpine and configure MaintNotificationsConfig ModeDisabled to suppress handshake warnings on Valkey. Resolve updater bug by dynamically matching custom repository asset name prefixes like PixezSync.
2026-06-12 15:44:49 +08:00
ryan 3228574a8c 兼容包名 2026-06-12 15:32:47 +08:00
ryan 752101612e 打印日志 2026-06-12 15:17:45 +08:00
ryan c9642fb5e2 修复退出异常问题 2026-06-12 14:32:27 +08:00
ryan 179a23f1a0 MAKE 调整 2026-06-12 14:29:20 +08:00
ryan 55831efd44 界面优化 2026-06-12 14:17:53 +08:00
ryan c916f566d9 系统控制台完成更新 2026-06-12 14:12:03 +08:00
ryan 407c1edf74 更新指导 2026-06-12 13:42:17 +08:00
ryan 654d7fd646 修复导出编译问题 2026-06-12 13:10:39 +08:00
ryan 16604e5f86 设置增加站名配置 2026-06-12 11:57:30 +08:00
ryan 6a1f89936e 登录界面优化 2026-06-12 11:35:48 +08:00
ryan d9df78d2c7 邮箱注册要求 2026-06-12 10:55:24 +08:00
ryan 63e3ade7f4 登录注册分开 2026-06-12 10:27:03 +08:00
ryan 1fe5118029 登录状态记录 2026-06-12 10:15:29 +08:00
ryan 62fcd245f2 fix: 映射后台任务查询接口的 task_type 参数为 Asynq 任务名以解决类型过滤无数据问题 2026-06-11 23:28:50 +08:00
ryan 01b80ac376 优化CI 2026-06-11 20:32:42 +08:00
ryan 50533e1837 build: 优化 Docker/Workflow 构建,使用 Next.js 环境变量注入版本和构建时间,并移除对 package.json 的硬编码替换 2026-06-11 20:18:49 +08:00
ryan 7c125ccef2 fix: 修复任务执行记录列表按任务类型和状态过滤失效的 Bug 并更新 Swagger 2026-06-11 20:14:02 +08:00
ryan 46760d4286 缓存处理修补 2026-06-11 19:07:22 +08:00
ryan 5915b31519 图片预热任务 2026-06-11 17:49:08 +08:00
ryan 7da4b72d24 任务日志优化 2026-06-11 16:56:53 +08:00
ryan f14875a8de 图片缓存不过期 2026-06-11 15:57:31 +08:00
ryan 1eef5336e3 修复文件管理页面分页问题 2026-06-11 15:52:35 +08:00
ryan 533f783268 质量优化 2026-06-11 15:36:46 +08:00
ryan 6b93320404 接口参数调整 2026-06-11 15:32:44 +08:00
ryan ad97ca7df1 图片压缩调用缓存 2026-06-11 15:23:15 +08:00
ryan 0221d4de14 缓存框架 2026-06-11 15:12:09 +08:00
ryan e8e0326879 图片压缩 2026-06-11 14:58:17 +08:00
ryan 2a3a17b6fe 优化 2026-06-11 14:28:07 +08:00
ryan eb9f6023f0 文件管理权限控制 2026-06-11 14:17:36 +08:00
ryan 5e0de01c4b 文件管理权限控制 2026-06-11 14:09:32 +08:00
ryan 312bc7d4d5 框架表改名 w_{name} 2026-06-11 09:27:15 +08:00
ryan 786fe71778 优化定时任务 2026-06-11 09:20:28 +08:00
ryan b5a1707898 优化 2026-06-11 09:10:06 +08:00
ryan 616242fad8 去除 access_token 访问写库逻辑 2026-06-11 09:09:17 +08:00
ryan 6cbc368dc1 修复任务日志显示问题 2026-06-11 09:03:40 +08:00
ryan c1a1904a67 修复重复注册问题 2026-06-11 09:03:24 +08:00
ryan 3023d47eec 解耦任务框架与业务任务 2026-06-11 08:52:44 +08:00
ryan 5dedff1324 修复任务参数类型转换 2026-06-11 08:40:08 +08:00
ryan 8964054fd8 修复任务参数类型转换 2026-06-11 08:26:30 +08:00
ryan 5241055030 根据名称获取认证源(名称比较不区分大小写) 2026-06-11 08:06:46 +08:00
ryan 983228227e AccessToken 默认非管理员权限 2026-06-10 22:50:44 +08:00
ryan bff9e8811d 界面优化 2026-06-10 20:25:21 +08:00
ryan 3ebead0d15 界面优化 2026-06-10 20:22:10 +08:00
ryan e6a6182a0c 优化任务管理 2026-06-10 20:15:12 +08:00
ryan db163034c0 优化任务管理 2026-06-10 19:36:35 +08:00
ryan 69edb1252b 升级 nextjs 2026-06-10 15:18:01 +08:00
ryan 15b3c83625 更新示例 2026-06-10 15:11:42 +08:00
ryan 57944398e6 质量优化 2026-06-10 13:50:58 +08:00
ryan 3ed4dec4a3 db manager 2026-06-10 13:42:42 +08:00
ryan d05acd804f 精简 2026-06-10 11:33:33 +08:00
ryan df1961c42d 压缩 2026-06-09 22:00:01 +08:00
ryan 1b5d37a8e8 修复CI 2026-06-09 21:54:34 +08:00
ryan ebf644adb7 修复单位问题 2026-06-09 21:54:03 +08:00
ryan 7aeaa7b0d2 调整CI 2026-06-09 21:50:13 +08:00
ryan 32e4a32462 make build 2026-06-09 21:45:57 +08:00
ryan 05ef5ed7bd 数据导出 2026-06-09 21:18:58 +08:00
ryan 0ead6348f6 make bin 2026-06-09 21:01:13 +08:00
ryan 949c021d45 质量优化 2026-06-09 20:39:22 +08:00
ryan 1c76c7158a 质量优化 2026-06-09 20:38:14 +08:00
ryan 673061265c 质量优化 2026-06-09 20:28:05 +08:00
ryan 50f39a6983 修复创建账号逻辑 2026-06-09 20:07:25 +08:00
ryan 055005688a 升级数据库后更新缓存 2026-06-09 16:47:49 +08:00
ryan b1c161b255 skill 2026-06-09 16:47:49 +08:00
ryan 6b3c0217f0 goose 迁移 2026-06-09 16:47:49 +08:00
ryan 40e8a7cfa3 重构获取公共参数 2026-06-09 16:47:49 +08:00
ryan bff09241d3 skill 2026-06-09 16:47:49 +08:00
ryan 73b220de3c 用户详情 2026-06-09 16:47:49 +08:00
ryan 92664273ed async task skill 2026-06-09 16:47:49 +08:00
ryan d705f2ff64 skill 2026-06-09 16:47:49 +08:00
ryan 8801b3976c go dev skill
go dev skill

go dev skill

shadcn skill
2026-06-09 16:47:49 +08:00
ryan c6eea8111d fix(revive): rename unused parameters to _ for lint compliance 2026-06-09 15:03:13 +08:00
ryan e06f76436e refactor: extract magic numbers to named constants for mnd lint compliance 2026-06-09 13:44:29 +08:00
ryan b05d26c9c6 docs: add package and exported symbol comments for revive lint compliance 2026-06-09 13:42:06 +08:00
ryan 4ac9857fe8 代码质量优化 2026-06-09 12:28:12 +08:00
ryan f428839602 前端优化 2026-06-09 11:35:31 +08:00
ryan 31f0fb4ceb 规约 2026-06-09 11:26:57 +08:00
ryan e100441e8a 规约 2026-06-09 11:16:02 +08:00
ryan d7521dc49a 界面优化 2026-06-09 10:40:43 +08:00
ryan d4d214d074 界面优化 2026-06-09 10:39:59 +08:00
ryan 61bc569bd8 clickhouse 日志采集 2026-06-09 10:39:59 +08:00
ryan b41457553d 优化 2026-06-09 09:19:56 +08:00
ryan 7a62a78fad smtp 发件前验证配置 2026-06-09 09:01:14 +08:00
ryan 2ae55da52e seo 检索开关 2026-06-09 08:56:25 +08:00
ryan b0023787c5 fix 2026-06-09 08:51:53 +08:00
ryan 7579d3865f eslint 2026-06-09 08:19:38 +08:00
ryan d14d222ede env load 2026-06-08 23:14:57 +08:00
ryan ddda7c44ef docker-compose.yml 2026-06-08 23:14:57 +08:00
ryan 957116d983 修改ci 2026-06-08 21:13:18 +08:00
ryan 356b3df81e 融合模式启动 2026-06-08 21:12:55 +08:00
ryan db0d503bb4 ci 2026-06-08 20:59:00 +08:00
ryan 2cce8a3175 ci 2026-06-08 20:55:18 +08:00
ryan a998f02f2b ci 2026-06-08 20:47:48 +08:00
ryan 31253eb23d remove idea 2026-06-08 20:41:35 +08:00
ryan cd3d0c9f82 重构 2026-06-08 20:38:17 +08:00
ryan 02f458856d 用户优化 2026-06-08 20:38:17 +08:00
ryan d99bd5231a 安全加固 2026-06-08 20:38:17 +08:00
ryan 0f65202659 个人信息页面增强 2026-06-08 20:38:17 +08:00
ryan b96624a251 模板系统 2026-06-08 20:38:17 +08:00
ryan 3aff95d256 优化 2026-06-08 20:38:17 +08:00
ryan 85f91b1ed7 更新 license .github 2026-06-08 20:38:17 +08:00
ryan e3ef6c9d27 修改路径 2026-06-08 20:38:17 +08:00
ryan c1fbf73f7e 更新项目 2026-06-08 20:38:01 +08:00
ryan c6cc8c3305 logs 2026-06-08 20:38:01 +08:00
ryan 5c7c995a95 框架化 2026-06-08 20:38:01 +08:00
ryan b03d2d7ea6 改名 2026-06-08 20:38:01 +08:00
ryan f136c9abdb 邮箱 2026-06-08 20:38:01 +08:00
ryan 72c74803be 优化 2026-06-08 20:38:01 +08:00
ryan db68a130ce 嵌入与邮箱 2026-06-08 20:37:57 +08:00
ryan 62bd5d09d4 移除 risk 2026-06-08 20:37:57 +08:00
ryan 47c9bc53fa skill 2026-06-08 20:37:52 +08:00
ryan 474e3da3b7 代码优化
界面优化
2026-06-08 20:37:52 +08:00
ryan 72d72810b2 cap 与系统信息 2026-06-08 20:37:47 +08:00
ryan 9d0f9f0576 user 2026-06-08 20:37:40 +08:00
ryan 4b72419a96 去除默认OIDC 2026-06-08 20:37:40 +08:00
ryan 53a4c92176 设置界面优化 2026-06-08 20:37:40 +08:00
ryan 589ae08318 async task framework 2026-06-08 20:37:40 +08:00
ryan 70a13dc107 upload+accessKey 2026-06-08 20:37:40 +08:00
ryan 360a26f109 oauth 2026-06-08 20:34:28 +08:00
ryan 48d414e197 裁剪
swagger

移除 merchant

swagger

改造首页内容为通用后台管理系统定位

- 修改首页标题从 'LINUX DO Credit' 改为 'Modern Platform'
- 更新副标题为 '为二次开发而生'
- 更新首页描述为通用平台的特点
- 更新首页特性标签为 '开箱即用、高度可扩展、工业级基建'
- 修改展示卡片为技术栈和二次开发相关
- 更新开发者示例代码为通用的注册和 API Key 获取示例
- 更新页脚品牌名为 'Modern Platform'
- 调整页脚导航链接为通用平台相关内容

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

去除遗留

裁剪
移除 /api/v1/user/pay-key 相关代码

- 删除后端 UpdatePayKey 处理器函数和 UpdatePayKeyRequest 结构体
- 删除 User 模型中的 PayKey 字段
- 删除 User.VerifyPayKey 方法
- 删除 EncryptPayKeyFailed 错误常量
- 删除 /api/v1/user/pay-key PUT 路由
- 删除 OAuth 返回中的 IsPayKey 字段
- 删除前端 UserService.updatePayKey 方法
- 删除前端所有支付密钥 UI 和逻辑
- 更新相关的导出和注释

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

去除遗留

api 修正

系统配置

前端裁剪

后端裁剪

init
2026-06-08 20:34:28 +08:00
ryan 8a782525de 压缩历史至 95081aff 2026-06-08 20:34:27 +08:00
1562 changed files with 19485 additions and 243637 deletions
@@ -125,7 +125,7 @@ func startThingCacheInvalidationListener() {
| Auth Source | `repository/auth_source_cache.go` | Otter | Redis JSON | `oauth:auth_source_invalidation` ✅ |
| OAuth 用户/Token | `apps/oauth/cache.go` | 自研 map | Redis JSON | ❌ 无 pub/sub(历史债) |
| 推送渠道 | `repository/push_channel.go` | 无 | Redis JSON | ❌ 仅 Redis Del |
| Storage 驱动 | `internal/infra/objectstore/storage.go` | RWMutex 快照 | — | `storage:config_invalidation` ✅ |
| Storage 驱动 | `internal/storage/storage.go` | RWMutex 快照 | — | `storage:config_invalidation` ✅ |
## 新增缓存工作流
@@ -210,7 +210,7 @@ make code-check
## 相关文件
- L1 引擎:`pkg/cache/ram/cache.go`
- DB/Redis 助手:`internal/infra/persistence/redis.go`(`GetJSON`, `SetJSON`, `HGetJSON`, `PrefixedKey`)
- DB/Redis 助手:`internal/db/redis.go`(`GetJSON`, `SetJSON`, `HGetJSON`, `PrefixedKey`)
- 金标准:`internal/repository/system_config_cache.go`
- 上传元数据:`internal/apps/upload/cache/meta_cache.go`
- Auth Source:`internal/repository/auth_source_cache.go`
@@ -1,25 +1,25 @@
---
name: "clickhouse-batchwriter"
description: "Wavelet 项目专用:当新增或修改 ClickHouse 批量写入、接入 internal/infra/persistence/batchwriter、将业务域异步 flush 到分析表、迁移 risk_control/节点访问日志/可观测时序写入、或评估 async_insert 与背压策略时必须使用。本技能指导分层职责、各域独立 Writer 实例、repository 批量 API 与禁止写法。"
description: "Wavelet 项目专用:当新增或修改 ClickHouse 批量写入、接入 internal/db/batchwriter、将业务域异步 flush 到分析表、迁移 risk_control/节点访问日志/可观测时序写入、或评估 async_insert 与背压策略时必须使用。本技能指导分层职责、各域独立 Writer 实例、repository 批量 API 与禁止写法。"
---
# ClickHouse 批量写入开发
开始前阅读根目录 `AGENTS.md`。ClickHouse 是辅助 OLAP 存储,**厌恶高频单条写入**(过多小 part);写入路径必须优先批量或异步聚合。
DDL 与表结构变更见 `database-migration` 技能。日志/分析用途表的判定、三库回落与切换见 `logstore` 技能。本技能只覆盖**运行时写入架构**。
DDL 与表结构变更见 `database-migration` 技能;本技能只覆盖**运行时写入架构**。
## 分层职责
| 层级 | 路径 | 职责 |
| :--- | :--- | :--- |
| 连接 | `internal/infra/persistence/clickhouse.go` | `ChConn`(原生批量写)、`ChDB`(GORM 查询);禁止在业务包直接 `clickhouse.Open` |
| 批量框架 | `internal/infra/persistence/batchwriter/` | 泛型队列 + 按条数/时间 flush + 非阻塞入队 + 优雅停机;**各业务域独立实例** |
| 连接 | `internal/db/clickhouse.go` | `ChConn`(原生批量写)、`ChDB`(GORM 查询);禁止在业务包直接 `clickhouse.Open` |
| 批量框架 | `internal/db/batchwriter/` | 泛型队列 + 按条数/时间 flush + 非阻塞入队 + 优雅停机;**各业务域独立实例** |
| Model | `internal/model/analytics/` | 列定义、`TableName()`、`BatchInsertSQL()`(及可选 `InsertColumns()`) |
| Repository | `internal/repository/analytics/` | `BatchInsert*` / `BatchInsertNodeAccessLogs` 等;`PrepareBatch` + 多行 `Append` + 一次 `Send` |
| 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` 逻辑 |
| Apps | `internal/apps/<domain>/` | 采集、入队、背压;`FlushFunc` 只调 repository,不写 SQL、不 `PrepareBatch` |
| 装配 | `internal/bootstrap/bootstrap.go` | 进程启动时调用 `Writer.Start`;初始化时需调用 `lifecycle.OnShutdown` 挂载停机钩子 |
| 生命周期 | `internal/lifecycle/lifecycle.go` | 统一协调全局并发优雅停机,业务包无需在 `bootstrap.go` 中硬编码 `Stop` 逻辑 |
**禁止**在 Handler / middleware 内直接 `db.ChConn.PrepareBatch`;**禁止**在 repository 内启动 goroutine 或维护全局 channel(队列生命周期由 apps + bootstrap 或专用 writer 包负责)。
@@ -37,7 +37,6 @@ writer.Stop(stopCtx) // close 队列 + drain + 最终 flush
- `QueueSize`: 10_000
- `MaxBatchSize`: 1_000
- `MinBatchSize`: 50(未达阈值则跳过按时间 flush,除非设了 `MaxFlushWait`)
- `FlushInterval`: 1s
各域可独立覆盖;可观测低频指标可用更小 `MaxBatchSize`(如 100)与更长 `FlushInterval`(如 2–5s),但**不要**退化为逐条 `Send`。
@@ -50,8 +49,7 @@ writer.Stop(stopCtx) // close 队列 + drain + 最终 flush
### FlushFunc 规范
- 签名:`func(ctx context.Context, items []T) error`
- **日志/分析用途表**:`logstore.Active(ctx)` 再调对应 `BatchInsert*`。禁止 apps 直连 `analyticsrepo` 或 `db.ChConn`。
- 仅 CH、无需主库回落的分析表:才直接调 `repository/analytics` 的 `BatchInsert*`。
- 内部调用 `internal/repository/analytics` 的 `BatchInsert*`(传入 `[]analyticsmodel.X`)
- 在 flush 边界记录一次错误日志,不要把 DB 驱动错误直接暴露给 HTTP 客户端
- `Start` 使用 `context.WithoutCancel(parent)`,避免请求 ctx 取消中断后台 flush
@@ -59,29 +57,28 @@ writer.Stop(stopCtx) // close 队列 + drain + 最终 flush
每个业务域拥有自己的 `Writer`、配置与 `FlushFunc`:
| 域 | 表 | 写入路径 |
| :--- | :--- | :--- |
| 管理端审计 | `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` |
| 域 | 表 | 现状 | 目标形态 |
| :--- | :--- | :--- | :--- |
| 管理端审计 | `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 去重 | 已接入 |
**不要**把 audit、access log、observability 并入同一 channel。
## 新增 ClickHouse 写入工作流
1. **Model**:在 `internal/model/analytics/` 定义 struct 与 `BatchInsertSQL()`(列顺序与 goose DDL 一致)。
2. **Goose DDL**:在 `internal/infra/persistence/migrator/goose/clickhouse/` 新增迁移(见 `database-migration`)。
2. **Goose DDL**:在 `internal/db/migrator/goose/clickhouse/` 新增迁移(见 `database-migration`)。
3. **Repository**:实现 `BatchInsertX(ctx, []analyticsmodel.X) error`:
- `len(items)==0` 直接返回
- `db.ChConn == nil` 返回明确错误
- 一次 `PrepareBatch` → 循环 `Append` → 一次 `Send`
4. **Writer 胶水**(`internal/apps/<domain>/`):
- `len(items)==0` 直接返回
- `db.ChConn == nil` 返回明确错误
- 一次 `PrepareBatch` → 循环 `Append` → 一次 `Send`
4. **Writer 胶水**(`internal/apps/<domain>/` 或 `internal/repository/analytics/<domain>_writer.go`):
- `New` + `Start`,并在初始化逻辑内通过 `lifecycle.OnShutdown("your_writer_name", Stop)` 注册停机回调
- 日志表的 `FlushFunc` 调 `logstore.Active`(见 `logstore` skill)
- 业务路径 `TryEnqueue`;HTTP 背压用 `IsFull()`
5. **测试**:
- repository:mock `ChConn` 验证 `BatchInsertSQL` 与 append 列数
- batchwriter:`go test ./internal/infra/persistence/batchwriter`
- repository:mock `ChConn` 验证 `BatchInsertSQL` 与 append 列数
- batchwriter:`go test ./internal/db/batchwriter`
6. 运行 `make code-check`;有 API 变更时 `make swagger`。
## 背压与丢弃策略
@@ -113,7 +110,7 @@ var globalChan chan any
## async_insert(补充,非主方案)
可在 `internal/infra/persistence/clickhouse.go` 的 `Settings` 增加服务端异步写入作为第二层防护:
可在 `internal/db/clickhouse.go` 的 `Settings` 增加服务端异步写入作为第二层防护:
```go
"async_insert": 1,
@@ -125,10 +122,15 @@ var globalChan chan any
## Bootstrap 装配示例
```go
// internal/platform/bootstrap/bootstrap.go(示意)
// internal/bootstrap/bootstrap.go(示意)
var userAccessLogWriter *batchwriter.Writer[*analytics.UserAccessLog]
func RegisterAPI(ctx context.Context) {
// 日志 writer 不依赖 clickhouse.enabled:flush 时由 logstore 选库
risk_control.InitLogWriter(ctx)
// ...
if config.Config.ClickHouse.Enabled {
initUserAccessLogWriter(ctx) // Start writer
risk_control.BindWriter(userAccessLogWriter) // 或逐步替换 InitLogWriter
}
}
```
@@ -139,7 +141,7 @@ func RegisterAPI(ctx context.Context) {
## 验证清单
```bash
go test ./internal/infra/persistence/batchwriter
go test ./internal/db/batchwriter
go test ./internal/repository/analytics
make code-check
```
@@ -147,17 +149,15 @@ make code-check
- flush 按 `MaxBatchSize` 与 `FlushInterval` 触发
- `Stop` 能 drain 队列内剩余项
- repository 层无 goroutine、无 channel
- 日志表:`clickhouse.enabled: false` 时 writer 仍 `Start`,flush 走主库 logstore
- 仅 CH 的分析表:未启用 CH 时不要 `Start`、不要入队
- `clickhouse.enabled: false` 时不 `Start` writer、不入队
## 相关文件速查
- 框架:`internal/infra/persistence/batchwriter/{config,writer,errs}.go`
- 连接:`internal/infra/persistence/clickhouse.go`
- 框架:`internal/db/batchwriter/{config,writer,errs}.go`
- 连接:`internal/db/clickhouse.go`
- 审计写入:`internal/apps/risk_control/logics.go`
- OpenFlare 写入胶水:`internal/apps/openflare/chwriter/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`
- 节点访问日志 repository:`internal/repository/analytics/node_access_log_writer.go`
- 可观测 repository:`internal/repository/analytics/node_observability_writer.go`
- 生命周期管理器:`internal/lifecycle/lifecycle.go`
- Bootstrap:`internal/bootstrap/bootstrap.go`
@@ -1,17 +1,17 @@
---
name: "database-migration"
description: "Wavelet 项目专用:当新增或修改数据库表结构、索引、初始化数据、系统配置 seed、模板 seed、默认管理员、goose SQL 迁移、internal/infra/persistence/migrator、ClickHouse 分析库 DDL 或数据库升级流程时必须使用。本技能指导在 internal/infra/persistence/migrator/goose 下编写 PostgreSQL/SQLite 双方言 SQL 迁移,以及在 goose/clickhouse 下编写 ClickHouse 单方言分析表迁移,并完成验证。"
description: "Wavelet 项目专用:当新增或修改数据库表结构、索引、初始化数据、系统配置 seed、模板 seed、默认管理员、goose SQL 迁移、internal/db/migrator、ClickHouse 分析库 DDL 或数据库升级流程时必须使用。本技能指导在 internal/db/migrator/goose 下编写 PostgreSQL/SQLite 双方言 SQL 迁移,以及在 goose/clickhouse 下编写 ClickHouse 单方言分析表迁移,并完成验证。"
---
# Wavelet 数据库升级操作指南
Wavelet 使用 `github.com/pressly/goose/v3` 执行 SQL 迁移。迁移入口是 `internal/infra/persistence/migrator.Migrate()`,SQL 文件嵌入在二进制中。
Wavelet 使用 `github.com/pressly/goose/v3` 执行 SQL 迁移。迁移入口是 `internal/db/migrator.Migrate()`,SQL 文件嵌入在二进制中。
## 基本规则
- SQL 迁移文件放在:
- `internal/infra/persistence/migrator/goose/postgres/`
- `internal/infra/persistence/migrator/goose/sqlite/`
- `internal/db/migrator/goose/postgres/`
- `internal/db/migrator/goose/sqlite/`
- PostgreSQL 和 SQLite 必须使用同一个版本号、同一个语义文件名。
- 迁移文件使用 goose SQL 标记:
@@ -51,7 +51,7 @@ Wavelet 使用 `github.com/pressly/goose/v3` 执行 SQL 迁移。迁移入口是
7. 至少运行:
```bash
go test ./internal/infra/persistence/migrator
go test ./internal/db/migrator
go test ./internal/model ./internal/apps/config ./internal/apps/admin/system_config
make code-check
```
@@ -81,7 +81,7 @@ make code-check
ClickHouse 是**辅助 OLAP 存储**,与 PostgreSQL/SQLite 主库**完全独立**的迁移与访问管线:
- 主库(PG/SQLite):业务事务数据、`goose_db_version`、双方言 SQL。
- 分析库(ClickHouse):分析型数据、`goose_clickhouse_version`、单方言 SQL。日志用途表还必须在主库建回落并走 `logstore`(见该 skill);CH 目录仍只放 CH DDL。
- 分析库(ClickHouse):访问日志、统计聚合等分析型数据、`goose_clickhouse_version`、单方言 SQL。
**不要**把 ClickHouse 表结构混入 PG/SQLite 迁移目录,也**不要**在 `support-files/`、`internal/apps/` 或 `internal/repository/` 中手写 DDL。
@@ -89,10 +89,10 @@ ClickHouse 是**辅助 OLAP 存储**,与 PostgreSQL/SQLite 主库**完全独
| 路径 | 职责 |
| :--- | :--- |
| `internal/infra/persistence/migrator/goose/clickhouse/` | **唯一** ClickHouse DDL 来源(goose SQL,嵌入二进制) |
| `internal/db/migrator/goose/clickhouse/` | **唯一** ClickHouse DDL 来源(goose SQL,嵌入二进制) |
| `internal/model/analytics/` | 分析表 Go model,列名须与 goose DDL 一致 |
| `internal/repository/analytics/` | 所有 ClickHouse 读写(批量写入、查询、聚合) |
| `internal/infra/persistence/clickhouse.go` | 连接初始化(`ChConn` 原生批量、`ChDB` GORM 查询) |
| `internal/db/clickhouse.go` | 连接初始化(`ChConn` 原生批量、`chDB` GORM 查询) |
### 迁移入口与版本表
@@ -116,16 +116,16 @@ ClickHouse 是**辅助 OLAP 存储**,与 PostgreSQL/SQLite 主库**完全独
按以下顺序落地,避免列名或类型漂移:
1. **Model**:在 `internal/model/analytics/` 定义 struct,`gorm:"column:..."` 与 DDL 列名一一对应;实现 `TableName()`,批量写入表可提供 `InsertColumns()` / `BatchInsertSQL()`。
2. **Goose SQL**:在 `internal/infra/persistence/migrator/goose/clickhouse/` 新增递增版本文件(格式同主库,如 `YYYYMMDDNNNN_create_xxx.sql`),编写 `-- +goose Up` / `-- +goose Down`。
2. **Goose SQL**:在 `internal/db/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` 技能)。**日志/分析用途表**还要同时建 PG/SQLite 回落并接入 `logstore`(见 `logstore` 技能),`FlushFunc` 调 `logstore.Active` 而不是 `analyticsrepo`;普通业务分析表仍只读 repository。
4. **Apps**:在 `internal/apps/<domain>/` 编排采集与入队;高频写入通过 `internal/db/batchwriter` 各域独立实例异步 flush(详见 `clickhouse-batchwriter` 技能),`FlushFunc` 只调 repository `BatchInsert*`;管理端统计 API 只读 repository,不触达 DDL。
### ClickHouse 验证
至少运行:
```bash
go test ./internal/infra/persistence/migrator
go test ./internal/db/migrator
go test ./internal/repository/analytics
make code-check
```
@@ -15,7 +15,7 @@ Wavelet 将「对象存储」与「上传业务」分为两层,**禁止混用
| 层级 | 包路径 | 职责 | 业务是否直接调用 |
| :--- | :--- | :--- | :--- |
| **对象存储引擎** | `internal/infra/objectstore` | `Backend` 接口:`Put` / `Get` / `Delete` / `Test`;按配置切换 Local / S3 / R2 / OSS / WebDAV | **禁止**(仅 upload 域内部使用) |
| **对象存储引擎** | `internal/storage` | `Backend` 接口:`Put` / `Get` / `Delete` / `Test`;按配置切换 Local / S3 / R2 / OSS / WebDAV | **禁止**(仅 upload 域内部使用) |
| **上传域服务** | `internal/apps/upload` | `w_uploads` 记录、权限、秒传、统计、文件服务、`upload.Ingest` | **必须** |
| **上传 HTTP 入口** | `internal/apps/upload/handler` | `POST /api/v1/upload` 等 multipart 接口 | 前端 / 用户侧上传 |
| **文件访问** | `internal/apps/upload/filesrv` | `GET /f/:id` 流式响应、访问控制、图片 WebP 压缩 | 展示 / 下载 |
+183
View File
@@ -0,0 +1,183 @@
---
name: go-code-review
description: Use when reviewing Go code or checking code against community style standards. Also use proactively before submitting a Go PR or when reviewing any Go code changes, even if the user doesn't explicitly request a style review. Does not cover language-specific syntax — delegates to specialized skills.
license: Apache-2.0
compatibility: Web server example in references uses slog (Go 1.21+)
metadata:
sources: "Go Wiki CodeReviewComments, Uber Style Guide"
allowed-tools: Bash(bash:*)
---
# Go 代码审查清单
## 审查流程
> 使用 `assets/review-template.md` 格式化代码审查输出,确保结构与"必须修复 / 建议修复 / 吹毛求疵"的严重程度分组保持一致。
1. 运行 `gofmt -d .` 和 `go vet ./...` 先捕获机械性问题
2. 逐文件阅读 diff;对于每个文件,按以下类别顺序检查
3. 标记问题时需要包含具体行号引用和规则名称
4. 审查完所有文件后,重新阅读标记项以确认它们是真实的问题
5. 按严重程度分组汇总发现(必须修复、建议修复、吹毛求疵)
> **验证**:完成审查后,再次阅读 diff 以验证每个标记的问题都是真实的。删除任何无法用具体行号引用的发现。
---
## 格式化
- [ ] **gofmt**:代码已使用 `gofmt` 或 `goimports` 格式化 → [go-linting](../go-linting/SKILL.md)
---
## 文档
- [ ] **注释句子**:注释是完整的句子,以被描述的名称开头,以句号结尾 → [go-documentation](../go-documentation/SKILL.md)
- [ ] **文档注释**:所有导出名称都有文档注释;非平凡的未导出声明也应有 → [go-documentation](../go-documentation/SKILL.md)
- [ ] **包注释**:包注释出现在 package 子句附近,无空行 → [go-documentation](../go-documentation/SKILL.md)
- [ ] **命名结果参数**:仅当它们能澄清含义时使用(例如,多个相同类型返回值),而不仅仅是为了启用裸返回 → [go-documentation](../go-documentation/SKILL.md)
---
## 错误处理
- [ ] **处理错误**:不使用 `_` 丢弃错误;处理、返回或(在特殊情况下)panic → [go-error-handling](../go-error-handling/SKILL.md)
- [ ] **错误字符串**:小写开头,无标点(除非以专有名词/首字母缩略词开头) → [go-error-handling](../go-error-handling/SKILL.md)
- [ ] **带内错误**:不使用魔术值(-1、""、nil);使用带 error 或 ok bool 的多返回值 → [go-error-handling](../go-error-handling/SKILL.md)
- [ ] **错误流缩进**:先处理错误并返回;保持正常路径的缩进最小化 → [go-error-handling](../go-error-handling/SKILL.md)
---
## 命名
- [ ] **MixedCaps**:使用 `MixedCaps` 或 `mixedCaps`,不使用下划线;未导出使用 `maxLength` 而非 `MAX_LENGTH` → [go-naming](../go-naming/SKILL.md)
- [ ] **首字母缩略词**:保持一致的大小写:`URL`/`url`、`ID`/`id`、`HTTP`/`http`(例如 `ServeHTTP`、`xmlHTTPRequest`) → [go-naming](../go-naming/SKILL.md)
- [ ] **变量名**:有限作用域用短名称(`i`、`r`、`c`);更广作用域用较长名称 → [go-naming](../go-naming/SKILL.md)
- [ ] **接收器名称**:类型的一两个字母缩写(`c` 代表 `Client`);不使用 `this`、`self`、`me`;各方法之间保持一致 → [go-naming](../go-naming/SKILL.md)
- [ ] **包名**:不重复(使用 `chubby.File` 而非 `chubby.ChubbyFile`);避免 `util`、`common`、`misc` → [go-packages](../go-packages/SKILL.md)
- [ ] **避免内置名称**:不遮蔽 `error`、`string`、`len`、`cap`、`append`、`copy`、`new`、`make` → [go-declarations](../go-declarations/SKILL.md)
---
## 并发
- [ ] **Goroutine 生命周期**:明确 goroutine 何时/是否退出;如不明显则添加文档 → [go-concurrency](../go-concurrency/SKILL.md)
- [ ] **同步函数**:优先同步而非异步;让调用者在需要时添加并发 → [go-concurrency](../go-concurrency/SKILL.md)
- [ ] **Context**:作为第一个参数;不放在 struct 中;不自定义 Context 类型;即使认为不需要也应传递 → [go-context](../go-context/SKILL.md)
---
## 接口
- [ ] **接口位置**:在消费方包中定义,而非实现方;生产者返回具体类型 → [go-interfaces](../go-interfaces/SKILL.md)
- [ ] **不提前定义接口**:不在使用前定义;不在实现方"为了 mock"而定义 → [go-interfaces](../go-interfaces/SKILL.md)
- [ ] **接收器类型**:如果会修改状态、有 sync 字段或体积大,使用指针;小的不可变类型使用值;不要混用 → [go-interfaces](../go-interfaces/SKILL.md)
---
## 数据结构
- [ ] **空切片**:优先使用 `var t []string`(nil)而非 `t := []string{}`(非 nil 零长度) → [go-data-structures](../go-data-structures/SKILL.md)
- [ ] **复制**:小心复制含指针/切片字段的结构体;不按值复制 `*T` 方法的接收器 → [go-data-structures](../go-data-structures/SKILL.md)
---
## 安全性
- [ ] **加密随机数**:密钥使用 `crypto/rand`,不使用 `math/rand` → [go-defensive](../go-defensive/SKILL.md)
- [ ] **不 panic**:常规错误处理使用 error 返回;仅在真正特殊的情况下 panic → [go-defensive](../go-defensive/SKILL.md)
---
## 声明与初始化
- [ ] **分组相似的**:相关的 `var`/`const`/`type` 放在括号块中;不相关的分开 → [go-declarations](../go-declarations/SKILL.md)
- [ ] **var vs :=**:有意使用零值时用 `var`;显式赋值时用 `:=` → [go-declarations](../go-declarations/SKILL.md)
- [ ] **缩小作用域**:将声明移到使用位置附近;使用 if-init 限制变量作用域 → [go-declarations](../go-declarations/SKILL.md)
- [ ] **Struct 初始化**:始终使用字段名;省略零值字段;零值 struct 使用 `var` → [go-declarations](../go-declarations/SKILL.md)
- [ ] **使用 `any`**:新代码中优先使用 `any` 而非 `interface{}` → [go-declarations](../go-declarations/SKILL.md)
---
## 函数
- [ ] **文件排序**:类型 → 构造函数 → 导出方法 → 未导出方法 → 工具函数 → [go-functions](../go-functions/SKILL.md)
- [ ] **签名格式化**:换行时所有参数各占一行并带尾逗号 → [go-functions](../go-functions/SKILL.md)
- [ ] **裸参数**:为含义不明确的 bool/int 参数添加 `/* name */` 注释,或使用自定义类型 → [go-functions](../go-functions/SKILL.md)
- [ ] **Printf 命名**:接受格式字符串的函数以 `f` 结尾,以便 `go vet` 检查 → [go-functions](../go-functions/SKILL.md)
---
## 风格
- [ ] **行长度**:无硬性限制,但避免令人不适的长行;按语义断行,而非任意长度 → [go-style-core](../go-style-core/SKILL.md)
- [ ] **裸返回**:仅在短函数中使用;中/大函数使用显式返回 → [go-style-core](../go-style-core/SKILL.md)
- [ ] **传值**:不要仅为节省字节而使用指针;小的固定大小类型传 `string` 而非 `*string` → [go-performance](../go-performance/SKILL.md)
- [ ] **字符串拼接**:简单拼接用 `+`;格式化用 `fmt.Sprintf`;循环中用 `strings.Builder` → [go-performance](../go-performance/SKILL.md)
---
## 日志
- [ ] **使用 slog**:新代码使用 `log/slog`,不使用 `log` 或 `fmt.Println` 进行运维日志记录 → [go-logging](../go-logging/SKILL.md)
- [ ] **结构化字段**:日志消息使用静态字符串加键值属性,不使用 fmt.Sprintf → [go-logging](../go-logging/SKILL.md)
- [ ] **适当的级别**:Debug 用于开发者追踪,Info 用于重要事件,Warn 用于可恢复的问题,Error 用于故障 → [go-logging](../go-logging/SKILL.md)
- [ ] **日志中无敏感信息**:PII、凭证和令牌永远不记录在日志中 → [go-logging](../go-logging/SKILL.md)
---
## 导入
- [ ] **导入分组**:标准库优先,然后空行,再外部包 → [go-packages](../go-packages/SKILL.md)
- [ ] **导入重命名**:除非冲突否则避免重命名;冲突时重命名本地/项目特定的导入 → [go-packages](../go-packages/SKILL.md)
- [ ] **空白导入**:`import _ "pkg"` 仅在 main 包或测试中使用 → [go-packages](../go-packages/SKILL.md)
- [ ] **点导入**:仅在测试中用于解决循环依赖 → [go-packages](../go-packages/SKILL.md)
---
## 泛型
- [ ] **何时使用**:仅当多个类型共享相同逻辑且接口不足时 → [go-generics](../go-generics/SKILL.md)
- [ ] **类型别名**:使用定义创建新类型;别名仅用于包迁移 → [go-generics](../go-generics/SKILL.md)
---
## 测试
- [ ] **示例**:包含可运行的 `Example` 函数或演示用法的测试 → [go-documentation](../go-documentation/SKILL.md)
- [ ] **有用的测试失败信息**:消息包含出了什么错、输入、实际值和期望值;顺序为 `got != want` → [go-testing](../go-testing/SKILL.md)
- [ ] **TestMain**:仅当所有测试都需要带清理的公共设置时使用;优先使用作用域化的 helper → [go-testing](../go-testing/SKILL.md)
- [ ] **真实传输**:优先使用 `httptest.NewServer` + 真实客户端而非 mock HTTP → [go-testing](../go-testing/SKILL.md)
---
## 自动化检查
运行自动化预审查检查:
```bash
bash scripts/pre-review.sh ./... # 文本输出
bash scripts/pre-review.sh --json ./... # 结构化 JSON 输出
```
或手动:`gofmt -l <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)
@@ -0,0 +1,23 @@
# Code Review: [PR Title]
## Summary
[Brief description of the changes]
## Findings
### Must Fix
- [ ] [file:line] Description of critical issue
### Should Fix
- [ ] [file:line] Description of recommended improvement
### Nits
- [ ] [file:line] Description of minor suggestion
## Automated Checks
- [ ] `gofmt -d .` — clean
- [ ] `go vet ./...` — clean
- [ ] `golangci-lint run` — clean
## Skills Applied
[List of go-* skills referenced during review]
@@ -0,0 +1,119 @@
# Web 服务器:Skill 的综合应用
本示例展示 Go skill 如何在真实的 HTTP 服务器中协同应用。每个部分
引用相关的 skill 以获取详细指导。
## 结构
```go
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"time"
)
// --- 接口(go-interfaces) ---
// Store 定义了数据访问边界。定义在消费方包中,
// 而非实现方包中。
type Store interface {
GetUser(ctx context.Context, id string) (*User, error)
}
// --- 类型与构造函数(go-naming、go-declarations) ---
// Server 处理用户 API 的 HTTP 请求。
type Server struct {
store Store
router *http.ServeMux
}
// NewServer 使用给定的依赖创建 Server。
// 调用者必须调用 Shutdown 来释放资源。
func NewServer(store Store) *Server {
s := &Server{store: store}
s.router = http.NewServeMux()
s.router.HandleFunc("GET /users/{id}", s.handleGetUser)
return s
}
// --- 错误处理(go-error-handling) ---
// 领域错误作为哨兵 —— 使用 errors.Is 进行检查。
var ErrNotFound = errors.New("not found")
// --- HTTP 处理器(go-control-flow、go-context、go-error-handling) ---
func (s *Server) handleGetUser(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() // go-context:从 request 派生
id := r.PathValue("id")
user, err := s.store.GetUser(ctx, id)
if err != nil {
if errors.Is(err, ErrNotFound) { // go-error-handling:errors.Is
http.Error(w, "user not found", http.StatusNotFound)
return // go-control-flow:提前返回
}
// HTTP 处理器是"记录或返回"规则的例外:在服务端记录详细信息,向客户端返回脱敏错误。
slog.Error("GetUser failed", "id", id, "err", err)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(user)
}
// --- 优雅关闭(go-concurrency、go-defensive) ---
func main() {
store := NewDBStore(os.Getenv("DATABASE_URL"))
srv := NewServer(store)
httpSrv := &http.Server{
Addr: ":8080",
Handler: srv.router,
ReadTimeout: 5 * time.Second, // go-defensive:使用 time.Duration
WriteTimeout: 10 * time.Second,
}
// go-concurrency:goroutine 生命周期清晰
go func() {
sigCh := make(chan os.Signal, 1) // go-concurrency:channel 大小为 1
signal.Notify(sigCh, os.Interrupt)
<-sigCh
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel() // go-defensive:defer 清理
httpSrv.Shutdown(ctx)
}()
slog.Info("starting server", "addr", httpSrv.Addr)
if err := httpSrv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
slog.Error("server error", "err", err)
os.Exit(1) // go-packages:仅在 main 中退出
}
}
```
## 应用的 Skill
| 领域 | Skill | 演示内容 |
|------|-------|----------|
| 接口在消费方 | [go-interfaces](../../go-interfaces/SKILL.md) | `Store` 在使用处定义 |
| 命名 | [go-naming](../../go-naming/SKILL.md) | MixedCaps、接收器缩写、清晰的函数名 |
| 错误处理 | [go-error-handling](../../go-error-handling/SKILL.md) | 哨兵错误、`errors.Is`、记录或返回 |
| Context | [go-context](../../go-context/SKILL.md) | 从 request 派生,逐层传递 |
| 控制流 | [go-control-flow](../../go-control-flow/SKILL.md) | 错误情况的提前返回 |
| 并发 | [go-concurrency](../../go-concurrency/SKILL.md) | 清晰的 goroutine 生命周期、channel 大小 |
| 防御性 | [go-defensive](../../go-defensive/SKILL.md) | `defer cancel()`、`time.Duration`、优雅关闭 |
| 包管理 | [go-packages](../../go-packages/SKILL.md) | 仅在 `main()` 中退出 |
| 日志 | [go-error-handling](../../go-error-handling/SKILL.md) | 结构化 slog,错误只处理一次 |
+246
View File
@@ -0,0 +1,246 @@
#!/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
+191
View File
@@ -0,0 +1,191 @@
---
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) —— 类型安全的原子操作
@@ -0,0 +1,132 @@
# 高级并发模式
来自 Effective Go 的高级并发模式详细参考。这些模式适用于特定场景 —— 在需要请求/响应多路复用或 CPU 密集型并行化时使用。
---
## Channel 的 Channel
> **来源**:Effective Go
Channel 是一等公民值,可以像其他值一样被分配和传递。一个强大的模式是在请求结构体中嵌入 **回复 channel**,让每个客户端提供自己的应答路径:
```go
type Request struct {
args []int
f func([]int) int
resultChan chan int
}
```
客户端发送一个包含函数、参数和接收结果 channel 的请求:
```go
request := &Request{[]int{3, 4, 5}, sum, make(chan int)}
clientRequests <- request
fmt.Printf("answer: %d\n", <-request.resultChan)
```
服务端处理器从队列中读取请求,并将结果发送回每个请求的回复 channel:
```go
func handle(queue chan *Request) {
for req := range queue {
req.resultChan <- req.f(req.args)
}
}
```
这个模式构成了限速、并行、非阻塞 RPC 系统的基础,无需任何互斥锁。
---
## CPU 密集型并行化
> **来源**:Effective Go(现代化版本)
当计算可以分解为独立的部分时,使用 `sync.WaitGroup` 等待完成,将其并行化到多个 CPU 核心上:
```go
type Vector []float64
func (v Vector) DoSome(i, n int, u Vector) {
for ; i < n; i++ {
v[i] += u.Op(v[i])
}
}
func (v Vector) DoAll(u Vector) {
numCPU := runtime.NumCPU()
var wg sync.WaitGroup
wg.Add(numCPU)
for i := 0; i < numCPU; i++ {
go func(i int) {
defer wg.Done()
v.DoSome(i*len(v)/numCPU, (i+1)*len(v)/numCPU, u)
}(i)
}
wg.Wait()
}
```
使用 `runtime.NumCPU()` 获取硬件核心数,或使用 `runtime.GOMAXPROCS(0)` 以遵循用户的资源配置。
> **重要**:不要混淆并发(将程序组织为独立执行的组件)和并行(在多个 CPU 上同时执行计算)。Go 是一门并发语言;并非所有并行化问题都适合它的模型。
---
## 常见错误
### 忘记通知完成
如果 goroutine 从未调用 `wg.Done()`(或从未在 done channel 上发送),等待的 goroutine 将永远阻塞:
```go
// 不好:缺少 wg.Done —— 死锁
var wg sync.WaitGroup
wg.Add(1)
go func() {
doWork()
}()
wg.Wait()
// 好:始终 defer wg.Done
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
doWork()
}()
wg.Wait()
```
### 无限制的 goroutine 创建
为每个工作项无限制地启动 goroutine 可能会耗尽内存或压垮下游资源。使用信号量来限制并发数:
```go
// 不好:一次性创建 len(items) 个 goroutine
var wg sync.WaitGroup
for _, item := range items {
wg.Add(1)
go func(it Item) {
defer wg.Done()
process(it)
}(item)
}
wg.Wait()
// 好:信号量将并发限制为 maxWorkers
var wg sync.WaitGroup
sem := make(chan struct{}, maxWorkers)
for _, item := range items {
wg.Add(1)
sem <- struct{}{}
go func(it Item) {
defer wg.Done()
defer func() { <-sem }()
process(it)
}(item)
}
wg.Wait()
```
@@ -0,0 +1,73 @@
# 使用 Channel 的缓冲池
使用有缓冲 channel 作为空闲列表来复用已分配的缓冲区,避免重复分配。这种"泄漏缓冲"模式使用带 `default` 的 `select` 进行非阻塞操作。
> **来源**:Effective Go
```go
var freeList = make(chan *Buffer, 100) // 有缓冲 channel 作为空闲列表
// 客户端:从空闲列表获取缓冲区或分配新的
func getBuffer() *Buffer {
select {
case b := <-freeList:
return b // 复用已有缓冲区
default:
return new(Buffer) // 空闲列表为空;分配新缓冲区
}
}
// 服务端:如有空间则将缓冲区归还空闲列表,否则丢弃
func putBuffer(b *Buffer) {
b.Reset() // 为重用做准备
select {
case freeList <- b:
// 缓冲区已归还空闲列表
default:
// 空闲列表已满;丢弃缓冲区(GC 会回收)
}
}
```
## 工作原理
1. **非阻塞接收**:客户端尝试从 `freeList` 获取缓冲区。如果为空,`default` 分支运行并分配新缓冲区。
2. **非阻塞发送**:服务端尝试归还缓冲区。如果 `freeList` 已满,`default` 分支运行,缓冲区被丢弃等待垃圾回收。
3. **有限内存**:channel 容量(100)限制了池中缓冲区的数量,防止无限增长。
当分配成本较高且缓冲区复用有益,但你不希望在池空或池满时出现阻塞行为时,这种模式非常有用。
## 何时使用
- 高频分配相似大小的对象
- 分配开销影响性能的代码路径
- 需要限制内存使用量的场景
## 生产环境替代方案
对于生产代码,考虑使用 `sync.Pool`,它提供类似功能并与垃圾收集器有更好的集成:
```go
var bufferPool = sync.Pool{
New: func() any {
return new(Buffer)
},
}
func getBuffer() *Buffer {
return bufferPool.Get().(*Buffer)
}
func putBuffer(b *Buffer) {
b.Reset()
bufferPool.Put(b)
}
```
`sync.Pool` 的优势:
- 垃圾收集期间自动清理
- 无需管理池大小
- 天生线程安全
- 高并发下性能更好
基于 channel 的方式对于理解 Go 的并发原语以及需要更多控制池行为的场景仍然很有价值。
@@ -0,0 +1,126 @@
# Goroutine 生命周期模式
管理 goroutine 生命周期的详细模式 —— 确保每个 goroutine 都有清晰的启动/停止机制并防止资源泄漏。
---
## 使生命周期清晰
> WaitGroup 示例和作用域规则在父 skill(SKILL.md § Goroutine 生命周期,核心规则)中。本参考涵盖:stop/done channel 模式、等待策略、init() 生命周期示例和同步 API 设计。
---
## Stop/Done Channel 模式
每个 goroutine 必须有可预测的停止机制。使用 stop channel 通知关闭,使用 done channel 确认退出:
```go
var (
stop = make(chan struct{}) // 通知 goroutine 停止
done = make(chan struct{}) // 通知我们 goroutine 已退出
)
go func() {
defer close(done)
ticker := time.NewTicker(delay)
defer ticker.Stop()
for {
select {
case <-ticker.C:
flush()
case <-stop:
return
}
}
}()
// 关闭时:
close(stop) // 通知 goroutine 停止
<-done // 并等待它退出
```
在已关闭的 channel 上发送会 panic —— 始终使用 `close()` 来发信号,不要直接发送:
```go
ch := make(chan int)
close(ch)
ch <- 13 // panic: 在已关闭的 channel 上发送
```
---
## 等待 Goroutine
> 多 goroutine 的 `sync.WaitGroup` 模式在父 skill 中(SKILL.md § Goroutine 生命周期)。以下是单 goroutine 的 done-channel 替代方案。
为单个 goroutine 使用 done channel:
```go
done := make(chan struct{})
go func() {
defer close(done)
// 工作...
}()
<-done // 等待 goroutine 完成
```
---
## 不在 init() 中使用 Goroutine
> 核心规则在父 skill 中(SKILL.md § 核心规则,规则 3)。以下是展示生命周期管理的扩展示例。
```go
// 不好:创建了不可控的后台 goroutine
func init() {
go doWork()
}
```
```go
// 好:显式的生命周期管理
type Worker struct {
stop chan struct{}
done chan struct{}
}
func NewWorker() *Worker {
w := &Worker{
stop: make(chan struct{}),
done: make(chan struct{}),
}
go w.doWork()
return w
}
func (w *Worker) Shutdown() {
close(w.stop)
<-w.done
}
```
---
## 优先使用同步函数
> 理由和优势表在父 skill 中(SKILL.md § 同步函数)。以下是具体的代码示例。
```go
// 好:同步函数 - 调用方控制并发
func ProcessItems(items []Item) ([]Result, error) {
var results []Result
for _, item := range items {
result, err := processItem(item)
if err != nil {
return nil, err
}
results = append(results, result)
}
return results, nil
}
// 调用方可以在需要时添加并发:
go func() {
results, err := ProcessItems(items)
// 处理结果
}()
```
@@ -0,0 +1,110 @@
# 同步原语模式
互斥锁和原子操作的详细模式 —— 涵盖互斥锁嵌入陷阱和类型安全的原子访问。
---
## 不要嵌入互斥锁
如果你通过指针使用结构体,互斥锁应该是非指针字段。不要在结构体中嵌入互斥锁,即使该结构体未被导出。
```go
// 不好:嵌入的互斥锁将 Lock/Unlock 暴露为 API 的一部分
type SMap struct {
sync.Mutex // Lock() 和 Unlock() 成为 SMap 的方法
data map[string]string
}
func (m *SMap) Get(k string) string {
m.Lock()
defer m.Unlock()
return m.data[k]
}
```
```go
// 好:命名字段使互斥锁保持为实现细节
type SMap struct {
mu sync.Mutex
data map[string]string
}
func (m *SMap) Get(k string) string {
m.mu.Lock()
defer m.mu.Unlock()
return m.data[k]
}
```
在不好的示例中,`Lock` 和 `Unlock` 方法无意中成为了导出 API 的一部分。在好的示例中,互斥锁是对调用方隐藏的实现细节。
---
## 原子操作:完整示例
标准 `sync/atomic` 包操作原始类型(`int32`、`int64` 等),容易忘记一致地使用原子操作。
```go
// 不好:容易忘记原子操作
type foo struct {
running int32 // 原子操作
}
func (f *foo) start() {
if atomic.SwapInt32(&f.running, 1) == 1 {
return // 已在运行
}
// 启动 Foo
}
func (f *foo) isRunning() bool {
return f.running == 1 // 竞争!忘记使用 atomic.LoadInt32
}
```
```go
// 好:类型安全的原子操作
type foo struct {
running atomic.Bool
}
func (f *foo) start() {
if f.running.Swap(true) {
return // 已在运行
}
// 启动 Foo
}
func (f *foo) isRunning() bool {
return f.running.Load() // 不可能意外地非原子读取
}
```
`atomic.Bool`、`atomic.Int64` 等类型(Go 1.19 起在标准库 `sync/atomic` 中可用,或通过 [go.uber.org/atomic](https://pkg.go.dev/go.uber.org/atomic))通过隐藏底层类型来增加类型安全性。
---
## Channel 方向示例
指定方向可以防止意外误用:
```go
// 好:指定方向 - 清晰的所有权
func sum(values <-chan int) int {
total := 0
for v := range values {
total += v
}
return total
}
```
```go
// 不好:未指定方向 - 允许意外误用
func sum(values chan int) (out int) {
for v := range values {
out += v
}
close(values) // 漏洞!这能通过编译但不应该发生。
}
```
+122
View File
@@ -0,0 +1,122 @@
---
name: go-context
description: 在 Go 中使用 context.Context 时使用 — 包括函数签名中的位置、传播取消和截止时间、以及在 context 中存储值与使用参数的对比。也适用于取消长时间运行的操作、设置超时或传递请求作用域数据,即使未直接提及 context.Context。不涵盖 goroutine 生命周期或 sync 原语(参见 go-concurrency)。
license: Apache-2.0
compatibility: 需要 Go 1.7+(context 在 Go 1.7 中移入标准库)
metadata:
sources: "Go Wiki CodeReviewComments"
---
# Go Context 用法
## Context 作为第一个参数
使用 Context 的函数应将其作为**第一个参数**:
```go
func F(ctx context.Context, /* 其他参数 */) error
func ProcessRequest(ctx context.Context, req *Request) (*Response, error)
```
这是 Go 中的一个强约定,使 context 的传递在代码库中可见且一致。
---
## 不要在结构体中存储 Context
不要在结构体类型中添加 Context 成员。相反,将 `ctx` 作为参数传递给每个需要它的方法:
```go
// 不好:Context 存储在结构体中
type Worker struct {
ctx context.Context // 不要这样做
}
// 好:Context 传递给方法
type Worker struct{ /* ... */ }
func (w *Worker) Process(ctx context.Context) error {
// Context 显式传递 — 生命周期清晰
}
```
**例外**:签名必须匹配标准库或第三方库中接口的方法可能需要变通处理。
---
## 不要创建自定义 Context 类型
不要创建自定义的 Context 类型或在函数签名中使用 `context.Context` 以外的接口:
```go
// 不好:自定义 context 类型
type MyContext interface {
context.Context
GetUserID() string
}
// 好:使用标准 context.Context 并提取值
func Process(ctx context.Context) error {
userID := GetUserID(ctx)
}
```
---
## 应用数据放在哪里
按以下优先级顺序考虑:
1. **函数参数** — 最明确且类型安全
2. **接收者** — 适用于属于该类型的数据
3. **全局变量** — 适用于真正的全局配置(谨慎使用)
4. **Context 值** — 仅用于请求作用域数据
Context 值适用于:
- 请求 ID 和追踪 ID
- 随请求流动的认证/授权信息
- 截止时间和取消信号
Context 值**不适用**于:
- 可选的函数参数
- 可以显式传递的数据
- 不随请求变化的配置
---
## 常见模式
> 在派生 context(WithTimeout、WithCancel、WithDeadline)、在循环或 HTTP 处理器中检查取消、使用带类型键的 context 值、或需要快速参考表时,阅读 [references/PATTERNS.md](references/PATTERNS.md)。
### 派生 Context
创建派生 context 后,始终立即 `defer cancel()`:
```go
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
```
### 检查取消
```go
select {
case <-ctx.Done():
return ctx.Err()
default:
// 执行工作
}
```
### Context 不可变性
Context 是不可变的 — 将同一个 `ctx` 传递给共享相同截止时间和取消信号的多个并发调用是安全的。
---
## 相关技能
- **Goroutine 协调**:在使用 context 进行 goroutine 取消、基于 select 的超时或 errgroup 时,参见 [go-concurrency](../go-concurrency/SKILL.md)
- **错误处理**:在决定如何包装或返回 `ctx.Err()` 取消错误时,参见 [go-error-handling](../go-error-handling/SKILL.md)
- **接口设计**:在设计接受 context 并结合接口的 API 时,参见 [go-interfaces](../go-interfaces/SKILL.md)
- **请求作用域日志**:在将 logger 注入 context 或将请求 ID 添加到结构化日志输出时,参见 [go-logging](../go-logging/SKILL.md)
@@ -0,0 +1,227 @@
# Context 模式
派生、检查和传播 `context.Context` 的常见模式。
---
## Context 不可变性
Context 是不可变的。将同一个 `ctx` 传递给共享相同截止时间、取消信号、凭据和父级追踪的多个调用是安全的:
```go
// 安全:同一个 context 传递给顺序调用
func ProcessBatch(ctx context.Context, items []Item) error {
for _, item := range items {
if err := process(ctx, item); err != nil {
return err
}
}
return nil
}
// 安全:同一个 context 传递给并发调用
func ProcessConcurrently(ctx context.Context, a, b *Data) error {
g, ctx := errgroup.WithContext(ctx)
g.Go(func() error { return processA(ctx, a) })
g.Go(func() error { return processB(ctx, b) })
return g.Wait()
}
```
---
## 何时使用 context.Background()
仅在**从不特定于请求**的函数中使用 `context.Background()`:
```go
func main() {
ctx := context.Background()
if err := run(ctx); err != nil {
log.Fatal(err)
}
}
func startBackgroundWorker() {
ctx := context.Background()
go worker(ctx)
}
```
**默认传递 Context**,即使你认为不需要。只有在有充分理由说明传递 context 是错误做法时,才直接使用 `context.Background()`:
```go
func LoadConfig(ctx context.Context) (*Config, error) {
// 即使现在不使用 ctx,接受它可以在未来添加功能时
// 不需要修改 API
}
```
---
## 派生 Context
```go
// 添加超时 — 持续时间结束后触发取消
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
// 添加取消 — 调用者控制何时取消
ctx, cancel := context.WithCancel(ctx)
defer cancel()
// 添加截止时间 — 在指定的墙钟时间触发取消
ctx, cancel := context.WithDeadline(ctx, time.Now().Add(time.Hour))
defer cancel()
// 添加值(谨慎使用 — 仅用于请求作用域数据)
ctx = context.WithValue(ctx, requestIDKey, reqID)
```
创建派生 context 后,**始终立即 `defer cancel()`**。这确保即使函数提前返回,资源也会被释放。
### 嵌套派生
派生的 context 形成树状结构。取消父级会取消所有子级:
```go
func handleRequest(ctx context.Context) error {
// 整个请求的父级超时
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
// 数据库调用的更短超时
dbCtx, dbCancel := context.WithTimeout(ctx, 5*time.Second)
defer dbCancel()
data, err := queryDB(dbCtx)
if err != nil {
return err
}
// 父级 context 的剩余时间适用于此处
return sendResponse(ctx, data)
}
```
---
## 检查取消
### 在长时间运行的循环中
```go
func LongRunningOperation(ctx context.Context) error {
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
// 执行工作
}
}
}
```
### 在高开销操作之前
在开始无法中断的工作之前检查取消:
```go
func ProcessItems(ctx context.Context, items []Item) error {
for _, item := range items {
if ctx.Err() != nil {
return ctx.Err()
}
if err := expensiveProcess(item); err != nil {
return err
}
}
return nil
}
```
### 区分取消原因
```go
if err := ctx.Err(); err != nil {
switch {
case errors.Is(err, context.Canceled):
// 调用者显式取消(例如客户端断开连接)
case errors.Is(err, context.DeadlineExceeded):
// 超时或截止时间已过
}
}
```
---
## 在 HTTP 处理器中遵守取消
```go
func handler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
result, err := slowOperation(ctx)
if err != nil {
if errors.Is(err, context.Canceled) {
// 客户端已断开连接 — 无需写入
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(result)
}
```
`r.Context()` 在以下情况被取消:
- 客户端关闭连接
- `http.Server` 的 `ReadTimeout` 或 `WriteTimeout` 触发
- `ServeHTTP` 方法返回
---
## Context 值的最佳实践
### 使用未导出的键类型
```go
type contextKey struct{}
var userIDKey contextKey
func WithUserID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, userIDKey, id)
}
func UserIDFromContext(ctx context.Context) (string, bool) {
id, ok := ctx.Value(userIDKey).(string)
return id, ok
}
```
使用未导出的结构体类型作为键可以防止与其他包的键发生冲突 — 即使它们使用相同的 string 或 int 值。
### 提供访问器函数
始终将 `context.WithValue` 和 `ctx.Value` 包装在类型化的辅助函数中(如上所示),而不是暴露键。这提供了类型安全性和一个可以修改实现的单一位置。
---
## 快速参考
| 模式 | 指导 |
|------|------|
| 参数位置 | 始终第一个:`func F(ctx context.Context, ...)` |
| 结构体存储 | 不要存储在结构体中;传递给方法 |
| 自定义类型 | 不要创建;使用 `context.Context` 接口 |
| 应用数据 | 优先选择 参数 > 接收者 > 全局变量 > context 值 |
| 请求作用域数据 | 适用于 context 值 |
| 共享 context | 安全 — context 是不可变的 |
| `context.Background()` | 仅用于非请求特定的代码 |
| 默认行为 | 即使认为不需要也要传递 context |
| `defer cancel()` | 在 `WithTimeout`/`WithCancel`/`WithDeadline` 之后始终立即 defer |
| 值键 | 使用未导出的结构体类型,提供访问器函数 |
| 取消检查 | 在高开销操作前使用 `ctx.Err()`;在循环中使用 `select` 监听 `ctx.Done()` |
+193
View File
@@ -0,0 +1,193 @@
---
name: go-control-flow
description: Use when writing conditionals, loops, or switch statements in Go — including if with initialization, early returns, for loop forms, range, switch, type switches, and blank identifier patterns. Also use when writing a simple if/else or for loop, even if the user doesn't mention guard clauses or variable scoping. Does not cover error flow patterns (see go-error-handling).
license: Apache-2.0
metadata:
sources: "Effective Go, Google Style Guide"
---
# Go 控制流
> 在使用 switch 语句、类型 switch 或带标签的 break 时,阅读 [references/SWITCH-PATTERNS.md](references/SWITCH-PATTERNS.md)
> 在使用 `_`、空白标识符导入或编译时接口检查时,阅读 [references/BLANK-IDENTIFIER.md](references/BLANK-IDENTIFIER.md)
---
## 带初始化的 If
`if` 和 `switch` 接受可选的初始化语句。使用它将变量限定在条件块作用域内:
```go
if err := file.Chmod(0664); err != nil {
log.Print(err)
return err
}
```
如果需要在 `if` 之后超出几行的范围使用该变量,请单独声明并使用标准 `if`:
```go
x, err := f()
if err != nil {
return err
}
// 大量使用 x 的代码
```
## 缩进错误流(守卫子句)
当 `if` 主体以 `break`、`continue`、`goto` 或 `return` 结尾时,省略不必要的 `else`。保持成功路径不缩进:
```go
f, err := os.Open(name)
if err != nil {
return err
}
d, err := f.Stat()
if err != nil {
f.Close()
return err
}
codeUsing(f, d)
```
当 `if` 已经返回时,绝不要将正常流程埋在 `else` 中。
---
## 重新声明和重新赋值
`:=` 短声明允许在同一作用域中重新声明变量:
```go
f, err := os.Open(name) // 声明 f 和 err
d, err := f.Stat() // 声明 d,重新赋值 err
```
变量 `v` 即使已经声明过,也可以出现在 `:=` 声明中,前提是:
1. 声明在与现有 `v` **相同的作用域**中
2. 值**可赋值**给 `v`
3. 声明中至少创建了**一个其他新变量**
### 变量遮蔽
**警告**:如果 `v` 在外层作用域中声明,`:=` 会创建一个**新的**遮蔽变量 — 这是常见的 bug 来源:
```go
// Bug:if 块内的 ctx 遮蔽了外层的 ctx
if *shortenDeadlines {
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
}
// 此处的 ctx 仍然是原始的 — 被遮蔽的 ctx 没有逃逸
// 修复:使用 = 而不是 :=
var cancel func()
ctx, cancel = context.WithTimeout(ctx, 3*time.Second)
```
---
## For 循环
Go 的 `for` 是唯一的循环结构,统一了 `while`、`do-while` 和 C 风格的 `for`:
```go
// 仅条件(Go 的 "while")
for x > 0 {
x = process(x)
}
// 无限循环
for {
if done() { break }
}
// C 风格的三组件形式
for i := 0; i < n; i++ { ... }
```
### Range
`range` 遍历切片、映射、字符串和通道:
```go
for i, v := range slice { ... } // 索引 + 值
for k, v := range myMap { ... } // 键 + 值(顺序不确定)
for i, r := range "héllo" { ... } // 字节索引 + rune(不是字节)
for v := range ch { ... } // 接收直到通道关闭
```
**关键规则:**
- 对字符串 range 产生 **rune**,不是字节 — `i` 是字节偏移量
- 对映射 range 的顺序**不确定** — 不要依赖它
- 使用 `_` 丢弃索引或值:`for _, v := range slice`
### 并行赋值
Go 没有逗号运算符。使用并行赋值处理多个循环变量:
```go
for i, j := 0, len(a)-1; i < j; i, j = i+1, j-1 {
a[i], a[j] = a[j], a[i]
}
```
`++` 和 `--` 是语句,不是表达式 — 它们不能出现在并行赋值中。
---
## Switch:带标签的 Break
`for` 循环内 `switch` 中的 `break` 只会中断 switch。使用带标签的 `break` 退出外层循环:
```go
Loop:
for _, v := range items {
switch v.Type {
case "done":
break Loop // 中断 for 循环
}
}
```
关于类型 switch,参见 **go-interfaces**:类型 Switch。
---
## 空白标识符
**绝不要随意丢弃错误** — 空指针解引用 panic 可能随之而来。
在编译时验证接口实现:`var _ io.Writer = (*MyType)(nil)`。
参见 **go-interfaces** 中的接口满足检查模式。
---
## 快速参考
| 模式 | Go 惯用法 |
|------|-----------|
| If 初始化 | `if err := f(); err != nil { }` |
| 提前返回 | 当 if 主体返回时省略 `else` |
| 重新声明 | `:=` 在相同作用域 + 新变量时重新赋值 |
| 遮蔽陷阱 | `:=` 在内层作用域创建新变量 |
| 并行赋值 | `i, j = i+1, j-1` |
| 无表达式 switch | `switch { case cond: }` |
| 逗号 case | `case 'a', 'b', 'c':` |
| 无 fallthrough | 默认行为(需要时显式使用 `fallthrough`) |
| 从 switch 中跳出循环 | `break Label` |
| 丢弃值 | `_, err := f()` |
| 副作用导入 | `import _ "pkg"` |
| 接口检查 | `var _ Interface = (*Type)(nil)` |
---
## 相关技能
- **错误流程**:在构建守卫子句、提前返回或错误优先模式时,参见 [go-error-handling](../go-error-handling/SKILL.md)
- **类型 switch**:在使用类型 switch、comma-ok 惯用法或接口满足检查时,参见 [go-interfaces](../go-interfaces/SKILL.md)
- **减少嵌套**:在减少嵌套深度或解决格式问题时,参见 [go-style-core](../go-style-core/SKILL.md)
- **变量作用域**:在使用 if 初始化、`:=` 重新声明或减少变量作用域时,参见 [go-declarations](../go-declarations/SKILL.md)
@@ -0,0 +1,71 @@
# 空白标识符模式
空白标识符 `_` 在 Go 中有多种用途:丢弃不需要的值、为副作用导入包、以及在编译时验证接口实现。
---
## 多重赋值
使用 `_` 丢弃多值表达式中不需要的值:
```go
if _, err := os.Stat(path); os.IsNotExist(err) {
fmt.Printf("%s does not exist\n", path)
}
```
### 绝不要随意丢弃错误
静默丢弃错误会引发空指针 panic:
```go
// 不好:忽略错误会在路径不存在时崩溃
fi, _ := os.Stat(path)
if fi.IsDir() { ... } // 空指针解引用
```
如果确实不需要错误,请记录原因:
```go
_ = logger.Sync() // 尽力刷新;错误不可操作
```
---
## 副作用导入
使用空白标识符仅为了 `init()` 副作用而导入包:
```go
import _ "net/http/pprof" // 注册 HTTP 处理器
import _ "image/png" // 注册 PNG 解码器
```
这通常用于注册驱动、编解码器或调试处理器,它们在 `init()` 期间将自己注册到注册表中。
---
## 接口实现检查
在编译时验证类型是否实现了接口,方法是将 nil 指针赋值给接口类型的空白标识符变量:
```go
var _ io.Writer = (*MyType)(nil)
```
如果 `*MyType` 不满足 `io.Writer`,这会产生编译错误,在运行时之前捕获缺失的方法。
**何时使用**:将此检查放在定义该类型的同一文件中,通常在类型声明之后。当类型必须满足另一个包中定义的接口时特别有用。
参见 [go-interfaces](../../go-interfaces/SKILL.md):接口满足检查,获取关于何时何地使用此模式的完整指导。
---
## 快速参考
| 模式 | 语法 |
|------|------|
| 丢弃值 | `_, err := f()` |
| 在 if 初始化中丢弃 | `if _, err := f(); err != nil { }` |
| 副作用导入 | `import _ "pkg"` |
| 接口检查 | `var _ Interface = (*Type)(nil)` |
@@ -0,0 +1,109 @@
# Switch 模式
Go `switch` 语句的详细模式,包括无表达式 switch、逗号 case、break 行为和带标签的 break。
---
## 无自动 Fallthrough
Go `switch` 的 case 默认**不会** fall through(与 C/Java 不同)。每个 case 主体隐式地 break。仅在明确需要时使用 `fallthrough` — 这在惯用 Go 中很少见。
```go
switch n {
case 1:
fmt.Println("one")
// 无 fallthrough — 下一个 case 不会执行
case 2:
fmt.Println("two")
}
```
---
## 无表达式 Switch
没有表达式的 `switch` 对 `true` 进行 switch。在将单个变量与多个条件进行比较时,用它来替代 if-else-if 链:
```go
func unhex(c byte) byte {
switch {
case '0' <= c && c <= '9':
return c - '0'
case 'a' <= c && c <= 'f':
return c - 'a' + 10
case 'A' <= c && c <= 'F':
return c - 'A' + 10
}
return 0
}
```
---
## 逗号分隔的 Case
多个值可以使用逗号共享一个 case 主体 — 不需要 `fallthrough`:
```go
func shouldEscape(c byte) bool {
switch c {
case ' ', '?', '&', '=', '#', '+', '%':
return true
}
return false
}
```
---
## 带标签的 Break
`switch` 中的 `break` 仅终止 switch,**不会**终止外层的 `for` 循环。使用标签来跳出循环:
```go
Loop:
for n := 0; n < len(src); n += size {
switch {
case src[n] < sizeOne:
break // 仅中断 switch
case src[n] < sizeTwo:
if n+1 >= len(src) {
break Loop // 跳出 for 循环
}
}
}
```
另一个常见模式 — 从 switch 内部中断 range 循环:
```go
Loop:
for _, v := range items {
switch v.Type {
case "done":
break Loop // 中断 for 循环
case "skip":
break // 仅中断 switch
}
}
```
**经验法则**:当 `for` 循环内有 `switch` 且需要从 case 中退出循环时,始终使用带标签的 break。
---
## 类型 Switch
关于类型 switch(`switch v := x.(type)`),参见 [go-interfaces](../../go-interfaces/SKILL.md):类型 Switch。
---
## 快速参考
| 模式 | 语法 |
|------|------|
| 无表达式 switch | `switch { case cond: }` |
| 逗号 case | `case 'a', 'b', 'c':` |
| 无 fallthrough | 默认行为;需要时使用 `fallthrough` 关键字 |
| 仅中断 switch | case 内使用 `break` |
| 中断外层循环 | 使用带标签的 `for` 和 `break Label` |
+140
View File
@@ -0,0 +1,140 @@
---
name: go-data-structures
description: Use when working with Go slices, maps, or arrays — choosing between new and make, using append, declaring empty slices (nil vs literal for JSON), implementing sets with maps, and copying data at boundaries. Also use when building or manipulating collections, even if the user doesn't ask about allocation idioms. Does not cover concurrent data structure safety (see go-concurrency).
license: Apache-2.0
metadata:
sources: "Effective Go, Google Style Guide, Uber Style Guide, Go Wiki CodeReviewComments"
---
# Go 数据结构
---
## 选择数据结构
```
你需要什么?
├─ 有序的元素集合
│ ├─ 编译时已知固定大小 → 数组 [N]T
│ └─ 动态大小 → 切片 []T
│ ├─ 知道大概的大小?→ make([]T, 0, capacity)
│ └─ 未知大小或需要 nil 安全的 JSON?→ var s []T (nil)
├─ 键值查找
│ └─ 映射 map[K]V
│ ├─ 知道大概的大小?→ make(map[K]V, capacity)
│ └─ 需要集合?→ map[T]struct{}(零大小值)
└─ 需要传递给函数?
└─ 如果调用者可能会修改它,则在边界处复制
```
> **此技能不适用的场景**:对于数据结构的并发访问(互斥锁、原子操作),参见 [go-concurrency](../go-concurrency/SKILL.md)。对于 API 边界处的防御性复制,参见 [go-defensive](../go-defensive/SKILL.md)。对于为性能预分配容量,参见 [go-performance](../go-performance/SKILL.md)。
---
## 切片
### append 函数
**始终赋值结果** — 底层数组可能会改变:
```go
x := []int{1, 2, 3}
x = append(x, 4, 5, 6)
// 将切片追加到切片
x = append(x, y...) // 注意 ...
```
### 二维切片
**独立的内部切片**(可以独立增长/缩小):
```go
picture := make([][]uint8, YSize)
for i := range picture {
picture[i] = make([]uint8, XSize)
}
```
**单次分配**(对于固定大小更高效):
```go
picture := make([][]uint8, YSize)
pixels := make([]uint8, XSize*YSize)
for i := range picture {
picture[i], pixels = pixels[:XSize], pixels[XSize:]
}
```
> 在调试意外的切片行为、跨 goroutine 共享切片或处理切片头时,阅读 [references/SLICES.md](references/SLICES.md)。
### 声明空切片
优先使用 nil 切片而非空字面量:
```go
// 好:nil 切片
var t []string
// 避免:非 nil 但零长度
t := []string{}
```
两者的 `len` 和 `cap` 都是零,但 nil 切片是首选风格。
**JSON 例外**:nil 切片编码为 `null`,而 `[]string{}` 编码为 `[]`。当需要 JSON 数组时使用非 nil。
在设计接口时,避免区分 nil 和非 nil 的零长度切片。
---
## 映射
### 实现集合
使用 `map[T]bool` — 惯用且阅读自然:
```go
attended := map[string]bool{"Ann": true, "Joe": true}
if attended[person] { // 不在映射中则为 false
fmt.Println(person, "was at the meeting")
}
```
---
## 复制
从另一个包复制结构体时要小心。如果类型的方法定义在指针类型(`*T`)上,复制值可能导致别名 bug。
**通用规则:** 如果类型 `T` 的方法与指针类型 `*T` 关联,则不要复制 `T` 的值。这适用于 `bytes.Buffer`、`sync.Mutex`、`sync.WaitGroup` 以及包含它们的类型。
```go
// 不好:复制互斥锁
var mu sync.Mutex
mu2 := mu // 几乎总是 bug
// 好:通过指针传递
func increment(sc *SafeCounter) {
sc.mu.Lock()
sc.count++
sc.mu.Unlock()
}
```
---
## 快速参考
| 主题 | 关键点 |
|------|--------|
| 切片 | 始终赋值 `append` 结果;`nil` 切片优于 `[]T{}` |
| 集合 | `map[T]bool` 是惯用写法 |
| 复制 | 如果方法在 `*T` 上则不要复制 `T`;注意别名问题 |
## 相关技能
- **防御性复制**:在 API 边界处复制切片或映射以防止修改时,参见 [go-defensive](../go-defensive/SKILL.md)
- **容量提示**:为已知工作负载预分配切片或映射容量时,参见 [go-performance](../go-performance/SKILL.md)
- **迭代模式**:在对切片、映射或通道使用 range 循环时,参见 [go-control-flow](../go-control-flow/SKILL.md)
- **声明风格**:在 `new`、`make`、`var` 和复合字面量之间选择时,参见 [go-declarations](../go-declarations/SKILL.md)
@@ -0,0 +1,146 @@
# Go 切片内部原理
> **来源**:Effective Go
---
## 三项描述符
切片是一个运行时数据结构,包含三个组件:
- **指针**:第一个可访问元素的地址
- **长度**:元素数量(`len(s)`)
- **容量**:到底层数组末尾的最大元素数(`cap(s)`)
```go
arr := [5]int{10, 20, 30, 40, 50}
s := arr[1:4] // s = [20, 30, 40]
// 指针:&arr[1],长度:3,容量:4
```
`nil` 切片的三项均为零/nil。
---
## 切片引用底层数组
切片不存储数据 — 它们描述数组的一部分:
```go
data := [4]int{1, 2, 3, 4}
a := data[0:2] // [1, 2]
b := data[1:3] // [2, 3]
b[0] = 99
fmt.Println(a) // [1, 99] - 两者都看到变化
fmt.Println(data) // [1, 99, 3, 4]
```
---
## 切片运算符
`s[lo:hi]` 创建从索引 `lo` 到 `hi-1` 的切片:
```go
s := []int{0, 1, 2, 3, 4, 5}
s[2:4] // [2, 3]
s[:3] // [0, 1, 2]
s[3:] // [3, 4, 5]
```
三索引形式 `s[lo:hi:max]` 将容量限制为 `max-lo`。
---
## 为什么 append 必须返回切片
切片头是**按值传递**的。函数可以修改元素但无法改变调用者的切片头:
```go
func Append(slice, data []byte) []byte {
l := len(slice)
if l+len(data) > cap(slice) {
newSlice := make([]byte, (l+len(data))*2)
copy(newSlice, slice)
slice = newSlice // 只改变局部变量
}
slice = slice[0 : l+len(data)]
copy(slice[l:], data)
return slice // 调用者必须接收新的切片头
}
```
当发生重新分配时,`slice` 指向新数组。调用者的原始引用仍指向旧数组 — 返回使调用者能够更新其引用。
---
## copy 函数
`copy(dst, src)` 复制元素并返回复制的数量:
```go
src := []int{1, 2, 3, 4, 5}
dst := make([]int, 3)
n := copy(dst, src) // n=3, dst=[1,2,3]
```
正确处理重叠切片。复制 `min(len(dst), len(src))` 个元素 — 不会发生重新分配。
---
## 切片常见陷阱
### 1. 共享底层数组
```go
original := []int{1, 2, 3, 4, 5}
subset := original[1:3]
subset[0] = 99
fmt.Println(original) // [1, 99, 3, 4, 5] - 被修改了!
// 修复:创建独立副本
subset := make([]int, 2)
copy(subset, original[1:3])
```
### 2. append 可能重新分配也可能不
```go
a := make([]int, 3, 5) // len=3, cap=5
b := a[0:3]
a = append(a, 4) // 在容量内 - 仍然共享
a = append(a, 5, 6) // 超出容量 - 现在独立
```
### 3. 大底层数组导致内存泄漏
```go
// 不好:小切片将整个文件保留在内存中
func getHeader(file []byte) []byte { return file[:100] }
// 好:复制以释放大数组
func getHeader(file []byte) []byte {
header := make([]byte, 100)
copy(header, file)
return header
}
```
### 4. nil vs 空切片
```go
var nilSlice []int // nil, len=0, cap=0
emptySlice := []int{} // 非 nil, len=0, cap=0
// 两者在 len、cap、append、range 中表现相同
// 未初始化状态优先使用 nil
```
## 快速参考
| 操作 | 行为 |
|------|------|
| `s[lo:hi]` | 从 lo 到 hi-1 的切片 |
| `s[lo:hi:max]` | 容量限制为 max-lo 的切片 |
| `append(s, x...)` | 返回新切片;可能重新分配 |
| `copy(dst, src)` | 返回复制数量;不重新分配 |
+188
View File
@@ -0,0 +1,188 @@
---
name: go-defensive
description: Use when hardening Go code at API boundaries — copying slices/maps, verifying interface compliance, using defer for cleanup, time.Time/time.Duration, or avoiding mutable globals. Also use when reviewing for robustness concerns like missing cleanup or unsafe crypto usage, even if the user doesn't mention "defensive programming." Does not cover error handling strategy (see go-error-handling).
license: Apache-2.0
compatibility: Uses crypto/rand.Text (Go 1.24+) in examples
metadata:
sources: "Effective Go, Uber Style Guide, Go Wiki CodeReviewComments"
---
# Go 防御性编程模式
## 防御性检查清单优先级
在加固 API 边界代码时,按以下顺序检查:
```
正在审查 API 边界?
├─ 1. 错误处理 → 返回错误;不要 panic(参见 go-error-handling)
├─ 2. 输入验证 → 复制从调用者接收的切片/map
├─ 3. 输出安全 → 在返回给调用者之前复制切片/map
├─ 4. 资源清理 → 使用 defer 进行 Close/Unlock/Cancel
├─ 5. 接口检查 → var _ Interface = (*Type)(nil) 编译时验证
├─ 6. 时间正确性 → 使用 time.Time 和 time.Duration,不要用 int/float
├─ 7. 枚举安全 → iota 从 1 开始,使零值无效
└─ 8. 加密安全 → 用 crypto/rand 生成密钥,绝不用 math/rand
```
---
## 快速参考
| 模式 | 规则 | 详情 |
|------|------|------|
| 边界复制 | 在接收和返回时复制切片/map | [BOUNDARY-COPYING.md](references/BOUNDARY-COPYING.md) |
| Defer 清理 | 在 `os.Open` 之后立即 `defer f.Close()` | 见下文 |
| 接口检查 | `var _ I = (*T)(nil)` | 参见 go-interfaces |
| 时间类型 | `time.Time` / `time.Duration`,绝不用原始 int | [TIME-ENUMS-TAGS.md](references/TIME-ENUMS-TAGS.md) |
| 枚举起始值 | `iota + 1` 使零值 = 无效 | 见下文 |
| 加密随机数 | 用 `crypto/rand` 生成密钥,绝不用 `math/rand` | 见下文 |
| Must 函数 | 仅在初始化时使用;失败时 panic | [MUST-FUNCTIONS.md](references/MUST-FUNCTIONS.md) |
| Panic/recover | 绝不跨包暴露 panic | [PANIC-RECOVER.md](references/PANIC-RECOVER.md) |
| 可变全局变量 | 用依赖注入替代 | 见下文 |
---
## 验证接口合规性
使用编译时检查来验证接口实现。完整模式请参见 **go-interfaces**:接口满足检查。
```go
var _ http.Handler = (*Handler)(nil)
```
## 在边界处复制切片和 Map
切片和 map 包含指向底层数据的指针。在 API 边界处复制,以防止意外修改。
```go
// 接收:复制传入的切片
d.trips = make([]Trip, len(trips))
copy(d.trips, trips)
// 返回:在返回之前复制 map
result := make(map[string]int, len(s.counters))
for k, v := range s.counters { result[k] = v }
```
> 在 API 边界处复制切片或 map,或决定何时需要防御性复制、何时可以跳过时,请阅读 [references/BOUNDARY-COPYING.md](references/BOUNDARY-COPYING.md)。
## 使用 Defer 清理资源
使用 `defer` 清理资源(文件、锁)。避免在多个返回路径中遗漏清理。
```go
p.Lock()
defer p.Unlock()
if p.count < 10 {
return p.count
}
p.count++
return p.count
```
Defer 的开销可以忽略不计。在 `os.Open` 之后立即放置 `defer f.Close()` 以提高清晰度。延迟函数的参数在 `defer` 执行时求值,而非在函数运行时。多个 defer 按 LIFO 顺序执行。
## 结构体字段标签
> **建议**:始终为需要序列化或反序列化的结构体添加显式字段标签。
```go
type User struct {
Name string `json:"name" yaml:"name"`
Email string `json:"email" yaml:"email"`
}
```
字段标签是**序列化契约**——重命名结构体字段而不更新标签会悄然破坏线格式兼容性。对于任何跨越序列化边界的类型,应将标签视为公共 API 的一部分。
## 枚举从 1 开始
枚举从非零值开始,以区分未初始化的值和有效值。
```go
const (
Add Operation = iota + 1 // Add=1,零值 = 未初始化
Subtract
Multiply
)
```
**例外**:当零值是合理的默认值时(例如 `LogToStdout = iota`)。
## 时间、结构体标签和嵌入
> 在使用 `time.Time`/`time.Duration` 代替原始 int、为序列化结构体添加字段标签,或决定是否在公共结构体中嵌入类型时,请阅读 [references/TIME-ENUMS-TAGS.md](references/TIME-ENUMS-TAGS.md)。
## 避免可变全局变量
通过注入依赖代替修改包级变量。这使代码可以在不需要全局 save/restore 的情况下进行测试。
```go
type signer struct {
now func() time.Time // 注入的;测试中用固定时间替换
}
func newSigner() *signer {
return &signer{now: time.Now}
}
```
> 在决定全局变量是否合适、设计 New() + Default() 包状态模式,或用依赖注入替代可变全局变量时,请阅读 [references/GLOBAL-STATE.md](references/GLOBAL-STATE.md)。
## 加密随机数
不要使用 `math/rand` 或 `math/rand/v2` 生成密钥——这是一个**安全问题**。时间种子的生成器输出是可预测的。
```go
import "crypto/rand"
func Key() string { return rand.Text() }
```
对于文本输出,直接使用 `crypto/rand.Text`,或用 `encoding/hex` 或 `encoding/base64` 编码随机字节。
---
## Panic 与 Recover
仅在真正不可恢复的情况下使用 `panic`。库函数应避免 panic。
```go
func safelyDo(work *Work) {
defer func() {
if err := recover(); err != nil {
log.Println("work failed:", err)
}
}()
do(work)
}
```
**关键规则:**
- 绝不跨包边界暴露 panic——始终转换为 error
- 如果库确实无法在 `init()` 中完成初始化,可以接受 panic
- 使用 recover 隔离服务器 goroutine 处理器中的 panic
> 在编写 HTTP 服务器中的 panic 恢复、在解析器中使用 panic 作为内部控制流机制,或在 log.Fatal 和 panic 之间做选择时,请阅读 [references/PANIC-RECOVER.md](references/PANIC-RECOVER.md)。
## Must 函数
`Must` 函数在出错时 panic——**仅**在程序初始化阶段使用,因为失败意味着程序无法运行。
```go
var validID = regexp.MustCompile(`^[a-z][a-z0-9-]{0,62}$`)
var tmpl = template.Must(template.ParseFiles("index.html"))
```
> 在编写自定义 Must 函数、决定 Must 是否适用于特定调用点,或将可能失败的初始化包装在 panic 辅助函数中时,请阅读 [references/MUST-FUNCTIONS.md](references/MUST-FUNCTIONS.md)。
---
## 相关技能
- **错误处理**:在选择返回错误还是 panic,或在边界处包装错误时,参见 [go-error-handling](../go-error-handling/SKILL.md)
- **并发安全**:在使用互斥锁、原子操作或通道保护共享状态时,参见 [go-concurrency](../go-concurrency/SKILL.md)
- **接口检查**:在添加编译时接口满足检查(`var _ I = (*T)(nil)`)时,参见 [go-interfaces](../go-interfaces/SKILL.md)
- **数据结构复制**:在处理切片/map 内部结构或指针别名时,参见 [go-data-structures](../go-data-structures/SKILL.md)
@@ -0,0 +1,101 @@
# 在 API 边界处复制切片和 Map
> **来源**:Uber 风格指南
切片和 map 包含对其底层数据的引用。在 API 边界处复制它们,以防止调用者修改内部状态(反之亦然)。
## 接收切片和 Map
当函数存储调用者传入的切片或 map 时,始终进行防御性复制。调用者保留原始引用,可以在函数返回后修改它。
### 切片
**不好**
```go
func (d *Driver) SetTrips(trips []Trip) {
d.trips = trips // 调用者仍然可以修改 d.trips
}
```
**好**
```go
func (d *Driver) SetTrips(trips []Trip) {
d.trips = make([]Trip, len(trips))
copy(d.trips, trips)
}
```
### Map
**不好**
```go
func (s *Server) SetConfig(cfg map[string]string) {
s.config = cfg // 调用者仍然可以修改 s.config
}
```
**好**
```go
func (s *Server) SetConfig(cfg map[string]string) {
s.config = make(map[string]string, len(cfg))
for k, v := range cfg {
s.config[k] = v
}
}
```
## 返回切片和 Map
返回内部切片或 map 时,返回副本以防止调用者修改你的内部状态。
### 返回 Map
**不好**
```go
func (s *Stats) Snapshot() map[string]int {
s.mu.Lock()
defer s.mu.Unlock()
return s.counters // 暴露了内部状态!
}
```
**好**
```go
func (s *Stats) Snapshot() map[string]int {
s.mu.Lock()
defer s.mu.Unlock()
result := make(map[string]int, len(s.counters))
for k, v := range s.counters {
result[k] = v
}
return result
}
```
### 返回切片
**不好**
```go
func (q *Queue) Items() []Item {
return q.items // 调用者可以追加、修改或重新切片
}
```
**好**
```go
func (q *Queue) Items() []Item {
result := make([]Item, len(q.items))
copy(result, q.items)
return result
}
```
## 何时不需要复制
防御性复制有开销。在以下情况下可以跳过:
- 数据**按约定是不可变的**,并且有清晰的文档说明
- 切片/map 是**为调用者新创建的**(不在内部存储)
- 性能分析表明复制在热路径中是瓶颈
如有疑问,就复制。与共享引用导致的 bug 相比,开销通常可以忽略不计。
@@ -0,0 +1,144 @@
# 全局状态模式
> **来源**:Google 风格指南, Effective Go
全局状态使程序更难以测试、推理和维护。依赖注入是首选替代方案,但某些全局状态在谨慎使用时是可以接受的。
## 何时可以接受全局状态
并非所有包级变量都有害。当全局状态是**真正进程级别的**且**不值得注入**时,它是合适的:
- **默认实例**——`http.DefaultClient`、`log.Default()`、`flag.CommandLine`
- **一次编译的值**——包级别的 `regexp.MustCompile(...)`
- **注册表**——`database/sql.Register`、`image.RegisterFormat`
- **单例基础设施**——进程级别的指标收集器或追踪导出器
## 全局变量的试金石测试
在添加包级变量之前,请问自己:
1. **它是否真正是进程级别的?** 如果两个 goroutine 或测试可能需要不同的值,它不应该是全局的
2. **它是否妨碍了测试?** 如果测试必须保存/恢复变量,或因此无法并行运行,应改为注入
3. **它可以是常量吗?** 如果值在初始化后永远不会改变,优先使用 `const` 或未导出的只初始化一次的 `var`
4. **它是否携带可变状态?** 可变全局变量是最危险的——仅在有完善文档、并发安全的单例情况下才可接受
## 包状态 API 模式:New() + Default()
标准库模式同时提供可定制的构造器和便捷的默认值。这使调用者可以在简单场景下使用默认值,在测试或特殊行为需求下注入自定义实例。
**好**
```go
package mylog
type Logger struct {
prefix string
out io.Writer
}
func New(prefix string, out io.Writer) *Logger {
return &Logger{prefix: prefix, out: out}
}
var defaultLogger = New("", os.Stderr)
func Default() *Logger { return defaultLogger }
func (l *Logger) Info(msg string) {
fmt.Fprintf(l.out, "%s%s\n", l.prefix, msg)
}
// 包级便捷函数委托给默认实例。
func Info(msg string) { defaultLogger.Info(msg) }
```
```go
// 调用者在简单场景下使用默认值
mylog.Info("starting server")
// 测试或特殊代码创建自定义实例
logger := mylog.New("[test] ", &buf)
logger.Info("test message")
```
此模式的标准库示例:
- `log.New()` + `log.Default()` + `log.Println()`
- `http.NewServeMux()` + `http.DefaultServeMux`
- `flag.NewFlagSet()` + `flag.CommandLine`
## 依赖注入作为首选替代方案
当代码需要可配置行为时,通过构造器参数或结构体字段接受依赖,而非读取包级变量。
**不好**
```go
var db *sql.DB
func GetUser(id int) (*User, error) {
return db.QueryRow("SELECT ...", id) // 依赖全局变量
}
```
**好**
```go
type UserStore struct {
db *sql.DB
}
func NewUserStore(db *sql.DB) *UserStore {
return &UserStore{db: db}
}
func (s *UserStore) GetUser(id int) (*User, error) {
return s.db.QueryRow("SELECT ...", id)
}
```
注入的好处:
- 测试可以提供 mock 或内存实现
- 多个实例可以共存(例如,只读副本与主库)
- 依赖在构造器签名中是显式的
## 注入时间
一个常见场景:替换 `time.Now` 以实现确定性测试。
**不好**
```go
func IsExpired(expiry time.Time) bool {
return time.Now().After(expiry) // 不可测试
}
```
**好**
```go
type Checker struct {
now func() time.Time
}
func NewChecker() *Checker {
return &Checker{now: time.Now}
}
func (c *Checker) IsExpired(expiry time.Time) bool {
return c.now().After(expiry)
}
```
测试用固定函数替换 `now`:
```go
c := &Checker{now: func() time.Time {
return time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
}}
```
## 总结
| 场景 | 方法 |
|------|------|
| 进程级单例(日志、指标) | 默认实例 + `New()` 构造器 |
| 一次编译的正则或模板 | 包级 `var` 配合 `MustCompile` |
| 注册表(数据库驱动、编解码器) | 包级 `Register()` 函数 |
| 可配置行为 | 通过构造器进行依赖注入 |
| 时间相关逻辑 | 注入 `func() time.Time` |
| 测试需要变化的任何东西 | 不要使用全局状态 |
@@ -0,0 +1,92 @@
# Must 函数
> **来源**:Uber 风格指南, Go 标准库约定
`Must` 函数包装一个可能失败的函数,在出错时 panic。**仅**在程序初始化阶段使用,因为失败意味着程序无法运行。
## 标准库示例
```go
// regexp.MustCompile 在模式无效时 panic
var validID = regexp.MustCompile(`^[a-z][a-z0-9-]{0,62}$`)
// template.Must 在模板解析失败时 panic
var tmpl = template.Must(template.ParseFiles("index.html"))
```
这些是安全的,因为它们在包初始化时运行——如果失败,程序无法正确运行。
## 何时使用 Must
```
这是在程序初始化期间调用的吗(包级 var、init、main 设置)?
├─ 是 → 失败是否不可恢复(配置、正则、模板)?
│ ├─ 是 → 使用 Must 是合适的
│ └─ 否 → 改为返回 error
└─ 否 → 绝不使用 Must——返回 error
```
### 适当的使用场景
- **包级 `var`**:编译正则表达式、解析模板、加载必需的配置
- **`init()` 或 `main()` 早期**:设置程序运行所必需的资源
- **测试辅助函数**:测试中优先使用 `t.Fatal`,但 Must 在测试 fixture 中是可以接受的
### 绝不使用 Must 的场景
- 运行时请求处理
- 用户提供的输入
- 可能合理失败的网络或文件操作
- 程序启动后调用的任何代码
## 编写 Must 函数
遵循命名约定 `MustX`,其中 `X` 是可能失败的函数名:
```go
func MustParseConfig(path string) *Config {
cfg, err := ParseConfig(path)
if err != nil {
panic(fmt.Sprintf("parsing config %s: %v", path, err))
}
return cfg
}
```
### 指南
- **命名**:`Must` 前缀 + 可能失败的函数名(例如 `MustParse`、`MustNew`、`MustCompile`)
- **Panic 消息**:包含输入和错误信息以便调试
- **文档**:始终记录函数在出错时会 panic
```go
// MustParseConfig 解析路径处的配置文件。
// 如果文件无法读取或包含无效配置,则会 panic。
func MustParseConfig(path string) *Config { ... }
```
### 泛型 Must 辅助函数
对于一次性使用,泛型 Must 辅助函数可以避免样板代码:
```go
func Must[T any](v T, err error) T {
if err != nil {
panic(err)
}
return v
}
// 在包级别使用
var cfg = Must(ParseConfig("app.yaml"))
```
## 与 Panic/Recover 的关系
Must 函数是对 `panic` 的受控使用。它们应该:
- 仅在初始化期间运行(因此不需要 recover)
- 产生清晰、可操作的 panic 消息
- 绝不在可以返回 error 的场景中使用
完整的 panic/recover 模式请参见 [PANIC-RECOVER.md](PANIC-RECOVER.md)。
@@ -0,0 +1,161 @@
# Panic 与 Recover 模式
> **来源**:Effective Go
## Panic 指南
`panic` 创建一个运行时错误来停止程序。仅在真正不可恢复的情况下使用。
### 何时 Panic
真正的库函数应**避免 panic**。如果问题可以被掩盖或绕过,让程序继续运行,而不是让整个程序崩溃。
```go
// 可接受:真正不可能的情况
func CubeRoot(x float64) float64 {
z := x/3
for i := 0; i < 1e6; i++ {
prevz := z
z -= (z*z*z-x) / (3*z*z)
if veryClose(z, prevz) {
return z
}
}
// 百万次迭代仍未收敛;出了问题。
panic(fmt.Sprintf("CubeRoot(%g) did not converge", x))
}
```
### 初始化中的 Panic
例外:如果库在 `init()` 期间确实无法完成初始化,panic 可能是合理的:
```go
var user = os.Getenv("USER")
func init() {
if user == "" {
panic("no value for $USER")
}
}
```
### 何时 Panic 是可接受的
除了初始化之外,panic 在以下窄泛场景中是可接受的:
1. **API 误用**——类似于核心语言对越界访问的 panic。`reflect` 包使用了这种方法。
2. **带有匹配 `recover` 的内部实现细节**在包边界处。Panic 简化了深层嵌套的控制流,而公共 API 仍然返回 error(下方的 Parse/parseInt 模式)。
3. **`panic("unreachable")`** 在 `log.Fatal` 之后,当编译器无法检测到不可达代码时。
#### Parse/parseInt 模式
在内部使用 panic 来回退复杂的递归,但始终在包边界处转换为 error:
```go
func parseInt(in string) int {
n, err := strconv.Atoi(in)
if err != nil {
panic(&syntaxError{"not a valid integer"})
}
return n
}
func Parse(in string) (_ *Node, err error) {
defer func() {
if p := recover(); p != nil {
sErr, ok := p.(*syntaxError)
if !ok {
panic(p) // 不是我们的——重新 panic
}
err = fmt.Errorf("syntax error: %v", sErr.msg)
}
}()
// ... 内部调用 parseInt
}
```
**关键**:类型检查 `p.(*syntaxError)` 确保只捕获*我们的* panic。意外的 panic(nil 指针等)正常传播。
---
## Recover 模式
`recover` 重新获得对正在 panic 的 goroutine 的控制。它只在延迟函数中有效。
### 基本恢复模式
```go
func safelyDo(work *Work) {
defer func() {
if err := recover(); err != nil {
log.Println("work failed:", err)
}
}()
do(work)
}
```
### 服务器 Goroutine 保护
在服务器中将 panic 隔离到各个 goroutine:
```go
func server(workChan <-chan *Work) {
for work := range workChan {
go safelyDo(work) // 每个 worker 都受保护
}
}
```
如果 `do(work)` panic,结果会被记录,goroutine 干净退出而不影响其他 goroutine。
### 包内部的 Panic/Recover
在内部使用 panic 但在 API 边界处转换为 error:
```go
// Error 是一个解析错误类型
type Error string
func (e Error) Error() string { return string(e) }
// 内部:使用 Error 类型 panic
func (regexp *Regexp) error(err string) {
panic(Error(err))
}
// 外部 API:将 panic 转换为 error 返回
func Compile(str string) (regexp *Regexp, err error) {
regexp = new(Regexp)
defer func() {
if e := recover(); e != nil {
regexp = nil
err = e.(Error) // 如果不是我们的 Error 类型则重新 panic
}
}()
return regexp.doParse(str), nil
}
```
**要点:**
- 延迟函数可以修改命名返回值
- 类型断言 `e.(Error)` 对意外错误类型重新 panic
- 绝不向客户端暴露 panic——始终在 API 边界处转换
---
## 快速参考
| 模式 | 描述 |
|------|------|
| 基本恢复 | `defer func() { if err := recover(); err != nil { ... } }()` |
| 服务器保护 | 将每个 goroutine 处理器包装在 safelyDo 中 |
| 包内部 | 内部 panic,在 API 边界处 recover 并返回 error |
| 类型安全恢复 | 使用类型断言对意外错误重新 panic |
## 何时使用
- **Panic**:仅用于真正不可恢复的情况或初始化失败
- **Recover**:服务器处理器、包内部错误简化
- **绝不**:跨包边界暴露 panic——始终转换为 error
@@ -0,0 +1,111 @@
# 时间、结构体标签和嵌入模式
## 使用 time.Time 和 time.Duration
始终使用 `time` 包。避免使用原始 `int` 表示时间值。
### 时间点
**不好**
```go
func isActive(now, start, stop int) bool {
return start <= now && now < stop
}
```
**好**
```go
func isActive(now, start, stop time.Time) bool {
return (start.Before(now) || start.Equal(now)) && now.Before(stop)
}
```
### 时长
**不好**
```go
func poll(delay int) {
time.Sleep(time.Duration(delay) * time.Millisecond)
}
poll(10) // 秒?毫秒?
```
**好**
```go
func poll(delay time.Duration) {
time.Sleep(delay)
}
poll(10 * time.Second)
```
### JSON 字段
当无法使用 `time.Duration` 时,在字段名中包含单位:
**不好**
```go
type Config struct {
Interval int `json:"interval"`
}
```
**好**
```go
type Config struct {
IntervalMillis int `json:"intervalMillis"`
}
```
## 避免在公共结构体中嵌入类型
嵌入类型会泄露实现细节并阻碍类型演进。
**不好**
```go
type ConcreteList struct {
*AbstractList
}
```
**好**
```go
type ConcreteList struct {
list *AbstractList
}
func (l *ConcreteList) Add(e Entity) {
l.list.Add(e)
}
func (l *ConcreteList) Remove(e Entity) {
l.list.Remove(e)
}
```
嵌入的问题:
- 向嵌入接口添加方法是破坏性变更
- 从嵌入结构体移除方法是破坏性变更
- 替换嵌入类型是破坏性变更
## 在序列化结构体中使用字段标签
始终为 JSON、YAML 等使用显式字段标签。
**不好**
```go
type Stock struct {
Price int
Name string
}
```
**好**
```go
type Stock struct {
Price int `json:"price"`
Name string `json:"name"`
// 可以安全地将 Name 重命名为 Symbol
}
```
标签使序列化契约显式化,并可以安全地进行重构。
+167
View File
@@ -0,0 +1,167 @@
---
name: go-documentation
description: 在编写或审查 Go 包、类型、函数或方法的文档时使用。在创建新的导出类型、函数或包时也应主动使用,即使用户没有明确询问文档问题。不涵盖未导出符号的代码注释(参见 go-style-core)。
license: Apache-2.0
metadata:
sources: "Google 风格指南"
allowed-tools: Bash(bash:*)
---
# Go 文档
## 可用脚本
- **`scripts/check-docs.sh`** — 报告缺少文档注释的导出函数、类型、方法、常量和包。运行 `bash scripts/check-docs.sh --help` 查看选项。
> 在为新包或导出类型编写文档注释并需要所有文档约定的完整参考时,请参阅 `assets/doc-template.go`。
---
## 文档注释
> **规范**:所有顶层导出名称必须有文档注释。
### 基本规则
1. 以被描述对象的名称开头
2. 冠词("a"、"an"、"the")可以放在名称前面
3. 使用完整句子(首字母大写,带标点符号)
```go
// A Request represents a request to run a command.
type Request struct { ...
// Encode writes the JSON encoding of req to w.
func Encode(w io.Writer, req *Request) { ...
```
行为不明显的未导出类型/函数也应有文档注释。
> **验证**:添加文档注释后,运行 `bash scripts/check-docs.sh` 验证是否有导出符号缺少文档。修复所有缺失后再继续。
---
## 注释语句
> **规范**:文档注释必须是完整的句子。
- 首字母大写,以标点符号结尾
- 例外:如果含义清晰,可以以小写标识符开头
- 结构体字段的行尾注释可以是短语
---
## 注释行长度
> **建议**:目标约 80 列,但不设硬性限制。
根据标点符号换行。不要拆分长 URL。
---
## 结构体文档
使用段落注释对字段分组。标记可选字段及默认值:
```go
type Options struct {
// 通用设置:
Name string
Group *FooGroup
// 自定义设置:
LargeGroupThreshold int // 可选;默认值:10
}
```
---
## 包注释
> **规范**:每个包必须有且仅有一个包注释。
```go
// Package math provides basic constants and mathematical functions.
package math
```
- 对于 `main` 包,使用二进制名称:`// The seed_generator command ...`
- 对于较长的包注释,使用 `doc.go` 文件
> 在编写包级文档、main 包注释、doc.go 文件或可运行示例时,请阅读 [references/EXAMPLES.md](references/EXAMPLES.md)。
---
## 文档编写要点
> **建议**:记录非显而易见的行为,显而易见的行为无需记录。
| 主题 | 何时记录... | 何时跳过... |
|------|------------|------------|
| 参数 | 非显而易见的行为、边界情况 | 只是重复类型签名 |
| 上下文 | 行为与标准取消不同 | 标准 `ctx.Err()` 返回 |
| 并发 | 线程安全性不明确(例如,看似读取但内部修改) | 只读安全、修改不安全 |
| 清理 | 始终记录资源释放要求 | — |
| 错误 | 哨兵值、错误类型(使用 `*PathError`) | — |
| 命名返回值 | 多个同类型参数、面向操作命名 | 类型本身已足够清晰 |
关键原则:
- 上下文取消返回 `ctx.Err()` 是隐含的 — 不要重复说明
- 只读操作默认线程安全;修改操作默认不安全 — 不要重复说明
- 始终记录清理要求(例如,`Call Stop to release resources`)
- 在错误类型文档中使用指针(`*PathError`),以确保 `errors.Is`/`errors.As` 正确使用
- 不要仅为启用裸返回而命名返回值 — 清晰性 > 简洁性
> 在记录参数行为、上下文取消、并发安全性、清理要求、错误返回或函数文档注释中的命名返回参数时,请阅读 [references/CONVENTIONS.md](references/CONVENTIONS.md)。
---
## 可运行示例
> **建议**:在测试文件(`*_test.go`)中提供可运行示例。
```go
func ExampleConfig_WriteTo() {
cfg := &Config{Name: "example"}
cfg.WriteTo(os.Stdout)
// Output:
// {"name": "example"}
}
```
示例会出现在 Godoc 中,附加到对应的文档元素上。
> 在编写可运行 Example 函数、选择示例命名约定(Example vs ExampleType_Method)或添加包级 doc.go 文件时,请阅读 [references/EXAMPLES.md](references/EXAMPLES.md)。
---
## Godoc 格式化
> 在格式化 godoc 标题、链接、列表或代码块,使用信号增强来标记弃用通知,或在本地预览文档输出时,请阅读 [references/FORMATTING.md](references/FORMATTING.md)。
---
## 快速参考
| 主题 | 关键规则 |
|------|---------|
| 文档注释 | 以名称开头,使用完整句子 |
| 行长度 | 约 80 字符,优先考虑可读性 |
| 包注释 | 每个包一个,放在 `package` 声明之前 |
| 参数 | 仅记录非显而易见的行为 |
| 上下文 | 记录与隐含行为不同的例外情况 |
| 并发 | 记录线程安全性不明确的情况 |
| 清理 | 始终记录资源释放要求 |
| 错误 | 记录哨兵值和类型(注意指针) |
| 示例 | 在测试文件中使用可运行示例 |
| 格式化 | 空行分隔段落,缩进表示代码 |
---
## 相关技能
- **命名约定**:在为文档注释描述的标识符选择名称时,参见 [go-naming](../go-naming/SKILL.md)
- **测试示例**:在编写出现在 godoc 中的可运行 `Example` 测试函数时,参见 [go-testing](../go-testing/SKILL.md)
- **Lint 强制执行**:在使用 revive 或其他 linter 强制执行文档注释存在性时,参见 [go-linting](../go-linting/SKILL.md)
- **风格原则**:在平衡文档详细程度与清晰简洁时,参见 [go-style-core](../go-style-core/SKILL.md)
@@ -0,0 +1,61 @@
// Package example demonstrates proper Go documentation conventions.
//
// This package shows how to write doc comments for packages, types,
// functions, methods, and constants following Google Go Style Guide
// conventions.
//
// # Getting Started
//
// Create a new Widget with [NewWidget]:
//
// w := example.NewWidget("name")
// defer w.Close()
package example
import "errors"
// ErrNotFound is returned when a requested item does not exist.
var ErrNotFound = errors.New("example: not found")
// MaxRetries is the default number of retry attempts.
const MaxRetries = 3
// Widget processes items with configurable options.
//
// A zero-value Widget is not valid; use [NewWidget] to create one.
// Widget is safe for concurrent use.
//
// # Cleanup
//
// Call [Widget.Close] when done to release resources.
type Widget struct {
name string
}
// NewWidget creates a Widget with the given name.
//
// Name must be non-empty; NewWidget panics otherwise.
func NewWidget(name string) *Widget {
if name == "" {
panic("example: name must be non-empty")
}
return &Widget{name: name}
}
// Process handles the given input and returns the result.
//
// Process returns [ErrNotFound] if the input references
// a missing item.
func (w *Widget) Process(input string) (string, error) {
return input, nil
}
// Close releases resources held by the Widget.
func (w *Widget) Close() error {
return nil
}
// Deprecated: Use [NewWidget] with functional options instead.
func NewWidgetLegacy(name string) *Widget {
return NewWidget(name)
}
@@ -0,0 +1,239 @@
# 文档约定参考
## 参数和配置
> **建议**:记录容易出错或非显而易见的参数,而非所有参数。
```go
// 不好:重复了显而易见的信息
// Sprintf formats according to a format specifier and returns the resulting string.
//
// format is the format, and data is the interpolation data.
func Sprintf(format string, data ...any) string
// 好:记录了非显而易见的行为
// Sprintf formats according to a format specifier and returns the resulting string.
//
// The provided data is used to interpolate the format string. If the data does
// not match the expected format verbs or the amount of data does not satisfy
// the format specification, the function will inline warnings about formatting
// errors into the output string.
func Sprintf(format string, data ...any) string
```
---
## 上下文
> **建议**:不要重复隐含的上下文行为;记录例外情况。
上下文取消被隐含地认为会中断函数并返回 `ctx.Err()`。不要记录这一点。
```go
// 不好:重复了隐含的行为
// Run executes the worker's run loop.
//
// The method will process work until the context is cancelled.
func (Worker) Run(ctx context.Context) error
// 好:只记录关键信息
// Run executes the worker's run loop.
func (Worker) Run(ctx context.Context) error
```
**当行为不同时记录:**
```go
// 好:非标准的取消行为
// Run executes the worker's run loop.
//
// If the context is cancelled, Run returns a nil error.
func (Worker) Run(ctx context.Context) error
// 好:特殊的上下文要求
// NewReceiver starts receiving messages sent to the specified queue.
// The context should not have a deadline.
func NewReceiver(ctx context.Context) *Receiver
```
---
## 并发
> **建议**:记录非显而易见的线程安全特性。
只读操作被认为是安全的;修改操作被认为是不安全的。不要重复说明这一点。
**何时记录:**
```go
// 不明确的操作(看似只读但内部有修改)
// Lookup returns the data associated with the key from the cache.
//
// This operation is not safe for concurrent use.
func (*Cache) Lookup(key string) (data []byte, ok bool)
// API 提供同步机制
// NewFortuneTellerClient returns an *rpc.Client for the FortuneTeller service.
// It is safe for simultaneous use by multiple goroutines.
func NewFortuneTellerClient(cc *rpc.ClientConn) *FortuneTellerClient
// 接口有并发要求
// A Watcher reports the health of some entity (usually a backend service).
//
// Watcher methods are safe for simultaneous use by multiple goroutines.
type Watcher interface {
Watch(changed chan<- bool) (unwatch func())
Health() error
}
```
---
## 清理
> **建议**:始终记录显式清理要求。
```go
// 好:
// NewTicker returns a new Ticker containing a channel that will send the
// current time on the channel after each tick.
//
// Call Stop to release the Ticker's associated resources when done.
func NewTicker(d Duration) *Ticker
// 好:展示如何清理
// Get issues a GET to the specified URL.
//
// When err is nil, resp always contains a non-nil resp.Body.
// Caller should close resp.Body when done reading from it.
//
// resp, err := http.Get("http://example.com/")
// if err != nil {
// // handle error
// }
// defer resp.Body.Close()
// body, err := io.ReadAll(resp.Body)
func (c *Client) Get(url string) (resp *Response, err error)
```
---
## 错误
> **建议**:记录重要的错误哨兵值和类型。
```go
// 好:记录哨兵值
// Read reads up to len(b) bytes from the File and stores them in b.
//
// At end of file, Read returns 0, io.EOF.
func (*File) Read(b []byte) (n int, err error)
// 好:记录错误类型(包含指针接收者)
// Chdir changes the current working directory to the named directory.
//
// If there is an error, it will be of type *PathError.
func Chdir(dir string) error
```
注意使用 `*PathError`(而非 `PathError`)可以确保 `errors.Is` 和 `errors.As` 的正确使用。
对于包级别的错误约定,在包注释中记录。
---
## 命名返回参数
> **建议**:在类型本身不够清晰时用于文档说明。
```go
// 好:多个同类型参数
func (n *Node) Children() (left, right *Node, err error)
// 好:面向操作的名称阐明了用法
// The caller must arrange for the returned cancel function to be called.
func WithTimeout(parent Context, d time.Duration) (ctx Context, cancel func())
// 不好:类型已经很清晰,命名没有增加信息
func (n *Node) Parent1() (node *Node)
func (n *Node) Parent2() (node *Node, err error)
// 好:类型已足够
func (n *Node) Parent1() *Node
func (n *Node) Parent2() (*Node, error)
```
不要仅为启用裸返回而命名返回值。清晰性 > 简洁性。
---
## 弃用通知
> **建议**:使用 `// Deprecated:` 注释标记符号为已弃用。
`Deprecated:` 段落必须出现在文档注释中紧接在符号之前。应说明使用什么替代。
**标准格式:**
```
// Deprecated: Use NewThing instead.
```
Godoc 会以特殊的视觉样式渲染 `Deprecated:` 注释,使其容易被发现。
**函数弃用:**
```go
// EstimateSize returns an approximate byte count.
//
// Deprecated: Use [Size] instead, which returns an exact count.
func EstimateSize(r io.Reader) (int64, error)
```
**类型弃用:**
```go
// LegacyClient talks to the v1 API.
//
// Deprecated: Use [Client] instead, which supports v2.
type LegacyClient struct{ /* ... */ }
```
**包弃用** — 在包文档注释中添加 `Deprecated:`:
```go
// Package old provides the original implementation.
//
// Deprecated: Use package example/new instead.
package old
```
始终建议具体的替代方案,让调用者知道迁移目标。
---
## 注释语句 — 详细说明
> **规范**:文档注释必须是完整的句子。
- 首字母大写,以标点符号结尾
- 例外:如果含义清晰,可以以小写标识符开头
- 结构体字段的行尾注释可以是短语:
```go
// 好:
// A Server handles serving quotes from Shakespeare.
type Server struct {
// BaseDir points to the base directory for Shakespeare's works.
//
// Expected structure:
// {BaseDir}/manifest.json
// {BaseDir}/{name}/{name}-part{number}.txt
BaseDir string
WelcomeMessage string // 用户登录时显示
ProtocolVersion string // 与传入请求进行校验
PageLength int // 每页行数(可选;默认值:20)
}
```
@@ -0,0 +1,107 @@
# 包注释和示例参考
## 包注释
> **规范**:每个包必须有且仅有一个包注释。
```go
// 好:
// Package math provides basic constants and mathematical functions.
//
// This package does not guarantee bit-identical results across architectures.
package math
```
### Main 包
使用二进制名称(与 BUILD 文件匹配):
```go
// 好:
// The seed_generator command is a utility that generates a Finch seed file
// from a set of JSON study configs.
package main
```
有效格式:`Binary seed_generator`、`Command seed_generator`、`The seed_generator command`、`Seed_generator ...`
### doc.go
- 对于较长的包注释,使用仅包含包注释和 `package` 声明的 `doc.go` 文件
- 放在 import 之后的维护者注释不会出现在 Godoc 中
- 保持 doc.go 文件专注于面向用户的文档
```go
// Package complex provides advanced mathematical operations for
// complex number arithmetic, including polar form conversion,
// matrix operations, and numerical integration.
//
// Basic usage
//
// Create a complex number and perform operations:
//
// z := complex.New(3, 4)
// magnitude := z.Abs() // 5.0
// conjugate := z.Conj() // (3, -4)
//
// Matrix operations
//
// The package supports complex-valued matrices:
//
// m := complex.NewMatrix(2, 2)
// m.Set(0, 0, complex.New(1, 0))
// det := m.Det()
package complex
```
---
## 可运行示例
> **建议**:提供可运行示例来展示包的用法。
将示例放在测试文件(`*_test.go`)中:
```go
// 好:
func ExampleConfig_WriteTo() {
cfg := &Config{
Name: "example",
}
if err := cfg.WriteTo(os.Stdout); err != nil {
log.Exitf("Failed to write config: %s", err)
}
// Output:
// {
// "name": "example"
// }
}
```
示例会出现在 Godoc 中,附加到对应的文档元素上。
### 命名约定
| 函数名称 | 文档对象 |
|----------|---------|
| `Example()` | 包级别示例 |
| `ExampleFoo()` | 函数 `Foo` |
| `ExampleBar_Baz()` | 方法 `Bar.Baz` |
| `ExampleFoo_suffix()` | `Foo` 示例的命名变体 |
### 技巧
- 使用 `// Output:` 注释使示例可通过 `go test` 进行测试和验证
- 保持示例专注于展示一个概念
- 使用真实但精简的数据
- 对于复杂的设置,使用 `testMain` 或辅助函数保持示例主体简洁
- 同一符号的多个示例使用小写 `_suffix`:
```go
func ExampleNewClient_withTimeout() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
client := NewClient(ctx)
// ...
}
```
@@ -0,0 +1,85 @@
# Godoc 格式化参考
## Godoc 格式化
> **建议**:使用 godoc 语法编写格式良好的文档。
**段落** - 用空行分隔:
```go
// 好:
// LoadConfig reads a configuration out of the named file.
//
// See some/shortlink for config file format details.
```
**逐字/代码块** - 额外缩进两个空格:
```go
// 好:
// Update runs the function in an atomic transaction.
//
// This is typically used with an anonymous TransactionFunc:
//
// if err := db.Update(func(state *State) { state.Foo = bar }); err != nil {
// //...
// }
```
**列表和表格** - 使用逐字格式:
```go
// 好:
// LoadConfig treats the following keys in special ways:
// "import" will make this configuration inherit from the named file.
// "env" if present will be populated with the system environment.
```
**标题** - 单行,首字母大写,无标点(括号/逗号除外),后跟段落:
```go
// 好:
// Using headings
//
// Headings come with autogenerated anchor tags for easy linking.
```
---
## 信号增强
> **建议**:添加注释以突出不寻常或容易被忽略的模式。
以下两种情况很难区分:
```go
if err := doSomething(); err != nil { // 常见
// ...
}
if err := doSomething(); err == nil { // 不寻常!
// ...
}
```
添加注释来增强信号:
```go
// 好:
if err := doSomething(); err == nil { // 如果没有错误
// ...
}
```
---
## 文档预览
> **建议**:在代码审查之前和期间预览文档。
```bash
go install golang.org/x/pkgsite/cmd/pkgsite@latest
pkgsite
```
这可以验证 godoc 格式化是否正确渲染。
+298
View File
@@ -0,0 +1,298 @@
#!/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
+168
View File
@@ -0,0 +1,168 @@
---
name: go-error-handling
description: Use when writing Go code that returns, wraps, or handles errors — choosing between sentinel errors, custom types, and fmt.Errorf (%w vs %v), structuring error flow, or deciding whether to log or return. Also use when propagating errors across package boundaries or using errors.Is/As, even if the user doesn't ask about error strategy. Does not cover panic/recover patterns (see go-defensive).
license: Apache-2.0
compatibility: Requires Go 1.13+ for errors.Is/errors.As and fmt.Errorf %w wrapping. Structured logging examples use slog (Go 1.21+).
metadata:
sources: "Google Style Guide, Uber Style Guide"
allowed-tools: Bash(bash:*)
---
# Go 错误处理
## 可用脚本
- **`scripts/check-errors.sh`** — 检测错误处理反模式:对 `err.Error()` 进行字符串比较、没有上下文的裸 `return err`、以及日志并返回违规。运行 `bash scripts/check-errors.sh --help` 查看选项。
在 Go 中,[错误是值](https://go.dev/blog/errors-are-values) — 它们由代码创建,也由代码消费。
## 选择错误策略
1. 系统边界(RPC、IPC、存储)?→ 使用 `%v` 包装以避免泄露内部细节
2. 调用者需要匹配特定条件?→ 哨兵或类型化错误,使用 `%w` 包装
3. 调用者只需要调试上下文?→ `fmt.Errorf("...: %w", err)`
4. 叶子函数,无需包装?→ 直接返回错误
**默认**:使用 `%w` 包装,并将其放在格式字符串的末尾。
---
## 核心规则
### 永不返回具体错误类型
**永不从导出函数返回具体错误类型** — 具体的 `nil` 指针可能变成非 nil 接口:
```go
// 不好:具体类型可能导致微妙的 bug
func Bad() *os.PathError { /*...*/ }
// 好:始终返回 error 接口
func Good() error { /*...*/ }
```
### 错误字符串
错误字符串**不应**大写,也**不应**以标点符号结尾。例外:导出名称、专有名词或缩写。
```go
// 不好
err := fmt.Errorf("Something bad happened.")
// 好
err := fmt.Errorf("something bad happened")
```
对于显示的消息(日志、测试失败、API 响应),大写是适当的。
### 出错时的返回值
当函数返回错误时,调用者必须将所有非错误返回值视为未指定,除非有明确文档说明。
**提示**:接受 `context.Context` 的函数通常应返回 `error`,以便调用者判断上下文是否被取消。
---
## 处理错误
遇到错误时,做出**深思熟虑的选择** — 不要用 `_` 丢弃:
1. **立即处理** — 解决错误并继续
2. **返回给调用者** — 可选择用上下文包装
3. **在特殊情况下** — `log.Fatal` 或 `panic`
有意忽略时:添加注释说明原因。
```go
n, _ := b.Write(p) // 永不返回非 nil 错误
```
对于相关的并发操作,使用 [`errgroup`](https://pkg.go.dev/golang.org/x/sync/errgroup):
```go
g, ctx := errgroup.WithContext(ctx)
g.Go(func() error { return task1(ctx) })
g.Go(func() error { return task2(ctx) })
if err := g.Wait(); err != nil { return err }
```
### 避免带内错误
不要返回 `-1`、`nil` 或空字符串来表示错误。使用多返回值:
```go
// 不好:带内错误值
func Lookup(key string) int // 缺失时返回 -1
// 好:显式的 error 或 ok 值
func Lookup(key string) (string, bool)
```
这可以防止调用者写出 `Parse(Lookup(key))` — 它会导致编译时错误,因为 `Lookup(key)` 有 2 个输出。
---
## 错误流程
在正常代码之前处理错误。提前返回使正常路径保持无缩进:
```go
// 好:错误优先,正常代码无缩进
if err != nil {
return err
}
// 正常代码
```
**错误只处理一次** — 记录日志或返回,不要两者都做:
```
遇到错误?
├─ 调用者可以采取行动?→ 返回(通过 %w 附带上下文)
├─ 在调用链顶部?→ 记录日志并处理
└─ 都不是?→ 以适当级别记录日志,继续执行
```
> 在组织复杂的错误流程、决定记录日志还是返回、实现一次处理模式、或选择结构化日志级别时,请阅读 [references/ERROR-FLOW.md](references/ERROR-FLOW.md)。
---
## 错误类型
> **建议**:推荐的最佳实践。
| 调用者需要匹配? | 消息类型 | 使用方式 |
|-----------------|---------|---------|
| 否 | 静态 | `errors.New("message")` |
| 否 | 动态 | `fmt.Errorf("msg: %v", val)` |
| 是 | 静态 | `var ErrFoo = errors.New("...")` |
| 是 | 动态 | 自定义 `error` 类型 |
**默认**:使用 `fmt.Errorf("...: %w", err)` 包装。升级为哨兵以使用 `errors.Is()`,升级为自定义类型以使用 `errors.As()`。
> 在定义哨兵错误、创建自定义错误类型、或为包 API 选择错误策略时,请阅读 [references/ERROR-TYPES.md](references/ERROR-TYPES.md)。
---
## 错误包装
> **建议**:推荐的最佳实践。
- **使用 `%v`**:在系统边界、用于日志记录、隐藏内部细节
- **使用 `%w`**:保留错误链以供 `errors.Is`/`errors.As` 使用
**关键规则**:将 `%w` 放在末尾。添加调用者没有的上下文。如果注释没有增加信息,直接返回 `err`。
> 在决定使用 %v 还是 %w、跨包边界包装错误、或添加上下文信息时,请阅读 [references/WRAPPING.md](references/WRAPPING.md)。
> **验证**:实现错误处理后,运行 `bash scripts/check-errors.sh` 检测常见的反模式。然后运行 `go vet ./...` 捕获其他问题。
---
## 相关技能
- **错误命名**:在命名哨兵错误(`ErrFoo`)或自定义错误类型时,参见 [go-naming](../go-naming/SKILL.md)
- **测试错误**:在使用 `errors.Is`/`errors.As` 测试错误语义或编写错误检查辅助函数时,参见 [go-testing](../go-testing/SKILL.md)
- **Panic 处理**:在决定 panic 还是返回错误、或编写 recover 守卫时,参见 [go-defensive](../go-defensive/SKILL.md)
- **守卫子句**:在组织提前返回的错误流程或减少嵌套时,参见 [go-control-flow](../go-control-flow/SKILL.md)
- **日志决策**:在选择日志级别、配置结构化日志、或决定日志消息中包含什么上下文时,参见 [go-logging](../go-logging/SKILL.md)
@@ -0,0 +1,153 @@
# 错误流程模式
错误流程、一次处理原则和日志决策的详细模式。
## 缩进错误流程
在继续正常代码之前先处理错误。这通过使读者能够快速找到正常路径来提高可读性。
```go
// 好:错误处理优先,正常代码无缩进
if err != nil {
// 错误处理
return // 或 continue 等
}
// 正常代码
```
```go
// 不好:正常代码隐藏在 else 子句中
if err != nil {
// 错误处理
} else {
// 正常代码因缩进看起来不自然
}
```
### 避免对长期使用的变量使用 if 初始化语句
如果变量在多行中使用,将声明移出:
```go
// 好:声明与错误检查分开
x, err := f()
if err != nil {
return err
}
// 大量使用 x 的代码
// 跨越多行
```
```go
// 不好:变量作用域限制在 else 块中,难以阅读
if x, err := f(); err != nil {
return err
} else {
// 大量使用 x 的代码
// 跨越多行
}
```
---
## 错误只处理一次
当调用者收到错误时,应该**只处理一次**。选择一种响应方式:
1. **返回错误**(包装或原文)让调用者处理
2. **记录日志并优雅降级**(不返回错误)
3. **匹配并处理**特定错误情况,返回其他错误
**如果返回了错误,就不要自己记录日志** — 让调用者处理。对同一错误既记录日志又返回是最常见的"一次处理"违规,导致重复噪音,因为调用栈上层的调用者也会处理该错误。
```go
// 不好:既记录日志又返回 — 导致日志噪音
u, err := getUser(id)
if err != nil {
log.Printf("Could not get user %q: %v", id, err)
return err // 调用者也会记录这个!
}
// 好:包装并返回 — 让调用者决定如何处理
u, err := getUser(id)
if err != nil {
return fmt.Errorf("get user %q: %w", id, err)
}
// 好:记录日志并优雅降级(不返回错误)
if err := emitMetrics(); err != nil {
// 写入指标失败不应影响应用程序
log.Printf("Could not emit metrics: %v", err)
}
// 继续执行...
// 好:匹配特定错误,返回其他错误
tz, err := getUserTimeZone(id)
if err != nil {
if errors.Is(err, ErrUserNotFound) {
// 用户不存在,使用 UTC
tz = time.UTC
} else {
return fmt.Errorf("get user %q: %w", id, err)
}
}
```
---
## 记录日志 vs 返回错误
> 错误只处理一次 — 记录日志或返回,不要两者都做。
### 决策流程
```
遇到错误?
├─ 调用者可以采取行动?→ 返回错误(通过 %w 附带上下文)
├─ 在调用链顶部?→ 记录日志并处理(返回 HTTP 状态码、退出等)
└─ 都不是?→ 以适当级别记录日志并继续
```
### 不要既记录日志又返回
```go
// 不好:错误既被记录又被返回 — 在日志中出现两次
func process(ctx context.Context, id string) error {
result, err := fetch(ctx, id)
if err != nil {
log.Printf("failed to fetch %s: %v", id, err)
return fmt.Errorf("fetching %s: %w", id, err)
}
return handle(result)
}
// 好:带上下文返回 — 让调用者决定是否记录日志
func process(ctx context.Context, id string) error {
result, err := fetch(ctx, id)
if err != nil {
return fmt.Errorf("fetching %s: %w", id, err)
}
return handle(result)
}
```
### 结构化日志
在生产代码中,优先使用结构化日志(Go 1.21+ 的 `slog`,或 `log/slog` 兼容库)而非 `log.Printf`:
```go
// 好:结构化字段可被机器解析
slog.Error("fetch failed", "id", id, "err", err)
// 避免:非结构化的字符串插值
log.Printf("fetch failed for %s: %v", id, err)
```
### 日志级别
| 级别 | 使用场景 |
|------|---------|
| Error | 需要关注的可操作故障 |
| Warn | 不需要立即处理的降级行为 |
| Info | 关键生命周期事件(启动、关闭、配置加载) |
| Debug | 开发期间有用的诊断细节 |
@@ -0,0 +1,151 @@
# 错误类型参考
本参考涵盖结构化错误类型、哨兵错误,以及如何为你的用例选择正确的错误类型。
---
## 错误结构
> 错误类型决策表在父技能中(SKILL.md § 错误类型)。
> 本参考涵盖:扩展的代码示例、哨兵错误、使用 `errors.Is`/`errors.As` 进行错误检查,以及结构化错误类型。
**关键考虑因素**:
- 调用者是否需要使用 `errors.Is` 或 `errors.As` 来匹配错误?
- 错误消息是静态的还是需要运行时值?
- 导出的错误变量/类型将成为公共 API 的一部分
```go
// 无需匹配,静态消息
func Open() error {
return errors.New("could not open")
}
// 需要匹配,静态消息 - 导出哨兵
var ErrCouldNotOpen = errors.New("could not open")
func Open() error {
return ErrCouldNotOpen
}
// 需要匹配,动态消息 - 使用自定义类型
type NotFoundError struct {
File string
}
func (e *NotFoundError) Error() string {
return fmt.Sprintf("file %q not found", e.File)
}
func Open(file string) error {
return &NotFoundError{File: file}
}
```
---
## 哨兵错误
最简单的结构化错误是无参数化的全局值:
```go
// 好:用于程序化检查的哨兵错误
var (
// ErrDuplicate 在该动物已被见过时发生。
ErrDuplicate = errors.New("duplicate")
// ErrMarsupial 因为我们不支持有袋类动物。
ErrMarsupial = errors.New("marsupials are not supported")
)
func process(animal Animal) error {
switch {
case seen[animal]:
return ErrDuplicate
case marsupial(animal):
return ErrMarsupial
}
seen[animal] = true
return nil
}
```
---
## 检查错误
对于直接比较(当错误未被包装时):
```go
// 好:与哨兵直接比较
switch err := process(an); err {
case ErrDuplicate:
return fmt.Errorf("feed %q: %v", an, err)
case ErrMarsupial:
alternate := an.BackupAnimal()
return handlePet(alternate)
}
```
当错误可能被包装时,使用 `errors.Is`:
```go
// 好:适用于被包装的错误
switch err := process(an); {
case errors.Is(err, ErrDuplicate):
return fmt.Errorf("feed %q: %v", an, err)
case errors.Is(err, ErrMarsupial):
// 尝试恢复...
}
```
**绝不**基于字符串内容匹配错误:
```go
// 不好:脆弱的字符串匹配
if regexp.MatchString(`duplicate`, err.Error()) {...}
if regexp.MatchString(`marsupial`, err.Error()) {...}
```
---
## 结构化错误类型
对于需要额外程序化信息的错误,使用结构体类型:
```go
// 好:具有可访问字段的结构化错误
type PathError struct {
Op string
Path string
Err error
}
func (e *PathError) Error() string {
return e.Op + " " + e.Path + ": " + e.Err.Error()
}
func (e *PathError) Unwrap() error { return e.Err }
```
调用者可以使用 `errors.As` 提取结构化错误:
```go
var pathErr *os.PathError
if errors.As(err, &pathErr) {
fmt.Println("Failed path:", pathErr.Path)
}
```
---
## 快速参考
| 场景 | 错误类型 |
|------|---------|
| 无需匹配,静态消息 | `errors.New("message")` |
| 无需匹配,动态消息 | `fmt.Errorf("msg: %v", val)` |
| 需要匹配,静态消息 | `var ErrFoo = errors.New(...)` |
| 需要匹配,动态消息 | 自定义结构体类型 |
| 检查哨兵错误 | `errors.Is(err, ErrFoo)` |
| 提取结构化错误 | `errors.As(err, &target)` |
@@ -0,0 +1,174 @@
# 错误包装参考
本参考涵盖使用 `%v` vs `%w` 的错误包装、放置约定、向错误添加上下文以及日志最佳实践。
---
## 包装错误:%v vs %w
> **建议**:推荐的最佳实践。
`%v` 和 `%w` 的选择会显著影响错误的传播和检查方式。
### 使用 %v 进行简单注释
当你需要以下操作时使用 `%v`:
- 添加上下文但不保留错误链以供程序化检查
- 创建全新的、独立的错误(特别是在 RPC/IPC 等系统边界)
- 向人类记录或显示错误
```go
// 好:%v 在系统边界 — 隐藏内部细节
func (s *Server) SuggestFortune(ctx context.Context, req *pb.Request) (*pb.Response, error) {
if err != nil {
return nil, fmt.Errorf("couldn't find fortune database: %v", err)
}
}
```
### 使用 %w 保留错误链
当你需要调用者以编程方式检查底层错误时使用 `%w`:
```go
// 好:%w 保留错误链以供 errors.Is/errors.As 使用
func (s *Server) internalFunction(ctx context.Context) error {
if err != nil {
return fmt.Errorf("couldn't find remote file: %w", err)
}
}
// 调用者现在可以检查:
if errors.Is(err, fs.ErrNotExist) {
// 处理未找到的情况
}
```
### 何时使用哪种
**使用 %w 的场景**:
- 在添加上下文的同时保留原始错误以供程序化检查
- 你明确记录并测试了所暴露的底层错误
**使用 %v 的场景**:
- 在系统边界(RPC、IPC、存储)转换为规范错误空间
- 向人类记录日志或显示
- 创建隐藏实现细节的独立错误
---
## %w 的放置位置
> **建议**:推荐的最佳实践。
将 `%w` 放在错误字符串的**末尾**,使错误文本反映错误链结构:
```go
// 好:%w 在末尾 — 从最新到最旧打印
err1 := fmt.Errorf("err1")
err2 := fmt.Errorf("err2: %w", err1)
err3 := fmt.Errorf("err3: %w", err2)
fmt.Println(err3) // err3: err2: err1
```
```go
// 不好:%w 在开头 — 从最旧到最新打印(令人困惑)
err1 := fmt.Errorf("err1")
err2 := fmt.Errorf("%w: err2", err1)
err3 := fmt.Errorf("%w: err3", err2)
fmt.Println(err3) // err1: err2: err3
```
```go
// 不好:%w 在中间 — 不连贯的顺序
err1 := fmt.Errorf("err1")
err2 := fmt.Errorf("err2-1 %w err2-2", err1)
err3 := fmt.Errorf("err3-1 %w err3-2", err2)
fmt.Println(err3) // err3-1 err2-1 err1 err2-2 err3-2
```
**模式**:使用 `context message: %w` 的形式
---
## 向错误添加信息
> **建议**:推荐的最佳实践。
### 添加上下文,而非冗余
添加你拥有但调用者/被调用者可能没有的信息。避免重复底层错误已提供的信息:
```go
// 好:添加有意义的上下文
if err := os.Open("settings.txt"); err != nil {
return fmt.Errorf("launch codes unavailable: %v", err)
}
// 输出:launch codes unavailable: open settings.txt: no such file or directory
```
```go
// 不好:重复了文件名
if err := os.Open("settings.txt"); err != nil {
return fmt.Errorf("could not open settings.txt: %v", err)
}
// 输出:could not open settings.txt: open settings.txt: no such file or directory
```
### 不要无目的地注释
如果注释仅表示失败而没有添加信息,直接返回错误:
```go
// 不好:注释没有增加信息
return fmt.Errorf("failed: %v", err)
// 好:直接返回错误
return err
```
---
## 记录错误日志
> **建议**:推荐的最佳实践。
当需要记录错误时,使用 `log/slog`(Go 1.21+)配合结构化键值对和适当的日志级别:
- **`slog.Error`**:保留用于需要调查的可操作问题。
- **`slog.Warn`**:用于可能需要关注但不可立即操作的问题。
- **`slog.Debug`**:用于开发追踪 — 仅在 handler 级别设为 `LevelDebug` 时才输出。
```go
// 好:使用适当级别的结构化日志
for _, q := range queries {
slog.Debug("handling query", "query", q)
q.Run()
}
// 好:在级别检查后保护昂贵的格式化操作
if slog.Default().Enabled(context.Background(), slog.LevelDebug) {
slog.Debug("query plan", "explain", q.Explain())
}
// 不好:即使禁用了 debug 日志也会执行昂贵的调用
slog.Debug("query plan", "explain", q.Explain())
```
### 保护敏感信息
注意日志消息中的 PII(个人身份信息)。许多日志接收器不适合存放敏感用户数据。
---
## 快速参考
| 模式 | 指导 |
|------|------|
| `%v` | 在系统边界使用、用于日志记录、隐藏细节 |
| `%w` | 保留错误链以供程序化检查 |
| `%w` 放置 | 始终在末尾:`"context: %w"` |
| 添加上下文 | 添加新信息,不要重复现有信息 |
| 空注释 | 直接返回 `err` 而非 `fmt.Errorf("failed: %v", err)` |
| 日志 | 不要既记录日志又返回;使用适当的日志级别 |
+266
View File
@@ -0,0 +1,266 @@
#!/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
@@ -0,0 +1,210 @@
---
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
@@ -0,0 +1,129 @@
# 函数式选项 vs 配置结构体
> **来源**:Google 风格指南, Uber 风格指南
函数式选项和配置结构体解决相同的问题 — 构造函数的可选配置 — 但它们有不同的权衡。根据 API 受众、可扩展性需求和复杂性预算来选择。
## 决策框架
```
需要可选配置?
├─ 内部或仅测试 API?
│ └─ 配置结构体(更简单,更少样板代码)
├─ 具有 3 个以上选项的公共 API?
│ └─ 函数式选项(可扩展,向后兼容)
├─ 选项需要校验或有相互依赖?
│ └─ 函数式选项(在 apply 或构造函数中校验)
├─ 所有选项通常一起指定?
│ └─ 配置结构体(一个字面量,无需 With* 仪式)
└─ 选项可能随时间增长?
└─ 函数式选项(添加 With* 不会破坏调用者)
```
## 配置结构体模式
配置结构体将可选参数分组为传递给构造函数的单个结构体。零值作为默认值,或提供 `DefaultConfig()`。
**好**
```go
type Config struct {
Timeout time.Duration // 零 = 无超时
MaxRetry int // 零 = 无重试
Logger *log.Logger // nil = 丢弃
}
func NewClient(addr string, cfg Config) *Client {
if cfg.Logger == nil {
cfg.Logger = log.New(io.Discard, "", 0)
}
return &Client{addr: addr, cfg: cfg}
}
```
```go
c := NewClient("localhost:8080", Config{
Timeout: 5 * time.Second,
MaxRetry: 3,
})
```
**不好** — 在公共 API 中依赖未导出的配置字段:
```go
type config struct { // 未导出:调用者无法构造
timeout time.Duration
}
func NewClient(addr string, cfg config) *Client { ... }
```
### 当零值不适用时
如果零是一个有效的非默认值(例如,超时为 0 表示"无超时",但期望的默认值是 30s),使用指针字段或哨兵值:
```go
type Config struct {
Timeout *time.Duration // nil = 使用默认值(30s),零 = 无超时
}
```
## 比较
| 方面 | 函数式选项 | 配置结构体 |
|------|-----------|-----------|
| **样板代码** | 高(每个选项需要类型 + apply + With*) | 低(一个结构体) |
| **可扩展性** | 添加 `With*` — 无破坏性变更 | 添加字段 — 无破坏性变更 |
| **向后兼容** | 对公共 API 极好 | 好(新字段获得零值) |
| **默认值** | 内置于构造函数 | 零值或 `DefaultConfig()` |
| **校验** | 在 `apply` 或构造函数循环中 | 在接收到结构体后的构造函数中 |
| **可发现性** | `With*` 函数出现在 godoc 中 | 所有字段在一个结构体中可见 |
| **可测试性** | 比较选项或测试构造函数输出 | 比较结构体字面量 |
| **调用者体验** | 只指定与默认值不同的部分 | 必须构造结构体字面量 |
| **零值歧义** | 无 — 未设置的选项不应用 | 可能需要指针字段 |
## 何时优先使用配置结构体
- **内部 API** — 更少的仪式,在调用处更易读
- **少量选项(1-3 个)** — 函数式选项的开销不值得
- **所有选项通常一起设置** — 可变参数风格没有好处
- **不需要校验** — 简单的字段赋值即可
- **选项是数据而非行为** — 结构体字段自然映射
```go
srv := NewServer(Config{
Port: 8080,
TLSCert: "/path/to/cert.pem",
TLSKey: "/path/to/key.pem",
})
```
## 何时优先使用函数式选项
- **公共/库 API** — 调用者不应跟踪内部配置的演变
- **3 个以上选项**,每个都是可选的
- **复杂默认值** — 默认值计算依赖于其他选项
- **按选项校验** — 在 apply 时拒绝无效值
- **选项可能增长** — 新的 `With*` 函数是纯粹增量的
```go
srv := NewServer(
WithPort(8080),
WithTLS("/path/to/cert.pem", "/path/to/key.pem"),
WithLogger(logger),
)
```
## 混合方法
对于同时需要便利性和可扩展性的 API,接受配置结构体用于常见设置,函数式选项用于高级覆盖:
```go
func NewServer(cfg Config, opts ...Option) *Server {
s := &Server{cfg: cfg}
for _, o := range opts {
o.apply(&s.cfg)
}
return s
}
```
谨慎使用 — 它增加了复杂性。每个 API 优先使用一种方法。
+107
View File
@@ -0,0 +1,107 @@
---
name: go-functions
description: Use when organizing functions within a Go file, formatting function signatures, designing return values, or following Printf-style naming conventions. Also use when a user is adding or refactoring any Go function, even if they don't mention function design or signature formatting. Does not cover functional options constructors (see go-functional-options).
license: Apache-2.0
metadata:
sources: "Effective Go, Google Style Guide, Uber Style Guide"
---
# Go 函数设计
> **本技能不适用的场景**:对于函数选项构造函数(`WithTimeout`、`WithLogger`),参见 [go-functional-options](../go-functional-options/SKILL.md)。对于错误返回约定,参见 [go-error-handling](../go-error-handling/SKILL.md)。对于函数和方法的命名,参见 [go-naming](../go-naming/SKILL.md)。
---
## 函数分组与排序
按以下规则组织文件中的函数:
1. 函数按**大致调用顺序**排序
2. 函数**按接收者分组**
3. **导出**函数排在最前面,位于 `struct`/`const`/`var` 定义之后
4. `NewXxx`/`newXxx` 构造函数紧跟在类型定义之后
5. 普通工具函数排在文件末尾
```go
type something struct{ ... }
func newSomething() *something { return &something{} }
func (s *something) Cost() int { return calcCost(s.weights) }
func (s *something) Stop() { ... }
func calcCost(n []int) int { ... }
```
---
## 函数签名
> 在格式化多行签名、包装返回值、缩短调用点或用自定义类型替换裸 bool 参数时,阅读 [references/SIGNATURES.md](references/SIGNATURES.md)。
尽量将签名保持在一行内。当必须换行时,将**所有参数放在各自的行上**并加尾随逗号:
```go
func (r *SomeType) SomeLongFunctionName(
foo1, foo2, foo3 string,
foo4, foo5, foo6 int,
) {
foo7 := bar(foo1)
}
```
为含义不明确的参数添加 `/* name */` 注释,或者更好的做法是用自定义类型替换裸 `bool` 参数。
---
## 接口指针
几乎不需要指向接口的指针。将接口作为值传递——底层数据仍然可以是指针。
```go
// 不好:接口指针
func process(r *io.Reader) { ... }
// 好:传递接口值
func process(r io.Reader) { ... }
```
---
## Printf 与 Stringer
> 在使用 %v/%s/%d 之外的 Printf 动词、实现 fmt.Stringer 或 fmt.GoStringer、编写自定义 Format() 方法或调试 String() 方法中的无限递归时,阅读 [references/PRINTF-STRINGER.md](references/PRINTF-STRINGER.md)。
### Printf 风格函数名
接受格式字符串的函数应以 `f` 结尾,以便 `go vet` 支持。在 `Printf` 调用之外使用格式字符串时,将其声明为 `const`。
在格式化日志或错误消息中的字符串时,优先使用 `%q` 而非手动加引号的 `%s`——它能安全地转义特殊字符并加上引号:
```go
return fmt.Errorf("unknown key %q", key) // 输出:unknown key "foo\nbar"
```
设计具有 3 个以上可选参数的构造函数时,参见 **go-functional-options**。
---
## 快速参考
| 主题 | 规则 |
|------|------|
| 文件排序 | 类型 -> 构造函数 -> 导出 -> 未导出 -> 工具函数 |
| 签名换行 | 所有参数各占一行,加尾随逗号 |
| 裸参数 | 添加 `/* name */` 注释或使用自定义类型 |
| 接口指针 | 几乎不需要;按值传递接口 |
| Printf 函数名 | 以 `f` 结尾以支持 `go vet` |
---
## 相关技能
- **错误返回**:在设计错误返回模式或在多返回值函数中包装错误时,参见 [go-error-handling](../go-error-handling/SKILL.md)
- **命名约定**:在为函数、方法命名或选择 getter/setter 模式时,参见 [go-naming](../go-naming/SKILL.md)
- **函数选项**:在设计具有 3 个以上可选参数的构造函数时,参见 [go-functional-options](../go-functional-options/SKILL.md)
- **格式化原则**:在决定行长度、裸返回或签名格式时,参见 [go-style-core](../go-style-core/SKILL.md)
@@ -0,0 +1,264 @@
# Printf、Stringer 与自定义格式化
Go 的 `fmt` 打印动词、`Stringer` 和 `GoStringer` 接口、自定义 `Format()` 方法以及常见陷阱的深度参考。
---
## Printf 动词
### 通用动词
| 动词 | 用途 |
|------|------|
| `%v` | 默认格式(结构体字段、切片元素) |
| `%+v` | 带字段名的结构体:`{Name:alice Age:30}` |
| `%#v` | Go 语法表示:`main.User{Name:"alice", Age:30}` |
| `%T` | 值的类型:`main.User` |
| `%%` | 字面百分号 |
### 字符串与字节动词
| 动词 | 用途 |
|------|------|
| `%s` | 纯字符串或字节切片 |
| `%q` | 带 Go 语法转义的引号字符串:`"hello\n"` |
| `%x` | 十六进制编码,小写:`68656c6c6f` |
| `%X` | 十六进制编码,大写:`68656C6C6F` |
### 整数动词
| 动词 | 用途 |
|------|------|
| `%d` | 十进制整数 |
| `%b` | 二进制 |
| `%o` | 八进制 |
| `%O` | 带 `0o` 前缀的八进制 |
| `%x` | 十六进制,小写 |
| `%X` | 十六进制,大写 |
### 浮点数动词
| 动词 | 用途 |
|------|------|
| `%f` | 小数点,无指数:`123.456` |
| `%e` | 科学计数法:`1.23456e+02` |
| `%g` | 紧凑格式:大指数用 `%e`,否则用 `%f` |
### 宽度与精度
```go
fmt.Sprintf("%10d", 42) // " 42" (宽度 10,右对齐)
fmt.Sprintf("%-10d", 42) // "42 " (宽度 10,左对齐)
fmt.Sprintf("%.2f", 3.14159) // "3.14" (2 位小数)
fmt.Sprintf("%010d", 42) // "0000000042" (零填充)
```
---
## 使用 `%q` 输出字符串
`%q` 动词在双引号内打印字符串,使空字符串和控制字符可见:
```go
fmt.Printf("value %q looks like English text", someText)
// 不好:手动添加引号
fmt.Printf("value \"%s\" looks like English text", someText)
```
在面向人类的输出中,如果值可能为空或包含控制字符,优先使用 `%q`。
---
## Printf 之外的格式字符串
在 `Printf` 风格调用之外声明格式字符串时,使用 `const`。这样 `go vet` 可以进行静态分析:
```go
// 不好:变量格式字符串——go vet 无法检查
msg := "unexpected values %v, %v\n"
fmt.Printf(msg, 1, 2)
// 好:常量格式字符串——go vet 可以验证
const msg = "unexpected values %v, %v\n"
fmt.Printf(msg, 1, 2)
```
---
## Printf 风格函数的命名
接受格式字符串的函数应以 `f` 结尾。这样 `go vet` 可以自动检查格式字符串:
```go
func Wrapf(err error, format string, args ...any) error
```
如果使用非标准名称,需要告知 `go vet`:
```bash
go vet -printfuncs=wrapf,statusf
```
---
## `fmt.Stringer` 接口
实现 `fmt.Stringer` 来控制类型在 `%v` 和 `%s` 下的显示方式:
```go
type fmt.Stringer interface {
String() string
}
```
```go
type Point struct{ X, Y int }
func (p Point) String() string {
return fmt.Sprintf("(%d, %d)", p.X, p.Y)
}
// fmt.Println(Point{1, 2}) → "(1, 2)"
// fmt.Sprintf("point: %v", p) → "point: (1, 2)"
// fmt.Sprintf("point: %s", p) → "point: (1, 2)"
```
### 何时实现 Stringer
- 类型将出现在日志消息或面向用户的输出中
- 默认的 `%v` 输出(仅字段值)不够有意义
- 需要一种区别于序列化的、对人类友好的表示
---
## `fmt.GoStringer` 接口
实现 `fmt.GoStringer` 来控制 `%#v` 输出。这对于默认 Go 语法表示具有误导性或过于冗长的类型很有用:
```go
type fmt.GoStringer interface {
GoString() string
}
```
```go
type Color struct{ R, G, B uint8 }
func (c Color) GoString() string {
return fmt.Sprintf("Color(%#02x, %#02x, %#02x)", c.R, c.G, c.B)
}
// fmt.Sprintf("%#v", Color{255, 128, 0})
// → "Color(0xff, 0x80, 0x00)" 而非 "main.Color{R:0xff, G:0x80, B:0x00}"
```
`GoString()` 的输出应该是有效的 Go 语法或接近有效语法——它用于调试,而非面向用户的显示。
---
## 使用 `fmt.Formatter` 自定义格式化
要完全控制所有格式动词,实现 `fmt.Formatter`:
```go
type fmt.Formatter interface {
Format(f fmt.State, verb rune)
}
```
```go
type Point struct{ X, Y int }
func (p Point) Format(f fmt.State, verb rune) {
switch verb {
case 'v':
if f.Flag('#') {
// %#v——Go 语法表示
fmt.Fprintf(f, "Point{X: %d, Y: %d}", p.X, p.Y)
return
}
if f.Flag('+') {
// %+v——带字段名的详细格式
fmt.Fprintf(f, "X:%d Y:%d", p.X, p.Y)
return
}
// %v——默认
fmt.Fprintf(f, "(%d, %d)", p.X, p.Y)
case 's':
fmt.Fprintf(f, "(%d, %d)", p.X, p.Y)
case 'q':
fmt.Fprintf(f, "%q", p.String())
default:
fmt.Fprintf(f, "%%!%c(Point=%d,%d)", verb, p.X, p.Y)
}
}
```
### `fmt.State` 方法
| 方法 | 返回值 |
|------|--------|
| `Flag(c int) bool` | 标志(`+`、`-`、`#`、`0`、` `)是否设置 |
| `Width() (int, bool)` | 宽度值以及是否指定了宽度 |
| `Precision() (int, bool)` | 精度值以及是否指定了精度 |
| `Write(b []byte) (int, error)` | 写入输出字节 |
仅在 `String()` 不够用时才实现 `fmt.Formatter`——很少需要这样做。常见原因:需要为 `%v`、`%+v`、`%#v` 提供不同输出,或者需要遵循宽度/精度标志。
---
## 无限递归陷阱
**在 `String()` 方法内部对接收者使用 `%s` 或 `%v` 调用 `fmt.Sprintf` 会导致无限递归:**
```go
type MyString string
// BUG:无限递归——Sprintf 调用 String(),String() 又调用 Sprintf...
func (m MyString) String() string {
return fmt.Sprintf("MyString: %s", m) // 崩溃:栈溢出
}
```
修复方法——将接收者转换为其底层类型以打破方法集:
```go
func (m MyString) String() string {
return fmt.Sprintf("MyString: %s", string(m)) // 安全:string 没有 String()
}
```
此陷阱还适用于:
- 底层类型为 string、[]byte 或另一个 Stringer 的类型
- 任何使用 `%s` 或 `%v` 格式化 `self` 的 `String()` 方法
- 使用 `%#v` 格式化 `self` 的 `GoString()` 方法
```go
type IPAddr [4]byte
// BUG:%v 调用 String(),无限递归
func (ip IPAddr) String() string {
return fmt.Sprintf("%v.%v.%v.%v", ip[0], ip[1], ip[2], ip[3])
// 这里安全——ip[0] 是 byte(uint8),没有 String() 方法。
// 但如果 ip 是一个包装了 Stringer 的命名类型,就会递归。
}
```
**经验法则**:在 `String()` 内部,永远不要将接收者(或重新转换为自身类型的接收者)传递给 `%s` 或 `%v` 动词。先转换为底层原始类型。
---
## 快速参考
| 主题 | 规则 |
|------|------|
| `%q` | 用于人类可读的字符串输出 |
| `%+v` | 带字段名的结构体 |
| `%#v` | Go 语法表示;通过 `GoStringer` 自定义 |
| 格式字符串存储 | 在 Printf 调用之外声明为 `const` |
| Printf 函数名 | 以 `f` 结尾以支持 `go vet` |
| `Stringer` | 实现 `String() string` 用于 `%v`/`%s` 输出 |
| `GoStringer` | 实现 `GoString() string` 用于 `%#v` 输出 |
| `Formatter` | 实现 `Format(fmt.State, rune)` 以完全控制动词 |
| 递归陷阱 | 永远不要在 `String()` 内部使用 `Sprintf("%s", receiver)`;转换为底层类型 |
@@ -0,0 +1,168 @@
# 函数签名
格式化 Go 函数签名、避免裸参数以及保持调用点可读性的详细规则。
---
## 单行 vs 多行
当签名能轻松放在一行时保持单行。当必须换行时,将**所有参数放在各自的行上**并加尾随逗号:
**不好**——部分换行使对齐变得脆弱:
```go
func (r *SomeType) SomeLongFunctionName(foo1, foo2, foo3 string,
foo4, foo5, foo6 int) {
foo7 := bar(foo1)
}
```
**好**——完全换行,尾随逗号:
```go
func (r *SomeType) SomeLongFunctionName(
foo1, foo2, foo3 string,
foo4, foo5, foo6 int,
) {
foo7 := bar(foo1)
}
```
### 返回值
当返回值也需要换行时,遵循相同的模式:
```go
func (r *SomeType) LongName(
foo1, foo2, foo3 string,
foo4, foo5, foo6 int,
) (
*Result,
error,
) {
// ...
}
```
对于更简单的情况,命名返回值可以与参数右括号在同一行:
```go
func (r *SomeType) LongName(
foo1, foo2, foo3 string,
) (result *Result, err error) {
// ...
}
```
---
## 缩短调用点
提取局部变量,而不是将函数调用拆分到多行:
```go
// 不好:过长的内联调用
result := foo.Call(
somePackage.ComplexFunction(arg1, arg2),
anotherPackage.Transform(data),
defaultOptions,
)
// 好:提取局部变量以提高清晰度
transformed := anotherPackage.Transform(data)
computed := somePackage.ComplexFunction(arg1, arg2)
result := foo.Call(computed, transformed, defaultOptions)
```
这提高了可读性,并使中间值可用于调试。
---
## 避免裸参数
函数调用中的裸参数会降低可读性。为含义不明确的参数添加 C 风格注释:
```go
// 不好:这些布尔值是什么意思?
printInfo("foo", true, true)
// 好:内联注释说明了意图
printInfo("foo", true /* isLocal */, true /* done */)
```
更好的做法是用自定义类型替换裸 `bool` 参数:
```go
type Region int
const (
UnknownRegion Region = iota
Local
)
type Status int
const (
Pending Status = iota
Done
)
func printInfo(name string, region Region, status Status)
```
### 何时使用每种方法
| 方法 | 时机 |
|------|------|
| C 风格注释 | 快速修复;调用点少;无法修改的第三方 API |
| 自定义类型 | 多个调用点;公开 API;多个 bool/int 参数 |
| 函数选项 | 3 个以上可选参数;参见 [go-functional-options](../../go-functional-options/SKILL.md) |
---
## 分组相关参数
当函数接受多个相同类型的参数时,将它们分组:
```go
// 可接受:将同类型参数分组
func Copy(dst, src string) error
// 可接受:尽管类型相同,但含义不同时分开声明
func Move(source string, destination string) error
```
当参数名称能清楚表明角色时使用分组;当不能清楚表明时使用分开声明。
---
## 方法接收者的位置
接收者放在函数名之前,格式类似于参数:
```go
// 短接收者——放在同一行
func (s *Server) Start(ctx context.Context) error { ... }
// 长接收者类型——如果整行过长则考虑换行
func (h *ComplicatedHandler) ServeHTTP(
w http.ResponseWriter,
r *http.Request,
) { ... }
```
参见 [go-naming](../../go-naming/SKILL.md) 了解接收者命名约定(简短的一到两个字母缩写)。
---
## 快速参考
| 主题 | 规则 |
|------|------|
| 单行 | 能放下时保持一行 |
| 多行 | 所有参数各占一行,尾随逗号 |
| 返回值换行 | 与参数相同的模式 |
| 调用点 | 提取局部变量而不是拆分调用 |
| 裸 bool | 添加 `/* name */` 注释或使用自定义类型 |
| 分组参数 | 当名称能清楚表明角色时将同类型分组 |
| 接收者 | 在函数名之前;简短缩写 |
+173
View File
@@ -0,0 +1,173 @@
---
name: go-generics
description: Use when deciding whether to use Go generics, writing generic functions or types, choosing constraints, or picking between type aliases and type definitions. Also use when a user is writing a utility function that could work with multiple types, even if they don't mention generics explicitly. Does not cover interface design without generics (see go-interfaces).
license: Apache-2.0
compatibility: Requires Go 1.18+ (generics were introduced in Go 1.18)
metadata:
sources: "Google Style Guide"
---
# Go 泛型与类型参数
---
## 何时使用泛型
从具体类型开始。只在出现第二种类型时才进行泛化。
### 优先使用泛型的场景
- 多种类型共享相同的逻辑(排序、过滤、map/reduce)
- 否则需要依赖 `any` 和大量的类型切换
- 正在构建可复用的数据结构(并发安全的集合、有序映射)
### 避免使用泛型的场景
- 实践中只有一种类型被实例化
- 接口已经能清晰地表达共享行为
- 泛型代码比特定类型的替代方案更难阅读
> "写代码,不要设计类型。"—— Robert Griesemer 和 Ian Lance Taylor
### 决策流程
```
多种类型是否共享相同的逻辑?
├─ 否 → 使用具体类型
├─ 是 → 它们是否共享一个有用的接口?
│ ├─ 是 → 使用接口
│ └─ 否 → 使用泛型
```
**不好:**
```go
// 过早使用泛型:只会被 int 调用
func Sum[T constraints.Integer | constraints.Float](vals []T) T {
var total T
for _, v := range vals {
total += v
}
return total
}
```
**好:**
```go
func SumInts(vals []int) int {
var total int
for _, v := range vals {
total += v
}
return total
}
```
---
## 类型参数命名
| 名称 | 典型用途 |
|------|----------|
| `T` | 通用类型参数 |
| `K` | 映射键类型 |
| `V` | 映射值类型 |
| `E` | 元素/项目类型 |
对于复杂约束,可以使用简短的描述性名称:
```go
func Marshal[Opts encoding.MarshalOptions](v any, opts Opts) ([]byte, error)
```
---
## 类型别名 vs 类型定义
类型别名(`type Old = new.Name`)很少使用——仅用于包迁移或渐进式 API 重构。
---
## 约束组合
使用 `~`(底层类型)和 `|`(联合)组合约束:
```go
type Numeric interface {
~int | ~int8 | ~int16 | ~int32 | ~int64 |
~float32 | ~float64
}
func Sum[T Numeric](vals []T) T {
var total T
for _, v := range vals {
total += v
}
return total
}
```
使用 `constraints` 包或 `cmp` 包(Go 1.21+)中的标准约束如 `cmp.Ordered`,而不是自己编写。
> 在编写自定义类型约束、使用 ~ 和 | 组合约束或调试类型推断问题时,阅读 [references/CONSTRAINTS.md](references/CONSTRAINTS.md)。
---
## 常见陷阱
### 不要包装标准库类型
```go
// 不好:泛型包装器增加了复杂度但没有价值
type Set[T comparable] struct {
m map[T]struct{}
}
// 更好:当用法简单时直接使用 map[T]struct{}
seen := map[string]struct{}{}
```
泛型在消除**多个调用点**之间的重复时才能证明其复杂度的合理性。单次使用的泛型只是多余的间接层。
### 不要为接口满足而使用泛型
```go
// 不好:T 仅用于满足接口——直接使用接口即可
func Process[T io.Reader](r T) error { ... }
// 好:直接接受接口
func Process(r io.Reader) error { ... }
```
### 避免过度约束
```go
// 不好:约束比需要的更严格
func Contains[T interface{ ~int | ~string }](slice []T, target T) bool { ... }
// 好:comparable 就足够了
func Contains[T comparable](slice []T, target T) bool { ... }
```
---
## 快速参考
| 主题 | 指导 |
|------|------|
| 何时使用泛型 | 仅在多种类型共享相同逻辑且接口不够用时 |
| 起点 | 先写具体代码;之后再泛化 |
| 命名 | 单个大写字母(`T`、`K`、`V`、`E`) |
| 类型别名 | 相同类型,替代名称;仅用于迁移 |
| 约束组合 | 使用 `~` 表示底层类型,`|` 表示联合;优先使用 `cmp.Ordered` 而非自定义 |
| 常见陷阱 | 不要对单次使用的代码或接口已足够时使用泛型 |
---
## 相关技能
- **接口 vs 泛型**:在决定接口是否已经能表达共享行为而无需泛型时,参见 [go-interfaces](../go-interfaces/SKILL.md)
- **类型声明**:在定义新类型、类型别名或在类型定义和别名之间选择时,参见 [go-declarations](../go-declarations/SKILL.md)
- **文档化泛型 API**:在为泛型函数编写文档注释和可运行示例时,参见 [go-documentation](../go-documentation/SKILL.md)
- **命名类型参数**:在为类型参数或约束接口选择名称时,参见 [go-naming](../go-naming/SKILL.md)
@@ -0,0 +1,169 @@
# Go 泛型中的类型约束
> **来源**:Google Go 风格指南、Go 语言规范
约束定义了类型参数支持的操作。选择满足函数需求的最窄约束——不要更多。
---
## 内置约束
> **规范**:在自行编写约束之前,优先使用标准约束。
| 约束 | 含义 |
|------|------|
| `any` | `interface{}` 的别名;对类型没有要求 |
| `comparable` | 支持 `==` 和 `!=`;映射键所必需 |
| `cmp.Ordered` | 支持 `<`、`<=`、`>=`、`>`(Go 1.21+,替代 `constraints.Ordered`) |
在新代码中优先使用 `cmp.Ordered`(来自 `cmp` 包),而不是已弃用的 `golang.org/x/exp/constraints.Ordered`。
---
## `~` 运算符(底层类型)
> **建议**:当你想接受基于原始类型构建的命名类型时使用 `~`。
`~T` 语法匹配任何**底层类型**为 `T` 的类型。没有 `~` 时,只有精确的类型匹配。
```go
type Celsius float64
type ExactFloat interface{ float64 } // 拒绝 Celsius
type AnyFloat64 interface{ ~float64 } // 接受 Celsius
```
当调用者可能基于基础类型定义命名类型时使用 `~`。仅在需要限制为精确的内置类型时才省略 `~`。
---
## 组合与编写约束
> **建议**:仅在没有标准约束适用时才定义自定义约束。
使用 `|` 组合类型并嵌入约束来组合它们:
```go
type Numeric interface {
~int | ~int8 | ~int16 | ~int32 | ~int64 |
~float32 | ~float64
}
type Addable interface {
Numeric | ~string // 数字和字符串拼接
}
```
约束可以同时要求方法和类型元素:
```go
type Stringer interface {
comparable
String() string
}
```
满足 `Stringer` 的类型必须是可比较的 **并且** 具有 `String()` 方法。
---
## 避免过度约束
> **规范**:使用支持所执行操作的最小约束。
**不好**
```go
// 只使用了 == 但限制为 int 和 string
func Contains[T interface{ ~int | ~string }](s []T, v T) bool { ... }
```
**好**
```go
// comparable 是 == 的最小约束
func Contains[T comparable](s []T, v T) bool { ... }
```
过度约束限制了复用,并迫使调用者绕过实现中根本不需要的限制。
## 类型推断
> **建议**:当类型明确时让编译器推断类型参数。
编译器从函数参数推断类型参数:
```go
result := slices.Contains[string](names, "alice") // 显式——不必要
result := slices.Contains(names, "alice") // 推断——推荐
```
仅在以下情况下才显式提供类型参数:没有可用于推断的函数参数、推断的类型不正确(例如无类型常量提升为错误的类型),或者将类型显式展示出来有助于可读性。
---
## 常见陷阱
### 接口已足够时不要使用泛型
> **规范**:来自 Google 风格指南——当类型共享一个有用的统一接口时,优先使用接口。
**不好**
```go
// T 仅用于满足 io.Reader——直接使用接口即可
func Process[T io.Reader](r T) error { ... }
```
**好**
```go
func Process(r io.Reader) error { ... }
```
如果约束是单个已有接口,直接接受该接口。
### 不要泛型地包装标准库类型
> **建议**:单次使用的泛型只是多余的间接层。
**不好**
```go
type Set[T comparable] struct{ m map[T]struct{} } // 永远只是 Set[string]
```
**好**
```go
seen := map[string]struct{}{} // 对于单次实例化直接使用 map
```
泛型在消除**多个调用点**之间的重复时才能证明其复杂度的合理性。如果只使用一种类型,从具体类型开始。
### 方法集与类型约束
你只能调用约束允许的操作:
**不好**
```go
func Stringify[T any](v T) string {
return v.String() // 编译错误:any 没有 String()
}
```
**好**
```go
func Stringify[T fmt.Stringer](v T) string {
return v.String()
}
```
---
## 快速参考
| 主题 | 指导 |
|------|------|
| 默认约束 | `any`——不需要对 T 进行任何操作时使用 |
| 相等性检查 | `comparable`——`==`、`!=` 和映射键所必需 |
| 排序 | `cmp.Ordered`(Go 1.21+)用于 `<`、`>` 比较 |
| 命名类型 | 使用 `~T` 接受底层类型为 T 的类型 |
| 联合类型 | 使用 `\|` 组合——例如 `~int \| ~float64` |
| 自定义约束 | 定义为包含类型元素和/或方法的接口 |
| 类型推断 | 当编译器可以推断时省略类型参数 |
| 最小约束 | 使用函数实际需要的最窄约束 |
+151
View File
@@ -0,0 +1,151 @@
---
name: go-interfaces
description: Use when defining or implementing Go interfaces, designing abstractions, creating mockable boundaries for testing, or composing types through embedding. Also use when deciding whether to accept an interface or return a concrete type, or using type assertions or type switches, even if the user doesn't explicitly mention interfaces. Does not cover generics-based polymorphism (see go-generics).
license: Apache-2.0
metadata:
sources: "Effective Go, Google Style Guide, Uber Style Guide"
allowed-tools: Bash(bash:*)
---
# Go 接口与组合
## 可用脚本
- **`scripts/check-interface-compliance.sh`**——查找缺少编译时合规性检查(`var _ I = (*T)(nil)`)的导出接口。运行 `bash scripts/check-interface-compliance.sh --help` 查看选项。
---
## 接受接口,返回具体类型
接口属于**消费**值的包,而不是**实现**值的包。从构造函数返回具体类型(通常是指针或结构体),这样可以在不重构的情况下添加新方法。
```go
// 好:消费者定义自己需要的接口
package consumer
type Thinger interface { Thing() bool }
func Foo(t Thinger) string { ... }
```
```go
// 好:生产者返回具体类型
package producer
type Thinger struct{ ... }
func (t Thinger) Thing() bool { ... }
func NewThinger() Thinger { return Thinger{ ... } }
```
```go
// 不好:生产者定义并返回自己的接口
package producer
type Thinger interface { Thing() bool }
type defaultThinger struct{ ... }
func NewThinger() Thinger { return defaultThinger{ ... } }
```
**不要在接口被使用之前定义它。** 如果没有现实的使用示例,很难判断接口是否真的有必要。
---
## 通用性:隐藏实现,暴露接口
如果一个类型仅用于实现某个接口,且没有该接口之外的导出方法,则从构造函数返回接口以隐藏实现:
```go
func NewHash() hash.Hash32 {
return &myHash{} // 未导出的类型
}
```
好处:实现可以在不影响调用者的情况下更改,替换算法只需更改构造函数调用。
---
## 类型断言:Comma-Ok 模式
不进行检查的话,失败的断言会导致运行时 panic。始终使用 comma-ok 模式进行安全测试:
```go
str, ok := value.(string)
if ok {
fmt.Printf("string value is: %q\n", str)
}
```
检查值是否实现了某个接口:
```go
if _, ok := val.(json.Marshaler); ok {
fmt.Printf("value %v implements json.Marshaler\n", val)
}
```
---
## 类型切换
重用变量名是惯用做法(`t := t.(type)`)——变量在每个 case 分支中拥有正确的类型。当 case 列出多个类型(`case int, int64:`)时,变量拥有接口类型。
---
## 嵌入
避免在公开结构体中嵌入类型——内部类型的完整方法集将成为你公开 API 的一部分。改用未导出的字段。
> 在使用结构体嵌入进行组合、重写嵌入方法、解决名称冲突、应用 HandlerFunc 适配器模式或决定是否在公开 API 类型中使用嵌入时,阅读 [references/EMBEDDING.md](references/EMBEDDING.md)。
---
## 接口满足检查
使用空标识符赋值在编译时验证类型是否实现了接口:
```go
var _ json.Marshaler = (*RawMessage)(nil)
```
如果 `*RawMessage` 没有实现 `json.Marshaler`,这会导致编译错误。
在以下情况下使用此模式:
- 没有能自动验证接口的静态转换
- 类型必须满足接口才能正确运行(例如自定义 JSON 序列化)
- 接口更改应该导致编译失败,而不是静默降级
**不要**为每个接口都添加这些检查——仅在没有其他静态转换能捕获错误时才使用。
> **验证**:在定义接口或实现后,运行 `bash scripts/check-interface-compliance.sh` 验证所有具体类型都有编译时的 `var _ I = (*T)(nil)` 检查。
---
## 接收者类型
如果不确定,使用指针接收者。不要在单个类型上混合接收者类型——如果任何方法需要指针,则所有方法都使用指针。仅在小型不可变类型(`Point`、`time.Time`)或基本类型上使用值接收者。
> 在为新类型决定使用指针接收者还是值接收者时,特别是对于包含 sync 原语或大型结构体的类型,阅读 [references/RECEIVER-TYPE.md](references/RECEIVER-TYPE.md)。
---
## 快速参考
| 概念 | 模式 | 说明 |
|------|------|------|
| 消费者拥有接口 | 在使用处定义接口 | 不在实现包中 |
| 安全类型断言 | `v, ok := x.(Type)` | 返回零值 + false |
| 类型切换 | `switch v := x.(type)` | 变量在每个 case 中拥有正确类型 |
| 接口嵌入 | `type RW interface { Reader; Writer }` | 方法的并集 |
| 结构体嵌入 | `type S struct { *T }` | 提升 T 的方法 |
| 接口检查 | `var _ I = (*T)(nil)` | 编译时验证 |
| 通用性 | 从构造函数返回接口 | 隐藏实现 |
---
## 相关技能
- **接口命名**:在为接口命名(`-er` 后缀约定)或选择接收者名称时,参见 [go-naming](../go-naming/SKILL.md)
- **错误类型**:在实现 `error` 接口、自定义错误类型或 `errors.As` 匹配时,参见 [go-error-handling](../go-error-handling/SKILL.md)
- **泛型 vs 接口**:在决定是否需要泛型或接口是否已足够时,参见 [go-generics](../go-generics/SKILL.md)
- **函数选项**:在使用基于接口的 Option 模式实现灵活构造函数时,参见 [go-functional-options](../go-functional-options/SKILL.md)
- **编译时检查**:在 API 边界添加 `var _ I = (*T)(nil)` 满足检查时,参见 [go-defensive](../go-defensive/SKILL.md)
@@ -0,0 +1,138 @@
# Go 中的嵌入模式
> **来源**:Effective Go、Uber 风格指南
Go 使用嵌入来实现组合而非继承。嵌入将内部类型的方法提升到外部类型,自动满足接口。
## 接口嵌入
通过嵌入来组合接口:
```go
type ReadWriter interface {
Reader
Writer
}
```
`ReadWriter` 既能做 `Reader` 能做的事,*也能*做 `Writer` 能做的事。接口中只能嵌入接口。
## 结构体嵌入
嵌入将内部类型的方法提升到外部类型,无需显式转发。
```go
type ReadWriter struct {
*Reader // *bufio.Reader
*Writer // *bufio.Writer
}
```
通过嵌入,`bufio.ReadWriter` 自动满足 `io.Reader`、`io.Writer` 和 `io.ReadWriter`。
混合使用嵌入字段和命名字段:
```go
type Job struct {
Command string
*log.Logger
}
job.Println("starting now...")
job.Logger.SetPrefix("Job: ")
```
## 方法重写
在外部类型上定义方法以重写提升的方法:
```go
func (job *Job) Printf(format string, args ...any) {
job.Logger.Printf("%q: %s", job.Command, fmt.Sprintf(format, args...))
}
```
外部方法优先——对 `job.Printf(...)` 的调用会调用外部方法,而嵌入方法仍可通过 `job.Logger.Printf(...)` 访问。
## 嵌入 vs 子类化
当调用嵌入方法时,接收者是**内部**类型,而非外部类型。嵌入类型不知道自己被嵌入——不存在类似于 `this` 或 `super` 的引用指向包含它的类型。
```go
type Base struct{}
func (b *Base) Name() string { return "Base" }
type Derived struct{ Base }
d := Derived{}
d.Name() // 返回 "Base",而非 "Derived"
```
## 名称冲突解决
1. **外部隐藏内部**——外部类型上的字段或方法会遮蔽嵌入类型在同名位置提升的字段或方法
2. **同级冲突是错误**——如果两个同深度的嵌入类型提升了相同的名称,则为编译错误(除非该名称从未被访问)
```go
type A struct{}
func (A) Hello() string { return "A" }
type B struct{}
func (B) Hello() string { return "B" }
type C struct {
A
B
}
// c.Hello() // 编译错误:选择器不明确
c.A.Hello() // 可以:显式消歧
```
## 不要在公开结构体中嵌入
嵌入将内部类型的完整方法集暴露为你的公开 API 的一部分。这带来了维护负担:嵌入类型方法的更改会破坏 API 的兼容性保证。
**不好**
```go
type SMap struct {
sync.Mutex // Lock 和 Unlock 现在是 SMap API 的一部分
data map[string]string
}
```
**好**
```go
type SMap struct {
mu sync.Mutex // 未导出的字段——实现细节
data map[string]string
}
func (m *SMap) Get(k string) string {
m.mu.Lock()
defer m.mu.Unlock()
return m.data[k]
}
```
例外:在测试类型和 API 稳定性无关紧要的内部结构体中,嵌入是可以接受的。
## HandlerFunc 适配器模式
方法可以在任何命名类型上定义,不仅仅是结构体。`http.HandlerFunc` 模式将普通函数转换为接口实现:
```go
type HandlerFunc func(ResponseWriter, *Request)
func (f HandlerFunc) ServeHTTP(w ResponseWriter, req *Request) {
f(w, req)
}
```
任何具有正确签名的函数都可以成为 HTTP 处理器:
```go
http.Handle("/args", http.HandlerFunc(ArgServer))
```
这种适配器模式在需要让独立函数满足单方法接口时非常有用。
@@ -0,0 +1,68 @@
# 接收者类型:指针 vs 值
> **建议**:Go Wiki CodeReviewComments
选择在方法上使用值接收者还是指针接收者可能很困难。**如果不确定,使用指针**,但有时值接收者也是合理的。
## 何时使用指针接收者
- **方法修改接收者**:接收者必须是指针
- **接收者包含 sync.Mutex 或类似类型**:必须使用指针以避免复制
- **大型结构体或数组**:指针接收者更高效。如果将所有元素作为参数传递感觉太大,那对值接收者来说也太大了
- **并发或被调方法可能修改**:如果更改必须对原始接收者可见,则必须使用指针
- **元素是指向可变内容的指针**:优先使用指针接收者使意图更清晰
## 何时使用值接收者
- **小型不变的结构体或基本类型**:值接收者以提高效率
- **Map、func 或 chan**:不要对它们使用指针
- **不重新切片/重新分配的切片**:如果方法不重新切片或重新分配切片,不要使用指针
- **没有可变字段的小型值类型**:像 `time.Time` 这样没有可变字段且没有指针的类型适合作为值接收者
- **简单基本类型**:`int`、`string` 等
```go
// 值接收者:小型、不可变类型
type Point struct {
X, Y float64
}
func (p Point) Distance(q Point) float64 {
return math.Hypot(q.X-p.X, q.Y-p.Y)
}
// 指针接收者:方法修改接收者
func (p *Point) ScaleBy(factor float64) {
p.X *= factor
p.Y *= factor
}
// 指针接收者:包含 sync.Mutex
type Counter struct {
mu sync.Mutex
count int
}
func (c *Counter) Increment() {
c.mu.Lock()
c.count++
c.mu.Unlock()
}
```
## 一致性规则
**不要混合接收者类型**。为类型上所有可用的方法统一选择指针或结构体类型。如果任何方法需要指针接收者,则所有方法都使用指针接收者。
```go
// 好:一致的指针接收者
type Buffer struct {
data []byte
}
func (b *Buffer) Write(p []byte) (int, error) { /* ... */ }
func (b *Buffer) Read(p []byte) (int, error) { /* ... */ }
func (b *Buffer) Len() int { return len(b.data) }
// 不好:混合接收者类型
func (b Buffer) Len() int { return len(b.data) } // 不一致
```
@@ -0,0 +1,224 @@
#!/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
+209
View File
@@ -0,0 +1,209 @@
---
name: go-linting
description: Use when setting up linting for a Go project, configuring golangci-lint, or adding Go checks to a CI/CD pipeline. Also use when starting a new Go project and deciding which linters to enable, even if the user only asks about "code quality" or "static analysis" without mentioning specific linter names. Does not cover code review process (see go-code-review).
license: Apache-2.0
metadata:
sources: "Uber Style Guide"
allowed-tools: Bash(bash:*)
---
# Go Lint
## 核心原则
比任何"推荐"的 linter 集合更重要的是:**在整个代码库中一致地进行 lint**。
一致的 lint 有助于捕获常见问题,并在不过度限制的情况下建立高标准的代码质量。
---
## 设置步骤
1. 使用下面的配置创建 `.golangci.yml`
2. 运行 `golangci-lint run ./...`
3. 如果出现错误,按类别逐一修复(先格式化,再 vet,再风格)
4. 重新运行直到通过
---
## 最低推荐 Linter
这些 linter 能捕获最常见的问题,同时保持高质量标准:
| Linter | 用途 |
|--------|------|
| [errcheck](https://github.com/kisielk/errcheck) | 确保错误被处理 |
| [goimports](https://pkg.go.dev/golang.org/x/tools/cmd/goimports) | 格式化代码和管理导入 |
| [revive](https://github.com/mgechev/revive) | 常见风格错误(golint 的现代替代品) |
| [govet](https://pkg.go.dev/cmd/vet) | 分析代码中的常见错误 |
| [staticcheck](https://staticcheck.dev) | 各种静态分析检查 |
> **注意**:`revive` 是现已弃用的 `golint` 的现代、更快的替代品。
---
## Lint 运行器:golangci-lint
使用 [golangci-lint](https://github.com/golangci/golangci-lint) 作为你的 lint 运行器。参见 uber-go/guide 的 [示例 .golangci.yml](https://github.com/uber-go/guide/blob/master/.golangci.yml)。
---
## 示例配置
> 在创建新的 `.golangci.yml` 或将现有配置与推荐基线进行比较时,参见 `assets/golangci.yml`。
在项目根目录创建 `.golangci.yml`:
```yaml
linters:
enable:
- errcheck
- goimports
- revive
- govet
- staticcheck
linters-settings:
goimports:
local-prefixes: github.com/your-org/your-repo
revive:
rules:
- name: blank-imports
- name: context-as-argument
- name: error-return
- name: error-strings
- name: exported
run:
timeout: 5m
```
### 运行
```bash
# 安装
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
# 运行所有 linter
golangci-lint run
# 对特定路径运行
golangci-lint run ./pkg/...
```
---
## 额外推荐的 Linter
除了最低集合之外,在生产项目中可以考虑以下 linter:
| Linter | 用途 | 何时启用 |
|--------|------|----------|
| [gosec](https://github.com/securego/gosec) | 安全漏洞检测 | 处理用户输入的服务始终启用 |
| [ineffassign](https://github.com/gordonklaus/ineffassign) | 检测无效赋值 | 始终——捕获死代码 |
| [misspell](https://github.com/client9/misspell) | 纠正注释/字符串中的常见拼写错误 | 始终 |
| [gocyclo](https://github.com/fzipp/gocyclo) | 圈复杂度阈值 | 当函数超过约 15 的复杂度时 |
| [exhaustive](https://github.com/nishanths/exhaustive) | 确保 switch 覆盖所有枚举值 | 使用 iota 枚举时 |
| [bodyclose](https://github.com/timakin/bodyclose) | 检测未关闭的 HTTP 响应体 | HTTP 客户端代码始终启用 |
---
## Nolint 指令
在抑制 lint 发现时,始终说明原因:
```go
//nolint:errcheck // 即发即忘的日志;错误不可操作
_ = logger.Sync()
```
规则:
- 使用 `//nolint:lintername`——永远不要使用裸 `//nolint`
- 将注释放在与发现相同的行
- 在 `//` 之后包含理由说明
---
## CI/CD 集成
### GitHub Actions
```yaml
# .github/workflows/lint.yml
name: Lint
on: [push, pull_request]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: stable
- uses: golangci/golangci-lint-action@v6
with:
version: latest
```
### Pre-commit Hook
```bash
#!/bin/sh
# .git/hooks/pre-commit
golangci-lint run --new-from-rev=HEAD~1
```
使用 `--new-from-rev` 只对更改的代码进行 lint,保持快速反馈循环。
---
## 可用脚本
- **`scripts/setup-lint.sh`**——生成 `.golangci.yml` 并运行初始 lint
```bash
bash scripts/setup-lint.sh github.com/your-org/your-repo
bash scripts/setup-lint.sh --force github.com/your-org/your-repo # 覆盖现有配置
bash scripts/setup-lint.sh --dry-run # 预览配置
bash scripts/setup-lint.sh --json # 结构化输出
```
> **验证**:在生成 `.golangci.yml` 后,运行 `golangci-lint run ./...` 验证配置有效并产生预期输出。如果因配置错误而失败,修复后重试。
> `scripts/setup-lint.sh` 生成**最低**配置(5 个核心 linter)。
> 对于已有项目,使用 `assets/golangci.yml` 作为起点——
> 它增加了 gosec、ineffassign、misspell、gocyclo 和 bodyclose。
---
## 快速参考
| 任务 | 命令/操作 |
|------|-----------|
| 安装 golangci-lint | `go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest` |
| 运行 linter | `golangci-lint run` |
| 对路径运行 | `golangci-lint run ./pkg/...` |
| 配置文件 | 项目根目录的 `.golangci.yml` |
| CI 集成 | 在管道中运行 `golangci-lint run` |
| Nolint 指令 | `//nolint:name // 原因`——永远不要使用裸 `//nolint` |
| CI 集成 | 使用 `golangci/golangci-lint-action` 用于 GitHub Actions |
| Pre-commit | `golangci-lint run --new-from-rev=HEAD~1` |
### Linter 选择指南
| 当你需要... | 使用 |
|-------------|------|
| 错误处理覆盖率 | errcheck |
| 导入格式化 | goimports |
| 风格一致性 | revive |
| Bug 检测 | govet、staticcheck |
| 以上全部 | golangci-lint 配合配置 |
---
## 相关技能
- **风格基础**:在解决 linter 执行的风格问题(格式化、嵌套、命名)时,参见 [go-style-core](../go-style-core/SKILL.md)
- **代码审查**:在将 linter 输出与手动审查清单结合使用时,参见 [go-code-review](../go-code-review/SKILL.md)
- **错误处理**:在 errcheck 标记未处理的错误并需要决定如何处理时,参见 [go-error-handling](../go-error-handling/SKILL.md)
- **测试**:在 CI 管道中将 linter 与测试一起运行时,参见 [go-testing](../go-testing/SKILL.md)
@@ -0,0 +1,31 @@
run:
timeout: 5m
linters:
enable:
# Minimum recommended
- errcheck
- goimports
- revive
- govet
- staticcheck
# Additional recommended
- gosec
- ineffassign
- misspell
- gocyclo
- bodyclose
linters-settings:
goimports:
local-prefixes: "" # Set to your module path
revive:
rules:
- name: exported
gocyclo:
min-complexity: 15
issues:
exclude-use-default: false
max-issues-per-linter: 0
max-same-issues: 0
+172
View File
@@ -0,0 +1,172 @@
#!/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
+179
View File
@@ -0,0 +1,179 @@
---
name: go-style-core
description: Use when working with Go formatting, line length, nesting, naked returns, semicolons, or core style principles. Also use when a style question isn't covered by a more specific skill, even if the user doesn't reference a specific style rule. Does not cover domain-specific patterns like error handling, naming, or testing (see specialized skills). Acts as fallback when no more specific style skill applies.
license: Apache-2.0
metadata:
sources: "Effective Go, Google Style Guide, Uber Style Guide, Go Wiki CodeReviewComments"
---
# Go 风格核心原则
## 风格原则(优先级顺序)
编写可读 Go 代码时,按以下重要性顺序应用这些原则:
### 优先级顺序
1. **清晰性** — 读者能否在没有额外上下文的情况下理解代码?
2. **简洁性** — 这是否是实现目标的最简单方式?
3. **精炼性** — 每一行是否都有其存在的价值?
4. **可维护性** — 后续修改是否容易?
5. **一致性** — 是否与周围代码和项目约定保持一致?
> 在解决清晰性、简洁性和精炼性之间的冲突时,或需要具体示例了解每个原则在实际 Go 代码中的应用时,请阅读 [references/PRINCIPLES.md](references/PRINCIPLES.md)。
---
## 格式化
运行 `gofmt` — 没有例外。**没有严格的行长度限制**,但 Uber 建议软限制为 99 个字符。按语义换行,而非按长度 — 选择重构而非仅仅换行。
> 在配置 gofmt、决定换行策略、应用 MixedCaps 规则或解决局部一致性问题时,请阅读 [references/FORMATTING.md](references/FORMATTING.md)。
---
## 减少嵌套
优先处理错误情况和特殊条件。提前返回或继续循环,使"正常路径"保持无缩进。
```go
// 不好:深度嵌套
for _, v := range data {
if v.F1 == 1 {
v = process(v)
if err := v.Call(); err == nil {
v.Send()
} else {
return err
}
} else {
log.Printf("Invalid v: %v", v)
}
}
// 好:扁平结构,提前返回
for _, v := range data {
if v.F1 != 1 {
log.Printf("Invalid v: %v", v)
continue
}
v = process(v)
if err := v.Call(); err != nil {
return err
}
v.Send()
}
```
### 不必要的 Else
如果变量在 if 的两个分支中都被赋值,使用默认值 + 覆盖模式。
```go
// 不好:在两个分支中都赋值
var a int
if b {
a = 100
} else {
a = 10
}
// 好:默认值 + 覆盖
a := 10
if b {
a = 100
}
```
---
## 裸返回
没有参数的 `return` 语句会返回命名返回值。这被称为"裸"返回。
```go
func split(sum int) (x, y int) {
x = sum * 4 / 9
y = sum - x
return // 返回 x, y
}
```
### 裸返回的使用指南
- **在小型函数中可以使用**:裸返回在只有几行的函数中是没问题的
- **在中大型函数中要明确**:一旦函数增长到中等大小,为了清晰起见应明确指定返回值
- **不要仅为了裸返回而命名返回值**:文档的清晰性始终比节省一两行更重要
```go
// 好:小型函数,裸返回很清晰
func minMax(a, b int) (min, max int) {
if a < b {
min, max = a, b
} else {
min, max = b, a
}
return
}
// 好:较大的函数,显式返回
func processData(data []byte) (result []byte, err error) {
result = make([]byte, 0, len(data))
for _, b := range data {
if b == 0 {
return nil, errors.New("null byte in data")
}
result = append(result, transform(b))
}
return result, nil // 显式返回:在较长的函数中更清晰
}
```
关于命名返回参数的指导,请参阅 **go-documentation**。
---
## 分号
Go 的词法分析器会在任何最后一个 token 是标识符、字面量或以下关键字之一的行后自动插入分号:`break continue fallthrough return ++ -- ) }`。
这意味着 **左花括号必须与控制结构在同一行**:
```go
// 好:花括号在同一行
if i < f() {
g()
}
// 不好:花括号在下一行 — 词法分析器会在 f() 后插入分号
if i < f() // 错误!
{ // 错误!
g()
}
```
在惯用 Go 中,显式分号仅出现在 `for` 循环子句中和用于分隔单行上的多个语句。
---
## 快速参考
| 原则 | 核心问题 |
|------|----------|
| 清晰性 | 读者能否理解代码的意图和原因? |
| 简洁性 | 这是否是最简单的方法? |
| 精炼性 | 信噪比是否高? |
| 可维护性 | 后续能否安全地修改? |
| 一致性 | 是否与周围代码保持一致? |
## 相关 Skill
- **命名约定**:在应用 MixedCaps、选择标识符名称或解决命名争议时,请参阅 [go-naming](../go-naming/SKILL.md)
- **错误流程**:在构建错误优先的守卫子句或通过提前返回减少嵌套时,请参阅 [go-error-handling](../go-error-handling/SKILL.md)
- **文档**:在编写文档注释、命名返回参数或包级别文档时,请参阅 [go-documentation](../go-documentation/SKILL.md)
- **Linting 执行**:在使用 golangci-lint 自动化风格检查或配置 CI 时,请参阅 [go-linting](../go-linting/SKILL.md)
- **代码审查**:在系统性代码审查中应用风格原则时,请参阅 [go-code-review](../go-code-review/SKILL.md)
- **日志风格**:在审查日志实践、在 log 和 slog 之间选择或组织日志输出时,请参阅 [go-logging](../go-logging/SKILL.md)

Some files were not shown because too many files have changed in this diff Show More