mcpField
G
go

go

github.com/spf13开发695245 次安装2 周前更新
代码仓库

关于 go

go 技能为编写、审查和重构 Go 程序提供惯用模式与实践,面向 Go 开发者。它强调依据模块版本选择标准库功能,并以清晰、简洁和可靠为原则。

主要功能

  1. 01Go 版本与功能门槛核对
  2. 02惯用代码与错误处理建议
  3. 03包与 API 设计指导
  4. 04标准库及依赖选择建议
  5. 05并发与测试实践

适用场景

  1. 01编写新的 Go 代码
  2. 02审查或重构现有 Go 项目
  3. 03设计 Go 包、模块或 API
  4. 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 项目结构、错误处理、并发或测试的问题

版本门槛:先检查模块的最低版本

每项受版本限制的功能都有一个最低版本要求:模块的包必须能够在引入该功能的版本上编译。推荐此类功能之前,先确定最低版本,并且只推荐该版本支持的功能。

  1. 阅读模块 go.mod 中的 go 指令。该版本是模块自身包的最低版本——声明 go 1.21 的模块必须仍能在 Go 1.21 上编译,无论你当前使用的是什么工具链。
  2. 检查文件的构建约束。由 //go:build go1.24 限定的文件可以使用 Go 1.24 的功能,即使模块的 go 指令指定了更低的版本。
  3. 如果最低版本未知,请阅读 go.mod 或询问。切勿猜测,也不要假设读者使用的是最新版本。

反过来的错误同样存在:如果模块的最低版本已经支持某种惯用写法,就不要指出它不可用;也绝不要仅仅为了使用便捷辅助函数而提高模块的 go 指令版本——go 指令设定的最低版本会约束该模块的每一个依赖方。

版本 → 功能索引

每项功能一行,查阅此处即可确认可用性,无需猜测。

起始版本功能
1.17//go:build 约束,必须用它替代 // +build 行
1.18类型参数(泛型)
1.18any 作为 interface{} 的别名
1.19sync/atomic 类型化值(atomic.Int64、atomic.Pointer[T])
1.20errors.Join
1.21slices 包
1.21maps 包
1.21cmp 包(cmp.Compare)
1.21min、max、clear 内置函数
1.21log/slog
1.21context.WithoutCancel
1.22math/rand/v2
1.22net/http ServeMux 方法和通配符模式
1.22for i := range n(遍历整数)
1.22每次迭代独立的循环变量
1.22slices.Concat
1.22cmp.Or
1.23遍历函数迭代器(iter.Seq、iter.Seq2)
1.23作为迭代器的 maps.Keys / maps.Values
1.23slices.Collect / slices.Values / slices.Sorted
1.24encoding/json 的 omitzero
1.24go.mod 中的 tool 指令
1.24泛型类型别名
1.24t.Context()、b.Loop()、t.Chdir()
1.25testing/synctest(synctest.Test)
1.25sync.WaitGroup.Go
1.26new(expr)
1.26自引用类型参数约束
1.26errors.AsType[T]
1.26log/slog.NewMultiHandler
1.26reflect 迭代器(Type.Fields、Type.Methods、Type.Ins、Type.Outs、Value.Fields、Value.Methods)
1.26搭配 go test -artifacts 使用的 t.ArtifactDir() / b.ArtifactDir() / f.ArtifactDir()
1.26B.Loop 不再阻止内联
1.26testing/cryptotest.SetGlobalRandom
1.26默认启用 Green Tea GC
1.2664 位平台上的堆基址随机化
1.26runtime/pprof 的 goroutineleak 配置文件(实验:GOEXPERIMENT=goroutineleakprofile)
1.26simd/archsimd(实验:GOEXPERIMENT=simd)
1.26runtime/secret(实验:GOEXPERIMENT=runtimesecret)
1.27泛型方法
1.27结构体字面量键可以是任意字段选择器
1.27广义函数类型推断
1.27go test 默认启用 stdversion vet 检查
1.27uuid 包
1.27strings.CutLast / bytes.CutLast
1.27net/url 的 URL.Clone / Values.Clone
1.27math/rand/v2 的 (*Rand).N
1.27testing/synctest.Sleep
1.27net/http/httptest.NewTestServer
1.27runtime/pprof 的 goroutineleak 配置文件正式可用
1.27time 计时器和节拍器始终为无缓冲;移除 asynctimerchan
1.27按大小优化的小型分配(小于 80 字节)
1.27encoding/json 基于 encoding/json/v2 实现
1.27simd(实验:GOEXPERIMENT=simd)
1.27arm64 和 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 块。函数的“正常路径”绝不应有缩进。

