Compare commits

...

302 Commits

Author SHA1 Message Date
ryan 4c0c389122 chore(release): v3.3.1
### 🛠 修复
- 修复了看板业务流量趋势与 Zone 已提供数据口径不一致的问题,业务量统一由访问日志聚合,避免边缘预聚合窗口差分导致近 24 小时趋势严重偏低。
- 修复了节点地图在缺少精确经纬度时,将香港、新加坡、台湾等地区错误回退到南美等占位坐标的问题,补全质心数据并改进复合地名匹配。

### ⚡️ 优化与改进
- 重构边缘可观测模型:访问日志为业务唯一真相,Agent 仅上报明细、主机指标与 OpenResty 健康连接;新增 edge_health 与 access_log_hourly,删除请求预聚合与 OpenResty 吞吐路径。
- 协议去掉旧兼容层,Agent 升级需销毁重建或二进制替换;旧本地观测缓冲会自动删除并在运行中重建。
- 调整 Agent 默认心跳为 3 秒、离线判定为 60 秒、离线补传窗口为 60 分钟,使节点状态与观测数据刷新更及时。
- 看板 UV 改为窗口内真正去重,Zone 分桶 UV 明确不可跨桶相加;网络趋势仅保留已提供/接收数据,磁盘读写改为按秒速率展示。
- Pages 支持多压缩格式上传、URL 导入部署包,以及可配置的包大小上限与历史保留数量;边缘按项目只保留最新激活部署,切换版本无需重发主配置。
- 新建代理规则时可选择直连、隧道或 Pages 源站类型,与详情页一致。
- 优化 Pages 部署包校验性能,不再为包内每个文件计算哈希,改由整包校验和保障完整性。

### 💄 其他/体验
- 更新可观测设计文档与运维说明,明确健康状态权威源与升级策略。
- 同步 Swagger 与变更日志,便于对照 API 与发布说明。
2026-07-18 16:37:20 +08:00
ryan a0ccafc6ee fix(geo): 修复香港等节点地图质心缺失落到南美占位
补全 Hong Kong/Singapore/Taiwan 质心数据,并改进复合地名与 ISO 匹配,
避免 geo 无精确经纬度时错误回退到巴西等地的占位坐标。
2026-07-18 13:52:20 +08:00
ryan 26057514a1 feat(obs): 去掉宿主机网卡趋势,磁盘读写改按速率展示
Agent 不再采集网卡累计字节,看板与节点网络图仅保留访问日志已提供/接收。
磁盘 IO 按小时换算为 B/s 曲线,摘要为近 24 小时平均速率,并同步 Swagger。
2026-07-18 13:17:13 +08:00
ryan 55f8c9a527 fix(obs): 对齐无兼容层与健康/UV 权威语义
Agent 本地旧观测缓冲直接删除并运行重建;设计文档去掉兼容期表述。
健康当前态以 PG status/message 为准,CH 仅存 status 与连接时序;
Zone 曲线标明分桶 UV,顶部为整窗独立访客。
2026-07-18 12:09:27 +08:00
ryan 802d516f5b docs: 观测重构 changelog 与 Swagger 同步
补充 unreleased 变更说明,并重新生成 Swagger 以匹配去兼容层后的 API。
2026-07-18 11:53:11 +08:00
ryan 71ce028f91 feat(frontend): 观测网络字节字段与 UV 文案对齐
网络图仅使用 bytes_provided/received;看板与节点 UV 改为 24h/查询窗口
独立访客;清理 traffic_reports 与 openresty 吞吐兼容字段;运维清理目标
改为 node_edge_health。
2026-07-18 11:53:11 +08:00
ryan f0e234df1f feat(obs): 访问日志 SSOT 与 edge_health,去掉协议兼容层
Agent 仅上报 host_metrics/edge_health/access_logs;业务流量与 UV 由
Server 侧访问日志聚合。新增 of_node_edge_health 与 of_access_log_hourly,
删除 request_reports/openresty 吞吐路径;API 不再暴露 traffic_reports
与 openresty_rx|tx。心跳/离线默认阈值与回填迁移一并入库。
2026-07-18 11:53:11 +08:00
ryan 9a0974cce8 docs(obs): 边缘可观测 SSOT 设计与实施计划
补充 observability 设计/数据模型/传输模型,更新架构与 Agent 文档侧栏,
并写入 M5 迁移与小时汇总回填运维说明。
2026-07-18 11:53:11 +08:00
ryan 3b9f4daa4e perf(pages): skip per-file hashes during package inspect
Inspect deployment archives via file handles and declared sizes instead of loading the whole package and hashing every member, while keeping whole-package checksums for Agent integrity checks.
2026-07-17 18:37:34 +08:00
ryan 285f127d48 feat(proxy-routes): align create form with upstream type selection
Let new proxy rules choose direct, tunnel, or Pages origin the same way as the detail reverse-proxy section, instead of only accepting a single upstream URL.
2026-07-17 18:20:35 +08:00
ryan 79820b33eb feat(pages): import deployment packages from URL
Add upload-from-url so admins can paste an HTTP(S) link and let the control
plane download the archive with browser-like headers. Private/LAN hosts and
insecure TLS certificates are allowed for internal artifact stores; package
size and format checks reuse the existing local-upload pipeline.
2026-07-17 17:55:12 +08:00
ryan ce736e2de4 feat(pages): pull latest by project and keep a single edge release
Agents now treat pages_project_id as the stable anchor and fetch the
control-plane active package via project latest APIs, so activating a
deployment updates edges without republishing main config. Local roots use
projects/{id}/current, only the newest release is retained after a successful
switch, hash/package races retry, and per-project failures no longer block
siblings.
2026-07-17 17:43:40 +08:00
ryan a0fcf9f627 feat(pages): configurable limits, multi-format packages, and dual versioning
Make Pages package size and history retention system-configurable, support
zip/tar.gz/tar.xz/tar.bz2/tar/7z uploads, prune history with clear keep-N
semantics, and rebind agent config to the live active Pages deployment so
main-config rollback never depends on pruned packages.
2026-07-17 17:16:48 +08:00
ryan 368df3f76b chore(release): v3.3.0
### 🛠 修复
- 修复了 WAF 站点为空绑定规则时请求异常的问题,规范空站点绑定为数组并兼容历史 JSON 空值,避免请求时 Lua 处理失败。
- 修复了 WAF PoW 验证在部分场景下的异常。
- 修复了 Pages 部署文件列表请求与后端路由不一致的问题,并补充回归测试。
- 默认关闭 Redis maintenance notification 自动协商,并应用到平台与 Asynq 客户端,减少在不支持的 Redis 服务上的兼容性警告。
- 修复了 WAF 规则编辑器画布状态不稳定的问题:改用 React Flow 受控节点状态,支持删除节点与连线,节点属性仅在选中后展示。

### ⚡️ 优化与改进
- 新增 WAF 可组合规则可视化编排能力,提供基于 React Flow 的规则编辑器、有序图形 API 与运行时 DAG 执行;规则仅在 OpenResty reload 时发布,并通过校验和驱动的 IP 组快照在受界共享内存中协调。
- 完善 WAF 地域匹配数据,使用完整国家与一级行政区数据,国家选项同时显示中文名称与 ISO 代码,行政区支持按名称或代码搜索。
- Agent 现内置国家与城市地址库,首次启动无需下载即可使用地区匹配,并会在后续自动更新数据。
- 优化了 SQL 日志输出:常规查询日志下调至 debug 级别,慢查询与错误日志不再输出 SQL 文本,避免敏感参数在生产日志中暴露。

### 💄 其他/体验
- 优化了 WAF 规则编辑器初始视图,缩小编排区高度与首次适配缩放比例,默认显示更多画布上下文。
- 服务启动监听就绪后再打印服务横幅,避免在监听失败时显示误导性信息。
- 改进了日志打印输出。
2026-07-14 09:07:06 +08:00
ryan 15e614b304 fix(waf): pow 2026-07-13 17:07:49 +08:00
ryan 46941f65d5 fix(waf): handle empty rule bindings
Encode empty site bindings as arrays and normalize legacy JSON null values in the OpenResty runtime to prevent request-time Lua failures.
2026-07-13 16:49:55 +08:00
ryan 0c2961ae6d fix(waf): complete geography match options
Use full country and ISO subdivision data, show localized country names with codes, and add searchable region selection.
2026-07-13 16:34:18 +08:00
ryan a61d55bb1b chore: improve log print 2026-07-13 16:12:30 +08:00
ryan 6b6c786cfe feat(startup): print service banner after listener ready 2026-07-13 15:49:19 +08:00
ryan 26be762c3a prettier 2026-07-13 15:44:03 +08:00
ryan 0548a8a5d4 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:43:15 +08:00
ryan 60bc03f519 fix(db): log SQL statements at debug level
Move routine GORM SQL output to debug and omit SQL text from slow-query and query-error logs to prevent sensitive parameters from being emitted at production log levels.
2026-07-13 15:39:48 +08:00
ryan 9dc3983e0f fix(frontend): WAF 规则编辑器缩小编排区高度和首次适配缩放比例,默认显示更多画布上下文 2026-07-13 15:39:48 +08:00
ryan 1eff7878a1 prettier 2026-07-13 15:10:28 +08:00
ryan 08e8eea932 fix(frontend): prettier config 2026-07-13 15:04:28 +08:00
ryan 85d5c8568c fix(frontend): stabilize WAF rule canvas
Use React Flow controlled node state, support deleting nodes and edges, and show node properties only after selection.
2026-07-13 14:59:20 +08:00
ryan 74ddf97b36 feat(agent): embed GeoLite2 City database
Initialize missing Country and City databases from embedded assets and use FyraLabs releases for periodic updates.
2026-07-13 14:38:36 +08:00
ryan a1a997bcda feat(waf): complete composable rule orchestration
Add the React Flow rule editor, ordered graph APIs and runtime DAG execution.\n\nPublish rules only on OpenResty reload and reconcile checksum-driven IP group snapshots in bounded shared memory.
2026-07-13 14:17:15 +08:00
ryan d36409fbf9 refactor(frontend): order waf rule bindings 2026-07-13 12:12:49 +08:00
ryan 4000366856 feat(frontend): create orchestrated waf rules 2026-07-13 12:00:24 +08:00
ryan af20e2e838 feat(waf): add composable rule graph core 2026-07-13 11:57:36 +08:00
ryan 2fff30e188 docs(waf): split orchestration migrations 2026-07-13 11:35:16 +08:00
ryan 74c2f57453 docs(waf): plan composable rule implementation 2026-07-13 11:23:34 +08:00
ryan 30e09f5985 docs(waf): design composable rule graph 2026-07-13 11:14:10 +08:00
ryan 43e293e062 fix: frontend optimization 2026-07-13 10:31:09 +08:00
ryan 439ac41da8 fix(frontend): correct Pages deployment files route
Align the Pages deployment file-list request with the backend route and add a regression test.
2026-07-13 09:29:22 +08:00
ryan d97581fb1e chore(release): v3.2.0
### 🛠 修复
- 修复了嵌入式静态前端访问 Zone 详情页时回退到默认首页 HTML,导致界面显示错误并触发 React hydration 异常的问题。
- 修复了 Zone 概览中“已提供的数据总计”长期为 0 的问题,确保 Agent 上报的访问日志流量字节数可以正确入库并用于统计。
- 修复了 Zone 域名导入、删除和更新相关接口与前端交互中的异常,提升域名管理流程的稳定性。
- 修复了 Docker 部署 ClickHouse 时监听配置被覆盖的问题,避免宿主机无法访问 ClickHouse 服务。

### ⚡️ 优化与改进
- 新增 Zone 与正规化 Zone 域名管理能力,将网站、域名、证书与反代路由关系收敛到更稳定的资源模型。
- 管理端网站入口调整为 Zone 列表与 Zone 详情页,支持在概览、域名、路由、证书和设置之间统一管理网站资源。
- 配置快照、OpenResty 渲染、Tunnel 与 Uptime Kuma 监控改为从 Zone 域名绑定读取域名和证书,减少反代路由中的冗余字段。
- 新增 Cloudflare 风格的 Zone 流量概览图,支持按 24 小时、7 天和 30 天查看唯一访问者、请求数与已提供数据趋势。
- 支持在系统设置中管理控制台菜单展示范围,便于按使用场景精简侧边栏入口。

### 💄 其他/体验
- 调整数据库自动清理设置文案,明确自动清理遵循 ClickHouse 表 TTL,并说明访问日志与观测数据的保留下限。
- 优化 Zone 列表、Zone 详情页和系统设置页面布局,使域名、路由和快捷创建流程更清晰。
- 补充 Zone 域名迁移与发布验证文档,便于升级前后核对配置快照与回滚策略。
2026-07-12 19:24:37 +08:00
ryan 83f126795d fix: 调整数据库自动清理设置文案 2026-07-12 19:07:56 +08:00
ryan 69467914fc fix(server): 修复 Agent 上报访问日志的 bytes_sent 在 Server 入库链路丢失,导致 Zone 概览“已提供的数据总计”长期为 0 的问题。 2026-07-12 19:02:52 +08:00
ryan c624512da6 fix(frontend): serve zone detail static export fallback 2026-07-12 18:51:01 +08:00
ryan 50717d1baf fix(frontend): support static export dynamic routes compliance via client useParams 2026-07-12 18:07:43 +08:00
ryan fc569d1758 fix(frontend): dynamically import zone dashboard with ssr: false to cure hydration mismatch
- Dynamically import `ZonePageClient` with `ssr: false` in `websites/[zoneId]/page.tsx`.
- Remove `generateStaticParams` to prevent dynamic paths from building with inconsistent SSG/ISR outputs.
- Remove redundant `mounted` check from `page-client.tsx` since dashboard is client-only.
- Add `bytes_sent` to `NodeAccessLog` on both Agent and Master Server.
- Create ClickHouse migration `202607120001_add_bytes_sent_to_node_access_logs.sql`.
- Refactor duplicate stats structs by centralizing them into `analyticsmodel` package with type aliases.
- Simplify access log store delegations and remove redundant mapping loops.
- Support full console menu display management in settings other-tab.
- Regenerate Swagger documentation.
- Update changelog index.md.
2026-07-12 17:52:40 +08:00
ryan ec4f1d4d23 feat(settings): support full console menu display management in settings other-tab
- Rebuild MENU_GROUPS in `other-tab.tsx` to include all 13 business console items with safety read-only constraints on Dashboard.
- Support reactive filtering in `OpenFlareSidebarMenu` using `menu_display_config` to hide items and empty collapsible groups.
- Fix React Hydration Error #418 when directly loading dynamic websites on SSR by wrapping client component with mounted state hook.
- Add `bytes_sent` to `NodeAccessLog` on both Agent and Master Server.
- Create ClickHouse migration `202607120001_add_bytes_sent_to_node_access_logs.sql`.
- Refactor duplicate stats structs by centralizing them into `analyticsmodel` package with type aliases.
- Simplify access log store delegations and remove redundant mapping loops.
- Regenerate Swagger documentation.
- Update changelog index.md.
2026-07-12 17:46:49 +08:00
ryan 9268acb84c feat(api): support traffic bytes tracking, refactor analytics models and fix website hydration
- Add `bytes_sent` to `NodeAccessLog` on both Agent and Master Server.
- Create ClickHouse migration `202607120001_add_bytes_sent_to_node_access_logs.sql`.
- Refactor duplicate stats structs by centralizing them into `analyticsmodel` package with type aliases.
- Simplify access log store delegations and remove redundant mapping loops.
- Fix React Hydration Error #418 when directly loading dynamic websites on SSR by wrapping client component with mounted state hook.
- Regenerate Swagger documentation.
- Update changelog index.md.
2026-07-12 17:42:19 +08:00
ryan 41cd23a64d feat(api): support traffic bytes tracking in edge access logs and refactor analytics models
- Add `bytes_sent` to `NodeAccessLog` on both Agent and Master Server.
- Create ClickHouse migration `202607120001_add_bytes_sent_to_node_access_logs.sql`.
- Refactor duplicate stats structs by centralizing them into `analyticsmodel` package with type aliases.
- Simplify access log store delegations and remove redundant mapping loops.
- Regenerate Swagger documentation.
- Update changelog index.md.
2026-07-12 17:27:57 +08:00
ryan f02fc9676a fix: resolve all build-test and code-check failures
- Fix Go backend mnd (magic number) and revive lint issues in zone stats.
- Remove unused React/Lucide imports and variables in zone overview.
- Add generateStaticParams and Suspense wrapper for websites/[zoneId] page to support Next.js static HTML export.
- Remove next/font/google dependency to allow fully offline frontend compilation.
- Fix hardcoded time dependency in ssl_renew_test.go.
- Fix async_tasks_test.go to respect minimum 90-day retention clamping in database auto-cleanup.
- Add Zone and ZoneDomain models to test database AutoMigrate schemas.
- Update integration tests to use the new zone_domain_ids route binding scheme.
2026-07-12 17:05:52 +08:00
ryan 31b4886a14 feat(zone): add Cloudflare-style traffic overview charts
Expose Zone stats API with multi-host access-log aggregates and time
series, and render unique visitors, requests and data served on the
Zone overview with 24h/7d/30d range controls.
2026-07-12 16:38:35 +08:00
ryan efcf61e32d feat(web): polish zone list and settings layout
Show Zone list as a table, align detail back navigation with proxy
route detail, and move edit/delete actions into the settings tab.
2026-07-12 16:22:16 +08:00
ryan d615d85a26 refactor: remove unused remarks and add quick domain create
Drop remark fields from Zone, Zone domains, proxy routes, WAF rule
groups and IP groups across models, APIs, UI and DB columns (keep
certificate/origin remarks). Add quick-create domain input for short
labels, @ apex and full FQDNs when binding domains.
2026-07-12 16:16:56 +08:00
ryan 8afd103751 fix(zone): register domain delete/update APIs and drop edit UI
补全 Zone 与 Zone 域名的 update/delete 路由与业务逻辑,修复删除域名
404;前端域名列表移除编辑入口,仅保留添加与删除。
2026-07-12 15:56:45 +08:00
ryan 7ef84cce52 feat(web): improve zone domain list and detail navigation
合并 Zone 域名与路由展示,上游地址多行显示并支持路由详情跳转;
域名列表对齐用户管理页样式;Zone 页支持 ?tab= 定位;反代详情返回
使用浏览器历史上一级。
2026-07-12 15:50:43 +08:00
ryan 96b8ddc077 fix(migrator): only import zone domains during upgrade window
Zone 历史导入仅在 goose 版本位于 [202607120002, 202607130001) 时执行,
phase-2 删列完成后日常启动不再进入导入逻辑。
2026-07-12 15:38:28 +08:00
ryan 03b81e5f74 refactor(migrator): use goose SQL only and auto-import zones on upgrade
移除 Go goose 迁移(bridge/legacy data/zone import),改为 goose SQL 占位
与结构迁移脚本;启动时 Migrate 在删旧列前自动导入历史域名,去掉
migrate-zones 手动命令及文档中的人工导入步骤。
2026-07-12 15:36:00 +08:00
ryan 5b52acdd6c refactor(zone): remove legacy route domain storage
第二阶段清理:删除 of_managed_domains 与 of_proxy_routes 冗余域名/证书列,
移除 ManagedDomain 模型与 API、路由侧 legacy 字段维护,以及前端 WebsiteService。
ImportLegacy 在旧列/旧表缺失时跳过对应源,保持幂等。
2026-07-12 15:31:01 +08:00
ryan fb3dd5afe6 docs(zone): add migration and release verification guide
补充 Zone 域名迁移操作指南(备份、migrate-zones、预览等价性、发布与回滚),
更新设计文档阶段说明、指南导航与 Unreleased 变更日志。
2026-07-12 15:31:01 +08:00
ryan 1160d5846a refactor(web): select route domains from zones
反代路由创建/域名配置改为绑定 zone_domain_ids,移除手写域名列表与
旧证书字段;列表与 WAF/UptimeKuma 消费端同步读取 zone_domains。
2026-07-12 15:23:48 +08:00
ryan 350b433cc1 feat(web): add zone-based website management
以稳定 Zone ID 替换旧托管域名详情页:列表展示根域与域名计数,详情提供
概览/域名/路由/证书/设置 Tabs,并补齐 ZoneService 与 vitest 行为测试。
Zone 列表 API 返回 domain_count;同步 Swagger 与重构计划进度。
2026-07-12 15:23:43 +08:00
ryan d4d9bad74d refactor(config): render routes from zone domains 2026-07-12 15:03:24 +08:00
ryan d0536fcdd5 refactor(proxy): bind routes through zone domains 2026-07-12 14:52:19 +08:00
ryan e51f1e583d chore: remove execution report artifact 2026-07-12 14:41:22 +08:00
ryan b835144cd0 merge: zone domain foundation 2026-07-12 14:41:04 +08:00
ryan 53c868e99b feat(zone): add zone management api and legacy importer 2026-07-12 14:35:53 +08:00
ryan 50678756d4 feat(zone): add normalized zone domain schema 2026-07-12 14:23:25 +08:00
ryan 3eb670c674 chore: ignore local worktrees 2026-07-12 14:17:07 +08:00
ryan d10132fb02 docs(plan): add zone domain refactor plan 2026-07-12 14:14:25 +08:00
ryan 61cf581621 docs(zone): clarify certificate ownership 2026-07-12 14:08:43 +08:00
ryan e2ac531abb docs(zone): define zone domain management model 2026-07-12 14:05:04 +08:00
ryan c8b1289043 fix(docker): preserve clickhouse listener config 2026-07-12 12:28:34 +08:00
ryan 13c5073bf8 chore(release): v3.1.2
### 🛠 修复
- 修复了节点与仪表盘 24 小时容量、网络、磁盘 IO 趋势在限流查询下几乎为空的问题,改为小时级聚合与计数器增量统计,历史时段可正常展示。
- 修复了静置场景下 ClickHouse CPU 偏高的问题,可观测与访问日志写入改为批量凑批并限制小 part 产生。
- 修复了数据清理接口将「物化表 TTL」误报为已删除行数的问题,短于表 TTL 的保留天数会被明确拒绝。
- 修复了可观测去重在入队/刷盘失败后仍占用键、导致故障窗口数据更易丢失的问题,并在 flush 失败时短重试与释放键。
- 修复了 Dashboard「每节点最新指标」被全局 LIMIT 截断导致安静节点缺失的问题,改为按节点取最新快照。
- 修复了 Docker 部署 ClickHouse 25.x 因后台池与 mutation 空闲阈值不兼容而无法启动的问题。
- 修复了小时预聚合仅有迁移后少量数据时 24 小时趋势再次残缺的问题:读路径按小时 merge(窗口完整走 rollup,缺口用 raw 补齐),并增加历史 backfill 迁移。

### ⚡️ 优化与改进
- 为容量与 OpenResty 指标增加小时预聚合表,窗口完整时优先走 rollup 降低查询压力。
- 审计日志写入增加最长等待刷盘,管理端可观测状态接口暴露 batch writer 队列深度、丢弃与 flush 错误指标。
- 小规格场景下调 ClickHouse 客户端连接池默认值,并调整 async_insert 合并超时与流量小时表 TTL。
- 流量独立访客在小时汇总中改为窗口峰值估计,并修正界面文案,避免被误解为全局真实 UV。

### 💄 其他/体验
- 同步环境变量与配置模板中的 ClickHouse 说明;部署文档改为将 performance.xml 下载到 ./config/clickhouse 后挂载,且不挂载 listen 配置。
2026-07-10 11:42:49 +08:00
ryan da1dd92404 fix(observability): merge rollup+raw hourly trends and backfill history
Prefer capacity/openresty rollups only when they cover the 24h window;
otherwise merge per hour so raw fills pre-MV gaps and rollup wins on
overlap. Add a one-time ANTI JOIN backfill migration for the last 30 days.
2026-07-10 11:27:51 +08:00
ryan 4b11279662 fix(observability): fall back to raw hourly when rollup is incomplete
Materialized capacity/openresty hourly tables only hold data after the MV
exists. Preferring any non-empty rollup hid full raw history and left 24h
charts with only recent hours. Use rollup only when its earliest bucket
covers the query window start.
2026-07-10 11:27:51 +08:00
ryan bbadcca294 ### 🛠 修复
- 修复了节点与仪表盘 24 小时容量、网络、磁盘 IO 趋势在限流查询下几乎为空的问题,改为小时级聚合与计数器增量统计,历史时段可正常展示。
- 修复了静置场景下 ClickHouse CPU 偏高的问题,可观测与访问日志写入改为批量凑批并限制小 part 产生。
- 修复了数据清理接口将「物化表 TTL」误报为已删除行数的问题,短于表 TTL 的保留天数会被明确拒绝。
- 修复了可观测去重在入队/刷盘失败后仍占用键、导致故障窗口数据更易丢失的问题,并在 flush 失败时短重试与释放键。
- 修复了 Dashboard「每节点最新指标」被全局 LIMIT 截断导致安静节点缺失的问题,改为按节点取最新快照。
- 修复了 Docker 部署 ClickHouse 25.x 因后台池与 mutation 空闲阈值不兼容而无法启动的问题。

### ⚡️ 优化与改进
- 为容量与 OpenResty 指标增加小时预聚合表,读路径优先 rollup,降低 24 小时趋势查询压力。
- 审计日志写入增加最长等待刷盘,管理端可观测状态接口暴露 batch writer 队列深度、丢弃与 flush 错误指标。
- 小规格场景下调 ClickHouse 客户端连接池默认值,并调整 async_insert 合并超时与流量小时表 TTL。
- 流量独立访客在小时汇总中改为窗口峰值估计,并修正界面文案,避免被误解为全局真实 UV。

### 💄 其他/体验
- 同步环境变量与配置模板中的 ClickHouse 说明;部署文档改为将 performance.xml 下载到 ./config/clickhouse 后挂载,且不挂载 listen 配置。
2026-07-10 11:27:51 +08:00
ryan 44ce6497a1 docs(docker): use ./config/clickhouse for CH performance mount
Move performance.xml to config/clickhouse and mount the directory to
/etc/clickhouse-server/config.d; update docs and compose paths.
2026-07-10 11:10:32 +08:00
ryan 4b83f91b31 docs(docker): use ./config/clickhouse for CH performance mount
Move performance.xml to config/clickhouse and mount the directory to
/etc/clickhouse-server/config.d; update docs and compose paths.
2026-07-10 10:59:21 +08:00
ryan 9d2fac5d4c docs(config): sync .env.example and config.example.yaml for CH defaults
Align ClickHouse pool/password placeholders and docker-compose env docs with
runtime defaults and published ports used in local Docker testing.
2026-07-10 10:48:30 +08:00
ryan b4b93ff4ed fix(docker): make ClickHouse startable on 25.x and reachable from host
Lower merge-tree free-entry thresholds for small background pools, bind
listen_host to 0.0.0.0 for published ports, and allow CLICKHOUSE_ENABLED=true
in tests for live_ch smoke coverage.
2026-07-10 10:44:49 +08:00
ryan 160e63558f fix(clickhouse): harden R/W path P0–P3 (cleanup, durability, rollups)
Honest TTL cleanup semantics; enqueue-safe dedup with flush retry and writer
metrics; model insert hooks; latest-per-node and hourly metric/openresty
rollups; small-host pool/async defaults, traffic hourly TTL, and UV labeling.
2026-07-10 10:34:04 +08:00
ryan 9b3555c569 fix(clickhouse): cut idle CPU from tiny parts and oversized merge pools
Observability writers flushed every few seconds with MinBatchSize unset,
creating constant small parts and merge load. Enable MinBatchSize with
MaxFlushWait, batch access logs more aggressively, and shrink ClickHouse
background pools for 3c hosts.
2026-07-10 10:08:32 +08:00
ryan b928928958 fix(observability): restore 24h capacity/network/disk trends via CH hourly agg
Node and dashboard 24h capacity, network, and disk IO charts only used the
latest limited raw snapshots (120/500 rows), so historical hour buckets stayed
empty. Prefer ClickHouse hourly aggregates with counter deltas, and fall back
to raw snapshots when aggregation is unavailable.
2026-07-10 09:48:45 +08:00
ryan b312460ddf chore(release): v3.1.1
### ⚡️ 优化与改进
- 将 cap_login_enabled 默认值由 true 变更为 false,默认关闭登录界面 PoW 人机验证。
2026-07-06 12:29:53 +08:00
ryan 50f7257d93 docs: readme 2026-07-05 23:53:26 +08:00
ryan 336185f01c release: v3.1.0
### 🛠 修复
- 修复 ClickHouse TTL 迁移中 DateTime64 时间列无法直接设置 TTL 导致 goose 启动失败的问题,改为通过 toDateTime() 转换后再应用 TTL。
- 修复 ClickHouse 迁移尝试缩短 ORDER BY 排序键时与隐式主键前缀冲突导致迁移失败的问题,移除不支持的 MODIFY ORDER BY 操作。
- 修复系统设置页面 URL tab 参数未包含 openflare-ops 选项卡导致无法正确定位的问题,并在无参数时默认选中 OpenFlare 选项卡。
- 修复系统自更新检测上游 GitHub Release 时,因资产包名称前缀 openflare-server 与仓库名不完全一致导致匹配失败并报错「未找到兼容的 Release」的问题。
- 修复全局搜索数据源覆盖不全的问题,补全所有核心业务控制台页面及管理员专有页面的检索支持。

### ⚡️ 优化与改进
- ClickHouse 启用 async_insert 异步写入缓冲,并调高 block_buffer_size 与连接池默认值,降低小 part 生成与连接争用。
- 优化 ClickHouse 写入路径:移除 Agent 心跳中的同步 ALTER DELETE 保留清理,batchwriter 新增 MinBatchSize 抑制过小批次定时 flush,可观测 writer 批次与 flush 间隔调优并补全去重。
- ClickHouse 分析表新增 TTL 自动过期策略,访问日志 180 天、节点访问日志 90 天、其余观测与聚合表 30 天自动清理。
- 访问日志与 WAF IP 组查询改为 ClickHouse 侧聚合与 SQL 分页,默认限制近 7 天查询窗口,浏览器分布查询增加 Top 100 限制。
- Dashboard 与节点可观测 API 消除无 LIMIT 全表扫描,增加短 TTL 内存缓存,前端轮询间隔分别调整为 60s/30s。
- ClickHouse 遗留治理 Phase 2:保留期清理改为 TTL MATERIALIZE TTL,统一 ChConn 读路径,新增 /admin/status/clickhouse 运维指标与 of_node_traffic_hourly 预聚合 MV。
- 审计访问日志写入时仅保留安全相关请求头并以 SHA-256 脱敏,将 headers 载荷上限收紧至 2KB,减小行宽与 merge CPU 开销。
- Docker 部署为 ClickHouse 增加 nofile ulimits 与性能配置挂载,限制 max_concurrent_queries 与后台合并争用。
- 数据库自动清理任务新增 OpenResty、FRPS、FRPC 观测表清理目标。

### 💄 其他/体验
- 隐藏侧边栏文档库中的「规范示例」与「接口文档」,将「使用文档」及其他相关页面链接统一跳转至外部文档站 https://open-flare.pages.dev/。
- 移除系统设置 OpenFlare 标签页下的版本信息卡片及对应升级管理弹窗逻辑。
- 系统设置页面支持通过 URL 持久化当前选中的 Tab 状态。
2026-07-04 09:43:31 +08:00
ryan 44bba0f19a fix(clickhouse): remove unsupported MODIFY ORDER BY from migration
ClickHouse keeps the implicit PRIMARY KEY when shortening ORDER BY,
which fails with "Primary key must be a prefix of the sorting key".
TTL-only changes are safe and unblock goose startup; narrowing ORDER BY
would require table recreation.
2026-07-02 16:45:48 +08:00
ryan f0eca028f9 fix(clickhouse): cast DateTime64 to DateTime in TTL migration 2026-07-02 16:21:30 +08:00
ryan 58624db397 perf(clickhouse): Phase 2 legacy governance — TTL cleanup, unified pool, MV, ops API
- Replace retention ALTER DELETE with MATERIALIZE TTL; use TRUNCATE for delete-all
- Remove GORM ClickHouse pool; migrate user access log reads to ChConn
- Drop query-side trim(remote_addr); enable wait_for_async_insert=1
- Add of_node_traffic_hourly MV and dashboard traffic trend fallback
- Add GET /admin/status/clickhouse operational metrics endpoint
2026-07-02 15:47:38 +08:00
ryan 38946d1af5 fix(clickhouse): resolve lint issues from optimization stack 2026-07-02 15:28:49 +08:00
ryan caf2ffcff4 perf(clickhouse): P1 TTL migrations, ORDER BY tune, remote_addr normalization 2026-07-02 15:25:03 +08:00
ryan 0e86fe3547 perf(clickhouse): P2 docker server tuning and audit log payload reduction 2026-07-02 15:23:17 +08:00
ryan 6525bef15d perf(clickhouse): P0/P1 access log and WAF query aggregation and SQL pagination 2026-07-02 15:23:17 +08:00
ryan 3e910f1961 perf(clickhouse): P0 dashboard/observability query limits, cache, slower polling 2026-07-02 15:23:17 +08:00
ryan 28c14eb054 perf(clickhouse): enable async_insert and tune connection/buffer defaults 2026-07-02 15:23:17 +08:00
ryan ae618905a3 perf(clickhouse): P0 write path — remove heartbeat DELETE, batchwriter MinBatchSize, tune chwriter 2026-07-02 15:23:17 +08:00
ryan 5ad151469c feat(frontend): delete unused version-upgrade-dialog component and use-openflare-server-upgrade hook
- Permanently delete version-upgrade-dialog.tsx and use-openflare-server-upgrade.ts as they are no longer referenced after removing the version info card from openflare-ops settings.
2026-06-30 21:04:52 +08:00
ryan 6467b32d8e fix(frontend): include openflare-ops tab in whitelist and make it default
- Include 'openflare-ops' in the list of validTabs so that specifying ?tab=openflare-ops correctly loads the OpenFlare settings tab.
- Set fallback tab default to 'openflare-ops' when no tab parameter is specified.
- Document changes in changelog.
2026-06-30 20:56:34 +08:00
ryan cf72420815 feat(frontend): persist selected tab on admin settings page 2026-06-30 20:53:33 +08:00
ryan 34225cb88a fix(updater): resolve release asset name matching for openflare-server
- Update expectedAssetNames helper to match lowercase repoName prefix and lowercase repoName with -server suffix (e.g. openflare-server).
- Fixes 'no compatible release found' error when checking GitHub Action releases.
2026-06-30 20:47:32 +08:00
ryan 389f02b6b0 feat(frontend): update navigation links and expand search coverage
- Hide 'Specification Examples' and 'API Docs' from sidebar documents group, pointing 'Use Docs' externally to pages.dev.
- Complete searchData array to cover all console business pages and missing admin-only pages.
- Add changelog records for these adjustments.
2026-06-30 20:36:55 +08:00
ryan 2fcbb945fb docs: update 2026-06-30 16:52:17 +08:00
ryan 23501259b2 docs: update 2026-06-30 16:42:41 +08:00
ryan c561e65cd3 docs: update 2026-06-30 16:38:53 +08:00
ryan 97095e8f12 release: v3.0.2
### 🛠 修复
- 修复 PostgreSQL 自增主键序列在历史数据迁移(INSERT 指定显式 ID)后与实际数据不同步的问题,通过新增全局序列同步脚本一键重置所有相关表的自增计数器。
2026-06-30 16:17:21 +08:00
ryan 23be2f9296 fix(db): 新增 PostgreSQL 数据库自增序列全局同步迁移脚本
为了解决因历史数据以显式 ID 方式迁移导致 PostgreSQL 自增序列计数器不同步,产生主键冲突唯一性约束报错(如 WAF 规则组和 IP 组保存失败)的问题,在 PostgreSQL 迁移中加入了对所有相关表 pg_get_serial_sequence 重置的代码。同时,在 SQLite 中补齐了对应的同名迁移文件。
2026-06-30 16:13:37 +08:00
ryan 113ea25aa4 release: v3.0.1
### ⚡️ 优化与改进
- 新增管理后台用户个人信息编辑与重置密码功能。
- 新增用户列表邮箱列展示以及基于邮箱的搜索过滤。
- 后端新增 `reset-passwd` 命令行工具,支持通过命令行直接重置用户密码。

### 🛠 修复
- 修复添加 DNS 账号时,因直接传递类静态方法作为 React Query 的 mutationFn 导致 JavaScript 运行时丢失 `this` 上下文报错 `this.post is not a function` 的问题。
- 修复侧边栏一级菜单项当前页面字体颜色被硬编码为 `#6366F1` 的问题,改用 CSS 主题变量 `text-sidebar-primary`,以保证多主题色彩一致。
- 修复默认主题(Default)遗漏声明 `destructive-foreground` 变量,导致删除确认按钮在某些状态下渲染为黑底黑字而无法阅读的问题。
- 修复 Cobra 命令行初始化注册逻辑,确保所有应用运行模式(All, API, Worker, Scheduler)都正确注册为 Cobra 子命令。
- 优化系统设置页面的色彩定义,移除硬编码的 Indigo 靛蓝色以适配多主题切换。

### 💄 其他/体验
- 在 `AGENTS.md` 规范中新增关于防止服务类静态方法 callback 上下文丢失的开发规范指南。
2026-06-30 11:58:09 +08:00
ryan c230d5a744 fix(frontend): 解决添加DNS账号时this.post is not a function报错
在 DnsAccountCreateDialog 组件中,mutationFn 错误地直接传递了类静态方法 DnsAccountService.create,导致执行时丢失 class constructor 上下文。现将其修改为使用箭头函数包裹,以保证 this 指向正确。
2026-06-30 11:52:24 +08:00
ryan c02b649b46 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:50 +08:00
ryan fb54d6da61 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:31:20 +08:00
ryan 9c7896df50 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:22:11 +08:00
ryan 01ebec6dfb 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:44 +08:00
ryan 2816152536 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:34:20 +08:00
ryan b029714c7a docs: update 2026-06-28 10:11:51 +08:00
ryan e0f452eaae docs: update 2026-06-28 10:09:43 +08:00
ryan d721a8fd74 docs: update 2026-06-28 10:08:31 +08:00
ryan 1e349e5cde docs: update default credentials to admin and 12345678 in documents 2026-06-28 10:03:45 +08:00
ryan f03c88ffad docs: update 2026-06-27 16:29:58 +08:00
ryan 3038304382 docs(design): update product boundaries and configuration references
- Update docs/design/index.md to document system constraints, including mandatory Redis/ClickHouse dependencies and dynamic Relay Web UI settings.
- Update docs/reference/configuration.md to reflect w_system_configs table keys, option groups, and new environment variable mappings.
- Clean up old plan files.
2026-06-27 16:03:16 +08:00
ryan 196bdabc80 docs(docs): update server start and quick-start docs to require PG/SQLite, Redis, and ClickHouse
- Restructure docs/deployment/server.md into Docker (Quick Start, Production Recommended, Advanced with Jaeger) and Local deployment.
- Update docs/guide/quick-start.md default docker-compose to use PostgreSQL, Redis, and ClickHouse as default.
2026-06-27 15:57:55 +08:00
ryan 77931c3c1e docs(docs): add google analytics tracking tag to head config 2026-06-27 15:41:08 +08:00
ryan a97d87acf5 fix(frontend): fix hasConfigDiff function missing WAF and site changes
Add check for waf_config_changed, added_sites, removed_sites, and modified_sites in hasConfigDiff helper to prevent disabling the publish button on WAF/site edits.
2026-06-27 15:40:16 +08:00
ryan 8d8814b416 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:32:27 +08:00
ryan 02ebb81929 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:26:13 +08:00
ryan 60222acf7e refactor(db): use version as primary key for ConfigVersion and reuse model layer
- Transition `of_config_versions` primary key from `id` to `version` string.
- Add database migration files for PostgreSQL and SQLite.
- Introduce GORM hooks to preserve JSON backward compatibility.
- Remove all localized private structures (`configVersionRecord`, `configVersionRow`) across `agent` and `flared` modules.
- Remove local database Row structures (`tlsCertificateRow`, `tunnelNodeRow`, `pagesProjectRow`) in `proxy_route` module.
- Reuse `model` query methods directly to fetch active config, tunnel nodes, and pages projects.
- Cache IP detection results in memory with a 10-minute TTL to prevent frequent HTTP egress queries to realip.cc.
- Integrate multiple fallback IP lookup providers (ifconfig.me, ip.sb, icanhazip.com) to guarantee IP detection reliability.
2026-06-27 14:00:55 +08:00
ryan 7b1fea8194 refactor(db): use version string as primary key for ConfigVersion
-transition `of_config_versions` primary key from `id` to `version` string.
-add database migration files `202606270001_make_version_primary_key.sql` for PostgreSQL and SQLite.
-introduce AfterFind/AfterCreate GORM hooks to preserve JSON backward compatibility.
-refactor API controllers, logics, and front-end typescript definitions to receive `string` parameter.
2026-06-27 13:46:55 +08:00
ryan ac7b776378 refactor(db): use version string as primary key for ConfigVersion
-transition `of_config_versions` primary key from `id` to `version` string.
-add database migration files `202606270001_make_version_primary_key.sql` for PostgreSQL and SQLite.
-introduce AfterFind/AfterCreate GORM hooks to preserve JSON backward compatibility.
-refactor API controllers, logics, and front-end typescript definitions to receive `string` parameter.
2026-06-27 13:41:50 +08:00
ryan 13d6966cb5 refactor(db): use version string as primary key for ConfigVersion
-transition `of_config_versions` primary key from `id` to `version` string.
-add database migration files `202606270001_make_version_primary_key.sql` for PostgreSQL and SQLite.
-introduce AfterFind/AfterCreate GORM hooks to preserve JSON backward compatibility.
-refactor API controllers, logics, and front-end typescript definitions to receive `string` parameter.
2026-06-27 13:23:30 +08:00
ryan b48414e1fe docs: update 2026-06-26 21:25:18 +08:00
ryan 894f8f1ea2 ui 优化 2026-06-26 20:48:24 +08:00
ryan b89dc9ec7e fix(waf): correct whitelist logic to bypass and add config/IP-group edit broadcasts
- Transition WAF whitelist filter from strict block-on-miss to bypass-on-hit logic

- Hook up broadcastIPGroupToAgents to CreateIPGroup and UpdateIPGroup WAF logics

- Hook up BroadcastActiveConfig to PublishConfigVersion and ActivateConfigVersion version logics

- Update WAF Lua tests in manager_test.go
2026-06-26 20:47:30 +08:00
ryan 49eae80c78 修复目录权限问题 2026-06-22 23:21:04 +08:00
ryan 8ed91dbf97 修复证书问题 2026-06-22 22:56:10 +08:00
ryan 92ceecc6ce 迁移配置表 2026-06-22 22:23:49 +08:00
ryan d9b8dc81ee fix(relay): prioritize IPv4 in dual-stack outbound IP detection
修复 Relay 节点在双栈网络环境下可能上报 IPv6 地址,导致 Tunnel frpc 客户端无法连接 frps 的问题。

根因:
- HTTPOutboundIPStrategy 首次尝试使用 tcp4 强制 IPv4 连接
- 失败时回退到双栈 tcp 客户端,此时可能通过 IPv6 连接并返回 IPv6 地址
- Relay 心跳将 IPv6 地址上报给 Server
- Tunnel frpc 尝试连接该 IPv6 地址失败

修复:
- 在回退到双栈客户端后,仍优先返回 IPv4 地址(通过 ip.To4() 转换)
- 仅在完全无 IPv4 路由时才返回 IPv6
- 确保 Relay 心跳上报的 IP 与 frpc 连接兼容

影响范围:
- pkg/geoip.HTTPOutboundIPStrategy.GetOutboundIP
- internal/apps/relay/heartbeat.Service(通过 nodeip.DetectWithContext 调用)

测试:
- 新增 TestHTTPOutboundIPStrategyPrioritizesIPv4 验证 IPv4 优先逻辑
- 所有现有测试保持通过
2026-06-22 15:49:08 +08:00
ryan deb232d840 refactor(edge): unify dynamic IP detection and prioritize IPv4 reporting
- Align agent, relay, and flared to dynamically resolve IP during heartbeat using the nodeip package (when not manually configured).
- Update GeoIP outbound IP strategy to prefer IPv4 HTTP client lookup using tcp4 dialer and fall back to dual-stack tcp.
- Optimize agent profile fingerprinting to exclude dynamic UptimeSeconds and ReportedAtUnix fields, preventing redundant updates.
- Refactor unit tests to prevent outbound network queries during tests.
2026-06-22 15:06:39 +08:00
ryan 346979344f docs(changelog): 补充 WS EOF 修复与 frpc stderr 日志条目 2026-06-22 14:22:52 +08:00
ryan 1e5f35b9a3 fix(openflare): refresh WS read deadline on JSON pong; capture frpc stderr
- read_pump: 收到客户端 JSON {"type":"pong"} 时调用 conn.SetReadDeadline 刷新
  服务端读超时。修复前,服务端仅在 WebSocket 协议层 Pong 帧时刷新 deadline,
  而客户端使用 JSON 应用层 pong 回复,导致服务端 90s 后超时关闭连接,
  客户端收到 EOF 并触发无限重连循环。在 Cloudflare 代理场景下,
  100s 空闲超时进一步加剧了此问题。

- frpc/manager: 捕获 frpc 子进程 stderr 并在进程异常退出时
  将其内容记录到结构化日志 stderr 字段,便于诊断 exit status 1 的
  具体原因(如配置格式错误、Auth Token 失败、relay 服务端不可达等)。
2026-06-22 14:22:20 +08:00
ryan 346024f346 feat(relay): support configurable frps webui port and fix node detail integration
- Implement configurable FRPS WebUI switch and custom port setting (relay_frps_web_ui_port) in system configs.
- Integrate settings into Relay Node detail manage page instead of global settings.
- Dynamically query server version to select matching Docker image tag for Relay installation.
- Clean up legacy code and fix backend linter/test warnings.
2026-06-22 12:48:54 +08:00
ryan ffe98f6307 移除Notice 2026-06-22 12:00:43 +08:00
ryan 6719c02d05 fix sidebar 2026-06-22 11:58:40 +08:00
ryan 0a3cd250d1 fix relay 2026-06-22 11:56:31 +08:00
ryan 53e6efa33b fix agent 2026-06-22 11:49:36 +08:00
ryan 3d15c65afd perf 2026-06-22 11:32:24 +08:00
ryan 16b02fd3f1 修复 Agent 升级版本比对逻辑 2026-06-22 11:26:55 +08:00
ryan e8778a8641 perf 2026-06-22 11:22:26 +08:00
ryan 7b14e15afb refactor(frontend): simplify routing paths for certificates, dns-accounts and ip-groups
- Remove /websites prefix from TLS certificates and DNS accounts routes.
- Remove /waf prefix from IP groups route.
- Correct relative import paths for shared components.
2026-06-22 10:56:40 +08:00
ryan 3a2878d070 feat(api): integrate TLS certificate renewal into async task framework
Replace native goroutines in RenewCertificate logic with Asynq task dispatching to support queue execution, retry capability, and detailed task execution logs.
2026-06-22 10:54:15 +08:00
ryan df3bcd3d19 perf 2026-06-21 14:51:08 +08:00
ryan 895dec208f fix(agent): write nginx pid and temp dirs under data_dir for non-root runtime
OpenResty running as openflare can no longer write pid or client/proxy temp
paths under the OpenResty install prefix. Templates and apply-time rendering
now use __OPENFLARE_PID_PATH__ and __OPENFLARE_NGINX_CACHE_DIR__ under
data_dir/var/run and data_dir/var/cache/nginx, with legacy pid path patched
at apply. Consolidate runtimeuser path helpers into the main package file so
IDEs resolve references across build tags.
2026-06-21 14:40:10 +08:00
ryan 9d56f02e64 chore(agent): move docker entrypoint script to scripts/
Relocate agent-entrypoint.sh from docker/ to scripts/ so operational
shell scripts live in one directory and update Dockerfile.agent copy path.
2026-06-21 14:26:25 +08:00
ryan 40291136b7 fix(openresty): disable server version disclosure in main config template
Add server_tokens off to the default OpenResty main config template, seeded
option template, and agent safe fallback config so responses no longer
expose nginx/OpenResty version numbers in Server headers or error pages.
2026-06-21 14:25:32 +08:00
ryan d3777eac2d fix(agent): unify agent and openresty runtime user as openflare
Introduce the shared openflare service account for the agent process and
OpenResty workers, normalize data_dir ownership on startup, and ensure
managed paths are chowned with 0755/0644 during sync and apply. Docker
entrypoint fixes volume ownership before dropping privileges; local systemd
install runs the service as openflare with CAP_NET_BIND_SERVICE.
2026-06-21 14:25:20 +08:00
ryan ee047cb351 修复 Pages 站点在未启用 SPA Fallback 时访问根路径 / 返回 404:OpenResty 渲染增加 location = / 精确匹配,通过 try_files 提供入口文件(index 指令在 try_files ... =404 场景下不会作用于根路径)。 2026-06-21 12:18:20 +08:00
ryan 6ed3c0c81f 收敛 Pages 部署包读取路径 2026-06-21 12:04:54 +08:00
ryan 13a375e042 修复 Agent 部署 Pages 问题 2026-06-21 11:53:54 +08:00
ryan 36f11c6ecb 修复代理路由详情认证配置 Tab:移除 PoW 配置(PoW 仅在 WAF 规则组中设置);保留 Basic Auth 保存能力;移除页头重复的「保存当前分区」按钮。 2026-06-21 11:28:50 +08:00
ryan 0f904b4b6d 修复代理路由详情认证配置 Tab:移除 PoW 配置(PoW 仅在 WAF 规则组中设置);保留 Basic Auth 保存能力;移除页头重复的「保存当前分区」按钮。 2026-06-21 11:26:37 +08:00
ryan e479ae75e6 修复 Pages 路由发布失败并报 pages module is not available:配置快照发布流程补齐 Pages 项目激活部署解析与 pages_deployment 写入。 2026-06-21 11:12:37 +08:00
ryan 6f267bbf21 修复仪表盘与节点详情「24 小时网络趋势」误按速率展示:改为 OpenResty 入/出站小时流量与近 24 小时总量摘要,Y 轴与 tooltip 自动换算 B/KB/MB/GB。 2026-06-21 11:08:25 +08:00
ryan ce2b931a78 修复 Pages 上传或节点同步时报 pages file size out of bounds:允许 ZIP 包内的 0 字节文件,并兼容未声明解压大小的 ZIP 条目。 2026-06-21 10:55:48 +08:00
ryan 6e86901a58 修复节点详情 OpenResty 连接数与吞吐显示为「—」:节点可观测 API 将 OpenResty 观测数据合并进 metric_snapshots;指标文案改为「请求/分钟」(近 60 秒窗口),连接数为 0 时正常显示 0。 2026-06-21 10:52:06 +08:00
ryan 42896a8473 仪表盘「24 小时请求趋势」摘要误显示当前小时请求量/错误量:改为汇总近 24 小时总量 2026-06-21 10:43:26 +08:00
ryan 665dd09e11 fix: 修复 Pages 部署包上传报「请求超时,请稍后重试」 2026-06-21 10:39:08 +08:00
ryan b12a9b0185 fix: 修复应用日志异常膨胀 2026-06-21 10:21:18 +08:00
ryan a343c7a605 fix: 修复 Agent 使用 volume 映射时 PoW/WAF 运行时配置无法加载 2026-06-21 10:15:18 +08:00
ryan 117d473c27 fix: 修复 WAF 规则组保存/绑定网站时报 of_waf_rule_group_bindings_pkey 冲突 2026-06-20 22:05:30 +08:00
ryan 99f6f3231a fix: 修复 WAF 规则组保存/绑定网站时报 of_waf_rule_group_bindings_pkey 冲突 2026-06-20 21:33:46 +08:00
ryan b04a358e5e fix: 配置版本列表按 created_at 倒序展示 2026-06-20 21:32:18 +08:00
ryan 6fc39d9e80 fix: 修复 WAF 规则组 PoW 策略发布后边缘不生效 2026-06-20 21:05:25 +08:00
ryan 889e79c8b8 fix: 收敛子代理站点标识双轨逻辑 2026-06-20 21:03:13 +08:00
ryan 9bf7e3cd1b fix: 修复 WAF 规则组 PoW 策略发布后边缘不生效 2026-06-20 20:47:38 +08:00
ryan 8751c0dee3 fix(openflare): mmdb 国家名节点在世界地图使用正确质心
- 从 world-geo 生成国家质心表,Server 在仅有 ISO/国家名时补全 geo 坐标
- 全球态势板在缺少经纬度时按 geo_name 解析质心,避免 fallback 到美国
2026-06-20 19:46:33 +08:00
ryan 498a9ed3ff fix(openflare): Agent 上报 IP 后由 Server 自动解析节点地理位置
- 启动时按 of_options.GeoIPProvider 初始化 pkg/geoip(bootstrap + runtime)
- mmdb 模式从内置 GeoLite2 种子到 data/;保存归属方式后热刷新 Provider
- Agent/Relay 心跳在服务端根据 IP 写入 geo 字段,尊重 geo_manual_override
- ipinfo 归属名称改为 City, Region, Country 可读格式
2026-06-20 19:36:17 +08:00
ryan ec53629971 fix(frontend): 修复配置版本快照侧栏无法滚动
将快照与发布预览 Sheet 内容区改为 flex-1 min-h-0 overflow-y-auto,
并固定侧栏高度为 h-svh,与项目内其他可滚动 Sheet 一致。
2026-06-20 19:22:23 +08:00
ryan 6c46f5d24f fix(openflare): Pages 部署包经 upload 存储下载
Agent 下载 Pages 包时统一通过 upload_id 走文件存储 API;legacy
artifact_path 仅用于一次性回填 upload 并清空路径。部署视图暴露
upload_id,并补充回归测试与 changelog。
2026-06-20 19:21:12 +08:00
ryan e077b12328 fix(frontend): cap envelope mismatch 2026-06-20 19:04:55 +08:00
ryan 570b639e07 fix(agent): commit GeoLite2 mmdb as build fallback
Vendor GeoLite2-Country.mmdb in the repository so agent builds still
work when the remote download is unavailable. Update the fetch script
and agent Dockerfile to prefer a fresh download and fall back to the
committed database file.
2026-06-20 14:04:42 +08:00
ryan 3a368119e5 fix(ci): fetch GeoLite2 mmdb before agent build
Agent embeds GeoLite2-Country.mmdb but the file is not checked into the
repository. Download it in CI, Docker, and Makefile build paths via
scripts/fetch-agent-geoip-mmdb.sh, and correct the gitignore exception
path for the geoipdata package.
2026-06-20 14:03:43 +08:00
ryan 6975a6c290 sync ci 2026-06-20 13:44:24 +08:00
ryan cdac1f8a45 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:23 +08:00
ryan 8c872f9b32 refactor(bootstrap): 引入解耦的全局生命周期管理器以隔离业务停机钩子
- 新建 internal/lifecycle 包,提供全局线程安全的 Shutdown 钩子注册与调度能力。
- 在 chwriter 与 risk_control 初始化阶段通过 OnShutdown 将其 Stop 函数注册到管理器中。
- bootstrap.Stop() 函数仅委托调用 lifecycle.Stop(),不再硬编码引入业务包,防止后续框架同步产生合并冲突。
2026-06-20 09:58:59 +08:00
ryan 2a0ebd16fa fix(backend): 修复优雅停机失效、系统设置并发读写冲突、文件服务API信封绕过以及测试uploads目录污染
- 修复 ClickHouse Batch Writer 与 RiskControl LogWriter 优雅停机,确保退出前队列数据正确刷盘。
- 修复 OpenFlare 系统配置参数全局变量并发读写的 Data Race 冲突,在读取配置时引入读锁保护。
- 修复 upload 模块的 API 错误响应格式,使用 response.Abort* 代替原始的 c.AbortWithStatus 与 c.JSON,保证全局信封格式统一。
- 修复 pkg/utils/network 的 isPrivateIPv4 以使用标准库 net.IP.IsPrivate() 校验,修复 format 的 Bytes2Size 边界,修改大小单位因子变量为只读常量。
- 重构 upload 文件服务与路由器测试,使用 t.TempDir() 代替硬编码的 uploads 相对路径写入和删除,解决测试目录文件污染问题。
2026-06-20 09:33:42 +08:00
ryan b3a55d4ab5 refactor(core): optimize performance, fix concurrency and clean up AGENTS.md design violations
- Concurrency: Added lock protection to WebSocket writes, fixed timer leaks, and prevented config cache listener context leaks.
- Performance: Added memory cache in ObservabilityBufferStore, periodic cleaning in CH Deduplicator, and buffered ZIP batch download writes.
- Design: Introduced Redis caching for OAuth session/tokens, sanitized raw DB error messages, segregated handlers and logics, and standard CAP response envelopes.
2026-06-20 09:09:02 +08:00
ryan 5bed2bae9f migrate database 2026-06-19 22:18:52 +08:00
ryan b6c4181c70 fix(openflare): serialize access log snowflake IDs as strings
- Return AccessLogView.id as string to avoid JS Number precision loss
- Store OpenFlareAccessLog IDs as uint64 with json id,string
- Update frontend AccessLogItem.id type to string
2026-06-19 20:10:09 +08:00
ryan 3187934f72 fix(openflare): ClickHouse count scan and dashboard traffic metrics
- Scan ClickHouse count()/countIf() aggregates as uint64 before int64 conversion
- Fix access log list count, region stats, and delete pre-count queries
- Aggregate dashboard 24h traffic totals from hourly trend buckets
- Show current hour value in traffic trend chart summary
- Rename docker-compose service to openflare
- Remove redundant config preview section from performance page
2026-06-19 17:34:19 +08:00
ryan 9491b2a744 feat(clickhouse): wire batchwriter into business ingestion paths
Migrate risk_control audit logs to internal/db/batchwriter and add
openflare/chwriter with per-table async flush for observability
timeseries and node access logs.

Replace single-row ClickHouse inserts and pre-insert SELECT count()
dedup with repository BatchInsert* APIs, in-process TTL dedup for
metric/report snapshots, and bootstrap initialization on API startup.
2026-06-19 17:20:39 +08:00
ryan ca6c20ebd9 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 17:20:39 +08:00
ryan 578110f615 observation clickhouse 2026-06-19 17:20:39 +08:00
ryan 32861c5db9 fix lint 2026-06-19 17:20:39 +08:00
ryan 0b34792709 refactor(edge): Server 协议统一与 wsclient 收敛(Phase 3 Batch 3)
- openflare/agent/relay/flared 协议类型改为 pkg/protocol 别名,删除重复 struct
- 新增 edge/wsclient Preset 表,三组件 wsclient 改为薄包装
- 更新设计文档、计划与 changelog
2026-06-19 15:00:46 +08:00
ryan db9a9f98fd refactor(edge): 抽取边缘运行时共享包并完成 Phase 3 重构
- 新增 internal/apps/edge/,三组件改为薄包装,删除 3000+ 行重复代码
- Agent 心跳周期下沉至 heartbeat/cycle.go
- 协议类型迁入 pkg/protocol/agent.go
- 补充设计文档与 changelog
2026-06-19 14:56:00 +08:00
ryan cc5e53c51e 文档更新 2026-06-19 14:45:17 +08:00
ryan 9eeeb09d2f feat(repo): rename output server binary from wavelet to openflare-server 2026-06-19 14:36:46 +08:00
ryan 84303d25b2 feat(repo): add build targets for agent, relay, and flared to Makefile 2026-06-19 14:33:32 +08:00
ryan 63cd906cfc refactor(repo): consolidate openflare-server to root and move subprojects to internal/apps
- Merge all files inside openflare-server to the repository root directory.
- Relocate agent, relay, and flared subprojects from internal/ to internal/apps/.
- Combine docker-compose files and update build context paths to root.
- Update GitHub workflows and Dockerfiles to refer to new directories and package names.
- Rewrite Go package imports across all files.
- Resolve database renew test race condition and clean up docs.
2026-06-19 14:23:29 +08:00
ryan 19d476ed7f refactor 2026-06-19 14:10:21 +08:00
ryan 8506a03f1c refactor(clickhouse): route of_node_access_logs through analytics repository
Move all ClickHouse DML for OpenFlare node access logs into
internal/repository/analytics; model layer keeps domain aggregation and
in-memory test store via a thin adapter. Aligns with goose-managed DDL
and openflare database startup dependency.
2026-06-19 12:13:18 +08:00
ryan 8a00f53b16 feat(clickhouse): integrate goose migrations and analytics repository
Merge upstream Wavelet patch to manage ClickHouse DDL via goose
(goose_clickhouse_version), add GORM ChDB access, and centralize
w_user_access_logs reads/writes in internal/repository/analytics.
OpenFlare of_node_access_logs DDL moves to goose/clickhouse; remove
manual clickhouse_schema startup and support-files SQL duplicates.
2026-06-19 12:11:41 +08:00
ryan fff26aa343 merge: migrate of_node_access_logs to ClickHouse
Integrate clickhouse worktree: node access logs now stored in ClickHouse
(openflare database) with mandatory startup dependency.
2026-06-19 11:45:51 +08:00
ryan 425ca89765 feat(openflare): migrate of_node_access_logs to ClickHouse
Move node access log storage from PostgreSQL/SQLite to ClickHouse
(database: openflare). System startup now requires a healthy ClickHouse
connection and auto-initializes the schema. Agent heartbeat writes access
logs via batch insert; goose migration drops the legacy relational table.
2026-06-19 11:45:48 +08:00
ryan 7eb943f02f update doc 2026-06-19 11:45:22 +08:00
ryan d78449cbc9 refactor(repo): replace legacy openflare-server with Wavelet rename
Delete the old monolithic openflare-server implementation and rename
Wavelet/ to openflare-server/ to complete the migration consolidation.
Update CI workflows, agent Dockerfiles, and deployment docs for the new
layout (frontend/, docker/Dockerfile).
2026-06-19 11:37:34 +08:00
ryan aa11c0f52a merge: replace legacy openflare-server with Wavelet rename 2026-06-19 11:30:05 +08:00
ryan 88360350a0 refactor(repo): replace legacy openflare-server with Wavelet rename
Delete the old monolithic openflare-server implementation and rename
Wavelet/ to openflare-server/ to complete the migration consolidation.
Update CI workflows, agent Dockerfiles, and deployment docs for the new
layout (frontend/, docker/Dockerfile).
2026-06-19 11:29:17 +08:00
ryan e3353cd09d docs(openflare): complete Swagger for protocol and dashboard APIs
Add Swagger annotations for all Agent, Relay, and Tunnel protocol
endpoints under /api/v1. Register AgentTokenAuth and TunnelTokenAuth
security definitions. Align dashboard API docs to return 404 for
insufficient admin permission.
2026-06-19 11:26:56 +08:00
ryan 46123d62ae Merge remote-tracking branch 'origin/dev' into dmux-2026-06-19-110554 2026-06-19 11:23:35 +08:00
ryan 68ddad98fb merge: unify protocol API responses to Wavelet format 2026-06-19 11:23:19 +08:00
ryan 33b4123444 refactor(openflare): unify protocol API responses to Wavelet format
Migrate Agent/Relay/Tunnel handlers from compat {success,message,data}
to response.OK and response.Abort* with real HTTP status codes. Remove
the compat package and update openflare-agent, openflare-relay, and
openflared clients to parse {error_msg,data}.
2026-06-19 11:23:12 +08:00
ryan 16e97d0dc3 merge: remove unused About page and API 2026-06-19 11:18:20 +08:00
ryan 7aa4cc908d merge: remove legacy openflare update API 2026-06-19 11:18:15 +08:00
ryan 8fb988ba0a refactor(openflare): remove unused About page and API
Delete AboutService and GET /api/v1/d/about since the about page was
never implemented in the Wavelet frontend.
2026-06-19 11:18:15 +08:00
ryan 53bd451450 refactor(openflare): remove legacy update API, use admin updater
Drop the migrated /api/v1/openflare/update compatibility layer and point
the OpenFlare upgrade UI at Wavelet's /api/v1/admin/update endpoints.
2026-06-19 11:17:45 +08:00
ryan 42ca0ec642 merge: fix(openflare) align admin permission model with Wavelet
Merge dmux-2026-06-19-110554; resolve changelog conflict keeping /api/v1/d/*
path migration and new AdminMiddlewares permission model.
2026-06-19 11:14:30 +08:00
ryan 49d32d0b25 fix(openflare): align admin permission model with Wavelet
Unify OpenFlare console auth to user.IsAdmin and token_admin instead of
the legacy Admin/Root role tiers. Route groups now use
apiutil.AdminMiddlewares (LoginRequired + LoginAdminRequired) so admin
checks cannot be skipped. Add middleware tests and extend integration
coverage for 401/404/400 responses.
2026-06-19 11:13:57 +08:00
ryan 915c00eb34 docs: 同步 OpenFlare 迁移文档与当前实码
更新后端 handover、实现计划 §12 端点对照表与 changelog,
反映 legacy 层已撤销、控制台 /api/v1/d/* 与协议路径现状,
并消除 OpenFlare-Token 桥接相关矛盾表述。
2026-06-19 11:13:10 +08:00
ryan e3309df336 merge: remove legacy global API rate limit options 2026-06-19 11:09:13 +08:00
ryan c69378f0de refactor(openflare): remove legacy global API rate limit options
Drop unused GlobalApi/Web/Critical rate limit settings migrated from the
old server but never wired up in Wavelet, including validation, defaults,
seed data, and a cleanup migration for existing databases.
2026-06-19 11:08:58 +08:00
ryan 71ebfa555f refactor(api): unify routes under /api/v1/d, agent, relay, tunnel
Remove the /openflare prefix from management APIs (/api/v1/d/*) and move
Agent, Relay, and Tunnel protocol endpoints under /api/v1. Update Wavelet
frontend services and node client binaries to match.
2026-06-19 11:08:06 +08:00
ryan 6bd7dc91fc refactor(openflare): mount console APIs under /api/v1/openflare
Move OpenFlare route registration from RegisterCustomRoutes to
v1.RegisterV1Routes so business APIs are mounted directly at
/api/v1/openflare instead of under the /custom example prefix.
Update handler Swagger annotations, frontend service base paths,
and regenerated swagger docs accordingly.
2026-06-18 21:25:20 +08:00
ryan 399c1bc88d refactor(openflare): migrate console APIs to v1 and centralize routing
Move OpenFlare management endpoints to /api/v1/custom/openflare with
Wavelet response envelopes and Abort* error handling. Keep agent, relay,
and flared protocol routes on /api/* with the legacy compat format.

- Add apiutil helpers and Swagger annotations for console handlers
- Register all OpenFlare routes in internal/router/openflare (not apps)
- Switch frontend services to OpenFlareBaseService and v1 paths
- Remove dead auth/compat code and legacy-base.service.ts
- Update integration tests and changelog
2026-06-18 21:25:20 +08:00
ryan aa348a1b48 merge: 规则组编辑改用 Sheet 抽屉 2026-06-18 20:24:23 +08:00
ryan a2f838605c feat(waf): 规则组新建/编辑改用右侧 Sheet 抽屉
将 RuleGroupDialog 替换为 RuleGroupSheet,使用 shadcn Sheet 侧栏交互,
与站点绑定等操作保持一致;添加规则条目仍保留弹窗。
2026-06-18 20:23:56 +08:00
ryan fb7d7ff3a4 merge: 实装 PoW 配置面板 2026-06-18 20:21:29 +08:00
ryan 89b3f5d843 feat(wavelet): 实装完整 PoW 配置面板
迁移旧前端 PoW 难度/TTL 与 IP/路径/UA 黑白名单编辑,替换占位提示。
2026-06-18 20:21:17 +08:00
ryan 056c75a853 前端优化 2026-06-18 20:20:34 +08:00
ryan 676899cab0 前端优化 2026-06-18 20:12:10 +08:00
ryan cf5c2b7258 前端优化 2026-06-18 20:07:22 +08:00
ryan 391823a915 feat(wavelet): 侧栏新增安全性折叠组收纳 WAF 与 IP 组
移除 WAF / IP 组页面右上角交叉导航,改由侧栏统一入口。
2026-06-18 20:00:04 +08:00
ryan b56b4c93b6 refactor(wavelet): 侧栏导航改为 openflareSidebarNav 动态配置
用 kind: item | group 的单一数组描述侧栏顺序,移除 slice(0, 4) 硬编码切分。
2026-06-18 19:54:08 +08:00
ryan 29176da35f feat(openflare): Pages 部署包改用 upload 本地文件存储框架
通过 upload.Ingest 摄取 zip 部署包并记录 upload_id,删除部署时调用
upload.Remove;Agent 下载改为 OpenDeploymentPackage 从存储后端流式输出。
新增 of_pages_deployments.upload_id 迁移,保留 artifact_path 兼容旧数据。
2026-06-18 19:52:59 +08:00
ryan 9ac5ff6925 fix(wavelet): 移除网站管理页面右上角互相跳转按钮
侧栏已有「网站管理」折叠组,去掉网站/证书/DNS 页头交叉导航。
2026-06-18 19:52:17 +08:00
ryan 1283aa5175 Merge branch 'main' into dev
合并侧栏「网站管理」折叠组:保留 dev 导航命名(版本发布等),
源站移入折叠组,并采用 matchesNavPath / isNavGroupActive 路由匹配逻辑。
2026-06-18 19:49:31 +08:00
ryan 1208f7ee94 feat(wavelet): 侧栏新增网站管理折叠组
使用 shadcn Collapsible 与 SidebarMenuSub 收纳网站、证书、DNS、源站入口。
2026-06-18 19:48:30 +08:00
ryan 0c5136e59e 前端优化 2026-06-18 19:43:22 +08:00
ryan 8b3d220d73 merge: 合并 main 至 dev
同步节点网络/磁盘趋势图 ECharts 样式对齐。
2026-06-18 19:35:45 +08:00
ryan 134d279f0c style(wavelet): 节点网络/磁盘趋势图对齐原版 ECharts
改用 TrendChart 组件,恢复系列摘要卡与平滑面积折线图样式。
2026-06-18 19:35:32 +08:00
ryan 2eb84fc7f8 前端优化 2026-06-18 19:33:59 +08:00
ryan dde0117a37 style(wavelet): 全球态势板地图调整为 2:3 比例
固定宽:高约 2:3 的竖向视口并居中展示,降低 ECharts 横向拉伸。
2026-06-18 19:15:06 +08:00
ryan ce72de2d63 Merge branch 'wavelet-top-domain' into dev 2026-06-18 19:15:06 +08:00
ryan bb5c626cd9 merge: 合并 OpenFlare 定时任务 Asynq 迁移至 dev 2026-06-18 19:14:52 +08:00
ryan 3342d3c39d feat(openflare): 将定时任务迁入 Wavelet Asynq 异步任务框架
将 SSL 续期、可观测数据清理、WAF IP 组同步与 Uptime Kuma 同步
从 API 进程内 cron 迁移为 Asynq Handler,并通过 w_schedules 种子迁移
注册默认定时调度;移除 bootstrap 中的 in-process cron 启动逻辑。
2026-06-18 19:14:47 +08:00
ryan 49c40b20b7 fix(wavelet): 修复全球态势板地图容器尺寸为 0
地图注册完成后再挂载 ResizeObserver,并在获得有效宽高后才初始化 ECharts。
2026-06-18 19:12:08 +08:00
ryan 194560c887 merge: 全球态势板布局紧凑化 2026-06-18 19:09:33 +08:00
ryan 7b05e2dd3c style(wavelet): 紧凑化全球态势板布局
地图固定高度约 260px,指标改为侧栏密集单元格,图例行内化以降低首屏占用。
2026-06-18 19:09:20 +08:00
ryan b59a2f3be3 merge: 合并 main 至 dev
同步节点详情 Tabs 布局重构。
2026-06-18 19:08:54 +08:00
ryan f1badac257 refactor(wavelet): 节点详情页改为 Tabs 布局
统一 Edge/Relay/Tunnel 详情壳层,拆分概览、数据看板与配置部署,
支持 tab URL 参数深链并优化信息层次与可读性。
2026-06-18 19:08:49 +08:00
ryan 2cc0f3e673 style(wavelet): 全球态势板对齐系统 Card/Badge 风格
移除旧前端渐变外壳,指标卡与图例复用总览页虚线 Card 与状态 Badge 样式。
2026-06-18 19:06:25 +08:00
ryan 769045af15 merge: 全球态势板视觉对齐系统风格 2026-06-18 19:06:25 +08:00
ryan 0d5fac1621 merge: 合并全球态势板迁移
将旧前端 WorldStage 世界地图板块纳入 dev 总览仪表盘。
2026-06-18 19:02:56 +08:00
ryan fbc668c7bf feat(wavelet): 迁移总览全球态势板
从旧前端移植 WorldStage 世界地图与摘要指标,替换顶部统计卡片区域。
2026-06-18 19:02:52 +08:00
ryan caa6649297 merge: 合并 main 至 dev
同步边缘节点详情数据看板与 main 分支最新变更。
2026-06-18 18:59:09 +08:00
ryan e875ffd865 merge: 合并 wavelet-top-domain 总览仪表盘图表改造
仅合并 ECharts 图表迁移与分布榜单板块,不包含 main 分支其他变更。
2026-06-18 18:58:17 +08:00
ryan e51ada4d60 fix(wavelet): 趋势图组件支持可配置标题与描述
便于节点详情页复用请求与容量趋势图。
2026-06-18 18:58:12 +08:00
ryan 22fbfc92c6 dmux 2026-06-18 18:58:07 +08:00
ryan 32d300ce58 merge: 合并 wavelet-top-domain 总览仪表盘图表改造
合并 ECharts 图表样式迁移与 Top Domain / 分布榜单板块,
保留节点详情页趋势图的可配置标题与描述参数。
2026-06-18 18:55:46 +08:00
ryan 1857fe4c98 feat(wavelet): 总览仪表盘对齐旧前端 ECharts 图表样式
将请求/容量趋势图迁移至 ECharts,并补充网络磁盘趋势、Top 节点榜单、
来源分布、状态码分布与 Top Domain 板块。
2026-06-18 18:54:53 +08:00
ryan 0b646238e9 补全 Wavelet 边缘节点详情数据看板
将旧版 openflare-server 节点观测界面迁移至 Wavelet 前端,包含运行诊断摘要、
系统信息、实时资源、网络流量、24 小时趋势图、请求结构分布与健康事件时间线。
2026-06-18 18:54:47 +08:00
ryan 48421c12de migrate frontend 2026-06-18 18:34:20 +08:00
ryan 75140fc3bb migrate frontend 2026-06-18 18:18:10 +08:00
ryan 15fa943d41 migrate frontend 2026-06-18 18:12:10 +08:00
ryan e7b8993181 migrate frontend 2026-06-18 18:05:00 +08:00
ryan 31b114b0cc migrate frontend 2026-06-18 17:43:11 +08:00
ryan a9fd0bd128 migrate frontend 2026-06-18 17:26:57 +08:00
ryan d7fe4ef8be migrate 2026-06-18 17:12:39 +08:00
ryan 001f5c968e migrate 2026-06-18 17:08:43 +08:00
ryan 01c28bb88b migrate 2026-06-18 17:08:21 +08:00
ryan dfc480c73a migrate 2026-06-18 16:59:02 +08:00
ryan e3bfd9ca6d migrate 2026-06-18 16:51:06 +08:00
ryan 61cfacba55 migrate key 2026-06-18 16:43:02 +08:00
ryan 5943372ce4 migrate db 2026-06-18 16:38:24 +08:00
ryan e889247387 migrate 3000 2026-06-18 16:24:43 +08:00
ryan 4ddda8e733 migrate 2026-06-18 16:18:40 +08:00
ryan 2480d74410 migrate 2026-06-18 16:13:39 +08:00
ryan 772962c2e9 migrate 2026-06-18 16:08:48 +08:00
ryan 3366edb3a1 plan 2026-06-18 15:42:06 +08:00
ryan 99738bbc17 wavelet init 2026-06-18 15:24:48 +08:00
ryan d6a7011885 更新文档 2026-06-17 10:48:05 +08:00
ryan 6ea2c90f75 [优化] 日志查询优化 2026-06-17 10:45:30 +08:00
ryan 959b134d67 [优化] 目录调整 2026-06-06 16:50:24 +08:00
ryan 314229b7ee [优化] Update 2026-06-06 15:49:15 +08:00
ryan 7ed75e79ce [优化] Update 2026-06-06 12:25:55 +08:00
ryan 52efc629b7 [优化] Update 2026-06-06 12:23:52 +08:00
ryan 123140b762 [优化] POW 系统接口方案 2026-06-06 12:21:55 +08:00
ryan 6bf5023af1 [优化] POW 系统接口方案 2026-06-06 12:13:55 +08:00
ryan 4be7c19798 [优化] POW 系统接口方案 2026-06-06 12:07:39 +08:00
ryan 32e1a07157 [优化] 重构数据库历史迁移校验架构 2026-06-06 11:50:57 +08:00
ryan 2662c65f57 [优化] 部署脚本优化 2026-06-06 11:06:45 +08:00
ryan 3cfefb4367 [优化] go 引用调整 2026-06-06 10:46:40 +08:00
ryan ee1110b752 [优化] 文档优化 2026-06-05 12:17:07 +08:00
ryan 9959397934 [优化] 文档优化 2026-06-05 11:49:50 +08:00
ryan 5f82f72a6f [优化] 文档优化 2026-06-05 11:44:25 +08:00
ryan 43e8ef154f [优化] response 结构调整 2026-06-05 11:42:25 +08:00
ryan 4dc4c745c8 [优化] response 结构调整 2026-06-05 11:32:10 +08:00
ryan a5257be319 [优化] 文档更新 2026-06-05 11:28:02 +08:00
ryan 30f36efe5a [优化] 文档更新 2026-06-05 11:27:49 +08:00
ryan 5a6c5cf72c [优化] 文档更新 2026-06-05 11:13:07 +08:00
ryan 189916d1db [优化] 文档更新 2026-06-05 11:12:06 +08:00
ryan 546856594e [优化] 文档更新 2026-06-05 10:42:06 +08:00
ryan 457b3397fd [优化] 文档更新 2026-06-05 10:33:18 +08:00
ryan 47ff33c653 [优化] 白名单优先级高于黑名单 2026-06-04 12:35:52 +08:00
ryan 1d41b108fc [优化] 界面优化 2026-06-04 12:19:04 +08:00
ryan b7a101fc60 [优化] CI 2026-06-04 12:11:55 +08:00
ryan c85d9104ec [优化] 每日定时清理预发布版本 2026-06-04 12:08:33 +08:00
ryan f3cdbdd7d5 [优化] 修复文档 2026-06-04 12:05:36 +08:00
1940 changed files with 276493 additions and 110531 deletions
+217
View File
@@ -0,0 +1,217 @@
---
name: "cache-framework"
description: "Wavelet 项目专用:当新增或修改业务缓存(RAM/Redis/DB 三层读路径)、缓存失效、多节点 pub/sub 同步、或评估高频读是否应接入缓存时必须使用。本技能说明系统标准缓存框架、参考实现、禁止写法与分布式一致性要求。"
---
# 系统三层缓存框架
开始前阅读根目录 `AGENTS.md`(含 **Skill 关联索引**)。Wavelet 标准读路径为 **本地 RAM → Redis → PostgreSQL**(由快到慢),不是 DB 优先。
详细性能背景见 `docs/PERFORMANCE.md`。
## 关联 Skill
| 关联 | 何时一并阅读 |
| :--- | :--- |
| [database-migration](../database-migration/SKILL.md) | 缓存对象对应新表/列/索引,或 seed 变更 |
| [new-setting](../new-setting/SKILL.md) | 系统配置类缓存(`GetSystemConfigByKey`、`ListSystemConfigsByKeys`) |
| [file-upload](../file-upload/SKILL.md) | 上传元数据 `upload:meta:{id}`、ingest/remove/cleanup 失效钩子 |
| [clickhouse-batchwriter](../clickhouse-batchwriter/SKILL.md) | 分析写入走 batchwriter,**不要**用本技能模式缓存 CH flush 队列 |
| [new-api](../new-api/SKILL.md) | 在 Handler 层接入 `GetXxxCached` 或评估高频读 |
| [new-async-task](../new-async-task/SKILL.md) | Worker/定时任务变更数据后必须 `Invalidate*`(如 `system:cleanup`) |
## 标准模式(金标准)
参考:`internal/repository/system_config_cache.go` + `GetSystemConfigByKey` / `ListSystemConfigsByKeys`。
| 层级 | 技术 | 职责 |
| :--- | :--- | :--- |
| L1 本地 | `pkg/cache/ram`(Otter v2) | 进程内热数据,最低延迟 |
| L2 共享 | Redis `db.GetJSON` / `SetJSON` / `HSetJSON` + `db.PrefixedKey` | 跨节点共享,带 TTL 或写穿 |
| L3 权威 | PostgreSQL via `db.DB(ctx)` | 唯一数据源 |
### 读路径模板
```go
func GetThingCached(ctx context.Context, key string) (Thing, error) {
ensureThingCacheListener() // 订阅 pub/sub,仅 sync.Once
if v, ok := thingRAM.GetIfPresent(key); ok {
return cloneThing(v), nil
}
if db.Redis != nil {
var v Thing
if err := db.GetJSON(ctx, redisKey(key), &v); err == nil {
thingRAM.Set(key, cloneThing(v))
return v, nil
}
}
v, err := loadThingFromDB(ctx, key)
if err != nil {
return Thing{}, err
}
populateThingCache(ctx, v) // 回写 RAM + Redis
return v, nil
}
```
### 写穿(populate)
DB miss 或业务创建成功后,**必须**回写上层:
```go
func populateThingCache(ctx context.Context, v Thing) {
thingRAM.Set(v.Key, cloneThing(v))
if db.Redis != nil {
_ = db.SetJSON(ctx, redisKey(v.Key), v, cacheTTL)
}
}
```
### 失效(Invalidate)— 分布式必做三步
数据变更(Admin 更新、软删除、状态迁移)时:
1. **本机 RAM** — `thingRAM.Invalidate(key)` 或 `InvalidateAll()`
2. **Redis** — `Del` / `HDel` 对应 key
3. **pub/sub 广播** — 通知**其他节点**清除 RAM(Redis 已由写节点清掉)
```go
func InvalidateThingCache(ctx context.Context, key string) error {
ensureThingCacheListener()
thingRAM.Invalidate(key)
if db.Redis != nil {
if err := db.Redis.Del(ctx, db.PrefixedKey(redisKey(key))).Err(); err != nil {
return err
}
publishThingRAMInvalidation(ctx, key) // 只广播 RAM 失效
}
return nil
}
```
### pub/sub 监听模板
```go
const thingInvalidationChannel = "domain:thing_invalidation"
func startThingCacheInvalidationListener() {
if db.Redis == nil {
return
}
go func() {
pubsub := db.Redis.Subscribe(context.Background(), thingInvalidationChannel)
defer func() { _ = pubsub.Close() }()
for msg := range pubsub.Channel() {
// 解析 payload,Invalidate RAM;勿重复 Del Redis
thingRAM.Invalidate(parsedKey)
}
}()
}
```
- 使用 `sync.Once` 启动监听;**`ensureListener` 必须在 `db.Redis == nil` 时直接 return,不可消费 Once**(否则测试或 Redis 晚初始化时监听器永不启动)。
- 测试可提供 `StopThingCacheListener` + 重置 `Once`(参考 `StopUploadMetaCacheListener`、`StopAuthSourceCacheListener`)。
- 其他节点收到消息后**只清 RAM**,不再删 Redis。
## 现有实现速查
| 域 | 文件 | L1 | L2 | pub/sub |
| :--- | :--- | :--- | :--- | :--- |
| 系统配置 | `repository/system_config_cache.go` | `pkg/cache/store` | ❌ 无 Redis 缓存 | `system:config_broadcast` (别名 `system:config_invalidation`) ✅ |
| CAPTCHA 运行时 | `apps/cap/runtime_settings.go` | atomic.Pointer | (借配置 Redis) | 订阅 `system:config_invalidation` ✅ |
| 上传元数据 | `apps/upload/cache/meta_cache.go` | Otter | Redis JSON | `upload:meta_invalidation` ✅ |
| 上传访问白名单 | `apps/upload/cache/access_cache.go` | 进程内 TTL | (借配置读路径) | `upload:file_access_invalidation` ✅ |
| Auth Source | `repository/auth_source_cache.go` | Otter | Redis JSON | `oauth:auth_source_invalidation` ✅ |
| OAuth 用户/Token | `apps/oauth/cache.go` | 自研 map | Redis JSON | ❌ 无 pub/sub(历史债) |
| 推送渠道 | `repository/push_channel.go` | 无 | Redis JSON | ❌ 仅 Redis Del |
| Storage 驱动 | `internal/storage/storage.go` | RWMutex 快照 | — | `storage:config_invalidation` ✅ |
## 新增缓存工作流
1. **判定是否需要缓存**:高频读、低变更、可容忍短暂 TTL;写路径必须能统一失效。
2. **选型 L1**:优先 `pkg/cache/ram.MustNew`;**禁止**自研 `map+mutex+TTL`,除非有充分理由并文档说明。
3. **选型 L2**:小对象 `SetJSON`;配置类多条目用 Redis Hash(`HSetJSON`)。
4. **定义 Redis key**:小写蛇形,带业务前缀(`upload:meta:{id}`);统一 `db.PrefixedKey`。
5. **实现 Invalidate + pub/sub**:凡多实例部署可读的 RAM 缓存**必须**有失效广播。
6. **挂载变更钩子**:在所有 DB 变更入口调用 Invalidate(含 Worker/定时任务,不只 HTTP Handler)。
7. **测试**:
- RAM hit / Redis hit / DB fallback
- Invalidate 清 L1+L2
- pub/sub 触发他机 RAM 失效(可用 miniredis Publish 模拟)
- `Reset*RAMCacheForTest` 仅清本机 RAM
8. 运行 `go test` 相关包 + `make code-check`。
## 变更钩子清单(上传元数据示例)
| 入口 | 动作 |
| :--- | :--- |
| `ingest.persistUploadRecord` 创建成功 | `SetUploadMetaCache` |
| `ingest.Remove` / `RemoveOwned` | `InvalidateUploadMetaCache` |
| `task/cleanup.go` 软删除 pending 文件 | `InvalidateUploadMetaCache` |
| 直接 `repository.SoftDeleteUpload` | **禁止** — 必须走 `upload.Remove` |
## 禁止写法
```go
// ❌ 自研 L1,与 pkg/cache/ram 重复
var mu sync.RWMutex
var items = map[uint64]entry{}
// ❌ 只清本机 RAM + Redis,无 pub/sub(多节点 RAM 脏读)
func Invalidate(ctx context.Context, id uint64) {
localDelete(id)
redis.Del(...)
}
// ❌ DB 变更后忘记 Worker 路径
// cleanup 任务删了 upload 行,但未 InvalidateUploadMetaCache
// ❌ 在 Handler 里直接查 DB,绕过已有 GetXxxCached
// ❌ Redis key 不用 PrefixedKey(多环境共 Redis 时冲突)
// ❌ 在 init() 里启动 pub/sub 监听 — 与 bootstrap 规范冲突;用 sync.Once 懒启动
```
## 特殊场景
### 敏感字段(ClientSecret)
模型 `json:"-"` 时,Redis DTO 用独立 `*RedisRecord` struct 显式序列化字段(见 `auth_source_cache.go`)。
### 批量读配置
批量接口必须与单 key 一致走 Redis(`ListSystemConfigsByKeys` 在 RAM miss 后逐 key `HGetJSON`,再 DB `IN`)。
### 仅进程内、短 TTL、配置衍生
可用进程内快照 + 订阅上游 pub/sub(`access_cache.go`、`cap/runtime_settings.go`),不必强行 Redis L2。
### OAuth 用户/Token
沿用 `oauth/cache.go`;新增逻辑调用 `SetCachedUser` / `SetCachedToken` 预热,变更调用 `InvalidateCachedUser` / `InvalidateCachedToken`。
## 验证清单
```bash
go test ./internal/repository/... ./internal/apps/upload/cache/...
make code-check
```
- [ ] L1 使用 `pkg/cache/ram`(或已文档化的例外)
- [ ] 读路径:RAM → Redis → DB
- [ ] 写穿 populate 在 DB load / 创建成功后
- [ ] Invalidate:RAM + Redis + Publish
- [ ] `ensureListener` + pub/sub 清他机 RAM
- [ ] 所有变更入口(含 Worker)已挂钩
- [ ] 测试含 Invalidate 与 pub/sub
## 相关文件
- L1 引擎:`pkg/cache/ram/cache.go`
- 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`
- 性能文档:`docs/PERFORMANCE.md`
@@ -0,0 +1,163 @@
---
name: "clickhouse-batchwriter"
description: "Wavelet 项目专用:当新增或修改 ClickHouse 批量写入、接入 internal/db/batchwriter、将业务域异步 flush 到分析表、迁移 risk_control/节点访问日志/可观测时序写入、或评估 async_insert 与背压策略时必须使用。本技能指导分层职责、各域独立 Writer 实例、repository 批量 API 与禁止写法。"
---
# ClickHouse 批量写入开发
开始前阅读根目录 `AGENTS.md`。ClickHouse 是辅助 OLAP 存储,**厌恶高频单条写入**(过多小 part);写入路径必须优先批量或异步聚合。
DDL 与表结构变更见 `database-migration` 技能;本技能只覆盖**运行时写入架构**。
## 分层职责
| 层级 | 路径 | 职责 |
| :--- | :--- | :--- |
| 连接 | `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` 只调 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 包负责)。
## batchwriter 框架契约
```go
writer, err := batchwriter.New[YourType](cfg, flushFunc, opts...)
writer.Start(ctx)
writer.TryEnqueue(item) // 非阻塞;满则 false
writer.IsFull() // 背压探测
writer.Stop(stopCtx) // close 队列 + drain + 最终 flush
```
### Config 默认值(`batchwriter.DefaultConfig()`)
- `QueueSize`: 10_000
- `MaxBatchSize`: 1_000
- `FlushInterval`: 1s
各域可独立覆盖;可观测低频指标可用更小 `MaxBatchSize`(如 100)与更长 `FlushInterval`(如 2–5s),但**不要**退化为逐条 `Send`。
### 可选回调
- `WithFlushErrorHandler[T]`:flush 失败时记录日志;批次丢弃后 worker 继续
- `WithDropHandler[T]`:队列满或未 `Start` 时丢弃项
### FlushFunc 规范
- 签名:`func(ctx context.Context, items []T) error`
- 内部调用 `internal/repository/analytics` 的 `BatchInsert*`(传入 `[]analyticsmodel.X`)
- 在 flush 边界记录一次错误日志,不要把 DB 驱动错误直接暴露给 HTTP 客户端
- `Start` 使用 `context.WithoutCancel(parent)`,避免请求 ctx 取消中断后台 flush
## 各域独立实例(不共享队列)
每个业务域拥有自己的 `Writer`、配置与 `FlushFunc`:
| 域 | 表 | 现状 | 目标形态 |
| :--- | :--- | :--- | :--- |
| 管理端审计 | `w_user_access_logs` | `risk_control` → `batchwriter` + `analyticsrepo.BatchInsert` | 已接入 |
| 边缘访问日志 | `of_node_access_logs` | `openflare/chwriter` 异步 flush | 已接入 |
| 可观测时序 | `of_node_metric_snapshots` 等 5 表 | `openflare/chwriter` 五表独立 writer + 进程内短 TTL 去重 | 已接入 |
**不要**把 audit、access log、observability 并入同一 channel。
## 新增 ClickHouse 写入工作流
1. **Model**:在 `internal/model/analytics/` 定义 struct 与 `BatchInsertSQL()`(列顺序与 goose DDL 一致)。
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>/` 或 `internal/repository/analytics/<domain>_writer.go`):
- `New` + `Start`,并在初始化逻辑内通过 `lifecycle.OnShutdown("your_writer_name", Stop)` 注册停机回调
- 业务路径 `TryEnqueue`;HTTP 背压用 `IsFull()`
5. **测试**:
- repository:mock `ChConn` 验证 `BatchInsertSQL` 与 append 列数
- batchwriter:`go test ./internal/db/batchwriter`
6. 运行 `make code-check`;有 API 变更时 `make swagger`。
## 背压与丢弃策略
| 场景 | 推荐策略 |
| :--- | :--- |
| 管理端 API 审计 | 队列满 → `IsFull()` 触发 429(见 `risk_control` middleware) |
| Agent 心跳指标 | 队列满 → `WithDropHandler` 记 warn;不阻塞心跳响应 |
| 边缘 access log | 优先扩大队列与 batch;必要时丢弃最旧或采样 |
## 禁止写法
```go
// ❌ 单条伪批量:每条都 PrepareBatch + Send
batch.Append(oneRow)
batch.Send()
// ❌ 写前 OLTP 式去重(高 RTT + 仍产生小 part)
SELECT count() FROM ... WHERE node_id = ? AND captured_at = ?
// ❌ Handler 内直接写 ClickHouse
db.ChConn.PrepareBatch(...)
// ❌ 全局单队列承载所有分析表
var globalChan chan any
```
去重应使用:`ReplacingMergeTree`、查询侧 `argMax`、或进程内短 TTL 去重缓存——**不要**在每次 insert 前 `SELECT count()`。
## async_insert(补充,非主方案)
可在 `internal/db/clickhouse.go` 的 `Settings` 增加服务端异步写入作为第二层防护:
```go
"async_insert": 1,
"wait_for_async_insert": 1,
```
**不能替代**应用层批量;接入前需评估丢失可观测性与服务端负载。优先完成 `batchwriter` 接入后再考虑。
## Bootstrap 装配示例
```go
// internal/bootstrap/bootstrap.go(示意)
var userAccessLogWriter *batchwriter.Writer[*analytics.UserAccessLog]
func RegisterAPI(ctx context.Context) {
// ...
if config.Config.ClickHouse.Enabled {
initUserAccessLogWriter(ctx) // Start writer
risk_control.BindWriter(userAccessLogWriter) // 或逐步替换 InitLogWriter
}
}
```
- `RegisterAPI` / `RegisterAll`:`Start`
- 进程优雅停机:业务模块在初始化时调用 `lifecycle.OnShutdown` 注册,由 `bootstrap.Stop()` 代理 `lifecycle.Stop()` 并发停机。
- 使用 `sync.Once` 保证幂等
## 验证清单
```bash
go test ./internal/db/batchwriter
go test ./internal/repository/analytics
make code-check
```
- flush 按 `MaxBatchSize` 与 `FlushInterval` 触发
- `Stop` 能 drain 队列内剩余项
- repository 层无 goroutine、无 channel
- `clickhouse.enabled: false` 时不 `Start` writer、不入队
## 相关文件速查
- 框架:`internal/db/batchwriter/{config,writer,errs}.go`
- 连接:`internal/db/clickhouse.go`
- 审计写入:`internal/apps/risk_control/logics.go`
- OpenFlare 写入胶水:`internal/apps/openflare/chwriter/writer.go`
- 节点访问日志 repository:`internal/repository/analytics/node_access_log_writer.go`
- 可观测 repository:`internal/repository/analytics/node_observability_writer.go`
- 生命周期管理器:`internal/lifecycle/lifecycle.go`
- Bootstrap:`internal/bootstrap/bootstrap.go`
+25
View File
@@ -0,0 +1,25 @@
# OS files
.DS_Store
Thumbs.db
# Editor files
*.swp
*.swo
*~
.idea/
.vscode/
# Python
__pycache__/
*.py[cod]
*.egg-info/
.eggs/
dist/
build/
# Logs
*.log
# Local config
.env
.env.local
+348
View File
@@ -0,0 +1,348 @@
# Contributing to AI Code Review Guide
Thank you for your interest in contributing! This document provides guidelines for contributing to this Claude Code Skill project.
## Claude Code Skill 开发规范
本项目是一个 Claude Code Skill,贡献者需要遵循以下规范。
### 目录结构
```
code-review-skill/
├── SKILL.md # Required: main file (always loaded)
├── README.md
├── CONTRIBUTING.md
├── LICENSE
├── reference/ # On-demand language/framework guides
│ ├── react.md # React 19 / Next.js / TanStack Query v5
│ ├── vue.md # Vue 3.5 Composition API
│ ├── angular.md # Angular 17+, Signals, Standalone, RxJS
│ ├── svelte.md # Svelte 5 / SvelteKit, runes, SSR boundary
│ ├── rust.md # Ownership, async, unsafe, cancellation
│ ├── typescript.md # Type safety, generics, strict mode
│ ├── nestjs.md # NestJS DI, modules, Guards/Pipes, DTOs
│ ├── python.md # Type hints, async, testing
│ ├── django.md # Django / DRF, N+1, serializers, async views
│ ├── fastapi.md # FastAPI, Depends, Pydantic v2, async
│ ├── java.md # Java 17/21, Spring Boot 3, virtual threads
│ ├── kotlin.md # Kotlin / Android, coroutines, Flow, Compose
│ ├── go.md # Error handling, goroutines, context
│ ├── csharp.md # C# / .NET 8, async, EF Core, ASP.NET Core
│ ├── php.md # PHP 8.x, types, PDO, security, Composer
│ ├── c.md # Memory safety, UB, error handling
│ ├── cpp.md # RAII, move semantics, exception safety
│ ├── qt.md # Object model, signals/slots, GUI perf
│ ├── css-less-sass.md # Variables, responsive, performance
│ ├── architecture-review-guide.md # SOLID, anti-patterns, coupling
│ ├── performance-review-guide.md # Web Vitals, N+1, complexity
│ ├── security-review-guide.md # OWASP Top 10, JWT, validation
│ ├── common-bugs-checklist.md # Quick-reference bug patterns
│ ├── code-quality-universal.md # Language-agnostic quality anti-patterns
│ └── code-review-best-practices.md # Communication & process
├── assets/ # Templates and quick reference
│ ├── review-checklist.md
│ └── pr-review-template.md
└── scripts/
└── pr-analyzer.py # PR complexity analyzer
```
### Frontmatter 规范
SKILL.md 必须包含 YAML frontmatter:
```yaml
---
name: skill-name
description: |
功能描述。触发条件说明。
Use when [具体使用场景]。
allowed-tools: ["Read", "Grep", "Glob"] # 可选:限制工具访问
---
```
#### 必需字段
| 字段 | 说明 | 约束 |
|------|------|------|
| `name` | Skill 标识符 | 小写字母、数字、连字符;最多 64 字符 |
| `description` | 功能和激活条件 | 最多 1024 字符;必须包含 "Use when" |
#### 可选字段
| 字段 | 说明 | 示例 |
|------|------|------|
| `allowed-tools` | 限制工具访问 | `["Read", "Grep", "Glob"]` |
### 命名约定
**Skill 名称规则**:
- 仅使用小写字母、数字和连字符(kebab-case)
- 最多 64 个字符
- 避免下划线或大写字母
```
✅ 正确:code-review-skill, typescript-advanced-types
❌ 错误:CodeReview, code_review, TYPESCRIPT
```
**文件命名规则**:
- reference 文件使用小写:`react.md`, `vue.md`
- 多词文件使用连字符:`common-bugs-checklist.md`
### Description 写法规范
Description 必须包含两部分:
1. **功能陈述**:具体说明 Skill 能做什么
2. **触发条件**:以 "Use when" 开头,说明何时激活
```yaml
# ✅ 正确示例
description: |
Provides comprehensive code review guidance for React 19, Vue 3, Rust,
TypeScript, Java, Python, and C/C++.
Helps catch bugs, improve code quality, and give constructive feedback.
Use when reviewing pull requests, conducting PR reviews, establishing
review standards, or mentoring developers through code reviews.
# ❌ 错误示例(太模糊,缺少触发条件)
description: |
Helps with code review.
```
### Progressive Disclosure(渐进式披露)
Claude 只在需要时加载支持文件,不会一次性加载所有内容。
#### 文件职责划分
| 文件 | 加载时机 | 内容 |
|------|----------|------|
| `SKILL.md` | 始终加载 | 核心原则、快速索引、何时使用 |
| `reference/*.md` | 按需加载 | 语言/框架的详细指南 |
| `assets/*.md` | 明确需要时 | 模板、清单 |
| `scripts/*.py` | 明确指引时 | 工具脚本 |
#### 内容组织原则
**SKILL.md**(~200 行以内):
- 简述:2-3 句话说明用途
- 核心原则和方法论
- 语言/框架索引表(链接到 reference/)
- 何时使用此 Skill
**reference/*.md**(详细内容):
- 完整的代码示例
- 所有最佳实践
- Review Checklist
- 边界情况和陷阱
### 文件引用规范
在 SKILL.md 中引用其他文件时:
```markdown
# ✅ 正确:使用 Markdown 链接格式
| **React** | [React Guide](reference/react.md) | Hooks, React 19, RSC |
| **Vue 3** | [Vue Guide](reference/vue.md) | Composition API |
详见 [React Guide](reference/react.md) 获取完整指南。
# ❌ 错误:使用代码块格式
参考 `reference/react.md` 文件。
```
**路径规则**:
- 使用相对路径(相对于 Skill 目录)
- 使用正斜杠 `/`,不使用反斜杠
- 不需要 `./` 前缀
### 约定(Conventions)
**严重级别(severity)**:审查意见统一使用 SKILL.md「Technique 4」的标记方案,三档由红到绿表示优先级:
- 🔴 `[blocking]` - 合并前必须修复
- 🟡 `[important]` - 应当修复,有异议可讨论
- 🟢 `[nit]` - 可选优化,不阻塞合并
新增 reference 指南时请沿用这套标记,不要自创等价的名称(如 critical/warning/suggestion)。
**语言策略**:现有指南是中英混合的——部分通篇中文,部分(如 fastapi.md、php.md)以英文为主。新增内容时**跟随同一领域既有指南的语言**:改某个指南就用它的语言;新建指南可自行选择中文或英文,但单个文件内部保持一致。
---
## 贡献类型
### 添加新语言支持
1. 在 `reference/` 目录创建新文件(如 `go.md`)
2. 遵循以下结构:
```markdown
# [Language] Code Review Guide
> 简短描述,一句话说明覆盖内容。
## 目录
- [主题1](#主题1)
- [主题2](#主题2)
- [Review Checklist](#review-checklist)
---
## 主题1
### 子主题
```[language]
// ❌ Bad pattern - 说明为什么不好
bad_code_example()
// ✅ Good pattern - 说明为什么好
good_code_example()
```
---
## Review Checklist
### 类别1
- [ ] 检查项 1
- [ ] 检查项 2
```
3. 在 `SKILL.md` 的索引表中添加链接
4. 更新 `README.md` 的统计信息
### 添加框架模式
1. 确保引用官方文档
2. 包含版本号(如 "React 19", "Vue 3.5+")
3. 提供可运行的代码示例
4. 添加对应的 checklist 项
### 改进现有内容
- 修复拼写或语法错误
- 更新过时的模式(注明版本变化)
- 添加边界情况示例
- 改进代码示例的清晰度
---
## 代码示例规范
### 格式要求
```markdown
// ❌ 问题描述 - 解释为什么这样做不好
problematic_code()
// ✅ 推荐做法 - 解释为什么这样做更好
recommended_code()
```
### 质量标准
- 示例应基于真实场景,避免人为构造
- 同时展示问题和解决方案
- 保持示例简洁聚焦
- 包含必要的上下文(import 语句等)
---
## 提交流程
### Issue 报告
- 使用 GitHub Issues 报告问题或建议
- 提供清晰的描述和示例
- 标注相关的语言/框架
### Pull Request 流程
1. Fork 仓库
2. 创建功能分支:`git checkout -b feature/add-go-support`
3. 进行修改
4. 提交(见下文 commit 格式)
5. 推送到 fork:`git push origin feature/add-go-support`
6. 创建 Pull Request
### Commit 消息格式
```
类型: 简短描述
详细说明(如需要)
- 具体变更 1
- 具体变更 2
```
**类型**:
- `feat`: 新功能或新内容
- `fix`: 修复错误
- `docs`: 仅文档变更
- `refactor`: 重构(不改变功能)
- `chore`: 维护性工作
**示例**:
```
feat: 添加 Go 语言代码审查指南
- 新增 reference/go.md
- 覆盖错误处理、并发、接口设计
- 更新 SKILL.md 索引表
```
---
## Skill 设计原则
### 单一职责
每个 Skill 专注一个核心能力。本 Skill 专注于**代码审查**,不应扩展到:
- 代码生成
- 项目初始化
- 部署配置
### 版本管理
- 在 reference 文件中标注框架/语言版本
- 更新时在 commit 中说明版本变化
- 过时内容应更新而非删除(除非完全废弃)
### 内容质量
- 所有建议应有依据(官方文档、最佳实践)
- 避免主观偏好(如代码风格),专注于客观问题
- 优先覆盖常见陷阱和安全问题
---
## 常见问题
### Q: 如何测试我的更改?
将修改后的 Skill 复制到 `~/.claude/skills/` 目录,然后在 Claude Code 中测试:
```bash
cp -r code-review-skill ~/.claude/skills/code-review-skill
```
### Q: 我应该更新 SKILL.md 还是 reference 文件?
- **SKILL.md**:只修改索引表或核心原则
- **reference/*.md**:添加/更新具体的语言或框架内容
### Q: 如何处理过时的内容?
1. 标注版本变化(如 "React 18 → React 19")
2. 保留旧版本内容(如果仍有用户使用)
3. 在 checklist 中更新相关项
---
## 问题咨询
如有任何问题,欢迎在 GitHub Issues 中提问。
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 tt-a1i
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+658
View File
@@ -0,0 +1,658 @@
<div align="center">
<h1>&#128269; Code Review Skill</h1>
<p>
<strong>A comprehensive, modular code review skill for Claude Code</strong><br/>
<strong>面向 Claude Code 的全面模块化代码审查技能</strong>
</p>
<p>
<a href="https://github.com/awesome-skills/code-review-skill/blob/main/LICENSE">
<img src="https://img.shields.io/badge/License-MIT-22c55e?style=flat-square" alt="License: MIT"/>
</a>
<img src="https://img.shields.io/badge/Claude_Code-Skill-7c3aed?style=flat-square&logo=anthropic&logoColor=white" alt="Claude Code Skill"/>
<img src="https://img.shields.io/badge/Total_Lines-16%2C000%2B-3b82f6?style=flat-square" alt="16000+ lines"/>
<img src="https://img.shields.io/badge/Languages-20%2B-f59e0b?style=flat-square" alt="20+ languages"/>
<img src="https://img.shields.io/badge/PRs-Welcome-ec4899?style=flat-square" alt="PRs Welcome"/>
</p>
<p>
<a href="#english">English</a>
&middot;
<a href="#chinese">中文</a>
&middot;
<a href="./CONTRIBUTING.md">Contributing</a>
</p>
</div>
---
<a name="english"></a>
## English
### What is this?
**Code Review Skill** is a production-ready skill for [Claude Code](https://claude.ai/code) that transforms AI-assisted code review from vague suggestions into a **structured, consistent, and expert-level** process.
It covers **20+ languages and frameworks** with over **16,000 lines** of carefully curated review guidelines — loaded progressively to minimize context window usage.
---
### &#10024; Key Features
- **Progressive Disclosure** — Core skill is ~190 lines; language guides (~200–1,000 lines each) load only when needed.
- **Four-Phase Review Process** — Structured workflow from understanding scope to delivering clear feedback.
- **Severity Labeling** — Every finding is categorized: `blocking` · `important` · `nit` · `suggestion` · `learning` · `praise`
- **Security-First** — Dedicated security checklists per language ecosystem.
- **Collaborative Tone** — Questions over commands, suggestions over mandates.
- **Automation Awareness** — Clearly separates what human review should catch vs. what linters handle.
---
### &#127760; Supported Languages & Frameworks
<table>
<thead>
<tr>
<th>Category</th>
<th>Technology</th>
<th>Guide</th>
<th>Lines</th>
</tr>
</thead>
<tbody>
<tr>
<td rowspan="6"><strong>Frontend</strong></td>
<td>&#9883;&#65039; React 19 / Next.js / TanStack Query v5</td>
<td><code>reference/react.md</code></td>
<td>~870</td>
</tr>
<tr>
<td>&#128154; Vue 3.5 + Composition API</td>
<td><code>reference/vue.md</code></td>
<td>~920</td>
</tr>
<tr>
<td>&#128302; Angular 17+ / Signals / Zoneless</td>
<td><code>reference/angular.md</code></td>
<td>~420</td>
</tr>
<tr>
<td>&#128293; Svelte 5 / SvelteKit</td>
<td><code>reference/svelte.md</code></td>
<td>~1,060</td>
</tr>
<tr>
<td>&#127912; CSS / Less / Sass</td>
<td><code>reference/css-less-sass.md</code></td>
<td>~660</td>
</tr>
<tr>
<td>&#128311; TypeScript</td>
<td><code>reference/typescript.md</code></td>
<td>~540</td>
</tr>
<tr>
<td rowspan="9"><strong>Backend</strong></td>
<td>&#9749; Java 17/21 + Spring Boot 3</td>
<td><code>reference/java.md</code></td>
<td>~410</td>
</tr>
<tr>
<td>&#9889; FastAPI</td>
<td><code>reference/fastapi.md</code></td>
<td>~590</td>
</tr>
<tr>
<td>PHP 8.x</td>
<td><code>reference/php.md</code></td>
<td>~700</td>
</tr>
<tr>
<td>&#128230; NestJS</td>
<td><code>reference/nestjs.md</code></td>
<td>~590</td>
</tr>
<tr>
<td>&#128013; Django / DRF</td>
<td><code>reference/django.md</code></td>
<td>~1,030</td>
</tr>
<tr>
<td>&#128057; Go</td>
<td><code>reference/go.md</code></td>
<td>~990</td>
</tr>
<tr>
<td>&#129408; Rust</td>
<td><code>reference/rust.md</code></td>
<td>~840</td>
</tr>
<tr>
<td>&#128187; C# / .NET 8</td>
<td><code>reference/csharp.md</code></td>
<td>~520</td>
</tr>
<tr>
<td>&#128013; Python</td>
<td><code>reference/python.md</code></td>
<td>~1,070</td>
</tr>
<tr>
<td rowspan="5"><strong>Mobile / Systems</strong></td>
<td>&#128241; Kotlin / Android</td>
<td><code>reference/kotlin.md</code></td>
<td>~1,020</td>
</tr>
<tr>
<td>&#127822; Swift / SwiftUI</td>
<td><code>reference/swift.md</code></td>
<td>~930</td>
</tr>
<tr>
<td>&#9881;&#65039; C</td>
<td><code>reference/c.md</code></td>
<td>~290</td>
</tr>
<tr>
<td>&#128297; C++</td>
<td><code>reference/cpp.md</code></td>
<td>~390</td>
</tr>
<tr>
<td>&#128421;&#65039; Qt Framework</td>
<td><code>reference/qt.md</code></td>
<td>~190</td>
</tr>
<tr>
<td rowspan="3"><strong>Cross-Cutting</strong></td>
<td>&#127963;&#65039; Architecture Design Review</td>
<td><code>reference/architecture-review-guide.md</code></td>
<td>~470</td>
</tr>
<tr>
<td>&#9889; Performance Review</td>
<td><code>reference/performance-review-guide.md</code></td>
<td>~820</td>
</tr>
<tr>
<td>&#128269; Universal Quality Anti-Patterns</td>
<td><code>reference/code-quality-universal.md</code></td>
<td>~490</td>
</tr>
</tbody>
</table>
---
### &#128260; The Four-Phase Review Process
```
Phase 1 - Context Gathering
Understand PR scope, linked issues, and intent
|
v
Phase 2 - High-Level Review
Architecture - Performance impact - Test strategy
|
v
Phase 3 - Line-by-Line Analysis
Logic - Security - Maintainability - Edge cases
|
v
Phase 4 - Summary & Decision
Structured feedback - Approval status - Action items
```
---
### &#127991;&#65039; Severity Labels
| Label | Meaning |
|-------|---------|
| &#128308; `blocking` | Must be fixed before merge |
| &#128992; `important` | Should be fixed; may block depending on context |
| &#128993; `nit` | Minor style or preference issue |
| &#128309; `suggestion` | Optional improvement worth considering |
| &#128218; `learning` | Educational note for the author |
| &#127775; `praise` | Explicitly highlight great work |
---
### &#128193; Repository Structure
```
code-review-skill/
|
+-- SKILL.md # Core skill - loaded on activation (~190 lines)
+-- README.md
+-- LICENSE
+-- CONTRIBUTING.md
|
+-- reference/ # On-demand language guides
| +-- react.md # React 19 / Next.js / TanStack Query v5
| +-- vue.md # Vue 3.5 Composition API
| +-- angular.md # Angular 17+ / Signals / Zoneless
| +-- svelte.md # Svelte 5 / SvelteKit
| +-- rust.md # Rust ownership, async/await, unsafe
| +-- typescript.md # TypeScript strict mode, generics, ESLint
| +-- nestjs.md # NestJS DI, Guards, Interceptors, DTOs
| +-- java.md # Java 17/21 & Spring Boot 3
| +-- php.md # PHP 8.x types, PDO, security, Composer
| +-- python.md # Python async, typing, pytest
| +-- django.md # Django / DRF security, serializers, async
| +-- fastapi.md # FastAPI Depends, Pydantic v2, async, test-driven verification
| +-- go.md # Go goroutines, channels, context, interfaces
| +-- kotlin.md # Kotlin / Android coroutines, Compose, Flow
| +-- swift.md # Swift 5.9+/6, SwiftUI, concurrency, optionals
| +-- csharp.md # C# 12 / .NET 8, EF Core, ASP.NET Core
| +-- c.md # C memory safety, UB, error handling
| +-- cpp.md # C++ RAII, move semantics, exception safety
| +-- qt.md # Qt object model, signals/slots, GUI perf
| +-- css-less-sass.md # CSS/Less/Sass variables, responsive design
| +-- architecture-review-guide.md # SOLID, anti-patterns, coupling/cohesion
| +-- code-quality-universal.md # Reuse audit, parameter sprawl, TOCTOU, no-op updates
| +-- performance-review-guide.md # Core Web Vitals, N+1, memory leaks
| +-- security-review-guide.md # Security checklist (all languages)
| +-- common-bugs-checklist.md # Language-specific bug patterns
| +-- code-review-best-practices.md # Communication & process guidelines
|
+-- assets/
| +-- review-checklist.md # Quick reference checklist
| +-- pr-review-template.md # PR review comment template
|
+-- scripts/
+-- pr-analyzer.py # PR complexity analyzer
```
---
### &#128640; Installation
**Clone to your Claude Code skills directory:**
```bash
# macOS / Linux
git clone https://github.com/awesome-skills/code-review-skill.git \
~/.claude/skills/code-review-skill
# Windows (PowerShell)
git clone https://github.com/awesome-skills/code-review-skill.git `
"$env:USERPROFILE\.claude\skills\code-review-skill"
```
**Or add to an existing plugin:**
```bash
cp -r code-review-skill ~/.claude/plugins/your-plugin/skills/code-review/
```
---
### &#128161; Usage
Once installed, activate the skill in your Claude Code session:
```
Use code-review-skill to review this PR
```
Or create a custom slash command in `.claude/commands/`:
```markdown
<!-- .claude/commands/review.md -->
Use code-review-skill to perform a thorough review of the changes in this PR.
Focus on: security, performance, and maintainability.
```
**Example prompts:**
| Prompt | What happens |
|--------|-------------|
| `Review this React component` | Loads `react.md` - checks hooks, Server Components, Suspense patterns |
| `Review this Java PR` | Loads `java.md` - checks virtual threads, JPA, Spring Boot 3 patterns |
| `Security review of this Go service` | Loads `go.md` + `security-review-guide.md` |
| `Architecture review` | Loads `architecture-review-guide.md` - SOLID, anti-patterns, coupling |
| `Performance review` | Loads `performance-review-guide.md` - Web Vitals, N+1, complexity |
---
### &#128300; Highlights by Language
<details>
<summary><strong>&#9883;&#65039; React 19</strong></summary>
- `useActionState` - Unified form state management
- `useFormStatus` - Access parent form status without prop drilling
- `useOptimistic` - Optimistic UI updates with automatic rollback
- Server Components & Server Actions patterns (Next.js 15+)
- Suspense boundary design, Error Boundary integration, streaming SSR
- `use()` Hook for consuming Promises
</details>
<details>
<summary><strong>&#9749; Java & Spring Boot 3</strong></summary>
- **Java 17/21**: Records, Pattern Matching for Switch, Text Blocks, Sealed Classes
- **Virtual Threads** (Project Loom): High-throughput I/O patterns
- **Spring Boot 3**: Constructor injection, `@ConfigurationProperties`, `ProblemDetail`
- **JPA Performance**: Solving N+1, correct `equals`/`hashCode` on Entities
</details>
<details>
<summary><strong>&#129408; Rust</strong></summary>
- Ownership patterns and common pitfalls
- `unsafe` code review requirements (mandatory `SAFETY` comments)
- Async/await - avoiding blocking in async context, cancellation safety
- Error handling: `thiserror` for libraries, `anyhow` for applications
</details>
<details>
<summary><strong>&#128057; Go</strong></summary>
- Goroutine lifecycle management and leak prevention
- Channel patterns, select usage
- `context.Context` propagation
- Interface design (accept interfaces, return structs)
- Error wrapping with `%w`
</details>
<details>
<summary><strong>&#9881;&#65039; C / C++</strong></summary>
- **C**: Pointer/buffer safety, undefined behavior, resource cleanup, integer overflow
- **C++**: RAII ownership, Rule of 0/3/5, move semantics, exception safety, `noexcept`
- **Qt**: Object parent/child memory model, thread-safe signal/slot connections, GUI performance
</details>
---
### &#129309; Contributing
Contributions are welcome! See [CONTRIBUTING.md](./CONTRIBUTING.md) for guidelines.
**Ideas:**
- New language guides (Ruby, Elixir, Scala...)
- Framework-specific guides (Laravel, Spring WebFlux...)
- Additional checklists and templates
- Translations of core documentation
---
### &#128196; License
MIT &copy; [awesome-skills](https://github.com/awesome-skills)
---
<a name="chinese"></a>
## 中文
### 这是什么?
**Code Review Skill** 是专为 [Claude Code](https://claude.ai/code) 打造的生产级代码审查技能,将 AI 辅助的代码审查从模糊建议转变为**结构化、一致且专业级**的流程。
覆盖 **20+ 种语言和框架**,拥有超过 **16,000 行**精心整理的代码审查指南——按需加载,最大程度减少上下文占用。
---
### &#10024; 核心特性
- **渐进式加载** — 核心技能仅 ~190 行,各语言指南(每份 200–1,000 行)仅在需要时才加载。
- **四阶段审查流程** — 从理解 PR 范围到输出清晰反馈,每一步都有规可循。
- **严重性标记** — 每条发现均分级:`blocking` · `important` · `nit` · `suggestion` · `learning` · `praise`
- **安全优先** — 每个语言生态均配备专属安全检查清单。
- **协作式语气** — 以提问替代命令,以建议替代指令。
- **自动化感知** — 明确区分人工审查应关注的内容与 linter 自动处理的内容。
---
### &#127760; 支持的语言与框架
| 分类 | 技术栈 | 指南文件 | 行数 |
|------|--------|----------|------|
| **前端** | &#9883;&#65039; React 19 / Next.js / TanStack Query v5 | `reference/react.md` | ~870 |
| | &#128154; Vue 3.5 Composition API | `reference/vue.md` | ~920 |
| | &#128302; Angular 17+ / Signals / Zoneless | `reference/angular.md` | ~420 |
| | &#128293; Svelte 5 / SvelteKit | `reference/svelte.md` | ~1,060 |
| | &#127912; CSS / Less / Sass | `reference/css-less-sass.md` | ~660 |
| | &#128311; TypeScript | `reference/typescript.md` | ~540 |
| **后端** | &#9749; Java 17/21 + Spring Boot 3 | `reference/java.md` | ~410 |
| | &#9889; FastAPI | `reference/fastapi.md` | ~590 |
| | PHP 8.x | `reference/php.md` | ~700 |
| | &#128230; NestJS | `reference/nestjs.md` | ~590 |
| | &#128013; Django / DRF | `reference/django.md` | ~1,030 |
| | &#128013; Python | `reference/python.md` | ~1,070 |
| | &#128057; Go | `reference/go.md` | ~990 |
| | &#129408; Rust | `reference/rust.md` | ~840 |
| | &#128187; C# / .NET 8 | `reference/csharp.md` | ~520 |
| **移动 / 系统** | &#128241; Kotlin / Android | `reference/kotlin.md` | ~1,020 |
| | &#127822; Swift / SwiftUI | `reference/swift.md` | ~930 |
| | &#9881;&#65039; C | `reference/c.md` | ~290 |
| | &#128297; C++ | `reference/cpp.md` | ~390 |
| | &#128421;&#65039; Qt 框架 | `reference/qt.md` | ~190 |
| **架构** | &#127963;&#65039; 架构设计审查 | `reference/architecture-review-guide.md` | ~470 |
| | &#9889; 性能审查 | `reference/performance-review-guide.md` | ~820 |
| | &#128269; 通用质量反模式 | `reference/code-quality-universal.md` | ~490 |
---
### &#128260; 四阶段审查流程
```
阶段一 - 上下文收集
理解 PR 范围、关联 Issue 和实现意图
|
v
阶段二 - 高层级审查
架构设计 - 性能影响 - 测试策略
|
v
阶段三 - 逐行深度分析
逻辑正确性 - 安全漏洞 - 可维护性 - 边界情况
|
v
阶段四 - 总结与决策
结构化反馈 - 审批状态 - 后续行动项
```
---
### &#127991;&#65039; 严重性标记说明
| 标记 | 含义 |
|------|------|
| &#128308; `blocking` | 合并前必须修复 |
| &#128992; `important` | 应当修复,视情况可能阻塞合并 |
| &#128993; `nit` | 风格或偏好上的小问题 |
| &#128309; `suggestion` | 值得考虑的可选优化 |
| &#128218; `learning` | 给作者的教育性说明 |
| &#127775; `praise` | 明确表扬优秀代码 |
---
### &#128193; 仓库结构
```
code-review-skill/
|
+-- SKILL.md # 核心技能,激活时加载(~190 行)
+-- README.md
+-- LICENSE
+-- CONTRIBUTING.md
|
+-- reference/ # 按需加载的语言指南
| +-- react.md # React 19 / Next.js / TanStack Query v5
| +-- vue.md # Vue 3.5 组合式 API
| +-- angular.md # Angular 17+ / Signals / Zoneless
| +-- svelte.md # Svelte 5 / SvelteKit
| +-- rust.md # Rust 所有权、async/await、unsafe
| +-- typescript.md # TypeScript strict 模式、泛型、ESLint
| +-- nestjs.md # NestJS 依赖注入、Guard、Interceptor、DTO
| +-- java.md # Java 17/21 & Spring Boot 3
| +-- php.md # PHP 8.x 类型、PDO、安全、Composer
| +-- python.md # Python async、类型注解、pytest
| +-- django.md # Django / DRF 安全、Serializer、异步视图
| +-- fastapi.md # FastAPI Depends、Pydantic v2、异步、测试驱动验证
| +-- go.md # Go goroutine、channel、context、接口
| +-- kotlin.md # Kotlin / Android 协程、Compose、Flow
| +-- swift.md # Swift 5.9+/6、SwiftUI、并发、可选值
| +-- csharp.md # C# 12 / .NET 8、EF Core、ASP.NET Core
| +-- c.md # C 内存安全、UB、错误处理
| +-- cpp.md # C++ RAII、移动语义、异常安全
| +-- qt.md # Qt 对象模型、信号/槽、GUI 性能
| +-- css-less-sass.md # CSS/Less/Sass 变量、响应式设计
| +-- architecture-review-guide.md # SOLID、反模式、耦合度分析
| +-- code-quality-universal.md # 复用审查、参数膨胀、抽象泄漏、TOCTOU
| +-- performance-review-guide.md # Core Web Vitals、N+1、内存泄漏
| +-- security-review-guide.md # 安全审查清单(全语言通用)
| +-- common-bugs-checklist.md # 各语言常见 Bug 模式
| +-- code-review-best-practices.md # 沟通与流程最佳实践
|
+-- assets/
| +-- review-checklist.md # 快速参考清单
| +-- pr-review-template.md # PR 审查评论模板
|
+-- scripts/
+-- pr-analyzer.py # PR 复杂度分析工具
```
---
### &#128640; 安装方法
**克隆到 Claude Code skills 目录:**
```bash
# macOS / Linux
git clone https://github.com/awesome-skills/code-review-skill.git \
~/.claude/skills/code-review-skill
# Windows(PowerShell)
git clone https://github.com/awesome-skills/code-review-skill.git `
"$env:USERPROFILE\.claude\skills\code-review-skill"
```
**或添加到现有插件:**
```bash
cp -r code-review-skill ~/.claude/plugins/your-plugin/skills/code-review/
```
---
### &#128161; 使用方式
安装后,在 Claude Code 会话中激活技能:
```
Use code-review-skill to review this PR
```
或在 `.claude/commands/` 中创建自定义斜杠命令:
```markdown
<!-- .claude/commands/review.md -->
使用 code-review-skill 对这次 PR 的变更进行全面审查。
重点关注:安全性、性能和可维护性。
```
**示例提示词:**
| 提示词 | 效果 |
|--------|------|
| `审查这个 React 组件` | 加载 `react.md`,检查 Hooks、Server Components、Suspense |
| `审查这个 Java PR` | 加载 `java.md`,检查虚拟线程、JPA、Spring Boot 3 |
| `对这个 Go 服务进行安全审查` | 加载 `go.md` + `security-review-guide.md` |
| `架构审查` | 加载 `architecture-review-guide.md`,检查 SOLID 与反模式 |
| `性能审查` | 加载 `performance-review-guide.md`,分析 Web Vitals、N+1 等 |
---
### &#128300; 各语言核心内容
<details>
<summary><strong>&#9883;&#65039; React 19</strong></summary>
- `useActionState` — 统一的表单状态管理
- `useFormStatus` — 无需 props 透传即可访问父表单状态
- `useOptimistic` — 带自动回滚的乐观 UI 更新
- Server Components & Server Actions(Next.js 15+)
- Suspense 边界设计、Error Boundary 集成、流式 SSR
- `use()` Hook 消费 Promise
</details>
<details>
<summary><strong>&#9749; Java & Spring Boot 3</strong></summary>
- **Java 17/21**:Records、Switch 模式匹配、文本块、Sealed Classes
- **虚拟线程**(Project Loom):高吞吐量 I/O 模式
- **Spring Boot 3**:构造器注入、`@ConfigurationProperties`、`ProblemDetail`
- **JPA 性能**:解决 N+1、Entity 正确的 `equals`/`hashCode` 实现
</details>
<details>
<summary><strong>&#129408; Rust</strong></summary>
- 所有权模式与常见陷阱
- `unsafe` 代码审查要求(必须有 `SAFETY` 注释)
- Async/await — 避免在异步上下文中阻塞,取消安全性
- 错误处理:库用 `thiserror`,应用用 `anyhow`
</details>
<details>
<summary><strong>&#128057; Go</strong></summary>
- Goroutine 生命周期管理与泄漏预防
- Channel 模式、select 用法
- `context.Context` 传播规范
- 接口设计原则(接受接口,返回结构体)
- 错误包装:使用 `%w`
</details>
<details>
<summary><strong>&#9881;&#65039; C / C++</strong></summary>
- **C**:指针/缓冲区安全、未定义行为、资源清理、整数溢出
- **C++**:RAII 所有权、Rule of 0/3/5、移动语义、异常安全、`noexcept`
- **Qt**:父子内存模型、线程安全的信号/槽连接、GUI 性能优化
</details>
---
### &#129309; 参与贡献
欢迎贡献!请查阅 [CONTRIBUTING.md](./CONTRIBUTING.md) 了解规范。
**可贡献方向:**
- 新增语言指南(Ruby、Elixir、Scala...)
- 框架专属指南(Laravel、Spring WebFlux...)
- 补充检查清单和审查模板
- 核心文档的多语言翻译
---
### &#128196; 开源协议
MIT &copy; [awesome-skills](https://github.com/awesome-skills)
---
<div align="center">
Made with &#10084;&#65039; for developers who care about code quality
</div>
+220
View File
@@ -0,0 +1,220 @@
---
name: code-review-skill
description: |
Provides comprehensive code review guidance for React 19, Vue 3, Angular 17+, Svelte 5, Rust, TypeScript, Java, PHP, Python, Django, Go, C#/.NET, Kotlin, Swift, NestJS, C/C++, and more.
Helps catch bugs, improve code quality, and give constructive feedback.
Use when: reviewing pull requests, conducting PR reviews, code review, reviewing code changes,
establishing review standards, mentoring developers, architecture reviews, security audits,
checking code quality, finding bugs, giving feedback on code.
allowed-tools:
- Read
- Grep
- Glob
- Bash # 运行 lint/test/build 命令验证代码质量
- WebFetch # 查阅最新文档和最佳实践
---
# Code Review Skill
Transform code reviews from gatekeeping to knowledge sharing through constructive feedback, systematic analysis, and collaborative improvement.
## When to Use This Skill
- Reviewing pull requests and code changes
- Establishing code review standards for teams
- Mentoring junior developers through reviews
- Conducting architecture reviews
- Creating review checklists and guidelines
- Improving team collaboration
- Reducing code review cycle time
- Maintaining code quality standards
## Core Principles
### 1. The Review Mindset
**Goals of Code Review:**
- Catch bugs and edge cases
- Ensure code maintainability
- Share knowledge across team
- Enforce coding standards
- Improve design and architecture
- Build team culture
**Not the Goals:**
- Show off knowledge
- Nitpick formatting (use linters)
- Block progress unnecessarily
- Rewrite to your preference
### 2. Effective Feedback
**Good Feedback is:**
- Specific and actionable
- Educational, not judgmental
- Focused on the code, not the person
- Balanced (praise good work too)
- Prioritized (critical vs nice-to-have)
```markdown
❌ Bad: "This is wrong."
✅ Good: "This could cause a race condition when multiple users
access simultaneously. Consider using a mutex here."
❌ Bad: "Why didn't you use X pattern?"
✅ Good: "Have you considered the Repository pattern? It would
make this easier to test. Here's an example: [link]"
❌ Bad: "Rename this variable."
✅ Good: "[nit] Consider `userCount` instead of `uc` for
clarity. Not blocking if you prefer to keep it."
```
### 3. Review Scope
**What to Review:**
- Logic correctness and edge cases
- Security vulnerabilities
- Performance implications
- Test coverage and quality
- Error handling
- Documentation and comments
- API design and naming
- Architectural fit
**What Not to Review Manually:**
- Code formatting (use Prettier, Black, etc.)
- Import organization
- Linting violations
- Simple typos
## Review Process
### Phase 1: Context Gathering (2-3 minutes)
Before diving into code, understand:
1. Read PR description and linked issue
2. Check PR size (>400 lines? Ask to split)
3. Review CI/CD status (tests passing?)
4. Understand the business requirement
5. Note any relevant architectural decisions
> For large diffs, pipe the diff through [`scripts/pr-analyzer.py`](scripts/pr-analyzer.py) (`git diff main...HEAD | python scripts/pr-analyzer.py`) to triage complexity and get a suggested review approach before reading.
### Phase 2: High-Level Review (5-10 minutes)
1. **Architecture & Design** - Does the solution fit the problem?
- For significant changes, consult [Architecture Review Guide](reference/architecture-review-guide.md)
- Check: SOLID principles, coupling/cohesion, anti-patterns
2. **Performance Assessment** - Are there performance concerns?
- For performance-critical code, consult [Performance Review Guide](reference/performance-review-guide.md)
- Check: Algorithm complexity, N+1 queries, memory usage
3. **File Organization** - Are new files in the right places?
4. **Testing Strategy** - Are there tests covering edge cases?
### Phase 3: Line-by-Line Review (10-20 minutes)
For each file, check:
- **Logic & Correctness** - Edge cases, off-by-one, null checks, race conditions
- **Security** - Input validation, injection risks, XSS, sensitive data
- **Performance** - N+1 queries, unnecessary loops, memory leaks
- **Maintainability** - Clear names, single responsibility, comments
- **Reuse** - Before accepting new code, search for existing utilities/helpers that could replace it. Check adjacent files and shared modules for similar patterns. See [Universal Quality Guide](reference/code-quality-universal.md) for anti-patterns like parameter sprawl, leaky abstractions, nested conditionals, stringly-typed code, TOCTOU, and no-op updates.
### Phase 4: Summary & Decision (2-3 minutes)
1. Summarize key concerns
2. Highlight what you liked
3. Make clear decision:
- ✅ Approve
- 💬 Comment (minor suggestions)
- 🔄 Request Changes (must address)
4. Offer to pair if complex
## Review Techniques
### Technique 1: The Checklist Method
Use checklists for consistent reviews. See [Security Review Guide](reference/security-review-guide.md) for comprehensive security checklist.
### Technique 2: The Question Approach
Instead of stating problems, ask questions:
```markdown
❌ "This will fail if the list is empty."
✅ "What happens if `items` is an empty array?"
❌ "You need error handling here."
✅ "How should this behave if the API call fails?"
```
### Technique 3: Suggest, Don't Command
Use collaborative language:
```markdown
❌ "You must change this to use async/await"
✅ "Suggestion: async/await might make this more readable. What do you think?"
❌ "Extract this into a function"
✅ "This logic appears in 3 places. Would it make sense to extract it?"
```
### Technique 4: Differentiate Severity
Use labels to indicate priority:
- 🔴 `[blocking]` - Must fix before merge
- 🟡 `[important]` - Should fix, discuss if disagree
- 🟢 `[nit]` - Nice to have, not blocking
- 💡 `[suggestion]` - Alternative approach to consider
- 📚 `[learning]` - Educational comment, no action needed
- 🎉 `[praise]` - Good work, keep it up!
**Severity levels:** 🔴 / 🟡 / 🟢 are the three severity tiers used as the standard across all guides in this skill — 🔴 blocks the merge, 🟡 should be addressed, 🟢 is optional. The remaining markers (💡 / 📚 / 🎉) are non-blocking annotations.
## Language-Specific Guides
根据审查的代码语言,查阅对应的详细指南:
| Language/Framework | Reference File | Key Topics |
|-------------------|----------------|------------|
| **React** | [React Guide](reference/react.md) | Hooks, useEffect, React 19 Actions, RSC, Suspense, TanStack Query v5 |
| **Vue 3** | [Vue Guide](reference/vue.md) | Composition API, 响应性系统, Props/Emits, Watchers, Composables |
| **Angular 17+** | [Angular Guide](reference/angular.md) | Signals, Standalone 组件, RxJS, Zoneless 变更检测, 模板优化 |
| **Rust** | [Rust Guide](reference/rust.md) | 所有权/借用, Unsafe 审查, 异步代码, 取消安全性, 错误处理 |
| **TypeScript** | [TypeScript Guide](reference/typescript.md) | 类型安全, async/await, 不可变性 |
| **Python** | [Python Guide](reference/python.md) | 可变默认参数, 异常处理, 类属性 |
| **Django / DRF** | [Django Guide](reference/django.md) | 安全审查, N+1 查询, Serializer 反模式, ViewSet, 异步视图 |
| **FastAPI** | [FastAPI Guide](reference/fastapi.md) | Depends, Pydantic v2 validation, async correctness, sessions/N+1, auth vs authorization, test-driven verification |
| **Java** | [Java Guide](reference/java.md) | Java 17/21 新特性, Spring Boot 3, 虚拟线程, Stream/Optional |
| **PHP** | [PHP Guide](reference/php.md) | PHP 8.x type system, PDO, security review, Composer, PHPUnit/PHPStan |
| **C# / .NET** | [C# Guide](reference/csharp.md) | C# 12 特性, 异步编程, EF Core 性能, ASP.NET Core, LINQ |
| **Go** | [Go Guide](reference/go.md) | 错误处理, goroutine/channel, context, 接口设计 |
| **Kotlin / Android** | [Kotlin Guide](reference/kotlin.md) | 协程, Flow, Jetpack Compose, 空安全, 内存泄漏, 架构模式 |
| **Swift / SwiftUI** | [Swift Guide](reference/swift.md) | Optionals, Swift Concurrency, Sendable/actors, SwiftUI property wrappers, value vs reference types, API design |
| **NestJS** | [NestJS Guide](reference/nestjs.md) | 依赖注入, 分层架构, DTO 验证, Guard/Interceptor, 循环依赖 |
| **Svelte / SvelteKit** | [Svelte Guide](reference/svelte.md) | Runes, Load 函数, Form Actions, Store 迁移, SSR/CSR 边界 |
| **C** | [C Guide](reference/c.md) | 指针/缓冲区, 内存安全, UB, 错误处理 |
| **C++** | [C++ Guide](reference/cpp.md) | RAII, 生命周期, Rule of 0/3/5, 异常安全 |
| **CSS/Less/Sass** | [CSS Guide](reference/css-less-sass.md) | 变量规范, !important, 性能优化, 响应式, 兼容性 |
| **Qt** | [Qt Guide](reference/qt.md) | 对象模型, 信号/槽, 内存管理, 线程安全, 性能 |
## Cross-Cutting Guides
Language-agnostic patterns applicable to all code reviews:
| Topic | Reference File | Key Topics |
|-------|----------------|------------|
| **Universal Quality** | [Universal Quality Guide](reference/code-quality-universal.md) | Reuse audit, parameter sprawl, leaky abstractions, nested conditionals, stringly-typed code, TOCTOU, no-op updates, redundant state |
## Additional Resources
- [Architecture Review Guide](reference/architecture-review-guide.md) - 架构设计审查指南(SOLID、反模式、耦合度)
- [Performance Review Guide](reference/performance-review-guide.md) - 性能审查指南(Web Vitals、N+1、复杂度)
- [Common Bugs Checklist](reference/common-bugs-checklist.md) - 按语言分类的常见错误清单
- [Security Review Guide](reference/security-review-guide.md) - 安全审查指南
- [Code Review Best Practices](reference/code-review-best-practices.md) - 代码审查最佳实践
- [PR Review Template](assets/pr-review-template.md) - PR 审查评论模板
- [Review Checklist](assets/review-checklist.md) - 快速参考清单
@@ -0,0 +1,114 @@
# PR Review Template
Copy and use this template for your code reviews.
---
## Summary
[Brief overview of what was reviewed - 1-2 sentences]
**PR Size:** [Small/Medium/Large] (~X lines)
**Review Time:** [X minutes]
## Strengths
- [What was done well]
- [Good patterns or approaches used]
- [Improvements from previous code]
## Required Changes
🔴 **[blocking]** [Issue description]
> [Code location or example]
> [Suggested fix or explanation]
🔴 **[blocking]** [Issue description]
> [Details]
## Important Suggestions
🟡 **[important]** [Issue description]
> [Why this matters]
> [Suggested approach]
## Minor Suggestions
🟢 **[nit]** [Minor improvement suggestion]
💡 **[suggestion]** [Alternative approach to consider]
## Learning Notes
📚 [Educational context worth sharing about X]
📚 [Background behind design decision Y]
## Security Considerations
- [ ] No hardcoded secrets
- [ ] Input validation present
- [ ] Authorization checks in place
- [ ] No SQL/XSS injection risks
## Test Coverage
- [ ] Unit tests added/updated
- [ ] Edge cases covered
- [ ] Error cases tested
## Verdict
**[ ] ✅ Approve** - Ready to merge
**[ ] 💬 Comment** - Minor suggestions, can merge
**[ ] 🔄 Request Changes** - Must address blocking issues
---
## Quick Copy Templates
### Blocking Issue
```
🔴 **[blocking]** [Title]
[Description of the issue]
**Location:** `file.ts:123`
**Suggested fix:**
\`\`\`typescript
// Your suggested code
\`\`\`
```
### Important Suggestion
```
🟡 **[important]** [Title]
[Why this is important]
**Consider:**
- Option A: [description]
- Option B: [description]
```
### Minor Suggestion
```
🟢 **[nit]** [Suggestion]
Not blocking, but consider [improvement].
```
### Praise
```
🎉 **[praise]** Great work on [specific thing]!
[Why this is good]
```
### Learning
```
📚 **[learning]** [Educational note]
For context, [X] works this way because [Y]. No action needed — just sharing.
```
+121
View File
@@ -0,0 +1,121 @@
# Code Review Quick Checklist
Quick reference checklist for code reviews.
## Pre-Review (2 min)
- [ ] Read PR description and linked issue
- [ ] Check PR size (<400 lines ideal)
- [ ] Verify CI/CD status (tests passing?)
- [ ] Understand the business requirement
## Architecture & Design (5 min)
- [ ] Solution fits the problem
- [ ] Consistent with existing patterns
- [ ] No simpler approach exists
- [ ] Will it scale?
- [ ] Changes in right location
## Logic & Correctness (10 min)
- [ ] Edge cases handled
- [ ] Null/undefined checks present
- [ ] Off-by-one errors checked
- [ ] Race conditions considered
- [ ] Error handling complete
- [ ] Correct data types used
## Security (5 min)
- [ ] No hardcoded secrets
- [ ] Input validated/sanitized
- [ ] SQL injection prevented
- [ ] XSS prevented
- [ ] Authorization checks present
- [ ] Sensitive data protected
## Performance (3 min)
- [ ] No N+1 queries
- [ ] Expensive operations optimized
- [ ] Large lists paginated
- [ ] No memory leaks
- [ ] Caching considered where appropriate
## Testing (5 min)
- [ ] Tests exist for new code
- [ ] Edge cases tested
- [ ] Error cases tested
- [ ] Tests are readable
- [ ] Tests are deterministic
## Code Quality (3 min)
- [ ] Clear variable/function names
- [ ] No code duplication
- [ ] Functions do one thing
- [ ] Complex code commented
- [ ] No magic numbers
## Documentation (2 min)
- [ ] Public APIs documented
- [ ] README updated if needed
- [ ] Breaking changes noted
- [ ] Complex logic explained
---
## Severity Labels
| Label | Meaning | Action |
|-------|---------|--------|
| 🔴 `[blocking]` | Must fix | Block merge |
| 🟡 `[important]` | Should fix | Discuss if disagree |
| 🟢 `[nit]` | Nice to have | Non-blocking |
| 💡 `[suggestion]` | Alternative | Consider |
| 📚 `[learning]` | Educational comment | No action needed |
| 🎉 `[praise]` | Good work | Celebrate! |
---
## Decision Matrix
| Situation | Decision |
|-----------|----------|
| Critical security issue | 🔴 Block, fix immediately |
| Breaking change without migration | 🔴 Block |
| Missing error handling | 🟡 Should fix |
| No tests for new code | 🟡 Should fix |
| Style preference | 🟢 Non-blocking |
| Minor naming improvement | 🟢 Non-blocking |
| Clever but working code | 💡 Suggest simpler |
---
## Time Budget
| PR Size | Target Time |
|---------|-------------|
| < 100 lines | 10-15 min |
| 100-400 lines | 20-40 min |
| > 400 lines | Ask to split |
---
## Red Flags
Watch for these patterns:
- `// TODO` in production code
- `console.log` left in code
- Commented out code
- `any` type in TypeScript
- Empty catch blocks
- `unwrap()` in Rust production code
- Magic numbers/strings
- Copy-pasted code blocks
- Missing null checks
- Hardcoded URLs/credentials
+702
View File
@@ -0,0 +1,702 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>code-review-skill(1) — User Commands (en_US)</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #14110d;
--bg-alt: #1a1611;
--fg: #c4b596;
--fg-bright:#e8d5a8;
--fg-dim: #7a6f56;
--fg-faint: #4a4334;
--amber: #d8964a;
--amber-2: #e8a455;
--red: #d56350;
--green: #8fae5a;
--blue: #6b94c4;
--rule: #2a2520;
}
html { background: var(--bg); }
body {
font-family: 'IBM Plex Mono', ui-monospace, 'SF Mono', Menlo, monospace;
font-size: 14px;
line-height: 1.65;
color: var(--fg);
background: var(--bg);
min-height: 100vh;
padding: 0 0 4rem;
-webkit-font-smoothing: antialiased;
}
/* faint scanline-free phosphor texture — very subtle */
body::before {
content: '';
position: fixed;
inset: 0;
pointer-events: none;
z-index: 0;
background:
radial-gradient(ellipse at 50% 0%, rgba(216,150,74,0.04) 0%, transparent 60%);
}
/* ─── HEADER / FOOTER BAND ─── */
.band {
position: sticky;
top: 0;
background: var(--bg);
border-bottom: 1px solid var(--rule);
z-index: 10;
font-size: 12px;
}
.band-inner {
max-width: 820px;
margin: 0 auto;
padding: 0.625rem 2rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
color: var(--fg-dim);
}
.band-l, .band-r {
color: var(--fg-bright);
letter-spacing: 0.04em;
white-space: nowrap;
}
.band-c { color: var(--fg-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.band a {
color: inherit;
text-decoration: none;
border-bottom: 1px dotted var(--fg-faint);
}
.band a:hover { color: var(--amber); border-bottom-color: var(--amber); }
/* ─── PAGE ─── */
main {
max-width: 820px;
margin: 0 auto;
padding: 3rem 2rem 0;
position: relative;
z-index: 1;
}
pre, .pre {
font-family: inherit;
white-space: pre;
color: inherit;
background: none;
margin: 0;
}
/* ─── SECTIONS ─── */
h2.sec {
color: var(--fg-bright);
font-weight: 600;
font-size: 14px;
letter-spacing: 0.04em;
margin: 2.75rem 0 0.875rem;
padding: 0;
}
h2.sec::before { content: ''; }
section.body {
padding-left: 7ch;
position: relative;
}
section.body p {
margin-bottom: 0.875rem;
max-width: 70ch;
}
section.body p:last-child { margin-bottom: 0; }
.em { color: var(--fg-bright); }
.dim { color: var(--fg-dim); }
.faint { color: var(--fg-faint); }
.amber { color: var(--amber); }
.red { color: var(--red); }
.green { color: var(--green); }
.blue { color: var(--blue); }
a.link {
color: var(--amber);
text-decoration: none;
border-bottom: 1px dotted var(--amber);
}
a.link:hover {
color: var(--bg);
background: var(--amber);
border-bottom-color: transparent;
}
/* ─── TITLE BLOCK ─── */
.title-block {
margin-bottom: 3rem;
}
.ascii-title {
color: var(--amber);
font-size: 12px;
line-height: 1;
margin: 1.5rem 0 2.25rem;
white-space: pre;
overflow-x: auto;
font-weight: 500;
letter-spacing: 0;
text-shadow: 0 0 12px rgba(216,150,74,0.25);
}
.one-liner {
color: var(--fg-bright);
margin-bottom: 0.5rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.lang-toggle {
font-size: 12px;
color: var(--fg-dim);
letter-spacing: 0.04em;
}
.lang-toggle a {
color: var(--fg-dim);
text-decoration: none;
border-bottom: 1px dotted var(--fg-faint);
padding-bottom: 1px;
margin: 0 0.25em;
}
.lang-toggle a.on {
color: var(--amber);
border-bottom-color: var(--amber);
}
.lang-toggle a:hover { color: var(--amber); border-bottom-color: var(--amber); }
.lang-toggle .sep { color: var(--fg-faint); }
.one-liner-sub {
color: var(--fg-dim);
}
/* ─── TABLES ─── */
.lang-row {
display: grid;
grid-template-columns: 26ch 1fr 7ch;
gap: 1ch;
padding: 0.125rem 0;
align-items: baseline;
transition: background 0.1s;
border-bottom: 1px dotted var(--rule);
}
.lang-row:hover { background: var(--bg-alt); }
.lang-row .file { color: var(--amber); }
.lang-row .desc { color: var(--fg); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.lang-row .desc .topics { color: var(--fg-dim); }
.lang-row .lines { text-align: right; color: var(--fg-dim); font-variant-numeric: tabular-nums; }
.dotleader {
color: var(--fg-faint);
display: none;
}
.cat-head {
color: var(--fg-bright);
margin: 1.25rem 0 0.5rem;
padding-bottom: 0.25rem;
border-bottom: 1px solid var(--rule);
}
.cat-head:first-child { margin-top: 0; }
/* ─── PHASE DIAGRAM ─── */
.phase-flow {
margin: 1rem 0 1.5rem;
color: var(--fg-dim);
line-height: 1.4;
font-size: 13px;
overflow-x: auto;
}
.phase-flow .box { color: var(--amber); }
.phase-flow .arrow { color: var(--fg-bright); }
.phase-list dt {
color: var(--fg-bright);
margin-top: 0.875rem;
}
.phase-list dt:first-child { margin-top: 0; }
.phase-list dd {
color: var(--fg);
max-width: 70ch;
margin-bottom: 0.125rem;
}
.phase-list dd.t {
color: var(--fg-dim);
font-size: 13px;
}
/* ─── SEVERITY LIST ─── */
.sev-list {
list-style: none;
}
.sev-list li {
display: grid;
grid-template-columns: 16ch 1fr;
gap: 1ch;
padding: 0.25rem 0;
border-bottom: 1px dotted var(--rule);
align-items: baseline;
}
.sev-list li:last-child { border-bottom: none; }
.sev-list li .label { color: var(--fg-bright); }
.sev-list li .desc { color: var(--fg); }
.sev-list li .desc .aside { color: var(--fg-dim); }
/* ─── CODE BLOCKS ─── */
.codeblock {
background: var(--bg-alt);
border-left: 2px solid var(--amber);
padding: 0.875rem 1.25rem;
margin: 0.875rem 0;
color: var(--fg);
overflow-x: auto;
max-width: 70ch;
}
.codeblock .prompt { color: var(--green); }
.codeblock .cmt { color: var(--fg-dim); }
.codeblock .cmd { color: var(--amber); }
.codeblock .arg { color: var(--fg-bright); }
.examples {
list-style: none;
max-width: 70ch;
}
.examples li {
padding: 0.375rem 0;
color: var(--fg);
}
.examples li::before {
content: '$ ';
color: var(--green);
}
.examples li .q { color: var(--fg-bright); }
.examples li .note {
display: block;
margin-top: 0.125rem;
padding-left: 2ch;
color: var(--fg-dim);
font-size: 13px;
}
.examples li .note::before { content: '↳ '; color: var(--fg-faint); }
/* ─── FILES TREE ─── */
.tree {
color: var(--fg);
line-height: 1.55;
}
.tree .dir { color: var(--amber); }
.tree .file { color: var(--fg); }
.tree .cmt { color: var(--fg-dim); }
.tree .branch { color: var(--fg-faint); }
/* ─── STATUS BAR / VIM-LIKE ─── */
.statusbar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: var(--amber);
color: var(--bg);
font-size: 12px;
letter-spacing: 0.02em;
z-index: 20;
}
.statusbar-inner {
max-width: 820px;
margin: 0 auto;
padding: 0.25rem 2rem;
display: flex;
justify-content: space-between;
gap: 1rem;
white-space: nowrap;
overflow: hidden;
}
.statusbar-l, .statusbar-r { display: flex; gap: 1.25rem; align-items: center; }
.statusbar-l > span:last-child {
overflow: hidden;
text-overflow: ellipsis;
max-width: 22ch;
}
.statusbar kbd {
background: var(--bg);
color: var(--amber);
padding: 1px 5px;
border-radius: 2px;
font-family: inherit;
font-size: 11px;
font-weight: 500;
}
/* ─── CURSOR ─── */
.cursor {
display: inline-block;
width: 0.55em;
height: 1em;
background: var(--amber);
vertical-align: -2px;
animation: blink 1.1s steps(1) infinite;
margin-left: 1px;
}
@keyframes blink { 50% { opacity: 0; } }
/* ─── SEPARATOR ─── */
.hr {
color: var(--rule);
margin: 2rem 0 0;
max-width: 70ch;
padding-left: 7ch;
user-select: none;
}
/* ─── BIB ─── */
.bib {
max-width: 70ch;
}
.bib dt {
color: var(--fg-bright);
margin-top: 0.5rem;
}
.bib dt:first-child { margin-top: 0; }
.bib dd { color: var(--fg-dim); }
/* ─── RESPONSIVE ─── */
@media (max-width: 720px) {
body { font-size: 13px; }
main { padding: 2rem 1rem 0; }
.band-inner, .statusbar-inner { padding: 0.5rem 1rem; font-size: 11px; }
section.body { padding-left: 4ch; }
.lang-row { grid-template-columns: 1fr 6ch; gap: 0.5ch; }
.lang-row .desc { display: none; }
.ascii-title { font-size: 9px; }
.sev-list li { grid-template-columns: 14ch 1fr; }
.phase-flow { font-size: 10px; }
.band-c { display: none; }
}
</style>
</head>
<body>
<!-- ═══ TOP BAND (man page header line) ═══ -->
<div class="band">
<div class="band-inner">
<span class="band-l">CODE-REVIEW-SKILL(1)</span>
<span class="band-c">User Commands &middot; Edition 2026.01</span>
<span class="band-r">CODE-REVIEW-SKILL(1)</span>
</div>
</div>
<main>
<!-- ═══ TITLE BLOCK ═══ -->
<div class="title-block">
<pre class="ascii-title"> ___ ___ ___ ___ ___ _____ _____ _____ __ ___ _ _____ _ _
/ __/ _ \| \| __| ___ | _ \ __\ \ / /_ _| __\ \ /\ / / __/ __| |/ /_ _| | | |
| (_| (_) | |) | _| |___|| / _| \ V / | || _| \ V V /__\__ \ ' &lt; | || |__| |__
\___\___/|___/|___| |_|_\___| \_/ |___|___| \_/\_/ |___/_|\_\___|____|____|</pre>
<div class="one-liner">
<span>
<span class="dim">$&nbsp;</span><span class="em">man code-review-skill</span><span class="cursor"></span>
</span>
<span class="lang-toggle">
<span class="dim">LANG=</span><a href="index.html">zh_CN</a><span class="sep">&nbsp;|&nbsp;</span><a href="index.en.html" class="on">en_US</a>
</span>
</div>
<div class="one-liner-sub">
v1.0 &middot; awesome-skills &middot; MIT &middot; 20 languages &middot; 16,000+ lines
</div>
</div>
<!-- ═══ NAME ═══ -->
<h2 class="sec">NAME</h2>
<section class="body">
<p>
<span class="em">code-review-skill</span> &mdash; A comprehensive, modular code review skill for Claude Code
</p>
</section>
<!-- ═══ SYNOPSIS ═══ -->
<h2 class="sec">SYNOPSIS</h2>
<section class="body">
<pre class="pre">
<span class="amber">Use code-review-skill to</span> review this PR
<span class="amber">Use code-review-skill to</span> review this &lt;<span class="dim">component</span>&gt;
<span class="amber">Use code-review-skill for</span> <span class="dim">[</span>security <span class="dim">|</span> performance <span class="dim">|</span> architecture<span class="dim">]</span> review</pre>
</section>
<!-- ═══ DESCRIPTION ═══ -->
<h2 class="sec">DESCRIPTION</h2>
<section class="body">
<p>A production-grade code review skill. It transforms AI-assisted code review from vague suggestions into a structured, consistent, expert-level collaborative process.</p>
<p>Core is only <span class="em">~190 lines</span>; the full <span class="em">16,000+ lines</span> of language guides load on demand. Covers <span class="em">20+</span> mainstream languages and frameworks &mdash; progressive loading, zero overhead.</p>
<p>Every finding carries an explicit severity label. Every review proceeds through four phases: PR context &middot; high-level assessment &middot; line-by-line analysis &middot; summary &amp; decision.</p>
</section>
<!-- ═══ LANGUAGES ═══ -->
<h2 class="sec">LANGUAGES</h2>
<section class="body">
<div class="cat-head">┌── frontend ──┘</div>
<div class="lang-row"><span class="file">react.md</span><span class="desc">React 19, Hooks, Server Components, TanStack v5 <span class="dotleader">.................</span></span><span class="lines">870</span></div>
<div class="lang-row"><span class="file">vue.md</span><span class="desc">Vue 3.5, Composition API, Composables, Watchers <span class="dotleader">.................</span></span><span class="lines">920</span></div>
<div class="lang-row"><span class="file">angular.md</span><span class="desc">Angular 17+, Signals, Standalone, Zoneless <span class="dotleader">..........................</span></span><span class="lines">420</span></div>
<div class="lang-row"><span class="file">svelte.md</span><span class="desc">Svelte 5, Runes, SvelteKit, SSR/CSR boundaries <span class="dotleader">..................</span></span><span class="lines">1,060</span></div>
<div class="lang-row"><span class="file">typescript.md</span><span class="desc">TypeScript strict mode, generics, immutability <span class="dotleader">..................</span></span><span class="lines">540</span></div>
<div class="lang-row"><span class="file">css-less-sass.md</span><span class="desc">CSS/Less/Sass variables, responsive, compatibility <span class="dotleader">..............</span></span><span class="lines">660</span></div>
<div class="cat-head">┌── backend ──┘</div>
<div class="lang-row"><span class="file">python.md</span><span class="desc">Python async, typing, pytest, mutable defaults <span class="dotleader">.................</span></span><span class="lines">1,070</span></div>
<div class="lang-row"><span class="file">django.md</span><span class="desc">Django/DRF security, N+1, serializers, async views <span class="dotleader">..............</span></span><span class="lines">1,030</span></div>
<div class="lang-row"><span class="file">java.md</span><span class="desc">Java 17/21, Spring Boot 3, virtual threads, JPA <span class="dotleader">................</span></span><span class="lines">800</span></div>
<div class="lang-row"><span class="file">php.md</span><span class="desc">PHP 8.x, types, PDO, security, Composer <span class="dotleader">...........................</span></span><span class="lines">700</span></div>
<div class="lang-row"><span class="file">go.md</span><span class="desc">Goroutines, channels, context, interface design <span class="dotleader">.................</span></span><span class="lines">990</span></div>
<div class="lang-row"><span class="file">rust.md</span><span class="desc">Ownership, async/await, unsafe, cancellation safety <span class="dotleader">.............</span></span><span class="lines">840</span></div>
<div class="lang-row"><span class="file">csharp.md</span><span class="desc">C# 12 / .NET 8, EF Core, ASP.NET Core, LINQ <span class="dotleader">.....................</span></span><span class="lines">520</span></div>
<div class="lang-row"><span class="file">nestjs.md</span><span class="desc">NestJS DI, guards, interceptors, DTO validation <span class="dotleader">.................</span></span><span class="lines">590</span></div>
<div class="cat-head">┌── mobile / systems ──┘</div>
<div class="lang-row"><span class="file">kotlin.md</span><span class="desc">Kotlin/Android coroutines, Compose, Flow, null safety <span class="dotleader">...........</span></span><span class="lines">1,020</span></div>
<div class="lang-row"><span class="file">swift.md</span><span class="desc">Swift 5.9+/6, SwiftUI, concurrency, Sendable, optionals <span class="dotleader">..........</span></span><span class="lines">930</span></div>
<div class="lang-row"><span class="file">c.md</span><span class="desc">C pointer safety, undefined behavior, resources <span class="dotleader">.................</span></span><span class="lines">210</span></div>
<div class="lang-row"><span class="file">cpp.md</span><span class="desc">C++ RAII, Rule of 0/3/5, move semantics, noexcept <span class="dotleader">...............</span></span><span class="lines">300</span></div>
<div class="lang-row"><span class="file">qt.md</span><span class="desc">Qt object model, signals/slots, GUI performance <span class="dotleader">.................</span></span><span class="lines">190</span></div>
<div class="cat-head">┌── cross-cutting ──┘</div>
<div class="lang-row"><span class="file">architecture-review-guide.md</span><span class="desc">SOLID, anti-patterns, coupling <span class="dotleader">...</span></span><span class="lines">470</span></div>
<div class="lang-row"><span class="file">performance-review-guide.md</span><span class="desc">Web Vitals, N+1, complexity <span class="dotleader">.......</span></span><span class="lines">850</span></div>
<div class="lang-row"><span class="file">code-quality-universal.md</span><span class="desc">TOCTOU, leaky abstractions, sprawl <span class="dotleader">....</span></span><span class="lines">320</span></div>
<div class="lang-row"><span class="file">security-review-guide.md</span><span class="desc">Injection, XSS, secrets, all langs <span class="dotleader">.....</span></span><span class="lines">—</span></div>
</section>
<!-- ═══ PHASES ═══ -->
<h2 class="sec">PHASES</h2>
<section class="body">
<pre class="phase-flow"> <span class="box">┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐</span>
<span class="box">│ context │</span> <span class="arrow">─▶</span> <span class="box">│ high level │</span> <span class="arrow">─▶</span> <span class="box">│ line by line│</span> <span class="arrow">─▶</span> <span class="box">│ decide │</span>
<span class="box">│ 2-3m │</span> <span class="box">│ 5-10m │</span> <span class="box">│ 10-20m │</span> <span class="box">│ 2-3m │</span>
<span class="box">└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘</span></pre>
<dl class="phase-list" style="margin-top:1.5rem;">
<dt>1. context gathering <span class="dim">— 2-3 min</span></dt>
<dd>Read the PR description and linked issues, assess scope, check CI status, understand the business intent.</dd>
<dt>2. high-level review <span class="dim">— 5-10 min</span></dt>
<dd>Evaluate architectural fit, performance impact, file organization, test strategy. See the whole first.</dd>
<dt>3. line-by-line analysis <span class="dim">— 10-20 min</span></dt>
<dd>Logic correctness &middot; security &middot; performance &middot; maintainability &middot; edge cases. One by one.</dd>
<dt>4. summary &amp; decision <span class="dim">— 2-3 min</span></dt>
<dd>Summarize findings, name what was done well, deliver approve / comment / request-changes.</dd>
</dl>
</section>
<!-- ═══ SEVERITY ═══ -->
<h2 class="sec">SEVERITY</h2>
<section class="body">
<ul class="sev-list">
<li>
<span class="label"><span class="red">●</span>&nbsp;[blocking]</span>
<span class="desc">must fix <span class="aside">— resolve before merge; security / correctness / serious logic</span></span>
</li>
<li>
<span class="label"><span style="color:#d68a3d;">●</span>&nbsp;[important]</span>
<span class="desc">should fix <span class="aside">— strongly recommended; discuss if you disagree</span></span>
</li>
<li>
<span class="label"><span style="color:#c7a648;">●</span>&nbsp;[nit]</span>
<span class="desc">nice to have <span class="aside">— style or preference; non-blocking</span></span>
</li>
<li>
<span class="label"><span class="blue">●</span>&nbsp;[suggestion]</span>
<span class="desc">alternative <span class="aside">— worth considering; author decides</span></span>
</li>
<li>
<span class="label"><span style="color:#9078b8;">●</span>&nbsp;[learning]</span>
<span class="desc">educational <span class="aside">— no action needed; share knowledge</span></span>
</li>
<li>
<span class="label"><span class="green">●</span>&nbsp;[praise]</span>
<span class="desc">good work <span class="aside">— say it out loud when you see it</span></span>
</li>
</ul>
</section>
<!-- ═══ INSTALLATION ═══ -->
<h2 class="sec">INSTALLATION</h2>
<section class="body">
<p>Clone into the Claude Code skills directory. Two commands.</p>
<pre class="codeblock"><span class="cmt"># macOS / Linux</span>
<span class="prompt">$</span> <span class="cmd">git clone</span> <span class="arg">https://github.com/awesome-skills/code-review-skill.git</span> \
~/.claude/skills/code-review-skill
<span class="cmt"># Windows PowerShell</span>
<span class="prompt">PS&gt;</span> <span class="cmd">git clone</span> <span class="arg">https://github.com/awesome-skills/code-review-skill.git</span> `
"$env:USERPROFILE\.claude\skills\code-review-skill"</pre>
</section>
<!-- ═══ EXAMPLES ═══ -->
<h2 class="sec">EXAMPLES</h2>
<section class="body">
<ul class="examples">
<li>
<span class="q">Use code-review-skill to review this PR</span>
<span class="note">runs the full four-phase review</span>
</li>
<li>
<span class="q">Review this React component</span>
<span class="note">loads react.md &middot; checks Hooks &middot; Server Components</span>
</li>
<li>
<span class="q">Security review of this Go service</span>
<span class="note">loads go.md + security-review-guide.md together</span>
</li>
<li>
<span class="q">Architecture review</span>
<span class="note">loads the architecture guide &middot; SOLID &middot; anti-patterns &middot; coupling</span>
</li>
</ul>
</section>
<!-- ═══ FILES ═══ -->
<h2 class="sec">FILES</h2>
<section class="body">
<pre class="tree">
<span class="dir">~/.claude/skills/code-review-skill/</span>
<span class="branch">├──</span> <span class="file">SKILL.md</span> <span class="cmt"># core, loaded on activation (~190 lines)</span>
<span class="branch">├──</span> <span class="file">README.md</span>
<span class="branch">├──</span> <span class="file">LICENSE</span> <span class="cmt"># MIT</span>
<span class="branch">├──</span> <span class="dir">reference/</span> <span class="cmt"># on-demand language guides</span>
<span class="branch">│ ├──</span> <span class="file">react.md</span> <span class="file">vue.md</span> <span class="file">angular.md</span> ...
<span class="branch">│ └──</span> <span class="file">architecture-review-guide.md</span> ...
<span class="branch">├──</span> <span class="dir">assets/</span>
<span class="branch">│ ├──</span> <span class="file">review-checklist.md</span> <span class="cmt"># quick reference</span>
<span class="branch">│ └──</span> <span class="file">pr-review-template.md</span> <span class="cmt"># PR comment template</span>
<span class="branch">└──</span> <span class="dir">scripts/</span>
<span class="branch">└──</span> <span class="file">pr-analyzer.py</span> <span class="cmt"># PR complexity analyzer</span></pre>
</section>
<!-- ═══ SEE ALSO ═══ -->
<h2 class="sec">SEE ALSO</h2>
<section class="body">
<p>
<a class="link" href="https://claude.ai/code" target="_blank">claude-code(1)</a>,
<a class="link" href="https://github.com/awesome-skills/code-review-skill" target="_blank">github / awesome-skills</a>,
<a class="link" href="https://github.com/awesome-skills/code-review-skill/blob/main/CONTRIBUTING.md" target="_blank">CONTRIBUTING(7)</a>,
<a class="link" href="https://github.com/awesome-skills/code-review-skill/blob/main/assets/review-checklist.md" target="_blank">review-checklist(7)</a>
</p>
</section>
<!-- ═══ AUTHORS ═══ -->
<h2 class="sec">AUTHORS</h2>
<section class="body">
<dl class="bib">
<dt>awesome-skills</dt>
<dd>maintainer, primary author</dd>
<dt>contributors</dt>
<dd>see <a class="link" href="https://github.com/awesome-skills/code-review-skill/graphs/contributors" target="_blank">graphs/contributors</a></dd>
</dl>
</section>
<!-- ═══ COPYRIGHT ═══ -->
<h2 class="sec">COPYRIGHT</h2>
<section class="body">
<p>
<span class="dim">Copyright (c) 2025 awesome-skills.</span><br>
Released under the MIT License.<br>
<span class="dim">This is free software: you are free to change and redistribute it.</span><br>
<span class="dim">There is NO WARRANTY, to the extent permitted by law.</span>
</p>
</section>
<div style="height: 4rem;"></div>
<!-- ═══ END-OF-PAGE BAND ═══ -->
<div style="border-top:1px solid var(--rule); margin-top:2rem; padding:0.625rem 0;">
<div style="display:flex; justify-content:space-between; color:var(--fg-dim); font-size:12px; white-space:nowrap; gap:1rem;">
<span class="band-l" style="color:var(--fg-bright);">CODE-REVIEW-SKILL(1)</span>
<span style="color:var(--fg-dim);">awesome-skills</span>
<span class="band-r" style="color:var(--fg-bright);">CODE-REVIEW-SKILL(1)</span>
</div>
</div>
</main>
<!-- ═══ VIM-LIKE STATUS BAR ═══ -->
<div class="statusbar">
<div class="statusbar-inner">
<div class="statusbar-l">
<span>-- NORMAL --</span>
<span>code-review-skill.1</span>
</div>
<div class="statusbar-r">
<span><kbd>g</kbd> top</span>
<span><kbd>G</kbd> end</span>
<span><kbd>q</kbd> quit</span>
<span id="pos">1,1</span>
</div>
</div>
</div>
<script>
// Vim-like keyboard nav for the man-page vibe
document.addEventListener('keydown', (e) => {
if (e.metaKey || e.ctrlKey || e.altKey) return;
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
if (e.key === 'g') {
window.scrollTo({ top: 0, behavior: 'smooth' });
} else if (e.key === 'G') {
window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
} else if (e.key === 'j') {
window.scrollBy({ top: 60, behavior: 'smooth' });
} else if (e.key === 'k') {
window.scrollBy({ top: -60, behavior: 'smooth' });
} else if (e.key === 'q') {
const ok = confirm('Quit man page?');
if (ok) window.close();
}
});
// Update line/col-like indicator from scroll position
const posEl = document.getElementById('pos');
function updatePos() {
const pct = Math.round((window.scrollY / (document.body.scrollHeight - window.innerHeight)) * 100) || 0;
const line = Math.max(1, Math.round((window.scrollY / 20)));
posEl.textContent = line + ',1 ' + (pct >= 99 ? 'Bot' : pct <= 1 ? 'Top' : pct + '%');
}
updatePos();
window.addEventListener('scroll', updatePos, { passive: true });
</script>
</body>
</html>
+702
View File
@@ -0,0 +1,702 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>code-review-skill(1) — User Commands</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #14110d;
--bg-alt: #1a1611;
--fg: #c4b596;
--fg-bright:#e8d5a8;
--fg-dim: #7a6f56;
--fg-faint: #4a4334;
--amber: #d8964a;
--amber-2: #e8a455;
--red: #d56350;
--green: #8fae5a;
--blue: #6b94c4;
--rule: #2a2520;
}
html { background: var(--bg); }
body {
font-family: 'IBM Plex Mono', ui-monospace, 'SF Mono', Menlo, monospace;
font-size: 14px;
line-height: 1.65;
color: var(--fg);
background: var(--bg);
min-height: 100vh;
padding: 0 0 4rem;
-webkit-font-smoothing: antialiased;
}
/* faint scanline-free phosphor texture — very subtle */
body::before {
content: '';
position: fixed;
inset: 0;
pointer-events: none;
z-index: 0;
background:
radial-gradient(ellipse at 50% 0%, rgba(216,150,74,0.04) 0%, transparent 60%);
}
/* ─── HEADER / FOOTER BAND ─── */
.band {
position: sticky;
top: 0;
background: var(--bg);
border-bottom: 1px solid var(--rule);
z-index: 10;
font-size: 12px;
}
.band-inner {
max-width: 820px;
margin: 0 auto;
padding: 0.625rem 2rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
color: var(--fg-dim);
}
.band-l, .band-r {
color: var(--fg-bright);
letter-spacing: 0.04em;
white-space: nowrap;
}
.band-c { color: var(--fg-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.band a {
color: inherit;
text-decoration: none;
border-bottom: 1px dotted var(--fg-faint);
}
.band a:hover { color: var(--amber); border-bottom-color: var(--amber); }
/* ─── PAGE ─── */
main {
max-width: 820px;
margin: 0 auto;
padding: 3rem 2rem 0;
position: relative;
z-index: 1;
}
pre, .pre {
font-family: inherit;
white-space: pre;
color: inherit;
background: none;
margin: 0;
}
/* ─── SECTIONS ─── */
h2.sec {
color: var(--fg-bright);
font-weight: 600;
font-size: 14px;
letter-spacing: 0.04em;
margin: 2.75rem 0 0.875rem;
padding: 0;
}
h2.sec::before { content: ''; }
section.body {
padding-left: 7ch;
position: relative;
}
section.body p {
margin-bottom: 0.875rem;
max-width: 70ch;
}
section.body p:last-child { margin-bottom: 0; }
.em { color: var(--fg-bright); }
.dim { color: var(--fg-dim); }
.faint { color: var(--fg-faint); }
.amber { color: var(--amber); }
.red { color: var(--red); }
.green { color: var(--green); }
.blue { color: var(--blue); }
a.link {
color: var(--amber);
text-decoration: none;
border-bottom: 1px dotted var(--amber);
}
a.link:hover {
color: var(--bg);
background: var(--amber);
border-bottom-color: transparent;
}
/* ─── TITLE BLOCK ─── */
.title-block {
margin-bottom: 3rem;
}
.ascii-title {
color: var(--amber);
font-size: 12px;
line-height: 1;
margin: 1.5rem 0 2.25rem;
white-space: pre;
overflow-x: auto;
font-weight: 500;
letter-spacing: 0;
text-shadow: 0 0 12px rgba(216,150,74,0.25);
}
.one-liner {
color: var(--fg-bright);
margin-bottom: 0.5rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.lang-toggle {
font-size: 12px;
color: var(--fg-dim);
letter-spacing: 0.04em;
}
.lang-toggle a {
color: var(--fg-dim);
text-decoration: none;
border-bottom: 1px dotted var(--fg-faint);
padding-bottom: 1px;
margin: 0 0.25em;
}
.lang-toggle a.on {
color: var(--amber);
border-bottom-color: var(--amber);
}
.lang-toggle a:hover { color: var(--amber); border-bottom-color: var(--amber); }
.lang-toggle .sep { color: var(--fg-faint); }
.one-liner-sub {
color: var(--fg-dim);
}
/* ─── TABLES ─── */
.lang-row {
display: grid;
grid-template-columns: 26ch 1fr 7ch;
gap: 1ch;
padding: 0.125rem 0;
align-items: baseline;
transition: background 0.1s;
border-bottom: 1px dotted var(--rule);
}
.lang-row:hover { background: var(--bg-alt); }
.lang-row .file { color: var(--amber); }
.lang-row .desc { color: var(--fg); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.lang-row .desc .topics { color: var(--fg-dim); }
.lang-row .lines { text-align: right; color: var(--fg-dim); font-variant-numeric: tabular-nums; }
.dotleader {
color: var(--fg-faint);
display: none;
}
.cat-head {
color: var(--fg-bright);
margin: 1.25rem 0 0.5rem;
padding-bottom: 0.25rem;
border-bottom: 1px solid var(--rule);
}
.cat-head:first-child { margin-top: 0; }
/* ─── PHASE DIAGRAM ─── */
.phase-flow {
margin: 1rem 0 1.5rem;
color: var(--fg-dim);
line-height: 1.4;
font-size: 13px;
overflow-x: auto;
}
.phase-flow .box { color: var(--amber); }
.phase-flow .arrow { color: var(--fg-bright); }
.phase-list dt {
color: var(--fg-bright);
margin-top: 0.875rem;
}
.phase-list dt:first-child { margin-top: 0; }
.phase-list dd {
color: var(--fg);
max-width: 70ch;
margin-bottom: 0.125rem;
}
.phase-list dd.t {
color: var(--fg-dim);
font-size: 13px;
}
/* ─── SEVERITY LIST ─── */
.sev-list {
list-style: none;
}
.sev-list li {
display: grid;
grid-template-columns: 16ch 1fr;
gap: 1ch;
padding: 0.25rem 0;
border-bottom: 1px dotted var(--rule);
align-items: baseline;
}
.sev-list li:last-child { border-bottom: none; }
.sev-list li .label { color: var(--fg-bright); }
.sev-list li .desc { color: var(--fg); }
.sev-list li .desc .aside { color: var(--fg-dim); }
/* ─── CODE BLOCKS ─── */
.codeblock {
background: var(--bg-alt);
border-left: 2px solid var(--amber);
padding: 0.875rem 1.25rem;
margin: 0.875rem 0;
color: var(--fg);
overflow-x: auto;
max-width: 70ch;
}
.codeblock .prompt { color: var(--green); }
.codeblock .cmt { color: var(--fg-dim); }
.codeblock .cmd { color: var(--amber); }
.codeblock .arg { color: var(--fg-bright); }
.examples {
list-style: none;
max-width: 70ch;
}
.examples li {
padding: 0.375rem 0;
color: var(--fg);
}
.examples li::before {
content: '$ ';
color: var(--green);
}
.examples li .q { color: var(--fg-bright); }
.examples li .note {
display: block;
margin-top: 0.125rem;
padding-left: 2ch;
color: var(--fg-dim);
font-size: 13px;
}
.examples li .note::before { content: '↳ '; color: var(--fg-faint); }
/* ─── FILES TREE ─── */
.tree {
color: var(--fg);
line-height: 1.55;
}
.tree .dir { color: var(--amber); }
.tree .file { color: var(--fg); }
.tree .cmt { color: var(--fg-dim); }
.tree .branch { color: var(--fg-faint); }
/* ─── STATUS BAR / VIM-LIKE ─── */
.statusbar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: var(--amber);
color: var(--bg);
font-size: 12px;
letter-spacing: 0.02em;
z-index: 20;
}
.statusbar-inner {
max-width: 820px;
margin: 0 auto;
padding: 0.25rem 2rem;
display: flex;
justify-content: space-between;
gap: 1rem;
white-space: nowrap;
overflow: hidden;
}
.statusbar-l, .statusbar-r { display: flex; gap: 1.25rem; align-items: center; }
.statusbar-l > span:last-child {
overflow: hidden;
text-overflow: ellipsis;
max-width: 22ch;
}
.statusbar kbd {
background: var(--bg);
color: var(--amber);
padding: 1px 5px;
border-radius: 2px;
font-family: inherit;
font-size: 11px;
font-weight: 500;
}
/* ─── CURSOR ─── */
.cursor {
display: inline-block;
width: 0.55em;
height: 1em;
background: var(--amber);
vertical-align: -2px;
animation: blink 1.1s steps(1) infinite;
margin-left: 1px;
}
@keyframes blink { 50% { opacity: 0; } }
/* ─── SEPARATOR ─── */
.hr {
color: var(--rule);
margin: 2rem 0 0;
max-width: 70ch;
padding-left: 7ch;
user-select: none;
}
/* ─── BIB ─── */
.bib {
max-width: 70ch;
}
.bib dt {
color: var(--fg-bright);
margin-top: 0.5rem;
}
.bib dt:first-child { margin-top: 0; }
.bib dd { color: var(--fg-dim); }
/* ─── RESPONSIVE ─── */
@media (max-width: 720px) {
body { font-size: 13px; }
main { padding: 2rem 1rem 0; }
.band-inner, .statusbar-inner { padding: 0.5rem 1rem; font-size: 11px; }
section.body { padding-left: 4ch; }
.lang-row { grid-template-columns: 1fr 6ch; gap: 0.5ch; }
.lang-row .desc { display: none; }
.ascii-title { font-size: 9px; }
.sev-list li { grid-template-columns: 14ch 1fr; }
.phase-flow { font-size: 10px; }
.band-c { display: none; }
}
</style>
</head>
<body>
<!-- ═══ TOP BAND (man page header line) ═══ -->
<div class="band">
<div class="band-inner">
<span class="band-l">CODE-REVIEW-SKILL(1)</span>
<span class="band-c">User Commands &middot; Edition 2026.01</span>
<span class="band-r">CODE-REVIEW-SKILL(1)</span>
</div>
</div>
<main>
<!-- ═══ TITLE BLOCK ═══ -->
<div class="title-block">
<pre class="ascii-title"> ___ ___ ___ ___ ___ _____ _____ _____ __ ___ _ _____ _ _
/ __/ _ \| \| __| ___ | _ \ __\ \ / /_ _| __\ \ /\ / / __/ __| |/ /_ _| | | |
| (_| (_) | |) | _| |___|| / _| \ V / | || _| \ V V /__\__ \ ' &lt; | || |__| |__
\___\___/|___/|___| |_|_\___| \_/ |___|___| \_/\_/ |___/_|\_\___|____|____|</pre>
<div class="one-liner">
<span>
<span class="dim">$&nbsp;</span><span class="em">man code-review-skill</span><span class="cursor"></span>
</span>
<span class="lang-toggle">
<span class="dim">LANG=</span><a href="index.html" class="on">zh_CN</a><span class="sep">&nbsp;|&nbsp;</span><a href="index.en.html">en_US</a>
</span>
</div>
<div class="one-liner-sub">
v1.0 &middot; awesome-skills &middot; MIT &middot; 20 languages &middot; 16,000+ lines
</div>
</div>
<!-- ═══ NAME ═══ -->
<h2 class="sec">NAME</h2>
<section class="body">
<p>
<span class="em">code-review-skill</span> &mdash; 面向 Claude Code 的全面、模块化代码审查技能
</p>
</section>
<!-- ═══ SYNOPSIS ═══ -->
<h2 class="sec">SYNOPSIS</h2>
<section class="body">
<pre class="pre">
<span class="amber">Use code-review-skill to</span> review this PR
<span class="amber">Use code-review-skill to</span> review this &lt;<span class="dim">component</span>&gt;
<span class="amber">Use code-review-skill for</span> <span class="dim">[</span>security <span class="dim">|</span> performance <span class="dim">|</span> architecture<span class="dim">]</span> review</pre>
</section>
<!-- ═══ DESCRIPTION ═══ -->
<h2 class="sec">DESCRIPTION</h2>
<section class="body">
<p>一份生产级的代码审查技能。它把 AI 辅助的代码审查从模糊建议提升为结构化、一致、专业级的协作流程。</p>
<p>核心仅约 <span class="em">190 行</span>,按需调阅共计 <span class="em">16,000+ 行</span> 的语言指南。覆盖 <span class="em">20+ 种</span> 主流语言与框架——按需加载,零冗余。</p>
<p>每一条审查意见都带有明确的严重性标记。每一次审查都按四个阶段推进:从 PR 上下文 &middot; 高层级评估 &middot; 逐行分析 &middot; 总结决策。</p>
</section>
<!-- ═══ LANGUAGES ═══ -->
<h2 class="sec">LANGUAGES</h2>
<section class="body">
<div class="cat-head">┌── frontend ──┘</div>
<div class="lang-row"><span class="file">react.md</span><span class="desc">React 19, Hooks, Server Components, TanStack v5 <span class="dotleader">.................</span></span><span class="lines">870</span></div>
<div class="lang-row"><span class="file">vue.md</span><span class="desc">Vue 3.5, Composition API, Composables, Watchers <span class="dotleader">.................</span></span><span class="lines">920</span></div>
<div class="lang-row"><span class="file">angular.md</span><span class="desc">Angular 17+, Signals, Standalone, Zoneless <span class="dotleader">..........................</span></span><span class="lines">420</span></div>
<div class="lang-row"><span class="file">svelte.md</span><span class="desc">Svelte 5, Runes, SvelteKit, SSR/CSR boundaries <span class="dotleader">..................</span></span><span class="lines">1,060</span></div>
<div class="lang-row"><span class="file">typescript.md</span><span class="desc">TypeScript strict mode, generics, immutability <span class="dotleader">..................</span></span><span class="lines">540</span></div>
<div class="lang-row"><span class="file">css-less-sass.md</span><span class="desc">CSS/Less/Sass variables, responsive, compatibility <span class="dotleader">..............</span></span><span class="lines">660</span></div>
<div class="cat-head">┌── backend ──┘</div>
<div class="lang-row"><span class="file">python.md</span><span class="desc">Python async, typing, pytest, mutable defaults <span class="dotleader">.................</span></span><span class="lines">1,070</span></div>
<div class="lang-row"><span class="file">django.md</span><span class="desc">Django/DRF security, N+1, serializers, async views <span class="dotleader">..............</span></span><span class="lines">1,030</span></div>
<div class="lang-row"><span class="file">java.md</span><span class="desc">Java 17/21, Spring Boot 3, virtual threads, JPA <span class="dotleader">................</span></span><span class="lines">800</span></div>
<div class="lang-row"><span class="file">php.md</span><span class="desc">PHP 8.x, types, PDO, security, Composer <span class="dotleader">...........................</span></span><span class="lines">700</span></div>
<div class="lang-row"><span class="file">go.md</span><span class="desc">Goroutines, channels, context, interface design <span class="dotleader">.................</span></span><span class="lines">990</span></div>
<div class="lang-row"><span class="file">rust.md</span><span class="desc">Ownership, async/await, unsafe, cancellation safety <span class="dotleader">.............</span></span><span class="lines">840</span></div>
<div class="lang-row"><span class="file">csharp.md</span><span class="desc">C# 12 / .NET 8, EF Core, ASP.NET Core, LINQ <span class="dotleader">.....................</span></span><span class="lines">520</span></div>
<div class="lang-row"><span class="file">nestjs.md</span><span class="desc">NestJS DI, guards, interceptors, DTO validation <span class="dotleader">.................</span></span><span class="lines">590</span></div>
<div class="cat-head">┌── mobile / systems ──┘</div>
<div class="lang-row"><span class="file">kotlin.md</span><span class="desc">Kotlin/Android coroutines, Compose, Flow, null safety <span class="dotleader">...........</span></span><span class="lines">1,020</span></div>
<div class="lang-row"><span class="file">swift.md</span><span class="desc">Swift 5.9+/6, SwiftUI, concurrency, Sendable, optionals <span class="dotleader">..........</span></span><span class="lines">930</span></div>
<div class="lang-row"><span class="file">c.md</span><span class="desc">C pointer safety, undefined behavior, resources <span class="dotleader">.................</span></span><span class="lines">210</span></div>
<div class="lang-row"><span class="file">cpp.md</span><span class="desc">C++ RAII, Rule of 0/3/5, move semantics, noexcept <span class="dotleader">...............</span></span><span class="lines">300</span></div>
<div class="lang-row"><span class="file">qt.md</span><span class="desc">Qt object model, signals/slots, GUI performance <span class="dotleader">.................</span></span><span class="lines">190</span></div>
<div class="cat-head">┌── cross-cutting ──┘</div>
<div class="lang-row"><span class="file">architecture-review-guide.md</span><span class="desc">SOLID, anti-patterns, coupling <span class="dotleader">...</span></span><span class="lines">470</span></div>
<div class="lang-row"><span class="file">performance-review-guide.md</span><span class="desc">Web Vitals, N+1, complexity <span class="dotleader">.......</span></span><span class="lines">850</span></div>
<div class="lang-row"><span class="file">code-quality-universal.md</span><span class="desc">TOCTOU, leaky abstractions, sprawl <span class="dotleader">....</span></span><span class="lines">320</span></div>
<div class="lang-row"><span class="file">security-review-guide.md</span><span class="desc">Injection, XSS, secrets, all langs <span class="dotleader">.....</span></span><span class="lines">—</span></div>
</section>
<!-- ═══ PHASES ═══ -->
<h2 class="sec">PHASES</h2>
<section class="body">
<pre class="phase-flow"> <span class="box">┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐</span>
<span class="box">│ context │</span> <span class="arrow">─▶</span> <span class="box">│ high level │</span> <span class="arrow">─▶</span> <span class="box">│ line by line│</span> <span class="arrow">─▶</span> <span class="box">│ decide │</span>
<span class="box">│ 2-3m │</span> <span class="box">│ 5-10m │</span> <span class="box">│ 10-20m │</span> <span class="box">│ 2-3m │</span>
<span class="box">└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘</span></pre>
<dl class="phase-list" style="margin-top:1.5rem;">
<dt>1. context gathering <span class="dim">— 2-3 min</span></dt>
<dd>读 PR 描述与关联 issue,评估规模,检查 CI 状态,理解业务需求。</dd>
<dt>2. high-level review <span class="dim">— 5-10 min</span></dt>
<dd>评估架构合理性、性能影响面、文件组织、测试策略。先看全局。</dd>
<dt>3. line-by-line analysis <span class="dim">— 10-20 min</span></dt>
<dd>逻辑正确性 &middot; 安全 &middot; 性能 &middot; 可维护性 &middot; 边界情况。一一过目。</dd>
<dt>4. summary &amp; decision <span class="dim">— 2-3 min</span></dt>
<dd>汇总问题,表扬亮点,给出 approve / comment / request-changes。</dd>
</dl>
</section>
<!-- ═══ SEVERITY ═══ -->
<h2 class="sec">SEVERITY</h2>
<section class="body">
<ul class="sev-list">
<li>
<span class="label"><span class="red">●</span>&nbsp;[blocking]</span>
<span class="desc">必须修复 <span class="aside">— 合并前解决;安全漏洞 / 数据正确性 / 严重逻辑</span></span>
</li>
<li>
<span class="label"><span style="color:#d68a3d;">●</span>&nbsp;[important]</span>
<span class="desc">应当修复 <span class="aside">— 强烈建议;有分歧应讨论</span></span>
</li>
<li>
<span class="label"><span style="color:#c7a648;">●</span>&nbsp;[nit]</span>
<span class="desc">细节建议 <span class="aside">— 风格或偏好,不阻塞合并</span></span>
</li>
<li>
<span class="label"><span class="blue">●</span>&nbsp;[suggestion]</span>
<span class="desc">可选优化 <span class="aside">— 替代方案,由作者决定</span></span>
</li>
<li>
<span class="label"><span style="color:#9078b8;">●</span>&nbsp;[learning]</span>
<span class="desc">知识分享 <span class="aside">— 教育性说明,无需采取行动</span></span>
</li>
<li>
<span class="label"><span class="green">●</span>&nbsp;[praise]</span>
<span class="desc">表扬肯定 <span class="aside">— 看到好代码就说出来</span></span>
</li>
</ul>
</section>
<!-- ═══ INSTALLATION ═══ -->
<h2 class="sec">INSTALLATION</h2>
<section class="body">
<p>克隆到 Claude Code skills 目录。两条命令即可。</p>
<pre class="codeblock"><span class="cmt"># macOS / Linux</span>
<span class="prompt">$</span> <span class="cmd">git clone</span> <span class="arg">https://github.com/awesome-skills/code-review-skill.git</span> \
~/.claude/skills/code-review-skill
<span class="cmt"># Windows PowerShell</span>
<span class="prompt">PS&gt;</span> <span class="cmd">git clone</span> <span class="arg">https://github.com/awesome-skills/code-review-skill.git</span> `
"$env:USERPROFILE\.claude\skills\code-review-skill"</pre>
</section>
<!-- ═══ EXAMPLES ═══ -->
<h2 class="sec">EXAMPLES</h2>
<section class="body">
<ul class="examples">
<li>
<span class="q">Use code-review-skill to review this PR</span>
<span class="note">激活完整四阶段流程</span>
</li>
<li>
<span class="q">Review this React component</span>
<span class="note">加载 react.md &middot; 检查 Hooks &middot; Server Components</span>
</li>
<li>
<span class="q">Security review of this Go service</span>
<span class="note">同时加载 go.md + security-review-guide.md</span>
</li>
<li>
<span class="q">Architecture review</span>
<span class="note">加载架构指南 &middot; SOLID &middot; 反模式 &middot; 耦合度</span>
</li>
</ul>
</section>
<!-- ═══ FILES ═══ -->
<h2 class="sec">FILES</h2>
<section class="body">
<pre class="tree">
<span class="dir">~/.claude/skills/code-review-skill/</span>
<span class="branch">├──</span> <span class="file">SKILL.md</span> <span class="cmt"># 核心,激活时加载 (~190 行)</span>
<span class="branch">├──</span> <span class="file">README.md</span>
<span class="branch">├──</span> <span class="file">LICENSE</span> <span class="cmt"># MIT</span>
<span class="branch">├──</span> <span class="dir">reference/</span> <span class="cmt"># 按需加载的语言指南</span>
<span class="branch">│ ├──</span> <span class="file">react.md</span> <span class="file">vue.md</span> <span class="file">angular.md</span> ...
<span class="branch">│ └──</span> <span class="file">architecture-review-guide.md</span> ...
<span class="branch">├──</span> <span class="dir">assets/</span>
<span class="branch">│ ├──</span> <span class="file">review-checklist.md</span> <span class="cmt"># 快速参考</span>
<span class="branch">│ └──</span> <span class="file">pr-review-template.md</span> <span class="cmt"># 评论模板</span>
<span class="branch">└──</span> <span class="dir">scripts/</span>
<span class="branch">└──</span> <span class="file">pr-analyzer.py</span> <span class="cmt"># PR 复杂度分析</span></pre>
</section>
<!-- ═══ SEE ALSO ═══ -->
<h2 class="sec">SEE ALSO</h2>
<section class="body">
<p>
<a class="link" href="https://claude.ai/code" target="_blank">claude-code(1)</a>,
<a class="link" href="https://github.com/awesome-skills/code-review-skill" target="_blank">github / awesome-skills</a>,
<a class="link" href="https://github.com/awesome-skills/code-review-skill/blob/main/CONTRIBUTING.md" target="_blank">CONTRIBUTING(7)</a>,
<a class="link" href="https://github.com/awesome-skills/code-review-skill/blob/main/assets/review-checklist.md" target="_blank">review-checklist(7)</a>
</p>
</section>
<!-- ═══ AUTHORS ═══ -->
<h2 class="sec">AUTHORS</h2>
<section class="body">
<dl class="bib">
<dt>awesome-skills</dt>
<dd>maintainer, primary author</dd>
<dt>contributors</dt>
<dd>see <a class="link" href="https://github.com/awesome-skills/code-review-skill/graphs/contributors" target="_blank">graphs/contributors</a></dd>
</dl>
</section>
<!-- ═══ COPYRIGHT ═══ -->
<h2 class="sec">COPYRIGHT</h2>
<section class="body">
<p>
<span class="dim">Copyright (c) 2025 awesome-skills.</span><br>
Released under the MIT License.<br>
<span class="dim">This is free software: you are free to change and redistribute it.</span><br>
<span class="dim">There is NO WARRANTY, to the extent permitted by law.</span>
</p>
</section>
<div style="height: 4rem;"></div>
<!-- ═══ END-OF-PAGE BAND ═══ -->
<div style="border-top:1px solid var(--rule); margin-top:2rem; padding:0.625rem 0;">
<div style="display:flex; justify-content:space-between; color:var(--fg-dim); font-size:12px; white-space:nowrap; gap:1rem;">
<span class="band-l" style="color:var(--fg-bright);">CODE-REVIEW-SKILL(1)</span>
<span style="color:var(--fg-dim);">awesome-skills</span>
<span class="band-r" style="color:var(--fg-bright);">CODE-REVIEW-SKILL(1)</span>
</div>
</div>
</main>
<!-- ═══ VIM-LIKE STATUS BAR ═══ -->
<div class="statusbar">
<div class="statusbar-inner">
<div class="statusbar-l">
<span>-- NORMAL --</span>
<span>code-review-skill.1</span>
</div>
<div class="statusbar-r">
<span><kbd>g</kbd> top</span>
<span><kbd>G</kbd> end</span>
<span><kbd>q</kbd> quit</span>
<span id="pos">1,1</span>
</div>
</div>
</div>
<script>
// Vim-like keyboard nav for the man-page vibe
document.addEventListener('keydown', (e) => {
if (e.metaKey || e.ctrlKey || e.altKey) return;
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
if (e.key === 'g') {
window.scrollTo({ top: 0, behavior: 'smooth' });
} else if (e.key === 'G') {
window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' });
} else if (e.key === 'j') {
window.scrollBy({ top: 60, behavior: 'smooth' });
} else if (e.key === 'k') {
window.scrollBy({ top: -60, behavior: 'smooth' });
} else if (e.key === 'q') {
const ok = confirm('Quit man page?');
if (ok) window.close();
}
});
// Update line/col-like indicator from scroll position
const posEl = document.getElementById('pos');
function updatePos() {
const pct = Math.round((window.scrollY / (document.body.scrollHeight - window.innerHeight)) * 100) || 0;
const line = Math.max(1, Math.round((window.scrollY / 20)));
posEl.textContent = line + ',1 ' + (pct >= 99 ? 'Bot' : pct <= 1 ? 'Top' : pct + '%');
}
updatePos();
window.addEventListener('scroll', updatePos, { passive: true });
</script>
</body>
</html>
+419
View File
@@ -0,0 +1,419 @@
# Angular Code Review Guide
> Angular 17+ 代码审查指南,覆盖 Signals、Standalone 组件、RxJS 反模式、Zoneless 变更检测、模板最佳实践及性能优化等核心主题。
## 目录
- [Signals 与变更检测](#signals-与变更检测)
- [Standalone 组件迁移](#standalone-组件迁移)
- [RxJS 反模式](#rxjs-反模式)
- [Zoneless 变更检测](#zoneless-变更检测)
- [模板最佳实践](#模板最佳实践)
- [性能优化](#性能优化)
- [Review Checklist](#review-checklist)
---
## Signals 与变更检测
### Signal + OnPush 自动触发变更检测
```typescript
// ❌ 可变状态 + OnPush = 界面不更新
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<p>{{ data.name }}</p>`,
})
export class UserProfile {
data = { name: 'Alice' };
changeName() { this.data.name = 'Bob'; } // UI 不会更新!
}
// ✅ Signal + OnPush = 自动变更检测
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<p>{{ name() }}</p>`,
})
export class UserProfile {
name = signal('Alice');
changeName() { this.name.set('Bob'); } // 自动触发 CD
}
```
### @Input() 对象变异不会被 OnPush 检测
```typescript
// ❌ 变异 Input 对象——引用不变,OnPush 不检测
@Input() config!: Config;
updateConfig() { this.config.theme = 'dark'; }
// ✅ 创建新引用
updateConfig() { this.config = { ...this.config, theme: 'dark' }; }
```
### computed() 用于派生状态
```typescript
// ❌ effect 用于同步状态——反模式,可能触发额外 CD 周期
export class CartComponent {
total = signal(0);
discounted = signal(0);
constructor() {
effect(() => this.discounted.set(this.total() * 0.9));
}
}
// ✅ computed 用于派生状态——惰性计算,无副作用
export class CartComponent {
total = signal(0);
discounted = computed(() => this.total() * 0.9);
}
```
### effect() 中 Signal 读取在 await 后不会被追踪
```typescript
// ❌ await 之后读取 Signal——依赖未被追踪
effect(async () => {
const data = await fetchUserData();
console.log(`Theme: ${theme()}`); // theme() 未被追踪!
});
// ✅ 在 await 之前同步读取
effect(async () => {
const currentTheme = theme(); // 同步读取,被追踪
const data = await fetchUserData();
console.log(`Theme: ${currentTheme}`);
});
```
### effect 只在特定场景使用
```typescript
// ❌ 用 effect 同步两个 Signal——永远用 computed
effect(() => { this.filtered.set(this.items().filter(i => i.active)); });
// ✅ effect 的合理场景:DOM 操作、分析日志、订阅外部源
effect(() => {
const canvas = this.canvasRef.nativeElement;
const ctx = canvas.getContext('2d');
ctx.fillStyle = this.color();
ctx.fillRect(0, 0, this.size(), this.size());
});
// 💡 "There are no situations where effect is good,
// only situations where it is appropriate."
```
---
## Standalone 组件迁移
### Angular 19+ standalone 是默认值
```typescript
// ❌ Legacy NgModule 组件
@Component({
selector: 'old-component',
standalone: false,
})
export class OldComponent {}
// ✅ 现代 Standalone 组件(Angular 19+ standalone 是默认值)
@Component({
selector: 'user-profile',
imports: [ProfilePhoto, RouterLink],
template: `<profile-photo /><a routerLink="/edit">Edit</a>`,
})
export class UserProfile {}
```
### 审查标记
```typescript
// ⚠️ 需要迁移的信号:
// 1. standalone: false
// 2. @NgModule declarations
// 3. 组件通过 NgModule 而非直接 import
// ✅ 迁移路径:
// 1. 删除 standalone: false
// 2. 将依赖添加到组件的 imports 数组
// 3. 如果不再有 declarations,删除 NgModule
```
---
## RxJS 反模式
### subscribe() 必须配 takeUntilDestroyed
```typescript
// ❌ 裸 subscribe——内存泄漏!组件销毁后仍继续接收数据
@Component({ /* ... */ })
export class UserProfile implements OnInit {
ngOnInit() {
this.data$.subscribe(data => this.processData(data));
}
}
// ✅ takeUntilDestroyed——自动在组件销毁时取消(需在构造函数或注入上下文中调用)
@Component({ /* ... */ })
export class UserProfile {
constructor() {
this.data$.pipe(takeUntilDestroyed()).subscribe(data => {
this.processData(data);
});
}
}
// ✅ 在构造函数外使用——传入 DestroyRef
@Component({ /* ... */ })
export class UserProfile {
private destroyRef = inject(DestroyRef);
startListening() {
this.data$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(/* ... */);
}
}
```
### toSignal 优于 AsyncPipe
```typescript
// ❌ AsyncPipe——需要导入,模板中有 | async
@Component({
imports: [AsyncPipe],
template: `{{ data$ | async }}`,
})
// ✅ toSignal——自动取消订阅,可在任何地方使用
export class UserProfile {
data = toSignal(this.data$, { initialValue: null });
// 模板直接用 data()
}
```
### 避免重复 toSignal 调用
```typescript
// ❌ toSignal 每次调用都创建新订阅
getData() {
return toSignal(this.http.get('/api/data'));
}
// ✅ 存储结果
data = toSignal(this.http.get('/api/data'), { initialValue: null });
```
---
## Zoneless 变更检测
### 普通属性变异不会被检测(Angular 21+)
```typescript
// ❌ Zoneless 下普通属性赋值不触发 CD
export class UserService {
user: User | null = null;
loadUser() { this.user = fetchResult; } // 不触发!
}
// ✅ Signal 自动触发 CD
export class UserService {
private _user = signal<User | null>(null);
readonly user = this._user.asReadonly();
loadUser() { this._user.set(fetchResult); }
}
```
### NgZone API 在 Zoneless 中失效
```typescript
// ❌ NgZone.onStable 在 zoneless 中永远不会触发
ngZone.onStable.subscribe(() => { /* 永远不触发 */ });
// ✅ 使用 afterNextRender
afterNextRender({ write: () => { /* CD 之后执行 */ } });
```
### Reactive Forms 变异需要 markForCheck
```typescript
// ❌ Reactive Forms 的 setValue/patchValue 在 zoneless 中不自动调度 CD
this.form.patchValue({ name: 'Alice' }); // UI 可能不更新
// ✅ 手动标记或通过 Signal 反映
this.form.patchValue({ name: 'Alice' });
this.cdr.markForCheck();
```
### Zoneless 下有效的 CD 触发器
| 触发器 | 说明 |
|--------|------|
| `signal.set()` / `.update()` | Signal 更新自动触发 |
| `ChangeDetectorRef.markForCheck()` | 手动标记 |
| `ComponentRef.setInput()` | 输入绑定 |
| 模板事件监听器回调 | 用户交互 |
---
## 模板最佳实践
### 复杂逻辑提取为 computed Signal
```typescript
// ❌ 模板中复杂表达式
template: `<div *ngIf="items.filter(i => i.active).length > 0 && user.role === 'admin'">`
// ✅ 提取为 computed
filteredItems = computed(() => this.items().filter(i => i.active));
shouldShow = computed(() => this.filteredItems().length > 0 && this.user().role === 'admin');
template: `@if (shouldShow()) { <div>...</div> }`
```
### 原生绑定优于 NgClass / NgStyle
```typescript
// ❌ NgClass/NgStyle——额外指令开销
template: `<div [ngClass]="{active: isActive}" [ngStyle]="{'color': textColor}">`
// ✅ 原生 class/style 绑定——性能更好
template: `<div [class.active]="isActive" [style.color]="textColor">`
```
### 模板专用成员标记 protected
```typescript
// ❂ 模板专用方法暴露为 public
export class UserProfile {
formatName(name: string) { return name.trim(); }
}
// ✅ 模板专用成员用 protected
export class UserProfile {
protected formatName(name: string) { return name.trim(); }
}
```
### Angular 管理的属性标记 readonly
```typescript
// ❌ input/output/model 可被意外覆盖
userId = input<string>();
userSaved = output<void>();
// ✅ readonly 防止意外赋值
readonly userId = input<string>();
readonly userSaved = output<void>();
readonly userName = model<string>();
```
### 命名规范:操作名而非事件名
```typescript
// ❌ 以事件命名
template: `<button (click)="handleClick()">Save</button>`
// ✅ 以操作命名
template: `<button (click)="saveUserData()">Save</button>`
```
---
## 性能优化
### effect 是最后手段——优先 computed
```typescript
// ❌ effect 用于状态同步——触发额外 CD,可能无限循环
effect(() => {
this.filteredItems.set(this.items().filter(i => i.active));
});
// ✅ computed——惰性计算,无副作用,无额外 CD
filteredItems = computed(() => this.items().filter(i => i.active));
```
### afterRenderEffect 分离读写阶段
```typescript
// ❌ 无阶段指定 = mixedReadWrite = 额外 DOM 回流
afterRenderEffect(() => {
const height = el.offsetHeight; // 读
el.style.height = height + 10 + 'px'; // 写
});
// ✅ 分离阶段减少回流
afterRenderEffect({
earlyRead: () => el.offsetHeight,
write: (height) => { el.style.height = height() + 10 + 'px'; },
read: () => verifyLayout(),
});
```
### inject() 优于构造函数注入
```typescript
// ❌ 构造函数注入——多依赖时难以阅读
export class UserService {
constructor(
private http: HttpClient,
private router: Router,
private auth: AuthService,
) {}
}
// ✅ inject()——更好的类型推断和可读性
export class UserService {
private http = inject(HttpClient);
private router = inject(Router);
private auth = inject(AuthService);
}
```
---
## Review Checklist
### Signals 与变更检测
- [ ] Signal + OnPush 用于模板状态(非可变对象)
- [ ] `@Input()` 对象通过新引用更新(非变异)
- [ ] 派生状态用 `computed()`,不用 `effect()`
- [ ] `effect()` 中 Signal 读取在 `await` 之前
- [ ] `effect()` 只用于 DOM 操作、日志、外部源订阅
### Standalone 组件
- [ ] 无 `standalone: false`(Angular 19+)
- [ ] 组件通过 `imports` 数组导入依赖
- [ ] 无不必要的 `@NgModule`
### RxJS
- [ ] `.subscribe()` 配 `takeUntilDestroyed` 或 `async` pipe
- [ ] 优先 `toSignal` 而非 `AsyncPipe`
- [ ] 无重复 `toSignal` 调用
### Zoneless
- [ ] 模板状态通过 Signal 管理(非普通属性)
- [ ] 无 `NgZone.onStable` / `NgZone.onMicrotaskEmpty`
- [ ] Reactive Forms 变异后有 `markForCheck()`
### 模板
- [ ] 复杂逻辑提取为 `computed` Signal
- [ ] 使用原生 `[class]`/`[style]` 而非 `NgClass`/`NgStyle`
- [ ] 模板专用成员标记 `protected`
- [ ] `input`/`output`/`model` 属性标记 `readonly`
- [ ] 事件处理器以操作命名(`saveData` 而非 `handleClick`)
### 性能
- [ ] `effect()` 不用于状态同步
- [ ] `afterRenderEffect` 分离读写阶段
- [ ] `inject()` 用于依赖注入
@@ -0,0 +1,472 @@
# Architecture Review Guide
架构设计审查指南,帮助评估代码的架构是否合理、设计是否恰当。
## SOLID 原则检查清单
### S - 单一职责原则 (SRP)
**检查要点:**
- 这个类/模块是否只有一个改变的理由?
- 类中的方法是否都服务于同一个目的?
- 如果要向非技术人员描述这个类,能否用一句话说清楚?
**代码审查中的识别信号:**
```
⚠️ 类名包含 "And"、"Manager"、"Handler"、"Processor" 等泛化词汇
⚠️ 一个类超过 200-300 行代码
⚠️ 类有超过 5-7 个公共方法
⚠️ 不同的方法操作完全不同的数据
```
**审查问题:**
- "这个类负责哪些事情?能否拆分?"
- "如果 X 需求变化,哪些方法需要改?如果 Y 需求变化呢?"
### O - 开闭原则 (OCP)
**检查要点:**
- 添加新功能时,是否需要修改现有代码?
- 是否可以通过扩展(继承、组合)来添加新行为?
- 是否存在大量的 if/else 或 switch 语句来处理不同类型?
**代码审查中的识别信号:**
```
⚠️ switch/if-else 链处理不同类型
⚠️ 添加新功能需要修改核心类
⚠️ 类型检查 (instanceof, typeof) 散布在代码中
```
**审查问题:**
- "如果要添加新的 X 类型,需要修改哪些文件?"
- "这个 switch 语句会随着新类型增加而增长吗?"
### L - 里氏替换原则 (LSP)
**检查要点:**
- 子类是否可以完全替代父类使用?
- 子类是否改变了父类方法的预期行为?
- 是否存在子类抛出父类未声明的异常?
**代码审查中的识别信号:**
```
⚠️ 显式类型转换 (casting)
⚠️ 子类方法抛出 NotImplementedException
⚠️ 子类方法为空实现或只有 return
⚠️ 使用基类的地方需要检查具体类型
```
**审查问题:**
- "如果用子类替换父类,调用方代码是否需要修改?"
- "这个方法在子类中的行为是否符合父类的契约?"
### I - 接口隔离原则 (ISP)
**检查要点:**
- 接口是否足够小且专注?
- 实现类是否被迫实现不需要的方法?
- 客户端是否依赖了它不使用的方法?
**代码审查中的识别信号:**
```
⚠️ 接口超过 5-7 个方法
⚠️ 实现类有空方法或抛出 NotImplementedException
⚠️ 接口名称过于宽泛 (IManager, IService)
⚠️ 不同的客户端只使用接口的部分方法
```
**审查问题:**
- "这个接口的所有方法是否都被每个实现类使用?"
- "能否将这个大接口拆分为更小的专用接口?"
### D - 依赖倒置原则 (DIP)
**检查要点:**
- 高层模块是否依赖于抽象而非具体实现?
- 是否使用依赖注入而非直接 new 对象?
- 抽象是否由高层模块定义而非低层模块?
**代码审查中的识别信号:**
```
⚠️ 高层模块直接 new 低层模块的具体类
⚠️ 导入具体实现类而非接口/抽象类
⚠️ 配置和连接字符串硬编码在业务逻辑中
⚠️ 难以为某个类编写单元测试
```
**审查问题:**
- "这个类的依赖能否在测试时被 mock 替换?"
- "如果要更换数据库/API 实现,需要修改多少地方?"
---
## 架构反模式识别
### 致命反模式
| 反模式 | 识别信号 | 影响 |
|--------|----------|------|
| **大泥球 (Big Ball of Mud)** | 没有清晰的模块边界,任何代码都可能调用任何其他代码 | 难以理解、修改和测试 |
| **上帝类 (God Object)** | 单个类承担过多职责,知道太多、做太多 | 高耦合,难以重用和测试 |
| **意大利面条代码** | 控制流程混乱,goto 或深层嵌套,难以追踪执行路径 | 难以理解和维护 |
| **熔岩流 (Lava Flow)** | 没人敢动的古老代码,缺乏文档和测试 | 技术债务累积 |
### 设计反模式
| 反模式 | 识别信号 | 建议 |
|--------|----------|------|
| **金锤子 (Golden Hammer)** | 对所有问题使用同一种技术/模式 | 根据问题选择合适的解决方案 |
| **过度工程 (Gas Factory)** | 简单问题用复杂方案解决,滥用设计模式 | YAGNI 原则,先简单后复杂 |
| **船锚 (Boat Anchor)** | 为"将来可能需要"而写的未使用代码 | 删除未使用代码,需要时再写 |
| **复制粘贴编程** | 相同逻辑出现在多处 | 提取公共方法或模块 |
### 审查问题
```markdown
🔴 [blocking] "这个类有 2000 行代码,建议拆分为多个专注的类"
🟡 [important] "这段逻辑在 3 个地方重复,考虑提取为公共方法?"
💡 [suggestion] "这个 switch 语句可以用策略模式替代,更易扩展"
```
---
## 耦合度与内聚性评估
### 耦合类型(从好到差)
| 类型 | 描述 | 示例 |
|------|------|------|
| **消息耦合** ✅ | 通过参数传递数据 | `calculate(price, quantity)` |
| **数据耦合** ✅ | 共享简单数据结构 | `processOrder(orderDTO)` |
| **印记耦合** ⚠️ | 共享复杂数据结构但只用部分 | 传入整个 User 对象但只用 name |
| **控制耦合** ⚠️ | 传递控制标志影响行为 | `process(data, isAdmin=true)` |
| **公共耦合** ❌ | 共享全局变量 | 多个模块读写同一个全局状态 |
| **内容耦合** ❌ | 直接访问另一模块的内部 | 直接操作另一个类的私有属性 |
### 内聚类型(从好到差)
| 类型 | 描述 | 质量 |
|------|------|------|
| **功能内聚** | 所有元素完成单一任务 | ✅ 最佳 |
| **顺序内聚** | 输出作为下一步输入 | ✅ 良好 |
| **通信内聚** | 操作相同数据 | ⚠️ 可接受 |
| **时间内聚** | 同时执行的任务 | ⚠️ 较差 |
| **逻辑内聚** | 逻辑相关但功能不同 | ❌ 差 |
| **偶然内聚** | 没有明显关系 | ❌ 最差 |
### 度量指标参考
```yaml
耦合指标:
CBO (类间耦合):
好: < 5
警告: 5-10
危险: > 10
Ce (传出耦合):
描述: 依赖多少外部类
好: < 7
Ca (传入耦合):
描述: 被多少类依赖
高值意味着: 修改影响大,需要稳定
内聚指标:
LCOM4 (方法缺乏内聚):
1: 单一职责 ✅
2-3: 可能需要拆分 ⚠️
>3: 应该拆分 ❌
```
### 审查问题
- "这个模块依赖了多少其他模块?能否减少?"
- "修改这个类会影响多少其他地方?"
- "这个类的方法是否都操作相同的数据?"
---
## 分层架构审查
### Clean Architecture 层次检查
```
┌─────────────────────────────────────┐
│ Frameworks & Drivers │ ← 最外层:Web、DB、UI
├─────────────────────────────────────┤
│ Interface Adapters │ ← Controllers、Gateways、Presenters
├─────────────────────────────────────┤
│ Application Layer │ ← Use Cases、Application Services
├─────────────────────────────────────┤
│ Domain Layer │ ← Entities、Domain Services
└─────────────────────────────────────┘
↑ 依赖方向只能向内 ↑
```
### 依赖规则检查
**核心规则:源代码依赖只能指向内层**
```typescript
// ❌ 违反依赖规则:Domain 层依赖 Infrastructure
// domain/User.ts
import { MySQLConnection } from '../infrastructure/database';
// ✅ 正确:Domain 层定义接口,Infrastructure 实现
// domain/UserRepository.ts (接口)
interface UserRepository {
findById(id: string): Promise<User>;
}
// infrastructure/MySQLUserRepository.ts (实现)
class MySQLUserRepository implements UserRepository {
findById(id: string): Promise<User> { /* ... */ }
}
```
### 审查清单
**层次边界检查:**
- [ ] Domain 层是否有外部依赖(数据库、HTTP、文件系统)?
- [ ] Application 层是否直接操作数据库或调用外部 API?
- [ ] Controller 是否包含业务逻辑?
- [ ] 是否存在跨层调用(UI 直接调用 Repository)?
**关注点分离检查:**
- [ ] 业务逻辑是否与展示逻辑分离?
- [ ] 数据访问是否封装在专门的层?
- [ ] 配置和环境相关代码是否集中管理?
### 审查问题
```markdown
🔴 [blocking] "Domain 实体直接导入了数据库连接,违反依赖规则"
🟡 [important] "Controller 包含业务计算逻辑,建议移到 Service 层"
💡 [suggestion] "考虑使用依赖注入来解耦这些组件"
```
---
## 设计模式使用评估
### 何时使用设计模式
| 模式 | 适用场景 | 不适用场景 |
|------|----------|------------|
| **Factory** | 需要创建不同类型对象,类型在运行时确定 | 只有一种类型,或类型固定不变 |
| **Strategy** | 算法需要在运行时切换,有多种可互换的行为 | 只有一种算法,或算法不会变化 |
| **Observer** | 一对多依赖,状态变化需要通知多个对象 | 简单的直接调用即可满足需求 |
| **Singleton** | 确实需要全局唯一实例,如配置管理 | 可以通过依赖注入传递的对象 |
| **Decorator** | 需要动态添加职责,避免继承爆炸 | 职责固定,不需要动态组合 |
### 过度设计警告信号
```
⚠️ Patternitis(模式炎)识别信号:
1. 简单的 if/else 被替换为策略模式 + 工厂 + 注册表
2. 只有一个实现的接口
3. 为了"将来可能需要"而添加的抽象层
4. 代码行数因模式应用而大幅增加
5. 新人需要很长时间才能理解代码结构
```
### 审查原则
```markdown
✅ 正确使用模式:
- 解决了实际的可扩展性问题
- 代码更容易理解和测试
- 添加新功能变得更简单
❌ 过度使用模式:
- 为了使用模式而使用
- 增加了不必要的复杂度
- 违反了 YAGNI 原则
```
### 审查问题
- "使用这个模式解决了什么具体问题?"
- "如果不用这个模式,代码会有什么问题?"
- "这个抽象层带来的价值是否大于它的复杂度?"
---
## 可扩展性评估
### 扩展性检查清单
**功能扩展性:**
- [ ] 添加新功能是否需要修改核心代码?
- [ ] 是否提供了扩展点(hooks、plugins、events)?
- [ ] 配置是否外部化(配置文件、环境变量)?
**数据扩展性:**
- [ ] 数据模型是否支持新增字段?
- [ ] 是否考虑了数据量增长的场景?
- [ ] 查询是否有合适的索引?
**负载扩展性:**
- [ ] 是否可以水平扩展(添加更多实例)?
- [ ] 是否有状态依赖(session、本地缓存)?
- [ ] 数据库连接是否使用连接池?
### 扩展点设计检查
```typescript
// ✅ 好的扩展设计:使用事件/钩子
class OrderService {
private hooks: OrderHooks;
async createOrder(order: Order) {
await this.hooks.beforeCreate?.(order);
const result = await this.save(order);
await this.hooks.afterCreate?.(result);
return result;
}
}
// ❌ 差的扩展设计:硬编码所有行为
class OrderService {
async createOrder(order: Order) {
await this.sendEmail(order); // 硬编码
await this.updateInventory(order); // 硬编码
await this.notifyWarehouse(order); // 硬编码
return await this.save(order);
}
}
```
### 审查问题
```markdown
💡 [suggestion] "如果将来需要支持新的支付方式,这个设计是否容易扩展?"
🟡 [important] "这里的逻辑是硬编码的,考虑使用配置或策略模式?"
📚 [learning] "事件驱动架构可以让这个功能更容易扩展"
```
---
## 代码结构最佳实践
### 目录组织
**按功能/领域组织(推荐):**
```
src/
├── user/
│ ├── User.ts (实体)
│ ├── UserService.ts (服务)
│ ├── UserRepository.ts (数据访问)
│ └── UserController.ts (API)
├── order/
│ ├── Order.ts
│ ├── OrderService.ts
│ └── ...
└── shared/
├── utils/
└── types/
```
**按技术层组织(不推荐):**
```
src/
├── controllers/ ← 不同领域混在一起
│ ├── UserController.ts
│ └── OrderController.ts
├── services/
├── repositories/
└── models/
```
### 命名约定检查
| 类型 | 约定 | 示例 |
|------|------|------|
| 类名 | PascalCase,名词 | `UserService`, `OrderRepository` |
| 方法名 | camelCase,动词 | `createUser`, `findOrderById` |
| 接口名 | I 前缀或无前缀 | `IUserService` 或 `UserService` |
| 常量 | UPPER_SNAKE_CASE | `MAX_RETRY_COUNT` |
| 私有属性 | 下划线前缀或无 | `_cache` 或 `#cache` |
### 文件大小指南
```yaml
建议限制:
单个文件: < 300 行
单个函数: < 50 行
单个类: < 200 行
函数参数: < 4 个
嵌套深度: < 4 层
超出限制时:
- 考虑拆分为更小的单元
- 使用组合而非继承
- 提取辅助函数或类
```
### 审查问题
```markdown
🟢 [nit] "这个 500 行的文件可以考虑按职责拆分"
🟡 [important] "建议按功能领域而非技术层组织目录结构"
💡 [suggestion] "函数名 `process` 不够明确,考虑改为 `calculateOrderTotal`?"
```
---
## 快速参考清单
### 架构审查 5 分钟速查
```markdown
□ 依赖方向是否正确?(外层依赖内层)
□ 是否存在循环依赖?
□ 核心业务逻辑是否与框架/UI/数据库解耦?
□ 是否遵循 SOLID 原则?
□ 是否存在明显的反模式?
```
### 红旗信号(必须处理)
```markdown
🔴 God Object - 单个类超过 1000 行
🔴 循环依赖 - A → B → C → A
🔴 Domain 层包含框架依赖
🔴 硬编码的配置和密钥
🔴 没有接口的外部服务调用
```
### 黄旗信号(建议处理)
```markdown
🟡 类间耦合度 (CBO) > 10
🟡 方法参数超过 5 个
🟡 嵌套深度超过 4 层
🟡 重复代码块 > 10 行
🟡 只有一个实现的接口
```
---
## 工具推荐
| 工具 | 用途 | 语言支持 |
|------|------|----------|
| **SonarQube** | 代码质量、耦合度分析 | 多语言 |
| **NDepend** | 依赖分析、架构规则 | .NET |
| **JDepend** | 包依赖分析 | Java |
| **Madge** | 模块依赖图 | JavaScript/TypeScript |
| **ESLint** | 代码规范、复杂度检查 | JavaScript/TypeScript |
| **CodeScene** | 技术债务、热点分析 | 多语言 |
---
## 参考资源
- [Clean Architecture - Uncle Bob](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html)
- [SOLID Principles in Code Review - JetBrains](https://blog.jetbrains.com/upsource/2015/08/31/what-to-look-for-in-a-code-review-solid-principles-2/)
- [Software Architecture Anti-Patterns](https://medium.com/@christophnissle/anti-patterns-in-software-architecture-3c8970c9c4f5)
- [Coupling and Cohesion in System Design](https://www.geeksforgeeks.org/system-design/coupling-and-cohesion-in-system-design/)
- [Design Patterns - Refactoring Guru](https://refactoring.guru/design-patterns)
+285
View File
@@ -0,0 +1,285 @@
# C Code Review Guide
> C code review guide focused on memory safety, undefined behavior, and portability. Examples assume C11.
## Table of Contents
- [Pointer and Buffer Safety](#pointer-and-buffer-safety)
- [Ownership and Resource Management](#ownership-and-resource-management)
- [Undefined Behavior Pitfalls](#undefined-behavior-pitfalls)
- [Integer Types and Overflow](#integer-types-and-overflow)
- [Error Handling](#error-handling)
- [Concurrency](#concurrency)
- [Macros and Preprocessor](#macros-and-preprocessor)
- [API Design and Const](#api-design-and-const)
- [Tooling and Build Checks](#tooling-and-build-checks)
- [Review Checklist](#review-checklist)
---
## Pointer and Buffer Safety
### Always carry size with buffers
```c
// ❌ Bad: ignores destination size
bool copy_name(char *dst, size_t dst_size, const char *src) {
strcpy(dst, src);
return true;
}
// ✅ Good: validate size and terminate
bool copy_name(char *dst, size_t dst_size, const char *src) {
size_t len = strlen(src);
if (len + 1 > dst_size) {
return false;
}
memcpy(dst, src, len + 1);
return true;
}
```
### Avoid dangerous APIs
Prefer `snprintf`, `fgets`, and explicit bounds over `gets`, `strcpy`, or `sprintf`.
```c
// ❌ Bad: unbounded write
sprintf(buf, "%s", input);
// ✅ Good: bounded write
snprintf(buf, buf_size, "%s", input);
```
### Use the right copy primitive
```c
// ❌ Bad: memcpy with overlapping regions
memcpy(dst, src, len);
// ✅ Good: memmove handles overlap
memmove(dst, src, len);
```
---
## Ownership and Resource Management
### One allocation, one free
Track ownership and clean up on every error path.
```c
// ✅ Good: cleanup label avoids leaks
int load_file(const char *path) {
int rc = -1;
FILE *f = NULL;
char *buf = NULL;
f = fopen(path, "rb");
if (!f) {
goto cleanup;
}
buf = malloc(4096);
if (!buf) {
goto cleanup;
}
if (fread(buf, 1, 4096, f) == 0) {
goto cleanup;
}
rc = 0;
cleanup:
free(buf);
if (f) {
fclose(f);
}
return rc;
}
```
---
## Undefined Behavior Pitfalls
### Common UB patterns
```c
// ❌ Bad: use after free
char *p = malloc(10);
free(p);
p[0] = 'a';
// ❌ Bad: uninitialized read
int x;
if (x > 0) { /* UB */ }
// ❌ Bad: signed overflow
int sum = a + b;
```
### Avoid pointer arithmetic past the object
```c
// ❌ Bad: pointer past the end then dereference
int arr[4];
int *p = arr + 4;
int v = *p; // UB
```
---
## Integer Types and Overflow
### Avoid signed/unsigned surprises
```c
// ❌ Bad: negative converted to large size_t
int len = -1;
size_t n = len;
// ✅ Good: validate before converting
if (len < 0) {
return -1;
}
size_t n = (size_t)len;
```
### Check for overflow in size calculations
```c
// ❌ Bad: potential overflow in multiplication
size_t bytes = count * sizeof(Item);
// ✅ Good: check before multiplying
if (count > SIZE_MAX / sizeof(Item)) {
return NULL;
}
size_t bytes = count * sizeof(Item);
```
---
## Error Handling
### Always check return values
```c
// ❌ Bad: ignore errors
fread(buf, 1, size, f);
// ✅ Good: handle errors
size_t read = fread(buf, 1, size, f);
if (read != size && ferror(f)) {
return -1;
}
```
### Consistent error contracts
- Use a clear convention: 0 for success, negative for failure.
- Document ownership rules on success and failure.
- If using `errno`, set it only for actual failures.
---
## Concurrency
### volatile is not synchronization
```c
// ❌ Bad: data race
volatile int stop = 0;
void worker(void) {
while (!stop) { /* ... */ }
}
// ✅ Good: C11 atomics
_Atomic int stop = 0;
void worker(void) {
while (!atomic_load(&stop)) { /* ... */ }
}
```
### Use mutexes for shared state
Protect shared data with `pthread_mutex_t` or equivalent. Avoid holding locks while doing I/O.
---
## Macros and Preprocessor
### Parenthesize arguments
```c
// ❌ Bad: macro with side effects
#define MIN(a, b) ((a) < (b) ? (a) : (b))
int x = MIN(i++, j++);
// ✅ Good: static inline function
static inline int min_int(int a, int b) {
return a < b ? a : b;
}
```
---
## API Design and Const
### Const-correctness and sizes
```c
// ✅ Good: explicit size and const input
int hash_bytes(const uint8_t *data, size_t len, uint8_t *out);
```
### Document nullability
Clearly document whether pointers may be NULL. Prefer returning error codes instead of NULL when possible.
---
## Tooling and Build Checks
```bash
# Warnings
clang -Wall -Wextra -Werror -Wconversion -Wshadow -std=c11 ...
# Sanitizers (debug builds)
clang -fsanitize=address,undefined -fno-omit-frame-pointer -g ...
clang -fsanitize=thread -fno-omit-frame-pointer -g ...
# Static analysis
clang-tidy src/*.c -- -std=c11
cppcheck --enable=warning,performance,portability src/
# Formatting
clang-format -i src/*.c include/*.h
```
---
## Review Checklist
### Memory and UB
- [ ] All buffers have explicit size parameters
- [ ] No out-of-bounds access or pointer arithmetic past objects
- [ ] No use after free or uninitialized reads
- [ ] Signed overflow and shift rules are respected
### API and Design
- [ ] Ownership rules are documented and consistent
- [ ] const-correctness is applied for inputs
- [ ] Error contracts are clear and consistent
### Concurrency
- [ ] No data races on shared state
- [ ] volatile is not used for synchronization
- [ ] Locks are held for minimal time
### Tooling and Tests
- [ ] Builds clean with warnings enabled
- [ ] Sanitizers run on critical code paths
- [ ] Static analysis results are addressed
@@ -0,0 +1,488 @@
# Universal Code Quality Anti-Patterns
> 语言无关的代码质量反模式指南,覆盖代码复用、抽象泄漏、参数膨胀、嵌套条件、字符串类型化、TOCTOU、空操作更新等核心主题。适用于所有语言的 PR 审查。
## 目录
- [代码复用审查](#代码复用审查)
- [参数膨胀](#参数膨胀)
- [抽象泄漏](#抽象泄漏)
- [字符串类型化](#字符串类型化)
- [嵌套条件表达式](#嵌套条件表达式)
- [复制粘贴变种](#复制粘贴变种)
- [空操作更新](#空操作更新)
- [TOCTOU 竞争条件](#toctou-竞争条件)
- [过度宽泛操作](#过度宽泛操作)
- [冗余状态](#冗余状态)
- [通用质量审查清单](#通用质量审查清单)
---
## 代码复用审查
Before accepting new code, search the existing codebase for reusable utilities.
### 搜索现有工具函数
```python
# ❌ 新写的路径拼接逻辑——项目中已有 PathBuilder
def get_config_path(name):
base = os.environ.get("APP_ROOT", ".")
return os.path.join(base, "config", name + ".json")
# ✅ 使用已有的 PathBuilder
def get_config_path(name):
return PathBuilder.config(f"{name}.json")
```
```javascript
// ❌ 手写 debounce——项目已有 lodash 或 utils/debounce.ts
function debounce(fn, ms) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), ms);
};
}
// ✅ 使用已有的工具函数
import { debounce } from "@/utils/debounce";
```
**审查要点:**
- 新增函数是否与已有 utility 重名或功能重叠?
- inline 逻辑是否可以提取为已有模块的调用?
- 检查相邻文件和 shared/utils 目录
---
## 参数膨胀
### 函数参数不断增长
```python
# ❌ 每次新需求加一个参数
def create_user(name, email, role, team, active, avatar_url, timezone):
...
# ✅ 使用配置对象 / dataclass
@dataclass
class CreateUserParams:
name: str
email: str
role: Role = Role.MEMBER
team: str | None = None
active: bool = True
avatar_url: str | None = None
timezone: str = "UTC"
def create_user(params: CreateUserParams) -> User:
...
```
```typescript
// ❌ 6+ 个 positional 参数
function renderWidget(
title: string, width: number, height: number,
theme: string, collapsible: boolean, icon: string
) { ... }
// ✅ Options object pattern
interface WidgetOptions {
title: string;
width?: number;
height?: number;
theme?: "light" | "dark";
collapsible?: boolean;
icon?: string;
}
function renderWidget(options: WidgetOptions) { ... }
```
**审查要点:**
- 函数参数是否 ≥ 4 个?考虑 options object / dataclass
- 新参数是否只是布尔标志?考虑 enum 或 strategy pattern
- 是否有 `enable_x`, `disable_y` 这类互斥参数?
---
## 抽象泄漏
### 暴露内部实现细节
```python
# ❌ 返回内部 ORM 对象——调用者被迫了解 SQLAlchemy
def get_users():
return session.query(User).filter(User.active == True).all()
# ✅ 返回 domain 对象,隐藏持久化层
def get_active_users() -> list[UserDTO]:
rows = user_repo.find_active()
return [UserDTO.from_row(r) for r in rows]
```
```typescript
// ❌ 组件接收 API response 原始结构
<UserCard user={apiResponse.data.results[0]} />
// ✅ 组件接收 domain 类型,adapter 处理映射
interface UserSummary {
displayName: string;
avatarUrl: string;
}
<UserCard user={adaptUser(apiResponse)} />
```
**审查要点:**
- 函数返回类型是否泄露底层实现(ORM, HTTP client, file format)?
- 组件/函数是否依赖外部系统的数据结构?
- 是否破坏了已有的抽象边界?
---
## 字符串类型化
### 用原始字符串代替常量/枚举
```python
# ❌ Magic strings 散落各处
if status == "active":
...
if role == "admin":
...
# ✅ 使用 enum
class Status(StrEnum):
ACTIVE = "active"
SUSPENDED = "suspended"
ARCHIVED = "archived"
if user.status == Status.ACTIVE:
...
```
```typescript
// ❌ Raw string event names——拼写错误不会报错
emitter.emit("userCreated", data);
emitter.on("usercreated", handler); // bug: typo
// ✅ 常量或 branded type
const Events = {
USER_CREATED: "userCreated",
USER_SUSPENDED: "userSuspended",
} as const;
emitter.emit(Events.USER_CREATED, data);
```
**审查要点:**
- 是否用字符串代替了已有的 enum/union type?
- 事件名、action type、status 值是否散落在多个文件?
- 字符串比较是否 case-sensitive 但未验证?
---
## 嵌套条件表达式
### 三元链和嵌套 if/else
```python
# ❌ 三元链难以阅读
label = (
"Admin" if role == "admin" else
"Manager" if role == "manager" else
"Viewer" if role == "viewer" else
"Unknown"
)
# ✅ 查找表或 match
ROLE_LABELS = {
"admin": "Admin",
"manager": "Manager",
"viewer": "Viewer",
}
label = ROLE_LABELS.get(role, "Unknown")
```
```typescript
// ❌ 嵌套三元
const bg = isHovered
? isSelected ? "blue" : "gray"
: isSelected ? "navy" : "white";
// ✅ 查找表(lookup map)
const bgMap: Record<string, string> = {
"true-true": "blue",
"true-false": "gray",
"false-true": "navy",
"false-false": "white",
};
const bg = bgMap[`${isHovered}-${isSelected}`];
```
```python
# ❌ 嵌套 if 3+ 层
def process(order):
if order is not None:
if order.items:
for item in order.items:
if item.price > 0:
...
# ✅ Early return + guard clauses
def process(order):
if not order or not order.items:
return
for item in order.items:
if item.price <= 0:
continue
...
```
**审查要点:**
- 三元表达式是否嵌套 ≥ 2 层?
- if/else 嵌套是否 ≥ 3 层?
- 能否用 lookup table、early return 或 match 替换?
---
## 复制粘贴变种
### 近乎重复的代码块
```python
# ❌ 两个函数几乎一样,只有字段名不同
def format_user(user):
return f"{user.first_name} {user.last_name} ({user.email})"
def format_employee(emp):
return f"{emp.first_name} {emp.last_name} ({emp.work_email})"
# ✅ 统一抽象
def format_person(first: str, last: str, email: str) -> str:
return f"{first} {last} ({email})"
```
```typescript
// ❌ Copy-paste handler 只改了 URL
async function deletePost(id: string) {
await fetch(`/api/posts/${id}`, { method: "DELETE" });
router.push("/posts");
}
async function deleteComment(id: string) {
await fetch(`/api/comments/${id}`, { method: "DELETE" });
router.push("/comments");
}
// ✅ 参数化
async function deleteResource(resource: string, id: string) {
await fetch(`/api/${resource}/${id}`, { method: "DELETE" });
router.push(`/${resource}`);
}
```
**审查要点:**
- 是否有 ≥ 2 段代码仅变量名/URL/字符串不同?
- 能否提取参数化的共享函数?
- 是否可以用 template method 或 strategy 消除变种?
---
## 空操作更新
### 无条件触发状态更新
```typescript
// ❌ 每次 poll 都触发 update——即使数据未变
useEffect(() => {
const interval = setInterval(() => {
fetch("/api/status").then(r => r.json()).then(setStatus);
}, 5000);
return () => clearInterval(interval);
}, []);
// ✅ 仅在值变化时更新
useEffect(() => {
const interval = setInterval(() => {
fetch("/api/status")
.then(r => r.json())
.then(data => {
setStatus(prev => isEqual(prev, data) ? prev : data);
});
}, 5000);
return () => clearInterval(interval);
}, []);
```
```python
# ❌ 每次 loop 都写 DB——即使值未变
for item in items:
item.status = compute_status(item)
session.commit()
# ✅ 仅在变化时写入
for item in items:
new_status = compute_status(item)
if item.status != new_status:
item.status = new_status
session.commit()
```
**审查要点:**
- polling / interval / event handler 是否无条件更新?
- wrapper function 是否尊重 same-reference return?
- DB 写入是否检查了实际变化?
---
## TOCTOU 竞争条件
### Time-of-Check-to-Time-of-Use
```python
# ❌ 先检查后操作——中间文件可能被删除/创建
if os.path.exists(path):
with open(path) as f:
data = f.read()
# ✅ 直接操作 + 处理异常
try:
with open(path) as f:
data = f.read()
except FileNotFoundError:
data = None
```
```python
# ❌ 检查余额 → 扣款 两步操作不是原子的
if account.balance >= amount:
account.balance -= amount
# ✅ 原子操作或锁
with account.lock:
if account.balance < amount:
raise InsufficientFundsError()
account.balance -= amount
```
```typescript
// ❌ Check-then-act 在 async 环境中不安全
if (!fileExists(path)) {
await writeFile(path, content);
}
// ✅ 直接操作 + catch
try {
await writeFile(path, content, { flag: "wx" });
} catch (e) {
if (e.code === "EEXIST") { /* handle */ }
else throw e;
}
```
**审查要点:**
- `if exists → operate` 模式是否可替换为 `try operate → catch`?
- 多步状态变更是否在事务/锁内?
- async 操作中 check 和 act 之间是否有 await?
---
## 过度宽泛操作
### 读取过多数据
```python
# ❌ 读取整个文件再取第一行
content = Path("log.txt").read_text()
first_line = content.split("\n")[0]
# ✅ 只读第一行,不加载整个文件
with open("log.txt") as f:
first_line = f.readline()
```
```typescript
// ❌ 加载所有 items 再过滤
const allItems = await db.query("SELECT * FROM orders");
const pending = allItems.filter(o => o.status === "pending");
// ✅ 数据库层过滤
const pending = await db.query(
"SELECT * FROM orders WHERE status = ?", ["pending"]
);
```
```python
# ❌ 读取整个列表找一条记录
users = list(User.objects.all())
user = next(u for u in users if u.id == user_id)
# ✅ 精确查询
user = User.objects.get(id=user_id)
```
**审查要点:**
- 是否读取了整个集合/文件再只用一小部分?
- 能否将过滤推到数据库/存储层?
- API 调用是否支持 pagination/limit 参数?
---
## 冗余状态
### 状态可以被推导
```typescript
// ❌ 同时存储 fullName 和 firstName + lastName
interface User {
firstName: string;
lastName: string;
fullName: string; // redundant
}
// ✅ fullName 是推导值
interface User {
firstName: string;
lastName: string;
}
const fullName = `${user.firstName} ${user.lastName}`;
```
```python
# ❌ 缓存值在源数据变化时可能过时
class Order:
total: float
item_count: int # redundant if len(items) gives the same
items: list[Item]
# ✅ 推导或 property
class Order:
items: list[Item]
@property
def total(self) -> float:
return sum(item.price for item in self.items)
@property
def item_count(self) -> int:
return len(self.items)
```
**审查要点:**
- 是否有字段可以从其他字段推导?
- 缓存值是否有 invalidation 机制?
- observer/effect 是否可以替换为直接调用?
---
## 通用质量审查清单
- [ ] **复用审查**: 搜索了现有 utility/helper,没有重复造轮子?
- [ ] **参数数量**: 函数参数 ≤ 3 个?超过则用 options object / dataclass?
- [ ] **抽象边界**: 返回类型没有暴露内部实现细节(ORM、HTTP client、file format)?
- [ ] **类型安全**: 没有 magic strings 代替已有的 enum/constant/union type?
- [ ] **条件深度**: 三元嵌套 ≤ 1 层?if/else 嵌套 ≤ 2 层?
- [ ] **DRY**: 没有 copy-paste-with-variation(≥ 2 段近似代码)?
- [ ] **空操作防护**: polling / interval / event handler 有 change-detection guard?
- [ ] **TOCTOU**: `if exists → operate` 替换为 `try operate → catch`?
- [ ] **数据精度**: 没有读取整个集合/文件只为了取子集?
- [ ] **冗余状态**: 没有可以从其他字段推导的存储字段?
@@ -0,0 +1,136 @@
# Code Review Best Practices
Comprehensive guidelines for conducting effective code reviews.
## Review Philosophy
### Goals of Code Review
**Primary Goals:**
- Catch bugs and edge cases before production
- Ensure code maintainability and readability
- Share knowledge across the team
- Enforce coding standards consistently
- Improve design and architecture decisions
**Secondary Goals:**
- Mentor junior developers
- Build team culture and trust
- Document design decisions through discussions
### What Code Review is NOT
- A gatekeeping mechanism to block progress
- An opportunity to show off knowledge
- A place to nitpick formatting (use linters)
- A way to rewrite code to personal preference
## Review Timing
### When to Review
| Trigger | Action |
|---------|--------|
| PR opened | Review within 24 hours, ideally same day |
| Changes requested | Re-review within 4 hours |
| Blocking issue found | Communicate immediately |
### Time Allocation
- **Small PR (<100 lines)**: 10-15 minutes
- **Medium PR (100-400 lines)**: 20-40 minutes
- **Large PR (>400 lines)**: Request to split, or 60+ minutes
## Review Depth Levels
### Level 1: Skim Review (5 minutes)
- Check PR description and linked issues
- Verify CI/CD status
- Look at file changes overview
- Identify if deeper review needed
### Level 2: Standard Review (20-30 minutes)
- Full code walkthrough
- Logic verification
- Test coverage check
- Security scan
### Level 3: Deep Review (60+ minutes)
- Architecture evaluation
- Performance analysis
- Security audit
- Edge case exploration
## Communication Guidelines
### Tone and Language
**Use collaborative language:**
- "What do you think about..." instead of "You should..."
- "Could we consider..." instead of "This is wrong"
- "I'm curious about..." instead of "Why didn't you..."
**Be specific and actionable:**
- Include code examples when suggesting changes
- Link to documentation or past discussions
- Explain the "why" behind suggestions
### Handling Disagreements
1. **Seek to understand**: Ask clarifying questions
2. **Acknowledge valid points**: Show you've considered their perspective
3. **Provide data**: Use benchmarks, docs, or examples
4. **Escalate if needed**: Involve senior dev or architect
5. **Know when to let go**: Not every hill is worth dying on
## Review Prioritization
### Must Fix (Blocking)
- Security vulnerabilities
- Data corruption risks
- Breaking changes without migration
- Critical performance issues
- Missing error handling for user-facing features
### Should Fix (Important)
- Test coverage gaps
- Moderate performance concerns
- Code duplication
- Unclear naming or structure
- Missing documentation for complex logic
### Nice to Have (Non-blocking)
- Style preferences beyond linting
- Minor optimizations
- Additional test cases
- Documentation improvements
## Anti-Patterns to Avoid
### Reviewer Anti-Patterns
- **Rubber stamping**: Approving without actually reviewing
- **Bike shedding**: Debating trivial details extensively
- **Scope creep**: "While you're at it, can you also..."
- **Ghosting**: Requesting changes then disappearing
- **Perfectionism**: Blocking for minor style preferences
### Author Anti-Patterns
- **Mega PRs**: Submitting 1000+ line changes
- **No context**: Missing PR description or linked issues
- **Defensive responses**: Arguing every suggestion
- **Silent updates**: Making changes without responding to comments
## Metrics and Improvement
### Track These Metrics
- Time to first review
- Review cycle time
- Number of review rounds
- Defect escape rate
- Review coverage percentage
### Continuous Improvement
- Hold retrospectives on review process
- Share learnings from escaped bugs
- Update checklists based on common issues
- Celebrate good reviews and catches
@@ -0,0 +1,248 @@
# Common Bugs Checklist
Quick-reference bug patterns organized by category. For detailed code examples, explanations, and comprehensive review checklists, see the dedicated language guides linked below.
## Universal Issues
### Logic Errors
- [ ] Off-by-one errors in loops and array access
- [ ] Incorrect boolean logic (De Morgan's law violations)
- [ ] Missing null/undefined checks
- [ ] Race conditions in concurrent code
- [ ] Incorrect comparison operators (`==` vs `===`, `=` vs `==`)
- [ ] Integer overflow/underflow
- [ ] Floating point comparison issues
### Resource Management
- [ ] Memory leaks (unclosed connections, listeners)
- [ ] File handles not closed
- [ ] Database connections not released
- [ ] Event listeners not removed
- [ ] Timers/intervals not cleared
### Error Handling
- [ ] Swallowed exceptions (empty catch blocks)
- [ ] Generic exception handling hiding specific errors
- [ ] Missing error propagation
- [ ] Incorrect error types thrown
- [ ] Missing finally/cleanup blocks
## TypeScript/JavaScript
- [ ] `==` instead of `===`
- [ ] Using `any` — prefer proper types or `unknown` with type guards
- [ ] Missing `await` on async calls
- [ ] Unhandled promise rejections (no try-catch around await)
- [ ] `this` context lost in callbacks
- [ ] Missing `key` prop in lists
- [ ] Closure capturing stale loop variable
- [ ] `parseInt` without radix parameter
- [ ] Modifying array/object during iteration
**Full guide:** [TypeScript Review Guide](typescript.md)
## React / React 19
- [ ] Hooks called conditionally or in loops (violates Rules of Hooks)
- [ ] `useEffect` dependency array incomplete or incorrect
- [ ] `useEffect` missing cleanup function (subscriptions, timers, fetches)
- [ ] `useEffect` used for derived state (use `useMemo` instead)
- [ ] `useMemo`/`useCallback` over-used or used without `React.memo`
- [ ] Component defined inside another component (re-mounts every render)
- [ ] Unstable props (inline objects/functions passed to memo components)
- [ ] Direct mutation of props
- [ ] List missing `key` or using array index as key (reorderable lists)
- [ ] Server Component using client APIs (`useState`, `useEffect`, `onClick`)
- [ ] `'use client'` on parent making entire subtree client-side
- [ ] `useActionState` calling `setState` instead of returning new state
- [ ] `useFormStatus` called in same component as `<form>` (must be in child)
- [ ] `useOptimistic` used for critical operations (payments, deletions)
- [ ] Single Suspense boundary for entire page (slow blocks fast)
- [ ] Missing Error Boundary wrapping Suspense
- [ ] `use()` Hook receiving a new Promise each render
**TanStack Query v5:**
- [ ] `queryKey` missing parameters that affect data
- [ ] Default `staleTime: 0` causing excessive refetches
- [ ] `useSuspenseQuery` with `enabled` option (not supported)
- [ ] Mutation not invalidating related queries on success
- [ ] Optimistic update missing rollback in `onError`
- [ ] Using v4 array syntax (`useQuery(['key'], fn)`) instead of v5 object syntax
**Testing:**
- [ ] Using `container.querySelector` instead of `screen.getByRole`
- [ ] Using `fireEvent` instead of `userEvent`
- [ ] Testing implementation details instead of user-visible behavior
- [ ] Using `getBy*` for async content (use `findBy*`)
**Full guide:** [React Review Guide](react.md)
## Vue 3
- [ ] Destructuring `reactive()` object loses reactivity (use `toRefs`)
- [ ] Passing `props.x` to composable instead of `() => props.x` or `toRef(props, 'x')`
- [ ] `watch` with async callback missing `onCleanup` (race condition)
- [ ] `computed` with side effects (mutations, API calls)
- [ ] `v-for` using index as `:key` when list can reorder
- [ ] `v-if` and `v-for` on the same element
- [ ] `defineProps` without TypeScript type declaration
- [ ] `withDefaults` object default values not using factory functions
- [ ] Directly mutating props instead of emitting events
- [ ] `watchEffect` with unclear dependencies causing over-triggering
**Full guide:** [Vue 3 Review Guide](vue.md)
## Python
- [ ] Mutable default arguments (`def f(x=[])`)
- [ ] Bare `except:` catching `KeyboardInterrupt` and `SystemExit`
- [ ] Shared mutable class attributes (`class C: items = []`)
- [ ] Using `is` instead of `==` for value comparison
- [ ] Forgetting `self` parameter in methods
- [ ] Modifying list while iterating
- [ ] String concatenation in loops (use `"".join()`)
- [ ] Not closing files (use `with` statement)
- [ ] Missing type annotations on public functions
**Full guide:** [Python Review Guide](python.md)
## Rust
**Ownership & Borrowing:**
- [ ] Unnecessary `clone()` to work around borrow checker
- [ ] `Arc<Mutex<T>>` when single-owner would suffice
- [ ] Storing borrows in structs when owned data is simpler
- [ ] Unnecessary `RefCell` (runtime checks vs compile-time)
**Unsafe Code:**
- [ ] `unsafe` block without `SAFETY:` comment explaining invariants
- [ ] `unsafe fn` without `# Safety` doc section
- [ ] Unsafe invariants split across modules
**Async & Concurrency:**
- [ ] Blocking in async context (`std::fs`, `std::thread::sleep`)
- [ ] Holding `std::sync::Mutex` across `.await`
- [ ] Spawned task missing `'static` lifetime bound
- [ ] Dropping a Future without awaiting (forgotten work)
**Error Handling:**
- [ ] `unwrap()`/`expect()` in production code
- [ ] Library using `anyhow` instead of `thiserror` (callers can't match)
- [ ] Swallowing error context (`map_err(|_| ...)`)
- [ ] Ignoring `must_use` return values
**Performance:**
- [ ] Unnecessary `.collect()` — prefer lazy iterators
- [ ] String concatenation in loops without `with_capacity`
- [ ] `Box<dyn Trait>` when `impl Trait` would work
**Full guide:** [Rust Review Guide](rust.md)
## Go
- [ ] Ignoring errors (`result, _ := SomeFunction()`)
- [ ] Goroutine with no exit mechanism (leak)
- [ ] Missing or incorrect `context.Context` propagation
- [ ] Loop variable capture issue (Go < 1.22)
- [ ] `defer` in loops (deferred until function, not loop iteration)
- [ ] Variable shadowing
- [ ] Map used before initialization
- [ ] Error wrapping with `%v` instead of `%w` (breaks `errors.Is`/`errors.As`)
**Full guide:** [Go Review Guide](go.md)
## Java / Spring Boot
- [ ] POJO/DTO with manual boilerplate instead of `record`
- [ ] Traditional switch missing `break` (use switch expressions)
- [ ] Field injection instead of constructor injection
- [ ] JPA N+1 query (missing `fetch join` or `@EntityGraph`)
- [ ] Incorrect `equals`/`hashCode` on JPA entities (use business key, not ID)
- [ ] `Optional.get()` without `isPresent()` check
- [ ] Stream operations with side effects
**Full guide:** [Java Review Guide](java.md)
## PHP
- [ ] Missing `declare(strict_types=1);` in new files
- [ ] Weak comparison (`==`, `!=`) in auth, token, payment, or state logic
- [ ] `in_array()` / `array_search()` used without strict mode
- [ ] SQL built with string concatenation instead of prepared statements
- [ ] User input echoed without context-aware escaping
- [ ] Passwords stored with `md5()` / `sha1()` instead of `password_hash()`
- [ ] Untrusted data passed to `unserialize()`
- [ ] PHP 8.2+ dynamic properties used instead of declared properties
- [ ] Errors hidden with `@` or swallowed in empty `catch` blocks
- [ ] File uploads using client-provided names or missing MIME/size validation
**Full guide:** [PHP Review Guide](php.md)
## Swift
- [ ] Force-unwrap (`!`) or `try!` where safe unwrapping is possible
- [ ] Closure capturing `self` strongly without `[weak self]` (retain cycle)
- [ ] Reference type (`class`) used where a value type (`struct`) is intended
- [ ] Errors swallowed instead of propagated via `throws` / `Result`
- [ ] Data race across concurrency boundaries (missing `Sendable`, `@MainActor`, actor isolation)
- [ ] Fire-and-forget `Task {}` that is never cancelled or leaks
- [ ] `@ObservedObject` used where `@StateObject` is required for ownership
- [ ] Implicitly unwrapped optional (`var x: T!`) outside IBOutlets
- [ ] Over-broad access control (`public` / `open` where `internal` suffices)
**Full guide:** [Swift Review Guide](swift.md)
## C
- [ ] Pointer/buffer overflow or underflow
- [ ] Undefined behavior (use-after-free, double-free, null deref)
- [ ] Missing error handling after allocation (`malloc` can return `NULL`)
- [ ] Integer overflow in size calculations
- [ ] Resource leaks (missing `free`, `fclose`, etc.)
- [ ] Missing `static` on file-local functions/variables
**Full guide:** [C Review Guide](c.md)
## C++
- [ ] Missing RAII wrapper for resources
- [ ] Violating Rule of 0/3/5 (destructor, copy, move)
- [ ] Exception safety issues (no `noexcept` where applicable)
- [ ] Dangling references from returned iterators or references
- [ ] Unnecessary copies (missing `std::move` or pass-by-reference)
**Full guide:** [C++ Review Guide](cpp.md)
## SQL
- [ ] String concatenation for queries (SQL injection risk) — use parameterized queries
- [ ] Missing indexes on filtered/joined columns
- [ ] `SELECT *` instead of specific columns
- [ ] N+1 query patterns
- [ ] Missing `LIMIT` on large tables
- [ ] Not handling `NULL` comparisons correctly (`IS NULL` vs `= NULL`)
- [ ] Missing transactions for related operations
- [ ] Incorrect JOIN types
- [ ] Collation / case sensitivity surprises across databases (MySQL vs Postgres defaults)
- [ ] Date and timezone handling errors (naive timestamps, server-local `NOW()`, DST)
**See also:** [Security Review Guide](security-review-guide.md) for SQL injection prevention
## API Design
- [ ] Inconsistent resource naming
- [ ] Wrong HTTP methods (POST for idempotent operations)
- [ ] Missing pagination for list endpoints
- [ ] Incorrect status codes
- [ ] Missing rate limiting
- [ ] Missing input validation and sanitization
- [ ] Trusting client-side validation only
## Testing
- [ ] Testing implementation details instead of behavior
- [ ] Missing edge case tests
- [ ] Flaky tests (non-deterministic)
- [ ] Tests with external dependencies (no mocks)
- [ ] Missing negative tests (error cases)
- [ ] Overly complex test setup
+385
View File
@@ -0,0 +1,385 @@
# C++ Code Review Guide
> C++ code review guide focused on memory safety, lifetime, API design, and performance. Examples assume C++17/20.
## Table of Contents
- [Ownership and RAII](#ownership-and-raii)
- [Lifetime and References](#lifetime-and-references)
- [Copy and Move Semantics](#copy-and-move-semantics)
- [Const-Correctness and API Design](#const-correctness-and-api-design)
- [Error Handling and Exception Safety](#error-handling-and-exception-safety)
- [Concurrency](#concurrency)
- [Performance and Allocation](#performance-and-allocation)
- [Templates and Type Safety](#templates-and-type-safety)
- [Tooling and Build Checks](#tooling-and-build-checks)
- [Review Checklist](#review-checklist)
---
## Ownership and RAII
### Prefer RAII and smart pointers
Use RAII to express ownership. Default to `std::unique_ptr`, use `std::shared_ptr` only for shared lifetime.
```cpp
// ❌ Bad: manual new/delete with early returns
Foo* make_foo() {
Foo* foo = new Foo();
if (!foo->Init()) {
delete foo;
return nullptr;
}
return foo;
}
// ✅ Good: RAII with unique_ptr
std::unique_ptr<Foo> make_foo() {
auto foo = std::make_unique<Foo>();
if (!foo->Init()) {
return {};
}
return foo;
}
```
### Wrap C resources
```cpp
// ✅ Good: wrap FILE* with unique_ptr
using FilePtr = std::unique_ptr<FILE, decltype(&fclose)>;
FilePtr open_file(const char* path) {
return FilePtr(fopen(path, "rb"), &fclose);
}
```
---
## Lifetime and References
### Avoid dangling references and views
`std::string_view` and `std::span` do not own data. Make sure the owner outlives the view.
```cpp
// ❌ Bad: returning string_view to a temporary
std::string_view bad_view() {
std::string s = make_name();
return s; // dangling
}
// ✅ Good: return owning string
std::string good_name() {
return make_name();
}
// ✅ Good: view tied to caller-owned data
std::string_view good_view(const std::string& s) {
return s;
}
```
### Lambda captures
```cpp
// ❌ Bad: capture reference that escapes
std::function<void()> make_task() {
int value = 42;
return [&]() { use(value); }; // dangling
}
// ✅ Good: capture by value
std::function<void()> make_task() {
int value = 42;
return [value]() { use(value); };
}
```
---
## Copy and Move Semantics
### Rule of 0/3/5
Prefer the Rule of 0 by using RAII types. If you own a resource, define or delete copy and move operations.
```cpp
// ❌ Bad: raw ownership with default copy
struct Buffer {
int* data;
size_t size;
explicit Buffer(size_t n) : data(new int[n]), size(n) {}
~Buffer() { delete[] data; }
// copy ctor/assign are implicitly generated -> double delete
};
// ✅ Good: Rule of 0 with std::vector
struct Buffer {
std::vector<int> data;
explicit Buffer(size_t n) : data(n) {}
};
```
### Delete unwanted copies
```cpp
struct Socket {
Socket() = default;
~Socket() { close(); }
Socket(const Socket&) = delete;
Socket& operator=(const Socket&) = delete;
Socket(Socket&&) noexcept = default;
Socket& operator=(Socket&&) noexcept = default;
};
```
---
## Const-Correctness and API Design
### Use const and explicit
```cpp
class User {
public:
const std::string& name() const { return name_; }
void set_name(std::string name) { name_ = std::move(name); }
private:
std::string name_;
};
struct Millis {
explicit Millis(int v) : value(v) {}
int value;
};
```
### Avoid object slicing
```cpp
struct Shape { virtual ~Shape() = default; };
struct Circle : Shape { void draw() const; };
// ❌ Bad: slices Circle into Shape
void draw(Shape shape);
// ✅ Good: pass by reference
void draw(const Shape& shape);
```
### Use override and final
```cpp
struct Base {
virtual void run() = 0;
};
struct Worker final : Base {
void run() override {}
};
```
---
## Error Handling and Exception Safety
### Prefer RAII for cleanup
```cpp
// ✅ Good: RAII handles cleanup on exceptions
void process() {
std::vector<int> data = load_data(); // safe cleanup
do_work(data);
}
```
### Do not throw from destructors
```cpp
struct File {
~File() noexcept { close(); }
void close();
};
```
### Use expected results for normal failures
```cpp
// ✅ Expected error: use optional or expected
std::optional<int> parse_int(const std::string& s) {
try {
return std::stoi(s);
} catch (...) {
return std::nullopt;
}
}
```
---
## Concurrency
### Protect shared data
```cpp
// ❌ Bad: data race
int counter = 0;
void inc() { counter++; }
// ✅ Good: atomic
std::atomic<int> counter{0};
void inc() { counter.fetch_add(1, std::memory_order_relaxed); }
```
### Use RAII locks
```cpp
std::mutex mu;
std::vector<int> data;
void add(int v) {
std::lock_guard<std::mutex> lock(mu);
data.push_back(v);
}
```
---
## Performance and Allocation
### Avoid repeated allocations
```cpp
// ❌ Bad: repeated reallocation
std::vector<int> build(int n) {
std::vector<int> out;
for (int i = 0; i < n; ++i) {
out.push_back(i);
}
return out;
}
// ✅ Good: reserve upfront
std::vector<int> build(int n) {
std::vector<int> out;
out.reserve(static_cast<size_t>(n));
for (int i = 0; i < n; ++i) {
out.push_back(i);
}
return out;
}
```
### String concatenation
```cpp
// ❌ Bad: repeated allocation
std::string join(const std::vector<std::string>& parts) {
std::string out;
for (const auto& p : parts) {
out += p;
}
return out;
}
// ✅ Good: reserve total size
std::string join(const std::vector<std::string>& parts) {
size_t total = 0;
for (const auto& p : parts) {
total += p.size();
}
std::string out;
out.reserve(total);
for (const auto& p : parts) {
out += p;
}
return out;
}
```
---
## Templates and Type Safety
### Prefer constrained templates (C++20)
```cpp
// ❌ Bad: overly generic
template <typename T>
T add(T a, T b) {
return a + b;
}
// ✅ Good: constrained
template <typename T>
requires std::is_integral_v<T>
T add(T a, T b) {
return a + b;
}
```
### Use static_assert for invariants
```cpp
template <typename T>
struct Packet {
static_assert(std::is_trivially_copyable_v<T>,
"Packet payload must be trivially copyable");
T payload;
};
```
---
## Tooling and Build Checks
```bash
# Warnings
clang++ -Wall -Wextra -Werror -Wconversion -Wshadow -std=c++20 ...
# Sanitizers (debug builds)
clang++ -fsanitize=address,undefined -fno-omit-frame-pointer -g ...
clang++ -fsanitize=thread -fno-omit-frame-pointer -g ...
# Static analysis
clang-tidy src/*.cpp -- -std=c++20
# Formatting
clang-format -i src/*.cpp include/*.h
```
---
## Review Checklist
### Safety and Lifetime
- [ ] Ownership is explicit (RAII, unique_ptr by default)
- [ ] No dangling references or views
- [ ] Rule of 0/3/5 followed for resource-owning types
- [ ] No raw new/delete in business logic
- [ ] Destructors are noexcept and do not throw
### API and Design
- [ ] const-correctness is applied consistently
- [ ] Constructors are explicit where needed
- [ ] Override/final used for virtual functions
- [ ] No object slicing (pass by ref or pointer)
### Concurrency
- [ ] Shared data is protected (mutex or atomics)
- [ ] Locking order is consistent
- [ ] No blocking while holding locks
### Performance
- [ ] Unnecessary allocations avoided (reserve, move)
- [ ] Copies avoided in hot paths
- [ ] Algorithmic complexity is reasonable
### Tooling and Tests
- [ ] Builds clean with warnings enabled
- [ ] Sanitizers run on critical code paths
- [ ] Static analysis (clang-tidy) results are addressed
+521
View File
@@ -0,0 +1,521 @@
# C# / .NET Code Review Guide
> C# / .NET 8 代码审查指南,覆盖 C# 12 新特性、异步编程、EF Core 性能、ASP.NET Core 最佳实践、依赖注入、LINQ 等核心主题。
## 目录
- [C# 12 新特性](#c-12-新特性)
- [异步编程](#异步编程)
- [EF Core 性能](#ef-core-性能)
- [ASP.NET Core 最佳实践](#aspnet-core-最佳实践)
- [依赖注入](#依赖注入)
- [LINQ 最佳实践](#linq-最佳实践)
- [Review Checklist](#review-checklist)
---
## C# 12 新特性
### Primary Constructors(非 record 类型)
```csharp
// ❌ 样板代码过多的传统构造函数
public class ProductService
{
private readonly ProductDbContext _db;
private readonly ILogger<ProductService> _logger;
public ProductService(ProductDbContext db, ILogger<ProductService> logger)
{
_db = db;
_logger = logger;
}
}
// ✅ Primary Constructor——简洁的依赖注入
public class ProductService(ProductDbContext db, ILogger<ProductService> logger)
{
public async Task<Product?> GetAsync(int id)
=> await db.Products.FindAsync(id);
}
// ⚠️ 注意:primary constructor 参数不是属性,不能被重新赋值
// ⚠️ 如果需要长期存储,显式声明字段
public class OrderService(OrderDbContext db)
{
private readonly OrderDbContext _db = db; // 显式捕获
}
```
### Collection Expressions
```csharp
// ❌ 传统集合初始化
int[] nums = new int[] { 1, 2, 3 };
List<string> names = new List<string> { "alice", "bob" };
// ✅ 集合表达式
int[] nums = [1, 2, 3];
List<string> names = ["alice", "bob"];
Span<char> span = ['a', 'b'];
// ✅ 展开运算符
int[] merged = [..nums, 4, 5];
```
### Default Lambda Parameters
```csharp
// ❌ 重载 lambda
var add = (int a, int b) => a + b;
var addDefault = (int a) => a + 1;
// ✅ 默认参数
var add = (int a, int b = 1) => a + b;
```
---
## 异步编程
### Task.Wait() / .Result / async void 是严重反模式
```csharp
// ❌ Task.Wait() —— 死锁风险(同步阻塞异步操作)
public ActionResult<Data> Get(int id)
{
var data = _service.GetDataAsync(id).Result; // 死锁!
return Ok(data);
}
// ❌ async void —— 异常无法捕获,会崩溃进程
public async void HandleEvent()
{
await _service.ProcessAsync(); // 异常直接崩溃
}
// ✅ async Task —— 全链路异步
public async Task<ActionResult<Data>> Get(int id)
{
var data = await _service.GetDataAsync(id);
return Ok(data);
}
```
### ConfigureAwait(false) 用于库代码
```csharp
// ❌ 库代码不必要地捕获 SynchronizationContext
public class LibraryService
{
public async Task<string> GetDataAsync()
{
var response = await _httpClient.GetAsync("/api/data");
return await response.Content.ReadAsStringAsync();
}
}
// ✅ 库代码使用 ConfigureAwait(false) 避免死锁
public class LibraryService
{
public async Task<string> GetDataAsync()
{
var response = await _httpClient.GetAsync("/api/data").ConfigureAwait(false);
return await response.Content.ReadAsStringAsync().ConfigureAwait(false);
}
}
```
### CancellationToken 传播
```csharp
// ❌ 丢弃 CancellationToken
public async Task<List<User>> SearchAsync(string query)
{
return await _db.Users.Where(u => u.Name.Contains(query)).ToListAsync();
}
// ✅ 全链路传递 CancellationToken
public async Task<List<User>> SearchAsync(string query, CancellationToken ct = default)
{
return await _db.Users
.Where(u => u.Name.Contains(query))
.ToListAsync(ct);
}
```
### Async Disposal
```csharp
// ❌ 同步 dispose 异步资源
public class DataClient : IDisposable
{
public void Dispose()
{
_httpClient.Dispose(); // 可能丢弃正在进行的请求
}
}
// ✅ IAsyncDisposable
public class DataClient : IAsyncDisposable
{
public async ValueTask DisposeAsync()
{
await _stream.DisposeAsync();
}
}
// ✅ 调用方使用 await using
await using var client = new DataClient();
```
---
## EF Core 性能
### N+1 查询问题
```csharp
// ❌ 经典 N+1——每个 Blog 触发一次查询获取 Posts
foreach (var blog in await context.Blogs.ToListAsync())
{
foreach (var post in blog.Posts) // 每次循环都查询数据库!
{
Console.WriteLine(post.Title);
}
}
// ✅ Eager Loading + 投影
await foreach (var blog in context.Blogs
.Select(b => new { b.Url, b.Posts })
.AsAsyncEnumerable())
{
foreach (var post in blog.Posts)
Console.WriteLine(post.Title);
}
```
### 过度获取(不投影)
```csharp
// ❌ 加载所有列——只需要 Url 时加载了全部字段
var urls = await context.Blogs.ToListAsync();
// ✅ 只投影需要的字段
var urls = await context.Blogs
.Select(b => b.Url)
.ToListAsync();
```
### 缺少分页
```csharp
// ❌ 无界结果集
var posts = await context.Posts
.Where(p => p.Title.StartsWith("A"))
.ToListAsync(); // 可能有百万条记录!
// ✅ 限制结果数量
var posts = await context.Posts
.Where(p => p.Title.StartsWith("A"))
.OrderBy(p => p.Id)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
```
### Cartesian Explosion(JOIN 笛卡尔爆炸)
```csharp
// ❌ 多个 Include 创建大量重复数据
var blogs = await context.Blogs
.Include(b => b.Posts)
.Include(b => b.Tags)
.ToListAsync(); // 每行重复 Blog 数据
// ✅ 使用 AsSplitQuery 拆分查询
var blogs = await context.Blogs
.Include(b => b.Posts)
.Include(b => b.Tags)
.AsSplitQuery()
.ToListAsync();
```
### 只读场景缺少 AsNoTracking
```csharp
// ❌ 默认跟踪——只读查询也付出跟踪开销
var products = await context.Products.ToListAsync();
// ✅ AsNoTracking——跳过变更跟踪,更快且更省内存
var products = await context.Products
.AsNoTracking()
.ToListAsync();
```
### 列上函数阻止索引使用
```csharp
// ✅ 可以使用索引——sargable
var posts1 = await context.Posts
.Where(p => p.Title.StartsWith("A"))
.ToListAsync();
// ❌ 无法使用索引——全表扫描
var posts2 = await context.Posts
.Where(p => p.Title.EndsWith("A"))
.ToListAsync();
// ❌ 列上套函数——全表扫描
var posts3 = await context.Posts
.Where(p => p.Title.ToLower() == "foo")
.ToListAsync();
```
### 同步 vs 异步数据库访问
```csharp
// ❌ 同步数据库调用——阻塞线程
var products = context.Products.ToList();
context.SaveChanges();
// ✅ 异步数据库调用
var products = await context.Products.ToListAsync();
await context.SaveChangesAsync();
```
---
## ASP.NET Core 最佳实践
### HttpClient 误用
```csharp
// ❌ 每次请求创建新的 HttpClient——socket 耗尽
using var client = new HttpClient();
var response = await client.GetAsync("https://api.example.com/data");
// ✅ IHttpClientFactory 注入
public class MyService
{
private readonly HttpClient _client;
public MyService(HttpClient client) => _client = client; // 从工厂注入
}
```
### HttpContext 在后台线程中使用
```csharp
// ❌ 在后台任务中捕获 scoped 服务——请求结束后已释放
_ = Task.Run(async () =>
{
await context.SaveChangesAsync(); // ObjectDisposedException!
});
// ✅ 创建新的 scope
_ = Task.Run(async () =>
{
await using var scope = serviceScopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.SaveChangesAsync();
});
```
### Request.Form 同步访问
```csharp
// ❌ 同步读取 Form——sync over async
var form = HttpContext.Request.Form;
// ✅ 异步读取
var form = await HttpContext.Request.ReadFormAsync();
```
### 异常用于控制流
```csharp
// ❌ 用异常判断是否存在——异常开销大,比直接检查慢得多
try
{
var user = await _db.Users.FirstAsync(u => u.Id == id);
}
catch (InvalidOperationException)
{
return NotFound();
}
// ✅ 使用检查而非异常
var user = await _db.Users.FirstOrDefaultAsync(u => u.Id == id);
if (user is null) return NotFound();
```
### 响应头在 Body 之后设置
```csharp
// ❌ body 已发送后再设置 header——抛异常
await next(context);
context.Response.Headers["X-Custom"] = "value"; // 可能抛异常!
// ✅ 使用 OnStarting 回调
context.Response.OnStarting(() =>
{
context.Response.Headers["X-Custom"] = "value";
return Task.CompletedTask;
});
await next(context);
```
---
## 依赖注入
### Scoped 服务注入 Singleton
```csharp
// ❌ Scoped 服务注入 Singleton——生命周期不匹配
services.AddSingleton<BackgroundWorker>();
services.AddScoped<IUserRepository, UserRepository>();
// BackgroundWorker 是 Singleton,UserRepository 是 Scoped
// → UserRepository 在多个请求间共享或已释放
// ✅ 在 Singleton 中通过 IServiceProvider 创建 scope
public class BackgroundWorker : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
public BackgroundWorker(IServiceScopeFactory scopeFactory)
=> _scopeFactory = scopeFactory;
protected override async Task ExecuteAsync(CancellationToken ct)
{
await using var scope = _scopeFactory.CreateAsyncScope();
var repo = scope.ServiceProvider.GetRequiredService<IUserRepository>();
}
}
```
---
## LINQ 最佳实践
### ToList 之后再 LINQ
```csharp
// ❌ 先 ToList 再过滤——全表加载到内存
var results = context.Posts
.Where(p => p.Title.StartsWith("A"))
.ToList()
.Where(p => SomeClientFilter(p)); // 客户端过滤,已加载全部行
// ✅ 尽可能让数据库执行过滤
var results = await context.Posts
.Where(p => p.Title.StartsWith("A") && SomeDbFilter(p))
.AsAsyncEnumerable()
.Where(p => SomeClientFilter(p)) // 只过滤数据库返回的行
.ToListAsync();
```
### Count() vs Any()
```csharp
// ❌ Count() 执行完整查询
if (context.Users.Count() > 0) { /* ... */ }
// ✅ Any() 更高效——遇到第一条记录就返回
if (await context.Users.AnyAsync()) { /* ... */ }
```
### 多次枚举 IEnumerable
```csharp
// ❌ IEnumerable 被枚举两次
public void Process(IEnumerable<int> numbers)
{
if (numbers.Any()) // 第一次枚举
{
foreach (var n in numbers) // 第二次枚举(可能是重新查询)
{
Console.WriteLine(n);
}
}
}
// ✅ 如果需要多次使用,先物化
public void Process(IEnumerable<int> numbers)
{
var list = numbers.ToList(); // 只枚举一次
if (list.Any())
{
foreach (var n in list)
{
Console.WriteLine(n);
}
}
}
```
### Select 中的副作用
```csharp
// ❌ Select 中执行副作用——不可预测的执行时机
var results = users.Select(u =>
{
_logger.LogInformation($"Processing {u.Name}"); // 副作用!
return u.Email;
}).ToList();
// ✅ 副作用放在 foreach 中
foreach (var user in users)
{
_logger.LogInformation("Processing {Name}", user.Name);
}
var results = users.Select(u => u.Email).ToList();
```
---
## Review Checklist
### C# 12 新特性
- [ ] Primary constructor 参数不被重新赋值
- [ ] 集合表达式语法一致(不混用新旧风格)
### 异步编程
- [ ] 无 `Task.Wait()`、`.Result`、`async void`
- [ ] 库代码使用 `ConfigureAwait(false)`
- [ ] `CancellationToken` 全链路传递
- [ ] 异步资源使用 `IAsyncDisposable` / `await using`
- [ ] 不混用同步和异步数据访问
### EF Core
- [ ] 无 N+1 查询(导航属性在循环中访问)
- [ ] 投影 `Select()` 避免过度获取
- [ ] 分页:`ToListAsync()` 前有 `Take()`/`Skip()`
- [ ] 多个 `Include()` 使用 `AsSplitQuery()`
- [ ] 只读查询使用 `AsNoTracking()`
- [ ] 列上无函数调用阻止索引使用
- [ ] 数据库调用全部异步
### ASP.NET Core
- [ ] HttpClient 通过 `IHttpClientFactory` 获取
- [ ] 后台任务中不直接使用 scoped 服务
- [ ] 使用 `ReadFormAsync` 代替 `Request.Form`
- [ ] 异常不用于控制流
- [ ] 响应头通过 `OnStarting` 设置
### 依赖注入
- [ ] Scoped 服务不注入 Singleton
- [ ] 后台任务创建新 scope
### LINQ
- [ ] 无不必要的 `ToList()` 后再 LINQ
- [ ] `Any()` 代替 `Count() > 0`
- [ ] IEnumerable 不被多次枚举(或先物化)
- [ ] Select 中无副作用
+661
View File
@@ -0,0 +1,661 @@
# CSS / Less / Sass Review Guide
CSS 及预处理器代码审查指南,覆盖性能、可维护性、响应式设计和浏览器兼容性。
## CSS 变量 vs 硬编码
### 应该使用变量的场景
```css
/* ❌ 硬编码 - 难以维护 */
.button {
background: #3b82f6;
border-radius: 8px;
}
.card {
border: 1px solid #3b82f6;
border-radius: 8px;
}
/* ✅ 使用 CSS 变量 */
:root {
--color-primary: #3b82f6;
--radius-md: 8px;
}
.button {
background: var(--color-primary);
border-radius: var(--radius-md);
}
.card {
border: 1px solid var(--color-primary);
border-radius: var(--radius-md);
}
```
### 变量命名规范
```css
/* 推荐的变量分类 */
:root {
/* 颜色 */
--color-primary: #3b82f6;
--color-primary-hover: #2563eb;
--color-text: #1f2937;
--color-text-muted: #6b7280;
--color-bg: #ffffff;
--color-border: #e5e7eb;
/* 间距 */
--spacing-xs: 4px;
--spacing-sm: 8px;
--spacing-md: 16px;
--spacing-lg: 24px;
--spacing-xl: 32px;
/* 字体 */
--font-size-sm: 14px;
--font-size-base: 16px;
--font-size-lg: 18px;
--font-weight-normal: 400;
--font-weight-bold: 700;
/* 圆角 */
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-full: 9999px;
/* 阴影 */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
/* 过渡 */
--transition-fast: 150ms ease;
--transition-normal: 300ms ease;
}
```
### 变量作用域建议
```css
/* ✅ 组件级变量 - 减少全局污染 */
.card {
--card-padding: var(--spacing-md);
--card-radius: var(--radius-md);
padding: var(--card-padding);
border-radius: var(--card-radius);
}
/* ⚠️ 避免频繁用 JS 动态修改变量 - 影响性能 */
```
### 审查清单
- [ ] 颜色值是否使用变量?
- [ ] 间距是否来自设计系统?
- [ ] 重复值是否提取为变量?
- [ ] 变量命名是否语义化?
---
## !important 使用规范
### 何时可以使用
```css
/* ✅ 工具类 - 明确需要覆盖 */
.hidden { display: none !important; }
.sr-only { position: absolute !important; }
/* ✅ 覆盖第三方库样式(无法修改源码时) */
.third-party-modal {
z-index: 9999 !important;
}
/* ✅ 打印样式 */
@media print {
.no-print { display: none !important; }
}
```
### 何时禁止使用
```css
/* ❌ 解决特异性问题 - 应该重构选择器 */
.button {
background: blue !important; /* 为什么需要 !important? */
}
/* ❌ 覆盖自己写的样式 */
.card { padding: 20px; }
.card { padding: 30px !important; } /* 直接修改原规则 */
/* ❌ 在组件样式中 */
.my-component .title {
font-size: 24px !important; /* 破坏组件封装 */
}
```
### 替代方案
```css
/* 问题:需要覆盖 .btn 的样式 */
/* ❌ 使用 !important */
.my-btn {
background: red !important;
}
/* ✅ 提高特异性 */
button.my-btn {
background: red;
}
/* ✅ 使用更具体的选择器 */
.container .my-btn {
background: red;
}
/* ✅ 使用 :where() 降低被覆盖样式的特异性 */
:where(.btn) {
background: blue; /* 特异性为 0 */
}
.my-btn {
background: red; /* 可以正常覆盖 */
}
```
### 审查问题
```markdown
🔴 [blocking] "发现 15 处 !important,请说明每处的必要性"
🟡 [important] "这个 !important 可以通过调整选择器特异性来解决"
💡 [suggestion] "考虑使用 CSS Layers (@layer) 来管理样式优先级"
```
---
## 性能考虑
### 🔴 高危性能问题
#### 1. `transition: all` 问题
```css
/* ❌ 性能杀手 - 浏览器检查所有可动画属性 */
.button {
transition: all 0.3s ease;
}
/* ✅ 明确指定属性 */
.button {
transition: background-color 0.3s ease, transform 0.3s ease;
}
/* ✅ 多属性时使用变量 */
.button {
--transition-duration: 0.3s;
transition:
background-color var(--transition-duration) ease,
box-shadow var(--transition-duration) ease,
transform var(--transition-duration) ease;
}
```
#### 2. box-shadow 动画
```css
/* ❌ 每帧触发重绘 - 严重影响性能 */
.card {
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
transition: box-shadow 0.3s ease;
}
.card:hover {
box-shadow: 0 8px 16px rgba(0,0,0,0.2);
}
/* ✅ 使用伪元素 + opacity */
.card {
position: relative;
}
.card::after {
content: '';
position: absolute;
inset: 0;
box-shadow: 0 8px 16px rgba(0,0,0,0.2);
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
border-radius: inherit;
}
.card:hover::after {
opacity: 1;
}
```
#### 3. 触发布局(Reflow)的属性
```css
/* ❌ 动画这些属性会触发布局重计算 */
.bad-animation {
transition: width 0.3s, height 0.3s, top 0.3s, left 0.3s, margin 0.3s;
}
/* ✅ 只动画 transform 和 opacity(仅触发合成) */
.good-animation {
transition: transform 0.3s, opacity 0.3s;
}
/* 位移用 translate 代替 top/left */
.move {
transform: translateX(100px); /* ✅ */
/* left: 100px; */ /* ❌ */
}
/* 缩放用 scale 代替 width/height */
.grow {
transform: scale(1.1); /* ✅ */
/* width: 110%; */ /* ❌ */
}
```
### 🟡 中等性能问题
#### 复杂选择器
```css
/* ❌ 过深的嵌套 - 选择器匹配慢 */
.page .container .content .article .section .paragraph span {
color: red;
}
/* ✅ 扁平化 */
.article-text {
color: red;
}
/* ❌ 通配符选择器 */
* { box-sizing: border-box; } /* 影响所有元素 */
[class*="icon-"] { display: inline; } /* 属性选择器较慢 */
/* ✅ 限制范围 */
.icon-box * { box-sizing: border-box; }
```
#### 大量阴影和滤镜
```css
/* ⚠️ 复杂阴影影响渲染性能 */
.heavy-shadow {
box-shadow:
0 1px 2px rgba(0,0,0,0.1),
0 2px 4px rgba(0,0,0,0.1),
0 4px 8px rgba(0,0,0,0.1),
0 8px 16px rgba(0,0,0,0.1),
0 16px 32px rgba(0,0,0,0.1); /* 5 层阴影 */
}
/* ⚠️ 滤镜消耗 GPU */
.blur-heavy {
filter: blur(20px) brightness(1.2) contrast(1.1);
backdrop-filter: blur(10px); /* 更消耗性能 */
}
```
### 性能优化建议
```css
/* 使用 will-change 提示浏览器(谨慎使用) */
.animated-element {
will-change: transform, opacity;
}
/* 动画完成后移除 will-change */
.animated-element.idle {
will-change: auto;
}
/* 使用 contain 限制重绘范围 */
.card {
contain: layout paint; /* 告诉浏览器内部变化不影响外部 */
}
```
### 审查清单
- [ ] 是否使用 `transition: all`?
- [ ] 是否动画 width/height/top/left?
- [ ] box-shadow 是否被动画?
- [ ] 选择器嵌套是否超过 3 层?
- [ ] 是否有不必要的 `will-change`?
---
## 响应式设计检查点
### Mobile First 原则
```css
/* ✅ Mobile First - 基础样式针对移动端 */
.container {
padding: 16px;
display: flex;
flex-direction: column;
}
/* 逐步增强 */
@media (min-width: 768px) {
.container {
padding: 24px;
flex-direction: row;
}
}
@media (min-width: 1024px) {
.container {
padding: 32px;
max-width: 1200px;
margin: 0 auto;
}
}
/* ❌ Desktop First - 需要覆盖更多样式 */
.container {
max-width: 1200px;
padding: 32px;
flex-direction: row;
}
@media (max-width: 1023px) {
.container {
padding: 24px;
}
}
@media (max-width: 767px) {
.container {
padding: 16px;
flex-direction: column;
max-width: none;
}
}
```
### 断点建议
```css
/* 推荐断点(基于内容而非设备) */
:root {
--breakpoint-sm: 640px; /* 大手机 */
--breakpoint-md: 768px; /* 平板竖屏 */
--breakpoint-lg: 1024px; /* 平板横屏/小笔记本 */
--breakpoint-xl: 1280px; /* 桌面 */
--breakpoint-2xl: 1536px; /* 大桌面 */
}
/* 使用示例 */
@media (min-width: 768px) { /* md */ }
@media (min-width: 1024px) { /* lg */ }
```
### 响应式审查清单
- [ ] 是否采用 Mobile First?
- [ ] 断点是否基于内容断裂点而非设备?
- [ ] 是否避免断点重叠?
- [ ] 文字是否使用相对单位(rem/em)?
- [ ] 触摸目标是否足够大(≥44px)?
- [ ] 是否测试了横竖屏切换?
### 常见问题
```css
/* ❌ 固定宽度 */
.container {
width: 1200px;
}
/* ✅ 最大宽度 + 弹性 */
.container {
width: 100%;
max-width: 1200px;
padding-inline: 16px;
}
/* ❌ 固定高度的文本容器 */
.text-box {
height: 100px; /* 文字可能溢出 */
}
/* ✅ 最小高度 */
.text-box {
min-height: 100px;
}
/* ❌ 小触摸目标 */
.small-button {
padding: 4px 8px; /* 太小,难以点击 */
}
/* ✅ 足够的触摸区域 */
.touch-button {
min-height: 44px;
min-width: 44px;
padding: 12px 16px;
}
```
---
## 浏览器兼容性
### 需要检查的特性
| 特性 | 兼容性 | 建议 |
|------|--------|------|
| CSS Grid | 现代浏览器 ✅ | IE 需要 Autoprefixer + 测试 |
| Flexbox | 广泛支持 ✅ | 旧版需要前缀 |
| CSS Variables | 现代浏览器 ✅ | IE 不支持,需要回退 |
| `gap` (flexbox) | 较新 ⚠️ | Safari 14.1+ |
| `:has()` | 较新 ⚠️ | Firefox 121+ |
| `container queries` | 较新 ⚠️ | 2023 年后的浏览器 |
| `@layer` | 较新 ⚠️ | 检查目标浏览器 |
### 回退策略
```css
/* CSS 变量回退 */
.button {
background: #3b82f6; /* 回退值 */
background: var(--color-primary); /* 现代浏览器 */
}
/* Flexbox gap 回退 */
.flex-container {
display: flex;
gap: 16px;
}
/* 旧浏览器回退 */
.flex-container > * + * {
margin-left: 16px;
}
/* Grid 回退 */
.grid {
display: flex;
flex-wrap: wrap;
}
@supports (display: grid) {
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
}
}
```
### Autoprefixer 配置
```javascript
// postcss.config.js
module.exports = {
plugins: [
require('autoprefixer')({
// 根据 browserslist 配置
grid: 'autoplace', // 启用 Grid 前缀(IE 支持)
flexbox: 'no-2009', // 只用现代 flexbox 语法
}),
],
};
// package.json
{
"browserslist": [
"> 1%",
"last 2 versions",
"not dead",
"not ie 11" // 根据项目需求
]
}
```
### 审查清单
- [ ] 是否检查了 [Can I Use](https://caniuse.com)?
- [ ] 新特性是否有回退方案?
- [ ] 是否配置了 Autoprefixer?
- [ ] browserslist 是否符合项目要求?
- [ ] 是否在目标浏览器中测试?
---
## Less / Sass 特定问题
### 嵌套深度
```scss
/* ❌ 过深嵌套 - 编译后选择器过长 */
.page {
.container {
.content {
.article {
.title {
color: red; // 编译为 .page .container .content .article .title
}
}
}
}
}
/* ✅ 最多 3 层 */
.article {
&__title {
color: red;
}
&__content {
p { margin-bottom: 1em; }
}
}
```
### Mixin vs Extend vs 变量
```scss
@use 'sass:color';
/* 变量 - 用于单个值 */
$primary-color: #3b82f6;
/* Mixin - 用于可配置的代码块 */
@mixin button-variant($bg, $text) {
background: $bg;
color: $text;
&:hover {
// Dart Sass 已弃用全局 darken()/lighten(),改用 color 模块
background: color.adjust($bg, $lightness: -10%);
// color.scale($bg, $lightness: -10%) 按比例调整,深浅过渡更自然
}
}
/* Extend - 用于共享相同样式(谨慎使用) */
%visually-hidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%); /* clip: rect() 已弃用,改用 clip-path */
white-space: nowrap; /* 避免内容被挤成一列后撑开布局 */
}
.sr-only {
@extend %visually-hidden;
}
/* ⚠️ @extend 的问题 */
// 可能产生意外的选择器组合
// 不能在 @media 中使用
// 优先使用 mixin
```
### 审查清单
- [ ] 嵌套是否超过 3 层?
- [ ] 是否滥用 @extend?
- [ ] Mixin 是否过于复杂?
- [ ] 编译后的 CSS 大小是否合理?
---
## 快速审查清单
### 🔴 必须修复
```markdown
□ transition: all
□ 动画 width/height/top/left/margin
□ 大量 !important
□ 硬编码的颜色/间距重复 >3 次
□ 选择器嵌套 >4 层
```
### 🟡 建议修复
```markdown
□ 缺少响应式处理
□ 使用 Desktop First
□ 复杂 box-shadow 被动画
□ 缺少浏览器兼容回退
□ CSS 变量作用域过大
```
### 🟢 优化建议
```markdown
□ 可以使用 CSS Grid 简化布局
□ 可以使用 CSS 变量提取重复值
□ 可以使用 @layer 管理优先级
□ 可以添加 contain 优化性能
```
---
## 工具推荐
| 工具 | 用途 |
|------|------|
| [Stylelint](https://stylelint.io/) | CSS 代码检查 |
| [PurgeCSS](https://purgecss.com/) | 移除未使用 CSS |
| [Autoprefixer](https://autoprefixer.github.io/) | 自动添加前缀 |
| [CSS Stats](https://cssstats.com/) | 分析 CSS 统计 |
| [Can I Use](https://caniuse.com/) | 浏览器兼容性查询 |
---
## 参考资源
- [CSS Performance Optimization - MDN](https://developer.mozilla.org/en-US/docs/Learn_web_development/Extensions/Performance/CSS)
- [What a CSS Code Review Might Look Like - CSS-Tricks](https://css-tricks.com/what-a-css-code-review-might-look-like/)
- [How to Animate Box-Shadow - Tobias Ahlin](https://tobiasahlin.com/blog/how-to-animate-box-shadow/)
- [Media Query Fundamentals - MDN](https://developer.mozilla.org/en-US/docs/Learn_web_development/Core/CSS_layout/Media_queries)
- [Autoprefixer - GitHub](https://github.com/postcss/autoprefixer)
File diff suppressed because it is too large Load Diff
+584
View File
@@ -0,0 +1,584 @@
# FastAPI Code Review Guide
> FastAPI code review guide covering dependency injection (`Depends`), Pydantic v2 validation boundaries, async correctness, database session lifecycle and N+1, security, and a test-driven verification workflow that turns the reviewer's in-process test client into a tool for *proving* bugs rather than guessing at them.
## Table of Contents
- [Dependency Injection (`Depends`)](#dependency-injection-depends)
- [Pydantic v2 Models & Validation](#pydantic-v2-models--validation)
- [Async Correctness](#async-correctness)
- [Database Sessions & N+1](#database-sessions--n1)
- [Security](#security)
- [Test-Driven Verification](#test-driven-verification)
- [Review Checklist](#review-checklist)
- [References](#references)
---
## Dependency Injection (`Depends`)
FastAPI's `Depends` is the seam that keeps routes thin and testable. Most review problems here come from doing real work in the route function instead of behind a dependency.
### Business logic belongs behind a dependency or service, not in the route
```python
# ❌ Bad — DB access, auth, and business rules all inline in the route
@app.get("/orders/{order_id}")
async def get_order(order_id: int):
conn = await asyncpg.connect(DATABASE_URL) # connection created per request
row = await conn.fetchrow("SELECT * FROM orders WHERE id = $1", order_id)
await conn.close()
if row is None:
raise HTTPException(404)
return dict(row)
# ✅ Good — the route declares what it needs; the session is injected and pooled
async def get_session() -> AsyncIterator[AsyncSession]:
async with SessionLocal() as session:
yield session
@app.get("/orders/{order_id}", response_model=OrderOut)
async def get_order(order_id: int, session: AsyncSession = Depends(get_session)):
order = await session.get(Order, order_id)
if order is None:
raise HTTPException(status_code=404, detail="Order not found")
return order
```
The injected version is also the version you can override in tests (see [Test-Driven Verification](#test-driven-verification)).
### `yield` dependencies must clean up, and cleanup runs even on error
```python
# ❌ Bad — no cleanup; the session leaks if the route raises
async def get_session() -> AsyncSession:
return SessionLocal()
# ✅ Good — the context manager closes the session on success AND on exception
async def get_session() -> AsyncIterator[AsyncSession]:
async with SessionLocal() as session:
yield session
```
Review point: confirm any `yield` dependency holding a resource (DB session, file handle, lock) releases it through a context manager or `try/finally`, so an exception in the route does not leak it.
### Don't re-create singletons per request
```python
# ❌ Bad — a new HTTP client (and connection pool) per request
@app.get("/proxy")
async def proxy(client: httpx.AsyncClient = Depends(lambda: httpx.AsyncClient())):
...
# ✅ Good — one client for the app lifetime, injected by reference
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.http = httpx.AsyncClient()
yield
await app.state.http.aclose()
def get_http(request: Request) -> httpx.AsyncClient:
return request.app.state.http
```
### Prefer the `Annotated` form and async dependencies
Since FastAPI 0.95 the idiomatic way to declare a dependency is `Annotated[T, Depends(...)]`, not the default-value form. It is reusable across routes and plays well with type checkers. Also prefer `async def` dependencies: a sync (`def`) dependency runs in the threadpool, which is wasted overhead for a small non-I/O check.
```python
# ⚠️ Older form — still works, but not the current idiom
@app.get("/items")
async def list_items(session: AsyncSession = Depends(get_session)): ...
# ✅ Good — Annotated form; define once, reuse everywhere
SessionDep = Annotated[AsyncSession, Depends(get_session)]
@app.get("/items")
async def list_items(session: SessionDep): ...
```
### Use dependencies to validate existence and permissions — they're cached per request
A dependency is the natural place to answer "does this resource exist and may this caller touch it?" Pydantic validates *shape*; a dependency validates against the database. FastAPI caches each dependency's result within a single request, so chaining small dependencies costs nothing extra and removes duplicated lookups.
```python
# ✅ Good — small dependencies chain; valid_post is resolved once per request
async def valid_post(post_id: int, session: SessionDep) -> Post:
post = await session.get(Post, post_id)
if post is None:
raise HTTPException(status_code=404, detail="Post not found")
return post
async def owned_post(post: Annotated[Post, Depends(valid_post)], user: CurrentUser) -> Post:
if post.owner_id != user.id:
raise HTTPException(status_code=403, detail="Forbidden")
return post
@app.delete("/posts/{post_id}", status_code=204)
async def delete_post(post: Annotated[Post, Depends(owned_post)], session: SessionDep):
await session.delete(post) # existence + ownership already enforced
await session.commit()
```
This is also the cleanest place to fix the auth-vs-authorization bug from the [Security](#security) section: the ownership check moves into a reusable `owned_post` dependency.
---
## Pydantic v2 Models & Validation
### Separate input and output models; never echo the ORM object directly
```python
# ❌ Bad — response_model is the DB model, so hashed_password leaks to the client
@app.post("/users", response_model=UserTable)
async def create_user(user: UserTable): # also accepts client-set id, is_admin...
...
# ✅ Good — distinct schemas draw the trust boundary
class UserCreate(BaseModel):
email: EmailStr
password: str
class UserOut(BaseModel):
id: int
email: EmailStr
model_config = ConfigDict(from_attributes=True) # read from ORM safely
@app.post("/users", response_model=UserOut, status_code=201)
async def create_user(payload: UserCreate, session: AsyncSession = Depends(get_session)):
...
```
`response_model` is a filter, not just documentation — fields absent from the output model are stripped from the response. Reusing the DB model as the response is the most common way sensitive fields leak.
### Use distinct Create and Update schemas
```python
# ❌ Bad — one schema for create and update means every field is required on PATCH
class ItemSchema(BaseModel):
name: str
price: float
# ✅ Good — update is a partial; create requires the full payload
class ItemCreate(BaseModel):
name: str
price: float = Field(gt=0)
class ItemUpdate(BaseModel):
name: str | None = None
price: float | None = Field(default=None, gt=0)
```
### Validate at the boundary, not after the DB write
```python
# ❌ Bad — negative quantity reaches the database before anything checks it
@app.post("/cart")
async def add_to_cart(item_id: int, quantity: int):
await save(item_id, quantity) # quantity = -5 silently accepted
# ✅ Good — the type system rejects it before the handler body runs
class CartLine(BaseModel):
item_id: int
quantity: int = Field(gt=0)
@app.post("/cart")
async def add_to_cart(line: CartLine):
await save(line.item_id, line.quantity)
```
---
## Async Correctness
This is the axis on which FastAPI differs most from Django and Flask, and the one most worth a reviewer's attention. FastAPI's throughput comes from a single event loop interleaving many concurrent requests. That model only holds if the loop is **never blocked**: one synchronous call on the loop stalls *every* in-flight request, not just its own. Get this wrong across the codebase and FastAPI does not just lose its edge — it performs *worse* than a sync framework like Flask, because Flask's worker-per-request model has no shared loop to choke. The reviewer's job is to keep work on the loop genuinely non-blocking and to treat every escape hatch as a cost, not a fix.
### Never call blocking code inside an `async def` route
```python
# ❌ Bad — blocking I/O on the loop freezes ALL concurrent requests, not just this one
@app.get("/report")
async def report():
data = requests.get("https://slow-api.example.com").json() # blocking socket
time.sleep(2) # blocks the loop
return data
# ✅ Good — await a native-async client; the loop serves other requests meanwhile
@app.get("/report")
async def report(client: httpx.AsyncClient = Depends(get_http)):
resp = await client.get("https://slow-api.example.com")
return resp.json()
```
### Prefer native-async SDKs over sync libraries
The right fix for blocking I/O is almost always a library that speaks `async` natively — not wrapping a sync one. Reach for the async client first; the threadpool is the last resort, not the default.
| Sync (blocks the loop) | Native-async replacement |
|------------------------|--------------------------|
| `requests` | `httpx.AsyncClient`, `aiohttp` |
| `psycopg2` (sync) | `asyncpg`, SQLAlchemy async engine |
| `redis-py` (sync) | `redis.asyncio` |
| `pymongo` | `motor` |
| `boto3` | `aioboto3` |
If you find `asyncio.run(...)`, a new event loop, or a manually started thread *inside* a route, that is a red flag — it's an attempt to bolt sync code onto the loop. `asyncio.run()` inside a running loop raises `RuntimeError` outright; the rest quietly burns the performance you adopted FastAPI for.
```python
# ❌ Bad — spinning up a loop/thread to call an async SDK from a sync context
@app.get("/users/{uid}")
def get_user(uid: int):
return asyncio.run(repo.fetch(uid)) # RuntimeError under the running loop
# ✅ Good — let the route be async and await the native client directly
@app.get("/users/{uid}")
async def get_user(uid: int):
return await repo.fetch(uid)
```
### The threadpool is a bounded escape hatch, not a default
A plain `def` route — and `run_in_threadpool(...)` — does not run on the loop; FastAPI runs it in a **bounded** worker threadpool (AnyIO's default cap is 40 threads). For an occasional, genuinely-unavoidable blocking call this is the correct tool:
```python
from fastapi.concurrency import run_in_threadpool
@app.get("/legacy")
async def legacy():
return await run_in_threadpool(blocking_library_call) # only if no async SDK exists
```
But it does not scale the way the loop does. Route every hot path through the threadpool and, under load, all workers block at once; further requests queue behind the cap and throughput collapses. Spawning your own threads or processes to "add concurrency" makes it worse: once live threads exceed the machine's core count, context-switch and GIL contention degrade performance sharply rather than improving it. The escape hatch is for the rare blocking dependency you cannot replace — not a substitute for choosing async SDKs.
Review heuristic: a `def` route is acceptable for a low-traffic endpoint with no async equivalent. A high-traffic endpoint doing blocking work in a `def` route (or via `run_in_threadpool`) is a scaling bug — flag it and ask for an async SDK.
### CPU-bound work belongs in a worker process, not the loop or the threadpool
Neither the event loop nor the threadpool helps CPU-bound work: under the GIL only one thread runs Python bytecode at a time, so a heavy computation blocks just as badly from a threadpool as from the loop. Offload it to a separate process (Celery, Arq, RQ, or `multiprocessing`).
```python
# ❌ Bad — a CPU-heavy job pins a worker; throughput drops for everyone
@app.post("/render")
async def render(doc: Doc):
return heavy_pdf_render(doc) # seconds of pure CPU on the loop
# ✅ Good — enqueue to a worker process; return a job handle
@app.post("/render", status_code=202)
async def render(doc: Doc):
job = await queue.enqueue(heavy_pdf_render, doc)
return {"job_id": job.id}
```
### Don't fire-and-forget unawaited coroutines
```python
# ❌ Bad — coroutine never awaited; the email is never sent (and no error surfaces)
@app.post("/signup")
async def signup(user: UserCreate):
send_welcome_email(user.email) # returns a coroutine, silently dropped
# ✅ Good — defer post-response work with BackgroundTasks
@app.post("/signup")
async def signup(user: UserCreate, tasks: BackgroundTasks):
tasks.add_task(send_welcome_email, user.email)
```
`BackgroundTasks` runs in-process and offers no retries or persistence — use it only for short, fire-and-forget work (send an email, log an event). Anything long-running or retry-critical (data processing, payments) belongs in a real task queue (Celery/Arq/RQ).
---
## Database Sessions & N+1
### One session per request, injected — not a global
```python
# ❌ Bad — a module-level session is shared across concurrent requests (not safe)
session = SessionLocal()
# ✅ Good — request-scoped session via dependency (see get_session above)
@app.get("/items")
async def list_items(session: AsyncSession = Depends(get_session)):
...
```
### Eager-load relationships to avoid N+1
```python
# ❌ Bad — one query for orders, then one query per order for its customer
orders = (await session.execute(select(Order))).scalars().all()
return [{"id": o.id, "customer": o.customer.name} for o in orders] # N+1
# ✅ Good — a single query with the relationship eager-loaded
stmt = select(Order).options(selectinload(Order.customer))
orders = (await session.execute(stmt)).scalars().all()
return [{"id": o.id, "customer": o.customer.name} for o in orders]
```
With async SQLAlchemy, lazy attribute access outside the session often raises instead of silently querying — but the design issue is the same. Look for relationship access inside a loop without an `options(...)` eager load.
### Paginate list endpoints
```python
# ❌ Bad — returns every row; degrades as the table grows
@app.get("/users")
async def list_users(session: AsyncSession = Depends(get_session)):
return (await session.execute(select(User))).scalars().all()
# ✅ Good — bounded page with a sane cap
@app.get("/users", response_model=list[UserOut])
async def list_users(
session: AsyncSession = Depends(get_session),
limit: int = Query(default=50, le=100),
offset: int = Query(default=0, ge=0),
):
stmt = select(User).limit(limit).offset(offset)
return (await session.execute(stmt)).scalars().all()
```
### Aggregate and join in SQL, not in Python
If a handler pulls rows into memory and then loops to group, count, or join them, the database is being used as dumb storage. Push the work down — the database does set operations far faster, and you transfer less data.
```python
# ❌ Bad — fetch every order, then tally per customer in Python
orders = (await session.execute(select(Order))).scalars().all()
totals: dict[int, float] = {}
for o in orders:
totals[o.customer_id] = totals.get(o.customer_id, 0) + o.amount
# ✅ Good — let the database group and sum
stmt = select(Order.customer_id, func.sum(Order.amount)).group_by(Order.customer_id)
totals = dict((await session.execute(stmt)).all())
```
---
## Security
### A declared auth dependency is not an enforced authorization check
This is the highest-value thing to look for. `Depends(get_current_user)` proves *who* the caller is — it does **not** prove they may touch *this* resource.
```python
# ❌ Bad — any authenticated user can delete any other user's document
@app.delete("/documents/{doc_id}")
async def delete_document(
doc_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
doc = await session.get(Document, doc_id)
await session.delete(doc) # never checks doc.owner_id == user.id
await session.commit()
# ✅ Good — ownership is verified before the mutation
@app.delete("/documents/{doc_id}", status_code=204)
async def delete_document(
doc_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
doc = await session.get(Document, doc_id)
if doc is None:
raise HTTPException(status_code=404, detail="Not found")
if doc.owner_id != user.id:
raise HTTPException(status_code=403, detail="Forbidden")
await session.delete(doc)
await session.commit()
```
The [Test-Driven Verification](#test-driven-verification) section reproduces exactly this bug with a failing test.
### Parameterize SQL; never f-string user input
```python
# ❌ Bad — SQL injection
await session.execute(text(f"SELECT * FROM users WHERE email = '{email}'"))
# ✅ Good — bound parameter
await session.execute(text("SELECT * FROM users WHERE email = :email"), {"email": email})
```
### Don't widen CORS to credentials + wildcard
```python
# ❌ Bad — wildcard origin together with credentials is rejected by browsers and unsafe
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_credentials=True)
# ✅ Good — enumerate trusted origins when credentials are allowed
app.add_middleware(
CORSMiddleware,
allow_origins=["https://app.example.com"],
allow_credentials=True,
)
```
Also check: secrets read from config/env (not hard-coded), `HTTPException` details that don't leak internals (stack traces, SQL), and rate limiting on auth endpoints.
---
## Test-Driven Verification
> Inspired by the test-driven development discipline: *if you didn't watch the test fail, you don't know it tests the right thing.* This matters even more for a coding agent than for a human reviewer. An agent's reading and reasoning are fallible — it can misread control flow, hallucinate a guarantee that isn't there, or rationalize a comfortable conclusion — so a prose verdict like "this looks safe" carries little weight on its own. An executable test is the one piece of **objective ground truth** the agent fully controls: it either passes or it doesn't, regardless of how confident the reasoning felt. That is what makes tests the agent's anchor of confidence. Reviewing the same way the discipline writes code — reproduce, don't assert — turns a hunch into proof.
A natural-language review comment ("this might let users delete each other's data") is exactly that kind of fallible hypothesis. FastAPI makes the ground truth cheap to obtain: an in-process client (`httpx.AsyncClient` over `ASGITransport`) runs the whole app, and `app.dependency_overrides` swaps out auth and the database without patching internals. So instead of trusting its own read of the code, the agent settles the question by reproduction.
### Reproduce a suspected bug with a failing test (Verify RED)
Suppose the reviewer suspects the `DELETE /documents/{doc_id}` route above never checks ownership. Write the test that asserts the *secure* behavior, then run it and **watch it fail** — the failure is the proof.
```python
# test_document_authorization.py
import pytest
from httpx import AsyncClient, ASGITransport
from fastapi import Header
from app.main import app
from app.deps import get_current_user, get_session
# Two users; the override picks one based on a test header.
USERS = {"alice": User(id=1, email="alice@example.com"),
"bob": User(id=2, email="bob@example.com")}
def fake_current_user(x_test_user: str = Header(default="alice")) -> User:
return USERS[x_test_user]
@pytest.mark.asyncio
async def test_user_cannot_delete_another_users_document(session): # async fixture
# Arrange: a document owned by Alice (id=1)
session.add(Document(id=10, owner_id=1, title="Alice's doc"))
await session.commit()
app.dependency_overrides[get_current_user] = fake_current_user
app.dependency_overrides[get_session] = lambda: session
# Act: Bob tries to delete Alice's document
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.delete("/documents/10", headers={"X-Test-User": "bob"})
# Assert the SECURE behavior we expect
assert resp.status_code == 403
app.dependency_overrides.clear()
```
Run it against the unfixed code and confirm the failure is the bug, not a typo:
```bash
$ pytest test_document_authorization.py
FAILED assert 204 == 403
# ^ the endpoint deleted Alice's document for Bob — vulnerability confirmed
```
A failure of `204 == 403` (not an import error, not a 404) is what makes the finding credible: the route returned success for an action that should have been forbidden. Now the fix from the [Security](#security) section turns it green:
```bash
$ pytest test_document_authorization.py
PASSED
```
Attach this test to the review. It documents the vulnerability, proves the fix, and guards against regression — far stronger than "consider checking ownership here."
### Prefer `dependency_overrides` over `patch`/`mock`
FastAPI's DI is the seam the TDD discipline asks for: when something is hard to test without mocking everything, that usually signals coupling — and `Depends` already gives you the injection point, so you rarely need `unittest.mock.patch`.
```python
# ❌ Bad — patching internals: brittle, couples the test to import paths
@patch("app.routes.orders.asyncpg.connect")
def test_get_order(mock_connect): ...
# ✅ Good — override the dependency with a real in-memory fake
app.dependency_overrides[get_session] = lambda: in_memory_session
app.dependency_overrides[get_current_user] = lambda: test_user
```
Always reset overrides between tests (`app.dependency_overrides.clear()` in a fixture teardown) so state doesn't leak across tests.
The reproduction above uses `httpx.AsyncClient` over `ASGITransport` with `@pytest.mark.asyncio` — the community convention for an async app, so the suite shares the app's event loop and you avoid loop-mismatch errors later. The synchronous `TestClient` is simpler and fine for a fully sync app, but standardizing on the async client from the start saves a painful migration once any route or fixture becomes async.
### Critique the PR's own tests, not just its source
A PR that ships tests is not automatically safe. Apply these checks to the *tests* in the diff:
```python
# ❌ Bad — happy-path only. Proves the route works when everything is correct,
# says nothing about the validation and authorization paths.
def test_create_item():
resp = client.post("/items", json={"name": "x", "price": 5})
assert resp.status_code == 201
# ✅ Good — the boundary and failure paths are where bugs live
def test_create_item_rejects_negative_price():
resp = client.post("/items", json={"name": "x", "price": -5})
assert resp.status_code == 422
def test_create_item_requires_authentication():
resp = client_without_auth.post("/items", json={"name": "x", "price": 5})
assert resp.status_code == 401
```
Review questions for the test suite:
- **Does it test behavior, or the mock?** An assertion that only confirms a mock was called proves the test's own setup, not the endpoint.
- **Are the failure paths covered?** 401/403/404/422 — not just 200/201. Bugs cluster at the boundaries.
- **Is the mock complete?** A partial mock of an external API response that omits fields the handler reads passes in the test and fails in production.
- **Were the tests written after the fact?** Tests added alongside an implementation and passing on the first run never demonstrated that they can fail — and so prove little. A test that reproduces the bug (fails first, then passes) is worth more than one that was green from birth.
---
## Review Checklist
### Dependency Injection
- [ ] Routes stay thin — DB access and business rules live behind `Depends`/services
- [ ] `yield` dependencies release resources via context manager or `try/finally`
- [ ] Singletons (HTTP clients, pools) created once in `lifespan`, not per request
- [ ] `Annotated[T, Depends(...)]` form used; dependencies are `async def` unless they do blocking I/O
- [ ] Existence/permission checks live in (cached) dependencies, not copy-pasted into routes
- [ ] Dependencies are overridable in tests (no resources created inline in the route)
### Validation
- [ ] Input and output use distinct Pydantic models; ORM objects are not the `response_model`
- [ ] `response_model` set so sensitive fields can't leak
- [ ] Separate Create vs Update schemas (update is partial)
- [ ] Constraints (`gt`, `le`, `EmailStr`, ...) enforced at the boundary, before the DB write
### Async
- [ ] No blocking calls (`requests`, `time.sleep`, blocking DB drivers) inside `async def`
- [ ] Native-async SDKs preferred (`httpx`, `asyncpg`, `redis.asyncio`, ...) over sync ones
- [ ] No `asyncio.run`/manual event loops/manual threads inside routes
- [ ] `run_in_threadpool`/`def` routes used only as a last resort, not on hot paths
- [ ] CPU-bound work offloaded to a worker process (Celery/Arq/RQ), not the loop or threadpool
- [ ] No unawaited coroutines; `BackgroundTasks` only for short fire-and-forget work
### Database
- [ ] One request-scoped session via dependency; no module-level shared session
- [ ] Relationships eager-loaded (`selectinload`/`joinedload`) where accessed in a loop
- [ ] Joins/aggregations done in SQL, not by looping in Python
- [ ] List endpoints are paginated with a capped `limit`
### Security
- [ ] Authentication dependency is backed by an explicit **authorization** check (ownership/role)
- [ ] All SQL parameterized; no f-string interpolation of user input
- [ ] CORS does not combine `allow_origins=["*"]` with `allow_credentials=True`
- [ ] Secrets come from config/env; error responses don't leak internals
### Tests
- [ ] Suspected bugs reproduced with a failing test (`TestClient`/`AsyncClient`) before being claimed
- [ ] `dependency_overrides` used instead of patching internals; overrides reset between tests
- [ ] Failure paths covered (401/403/404/422), not just the happy path
- [ ] Mocks of external responses are complete, not partial
- [ ] New tests demonstrate they can fail (reproduce-then-fix), not green from birth
---
## References
- [FastAPI official documentation](https://fastapi.tiangolo.com/) — async, dependencies, testing
- [zhanymkanov/fastapi-best-practices](https://github.com/zhanymkanov/fastapi-best-practices) — production conventions (async routes, dependency caching, project structure)
+989
View File
@@ -0,0 +1,989 @@
# Go 代码审查指南
基于 Go 官方指南、Effective Go 和社区最佳实践的代码审查清单。
## 快速审查清单
### 必查项
- [ ] 错误是否正确处理(不忽略、有上下文)
- [ ] goroutine 是否有退出机制(避免泄漏)
- [ ] context 是否正确传递和取消
- [ ] 接收器类型选择是否合理(值/指针)
- [ ] 是否使用 `gofmt` 格式化代码
### 高频问题
- [ ] 循环变量捕获问题(Go < 1.22)
- [ ] nil 检查是否完整
- [ ] map 是否初始化后使用
- [ ] defer 在循环中的使用
- [ ] 变量遮蔽(shadowing)
---
## 1. 错误处理
### 1.1 永远不要忽略错误
```go
// ❌ 错误:忽略错误
result, _ := SomeFunction()
// ✅ 正确:处理错误
result, err := SomeFunction()
if err != nil {
return fmt.Errorf("some function failed: %w", err)
}
```
### 1.2 错误包装与上下文
```go
// ❌ 错误:丢失上下文
if err != nil {
return err
}
// ❌ 错误:使用 %v 丢失错误链
if err != nil {
return fmt.Errorf("failed: %v", err)
}
// ✅ 正确:使用 %w 保留错误链
if err != nil {
return fmt.Errorf("failed to process user %d: %w", userID, err)
}
```
### 1.3 使用 errors.Is 和 errors.As
```go
// ❌ 错误:直接比较(无法处理包装错误)
if err == sql.ErrNoRows {
// ...
}
// ✅ 正确:使用 errors.Is(支持错误链)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
// ✅ 正确:使用 errors.As 提取特定类型
var pathErr *os.PathError
if errors.As(err, &pathErr) {
log.Printf("path error: %s", pathErr.Path)
}
```
### 1.4 自定义错误类型
```go
// ✅ 推荐:定义 sentinel 错误
var (
ErrNotFound = errors.New("not found")
ErrUnauthorized = errors.New("unauthorized")
)
// ✅ 推荐:带上下文的自定义错误
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation error on %s: %s", e.Field, e.Message)
}
```
### 1.5 错误处理只做一次
```go
// ❌ 错误:既记录又返回(重复处理)
if err != nil {
log.Printf("error: %v", err)
return err
}
// ✅ 正确:只返回,让调用者决定
if err != nil {
return fmt.Errorf("operation failed: %w", err)
}
// ✅ 或者:只记录并处理(不返回)
if err != nil {
log.Printf("non-critical error: %v", err)
// 继续执行备用逻辑
}
```
---
## 2. 并发与 Goroutine
### 2.1 避免 Goroutine 泄漏
```go
// ❌ 错误:goroutine 永远无法退出
func bad() {
ch := make(chan int)
go func() {
val := <-ch // 永远阻塞,无人发送
fmt.Println(val)
}()
// 函数返回,goroutine 泄漏
}
// ✅ 正确:使用 context 或 done channel
func good(ctx context.Context) {
ch := make(chan int)
go func() {
select {
case val := <-ch:
fmt.Println(val)
case <-ctx.Done():
return // 优雅退出
}
}()
}
```
### 2.2 Channel 使用规范
```go
// ❌ 错误:向 nil channel 发送(永久阻塞)
var ch chan int
ch <- 1 // 永久阻塞
// ❌ 错误:向已关闭的 channel 发送(panic)
close(ch)
ch <- 1 // panic!
// ✅ 正确:发送方关闭 channel
func producer(ch chan<- int) {
defer close(ch) // 发送方负责关闭
for i := 0; i < 10; i++ {
ch <- i
}
}
// ✅ 正确:接收方检测关闭
for val := range ch {
process(val)
}
// 或者
val, ok := <-ch
if !ok {
// channel 已关闭
}
```
### 2.3 使用 sync.WaitGroup
```go
// ❌ 错误:Add 在 goroutine 内部
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
go func() {
wg.Add(1) // 竞态条件!
defer wg.Done()
work()
}()
}
wg.Wait()
// ✅ 正确:Add 在 goroutine 启动前
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
work()
}()
}
wg.Wait()
```
### 2.4 避免在循环中捕获变量(Go < 1.22)
```go
// ❌ 错误(Go < 1.22):捕获循环变量
for _, item := range items {
go func() {
process(item) // 所有 goroutine 可能使用同一个 item
}()
}
// ✅ 正确:传递参数
for _, item := range items {
go func(it Item) {
process(it)
}(item)
}
// ✅ Go 1.22+:默认行为已修复,每次迭代创建新变量
```
### 2.5 Worker Pool 模式
```go
// ✅ 推荐:限制并发数量
func processWithWorkerPool(ctx context.Context, items []Item, workers int) error {
jobs := make(chan Item, len(items))
results := make(chan error, len(items))
// 启动 worker
for w := 0; w < workers; w++ {
go func() {
for item := range jobs {
results <- process(item)
}
}()
}
// 发送任务
for _, item := range items {
jobs <- item
}
close(jobs)
// 收集结果
for range items {
if err := <-results; err != nil {
return err
}
}
return nil
}
```
---
## 3. Context 使用
### 3.1 Context 作为第一个参数
```go
// ❌ 错误:context 不是第一个参数
func Process(data []byte, ctx context.Context) error
// ❌ 错误:context 存储在 struct 中
type Service struct {
ctx context.Context // 不要这样做!
}
// ✅ 正确:context 作为第一个参数,命名为 ctx
func Process(ctx context.Context, data []byte) error
```
### 3.2 传播而非创建新的根 Context
```go
// ❌ 错误:在调用链中创建新的根 context
func middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := context.Background() // 丢失了请求的 context!
process(ctx)
next.ServeHTTP(w, r)
})
}
// ✅ 正确:从请求中获取并传播
func middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
ctx = context.WithValue(ctx, key, value)
process(ctx)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
```
### 3.3 始终调用 cancel 函数
```go
// ❌ 错误:未调用 cancel
ctx, cancel := context.WithTimeout(parentCtx, 5*time.Second)
// 缺少 cancel() 调用,可能资源泄漏
// ✅ 正确:使用 defer 确保调用
ctx, cancel := context.WithTimeout(parentCtx, 5*time.Second)
defer cancel() // 即使超时也要调用
```
### 3.4 响应 Context 取消
```go
// ✅ 推荐:在长时间操作中检查 context
func LongRunningTask(ctx context.Context) error {
for {
select {
case <-ctx.Done():
return ctx.Err() // 返回 context.Canceled 或 context.DeadlineExceeded
default:
// 执行一小部分工作
if err := doChunk(); err != nil {
return err
}
}
}
}
```
### 3.5 区分取消原因
```go
// ✅ 根据 ctx.Err() 区分取消原因
if err := ctx.Err(); err != nil {
switch {
case errors.Is(err, context.Canceled):
log.Println("operation was canceled")
case errors.Is(err, context.DeadlineExceeded):
log.Println("operation timed out")
}
return err
}
```
---
## 4. 接口设计
### 4.1 接受接口,返回结构体
```go
// ❌ 不推荐:接受具体类型
func SaveUser(db *sql.DB, user User) error
// ✅ 推荐:接受接口(解耦、易测试)
type UserStore interface {
Save(ctx context.Context, user User) error
}
func SaveUser(store UserStore, user User) error
// ❌ 不推荐:返回接口
func NewUserService() UserServiceInterface
// ✅ 推荐:返回具体类型
func NewUserService(store UserStore) *UserService
```
### 4.2 在消费者处定义接口
```go
// ❌ 不推荐:在实现包中定义接口
// package database
type Database interface {
Query(ctx context.Context, query string) ([]Row, error)
// ... 20 个方法
}
// ✅ 推荐:在消费者包中定义所需的最小接口
// package userservice
type UserQuerier interface {
QueryUsers(ctx context.Context, filter Filter) ([]User, error)
}
```
### 4.3 保持接口小而专注
```go
// ❌ 不推荐:大而全的接口
type Repository interface {
GetUser(id int) (*User, error)
CreateUser(u *User) error
UpdateUser(u *User) error
DeleteUser(id int) error
GetOrder(id int) (*Order, error)
CreateOrder(o *Order) error
// ... 更多方法
}
// ✅ 推荐:小而专注的接口
type UserReader interface {
GetUser(ctx context.Context, id int) (*User, error)
}
type UserWriter interface {
CreateUser(ctx context.Context, u *User) error
UpdateUser(ctx context.Context, u *User) error
}
// 组合接口
type UserRepository interface {
UserReader
UserWriter
}
```
### 4.4 避免空接口滥用
```go
// ❌ 不推荐:过度使用 interface{}
func Process(data interface{}) interface{}
// ✅ 推荐:使用泛型(Go 1.18+)
func Process[T any](data T) T
// ✅ 推荐:定义具体接口
type Processor interface {
Process() Result
}
```
---
## 5. 接收器类型选择
### 5.1 使用指针接收器的情况
```go
// ✅ 需要修改接收器时
func (u *User) SetName(name string) {
u.Name = name
}
// ✅ 接收器包含 sync.Mutex 等同步原语
type SafeCounter struct {
mu sync.Mutex
count int
}
func (c *SafeCounter) Inc() {
c.mu.Lock()
defer c.mu.Unlock()
c.count++
}
// ✅ 接收器是大型结构体(避免复制开销)
type LargeStruct struct {
Data [1024]byte
// ...
}
func (l *LargeStruct) Process() { /* ... */ }
```
### 5.2 使用值接收器的情况
```go
// ✅ 接收器是小型不可变结构体
type Point struct {
X, Y float64
}
func (p Point) Distance(other Point) float64 {
return math.Sqrt(math.Pow(p.X-other.X, 2) + math.Pow(p.Y-other.Y, 2))
}
// ✅ 接收器是基本类型的别名
type Counter int
func (c Counter) String() string {
return fmt.Sprintf("%d", c)
}
// ✅ 接收器是 map、func、chan(本身是引用类型)
type StringSet map[string]struct{}
func (s StringSet) Contains(key string) bool {
_, ok := s[key]
return ok
}
```
### 5.3 一致性原则
```go
// ❌ 不推荐:混合使用接收器类型
func (u User) GetName() string // 值接收器
func (u *User) SetName(n string) // 指针接收器
// ✅ 推荐:如果有任何方法需要指针接收器,全部使用指针
func (u *User) GetName() string { return u.Name }
func (u *User) SetName(n string) { u.Name = n }
```
---
## 6. 性能优化
### 6.1 预分配 Slice
```go
// ❌ 不推荐:动态增长
var result []int
for i := 0; i < 10000; i++ {
result = append(result, i) // 多次分配和复制
}
// ✅ 推荐:预分配已知大小
result := make([]int, 0, 10000)
for i := 0; i < 10000; i++ {
result = append(result, i)
}
// ✅ 或者直接初始化
result := make([]int, 10000)
for i := 0; i < 10000; i++ {
result[i] = i
}
```
### 6.2 避免不必要的堆分配
```go
// ❌ 可能逃逸到堆
func NewUser() *User {
return &User{} // 逃逸到堆
}
// ✅ 考虑返回值(如果适用)
func NewUser() User {
return User{} // 可能在栈上分配
}
// 检查逃逸分析
// go build -gcflags '-m -m' ./...
```
### 6.3 使用 sync.Pool 复用对象
```go
// ✅ 推荐:高频创建/销毁的对象使用 sync.Pool
var bufferPool = sync.Pool{
New: func() interface{} {
return new(bytes.Buffer)
},
}
func ProcessData(data []byte) string {
buf := bufferPool.Get().(*bytes.Buffer)
defer func() {
buf.Reset()
bufferPool.Put(buf)
}()
buf.Write(data)
return buf.String()
}
```
### 6.4 字符串拼接优化
```go
// ❌ 不推荐:循环中使用 + 拼接
var result string
for _, s := range strings {
result += s // 每次创建新字符串
}
// ✅ 推荐:使用 strings.Builder
var builder strings.Builder
for _, s := range strings {
builder.WriteString(s)
}
result := builder.String()
// ✅ 或者使用 strings.Join
result := strings.Join(strings, "")
```
### 6.5 避免 interface{} 转换开销
```go
// ❌ 热路径中使用 interface{}
func process(data interface{}) {
switch v := data.(type) { // 类型断言有开销
case int:
// ...
}
}
// ✅ 热路径中使用泛型或具体类型
func process[T int | int64 | float64](data T) {
// 编译时确定类型,无运行时开销
}
```
---
## 7. 测试
### 7.1 表驱动测试
```go
// ✅ 推荐:表驱动测试
func TestAdd(t *testing.T) {
tests := []struct {
name string
a, b int
expected int
}{
{"positive numbers", 1, 2, 3},
{"with zero", 0, 5, 5},
{"negative numbers", -1, -2, -3},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := Add(tt.a, tt.b)
if result != tt.expected {
t.Errorf("Add(%d, %d) = %d; want %d",
tt.a, tt.b, result, tt.expected)
}
})
}
}
```
### 7.2 并行测试
```go
// ✅ 推荐:独立测试用例并行执行
func TestParallel(t *testing.T) {
tests := []struct {
name string
input string
}{
{"test1", "input1"},
{"test2", "input2"},
}
for _, tt := range tests {
tt := tt // Go < 1.22 需要复制
t.Run(tt.name, func(t *testing.T) {
t.Parallel() // 标记为可并行
result := Process(tt.input)
// assertions...
})
}
}
```
### 7.3 使用接口进行 Mock
```go
// ✅ 定义接口以便测试
type EmailSender interface {
Send(to, subject, body string) error
}
// 生产实现
type SMTPSender struct { /* ... */ }
// 测试 Mock
type MockEmailSender struct {
SendFunc func(to, subject, body string) error
}
func (m *MockEmailSender) Send(to, subject, body string) error {
return m.SendFunc(to, subject, body)
}
func TestUserRegistration(t *testing.T) {
mock := &MockEmailSender{
SendFunc: func(to, subject, body string) error {
if to != "test@example.com" {
t.Errorf("unexpected recipient: %s", to)
}
return nil
},
}
service := NewUserService(mock)
// test...
}
```
### 7.4 测试辅助函数
```go
// ✅ 使用 t.Helper() 标记辅助函数
func assertEqual(t *testing.T, got, want interface{}) {
t.Helper() // 错误报告时显示调用者位置
if got != want {
t.Errorf("got %v, want %v", got, want)
}
}
// ✅ 使用 t.Cleanup() 清理资源
func TestWithTempFile(t *testing.T) {
f, err := os.CreateTemp("", "test")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
os.Remove(f.Name())
})
// test...
}
```
---
## 8. 常见陷阱
### 8.1 Nil Slice vs Empty Slice
```go
var nilSlice []int // nil, len=0, cap=0
emptySlice := []int{} // not nil, len=0, cap=0
made := make([]int, 0) // not nil, len=0, cap=0
// ✅ JSON 编码差异
json.Marshal(nilSlice) // null
json.Marshal(emptySlice) // []
// ✅ 推荐:需要空数组 JSON 时显式初始化
if slice == nil {
slice = []int{}
}
```
### 8.2 Map 初始化
```go
// ❌ 错误:未初始化的 map
var m map[string]int
m["key"] = 1 // panic: assignment to entry in nil map
// ✅ 正确:使用 make 初始化
m := make(map[string]int)
m["key"] = 1
// ✅ 或者使用字面量
m := map[string]int{}
```
### 8.3 Defer 在循环中
```go
// ❌ 潜在问题:defer 在函数结束时才执行
func processFiles(files []string) error {
for _, file := range files {
f, err := os.Open(file)
if err != nil {
return err
}
defer f.Close() // 所有文件在函数结束时才关闭!
// process...
}
return nil
}
// ✅ 正确:使用闭包或提取函数
func processFiles(files []string) error {
for _, file := range files {
if err := processFile(file); err != nil {
return err
}
}
return nil
}
func processFile(file string) error {
f, err := os.Open(file)
if err != nil {
return err
}
defer f.Close()
// process...
return nil
}
```
### 8.4 Slice 底层数组共享
```go
// ❌ 潜在问题:切片共享底层数组
original := []int{1, 2, 3, 4, 5}
slice := original[1:3] // [2, 3]
slice[0] = 100 // 修改了 original!
// original 变成 [1, 100, 3, 4, 5]
// ✅ 正确:需要独立副本时显式复制
slice := make([]int, 2)
copy(slice, original[1:3])
slice[0] = 100 // 不影响 original
```
### 8.5 字符串子串内存泄漏
```go
// ❌ 潜在问题:子串持有整个底层数组
func getPrefix(s string) string {
return s[:10] // 仍引用整个 s 的底层数组
}
// ✅ 正确:创建独立副本(Go 1.18+)
func getPrefix(s string) string {
return strings.Clone(s[:10])
}
// ✅ Go 1.18 之前
func getPrefix(s string) string {
return string([]byte(s[:10]))
}
```
### 8.6 Interface Nil 陷阱
```go
// ❌ 陷阱:interface 的 nil 判断
type MyError struct{}
func (e *MyError) Error() string { return "error" }
func returnsError() error {
var e *MyError = nil
return e // 返回的 error 不是 nil!
}
func main() {
err := returnsError()
if err != nil { // true! interface{type: *MyError, value: nil}
fmt.Println("error:", err)
}
}
// ✅ 正确:显式返回 nil
func returnsError() error {
var e *MyError = nil
if e == nil {
return nil // 显式返回 nil
}
return e
}
```
### 8.7 Time 比较
```go
// ❌ 不推荐:直接使用 == 比较 time.Time
if t1 == t2 { // 可能因为单调时钟差异而失败
// ...
}
// ✅ 推荐:使用 Equal 方法
if t1.Equal(t2) {
// ...
}
// ✅ 比较时间范围
if t1.Before(t2) || t1.After(t2) {
// ...
}
```
---
## 9. 代码组织
### 9.1 包命名
```go
// ❌ 不推荐
package common // 过于宽泛
package utils // 过于宽泛
package helpers // 过于宽泛
package models // 按类型分组
// ✅ 推荐:按功能命名
package user // 用户相关功能
package order // 订单相关功能
package postgres // PostgreSQL 实现
```
### 9.2 避免循环依赖
```go
// ❌ 循环依赖
// package a imports package b
// package b imports package a
// ✅ 解决方案1:提取共享类型到独立包
// package types (共享类型)
// package a imports types
// package b imports types
// ✅ 解决方案2:使用接口解耦
// package a 定义接口
// package b 实现接口
```
### 9.3 导出标识符规范
```go
// ✅ 只导出必要的标识符
type UserService struct {
db *sql.DB // 私有
}
func (s *UserService) GetUser(id int) (*User, error) // 公开
func (s *UserService) validate(u *User) error // 私有
// ✅ 内部包限制访问
// internal/database/... 只能被同项目代码导入
```
---
## 10. 工具与检查
### 10.1 必须使用的工具
```bash
# 格式化(必须)
gofmt -w .
goimports -w .
# 静态分析
go vet ./...
# 竞态检测
go test -race ./...
# 逃逸分析
go build -gcflags '-m -m' ./...
```
### 10.2 推荐的 Linter
```bash
# golangci-lint(集成多个 linter)
golangci-lint run
# 常用检查项
# - errcheck: 检查未处理的错误
# - gosec: 安全检查
# - ineffassign: 无效赋值
# - staticcheck: 静态分析
# - unused: 未使用的代码
```
### 10.3 Benchmark 测试
```go
// ✅ 性能基准测试
func BenchmarkProcess(b *testing.B) {
data := prepareData()
b.ResetTimer() // 重置计时器
for i := 0; i < b.N; i++ {
Process(data)
}
}
// 运行 benchmark
// go test -bench=. -benchmem ./...
```
---
## 参考资源
- [Effective Go](https://go.dev/doc/effective_go)
- [Go Code Review Comments](https://go.dev/wiki/CodeReviewComments)
- [Go Common Mistakes](https://go.dev/wiki/CommonMistakes)
- [100 Go Mistakes](https://100go.co/)
- [Go Proverbs](https://go-proverbs.github.io/)
- [Uber Go Style Guide](https://github.com/uber-go/guide/blob/master/style.md)
+405
View File
@@ -0,0 +1,405 @@
# Java Code Review Guide
Java 审查重点:Java 17/21 新特性、Spring Boot 3 最佳实践、并发编程(虚拟线程)、JPA 性能优化以及代码可维护性。
## 目录
- [现代 Java 特性 (17/21+)](#现代-java-特性-1721)
- [Stream API & Optional](#stream-api--optional)
- [Spring Boot 最佳实践](#spring-boot-最佳实践)
- [JPA 与 数据库性能](#jpa-与-数据库性能)
- [并发与虚拟线程](#并发与虚拟线程)
- [Lombok 使用规范](#lombok-使用规范)
- [异常处理](#异常处理)
- [测试规范](#测试规范)
- [Review Checklist](#review-checklist)
---
## 现代 Java 特性 (17/21+)
### Record (记录类)
```java
// ❌ 传统的 POJO/DTO:样板代码多
public class UserDto {
private final String name;
private final int age;
public UserDto(String name, int age) {
this.name = name;
this.age = age;
}
// getters, equals, hashCode, toString...
}
// ✅ 使用 Record:简洁、不可变、语义清晰
public record UserDto(String name, int age) {
// 紧凑构造函数进行验证
public UserDto {
if (age < 0) throw new IllegalArgumentException("Age cannot be negative");
}
}
```
### Switch 表达式与模式匹配
```java
// ❌ 传统的 Switch:容易漏掉 break,不仅冗长且易错
String type = "";
switch (obj) {
case Integer i: // Java 16+
type = String.format("int %d", i);
break;
case String s:
type = String.format("string %s", s);
break;
default:
type = "unknown";
}
// ✅ Switch 表达式:无穿透风险,强制返回值
String type = switch (obj) {
case Integer i -> "int %d".formatted(i);
case String s -> "string %s".formatted(s);
case null -> "null value"; // Java 21 处理 null
default -> "unknown";
};
```
### 文本块 (Text Blocks)
```java
// ❌ 拼接 SQL/JSON 字符串
String json = "{\n" +
" \"name\": \"Alice\",\n" +
" \"age\": 20\n" +
"}";
// ✅ 使用文本块:所见即所得
String json = """
{
"name": "Alice",
"age": 20
}
""";
```
---
## Stream API & Optional
### 避免滥用 Stream
```java
// ❌ 简单的循环不需要 Stream(性能开销 + 可读性差)
items.stream().forEach(item -> {
process(item);
});
// ✅ 简单场景直接用 for-each
for (var item : items) {
process(item);
}
// ❌ 极其复杂的 Stream 链
List<Dto> result = list.stream()
.filter(...)
.map(...)
.peek(...)
.sorted(...)
.collect(...); // 难以调试
// ✅ 拆分为有意义的步骤
var filtered = list.stream().filter(...).toList();
// ...
```
### Optional 正确用法
```java
// ❌ 将 Optional 用作参数或字段(序列化问题,增加调用复杂度)
public void process(Optional<String> name) { ... }
public class User {
private Optional<String> email; // 不推荐
}
// ✅ Optional 仅用于返回值
public Optional<User> findUser(String id) { ... }
// ❌ 既然用了 Optional 还在用 isPresent() + get()
Optional<User> userOpt = findUser(id);
if (userOpt.isPresent()) {
return userOpt.get().getName();
} else {
return "Unknown";
}
// ✅ 使用函数式 API
return findUser(id)
.map(User::getName)
.orElse("Unknown");
```
---
## Spring Boot 最佳实践
### 依赖注入 (DI)
```java
// ❌ 字段注入 (@Autowired)
// 缺点:难以测试(需要反射注入),掩盖了依赖过多的问题,且不可变性差
@Service
public class UserService {
@Autowired
private UserRepository userRepo;
}
// ✅ 构造器注入 (Constructor Injection)
// 优点:依赖明确,易于单元测试 (Mock),字段可为 final
@Service
public class UserService {
private final UserRepository userRepo;
public UserService(UserRepository userRepo) {
this.userRepo = userRepo;
}
}
// 💡 提示:结合 Lombok @RequiredArgsConstructor 可简化代码,但要小心循环依赖
```
### 配置管理
```java
// ❌ 硬编码配置值
@Service
public class PaymentService {
private String apiKey = "sk_live_12345";
}
// ❌ 直接使用 @Value 散落在代码中
@Value("${app.payment.api-key}")
private String apiKey;
// ✅ 使用 @ConfigurationProperties 类型安全配置
@ConfigurationProperties(prefix = "app.payment")
public record PaymentProperties(String apiKey, int timeout, String url) {}
```
---
## JPA 与 数据库性能
### N+1 查询问题
```java
// ❌ FetchType.EAGER 或 循环中触发懒加载
// Entity 定义
@Entity
public class User {
@OneToMany(fetch = FetchType.EAGER) // 危险!
private List<Order> orders;
}
// 业务代码
List<User> users = userRepo.findAll(); // 1 条 SQL
for (User user : users) {
// 如果是 Lazy,这里会触发 N 条 SQL
System.out.println(user.getOrders().size());
}
// ✅ 使用 @EntityGraph 或 JOIN FETCH
@Query("SELECT u FROM User u JOIN FETCH u.orders")
List<User> findAllWithOrders();
```
### 事务管理
```java
// ❌ 在 Controller 层开启事务(数据库连接占用时间过长)
// ❌ 在 private 方法上加 @Transactional(AOP 不生效)
@Transactional
private void saveInternal() { ... }
// ✅ 在 Service 层公共方法加 @Transactional
// ✅ 读操作显式标记 readOnly = true (性能优化)
@Service
public class UserService {
@Transactional(readOnly = true)
public User getUser(Long id) { ... }
@Transactional
public void createUser(UserDto dto) { ... }
}
```
### Entity 设计
```java
// ❌ 在 Entity 中使用 Lombok @Data
// @Data 生成的 equals/hashCode 包含所有字段,可能触发懒加载导致性能问题或异常
@Entity
@Data
public class User { ... }
// ✅ 仅使用 @Getter, @Setter
// ✅ 自定义 equals/hashCode (通常基于 ID)
@Entity
@Getter
@Setter
public class User {
@Id
private Long id;
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof User)) return false;
return id != null && id.equals(((User) o).id);
}
@Override
public int hashCode() {
return getClass().hashCode();
}
}
```
---
## 并发与虚拟线程
### 虚拟线程 (Java 21+)
```java
// ❌ 传统线程池处理大量 I/O 阻塞任务(资源耗尽)
ExecutorService executor = Executors.newFixedThreadPool(100);
// ✅ 使用虚拟线程处理 I/O 密集型任务(高吞吐量)
// Spring Boot 3.2+ 开启:spring.threads.virtual.enabled=true
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
// 在虚拟线程中,阻塞操作(如 DB 查询、HTTP 请求)几乎不消耗 OS 线程资源
```
### 线程安全
```java
// ❌ SimpleDateFormat 是线程不安全的
private static final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
// ✅ 使用 DateTimeFormatter (Java 8+)
private static final DateTimeFormatter dtf = DateTimeFormatter.ofPattern("yyyy-MM-dd");
// ❌ HashMap 在多线程环境会数据丢失(Java 7 及之前 resize 还可能死循环,Java 8 修复了死循环但仍非线程安全)
// ✅ 使用 ConcurrentHashMap
Map<String, String> cache = new ConcurrentHashMap<>();
```
---
## Lombok 使用规范
```java
// ❌ 滥用 @Builder 导致无法强制校验必填字段
@Builder
public class Order {
private String id; // 必填
private String note; // 选填
}
// 调用者可能漏掉 id: Order.builder().note("hi").build();
// ✅ 关键业务对象建议手动编写 Builder 或构造函数以确保不变量
// 或者在 build() 方法中添加校验逻辑 (Lombok @Builder.Default 等)
```
---
## 异常处理
### 全局异常处理
```java
// ❌ 到处 try-catch 吞掉异常或只打印日志
try {
userService.create(user);
} catch (Exception e) {
e.printStackTrace(); // 不应该在生产环境使用
// return null; // 吞掉异常,上层不知道发生了什么
}
// ✅ 自定义异常 + @ControllerAdvice (Spring Boot 3 ProblemDetail)
public class UserNotFoundException extends RuntimeException { ... }
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(UserNotFoundException.class)
public ProblemDetail handleNotFound(UserNotFoundException e) {
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage());
}
}
```
---
## 测试规范
### 单元测试 vs 集成测试
```java
// ❌ 单元测试依赖真实数据库或外部服务
@SpringBootTest // 启动整个 Context,慢
public class UserServiceTest { ... }
// ✅ 单元测试使用 Mockito
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@Mock UserRepository repo;
@InjectMocks UserService service;
@Test
void shouldCreateUser() { ... }
}
// ✅ 集成测试使用 Testcontainers
@Testcontainers
@SpringBootTest
class UserRepositoryTest {
@Container
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:15");
// ...
}
```
---
## Review Checklist
### 基础与规范
- [ ] 遵循 Java 17/21 新特性(Switch 表达式, Records, 文本块)
- [ ] 避免使用已过时的类(Date, Calendar, SimpleDateFormat)
- [ ] 集合操作是否优先使用了 Stream API 或 Collections 方法?
- [ ] Optional 仅用于返回值,未用于字段或参数
### Spring Boot
- [ ] 使用构造器注入而非 @Autowired 字段注入
- [ ] 配置属性使用了 @ConfigurationProperties
- [ ] Controller 职责单一,业务逻辑下沉到 Service
- [ ] 全局异常处理使用了 @ControllerAdvice / ProblemDetail
### 数据库 & 事务
- [ ] 读操作事务标记了 `@Transactional(readOnly = true)`
- [ ] 检查是否存在 N+1 查询(EAGER fetch 或循环调用)
- [ ] Entity 类未使用 @Data,正确实现了 equals/hashCode
- [ ] 数据库索引是否覆盖了查询条件
### 并发与性能
- [ ] I/O 密集型任务是否考虑了虚拟线程?
- [ ] 线程安全类是否使用正确(ConcurrentHashMap vs HashMap)
- [ ] 锁的粒度是否合理?避免在锁内进行 I/O 操作
### 可维护性
- [ ] 关键业务逻辑有充分的单元测试
- [ ] 日志记录恰当(使用 Slf4j,避免 System.out)
- [ ] 魔法值提取为常量或枚举
File diff suppressed because it is too large Load Diff
+593
View File
@@ -0,0 +1,593 @@
# NestJS Code Review Guide
> NestJS 代码审查指南,覆盖依赖注入与分层架构、模块组织、Guard/Interceptor/Pipe、DTO 验证、错误处理、循环依赖及测试模式等核心主题。
## 目录
- [依赖注入与分层架构](#依赖注入与分层架构)
- [模块组织](#模块组织)
- [Guard / Interceptor / Pipe](#guard--interceptor--pipe)
- [验证模式 (DTO)](#验证模式-dto)
- [错误处理](#错误处理)
- [循环依赖](#循环依赖)
- [测试模式](#测试模式)
- [Review Checklist](#review-checklist)
---
## 依赖注入与分层架构
### 三层架构:Controller → Service → Repository
```typescript
// ❌ ORM 直接注入 Controller,跳过 Service 层
@Controller('users')
export class UsersController {
constructor(private readonly prisma: PrismaService) {}
@Get()
findAll() {
return this.prisma.user.findMany();
}
}
// ✅ Controller → Service → Repository
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Get()
findAll() {
return this.usersService.findAll();
}
}
@Injectable()
export class UsersService {
constructor(private readonly usersRepo: UsersRepository) {}
findAll() {
return this.usersRepo.findAll();
}
}
```
### Repository 之间不应互相注入
```typescript
// ❌ Repository 导入另一个 Repository——编排逻辑属于 Service
@Injectable()
export class OrdersRepository {
constructor(private readonly usersRepository: UsersRepository) {}
}
// ✅ 跨 Repository 编排在 Service 中完成
@Injectable()
export class OrdersService {
constructor(
private readonly ordersRepo: OrdersRepository,
private readonly usersRepo: UsersRepository,
) {}
}
```
### God Service:依赖超过 8 个时拆分
```typescript
// ❌ 9 个依赖的巨型 Service
@Injectable()
export class OrdersService {
constructor(
private readonly ordersRepo: OrdersRepository,
private readonly usersRepo: UsersRepository,
private readonly productsRepo: ProductsRepository,
private readonly paymentsService: PaymentsService,
private readonly mailerService: MailerService,
private readonly inventoryService: InventoryService,
private readonly discountService: DiscountService,
private readonly taxService: TaxService,
private readonly auditService: AuditService,
) {}
}
// ✅ 拆分为 Use-Case Service(一个文件一个操作)
@Injectable()
export class CreateOrderService {
constructor(
private readonly ordersRepo: OrdersRepository,
private readonly paymentsService: PaymentsService,
) {}
async execute(dto: CreateOrderDto) { /* ... */ }
}
```
### Symbol Token 实现依赖反转
```typescript
// ❌ 直接依赖具体实现——测试时无法替换
@Injectable()
export class UsersService {
constructor(private readonly repo: TypeOrmUserRepository) {}
}
// ✅ 接口 + Symbol Token——可替换为内存实现
export const USER_REPOSITORY = Symbol('USER_REPOSITORY');
export interface UserRepository {
findAll(): Promise<User[]>;
findById(id: string): Promise<User | null>;
}
// module:
{
provide: USER_REPOSITORY,
useClass: TypeOrmUserRepository,
}
// service:
@Injectable()
export class UsersService {
constructor(@Inject(USER_REPOSITORY) private readonly repo: UserRepository) {}
}
```
---
## 模块组织
### 推荐四层结构
```
src/
common/ ← 全局技术基础设施(Guards、Filters、Interceptors、Decorators)
core/ ← 内部基础设施(Config、Database、Queue 配置)
integrations/ ← 外部服务封装(Mailer、Storage、Stripe、SMS)
modules/ ← 按领域组织的业务逻辑
[feature]/
dtos/
repositories/
services/
internal/ ← 模块内共享 Service
use-cases/ ← 一个文件 = 一个操作
types/
[feature].controller.ts
[feature].module.ts
```
### Domain 必须框架无关
```typescript
// ❌ Domain Entity 依赖 NestJS——不可独立测试
import { Injectable } from '@nestjs/common';
@Injectable()
export class User {
constructor(private readonly email: string) {}
}
// ✅ Domain 是纯类,无框架装饰器
export class User {
private constructor(private readonly email: string) {}
static create(email: string): User {
return new User(email);
}
}
```
### 关键规则
- `common/` 必须 **不涉及业务**——如果需要知道"订单",它不属于这里
- `integrations/` 封装每个外部服务;换 SendGrid → AWS SES 只改一个目录
- 使用 **Use-Case Service**(一个文件一个操作)而非 15 个方法的巨型 `XxxService`
---
## Guard / Interceptor / Pipe
### 业务逻辑不应放在 Guard 中
```typescript
// ❌ Guard 中查询数据库 + 业务判断
@Injectable()
export class OrderOwnershipGuard implements CanActivate {
constructor(private readonly prisma: PrismaService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest();
const order = await this.prisma.order.findUnique({
where: { id: req.params.id },
});
if (order.userId !== req.user.id) {
return false; // 数据获取 + 业务规则判断都在 Guard 里
}
return true;
}
}
// ✅ Guard 只做授权检查(角色/权限)
@Injectable()
export class RolesGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const requiredRoles = this.reflector.getAllAndOverride<string[]>('roles', [
context.getHandler(),
context.getClass(),
]);
if (!requiredRoles) return true;
const { user } = context.switchToHttp().getRequest();
return requiredRoles.some((role) => user.roles?.includes(role));
}
}
```
### Interceptor 只用于横切关注点
```typescript
// ❌ Interceptor 中执行业务逻辑
@Injectable()
export class PricingInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler) {
// 计算折扣——这不是横切关注点!
return next.handle().pipe(map(data => applyDiscount(data)));
}
}
// ✅ Interceptor 用于日志、缓存、响应转换、计时
@Injectable()
export class LoggingInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler) {
const now = Date.now();
const req = context.switchToHttp().getRequest();
return next.handle().pipe(
tap(() => console.log(`${req.method} ${req.url} - ${Date.now() - now}ms`)),
);
}
}
```
### 全局 ValidationPipe 必须配置 whitelist
```typescript
// ❌ 没有 whitelist——请求体中的额外属性直接传入
async function bootstrap() {
const app = await NestFactory.create(AppModule);
await app.listen(3000);
}
// ✅ 全局 ValidationPipe + whitelist 过滤未知属性
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
await app.listen(3000);
}
```
---
## 验证模式 (DTO)
### @ValidateNested() 必须搭配 @Type()
```typescript
// ❌ 只有 @ValidateNested——嵌套对象验证被静默跳过!
export class CreateOrderDto {
@ValidateNested()
shipping: AddressDto;
}
// ✅ @ValidateNested + @Type 配对使用
import { Type } from 'class-transformer';
export class CreateOrderDto {
@ValidateNested()
@Type(() => AddressDto)
shipping: AddressDto;
@IsArray()
@ValidateNested({ each: true })
@Type(() => OrderItemDto)
items: OrderItemDto[];
}
```
### 禁止裸 any Body
```typescript
// ❌ 没有 DTO——无验证、无类型安全、无 Swagger 文档
@Post()
create(@Body() body: any) {
return this.service.create(body);
}
// ✅ 为每个操作创建 DTO
export class CreateUserDto {
@IsEmail()
email: string;
@IsString()
@MinLength(2)
@MaxLength(100)
name: string;
}
@Post()
create(@Body() dto: CreateUserDto) {
return this.service.create(dto);
}
```
### Create 和 Update 应使用不同 DTO
```typescript
// ❌ PATCH 也要求所有字段——不合理的 API 设计
@Patch(':id')
update(@Body() dto: CreateUserDto) { /* all fields required */ }
// ✅ Update 使用 PartialType
export class UpdateUserDto extends PartialType(CreateUserDto) {}
@Patch(':id')
update(@Body() dto: UpdateUserDto) { /* all fields optional */ }
```
### 可选嵌套对象
```typescript
// ❌ 可选嵌套对象缺少 @IsOptional
export class UpdateOrderDto {
@ValidateNested()
@Type(() => AddressDto)
shipping?: AddressDto; // undefined 时仍尝试验证
}
// ✅ @IsOptional + @ValidateNested + @Type
export class UpdateOrderDto {
@IsOptional()
@ValidateNested()
@Type(() => AddressDto)
shipping?: AddressDto;
}
```
---
## 错误处理
### 禁止吞掉错误
```typescript
// ❌ catch { return null }——隐藏了问题,调用者无法区分"不存在"和"出错了"
async findOne(id: string) {
try {
return await this.repo.findById(id);
} catch (e) {
return null;
}
}
// ✅ 抛出有意义的异常
async findOne(id: string): Promise<User> {
const user = await this.repo.findById(id);
if (!user) {
throw new NotFoundException(`User ${id} not found`);
}
return user;
}
```
### 使用内置异常类
```typescript
// ❌ 手动构造 HTTP 响应
throw new HttpException('Bad request', 400);
// ✅ 使用语义化的内置异常
throw new BadRequestException('Invalid email format');
throw new NotFoundException('User not found');
throw new ConflictException('Email already taken');
throw new ForbiddenException('Insufficient permissions');
throw new UnauthorizedException('Invalid credentials');
```
### 自定义异常过滤器
```typescript
// ✅ 全局异常过滤器——统一响应格式
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
private readonly logger = new Logger(AllExceptionsFilter.name);
catch(exception: unknown, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse();
const request = ctx.getRequest();
const status =
exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
this.logger.error(`${request.method} ${request.url} - ${status}`, exception instanceof Error ? exception.stack : '');
response.status(status).json({
statusCode: status,
timestamp: new Date().toISOString(),
path: request.url,
});
}
}
```
---
## 循环依赖
### 模块间循环引用
```typescript
// ❌ Module A ↔ Module B
@Module({ imports: [UsersModule] })
export class OrdersModule {}
@Module({ imports: [OrdersModule] })
export class UsersModule {}
// ✅ 提取共享逻辑到第三个模块
@Module({
providers: [SharedService],
exports: [SharedService],
})
export class SharedModule {}
@Module({ imports: [SharedModule] })
export class OrdersModule {}
@Module({ imports: [SharedModule] })
export class UsersModule {}
```
### forwardRef 是最后手段
```typescript
// ⚠️ forwardRef 表示设计有问题——优先重新设计
@Module({
imports: [forwardRef(() => UsersModule)],
})
export class OrdersModule {}
// ✅ 重新设计消除循环:
// 1. 提取共享模块
// 2. 使用事件驱动(EventEmitter)代替直接调用
// 3. 将共享逻辑提升到上层 Service
```
---
## 测试模式
### Use-Case 可脱离 NestJS 测试
```typescript
// ✅ 无需 NestFactory——直接 new
describe('CreateUserHandler', () => {
let handler: CreateUserHandler;
let repo: InMemoryUserRepository;
beforeEach(() => {
repo = new InMemoryUserRepository();
handler = new CreateUserHandler(repo);
});
it('creates a user', async () => {
const id = await handler.execute(
new CreateUserCommand('user@example.com', 'Alice'),
);
expect(id).toBeDefined();
});
it('rejects duplicate email', async () => {
await handler.execute(new CreateUserCommand('user@example.com', 'Alice'));
await expect(
handler.execute(new CreateUserCommand('user@example.com', 'Bob')),
).rejects.toThrow('already exists');
});
});
```
### E2E 测试应配置与生产一致的 Pipes
```typescript
describe('UsersController (e2e)', () => {
let app: INestApplication;
beforeAll(async () => {
const moduleFixture = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
// 必须与 main.ts 中相同的全局配置
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
await app.init();
});
it('/POST users - valid', () => {
return request(app.getHttpServer())
.post('/users')
.send({ email: 'test@test.com', name: 'Test' })
.expect(201);
});
it('/POST users - extra fields rejected', () => {
return request(app.getHttpServer())
.post('/users')
.send({ email: 'test@test.com', name: 'Test', role: 'admin' })
.expect(400);
});
});
```
---
## Review Checklist
### 分层架构
- [ ] ORM/Prisma 未直接注入 Controller
- [ ] 业务逻辑不在 Controller 中
- [ ] Repository 之间无互相注入
- [ ] Service 依赖数 ≤ 8(超出则拆分为 Use-Case)
### 依赖注入
- [ ] 接口 + Symbol Token 用于可替换的依赖
- [ ] 无 `forwardRef()`(如有,需设计文档说明原因)
- [ ] Scoped 服务未注入到 Singleton 中
### 验证
- [ ] 每个 `@ValidateNested()` 都有对应的 `@Type()`
- [ ] 全局 `ValidationPipe({ whitelist: true, forbidNonWhitelisted: true })` 已配置
- [ ] 无 `@Body() body: any`——必须使用 DTO
- [ ] Create 和 Update 使用不同 DTO(`PartialType`)
- [ ] 数组验证使用 `{ each: true }`
- [ ] 可选嵌套对象使用 `@IsOptional()` + `@ValidateNested()` + `@Type()`
### Guard / Interceptor / Pipe
- [ ] Guard 只做授权检查,不查询数据库
- [ ] Interceptor 只用于横切关注点(日志、缓存、响应转换)
- [ ] 业务规则在 Service 中
### 错误处理
- [ ] 无 `catch { return null }`——抛出有意义的异常
- [ ] 使用 NestJS 内置异常类
- [ ] 自定义异常过滤器在 `common/filters/` 中
### 模块
- [ ] 无循环模块引用
- [ ] Domain Entity 无框架装饰器(`@Injectable` 等)
- [ ] 外部服务调用在 `integrations/` 中
### 测试
- [ ] Use-Case Service 可脱离 NestJS 测试
- [ ] E2E 测试配置与生产一致的全局 Pipes/Guards
- [ ] Domain Entity 零框架依赖
@@ -0,0 +1,816 @@
# Performance Review Guide
性能审查指南,覆盖前端、后端、数据库、算法复杂度和 API 性能。
## 目录
- [前端性能 (Core Web Vitals)](#前端性能-core-web-vitals)
- [JavaScript 性能](#javascript-性能)
- [内存管理](#内存管理)
- [数据库性能](#数据库性能)
- [API 性能](#api-性能)
- [算法复杂度](#算法复杂度)
- [性能审查清单](#性能审查清单)
---
## 前端性能 (Core Web Vitals)
### 2024 核心指标
| 指标 | 全称 | 目标值 | 含义 |
|------|------|--------|------|
| **LCP** | Largest Contentful Paint | ≤ 2.5s | 最大内容绘制时间 |
| **INP** | Interaction to Next Paint | ≤ 200ms | 交互响应时间(2024 年替代 FID)|
| **CLS** | Cumulative Layout Shift | ≤ 0.1 | 累积布局偏移 |
| **FCP** | First Contentful Paint | ≤ 1.8s | 首次内容绘制 |
| **TBT** | Total Blocking Time | ≤ 200ms | 主线程阻塞时间 |
### LCP 优化检查
```javascript
// ❌ LCP 图片懒加载 - 延迟关键内容
<img src="hero.jpg" loading="lazy" />
// ✅ LCP 图片立即加载
<img src="hero.jpg" fetchpriority="high" />
// ❌ 未优化的图片格式
<img src="hero.png" /> // PNG 文件过大
// ✅ 现代图片格式 + 响应式
<picture>
<source srcset="hero.avif" type="image/avif" />
<source srcset="hero.webp" type="image/webp" />
<img src="hero.jpg" alt="Hero" />
</picture>
```
**审查要点:**
- [ ] LCP 元素是否设置 `fetchpriority="high"`?
- [ ] 是否使用 WebP/AVIF 格式?
- [ ] 是否有服务端渲染或静态生成?
- [ ] CDN 是否配置正确?
### FCP 优化检查
```html
<!-- ❌ 阻塞渲染的 CSS -->
<link rel="stylesheet" href="all-styles.css" />
<!-- ✅ 关键 CSS 内联 + 异步加载其余 -->
<style>/* 首屏关键样式 */</style>
<link rel="preload" href="styles.css" as="style" onload="this.onload=null;this.rel='stylesheet'" />
<!-- ❌ 阻塞渲染的字体 -->
@font-face {
font-family: 'CustomFont';
src: url('font.woff2');
}
<!-- ✅ 字体显示优化 -->
@font-face {
font-family: 'CustomFont';
src: url('font.woff2');
font-display: swap; /* 先用系统字体,加载后切换 */
}
```
### INP 优化检查
```javascript
// ❌ 长任务阻塞主线程
button.addEventListener('click', () => {
// 耗时 500ms 的同步操作
processLargeData(data);
updateUI();
});
// ✅ 拆分长任务
button.addEventListener('click', async () => {
// 让出主线程
await scheduler.yield?.() ?? new Promise(r => setTimeout(r, 0));
// 分批处理
for (const chunk of chunks) {
processChunk(chunk);
await scheduler.yield?.();
}
updateUI();
});
// ✅ 使用 Web Worker 处理复杂计算
const worker = new Worker('heavy-computation.js');
worker.postMessage(data);
worker.onmessage = (e) => updateUI(e.data);
```
### CLS 优化检查
```css
/* ❌ 未指定尺寸的媒体 */
img { width: 100%; }
/* ✅ 预留空间 */
img {
width: 100%;
aspect-ratio: 16 / 9;
}
/* ❌ 动态插入内容导致布局偏移 */
.ad-container { }
/* ✅ 预留固定高度 */
.ad-container {
min-height: 250px;
}
```
**CLS 审查清单:**
- [ ] 图片/视频是否有 width/height 或 aspect-ratio?
- [ ] 字体加载是否使用 `font-display: swap`?
- [ ] 动态内容是否预留空间?
- [ ] 是否避免在现有内容上方插入内容?
---
## JavaScript 性能
### 代码分割与懒加载
```javascript
// ❌ 一次性加载所有代码
import { HeavyChart } from './charts';
import { PDFExporter } from './pdf';
import { AdminPanel } from './admin';
// ✅ 按需加载
const HeavyChart = lazy(() => import('./charts'));
const PDFExporter = lazy(() => import('./pdf'));
// ✅ 路由级代码分割
const routes = [
{
path: '/dashboard',
component: lazy(() => import('./pages/Dashboard')),
},
{
path: '/admin',
component: lazy(() => import('./pages/Admin')),
},
];
```
### Bundle 体积优化
```javascript
// ❌ 导入整个库
import _ from 'lodash';
import moment from 'moment';
// ✅ 按需导入
import debounce from 'lodash/debounce';
import { format } from 'date-fns';
// ❌ 未使用 Tree Shaking
export default {
fn1() {},
fn2() {}, // 未使用但被打包
};
// ✅ 命名导出支持 Tree Shaking
export function fn1() {}
export function fn2() {}
```
**Bundle 审查清单:**
- [ ] 是否使用动态 import() 进行代码分割?
- [ ] 大型库是否按需导入?
- [ ] 是否分析过 bundle 大小?(webpack-bundle-analyzer)
- [ ] 是否有未使用的依赖?
### 列表渲染优化
```javascript
// ❌ 渲染大列表
function List({ items }) {
return (
<ul>
{items.map(item => <li key={item.id}>{item.name}</li>)}
</ul>
); // 10000 条数据 = 10000 个 DOM 节点
}
// ✅ 虚拟列表 - 只渲染可见项
import { FixedSizeList } from 'react-window';
function VirtualList({ items }) {
return (
<FixedSizeList
height={400}
itemCount={items.length}
itemSize={35}
>
{({ index, style }) => (
<div style={style}>{items[index].name}</div>
)}
</FixedSizeList>
);
}
```
**大数据审查要点:**
- [ ] 列表超过 100 项是否使用虚拟滚动?
- [ ] 表格是否支持分页或虚拟化?
- [ ] 是否有不必要的全量渲染?
---
## 内存管理
### 常见内存泄漏
#### 1. 未清理的事件监听
```javascript
// ❌ 组件卸载后事件仍在监听
useEffect(() => {
window.addEventListener('resize', handleResize);
}, []);
// ✅ 清理事件监听
useEffect(() => {
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
```
#### 2. 未清理的定时器
```javascript
// ❌ 定时器未清理
useEffect(() => {
setInterval(fetchData, 5000);
}, []);
// ✅ 清理定时器
useEffect(() => {
const timer = setInterval(fetchData, 5000);
return () => clearInterval(timer);
}, []);
```
#### 3. 闭包引用
```javascript
// ❌ 闭包持有大对象引用
function createHandler() {
const largeData = new Array(1000000).fill('x');
return function handler() {
// largeData 被闭包引用,无法被回收
console.log(largeData.length);
};
}
// ✅ 只保留必要数据
function createHandler() {
const largeData = new Array(1000000).fill('x');
const length = largeData.length; // 只保留需要的值
return function handler() {
console.log(length);
};
}
```
#### 4. 未清理的订阅
```javascript
// ❌ WebSocket/EventSource 未关闭
useEffect(() => {
const ws = new WebSocket('wss://...');
ws.onmessage = handleMessage;
}, []);
// ✅ 清理连接
useEffect(() => {
const ws = new WebSocket('wss://...');
ws.onmessage = handleMessage;
return () => ws.close();
}, []);
```
### 内存审查清单
```markdown
- [ ] useEffect 是否都有清理函数?
- [ ] 事件监听是否在组件卸载时移除?
- [ ] 定时器是否被清理?
- [ ] WebSocket/SSE 连接是否关闭?
- [ ] 大对象是否及时释放?
- [ ] 是否有全局变量累积数据?
```
### 检测工具
| 工具 | 用途 |
|------|------|
| Chrome DevTools Memory | 堆快照分析 |
| MemLab (Meta) | 自动化内存泄漏检测 |
| Performance Monitor | 实时内存监控 |
---
## 数据库性能
### N+1 查询问题
```python
# ❌ N+1 问题 - 1 + N 次查询
users = User.objects.all() # 1 次查询
for user in users:
print(user.profile.bio) # N 次查询(每个用户一次)
# ✅ Eager Loading - 2 次查询
users = User.objects.select_related('profile').all()
for user in users:
print(user.profile.bio) # 无额外查询
# ✅ 多对多关系用 prefetch_related
posts = Post.objects.prefetch_related('tags').all()
```
```javascript
// TypeORM 示例
// ❌ N+1 问题
const users = await userRepository.find();
for (const user of users) {
const posts = await user.posts; // 每次循环都查询
}
// ✅ Eager Loading
const users = await userRepository.find({
relations: ['posts'],
});
```
### 索引优化
```sql
-- ❌ 全表扫描
SELECT * FROM orders WHERE status = 'pending';
-- ✅ 添加索引
CREATE INDEX idx_orders_status ON orders(status);
-- ❌ 索引失效:函数操作
SELECT * FROM users WHERE YEAR(created_at) = 2024;
-- ✅ 范围查询可用索引
SELECT * FROM users
WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01';
-- ❌ 索引失效:LIKE 前缀通配符
SELECT * FROM products WHERE name LIKE '%phone%';
-- ✅ 前缀匹配可用索引
SELECT * FROM products WHERE name LIKE 'phone%';
```
### 查询优化
```sql
-- ❌ SELECT * 获取不需要的列
SELECT * FROM users WHERE id = 1;
-- ✅ 只查询需要的列
SELECT id, name, email FROM users WHERE id = 1;
-- ❌ 大表无 LIMIT
SELECT * FROM logs WHERE type = 'error';
-- ✅ 分页查询
SELECT * FROM logs WHERE type = 'error' LIMIT 100 OFFSET 0;
-- ❌ 在循环中执行查询
for id in user_ids:
cursor.execute("SELECT * FROM users WHERE id = %s", (id,))
-- ✅ 批量查询
cursor.execute("SELECT * FROM users WHERE id IN %s", (tuple(user_ids),))
```
### 数据库审查清单
```markdown
🔴 必须检查:
- [ ] 是否存在 N+1 查询?
- [ ] WHERE 子句列是否有索引?
- [ ] 是否避免了 SELECT *?
- [ ] 大表查询是否有 LIMIT?
🟡 建议检查:
- [ ] 是否使用了 EXPLAIN 分析查询计划?
- [ ] 复合索引列顺序是否正确?
- [ ] 是否有未使用的索引?
- [ ] 是否有慢查询日志监控?
```
---
## API 性能
### 分页实现
```javascript
// ❌ 返回全部数据
app.get('/users', async (req, res) => {
const users = await User.findAll(); // 可能返回 100000 条
res.json(users);
});
// ✅ 分页 + 限制最大数量
app.get('/users', async (req, res) => {
const page = parseInt(req.query.page) || 1;
const limit = Math.min(parseInt(req.query.limit) || 20, 100); // 最大 100
const offset = (page - 1) * limit;
const { rows, count } = await User.findAndCountAll({
limit,
offset,
order: [['id', 'ASC']],
});
res.json({
data: rows,
pagination: {
page,
limit,
total: count,
totalPages: Math.ceil(count / limit),
},
});
});
```
### 缓存策略
```javascript
// ✅ Redis 缓存示例
async function getUser(id) {
const cacheKey = `user:${id}`;
// 1. 检查缓存
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
// 2. 查询数据库
const user = await db.users.findById(id);
// 3. 写入缓存(设置过期时间)
await redis.setex(cacheKey, 3600, JSON.stringify(user));
return user;
}
// ✅ HTTP 缓存头
app.get('/static-data', (req, res) => {
res.set({
'Cache-Control': 'public, max-age=86400', // 24 小时
'ETag': 'abc123',
});
res.json(data);
});
```
### 响应压缩
```javascript
// ✅ 启用 Gzip/Brotli 压缩
const compression = require('compression');
app.use(compression());
// ✅ 只返回必要字段
// 请求: GET /users?fields=id,name,email
app.get('/users', async (req, res) => {
const fields = req.query.fields?.split(',') || ['id', 'name'];
const users = await User.findAll({
attributes: fields,
});
res.json(users);
});
```
### 限流保护
```javascript
// ✅ 速率限制
const rateLimit = require('express-rate-limit');
const limiter = rateLimit({
windowMs: 60 * 1000, // 1 分钟
max: 100, // 最多 100 次请求
message: { error: 'Too many requests, please try again later.' },
});
app.use('/api/', limiter);
```
### API 审查清单
```markdown
- [ ] 列表接口是否有分页?
- [ ] 是否限制了每页最大数量?
- [ ] 热点数据是否有缓存?
- [ ] 是否启用了响应压缩?
- [ ] 是否有速率限制?
- [ ] 是否只返回必要字段?
```
---
## 算法复杂度
### 常见复杂度对比
| 复杂度 | 名称 | 10 条 | 1000 条 | 100 万条 | 示例 |
|--------|------|-------|---------|----------|------|
| O(1) | 常数 | 1 | 1 | 1 | 哈希查找 |
| O(log n) | 对数 | 3 | 10 | 20 | 二分查找 |
| O(n) | 线性 | 10 | 1000 | 100 万 | 遍历数组 |
| O(n log n) | 线性对数 | 33 | 10000 | 2000 万 | 快速排序 |
| O(n²) | 平方 | 100 | 100 万 | 1 万亿 | 嵌套循环 |
| O(2ⁿ) | 指数 | 1024 | ∞ | ∞ | 递归斐波那契 |
### 代码审查中的识别
```javascript
// ❌ O(n²) - 嵌套循环
function findDuplicates(arr) {
const duplicates = [];
for (let i = 0; i < arr.length; i++) {
for (let j = i + 1; j < arr.length; j++) {
if (arr[i] === arr[j]) {
duplicates.push(arr[i]);
}
}
}
return duplicates;
}
// ✅ O(n) - 使用 Set
function findDuplicates(arr) {
const seen = new Set();
const duplicates = new Set();
for (const item of arr) {
if (seen.has(item)) {
duplicates.add(item);
}
seen.add(item);
}
return [...duplicates];
}
```
```javascript
// ❌ O(n²) - 每次循环都调用 includes
function removeDuplicates(arr) {
const result = [];
for (const item of arr) {
if (!result.includes(item)) { // includes 是 O(n)
result.push(item);
}
}
return result;
}
// ✅ O(n) - 使用 Set
function removeDuplicates(arr) {
return [...new Set(arr)];
}
```
```javascript
// ❌ O(n) 查找 - 每次都遍历
const users = [{ id: 1, name: 'A' }, { id: 2, name: 'B' }, ...];
function getUser(id) {
return users.find(u => u.id === id); // O(n)
}
// ✅ O(1) 查找 - 使用 Map
const userMap = new Map(users.map(u => [u.id, u]));
function getUser(id) {
return userMap.get(id); // O(1)
}
```
### 空间复杂度考虑
```javascript
// ⚠️ O(n) 空间 - 创建新数组
const doubled = arr.map(x => x * 2);
// ✅ O(1) 空间 - 原地修改(如果允许)
for (let i = 0; i < arr.length; i++) {
arr[i] *= 2;
}
// ⚠️ 递归深度过大可能栈溢出
function factorial(n) {
if (n <= 1) return 1;
return n * factorial(n - 1); // O(n) 栈空间
}
// ✅ 迭代版本 O(1) 空间
function factorial(n) {
let result = 1;
for (let i = 2; i <= n; i++) {
result *= i;
}
return result;
}
```
### 复杂度审查问题
```markdown
💡 "这个嵌套循环的复杂度是 O(n²),数据量大时会有性能问题"
🔴 "这里用 Array.includes() 在循环中,整体是 O(n²),建议用 Set"
🟡 "这个递归深度可能导致栈溢出,建议改为迭代或尾递归"
```
---
## 性能审查清单
### 🔴 必须检查(阻塞级)
**前端:**
- [ ] LCP 图片是否懒加载?(不应该)
- [ ] 是否有 `transition: all`?
- [ ] 是否动画 width/height/top/left?
- [ ] 列表 >100 项是否虚拟化?
**后端:**
- [ ] 是否存在 N+1 查询?
- [ ] 列表接口是否有分页?
- [ ] 是否有 SELECT * 查大表?
**通用:**
- [ ] 是否有 O(n²) 或更差的嵌套循环?
- [ ] useEffect/事件监听是否有清理?
### 🟡 建议检查(重要级)
**前端:**
- [ ] 是否使用代码分割?
- [ ] 大型库是否按需导入?
- [ ] 图片是否使用 WebP/AVIF?
- [ ] 是否有未使用的依赖?
**后端:**
- [ ] 热点数据是否有缓存?
- [ ] WHERE 列是否有索引?
- [ ] 是否有慢查询监控?
**API:**
- [ ] 是否启用响应压缩?
- [ ] 是否有速率限制?
- [ ] 是否只返回必要字段?
### 🟢 优化建议(建议级)
- [ ] 是否分析过 bundle 大小?
- [ ] 是否使用 CDN?
- [ ] 是否有性能监控?
- [ ] 是否做过性能基准测试?
---
## 性能度量阈值
### 前端指标
| 指标 | 好 | 需改进 | 差 |
|------|-----|--------|-----|
| LCP | ≤ 2.5s | 2.5-4s | > 4s |
| INP | ≤ 200ms | 200-500ms | > 500ms |
| CLS | ≤ 0.1 | 0.1-0.25 | > 0.25 |
| FCP | ≤ 1.8s | 1.8-3s | > 3s |
| Bundle Size (JS) | < 200KB | 200-500KB | > 500KB |
### 后端指标
| 指标 | 好 | 需改进 | 差 |
|------|-----|--------|-----|
| API 响应时间 | < 100ms | 100-500ms | > 500ms |
| 数据库查询 | < 50ms | 50-200ms | > 200ms |
| 页面加载 | < 3s | 3-5s | > 5s |
---
## 工具推荐
### 前端性能
| 工具 | 用途 |
|------|------|
| [Lighthouse](https://developer.chrome.com/docs/lighthouse/) | Core Web Vitals 测试 |
| [WebPageTest](https://www.webpagetest.org/) | 详细性能分析 |
| [webpack-bundle-analyzer](https://github.com/webpack-contrib/webpack-bundle-analyzer) | Bundle 分析 |
| [Chrome DevTools Performance](https://developer.chrome.com/docs/devtools/performance/) | 运行时性能分析 |
### 内存检测
| 工具 | 用途 |
|------|------|
| [MemLab](https://github.com/facebookincubator/memlab) | 自动化内存泄漏检测 |
| Chrome Memory Tab | 堆快照分析 |
### 后端性能
| 工具 | 用途 |
|------|------|
| EXPLAIN | 数据库查询计划分析 |
| [pganalyze](https://pganalyze.com/) | PostgreSQL 性能监控 |
| [New Relic](https://newrelic.com/) / [Datadog](https://www.datadoghq.com/) | APM 监控 |
---
## 低级别效率反模式
代码层面的效率失误,独立于架构层面的性能问题。补充 [common-bugs-checklist.md](common-bugs-checklist.md) 中已涵盖的资源管理与并发缺陷。
### 不必要的重复工作
- [ ] 同一函数 / 查询是否在同一 request/render 中被重复调用?
- [ ] 文件 / 配置是否在循环内重复读取(loop-invariant)?
- [ ] 计算结果是否可以被缓存或向下游传递?
```typescript
// ❌ loop-invariant 在循环内反复执行
for (const path of paths) {
const config = JSON.parse(fs.readFileSync("config.json", "utf-8"));
processFile(path, config);
}
// ✅ 提到循环外
const config = JSON.parse(fs.readFileSync("config.json", "utf-8"));
for (const path of paths) processFile(path, config);
```
### 错失的并发机会
- [ ] 独立的 async 操作是否顺序 `await`?
- [ ] 是否可以用 `Promise.all` / `asyncio.gather` / `tokio::join!` 并发?
```typescript
// ❌ 顺序 await
const a = await fetchA();
const b = await fetchB();
// ✅ 并发
const [a, b] = await Promise.all([fetchA(), fetchB()]);
```
### 热路径膨胀
- [ ] 模块级 / import 时代码是否执行重操作(文件 I/O、网络、大对象构造)?
- [ ] per-request 路径是否有可延迟的初始化?
- [ ] 启动时代码是否阻塞首次请求?
### 无界数据结构
> 资源生命周期相关缺陷(未关闭的连接、未移除的监听器、未清除的定时器)见 [common-bugs-checklist.md → Resource Management](common-bugs-checklist.md#resource-management)。本节聚焦 *容量边界*。
- [ ] 全局 dict / list / 缓存是否有 `max-size` 或 TTL?
- [ ] 累积型数据结构(队列、日志、metrics buffer)是否有上限?
- [ ] 每请求分配的对象是否会被持久引用而无法 GC?
```python
# ❌ 无界缓存
_cache: dict[str, Any] = {}
# ✅ 有界 LRU
from functools import lru_cache
@lru_cache(maxsize=256)
def get_cached(key: str) -> Any:
return expensive_computation(key)
```
---
## 参考资源
- [Core Web Vitals - web.dev](https://web.dev/articles/vitals)
- [Optimizing Core Web Vitals - Vercel](https://vercel.com/guides/optimizing-core-web-vitals-in-2024)
- [MemLab - Meta Engineering](https://engineering.fb.com/2022/09/12/open-source/memlab/)
- [Big O Cheat Sheet](https://www.bigocheatsheet.com/)
- [N+1 Query Problem - Stack Overflow](https://stackoverflow.com/questions/97197/what-is-the-n1-selects-problem-in-orm-object-relational-mapping)
- [API Performance Optimization](https://algorithmsin60days.com/blog/optimizing-api-performance/)
+704
View File
@@ -0,0 +1,704 @@
# PHP Code Review Guide
> PHP 8.x code review guide covering the type system, modern language features, OOP modeling, PDO data access, security, error handling, Composer dependencies, performance, and testing.
## Table of Contents
- [Quick Review Checklist](#quick-review-checklist)
- [Type System & Modern PHP](#type-system--modern-php)
- [Object Modeling](#object-modeling)
- [Input, Output & Security](#input-output--security)
- [Database Access](#database-access)
- [Error Handling](#error-handling)
- [Composer & Dependencies](#composer--dependencies)
- [Performance & Resource Management](#performance--resource-management)
- [Testing & Static Analysis](#testing--static-analysis)
- [Review Checklist](#review-checklist)
- [References](#references)
---
## Quick Review Checklist
### Must-check
- [ ] New files enable `declare(strict_types=1);`
- [ ] Public APIs have parameter, return, and property types
- [ ] User input is validated; output is escaped per context
- [ ] SQL uses parameterized queries or ORM binding
- [ ] Passwords use `password_hash()` / `password_verify()`
- [ ] File uploads validate MIME, size, extension, and storage path
- [ ] `composer.lock` is committed; dependency ranges are reasonable
- [ ] PHPUnit/Pest tests and PHPStan/Psalm static analysis are present
### Common issues
- [ ] Loose comparison `==` / `!=` causing type-juggling vulnerabilities
- [ ] `md5()` / `sha1()` used to store passwords
- [ ] Concatenating SQL, HTML, shell commands, or file paths
- [ ] Using `@` to suppress errors
- [ ] `unserialize()` on untrusted data
- [ ] `$_GET` / `$_POST` / `$_FILES` flowing straight into business logic
- [ ] PHP 8.2+ dynamic properties trigger a deprecation; PHP 9 may turn it into an error
---
## Type System & Modern PHP
### strict_types and explicit types
```php
<?php
// ❌ weak boundary: passing "42" gets silently coerced
function findUser($id) {
return User::find($id);
}
// ✅ enable strict_types at the top of the file; type the public API
declare(strict_types=1);
function findUser(int $id): ?User
{
return User::find($id);
}
```
Don't leave type checking entirely to runtime input validation. Type declarations express an internal contract; input validation expresses how much to trust the boundary. You need both.
### Avoid loose comparisons
```php
<?php
// ❌ strings like "0e12345" can be treated as 0 under loose comparison
if ($providedHash == $storedHash) {
grantAccess();
}
// ✅ strict comparison; use hash_equals() for secrets or tokens
if (hash_equals($storedHash, $providedHash)) {
grantAccess();
}
// ✅ match uses identity checks, so fewer type-juggling surprises than switch
$status = match ($code) {
200 => 'ok',
404 => 'not_found',
default => 'unknown',
};
```
Pay attention to `==`, `!=`, and `in_array($x, $list)` (loose by default) in auth, payment, state machine, and permission logic. Use `===`, `!==`, and `in_array($x, $list, true)` where it matters.
### Union / intersection / nullable types
```php
<?php
// ❌ mixed or untyped makes callers guess the return shape
function loadConfig($source) {
return parseConfig($source);
}
// ✅ express the real contract with types
function loadConfig(string|PathInfo $source): Config
{
return parseConfig($source);
}
// ✅ make null explicit when it's a real business state
function currentUser(): ?User
{
return Auth::user();
}
```
`mixed` can show up at the boundary or while migrating legacy code, but in core business services it usually signals missing modeling.
### The nullsafe operator shouldn't hide missing state
```php
<?php
// ❌ chained nullsafe blurs the reason for failure
$country = $order?->customer?->profile?->country;
// ✅ branch explicitly on critical business state
if ($order === null) {
throw new OrderNotFound();
}
$customer = $order->customer();
if ($customer === null) {
throw new MissingCustomer($order->id);
}
$country = $customer->profile()?->country;
```
Distinguish "optional display field" from "business invariant that must exist." The former is a good fit for `?->`; the latter should fail loudly.
---
## Object Modeling
### Use readonly properties and value objects
```php
<?php
// ❌ public mutable fields let callers change state at will
class Money
{
public $amount;
public $currency;
}
// ✅ express an immutable value object with types and readonly
final readonly class Money
{
public function __construct(
public int $amount,
public string $currency,
) {
if ($amount < 0) {
throw new InvalidArgumentException('Amount must be non-negative');
}
}
}
```
For DTOs, config, and domain value objects, check first whether a `readonly class` or readonly properties can remove hidden side effects.
### Enums instead of string states
```php
<?php
// ❌ string states are easy to typo and can't enumerate the legal set
if ($order->status === 'paied') {
ship($order);
}
// ✅ an enum surfaces illegal states earlier
enum OrderStatus: string
{
case Pending = 'pending';
case Paid = 'paid';
case Cancelled = 'cancelled';
}
if ($order->status === OrderStatus::Paid) {
ship($order);
}
```
When reviewing state machines, permissions, or type fields, look for "magic string values." If the value set is stable, suggest an enum; if it comes from an external system, convert it to an internal enum before it enters the business layer.
### Don't rely on dynamic properties
```php
<?php
// ❌ PHP 8.2+ triggers a deprecation when creating a dynamic property
$user = new User();
$user->emali = 'a@example.com'; // a typo also silently creates a property
// ✅ declare properties or use a dedicated data structure
final class User
{
public function __construct(
public string $email,
) {}
}
```
`#[AllowDynamicProperties]` should be an exception for legacy compatibility, not the default for new code. Watch for serialization, ORM hydration, and test doubles that secretly rely on dynamic properties.
### Don't do heavy I/O in constructors
```php
<?php
// ❌ quietly connecting to the DB on construction makes testing and error handling hard
final class ReportService
{
private PDO $pdo;
public function __construct()
{
$this->pdo = new PDO($_ENV['DSN']);
}
}
// ✅ inject dependencies from the outside
final class ReportService
{
public function __construct(
private PDO $pdo,
) {}
}
```
A constructor should establish the object's invariants — not send HTTP requests, open connections, read large files, or run complex queries.
---
## Input, Output & Security
### Validate input at the boundary
```php
<?php
// ❌ superglobals flow straight into business logic
$user = $service->create($_POST['email'], $_POST['age']);
// ✅ validate and coerce types at the boundary first
$email = filter_input(INPUT_POST, 'email', FILTER_VALIDATE_EMAIL);
$age = filter_input(INPUT_POST, 'age', FILTER_VALIDATE_INT, [
'options' => ['min_range' => 0, 'max_range' => 130],
]);
if ($email === false || $email === null || $age === false || $age === null) {
throw new InvalidInput();
}
$user = $service->create($email, $age);
```
`filter_input()` only handles a slice of basic validation. Complex rules, cross-field constraints, and business constraints still need a dedicated validator or request DTO.
### Escape output per context
```php
<?php
// ❌ user input goes straight into HTML
echo "<h1>Hello {$_GET['name']}</h1>";
// ✅ use htmlspecialchars in an HTML text context
$name = (string) ($_GET['name'] ?? '');
echo '<h1>Hello ' . htmlspecialchars($name, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</h1>';
```
Different contexts need different escaping: HTML text, HTML attributes, URLs, JavaScript strings, and CSS are all different. When a template engine's default escaping is turned off, treat it as a security risk.
### Passwords and randomness
```php
<?php
// ❌ md5/sha1 must not be used for password storage
$hash = md5($password);
// ✅ use PHP's built-in password API
$hash = password_hash($password, PASSWORD_DEFAULT);
if (!password_verify($password, $hash)) {
throw new InvalidCredentials();
}
// ✅ use a CSPRNG for tokens
$token = bin2hex(random_bytes(32));
$code = random_int(100000, 999999);
```
Don't hand-roll salts, round migration, or password comparison. Use `password_needs_rehash()` when you need to upgrade the cost factor.
### Deserialization and object injection
```php
<?php
// ❌ untrusted input into unserialize can trigger object injection
$payload = unserialize($_COOKIE['state']);
// ✅ prefer JSON for external data, and validate its schema/shape
$payload = json_decode($_COOKIE['state'] ?? '{}', true, flags: JSON_THROW_ON_ERROR);
```
If you must process historical serialized data, at least restrict `allowed_classes` and make sure the relevant classes' magic methods can't produce dangerous side effects.
### File uploads and paths
```php
<?php
// ❌ building the path from the raw filename
$target = __DIR__ . '/uploads/' . $_FILES['avatar']['name'];
move_uploaded_file($_FILES['avatar']['tmp_name'], $target);
// ✅ generate a server-side filename, check the upload error and MIME
$file = $_FILES['avatar'];
if ($file['error'] !== UPLOAD_ERR_OK) {
throw new UploadFailed();
}
$finfo = new finfo(FILEINFO_MIME_TYPE);
$mime = $finfo->file($file['tmp_name']);
if (!in_array($mime, ['image/png', 'image/jpeg'], true)) {
throw new InvalidFileType();
}
$target = __DIR__ . '/uploads/' . bin2hex(random_bytes(16)) . '.jpg';
move_uploaded_file($file['tmp_name'], $target);
```
When reviewing upload features, check size limits, MIME detection, extensions, a non-executable storage directory, path traversal, overwrite protection, and any virus-scan or async-processing requirements.
---
## Database Access
### Use parameterized queries
```php
<?php
// ❌ concatenated SQL is an injection risk
$sql = "SELECT * FROM users WHERE email = '" . $_GET['email'] . "'";
$user = $pdo->query($sql)->fetch();
// ✅ PDO prepared statement + bound value
$stmt = $pdo->prepare('SELECT id, email FROM users WHERE email = :email');
$stmt->execute(['email' => $email]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
```
Parameters can only bind values — not table names, column names, or sort direction. Dynamic identifiers must go through a whitelist mapping.
```php
<?php
// ✅ whitelist the dynamic sort column
$columns = [
'created' => 'created_at',
'email' => 'email',
];
$column = $columns[$_GET['sort'] ?? 'created'] ?? $columns['created'];
$stmt = $pdo->query("SELECT id, email FROM users ORDER BY {$column} DESC");
```
### Wrap multi-step writes in transactions
```php
<?php
// ❌ multi-step writes with no transaction leave half-finished state on failure
$orderId = $orders->create($cart);
$inventory->reserve($cart);
$payments->charge($orderId);
// ✅ explicit transaction boundary
$pdo->beginTransaction();
try {
$orderId = $orders->create($cart);
$inventory->reserve($cart);
$payments->recordIntent($orderId);
$pdo->commit();
} catch (Throwable $e) {
$pdo->rollBack();
throw $e;
}
```
Don't casually put external, non-rollbackable side effects (an actual charge, an email, a message dispatch) inside a database transaction. Common patterns are an outbox, an idempotency key, or triggering after the transaction commits.
### Avoid N+1 queries
```php
<?php
// ❌ querying inside a loop
foreach ($orders as $order) {
$customer = $customerRepo->find($order->customerId);
render($order, $customer);
}
// ✅ batch-load, then map
$customerIds = array_unique(array_map(fn ($o) => $o->customerId, $orders));
$customers = $customerRepo->findByIds($customerIds);
foreach ($orders as $order) {
render($order, $customers[$order->customerId] ?? null);
}
```
In ORMs like Laravel/Doctrine, check eager loading, join fetch, selected columns, pagination, and indexes.
---
## Error Handling
### Catch specific exceptions, keep context
```php
<?php
// ❌ swallowing the exception leaves callers unable to know it failed
try {
$mailer->send($message);
} catch (Exception $e) {
}
// ✅ catch a specific exception, keep context, and rethrow
try {
$mailer->send($message);
} catch (TransportException $e) {
throw new NotificationFailed($userId, previous: $e);
}
```
Empty `catch` blocks, `error_log()`-and-continue without surfacing the error, and turning every exception into `RuntimeException('failed')` in production code all deserve a question.
### Don't suppress errors with @
```php
<?php
// ❌ hides the real error and makes debugging hard
$content = @file_get_contents($path);
// ✅ handle failure explicitly
$content = file_get_contents($path);
if ($content === false) {
throw new RuntimeException("Unable to read file: {$path}");
}
```
`@` is common around file, network, array access, and legacy library calls. Push for an explicit branch, or convert third-party errors into project exceptions.
### Don't leak sensitive data in logs
```php
<?php
// ❌ writing tokens, passwords, or the full request body to the log
$logger->error('Login failed', ['request' => $_POST]);
// ✅ log non-sensitive context that still helps locate the problem
$logger->warning('Login failed', [
'email_hash' => hash('sha256', strtolower($email)),
'ip' => $requestIp,
]);
```
Check logs, exception messages, the debug toolbar, error pages, and failed-queue records. Sensitive data includes passwords, tokens, sessions, PII, payment data, and full cookies.
---
## Composer & Dependencies
### Lock reproducible dependencies
```json
{
"require": {
"php": "^8.2",
"monolog/monolog": "^3.0"
},
"require-dev": {
"phpunit/phpunit": "^11.0",
"phpstan/phpstan": "^1.10"
}
}
```
When reviewing `composer.json` / `composer.lock`, watch for:
- Application repos commit `composer.lock`; library repos usually don't
- `require-dev` shouldn't make it into the production image
- The PHP platform version matches the CI version
- Autoload rules aren't too broad (don't load test or script directories)
- `scripts` commands don't depend on a developer's local secret config
### Dependency security and maintenance
```bash
composer audit
composer outdated --direct
composer validate --strict
```
When adding a package, look at its maintenance status — download count isn't the only signal. What matters is its security history, release cadence, minimal dependency footprint, and whether it duplicates the standard library or a framework built-in.
---
## Performance & Resource Management
### Stream large datasets with generators or pagination
```php
<?php
// ❌ loading every record at once
$rows = $repo->all();
foreach ($rows as $row) {
exportRow($row);
}
// ✅ paginate or use a generator to avoid a memory spike
foreach ($repo->cursor() as $row) {
exportRow($row);
}
```
A PHP request lifecycle is short, but CLI jobs, queue workers, and export tasks run for a long time. For that kind of code, watch memory growth, unclosed resources, and global-state pollution especially closely.
### Avoid expensive work inside loops
```php
<?php
// ❌ re-parsing config or opening a connection on every iteration
foreach ($items as $item) {
$client = new ApiClient($_ENV['API_KEY']);
$client->send($item);
}
// ✅ create reusable dependencies outside the loop
$client = new ApiClient($_ENV['API_KEY']);
foreach ($items as $item) {
$client->send($item);
}
```
Watch for database queries, HTTP requests, regex compilation, large array copies, accumulating `array_merge()` appends, and repeatedly reading env vars or config files inside loops.
### Release or scope resources
```php
<?php
// ✅ close file handles after use
$handle = fopen($path, 'rb');
if ($handle === false) {
throw new RuntimeException('Unable to open file');
}
try {
while (($line = fgets($handle)) !== false) {
process($line);
}
} finally {
fclose($handle);
}
```
PDO connections are usually managed by the container, but file handles, curl handles, temp files, locks, and cached objects in queue workers still need an explicit lifecycle.
---
## Testing & Static Analysis
### Test behavior, not implementation details
```php
<?php
// ❌ asserting an internal method call makes refactoring expensive
$mailer->expects($this->once())->method('buildTemplate');
// ✅ assert observable results
$service->sendWelcomeEmail($user);
$this->assertTrue($mailbox->hasMessageFor($user->email));
```
For business services, controllers, and queue jobs, prefer covering observable behavior: inputs/outputs, database state, published events, and dispatched messages.
### Static analysis and formatting
```bash
vendor/bin/phpunit
vendor/bin/phpstan analyse
vendor/bin/psalm
vendor/bin/php-cs-fixer fix --dry-run --diff
vendor/bin/rector process --dry-run
```
When reviewing a PR, check whether the new code lowers the PHPStan/Psalm level, leans heavily on baseline ignores, or uses `@phpstan-ignore-next-line` to paper over a real type problem.
### Isolate test data
```php
<?php
// ❌ the test depends on real time and external services
$service->expireOldSessions();
// ✅ inject a clock and a fake gateway
$clock->setNow(new DateTimeImmutable('2026-01-01T00:00:00Z'));
$service->expireOldSessions();
```
Watch for database transaction rollback, fixture cleanup, randomness, time, queues, caches, and external APIs. Slow PHP tests are usually not a language problem — it's that the boundaries aren't isolated.
---
## Review Checklist
### Types & modeling
- [ ] `declare(strict_types=1);` at the top of the file
- [ ] Parameters, return values, and properties have explicit types
- [ ] `===` / `!==` used; collection lookups use strict mode
- [ ] Stable state sets use an enum, not magic strings
- [ ] New code doesn't rely on dynamic properties
- [ ] Value objects are readonly or otherwise immutable
### Security
- [ ] Input is validated and type-coerced at the boundary
- [ ] Output is escaped per HTML/URL/JS/CSS context
- [ ] SQL uses prepared statements or ORM binding
- [ ] Dynamic table/column/sort names go through a whitelist
- [ ] Passwords use `password_hash()` / `password_verify()`
- [ ] Tokens, codes, and filenames use `random_bytes()` / `random_int()`
- [ ] Untrusted input never reaches `unserialize()`
- [ ] File uploads check the error code, size, MIME, extension, and storage directory
- [ ] No injection or leakage risk in shell commands, path building, or log output
### Data & transactions
- [ ] Multi-step writes have a transaction or compensation mechanism
- [ ] External side effects are designed to be idempotent
- [ ] N+1 queries avoided
- [ ] Pagination, indexes, and selected columns are reasonable
- [ ] Database errors aren't swallowed
### Maintainability
- [ ] Constructors don't do heavy I/O
- [ ] Dependency injection is clear; no hidden global state
- [ ] No `@` error suppression
- [ ] Exceptions preserve context and `previous`
- [ ] Composer dependency ranges, autoload, and scripts are reasonable
- [ ] Application repos commit `composer.lock`
### Testing & tooling
- [ ] PHPUnit/Pest cover the critical and failure paths
- [ ] PHPStan/Psalm config doesn't lower strictness
- [ ] New ignores/baselines are explained
- [ ] Formatting tools and CI commands are reproducible
- [ ] Tests isolate time, randomness, the database, queues, and external APIs
---
## References
- [PHP Manual: Type declarations](https://www.php.net/manual/en/language.types.declarations.php)
- [PHP Manual: match](https://www.php.net/match)
- [PHP Manual: Enumerations](https://www.php.net/manual/en/language.enumerations.overview.php)
- [PHP Manual: Properties](https://www.php.net/manual/en/language.oop5.properties.php)
- [PHP Manual: PDO](https://www.php.net/manual/en/class.pdo.php)
- [PHP Manual: password_hash](https://www.php.net/manual/en/function.password-hash.php)
- [PHP Manual: random_bytes](https://www.php.net/manual/en/function.random-bytes.php)
- [Composer documentation](https://getcomposer.org/doc/)
- [PHPUnit documentation](https://docs.phpunit.de/)
- [PHPStan documentation](https://phpstan.org/user-guide/getting-started)
- [Psalm documentation](https://psalm.dev/docs/)
File diff suppressed because it is too large Load Diff
+186
View File
@@ -0,0 +1,186 @@
# Qt Code Review Guide
> Code review guidelines focusing on object model, signals/slots, event loop, and GUI performance. Examples based on Qt 5.15 / Qt 6.
## Table of Contents
- [Object Model & Memory Management](#object-model--memory-management)
- [Signals & Slots](#signals--slots)
- [Containers & Strings](#containers--strings)
- [Threads & Concurrency](#threads--concurrency)
- [GUI & Widgets](#gui--widgets)
- [Meta-Object System](#meta-object-system)
- [Review Checklist](#review-checklist)
---
## Object Model & Memory Management
### Use Parent-Child Ownership Mechanism
Qt's `QObject` hierarchy automatically manages memory. For `QObject`, prefer setting a parent object over manual `delete` or smart pointers.
```cpp
// ❌ Manual management prone to memory leaks
QWidget* w = new QWidget();
QLabel* l = new QLabel();
l->setParent(w);
// ... If w is deleted, l is automatically deleted. But if w leaks, l also leaks.
// ✅ Specify parent in constructor
QWidget* w = new QWidget(this); // Owned by 'this'
QLabel* l = new QLabel(w); // Owned by 'w'
```
### Use Smart Pointers with QObject
If a `QObject` has no parent, use `QScopedPointer` or `std::unique_ptr` with a custom deleter (use `deleteLater` if cross-thread). Avoid `std::shared_ptr` for `QObject` unless necessary, as it confuses the parent-child ownership system.
```cpp
// ✅ Scoped pointer for local/member QObject without parent
QScopedPointer<MyObject> obj(new MyObject());
// ✅ Safe pointer to prevent dangling pointers
QPointer<MyObject> safePtr = obj.data();
if (safePtr) {
safePtr->doSomething();
}
```
### Use `deleteLater()`
For asynchronous deletion, especially in slots or event handlers, use `deleteLater()` instead of `delete` to ensure pending events in the event loop are processed.
---
## Signals & Slots
### Prefer Function Pointer Syntax
Use compile-time checked syntax (Qt 5+).
```cpp
// ❌ String-based (runtime check only, slower)
connect(sender, SIGNAL(valueChanged(int)), receiver, SLOT(updateValue(int)));
// ✅ Compile-time check
connect(sender, &Sender::valueChanged, receiver, &Receiver::updateValue);
```
### Connection Types
Be explicit or aware of connection types when crossing threads.
- `Qt::AutoConnection` (Default): Direct if same thread, Queued if different thread.
- `Qt::QueuedConnection`: Always posts event (thread-safe across threads).
- `Qt::DirectConnection`: Immediate call (dangerous if accessing non-thread-safe data across threads).
### Avoid Loops
Check logic that might cause infinite signal loops (e.g., `valueChanged` -> `setValue` -> `valueChanged`). Block signals or check for equality before setting values.
```cpp
void MyClass::setValue(int v) {
if (m_value == v) return; // ✅ Good: Break loop
m_value = v;
emit valueChanged(v);
}
```
---
## Containers & Strings
### QString Efficiency
- Use `QStringLiteral("...")` for compile-time string creation to avoid runtime allocation.
- Use `QLatin1String` for comparison with ASCII literals (in Qt 5).
- Prefer `arg()` for formatting (or `QStringBuilder`'s `%` operator).
```cpp
// ❌ Runtime conversion
if (str == "test") ...
// ✅ Prefer QLatin1String for comparison with ASCII literals (in Qt 5)
if (str == QLatin1String("test")) ... // Qt 5
if (str == u"test"_s) ... // Qt 6
```
### Container Selection
- **Qt 6**: `QList` is now the default choice (unified with `QVector`).
- **Qt 5**: Prefer `QVector` over `QList` for contiguous memory and cache performance, unless stable references are needed.
- Be aware of Implicit Sharing (Copy-on-Write). Passing containers by value is cheap *until* modified. Use `const &` for read-only access.
```cpp
// ❌ Forces deep copy if function modifies 'list'
void process(QVector<int> list) {
list[0] = 1;
}
// ✅ Read-only reference
void process(const QVector<int>& list) { ... }
```
---
## Threads & Concurrency
### Subclassing QThread vs Worker Object
Prefer the "Worker Object" pattern over subclassing `QThread` implementation details.
```cpp
// ❌ Business logic inside QThread::run()
class MyThread : public QThread {
void run() override { ... }
};
// ✅ Worker object moved to thread
QThread* thread = new QThread;
Worker* worker = new Worker;
worker->moveToThread(thread);
connect(thread, &QThread::started, worker, &Worker::process);
thread->start();
```
### GUI Thread Safety
**NEVER** access UI widgets (`QWidget` and subclasses) from a background thread. Use signals/slots to communicate updates to the main thread.
---
## GUI & Widgets
### Logic Separation
Keep business logic out of UI classes (`MainWindow`, `Dialog`). UI classes should only handle display and user input forwarding.
### Layouts
Avoid fixed sizes (`setGeometry`, `resize`). Use layouts (`QVBoxLayout`, `QGridLayout`) to handle different DPIs and window resizing gracefully.
### Blocking Event Loop
Never execute long-running operations on the main thread (freezes GUI).
- **Bad**: `Sleep()`, `while(busy)`, synchronous network calls.
- **Good**: `QProcess`, `QThread`, `QtConcurrent`, or asynchronous APIs (`QNetworkAccessManager`).
---
## Meta-Object System
### Properties & Enums
Use `Q_PROPERTY` for values exposed to QML or needing introspection.
Use `Q_ENUM` to enable string conversion for enums.
```cpp
class MyObject : public QObject {
Q_OBJECT
Q_PROPERTY(int value READ value WRITE setValue NOTIFY valueChanged)
public:
enum State { Idle, Running };
Q_ENUM(State)
// ...
};
```
### qobject_cast
Use `qobject_cast<T*>` for QObjects instead of `dynamic_cast`. It is faster and doesn't require RTTI.
---
## Review Checklist
- [ ] **Memory**: Is parent-child relationship correct? Are dangling pointers avoided (using `QPointer`)?
- [ ] **Signals**: Are connections checked? Do lambdas use safe captures (context object)?
- [ ] **Threads**: Is UI accessed only from main thread? Are long tasks offloaded?
- [ ] **Strings**: Are `QStringLiteral` or `tr()` used appropriately?
- [ ] **Style**: Naming conventions (camelCase for methods, PascalCase for classes).
- [ ] **Resources**: Are resources (images, styles) loaded from `.qrc`?
+871
View File
@@ -0,0 +1,871 @@
# React Code Review Guide
React 审查重点:Hooks 规则、性能优化的适度性、组件设计、以及现代 React 19/RSC 模式。
## 目录
- [基础 Hooks 规则](#基础-hooks-规则)
- [useEffect 模式](#useeffect-模式)
- [useMemo / useCallback](#usememo--usecallback)
- [组件设计](#组件设计)
- [Error Boundaries & Suspense](#error-boundaries--suspense)
- [Server Components (RSC)](#server-components-rsc)
- [React 19 Actions & Forms](#react-19-actions--forms)
- [Suspense & Streaming SSR](#suspense--streaming-ssr)
- [TanStack Query v5](#tanstack-query-v5)
- [Review Checklists](#review-checklists)
---
## 基础 Hooks 规则
```tsx
// ❌ 条件调用 Hooks — 违反 Hooks 规则
function BadComponent({ isLoggedIn }) {
if (isLoggedIn) {
const [user, setUser] = useState(null); // Error!
}
return <div>...</div>;
}
// ✅ Hooks 必须在组件顶层调用
function GoodComponent({ isLoggedIn }) {
const [user, setUser] = useState(null);
if (!isLoggedIn) return <LoginPrompt />;
return <div>{user?.name}</div>;
}
```
---
## useEffect 模式
```tsx
// ❌ 依赖数组缺失或不完整
function BadEffect({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser(userId).then(setUser);
}, []); // 缺少 userId 依赖!
}
// ✅ 完整的依赖数组
function GoodEffect({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
let cancelled = false;
fetchUser(userId).then(data => {
if (!cancelled) setUser(data);
});
return () => { cancelled = true; }; // 清理函数
}, [userId]);
}
// ❌ useEffect 用于派生状态(反模式)
function BadDerived({ items }) {
const [filteredItems, setFilteredItems] = useState([]);
useEffect(() => {
setFilteredItems(items.filter(i => i.active));
}, [items]); // 不必要的 effect + 额外渲染
return <List items={filteredItems} />;
}
// ✅ 直接在渲染时计算,或用 useMemo
function GoodDerived({ items }) {
const filteredItems = useMemo(
() => items.filter(i => i.active),
[items]
);
return <List items={filteredItems} />;
}
// ❌ useEffect 用于事件响应
function BadEventEffect() {
const [query, setQuery] = useState('');
useEffect(() => {
if (query) {
analytics.track('search', { query }); // 应该在事件处理器中
}
}, [query]);
}
// ✅ 在事件处理器中执行副作用
function GoodEvent() {
const [query, setQuery] = useState('');
const handleSearch = (q: string) => {
setQuery(q);
analytics.track('search', { query: q });
};
}
```
---
## useMemo / useCallback
```tsx
// ❌ 过度优化 — 常量不需要 useMemo
function OverOptimized() {
const config = useMemo(() => ({ timeout: 5000 }), []); // 无意义
const handleClick = useCallback(() => {
console.log('clicked');
}, []); // 如果不传给 memo 组件,无意义
}
// ✅ 只在需要时优化
function ProperlyOptimized() {
const config = { timeout: 5000 }; // 简单对象直接定义
const handleClick = () => console.log('clicked');
}
// ❌ useCallback 依赖总是变化
function BadCallback({ data }) {
// data 每次渲染都是新对象,useCallback 无效
const process = useCallback(() => {
return data.map(transform);
}, [data]);
}
// ✅ useMemo + useCallback 配合 React.memo 使用
const MemoizedChild = React.memo(function Child({ onClick, items }) {
return <div onClick={onClick}>{items.length}</div>;
});
function Parent({ rawItems }) {
const items = useMemo(() => processItems(rawItems), [rawItems]);
const handleClick = useCallback(() => {
console.log(items.length);
}, [items]);
return <MemoizedChild onClick={handleClick} items={items} />;
}
```
---
## 组件设计
```tsx
// ❌ 在组件内定义组件 — 每次渲染都创建新组件
function BadParent() {
function ChildComponent() { // 每次渲染都是新函数!
return <div>child</div>;
}
return <ChildComponent />;
}
// ✅ 组件定义在外部
function ChildComponent() {
return <div>child</div>;
}
function GoodParent() {
return <ChildComponent />;
}
// ❌ Props 总是新对象引用
function BadProps() {
return (
<MemoizedComponent
style={{ color: 'red' }} // 每次渲染新对象
onClick={() => {}} // 每次渲染新函数
/>
);
}
// ✅ 稳定的引用
const style = { color: 'red' };
function GoodProps() {
const handleClick = useCallback(() => {}, []);
return <MemoizedComponent style={style} onClick={handleClick} />;
}
```
---
## Error Boundaries & Suspense
```tsx
// ❌ 没有错误边界
function BadApp() {
return (
<Suspense fallback={<Loading />}>
<DataComponent /> {/* 错误会导致整个应用崩溃 */}
</Suspense>
);
}
// ✅ Error Boundary 包裹 Suspense
function GoodApp() {
return (
<ErrorBoundary fallback={<ErrorUI />}>
<Suspense fallback={<Loading />}>
<DataComponent />
</Suspense>
</ErrorBoundary>
);
}
```
---
## Server Components (RSC)
```tsx
// ❌ 在 Server Component 中使用客户端特性
// app/page.tsx (Server Component by default)
function BadServerComponent() {
const [count, setCount] = useState(0); // Error! No hooks in RSC
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}
// ✅ 交互逻辑提取到 Client Component
// app/counter.tsx
'use client';
function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}
// app/page.tsx (Server Component)
async function GoodServerComponent() {
const data = await fetchData(); // 可以直接 await
return (
<div>
<h1>{data.title}</h1>
<Counter /> {/* 客户端组件 */}
</div>
);
}
// ❌ 'use client' 放置不当 — 整个树都变成客户端
// layout.tsx
'use client'; // 这会让所有子组件都成为客户端组件
export default function Layout({ children }) { ... }
// ✅ 只在需要交互的组件使用 'use client'
// 将客户端逻辑隔离到叶子组件
```
---
## React 19 Actions & Forms
React 19 引入了 Actions 系统和新的表单处理 Hooks,简化异步操作和乐观更新。
### useActionState
```tsx
// ❌ 传统方式:多个状态变量
function OldForm() {
const [isPending, setIsPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const [data, setData] = useState(null);
const handleSubmit = async (formData: FormData) => {
setIsPending(true);
setError(null);
try {
const result = await submitForm(formData);
setData(result);
} catch (e) {
setError(e.message);
} finally {
setIsPending(false);
}
};
}
// ✅ React 19: useActionState 统一管理
import { useActionState } from 'react';
function NewForm() {
const [state, formAction, isPending] = useActionState(
async (prevState, formData: FormData) => {
try {
const result = await submitForm(formData);
return { success: true, data: result };
} catch (e) {
return { success: false, error: e.message };
}
},
{ success: false, data: null, error: null }
);
return (
<form action={formAction}>
<input name="email" />
<button disabled={isPending}>
{isPending ? 'Submitting...' : 'Submit'}
</button>
{state.error && <p className="error">{state.error}</p>}
</form>
);
}
```
### useFormStatus
```tsx
// ❌ Props 透传表单状态
function BadSubmitButton({ isSubmitting }) {
return <button disabled={isSubmitting}>Submit</button>;
}
// ✅ useFormStatus 访问父 <form> 状态(无需 props)
import { useFormStatus } from 'react-dom';
function SubmitButton() {
const { pending, data, method, action } = useFormStatus();
// 注意:必须在 <form> 内部的子组件中使用
return (
<button disabled={pending}>
{pending ? 'Submitting...' : 'Submit'}
</button>
);
}
// ❌ useFormStatus 在 form 同级组件中调用——不工作
function BadForm() {
const { pending } = useFormStatus(); // 这里无法获取状态!
return (
<form action={action}>
<button disabled={pending}>Submit</button>
</form>
);
}
// ✅ useFormStatus 必须在 form 的子组件中
function GoodForm() {
return (
<form action={action}>
<SubmitButton /> {/* useFormStatus 在这里面调用 */}
</form>
);
}
```
### useOptimistic
```tsx
// ❌ 等待服务器响应再更新 UI
function SlowLike({ postId, likes }) {
const [likeCount, setLikeCount] = useState(likes);
const [isPending, setIsPending] = useState(false);
const handleLike = async () => {
setIsPending(true);
const newCount = await likePost(postId); // 等待...
setLikeCount(newCount);
setIsPending(false);
};
}
// ✅ useOptimistic 即时反馈,失败自动回滚
import { useOptimistic } from 'react';
function FastLike({ postId, likes }) {
const [optimisticLikes, addOptimisticLike] = useOptimistic(
likes,
(currentLikes, increment: number) => currentLikes + increment
);
const handleLike = async () => {
addOptimisticLike(1); // 立即更新 UI
try {
await likePost(postId); // 后台同步
} catch {
// React 自动回滚到 likes 原值
}
};
return <button onClick={handleLike}>{optimisticLikes} likes</button>;
}
```
### Server Actions (Next.js 15+)
```tsx
// ❌ 客户端调用 API
'use client';
function ClientForm() {
const handleSubmit = async (formData: FormData) => {
const res = await fetch('/api/submit', {
method: 'POST',
body: formData,
});
// ...
};
}
// ✅ Server Action + useActionState
// actions.ts
'use server';
export async function createPost(prevState: any, formData: FormData) {
const title = formData.get('title');
await db.posts.create({ title });
revalidatePath('/posts');
return { success: true };
}
// form.tsx
'use client';
import { createPost } from './actions';
function PostForm() {
const [state, formAction, isPending] = useActionState(createPost, null);
return (
<form action={formAction}>
<input name="title" />
<SubmitButton />
</form>
);
}
```
---
## Suspense & Streaming SSR
Suspense 和 Streaming 是 React 18+ 的核心特性,在 2025 年的 Next.js 15 等框架中广泛使用。
### 基础 Suspense
```tsx
// ❌ 传统加载状态管理
function OldComponent() {
const [data, setData] = useState(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
fetchData().then(setData).finally(() => setIsLoading(false));
}, []);
if (isLoading) return <Spinner />;
return <DataView data={data} />;
}
// ✅ Suspense 声明式加载状态
function NewComponent() {
return (
<Suspense fallback={<Spinner />}>
<DataView /> {/* 内部使用 use() 或支持 Suspense 的数据获取 */}
</Suspense>
);
}
```
### 多个独立 Suspense 边界
```tsx
// ❌ 单一边界——所有内容一起加载
function BadLayout() {
return (
<Suspense fallback={<FullPageSpinner />}>
<Header />
<MainContent /> {/* 慢 */}
<Sidebar /> {/* 快 */}
</Suspense>
);
}
// ✅ 独立边界——各部分独立流式传输
function GoodLayout() {
return (
<>
<Header /> {/* 立即显示 */}
<div className="flex">
<Suspense fallback={<ContentSkeleton />}>
<MainContent /> {/* 独立加载 */}
</Suspense>
<Suspense fallback={<SidebarSkeleton />}>
<Sidebar /> {/* 独立加载 */}
</Suspense>
</div>
</>
);
}
```
### Next.js 15 Streaming
```tsx
// app/page.tsx - 自动 Streaming
export default async function Page() {
// 这个 await 不会阻塞整个页面
const data = await fetchSlowData();
return <div>{data}</div>;
}
// app/loading.tsx - 自动 Suspense 边界
export default function Loading() {
return <Skeleton />;
}
```
### use() Hook (React 19)
```tsx
// ✅ 在组件中读取 Promise
import { use } from 'react';
function Comments({ commentsPromise }) {
const comments = use(commentsPromise); // 自动触发 Suspense
return (
<ul>
{comments.map(c => <li key={c.id}>{c.text}</li>)}
</ul>
);
}
// 父组件创建 Promise,子组件消费
function Post({ postId }) {
const commentsPromise = fetchComments(postId); // 不 await
return (
<article>
<PostContent id={postId} />
<Suspense fallback={<CommentsSkeleton />}>
<Comments commentsPromise={commentsPromise} />
</Suspense>
</article>
);
}
```
---
## TanStack Query v5
TanStack Query 是 React 生态中最流行的数据获取库,v5 是当前稳定版本。
### 基础配置
```tsx
// ❌ 不正确的默认配置
const queryClient = new QueryClient(); // 默认配置可能不适合
// ✅ 生产环境推荐配置
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 分钟内数据视为新鲜
gcTime: 1000 * 60 * 30, // 30 分钟后垃圾回收(v5 重命名)
retry: 3,
refetchOnWindowFocus: false, // 根据需求决定
},
},
});
```
### queryOptions (v5 新增)
```tsx
// ❌ 重复定义 queryKey 和 queryFn
function Component1() {
const { data } = useQuery({
queryKey: ['users', userId],
queryFn: () => fetchUser(userId),
});
}
function prefetchUser(queryClient, userId) {
queryClient.prefetchQuery({
queryKey: ['users', userId], // 重复!
queryFn: () => fetchUser(userId), // 重复!
});
}
// ✅ queryOptions 统一定义,类型安全
import { queryOptions } from '@tanstack/react-query';
const userQueryOptions = (userId: string) =>
queryOptions({
queryKey: ['users', userId],
queryFn: () => fetchUser(userId),
});
function Component1({ userId }) {
const { data } = useQuery(userQueryOptions(userId));
}
function prefetchUser(queryClient, userId) {
queryClient.prefetchQuery(userQueryOptions(userId));
}
// getQueryData 也是类型安全的
const user = queryClient.getQueryData(userQueryOptions(userId).queryKey);
```
### 常见陷阱
```tsx
// ❌ staleTime 为 0 导致过度请求
useQuery({
queryKey: ['data'],
queryFn: fetchData,
// staleTime 默认为 0,每次组件挂载都会 refetch
});
// ✅ 设置合理的 staleTime
useQuery({
queryKey: ['data'],
queryFn: fetchData,
staleTime: 1000 * 60, // 1 分钟内不会重新请求
});
// ❌ 在 queryFn 中使用不稳定的引用
function BadQuery({ filters }) {
useQuery({
queryKey: ['items'], // queryKey 没有包含 filters!
queryFn: () => fetchItems(filters), // filters 变化不会触发重新请求
});
}
// ✅ queryKey 包含所有影响数据的参数
function GoodQuery({ filters }) {
useQuery({
queryKey: ['items', filters], // filters 是 queryKey 的一部分
queryFn: () => fetchItems(filters),
});
}
```
### useSuspenseQuery
> **重要限制**:useSuspenseQuery 与 useQuery 有显著差异,选择前需了解其限制。
#### useSuspenseQuery 的限制
| 特性 | useQuery | useSuspenseQuery |
|------|----------|------------------|
| `enabled` 选项 | ✅ 支持 | ❌ 不支持 |
| `placeholderData` | ✅ 支持 | ❌ 不支持 |
| `data` 类型 | `T \| undefined` | `T`(保证有值)|
| 错误处理 | `error` 属性 | 抛出到 Error Boundary |
| 加载状态 | `isLoading` 属性 | 挂起到 Suspense |
#### 不支持 enabled 的替代方案
```tsx
// ❌ 使用 useQuery + enabled 实现条件查询
function BadSuspenseQuery({ userId }) {
const { data } = useSuspenseQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
enabled: !!userId, // useSuspenseQuery 不支持 enabled!
});
}
// ✅ 组件组合实现条件渲染
function GoodSuspenseQuery({ userId }) {
// useSuspenseQuery 保证 data 是 T 不是 T | undefined
const { data } = useSuspenseQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
return <UserProfile user={data} />;
}
function Parent({ userId }) {
if (!userId) return <NoUserSelected />;
return (
<Suspense fallback={<UserSkeleton />}>
<GoodSuspenseQuery userId={userId} />
</Suspense>
);
}
```
#### 错误处理差异
```tsx
// ❌ useSuspenseQuery 没有 error 属性
function BadErrorHandling() {
const { data, error } = useSuspenseQuery({...});
if (error) return <Error />; // error 总是 null!
}
// ✅ 使用 Error Boundary 处理错误
function GoodErrorHandling() {
return (
<ErrorBoundary fallback={<ErrorMessage />}>
<Suspense fallback={<Loading />}>
<DataComponent />
</Suspense>
</ErrorBoundary>
);
}
function DataComponent() {
// 错误会抛出到 Error Boundary
const { data } = useSuspenseQuery({
queryKey: ['data'],
queryFn: fetchData,
});
return <Display data={data} />;
}
```
#### 何时选择 useSuspenseQuery
```tsx
// ✅ 适合场景:
// 1. 数据总是需要的(无条件查询)
// 2. 组件必须有数据才能渲染
// 3. 使用 React 19 的 Suspense 模式
// 4. 服务端组件 + 客户端 hydration
// ❌ 不适合场景:
// 1. 条件查询(根据用户操作触发)
// 2. 需要 placeholderData 或初始数据
// 3. 需要在组件内处理 loading/error 状态
// 4. 多个查询有依赖关系
// ✅ 多个独立查询用 useSuspenseQueries
function MultipleQueries({ userId }) {
const [userQuery, postsQuery] = useSuspenseQueries({
queries: [
{ queryKey: ['user', userId], queryFn: () => fetchUser(userId) },
{ queryKey: ['posts', userId], queryFn: () => fetchPosts(userId) },
],
});
// 两个查询并行执行,都完成后组件渲染
return <Profile user={userQuery.data} posts={postsQuery.data} />;
}
```
### 乐观更新 (v5 简化)
```tsx
// ❌ 手动管理缓存的乐观更新(复杂)
const mutation = useMutation({
mutationFn: updateTodo,
onMutate: async (newTodo) => {
await queryClient.cancelQueries({ queryKey: ['todos'] });
const previousTodos = queryClient.getQueryData(['todos']);
queryClient.setQueryData(['todos'], (old) => [...old, newTodo]);
return { previousTodos };
},
onError: (err, newTodo, context) => {
queryClient.setQueryData(['todos'], context.previousTodos);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
// ✅ v5 简化:使用 variables 进行乐观 UI
function TodoList() {
const { data: todos } = useQuery(todosQueryOptions);
const { mutate, variables, isPending } = useMutation({
mutationFn: addTodo,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
return (
<ul>
{todos?.map(todo => <TodoItem key={todo.id} todo={todo} />)}
{/* 乐观显示正在添加的 todo */}
{isPending && <TodoItem todo={variables} isOptimistic />}
</ul>
);
}
```
### v5 状态字段变化
```tsx
// v4: isLoading 表示首次加载或后续获取
// v5: isPending 表示没有数据,isLoading = isPending && isFetching
const { data, isPending, isFetching, isLoading } = useQuery({...});
// isPending: 缓存中没有数据(首次加载)
// isFetching: 正在请求中(包括后台刷新)
// isLoading: isPending && isFetching(首次加载中)
// ❌ v4 代码直接迁移
if (isLoading) return <Spinner />; // v5 中行为可能不同
// ✅ 明确意图
if (isPending) return <Spinner />; // 没有数据时显示加载
// 或
if (isLoading) return <Spinner />; // 首次加载中
```
---
## Review Checklists
### Hooks 规则
- [ ] Hooks 在组件/自定义 Hook 顶层调用
- [ ] 没有条件/循环中调用 Hooks
- [ ] useEffect 依赖数组完整
- [ ] useEffect 有清理函数(订阅/定时器/请求)
- [ ] 没有用 useEffect 计算派生状态
### 性能优化(适度原则)
- [ ] useMemo/useCallback 只用于真正需要的场景
- [ ] React.memo 配合稳定的 props 引用
- [ ] 没有在组件内定义子组件
- [ ] 没有在 JSX 中创建新对象/函数(除非传给非 memo 组件)
- [ ] 长列表使用虚拟化(react-window/react-virtual)
### 组件设计
- [ ] 组件职责单一,不超过 200 行
- [ ] 逻辑与展示分离(Custom Hooks)
- [ ] Props 接口清晰,使用 TypeScript
- [ ] 避免 Props Drilling(考虑 Context 或组合)
### 状态管理
- [ ] 状态就近原则(最小必要范围)
- [ ] 复杂状态用 useReducer
- [ ] 全局状态用 Context 或状态库
- [ ] 避免不必要的状态(派生 > 存储)
### 错误处理
- [ ] 关键区域有 Error Boundary
- [ ] Suspense 配合 Error Boundary 使用
- [ ] 异步操作有错误处理
### Server Components (RSC)
- [ ] 'use client' 只用于需要交互的组件
- [ ] Server Component 不使用 Hooks/事件处理
- [ ] 客户端组件尽量放在叶子节点
- [ ] 数据获取在 Server Component 中进行
### React 19 Forms
- [ ] 使用 useActionState 替代多个 useState
- [ ] useFormStatus 在 form 子组件中调用
- [ ] useOptimistic 不用于关键业务(支付等)
- [ ] Server Action 正确标记 'use server'
### Suspense & Streaming
- [ ] 按用户体验需求划分 Suspense 边界
- [ ] 每个 Suspense 有对应的 Error Boundary
- [ ] 提供有意义的 fallback(骨架屏 > Spinner)
- [ ] 避免在 layout 层级 await 慢数据
### TanStack Query
- [ ] queryKey 包含所有影响数据的参数
- [ ] 设置合理的 staleTime(不是默认 0)
- [ ] useSuspenseQuery 不使用 enabled
- [ ] Mutation 成功后 invalidate 相关查询
- [ ] 理解 isPending vs isLoading 区别
### 测试
- [ ] 使用 @testing-library/react
- [ ] 用 screen 查询元素
- [ ] 用 userEvent 代替 fireEvent
- [ ] 优先使用 *ByRole 查询
- [ ] 测试行为而非实现细节
+842
View File
@@ -0,0 +1,842 @@
# Rust Code Review Guide
> Rust 代码审查指南。编译器能捕获内存安全问题,但审查者需要关注编译器无法检测的问题——业务逻辑、API 设计、性能、取消安全性和可维护性。
## 目录
- [所有权与借用](#所有权与借用)
- [Unsafe 代码审查](#unsafe-代码审查最关键)
- [异步代码](#异步代码)
- [取消安全性](#取消安全性)
- [spawn vs await](#spawn-vs-await)
- [错误处理](#错误处理)
- [性能](#性能)
- [Trait 设计](#trait-设计)
- [Review Checklist](#rust-review-checklist)
---
## 所有权与借用
### 避免不必要的 clone()
```rust
// ❌ clone() 是"Rust 的胶带"——用于绕过借用检查器
fn bad_process(data: &Data) -> Result<()> {
let owned = data.clone(); // 为什么需要 clone?
expensive_operation(owned)
}
// ✅ 审查时问:clone 是否必要?能否用借用?
fn good_process(data: &Data) -> Result<()> {
expensive_operation(data) // 传递引用
}
// ✅ 如果确实需要 clone,添加注释说明原因
fn justified_clone(data: &Data) -> Result<()> {
// Clone needed: data will be moved to spawned task
let owned = data.clone();
tokio::spawn(async move {
process(owned).await
});
Ok(())
}
```
### Arc<Mutex<T>> 的使用
```rust
// ❌ Arc<Mutex<T>> 可能隐藏不必要的共享状态
struct BadService {
cache: Arc<Mutex<HashMap<String, Data>>>, // 真的需要共享?
}
// ✅ 考虑是否需要共享,或者设计可以避免
struct GoodService {
cache: HashMap<String, Data>, // 单一所有者
}
// ✅ 如果确实需要并发访问,考虑更好的数据结构
use dashmap::DashMap;
struct ConcurrentService {
cache: DashMap<String, Data>, // 更细粒度的锁
}
```
### Cow (Copy-on-Write) 模式
```rust
use std::borrow::Cow;
// ❌ 总是分配新字符串
fn bad_process_name(name: &str) -> String {
if name.is_empty() {
"Unknown".to_string() // 分配
} else {
name.to_string() // 不必要的分配
}
}
// ✅ 使用 Cow 避免不必要的分配
fn good_process_name(name: &str) -> Cow<'_, str> {
if name.is_empty() {
Cow::Borrowed("Unknown") // 静态字符串,无分配
} else {
Cow::Borrowed(name) // 借用原始数据
}
}
// ✅ 只在需要修改时才分配
fn normalize_name(name: &str) -> Cow<'_, str> {
if name.chars().any(|c| c.is_uppercase()) {
Cow::Owned(name.to_lowercase()) // 需要修改,分配
} else {
Cow::Borrowed(name) // 无需修改,借用
}
}
```
---
## Unsafe 代码审查(最关键!)
### 基本要求
```rust
// ❌ unsafe 没有安全文档——这是红旗
unsafe fn bad_transmute<T, U>(t: T) -> U {
std::mem::transmute(t)
}
// ✅ 每个 unsafe 必须解释:为什么安全?什么不变量?
/// Transmutes `T` to `U`.
///
/// # Safety
///
/// - `T` and `U` must have the same size and alignment
/// - `T` must be a valid bit pattern for `U`
/// - The caller ensures no references to `t` exist after this call
unsafe fn documented_transmute<T, U>(t: T) -> U {
// SAFETY: Caller guarantees size/alignment match and bit validity
std::mem::transmute(t)
}
```
### Unsafe 块注释
```rust
// ❌ 没有解释的 unsafe 块
fn bad_get_unchecked(slice: &[u8], index: usize) -> u8 {
unsafe { *slice.get_unchecked(index) }
}
// ✅ 每个 unsafe 块必须有 SAFETY 注释
fn good_get_unchecked(slice: &[u8], index: usize) -> u8 {
debug_assert!(index < slice.len(), "index out of bounds");
// SAFETY: We verified index < slice.len() via debug_assert.
// In release builds, callers must ensure valid index.
unsafe { *slice.get_unchecked(index) }
}
// ✅ 封装 unsafe 提供安全 API
pub fn checked_get(slice: &[u8], index: usize) -> Option<u8> {
if index < slice.len() {
// SAFETY: bounds check performed above
Some(unsafe { *slice.get_unchecked(index) })
} else {
None
}
}
```
### 常见 unsafe 模式
```rust
// ✅ FFI 边界
extern "C" {
fn external_function(ptr: *const u8, len: usize) -> i32;
}
pub fn safe_wrapper(data: &[u8]) -> Result<i32, Error> {
// SAFETY: data.as_ptr() is valid for data.len() bytes,
// and external_function only reads from the buffer.
let result = unsafe {
external_function(data.as_ptr(), data.len())
};
if result < 0 {
Err(Error::from_code(result))
} else {
Ok(result)
}
}
// ✅ 性能关键路径的 unsafe
pub fn fast_copy(src: &[u8], dst: &mut [u8]) {
assert_eq!(src.len(), dst.len(), "slices must be equal length");
// SAFETY: src and dst are valid slices of equal length,
// and dst is mutable so no aliasing.
unsafe {
std::ptr::copy_nonoverlapping(
src.as_ptr(),
dst.as_mut_ptr(),
src.len()
);
}
}
```
---
## 异步代码
### 避免阻塞操作
```rust
// ❌ 在 async 上下文中阻塞——会饿死其他任务
async fn bad_async() {
let data = std::fs::read_to_string("file.txt").unwrap(); // 阻塞!
std::thread::sleep(Duration::from_secs(1)); // 阻塞!
}
// ✅ 使用异步 API
async fn good_async() -> Result<String> {
let data = tokio::fs::read_to_string("file.txt").await?;
tokio::time::sleep(Duration::from_secs(1)).await;
Ok(data)
}
// ✅ 如果必须使用阻塞操作,用 spawn_blocking
async fn with_blocking() -> Result<Data> {
let result = tokio::task::spawn_blocking(|| {
// 这里可以安全地进行阻塞操作
expensive_cpu_computation()
}).await?;
Ok(result)
}
```
### Mutex 和 .await
```rust
// ❌ 跨 .await 持有 std::sync::Mutex——可能死锁
async fn bad_lock(mutex: &std::sync::Mutex<Data>) {
let guard = mutex.lock().unwrap();
async_operation().await; // 持锁等待!
process(&guard);
}
// ✅ 方案1:最小化锁范围
async fn good_lock_scoped(mutex: &std::sync::Mutex<Data>) {
let data = {
let guard = mutex.lock().unwrap();
guard.clone() // 立即释放锁
};
async_operation().await;
process(&data);
}
// ✅ 方案2:使用 tokio::sync::Mutex(可跨 await)
async fn good_lock_tokio(mutex: &tokio::sync::Mutex<Data>) {
let guard = mutex.lock().await;
async_operation().await; // OK: tokio Mutex 设计为可跨 await
process(&guard);
}
// 💡 选择指南:
// - std::sync::Mutex:低竞争、短临界区、不跨 await
// - tokio::sync::Mutex:需要跨 await、高竞争场景
```
### 异步 trait 方法
```rust
// ❌ async trait 方法的陷阱(旧版本)
#[async_trait]
trait BadRepository {
async fn find(&self, id: i64) -> Option<Entity>; // 隐式 Box
}
// ✅ Rust 1.75+:原生 async trait 方法
trait Repository {
async fn find(&self, id: i64) -> Option<Entity>;
// 返回具体 Future 类型以避免 allocation
fn find_many(&self, ids: &[i64]) -> impl Future<Output = Vec<Entity>> + Send;
}
// ✅ 对于需要 dyn 的场景
trait DynRepository: Send + Sync {
fn find(&self, id: i64) -> Pin<Box<dyn Future<Output = Option<Entity>> + Send + '_>>;
}
```
---
## 取消安全性
### 什么是取消安全
```rust
// 当一个 Future 在 .await 点被 drop 时,它处于什么状态?
// 取消安全的 Future:可以在任何 await 点安全取消
// 取消不安全的 Future:取消可能导致数据丢失或不一致状态
// ❌ 取消不安全的例子
async fn cancel_unsafe(conn: &mut Connection) -> Result<()> {
let data = receive_data().await; // 如果这里被取消...
conn.send_ack().await; // ...确认永远不会发送,数据可能丢失
Ok(())
}
// ✅ 取消安全的版本
async fn cancel_safe(conn: &mut Connection) -> Result<()> {
// 使用事务或原子操作确保一致性
let transaction = conn.begin_transaction().await?;
let data = receive_data().await;
transaction.commit_with_ack(data).await?; // 原子操作
Ok(())
}
```
### select! 中的取消安全
```rust
use tokio::select;
// ❌ 在 select! 中使用取消不安全的 Future
async fn bad_select(stream: &mut TcpStream) {
let mut buffer = vec![0u8; 1024];
loop {
select! {
// read_exact 不是取消安全的:timeout 先完成时,
// 已经读进 buffer 的部分字节会随 Future 一起丢弃
result = stream.read_exact(&mut buffer) => {
result?;
handle_data(&buffer);
}
_ = tokio::time::sleep(Duration::from_secs(5)) => {
println!("Timeout");
}
}
}
}
// ✅ 使用取消安全的 API
async fn good_select(stream: &mut TcpStream) {
let mut buffer = vec![0u8; 1024];
loop {
select! {
// read 是取消安全的:被取消时未读取的数据仍留在流中
// 真的需要按定长读取时,把 read_exact 丢到单独的 task 里,
// 这里 select! 它的 JoinHandle,取消就不会丢字节
result = stream.read(&mut buffer) => {
match result {
Ok(0) => break, // EOF
Ok(n) => handle_data(&buffer[..n]),
Err(e) => return Err(e),
}
}
_ = tokio::time::sleep(Duration::from_secs(5)) => {
println!("Timeout, retrying...");
}
}
}
}
// ✅ 使用 tokio::pin! 确保 Future 可以安全重用
async fn pinned_select() {
let sleep = tokio::time::sleep(Duration::from_secs(10));
tokio::pin!(sleep);
loop {
select! {
_ = &mut sleep => {
println!("Timer elapsed");
break;
}
data = receive_data() => {
process(data).await;
// sleep 继续倒计时,不会重置
}
}
}
}
```
### 文档化取消安全性
```rust
/// Reads a complete message from the stream.
///
/// # Cancel Safety
///
/// This method is **not** cancel safe. If cancelled while reading,
/// partial data may be lost and the stream state becomes undefined.
/// Use `read_message_cancel_safe` if cancellation is expected.
async fn read_message(stream: &mut TcpStream) -> Result<Message> {
let len = stream.read_u32().await?;
let mut buffer = vec![0u8; len as usize];
stream.read_exact(&mut buffer).await?;
Ok(Message::from_bytes(&buffer))
}
/// Reads a message with cancel safety.
///
/// # Cancel Safety
///
/// This method is cancel safe. If cancelled, any partial data
/// is preserved in the internal buffer for the next call.
async fn read_message_cancel_safe(reader: &mut BufferedReader) -> Result<Message> {
reader.read_message_buffered().await
}
```
---
## spawn vs await
### 何时使用 spawn
```rust
// ❌ 不必要的 spawn——增加开销,失去结构化并发
async fn bad_unnecessary_spawn() {
let handle = tokio::spawn(async {
simple_operation().await
});
handle.await.unwrap(); // 为什么不直接 await?
}
// ✅ 直接 await 简单操作
async fn good_direct_await() {
simple_operation().await;
}
// ✅ spawn 用于真正的并行执行
async fn good_parallel_spawn() {
let task1 = tokio::spawn(fetch_from_service_a());
let task2 = tokio::spawn(fetch_from_service_b());
// 两个请求并行执行
let (result1, result2) = tokio::try_join!(task1, task2)?;
}
// ✅ spawn 用于后台任务(fire-and-forget)
async fn good_background_spawn() {
// 启动后台任务,不等待完成
tokio::spawn(async {
cleanup_old_sessions().await;
log_metrics().await;
});
// 继续执行其他工作
handle_request().await;
}
```
### spawn 的 'static 要求
```rust
// ❌ spawn 的 Future 必须是 'static
async fn bad_spawn_borrow(data: &Data) {
tokio::spawn(async {
process(data).await; // Error: `data` 不是 'static
});
}
// ✅ 方案1:克隆数据
async fn good_spawn_clone(data: &Data) {
let owned = data.clone();
tokio::spawn(async move {
process(&owned).await;
});
}
// ✅ 方案2:使用 Arc 共享
async fn good_spawn_arc(data: Arc<Data>) {
let data = Arc::clone(&data);
tokio::spawn(async move {
process(&data).await;
});
}
// ✅ 方案3:使用作用域任务(tokio-scoped 或 async-scoped)
async fn good_scoped_spawn(data: &Data) {
// 假设使用 async-scoped crate
async_scoped::scope(|s| async {
s.spawn(async {
process(data).await; // 可以借用
});
}).await;
}
```
### JoinHandle 错误处理
```rust
// ❌ 忽略 spawn 的错误
async fn bad_ignore_spawn_error() {
let handle = tokio::spawn(async {
risky_operation().await
});
let _ = handle.await; // 忽略了 panic 和错误
}
// ✅ 正确处理 JoinHandle 结果
async fn good_handle_spawn_error() -> Result<()> {
let handle = tokio::spawn(async {
risky_operation().await
});
match handle.await {
Ok(Ok(result)) => {
// 任务成功完成
process_result(result);
Ok(())
}
Ok(Err(e)) => {
// 任务内部错误
Err(e.into())
}
Err(join_err) => {
// 任务 panic 或被取消
if join_err.is_panic() {
error!("Task panicked: {:?}", join_err);
}
Err(anyhow!("Task failed: {}", join_err))
}
}
}
```
### 结构化并发 vs spawn
```rust
// ✅ 优先使用 join!(结构化并发)
async fn structured_concurrency() -> Result<(A, B, C)> {
// 所有任务在同一个作用域内
// 如果任何一个失败,其他的会被取消
tokio::try_join!(
fetch_a(),
fetch_b(),
fetch_c()
)
}
// ✅ 使用 spawn 时考虑任务生命周期
struct TaskManager {
handles: Vec<JoinHandle<()>>,
}
impl TaskManager {
async fn shutdown(self) {
// 优雅关闭:等待所有任务完成
for handle in self.handles {
if let Err(e) = handle.await {
error!("Task failed during shutdown: {}", e);
}
}
}
async fn abort_all(self) {
// 强制关闭:取消所有任务
for handle in self.handles {
handle.abort();
}
}
}
```
---
## 错误处理
### 库 vs 应用的错误类型
```rust
// ❌ 库代码用 anyhow——调用者无法 match 错误
pub fn parse_config(s: &str) -> anyhow::Result<Config> { ... }
// ✅ 库用 thiserror,应用用 anyhow
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("invalid syntax at line {line}: {message}")]
Syntax { line: usize, message: String },
#[error("missing required field: {0}")]
MissingField(String),
#[error(transparent)]
Io(#[from] std::io::Error),
}
pub fn parse_config(s: &str) -> Result<Config, ConfigError> { ... }
```
### 保留错误上下文
```rust
// ❌ 吞掉错误上下文
fn bad_error() -> Result<()> {
operation().map_err(|_| anyhow!("failed"))?; // 原始错误丢失
Ok(())
}
// ✅ 使用 context 保留错误链
fn good_error() -> Result<()> {
operation().context("failed to perform operation")?;
Ok(())
}
// ✅ 使用 with_context 进行懒计算
fn good_error_lazy() -> Result<()> {
operation()
.with_context(|| format!("failed to process file: {}", filename))?;
Ok(())
}
```
### 错误类型设计
```rust
// ✅ 使用 #[source] 保留错误链
#[derive(Debug, thiserror::Error)]
pub enum ServiceError {
#[error("database error")]
Database(#[source] sqlx::Error),
#[error("network error: {message}")]
Network {
message: String,
#[source]
source: reqwest::Error,
},
#[error("validation failed: {0}")]
Validation(String),
}
// ✅ 为常见转换实现 From
impl From<sqlx::Error> for ServiceError {
fn from(err: sqlx::Error) -> Self {
ServiceError::Database(err)
}
}
```
---
## 性能
### 避免不必要的 collect()
```rust
// ❌ 不必要的 collect——中间分配
fn bad_sum(items: &[i32]) -> i32 {
items.iter()
.filter(|x| **x > 0)
.collect::<Vec<_>>() // 不必要!
.iter()
.sum()
}
// ✅ 惰性迭代
fn good_sum(items: &[i32]) -> i32 {
items.iter().filter(|x| **x > 0).copied().sum()
}
```
### 字符串拼接
```rust
// ❌ 字符串拼接在循环中重复分配
fn bad_concat(items: &[&str]) -> String {
let mut s = String::new();
for item in items {
s = s + item; // 每次都重新分配!
}
s
}
// ✅ 预分配或用 join
fn good_concat(items: &[&str]) -> String {
items.join("")
}
// ✅ 使用 with_capacity 预分配
fn good_concat_capacity(items: &[&str]) -> String {
let total_len: usize = items.iter().map(|s| s.len()).sum();
let mut result = String::with_capacity(total_len);
for item in items {
result.push_str(item);
}
result
}
// ✅ 使用 write! 宏
use std::fmt::Write;
fn good_concat_write(items: &[&str]) -> String {
let mut result = String::new();
for item in items {
write!(result, "{}", item).unwrap();
}
result
}
```
### 避免不必要的分配
```rust
// ❌ 不必要的 Vec 分配
fn bad_check_any(items: &[Item]) -> bool {
let filtered: Vec<_> = items.iter()
.filter(|i| i.is_valid())
.collect();
!filtered.is_empty()
}
// ✅ 使用迭代器方法
fn good_check_any(items: &[Item]) -> bool {
items.iter().any(|i| i.is_valid())
}
// ❌ String::from 用于静态字符串
fn bad_static() -> String {
String::from("error message") // 运行时分配
}
// ✅ 返回 &'static str
fn good_static() -> &'static str {
"error message" // 无分配
}
```
---
## Trait 设计
### 避免过度抽象
```rust
// ❌ 过度抽象——不是 Java,不需要 Interface 一切
trait Processor { fn process(&self); }
trait Handler { fn handle(&self); }
trait Manager { fn manage(&self); } // Trait 过多
// ✅ 只在需要多态时创建 trait
// 具体类型通常更简单、更快
struct DataProcessor {
config: Config,
}
impl DataProcessor {
fn process(&self, data: &Data) -> Result<Output> {
// 直接实现
}
}
```
### Trait 对象 vs 泛型
```rust
// ❌ 不必要的 trait 对象(动态分发)
fn bad_process(handler: &dyn Handler) {
handler.handle(); // 虚表调用
}
// ✅ 使用泛型(静态分发,可内联)
fn good_process<H: Handler>(handler: &H) {
handler.handle(); // 可能被内联
}
// ✅ trait 对象适用场景:异构集合
fn store_handlers(handlers: Vec<Box<dyn Handler>>) {
// 需要存储不同类型的 handlers
}
// ✅ 使用 impl Trait 返回类型
fn create_handler() -> impl Handler {
ConcreteHandler::new()
}
```
---
## Rust Review Checklist
### 编译器不能捕获的问题
**业务逻辑正确性**
- [ ] 边界条件处理正确
- [ ] 状态机转换完整
- [ ] 并发场景下的竞态条件
**API 设计**
- [ ] 公共 API 难以误用
- [ ] 类型签名清晰表达意图
- [ ] 错误类型粒度合适
### 所有权与借用
- [ ] clone() 是有意为之,文档说明了原因
- [ ] Arc<Mutex<T>> 真的需要共享状态吗?
- [ ] RefCell 的使用有正当理由
- [ ] 生命周期不过度复杂
- [ ] 考虑使用 Cow 避免不必要的分配
### Unsafe 代码(最重要)
- [ ] 每个 unsafe 块有 SAFETY 注释
- [ ] unsafe fn 有 # Safety 文档节
- [ ] 解释了为什么是安全的,不只是做什么
- [ ] 列出了必须维护的不变量
- [ ] unsafe 边界尽可能小
- [ ] 考虑过是否有 safe 替代方案
### 异步/并发
- [ ] 没有在 async 中阻塞(std::fs、thread::sleep)
- [ ] 没有跨 .await 持有 std::sync 锁
- [ ] spawn 的任务满足 'static
- [ ] 锁的获取顺序一致
- [ ] Channel 缓冲区大小合理
### 取消安全性
- [ ] select! 中的 Future 是取消安全的
- [ ] 文档化了 async 函数的取消安全性
- [ ] 取消不会导致数据丢失或不一致状态
- [ ] 使用 tokio::pin! 正确处理需要重用的 Future
### spawn vs await
- [ ] spawn 只用于真正需要并行的场景
- [ ] 简单操作直接 await,不要 spawn
- [ ] spawn 的 JoinHandle 结果被正确处理
- [ ] 考虑任务的生命周期和关闭策略
- [ ] 优先使用 join!/try_join! 进行结构化并发
### 错误处理
- [ ] 库:thiserror 定义结构化错误
- [ ] 应用:anyhow + context
- [ ] 没有生产代码 unwrap/expect
- [ ] 错误消息对调试有帮助
- [ ] must_use 返回值被处理
- [ ] 使用 #[source] 保留错误链
### 性能
- [ ] 避免不必要的 collect()
- [ ] 大数据传引用
- [ ] 字符串用 with_capacity 或 write!
- [ ] impl Trait vs Box<dyn Trait> 选择合理
- [ ] 热路径避免分配
- [ ] 考虑使用 Cow 减少克隆
### 代码质量
- [ ] cargo clippy 零警告
- [ ] cargo fmt 格式化
- [ ] 文档注释完整
- [ ] 测试覆盖边界条件
- [ ] 公共 API 有文档示例
@@ -0,0 +1,266 @@
# Security Review Guide
Security-focused code review checklist based on OWASP Top 10 and best practices.
## Authentication & Authorization
### Authentication
- [ ] Passwords hashed with strong algorithm (bcrypt, argon2)
- [ ] Password complexity requirements enforced
- [ ] Account lockout after failed attempts
- [ ] Secure password reset flow
- [ ] Multi-factor authentication for sensitive operations
- [ ] Session tokens are cryptographically random
- [ ] Session timeout implemented
### Authorization
- [ ] Authorization checks on every request
- [ ] Principle of least privilege applied
- [ ] Role-based access control (RBAC) properly implemented
- [ ] No privilege escalation paths
- [ ] Direct object reference checks (IDOR prevention)
- [ ] API endpoints protected appropriately
### JWT Security
```typescript
// ❌ Insecure JWT configuration
jwt.sign(payload, 'weak-secret');
// ✅ Secure JWT configuration
jwt.sign(payload, process.env.JWT_SECRET, {
algorithm: 'RS256',
expiresIn: '15m',
issuer: 'your-app',
audience: 'your-api'
});
// ❌ Not verifying JWT properly
const decoded = jwt.decode(token); // No signature verification!
// ✅ Verify signature and claims
const decoded = jwt.verify(token, publicKey, {
algorithms: ['RS256'],
issuer: 'your-app',
audience: 'your-api'
});
```
## Input Validation
### SQL Injection Prevention
```python
# ❌ Vulnerable to SQL injection
query = f"SELECT * FROM users WHERE id = {user_id}"
# ✅ Use parameterized queries
cursor.execute("SELECT * FROM users WHERE id = %s", (user_id,))
# ✅ Use ORM with proper escaping
User.objects.filter(id=user_id)
```
### XSS Prevention
```typescript
// ❌ Vulnerable to XSS
element.innerHTML = userInput;
// ✅ Use textContent for plain text
element.textContent = userInput;
// ✅ Use DOMPurify for HTML
element.innerHTML = DOMPurify.sanitize(userInput);
// ✅ React automatically escapes (but watch dangerouslySetInnerHTML)
return <div>{userInput}</div>; // Safe
return <div dangerouslySetInnerHTML={{__html: userInput}} />; // Dangerous!
```
### Command Injection Prevention
```python
# ❌ Vulnerable to command injection
os.system(f"convert {filename} output.png")
# ✅ Use subprocess with list arguments
subprocess.run(['convert', filename, 'output.png'], check=True)
# ✅ Validate and sanitize input
import shlex
safe_filename = shlex.quote(filename)
```
### Path Traversal Prevention
```typescript
// ❌ Vulnerable to path traversal
const filePath = `./uploads/${req.params.filename}`;
// ✅ Validate and sanitize path
const path = require('path');
const safeName = path.basename(req.params.filename);
const uploadsDir = path.resolve('./uploads');
const filePath = path.resolve(uploadsDir, safeName);
// Verify it's still within uploads directory (both sides absolute)
if (!filePath.startsWith(uploadsDir + path.sep)) {
throw new Error('Invalid path');
}
```
## Data Protection
### Sensitive Data Handling
- [ ] No secrets in source code
- [ ] Secrets stored in environment variables or secret manager
- [ ] Sensitive data encrypted at rest
- [ ] Sensitive data encrypted in transit (HTTPS)
- [ ] PII handled according to regulations (GDPR, etc.)
- [ ] Sensitive data not logged
- [ ] Secure data deletion when required
### Configuration Security
```yaml
# ❌ Secrets in config files
database:
password: "super-secret-password"
# ✅ Reference environment variables
database:
password: ${DATABASE_PASSWORD}
```
### Error Messages
```typescript
// ❌ Leaking sensitive information
catch (error) {
return res.status(500).json({
error: error.stack, // Exposes internal details
query: sqlQuery // Exposes database structure
});
}
// ✅ Generic error messages
catch (error) {
logger.error('Database error', { error, userId }); // Log internally
return res.status(500).json({
error: 'An unexpected error occurred'
});
}
```
## API Security
### Rate Limiting
- [ ] Rate limiting on all public endpoints
- [ ] Stricter limits on authentication endpoints
- [ ] Per-user and per-IP limits
- [ ] Graceful handling when limits exceeded
### CORS Configuration
```typescript
// ❌ Overly permissive CORS
app.use(cors({ origin: '*' }));
// ✅ Restrictive CORS
app.use(cors({
origin: ['https://your-app.com'],
methods: ['GET', 'POST'],
credentials: true
}));
```
### HTTP Headers
```typescript
// Security headers to set
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
}
},
hsts: { maxAge: 31536000, includeSubDomains: true },
noSniff: true,
xssFilter: true,
frameguard: { action: 'deny' }
}));
```
## Cryptography
### Secure Practices
- [ ] Using well-established algorithms (AES-256, RSA-2048+)
- [ ] Not implementing custom cryptography
- [ ] Using cryptographically secure random number generation
- [ ] Proper key management and rotation
- [ ] Secure key storage (HSM, KMS)
### Common Mistakes
```typescript
// ❌ Weak random generation
const token = Math.random().toString(36);
// ✅ Cryptographically secure random
const crypto = require('crypto');
const token = crypto.randomBytes(32).toString('hex');
// ❌ MD5/SHA1 for passwords
const hash = crypto.createHash('md5').update(password).digest('hex');
// ✅ Use bcrypt or argon2
const bcrypt = require('bcrypt');
const hash = await bcrypt.hash(password, 12);
```
## Dependency Security
### Checklist
- [ ] Dependencies from trusted sources only
- [ ] No known vulnerabilities (npm audit, cargo audit)
- [ ] Dependencies kept up to date
- [ ] Lock files committed (package-lock.json, Cargo.lock)
- [ ] Minimal dependency usage
- [ ] License compliance verified
### Audit Commands
```bash
# Node.js
npm audit
npm audit fix
# Python
pip-audit
safety check
# Rust
cargo audit
# General
snyk test
```
## Logging & Monitoring
### Secure Logging
- [ ] No sensitive data in logs (passwords, tokens, PII)
- [ ] Logs protected from tampering
- [ ] Appropriate log retention
- [ ] Security events logged (login attempts, permission changes)
- [ ] Log injection prevented
```typescript
// ❌ Logging sensitive data
logger.info(`User login: ${email}, password: ${password}`);
// ✅ Safe logging
logger.info('User login attempt', { email, success: true });
```
## Security Review Severity Levels
| Severity | Description | Action |
|----------|-------------|--------|
| **Critical** | Immediate exploitation possible, data breach risk | Block merge, fix immediately |
| **High** | Significant vulnerability, requires specific conditions | Block merge, fix before release |
| **Medium** | Moderate risk, defense in depth concern | Should fix, can merge with tracking |
| **Low** | Minor issue, best practice violation | Nice to fix, non-blocking |
| **Info** | Suggestion for improvement | Optional enhancement |
File diff suppressed because it is too large Load Diff
+932
View File
@@ -0,0 +1,932 @@
# Swift Code Review Guide
A code review checklist for modern Swift (5.9+/6), covering SwiftUI, Swift Concurrency, and the Swift API Design Guidelines.
## Quick Review Checklist
### Must-Check Items
- [ ] Are force-unwraps (`!`) and `try!` avoided in favor of safe unwrapping
- [ ] Do closures that capture `self` use `[weak self]` to avoid retain cycles
- [ ] Is the value vs reference type choice intentional (struct vs class)
- [ ] Are errors propagated with `throws`/`Result` instead of being swallowed
- [ ] Are concurrency boundaries data-race-safe (`Sendable`, `@MainActor`, actors)
### Common Issues
- [ ] Fire-and-forget `Task {}` that leaks or is never cancelled
- [ ] Wrong SwiftUI property wrapper (`@ObservedObject` where `@StateObject` is needed)
- [ ] O(n^2) lookups in loops that could use a `Set` or `Dictionary`
- [ ] Implicitly unwrapped optionals (`var x: T!`) outside of IBOutlets
- [ ] Over-broad access control (`public`/`open` where `internal` suffices)
- [ ] Naming that ignores the Swift API Design Guidelines
---
## 1. Optionals and Unwrapping
### 1.1 Avoid Force-Unwrapping
```swift
// ❌ Wrong: crashes at runtime if nil
let name = user.name!
let url = URL(string: urlString)!
// ✅ Correct: bind with guard let / if let
guard let name = user.name else {
return
}
if let url = URL(string: urlString) {
load(url)
}
```
### 1.2 Use Nil-Coalescing for Defaults
```swift
// ❌ Wrong: verbose and crash-prone
let count: Int
if let c = dictionary["count"] {
count = c
} else {
count = 0
}
// ✅ Correct: nil-coalescing
let count = dictionary["count"] ?? 0
```
### 1.3 Prefer guard let for Early Exit
```swift
// ❌ Wrong: deep nesting (pyramid of doom)
func process(_ input: String?) {
if let input = input {
if let value = Int(input) {
if value > 0 {
handle(value)
}
}
}
}
// ✅ Correct: guard keeps the happy path unindented
func process(_ input: String?) {
guard let input,
let value = Int(input),
value > 0 else {
return
}
handle(value)
}
```
### 1.4 Avoid Implicitly Unwrapped Optionals
```swift
// ❌ Wrong: T! is a hidden force-unwrap on every access
class ViewModel {
var service: NetworkService!
}
// ✅ Correct: inject a non-optional dependency
class ViewModel {
private let service: NetworkService
init(service: NetworkService) {
self.service = service
}
}
```
### 1.5 Use Optional Chaining and map/flatMap
```swift
// ❌ Wrong: manual unwrapping just to transform
var initial: String?
if let name = user.name {
initial = String(name.prefix(1))
}
// ✅ Correct: optional chaining + map
let initial = user.name.map { String($0.prefix(1)) }
// ✅ Correct: flatMap to avoid double optionals
let port: Int? = components.port.flatMap { Int(exactly: $0) }
```
---
## 2. Memory Management and Retain Cycles
### 2.1 Use [weak self] in Escaping Closures
```swift
// ❌ Wrong: closure strongly captures self, creating a retain cycle
class ImageLoader {
var onComplete: (() -> Void)?
func load() {
service.fetch { data in
self.cache = data // self is retained by the closure
self.onComplete?()
}
}
}
// ✅ Correct: capture self weakly and guard
class ImageLoader {
var onComplete: (() -> Void)?
func load() {
service.fetch { [weak self] data in
guard let self else { return }
self.cache = data
self.onComplete?()
}
}
}
```
### 2.2 weak vs unowned
```swift
// ✅ Use weak when the reference can legitimately become nil
class Controller {
weak var delegate: ControllerDelegate?
}
// ✅ Use unowned only when the captured object is guaranteed to
// outlive the closure (e.g. self owns the closure tightly).
// unowned crashes if accessed after deallocation.
class Owner {
lazy var describe: () -> String = { [unowned self] in
self.name
}
let name = "owner"
}
// ❌ Wrong: unowned on something that can outlive self -> crash
networkClient.onResponse = { [unowned self] in self.update() }
// Prefer [weak self] here, since onResponse may fire after self is gone.
```
### 2.3 Break Delegate Retain Cycles
```swift
// ❌ Wrong: strong delegate keeps both objects alive forever
protocol DataSourceDelegate: AnyObject {}
class DataSource {
var delegate: DataSourceDelegate? // strong by default
}
// ✅ Correct: delegates should be weak (and protocol AnyObject-bound)
class DataSource {
weak var delegate: DataSourceDelegate?
}
```
### 2.4 Closures Stored as Properties
```swift
// ❌ Wrong: stored closure captures self strongly -> permanent cycle
class Timer {
var tick: (() -> Void)!
func configure() {
tick = { self.count += 1 }
}
var count = 0
}
// ✅ Correct: weak capture for stored closures referencing self
class Timer {
var tick: (() -> Void)?
func configure() {
tick = { [weak self] in self?.count += 1 }
}
var count = 0
}
```
---
## 3. Value vs Reference Types
### 3.1 Prefer Structs by Default
```swift
// ✅ Use a struct for data/models with value semantics
struct Coordinate {
var latitude: Double
var longitude: Double
}
// Copies are independent; no shared mutable state, thread-friendly.
var a = Coordinate(latitude: 1, longitude: 2)
var b = a
b.latitude = 99 // a is unchanged
```
### 3.2 Use a Class for Identity or Shared State
```swift
// ✅ Use a class when instances have identity or must be shared/mutated
// by reference, or when you need inheritance / Objective-C interop.
final class DatabaseConnection {
private(set) var isOpen = false
func open() { isOpen = true }
}
// Two references point to the same connection.
let conn1 = DatabaseConnection()
let conn2 = conn1
conn1.open()
// conn2.isOpen == true
```
### 3.3 Mark Classes final When Not Subclassed
```swift
// ❌ Wrong: open to subclassing unintentionally (slower dispatch, fragile API)
class UserViewModel {}
// ✅ Correct: final enables static dispatch and signals intent
final class UserViewModel {}
```
### 3.4 Beware Reference Types Inside Structs
```swift
// ❌ Surprising: struct copy still shares the inner class instance
final class Box { var value = 0 }
struct Container { var box = Box() }
var x = Container()
var y = x
y.box.value = 42 // x.box.value is also 42 (shared reference!)
// ✅ Correct: use value semantics throughout, or copy on write deliberately
struct Container {
var value = 0 // plain value type, copies are independent
}
```
---
## 4. Error Handling
### 4.1 Avoid try! and try?
```swift
// ❌ Wrong: try! crashes on any thrown error
let data = try! Data(contentsOf: url)
// ❌ Often wrong: try? silently discards the error and the cause
let data = try? Data(contentsOf: url) // data is nil, you lose "why"
// ✅ Correct: propagate or handle with do-catch
do {
let data = try Data(contentsOf: url)
process(data)
} catch {
log.error("failed to read \(url): \(error)")
}
```
### 4.2 Define Meaningful Error Types
```swift
// ✅ Recommended: an Error enum communicates failure modes precisely
enum NetworkError: Error {
case invalidURL
case unauthorized
case server(statusCode: Int)
case decoding(underlying: Error)
}
func fetch(_ path: String) throws -> Data {
guard let url = URL(string: path) else {
throw NetworkError.invalidURL
}
// ...
}
```
### 4.3 Use Result for Stored or Deferred Outcomes
```swift
// ✅ Result is useful at callback boundaries or when storing an outcome
func load(completion: @escaping (Result<User, NetworkError>) -> Void) {
// completion(.success(user)) or completion(.failure(.unauthorized))
}
// ✅ Convert between Result and throws as needed
let user = try result.get()
```
### 4.4 Typed Throws (Swift 6)
```swift
// ✅ Typed throws constrains the error type when it is fully known.
// Use it for closed, exhaustive error domains; prefer untyped
// `throws` for library APIs that may grow new error cases.
func parse(_ raw: String) throws(ParsingError) -> Token {
guard let token = Token(raw) else {
throw ParsingError.malformed
}
return token
}
do {
let token = try parse(input)
} catch {
// `error` is statically known to be ParsingError
handle(error)
}
```
### 4.5 Don't Catch and Rethrow Without Value
```swift
// ❌ Wrong: catch that adds nothing but obscures the trace
do {
try work()
} catch {
throw error // pointless
}
// ✅ Correct: only catch to add context or recover
do {
try work()
} catch {
throw AppError.workFailed(underlying: error)
}
```
---
## 5. Swift Concurrency
### 5.1 Prefer async/await Over Nested Callbacks
```swift
// ❌ Wrong: callback pyramid, error handling scattered
func loadProfile(completion: @escaping (Result<Profile, Error>) -> Void) {
fetchUser { userResult in
switch userResult {
case .success(let user):
fetchAvatar(user) { avatarResult in /* ... */ }
case .failure(let error):
completion(.failure(error))
}
}
}
// ✅ Correct: linear async/await
func loadProfile() async throws -> Profile {
let user = try await fetchUser()
let avatar = try await fetchAvatar(user)
return Profile(user: user, avatar: avatar)
}
```
### 5.2 Use @MainActor for UI State
```swift
// ❌ Wrong: mutating UI state from a background context (data race / crash)
func refresh() async {
let items = try? await api.load()
self.items = items ?? [] // may run off the main thread
}
// ✅ Correct: isolate UI-facing types to the main actor
@MainActor
final class FeedViewModel: ObservableObject {
@Published var items: [Item] = []
func refresh() async {
let loaded = (try? await api.load()) ?? []
items = loaded // guaranteed on the main actor
}
}
```
### 5.3 Protect Mutable State with Actors
```swift
// ❌ Wrong: shared mutable state without synchronization (data race)
final class Counter {
var value = 0
func increment() { value += 1 }
}
// ✅ Correct: an actor serializes access to its mutable state
actor Counter {
private(set) var value = 0
func increment() { value += 1 }
}
let counter = Counter()
await counter.increment() // access is awaited and serialized
```
### 5.4 Conform Shared Types to Sendable
```swift
// ❌ Wrong: passing a non-Sendable class across actors (Swift 6 error)
final class Config { // mutable, not Sendable
var retries = 3
}
// ✅ Correct: make shared types Sendable (immutable value type is ideal)
struct Config: Sendable {
let retries: Int
}
// ✅ For reference types, use final + immutable stored properties,
// or @unchecked Sendable only with manual synchronization.
final class Cache: @unchecked Sendable {
private let lock = NSLock()
private var storage: [String: Data] = [:]
// all access guarded by lock
}
```
### 5.5 Handle Task Cancellation
```swift
// ❌ Wrong: ignores cancellation, keeps working after the view is gone
func search(_ query: String) async -> [Result] {
var results: [Result] = []
for page in 0..<100 {
results += await fetchPage(query, page) // never stops
}
return results
}
// ✅ Correct: check for cancellation cooperatively
func search(_ query: String) async throws -> [Result] {
var results: [Result] = []
for page in 0..<100 {
try Task.checkCancellation()
results += try await fetchPage(query, page)
}
return results
}
```
### 5.6 Don't Leak Fire-and-Forget Tasks
```swift
// ❌ Wrong: unstructured Task with no handle, never cancelled
final class ViewModel {
func onAppear() {
Task {
await self.stream() // runs forever even after dismissal
}
}
}
// ✅ Correct: retain the handle and cancel it (or use .task in SwiftUI)
final class ViewModel {
private var streamTask: Task<Void, Never>?
func onAppear() {
streamTask = Task { [weak self] in
await self?.stream()
}
}
func onDisappear() {
streamTask?.cancel()
}
}
```
### 5.7 Use Structured Concurrency for Parallelism
```swift
// ❌ Wrong: sequential awaits where work could run concurrently
let a = await loadA()
let b = await loadB() // waits for A to finish first
// ✅ Correct: async let runs them concurrently
async let a = loadA()
async let b = loadB()
let (resultA, resultB) = await (a, b)
// ✅ For a dynamic number of children, use a task group
try await withThrowingTaskGroup(of: Item.self) { group in
for id in ids {
group.addTask { try await fetch(id) }
}
for try await item in group {
store(item)
}
}
```
---
## 6. SwiftUI
### 6.1 Choose the Right State Wrapper
```swift
// ✅ @State: simple value-type state owned by this view
struct Toggle: View {
@State private var isOn = false
var body: some View { /* ... */ }
}
// ✅ @StateObject: the view CREATES and OWNS a reference-type model
struct ProfileScreen: View {
@StateObject private var model = ProfileViewModel()
var body: some View { /* ... */ }
}
// ✅ @ObservedObject: the model is OWNED elsewhere and passed in
struct ProfileHeader: View {
@ObservedObject var model: ProfileViewModel
var body: some View { /* ... */ }
}
// ✅ @Binding: a two-way reference to state owned by a parent
struct SearchField: View {
@Binding var text: String
var body: some View { /* ... */ }
}
```
### 6.2 @StateObject vs @ObservedObject
```swift
// ❌ Wrong: @ObservedObject for an object the view itself creates.
// SwiftUI may recreate the view, re-instantiating the model and
// losing its state on every re-render.
struct CounterView: View {
@ObservedObject var model = CounterModel() // recreated unexpectedly
}
// ✅ Correct: @StateObject ties the model's lifetime to the view
struct CounterView: View {
@StateObject private var model = CounterModel()
}
```
### 6.3 Preserve View Identity
```swift
// ❌ Wrong: index-based id reuses identity when the array reorders,
// causing wrong animations and stale state.
ForEach(0..<items.count, id: \.self) { i in
ItemRow(item: items[i])
}
// ✅ Correct: use a stable, unique identifier
ForEach(items) { item in // Item: Identifiable
ItemRow(item: item)
}
// ✅ Use .id(...) to deliberately reset a view's state
ProfileView(user: user)
.id(user.id) // new identity per user -> fresh state
```
### 6.4 Avoid Over-Rendering
```swift
// ❌ Wrong: a single huge body re-renders everything on any change
struct Dashboard: View {
@ObservedObject var model: DashboardModel
var body: some View {
VStack {
// header + heavy chart + list all recompute together
}
}
}
// ✅ Correct: extract subviews so only the affected part re-renders.
// Each child observes only the state it needs.
struct Dashboard: View {
var body: some View {
VStack {
HeaderView()
ChartView()
ItemList()
}
}
}
```
### 6.5 Do Async Work with .task
```swift
// ❌ Wrong: kicking off work in onAppear without cancellation
.onAppear {
Task { await model.load() } // not cancelled when view disappears
}
// ✅ Correct: .task is tied to the view's lifetime and auto-cancels
.task {
await model.load()
}
// ✅ Re-run when an input changes
.task(id: query) {
await model.search(query)
}
```
---
## 7. Protocols and Generics
### 7.1 Protocol-Oriented Design
```swift
// ✅ Compose behavior with protocols and default implementations
protocol Identifiable2 {
var id: String { get }
}
protocol Describable {
var description: String { get }
}
extension Describable {
var description: String { "no description" } // default
}
```
### 7.2 Prefer some Over any
```swift
// ❌ Slower: `any` is an existential box with dynamic dispatch
func makeShape() -> any Shape { Circle() }
// ✅ Faster: `some` is an opaque type resolved at compile time,
// preserving the concrete type and enabling static dispatch.
func makeShape() -> some Shape { Circle() }
// Use `any` only when you genuinely need heterogeneous values:
let shapes: [any Shape] = [Circle(), Square()]
```
### 7.3 Generic Constraints Over Existentials
```swift
// ❌ Wrong: existential parameter loses the concrete type and is slower
func logTotal(_ items: [any Numeric]) {
// awkward: the concrete numeric type is erased, so arithmetic needs casts
}
// ✅ Correct: a generic constraint keeps full type information
func total<T: Numeric>(_ items: [T]) -> T {
items.reduce(.zero, +)
}
```
### 7.4 Associated Types with Primary Associated Types
```swift
// ✅ Primary associated types (Swift 5.7+) allow lightweight constraints
protocol Container<Item> {
associatedtype Item
var count: Int { get }
subscript(_ index: Int) -> Item { get }
}
// Constrain the element type without a where-clause:
func first(in container: some Container<Int>) -> Int {
container[0]
}
```
---
## 8. Access Control and API Design
### 8.1 Use the Narrowest Access Level
```swift
// ❌ Wrong: everything public exposes internal details as API surface
public class Service {
public var cache: [String: Data] = [:]
public func reset() {}
}
// ✅ Correct: expose only the intended API; hide the rest
public final class Service {
private var cache: [String: Data] = [:]
public func reset() { cache.removeAll() }
}
```
### 8.2 private vs fileprivate vs internal vs public/open
```swift
// private: visible only within the enclosing declaration (and its extensions in the same file)
// fileprivate: visible within the same source file
// internal: visible within the module (the default)
// public: visible outside the module, but not subclassable/overridable
// open: visible outside the module AND subclassable/overridable
// ✅ Use private(set) to expose read-only state
public final class Account {
public private(set) var balance: Decimal = 0
}
```
### 8.3 Follow the Swift API Design Guidelines
```swift
// ❌ Wrong: redundant words, unclear argument roles
func insertObject(_ object: Element, atIndex index: Int)
list.removeElement(at: 0)
// ✅ Correct: read at the call site like a phrase; omit needless words
func insert(_ element: Element, at index: Int)
list.insert(item, at: 0) // reads as "insert item at 0"
list.remove(at: 0)
// ✅ Boolean properties read as assertions
var isEmpty: Bool
var hasChanges: Bool
```
### 8.4 Name Methods by Side Effects
```swift
// ✅ Mutating verb vs non-mutating noun pairs (the "ed/ing" rule)
var sorted = array.sorted() // returns a new value (non-mutating)
array.sort() // mutates in place (imperative verb)
let reversed = text.reversed()
text.reverse()
```
---
## 9. Collections and Functional Style
### 9.1 Prefer map/filter/compactMap
```swift
// ❌ Verbose: manual loop with mutable accumulator
var names: [String] = []
for user in users {
if user.isActive {
names.append(user.name)
}
}
// ✅ Correct: declarative transform
let names = users.filter(\.isActive).map(\.name)
```
### 9.2 compactMap to Drop nils
```swift
// ❌ Wrong: map leaves an [Int?] you then have to unwrap
let numbers = strings.map { Int($0) } // [Int?]
// ✅ Correct: compactMap removes nils and unwraps
let numbers = strings.compactMap { Int($0) } // [Int]
```
### 9.3 Avoid O(n^2) Membership Checks
```swift
// ❌ Wrong: contains on an Array is O(n); the loop is O(n*m)
let result = candidates.filter { blocked.contains($0) } // blocked: [ID]
// ✅ Correct: a Set makes membership O(1)
let blockedSet = Set(blocked)
let result = candidates.filter { blockedSet.contains($0) }
```
### 9.4 reduce and Dictionary Grouping
```swift
// ✅ Group with Dictionary(grouping:)
let byFirstLetter = Dictionary(grouping: words) { $0.first }
// ❌ Wrong: reduce(into:) is preferred over reduce that copies each step
let total = numbers.reduce(0) { $0 + $1 } // fine for scalars
// ✅ Use reduce(into:) when accumulating into a collection (avoids copies)
let counts = words.reduce(into: [:]) { acc, word in
acc[word, default: 0] += 1
}
```
### 9.5 Use lazy for Chained Transforms on Large Sequences
```swift
// ❌ Wrong: each step allocates an intermediate array
let firstMatch = bigArray.map(expensive).filter(isValid).first
// ✅ Correct: lazy avoids intermediate arrays and stops early
let firstMatch = bigArray.lazy.map(expensive).filter(isValid).first
```
---
## 10. Testing
### 10.1 Arrange-Act-Assert with XCTest
```swift
import XCTest
@testable import MyApp
final class PriceCalculatorTests: XCTestCase {
func testDiscountApplied() {
// Arrange
let calculator = PriceCalculator(discount: 0.1)
// Act
let total = calculator.total(for: 100)
// Assert
XCTAssertEqual(total, 90, accuracy: 0.001)
}
}
```
### 10.2 Testing async Code
```swift
// ✅ Mark the test method async and await directly
func testFetchUser() async throws {
let service = UserService(client: MockClient())
let user = try await service.fetchUser(id: "42")
XCTAssertEqual(user.id, "42")
}
// ✅ Assert that an async call throws the expected error
func testFetchUserUnauthorized() async {
let service = UserService(client: UnauthorizedClient())
do {
_ = try await service.fetchUser(id: "42")
XCTFail("expected to throw")
} catch NetworkError.unauthorized {
// expected
} catch {
XCTFail("unexpected error: \(error)")
}
}
```
### 10.3 Inject Dependencies via Protocols
```swift
// ✅ Depend on a protocol so tests can substitute a mock
protocol HTTPClient {
func get(_ url: URL) async throws -> Data
}
struct MockClient: HTTPClient {
var result: Result<Data, Error>
func get(_ url: URL) async throws -> Data {
try result.get()
}
}
```
### 10.4 Avoid Sleeps; Await Expectations or Values
```swift
// ❌ Wrong: arbitrary sleep makes tests slow and flaky
func testCallback() {
var done = false
object.run { done = true }
Thread.sleep(forTimeInterval: 1)
XCTAssertTrue(done)
}
// ✅ Correct: use XCTestExpectation for callback APIs
func testCallback() {
let expectation = expectation(description: "callback fired")
object.run { expectation.fulfill() }
wait(for: [expectation], timeout: 1.0)
}
// ✅ Better: refactor to async and await the value directly
func testCallback() async {
let value = await object.run()
XCTAssertEqual(value, expected)
}
```
---
## References
- [Swift API Design Guidelines](https://www.swift.org/documentation/api-design-guidelines/)
- [The Swift Programming Language](https://docs.swift.org/swift-book/)
- [Swift Concurrency (TSPL)](https://docs.swift.org/swift-book/documentation/the-swift-programming-language/concurrency/)
- [Migrating to Swift 6](https://www.swift.org/migration/documentation/migrationguide/)
- [Apple: Managing Model Data in Your App (SwiftUI)](https://developer.apple.com/documentation/swiftui/managing-model-data-in-your-app)
- [Apple: Automatic Reference Counting](https://docs.swift.org/swift-book/documentation/the-swift-programming-language/automaticreferencecounting/)
- [WWDC: Protocol-Oriented Programming in Swift](https://developer.apple.com/videos/play/wwdc2015/408/)
- [Swift Evolution](https://github.com/apple/swift-evolution)
+553
View File
@@ -0,0 +1,553 @@
# TypeScript/JavaScript Code Review Guide
> TypeScript 代码审查指南,覆盖类型系统、泛型、条件类型、strict 模式、async/await 模式等核心主题。
## 目录
- [类型安全基础](#类型安全基础)
- [泛型模式](#泛型模式)
- [高级类型](#高级类型)
- [Strict 模式配置](#strict-模式配置)
- [异步处理](#异步处理)
- [不可变性](#不可变性)
- [ESLint 规则](#eslint-规则)
- [Review Checklist](#review-checklist)
---
## 类型安全基础
### 避免使用 any
```typescript
// ❌ Using any defeats type safety
function processData(data: any) {
return data.value; // 无类型检查,运行时可能崩溃
}
// ✅ Use proper types
interface DataPayload {
value: string;
}
function processData(data: DataPayload) {
return data.value;
}
// ✅ 未知类型用 unknown + 类型守卫
function processUnknown(data: unknown) {
if (typeof data === 'object' && data !== null && 'value' in data) {
return (data as { value: string }).value;
}
throw new Error('Invalid data');
}
```
### 类型收窄
```typescript
// ❌ 不安全的类型断言
function getLength(value: string | string[]) {
return (value as string[]).length; // 如果是 string 会出错
}
// ✅ 使用类型守卫
function getLength(value: string | string[]): number {
if (Array.isArray(value)) {
return value.length;
}
return value.length;
}
// ✅ 使用 in 操作符
interface Dog { bark(): void }
interface Cat { meow(): void }
function speak(animal: Dog | Cat) {
if ('bark' in animal) {
animal.bark();
} else {
animal.meow();
}
}
```
### 字面量类型与 as const
```typescript
// ❌ 类型过于宽泛
const config = {
endpoint: '/api',
method: 'GET' // 类型是 string
};
// ✅ 使用 as const 获得字面量类型
const config = {
endpoint: '/api',
method: 'GET'
} as const; // method 类型是 'GET'
// ✅ 用于函数参数
function request(method: 'GET' | 'POST', url: string) { ... }
request(config.method, config.endpoint); // 正确!
```
---
## 泛型模式
### 基础泛型
```typescript
// ❌ 重复代码
function getFirstString(arr: string[]): string | undefined {
return arr[0];
}
function getFirstNumber(arr: number[]): number | undefined {
return arr[0];
}
// ✅ 使用泛型
function getFirst<T>(arr: T[]): T | undefined {
return arr[0];
}
```
### 泛型约束
```typescript
// ❌ 泛型没有约束,无法访问属性
function getProperty<T>(obj: T, key: string) {
return obj[key]; // Error: 无法索引
}
// ✅ 使用 keyof 约束
function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
return obj[key];
}
const user = { name: 'Alice', age: 30 };
getProperty(user, 'name'); // 返回类型是 string
getProperty(user, 'age'); // 返回类型是 number
getProperty(user, 'foo'); // Error: 'foo' 不在 keyof User
```
### 泛型默认值
```typescript
// ✅ 提供合理的默认类型
interface ApiResponse<T = unknown> {
data: T;
status: number;
message: string;
}
// 可以不指定泛型参数
const response: ApiResponse = { data: null, status: 200, message: 'OK' };
// 也可以指定
const userResponse: ApiResponse<User> = { ... };
```
### 常见泛型工具类型
```typescript
// ✅ 善用内置工具类型
interface User {
id: number;
name: string;
email: string;
}
type PartialUser = Partial<User>; // 所有属性可选
type RequiredUser = Required<User>; // 所有属性必需
type ReadonlyUser = Readonly<User>; // 所有属性只读
type UserKeys = keyof User; // 'id' | 'name' | 'email'
type NameOnly = Pick<User, 'name'>; // { name: string }
type WithoutId = Omit<User, 'id'>; // { name: string; email: string }
type UserRecord = Record<string, User>; // { [key: string]: User }
```
---
## 高级类型
### 条件类型
```typescript
// ✅ 根据输入类型返回不同类型
type IsString<T> = T extends string ? true : false;
type A = IsString<string>; // true
type B = IsString<number>; // false
// ✅ 提取数组元素类型
type ElementType<T> = T extends (infer U)[] ? U : never;
type Elem = ElementType<string[]>; // string
// ✅ 提取函数返回类型(内置 ReturnType)
type MyReturnType<T> = T extends (...args: any[]) => infer R ? R : never;
```
### 映射类型
```typescript
// ✅ 转换对象类型的所有属性
type Nullable<T> = {
[K in keyof T]: T[K] | null;
};
interface User {
name: string;
age: number;
}
type NullableUser = Nullable<User>;
// { name: string | null; age: number | null }
// ✅ 添加前缀
type Getters<T> = {
[K in keyof T as `get${Capitalize<string & K>}`]: () => T[K];
};
type UserGetters = Getters<User>;
// { getName: () => string; getAge: () => number }
```
### 模板字面量类型
```typescript
// ✅ 类型安全的事件名称
type EventName = 'click' | 'focus' | 'blur';
type HandlerName = `on${Capitalize<EventName>}`;
// 'onClick' | 'onFocus' | 'onBlur'
// ✅ API 路由类型
type ApiRoute = `/api/${string}`;
const route: ApiRoute = '/api/users'; // OK
const badRoute: ApiRoute = '/users'; // Error
```
### Discriminated Unions
```typescript
// ✅ 使用判别属性实现类型安全
type Result<T, E> =
| { success: true; data: T }
| { success: false; error: E };
function handleResult(result: Result<User, Error>) {
if (result.success) {
console.log(result.data.name); // TypeScript 知道 data 存在
} else {
console.log(result.error.message); // TypeScript 知道 error 存在
}
}
// ✅ Redux Action 模式
type Action =
| { type: 'INCREMENT'; payload: number }
| { type: 'DECREMENT'; payload: number }
| { type: 'RESET' };
function reducer(state: number, action: Action): number {
switch (action.type) {
case 'INCREMENT':
return state + action.payload; // payload 类型已知
case 'DECREMENT':
return state - action.payload;
case 'RESET':
return 0; // 这里没有 payload
}
}
```
---
## Strict 模式配置
### 推荐的 tsconfig.json
```json
{
"compilerOptions": {
// ✅ 必须开启的 strict 选项
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": true,
"noImplicitThis": true,
"useUnknownInCatchVariables": true,
// ✅ 额外推荐选项
"noUncheckedIndexedAccess": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"exactOptionalPropertyTypes": true,
"noPropertyAccessFromIndexSignature": true
}
}
```
### noUncheckedIndexedAccess 的影响
```typescript
// tsconfig: "noUncheckedIndexedAccess": true
const arr = [1, 2, 3];
const first = arr[0]; // 类型是 number | undefined
// ❌ 直接使用可能出错
console.log(first.toFixed(2)); // Error: 可能是 undefined
// ✅ 先检查
if (first !== undefined) {
console.log(first.toFixed(2));
}
// ✅ 或使用非空断言(确定时)
console.log(arr[0]!.toFixed(2));
```
---
## 异步处理
### Promise 错误处理
```typescript
// ❌ Not handling async errors
async function fetchUser(id: string) {
const response = await fetch(`/api/users/${id}`);
return response.json(); // 网络错误未处理
}
// ✅ Handle errors properly
async function fetchUser(id: string): Promise<User> {
try {
const response = await fetch(`/api/users/${id}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return await response.json();
} catch (error) {
if (error instanceof Error) {
throw new Error(`Failed to fetch user: ${error.message}`);
}
throw error;
}
}
```
### Promise.all vs Promise.allSettled
```typescript
// ❌ Promise.all 一个失败全部失败
async function fetchAllUsers(ids: string[]) {
const users = await Promise.all(ids.map(fetchUser));
return users; // 一个失败就全部失败
}
// ✅ Promise.allSettled 获取所有结果
async function fetchAllUsers(ids: string[]) {
const results = await Promise.allSettled(ids.map(fetchUser));
const users: User[] = [];
const errors: Error[] = [];
for (const result of results) {
if (result.status === 'fulfilled') {
users.push(result.value);
} else {
errors.push(result.reason);
}
}
return { users, errors };
}
```
### 竞态条件处理
```typescript
// ❌ 竞态条件:旧请求可能覆盖新请求
function useSearch() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
useEffect(() => {
fetch(`/api/search?q=${query}`)
.then(r => r.json())
.then(setResults); // 旧请求可能后返回!
}, [query]);
}
// ✅ 使用 AbortController
function useSearch() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
useEffect(() => {
const controller = new AbortController();
fetch(`/api/search?q=${query}`, { signal: controller.signal })
.then(r => r.json())
.then(setResults)
.catch(e => {
if (e.name !== 'AbortError') throw e;
});
return () => controller.abort();
}, [query]);
}
```
---
## 不可变性
### Readonly 与 ReadonlyArray
```typescript
// ❌ 可变参数可能被意外修改
function processUsers(users: User[]) {
users.sort((a, b) => a.name.localeCompare(b.name)); // 修改了原数组!
return users;
}
// ✅ 使用 readonly 防止修改
function processUsers(users: readonly User[]): User[] {
return [...users].sort((a, b) => a.name.localeCompare(b.name));
}
// ✅ 深度只读
type DeepReadonly<T> = {
readonly [K in keyof T]: T[K] extends object ? DeepReadonly<T[K]> : T[K];
};
```
### 不变式函数参数
```typescript
// ✅ 使用 as const 和 readonly 保护数据
function createConfig<T extends readonly string[]>(routes: T) {
return routes;
}
const routes = createConfig(['home', 'about', 'contact'] as const);
// 类型是 readonly ['home', 'about', 'contact']
```
---
## ESLint 规则
### 推荐的 @typescript-eslint 规则
```javascript
// eslint.config.js(flat config,typescript-eslint v8)
import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';
export default tseslint.config(
eslint.configs.recommended,
// 需要类型信息的规则集,对应旧的 recommended-requiring-type-checking
tseslint.configs.recommendedTypeChecked,
tseslint.configs.strictTypeChecked,
{
languageOptions: {
parserOptions: {
// 让带类型的规则自动找到对应 tsconfig
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
// ✅ 类型安全
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/no-unsafe-assignment': 'error',
'@typescript-eslint/no-unsafe-member-access': 'error',
'@typescript-eslint/no-unsafe-call': 'error',
'@typescript-eslint/no-unsafe-return': 'error',
// ✅ 最佳实践
'@typescript-eslint/explicit-function-return-type': 'warn',
'@typescript-eslint/no-floating-promises': 'error',
'@typescript-eslint/await-thenable': 'error',
'@typescript-eslint/no-misused-promises': 'error',
// ✅ 代码风格
'@typescript-eslint/consistent-type-imports': 'error',
'@typescript-eslint/prefer-nullish-coalescing': 'error',
'@typescript-eslint/prefer-optional-chain': 'error',
},
},
);
```
### 常见 ESLint 错误修复
```typescript
// ❌ no-floating-promises: Promise 必须被处理
async function save() { ... }
save(); // Error: 未处理的 Promise
// ✅ 显式处理
await save();
// 或
save().catch(console.error);
// 或明确忽略
void save();
// ❌ no-misused-promises: 不能在非 async 位置使用 Promise
const items = [1, 2, 3];
items.forEach(async (item) => { // Error!
await processItem(item);
});
// ✅ 使用 for...of
for (const item of items) {
await processItem(item);
}
// 或 Promise.all
await Promise.all(items.map(processItem));
```
---
## Review Checklist
### 类型系统
- [ ] 没有使用 `any`(使用 `unknown` + 类型守卫代替)
- [ ] 接口和类型定义完整且有意义的命名
- [ ] 使用泛型提高代码复用性
- [ ] 联合类型有正确的类型收窄
- [ ] 善用工具类型(Partial、Pick、Omit 等)
### 泛型
- [ ] 泛型有适当的约束(extends)
- [ ] 泛型参数有合理的默认值
- [ ] 避免过度泛型化(KISS 原则)
### Strict 模式
- [ ] tsconfig.json 启用了 strict: true
- [ ] 启用了 noUncheckedIndexedAccess
- [ ] 没有使用 @ts-ignore(改用 @ts-expect-error)
### 异步代码
- [ ] async 函数有错误处理
- [ ] Promise rejection 被正确处理
- [ ] 没有 floating promises(未处理的 Promise)
- [ ] 并发请求使用 Promise.all 或 Promise.allSettled
- [ ] 竞态条件使用 AbortController 处理
### 不可变性
- [ ] 不直接修改函数参数
- [ ] 使用 spread 操作符创建新对象/数组
- [ ] 考虑使用 readonly 修饰符
### ESLint
- [ ] 使用 @typescript-eslint/recommended
- [ ] 没有 ESLint 警告或错误
- [ ] 使用 consistent-type-imports
+924
View File
@@ -0,0 +1,924 @@
# Vue 3 Code Review Guide
> Vue 3 Composition API 代码审查指南,覆盖响应性系统、Props/Emits、Watchers、Composables、Vue 3.5 新特性等核心主题。
## 目录
- [响应性系统](#响应性系统)
- [Props & Emits](#props--emits)
- [Vue 3.5 新特性](#vue-35-新特性)
- [Watchers](#watchers)
- [模板最佳实践](#模板最佳实践)
- [Composables](#composables)
- [性能优化](#性能优化)
- [Review Checklist](#review-checklist)
---
## 响应性系统
### ref vs reactive 选择
```vue
<!-- ✅ 基本类型用 ref -->
<script setup lang="ts">
const count = ref(0)
const name = ref('Vue')
// ref 需要 .value 访问
count.value++
</script>
<!-- ✅ 对象/数组用 reactive(可选)-->
<script setup lang="ts">
const state = reactive({
user: null,
loading: false,
error: null
})
// reactive 直接访问
state.loading = true
</script>
<!-- 💡 现代最佳实践:全部使用 ref,保持一致性 -->
<script setup lang="ts">
const user = ref<User | null>(null)
const loading = ref(false)
const error = ref<Error | null>(null)
</script>
```
### 解构 reactive 对象
```vue
<!-- ❌ 解构 reactive 会丢失响应性 -->
<script setup lang="ts">
const state = reactive({ count: 0, name: 'Vue' })
const { count, name } = state // 丢失响应性!
</script>
<!-- ✅ 使用 toRefs 保持响应性 -->
<script setup lang="ts">
const state = reactive({ count: 0, name: 'Vue' })
const { count, name } = toRefs(state) // 保持响应性
// 或者直接使用 ref
const count = ref(0)
const name = ref('Vue')
</script>
```
### computed 副作用
```vue
<!-- ❌ computed 中产生副作用 -->
<script setup lang="ts">
const fullName = computed(() => {
console.log('Computing...') // 副作用!
otherRef.value = 'changed' // 修改其他状态!
return `${firstName.value} ${lastName.value}`
})
</script>
<!-- ✅ computed 只用于派生状态 -->
<script setup lang="ts">
const fullName = computed(() => {
return `${firstName.value} ${lastName.value}`
})
// 副作用放在 watch 或事件处理中
watch(fullName, (name) => {
console.log('Name changed:', name)
})
</script>
```
### shallowRef 优化
```vue
<!-- ❌ 大型对象使用 ref 会深度转换 -->
<script setup lang="ts">
const largeData = ref(hugeNestedObject) // 深度响应式,性能开销大
</script>
<!-- ✅ 使用 shallowRef 避免深度转换 -->
<script setup lang="ts">
const largeData = shallowRef(hugeNestedObject)
// 整体替换才会触发更新
function updateData(newData) {
largeData.value = newData // ✅ 触发更新
}
// ❌ 修改嵌套属性不会触发更新
// largeData.value.nested.prop = 'new'
// 需要手动触发时使用 triggerRef
import { triggerRef } from 'vue'
largeData.value.nested.prop = 'new'
triggerRef(largeData)
</script>
```
---
## Props & Emits
### 直接修改 props
```vue
<!-- ❌ 直接修改 props -->
<script setup lang="ts">
const props = defineProps<{ user: User }>()
props.user.name = 'New Name' // 永远不要直接修改 props!
</script>
<!-- ✅ 使用 emit 通知父组件更新 -->
<script setup lang="ts">
const props = defineProps<{ user: User }>()
const emit = defineEmits<{
update: [name: string]
}>()
const updateName = (name: string) => emit('update', name)
</script>
```
### defineProps 类型声明
```vue
<!-- ❌ defineProps 缺少类型声明 -->
<script setup lang="ts">
const props = defineProps(['title', 'count']) // 无类型检查
</script>
<!-- ✅ 使用类型声明 + withDefaults -->
<script setup lang="ts">
interface Props {
title: string
count?: number
items?: string[]
}
const props = withDefaults(defineProps<Props>(), {
count: 0,
items: () => [] // 对象/数组默认值需要工厂函数
})
</script>
```
### defineEmits 类型安全
```vue
<!-- ❌ defineEmits 缺少类型 -->
<script setup lang="ts">
const emit = defineEmits(['update', 'delete']) // 无类型检查
emit('update', someValue) // 参数类型不安全
</script>
<!-- ✅ 完整的类型定义 -->
<script setup lang="ts">
const emit = defineEmits<{
update: [id: number, value: string]
delete: [id: number]
'custom-event': [payload: CustomPayload]
}>()
// 现在有完整的类型检查
emit('update', 1, 'new value') // ✅
emit('update', 'wrong') // ❌ TypeScript 报错
</script>
```
---
## Vue 3.5 新特性
### Reactive Props Destructure (3.5+)
```vue
<!-- Vue 3.5 之前:解构会丢失响应性 -->
<script setup lang="ts">
const props = defineProps<{ count: number }>()
// 需要使用 props.count 或 toRefs
</script>
<!-- ✅ Vue 3.5+:解构保持响应性 -->
<script setup lang="ts">
const { count, name = 'default' } = defineProps<{
count: number
name?: string
}>()
// count 和 name 自动保持响应性!
// 可以直接在模板和 watch 中使用
watch(() => count, (newCount) => {
console.log('Count changed:', newCount)
})
</script>
<!-- ✅ 配合默认值使用 -->
<script setup lang="ts">
const {
title,
count = 0,
items = () => [] // 函数作为默认值(对象/数组)
} = defineProps<{
title: string
count?: number
items?: () => string[]
}>()
</script>
```
### defineModel (3.4+)
```vue
<!-- ❌ 传统 v-model 实现:冗长 -->
<script setup lang="ts">
const props = defineProps<{ modelValue: string }>()
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
// 需要 computed 来双向绑定
const value = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
</script>
<!-- ✅ defineModel:简洁的 v-model 实现 -->
<script setup lang="ts">
// 自动处理 props 和 emit
const model = defineModel<string>()
// 直接使用
model.value = 'new value' // 自动 emit
</script>
<template>
<input v-model="model" />
</template>
<!-- ✅ 命名 v-model -->
<script setup lang="ts">
// v-model:title 的实现
const title = defineModel<string>('title')
// 带默认值和选项
const count = defineModel<number>('count', {
default: 0,
required: false
})
</script>
<!-- ✅ 多个 v-model -->
<script setup lang="ts">
const firstName = defineModel<string>('firstName')
const lastName = defineModel<string>('lastName')
</script>
<template>
<!-- 父组件使用:<MyInput v-model:first-name="first" v-model:last-name="last" /> -->
</template>
<!-- ✅ v-model 修饰符 -->
<script setup lang="ts">
const [model, modifiers] = defineModel<string>()
// 检查修饰符
if (modifiers.capitalize) {
// 处理 .capitalize 修饰符
}
</script>
```
### useTemplateRef (3.5+)
```vue
<!-- 传统方式:ref 属性与变量同名 -->
<script setup lang="ts">
const inputRef = ref<HTMLInputElement | null>(null)
</script>
<template>
<input ref="inputRef" />
</template>
<!-- ✅ useTemplateRef:更清晰的模板引用 -->
<script setup lang="ts">
import { useTemplateRef } from 'vue'
const input = useTemplateRef<HTMLInputElement>('my-input')
onMounted(() => {
input.value?.focus()
})
</script>
<template>
<input ref="my-input" />
</template>
<!-- ✅ 动态 ref -->
<script setup lang="ts">
const refKey = ref('input-a')
const dynamicInput = useTemplateRef<HTMLInputElement>(refKey)
</script>
```
### useId (3.5+)
```vue
<!-- ❌ 手动生成 ID 可能冲突 -->
<script setup lang="ts">
const id = `input-${Math.random()}` // SSR 不一致!
</script>
<!-- ✅ useId:SSR 安全的唯一 ID -->
<script setup lang="ts">
import { useId } from 'vue'
const id = useId() // 例如:'v-0'
</script>
<template>
<label :for="id">Name</label>
<input :id="id" />
</template>
<!-- ✅ 表单组件中使用 -->
<script setup lang="ts">
const inputId = useId()
const errorId = useId()
</script>
<template>
<label :for="inputId">Email</label>
<input
:id="inputId"
:aria-describedby="errorId"
/>
<span :id="errorId" class="error">{{ error }}</span>
</template>
```
### onWatcherCleanup (3.5+)
```vue
<!-- 传统方式:watch 第三个参数 -->
<script setup lang="ts">
watch(source, async (value, oldValue, onCleanup) => {
const controller = new AbortController()
onCleanup(() => controller.abort())
// ...
})
</script>
<!-- ✅ onWatcherCleanup:更灵活的清理 -->
<script setup lang="ts">
import { onWatcherCleanup } from 'vue'
watch(source, async (value) => {
const controller = new AbortController()
onWatcherCleanup(() => controller.abort())
// 可以在任意位置调用,不限于回调开头
if (someCondition) {
const anotherResource = createResource()
onWatcherCleanup(() => anotherResource.dispose())
}
await fetchData(value, controller.signal)
})
</script>
```
### Deferred Teleport (3.5+)
```vue
<!-- ❌ Teleport 目标必须在挂载时存在 -->
<template>
<Teleport to="#modal-container">
<!-- 如果 #modal-container 不存在会报错 -->
</Teleport>
</template>
<!-- ✅ defer 属性延迟挂载 -->
<template>
<Teleport to="#modal-container" defer>
<!-- 等待目标元素存在后再挂载 -->
<Modal />
</Teleport>
</template>
```
---
## Watchers
### watch vs watchEffect
```vue
<script setup lang="ts">
// ✅ watch:明确指定依赖,惰性执行
watch(
() => props.userId,
async (userId) => {
user.value = await fetchUser(userId)
}
)
// ✅ watchEffect:自动收集依赖,立即执行
watchEffect(async () => {
// 自动追踪 props.userId
user.value = await fetchUser(props.userId)
})
// 💡 选择指南:
// - 需要旧值?用 watch
// - 需要惰性执行?用 watch
// - 依赖复杂?用 watchEffect
</script>
```
### watch 清理函数
```vue
<!-- ❌ watch 缺少清理函数,可能内存泄漏 -->
<script setup lang="ts">
watch(searchQuery, async (query) => {
const controller = new AbortController()
const data = await fetch(`/api/search?q=${query}`, {
signal: controller.signal
})
results.value = await data.json()
// 如果 query 快速变化,旧请求不会被取消!
})
</script>
<!-- ✅ 使用 onCleanup 清理副作用 -->
<script setup lang="ts">
watch(searchQuery, async (query, _, onCleanup) => {
const controller = new AbortController()
onCleanup(() => controller.abort()) // 取消旧请求
try {
const data = await fetch(`/api/search?q=${query}`, {
signal: controller.signal
})
results.value = await data.json()
} catch (e) {
if (e.name !== 'AbortError') throw e
}
})
</script>
```
### watch 选项
```vue
<script setup lang="ts">
// ✅ immediate:立即执行一次
watch(
userId,
async (id) => {
user.value = await fetchUser(id)
},
{ immediate: true }
)
// ✅ deep:深度监听(性能开销大,谨慎使用)
watch(
state,
(newState) => {
console.log('State changed deeply')
},
{ deep: true }
)
// ✅ flush: 'post':DOM 更新后执行
watch(
source,
() => {
// 可以安全访问更新后的 DOM
// nextTick 不再需要
},
{ flush: 'post' }
)
// ✅ once: true (Vue 3.4+):只执行一次
watch(
source,
(value) => {
console.log('只会执行一次:', value)
},
{ once: true }
)
</script>
```
### 监听多个源
```vue
<script setup lang="ts">
// ✅ 监听多个 ref
watch(
[firstName, lastName],
([newFirst, newLast], [oldFirst, oldLast]) => {
console.log(`Name changed from ${oldFirst} ${oldLast} to ${newFirst} ${newLast}`)
}
)
// ✅ 监听 reactive 对象的特定属性
watch(
() => [state.count, state.name],
([count, name]) => {
console.log(`count: ${count}, name: ${name}`)
}
)
</script>
```
---
## 模板最佳实践
### v-for 的 key
```vue
<!-- ❌ v-for 中使用 index 作为 key -->
<template>
<li v-for="(item, index) in items" :key="index">
{{ item.name }}
</li>
</template>
<!-- ✅ 使用唯一标识作为 key -->
<template>
<li v-for="item in items" :key="item.id">
{{ item.name }}
</li>
</template>
<!-- ✅ 复合 key(当没有唯一 ID 时)-->
<template>
<li v-for="(item, index) in items" :key="`${item.name}-${item.type}-${index}`">
{{ item.name }}
</li>
</template>
```
### v-if 和 v-for 优先级
```vue
<!-- ❌ v-if 和 v-for 同时使用 -->
<template>
<li v-for="user in users" v-if="user.active" :key="user.id">
{{ user.name }}
</li>
</template>
<!-- ✅ 使用 computed 过滤 -->
<script setup lang="ts">
const activeUsers = computed(() =>
users.value.filter(user => user.active)
)
</script>
<template>
<li v-for="user in activeUsers" :key="user.id">
{{ user.name }}
</li>
</template>
<!-- ✅ 或用 template 包裹 -->
<template>
<template v-for="user in users" :key="user.id">
<li v-if="user.active">
{{ user.name }}
</li>
</template>
</template>
```
### 事件处理
```vue
<!-- ❌ 内联复杂逻辑 -->
<template>
<button @click="items = items.filter(i => i.id !== item.id); count--">
Delete
</button>
</template>
<!-- ✅ 使用方法 -->
<script setup lang="ts">
const deleteItem = (id: number) => {
items.value = items.value.filter(i => i.id !== id)
count.value--
}
</script>
<template>
<button @click="deleteItem(item.id)">Delete</button>
</template>
<!-- ✅ 事件修饰符 -->
<template>
<!-- 阻止默认行为 -->
<form @submit.prevent="handleSubmit">...</form>
<!-- 阻止冒泡 -->
<button @click.stop="handleClick">...</button>
<!-- 只执行一次 -->
<button @click.once="handleOnce">...</button>
<!-- 键盘修饰符 -->
<input @keyup.enter="submit" @keyup.esc="cancel" />
</template>
```
---
## Composables
### Composable 设计原则
```typescript
// ✅ 好的 composable 设计
export function useCounter(initialValue = 0) {
const count = ref(initialValue)
const increment = () => count.value++
const decrement = () => count.value--
const reset = () => count.value = initialValue
// 返回响应式引用和方法
return {
count: readonly(count), // 只读防止外部修改
increment,
decrement,
reset
}
}
// ❌ 不要返回 .value
export function useBadCounter() {
const count = ref(0)
return {
count: count.value // ❌ 丢失响应性!
}
}
```
### Props 传递给 composable
```vue
<!-- ❌ 传递 props 到 composable 丢失响应性 -->
<script setup lang="ts">
const props = defineProps<{ userId: string }>()
const { user } = useUser(props.userId) // 丢失响应性!
</script>
<!-- ✅ 使用 toRef 或 computed 保持响应性 -->
<script setup lang="ts">
const props = defineProps<{ userId: string }>()
const userIdRef = toRef(props, 'userId')
const { user } = useUser(userIdRef) // 保持响应性
// 或使用 computed
const { user } = useUser(computed(() => props.userId))
// ✅ Vue 3.5+:直接解构使用
const { userId } = defineProps<{ userId: string }>()
const { user } = useUser(() => userId) // getter 函数
</script>
```
### 异步 Composable
```typescript
// ✅ 异步 composable 模式
export function useFetch<T>(url: MaybeRefOrGetter<string>) {
const data = ref<T | null>(null)
const error = ref<Error | null>(null)
const loading = ref(false)
const execute = async () => {
loading.value = true
error.value = null
try {
const response = await fetch(toValue(url))
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
data.value = await response.json()
} catch (e) {
error.value = e as Error
} finally {
loading.value = false
}
}
// 响应式 URL 时自动重新获取
watchEffect(() => {
toValue(url) // 追踪依赖
execute()
})
return {
data: readonly(data),
error: readonly(error),
loading: readonly(loading),
refetch: execute
}
}
// 使用
const { data, loading, error, refetch } = useFetch<User[]>('/api/users')
```
### 生命周期与清理
```typescript
// ✅ Composable 中正确处理生命周期
export function useEventListener(
target: MaybeRefOrGetter<EventTarget>,
event: string,
handler: EventListener
) {
// 组件挂载后添加
onMounted(() => {
toValue(target).addEventListener(event, handler)
})
// 组件卸载时移除
onUnmounted(() => {
toValue(target).removeEventListener(event, handler)
})
}
// ✅ 使用 effectScope 管理副作用
export function useFeature() {
const scope = effectScope()
scope.run(() => {
// 所有响应式效果都在这个 scope 内
const state = ref(0)
watch(state, () => { /* ... */ })
watchEffect(() => { /* ... */ })
})
// 清理所有效果
onUnmounted(() => scope.stop())
return { /* ... */ }
}
```
---
## 性能优化
### v-memo
```vue
<!-- ✅ v-memo:缓存子树,避免重复渲染 -->
<template>
<div v-for="item in list" :key="item.id" v-memo="[item.id === selected]">
<!-- 只有当 item.id === selected 变化时才重新渲染 -->
<ExpensiveComponent :item="item" :selected="item.id === selected" />
</div>
</template>
<!-- ✅ 配合 v-for 使用 -->
<template>
<div
v-for="item in list"
:key="item.id"
v-memo="[item.name, item.status]"
>
<!-- 只有 name 或 status 变化时重新渲染 -->
</div>
</template>
```
### defineAsyncComponent
```vue
<script setup lang="ts">
import { defineAsyncComponent } from 'vue'
// ✅ 懒加载组件
const HeavyChart = defineAsyncComponent(() =>
import('./components/HeavyChart.vue')
)
// ✅ 带加载和错误状态
const AsyncModal = defineAsyncComponent({
loader: () => import('./components/Modal.vue'),
loadingComponent: LoadingSpinner,
errorComponent: ErrorDisplay,
delay: 200, // 延迟显示 loading(避免闪烁)
timeout: 3000 // 超时时间
})
</script>
```
### KeepAlive
```vue
<template>
<!-- ✅ 缓存动态组件 -->
<KeepAlive>
<component :is="currentTab" />
</KeepAlive>
<!-- ✅ 指定缓存的组件 -->
<KeepAlive include="TabA,TabB">
<component :is="currentTab" />
</KeepAlive>
<!-- ✅ 限制缓存数量 -->
<KeepAlive :max="10">
<component :is="currentTab" />
</KeepAlive>
</template>
<script setup lang="ts">
// KeepAlive 组件的生命周期钩子
onActivated(() => {
// 组件被激活时(从缓存恢复)
refreshData()
})
onDeactivated(() => {
// 组件被停用时(进入缓存)
pauseTimers()
})
</script>
```
### 虚拟列表
```vue
<!-- ✅ 大型列表使用虚拟滚动 -->
<script setup lang="ts">
import { useVirtualList } from '@vueuse/core'
const { list, containerProps, wrapperProps } = useVirtualList(
items,
{ itemHeight: 50 }
)
</script>
<template>
<div v-bind="containerProps" style="height: 400px; overflow: auto">
<div v-bind="wrapperProps">
<div v-for="item in list" :key="item.data.id" style="height: 50px">
{{ item.data.name }}
</div>
</div>
</div>
</template>
```
---
## Review Checklist
### 响应性系统
- [ ] ref 用于基本类型,reactive 用于对象(或统一用 ref)
- [ ] 没有解构 reactive 对象(或使用了 toRefs)
- [ ] props 传递给 composable 时保持了响应性
- [ ] shallowRef/shallowReactive 用于大型对象优化
- [ ] computed 中没有副作用
### Props & Emits
- [ ] defineProps 使用 TypeScript 类型声明
- [ ] 复杂默认值使用 withDefaults + 工厂函数
- [ ] defineEmits 有完整的类型定义
- [ ] 没有直接修改 props
- [ ] 考虑使用 defineModel 简化 v-model(Vue 3.4+)
### Vue 3.5 新特性(如适用)
- [ ] 使用 Reactive Props Destructure 简化 props 访问
- [ ] 使用 useTemplateRef 替代 ref 属性
- [ ] 表单使用 useId 生成 SSR 安全的 ID
- [ ] 使用 onWatcherCleanup 处理复杂清理逻辑
### Watchers
- [ ] watch/watchEffect 有适当的清理函数
- [ ] 异步 watch 处理了竞态条件
- [ ] flush: 'post' 用于 DOM 操作的 watcher
- [ ] 避免过度使用 watcher(优先用 computed)
- [ ] 考虑 once: true 用于一次性监听
### 模板
- [ ] v-for 使用唯一且稳定的 key
- [ ] v-if 和 v-for 没有在同一元素上
- [ ] 事件处理使用方法而非内联复杂逻辑
- [ ] 大型列表使用虚拟滚动
### Composables
- [ ] 相关逻辑提取到 composables
- [ ] composables 返回响应式引用(不是 .value)
- [ ] 纯函数不要包装成 composable
- [ ] 副作用在组件卸载时清理
- [ ] 使用 effectScope 管理复杂副作用
### 性能
- [ ] 大型组件拆分为小组件
- [ ] 使用 defineAsyncComponent 懒加载
- [ ] 避免不必要的响应式转换
- [ ] v-memo 用于昂贵的列表渲染
- [ ] KeepAlive 用于缓存动态组件
+388
View File
@@ -0,0 +1,388 @@
#!/usr/bin/env python3
"""
PR Analyzer - Analyze PR complexity and suggest review approach.
Usage:
python pr-analyzer.py [--diff-file FILE] [--stats]
Or pipe diff directly:
git diff main...HEAD | python pr-analyzer.py
"""
import os
import sys
import re
import argparse
from collections import defaultdict
from dataclasses import dataclass
from typing import List, Dict, Optional
RISK_NO_TESTS = "NO_TEST_CHANGES"
@dataclass
class FileStats:
"""Statistics for a single file."""
filename: str
additions: int = 0
deletions: int = 0
is_test: bool = False
is_config: bool = False
language: str = "unknown"
@dataclass
class PRAnalysis:
"""Complete PR analysis results."""
total_files: int
total_additions: int
total_deletions: int
files: List[FileStats]
complexity_score: float
size_category: str
estimated_review_time: int
risk_factors: List[str]
suggestions: List[str]
def detect_language(filename: str) -> str:
"""Detect programming language from filename."""
_, ext = os.path.splitext(filename)
extensions = {
'.py': 'Python',
'.js': 'JavaScript',
'.ts': 'TypeScript',
'.tsx': 'TypeScript/React',
'.jsx': 'JavaScript/React',
'.rs': 'Rust',
'.go': 'Go',
'.c': 'C',
'.h': 'C/C++',
'.cpp': 'C++',
'.hpp': 'C++',
'.cc': 'C++',
'.cxx': 'C++',
'.hh': 'C++',
'.hxx': 'C++',
'.java': 'Java',
'.kt': 'Kotlin',
'.swift': 'Swift',
'.rb': 'Ruby',
'.php': 'PHP',
'.cs': 'C#',
'.vue': 'Vue',
'.svelte': 'Svelte',
'.sql': 'SQL',
'.md': 'Markdown',
'.json': 'JSON',
'.yaml': 'YAML',
'.yml': 'YAML',
'.toml': 'TOML',
'.css': 'CSS',
'.scss': 'SCSS',
'.less': 'Less',
'.html': 'HTML',
'.zig': 'Zig',
'.ex': 'Elixir',
'.exs': 'Elixir',
'.erl': 'Erlang',
'.scala': 'Scala',
'.lua': 'Lua',
}
return extensions.get(ext.lower(), 'unknown')
def is_test_file(filename: str) -> bool:
"""Check if file is a test file."""
test_patterns = [
r'test_.*\.py$',
r'.*_test\.py$',
r'.*\.test\.(js|ts|tsx)$',
r'.*\.spec\.(js|ts|tsx)$',
r'tests?/',
r'__tests__/',
]
return any(re.search(p, filename) for p in test_patterns)
def is_config_file(filename: str) -> bool:
"""Check if file is a configuration file."""
config_patterns = [
r'\.env',
r'config\.',
r'\.json$',
r'\.yaml$',
r'\.yml$',
r'\.toml$',
r'Cargo\.toml$',
r'package\.json$',
r'tsconfig\.json$',
]
return any(re.search(p, filename) for p in config_patterns)
def parse_diff(diff_content: str) -> List[FileStats]:
"""Parse git diff output and extract file statistics."""
files = []
current_file = None
for line in diff_content.split('\n'):
# New file header
if line.startswith('diff --git'):
if current_file:
files.append(current_file)
# "diff --git a/<path> b/<path>" — match the b/ side via a
# backreference so a literal "b/" inside paths like lib/, web/ or
# db/ can't be mistaken for the prefix. Renames have differing
# paths, so fall back to the b/ side after the separating space.
match = re.match(r'diff --git a/(.+?) b/\1', line)
if not match:
match = re.search(r' b/(.+)$', line)
if match:
filename = match.group(1)
current_file = FileStats(
filename=filename,
language=detect_language(filename),
is_test=is_test_file(filename),
is_config=is_config_file(filename),
)
else:
current_file = None
elif current_file:
if line.startswith('+') and not line.startswith('+++'):
current_file.additions += 1
elif line.startswith('-') and not line.startswith('---'):
current_file.deletions += 1
if current_file:
files.append(current_file)
return files
def calculate_complexity(files: List[FileStats]) -> float:
"""Calculate complexity score (0-1 scale)."""
if not files:
return 0.0
total_changes = sum(f.additions + f.deletions for f in files)
# Base complexity from size
size_factor = min(total_changes / 1000, 1.0)
# Factor for number of files
file_factor = min(len(files) / 20, 1.0)
# Factor for non-test code ratio
test_lines = sum(f.additions + f.deletions for f in files if f.is_test)
non_test_ratio = 1 - (test_lines / max(total_changes, 1))
# Factor for language diversity
languages = set(f.language for f in files if f.language != 'unknown')
lang_factor = min(len(languages) / 5, 1.0)
complexity = (
size_factor * 0.4 +
file_factor * 0.2 +
non_test_ratio * 0.2 +
lang_factor * 0.2
)
return round(complexity, 2)
def categorize_size(total_changes: int) -> str:
"""Categorize PR size."""
if total_changes < 50:
return "XS (Extra Small)"
elif total_changes < 200:
return "S (Small)"
elif total_changes < 400:
return "M (Medium)"
elif total_changes < 800:
return "L (Large)"
else:
return "XL (Extra Large) - Consider splitting"
def estimate_review_time(files: List[FileStats], complexity: float) -> int:
"""Estimate review time in minutes."""
total_changes = sum(f.additions + f.deletions for f in files)
# Base time: ~1 minute per 20 lines
base_time = total_changes / 20
# Adjust for complexity
adjusted_time = base_time * (1 + complexity)
# Minimum 5 minutes, maximum 120 minutes
return max(5, min(120, int(adjusted_time)))
def identify_risk_factors(files: List[FileStats]) -> List[str]:
"""Identify potential risk factors in the PR."""
risks = []
total_changes = sum(f.additions + f.deletions for f in files)
test_changes = sum(f.additions + f.deletions for f in files if f.is_test)
if total_changes > 400:
risks.append("Large PR (>400 lines) - harder to review thoroughly")
if test_changes == 0 and total_changes > 50:
risks.append(f"{RISK_NO_TESTS}: No test changes - verify test coverage")
if total_changes > 100 and test_changes / max(total_changes, 1) < 0.2:
risks.append("Low test ratio (<20%) - consider adding more tests")
# Security-sensitive files
security_patterns = ['.env', 'auth', 'security', 'password', 'token', 'secret']
for f in files:
if any(p in f.filename.lower() for p in security_patterns):
risks.append(f"Security-sensitive file: {f.filename}")
break
# Database changes
for f in files:
if 'migration' in f.filename.lower() or f.language == 'SQL':
risks.append("Database changes detected - review carefully")
break
# Config changes
config_files = [f for f in files if f.is_config]
if config_files:
risks.append(f"Configuration changes in {len(config_files)} file(s)")
return risks
def generate_suggestions(files: List[FileStats], complexity: float, risks: List[str]) -> List[str]:
"""Generate review suggestions."""
suggestions = []
total_changes = sum(f.additions + f.deletions for f in files)
if total_changes > 800:
suggestions.append("Consider splitting this PR into smaller, focused changes")
if complexity > 0.7:
suggestions.append("High complexity - allocate extra review time")
suggestions.append("Consider pair reviewing for critical sections")
if any(RISK_NO_TESTS in r for r in risks):
suggestions.append("Request test additions before approval")
# Language-specific suggestions
languages = set(f.language for f in files)
if 'TypeScript' in languages or 'TypeScript/React' in languages:
suggestions.append("Check for proper type usage (avoid 'any')")
if 'Rust' in languages:
suggestions.append("Check for unwrap() usage and error handling")
if 'C' in languages or 'C++' in languages or 'C/C++' in languages:
suggestions.append("Check for memory safety, bounds checks, and UB risks")
if 'SQL' in languages:
suggestions.append("Review for SQL injection and query performance")
if not suggestions:
suggestions.append("Standard review process should suffice")
return suggestions
def analyze_pr(diff_content: str) -> PRAnalysis:
"""Perform complete PR analysis."""
files = parse_diff(diff_content)
total_additions = sum(f.additions for f in files)
total_deletions = sum(f.deletions for f in files)
total_changes = total_additions + total_deletions
complexity = calculate_complexity(files)
risks = identify_risk_factors(files)
suggestions = generate_suggestions(files, complexity, risks)
return PRAnalysis(
total_files=len(files),
total_additions=total_additions,
total_deletions=total_deletions,
files=files,
complexity_score=complexity,
size_category=categorize_size(total_changes),
estimated_review_time=estimate_review_time(files, complexity),
risk_factors=risks,
suggestions=suggestions,
)
def print_analysis(analysis: PRAnalysis, show_files: bool = False):
"""Print analysis results."""
print("\n" + "=" * 60)
print("PR ANALYSIS REPORT")
print("=" * 60)
print(f"\n📊 SUMMARY")
print(f" Files changed: {analysis.total_files}")
print(f" Additions: +{analysis.total_additions}")
print(f" Deletions: -{analysis.total_deletions}")
print(f" Total changes: {analysis.total_additions + analysis.total_deletions}")
print(f"\n📏 SIZE: {analysis.size_category}")
print(f" Complexity score: {analysis.complexity_score}/1.0")
print(f" Estimated review time: ~{analysis.estimated_review_time} minutes")
if analysis.risk_factors:
print(f"\n⚠️ RISK FACTORS:")
for risk in analysis.risk_factors:
print(f" • {risk}")
print(f"\n💡 SUGGESTIONS:")
for suggestion in analysis.suggestions:
print(f" • {suggestion}")
if show_files:
print(f"\n📁 FILES:")
# Group by language
by_lang: Dict[str, List[FileStats]] = defaultdict(list)
for f in analysis.files:
by_lang[f.language].append(f)
for lang, lang_files in sorted(by_lang.items()):
print(f"\n [{lang}]")
for f in lang_files:
prefix = "🧪" if f.is_test else "⚙️" if f.is_config else "📄"
print(f" {prefix} {f.filename} (+{f.additions}/-{f.deletions})")
print("\n" + "=" * 60)
def main():
parser = argparse.ArgumentParser(description='Analyze PR complexity')
parser.add_argument('--diff-file', '-f', help='Path to diff file')
parser.add_argument('--stats', '-s', action='store_true', help='Show file details')
args = parser.parse_args()
# Read diff from file or stdin
try:
if args.diff_file:
with open(args.diff_file, 'r', encoding='utf-8', errors='replace') as f:
diff_content = f.read()
elif not sys.stdin.isatty():
diff_content = sys.stdin.buffer.read().decode('utf-8', errors='replace')
else:
print("Usage: git diff main...HEAD | python pr-analyzer.py")
print(" python pr-analyzer.py -f diff.txt")
sys.exit(1)
except OSError as e:
print(f"Error reading diff input: {e}", file=sys.stderr)
sys.exit(1)
if not diff_content.strip():
print("No diff content provided")
sys.exit(1)
analysis = analyze_pr(diff_content)
print_analysis(analysis, show_files=args.stats)
if __name__ == '__main__':
main()
@@ -0,0 +1,75 @@
#!/usr/bin/env python3
"""Tests for pr-analyzer.py diff parsing (stdlib unittest, no extra deps)."""
import importlib.util
import os
import unittest
# The script has a hyphen in its name, so load it by path.
_HERE = os.path.dirname(os.path.abspath(__file__))
_spec = importlib.util.spec_from_file_location(
'pr_analyzer', os.path.join(_HERE, 'pr-analyzer.py')
)
pr_analyzer = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(pr_analyzer)
class ParseDiffFilenameTest(unittest.TestCase):
def test_lib_prefixed_path(self):
# "lib/" embeds a literal "b/" that the old regex swallowed.
diff = (
"diff --git a/lib/foo.py b/lib/foo.py\n"
"index 1234567..89abcde 100644\n"
"--- a/lib/foo.py\n"
"+++ b/lib/foo.py\n"
"@@ -1,2 +1,3 @@\n"
" unchanged\n"
"+added line\n"
"-removed line\n"
)
files = pr_analyzer.parse_diff(diff)
self.assertEqual(len(files), 1)
self.assertEqual(files[0].filename, 'lib/foo.py')
self.assertEqual(files[0].additions, 1)
self.assertEqual(files[0].deletions, 1)
def test_normal_path(self):
diff = (
"diff --git a/src/main.py b/src/main.py\n"
"index 1111111..2222222 100644\n"
"--- a/src/main.py\n"
"+++ b/src/main.py\n"
"@@ -0,0 +1 @@\n"
"+print('hi')\n"
)
files = pr_analyzer.parse_diff(diff)
self.assertEqual(len(files), 1)
self.assertEqual(files[0].filename, 'src/main.py')
def test_other_embedded_b_slash_prefixes(self):
# web/ and db/ also contain a literal "b/".
diff = (
"diff --git a/web/x.js b/web/x.js\n"
"+++ b/web/x.js\n"
"+console.log(1)\n"
"diff --git a/db/y.sql b/db/y.sql\n"
"+++ b/db/y.sql\n"
"+SELECT 1;\n"
)
files = pr_analyzer.parse_diff(diff)
self.assertEqual([f.filename for f in files], ['web/x.js', 'db/y.sql'])
def test_rename_falls_back_to_b_side(self):
diff = (
"diff --git a/old/name.py b/new/name.py\n"
"similarity index 100%\n"
"rename from old/name.py\n"
"rename to new/name.py\n"
)
files = pr_analyzer.parse_diff(diff)
self.assertEqual(len(files), 1)
self.assertEqual(files[0].filename, 'new/name.py')
if __name__ == '__main__':
unittest.main()
+138
View File
@@ -0,0 +1,138 @@
---
name: "database-migration"
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/db/migrator.Migrate()`,SQL 文件嵌入在二进制中。
## 基本规则
- SQL 迁移文件放在:
- `internal/db/migrator/goose/postgres/`
- `internal/db/migrator/goose/sqlite/`
- PostgreSQL 和 SQLite 必须使用同一个版本号、同一个语义文件名。
- 迁移文件使用 goose SQL 标记:
```sql
-- +goose Up
...
-- +goose Down
...
```
- 不要把表结构、默认系统配置、默认模板、默认管理员初始化写回 Go 代码。
- 编辑表结构(DDL)和插入表数据(DML/Seed)不要放在同一个 SQL 文件里,必须分成两个独立的 SQL 文件完成(例如,先通过一个文件修改表结构,再通过下一个递增版本号的文件插入/初始化数据)。
- 插入定时任务(schedules 表数据)时绝对不能指定 `id`,必须依靠数据库自增(Identity 或 AUTOINCREMENT)自动分配,防止与用户手动或后续插入的定时任务产生 ID 冲突。
- 不要添加物理外键;关系字段使用显式索引。
- 数据库默认值应匹配 Go model 零值或业务兜底值。
- 系统配置仍然保存字符串值;布尔值写 `"true"` / `"false"`,数字写十进制字符串,复杂结构写合法 JSON 字符串。
## 新增迁移流程
1. 先确认涉及的 Go model、读写路径和前端/接口消费方。
2. 选择下一个递增版本号,格式建议 `YYYYMMDDNNNN`,例如:
```text
202606090002_add_example_column.sql
```
3. 在 PostgreSQL 和 SQLite 目录各新增同名 SQL 文件。
4. 写 `Up`:
- 表结构变更使用 SQL DDL。
- 初始化/seed 数据使用 SQL `INSERT`。
- 需要幂等时使用 `IF NOT EXISTS` 或 `ON CONFLICT ... DO NOTHING`。
5. 写 `Down`:
- 能安全回滚的结构变更写反向 DDL。
- seed 数据按 key/name 等稳定标识删除。
6. 如果变更 API handler,运行 `make swagger`。
7. 至少运行:
```bash
go test ./internal/db/migrator
go test ./internal/model ./internal/apps/config ./internal/apps/admin/system_config
make code-check
```
## 方言注意事项
- PostgreSQL 自增主键用 `BIGSERIAL`;SQLite 自增主键用 `INTEGER PRIMARY KEY AUTOINCREMENT`。
- PostgreSQL 时间类型优先 `TIMESTAMPTZ`;SQLite 使用 `DATETIME`。
- PostgreSQL JSON 字段用 `JSONB`;SQLite 用 `JSON` 或 `TEXT`。
- 两个方言目录的字段名、索引名、seed 数据语义必须保持一致。
## 修改默认系统配置
- 新增或调整系统配置 seed 时,更新两个方言的 SQL 文件。
- `visibility` 使用常量语义:`0` 不公开,`1` 通过 `/api/v1/config/public` 返回。
- 公共配置 API 直接返回所有 `visibility = 1` 的配置键值,不要在 handler 中重新硬编码 key 列表。
## 验证重点
- goose 能在空库上完整执行。
- `system_configs`、默认 `admin`、内置模板能按预期初始化。
- 新增表/列与 Go model 的列名、类型和默认值兼容。
- 前端或接口消费的公共配置值仍按字符串解析。
## ClickHouse 分析库(辅助 OLAP)
ClickHouse 是**辅助 OLAP 存储**,与 PostgreSQL/SQLite 主库**完全独立**的迁移与访问管线:
- 主库(PG/SQLite):业务事务数据、`goose_db_version`、双方言 SQL。
- 分析库(ClickHouse):访问日志、统计聚合等分析型数据、`goose_clickhouse_version`、单方言 SQL。
**不要**把 ClickHouse 表结构混入 PG/SQLite 迁移目录,也**不要**在 `support-files/`、`internal/apps/` 或 `internal/repository/` 中手写 DDL。
### 目录与职责
| 路径 | 职责 |
| :--- | :--- |
| `internal/db/migrator/goose/clickhouse/` | **唯一** ClickHouse DDL 来源(goose SQL,嵌入二进制) |
| `internal/model/analytics/` | 分析表 Go model,列名须与 goose DDL 一致 |
| `internal/repository/analytics/` | 所有 ClickHouse 读写(批量写入、查询、聚合) |
| `internal/db/clickhouse.go` | 连接初始化(`ChConn` 原生批量、`ChDB` GORM 查询) |
### 迁移入口与版本表
- 入口:`migrator.MigrateClickHouse()`,在 `cmd/root.go` 的 `PreRun` 中于 `migrator.Migrate()` 之后调用。
- 仅当 `clickhouse.enabled: true` 时执行;禁用时直接跳过(见 `TestMigrateClickHouseSkipsWhenDisabled`)。
- 版本表:`goose_clickhouse_version`,与主库 `goose_db_version` **分离**,互不影响。
- 方言:仅 ClickHouse,**无** SQLite 镜像目录。
### ClickHouse 迁移规则
1. **DDL 只写 goose SQL**:`CREATE TABLE IF NOT EXISTS ...`,禁止 GORM `AutoMigrate`、禁止在 repository 或 handler 中建表。
2. **无事务**:ClickHouse 不支持 goose 事务包装;每个 `Up`/`Down` 语句独立提交。
3. **幂等 Up**:表用 `IF NOT EXISTS`;`Down` 用 `DROP TABLE IF EXISTS`。
4. **Down 谨慎**:MergeTree 等引擎上 `DROP TABLE` 会立即删除数据,生产环境通常只前滚;仅在开发/测试需要回滚时编写 `Down`。
5. **DDL 与 DML 分离**:与主库相同,表结构变更与数据初始化分文件、分版本号;分析表通常无 seed,批量写入由 repository 在运行时完成。
6. **引擎与排序键**:在 SQL 中显式声明 `ENGINE`、`PARTITION BY`、`ORDER BY` 等,与查询模式对齐(例如按 `created_at` 分区)。
7. **禁止重复 DDL**:不要在 `support-files/`、`apps` 初始化逻辑或 `repository/analytics` 中复制建表语句。
### 新增分析表工作流
按以下顺序落地,避免列名或类型漂移:
1. **Model**:在 `internal/model/analytics/` 定义 struct,`gorm:"column:..."` 与 DDL 列名一一对应;实现 `TableName()`,批量写入表可提供 `InsertColumns()` / `BatchInsertSQL()`。
2. **Goose SQL**:在 `internal/db/migrator/goose/clickhouse/` 新增递增版本文件(格式同主库,如 `YYYYMMDDNNNN_create_xxx.sql`),编写 `-- +goose Up` / `-- +goose Down`。
3. **Repository**:在 `internal/repository/analytics/` 实现 `BatchInsert*`(`db.ChConn` 一次 `PrepareBatch` + 多行 `Append` + 一次 `Send`)与查询(`db.ChDB`);连接未初始化时返回明确错误,**不要**在 handler 写 SQL,**不要**在 repository 内维护 channel/goroutine。
4. **Apps**:在 `internal/apps/<domain>/` 编排采集与入队;高频写入通过 `internal/db/batchwriter` 各域独立实例异步 flush(详见 `clickhouse-batchwriter` 技能),`FlushFunc` 只调 repository `BatchInsert*`;管理端统计 API 只读 repository,不触达 DDL。
### ClickHouse 验证
至少运行:
```bash
go test ./internal/db/migrator
go test ./internal/repository/analytics
make code-check
```
验证重点:
- goose 能在空 ClickHouse 实例上完整执行 `Up`。
- `internal/model/analytics` 列名、类型与 goose SQL 一致。
- repository 读写路径不依赖 handler 内联 SQL。
- `clickhouse.enabled: false` 时启动不报错、不执行迁移。
+265
View File
@@ -0,0 +1,265 @@
---
name: "file-upload"
description: "Wavelet 项目专用:当业务需要上传文件、读取已上传文件、在 Worker/任务中程序化摄取字节流、选择存储引擎能力、或排查 w_uploads / 文件统计异常时必须使用。本技能指导 storage 与 upload 分层、upload.Ingest 策略选型、前后端接入与禁止旁路写表。"
---
# 存储引擎与文件上传开发规范
本技能是 Wavelet **文件上传与对象存储**的唯一开发指导。开始开发前先阅读仓库根目录 [AGENTS.md](file:///Users/ryan/DEV/Go/Wavelet/AGENTS.md),遵守项目级核心规则。
---
## 架构分层(必须理解)
Wavelet 将「对象存储」与「上传业务」分为两层,**禁止混用职责**:
| 层级 | 包路径 | 职责 | 业务是否直接调用 |
| :--- | :--- | :--- | :--- |
| **对象存储引擎** | `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 压缩 | 展示 / 下载 |
```text
业务模块 ──► upload.Ingest / upload.Remove(唯一写入门禁)
├── storage.Backend.Put/Get/Delete
├── repository.CreateUpload(仅 upload 内部)
└── RecordUploadStatsAdd/Remove(ingest 内置,禁止业务直调)
```
---
## 核心防线(Guardrails)
以下写法**一律禁止**:
```go
// ❌ 业务包直接写 blob
storage.Active(ctx); backend.Put(...)
// ❌ 旁路写 w_uploads
db.DB(ctx).Create(&model.Upload{})
repository.CreateUpload(ctx, upload) // 仅 internal/apps/upload 允许
// ❌ 手动维护统计
upload.ApplyUploadStatsAdd(ctx, upload) // 已 Deprecated
// ❌ 业务表存物理路径
invoice.FilePath = "uploads/2026/01/02/123.pdf"
```
**正确做法**:业务表只存 `upload_id`(`uint64` / JSON string),通过 `/f/{id}` 或 `upload.OpenStoredObject` 访问。
---
## Ingest 策略选型(Policy Decision)
根据场景选择 `upload.Ingest` 的 `Policy`:
| 场景 | Policy | 哈希命中时 | 未命中时 | 典型调用方 |
| :--- | :--- | :--- | :--- | :--- |
| 用户 HTTP 上传(含秒传) | `PolicyDedupNewRecord` | 复用 path,**新建记录 + 统计** | 写 blob + 新建记录 + 统计 | `handler.UploadFile`(已内置) |
| Worker 生成全新文件 | `PolicyCreate` | 不查重,始终写 blob + 记录 | 同左 | 报表导出、定时生成 |
| 镜像 / 去重摄取(Pixez) | `PolicyResolveExisting` | **直接返回已有记录**,不建新记录、不加统计 | 写 blob + 新建记录 + 统计 | 异步镜像任务 |
| 业务只需引用已有文件 | 不调 Ingest | — | — | 业务 API 校验 `upload_id` 即可 |
### Result 字段含义
| 字段 | 含义 |
| :--- | :--- |
| `Created` | 是否新建了 `w_uploads` 记录 |
| `Stored` | 是否写入了新 blob |
| `Resolved` | 是否通过哈希解析到已有记录(仅 `PolicyResolveExisting`) |
---
## 后端:程序化上传(Worker / 业务逻辑)
### 标准模板
在 `logics.go`(接受 `context.Context`,不依赖 `*gin.Context`)中调用:
```go
import (
"bytes"
"github.com/Rain-kl/Wavelet/internal/apps/upload"
"github.com/Rain-kl/Wavelet/internal/model"
)
func ingestMirrorFile(ctx context.Context, userID uint64, data []byte, hash, filename, mime, ext string) (model.Upload, error) {
accessMode := 1
result, err := upload.Ingest(ctx, upload.IngestRequest{
UserID: userID,
Reader: bytes.NewReader(data),
Size: int64(len(data)),
FileName: filename,
MimeType: mime,
Extension: ext,
Hash: hash, // 必填:SHA-256 hex
Type: "your_biz_type",
AccessMode: &accessMode,
Metadata: model.UploadMetadata{
Extra: map[string]any{"source": "worker"},
},
Policy: upload.PolicyResolveExisting,
})
if err != nil {
return model.Upload{}, err
}
return result.Upload, nil
}
```
### Request 关键字段
| 字段 | 说明 |
| :--- | :--- |
| `Hash` | **必填**,推荐 SHA-256 hex;用于秒传 / 镜像去重 |
| `Type` | 业务分类(如 `avatar`、`invoice`、`pixez_mirror`),用于筛选与统计 |
| `AccessMode` | `nil` 时按 type 默认:`avatar` → 公开(1),其余 → 私有(0) |
| `SkipExtensionCheck` | Worker 场景若已自行校验扩展名,可设为 `true` |
| `ObjectKeyFn` | 可选自定义存储路径;默认 `uploads/YYYY/MM/DD/{id}.{ext}` |
### 错误处理
| 错误 | 含义 | Handler 映射建议 |
| :--- | :--- | :--- |
| `upload.ErrIngestStorageReadOnly` | 存储迁移维护中 | `response.AbortConflict` |
| `ingest.ErrForbidden` | 无权删除他人文件 | HTTP 403 |
| `shared.ErrUnsupportedFormat` | 扩展名不在白名单 | `response.AbortBadRequest` |
### 删除
```go
// 管理员 / 系统删除
_, err := upload.Remove(ctx, uploadID)
// 用户删除自己的文件
_, err := upload.RemoveOwned(ctx, userID, uploadID)
```
### 读取已存储对象(不上传)
```go
uploadRec, err := repository.GetActiveUploadByID(ctx, uploadID)
obj, err := uploadstorage.OpenStoredObject(ctx, &uploadRec)
defer obj.Body.Close()
```
或通过门面(若已从 `exports` 暴露 `OpenStoredObject`)读取。HTTP 对外访问统一走 `GET /f/:id`。
---
## 后端:业务 API 引用已上传文件
推荐 **两步流程**(先上传、后提交业务):
1. 前端 `POST /api/v1/upload` → 获得 `upload.id`
2. 业务 API 接收 `upload_id`,用 `repository.GetActiveUploadByID` 校验存在且 `status` 为 active
3. (可选)校验 `upload.Type` 是否为预期业务类型
4. 将 `upload_id` 写入业务表字段(如 `cover_file_id`)
**禁止**在业务 Handler 中重复实现 multipart 解析,除非有极强的特殊协议需求。
---
## 前端:用户侧上传
使用 `frontend/lib/services/upload/`:
```typescript
import { services } from '@/lib/services'
import { getFileUrl } from '@/lib/services/upload'
// 上传
const upload = await services.upload.uploadFile(file, 'invoice', { orderId: '123' })
// 展示
const url = getFileUrl(upload.id) // → /f/{id}
// Base64 图片(头像等)
const res = await services.upload.uploadBase64Image(croppedBase64, 'avatar', 'avatar.png')
```
### 前端规范
- 新增上传相关 API 时,扩展 `UploadService` / `AdminUploadService`,在 `frontend/lib/services/index.ts` 注册
- 图片预览使用 `getFileUrl(id, quality?)` 或 `FileImagePreview` 组件
- 业务表单项只提交 `upload_id`,不要提交 blob URL 或 `file_path`
---
## 统计与排查
`w_upload_stats` 由 `upload.Ingest` / `upload.Remove` **自动维护**,业务不得手动增量。
若发现 trend / total 与 `w_uploads` 不一致(常见于历史旁路写表):
```go
upload.RebuildUploadStats(ctx) // 从 w_uploads 全量重建统计
```
排查清单:
1. 业务是否绕过 `upload.Ingest` 直接 `db.Create(&model.Upload{})`?
2. 是否手动调用已 Deprecated 的 `ApplyUploadStatsAdd`?
3. 删除是否走 `upload.Remove`(须在软删**前**扣减统计)?
---
## 测试要求
### 后端 ingest 测试
- 使用 `testhelper.SetupTestEnvironment(t)` 初始化 DB
- 存储 mock:`storage.MockStorage(...)` + `storage.IsEnabledFunc = func() bool { return true }`
- **禁止**在源码目录硬编码 `uploads/test` 路径;本地文件测试用 `t.TempDir()` 或 mock backend
- 覆盖:三种 Policy、Remove 后统计归零、ReadOnly 拒绝写入
参考:[internal/apps/upload/ingest/ingest_test.go](file:///Users/ryan/DEV/Go/Wavelet/internal/apps/upload/ingest/ingest_test.go)
### Handler 回归
修改 upload handler 后运行:
```bash
go test ./internal/apps/upload/...
make code-check
```
若变更 HTTP 接口,运行 `make swagger`。
---
## 存量代码迁移(旁路写表 → Ingest)
将以下模式:
```go
storage.Active(ctx)
backend.Put(ctx, key, reader, size, mime)
db.DB(ctx).Create(&upload)
```
替换为:
```go
upload.Ingest(ctx, upload.IngestRequest{ Policy: upload.PolicyResolveExisting, ... })
```
迁移完成后执行一次 `upload.RebuildUploadStats(ctx)` 修复历史统计偏差。
---
## 质量门禁 Checklist
完成文件上传相关开发后,确认:
- [ ] 业务模块无 `repository.CreateUpload` / `SoftDeleteUpload` 调用
- [ ] 业务模块无 `storage.Active` + `Put` 直接写文件
- [ ] 业务表存 `upload_id`,不存 `file_path`
- [ ] Worker 摄取使用正确的 `Policy`
- [ ] 新增测试覆盖 ingest 路径
- [ ] `make code-check` 通过
- [ ] HTTP 变更已 `make swagger`
+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
+187
View File
@@ -0,0 +1,187 @@
---
name: go-logging
description: 在选择日志方案、配置 slog、编写结构化日志语句或决定日志级别时使用。也适用于设置生产日志、为日志添加请求作用域上下文或从 log 迁移到 slog 的场景,即使用户未明确提及日志。不涵盖错误处理策略(参见 go-error-handling)。
license: Apache-2.0
compatibility: slog requires Go 1.21+; slog/slogtest requires Go 1.22+
metadata:
sources: "Google Style Guide, Uber Style Guide"
---
# Go 日志
## 核心原则
日志是给**运维人员**看的,不是给开发人员看的。每一行日志都应该帮助某人诊断生产问题。如果不能达到这个目的,就是噪音。
---
## 选择日志器
> **规范**:在新的 Go 代码中使用 `log/slog`。
`slog` 是结构化的、分级别的,并且在标准库中(Go 1.21+)。它涵盖了绝大多数生产日志需求。
```
选择哪个日志器?
├─ 新的生产代码 → log/slog
├─ 简单 CLI / 一次性 → log(标准库)
└─ 有性能瓶颈 → zerolog 或 zap(先做基准测试)
```
除非性能分析显示 `slog` 在热路径中是瓶颈,否则不要引入第三方日志库。引入时,保持相同的结构化键值风格。
> 在设置 slog handler、配置 JSON/文本输出或从 log.Printf 迁移到 slog 时,阅读 [references/LOGGING-PATTERNS.md](references/LOGGING-PATTERNS.md)。
---
## 结构化日志
> **规范**:始终使用键值对。永远不要将值插值到消息字符串中。
消息是描述发生了什么的**静态描述**。动态数据放在键值属性中:
```go
// 好:静态消息,结构化字段
slog.Info("order placed", "order_id", orderID, "total", total)
// 不好:动态数据嵌入到消息字符串中
slog.Info(fmt.Sprintf("order %d placed for $%.2f", orderID, total))
```
### 键名
> **建议**:日志属性键使用 `snake_case`。
键应为小写、下划线分隔,并在整个代码库中保持一致:`user_id`、`request_id`、`elapsed_ms`。
### 类型化属性
对于性能关键路径,使用类型化构造函数以避免分配:
```go
slog.LogAttrs(ctx, slog.LevelInfo, "request handled",
slog.String("method", r.Method),
slog.Int("status", code),
slog.Duration("elapsed", elapsed),
)
```
> 在优化日志性能或使用 Enabled() 进行预检查时,阅读 [references/LEVELS-AND-CONTEXT.md](references/LEVELS-AND-CONTEXT.md)。
---
## 日志级别
> **建议**:一致地遵循这些级别语义。
| 级别 | 何时使用 | 生产默认 |
|------|----------|----------|
| Debug | 仅开发人员的诊断,跟踪内部状态 | 禁用 |
| Info | 重要的生命周期事件:启动、关闭、配置加载 | 启用 |
| Warn | 意外但可恢复:使用了弃用功能、重试成功 | 启用 |
| Error | 操作失败,需要运维人员关注 | 启用 |
**经验法则**:
- 如果没有人需要对其采取行动,那就不是 Error——使用 Warn 或 Info
- 如果只在连接调试器时才有用,那就是 Debug
- `slog.Error` 应始终包含 `"err"` 属性
```go
slog.Error("payment failed", "err", err, "order_id", id)
slog.Warn("retry succeeded", "attempt", n, "endpoint", url)
slog.Info("server started", "addr", addr)
slog.Debug("cache lookup", "key", key, "hit", hit)
```
> 在 Warn 和 Error 之间选择或定义自定义详细级别时,阅读 [references/LEVELS-AND-CONTEXT.md](references/LEVELS-AND-CONTEXT.md)。
---
## 请求作用域日志
> **建议**:从 context 派生日志器以携带请求作用域字段。
使用中间件为日志器添加请求 ID、用户 ID 或跟踪 ID,然后通过 context 或作为显式参数将增强后的日志器传递给下游:
```go
func middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
logger := slog.With("request_id", requestID(r))
ctx := context.WithValue(r.Context(), loggerKey, logger)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
```
该请求中所有后续的日志调用都会自动携带 `request_id`。
> 在实现日志中间件或通过 context 传递日志器时,阅读 [references/LOGGING-PATTERNS.md](references/LOGGING-PATTERNS.md)。
---
## 日志或返回,不要同时
> **规范**:每个错误恰好处理一次——要么记录它,要么返回它。
记录错误然后返回它会导致重复噪音,因为栈上游的调用者也会处理该错误。
```go
// 不好:在这里记录,并且栈上游的每个调用者也会记录
if err != nil {
slog.Error("query failed", "err", err)
return fmt.Errorf("query: %w", err)
}
// 好:包装并返回——让调用者决定
if err != nil {
return fmt.Errorf("query: %w", err)
}
```
**例外**:HTTP 处理器和其他栈顶边界可以在服务端记录详细错误,同时向客户端返回脱敏消息:
```go
if err != nil {
slog.Error("checkout failed", "err", err, "user_id", uid)
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
```
参见 [go-error-handling](../go-error-handling/SKILL.md) 了解完整的处理一次模式和错误包装指导。
---
## 不应记录的内容
> **规范**:永远不要记录密钥、凭证、PII 或高基数无界数据。
- 密码、API 密钥、令牌、会话 ID
- 完整的信用卡号、社会安全号
- 可能包含用户数据的请求/响应体
- 无界大小的完整切片或映射
> 在决定哪些数据可以安全包含在日志属性中时,阅读 [references/LEVELS-AND-CONTEXT.md](references/LEVELS-AND-CONTEXT.md)。
---
## 快速参考
| 应该 | 不应该 |
|------|--------|
| `slog.Info("msg", "key", val)` | `log.Printf("msg %v", val)` |
| 静态消息 + 结构化字段 | 在消息中使用 `fmt.Sprintf` |
| `snake_case` 键 | camelCase 或不一致的键 |
| 日志或返回错误 | 同时日志和返回同一错误 |
| 从 context 派生日志器 | 每次调用创建新日志器 |
| `slog.Error` 配合 `"err"` 属性 | 用 `slog.Info` 记录错误 |
| 在热路径上预检查 `Enabled()` | 始终分配日志参数 |
---
## 相关技能
- **错误处理**:在决定是记录还是返回错误,或了解处理一次模式时,参见 [go-error-handling](../go-error-handling/SKILL.md)
- **上下文传播**:在通过 context 传递请求作用域值(包括日志器)时,参见 [go-context](../go-context/SKILL.md)
- **性能**:在优化热路径日志或减少日志调用中的分配时,参见 [go-performance](../go-performance/SKILL.md)
- **代码审查**:在审查 Go PR 中的日志实践时,参见 [go-code-review](../go-code-review/SKILL.md)
@@ -0,0 +1,244 @@
# 级别与上下文
关于日志级别语义、基于 context 的日志模式、性能考虑以及哪些内容不应出现在日志中的详细指导。
## 级别语义
### Debug
仅开发人员的诊断。生产中默认禁用。用于跟踪在开发或故障排查期间有帮助的内部状态:
```go
slog.Debug("cache lookup", "key", key, "hit", hit)
slog.Debug("parsed config", "fields", len(cfg.Fields))
slog.Debug("SQL query", "query", q, "args", args)
```
**何时使用**:内部状态转换、缓存行为、开发期间的详细请求/响应数据。
### Info
确认系统按预期运行的重要事件。这些应在生产中对理解系统行为有用:
```go
slog.Info("server started", "addr", addr, "version", version)
slog.Info("config loaded", "path", cfgPath, "env", env)
slog.Info("migration completed", "version", v, "elapsed_ms", elapsed)
slog.Info("user registered", "user_id", uid)
```
**何时使用**:启动/关闭、配置变更、重要业务事件、周期性健康摘要。
### Warn
发生了意外的事情,但系统已恢复或优雅降级。运维人员可能想要调查但不需要立即行动:
```go
slog.Warn("retry succeeded", "attempt", n, "endpoint", url)
slog.Warn("deprecated endpoint called", "path", r.URL.Path, "user_id", uid)
slog.Warn("rate limit approaching", "current", rate, "limit", max)
slog.Warn("fallback to default config", "err", err)
```
**何时使用**:最终成功的重试、弃用的代码路径、接近资源限制、回退行为。
### Error
操作失败并需要运维人员关注。系统无法完成请求或任务:
```go
slog.Error("payment failed", "err", err, "order_id", id, "amount", amt)
slog.Error("database connection lost", "err", err, "host", dbHost)
slog.Error("message processing failed", "err", err, "msg_id", msgID)
```
**何时使用**:影响用户的失败操作、丢失的连接、数据完整性问题、未恢复的外部服务故障。
**始终包含错误**:`slog.Error` 调用应始终带有包含实际错误值的 `"err"` 属性。
### 在 Warn 和 Error 之间选择
```
操作最终是否成功?
├─ 是(经过重试/回退后)→ Warn
└─ 否(调用者收到错误)→ Error
├─ 需要立即关注 → Error
└─ 可以等到下次审查 → Warn
```
---
## 自定义详细级别
slog 级别是整数。在标准级别之间定义自定义子级别以实现细粒度控制:
```go
const (
LevelTrace = slog.Level(-8) // 低于 Debug
LevelNotice = slog.Level(2) // 在 Info 和 Warn 之间
)
slog.Log(ctx, LevelTrace, "detailed trace", "span_id", spanID)
```
使用 `HandlerOptions.Level` 配合 `slog.LevelVar` 在运行时控制最低级别。
---
## 基于 Context 的日志
### 模式 1:Context 中的日志器
在 context 中存储增强后的 `*slog.Logger`。每个中间件层添加自己的字段:
```go
func authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userID := authenticate(r)
logger := loggerFromCtx(r.Context()).With("user_id", userID)
ctx := context.WithValue(r.Context(), loggerKey, logger)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
```
**优点**:简单,与任何 handler 链配合使用。
**缺点**:需要纪律来始终使用 `loggerFromCtx`。
### 模式 2:显式日志器参数
将 `*slog.Logger` 作为函数参数与 context 一起传递:
```go
func processOrder(ctx context.Context, logger *slog.Logger, order *Order) error {
logger.Info("processing order", "order_id", order.ID)
// ...
}
```
**优点**:显式依赖,更易测试,无需 context 键。
**缺点**:每个函数签名中都有额外参数。
### 何时使用哪种
| 场景 | 推荐 |
|------|------|
| HTTP 处理器 / 中间件链 | Context 中的日志器 |
| 无 HTTP 依赖的库代码 | 显式参数 |
| 后台工作器 / 批处理任务 | 显式参数 |
| 深层调用链(5 层以上) | Context 中的日志器 |
---
## 性能考虑
### 使用 Enabled() 预检查
当日志级别被禁用时避免分配日志参数:
```go
// 开销大:参数始终被求值,即使 Debug 被禁用
slog.Debug("request details",
"headers", fmt.Sprintf("%v", r.Header),
"body", string(bodyBytes),
)
// 更好:禁用时完全跳过
if slog.Default().Enabled(ctx, slog.LevelDebug) {
slog.Debug("request details",
"headers", fmt.Sprintf("%v", r.Header),
"body", string(bodyBytes),
)
}
```
当参数构造开销大(格式化、序列化或读取数据)时,这很重要。对于简单属性(`slog.String`、`slog.Int`),开销可以忽略不计。
### 在热路径上使用 LogAttrs
`slog.LogAttrs` 避免了便捷方法(`slog.Info` 等)产生的 `[]any` 分配:
```go
// 标准——为键值对分配一个 []any
slog.Info("request handled", "method", r.Method, "status", code)
// 更快——类型化属性,无 []any 分配
slog.LogAttrs(ctx, slog.LevelInfo, "request handled",
slog.String("method", r.Method),
slog.Int("status", code),
)
```
### 避免在紧凑循环中记录日志
如果循环处理数千个项目,记录摘要而不是每次迭代:
```go
// 不好:10k 项目批次中每个项目一条日志
for _, item := range items {
slog.Debug("processing item", "id", item.ID)
process(item)
}
// 好:记录摘要
slog.Info("batch started", "count", len(items))
processed, failed := processBatch(items)
slog.Info("batch completed", "processed", processed, "failed", failed)
```
---
## 不应记录的内容
### 密钥和凭证
永远不要记录:
- 密码、API 密钥、令牌(OAuth、JWT、会话)
- 私钥、证书
- 包含凭证的数据库连接字符串
```go
// 不好
slog.Info("connecting", "dsn", dsn) // 可能包含密码
// 好
slog.Info("connecting", "host", dbHost, "database", dbName)
```
### 个人身份信息(PII)
除非调试所需且你的保留策略允许,否则避免记录:
- 电子邮件地址、电话号码
- 完整姓名、物理地址
- IP 地址(在某些司法管辖区)
- 信用卡号、社会安全号
如果必须记录用户标识符,使用不透明 ID 而非 PII。
### 高基数无界数据
不要记录完整的请求体、Info 级别的完整栈跟踪或无界集合:
```go
// 不好:无界数据
slog.Info("received", "body", string(requestBody))
slog.Info("users loaded", "users", users) // 可能有 10 万条记录
// 好:有界摘要
slog.Info("received", "content_length", len(requestBody), "content_type", ct)
slog.Info("users loaded", "count", len(users))
```
### 决策表
| 数据类型 | 记录吗? | 替代方案 |
|----------|----------|----------|
| 请求 ID / 跟踪 ID | 是 | — |
| 用户 ID(不透明的) | 是 | — |
| HTTP 方法、路径、状态 | 是 | — |
| 错误消息 | 是 | — |
| 密码 / 令牌 | **永不** | 记录令牌前缀或 "已脱敏" |
| 完整请求体 | **否** | 记录内容长度和类型 |
| PII(邮箱、姓名) | **避免** | 记录不透明用户 ID |
| 大型集合 | **否** | 记录数量或摘要 |
| 栈跟踪 | 仅 Debug | 使用 `slog.Debug` |
@@ -0,0 +1,314 @@
# 日志模式
关于 slog 设置、handler 配置、测试、HTTP 中间件以及从旧版 `log` 包迁移的详细模式。
## 设置 slog
### 基本配置
```go
package main
import (
"log/slog"
"os"
)
func main() {
// JSON handler 用于生产(机器可解析)
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
slog.SetDefault(logger)
slog.Info("server started", "addr", ":8080")
// 输出:{"time":"...","level":"INFO","msg":"server started","addr":":8080"}
}
```
### 用于开发的 Text Handler
```go
// 本地开发的人类可读输出
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
Level: slog.LevelDebug,
}))
slog.SetDefault(logger)
// 输出:time=... level=DEBUG msg="cache lookup" key=user:42 hit=true
```
### 动态级别控制
使用 `slog.LevelVar` 在运行时更改最低级别(例如通过管理端点或信号处理器):
```go
var programLevel = new(slog.LevelVar) // 默认 Info
func init() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: programLevel,
}))
slog.SetDefault(logger)
}
// 从管理端点或信号处理器调用
func enableDebug() {
programLevel.Set(slog.LevelDebug)
}
```
---
## 自定义 Handler 模式
### 添加源位置
```go
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
AddSource: true,
Level: slog.LevelInfo,
}))
// 输出包含:"source":{"function":"main.handleRequest","file":"server.go","line":42}
```
### 使用默认属性包装 Handler
使用 `slog.Handler` 中间件向每条日志记录注入字段:
```go
type contextHandler struct {
inner slog.Handler
attrs []slog.Attr
}
func (h *contextHandler) Enabled(ctx context.Context, level slog.Level) bool {
return h.inner.Enabled(ctx, level)
}
func (h *contextHandler) Handle(ctx context.Context, r slog.Record) error {
r.AddAttrs(h.attrs...)
return h.inner.Handle(ctx, r)
}
func (h *contextHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return &contextHandler{inner: h.inner.WithAttrs(attrs), attrs: h.attrs}
}
func (h *contextHandler) WithGroup(name string) slog.Handler {
return &contextHandler{inner: h.inner.WithGroup(name), attrs: h.attrs}
}
```
### 多 Handler(扇出)
写入多个目标(例如 stdout + 文件):
```go
type multiHandler struct {
handlers []slog.Handler
}
func (m *multiHandler) Enabled(ctx context.Context, level slog.Level) bool {
for _, h := range m.handlers {
if h.Enabled(ctx, level) {
return true
}
}
return false
}
func (m *multiHandler) Handle(ctx context.Context, r slog.Record) error {
var errs []error
for _, h := range m.handlers {
if h.Enabled(ctx, r.Level) {
if err := h.Handle(ctx, r); err != nil {
errs = append(errs, err)
}
}
}
return errors.Join(errs...)
}
func (m *multiHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
handlers := make([]slog.Handler, len(m.handlers))
for i, h := range m.handlers {
handlers[i] = h.WithAttrs(attrs)
}
return &multiHandler{handlers: handlers}
}
func (m *multiHandler) WithGroup(name string) slog.Handler {
handlers := make([]slog.Handler, len(m.handlers))
for i, h := range m.handlers {
handlers[i] = h.WithGroup(name)
}
return &multiHandler{handlers: handlers}
}
```
---
## 使用 slogtest 测试
Go 1.22+ 提供了 `testing/slogtest` 来验证 handler 实现:
```go
package myhandler_test
import (
"testing"
"testing/slogtest"
)
func TestHandler(t *testing.T) {
// newHandler 返回你的自定义 slog.Handler 和一个
// 将输出解析为 []map[string]any 的函数用于验证。
results := func(t *testing.T) map[string]any {
// 在此解析你的 handler 输出
}
h := NewMyHandler(buf, nil)
slogtest.Run(t, func(t *testing.T) slog.Handler { return h }, results)
}
```
### 在测试中捕获日志
对于断言日志输出的单元测试,写入 buffer:
```go
func TestOrderProcessing(t *testing.T) {
var buf bytes.Buffer
logger := slog.New(slog.NewJSONHandler(&buf, nil))
processOrder(logger, order)
if !strings.Contains(buf.String(), `"order_id"`) {
t.Error("expected order_id in log output")
}
}
```
---
## HTTP 请求日志中间件
一个完整的中间件,记录每个请求的计时、状态和请求作用域字段:
```go
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
reqID := r.Header.Get("X-Request-ID")
if reqID == "" {
reqID = uuid.NewString()
}
logger := slog.With(
"request_id", reqID,
"method", r.Method,
"path", r.URL.Path,
)
// 包装 response writer 以捕获状态码
rw := &responseWriter{ResponseWriter: w, status: http.StatusOK}
// 将日志器存入 context 供下游 handler 使用
ctx := context.WithValue(r.Context(), loggerKey, logger)
next.ServeHTTP(rw, r.WithContext(ctx))
logger.Info("request completed",
"status", rw.status,
"elapsed_ms", time.Since(start).Milliseconds(),
)
})
}
type responseWriter struct {
http.ResponseWriter
status int
}
func (rw *responseWriter) WriteHeader(code int) {
rw.status = code
rw.ResponseWriter.WriteHeader(code)
}
```
### 从 Context 获取日志器
```go
type ctxKey struct{}
var loggerKey = ctxKey{}
func loggerFromCtx(ctx context.Context) *slog.Logger {
if l, ok := ctx.Value(loggerKey).(*slog.Logger); ok {
return l
}
return slog.Default()
}
```
---
## 从 log.Printf 迁移到 slog
### 第 1 步:替换直接调用
```go
// 迁移前
log.Printf("user %s logged in from %s", userID, ip)
// 迁移后
slog.Info("user logged in", "user_id", userID, "ip", ip)
```
### 第 2 步:替换 main() 中的 log.Fatalf
```go
// 迁移前
log.Fatalf("failed to connect: %v", err)
// 迁移后——slog 没有 Fatal;在 main 中使用 slog + os.Exit
slog.Error("failed to connect", "err", err)
os.Exit(1)
```
### 第 3 步:桥接旧代码
如果逐步迁移,将标准 `log` 包的输出通过 slog 重定向:
```go
// 在 main() 中,设置 slog 之后:
slog.SetDefault(logger)
// 标准 log 包现在通过 slog 的默认 handler 写入。
// 这是因为 slog.SetDefault 也会更新 log.Default()。
```
### 第 4 步:替换日志器参数
```go
// 迁移前:传递 *log.Logger
func NewServer(addr string, logger *log.Logger) *Server
// 迁移后:显式传递 *slog.Logger
func NewServer(addr string, logger *slog.Logger) *Server
// 或从 handler 中的 context 派生
func (s *Server) handleRequest(ctx context.Context) {
logger := loggerFromCtx(ctx)
logger.Info("handling request")
}
```
### 迁移清单
| 步骤 | 更改什么 | 验证 |
|------|----------|------|
| 1 | `log.Printf` → `slog.Info/Warn/Error` | `rg 'log\.Printf'` 返回 0 个匹配 |
| 2 | `log.Fatalf` → `slog.Error` + `os.Exit(1)` 在 main 中 | 仅在 `main()` 中 |
| 3 | 在 main 中尽早设置 `slog.SetDefault` | 旧版 `log` 调用通过 slog 路由 |
| 4 | `*log.Logger` 参数 → `*slog.Logger` | 所有构造函数已更新 |
| 5 | 移除已替换处的 `"log"` 导入 | `goimports` 会自动处理 |
+138
View File
@@ -0,0 +1,138 @@
---
name: go-packages
description: Use when creating Go packages, organizing imports, managing dependencies, or deciding how to structure Go code into packages. Also use when starting a new Go project or splitting a growing codebase into packages, even if the user doesn't explicitly ask about package organization. Does not cover naming individual identifiers (see go-naming).
license: Apache-2.0
metadata:
sources: "Google Style Guide, Uber Style Guide, Go Wiki CodeReviewComments"
---
# Go 包和 Import
> **本技能不适用的场景**:对于包内单个标识符的命名,参见 [go-naming](../go-naming/SKILL.md)。对于单文件中函数的组织,参见 [go-functions](../go-functions/SKILL.md)。对于强制执行 import 规则的 linter 配置,参见 [go-linting](../go-linting/SKILL.md)。
## 包组织
### 避免 Util 包
包名应描述包提供的内容。避免使用 `util`、`helper`、`common` 等泛化名称——它们会模糊含义并导致 import 冲突。
```go
// 好:有意义的包名
db := spannertest.NewDatabaseFromFile(...)
_, err := f.Seek(0, io.SeekStart)
// 不好:模糊的名称遮蔽含义
db := test.NewDatabaseFromFile(...)
_, err := f.Seek(0, common.SeekStart)
```
泛化名称可以作为名称的*一部分*(例如 `stringutil`),但不应成为整个包名。
### Package Size
| 问题 | 操作 |
|------|------|
| 你能用一句话描述它的用途吗? | 不能 → 按职责拆分 |
| 文件中从未共享未导出的符号? | 这些文件可以是独立的包 |
| 不同的用户群体使用不同部分? | 按用户边界拆分 |
| Godoc 页面过于庞大? | 拆分以提高可发现性 |
**不要拆分**的原因仅仅是文件很长、创建只有单一类型的包,或会产生循环依赖。
> 在决定是否拆分或合并包、组织包内文件或构建 CLI 程序时,阅读 [references/PACKAGE-SIZE.md](references/PACKAGE-SIZE.md)。
---
## Import
Import 按组组织,组之间用空行分隔。标准库包始终放在第一组。使用
[goimports](https://pkg.go.dev/golang.org/x/tools/cmd/goimports) 自动管理。
```go
import (
"fmt"
"os"
"github.com/foo/bar"
"rsc.io/goversion/version"
)
```
**快速规则:**
| 规则 | 指导 |
|------|------|
| 分组 | 标准库优先,然后是外部包。扩展分组:标准库 → 其他 → proto → 副作用 |
| 重命名 | 除非冲突,否则避免重命名。重命名最本地的 import。Proto 包加 `pb` 后缀 |
| 空白 import(`import _`) | 仅在 `main` 包或测试中使用 |
| 点 import(`import .`) | 永不使用,除非用于循环依赖的测试文件 |
> 在组织扩展分组的 import、重命名 proto 包或决定使用空白/点 import 时,阅读 [references/IMPORTS.md](references/IMPORTS.md)。
---
## 避免 init()
尽可能避免 `init()`。当不可避免时,它必须是:
1. 完全确定性的
2. 不依赖于其他 `init()` 的执行顺序
3. 不依赖环境状态(环境变量、工作目录、参数)
4. 不进行 I/O(文件系统、网络、系统调用)
**可接受的使用场景**:无法用单个赋值完成的复杂表达式、可插拔钩子(例如 `database/sql` 方言)、确定性预计算。
> 在需要将 init() 重构为显式函数或理解可接受的 init() 使用场景时,阅读 [references/PACKAGE-SIZE.md](references/PACKAGE-SIZE.md)。
---
## Main 中的退出
仅在 `main()` 中调用 `os.Exit` 或 `log.Fatal*`。所有其他函数应返回 error。
**原因**:不明显的控制流、不可测试、`defer` 语句被跳过。
**最佳实践**:使用 `run()` 模式——将逻辑提取到
`func run() error` 中,在 `main()` 中调用并使用单一退出点:
```go
func main() {
if err := run(); err != nil {
log.Fatal(err)
}
}
```
> 在实现 run() 模式、构建 CLI 子命令或选择 flag 命名约定时,阅读 [references/PACKAGE-SIZE.md](references/PACKAGE-SIZE.md)。
---
## 命令行 Flag
> **建议**:仅在 `package main` 中定义 flag。
- Flag 名称使用 `snake_case`:`--output_dir` 而非 `--outputDir`
- 库应通过参数接收配置,而非直接读取 flag——
这使它们可测试且可复用
- 优先使用标准 `flag` 包;仅在需要 POSIX 约定
(双破折号、单字符快捷方式)时使用 `pflag`
```go
// 好:Flag 在 main 中定义,作为参数传递给库
func main() {
outputDir := flag.String("output_dir", ".", "directory for output files")
flag.Parse()
if err := mylib.Generate(*outputDir); err != nil {
log.Fatal(err)
}
}
```
---
## 相关技能
- **包命名**:在选择包名、避免名称重复或命名导出符号时,参见 [go-naming](../go-naming/SKILL.md)
- **跨包的错误处理**:在使用 `%w` vs `%v` 在包边界包装错误时,参见 [go-error-handling](../go-error-handling/SKILL.md)
- **Import linting**:在配置 goimports local-prefixes 或强制执行 import 分组时,参见 [go-linting](../go-linting/SKILL.md)
- **全局状态**:在用显式初始化替换 `init()` 或避免可变全局变量时,参见 [go-defensive](../go-defensive/SKILL.md)
@@ -0,0 +1,110 @@
# Import 组织
Go import 组织的详细规则和示例。
## Import 分组
Import 按组组织,组之间用空行分隔。标准库包始终放在第一组。
**最小分组(Uber):** 标准库,然后其他所有。
**扩展分组(Google):** 标准库 → 其他 → protocol buffers → 副作用。
```go
// 好:标准库与外部包分开
import (
"fmt"
"os"
"go.uber.org/atomic"
"golang.org/x/sync/errgroup"
)
```
```go
// 好:完整分组,包含 proto 和副作用
import (
"fmt"
"os"
"github.com/dsnet/compress/flate"
"golang.org/x/text/encoding"
foopb "myproj/foo/proto/proto"
_ "myproj/rpc/protocols/dial"
)
```
## Import 重命名
避免重命名 import,除非为了避免名称冲突;好的包名不需要重命名。
在发生冲突时,**优先重命名最本地的或项目特定的 import**。
**必须重命名:** 与其他 import 冲突、生成的 protocol buffer 包
(删除下划线,添加 `pb` 后缀)。
**可以重命名:** 无意义的名称(例如 `v1`)、与本地变量冲突。
```go
// 好:Proto 包用 pb 后缀重命名
import (
foosvcpb "path/to/package/foo_service_go_proto"
)
// 好:当需要 url 变量时使用 urlpkg
import (
urlpkg "net/url"
)
func parseEndpoint(url string) (*urlpkg.URL, error) {
return urlpkg.Parse(url)
}
```
## 空白 Import(`import _`)
仅为副作用而导入的包(使用 `import _ "pkg"`)
应仅在程序的主包(main)或需要它们的测试中导入。
```go
// 好:在主包中使用空白 import
package main
import (
_ "time/tzdata"
_ "image/jpeg"
)
```
## 点 Import(`import .`)
**不要**使用点 import。它们使程序难以阅读,因为不清楚
`Quux` 这样的名称是当前包中的顶层标识符还是导入包中的。
**例外:** `import .` 形式在由于循环依赖而无法成为被测试包的一部分的测试文件中可能有用:
```go
package foo_test
import (
"bar/testutil" // 也导入了 "foo"
. "foo"
)
```
在这种情况下,测试文件不能是 `foo` 包,因为它使用了
`bar/testutil`,而后者导入了 `foo`。因此 `import .` 形式让文件
假装是 `foo` 包的一部分,即使实际上不是。
**除了这一种情况外,不要在程序中使用 `import .`。**
```go
// 不好:点 import 隐藏了来源
import . "foo"
var myThing = Bar() // Bar 来自哪里?
// 好:显式限定
import "foo"
var myThing = foo.Bar()
```
@@ -0,0 +1,214 @@
# 包大小、程序结构和 CLI
关于包拆分、避免 init()、run() 模式和 CLI 结构的详细指南。
## 何时拆分包
```
包是否变得太大?
├─ 你能用一句话描述它的用途吗?
│ ├─ 不能 → 按职责拆分
│ └─ 能 → 保留,但检查以下内容
├─ 包中的文件是否从未导入彼此的未导出符号?
│ └─ 是 → 这些文件可以是独立的包
├─ 包是否有不同的用户群体使用不同部分?
│ └─ 是 → 按用户边界拆分
└─ godoc 页面是否过于庞大?
└─ 是 → 拆分以提高可发现性
```
### 何时不应拆分
- 不要仅因为文件很长就拆分——聚焦的包中的大文件是可以的
- 不要创建只包含一个类型或函数的包
- 如果会产生循环依赖则不要拆分
- 避免将内部辅助工具拆分到 `util` 或 `internal/helpers` 包中
### 何时合并包
- 如果客户端代码很可能需要两个类型交互,保持它们在一起
- 如果类型有紧密耦合的实现
- 如果用户需要同时导入两个包才能有意义地使用其中任何一个
### 文件组织
Go 中没有"一个类型一个文件"的惯例。文件应该足够聚焦以便知道哪个文件包含什么内容,且足够小以便轻松查找。
---
## 避免 init()
优先使用显式函数而非 `init()`:
```go
// 不好:init() 带有 I/O 和环境依赖
var _config Config
func init() {
cwd, _ := os.Getwd()
raw, _ := os.ReadFile(path.Join(cwd, "config.yaml"))
yaml.Unmarshal(raw, &_config)
}
```
```go
// 好:用于加载配置的显式函数
func loadConfig() (Config, error) {
cwd, err := os.Getwd()
if err != nil {
return Config{}, err
}
raw, err := os.ReadFile(path.Join(cwd, "config.yaml"))
if err != nil {
return Config{}, err
}
var config Config
if err := yaml.Unmarshal(raw, &config); err != nil {
return Config{}, err
}
return config, nil
}
```
**init() 的可接受使用场景:**
- 无法用单个赋值完成的复杂表达式
- 可插拔钩子(例如 `database/sql` 方言、编码注册表)
- 确定性预计算
---
## Main 中的退出
仅在 `main()` 中调用 `os.Exit` 或 `log.Fatal*`。所有其他函数应
返回 error 来表示失败。
**为什么这很重要:**
- 不明显的控制流:任何函数都可以退出程序
- 难以测试:退出程序的函数也会退出测试
- 跳过的清理:`defer` 语句会被跳过
```go
// 不好:在辅助函数中使用 log.Fatal
func readFile(path string) string {
f, err := os.Open(path)
if err != nil {
log.Fatal(err) // 退出程序,跳过 defer
}
b, err := io.ReadAll(f)
if err != nil {
log.Fatal(err)
}
return string(b)
}
```
```go
// 好:返回 error,让 main() 决定是否退出
func main() {
body, err := readFile(path)
if err != nil {
log.Fatal(err)
}
fmt.Println(body)
}
func readFile(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
b, err := io.ReadAll(f)
if err != nil {
return "", err
}
return string(b), nil
}
```
### run() 模式
优先在 `main()` 中**最多调用一次** `os.Exit` 或 `log.Fatal`。将
业务逻辑提取到返回 error 的独立函数中。
```go
func main() {
if err := run(); err != nil {
log.Fatal(err)
}
}
func run() error {
args := os.Args[1:]
if len(args) != 1 {
return errors.New("missing file")
}
f, err := os.Open(args[0])
if err != nil {
return err
}
defer f.Close() // 将始终执行
b, err := io.ReadAll(f)
if err != nil {
return err
}
// 处理 b...
return nil
}
```
**`run()` 模式的优势:**
- 简短的 `main()` 函数,单一退出点
- 所有业务逻辑都可测试
- `defer` 语句始终执行
---
## 命令行接口
### Flag 命名
使用小写、连字符分隔的 flag 名称:
```go
// 好
flag.String("output-dir", ".", "directory for output files")
flag.Bool("dry-run", false, "print actions without executing")
// 不好
flag.String("outputDir", ".", "") // camelCase
flag.String("output_dir", ".", "") // 下划线
```
### 子命令
对于带有子命令的复杂 CLI,为每个子命令使用 `flag.NewFlagSet`:
```go
func main() {
serveCmd := flag.NewFlagSet("serve", flag.ExitOnError)
port := serveCmd.Int("port", 8080, "listen port")
migrateCmd := flag.NewFlagSet("migrate", flag.ExitOnError)
dryRun := migrateCmd.Bool("dry-run", false, "preview changes")
switch os.Args[1] {
case "serve":
serveCmd.Parse(os.Args[2:])
runServe(*port)
case "migrate":
migrateCmd.Parse(os.Args[2:])
runMigrate(*dryRun)
default:
fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1])
os.Exit(1)
}
}
```
对于更大的 CLI,考虑使用 `cobra` 或 `urfave/cli` 等库。仅从
`main()` 退出。
+152
View File
@@ -0,0 +1,152 @@
---
name: go-performance
description: Use when optimizing Go code, investigating slow performance, or writing performance-critical sections. Also use when a user mentions slow Go code, string concatenation in loops, or asks about benchmarking, even if the user doesn't explicitly mention performance patterns. Does not cover concurrent performance patterns (see go-concurrency).
license: Apache-2.0
metadata:
sources: "Uber Style Guide, Google Style Guide, Go Wiki CodeReviewComments"
allowed-tools: Bash(bash:*)
---
# Go 性能模式
## 可用脚本
- **`scripts/bench-compare.sh`** — 运行 Go 基准测试 N 次,并可选通过 benchstat 进行基线比较。支持保存结果以供未来比较。运行 `bash scripts/bench-compare.sh --help` 查看选项。
性能特定的指南仅适用于**热点路径**。不要过早优化——将这些模式集中在最重要的地方。
---
## 优先使用 strconv 而非 fmt
在基本类型和字符串之间转换时,`strconv` 比 `fmt` 更快:
```go
s := strconv.Itoa(rand.Int()) // 比 fmt.Sprint() 快约 2 倍
```
| 方式 | 速度 | 分配次数 |
|------|------|---------|
| `fmt.Sprint` | 143 ns/op | 2 allocs/op |
| `strconv.Itoa` | 64.2 ns/op | 1 allocs/op |
> 在 strconv 和 fmt 之间选择类型转换方式时,或需要完整的转换对照表时,阅读 [references/STRING-OPTIMIZATION.md](references/STRING-OPTIMIZATION.md)。
---
## 避免重复的字符串到字节转换
将固定字符串在循环外转换为 `[]byte` 一次:
```go
data := []byte("Hello world")
for i := 0; i < b.N; i++ {
w.Write(data) // 比每次迭代 []byte("...") 快约 7 倍
}
```
> 在优化热点循环中的重复字节转换时,阅读 [references/STRING-OPTIMIZATION.md](references/STRING-OPTIMIZATION.md)。
---
## 优先指定容器容量
尽可能指定容器容量,以便预先分配内存。这可以最大程度减少后续添加元素时因复制和调整大小而产生的分配。
### Map 容量提示
使用 `make()` 初始化 map 时提供容量提示:
```go
m := make(map[string]os.DirEntry, len(files))
```
**注意**:与 slice 不同,map 的容量提示不保证完整的预分配——它只是近似计算所需的哈希桶数量。
### Slice 容量
使用 `make()` 初始化 slice 时提供容量提示,特别是在追加时:
```go
data := make([]int, 0, size)
```
与 map 不同,slice 容量**不是提示**——编译器会精确分配那么多内存。后续的 `append()` 操作在达到容量之前不会产生任何分配。
| 方式 | 时间(1 亿次迭代) |
|------|------------------------|
| 无容量 | 2.48s |
| 指定容量 | 0.21s |
指定容量的版本**快约 12 倍**,因为追加期间零重新分配。
---
## 传值
不要仅为了节省几个字节就将指针作为函数参数传递。如果函数在整个函数体中仅通过 `*x` 引用其参数 `x`,则该参数不应该是`指针。
```go
func process(s string) { // 不是 *string —— string 是小的固定大小头部
fmt.Println(s)
}
```
**常见的按值传递类型**:`string`、`io.Reader`、小结构体。
**例外**:
- 复制代价高的大结构体
- 未来可能增长的小结构体
---
## 字符串拼接
根据复杂度选择正确的策略:
| 方法 | 最佳用途 |
|------|---------|
| `+` | 少量字符串,简单拼接 |
| `fmt.Sprintf` | 混合类型的格式化输出 |
| `strings.Builder` | 循环/逐段构建 |
| `strings.Join` | 连接 slice |
| 反引号字面量 | 常量多行文本 |
> 在选择字符串拼接策略、在循环中使用 strings.Builder 或在 fmt.Sprintf 和手动拼接之间做决定时,阅读 [references/STRING-OPTIMIZATION.md](references/STRING-OPTIMIZATION.md)。
---
## 基准测试和性能分析
在优化前后始终要进行测量。使用 Go 内置的基准测试框架和性能分析工具。
```bash
go test -bench=. -benchmem -count=10 ./...
```
> 在编写基准测试、使用 benchstat 比较结果、使用 pprof 进行性能分析或解读基准测试输出时,阅读 [references/BENCHMARKS.md](references/BENCHMARKS.md)。
> **验证**:在应用优化后,运行 `bash scripts/bench-compare.sh` 测量实际影响。只保留有可衡量改进的优化。
---
## 快速参考
| 模式 | 不好 | 好 | 改进 |
|------|-----|------|-------------|
| 整数转字符串 | `fmt.Sprint(n)` | `strconv.Itoa(n)` | 快约 2 倍 |
| 重复 `[]byte` | 循环中 `[]byte("str")` | 在循环外转换一次 | 快约 7 倍 |
| Map 初始化 | `make(map[K]V)` | `make(map[K]V, size)` | 更少分配 |
| Slice 初始化 | `make([]T, 0)` | `make([]T, 0, cap)` | 快约 12 倍 |
| 小型固定大小参数 | `*string`、`*io.Reader` | `string`、`io.Reader` | 无间接引用 |
| 简单字符串连接 | `s1 + " " + s2` | (已经很好) | 对少量字符串使用 `+` |
| 循环构建字符串 | 重复 `+=` | `strings.Builder` | O(n) vs O(n²) |
---
## 相关技能
- **数据结构**:在 slice、map 和数组之间选择或理解分配语义时,参见 [go-data-structures](../go-data-structures/SKILL.md)
- **声明模式**:在使用 `make` 配合容量提示或初始化 map 和 slice 时,参见 [go-declarations](../go-declarations/SKILL.md)
- **并发**:在跨 goroutine 并行化工作或使用 sync.Pool 复用缓冲区时,参见 [go-concurrency](../go-concurrency/SKILL.md)
- **风格原则**:在判断优化是否值得牺牲可读性时,参见 [go-style-core](../go-style-core/SKILL.md)
@@ -0,0 +1,281 @@
# 基准测试方法
## 编写基准测试
Go 基准测试使用 `testing.B` 类型,位于 `_test.go` 文件中。
基准测试函数名必须以 `Benchmark` 开头。
```go
func BenchmarkStrconv(b *testing.B) {
for i := 0; i < b.N; i++ {
s := strconv.Itoa(rand.Int())
_ = s
}
}
func BenchmarkFmtSprint(b *testing.B) {
for i := 0; i < b.N; i++ {
s := fmt.Sprint(rand.Int())
_ = s
}
}
```
关键规则:
- 使用 `b.N` 作为循环边界——框架会调整它以获得稳定的计时
- 将结果赋值给变量(或 `_`),防止编译器优化掉调用
- 在不需要测量的昂贵设置之后使用 `b.ResetTimer()`
- 使用 `b.ReportAllocs()` 或 `-benchmem` 标志跟踪分配情况
### 子基准测试
```go
func BenchmarkConvert(b *testing.B) {
for _, size := range []int{10, 100, 1000} {
b.Run(fmt.Sprintf("size=%d", size), func(b *testing.B) {
data := make([]byte, size)
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = string(data)
}
})
}
}
```
---
## 运行基准测试
```bash
# 运行包中的所有基准测试
go test -bench=. ./...
# 运行特定基准测试并显示内存统计
go test -bench=BenchmarkStrconv -benchmem ./...
# 多次运行以获得统计显著性
go test -bench=. -benchmem -count=10 ./...
```
`-benchmem` 标志报告每次操作的分配次数。`-count` 标志将每个基准测试运行 N 次以获得统计显著性。
---
## 解读结果
```
BenchmarkStrconv-8 18705042 64.2 ns/op 16 B/op 1 allocs/op
BenchmarkFmtSprint-8 8249536 143.0 ns/op 16 B/op 2 allocs/op
```
| 字段 | 含义 |
|------|------|
| `-8` | GOMAXPROCS |
| `18705042` | 迭代次数 |
| `64.2 ns/op` | 每次操作时间 |
| `16 B/op` | 每次操作分配的字节数 |
| `1 allocs/op` | 每次操作的堆分配次数 |
---
## 使用 benchstat 进行比较
`benchstat` 对基准测试结果进行统计比较。安装它并将基准测试输出保存到文件:
```bash
# 安装 benchstat
go install golang.org/x/perf/cmd/benchstat@latest
# 运行基准测试并保存结果
go test -bench=. -benchmem -count=10 ./... > old.txt
# 进行修改后再次运行
go test -bench=. -benchmem -count=10 ./... > new.txt
# 比较结果
benchstat old.txt new.txt
```
### 解读 benchstat 输出
```
name old time/op new time/op delta
Strconv-8 64.2ns ± 2% 61.8ns ± 1% -3.74% (p=0.001 n=10+10)
```
- **delta**:变化百分比(负数 = 更快)
- **p-value**:统计显著性(p < 0.05 为显著)
- **n**:使用的有效样本数量
提示:
- 始终使用 `-count=10` 或更高以获得可靠结果
- 小的 p 值确认变化是真实的,而非噪声
- 如果 benchstat 显示 `~`(波浪号),则差异不具有统计显著性
---
## 来自性能模式的基准测试示例
### strconv vs fmt
| 方式 | 速度 | 分配次数 |
|------|------|---------|
| `fmt.Sprint` | 143 ns/op | 2 allocs/op |
| `strconv.Itoa` | 64.2 ns/op | 1 allocs/op |
### 重复字节转换
```go
func BenchmarkRepeatedConversion(b *testing.B) {
var buf bytes.Buffer
for i := 0; i < b.N; i++ {
buf.Write([]byte("Hello world"))
}
}
func BenchmarkSingleConversion(b *testing.B) {
var buf bytes.Buffer
data := []byte("Hello world")
for i := 0; i < b.N; i++ {
buf.Write(data)
}
}
```
| 方式 | 速度 |
|------|------|
| 重复转换 | 22.2 ns/op |
| 单次转换 | 3.25 ns/op |
### Slice 容量
```go
func BenchmarkNoCapacity(b *testing.B) {
for n := 0; n < b.N; n++ {
data := make([]int, 0)
for k := 0; k < 1000; k++ {
data = append(data, k)
}
}
}
func BenchmarkWithCapacity(b *testing.B) {
for n := 0; n < b.N; n++ {
data := make([]int, 0, 1000)
for k := 0; k < 1000; k++ {
data = append(data, k)
}
}
}
```
| 方式 | 时间(1 亿次迭代) |
|------|------------------------|
| 无容量 | 2.48s |
| 指定容量 | 0.21s |
---
## 使用 pprof 进行性能分析
使用 `pprof` 在优化前识别瓶颈。基准测试衡量改进效果;pprof 找到需要改进的地方。
### CPU 性能分析
```bash
# 从基准测试生成 CPU 分析文件
go test -bench=BenchmarkHotPath -cpuprofile=cpu.prof ./...
# 使用 pprof 分析
go tool pprof cpu.prof
```
常用 pprof 命令:
```
(pprof) top10 # 按 CPU 时间排列的前 10 个函数
(pprof) list funcName # 某个函数的带注释源码
(pprof) web # 浏览器中的交互式图表
```
### 内存性能分析
```bash
# 生成内存分析文件
go test -bench=BenchmarkHotPath -memprofile=mem.prof ./...
# 分析分配情况
go tool pprof -alloc_space mem.prof
```
### 运行中服务的 HTTP 性能分析
```go
import _ "net/http/pprof"
func main() {
go func() {
log.Println(http.ListenAndServe("localhost:6060", nil))
}()
// ... 应用程序代码 ...
}
```
通过 `http://localhost:6060/debug/pprof/` 访问性能分析数据。
### 性能分析工作流
1. 对疑似热点路径进行**基准测试**
2. 使用 pprof **分析**以确认时间花在了哪里
3. 使用本技能中的模式进行**优化**
4. **重新基准测试**以用 benchstat 验证改进
5. **重新分析**以检查是否出现新的瓶颈
---
## 常见错误
### 忽略 b.N
测试框架会调整 `b.N` 以获得稳定的计时。使用固定迭代次数会产生无意义的结果:
```go
// 不好:忽略 b.N —— 基准测试框架无法校准
func BenchmarkFixed(b *testing.B) {
for i := 0; i < 1000; i++ {
doWork()
}
}
// 好:使用 b.N 作为循环边界
func BenchmarkCorrect(b *testing.B) {
for i := 0; i < b.N; i++ {
doWork()
}
}
```
### 未防止编译器优化消除
如果函数调用的结果未被使用,编译器可能会完全优化掉该调用。将结果赋值给包级变量:
```go
// 不好:编译器可能会优化掉调用
func BenchmarkElided(b *testing.B) {
for i := 0; i < b.N; i++ {
expensiveFunc()
}
}
// 好:赋值给包级变量以防止优化消除
var benchResult int
func BenchmarkKept(b *testing.B) {
var r int
for i := 0; i < b.N; i++ {
r = expensiveFunc()
}
benchResult = r
}
```
@@ -0,0 +1,134 @@
# 字符串优化模式
## strconv vs fmt
在基本类型和字符串之间转换时,`strconv` 比 `fmt` 更快,因为 `fmt` 使用反射并处理任意类型。
**不好:**
```go
for i := 0; i < b.N; i++ {
s := fmt.Sprint(rand.Int())
}
```
**好:**
```go
for i := 0; i < b.N; i++ {
s := strconv.Itoa(rand.Int())
}
```
**基准测试比较:**
| 方式 | 速度 | 分配次数 |
|------|------|---------|
| `fmt.Sprint` | 143 ns/op | 2 allocs/op |
| `strconv.Itoa` | 64.2 ns/op | 1 allocs/op |
常用转换:
| 任务 | `fmt` | `strconv` |
|------|-------|-----------|
| Int → string | `fmt.Sprint(n)` | `strconv.Itoa(n)` |
| Int64 → string | `fmt.Sprint(n)` | `strconv.FormatInt(n, 10)` |
| Float → string | `fmt.Sprint(f)` | `strconv.FormatFloat(f, 'f', -1, 64)` |
| String → int | — | `strconv.Atoi(s)` |
| Bool → string | `fmt.Sprint(b)` | `strconv.FormatBool(b)` |
---
## 重复的字符串到字节转换
不要重复从固定字符串创建字节切片。应该只转换一次并保存结果。
**不好:**
```go
for i := 0; i < b.N; i++ {
w.Write([]byte("Hello world"))
}
```
**好:**
```go
data := []byte("Hello world")
for i := 0; i < b.N; i++ {
w.Write(data)
}
```
**基准测试比较:**
| 方式 | 速度 |
|------|------|
| 重复转换 | 22.2 ns/op |
| 单次转换 | 3.25 ns/op |
好的版本**快约 7 倍**,因为它避免了每次迭代都分配新的字节切片。
---
## 字符串拼接
根据复杂度选择正确的字符串构建策略。
### 简单场景使用 `+`
```go
key := "projectid: " + p
```
`+` 运算符对于少量、固定数量的字符串是高效的。编译器通常可以优化相邻的字符串字面量。
### 格式化使用 `fmt.Sprintf`
```go
// 好:清晰的格式化
str := fmt.Sprintf("%s [%s:%d]-> %s", src, qos, mtu, dst)
// 不好:使用 + 手动转换
str := src.String() + " [" + qos.String() + ":" + strconv.Itoa(mtu) + "]-> " + dst.String()
```
当写入 `io.Writer` 时,直接使用 `fmt.Fprintf` 而不是先用 `fmt.Sprintf` 构建临时字符串。
### 逐段构建使用 `strings.Builder`
`strings.Builder` 花费摊销线性时间,而重复使用 `+` 或
`fmt.Sprintf` 在构建大字符串时花费二次时间:
```go
b := new(strings.Builder)
for i, d := range digitsOfPi {
fmt.Fprintf(b, "the %d digit of pi is: %d\n", i, d)
}
str := b.String()
```
### 常量多行字符串使用反引号
```go
// 好:原始字符串字面量
usage := `Usage:
custom_tool [args]`
// 不好:使用转义序列拼接
usage := "" +
"Usage:\n" +
"\n" +
"custom_tool [args]"
```
### 策略总结
| 方法 | 最佳用途 | 性能 |
|------|---------|------|
| `+` | 少量字符串,简单拼接 | 小 n 时 O(n) |
| `fmt.Sprintf` | 格式化输出 | 较慢,但更清晰 |
| `strings.Builder` | 循环/逐段构建 | 摊销 O(n) |
| `strings.Join` | 连接 slice | O(n) |
| 反引号字面量 | 常量多行文本 | 零开销 |
+252
View File
@@ -0,0 +1,252 @@
#!/usr/bin/env bash
set -euo pipefail
VERSION="1.1.0"
SCRIPT_NAME="$(basename "$0")"
usage() {
cat <<EOF
$SCRIPT_NAME v$VERSION — Run Go benchmarks with optional comparison
USAGE
bash $SCRIPT_NAME [options] [package]
DESCRIPTION
Wrapper around 'go test -bench' that runs benchmarks multiple times and
optionally compares results against a saved baseline using benchstat.
Results can be saved to a file for future comparison. If benchstat is
installed and a baseline is provided, a statistical comparison is shown.
EXIT CODES
0 Benchmarks ran successfully
1 go test failed (compilation error, test failure, no benchmarks found)
2 Usage error (missing arguments, bad flags, file exists without --force)
OPTIONS
-h, --help Show this help message
-v, --version Show version
-n, --count N Number of benchmark iterations (default: 5)
-b, --baseline FILE Compare results against this baseline file
-s, --save FILE Save benchmark results to this file
-f, --filter REGEX Benchmark filter regex (default: ".")
--json Output metadata as JSON (human output goes to stderr)
--benchmem Include memory allocation stats (default: on)
--no-benchmem Disable memory allocation stats
--force Allow --save to overwrite existing files
--limit N Max benchmark result lines to include (default: 0 = all)
ARGUMENTS
package Go package to benchmark (default: ./...)
EXAMPLES
bash $SCRIPT_NAME
bash $SCRIPT_NAME -n 10 ./pkg/parser
bash $SCRIPT_NAME --save baseline.txt ./...
bash $SCRIPT_NAME --baseline baseline.txt --save current.txt ./...
bash $SCRIPT_NAME --filter BenchmarkSort -n 3
bash $SCRIPT_NAME --json --limit 5 ./...
bash $SCRIPT_NAME --save results.txt --force ./...
EOF
}
json_escape() {
local s="$1"
s="${s//\\/\\\\}"
s="${s//\"/\\\"}"
s="${s//$'\t'/\\t}"
s="${s//$'\r'/}"
s="${s//$'\n'/\\n}"
printf '%s' "$s"
}
# Print human-readable output: stdout in text mode, stderr in JSON mode.
log() {
if $JSON_OUTPUT; then
echo "$@" >&2
else
echo "$@"
fi
}
COUNT=5
BASELINE=""
SAVE=""
FILTER="."
PACKAGE=""
JSON_OUTPUT=false
BENCHMEM=true
FORCE=false
LIMIT=0
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help) usage; exit 0 ;;
-v|--version) echo "$SCRIPT_NAME v$VERSION"; exit 0 ;;
-n|--count) COUNT="${2:?error: --count requires a number}"; shift 2 ;;
-b|--baseline) BASELINE="${2:?error: --baseline requires a file path}"; shift 2 ;;
-s|--save) SAVE="${2:?error: --save requires a file path}"; shift 2 ;;
-f|--filter) FILTER="${2:?error: --filter requires a regex}"; shift 2 ;;
--json) JSON_OUTPUT=true; shift ;;
--benchmem) BENCHMEM=true; shift ;;
--no-benchmem) BENCHMEM=false; shift ;;
--force) FORCE=true; shift ;;
--limit) LIMIT="${2:?error: --limit requires a number}"; shift 2 ;;
-*) echo "error: unknown option: $1" >&2; usage >&2; exit 2 ;;
*) PACKAGE="$1"; shift ;;
esac
done
PACKAGE="${PACKAGE:-./...}"
if ! command -v go &>/dev/null; then
echo "error: 'go' command not found in PATH" >&2
exit 2
fi
if ! [[ "$COUNT" =~ ^[1-9][0-9]*$ ]]; then
echo "error: --count must be a positive integer, got: $COUNT" >&2
exit 2
fi
if ! [[ "$LIMIT" =~ ^[0-9]+$ ]]; then
echo "error: --limit must be a non-negative integer, got: $LIMIT" >&2
exit 2
fi
if [[ -n "$BASELINE" && ! -f "$BASELINE" ]]; then
echo "error: baseline file not found: $BASELINE" >&2
exit 2
fi
if [[ -n "$SAVE" && -f "$SAVE" ]] && ! $FORCE; then
echo "error: save target already exists: $SAVE (use --force to overwrite)" >&2
exit 2
fi
HAS_BENCHSTAT=false
if command -v benchstat &>/dev/null; then
HAS_BENCHSTAT=true
fi
BENCH_ARGS=(-bench "$FILTER" -count "$COUNT" -run '^$')
if $BENCHMEM; then
BENCH_ARGS+=(-benchmem)
fi
TMPFILE=$(mktemp "${TMPDIR:-/tmp}/bench-XXXXXX.txt")
trap 'rm -f "$TMPFILE"' EXIT
log "Running benchmarks: go test ${BENCH_ARGS[*]} $PACKAGE"
log "Iterations: $COUNT"
log ""
GO_EXIT=0
if $JSON_OUTPUT; then
go test "${BENCH_ARGS[@]}" "$PACKAGE" 2>&1 | tee "$TMPFILE" >&2 || GO_EXIT=$?
else
go test "${BENCH_ARGS[@]}" "$PACKAGE" 2>&1 | tee "$TMPFILE" || GO_EXIT=$?
fi
BENCH_COUNT=$(grep -cE '^Benchmark' "$TMPFILE" || true)
TRUNCATED=false
if [[ $LIMIT -gt 0 && $BENCH_COUNT -gt $LIMIT ]]; then
TRUNCATED=true
fi
if ! $JSON_OUTPUT && $TRUNCATED; then
log ""
log "Note: $BENCH_COUNT benchmark results found, showing first $LIMIT (--limit $LIMIT)"
fi
if [[ -n "$SAVE" ]]; then
cp "$TMPFILE" "$SAVE"
log ""
log "Results saved to: $SAVE"
fi
if [[ -n "$BASELINE" ]]; then
log ""
log "=== Comparison with baseline: $BASELINE ==="
log ""
if $HAS_BENCHSTAT; then
if $JSON_OUTPUT; then
benchstat "$BASELINE" "$TMPFILE" >&2 || true
else
benchstat "$BASELINE" "$TMPFILE" || true
fi
else
log "note: install benchstat for statistical comparison:"
log " go install golang.org/x/perf/cmd/benchstat@latest"
log ""
log "--- Baseline ---"
if $JSON_OUTPUT; then
grep -E '^Benchmark' "$BASELINE" >&2 || true
else
grep -E '^Benchmark' "$BASELINE" || true
fi
log ""
log "--- Current ---"
if $JSON_OUTPUT; then
grep -E '^Benchmark' "$TMPFILE" >&2 || true
else
grep -E '^Benchmark' "$TMPFILE" || true
fi
fi
fi
FINAL_EXIT=0
if [[ $GO_EXIT -ne 0 ]]; then
FINAL_EXIT=1
if ! $JSON_OUTPUT; then
log ""
log "error: go test exited with code $GO_EXIT"
fi
elif [[ $BENCH_COUNT -eq 0 ]]; then
FINAL_EXIT=1
if ! $JSON_OUTPUT; then
log ""
log "error: no benchmarks found matching filter: $FILTER"
fi
fi
if $JSON_OUTPUT; then
BENCH_OUTPUT=$(<"$TMPFILE")
if $TRUNCATED; then
limited=""
bench_seen=0
while IFS= read -r line; do
if [[ "$line" =~ ^Benchmark ]]; then
bench_seen=$((bench_seen + 1))
if [[ $bench_seen -le $LIMIT ]]; then
limited+="$line"$'\n'
fi
else
limited+="$line"$'\n'
fi
done < "$TMPFILE"
BENCH_OUTPUT="$limited"
fi
escaped_package=$(json_escape "$PACKAGE")
escaped_filter=$(json_escape "$FILTER")
escaped_baseline=$(json_escape "$BASELINE")
escaped_save=$(json_escape "$SAVE")
escaped_output=$(json_escape "$BENCH_OUTPUT")
printf '{"count":%d,' "$COUNT"
printf '"package":"%s",' "$escaped_package"
printf '"filter":"%s",' "$escaped_filter"
printf '"benchmarks_found":%d,' "$BENCH_COUNT"
printf '"baseline":"%s",' "$escaped_baseline"
printf '"save":"%s",' "$escaped_save"
printf '"exit_code":%d,' "$GO_EXIT"
printf '"output":"%s"' "$escaped_output"
if $TRUNCATED; then
printf ',"truncated":true'
fi
printf '}\n'
fi
exit $FINAL_EXIT
+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