关于 go
go 技能为编写、审查和重构 Go 程序提供惯用模式与实践,面向 Go 开发者。它强调依据模块版本选择标准库功能,并以清晰、简洁和可靠为原则。
主要功能
- 01Go 版本与功能门槛核对
- 02惯用代码与错误处理建议
- 03包与 API 设计指导
- 04标准库及依赖选择建议
- 05并发与测试实践
适用场景
- 01编写新的 Go 代码
- 02审查或重构现有 Go 项目
- 03设计 Go 包、模块或 API
- 04排查版本兼容与测试问题
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ spf13/ go-skills --skill go安装后在 Claude Code 中输入/go确认已加载。
附带文件.claude-plugin/plugin.json
详细介绍
惯用 Go:Go 的方式
用于构建健壮、高效且易于维护的应用程序的惯用 Go 模式和最佳实践。
何时启用
- 编写新的 Go 代码
- 审查或审计现有 Go 代码
- 重构 Go 代码(尤其是看起来像 Java/Spring Boot 模式的 Go 代码)
- 设计 Go 包、模块或 API
- 在标准库和第三方库之间进行选择
- 任何有关 Go 项目结构、错误处理、并发或测试的问题
版本门槛:先检查模块的最低版本
每项受版本限制的功能都有一个最低版本要求:模块的包必须能够在引入该功能的版本上编译。推荐此类功能之前,先确定最低版本,并且只推荐该版本支持的功能。
- 阅读模块
go.mod中的go指令。该版本是模块自身包的最低版本——声明go 1.21的模块必须仍能在 Go 1.21 上编译,无论你当前使用的是什么工具链。 - 检查文件的构建约束。由
//go:build go1.24限定的文件可以使用 Go 1.24 的功能,即使模块的go指令指定了更低的版本。 - 如果最低版本未知,请阅读
go.mod或询问。切勿猜测,也不要假设读者使用的是最新版本。
反过来的错误同样存在:如果模块的最低版本已经支持某种惯用写法,就不要指出它不可用;也绝不要仅仅为了使用便捷辅助函数而提高模块的 go 指令版本——go 指令设定的最低版本会约束该模块的每一个依赖方。
版本 → 功能索引
每项功能一行,查阅此处即可确认可用性,无需猜测。
| 起始版本 | 功能 |
|---|---|
| 1.17 | //go:build 约束,必须用它替代 // +build 行 |
| 1.18 | 类型参数(泛型) |
| 1.18 | any 作为 interface{} 的别名 |
| 1.19 | sync/atomic 类型化值(atomic.Int64、atomic.Pointer[T]) |
| 1.20 | errors.Join |
| 1.21 | slices 包 |
| 1.21 | maps 包 |
| 1.21 | cmp 包(cmp.Compare) |
| 1.21 | min、max、clear 内置函数 |
| 1.21 | log/slog |
| 1.21 | context.WithoutCancel |
| 1.22 | math/rand/v2 |
| 1.22 | net/http ServeMux 方法和通配符模式 |
| 1.22 | for i := range n(遍历整数) |
| 1.22 | 每次迭代独立的循环变量 |
| 1.22 | slices.Concat |
| 1.22 | cmp.Or |
| 1.23 | 遍历函数迭代器(iter.Seq、iter.Seq2) |
| 1.23 | 作为迭代器的 maps.Keys / maps.Values |
| 1.23 | slices.Collect / slices.Values / slices.Sorted |
| 1.24 | encoding/json 的 omitzero |
| 1.24 | go.mod 中的 tool 指令 |
| 1.24 | 泛型类型别名 |
| 1.24 | t.Context()、b.Loop()、t.Chdir() |
| 1.25 | testing/synctest(synctest.Test) |
| 1.25 | sync.WaitGroup.Go |
| 1.26 | new(expr) |
| 1.26 | 自引用类型参数约束 |
| 1.26 | errors.AsType[T] |
| 1.26 | log/slog.NewMultiHandler |
| 1.26 | reflect 迭代器(Type.Fields、Type.Methods、Type.Ins、Type.Outs、Value.Fields、Value.Methods) |
| 1.26 | 搭配 go test -artifacts 使用的 t.ArtifactDir() / b.ArtifactDir() / f.ArtifactDir() |
| 1.26 | B.Loop 不再阻止内联 |
| 1.26 | testing/cryptotest.SetGlobalRandom |
| 1.26 | 默认启用 Green Tea GC |
| 1.26 | 64 位平台上的堆基址随机化 |
| 1.26 | runtime/pprof 的 goroutineleak 配置文件(实验:GOEXPERIMENT=goroutineleakprofile) |
| 1.26 | simd/archsimd(实验:GOEXPERIMENT=simd) |
| 1.26 | runtime/secret(实验:GOEXPERIMENT=runtimesecret) |
| 1.27 | 泛型方法 |
| 1.27 | 结构体字面量键可以是任意字段选择器 |
| 1.27 | 广义函数类型推断 |
| 1.27 | go test 默认启用 stdversion vet 检查 |
| 1.27 | uuid 包 |
| 1.27 | strings.CutLast / bytes.CutLast |
| 1.27 | net/url 的 URL.Clone / Values.Clone |
| 1.27 | math/rand/v2 的 (*Rand).N |
| 1.27 | testing/synctest.Sleep |
| 1.27 | net/http/httptest.NewTestServer |
| 1.27 | runtime/pprof 的 goroutineleak 配置文件正式可用 |
| 1.27 | time 计时器和节拍器始终为无缓冲;移除 asynctimerchan |
| 1.27 | 按大小优化的小型分配(小于 80 字节) |
| 1.27 | encoding/json 基于 encoding/json/v2 实现 |
| 1.27 | simd(实验:GOEXPERIMENT=simd) |
| 1.27 | arm64 和 WebAssembly 上的 simd/archsimd(实验:GOEXPERIMENT=simd) |
强制检查:stdversion
从 Go 1.27 起,go test 默认运行 stdversion vet 检查。它会报告引用文件中使用的标准库符号是否超出了当前 Go 版本的支持范围;该版本由 go.mod 中的 go 指令以及文件上的构建标签决定。
- 在认定受版本限制的代码正确之前,先运行
go vet ./...(或go test ./...)。 - 将
stdversion检查结果视为版本限制错误,而非误报:代码可能超出了模块最低版本的支持范围;也可能是有意提高最低版本,此时必须通知该模块的所有依赖方。 - 此检查仅覆盖标准库符号。语言特性(
new(expr)、泛型方法)以及不引入新符号的行为变更,仍需你手动控制其版本适用范围。
核心原则
1. 清晰胜过巧妙
Go 倡导可读性和简洁性,而非抽象和巧思。代码应该一目了然。如果一个函数需要读三遍才能理解其控制流,就应该重写。
// Idiomatic: Direct, linear control flow. Note: no "Get" prefix — Go omits it.
func LookupUser(id string) (*User, error) {
user, err := db.FindUser(id)
if err != nil {
return nil, fmt.Errorf("finding user %s: %w", id, err)
}
return user, nil
}
2. 让零值开箱即用
设计类型时,应确保零值无需初始化即可立即使用。这样可以省去样板构造函数。sync.Mutex 和 bytes.Buffer 堪称典范。
// Idiomatic: Ready to use immediately
type Counter struct {
mu sync.Mutex
count int
}
func (c *Counter) Inc() {
c.mu.Lock()
c.count++
c.mu.Unlock()
}
3. 尽早返回,让正常路径保持在最左侧
立即处理错误和边缘情况并返回。主逻辑不要使用 else 块。函数的“正常路径”绝不应有缩进。
少写代码:必要性阶梯
写代码前,先在第一个符合条件的阶梯处停下。理解问题后再往上攀登——先阅读变更涉及的代码,并追踪实际流程。如果两个阶梯都适用,就选较高的那个,然后继续。
- 这有必要存在吗?“以后可能会用到”不算必要。跳过它,并用一句话说明原因。
- **代码库里已经有了吗?**动笔前先搜索——这个辅助函数、类型或模式可能已经存在于另一个包中。重复实现已有功能是这种错误最常见的形式。
- **标准库能做到吗?**在 Go 中,这一阶梯比其他任何阶梯都更常用:用
net/http的 ServeMux 代替路由器,用slog代替日志模块,用slices/maps/cmp代替手写循环,用errors.Join代替multierr,用math/rand/v2代替随机数生成器辅助函数。参见 标准库:使用新的包。 - **工具链或平台已经能做到吗?**这些功能免费且已安装:用
//go:embed代替资源处理流水线或加载器,用os.Root代替手写路径约束,用sync.OnceValue代替延迟初始化辅助函数,用tool指令代替脚本,用构建标签代替 Shell 包装器,用CHECK约束或唯一索引代替应用代码中的重复实现。在服务的模板中,原生输入控件胜过组件库及其打包产物。 - 它已经在
go.mod中了吗?使用你已经引入的依赖。能用几行代码或现有导入实现的功能,绝不要新增模块——但这并不意味着只能使用标准库:golang.org/x/sync/errgroup、afero和go-cmp都是已安装的依赖,也是本技能推荐使用的工具(参见 并发模式和测试模式),不必手写实现。 - **能写成一行吗?**就写一行。
- **然后才考虑:**实现可行方案所需的最少代码——不添加额外文件、层次或可调参数。只有一个实现的接口、只生产一种产品的工厂,以及值永不变化的配置字段,都是第 7 阶梯上的失败案例。
第 3 和第 4 阶梯提到的功能受版本限制(sync.OnceValue 在 Go 1.21 中推出;os.Root 和 tool 指令在 Go 1.24 中推出),因此在推荐之前,先确认模块的最低版本——参见 版本限制。
**绝不要省略:**信任边界处的校验、防止数据丢失的错误处理、安全措施、清理操作,以及明确要求的任何内容。在 Go 中,这意味着仍要检查并包装错误,保留用于释放资源的 defer,保留用于保证映射安全的互斥锁,并且复杂分支或解析器仍要留下一项可运行的测试(参见 测试模式)。偷懒只会缩短解决方案,绝不能减少阅读,更不能省掉检查。
**修复根因,而不是报告中提到的路径。**Bug 报告指出的是症状。修改前,先搜索该函数的所有调用方:在所有调用方共同经过的位置加一处保护,比在每个调用点都加保护改动更少,也能修复工单中未提及的调用方。
**说明你接受的上限。**当最简实现存在已知限制时,应在限制所在的位置记录下来:// yagni: whole-table lock — per-key locks if write throughput matters。明确说明上限是简化,未说明的上限则是隐患。
包组织:默认采用扁平结构
**反模式:**默认使用深层嵌套的目录树,或过度依赖 internal/ 文件夹来人为强制执行“整洁架构”分层。这会导致循环依赖和难以浏览。
1. 单包默认原则
从扁平结构开始。如果你正在构建微服务或简单工具,就把所有内容放在根目录(或与 main.go 并列)。只有在确实需要新的命名空间来理清代码,或需要解耦严格独立的领域时,才创建新包。
一个二进制文件意味着在模块根目录放置 main.go。只有当模块要发布多个二进制文件时,才使用 cmd/<name>/——每个二进制文件单独占一个目录,目录中不放其他内容。
2. 正确使用 internal/
internal/ 目录有一项特定的编译器限制:它会阻止其他模块导入其中的代码。
- 对于应用: 如果你正在构建可执行二进制文件,本来就没人能导入你的代码。在这里使用
internal/通常只会增加不必要的路径层级。 - 对于库: 谨慎使用
internal/。它应仅用于复杂子系统:你需要在自己的包之间共享导出类型,但又必须绝对禁止最终用户依赖这些类型。
// Idiomatic: A flat, feature-focused library or simple app
myproject/
├── main.go # Entry point (if application)
├── server.go # Core logic
├── config.go # Configuration
├── parser.go # Domain specific parsing
├── parser_test.go
├── go.mod
└── go.sum
3. 服务应用的领域包
当应用确实包含彼此独立、可单独测试的领域时,为每个领域创建自己的顶层包。规则仍然是只深入一层——不嵌套 internal/,也不采用整洁架构分层。每个包负责一项职责。由 main.go 将它们组装起来。
创建包的判断信号:能否用一句话描述这个领域?它能否在不了解调用它的包的情况下完成自己的工作?如果可以,就值得拥有自己的包。
// Idiomatic: A web service with distinct domain packages
myservice/
├── main.go # wires everything together; no business logic here
├── config/ # Config struct, env loading
├── auth/ # identity verification, session middleware
├── db/ # data store client + all queries
├── storage/ # blob storage (S3, R2, GCS)
├── billing/ # payment provider + credit ledger
├── jobs/ # job lifecycle + queue dispatch + worker handler (same domain, one package)
├── web/ # HTTP handlers + HTML templates + static assets (tightly coupled by design)
├── transcribe/ # domain-specific processing — independent pure functions
├── Makefile
├── Dockerfile
└── .env.example
领域包规则:
- 每个包都只有一个明确用途——按它的功能命名,而不是按它所处的层命名(使用
jobs/,而非service/) - 导入方向保持单向:
web可以导入jobs,jobs可以导入db,但下层不能导入上层,也不能导入main。同级包之间可以互相导入——循环导入和向上导入才是问题。main是组装入口,不是库 - 总是一起出现的相关子职责应放在同一个包中(例如,创建作业和处理工作线程都放在
jobs/中,因为它们属于同一个作业生命周期领域) - HTTP 处理程序及其渲染的模板应放在一起,置于
web/中——它们本就紧密耦合,而且//go:embed只能读取自身包目录中的文件,因此模板和静态资源必须放在那里 - 不要创建
utils/、helpers/或common/包——这些都是职责归属不清的表现
应当避免的反模式: 整洁架构 / DDD 分层(service/、repository/、controller/、domain/)。这些按层命名的包会导致循环导入、迫使你大量创建接口,而且在 Go 中完全无法提升清晰度。按领域命名的包(auth/、billing/、jobs/)才是“过于扁平”和“过度设计”之间的正确折中。
4. 何时不再适合保持扁平结构
扁平结构是一种默认选择,不是不可更改的承诺。当代码表明边界已经存在时,就拆分:
main.go已经超过几百行,或者某个文件现在包含了多个互不相关的职责。- 一个结构体同时保存两个互不相关职责的状态,或者一个互斥锁同时保护这两者。
- 测试某项职责之前,必须先构建整个程序——先准备好服务器、配置和数据库句柄,测试才能运行。
- 某个文件的导入彼此无关:一半导入只服务于文件的一半内容。
- HTTP 处理程序、嵌入资源或对操作系统的 Shell 调用,与完全不需要它们的纯逻辑并列在一起。
这些是症状,不是目标。沿领域边界拆分并保持只深入一层;绝不要为了满足这份清单而按层拆分。如果一个包永远无法在不更改相邻包的情况下进行修改,那它就是一个顶着两个名字的包。
5. 拆分上帝对象
按以下步骤进行机械式拆分:
- 将状态和保护它的互斥锁一起移走——字段与其锁分离会引入新缺陷,而不是完成重构。
- 移走会操作该状态的方法,不要移走其他内容。
- 保持字段未导出。构造函数(
NewX)是边界:它接收包所需的内容,返回该类型,并隐藏互斥锁。 - 由
main构造它,并交给需要它的对象。新包不应知道谁会调用它;如果它需要回调,说明边界划错了。 - 接口由消费者定义(参见接口设计)——新包不应定义接口来描述自己的调用方。
测试应随状态一起迁移。如果新包仍无法独立测试,说明拆分尚未完成。
接口设计
1. 接口应在使用中发现,而不是提前设计
先编写具体类型。只有当你发现消费者需要以可互换的方式使用多个类型时,才定义接口。
2. 在使用接口的地方定义它
接口应属于使用它的包,而不是实现它的包。这样可以解耦各个包。
// processor/processor.go
// Idiomatic: The consumer defines exactly what it needs.
// The concrete 'UserStore' doesn't even need to know this interface exists.
type UserFetcher interface {
FetchUser(id string) (*User, error)
}
type Processor struct {
fetcher UserFetcher
}
3. 接受接口,返回结构体
将尽可能小的接口作为输入参数(例如,使用 io.Reader 而不是 *os.File),但返回具体结构体,这样调用方就不必通过类型断言来访问特定字段或方法。
库 API 设计
以领域对象作为入口
设计封装有状态资源(保险库、数据库连接、配置存储)的 Go 库时,应将该资源的结构体作为主要对象。它的方法返回领域类型的子对象。避免:
- 将配置/资源结构体作为每个包级函数的第一个参数
- 在可能被嵌入使用的库代码中使用包级全局状态(例如 pflag 的默认
FlagSet)
应这样做:
// Open the primary resource once
v, err := vault.Open(path)
// Domain operations are methods on the primary object
// Each call is stateless (reloads fresh) — no cached state on the struct
idx, err := v.People() // returns *people.Index, error
note, err := v.Daily(time.Now()) // returns *daily.Note, error
mtgs, err := v.Meetings() // returns *meetings.Index, error
// Domain types live in sub-packages — callers use type inference
p, err := idx.FindOne("Steve") // *people.Person
这种模式的优点:
- 主结构体(
Vault)是唯一入口——调用方只需导入一个包即可开始使用 - 子包定义丰富的领域类型(
people.Person、daily.Note)——每种类型都与拥有其逻辑的代码放在一起 - 没有全局状态,意味着该库可安全地并发使用、创建多个实例并进行测试
- 无状态的方法调用(每次都重新加载最新数据)让结构体保持简单——无需处理缓存失效逻辑
- 类型推断(
:=)意味着调用方声明变量时很少需要显式导入子包
何时改用全局实例: 仅适用于仅供 CLI 使用的工具(例如 pflag 本身),此类工具确实始终只有一个实例,且对终端用户而言,易用性比库的健壮性更重要。
并发模式
反模式: 繁重的静态工作池。Go 的调度器效率极高;无需像其他语言管理 OS 线程那样手动管理工作池。
1. 通过通信共享内存
如果可以通过 channel 传递数据,就不要用互斥锁保护共享数据。channel 用于协调执行;互斥锁用于串行化执行。
2. 限制并发
要限制并发,请使用带有 SetLimit 的 errgroup。既然已有此功能,就不要手动实现信号量 channel 或固定工作池。
func FetchAll(ctx context.Context, urls []string, maxConcurrent int) error {
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(maxConcurrent)
for _, url := range urls {
g.Go(func() error {
return fetch(ctx, url)
})
}
return g.Wait()
}
自 Go 1.22 起,循环变量按每次迭代分别定义——绝不要再生成旧式的 url := url 捕获行。
不需要传播错误时,sync.WaitGroup.Go(Go 1.25)可以省去 Add/Done 样板代码:
var wg sync.WaitGroup
for _, url := range urls {
wg.Go(func() { process(url) })
}
wg.Wait()
3. 永远不要在不知道如何停止的情况下启动 Goroutine
每个 go func() 都必须有明确的退出条件,通常由 context.Context 或已关闭的 channel 控制。
从 Go 1.27 起,这条规则可以衡量,而不再只是期望:goroutineleak profile 已在 runtime/pprof 中普遍可用,也可通过 /debug/pprof/goroutineleak 端点访问。
import (
"os"
"runtime/pprof"
)
if p := pprof.Lookup("goroutineleak"); p != nil {
_ = p.WriteTo(os.Stderr, 1)
}
泄漏的 Goroutine 会阻塞在并发原语(channel、sync.Mutex、sync.Cond 等)上,并且不可能解除阻塞——运行时通过垃圾回收器的可达性来证明这一点。由于这种技术基于可达性,因此无法检测因阻塞在全局变量或可运行 Goroutine 的局部变量所引用的原语上而导致的泄漏,所以 profile 干净只能作为证据,而非证明。在 Go 1.26 中,该 profile 需通过 GOEXPERIMENT=goroutineleakprofile 才能实验性启用;Go 1.27 中已删除这项实验。
配置与结构体设计
复杂初始化使用函数选项
当结构体有许多可选配置参数时,避免使用庞大的构造函数。使用函数选项模式。
type Server struct {
addr string
timeout time.Duration
}
type Option func(*Server)
func WithTimeout(d time.Duration) Option {
return func(s *Server) { s.timeout = d }
}
func NewServer(addr string, opts ...Option) *Server {
s := &Server{
addr: addr,
timeout: 30 * time.Second, // Sane default
}
for _, opt := range opts {
opt(s)
}
return s
}
使用 new(expr) 创建可选指针字段(Go 1.26)
指针字段是 Go 区分“缺失”和“存在但为零值”的方式。从 Go 1.26 起,new 接受表达式,因此不再需要临时变量:
type Person struct {
Name string
Age *int // nil means "not recorded"; a pointer separates absent from 0
}
// Old
age := yearsSince(born)
p := Person{Name: name, Age: &age}
// New (Go 1.26) — the canonical use of new(expr)
p := Person{Name: name, Age: new(yearsSince(born))}
在 Go 1.26 以下版本中,继续使用 v := expr; &v 形式。
错误处理
1. 错误是值
错误不是需要捕获的异常,而是需要处理的值。请显式检查错误。
2. 为上下文包装错误,而非为了堆栈跟踪
返回错误时,补充说明你当时尝试执行的操作。
// Idiomatic
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("loading config file %s: %w", path, err)
}
3. 使用 errors.AsType[T],而不是 errors.As(Go 1.26)
对于最低版本为 Go 1.26 或更高版本的模块,请使用 errors.AsType[T](err)——它是泛型且类型安全的,并且速度更快,因为无需预先声明目标,也无需传入指向指针的指针:
// Classic errors.As
var pathErr *fs.PathError
if errors.As(err, &pathErr) {
// use pathErr
}
// Go 1.26 — the value falls straight out of the call
if pathErr, ok := errors.AsType[*fs.PathError](err); ok {
// use pathErr
}
在 Go 1.26 以下版本中,errors.As 搭配 &target 仍是必需形式;这种写法正确,且受所有版本支持。
测试模式
反模式: 依赖重量级 BDD 框架(如 Ginkgo)或复杂的模拟生成工具。Go 测试就应该是 Go 编程。
布局决定了哪些内容可测试。 package main 无法导入,因此其中的代码只能通过构建整个二进制文件来测试。若某个领域必须在构建服务的情况下才能测试,那么它就应该拥有自己的包——这正是包组织中划分触发条件的实际意义。
1. 表驱动测试
这是 Go 单元测试的绝对标准。遍历一个包含输入和预期输出的结构体切片,并使用 t.Run()。
func TestParseConfig(t *testing.T) {
tests := []struct {
name string
input string
wantErr bool
}{
{"valid config", "port=8080", false},
{"invalid format", "port=abc", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := ParseConfig(tt.input)
if (err != nil) != tt.wantErr {
t.Fatalf("ParseConfig() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
2. 使用 t.Helper() 编写有意义的辅助函数
提取重复的断言逻辑时,务必调用 t.Helper(),确保失败信息指向实际的测试用例,而不是辅助函数所在的行。
3. 优先使用 Fake 和 Stub,而非重量级 Mock
利用 Go 的隐式接口编写简单的手动 Fake。这样可以保持测试依赖轻量,并让测试逻辑清晰易懂。
4. Golden 文件与 testdata 目录
对于需要复杂输入或会生成大量输出的测试,请使用名为 testdata 的目录。go test 工具会明确忽略这些目录。
5. 文件系统抽象(Afero 模式)
不要在业务逻辑深处硬编码对 os 包的调用。接受文件系统接口,让测试能够在内存中运行,而不触碰磁盘。github.com/spf13/afero 是业界标准。
import "github.com/spf13/afero"
type FileProcessor struct {
fs afero.Fs
}
func NewFileProcessor(fs afero.Fs) *FileProcessor {
return &FileProcessor{fs: fs}
}
在测试中注入 afero.NewMemMapFs(),即可完全消除磁盘 I/O,避免测试不稳定且运行缓慢。
6. 使用 cmp,而非 DeepEqual
比较复杂结构体或映射时,请使用 github.com/google/go-cmp/cmp,获得丰富易读的差异信息,而不是严格的 reflect.DeepEqual。
7. 现代 testing 新增功能(Go 1.24+)
// Test-scoped context — canceled automatically when the test ends.
// Use instead of context.Background() in tests.
func TestFetch(t *testing.T) {
ctx := t.Context()
...
}
// t.Chdir(dir) changes working directory for the test and restores it after.
基准测试:始终写 b.Loop()。 B.Loop 在 Go 1.24 中引入,是规定的基准测试写法;从 Go 1.26 起,它不再妨碍循环体内联,因此迁移出 b.N 完全安全:
func BenchmarkParse(b *testing.B) {
for b.Loop() {
Parse(input)
}
}
在 for b.Loop() 循环体内赋值的参数、结果和变量仍会保持存活,因此编译器无法优化掉被测调用——但循环条件必须严格写成 b.Loop()。绝不要使用 for i := 0; i < b.N; i++ 的形式,也不要混用两种形式。
测试输出文件放在 t.ArtifactDir() 中(Go 1.26)。 T.ArtifactDir、B.ArtifactDir 和 F.ArtifactDir 会为测试生成的文件返回一个专属目录。使用 go test -artifacts 时,该目录位于 -outputdir 下(或当前目录下);不使用此标志时,它是一个临时目录,会在测试结束时删除,因此写入其中不会污染目录树。
func TestRender(t *testing.T) {
out := filepath.Join(t.ArtifactDir(), "render.png")
if err := render(out); err != nil {
t.Fatal(err)
}
}
不要为测试输出自行编写 os.MkdirTemp 清理逻辑,也不要将构件写在 testdata/ 中的测试固件旁边。
确定性的加密随机数:cryptotest.SetGlobalRandom(Go 1.26)。 它会在测试期间设置全局、确定性的加密随机数源,影响 crypto/rand 以及 crypto/... 包中的隐式随机数:
func TestKeyGeneration(t *testing.T) {
cryptotest.SetGlobalRandom(t, 42) // fixed seed for this test
...
}
它会更改进程级状态,因此不能用于并行测试或具有并行祖先的测试。GODEBUG=cryptocustomrand=1 设置会临时恢复旧行为。
8. 使用 testing/synctest 测试并发代码(Go 1.25)
使用虚拟时钟,以确定性方式测试 goroutine 和超时——测试中不使用 time.Sleep,也不依赖不稳定的时序假设:
func TestTimeout(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), time.Second)
defer cancel()
time.Sleep(2 * time.Second) // instant — fake clock advances when all goroutines block
if ctx.Err() == nil {
t.Fatal("expected timeout")
}
})
}
绝不要在测试中写 time.Sleep(100 * time.Millisecond) 来“等待 goroutine”。请使用 synctest、通道或显式同步。
从 Go 1.27 起,synctest.Sleep 将 time.Sleep 与 synctest.Wait 结合起来——它会阻塞,直到虚拟时钟在 bubble 中前进指定时长,且该 bubble 中的所有其他 goroutine 都处于持久阻塞状态:
synctest.Test(t, func(t *testing.T) {
go worker(t) // system under test
synctest.Sleep(time.Second) // advances the clock, then waits for it to settle
// assertions see a quiescent system
})
在 bubble 中,优先使用它,而不是直接调用 time.Sleep:当测试代码与被测代码休眠相同时间时,普通的 time.Sleep 会导致唤醒顺序不可预测。
Go 1.27 还引入了 httptest.NewTestServer(t, handler),它会在内存中的虚拟网络上运行 http.Server,不使用端口或真实套接字,因此可在 synctest bubble 中进行 HTTP 测试:
srv := httptest.NewTestServer(t, mux)
defer srv.Close()
resp, err := srv.Client().Get("http://example.com/") // routed to srv, address ignored
srv.Client() 返回的客户端会将 HTTP 和 HTTPS 请求转发至该服务,无论目标主机是什么,因此测试无需使用 srv.URL 作为基址。在 Go 1.27 之前,httptest.NewServer 会绑定真实的 localhost 端口,无法在 synctest bubble 中使用。
泛型(Go 1.18+)
泛型用于消除重复算法,而不是创建类型层次结构。如果你从继承或多态的角度考虑泛型,请停下来——你写的是 Java。
何时使用泛型
当你有同一种算法需要处理多种具体类型时,使用泛型:
// Good: generic algorithm, concrete types as inputs
func Map[S, T any](slice []S, f func(S) T) []T {
result := make([]T, len(slice))
for i, v := range slice {
result[i] = f(v)
}
return result
}
// Good: constraint expresses a meaningful requirement
func Min[T cmp.Ordered](a, b T) T {
if a < b {
return a
}
return b
}
何时不要使用泛型
// Bad: generic interface for polymorphism — this is Java
type Repository[T any] interface {
Find(id string) (T, error)
Save(entity T) error
}
// Good: a concrete interface for what you actually need
type UserStore interface {
FindUser(id string) (*User, error)
SaveUser(u *User) error
}
- 不要创建泛型基类型、泛型服务或泛型仓库。
- 不要使用
any作为约束来表示“我还不知道类型”。这是一种设计异味。 - **需要 map 键或相等性检查时,**使用
comparable。 - **需要
<、>、<=、>=时,**使用cmp.Ordered。 - 从具体实现开始。只有当相同逻辑在 3 种以上类型中重复时,才将其泛型化。
- 从 Go 1.24 起,泛型类型别名已得到全面支持。
标准库:使用新包
LLM 经常建议使用第三方工具,或手动编写早在 Go 1.21 就已加入标准库的辅助函数。始终先检查标准库。
slices 包(Go 1.21)
import "slices"
// Searching and testing
slices.Contains(s, "value")
slices.Index(s, "value") // returns -1 if not found
slices.ContainsFunc(s, func(v string) bool { return v == "x" })
// Sorting
slices.Sort(s) // sorts in place, works on any ordered type
slices.SortFunc(s, func(a, b T) int { return cmp.Compare(a.Name, b.Name) })
slices.IsSorted(s)
// Manipulation
slices.Reverse(s)
slices.Compact(s) // removes consecutive duplicates
slices.Delete(s, i, j) // removes elements [i, j)
slices.Clone(s) // shallow copy
slices.Concat(s1, s2, s3) // concatenate multiple slices (1.22)
// Iterator bridging (1.23)
slices.Collect(it) // iterator -> slice
slices.Sorted(it) // iterator -> sorted slice
slices.Values(s) // slice -> iterator
当已有 slices.Sort(s) 时,绝不要写 sort.Slice(s, func(i, j int) bool { return s[i] < s[j] })。
maps 包(Go 1.21;迭代器为 1.23)
import "maps"
maps.Keys(m) // iterator over keys — collect with slices.Collect / slices.Sorted
maps.Values(m) // iterator over values
maps.Clone(m) // shallow copy
maps.Copy(dst, src) // copies all entries from src into dst
maps.DeleteFunc(m, func(k K, v V) bool { ... }) // delete entries matching predicate
maps.Equal(m1, m2) // reports whether two maps are equal
// Common idiom: sorted keys in one line
keys := slices.Sorted(maps.Keys(m))
cmp 包(Go 1.21)
import "cmp"
cmp.Compare(a, b) // returns -1, 0, or 1; works on any cmp.Ordered type
cmp.Or(a, b, c) // returns first non-zero value — replaces ternary workarounds (Go 1.22)
min(a, b) // built-in since Go 1.21
max(a, b) // built-in since Go 1.21
cmp.Or 特别适用于默认值模式:
// Instead of: if cfg.Timeout == 0 { cfg.Timeout = 30 * time.Second }
cfg.Timeout = cmp.Or(cfg.Timeout, 30*time.Second)
errors.Join(Go 1.20)
// Combine multiple errors — no third-party library needed
err := errors.Join(err1, err2, err3)
// Works correctly with errors.Is and errors.As
if errors.Is(err, ErrNotFound) { ... }
请使用它,而不是 fmt.Errorf("%w; %w", err1, err2) 或任何 multierr 包。
迭代器:iter 和 Range-over-Func(Go 1.23)
这是 API 在不分配切片的情况下公开序列的标准方式。返回 iter.Seq[T] 或 iter.Seq2[K, V];调用方使用普通的 range:
func (idx *Index) All() iter.Seq[*Person] {
return func(yield func(*Person) bool) {
for _, p := range idx.people {
if !yield(p) {
return
}
}
}
}
// Caller — just a normal range loop
for p := range idx.All() { ... }
对于大型或惰性生成的序列,优先返回迭代器而不是切片。不要自创 Next()/HasNext() 迭代器类型——那是 Java 的做法。
math/rand/v2(Go 1.22)
始终导入 math/rand/v2,不要使用旧版 math/rand。它会自动设定随机种子,API 也更简洁:
import "math/rand/v2"
rand.IntN(100) // was rand.Intn — note the capital N
rand.N(10 * time.Second) // generic: random value in [0, n) for any integer type
从 Go 1.27 开始,泛型 N 也成为方法,因此有种子的来源不再需要包级泛型:
r := rand.New(rand.NewPCG(seed1, seed2))
n := r.N(100) // Go 1.27 — in [0, 100), type inferred as int
在 Go 1.27 之前,默认来源请使用包级函数 rand.N(100);如果 int 类型足够,则使用 r.IntN(100)。
encoding/json:omitzero(Go 1.24)
omitzero 会省略任何零值,包括 time.Time{} 和零值结构体;omitempty 一直无法正确处理这些值:
type Event struct {
Name string `json:"name"`
StartedAt time.Time `json:"started_at,omitzero"` // omitempty would emit "0001-01-01T..."
}
在 Go 1.27 中,encoding/json 由 encoding/json/v2 实现支持,其默认行为更严格——它会拒绝 JSON 字符串中的无效 UTF-8 和对象中的重复名称;v1 的编组和解组行为仍会保留,但错误消息文本可能不同。cobra-viper 技能介绍了这些变化对配置解码的影响。
uuid 包(Go 1.27)
现在,标准库可以生成和解析 UUID。对于可要求使用 Go 1.27 的模块,这可以取代惯常推荐的 github.com/google/uuid:
import "uuid"
u := uuid.New() // algorithm suitable for most purposes
v7 := uuid.NewV7() // time-ordered; good for database keys
id, err := uuid.Parse(s)
MustParse 遇到无效输入时会触发 panic;凡是涉及用户输入的场景,都请使用 Parse。随机部分由密码学安全的生成器生成。在 Go 1.27 之前,请继续使用 github.com/google/uuid。
strings.CutLast / bytes.CutLast(Go 1.27)
围绕分隔符的最后一次出现位置截取,取代 LastIndex 加切片的惯用写法:
// Old
i := strings.LastIndex(path, "/")
if i < 0 {
return path, "", false
}
return path[:i], path[i+1:], true
// New (Go 1.27)
before, after, found := strings.CutLast(path, "/")
两者都会返回原始数据的切片(不复制),并报告是否找到分隔符。在 Go 1.27 之前,可使用 LastIndex 写法作为替代方案。
log/slog.NewMultiHandler(Go 1.26)
将一条日志记录分发给多个处理器——例如将文本写入 stderr、将 JSON 写入文件——无需编写日志包装器:
handler := slog.NewMultiHandler(
slog.NewTextHandler(os.Stderr, nil),
slog.NewJSONHandler(file, nil),
)
logger := slog.New(handler)
如果任一处理器已启用,MultiHandler.Enabled 就会返回 true。在 Go 1.26 之前,请编写一个小型 slog.Handler,将记录转发给处理器切片。
net/url 克隆(Go 1.27)
URL.Clone 和 Values.Clone 会返回深拷贝。在修改并非由你创建的 URL 或查询映射之前,请先克隆它们——普通的结构体拷贝仍会共享 userinfo 指针和查询映射:
u2 := u.Clone() // deep copy of all URL fields
q2 := q.Clone() // deep copy of Values
q2.Set("page", "2") // q is untouched
在 Go 1.27 之前,请手动拷贝(并记得 url.Userinfo 和 Values 映射都需要各自拷贝)。
reflect 迭代器(Go 1.26)
Type.Fields、Type.Methods、Type.Ins、Type.Outs、Value.Fields 和 Value.Methods 会返回迭代器,因此可与本技能其他部分使用的 range-over-func 风格组合:
for field := range reflect.TypeOf(cfg).Fields() {
fmt.Println(field.Name, field.Type)
}
在 Go 1.26 之前,请使用 NumField()/Field(i) 按索引访问。
并发:现代模式
sync/atomic 类型化值(Go 1.19)
请使用类型化原子值,而不是基于函数的 API:
// Old (still works but avoid for new code)
var count int64
atomic.AddInt64(&count, 1)
val := atomic.LoadInt64(&count)
// New — type-safe, no pointer arithmetic
var count atomic.Int64
count.Add(1)
val := count.Load()
// Other typed atomics
var flag atomic.Bool
var ptr atomic.Pointer[MyStruct]
var val32 atomic.Int32
var val64 atomic.Uint64
context.WithoutCancel(Go 1.21)
需要取消上下文的取消能力、同时保留其值时(例如,后台任务需要在请求结束后继续运行):
// The background job should keep running even after the HTTP request context cancels
bgCtx := context.WithoutCancel(requestCtx)
go doBackgroundWork(bgCtx)
time 通道始终无缓冲(Go 1.27)
由 time 包创建的通道(定时器和节拍器)现在始终无缓冲(同步),不受 GODEBUG 设置影响。Go 1.23 将容量为 0 的定时器通道设为声明 go 1.23 或更高版本的模块的行为,并提供 asynctimerchan=1 作为退出选项;Go 1.27 永久移除了该设置,因此不再有办法恢复原行为。
使用定时器和节拍器时的影响:
Stop和Reset保证在调用前准备好的值不会在调用后发送或接收。围绕Reset使用缓冲通道的做法已经过时,定时器触发的值也不会再被静默丢弃。- 定时器或节拍器通道的
len和cap均为0,因此不要通过轮询长度来判断接收是否会成功——应使用带select和default的非阻塞接收。 - 节拍器会调整间隔或丢弃节拍,以弥补接收方处理缓慢的问题,因此应及时读取
Ticker.C,或停止节拍器。
不影响代码的运行时变更(Go 1.26 / 1.27)
了解以下行为,避免无谓地追查或“修复”:
- Green Tea GC(Go 1.26) — 默认启用;GC 开销降低 10–40%。使用
GOEXPERIMENT=nogreenteagc退出(预计将在 Go 1.27 移除)。 - 堆基址随机化(Go 1.26) — 在 64 位平台上,分配的对象不再位于可预测的地址。使用
GOEXPERIMENT=norandomizedheapbase64退出。 - 按大小优化的小对象分配(Go 1.27) — 80 字节以下的分配最多可便宜 30%,代价是二进制大小增加约 60 KB。使用
GOEXPERIMENT=nosizespecializedmalloc退出(预计将在 Go 1.28 移除)。
HTTP:使用改进后的标准库路由器(Go 1.22)
LLM 往往会不假思索地推荐 gorilla/mux 或 chi 来处理任何不那么简单的路由。从 Go 1.22 开始,标准库 net/http 的 ServeMux 原生支持按方法和路径参数进行路由。
mux := http.NewServeMux()
// Method-scoped routes
mux.HandleFunc("GET /users", listUsers)
mux.HandleFunc("POST /users", createUser)
// Path parameters — accessed via r.PathValue
mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
// ...
})
// Wildcard
mux.HandleFunc("GET /files/{path...}", serveFile)
只有在需要命名路由生成或正则表达式约束时,才使用 chi 或 gorilla/mux——中间件并不足以成为引入框架的理由(见下文)。对于纯粹按方法和路径进行的路由,标准库已经足够。
生产服务器:始终设置超时
http.ListenAndServe(addr, mux) 没有任何超时设置——单个缓慢的客户端就能让连接永远保持打开状态(慢速 Loris 攻击)。LLM 生成的服务器几乎从不设置这些超时。生产服务器必须设置超时:
srv := &http.Server{
Addr: ":8080",
Handler: mux,
ReadHeaderTimeout: 5 * time.Second, // slow-loris protection
ReadTimeout: 10 * time.Second, // full request read
WriteTimeout: 30 * time.Second, // response write (covers handler time)
IdleTimeout: 120 * time.Second, // keep-alive connections
}
如果需要比 WriteTimeout 更精细的路由级控制,可使用 http.TimeoutHandler 或处理函数级别的 context.WithTimeout。出站调用也需要遵守同样的原则:http.DefaultClient 也没有超时设置——应创建一个带超时的客户端。
优雅关闭
每个生产服务器都需要一条关闭路径,以停止接受连接并处理完进行中的请求。模式如下:
func run(ctx context.Context) error {
ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
defer stop()
srv := &http.Server{ /* ... timeouts as above ... */ }
errCh := make(chan error, 1)
go func() { errCh <- srv.ListenAndServe() }()
select {
case err := <-errCh:
return err // ListenAndServe failed at startup
case <-ctx.Done():
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
return srv.Shutdown(shutdownCtx) // drain in-flight, then exit
}
}
Shutdown需要一个带有独立截止时间的新上下文——信号上下文已经被取消。- 长连接(SSE、websocket)必须监听
r.Context(),否则它们会让关闭过程一直等待,直到排空截止时间到达。
中间件只是一种函数
无需框架。中间件的类型是 func(http.Handler) http.Handler:
func withRequestLog(logger *slog.Logger, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
logger.Info("request", "method", r.Method, "path", r.URL.Path, "dur", time.Since(start))
})
}
// Compose by wrapping — outermost runs first
handler := withRequestLog(logger, withAuth(mux))
函数组合已经能做到的事,不要引入中间件框架。
语法:使用当前的 Go 语法
LLM 经常生成过时的语法。请熟悉当前的惯用写法:
对整数使用 Range(Go 1.22)
// Old
for i := 0; i < 10; i++ { ... }
// New
for i := range 10 { ... }
new(expr)(Go 1.26)
new 接受一个表达式,并返回指向新变量的指针,该变量保存表达式的值,可替代 v := expr; &v 这种写法:
// Old
v := yearsSince(born)
p := &v
// New (Go 1.26)
p := new(yearsSince(born))
典型用例是可选的结构体指针字段——参见上文的“配置和结构体设计”。
泛型方法(Go 1.27)
方法声明可以声明自己的类型参数,因此泛型辅助函数可以限定在某个类型上,而不是整个包。math/rand/v2 新增了方法形式 (*Rand) N[Int intType](Int) Int,同时保留现有的包级泛型 N:
import "math/rand/v2"
r := rand.New(rand.NewPCG(seed1, seed2))
n := r.N(100) // method form, Go 1.27
有两项硬性限制:
- 接口的方法不能声明类型参数。
- 泛型方法不能实现接口方法。
在 Go 1.27 之前,将辅助函数提升为包级泛型函数。
自引用类型参数约束(Go 1.26)
泛型类型现在可以在自己的类型参数列表中引用自身:
type Adder[A Adder[A]] interface {
Add(A) A
}
在 Go 1.26 之前,这种写法会被编译器拒绝。
结构体字面量键:任意字段选择器(Go 1.27)
结构体字面量中的键可以是该结构体类型的任何有效字段选择器,而不只是顶层字段名,因此可以直接指定提升字段:
type Base struct{ ID int }
type User struct {
Base
Name string
}
u := User{ID: 1, Name: "steve"} // Go 1.27 — Base.ID named directly
在 Go 1.27 之前,需显式初始化嵌入值:User{Base: Base{ID: 1}, Name: "steve"}。
广义函数类型推断(Go 1.27)
只要泛型函数被赋值给或转换为匹配的函数类型,函数类型推断就会在所有此类上下文中生效:
type Less func(a, b string) int
// Go 1.26 and earlier: "cannot use generic function cmp.Compare without instantiation"
var less Less = Less(cmp.Compare) // Go 1.27 infers T = string
在 Go 1.27 之前,需显式实例化:cmp.Compare[string]。
构建约束
// Old (deprecated — do not generate)
// +build linux darwin
// Current
//go:build linux || darwin
自 Go 1.17 起,必须使用 //go:build 形式。切勿生成旧式 // +build 语法。
使用 any 代替 interface{}
// Old
func Print(v interface{}) { ... }
// Current — `any` is a built-in alias for interface{} since Go 1.18
func Print(v any) { ... }
go.mod 中的工具依赖(Go 1.24)
切勿生成包含空白导入的 tools.go 文件。使用 tool 指令跟踪开发工具:
go get -tool golang.org/x/tools/cmd/stringer
go tool stringer -type=Color # run it
使用 log/slog 进行结构化日志记录(Go 1.21)
上面的技能说明介绍了基本设置。以下是 LLM 最常出错的用法:
// Pass a logger via context for request-scoped logging
func HandleRequest(ctx context.Context, r *Request) {
logger := slog.With("request_id", r.ID, "user_id", r.UserID)
// All subsequent log calls carry these fields automatically
logger.Info("handling request")
process(ctx, logger, r)
}
// Group related fields
logger.With(slog.Group("http",
slog.String("method", r.Method),
slog.String("path", r.URL.Path),
slog.Int("status", status),
))
// Log at the right level — LLMs over-use Info
logger.Debug("cache miss", "key", key) // internal state, high volume
logger.Info("server started", "addr", addr) // lifecycle events
logger.Warn("retrying", "attempt", n) // recoverable problems
logger.Error("request failed", "err", err) // needs attention
- 切勿在
main之外使用包级log或slog全局变量。将*slog.Logger作为依赖传递。 - 切勿记录错误后再返回错误。在边界处记录日志,并沿调用栈返回错误。
- 仅在
main中,或在未提供日志记录器的库中,将slog.Default()用作回退方案。
实验性包:不可用于生产环境
这些 API 通过 GOEXPERIMENT 构建时标志启用,且明确不稳定。可以用它们进行原型开发,或为未来版本做准备,但切勿将它们用于生产代码或供他人依赖的库。
simd(Go 1.27) — 通过GOEXPERIMENT=simd启用的可移植、与向量大小无关的 SIMD,适用于所有架构。只需编写一次向量代码,编译器会将其映射到相应的硬件宽度。simd/archsimd(Go 1.26;Go 1.27 扩展) — 通过GOEXPERIMENT=simd启用的架构专用 SIMD。Go 1.26 首先为 amd64 引入;Go 1.27 将其扩展到 arm64 Neon 和 WebAssembly 128 位 SIMD。它有意不具备可移植性。runtime/secret(Go 1.26) — 通过GOEXPERIMENT=runtimesecret启用,仅适用于 amd64 和 arm64 Linux。自 Go 1.27 起,在机密模式下创建的 goroutine 也会在机密模式下执行。
调试:Go 工具链不是问题所在
Go 工具极其可靠。它几乎从来不是 Bug 的根源。
调试时,不要浪费时间怀疑 go run、go build、go test 或构建缓存。Go 工具链会按其说明行事:
go run始终会从源代码重新编译,不会使用过时的缓存二进制文件。go build是确定且正确的。go test会运行实际编译出的测试二进制文件。- 构建缓存以源代码内容为键——如果源代码发生变化,缓存会自动失效。
如果修改代码后错误仍然存在,原因可能是以下之一(按可能性从高到低排列):
- 修改没有修复底层逻辑错误。
- 修改发生在错误的文件、函数或包中。
- 还有另一个存在相同 Bug 的调用点未更新。
- 错误来自与正在修改的代码路径不同的路径。
与其归咎于工具,不如这样做:
- 仔细重读错误消息。Go 的错误消息准确无误。
- 确认你修改的文件确实是正在编译的文件(
go list -f '{{.GoFiles}}' .)。 - 在确切的位置添加
fmt.Println或t.Log,确认执行流程能到达该处。 - 检查已更改函数的所有调用点是否都已更新。
在穷尽所有代码层面的解释之前,不要建议清除构建缓存(go clean -cache)、重启 Go 工具链或进行其他工具层面的干预。工具不会骗你。