少写代码:必要性阶梯

写代码前,先在第一个符合条件的阶梯处停下。理解问题后再往上攀登——先阅读变更涉及的代码,并追踪实际流程。如果两个阶梯都适用,就选较高的那个,然后继续。

  1. 这有必要存在吗?“以后可能会用到”不算必要。跳过它,并用一句话说明原因。
  2. **代码库里已经有了吗?**动笔前先搜索——这个辅助函数、类型或模式可能已经存在于另一个包中。重复实现已有功能是这种错误最常见的形式。
  3. **标准库能做到吗?**在 Go 中,这一阶梯比其他任何阶梯都更常用:用 net/http 的 ServeMux 代替路由器,用 slog 代替日志模块,用 slices/maps/cmp 代替手写循环,用 errors.Join 代替 multierr,用 math/rand/v2 代替随机数生成器辅助函数。参见 标准库:使用新的包。
  4. **工具链或平台已经能做到吗?**这些功能免费且已安装:用 //go:embed 代替资源处理流水线或加载器,用 os.Root 代替手写路径约束,用 sync.OnceValue 代替延迟初始化辅助函数,用 tool 指令代替脚本,用构建标签代替 Shell 包装器,用 CHECK 约束或唯一索引代替应用代码中的重复实现。在服务的模板中,原生输入控件胜过组件库及其打包产物。
  5. 它已经在 go.mod 中了吗?使用你已经引入的依赖。能用几行代码或现有导入实现的功能,绝不要新增模块——但这并不意味着只能使用标准库:golang.org/x/sync/errgroup、afero 和 go-cmp 都是已安装的依赖,也是本技能推荐使用的工具(参见 并发模式和测试模式),不必手写实现。
  6. **能写成一行吗?**就写一行。
  7. **然后才考虑:**实现可行方案所需的最少代码——不添加额外文件、层次或可调参数。只有一个实现的接口、只生产一种产品的工厂,以及值永不变化的配置字段,都是第 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. 拆分上帝对象

按以下步骤进行机械式拆分:

  1. 将状态和保护它的互斥锁一起移走——字段与其锁分离会引入新缺陷,而不是完成重构。
  2. 移走会操作该状态的方法,不要移走其他内容。
  3. 保持字段未导出。构造函数(NewX)是边界:它接收包所需的内容,返回该类型,并隐藏互斥锁。
  4. 由 main 构造它,并交给需要它的对象。新包不应知道谁会调用它;如果它需要回调,说明边界划错了。
  5. 接口由消费者定义(参见接口设计)——新包不应定义接口来描述自己的调用方。

测试应随状态一起迁移。如果新包仍无法独立测试,说明拆分尚未完成。

接口设计

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 会运行实际编译出的测试二进制文件。
  • 构建缓存以源代码内容为键——如果源代码发生变化,缓存会自动失效。

如果修改代码后错误仍然存在,原因可能是以下之一(按可能性从高到低排列):

  1. 修改没有修复底层逻辑错误。
  2. 修改发生在错误的文件、函数或包中。
  3. 还有另一个存在相同 Bug 的调用点未更新。
  4. 错误来自与正在修改的代码路径不同的路径。

与其归咎于工具,不如这样做:

  • 仔细重读错误消息。Go 的错误消息准确无误。
  • 确认你修改的文件确实是正在编译的文件(go list -f '{{.GoFiles}}' .)。
  • 在确切的位置添加 fmt.Println 或 t.Log,确认执行流程能到达该处。
  • 检查已更改函数的所有调用点是否都已更新。

在穷尽所有代码层面的解释之前,不要建议清除构建缓存(go clean -cache)、重启 Go 工具链或进行其他工具层面的干预。工具不会骗你。

评论

支持 Markdown