Go 语言全面速查手册:语法、标准库、并发与工具链

速查总览:这一页怎么查

本页是 Go 的速查手册,按问题翻而不是从头读:每章第一张卡把该章的语法 / API / 参数一次列全,后面的卡再展开语义与坑。这一章给三张索引——章节地图、语言定位与选型对照、版本特性年表。

全页十九章,按「语法 → 并发 → 工程 → 运行时」排列。不必顺读:先在下表按关键词定位,再翻该章第一张速查表。

章节地图

章查什么关键词
01装环境、go 子命令、编译报错与 panic 反查go run · go build · declared and not used
02类型、声明、控制流、切片 / map / 指针 / 字符串:= · make · append · rune · iota
03函数签名的全部形式、方法接收者、defer 规则多返回值 · variadic · 闭包 · defer
04结构体、嵌入、结构体标签、接口与类型断言struct tag · embedding · type switch
05errors 包全表、%w 包装链、panic / recovererrors.Is · As · Join · recover
06channel 的读写真值表、select、Pipeline / Fan-inchan · close · select · nil channel
07sync 与 context 全表、并发模式、内存模型Mutex · Once · WaitGroup · WithCancel
08泛型语法、约束全表、slices / maps / cmp、迭代器[T any] · comparable · iter.Seq
09go.mod 指令、go mod 子命令、可见性与目录约定require · replace · internal · init()
10深水区:标准库那些查文档查不到的契约2006-01-02 · omitempty · 排序稳定性
11标准库按包的 API 速查(net/http / fmt / strings …)ServeMux · Sprintf · slog
12testing API 与 go test 参数全表、基准、模糊测试t.Run · -race · -bench · FuzzXxx
13Gin 的路由 / 绑定 / 中间件 API 与 binding 标签全表gin.Context · ShouldBindJSON
14reflect 的 Kind 全表与可设置性规则TypeOf · ValueOf · StructTag
15GMP、逃逸、GC,以及 GODEBUG / GOGC 等调优参数全表GOMAXPROCS · GOMEMLIMIT · pprof
16go:embed、构建约束、交叉编译矩阵、cgo、database/sql//go:build · GOOS · CGO_ENABLED
17TUI 与 GUI 库对照,含二进制体积Bubble Tea · Fyne · Wails
18版本年表、提案流程、生态选型与资料索引Go 1.x · pkg.go.dev · 代理

三条最常见的查法

你手上有的去哪
一条编译报错或 panic 原文01 章两张反查表,按报错原文找病因
一个函数名,想知道怎么调11 章按包速查;语义上的坑在 10 章
一个参数 / 环境变量,想知道干什么15 章运行时参数表、16 章构建变量表
查表时注意同一件事往往有新旧两套 API:排序有 sort 与 slices、随机数有 math/rand 与 math/rand/v2、错误包装前后有 fmt.Errorf 与 errors.Join。搜索引擎给的答案大多是旧的那套,本页凡有新旧之分都并排列出,认准标了版本的那一行。
本页以 Go 1.25 为基准,凡是标了版本的特性都写明「Go 1.x 起」。官方的 Go 1 兼容性承诺意味着这些表里的老 API 不会消失,新版本只做加法——所以查到的写法,旧项目里照样能用。

Go 的立身之本是极致的简单性:语言特性刻意做减法(「少即是多」),换来大团队里人人读得懂彼此的代码、秒级编译,以及单个静态二进制的部署体验。

今天它统治哪些领域

  • 云原生基础设施几乎是 Go 的天下:Docker、Kubernetes、etcd、Prometheus、Terraform 全是 Go 写的;
  • 微服务与网络中间件:Google、Uber、Cloudflare、字节跳动都在大规模使用;
  • CLI 工具:交叉编译一条命令出各平台单文件,没有运行时依赖。

和邻居怎么选

  • vs Rust:Rust 性能上限更高但曲线陡峭,常规服务端业务 Go 的开发效率更划算;
  • vs Java:Go 启动快、内存小、单二进制部署,Java 胜在三十年生态存量;
  • 什么时候别选:要极致表达力、要做数据科学与 AI、要写 GUI。
// Go 的气质一屏看完:并发是语言原语
// ⚠️ 这是「预览」——现在一行看不懂都正常,06 章会从头讲
package main

import "fmt"

func fetch(u string, ch chan string) {
  ch <- "fetched " + u   // 把结果送进 channel
}

func main() {
  urls := []string{"a.com", "b.com", "c.com"}
  ch := make(chan string)

  for _, u := range urls {
    go fetch(u, ch)      // go 一个关键字就开启并发
  }
  for range urls {
    fmt.Println(<-ch)    // 从 channel 收结果
  }
}
网上大量教程还停在 GOPATH 时代,照抄最典型的错误是拿 go get 装命令行工具——在模块目录里那么做只会把一串包写进 go.mod 的 // indirect 块。装工具用 go install pkg@version。
本页以 Go 1.22+ 为基准;官方承诺 Go 1 兼容性,十年前的代码今天照样编译。别把别的语言的模式(类继承、异常、重度抽象)搬进来,练熟三板斧:goroutine + channel、组合代替继承、显式 if err != nil。

Go 一年两版(2 月与 8 月),每版只做加法。下表按版本查特性能不能用,写 go.mod 的 go 指令时对照它。

版本特性年表

版本标志性特性本页哪章
1.18泛型、模糊测试 FuzzXxx、工作区 go work08 · 12 · 09
1.19GOMEMLIMIT 软内存上限、atomic 的类型化原子变量、文档注释新语法15 · 07
1.20errors.Join 多错误包装、切片转数组05
1.21slices / maps / cmp 包、log/slog、min / max / clear 内建、工具链自动下载08 · 11
1.22循环变量每轮新建、for i := range 10、ServeMux 支持方法与路径参数、math/rand/v202 · 06 · 11
1.23range over func 迭代器与 iter.Seq、unique 包、遥测08
1.24泛型类型别名、tool 指令管理工具依赖、os.Root、weak 包09 · 12
1.25testing/synctest 转正、容器感知的 GOMAXPROCS、实验性绿茶 GC12 · 15

三个版本号,别搞混

写在哪含义写错的后果
go 1.25.0(go.mod)语言版本:允许用到哪一版的语法与库写低了,新语法报 requires go1.x or later
toolchain go1.25.1构建用的工具链版本,不够新会自动下载离线环境要设 GOTOOLCHAIN=local
go version 输出本机装的那一个与上面两个可以不同,且常常不同
别把 go get -u 当成「升级 Go」——它升的是依赖,不是工具链。升 Go 用 go install golang.org/dl/go1.25@latest 或直接换官方包;把模块的语言版本抬高用 go mod edit -go=1.25。另外 go get 自 1.18 起就不再构建与安装命令行工具(1.16 起已弃用这种用法),装工具一律 go install pkg@version。
go.mod 里的 go 指令不是「最低要求」那么简单,它同时决定语言语义:写 go 1.21 的模块即使用 Go 1.25 编译,循环变量仍按旧的每循环共享语义走。想吃到 1.22 的循环变量修复,必须把这一行抬上去。

起步 · 命令 · 报错速查

三张表解决「跑不起来」:命令表(装好 Go、go 的全部子命令、最小程序骨架逐行含义)、编译报错反查表与运行期 panic 反查表——两张都按报错原文查病因与修法,外加一张调试手段表。

Go 的工具链全在一个 go 命令里:编译、测试、格式化、依赖、性能分析都不用额外装东西。下表把日常会敲到的全列出来。

装好 Go:三步

步骤做什么备注
1到 go.dev/dl 下官方包安装Linux 用官方 tarball,发行版仓库常落后好几个版本
2go version 验证输出形如 go version go1.25.1 windows/amd64
3go mod init hello → 写 main.go → go run .代码必须待在模块里,第一天就建模块

go 子命令全表

命令作用常用参数
go run .编译并运行当前目录的包,不留二进制-race、-tags
go build编译出二进制-o name、-ldflags、-tags、-gcflags
go install pkg@version装命令行工具到 GOBIN版本可写 @latest
go test ./...跑测试-run、-v、-race、-cover、-bench
go fmt ./...格式化(包装 gofmt -l -w)无可配置项,这是特性
go vet ./...静态检查:Printf 动词、锁拷贝、无用赋值go test 会自动跑一部分
go mod init/tidy/download建模块 / 补齐并清理依赖 / 预下载tidy 最常用
go mod why -m pkg这个依赖是谁引进来的配合 go mod graph
go get pkg@v1.2.3增删改依赖版本(不再负责装工具)-u 升级、@none 移除
go work init/use多模块工作区(1.18+)本地联调多仓库
go generate ./...执行源码里的 //go:generate 指令它不会被 build 自动触发
go doc pkg.Sym终端查文档-all 看全部
go env / go env -w K=V看 / 持久化写环境变量GOPROXY、GOPRIVATE、GOBIN
go list -m all列出全部依赖模块-json 给机器读
go tool pprof / trace性能剖析与执行轨迹见 15 章
go clean -cache -modcache清构建缓存 / 模块缓存怀疑缓存坏了再用

最小程序骨架:四行各管一件事

写法规则
package main每个 .go 文件第一行;只有 main 包能编译成可执行程序
import "fmt"多个包写成括号块,由 gofmt 自动排序分组
func main() {入口,不收参数不返回值;参数取 os.Args,退出码用 os.Exit
x := 42短变量声明,等价 var x int = 42;只能在函数内用
// main.go —— 最小可运行程序
package main        // ① 本文件属于 main 包 = 可执行程序

import "fmt"        // ② 引入标准库的 fmt 包

func main() {        // ③ 入口,{ 必须在这一行(见下卡分号规则)
  msg := "Hello, Go!"   // ④ 短变量声明,类型推断为 string
  fmt.Println(msg)
}

// ---- 终端里依次执行 ----
// mkdir hello && cd hello
// go mod init hello   → 生成 go.mod
// go run .            → Hello, Go!
// go build -o hello   → 产出单个静态二进制
go install 装完却「找不到命令」,是新环境上最常见的一次卡壳:二进制落在 go env GOBIN 指的目录(没设就是 $(go env GOPATH)/bin),而这个目录默认不在 PATH 里——把它加进 PATH 是装好 Go 之后的第四步。另外两个位置也常搞混:go build 的产物落在当前目录,go run 的产物在临时目录里跑完即删,CI 里想要产物必须用 go build -o。
go run .(跑当前目录整个包)和 go run main.go(只跑这一个文件)不一样:程序拆成多文件后,后者会因「找不到另一个文件里的函数」报 undefined——日常一律 go run .。另外 Go 没有 Debug / Release 模式,go build 默认就是优化过的;交叉编译只要 GOOS=linux GOARCH=amd64 go build。

Go 的报错措辞高度固定,几乎可以当查询键用。下表左列是报错原文(省略 文件:行:列 前缀),照着找即可。

语法与类型

报错原文原因修法
declared and not used: x声明了变量却没用;这是错误不是警告删掉,或先补上真正要写的那行逻辑
"os" imported and not usedimport 了没用删掉;只为副作用引入写 _ "pkg"
undefined: foo拼错、忘了 import、或首字母小写没导出改大写导出,或补 import
cannot use x (variable of type int) as int64 valueGo 没有任何隐式类型转换显式写 int64(x)
invalid operation: a + b (mismatched types int and float64)同上,运算两侧类型必须一致float64(a) + b
syntax error: unexpected semicolon or newline before {左花括号换行写了——Go 在行尾自动插分号{ 必须跟在同一行末尾
assignment mismatch: 2 variables but f returns 1 value返回值个数对不上照函数签名接,多余的用 _
missing return有返回值的函数存在走不到 return 的路径末尾补 return 或 panic
non-declaration statement outside function body在函数外写了语句(如 x := 1)包级只能 var / const / func / type
X does not implement Y (method M has pointer receiver)方法定义在 *T 上,你传了 T传 &v,或把接收者改成值
cannot use nil as T value只有指针、切片、map、chan、func、接口能为 nil用零值或改成指针类型
initialization cycle / import cycle not allowed包级变量或包之间循环依赖抽出公共包,或把初始化挪进 init()
cannot infer T泛型类型参数推不出来显式写 f[int](…)(见 08 章)

模块与构建

报错原文原因修法
go.mod file not found in current directory or any parent不在模块里go mod init 模块名
no required module provides package X; to add it: go get X依赖没记进 go.modgo mod tidy(照抄它给的命令也行)
missing go.sum entry; to add it: go mod download校验和缺失go mod tidy
module X@latest found, but does not contain package X/y子包路径写错或已迁移去 pkg.go.dev 核对导入路径
ambiguous import: found package in multiple modules同一包被两个模块提供(常见于 v2 迁移)go mod why 定位,用 replace 或升级
build constraints exclude all Go files//go:build 或文件名后缀把文件全排除了核对 GOOS / GOARCH / -tags(16 章)
requires go >= 1.25 (running go 1.22)依赖要求更新的语言版本升工具链,或 go mod edit -go=1.25
verifying module: checksum mismatch校验和与 go.sum 不符私有库设 GOPRIVATE;公共库别急着删 go.sum
别用 _ = x 来「消灭」declared and not used 然后忘掉它——那等于把 Go 特意为你设的检查关掉。它几乎总是真 bug 的信号:算了值忘了用、复制粘贴留了半截逻辑、或者本该赋给已有变量却用 := 新建了同名变量(变量遮蔽,最隐蔽的一种)。看到它先想「我是不是漏写了一行」。
Go 的报错都带 文件:行:列,编辑器里点一下就跳过去。遇到不认识的报错,直接把那一行原文丢进搜索引擎——措辞固定,命中率很高。编译器一次只报前十条,先修最上面那条,下面的经常是它的连锁反应。

编译过了之后出的错都在这张表里。Go 的 panic 自带完整调用栈,从上往下找第一个属于你自己代码的路径,那才是出事点。

panic / fatal error 反查

原文原因修法
invalid memory address or nil pointer dereference解引用 nil 指针;常见于忽略了 err 继续用返回值先判 err != nil 再用;结构体指针字段记得初始化
index out of range [3] with length 3下标越界(下标从 0 起)循环用 range;边界用 len() 判
slice bounds out of range [:5] with capacity 3切片表达式超过容量切之前比 len / cap
assignment to entry in nil mapmap 声明了没 makem := map[string]int{} 或 make(map[...]...)
integer divide by zero整数除零(浮点除零不 panic,给 +Inf)除前判零
interface conversion: interface {} is string, not int类型断言断错了用双返回值形式 v, ok := x.(int)
send on closed channel / close of closed channel关闭的 channel 上还在写 / 重复 close谁发送谁关闭,且只关一次(06 章)
fatal error: all goroutines are asleep - deadlock!全部 goroutine 都阻塞:无缓冲 channel 单边读写、忘了 Done检查 channel 配对与 WaitGroup 计数
fatal error: concurrent map writes多 goroutine 同时写同一个 map加锁或换 sync.Map;这是 fatal,recover 不住
sync: negative WaitGroup counterDone 比 Add 多Add 写在 go 之前,Done 用 defer
sync: unlock of unlocked mutex重复解锁,或锁被值拷贝了锁一律用指针传;go vet 能查出拷贝
fatal error: stack overflow无限递归(含 String() 里 %v 打自己)递归加终止条件;String() 里转成别名类型再打
signal: killed / out of memory被 OOM killer 干掉设 GOMEMLIMIT,查泄漏(15 章)
panic: test timed out after 10m0s测试卡死,栈里能看到卡在哪加 context 超时;并发测试见 12 章

调试手段

手段用途
go run -race . / go test -race数据竞争检测器,几乎是查并发 bug 的唯一有效手段
GOTRACEBACK=all / =crashpanic 时打印全部 goroutine 栈 / 再产出 core dump
dlv debug / dlv test / dlv attach pidDelve 调试器:断点、单步、看 goroutine
GODEBUG=gctrace=1,schedtrace=1000GC 与调度器实时轨迹(15 章有全表)
go build -gcflags="-m"逃逸分析:哪些变量被挪到堆上
kill -QUIT pid让正在跑的进程打出全部 goroutine 栈,查死锁与泄漏
fatal error: 开头的不是 panic,recover 拦不住——并发读写 map、死锁、栈溢出、OOM 都属于这一类,运行时会直接终止进程。看到 fatal error 就别再找哪里漏了 recover,问题一定在数据结构的并发访问或资源上。
recover() 只在 defer 的函数里直接调用才有效,而且只救得了同一个 goroutine:子 goroutine 里 panic 会直接带走整个进程。所以给每个自己起的 goroutine 配一个 defer func(){ if r := recover(); r != nil { … } }() 是服务端的常规做法(05 章)。

基础语法与类型

四张表把语言地基一次列全:基本类型与零值、声明与赋值的全部形式、控制流与 range 的六种目标、内建函数全表。后面几张卡展开切片扩容、map 的取值约定、指针与 rune 这些容易出错的地方。

Go 的语法面很小:25 个关键字、一个循环关键字、十几个内建函数。这一张卡就是那张全表。

基本类型与零值

类型说明零值
bool不能和数字互转false
int / uint跟平台走,64 位机上是 64 位;日常整数一律用 int0
int8/16/32/64、uint8/16/32/64宽度确定;溢出是回绕,不报错0
byte = uint8,rune = int32别名;rune 存一个 Unicode 码点0
float32 / float64默认 float64;== 比的是精确值,算出来的结果别拿它判等0
string不可变字节序列,len 给字节数""
[N]T 数组长度是类型的一部分,赋值即拷贝各元素零值
[]T 切片、map[K]V、chan T引用型;nil 切片能直接 append / range,nil map 只能读,写要先 make,nil chan 收发永久阻塞nil
*T、func、interface指针 / 函数值 / 接口nil
struct值类型,字段全可比较时整体可比较各字段零值

零值即可用:var buf bytes.Buffer、var mu sync.Mutex、var wg sync.WaitGroup 都不用初始化。

声明与赋值的全部形式

写法含义能用在哪
var x int声明并取零值包级 / 函数内
var x = 42推断类型包级 / 函数内
x := 42短声明仅函数内
var ( a int; b string )成组声明包级 / 函数内
a, b = b, a多重赋值,右侧先整体求值函数内
a, err := f()多返回值;只要有一个新变量就允许用 :=函数内
const Pi = 3.14常量,无类型常量能自动适配包级 / 函数内
const ( A = iota; B; C )枚举:0、1、2;可写 1 << iota 造位标志const 块内
T(x)唯一的类型转换形式,没有隐式转换处处
_ = x空白标识符,丢弃值处处

控制流:一个 for 顶三个

写法等价于
for i := 0; i < n; i++ {}C 风格三段式
for cond {}while
for {}死循环,配 break / return
for i := range 10 {}Go 1.22 起:循环 10 次
for i, v := range s切片 / 数组:下标 + 元素副本
for k, v := range mmap:顺序随机,刻意为之
for i, r := range str字符串:字节下标 + rune
for v := range chchannel:一直收到 close
for v := range seqGo 1.23 起:迭代器函数(08 章)
if v, err := f(); err != nil {}带初始化的 if,变量作用域仅在 if / else 内
switch { case x > 3: }无表达式 switch = 多路 if;自动 break,要穿透写 fallthrough
switch v := x.(type) {}类型 switch(04 章)
outer: for { break outer }标签跳出多层循环

内建函数全表

函数作用备注
len / cap长度 / 容量字符串的 len 是字节数
make(T, len, cap)建切片 / map / chan只有这三种
new(T)分配零值并返回 *T日常更常用 &T{}
append(s, v...)追加,必须接收返回值容量不够时换底层数组
copy(dst, src)拷贝,返回实际拷贝数取两者较短的长度
delete(m, k)删 map 键键不存在也安全
clear(x)Go 1.21:清空 map / 切片置零—
min(...) / max(...)Go 1.21:任意个有序值编译期内建,非函数调用
close(ch)关闭 channel只能关一次(06 章)
panic / recover抛出 / 捕获见 05 章
complex/real/imag复数科学计算才用
:= 的遮蔽是全页最贵的坑:在 if / for 里用 := 给外层已有的变量「赋值」,实际是新建了一个同名变量,外层那个原封不动。典型受害者是 err 与命名返回值。go vet -vettool 系的 shadow 检查或 golangci-lint 能查出来,日常写的时候也要留心:内层想改外层变量,用 = 不用 :=。
无类型常量是 Go 里少见的「宽松」之处:const x = 1 能直接赋给 int、float64、byte 等任意数值类型,因为它在使用点才定类型,且精度至少 256 位。所以 const big = 1 << 100 合法,只要你不把它赋给放不下的变量。

Go 用「零值」取代了 null/undefined 这一整类问题:任何变量声明出来就有确定的值,读到的永远不是垃圾。

四个关键词各管一件事

var 显式声明,函数内可用 := 短声明(类型推断,仅限函数内)。未初始化变量有零值(int→0、string→""、bool→false、指针/slice/map/chan→nil),不存在 undefined。跨类型必须显式转换 T(x)。常量在编译期确定,iota 用于生成自增枚举。

var name string = "Alice"
count := 0                 // 推断为 int
var price, qty = 9.99, 3  // 多变量

// 零值:声明即可用,无需初始化
var i int      // 0
var s string   // ""
var p *int     // nil

// 必须显式转换,无隐式
var x int = 10
var y float64 = float64(x)

// 常量 + iota 枚举(自增,从 0 开始)
const (
  StatusPending  = iota  // 0
  StatusActive            // 1
  StatusClosed            // 2
)

// 未使用的变量会编译报错,用 _ 丢弃
_, err := fmt.Println("hi")
:= 在内层作用域会遮蔽(shadow)外层同名变量。最常见的是在 if/for 里写 err := 新建了一个局部 err,外层那个永远是 nil——错误被悄悄吞掉。内层想复用外层变量时用 = 而非 :=。
iota 生成的枚举默认打印成数字;想让它输出可读名字(如 "Active" 而非 1),给类型实现 String() 方法即可——见 04 章「Stringer:让类型自带可读输出」。

三个关键字覆盖全部控制流,而且 for 一个顶别的语言四个——Go 在这里做的减法比加法多。

三个关键字,四种循环形态

条件不加括号,但 {} 必须有。if/switch 支持初始化语句,变量作用域限于该块。for 是 Go 唯一的循环关键字,覆盖经典三段式、while、无限循环、range 四种形态(Go 1.22+ 还能 range 整数)。switch 默认每个 case 自动 break,要贯穿用 fallthrough。

// if 带初始化,val/ok 仅在 if-else 内有效
if val, ok := myMap["key"]; ok {
  fmt.Println(val)
}

for i := 0; i < 10; i++ { }   // 三段式
for count < 10 { count++ }      // 相当于 while
for { if done { break } }      // 无限循环

for i, v := range nums { }        // range 切片:索引,值
for k, v := range myMap { }       // range map:顺序随机!
for i := range 5 { }              // Go1.22+:range 整数 0..4

// switch 自动 break,一个 case 可多值
switch day {
case 6, 7: fmt.Println("周末")
default: fmt.Println("工作日")
}

// 无表达式 switch:替代 if-else 链
switch {
case score >= 90: grade = "A"
case score >= 80: grade = "B"
default:           grade = "C"
}
switch(以及 select)里的 break 只跳出 switch,跳不出外层 for。for i := 0; i < 5; i++ { switch { case i == 2: break }; count++ } 跑完 count 仍然是 5——你以为 i==2 时提前退出了,其实一次都没退出。要跳出循环必须给循环打标签:Loop: for ... { switch { case x: break Loop } }。
另一个高频误会:for _, v := range items 拿到的 v 是元素副本,对结构体切片改 v.Qty = 99 原切片纹丝不动,要改得走 items[i]。
无表达式的 switch 等价于 switch true,是写多条件分支最干净的方式,比一长串 if-else if 可读性高得多。

切片是 Go 里最容易「用对但说不清」的东西——记住它的三字段真身,共享、分裂、扩容三类行为就都能推出来。

从切片头往下推

探源:切片的一切行为,先记住它的真身——切片不是数组,而是一个 {指向底层数组的指针, 长度 len, 容量 cap} 的三字段小结构(「切片头」)。从这个根往下推:① 传参传的是切片头的拷贝,但指针仍指向同一底层数组——所以在函数里改元素会影响原切片;② append 在 cap 够时原地写、返回指向同一数组的新头(会串改别的切片),cap 不够时另分配一块更大的数组再拷贝、返回指向新数组的头(从此两不相干)——这就是「有时共享、有时分裂」的来历;③ s[i:j] 只是新造一个头指向原数组的一段,零拷贝、共享内存。数组则相反:定长、长度是类型的一部分、赋值即整份拷贝。make([]T, len, cap) 预分配容量;Map 是哈希表,key 必须可比较,用 comma-ok 安全读取。

数组与切片:几乎处处相反

数组 [N]T切片 []T
长度是类型的一部分([3]int 与 [4]int 是两个类型)运行时可变
赋值 / 传参整份拷贝拷贝的是三字段的头,底层数组共享
能否 append不能能(可能原地写,也可能另分配)
零值各元素的零值nil,但 len/append 照常可用
日常使用频率很低极高
  • 「数组传参会整份拷贝」这条决定了它在 Go 里几乎只出现在两个地方:固定长度的缓冲区,以及作为 map 的键(切片不可比较,不能当键);
  • 而 nil 切片可以直接 append、可以 len、可以 range——所以「先判空再初始化」的写法在 Go 里多数时候是多余的。
nums := []int{1, 2, 3}
nums = append(nums, 4, 5)
buf := make([]int, 0, 10)   // len=0, cap=10
sub := nums[1:3]               // [start:end),共享底层数组

dst := make([]int, len(sub))
copy(dst, sub)                  // 需要独立副本时显式 copy

ages := map[string]int{"Alice": 28}
ages["Bob"] = 25             // 增 / 改
delete(ages, "Bob")            // 删

// comma-ok:区分"不存在"和"值为零值"
if age, ok := ages["Dave"]; ok {
  fmt.Println(age)
}
两个高频坑:① 向 nil map 写入会 panic(var m map[string]int; m["x"]=1 直接崩;读 nil map 则安全返回零值)——map 必须先 make 或字面量初始化。② 切片传参共享底层数组,改元素会影响原切片,但 append 触发扩容后又变成两份——需要明确隔离时用 copy。
把切片交给别人(当返回值、传参、存进结构体)之前,用三索引切片 s[a:b:b] 把 cap 卡死到 len,避免对方 append 时踩到你的数据。s := []int{1,2,3,4,5} 时 s[1:3] 的 cap 是 4,对它 append 会就地把 s[3] 覆盖成新值;写成 s[1:3:3] 后 cap 变 2,append 必然另开数组,原切片完全不受影响。
另一条:能预估元素个数就用 make([]T, 0, n) 一次分配到位,省掉反复扩容拷贝。

Go 有指针但没有指针运算,于是它只承担一件事:让被调用方改到调用方的变量。

什么时候该传指针

&x 取变量地址得到指针 *T,*p 解引用读写它指向的值。指针的零值是 nil,解引用 nil 指针会 panic。何时传指针:① 想在函数里改到调用方的变量(Go 传参一律拷贝,传值改不到原值);② 避免拷贝大结构体的开销。而 map、slice、channel 本身已是引用类型——传值即共享底层数据,无需再取指针;普通小值、只读场景直接传值最省心。Go 没有指针运算(不能像 C 那样 p+1),因此安全得多。

x := 10
p := &x            // p 是 *int,指向 x
fmt.Println(*p)    // 解引用:10
*p = 20            // 通过指针改原值
fmt.Println(x)     // 20

var np *int        // 零值 nil
// fmt.Println(*np) // 解引用 nil 会 panic

// 传指针,函数才能改到调用方的变量
func double(n *int) { *n *= 2 }
double(&x)          // x 变成 40

// 大结构体传指针,省一次拷贝
func scale(r *Rectangle, f float64) { r.Width *= f }

// map/slice 本身是引用:传值即共享,无需再取 &
func fill(m map[string]int) { m["k"] = 1 } // 原 map 被改
解引用 nil 指针(var p *T; *p)会运行时 panic,用前先确认非 nil。另外 Go 里极少直接写 new(T)——需要指针时通常用 &T{...} 取结构体字面量的地址,更直观。
从 C/C++ 过来的人常纠结「返回局部变量的地址会不会悬空」——在 Go 里完全安全,编译器的逃逸分析会自动把它挪到堆上。func newBig(n int) *Big { b := Big{N: n}; return &b } 用 go build -gcflags=-m 编译,输出里明确写着 moved to heap: b。
所以构造函数直接 return &T{...} 就行,不必为了「安全」多拷一层。想知道某个变量到底逃没逃逸、值不值得改传指针,-gcflags=-m 就是标准答案,别靠猜。

len(s) 数的是字节不是字符——这一条决定了 Go 里所有字符串处理的正确姿势。

字节、码点与两种转换

字符串是只读的字节序列(按 UTF-8 编码),创建后不可修改。len(s) 返回的是字节数而非字符数——一个中文字占 3 字节。s[i] 取到的是单个字节(byte,即 uint8)。要按字符遍历用 for range,每轮拿到一个 rune(int32,一个 Unicode 码点)及其字节偏移。[]byte(s) 与 []rune(s) 显式转换分别按字节、按码点拆开。

s := "héllo,世界"
fmt.Println(len(s))          // 字节数,不是字符数

// 索引取字节;range 按 rune(字符)遍历
for i, r := range s {
  fmt.Printf("%d:%c ", i, r) // i 是字节偏移,r 是 rune
}

// 字符串不可变:s[0] = 'H' 编译报错
b := []byte(s)               // 按字节拆
b[0] = 'H'                   // 只能改副本
s2 := string(b)              // []byte 转回 string

r := []rune(s)               // 按码点拆
fmt.Println(len(r))          // 这才是字符个数
for i, r := range s 里的 i 是字节偏移,不是「第几个字符」,把它当计数器或下标用必错。s := "héllo,世界"(len(s) 为 15,实际只有 8 个字符),range 拿到的 i 依次是 0、1、3、4、5、6、9——中间直接跳号,多字节字符各占 2 到 3。
同理 s[1] 取到的是字节 195,string(s[1]) 得到乱码 "Ã" 而不是 "é"。要按字符定位就先转 []rune(s),只是想数个数则用 utf8.RuneCountInString(s),别让它俩混用。
处理多字节文本先想清楚要「字节」还是「字符」:按字节切片 s[:3] 可能把一个 rune 切成两半、产生乱码,要按字符处理先转 []rune。字符串的拼接、查找、替换等操作见 11 章「标准库实战」的 strings 包。

Go 的 I/O 分三层各司其职:fmt 管格式化(打印与拼字符串)、bufio 管缓冲(高效按行读)、os 管系统调用(标准流与文件句柄)。三者组合覆盖了日常输入输出的全部场景。下面代码里的 log.Fatal(err)(打印错误信息并立即退出程序)与 scanner.Err() 都属于错误处理,系统讲解见 05 章。

fmt:格式化动词与三兄弟分工

  • 调试四件套:%v 万能打印任何值,%+v 给结构体带上字段名(结构体 04 章正式讲),%#v 输出 Go 语法表示(能看出类型和引号),%T 打印值的类型;
  • 类型专用动词:%d 整数、%s 字符串、%f 浮点、%t 布尔、%q 带引号字符串(排查首尾空白很好用);宽度精度写在 % 和动词之间,如 %.2f 保留两位小数、%6d 右对齐占 6 格;
  • 三兄弟分工:Println 空格分隔并自动换行;Printf 按动词格式化,换行要自己写 \n;Sprintf 只返回字符串不打印,是拼接格式化文本的标准方式。错误信息用 fmt.Fprintln(os.Stderr, ...) 走标准错误流,别混进 stdout 污染正常输出(管道、重定向时立刻见分晓)。

读输入:认准 bufio.Scanner

  • 固定套路:bufio.NewScanner(os.Stdin) 创建,for scanner.Scan() 按行循环,循环体里 scanner.Text() 拿到一行(已去掉换行符),循环结束后必须检查 scanner.Err();
  • fmt.Scanln/Scanf 按空白分词、读不了含空格的整行——交互式输入一律用 Scanner,Scan 系列只适合读「空格分隔的几个值」这类场景。

文件读写:按大小选姿势

  • 小文件一步到位:os.ReadFile 整个读成 []byte(要文本就 string(data) 显式转换);os.WriteFile 一步写入,第三个参数 0644 是 Unix 权限位(所有者读写、其他人只读);
  • 大文件流式处理:os.Open 拿到 *os.File,紧跟 defer f.Close() 保证句柄释放(03 章会正式讲这个惯用法),再包一层 bufio.NewScanner(f) 逐行处理——内存占用恒定,GB 级日志也不怕。
// —— fmt 三兄弟:Println / Printf / Sprintf ——
name, price := "Go", 3.14159
fmt.Println("hello", name)             // 空格分隔 + 自动换行
fmt.Printf("%.2f 元\n", price)        // 精度:保留 2 位小数
msg := fmt.Sprintf("%s=%v", name, price)  // 只返回字符串,不打印
fmt.Fprintln(os.Stderr, "出错了")      // 错误信息走 stderr

// 常用动词:%v 万能 · %#v Go 语法 · %T 类型(%+v 等 04 章结构体再用)
ages := map[string]int{"Ann": 18}   // map 见本章「数组、切片与 Map」卡
fmt.Printf("%v | %#v | %T\n", ages, ages, ages)
// 输出:map[Ann:18] | map[string]int{"Ann":18} | map[string]int
fmt.Printf("%q\n", name)              // "Go"(带引号,排查空白)

// —— 读输入的标准姿势:bufio.Scanner 按行循环 ——
scanner := bufio.NewScanner(os.Stdin)
for scanner.Scan() {                    // 每次读一行(不含换行符)
  line := scanner.Text()
  fmt.Println("读到:", line)
}
if err := scanner.Err(); err != nil {   // 循环结束必须查错
  fmt.Fprintln(os.Stderr, err)
}

// —— 文件:小文件一步到位,大文件流式 ——
data, err := os.ReadFile("config.txt")  // 整个文件读成 []byte
if err != nil { log.Fatal(err) }
fmt.Println(string(data))               // []byte 转 string 要显式

os.WriteFile("out.txt", []byte(msg), 0644)  // 0644 权限位

f, err := os.Open("huge.log")          // 大文件别一次读进内存
if err != nil { log.Fatal(err) }
defer f.Close()                         // defer 保证句柄释放
sc := bufio.NewScanner(f)
for sc.Scan() { process(sc.Text()) }    // 逐行流式,内存恒定
Scanner 默认单行缓冲上限约 64KB(bufio.MaxScanTokenSize)。某行超长时 Scan() 直接返回 false,循环安静地提前结束——不检查 scanner.Err()(此时是 bufio.ErrTooLong)你会误以为文件到头了,数据被悄悄截断。处理日志、JSON Lines 这类可能出现超长行的输入,先用 scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) 调大上限,并且永远在循环后查 Err()。
想把输出写到「任意目的地」,认准 F 前缀家族:Fprintf / Fprintln 的第一个参数是 io.Writer——传 os.Stderr 是错误流、传 *os.File 是写文件、传 http.ResponseWriter 是 HTTP 响应,一套格式化动词通吃所有输出目标。

函数与方法

函数的全部写法与 defer 的五条规则列成表。Go 没有类,但任意命名类型都能挂方法;接收者选值还是指针是这一章最常查的一格。

函数是一等公民:能当参数、当返回值、当结构体字段。没有重载、没有默认参数、没有可选参数——想要这些就用结构体或可变参数。

函数的全部形态

写法说明
func f(a, b int) int同类型参数可合并写
func f() (int, error)多返回值,Go 里错误处理的基础
func f() (n int, err error)命名返回值:可裸 return,可被 defer 改写
func f(xs ...int)可变参数,函数内 xs 是 []int
f(s...)切片展开传入
func (r T) M() / func (r *T) M()值接收者 / 指针接收者
func() { … }()立即执行的匿名函数
var f func(int) int函数类型变量,零值 nil
func f[T any](x T) T泛型函数(08 章)
T.M / v.M方法值与方法表达式:v.M 绑定接收者,T.M 把接收者变成第一个参数

接收者:值还是指针

情况选哪个
方法要修改接收者必须指针
结构体大 / 含锁 / 含切片 map指针(含 sync 字段的类型绝不能值拷贝)
小的不可变值(时间、坐标、枚举)值接收者
同一类型的其它方法已经用了指针跟着用指针,一个类型别混用
要让 T(非 *T)满足接口值接收者——方法集规则见下

方法集:*T 拥有 T 与 *T 的全部方法;T 只拥有值接收者方法。所以把 T 赋给接口时,指针接收者的方法不算数。

defer 的五条规则

规则后果
函数返回前执行,不是块结束时循环里 defer 会攒到函数结束才释放
多个 defer 后进先出成对的加锁 / 解锁天然正确
参数在 defer 那一行就求值defer fmt.Println(i) 打的是当时的 i
panic 时照样执行所以 recover 只能写在 defer 里
能改命名返回值defer func(){ err = … }(),本章末卡讲透

不执行的两种情况:os.Exit() 直接退出,以及进程被信号杀死。

defer 的参数即时求值,但方法的接收者也算参数:defer file.Close() 里 file 当场就定了,之后把 file 重新赋值不影响它关的是哪个。反过来 defer func(){ f.Close() }() 是闭包,关的是执行时的 f。两种写法都对,但含义不同——循环里开文件要用前者,并且把循环体包成一个小函数,否则文件句柄要攒到函数返回才释放。
想要「可选参数」,Go 的惯用法是 Functional Options:func New(opts ...Option),Option 是 func(*Config)。标准库与主流库(gRPC、zap)都用这个模式,比传一个到处是零值的配置结构体更好扩展。
常用 · 日常写法

多返回值让「返回结果 + 返回错误」不需要任何额外机制,defer 则把资源释放钉在了获取它的那一行旁边。

三件事,以及 defer 的两条精确规则

函数可返回多值,惯例最后一个是 error;相邻同类型参数可合并写 (a, b int);可变参数 ...T 在函数内就是切片,调用端用 slice... 展开。defer 值得往根上理解:每遇到一个 defer,Go 就把这次调用压进当前函数的一个栈,函数返回前再逐个弹出执行——后进先出(LIFO)由此而来(后获取的资源先释放,天然匹配嵌套获取的顺序)。一个关键细节也从这里推出:defer 的参数在「注册那一刻」就求值、只是调用被推迟——所以 defer fmt.Println(i) 打印的是当时的 i,而 defer func(){ …i… }() 因闭包引用,用的是返回时的最新值。无论正常返回还是 panic,栈里的 defer 都会被弹出执行——这正是它做资源释放最可靠的原因。

func divide(a, b float64) (float64, error) {
  if b == 0 {
    return 0, errors.New("除数为 0")
  }
  return a / b, nil
}
result, err := divide(10, 2)

// 可变参数,内部是切片
func sum(nums ...int) int {
  total := 0
  for _, n := range nums { total += n }
  return total
}
sum(1, 2, 3)
sum(xs...)                      // 展开切片

func readFile(path string) error {
  f, err := os.Open(path)
  if err != nil { return err }
  defer f.Close()             // 函数结束自动关闭
  // ... 读取
  return nil
}
循环里 defer 会把每次迭代的资源都堆到函数返回时才释放,循环上万次可能耗尽文件句柄/连接。需要在每轮结束就释放时,把循环体抽成独立函数(让 defer 随该函数返回),或显式调用 Close 而不用 defer。
想在 defer 里改动函数的返回值——最典型是 recover 之后把 panic 转成 error 返回——返回值必须命名。同一段 defer func(){ if r := recover(); r != nil { err = ... } }():签名写成 (err error) 时调用方拿到 recovered: inner boom,写成 error 再在函数体内 var err error,调用方拿到的是 nil——错误被悄无声息地吞掉了。
同一条规则也能用来做收尾加工:func f() (n int) { defer func(){ n *= 2 }(); return 5 } 返回的是 10。

Go 没有类,但任意命名类型都能挂方法——接口的隐式实现就建在这条规则上。

值接收者与指针接收者

Go 没有类,但能给任意命名类型定义方法(下例的 struct 结构体见 04 章「结构体与接口」)。值接收者 (r T) 操作的是副本;指针接收者 (r *T) 能修改原值、也避免大结构体拷贝。调用时 Go 自动取地址/解引用。闭包能捕获并记住外层变量。

值接收者还是指针接收者

值接收者 (r T)指针接收者 (r *T)
能改到原值不能(操作的是副本)能
大结构体开销每次调用都拷贝只传一个指针
方法集T 与 *T 都有这个方法只有 *T 有
并发安全天然拿到独立副本要自己保护
  • 第三行是最容易出事的一行:用指针接收者实现接口时,只有 *T 满足该接口——把一个 T 的值赋给接口变量会直接编译失败,报错常被误读成「我明明实现了呀」;
  • 实务上的默认答案:同一个类型的所有方法统一用一种接收者(通常是指针),混着写会让方法集变得难以推理。
type Rectangle struct { Width, Height float64 }

func (r Rectangle) Area() float64 {   // 值接收者
  return r.Width * r.Height
}
func (r *Rectangle) Scale(f float64) { // 指针接收者,可改原值
  r.Width *= f
  r.Height *= f
}

rect := Rectangle{Width: 10, Height: 5}
rect.Scale(2)                  // 自动 (&rect).Scale(2)
fmt.Println(rect.Area())       // 200

// 闭包:捕获外层变量
func makeCounter() func() int {
  count := 0
  return func() int { count++; return count }
}
c := makeCounter()
c(); c()                        // 1, 2(count 被闭包记住)
具体怎么炸:func (d *Dog) Speak() string 配上 var s Speaker = Dog{"rex"},编译期直接报 Dog does not implement Speaker (method Speak has pointer receiver),改成 &Dog{"rex"} 就好。
隐蔽的是它常伪装成别的错:往 []Speaker 里 append 一个值、把值存进 map 再取出来调方法、把值传给收 interface 的函数,报的都是这同一条,但报错位置离你写方法的地方很远。
经验做法是同一类型的接收者别混用:只要有任何一个方法需要指针接收者,就把这个类型的方法全写成指针接收者。
方法集指「某个类型拥有哪些方法」这份清单,规则是:T 的方法集只含值接收者的方法,*T 的方法集含值接收者 + 指针接收者的全部方法。这条不对称正是坑的来源——把 T(而非 &T)赋给接口时,指针接收者的方法不算数,于是「明明写了这个方法」却报未实现(见 04 章接口卡)。经验法则因此是:只要类型有任意一个方法需要指针接收者,就让它所有方法都用指针接收者,方法集一致就不会踩这个坑。
进阶 · 精确规则与陷阱

命名返回值本身只是语法糖,真正的用处是它让 defer 有机会在 return 之后改写返回值。

为什么 defer 改得动它

返回值可以像参数一样命名:func f() (result int, err error)。命名返回值在函数进入时就被声明为局部变量并置零值,不带参数的 return(naked return)直接把它们当前的值交出去。探源:正因为它们是「函数栈上一直存在、返回时才交还」的变量,defer 就能在 return 之后、真正交还之前回写它们——这让两件事成为可能:① 统一错误包装:一处 defer 给所有出口的 err 套上同一层上下文;② recover 转 error:在 defer 里 recover() 到 panic 后写回 err,让调用方拿到普通错误而非崩溃(panic/recover 见 05 章)。

// ① 命名返回值 + defer 统一包装错误
func loadConfig(path string) (cfg *Config, err error) {
  defer func() {
    if err != nil {                // 每个出口的 err 都被套上同一层上下文
      err = fmt.Errorf("loadConfig %s: %w", path, err)
    }
  }()
  f, err := os.Open(path)
  if err != nil { return nil, err }   // 这里的 err 会被 defer 回写
  defer f.Close()
  return parse(f)                    // 出错时同样被包装
}

// ② defer 里 recover,把 panic 转成普通 error(naked return)
func safeParse(b []byte) (n int, err error) {
  defer func() {
    if r := recover(); r != nil {
      err = fmt.Errorf("parse panicked: %v", r) // 写回命名返回值 err
    }
  }()
  n = mustParse(b)                    // 内部可能 panic
  return                              // naked return:交出 n、err 的当前值
}
naked return 在长函数里可读性差(一眼看不出返回了什么),官方建议只在短函数用,长函数仍显式写 return n, err。另外只有命名返回值才能被 defer 改到——若签名写成 (int, error),defer 里对局部变量赋值不会影响真正返回的值。
「命名返回值 + defer 回写」是 Go 里做统一错误包装与 recover 兜底的标准手法。别滥用:没有包装或兜底需求时,普通的 (T, error) 更直观。

结构体与接口

三张表:结构体标签全表(json 的每个选项、各库的标签名)、嵌入与方法提升的规则、接口断言与常用小接口。Go 用组合代替继承、用隐式实现代替 implements,规则都在这里。

结构体与接口的规则不多,但标签拼错不报错、接口 nil 判断反直觉,这两处最值得查着写。

结构体标签:谁在用、怎么写

标签使用者示例
jsonencoding/json`json:"name,omitempty"`
xml / yaml / toml各编解码库`yaml:"max_conn"`
db / gormsqlx / GORM`gorm:"primaryKey"`
form / uri / headerGin 的绑定(13 章)`form:"page"`
binding / validatego-playground/validator`binding:"required,email"`

json 标签的全部选项

写法效果
json:"name"改字段名
json:"-"完全不参与编解码
json:"-,"字段名真的叫 -(注意那个逗号)
json:",omitempty"零值时省略;判据是零值不是「没设置」(10 章)
json:",omitzero"Go 1.24:按类型的 IsZero() 判,修了 omitempty 对结构体无效的老问题
json:",string"数字 / 布尔编码成字符串,躲 JS 大整数丢精度
不写标签用字段名原样;小写字段永远不参与

嵌入与方法提升

写法行为
struct{ Base }嵌入值:Base 的字段与方法都被提升,可 x.Method() 直接调
struct{ *Base }嵌入指针:同上,但零值是 nil,调方法会 panic
struct{ sync.Mutex }嵌入锁,于是 x.Lock() 可用;但这会把 Lock 暴露给调用方
struct{ io.Reader }嵌入接口:自动满足该接口,只需覆写想改的方法
同名冲突浅层压深层;同层同名则该名字不可用,必须写全路径 x.Base.F

嵌入不是继承:Base 的方法里调的还是 Base 自己的方法,没有虚函数分派。

接口断言与常用小接口

写法说明
v := x.(T)断言失败直接 panic
v, ok := x.(T)安全形式,日常一律用它
switch v := x.(type)类型 switch;case nil 匹配接口本身为 nil
x.(interface{ M() })断言成匿名接口:只关心「有没有这个方法」
fmt.StringerString() string,决定 %v 怎么打
errorError() string(05 章)
io.Reader / io.Writer / io.Closer全生态的流契约
json.Marshaler / encoding.TextMarshaler自定义序列化
sort.InterfaceLen/Less/Swap;新代码用 slices.SortFunc
anyinterface{} 的别名(Go 1.18 起)
装了 nil 指针的接口不等于 nil:接口值由「类型 + 值」两部分构成,把一个 (*MyErr)(nil) 赋给 error 后,类型那格非空,于是 err != nil 成立,调方法就 panic。这是 Go 最有名的一个坑。修法:函数返回错误时直接 return nil,别返回一个类型化的 nil 指针变量。
接口要小:标准库里最有生命力的接口都只有一两个方法。惯用法是「在使用方定义接口」——消费者声明自己需要什么行为,生产者不必知道接口的存在,这正是隐式实现带来的好处,也让测试时替换实现变得零成本。

Go 用组合代替继承:嵌入一个类型,它的字段和方法就被提升到外层——没有父类,也没有方法覆盖的复杂规则。

嵌入、标签与导出规则

结构体是组织数据的核心,无继承、用组合。匿名嵌入会把内嵌类型的字段和方法提升到外层直接访问。字段标签(反引号包裹)为 JSON 序列化、ORM 提供元数据。字段首字母大写才会被导出/序列化。

type Address struct { City, Country string }

type Employee struct {
  Name    string
  Address              // 匿名嵌入:字段被提升
  Salary  float64
}

emp := Employee{Name: "Dave", Address: Address{City: "BJ"}}
fmt.Println(emp.City)  // 直接访问,等价 emp.Address.City

// 字段标签:控制 JSON 序列化
type Product struct {
  Name  string  `json:"name"`
  Price float64 `json:"price,omitempty"` // 为 0 时省略
  SKU   string  `json:"-"`               // 永不序列化
}
结构体能否用 == 比较、能否作 map 的 key,取决于字段类型:含 slice / map / func 字段的结构体不可比较,强行 == 或当 key 会编译报错。需要比较时改用手写 Equal 方法或 reflect.DeepEqual(慢,仅测试用)。
匿名嵌入在 JSON 里是平铺而不是嵌套,这点可以主动利用。struct{ Base; Name string } 序列化出来是 {"id":1,"kind":"k","name":"n"},而写成具名字段 Base Base 才会嵌套成 {"base":{...},"name":"n"}。所以多个出参结构体共用 ID、CreatedAt 这类公共字段时,抽一个 Base 嵌进去,对外的 JSON 格式一点不变。
反过来要留神:外层字段的 json tag 和被嵌入字段撞名时外层胜出,被盖住的那个会从输出里静默消失,Base.ID 直接不见了、无任何报错。

接口值是「动态类型 + 动态值」一对指针——隐式实现、类型断言、typed-nil 陷阱全都能从这一对推出来。

从两格表示往下推

探源:接口的所有行为都能从它的底层表示推出来——一个接口值 = (动态类型, 动态值) 一对指针(一格存类型与方法表,一格存数据指针)。从这个根出发:① 隐式实现——编译器只看「某类型有没有接口要求的全部方法」,有就能自动塞进这一对里,无需写 implements;这也是 Go 崇尚小接口的原因(方法越少越容易被满足、越好组合)。② 类型断言 x.(T) / 类型 switch 就是把那对里的「动态类型」取出来比对、还原具体类型(comma-ok 形式不 panic)。③ typed-nil 陷阱:接口只有「类型」和「值」两格都空时才 == nil;把一个值为 nil 的具体指针赋给接口,「类型」格非空,于是接口 != nil——出错时请直接 return nil,别返回「nil 的具体指针」。空接口 any(Go 1.18 起是 interface{} 的别名)两格随便填,故接受任意类型。④ 接口嵌入:接口里可以嵌入别的接口,方法集取并集——标准库的 io.ReadWriter 就是把 Reader 和 Writer 组合而成,小接口由此拼成大接口。

type Shape interface {
  Area() float64
}

type Circle struct { R float64 }
func (c Circle) Area() float64 { return math.Pi * c.R * c.R }
// Circle 自动满足 Shape,无需声明

func describe(s Shape) { fmt.Println(s.Area()) }
describe(Circle{R: 5})

// 类型断言(comma-ok 不 panic)
var s Shape = Circle{R: 5}
if c, ok := s.(Circle); ok { fmt.Println(c.R) }

// 类型 switch
func kind(v any) {
  switch x := v.(type) {
  case int:    fmt.Println("int", x)
  case string: fmt.Println("string", x)
  default:     fmt.Println("other")
  }
}

// 接口嵌入:方法集取并集,小接口拼成大接口
type Reader interface { Read(p []byte) (int, error) }
type Writer interface { Write(p []byte) (int, error) }
type ReadWriter interface {   // = Reader + Writer
  Reader
  Writer
}
typed-nil 陷阱:接口值只有类型和值都为 nil 时才 == nil。若把一个值为 nil 的具体指针(如 var e *MyError = nil)赋给 error 接口再返回,调用方 err != nil 会意外为 true。所以出错才返回具体错误,无错时直接 return nil,别返回"nil 的具体指针"。
"接受接口,返回结构体"是 Go 的核心哲学:参数用接口(灵活、便于测试时传 mock),返回值用具体类型(调用方拿到完整信息)。并且接口应小——标准库的 io.Reader/Writer 都只有一个方法。

「有方法就满足接口」这条规则最日常的一次落地:实现 String() string,fmt 打印你的类型时就自动用它。

怎么用,用在哪

接口隐式实现最日常的一次落地:标准库定义了 type Stringer interface{ String() string }。任何类型只要实现 String() string,fmt 的 %v/%s、Println 以及 log 打印它时就会自动调用这个方法——无需注册、无需声明,正是「有方法就满足接口」的直接结果。最常见的用途是给 iota 枚举(见 02 章)配上人类可读的名字,而不是打印出一个光秃秃的数字。

type Status int

const (
  Pending Status = iota   // 0
  Active                    // 1
  Closed                    // 2
)

// 实现 String(),即自动满足 fmt.Stringer
func (s Status) String() string {
  switch s {
  case Pending: return "Pending"
  case Active:  return "Active"
  case Closed:  return "Closed"
  default:      return fmt.Sprintf("Status(%d)", int(s))
  }
}

fmt.Println(Active)           // 打印 "Active",不是 1
fmt.Printf("%v", Pending)   // "Pending"(%v/%s 都会调 String)
别在 String() 里触发无限递归:方法内用 %v/%s 打印接收者自身会再次调用 String()、无限循环。要打底层数值时先转成底层类型(如 int(s))再格式化。
手写一长串 case 很枯燥,标准工具 stringer 能自动生成:写一行 //go:generate stringer -type=Status,再跑 go generate ./... 即产出。类似地,实现 error 接口的 Error() string 也是同一套「实现方法即满足接口」的机制(见 05 章)。

错误处理

errors 包的全部 API、标准库哨兵错误一览、四种判定方式的取舍,以及 panic / recover 的规则表。Go 没有 try/catch:错误是普通的值,显式传播。

错误处理只有一条主线:创建 → 包装(加上下文)→ 判定。三步各有固定写法。

errors 与 fmt 的错误 API 全表

API作用版本
errors.New("msg")造一个错误;包级变量形式即哨兵错误—
fmt.Errorf("ctx: %w", err)包装:加上下文并保留原错误1.13
fmt.Errorf("%w; %w", e1, e2)包装多个错误1.20
errors.Is(err, target)沿包装链找是不是某个哨兵1.13
errors.As(err, &myErr)沿链找某个具体类型并取出1.13
errors.Unwrap(err)剥一层;日常用 Is / As 就够1.13
errors.Join(e1, e2)把多个错误并列成一个,Is 能逐个匹配1.20
实现 Unwrap() error让自定义错误加入包装链—
实现 Is(error) bool / As(any) bool自定义匹配规则—

标准库里最常判的哨兵错误

哨兵何时出现
io.EOF读到结尾;这不是异常,是正常终止信号
io.ErrUnexpectedEOF读到一半断了,这才是真错
sql.ErrNoRowsQueryRow().Scan() 没查到(16 章)
os.ErrNotExist / fs.ErrExist文件不存在 / 已存在,配 errors.Is 判
context.Canceled / context.DeadlineExceeded取消 / 超时(07 章)
http.ErrServerClosed优雅关闭时的正常返回,别当错误报警
strconv.ErrSyntax / ErrRange解析失败 / 溢出,包在 *strconv.NumError 里

四种判定方式,选哪个

写法什么时候用
if err != nil九成场景:只关心成没成
errors.Is(err, ErrX)要区分具体哪种失败;别用 ==,包装过就不等了
errors.As(err, &e)要读错误里的字段(状态码、字段名)
strings.Contains(err.Error(), …)永远别这么做:措辞一改就失效

panic / recover 规则

规则含义
recover() 只在 defer 的函数里直接调用才有效再包一层函数就失效
只救得了同一个 goroutine子 goroutine panic 会带走整个进程
fatal error: 拦不住并发写 map、死锁、栈溢出、OOM(01 章)
该 panic 的场合程序员错误与初始化失败:配置解析不了、正则编译不过(MustCompile)
不该 panic 的场合一切可预期的失败:网络、IO、用户输入
%w 与 %v 一字之差,行为天差地别:%v 只把错误文本拼进去,包装链就此断掉,上游的 errors.Is 再也匹配不到。反过来也要克制:包装等于把下层错误纳入自己的 API 契约,库的内部错误一旦用 %w 漏出去,调用方就会开始依赖它,之后想换实现都不行——不想承诺就用 %v。
包装时加的上下文要说清「在做什么」,别重复下层已经说过的话:fmt.Errorf("load config %s: %w", path, err) 好过 fmt.Errorf("error: %w", err)。整条链读起来应该像一句话:load config /etc/a.yaml: open /etc/a.yaml: no such file or directory。

Go 没有异常——错误是一个普通返回值。满屏的 if err != nil 不是啰嗦,是「控制流永远看得见」的代价与保证。

从「错误是值」往下推

探源:Go 错误处理的一切都源于一个刻意的决定——不设异常/try-catch,错误就是一个普通的返回值,类型是接口 interface{ Error() string }。从这个根往下:① 既然是值,就得显式检查与传递——满屏 if err != nil 不是啰嗦,而是「控制流永远看得见、错误无法被静默吞掉」的代价与保证;② 值可以被包装:fmt.Errorf("…: %w", err) 用 %w 套一层保留链路,errors.Is(比对哨兵错误)/errors.As(提取具体类型)能穿透多层 wrap;③ 自定义错误只需实现 Error() string,因为它本就只是个满足接口的普通值。panic 是另一回事:它对应「程序自身有 bug」(越界、解空指针),会展开调用栈——所以库代码应返回 error,把 panic 只留给真正不可恢复的编程错误。

import "errors"

// 哨兵错误 + 包裹
var ErrNotFound = errors.New("not found")

func getUser(id int) (*User, error) {
  u, err := db.Query(id)
  if err != nil {
    return nil, fmt.Errorf("getUser %d: %w", id, err)
  }
  return u, nil
}

// 即使被 wrap,也能识别原始错误
if errors.Is(err, ErrNotFound) { ... }

// 自定义错误类型
type ValidationError struct { Field, Msg string }
func (e *ValidationError) Error() string {
  return fmt.Sprintf("%s: %s", e.Field, e.Msg)
}

var ve *ValidationError
if errors.As(err, &ve) { fmt.Println(ve.Field) }
要让上层能 Is/As 识别,包裹时必须用 %w;写成 %v 只会拼成字符串、丢掉错误类型。反过来,对外部 API 别把内部错误链原样 %w 暴露出去(泄漏实现细节),边界处转成对外的稳定错误。
批量校验、并发收集多个失败时用 errors.Join(errs...),别自己拼字符串。它把消息按换行连成 "err A\nerr B",且 errors.Is 对其中每一个都返回 true;传进去的 nil 会被自动跳过,全是 nil 时整体返回 nil。
所以可以放心地一路 errs = append(errs, mayFail()) 收着,最后直接 return errors.Join(errs...),不用先判空。注意 Join 出来的多错误值 errors.Unwrap 返回 nil,要判断内容只能用 Is/As。

panic 对应的是「程序自己有 bug」,不是「这次操作失败了」——把它当异常用,是从别的语言迁过来最常见的误用。

什么时候该 panic,什么时候该 recover

panic 用于不可恢复的编程错误(越界、解空指针、启动时必填配置缺失),触发后沿调用栈展开、执行 defer,最终崩溃退出。recover 只能在 defer 中捕获 panic,典型用于服务器中间件兜底,防止单个请求拖垮整个进程。

// recover 必须在 defer 里
func safe(fn func()) {
  defer func() {
    if r := recover(); r != nil {
      log.Printf("recovered: %v", r)
    }
  }()
  fn()
}

// HTTP 中间件中的典型兜底
func recoverMW(next http.Handler) http.Handler {
  return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    defer func() {
      if err := recover(); err != nil {
        http.Error(w, "500", http.StatusInternalServerError)
        log.Printf("panic: %v", err)
      }
    }()
    next.ServeHTTP(w, r)
  })
}
最大的误区是把 panic 当异常用。库代码不应 panic,应返回 error;可预期的失败(文件不存在、网络超时、用户输入非法)一律走 error 返回值。遇到 panic 通常意味着代码有 bug,而不是"正常的错误分支"。
recover 只能捞回同一个 goroutine 里的 panic。在 main 里 defer 一个 recover,再 go func(){ panic("...") }(),进程照样整个崩掉、退出码 2,那个 recover 一次都没执行到。
这意味着 HTTP 中间件的兜底 recover 保不住你手动 go 出去的 goroutine:一个后台任务里的空指针就能带走整个服务进程。凡是自己启动的 goroutine,都要在它自己的函数体第一行加 defer func(){ if r := recover(); r != nil { log... } }()。

Goroutine 与 Channel

两张表把 channel 的行为定死:nil / 空 / 满 / 已关闭四种状态下,发送、接收、close 分别会怎样,以及 select 的裁决规则。后面的卡展开 WaitGroup 与 Pipeline / Fan-in 模式。

channel 的坑几乎全来自「没想到这种状态下会阻塞 / 会 panic」。下面这张真值表值得记住。

channel 操作真值表

状态发送 ch <- v接收 <-chclose(ch)
nil(未 make)永久阻塞永久阻塞panic
空(有缓冲、无数据)成功阻塞到有数据成功
满(缓冲写满)阻塞到有空位成功成功
已关闭panic立刻返回零值,v, ok := <-ch 的 ok 为 falsepanic

无缓冲 channel 的发送与接收必须配对才能各自继续——它同时是一次同步点。

声明与操作

写法含义
make(chan T)无缓冲:收发必须同时就位
make(chan T, n)缓冲 n:满了才阻塞发送方
chan<- T / <-chan T只写 / 只读,写在参数上让方向由编译器保证
v, ok := <-chok == false 表示已关闭且取空
for v := range ch收到 close 自动结束
len(ch) / cap(ch)缓冲中的元素数 / 容量
chan struct{}纯信号,零内存;常用作退出通知

select 的五条规则

规则说明
多个 case 都就绪伪随机挑一个,不按书写顺序
都没就绪且有 default立刻走 default(非阻塞收发)
都没就绪且无 default阻塞等待
case <-nilCh永远不就绪——把变量置 nil 可以「关掉」一个分支
空 select{}永久阻塞,配 main 里挂住进程

超时写法:case <-time.After(d);取消写法:case <-ctx.Done()(07 章)。

goroutine 泄漏比 channel panic 更常见,而且不报错:往无人接收的 channel 发送、或从无人发送的 channel 接收,那个 goroutine 就永远卡在那,连同它引用的内存一起留下。凡是自己起的 goroutine,都要能说出它什么时候退出——写发送就配 select { case ch <- v: case <-ctx.Done(): return }。查泄漏用 pprof 的 goroutine 剖面或 kill -QUIT(15 章)。
关闭是「发送方的权利」:channel 由唯一发送方 close,接收方只读。多个发送方时别去关它——要么改用 context 通知退出,要么加一个专门的协调者。接收方需要知道「没有更多数据了」时才需要 close;只是想回收资源的话,channel 会被 GC,不 close 也不泄漏。
常用 · 日常写法

go 一个函数就起一条协程,成本低到可以随手起几万条——真正需要动脑的是「谁来等它们结束」。

起、等、以及别泄漏

探源:goroutine 廉价的根源在于它不是 OS 线程——go f() 把任务交给 Go runtime 的调度器,由它把成千上万个 goroutine M:N 复用到少数几个 OS 线程上;每个初始栈只有几 KB 且可增长,切换在用户态完成、不陷入内核。这就是「一个程序开几十万个也扛得住」的来历(OS 线程通常几 MB 栈、切换要进内核,开不了这么多)。由此还带出一个坑:main 本身也是一个 goroutine,它一返回,整个进程连同所有还没跑完的 goroutine 会被直接掐断——所以必须显式等待,用 sync.WaitGroup(Add/Done/Wait)或 channel 同步。

func processAll(items []string) {
  var wg sync.WaitGroup
  for _, item := range items {
    wg.Add(1)               // 计数 +1
    go func(it string) {
      defer wg.Done()       // 结束时 -1
      process(it)
    }(item)
  }
  wg.Wait()                 // 阻塞到计数归零
}
两点:① Go 1.22 之前,闭包直接捕获 for 循环变量会让所有 goroutine 共享同一变量、全拿到最后一个值(1.22+ 每轮迭代是新变量已修复,但旧代码常显式传参兜底)。② 启动了 goroutine 却不 Wait / 不接收其结果,会造成 goroutine 泄漏。
wg.Add(1) 必须写在 go 语句之前的主 goroutine 里,绝不能挪进 goroutine 内部。把 wg.Add(1) 放进 goroutine 第一行、循环启 100 个,wg.Wait() 返回时只有 92 个真正跑完——Wait 在还有大批 Add 没来得及执行时就看到计数为 0、提前放行了,剩下任务的结果连同它们的错误一起被丢掉。
记法固定成一句:Add 在外,Done 在内(且配 defer)。这类 bug 在本机小数据量下经常「碰巧」全对,压力一上来才露馅。

channel 不只是队列,它同时是同步原语——「通过通信共享内存」这句口号的可执行版本就是它。

缓冲、方向与 select

channel 是类型化管道。无缓冲 channel 收发两端互相阻塞直到配对;带缓冲 make(chan T, n) 满了才阻塞发送、空了才阻塞接收。发送方负责 close;for range ch 会一直收到 close 才结束。select 同时等待多个 channel,配 time.After 做超时、default 做非阻塞。无缓冲 channel 收发双方必须同时就绪才能完成(类似当面交接);带缓冲 channel 则在缓冲未满时可先写入、无需对方即时接收。

ch := make(chan int)        // 无缓冲
buf := make(chan int, 10)   // 带缓冲

go func() {
  defer close(ch)            // 生产者关闭
  for i := 0; i < 3; i++ { ch <- i }
}()
for v := range ch {          // 收到 close 才退出
  fmt.Println(v)
}

// select:多路等待 + 超时
select {
case msg := <-ch1: fmt.Println(msg)
case <-time.After(3 * time.Second): fmt.Println("超时")
default: fmt.Println("无数据(非阻塞)")
}

// 单向 channel:编译期限制方向
func producer(out chan<- int) { out <- 1 }  // 只写
func consumer(in <-chan int) { <-in }      // 只读
几个会直接 panic 的操作:向已关闭的 channel 发送、重复 close、close 一个 nil channel。规则——只有发送方 close,接收方永不 close。另外,无人接收的发送(或无人发送的接收)会让 goroutine 永久阻塞,是泄漏的常见来源。
select 里混进一个已关闭的 channel,那条分支会永远处于就绪态,把循环变成疯转的 CPU 火炉——1000 轮里已关闭的那条被选中 490 次,每次立刻返回零值。
正确姿势是用 v, ok := <-ch 判断 ok == false,然后把该变量置为 nil:nil channel 收发永远阻塞,置 nil 后那条分支再也不会命中,等于优雅地把它从 select 里摘掉了。这也是多路合并(fan-in)里逐个关掉输入源的标准写法。
进阶 · 精确规则与陷阱

把 goroutine 与 channel 拼成固定套路,绝大多数并发需求都能落进这几个模式里,不必自己发明。

三个可复用的骨架

把处理拆成多个 stage,每个 stage 是一个 goroutine,用 channel 首尾相连,数据像流水线一样流过——这就是 Pipeline。当某个 stage 是瓶颈时,用 fan-out 启动多个相同 goroutine 并行消费同一个输入 channel,再用 fan-in 把它们的输出合并回一个 channel。每个 stage 只需遵守同一约定:从入 channel 读、往出 channel 写、干完 close 自己的出口——天然解耦、可自由组合。

// stage1:生成数据,返回只读 channel
func gen(nums ...int) <-chan int {
  out := make(chan int)
  go func() {
    defer close(out)
    for _, n := range nums { out <- n }
  }()
  return out
}

// stage2:平方,入 → 出
func sq(in <-chan int) <-chan int {
  out := make(chan int)
  go func() {
    defer close(out)
    for n := range in { out <- n * n }
  }()
  return out
}

// fan-in:把多个 channel 合并成一个
func merge(cs ...<-chan int) <-chan int {
  out := make(chan int)
  var wg sync.WaitGroup
  wg.Add(len(cs))
  for _, c := range cs {
    go func(c <-chan int) {
      defer wg.Done()
      for v := range c { out <- v }
    }(c)
  }
  go func() { wg.Wait(); close(out) }()  // 全部收完再关
  return out
}

in := gen(2, 3, 4, 5)
// fan-out:两个 sq 并行消费同一个 in
c1, c2 := sq(in), sq(in)
for v := range merge(c1, c2) {          // fan-in 合并
  fmt.Println(v)                         // 4 9 16 25(顺序不定)
}
fan-out 时多个 goroutine 共享同一个输入 channel 是安全的(channel 本身并发安全,值不会被读两次);真正的坑在输出端——多个 goroutine 往同一个出口写,必须等所有写入方都结束才能 close,否则会向已关闭 channel 发送而 panic。上面用一个独立 goroutine 在 wg.Wait() 后统一 close 正是这个套路。
若下游可能提前退出(break 出 range、不再接收),上游 stage 会卡在发送上永久泄漏。生产级 pipeline 要给每个 stage 传 ctx 或 done channel,在 select 里同时监听 <-ctx.Done(),让整条流水线能被一键取消。

并发模式与同步

sync、sync/atomic、context 与 errgroup 的 API 一次列全,外加一张「这个并发场景用哪个原语」的选型表。后两卡是深水区:更少见的原语与内存模型。

并发工具就这么几个,难的是选。先看选型表定方向,再到 API 表查签名。

场景选型

场景用什么
传递数据、交接所有权channel
保护一段共享状态sync.Mutex
读多写极少sync.RWMutex(写少到可忽略才划算)
等一批任务做完sync.WaitGroup
等一批任务,要收错误errgroup.Group
只做一次的初始化sync.Once / OnceValue
计数器、标志位atomic.Int64 / atomic.Bool
取消与超时context
限制并发数带缓冲 channel 当信号量,或 errgroup.SetLimit

sync / sync/atomic 全表

类型 / 函数要点版本
Mutex.Lock/Unlock/TryLock零值可用;不可重入;defer Unlock()TryLock 1.18
RWMutex.RLock/RUnlock读锁可并行;写锁会饿死后续读锁,这是故意的—
WaitGroup.Add/Done/WaitAdd 必须在 go 之前,Done 用 defer—
WaitGroup.Go(f)起 goroutine 并自动 Add / Done1.25
Once.Do(f)只执行一次,即使 f panic 也算用掉了—
OnceFunc / OnceValue / OnceValues更顺手的懒加载封装1.21
sync.Map只适合两种场景:键集稳定的读多写少、各 goroutine 写不相交的键—
sync.Pool复用临时对象;GC 时会被清空,别当缓存—
sync.Cond条件变量;九成场景 channel 更清楚—
atomic.Int32/64、Bool、Pointer[T]类型化原子变量,比老的函数式 API 难用错1.19
atomic.AddInt64 / CompareAndSwap…、atomic.Value老式函数 API 与「装任意值」的 Value,都更早就有,仍然可用—

context 全表

API作用版本
context.Background()根 context,main / 请求入口用—
context.TODO()还不知道传什么时的占位—
WithCancel(parent)手动取消;cancel 必须调,通常 defer cancel()—
WithTimeout / WithDeadline相对 / 绝对超时—
WithValue(parent, k, v)传请求作用域的值;键要用自定义类型,别用字符串—
WithCancelCause + context.Cause(ctx)取消时带上原因1.20
WithoutCancel(ctx)保留值但脱离取消:请求结束后还要跑的收尾1.21
AfterFunc(ctx, f)ctx 结束时自动跑 f1.21
ctx.Done() / Err() / Deadline()取消信号 / 原因 / 截止时间—

golang.org/x/sync/errgroup

API作用
errgroup.WithContext(ctx)返回 group 与子 ctx:任一任务出错就取消全部
g.Go(func() error)起一个任务
g.Wait()等全部结束,返回第一个非 nil 错误
g.SetLimit(n) / g.TryGo限制并发数 / 满了就不起
忘了调 cancel 会泄漏:WithCancel / WithTimeout 返回的 cancel 不调用,父 context 就一直持有这个子节点,定时器也不释放——go vet 的 lostcancel 检查专门查它。反过来,defer cancel() 写在把 ctx 传给后台 goroutine 的场景里会提前掐断它:函数一返回 ctx 就废了。那种情况要么等任务结束再 cancel,要么用 context.WithoutCancel。
context 一律作为函数的第一个参数,名字就叫 ctx,并且沿调用链往下传,不要存进结构体字段。它只该装两样东西:取消信号与请求作用域的元数据(trace id、用户身份)——把可选参数、数据库连接塞进 WithValue 是常见的误用。
常用 · 日常写法

context 解决的是「怎么把取消信号传遍整棵调用树」——它是 Go 里唯一被标准库和整个生态共同认可的取消机制。

四个构造函数与一条纪律

context.Context 在 goroutine 树中传递取消信号、超时与请求范围的值,惯例作为函数第一个参数 ctx。WithTimeout/WithCancel 返回 cancel 函数,必须 defer cancel() 否则泄漏。耗时循环里用 select { case <-ctx.Done(): } 响应取消。

func fetch(ctx context.Context, url string) error {
  ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
  defer cancel()            // 必须调用,否则泄漏

  req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
  _, err := http.DefaultClient.Do(req)
  return err              // 超时会自动取消请求
}

// 在耗时任务中响应取消
func work(ctx context.Context) error {
  for {
    select {
    case <-ctx.Done(): return ctx.Err() // 提前退出
    default: // 继续干活...
    }
  }
}
context.WithValue 若用内置类型(如 string)当 key,有跨包碰撞风险且类型不安全。务必用自定义未导出类型作 key:type ctxKey struct{}——struct{} 是「空结构体」:不含任何字段、占 0 字节的类型,凡是「只要这个类型本身、不需要它装数据」的场合都用它(这里当 key,还常见于 chan struct{} 只传信号不传值、map[T]struct{} 模拟集合)。而且 Context 只应传请求范围的元数据(trace id、用户身份),别拿它传函数的必备业务参数。
出问题时用 ctx.Err() 一眼分清是自己超时还是被上游取消:WithTimeout 到点返回 context deadline exceeded,WithCancel 被调用返回 context canceled。前者说明下游真的慢,该去查耗时或调超时预算;后者多半是客户端断连或兄弟任务先失败引发的连锁取消,重试毫无意义,日志里也不该报成错误。
另外 ctx 只当函数第一个参数往下传,别存进结构体字段——一存就没法按请求区分生命周期了。

channel 管「传递」,sync 包管「保护」——两者不是替代关系,用错方向会让代码同时变慢和变复杂。

什么时候用锁而不是 channel

共享状态用 sync.Mutex(Lock + defer Unlock)保护;sync.Once 保证某段代码只执行一次(单例初始化);Worker Pool 用固定数量 goroutine 消费同一任务 channel,控制并发度;errgroup 并发跑多任务,任一出错即取消其余并返回首个错误。

channel 还是锁:按「要传递还是要保护」选

场景用什么为什么
把数据的所有权交给另一条 goroutinechannel交出去就摸不到,天然没有竞争
多条 goroutine 读写同一份状态sync.Mutex用 channel 模拟锁只会更慢更绕
等一组任务全部结束sync.WaitGroup比用 channel 计数直白
只改一个计数器sync/atomic连锁都不用
只需初始化一次sync.Once比双重检查锁安全
  • 「不要通过共享内存来通信」这句口号常被读成「永远别用锁」——官方文档紧接着的那半句是「而要通过通信来共享内存」,说的是默认倾向,不是禁令;
  • 判据一句话:数据要「换手」用 channel,数据要「共用」用锁。
type Counter struct {
  mu sync.Mutex
  n  int
}
func (c *Counter) Inc() {
  c.mu.Lock()
  defer c.mu.Unlock()
  c.n++                     // 同一时刻仅一个 goroutine 能改
}

// Worker Pool:固定 worker 消费同一 channel
func pool(jobs <-chan int, results chan<- int, n int) {
  var wg sync.WaitGroup
  for i := 0; i < n; i++ {
    wg.Add(1)
    go func() {
      defer wg.Done()
      for j := range jobs { results <- j * j }
    }()
  }
  wg.Wait(); close(results)
}

// errgroup:任一失败即取消其余
g, ctx := errgroup.WithContext(ctx)
for _, url := range urls {
  g.Go(func() error { return fetch(ctx, url) })
}
if err := g.Wait(); err != nil { ... }
含 sync.Mutex 的结构体一旦被值拷贝,锁就废了。把方法写成值接收者 func (c Counter) Inc(),每次调用锁的都是一份新副本,多个 goroutine 各锁各的、互斥形同虚设,而且 c.n++ 也写在副本上,原对象永远是 0。把 sync.WaitGroup 当参数传值同理。
好在 go vet 抓得住:它报 Inc passes lock by value: Counter contains sync.Mutex。凡是带锁的结构体,方法一律用指针接收者、传递一律用指针,并把 vet 挂进 CI。
数据竞争是并发头号 bug。开发期常用 go test -race ./...(或 go run -race .)在运行时检测,CI 里也应常驻 -race。原则上:能用 channel 表达的协作就别用"共享内存 + 锁"。
进阶 · 精确规则与陷阱

锁不是唯一选择:只改一个数就用原子操作,读远多于写就用读写锁,反复分配同类对象就用对象池。

三个更专用的工具

Mutex 之外,按场景选更合适的工具。单个变量的计数用 sync/atomic 比锁更快、无锁:atomic.Int64 等类型(Go 1.19+)自带 Add/Load/Store/CompareAndSwap。读多写少用 sync.RWMutex:多个读可并发持锁(RLock),写独占(Lock)。sync.Pool 复用临时对象、削减 GC 压力(如 buffer 池)。

// atomic:无锁计数器(比 Mutex 轻)
var hits atomic.Int64          // Go1.19+ 类型化原子
hits.Add(1)                    // 并发安全自增
fmt.Println(hits.Load())       // 读取当前值

// RWMutex:读多写少,多个读可并发
type Cache struct {
  mu sync.RWMutex
  m  map[string]string
}
func (c *Cache) Get(k string) string {
  c.mu.RLock()                 // 读锁:多个 goroutine 可同时持有
  defer c.mu.RUnlock()
  return c.m[k]
}
func (c *Cache) Set(k, v string) {
  c.mu.Lock()                  // 写锁:独占
  defer c.mu.Unlock()
  c.m[k] = v
}

// sync.Pool:复用临时对象,减轻 GC
var bufPool = sync.Pool{
  New: func() any { return new(bytes.Buffer) },
}
buf := bufPool.Get().(*bytes.Buffer)
buf.Reset()
defer bufPool.Put(buf)         // 用完归还池中
RWMutex 的读锁不可升级为写锁(RLock 期间再 Lock 会死锁);临界区里别做重活,否则并发照样被拖垮。sync.Pool 里的对象随时可能被 GC 回收,只能放"可重建的临时对象",绝不能放连接、未落盘的状态这类必须存活的东西。别一上来就上 sync.Map——多数场景"map + Mutex"更快也更好懂。
原子操作只适合单个变量的简单读改写;一旦涉及多个变量的一致性(如"扣库存的同时记流水"),仍要回到 Mutex 保护整段临界区。判定竞态别靠猜,go test -race 是唯一可靠手段。

「加了锁就没事」是靠不住的直觉——Go 内存模型定义的是哪些操作之间存在 happens-before 关系,只有它能回答「另一条 goroutine 能不能看到我写的值」。

规则与检测手段

探源(-race 只是手段,这才是「为什么」):现代 CPU 和编译器为了快,会对读写重排序、把变量缓存在寄存器里。所以一个 goroutine 写、另一个 goroutine 无同步地读同一变量时,读方可能看到旧值、半个值、或永远看不到更新——这就是数据竞争,在 Go 里属未定义行为(UB),不是「偶尔读到旧值」这么温和,程序可以任意出错。Go 内存模型用 happens-before 定义了「一次写何时保证对另一次读可见」:只要两个事件间存在 happens-before 关系,读方就一定看到写方的结果。建立 happens-before 的手段就那几种:channel 的发送 happens-before 对应的接收完成;Mutex.Unlock happens-before 下一次 Lock;Once.Do 里的初始化 happens-before 所有 Do 返回;WaitGroup.Wait 返回前,所有 Done 之前的写都可见。没有这些同步,就没有任何可见性保证——哪怕你「看代码觉得该没问题」。

// ✗ 数据竞争:done 无同步,读方可能永远看不到 true(UB,不只是慢)
var done bool
var msg string
go func() { msg = "hi"; done = true }()
for !done {}                 // 可能死循环;即便退出,msg 也未必可见

// ✓ 用 channel 建立 happens-before:发送前的写,接收后一定可见
var msg2 string
ch := make(chan struct{})
go func() {
  msg2 = "hi"              // 这次写……
  close(ch)                 // ……happens-before 下面的接收
}()
<-ch                        // 收到后读 msg2 保证是 "hi"
fmt.Println(msg2)
别用「加个 time.Sleep 等它写完」或裸 bool 标志位来同步——这些不建立 happens-before,在弱内存序的机器(如 ARM)上会真实出错,且 -race 之外极难复现。要么 channel、要么 sync 原语、要么 sync/atomic。
记不住细则没关系,记住铁律:凡是被多个 goroutine 访问、且至少一个是写的变量,一律用 channel 或 sync 保护。至于有没有漏网的竞争,go test -race(见本章前文)在运行到的路径上能可靠报出。

泛型

泛型语法与约束写法一张表,slices / maps / cmp 三个泛型标准库包的 API 一张表,再加 iter 迭代器。泛型适合数据结构与通用算法,别拿它套业务逻辑。

泛型的语法面很小:类型参数列表 + 约束。真正天天用到的是标准库那几十个泛型函数。

语法

写法含义
func Map[T, U any](s []T, f func(T) U) []U泛型函数,两个类型参数
type Stack[T any] struct { items []T }泛型类型;方法写成 func (s *Stack[T]) Push(v T)
Map(xs, f)类型推断:多数时候不用写类型实参
Map[int, string](xs, f)推不出来时显式实例化
type List[T any] = OldList[T]泛型类型别名(Go 1.24)
var zero T取类型参数的零值,唯一写法

方法不能有自己的类型参数——需要的话把参数提到类型上,或者写成普通函数。

约束全表

约束允许什么
any任意类型;只能赋值、传递,不能比较也不能运算
comparable支持 == / !=,能当 map 键
cmp.Ordered支持 < > <= >=:整数、浮点、字符串(Go 1.21 进标准库)
interface{ ~int | ~string }联合:两者之一
~int波浪号 = 底层类型是 int,这样 type MyID int 也满足
interface{ String() string }方法约束:普通接口也能当约束用
interface{ ~[]E; Len() int }类型集与方法可以同时要求
constraints.Integer 等在 golang.org/x/exp/constraints,不在标准库

slices 包(Go 1.21+)

函数作用
Contains / ContainsFunc是否包含
Index / IndexFunc找下标,没有给 -1
Sort / SortFunc / SortStableFunc排序;Func 版返回 负 / 零 / 正,不是 bool
IsSorted / BinarySearch / BinarySearchFunc有序判断与二分
Min / Max / MinFunc / MaxFunc空切片会 panic
Equal / EqualFunc / Compare比较两个切片
Clone / Grow / Clip浅拷贝 / 预留容量 / 削掉多余容量
Compact / CompactFunc去掉相邻重复项
Insert / Delete / Replace / Reverse原地增删改
Concat(1.22)· Repeat / Chunk(1.23)拼接 / 重复 / 按块切
All / Values / Sorted / Collect(1.23)与迭代器互转

maps 与 cmp

函数作用
maps.Keys / maps.ValuesGo 1.23 起返回迭代器,要切片得配 slices.Collect / slices.Sorted
maps.All / maps.Insert / maps.Collect键值迭代器与反向收集
maps.Clone / Copy / Equal / DeleteFunc常规操作
cmp.Compare(a, b)返回 -1 / 0 / 1,配 slices.SortFunc
cmp.Or(vals...)(1.22)取第一个非零值,写默认值很顺手

iter:range over func(Go 1.23)

类型 / 函数含义
iter.Seq[V]func(yield func(V) bool),单值迭代器
iter.Seq2[K, V]双值迭代器,for k, v := range seq
yield 返回 false调用方 break 了,必须立刻停止并返回
iter.Pull(seq)把推模式转成拉模式,返回 next, stop;stop 要 defer
slices.SortFunc 的比较函数返回的是三态整数,不是布尔——从老的 sort.Slice(返回 i < j 的 bool)搬过来最容易写错,编译器能拦下类型,但如果你写了 func(a, b T) int { if a.X < b.X { return 1 }; return 0 } 这种「反了」的实现,编译通过、排序结果静默错误。惯用写法是直接 return cmp.Compare(a.X, b.X)。
不确定要不要用泛型时,先别用。官方的建议是:当你发现自己为 int / string / float64 写了三份一模一样的函数时再上。any + 类型断言在热路径上反而更慢,而泛型的编译期实例化没有运行时开销。

泛型在 Go 里来得很晚,也刻意做得很克制:它解决的是「同一段逻辑对多种类型重复写」,不是要做成一套类型体操。

类型参数与约束

[T any] 声明类型参数。约束(constraint)本身是接口:可用联合类型 int | float64 限定 T 支持的操作;内置 comparable 约束可用 == 比较的类型(map 的 key 必须 comparable);cmp.Ordered(Go 1.21+)覆盖可比较大小的类型。

// 泛型函数:类型自动推断
func Map[T, U any](s []T, f func(T) U) []U {
  r := make([]U, len(s))
  for i, v := range s { r[i] = f(v) }
  return r
}
doubled := Map(nums, func(n int) int { return n * 2 })

// 联合类型约束
type Number interface { ~int | ~int64 | ~float64 }

func Sum[T Number](s []T) T {
  var total T
  for _, v := range s { total += v }
  return total
}

// comparable 约束
func Contains[T comparable](s []T, target T) bool {
  for _, v := range s { if v == target { return true } }
  return false
}
comparable 只在编译期拦得住 slice、map、func 这类类型参数,拦不住藏在接口里的它们。func Contains[T comparable](...) 用 T = any 实例化能正常编译通过,可一旦实际传进去的动态值是 []int,运行时立刻 panic: runtime error: comparing uncomparable type []int——单测里用 string、int 全绿,线上来一个 slice 就崩。
所以别拿 any 去满足 comparable。真有这种需求就换成显式的联合约束,或者让调用方传一个比较函数进来。
约束里的 ~int 表示"底层类型是 int 的所有类型",能覆盖 type MyID int 这类自定义类型。类型参数通常能自动推断,调用端无需显式写 Sum[int](...)。

泛型最直接的红利不是让你自己写泛型,而是标准库终于能提供 slices/maps 这类通用工具函数了。

泛型类型与标准库新包

泛型结构体可构建类型安全的容器(Stack/Queue/Tree),取出元素无需类型断言。Go 1.21+ 的 slices/maps 包提供大量泛型实用函数;1.23+ 的 maps.Keys/slices.Values 返回迭代器(range-over-func)。

type Stack[T any] struct { items []T }

func (s *Stack[T]) Push(x T) { s.items = append(s.items, x) }
func (s *Stack[T]) Pop() (T, bool) {
  var zero T
  if len(s.items) == 0 { return zero, false }
  x := s.items[len(s.items)-1]
  s.items = s.items[:len(s.items)-1]
  return x, true
}

st := &Stack[int]{}
st.Push(1); st.Push(2)
v, ok := st.Pop()             // v 是 int,无需断言

// 标准库泛型函数(Go1.21+)
slices.Contains([]int{1, 2, 3}, 2)  // true
slices.Sort(nums)                       // 原地排序
slices.Index(nums, 5)               // 找不到返回 -1
for k := range maps.Keys(m) { ... }     // 迭代器(Go1.23+)
别过度泛型化。数据结构(Stack/Queue/Set)和通用算法(Map/Filter/Reduce)适合泛型;普通业务逻辑套泛型往往只会增加心智负担、降低可读性。能用普通函数或接口解决就别上泛型。
要稳定地遍历 map(打日志、做快照 diff、写测试断言),一行就够:for _, k := range slices.Sorted(maps.Keys(m))。对 map[string]int{"z":1,"a":2,"m":3} 每次都得到 [a m z],彻底躲开 map 遍历顺序随机导致的偶发失败用例。
这一行替代了以前「先 make 一个 keys 切片、循环 append、再 sort.Strings」的四行样板;取有序的值同理用 slices.Sorted(maps.Values(m))。

Go 1.23 让 for range 可以直接 range 一个函数。这是泛型之后最大的一次语言改动——在此之前,「遍历我的自定义容器」只能靠返回切片(一次全部物化)或者暴露 Next() 方法(调用方要写 for 循环加判断),现在有了统一写法。

迭代器就是一个特定形状的函数

  • iter.Seq[V] 的定义就是 func(yield func(V) bool);iter.Seq2[K, V] 多一个参数,对应 for k, v := range。打印类型确认:%T 输出 iter.Seq[int]。
  • 你写的那个函数负责把值一个一个交给 yield;for range 的循环体被编译器包成了这个 yield。
  • yield 返回 false 表示消费方不想再要了(break、return、外层 panic),此时你必须立刻 return,否则就是往一个已经结束的循环里灌数据。

惰性是白送的:一个百万级的序列

  • 把一个能产 100 万个值的迭代器交给 for range,循环体里数到 3 就 break——只跑了 3 次。生产端根本没往下走,因为第 3 次 yield 返回了 false。
  • 这正是它比「返回 []T」强的地方:返回切片必须先把全部元素造出来,而迭代器按需生产,还能表达无限序列。

标准库已经全面铺开

  • maps.Keys(m) / maps.Values(m) 现在返回的是迭代器而不是切片——这是从 1.21 那版 x/exp/maps 迁过来时最容易踩的差异。
  • 配套的收集函数:slices.Collect(seq) 收成切片、slices.Sorted(seq) 收完顺手排序、slices.Values(s) / slices.All(s) 把切片变回迭代器。slices.Sorted(maps.Keys(m)) 就是「取出所有键并排序」的标准一行写法。
  • 别的包也在跟进:strings.SplitSeq、bytes.Lines 这类「边切边给」的版本,省掉中间切片的分配。
import ("iter"; "maps"; "slices")

// 一个迭代器就是这个形状的函数
func Countdown(n int) iter.Seq[int] {
    return func(yield func(int) bool) {
        for i := n; i > 0; i-- {
            if !yield(i) {
                return   // 消费方 break 了,必须立刻收摊
            }
        }
    }
}

for v := range Countdown(5) { fmt.Print(v, " ") }   // 5 4 3 2 1

// 惰性:数到 3 就 break,生产端只跑 3 次
for v := range Countdown(1000000) { if ... { break } }

// 标准库:Keys 返回迭代器,不是切片
keys := slices.Sorted(maps.Keys(m))   // []string,已排序
all  := slices.Collect(Countdown(3))  // [3 2 1]
两个坑:① yield 返回 false 之后必须立刻 return,继续调用 yield 会 panic(运行时会报「range function continued iteration after loop body exit」这类错)。写循环时习惯性地 if !yield(v) { return },别只写 yield(v);② maps.Keys 的返回值变了——标准库版返回 iter.Seq,而老代码里从 golang.org/x/exp/maps 导入的同名函数返回 []K。两者签名不同、名字一样,迁移时把 keys := maps.Keys(m) 直接当切片用会编译不过,要套一层 slices.Collect 或 slices.Sorted。
什么时候自己写迭代器:你的类型内部有一批元素,而你不想暴露内部结构、也不想每次都复制一份切片出去。树的中序遍历、分页拉取、按行读文件都是典型。反过来,只是想返回三五个元素,直接返回切片更简单——迭代器的收益在「量大」或「无限」时才显现。

包与模块

go.mod 的每条指令、go mod 的每个子命令、与模块相关的全部环境变量列成三张表,再加版本号规则(v2 路径、伪版本)与可见性、初始化顺序。

依赖出问题时,答案基本都在这三张表里:go.mod 写了什么、该敲哪条命令、哪个环境变量在挡路。

go.mod 指令全表

指令作用
module example.com/app模块路径 = 别人 import 你的前缀
go 1.25.0语言版本,决定语法与语义(00 章)
toolchain go1.25.1构建所需工具链,不够新会自动下载
require x v1.2.3依赖;// indirect 表示不是你直接 import 的
replace a => ../local换源:本地联调、打补丁;只在主模块生效
exclude x v1.2.3排除某个坏版本
retract v1.0.1作者声明自己发的某版作废
tool golang.org/x/tools/cmd/stringerGo 1.24:把工具依赖记进模块,go tool 直接跑
godebug default=go1.21Go 1.23:为兼容旧行为固定 GODEBUG

命令与版本号

要做的事命令 / 规则
补齐并清理依赖go mod tidy(最常用的一条)
升某个依赖 / 升全部go get x@v1.3.0 / go get -u ./...
降级或移除go get x@v1.1.0 / go get x@none
这依赖谁引进来的go mod why -m x、go mod graph
把依赖固化进仓库go mod vendor;目录存在时构建默认就用它,想绕开写 -mod=mod
多模块本地联调go work init ./a ./b
v2 及以上模块路径必须带后缀:example.com/app/v2
伪版本v0.0.0-20250102150405-abcdef123456:没打 tag 时按提交时间 + 哈希
+incompatible对方发了 v2+ 的 tag 却没改模块路径
选版本的规则MVS 最小版本选择:取各方要求里最高的那个最低版本,不会偷偷升级

相关环境变量

变量作用
GOPROXY模块代理,默认 https://proxy.golang.org,direct;国内常设 https://goproxy.cn,direct
GOPRIVATE私有仓库通配符:一次关掉代理与校验(等于同时设下面两个)
GONOPROXY / GONOSUMDB分别控制「不走代理」「不查校验和」
GOSUMDB / GOFLAGS校验和数据库 / 给所有 go 命令加默认参数
GOMODCACHE / GOCACHE模块缓存 / 构建缓存目录
GOTOOLCHAINauto 自动下载、local 只用本机那个
GOWORK=off临时忽略 go.work
GOVCS / GOINSECURE允许的版本控制系统 / 允许 http

写法:go env -w GOPROXY=https://goproxy.cn,direct 会持久化到 go env GOENV 指的那个文件。

replace 只在主模块里生效:你在库里写的 replace,别人依赖这个库时完全不起作用。所以它只适合本地联调与临时打补丁,提交前记得撤掉,否则 CI 上会因为找不到那个本地路径而失败。长期替换要用 fork + 正式版本号。
模块路径要写成真实可访问的地址(github.com/you/app),哪怕暂时不开源。写成 myapp 这种裸名字,将来被别的模块 import 时无路可走,只能改名——而改模块路径等于改所有人的 import。

Go 的可见性只有一条规则:首字母大写即导出——没有 public/private 关键字,也就没有第二种约定。

包、导入与导出规则

每个 .go 首行声明包名。首字母大小写决定可见性(没有 public/private 关键字):大写=导出(跨包可访问),小写=包内私有。internal/ 是特殊目录,只能被同模块代码导入,用于隐藏实现、划清 API 边界。import 可起别名解决命名冲突。

package models

type User struct {
  Name  string     // 大写:导出
  email string     // 小写:包内私有
}

func NewUser(name string) *User { // 导出构造函数
  return &User{Name: name}
}

import (
  "fmt"                       // 标准库
  "myapp/internal/models"     // 本项目(internal 仅本模块可导)
  "github.com/gin-gonic/gin"   // 第三方

  pgx "github.com/jackc/pgx/v5" // 别名导入
)
import 路径的最后一段是目录名,代码里用的却是文件里 package 声明的包名,两者不一致时最容易混淆。目录叫 utilhelpers/ 但文件写的是 package myutil,那么 import "example.com/liba/utilhelpers" 之后必须写 myutil.Do();写成 utilhelpers.Do() 会同时报 undefined: utilhelpers 和 "...utilhelpers" imported as myutil and not used,两条错误其实是同一个原因,很容易去改 import 路径、越改越错。
省事的做法:目录名和包名保持一致;确实不一致时在 import 里显式写别名 myutil "example.com/liba/utilhelpers"。
未使用的 import 会编译失败。让编辑器在保存时跑 goimports 自动增删并分组 import,比手动管理省心得多。

init() 是唯一在 main 之前自动跑的东西,顺序由依赖图决定——好用,也最容易埋隐式耦合。

初始化顺序与它的代价

一个包被加载时,Go 按固定顺序完成初始化:① 先算所有包级变量(按依赖顺序,不是书写顺序);② 再执行该包所有的 init() 函数;③ 被依赖的包先于依赖它的包初始化,main 包最后。init() 无参数无返回值、不能被手动调用,一个文件里可以写多个。典型用途:注册驱动 / 插件(如 database/sql 靠匿名导入触发驱动的 init 把自己注册进去)、校验或预计算启动期必需的全局状态。

package config

var defaults = loadDefaults()   // ① 包级变量先算

func init() {                   // ② 再跑 init(可有多个)
  if os.Getenv("APP_ENV") == "" {
    log.Fatal("APP_ENV 必填")   // 启动期校验,缺失就别启动
  }
}

// 匿名导入:只为触发对方包的 init(注册驱动),不直接用它的符号
import _ "github.com/lib/pq"   // pq 的 init 里 sql.Register("postgres", ...)
init() 里别做重活或依赖外部服务(连数据库、发网络请求):它在 main 之前跑,出错只能 log.Fatal 硬崩、无法优雅处理,也拖慢启动、给测试添乱。可延迟的初始化用 sync.Once 懒加载(见 07 章)更可控。
匿名导入 import _ "pkg" 是专门「只要副作用(它的 init),不要它的符号」的写法——否则未使用的 import 会编译报错。数据库驱动、图片格式解码器(image/png 等)都靠这招注册自己。

module 解决的是「依赖版本谁说了算」;目录约定解决的是「哪些代码允许被别人 import」。

go.mod、版本选择与目录约定

go mod init <module-path> 创建 go.mod;require 锁定依赖版本,go.sum 存校验和。go mod tidy 自动增补缺失、清理多余依赖。约定结构:cmd/ 放入口、internal/ 放私有实现、pkg/ 放可被外部复用的公共代码。

go mod init github.com/me/myapp

// go.mod
module github.com/me/myapp
go 1.25
require (
  github.com/gin-gonic/gin v1.12.0
  gorm.io/gorm v1.31.0
)

go mod tidy                 // 增补/清理依赖
go get pkg@v1.2.3           // 装指定版本
go build ./cmd/server       // 编译成单一可执行文件

// 约定结构
// myapp/
//   cmd/server/main.go   入口
//   internal/            私有实现(handler/service/repo)
//   pkg/                 可外部复用
两个坑:① 用 go get 装了、但代码里还没 import 的依赖,下一次 go mod tidy 会直接从 go.mod 删掉——go get github.com/google/uuid 之后不写 import,tidy 完整个 require 块凭空消失,再 build 就报找不到包。顺序要反过来:先写 import,再 tidy。
② 手改 go.mod 里的 go 指令行要当心:改成 go 1.99 后执行 build,Go 不是报错,而是去网上下载对应版本的工具链(go: downloading go1.99.0 ... toolchain not available)——在无外网的 CI 里就表现为一次莫名其妙的构建失败。
单一静态二进制是 Go 的部署王牌:go build 产出无外部依赖的可执行文件;加 CGO_ENABLED=0 编译后能塞进 scratch 空镜像,Docker 镜像可小到几 MB。

标准库:深水区

下一章按包逐个列 API,这一章是深水区——讲那些查文档查不到、但不知道就会错的事。Go 标准库的设计一贯克制,可正因为克制,它把不少判断留给了你:time 的格式串用的是一个具体时刻而不是占位符、写错了还不报错;json 的 omitempty 判据是「零值」而不是「没设置」;sort.Slice 从第 13 个元素开始就不稳定了,元素少的时候测不出来;len(字符串) 给的是字节数不是字符数。这些都是契约问题,不是用法问题。本章五张卡各挑一个包的契约讲透,代码与输出都在 Go 1.25 上真跑过;「这个函数怎么调」去下一章「标准库速查」。

Go 的时间格式化是全世界独一份的设计:格式串不是占位符,而是一个具体的时刻。第一次见到 t.Format("2006-01-02") 的人没有不困惑的,而它的坑在于——写错了不会报错。

参考时刻:为什么是 2006-01-02 15:04:05

  • 这串数字不是随便选的,它按顺序是 1 2 3 4 5 6:
    01 → 月    02 → 日    03 → 时(12 小时制)
    04 → 分    05 → 秒    06 → 年
    15 → 时(24 小时制)   -07:00 → 时区   Mon / Jan → 星期与月名
  • 设计意图是「用例子代替记忆」:你不用背 %Y 是年还是 %y,直接把想要的样子照着这个时刻写出来。Format("01/02 03:04PM") 对 2025-03-07 15:04:05 给出 03/07 03:04PM。

坑在这里:写错了不报错,原样输出

  • 带着别的语言的习惯写 t.Format("%Y-%m-%d"),Go 不会报错、不会 panic,而是把它当成普通文本原样打出来:
    t.Format("%Y-%m-%d")  →  %Y-%m-%d
  • 同理,把 15(24 小时制)误写成 03(12 小时制)也不会有任何提示,只是下午三点被打成 03——日志里看着像凌晨三点,而且要到出问题时才会有人发现。
  • 所以格式串值得用常量集中定义,或者直接用标准库预置的(time.RFC3339 是跨系统传输的默认选择)。

Duration 是有类型的整数,不是数字

  • time.Duration 底层是纳秒计的 int64,但它有自己的类型和 String():90 * time.Second 打印出来是 1m30s,不是 90000000000。
  • 要拿浮点秒数用 .Seconds();别拿一个裸 int 去乘——n * time.Second 里 n 必须是 time.Duration 或无类型常量,变量得写 time.Duration(n) * time.Second。

比较时间:用 Equal,不要用 ==

  • time.Time 是结构体,== 比的是全部字段——包括时区指针和单调时钟读数。
    t2 := t1.UTC()
    t1 == t2       →  false     ← 同一个时刻,却不相等
    t1.Equal(t2)   →  true      ← 这才是「是不是同一时刻」
  • time.Now() 返回的值额外带一份单调时钟读数(用于计算间隔时不受系统改时间影响)。经过序列化、UTC()、Round() 之后这份读数会被剥掉,于是 == 的结果随处理路径而变。
  • 规则很简单:判断时刻是否相同一律用 Equal,先后用 Before/After,算间隔用 Sub 或 time.Since。
// 参考时刻:01 月 02 日 03 时(12h) 04 分 05 秒 06 年,15 是 24 小时制
t := time.Date(2025, 3, 7, 15, 4, 5, 0, time.UTC)

t.Format("2006-01-02 15:04:05")   // 2025-03-07 15:04:05
t.Format("01/02 03:04PM")         // 03/07 03:04PM
t.Format(time.RFC3339)             // 跨系统传输用这个
t.Format("%Y-%m-%d")             // %Y-%m-%d —— 不报错,原样输出!

// 解析:布局串写法完全一样
ts, err := time.Parse(time.RFC3339, "2025-03-07T15:04:05Z")

// Duration 是带类型的 int64,打印有单位
d := 90 * time.Second        // 打印为 1m30s
d.Seconds()                   // 90(float64)

var n int = 30
// timeout := n * time.Second     // 编译错误:类型不匹配
timeout := time.Duration(n) * time.Second   // 变量要显式转换

// 比较:Equal / Before / After,别用 ==
if t.Equal(other) { }
if t.Before(deadline) { }
elapsed := time.Since(start)   // 等价于 time.Now().Sub(start)
time.After 会不会漏,取决于你的 go.mod 写了哪个版本。Go 1.23 起,未被引用的定时器可以立即被 GC 回收,不必等它触发——在 select 里跑 20 万轮 time.After(30 * time.Second),GC 后存活堆对象从 258 只涨到 279。但这个改动由 go.mod 里的 go 指令门控(和循环变量那次一样):把它改回 go 1.21 再跑同一份代码,存活堆对象涨到 60 万。所以网上「time.After 必漏」的说法对老项目仍然成立,对新项目已经过时——先看你的 go 指令。
time.Ticker 则不在此列:它会反复触发,始终被运行时引用,不 Stop() 就一直不回收,循环里用完务必 defer ticker.Stop()。
另外 time.Parse 不带时区信息的输入会被当成 UTC,不是本地时区。要按本地解析得用 time.ParseInLocation。
存储和传输一律用 UTC,只在展示给人看的最后一步转本地时区。数据库里存带时区的本地时间,是跨时区服务最常见的一类脏数据来源——夏令时切换那天甚至会出现同一个本地时刻对应两个真实时刻。time.Now().UTC() 落库、t.In(loc) 展示,中间所有计算都在 UTC 上做。

encoding/json 好用到让人忘记它有契约。三条最常踩的:字段必须导出、omitempty 判的是零值不是「有没有设置」、反序列化进 interface{} 时所有数字都变成 float64。

只有导出字段会被处理

  • json 靠反射工作,而反射读不到小写开头的字段。所以结构体里的 name string 既不会被序列化,也不会被反序列化——而且不会有任何报错,你只会看到 JSON 里少了一块。
  • 这也是为什么所有要走 JSON 的结构体字段都得大写开头,再用 tag 指定小写的键名。

omitempty 判的是零值

一个所有字段都是零值的结构体:

User{Name: "a", Age: 0, Admin: false, Tags: []string{}}
  →  {"name":"a"}
  • Age: 0、Admin: false、甚至空切片 []string{} 全被省略了。
  • 问题在于:「年龄是 0」和「没填年龄」在 Go 里是同一个值,omitempty 分不清。同理「false」和「没设置」也分不清——一个布尔开关加上 omitempty,就永远发不出 false 这个值。
  • 真的需要区分「零」和「没有」,用指针(*int / *bool):nil 表示没有,指向 0 表示真的是 0。代价是每次读都要判空。

反序列化进 interface{}:数字全是 float64

  • 没有目标结构体、直接解进 interface{} 时,JSON 的数字一律变成 float64,不管它看着多像整数:
    {"n": 42}  →  m["n"] 的类型是 float64
  • 于是 m["n"].(int) 会直接 panic——必须先断言成 float64 再转。
  • 更严重的是精度:float64 只能精确表示 2⁵³ 以内的整数。10000000000000001 解出来变成 1e+16——末位的 1 没了,而且没有任何报错。雪花 ID、订单号这类大整数走这条路一定出事。
  • 解法:能定义结构体就定义结构体(int64 字段会被正确解析);实在要用动态解析,就给 Decoder 开 UseNumber(),数字会保留成 json.Number(本质是字符串),再按需转 Int64()。
type User struct {
    Name  string   `json:"name"`
    Age   int      `json:"age,omitempty"`     // 0 会被省略
    Admin bool     `json:"admin,omitempty"`   // false 会被省略!
    Notes string   `json:"-"`                  // 永不参与
    id    string                                // 小写:反射看不见,静默忽略
}

// 要区分「零」和「没有」,用指针
type Patch struct {
    Age   *int   `json:"age,omitempty"`    // nil=没填, &0=真的是 0
    Admin *bool  `json:"admin,omitempty"`
}

// 动态解析:数字默认全是 float64
var v interface{}
json.Unmarshal(data, &v)
m := v.(map[string]interface{})
// n := m["n"].(int)        // panic:实际是 float64
n := int(m["n"].(float64))

// 大整数要保精度:UseNumber
dec := json.NewDecoder(r)
dec.UseNumber()                      // 数字保留为 json.Number
dec.Decode(&v)
id, err := m["id"].(json.Number).Int64()

// 大流用 Decoder/Encoder,别把整个文件读进内存再 Unmarshal
for dec.More() {
    var u User
    if err := dec.Decode(&u); err != nil { break }
}
Unmarshal 不会清空目标。把 JSON 解进一个已经有值的结构体时,JSON 里没出现的字段保持原样而不是被置零。复用同一个变量循环解析多条记录时,上一条的残留会串到下一条上——循环里每次都新建一个变量,别在循环外声明一次反复用。
另外切片和 map 是合并而不是替换的一部分场景也源于此。而 Marshal 侧则相反:nil 切片序列化成 null,空切片序列化成 []——前端拿到 null 去 .map() 就炸了,所以返回列表前习惯写 if s == nil { s = []T{} }。
tag 写错不会报错。`json:"name"` 少写一个引号、把冒号写成等号、或者在逗号后多打一个空格(`json:"age, omitempty"`),编译和运行都不会有任何提示,只是那条规则静默失效——最后一种尤其隐蔽,omitempty 会被当成一个叫「空格 omitempty」的未知选项而失效。go vet 能查出一部分结构体标签问题,值得在 CI 里跑。

「sort.Slice 不保证稳定」写在文档里,但没人真当回事——因为写单元测试时它看起来是稳定的。下面给出那个具体的边界,它比任何文档警告都有说服力。

边界是 12 / 13

构造一批 key 大量重复的元素,记下原始下标,分别用 sort.Slice 和 sort.SliceStable 排,看相等元素的原有次序有没有被打乱:

n=10     sort.Slice 保持原序? true     与 SliceStable 结果相同? true
n=12     sort.Slice 保持原序? true     与 SliceStable 结果相同? true
n=13     sort.Slice 保持原序? false    与 SliceStable 结果相同? false
n=20     保持原序? false
n=1000   保持原序? false
  • 12 个及以下稳定,13 个开始不稳定。原因是 Go 的排序是 pdqsort,元素少于某个阈值时直接退化成插入排序,而插入排序恰好是稳定的。
  • 这就是最危险的形态:你的单元测试喂了 5 条数据,全过;线上来了 13 条,顺序就变了。而且不是每次都变、不是每条都变,排查时极难联想到排序函数。
  • 这个阈值是实现细节,不是承诺——不要反过来依赖「小切片是稳定的」,下个版本就可能变。

该用哪个

  • 需要稳定就明写 sort.SliceStable。它慢一些(多一点内存搬运),但「慢一点」和「顺序偶尔错」不是一个量级的问题。
  • 更好的办法是让排序键唯一:比较函数里加一个决胜字段(原始下标、ID、创建时间),这样稳不稳定都不影响结果,还能顺便让排序变得可复现。
  • Go 1.21 起有了泛型版的 slices 包:slices.SortFunc(不稳定)/slices.SortStableFunc(稳定)。新代码优先用 slices——类型安全、不用写 func(i, j int) 这种按下标比较的形式,也就少了一类「排 a 却比 b」的低级错误。

比较函数的契约

  • sort.Slice 收的是 less(i, j int) bool——严格小于,相等时必须返回 false。写成 <= 会让「相等」同时满足两个方向,破坏全序,结果是未定义的(可能顺序乱,也可能死循环)。
  • slices.SortFunc 换成了三态的 cmp(a, b E) int(负/零/正),语义更清楚。整数直接用标准库的 cmp.Compare(a, b),别写 a - b——大整数相减会溢出,和 C 那边 qsort 的坑一模一样。
  • 浮点要小心 NaN:任何与 NaN 的比较都是 false,会破坏传递性。
import ("cmp"; "slices"; "sort")

// Go 1.21+ 首选:泛型、类型安全、三态比较
slices.SortFunc(users, func(a, b User) int {
    return cmp.Compare(a.Age, b.Age)      // 别写 a.Age - b.Age
})

// 需要稳定就明写 Stable 版
slices.SortStableFunc(users, func(a, b User) int {
    return cmp.Compare(a.Age, b.Age)
})

// 更稳妥:让键唯一,稳不稳定都不影响结果
slices.SortFunc(users, func(a, b User) int {
    if c := cmp.Compare(a.Age, b.Age); c != 0 {
        return c
    }
    return cmp.Compare(a.ID, b.ID)          // 决胜字段
})

// 老 API:less(i, j) 必须是【严格】小于,相等返回 false
sort.Slice(users, func(i, j int) bool {
    return users[i].Age < users[j].Age    // 写 <= 会破坏全序
})

// slices 包顺带的实用函数(都不需要预排序,除了 BinarySearch)
slices.Contains(s, v)
slices.Index(s, v)
slices.Reverse(s)
i, found := slices.BinarySearch(sorted, v)   // 前提:已排序
slices.BinarySearch 和 sort.Search 都要求切片已按同一个序排好,而它们不会检查——在未排序的切片上它们会安静地返回「没找到」,尽管元素就在里面。这一点和 C 的 bsearch 完全一样:危险的方向是漏查,不是查错。
另外注意 slices.Sort 系列就地修改原切片。如果那个切片是从别处(函数参数、结构体字段、map 的值)拿来的,你排的是人家的数据——需要保留原序就先 slices.Clone。
「顺序偶尔不对」这类线上问题,先怀疑排序键不唯一。分页尤其典型:按「创建时间」排序而同一秒有多条记录时,第 1 页和第 2 页可能重复或漏掉同一条数据——因为两次查询各自排了一遍,相等元素的次序不一定一致。分页排序键必须唯一(补一个 ID 作决胜字段),这条对数据库排序同样成立。

Go 的字符串是只读的字节切片,编码固定为 UTF-8。这个设计很干净,但它意味着「字符」这个概念在语言层面并不存在——你拿到的要么是字节,要么是码点,得自己分清用哪个。

三个层次

  • len(s) 是字节数,不是字符数。len("héllo") 是 6(é 占两个字节),而字符数是 5。
  • s[i] 取到的是一个 byte(uint8)。"héllo"[1] 是 195——那是 é 的第一个字节,不是一个完整字符。按下标切中文字符串,切出来的是乱码。
  • for i, r := range s 才是按字符遍历:r 是 rune(即 int32,一个 Unicode 码点),i 是该字符起始位置的字节下标(所以 i 不连续)。
  • 要按字符随机访问就转 []rune(s)——但这会完整拷贝一遍,只为求个长度的话用 utf8.RuneCountInString(s) 更省。
  • 再往上还有一层:「用户看到的一个字符」可能由多个码点组成(emoji、带音标的组合字符)。真要按人眼的字符处理,标准库不够用,得上 golang.org/x/text。

拼接:Builder 不是「优化」,是唯一正确的写法

  • 字符串不可变,所以 s += x 每次都要新分配一块内存并把旧内容整个拷过去。循环里拼 n 段,总代价是 O(n²)。
  • 拼 20000 段(每段 10 字节):
    s += "..."          20086 次内存分配
    strings.Builder        25 次内存分配
    分配次数差了三个数量级,耗时差距同样在三个数量级上(具体倍数看机器,自己跑一下)。
  • 分配次数是确定的、和机器无关:Builder 用的是切片式的倍增扩容,20000 次写入只需要约 25 次扩容。
  • 什么时候可以不管:拼固定的两三段时随便写,差别可以忽略。只要有循环,就用 Builder。已知总长度还可以先 Grow(n) 把那 25 次也省掉。

选哪个包

  • strings:查找、切分、替换、大小写。strings.Builder 拼接。
  • strconv:字符串与数字互转。Atoi 会返回 error(这一点比 C 的 atoi 强得多),Atoi("12abc") 返回 strconv.Atoi: parsing "12abc": invalid syntax,溢出则返回 value out of range 且值为 MaxInt64。返回值和 error 都要看。
  • bytes:和 strings 几乎同名的一套 API,但作用于 []byte。处理 I/O 数据时用它能省掉一次转换拷贝(string(b) 和 []byte(s) 都会复制内容)。
  • fmt.Sprintf 最灵活但也最慢(走反射)。热路径上把 Sprintf("%d", n) 换成 strconv.Itoa(n) 是很实在的一笔。
s := "héllo"

len(s)                        // 6 —— 字节数,不是字符数
s[1]                         // 195 —— 一个 byte,不是 'é'
utf8.RuneCountInString(s)      // 5 —— 字符(码点)数
[]rune(s)[1]                  // 'é' —— 但转换会整体拷贝

// 按字符遍历:i 是字节下标(不连续),r 是码点
for i, r := range s {
    fmt.Printf("%d:%c ", i, r)   // 0:h 1:é 3:l 4:l 5:o
}

// 拼接:只要有循环就用 Builder
var sb strings.Builder
sb.Grow(200000)                 // 已知总长可以预分配
for _, part := range parts {
    sb.WriteString(part)
}
result := sb.String()

// 数字转换:返回值和 error 都要看
n, err := strconv.Atoi("12abc")
// err: strconv.Atoi: parsing "12abc": invalid syntax

// 热路径上别用 Sprintf 做简单转换
s1 := fmt.Sprintf("%d", n)      // 走反射,慢
s2 := strconv.Itoa(n)          // 直接得多
按下标或字节长度切分字符串,遇到非 ASCII 就会切出乱码。s[:10] 截的是前 10 个字节,很可能把一个多字节字符从中间劈开,结果是一个无效的 UTF-8 序列(打印出来是 �)。做「摘要截断」这类需求时,要么先转 []rune 再切,要么用 utf8.DecodeLastRuneInString 往回退到字符边界。
另一个相关的坑:strings.Split(s, "") 按字符切而不是字节,和 s[i] 的行为不一致——这两套心智模型混用是乱码的常见来源。
string 和 []byte 互转会拷贝内容(因为 string 不可变、[]byte 可变,共享内存就不安全了)。所以在「读字节 → 转 string → 处理 → 转回字节」这样的链路里,你可能白白拷了好几遍。数据本来就是 []byte 的话,全程用 bytes 包处理,只在最后需要交给 string 接口时转一次。编译器对 map[string(b)] 这类特定写法有免拷贝优化,但别指望它覆盖所有情况。

这两个包都很小,但它们定义的是整个 Go 生态共同遵守的约定——不按约定写,你的错误别人判断不了,你的取消信号传不下去。

errors:包装链与两种判断

  • 加上下文用 %w,不是 %v。fmt.Errorf("读配置: %w", err) 会把原错误挂在链上,用 %v 则只是把它变成一段文本,原错误就此丢失,上层再也判断不出它是什么。
  • 判断用两个函数,分工明确:errors.Is(err, target) 问「链上有没有这个特定的错误值」(io.EOF、sql.ErrNoRows、context.DeadlineExceeded 这类哨兵值);errors.As(err, &target) 问「链上有没有这个类型的错误」,有就把它取出来读字段。
  • 永远别用 err.Error() == "..." 或 strings.Contains(err.Error(), ...) 判断错误——错误文案是给人看的,随时会改。
  • 一个错误可以包装多层,Is/As 会沿着整条链找下去。Go 1.20 起 Errorf 支持多个 %w,可以合并多个错误。

context:取消的契约

  • context.Context 是函数的第一个参数,名字叫 ctx。这不是风格建议,是整个生态的硬约定——标准库、数据库驱动、HTTP 客户端全都按这个来。
  • 谁创建谁负责 cancel。WithCancel/WithTimeout/WithDeadline 都返回一个 cancel 函数,必须调用,通常写成 defer cancel()。不调用会泄漏——父 context 会一直持有这个子节点,定时器也不会被释放。go vet 能查出一部分漏调用。
  • 取消之后 ctx.Err() 告诉你原因,两个值:超时是 context deadline exceeded,主动取消是 context canceled。用 errors.Is(err, context.DeadlineExceeded) 判断(它们支持包装链,所以经过多层封装依然判得出来)。
  • 取消是「广播」不是「命令」:cancel() 只是关掉 Done() 通道,正在跑的 goroutine 必须自己去 select 它才会停。一个不检查 ctx.Done() 的循环,取消了也照跑不误。

context 的三条禁忌

  • 别把 ctx 存进结构体字段,一律显式传参。存进去就意味着这个对象绑定了某一次调用的生命周期,复用时会拿到一个早已取消的 context。(http.Request 是历史遗留的例外。)
  • context.WithValue 只用来传「请求作用域的元数据」——trace ID、认证信息这类。别拿它传函数参数:类型不安全(取出来是 interface{})、编译期查不出、调用方看不出这个函数到底需要什么。key 要用自定义的非导出类型,直接用字符串会和别的包撞。
  • 别传 nil context。不确定用什么就 context.Background()(顶层)或 context.TODO()(暂时没想好,留个记号)。
// 包装:用 %w 保住原错误
if err != nil {
    return fmt.Errorf("读配置 %s: %w", path, err)   // %w 不是 %v
}

// 判断哨兵值:Is 会沿着整条包装链找
if errors.Is(err, sql.ErrNoRows) { }
if errors.Is(err, context.DeadlineExceeded) { }

// 判断错误类型并取出字段:As
var pathErr *os.PathError
if errors.As(err, &pathErr) {
    log.Println("出问题的路径:", pathErr.Path)
}

// 自定义哨兵错误
var ErrNotFound = errors.New("not found")

// ---------- context ----------
// 创建方负责 cancel,一律 defer
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()                     // 漏了就泄漏

// ctx 永远是第一个参数
func FetchUser(ctx context.Context, id int64) (*User, error) {
    select {
    case <-ctx.Done():
        return nil, ctx.Err()      // deadline exceeded / canceled
    case r := <-work:
        return r, nil
    }
}

// WithValue 的 key 用自定义类型,别用裸字符串
type ctxKey struct{}
ctx = context.WithValue(ctx, ctxKey{}, traceID)
被包装过的 nil 不是 nil。这是 Go 最著名的陷阱之一:把一个值为 nil 的具体类型指针赋给 error 接口,接口本身就不是 nil 了(它有类型信息),于是 if err != nil 成立、调用者以为出错了。所以函数返回 error 时,成功路径要直接 return nil,别 return 一个可能为 nil 的自定义错误变量。
context 那边对应的坑是:ctx.Done() 关闭后,你的清理逻辑本身可能还需要一个可用的 context——这时不能再用那个已取消的 ctx 去做收尾(比如回写状态),要用 context.WithoutCancel(ctx)(Go 1.21+)或一个新的带超时的 context。
包装与直接返回的界线:只有当你能加上调用方不知道的信息时才包装(哪个文件、哪个 ID、第几次重试)。单纯 return fmt.Errorf("%w", err) 什么也没加,只是让错误信息变长。
反过来,库的边界上要小心别把内部实现漏出去——把 sql.ErrNoRows 原样包装着往外抛,调用方就被迫依赖你用了 SQL 数据库。这种地方应该转换成自己定义的哨兵错误。

标准库速查

第一张卡是全库地图(按用途分组,一行一个包),后面按包逐个列常用 API:net/http、fmt、strings、strconv、slices、os/io/bufio、time、errors/sync/context、encoding/json、log/slog。「不知道就会错」的语义契约在上一章(10 章)。

先用这张总表定位:整个标准库压成一屏,按用途分组,每行一个包配一句「干什么 + 高频 API」,标 ★ 的包本章后面有专卡详解,其余按需查文档。

// —— 字符串 · 格式化 · 文本 ——
fmt.Sprintf("%s=%d", name, age)               // 格式化 I/O:Printf / Errorf(%w) / Sscanf  ★
strings.Split("a,b,c", ",")                   // 查找 / 切分 / 替换 / 拼接 Builder  ★
strconv.Atoi("42")                            // 字符串↔数字:Itoa / ParseFloat / FormatInt  ★
bytes.NewBuffer(nil)                          // []byte 版 strings,可变缓冲 Buffer
utf8.RuneCountInString("héllo")               // rune 与 UTF-8:DecodeRune(unicode/utf8)
regexp.MustCompile("[0-9]+")                  // 正则:FindStringSubmatch / ReplaceAllString
template.New("t").Parse(src)                  // text/template 文本模板:Execute

// —— 集合 · 排序 · 算法 ——
slices.Sort(xs)                               // 切片泛型 1.21+:Contains / Index / BinarySearch  ★
maps.Clone(m)                                 // map 泛型 1.21+:Keys / Values / Equal  ★
sort.Slice(xs, less)                          // 泛型前老 API:Search / Ints / Strings  ★
cmp.Compare(a, b)                             // 有序比较辅助:Ordered / Or(配 slices)
heap.Push(&h, 3)                              // container/heap 堆;list 双向链表

// —— 文件 · I/O · 路径 ——
os.ReadFile("cfg.json")                       // 文件 / 环境 / 进程:Open / Getenv / Args / Exit  ★
io.Copy(dst, src)                             // 流式接口:Reader / Writer / ReadAll  ★
bufio.NewScanner(os.Stdin)                    // 缓冲读写、按行扫描:NewReader / Writer  ★
filepath.Join("a", "b.txt")                   // 跨平台路径:Base / Ext / Walk(path/filepath)
os.DirFS("assets")                            // io/fs 只读文件系统;//go:embed 编译期嵌入

// —— 编码 · 序列化 ——
json.Marshal(v)                               // JSON:Unmarshal / NewEncoder / Decoder  ★
xml.Marshal(v)                                // encoding/xml、encoding/csv 同风格
base64.StdEncoding.EncodeToString(b)          // base64 / hex:二进制↔文本
gob.NewEncoder(w)                             // encoding/gob:Go 专用二进制序列化

// —— 网络 · Web ——
http.ListenAndServe(":8080", mux)             // HTTP 服务 / 客户端:ServeMux / Get / Client  ★
url.Parse("https://x?q=1")                    // net/url:Values / QueryEscape
net.Dial("tcp", addr)                         // net 底层:Listen / LookupHost
template.ParseFiles("p.html")                 // html/template:自动转义防 XSS

// —— 并发 · 时间 ——
sync.WaitGroup{}                              // 锁 / 等待组 / 单次:Mutex / RWMutex / Once / Map  ★
atomic.Int64{}                                // sync/atomic 无锁原子:Add / CompareAndSwap
context.WithTimeout(ctx, d)                   // 取消 / 超时 / 传值:WithCancel / Done  ★
time.Since(start)                             // 时刻 / 时长 / 定时:Now / Format / Ticker / After  ★

// —— 错误 · 日志 · 运行时 · 数学 · 工具 ——
errors.Is(err, ErrNotFound)                   // 错误链:New / As / Join / Unwrap  ★
slog.Info("msg", "k", v)                      // log/slog 结构化日志 ★;log.Fatal 简单日志
runtime.NumGoroutine()                        // 运行时 / GC:GOMAXPROCS;reflect 反射(慎用)
math.Sqrt(2); rand.Intn(6)                    // math / math/rand / math/big 大数
sha256.Sum256(b)                              // crypto/sha256 哈希;crypto/rand 安全随机
flag.Parse()                                  // flag 命令行参数;exec.Command 跑外部命令
t.Run("case", fn)                             // testing 单测 / 基准 / 模糊:httptest(↓见 12 章)
同名不同命的 rand:math/rand 生成的是可预测的伪随机数(种子固定则序列固定),只配做模拟 / 采样;一切安全用途——token、密码盐、会话 ID——必须用 crypto/rand(密码学安全的),否则可被预测攻破。两者 import 路径只差一截,极易拿错。
记不住包名很正常——终端里 go doc strings.Split 直接查任意包 / 函数的签名与文档,go doc -all net/http 列全包;想交互式浏览就上 pkg.go.dev,每个函数都带官方可运行示例。

Go 的标准库自带一个能上生产的 HTTP 服务器——这在主流语言里是少数派,也是 Go 在服务端流行的直接原因之一。

从 Handler 到 Server

标准库 net/http 足以构建生产服务。Go 1.22+ 的 ServeMux 原生支持方法 + 路径参数("GET /users/{id}",用 r.PathValue("id") 取值)。handler 签名固定 (w http.ResponseWriter, r *http.Request);中间件用"函数包装函数"实现。

type User struct {
  ID   int    `json:"id"`
  Name string `json:"name"`
}

func getUser(w http.ResponseWriter, r *http.Request) {
  id := r.PathValue("id")        // Go1.22+ 路径参数
  log.Printf("查询用户 id=%s", id)  // 用上路径参数(否则未使用会编译报错)
  u := User{ID: 1, Name: "Alice"}
  w.Header().Set("Content-Type", "application/json")
  json.NewEncoder(w).Encode(u)
}

func main() {
  mux := http.NewServeMux()
  mux.HandleFunc("GET /users/{id}", getUser)
  mux.HandleFunc("POST /users", createUser)
  http.ListenAndServe(":8080", mux)
}

// 中间件:函数包装函数
func logging(next http.Handler) http.Handler {
  return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    start := time.Now()
    next.ServeHTTP(w, r)
    log.Printf("%s %s %v", r.Method, r.URL.Path, time.Since(start))
  })
}
w.Header().Set(...) 必须在任何 Write / WriteHeader 之前调用,晚一步就彻底失效、且不报任何错。把 json.NewEncoder(w).Encode(u) 写在 Set("Content-Type", "application/json") 前面,真实响应头变成 Go 自动嗅探出的 text/plain; charset=utf-8——body 是合法 JSON,服务端一切正常,前端却按纯文本处理。
同理,写完 body 再 WriteHeader(500) 也无效:状态码停在 200,只在服务端日志留一行 http: superfluous response.WriteHeader call。顺序永远是先设头、再定状态码、最后写 body。
简单服务、少量中间件就用标准库,零依赖、零学习成本。等到需要路由分组、自动绑定校验、海量现成中间件时,再上 Gin(见下一节)——Gin 本身也是建在 net/http 之上的。

格式化输入输出全家桶(基础打印详见 02 章):Print 系打印到 stdout,Sprintf 返回字符串,Fprintf 写入任意 Writer,Errorf 造带 %w 的错误,Scan/Sscan/Sscanf 反向解析。下面每个函数各配一行最小示例。

// —— 打印到 stdout ——
name, age := "Alice", 30
fmt.Print("no newline")                // 不换行,参数间按需补空格
fmt.Println("hi", name)                 // hi Alice(参数间自动空格 + 换行)
fmt.Printf("%s is %d\n", name, age)    // 按动词格式化 %d %s %v %+v %T

// —— 格式化为字符串 / 写入 Writer ——
s := fmt.Sprintf("%s-%03d", name, age) // 返回字符串而非打印,最常用
fmt.Fprintf(w, "id=%d\n", 1)           // 写入任意 io.Writer(文件 / http.ResponseWriter)

// —— 造错误 ——
err := fmt.Errorf("open: %w", base)    // %w 包裹底层 error,供 errors.Is/As 解包

// —— 反向解析 ——
var x, y int
fmt.Scan(&x, &y)                         // 从 stdin 按空白分词读值
fmt.Sscan("3 5", &x, &y)                // 从字符串按空白分词读值 → 3 5
var maj, min int
fmt.Sscanf("v1.22", "v%d.%d", &maj, &min) // 从字符串按格式反解 → 1 22
Printf 的动词和参数对不上不会编译报错,只会在输出里留下畸形串。fmt.Printf("%d %s", "hello", 42) 打出 %!d(string=hello) %!s(int=42);少给参数是 %!s(MISSING),多给是 %!(EXTRA string=two),把 %w 误用在 Printf 里则是 %!w(...)。这些串常年混在日志里没人细看,直到线上排查时发现关键字段全是乱码。
好在 go vet 全都抓得住——上面每一条它都精确报出了行号和原因,把 go vet ./... 挂进 CI 这类问题就绝迹了。
%v 打印任意值的默认格式,%+v 连带 struct 字段名,%#v 打 Go 语法表示,调试时先摸出这三个动词就够用。

strings 提供字符串查找、切分、替换、裁剪(字符串不可变,均返回新串)与高效拼接 Builder;strconv 负责字符串与数字互转。下面每个函数各配一行最小示例。

// —— strings:判断与查找 ——
strings.Contains("gopher", "go")      // true
strings.HasPrefix("gopher", "go")     // true:前缀判断
strings.HasSuffix("main.go", ".go")   // true:后缀判断
strings.Index("gopher", "ph")         // 3:首次出现下标,无则 -1

// —— strings:切分与拼接 ——
strings.Split("a,b,c", ",")             // [a b c]
strings.Join([]string{"a", "b"}, "-")  // "a-b"
strings.Fields("  a  b c ")           // [a b c]:按连续空白切分
strings.Repeat("ab", 3)                 // "ababab"

// —— strings:替换与裁剪 ——
strings.Replace("a.b.c", ".", "/", 1) // "a/b.c":替换前 n 处(-1 全部)
strings.ReplaceAll("a.b.c", ".", "/") // "a/b/c"
strings.TrimSpace("  hi  ")            // "hi":去两端空白
strings.Trim("[hi]", "[]")            // "hi":去两端属于 cutset 的字符
strings.ToUpper("go")                   // "GO"
strings.ToLower("GO")                   // "go"

// —— strings.Builder:高效拼接,免 += 反复分配 ——
var b strings.Builder
b.WriteString("id=")                  // 累积字符串
b.WriteByte('!')                       // 累积单字节
s := b.String()                          // 取最终结果 "id=!"

// —— strconv:字符串 ↔ 数字 ——
n, err := strconv.Atoi("42")          // string → int,最常用
str := strconv.Itoa(42)                // int → "42"
i, _ := strconv.ParseInt("ff", 16, 64)  // 按 base 解析 → int64(255)
f, _ := strconv.ParseFloat("3.14", 64) // → float64
ok, _ := strconv.ParseBool("true")     // → bool
strconv.FormatInt(255, 16)             // "ff":int64 → 指定进制串
strconv.FormatFloat(3.14, 'f', 2, 64) // "3.14":float64 → 串
strconv.Quote("a\tb")                 // 加双引号并转义特殊字符
Trim / TrimLeft / TrimRight 的第二个参数是字符集合(cutset),不是要去掉的前后缀——它会挨个字符地啃,啃到不在集合里的字符才停。strings.TrimLeft("foobar", "fo") 得到的是 "bar"(连中间那两个 o 都被啃掉了),而你想要的 "obar" 得用 strings.TrimPrefix("foobar", "fo")。
规则记死:去前后缀一律用 TrimPrefix / TrimSuffix,Trim 系只留给「去空白、去引号、去标点」这类真按字符集合的场景。
字符串与数字互转认准 strconv,别用 fmt.Sprintf 顶替 Itoa、也别用 Sscanf 顶替 Atoi:白白付出格式解析开销。

slices / maps(Go 1.21+ 泛型,免手写循环)提供切片与 map 的查找、排序、增删、比较;sort 是泛型出现前的老 API,维护老项目仍常见。下面每个函数各配一行最小示例。

// —— slices(Go 1.21+ 泛型)——
xs := []int{3, 1, 2}
slices.Contains(xs, 2)                   // true
slices.Index(xs, 2)                      // 2:元素下标,无则 -1
slices.Sort(xs)                          // [1 2 3] 原地升序(需 cmp.Ordered)
slices.Max(xs)                           // 3:最大值
slices.Min(xs)                           // 1:最小值
slices.Reverse(xs)                       // [3 2 1] 原地反转
slices.Equal(xs, []int{3, 2, 1})       // true:逐元素相等
i, ok := slices.BinarySearch(xs, 2)      // 需已排序 → 下标, 是否命中
xs = slices.Insert(xs, 1, 9)             // 在下标 1 处插入 9
xs = slices.Delete(xs, 0, 1)              // 删除 [0,1) 区间
slices.SortFunc(users, func(a, b User) int {
  return cmp.Compare(a.Age, b.Age)       // 自定义比较:返回 <0 / 0 / >0
})

// —— maps(Go 1.21+)——
m := map[string]int{"a": 1, "b": 2}
c := maps.Clone(m)                       // 浅拷贝
maps.Equal(m, c)                         // true:键值相等判断
maps.DeleteFunc(m, func(k string, v int) bool { return v == 1 }) // 按条件批量删除
ks := slices.Sorted(maps.Keys(m))        // Keys → iter.Seq(1.23+)收成有序键
vs := slices.Sorted(maps.Values(m))      // Values 同理取值迭代器

// —— sort(老 API,泛型前的做法)——
sort.Slice(users, func(i, j int) bool {
  return users[i].Age < users[j].Age    // 按下标比较,最常用
})
sort.SliceStable(users, less)            // 稳定版,相等元素保持原序
sort.Sort(data)                          // data 需实现 Len/Less/Swap
sort.Search(n, f)                        // 二分找满足条件的最小下标
sort.Ints([]int{3, 1, 2})              // 基础类型快捷排序
sort.Strings([]string{"b", "a"})        // 字符串升序
slices.Clone 和 maps.Clone 都是浅拷贝,只复制最外面那一层。nested := [][]int{{1,2}},nc := slices.Clone(nested) 之后改 nc[0][0] = 99,nested[0][0] 跟着变成 99;maps.Clone(map[string][]int{"k":{1,2}}) 一样,改副本里的 slice 会串改原 map。元素是不含引用字段的 struct 时才是真副本(改 cl[0].Age 原切片不动)。
这在「拷一份配置改改再用」的场景最致命:改的其实是全局那份。要深拷贝只能自己逐层复制,别指望这两个函数。
新代码优先用 slices/maps(Go 1.21+,泛型、类型安全、无需断言);只有维护老项目或目标版本低时才退回 sort.Slice。

os 管文件、环境变量与进程参数,io 提供流式拷贝与读全,bufio 给读写加缓冲(具体用法详见本章「输入解析与缓冲写」卡)。下面每个函数各配一行最小示例。

// —— os:文件读写 ——
src, _ := os.Open("in.txt")             // 只读打开 → *os.File
dst, _ := os.Create("copy.txt")          // 创建或截断 → *os.File
data, err := os.ReadFile("cfg.json")  // 小文件一次读全 → []byte
os.WriteFile("out.txt", data, 0644)     // 一次写全

// —— os:环境变量与进程 ——
url := os.Getenv("DATABASE_URL")        // 未设返回 ""
v, ok := os.LookupEnv("PORT")           // ok 可区分未设与空值
_ = os.Args                              // []string 命令行参数,Args[0] 是程序名
if len(os.Args) < 2 { os.Exit(1) }      // 立即退出,不执行 defer

// —— io:流式处理 ——
io.Copy(dst, src)                        // 流式拷贝,不把整块读进内存
all, _ := io.ReadAll(src)                // 读到 EOF → []byte(取代旧 ioutil.ReadAll)

// —— bufio:缓冲(详见输入解析卡)——
sc := bufio.NewScanner(os.Stdin)         // 逐行 / 逐词扫描输入
for sc.Scan() { process(sc.Text()) }
br := bufio.NewReader(src)               // 缓冲读
bw := bufio.NewWriter(dst)               // 缓冲写,收尾必须 Flush
两个「静默丢数据」的坑:① bufio.NewWriter 写完不 Flush,数据全烂在内存缓冲里——写入一串字符后直接 f.Close(),读出来是长度为 0 的空文件,全程零报错。写法固定成 bw := bufio.NewWriter(f); defer bw.Flush(),且要保证 Flush 排在 f.Close() 之前执行。
② bufio.Scanner 默认单行上限约 64KB,遇到超长行会直接停止扫描:读一个 100KB 的长行,循环一行都没进就退出了,只有事后查 sc.Err() 才看得到 bufio.Scanner: token too long。所以 Scanner 循环后必须检查 sc.Err();行可能很长就先 sc.Buffer(...) 调大上限。
小配置文件用 os.ReadFile 一次读完最简单;大文件、网络流、逐行处理才上 bufio/io.Copy,避免一次性吃满内存。

time 处理当前时刻、时长计算、休眠、格式化/解析与定时器。格式化的 layout 必须写参考时刻 2006-01-02 15:04:05。下面每个函数各配一行最小示例。

// —— 取时刻与计时 ——
start := time.Now()                      // 当前时刻 Time
elapsed := time.Since(start)             // 距 start 的 Duration(= Now().Sub(start)),计时首选
time.Sleep(2 * time.Second)              // 阻塞休眠;Duration 常量按倍数相乘

// —— 格式化与解析(layout = 参考时刻)——
time.Now().Format("2006-01-02 15:04:05") // 参考时刻当模板,别写 YYYY
t, _ := time.Parse("2006-01-02", "2024-01-15") // 按 layout 解析

// —— 与 Unix 时间戳互转 ——
sec := t.Unix()                          // Time → Unix 秒时间戳
t2 := time.Unix(sec, 0)                 // Unix 秒 → Time

// —— 定时器与超时 ——
select {
case <-time.After(3 * time.Second):      // 超时分支,返回 <-chan Time
  fmt.Println("timeout")
case r := <-ch:
  use(r)
}
tk := time.NewTicker(time.Second)        // 周期触发,用完 Stop 防泄漏
defer tk.Stop()
time 的格式模板必须是那串特定数字 2006-01-02 15:04:05(即 1/2 3:04:05PM '06 这个参考时刻)。写成 "YYYY-MM-DD" 或别的年月日不会报错,但格式化结果全错——Go 最常见的易错点之一。
两条要点:① time.Parse 在 layout 不含时区信息时,结果一律落在 UTC 而不是本地时区——time.Parse("2006-01-02", "2024-01-15") 的 Location 是 UTC,而 time.Now() 是 Local,两者直接相减会凭空差出几个时区的小时数。要按本地时区解析请用 time.ParseInLocation。
② 比较两个时刻用 t1.Equal(t2),别用 ==:time.Now() 携带单调时钟读数,t == t.Round(0) 是 false,而 t.Equal(t.Round(0)) 是 true。

errors 造错误并沿 %w 链比较/提取,sync 提供锁、等待组、单次执行等并发原语,context 让取消与超时沿调用链传播。下面每个函数各配一行最小示例。

// —— errors:错误链 ——
var ErrNotFound = errors.New("not found") // 造简单错误
err := fmt.Errorf("query: %w", ErrNotFound) // %w 包裹下层
errors.Is(err, ErrNotFound)              // true:沿 %w 链比较(替代 ==)
var pathErr *os.PathError
errors.As(err, &pathErr)                 // 提取链中特定类型
errors.Unwrap(err)                       // 取被包裹的下一层错误

// —— sync:并发原语 ——
var mu sync.Mutex
mu.Lock(); mu.Unlock()                   // 互斥锁
var rw sync.RWMutex
rw.RLock(); rw.RUnlock()                 // 读写锁:多读少写场景
var once sync.Once
once.Do(setup)                           // f 只执行一次(懒初始化 / 单例)
var sm sync.Map
sm.Store("k", 1)                        // 并发安全 map:Load/Store/Delete/Range
var wg sync.WaitGroup
for _, job := range jobs {
  wg.Add(1)
  go func() { defer wg.Done(); run(job) }()
}
wg.Wait()                                // 等一组 goroutine 全部完成

// —— context:取消传播 ——
root := context.Background()             // 根 context
ctx1, cancel1 := context.WithCancel(root) // 派生可手动取消的 ctx
defer cancel1()
ctx, cancel := context.WithTimeout(root, 5*time.Second) // 到时自动取消
defer cancel()                          // 务必 defer cancel()
ctx = context.WithValue(ctx, keyID, 42) // 挂请求域值
<-ctx.Done()                             // <-chan struct{}:取消信号
errors.As 的第二个参数必须是指向目标类型的指针,传错不是返回 false,而是当场运行时 panic——传一个非指针的值进去,立刻 panic: errors: target must be a non-nil pointer。
正确写法是先声明再取址:var pe *os.PathError; if errors.As(err, &pe) { ... }——注意声明出来的已经是指针类型,传进去的其实是指针的指针。
这条尤其要小心:它藏在错误处理分支里,平时的成功路径根本跑不到,往往是线上真出错时才第一次执行,一崩就是雪上加霜。
errors.Is 判「是不是这个错误」,errors.As 判「是不是这类错误并取出来」;跨 goroutine 共享状态别裸用 map,要么 sync.Mutex 保护、要么用 channel 传递。

encoding/json 在值与 JSON 间编解码:Marshal/Unmarshal 走字节,Encoder/Decoder 走流。只有导出字段(大写开头)才会被编解码,键名、省略零值、忽略等靠 struct 标签控制。下面每个函数各配一行最小示例。

type User struct {
  ID   int        // 导出字段才会被编解码;键名 / omitempty 用 struct 标签控制
  Name string
}
u := User{ID: 1, Name: "Alice"}

// —— 字节级编解码 ——
b, _ := json.Marshal(u)                  // 值 → JSON 字节 {"ID":1,"Name":"Alice"}
b2, _ := json.MarshalIndent(u, "", "  ") // 带缩进美化
var u2 User
json.Unmarshal(b, &u2)                   // JSON → 值,第二参须传指针

// —— 流式编解码:直接对接 http / 文件 ——
json.NewEncoder(w).Encode(u)             // 写入 io.Writer(如 ResponseWriter)
json.NewDecoder(r.Body).Decode(&u2)      // 从 io.Reader 读取(如 r.Body)
只有导出(首字母大写)的字段才能被 json 编解码——小写字段既不会被 Marshal 输出、也不会被 Unmarshal 填充,且全程不报错,是常见的静默丢字段坑。
能定义 struct 就别用 map[string]any 接 JSON——把 10000000000000001 解到 any 里会变成 float64(1e+16),末尾那个 1 直接丢失精度,订单号、雪花 ID 这类大整数会悄悄对不上,且全程不报错。非用不可时改成 d := json.NewDecoder(r); d.UseNumber()。
另一条:nil 切片序列化成 null,空切片 []int{} 才是 [](同一结构体里两者输出 {"a":null,"b":[]})。想让前端稳定拿到 [],返回前用 make([]T, 0) 初始化。

strconv 管字符串与数字互转,bufio.Writer 管把成千上万次小写入攒成几次系统调用——两者都是「量大了才显出差别」的东西。

转换与缓冲

逐行读 stdin、文件按大小选读法,这些基本姿势 02 章「输入输出」已经讲过;实战里紧接着的是两件事。其一,解析:Scanner 给你的永远是字符串,转数字认准 strconv——Atoi 返回 (int, error),正是 05 章 if err != nil 惯例的日常主场;从固定格式的字符串里提取字段用 fmt.Sscanf,读空白分隔的几个简单值用 fmt.Scan(它们按空白分词、读不了含空格的整行,整行仍是 Scanner 的活)。其二,缓冲写:大量小笔写入别直接写 *os.File(每笔都是一次系统调用),包一层 bufio.NewWriter 攒够再落盘,收尾必须 Flush——由此还牵出 defer 执行顺序(LIFO)这个必须搞对的细节。

// 02 章的 Scanner 骨架 + strconv 解析:从 stdin 求和
scanner := bufio.NewScanner(os.Stdin)
sum := 0
for scanner.Scan() {
  n, err := strconv.Atoi(scanner.Text()) // 字符串 → int
  if err != nil { continue }            // 非数字行跳过
  sum += n
}
if err := scanner.Err(); err != nil { log.Fatal(err) }

// —— fmt.Scan / Sscanf:按空白或格式读简单值 ——
var a, b int
fmt.Scan(&a, &b)                        // 输入 "3 5" → a=3 b=5
var major, minor int
fmt.Sscanf("v1.22", "v%d.%d", &major, &minor)

// —— 缓冲写:Create + NewWriter,defer 顺序决定数据是否有效 ——
f, err := os.Create("out.txt")
if err != nil { log.Fatal(err) }
defer f.Close()                         // 后执行:关句柄
w := bufio.NewWriter(f)
defer w.Flush()                         // 先执行:刷缓冲(LIFO)
fmt.Fprintf(w, "sum=%d\n", sum)
defer 的 LIFO 顺序决定数据是否有效:必须先 defer f.Close() 再 defer w.Flush(),倒序执行时才是「先刷缓冲、再关文件」;写反了会先关文件再 Flush,缓冲区里的数据整段丢失且不报错。
bufio.Writer 的数据先攒在内存缓冲里,不 Flush 就可能整段丢失——defer w.Flush() 要放在 defer f.Close() 之后注册:defer 是 LIFO,后注册先执行,才能保证「先刷缓冲、再关文件」。(数字转换为何认准 strconv、别拿 fmt 顶替,见上文「strings · strconv」卡。)

log/slog(Go 1.21 起进标准库)把日志从「一行字符串」变成「带字段的结构」——可被机器检索,是上生产的前提。

结构化日志怎么写

log/slog(Go 1.21+ 进标准库)是现代 Go 服务的默认日志方式,取代只能打字符串的老 log。核心是结构化:日志由「消息 + 一串 key/value 属性」组成,可输出成机器可解析的 JSON,天然适配 ELK / Loki 这类日志系统。自带分级(Debug/Info/Warn/Error)、可绑定公共字段(With),并能把 context 里的请求信息一并带出。

// 直接用默认 logger(输出到 stderr)
slog.Info("user login", "uid", 42, "ip", "1.2.3.4")
slog.Error("db failed", "err", err)   // key/value 成对给

// 换成 JSON 输出(生产常用),并设最低级别
h := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
  Level: slog.LevelInfo,
})
logger := slog.New(h)
slog.SetDefault(logger)              // 设为全局默认
// 输出:{"time":"...","level":"INFO","msg":"user login","uid":42,...}

// With:绑定一组公共字段,衍生子 logger
reqLog := logger.With("request_id", rid)
reqLog.Info("handling", "path", r.URL.Path) // 每条都自动带 request_id
属性必须成对出现(key、value、key、value……),漏一个会得到一条 !BADKEY 记录而非编译错误。想让编译期就防呆、也更省分配,用强类型属性:slog.Int("uid", 42)、slog.String("ip", ip)。
别再用 fmt.Println 调试、用 log.Printf 记业务日志了。库代码里别直接打日志,把 logger 通过参数 / 字段传进去;HTTP 服务则在中间件里给每个请求 With("request_id", ...),全链路日志即可串联(中间件见本章 net/http、13 章 Gin)。

测试与工具链

testing 包的 API 与 go test 的参数各一张全表,外加测试辅助包清单。测试在 Go 里是语言内建能力:文件名 _test.go、函数名 TestXxx,不需要任何框架。

四种测试函数签名,一套 t.* 方法,一串命令行参数——记住这张表基本就够用。

四种测试函数

签名用途
func TestXxx(t *testing.T)单元测试
func BenchmarkXxx(b *testing.B)基准测试
func FuzzXxx(f *testing.F)模糊测试(1.18+)
func ExampleXxx()文档示例;带 // Output: 注释就会被当测试跑
func TestMain(m *testing.M)整个包的前后置,必须调 m.Run()

*testing.T 常用方法

方法说明版本
t.Errorf / t.Fatalf标记失败并继续 / 立刻结束这个测试—
t.Run(name, f)子测试,表驱动的骨架—
t.Parallel()声明本测试可并行,与同样声明的一起跑—
t.Cleanup(f)测试结束时执行,比 defer 更适合辅助函数1.14
t.TempDir()临时目录,结束自动删1.15
t.Setenv(k, v)临时设环境变量,结束自动还原;与 t.Parallel 互斥1.17
t.Helper()把本函数标记为辅助函数,报错时指向调用处—
t.Skip / t.SkipNow跳过;配 testing.Short() 用—
t.Chdir(dir)临时切工作目录1.24
t.Context()随测试结束而取消的 context1.24
b.Loop()基准循环的新写法:for b.Loop() {},自动处理计时与内联优化1.24
b.ReportAllocs() / b.ResetTimer()报告分配 / 重置计时器—
f.Add(seed) / f.Fuzz(fn)模糊测试的种子与目标函数1.18

go test 参数全表

参数作用
-run 'TestA/sub'正则筛选测试;斜杠分隔子测试
-v / -json详细输出 / 机器可读
-race数据竞争检测,CI 必开
-cover / -coverprofile=c.out覆盖率;配 go tool cover -html=c.out 看报告
-bench . -benchmem跑基准并报告内存分配
-benchtime=5s / -count=10单次时长 / 重复次数(配 benchstat)
-timeout=30s超时上限,默认 10 分钟
-short让测试里的 testing.Short() 为真
-failfast / -shuffle=on首次失败即停 / 打乱顺序,查测试间依赖
-parallel n / -cpu 1,4并行度 / 以不同 GOMAXPROCS 各跑一遍
-fuzz Fuzz -fuzztime=30s跑模糊测试;不加 -fuzz 时只跑种子语料
-count=1绕过测试结果缓存的惯用写法

辅助包

包用途
net/http/httptestNewServer 起假服务、NewRecorder 录响应
testing/synctestGo 1.25 转正:假时钟,把并发与超时测得确定(本章末卡)
testing/iotest / testing/quick构造出错的 Reader / 老式属性测试
github.com/google/go-cmpcmp.Diff 打出结构体差异,比手写比较好读
测试结果是有缓存的:同样的代码与输入,第二次 go test 直接打印 (cached) 不再跑。这在测试依赖外部状态(数据库、网络、文件)时会骗人——看到「过了」其实根本没执行。要强制重跑加 -count=1,这是 Go 里最常用的一个「反缓存」参数。
t.Parallel() 要配合子测试的变量捕获一起看:Go 1.22 起循环变量每轮新建,老代码里那句 tc := tc 已经不需要了。但 t.Parallel() 仍然会让子测试在父测试函数返回后才真正跑,所以父测试里用 defer 做的清理会提前发生——清理一律用 t.Cleanup。

Go 的测试不需要任何框架:一个 _test.go 文件加 go test 就够,而表驱动是社区公认的默认写法。

从最小测试到表驱动

测试文件以 _test.go 结尾、与被测代码同包;测试函数 TestXxx(t *testing.T)。表驱动测试是 Go 主流风格,配 t.Run 跑子测试、独立报告每个用例。net/http/httptest 可在内存里模拟请求来测 handler。

func TestAdd(t *testing.T) {
  tests := []struct {
    name     string
    a, b     int
    want     int
  }{
    {"正数", 2, 3, 5},
    {"负数", -1, -1, -2},
  }
  for _, tt := range tests {
    t.Run(tt.name, func(t *testing.T) {   // 子测试,独立显示
      if got := Add(tt.a, tt.b); got != tt.want {
        t.Errorf("got %d, want %d", got, tt.want)
      }
    })
  }
}

// 用 httptest 测 handler(无需真实端口)
func TestGetUser(t *testing.T) {
  req := httptest.NewRequest("GET", "/users/1", nil)
  w := httptest.NewRecorder()
  getUser(w, req)
  if w.Code != http.StatusOK {
    t.Errorf("want 200, got %d", w.Code)
  }
}
给 t.Run 的子测试加了 t.Parallel() 后,子测试会立刻暂停、等外层循环整个跑完才真正开始执行。三个并行子测试往一个共享切片里 append,循环结束后那行断言看到的是空切片(len=0),要到 t.Cleanup 里才看得到 [one three two]——顺序还是乱的。所以「循环跑完再统一校验累积结果」这种写法一旦配上 t.Parallel 就必错,校验得挪进子测试内部或 t.Cleanup。
另外子测试名里的空格会被替换成下划线,-run "TestX/has space" 匹配不上,得写成 has_space。
go test ./... 跑全部,常用旗标:-v 详细、-run TestAdd 过滤、-cover 覆盖率、-race 竞态检测。子测试名会拼成 TestAdd/正数,方便 -run "TestAdd/正数" 精确定位。

Go 把编译、测试、格式化、依赖管理全部收进一个 go 命令——没有 Makefile、没有 npm/pip、没有 lint 配置文件之争。这几条现在记个脸熟即可,测试与工具链的完整用法见 12 章,依赖管理见 09 章。

日常四条

  • go run . 编译并直接运行当前目录,开发时用得最多;
  • go build -o app . 产出可分发的单个二进制(目标机器不需要装 Go);
  • go test ./... 跑当前模块下所有包的测试(./... 这个「三点」写法代表递归所有子目录,很多命令都能用);
  • go fmt ./... 按官方唯一风格重排代码——Go 没有代码风格之争,因为格式不由人决定。

两条能救命的检查

  • go vet ./... 静态检查,抓编译器不管但几乎必错的写法(Printf 参数与格式串对不上、锁被值拷贝等);提交前跑一次;
  • go test -race ./... 打开竞态检测器,跑测试时自动发现多个 goroutine 并发读写同一份数据的问题——写并发代码后必跑(见 07 章)。

依赖管理

  • go mod init 名字 初始化模块;go get 包路径 添加依赖;
  • go mod tidy 自动补齐缺的、删掉没用的依赖——改完 import 后跑一下,go.mod 就整理干净了;
  • 依赖版本锁在 go.sum 里,两个文件都要提交进 Git。完整机制见 09 章。
// ── 日常 ──────────────────────────
// go run .              编译并运行当前目录
// go build -o app .     产出单个二进制
// go test ./...         跑所有包的测试
// go fmt ./...          官方格式化(无配置项)

// ── 检查 ──────────────────────────
// go vet ./...          静态检查,提交前跑
// go test -race ./...   竞态检测,写并发后必跑

// ── 依赖 ──────────────────────────
// go mod init hello              初始化模块
// go get github.com/gin-gonic/gin  添加依赖
// go mod tidy                    补齐/清理依赖

// ── 查文档,不用开浏览器 ──────────
// go doc fmt.Println    看某个函数的签名与说明
// go doc -all strings   看整个包的全部导出内容
go get 只负责管理依赖,不再用来安装命令行工具(1.16 起弃用,1.18 起彻底移除该能力)——装工具一律用 go install 包路径@版本。这个坑隐蔽在于老写法 go get -u 某工具 在模块目录里不会报错:它会把工具的依赖悄悄塞进你的 go.mod,却什么二进制都没装,你只会困惑「装完了怎么找不到命令」。另外 go install 在模块目录之外必须带版本后缀(如 @latest),否则报 'go install' requires a version when current directory is not in a module。
国内拉依赖慢或超时,配一次代理即可:go env -w GOPROXY=https://goproxy.cn,direct。go env -w 写入的是持久配置,不用每次设环境变量;go env 不带参数可以查看当前所有配置。

go test -bench 给的是可重复的数字,配 -benchmem 还能看出每次操作分配了多少内存——性能讨论应该从这里开始,而不是从直觉。

基准测试与配套工具

性能测试 BenchmarkXxx(b *testing.B),循环 b.N 次、次数由框架自动校准。工具链是 Go 的核心优势且统一内置:gofmt 强制格式(没有配置项,故意的)、go vet 静态检查、golangci-lint 综合 lint、go test -race 竞态。

func BenchmarkAdd(b *testing.B) {
  for i := 0; i < b.N; i++ {   // b.N 自动调整
    Add(2, 3)
  }
}
// go test -bench=. -benchmem

// 必备命令
gofmt -w .          // 自动格式化(统一风格)
go vet ./...        // 静态分析常见错误
golangci-lint run   // 综合 linter(业界标准)
for i := 0; i < b.N; i++ 里如果被测函数的返回值没被真正用掉,编译器会把整个调用优化没,跑出一个漂亮但完全虚假的数字。同一个 Add(2,3):老式 b.N 写法报 0.2249 ns/op——比一个时钟周期还短,显然什么都没执行;改用 for b.Loop() 后是 1.151 ns/op。
b.Loop() 既能阻止这类优化,又会自动把循环之前的准备工作排除在计时之外,新写的 benchmark 优先用它;仍用 b.N 时务必把结果赋给一个包级变量兜住。
gofmt 没有任何配置项是有意的设计:全 Go 社区代码风格统一,省去无意义的格式争论与 review 噪音。把 gofmt/goimports 接进编辑器保存钩子和 CI,风格问题就此消失。

模糊测试让引擎自己去构造你没想到的输入;接口则让替身注入不需要任何 mock 框架。

三样让测试更扎实的东西

模糊测试(Go 1.18+)FuzzXxx(f *testing.F) 自动生成大量随机输入,专找边界 bug 与 panic:f.Add 喂种子语料、f.Fuzz 跑目标。Go 靠接口做 mock——把依赖声明成接口,测试时传入假实现,不需要 mock 框架。testing.T 自带辅助:t.TempDir 给临时目录(自动删)、t.Cleanup 注册清理、t.Parallel 标记并行、t.Helper 让失败行号指到调用处。

// 模糊测试:自动灌随机输入找 panic/边界 bug
func FuzzReverse(f *testing.F) {
  f.Add("hello")                 // 种子语料
  f.Fuzz(func(t *testing.T, s string) {
    r := Reverse(Reverse(s))
    if r != s {                  // 反转两次应还原
      t.Errorf("往返失败: %q != %q", r, s)
    }
  })
}
// go test -fuzz=FuzzReverse

// 用接口做 mock,无需第三方框架
type Store interface { Get(id int) (string, error) }

type fakeStore struct{ data map[int]string }
func (s fakeStore) Get(id int) (string, error) {
  return s.data[id], nil        // 测试用假实现
}

func TestService(t *testing.T) {
  svc := NewService(fakeStore{data: map[int]string{1: "Ann"}})
  got, _ := svc.Name(1)
  if got != "Ann" { t.Fatalf("got %q", got) }
}

// 测试辅助:临时目录用完自动清理
func TestWrite(t *testing.T) {
  dir := t.TempDir()             // 测试结束自动删除
  path := filepath.Join(dir, "f.txt")
  t.Cleanup(func() { /* 释放资源 */ })
  _ = path
}
模糊测试发现的失败用例会被写进 testdata/fuzz/ 作为回归语料,务必提交进版本库,否则别人复现不到同一个反例。t.Parallel 的子测试也要当心:表驱动里若闭包直接引用循环变量 tt,Go 1.22 前所有并行子测试会共享最后一个 tt——旧代码需 tt := tt 拷贝一份(1.22+ 已修复)。
别追求"100% 覆盖率"这个数字。go test -coverprofile=c.out && go tool cover -html=c.out 生成可视化报告,用它找完全没测到的关键分支(错误路径、边界条件),而不是为刷满行数去测 getter/setter。

测带超时、重试、心跳的并发代码一直是 Go 测试里最脏的活——要么真的 time.Sleep 把测试拖成几十秒,要么把时钟抽象成接口污染业务代码,而且还偶尔 flaky。testing/synctest(Go 1.24 试验、1.25 转正)用一个「泡泡」把这类测试变成确定性的、瞬间完成的。

假时钟:24 小时瞬间过完

  • synctest.Test(t, func(t *testing.T) { … }) 里跑的代码用的是一个假时钟:只要泡泡内所有 goroutine 都阻塞了,时间就自动往前跳到下一个定时器该触发的时刻。
  • 对照:同一段「起一个 goroutine 睡够时间再关 channel」的代码——真实时钟版 time.Sleep(2 * time.Second) 让测试真跑了 2.001s;泡泡版把睡眠改成 24 小时,泡泡内 time.Since(start) 读到 24h0m0s,而墙上时钟耗时是 0s,整个测试 PASS (0.00s)。
  • 意味着「测一小时后触发的清理逻辑」不再需要把超时参数改小、也不需要注入时钟接口——业务代码一个字不改。

顺带白送两个检查

  • 死锁检测:泡泡内所有 goroutine 都阻塞且无法推进时,立刻 panic deadlock: all goroutines in bubble are blocked。是瞬间失败(0.00s),不是等到 -timeout 才被打死——排查起来天差地别。
  • goroutine 泄漏检测:泡泡函数返回时若还有阻塞的 goroutine 没退,panic deadlock: main bubble goroutine has exited but blocked goroutines remain。等于给每个测试白送一个 goroutine leak 检查。

用它的边界

  • 泡泡管的是泡泡内创建的 goroutine 和定时器。泡泡外已有的 goroutine、真实网络 I/O、真实文件读写不受管——假时钟推不动它们,反而可能把泡泡逼进死锁检测。
  • 所以它的主场是纯内存的并发逻辑:channel 编排、context 超时、重试退避、限流器、缓存过期。要碰真实网络就退回 httptest(见本章速查表的辅助包一节)。
import ("testing"; "testing/synctest"; "time")

func TestTimeout(t *testing.T) {
    synctest.Test(t, func(t *testing.T) {
        start := time.Now()
        done := make(chan struct{})

        go func() {
            time.Sleep(24 * time.Hour)   // 假时钟:瞬间
            close(done)
        }()

        <-done
        t.Log(time.Since(start))   // 泡泡内读到 24h0m0s
    })
    // 墙上时钟0s,整个测试 PASS (0.00s)
}

// 泡泡内全员阻塞 → 立刻 panic,不用等 -timeout:
//   deadlock: all goroutines in bubble are blocked
// 泡泡结束时还有 goroutine 没退 → 也 panic:
//   deadlock: main bubble goroutine has exited but
//   blocked goroutines remain
别把真实 I/O 塞进泡泡。假时钟只推得动泡泡内的定时器,一旦某个 goroutine 卡在真实的网络读上,泡泡里其它 goroutine 全阻塞时会被判成 deadlock——报错指向死锁,真凶却是那次网络调用。判据很简单:泡泡里只放纯内存的并发逻辑,外部依赖先用接口或 httptest 挡掉。
改造老测试的顺手做法:把那些「为了跑得快而被改小的超时」改回生产上真正的值,再整段包进 synctest.Test。测试变快的同时,测的还是真实配置——原来那种「生产 30 秒超时、测试里写 50 毫秒」的做法,本质上测的是另一份代码。

Gin Web 框架

Gin 的路由 / 上下文 / 绑定 API 与 binding 校验标签各一张全表。Gin 是 Go 生态最流行的 Web 框架,基于 radix 树路由,在 net/http(11 章)之上提供分组、绑定校验、中间件链与统一错误管理。当前 v1.12.x,搭配 Go 1.25+。

Gin 的 API 面基本都挂在两个对象上:*gin.Engine(路由)与 *gin.Context(请求处理)。

引擎与路由

API说明
gin.Default()带 Logger + Recovery 中间件
gin.New()空引擎,中间件自己加
gin.SetMode(gin.ReleaseMode)关掉调试输出;也可用环境变量 GIN_MODE=release
r.GET/POST/PUT/PATCH/DELETE/HEAD/OPTIONS注册路由
r.Any(path, h) / r.Handle(method, …)全方法 / 动态指定方法
r.Group("/api/v1", mw...)路由分组,可嵌套
r.Use(mw...)挂全局中间件
r.NoRoute(h) / r.NoMethod(h)404 / 405 兜底
r.Static / StaticFS / StaticFile静态资源;配 go:embed 见 16 章
r.LoadHTMLGlob("tpl/*")加载模板,配 c.HTML
r.Run(":8080")启动;生产要用 http.Server 自己起,才能优雅关闭
路径参数/u/:id 精确一段、/f/*path 通配剩余

*gin.Context:取输入

API取什么
c.Param("id")路径参数
c.Query("q") / c.DefaultQuery("p", "1")查询串
c.QueryArray / c.QueryMap重复键 / a[k]=v 形式
c.PostForm / c.FormFile / c.MultipartForm表单与上传
c.GetHeader / c.Cookie / c.ClientIP头 / cookie / 客户端 IP(需正确配 SetTrustedProxies)
c.Request / c.Writer原生 *http.Request 与 ResponseWriter

绑定:Should 系与 Must 系

API行为
c.ShouldBindJSON(&req)失败只返回 error,由你决定怎么回——日常一律用这个
c.ShouldBindQuery / ShouldBindUri / ShouldBindHeader分别绑查询串 / 路径参数 / 请求头
c.ShouldBind(&req)按 Content-Type 自动选;GET 时走 query
c.ShouldBindBodyWith要读两次 body 时用(会缓存)
c.Bind*(无 Should)失败时自动写 400 并 Abort,错误信息不可控

binding 校验标签(go-playground/validator)

标签含义
required不能是零值;想区分「没传」与「传了 0」用指针字段
omitempty为空就跳过后面的校验
min=1,max=10 / len=6数字比大小,字符串 / 切片比长度
gt/gte/lt/lte=N数值比较
oneof=a b c枚举值
email / url / uuid / ip常见格式
alphanum / numeric / datetime=2006-01-02字符集 / 日期格式
dive进入切片 / map 逐元素校验:binding:"dive,email"
required_if=Type a / required_with=Other条件必填
eqfield=Password / nefield跨字段比较(确认密码)
-跳过该字段

输出与流程控制

API说明
c.JSON(200, gin.H{…})gin.H 就是 map[string]any
c.String / c.XML / c.HTML / c.Data / c.File其它响应形式
c.Redirect(302, url) / c.Status(204)跳转 / 只写状态码
c.Next() / c.Abort() / c.AbortWithStatusJSON中间件:放行 / 中止后续处理器
c.Set(k, v) / c.Get(k) / c.MustGet(k)请求内传值(认证信息等)
c.Error(err) / c.Errors收集错误,交给统一错误中间件
c.Abort() 不会让当前处理器函数返回——它只是标记「后面的处理器不要再执行」。写成 if bad { c.AbortWithStatusJSON(400, …) } 却忘了 return,下面的代码照样跑,最后还可能再写一次响应,日志里出现 headers were already written。惯用写法永远是 c.AbortWithStatusJSON(...); return。
中间件里起 goroutine 必须用 c.Copy():请求结束后原 *gin.Context 会被回收复用,后台 goroutine 再读它就是读到别人的请求。c.Copy() 给一份只读副本,专为这种场景准备。

Web 框架在 Go 里是可选项——先看清标准库能做到哪一步,再决定要不要引入框架。

路由与最小服务

gin.Default() 预装 Logger + Recovery 中间件,要纯净实例用 gin.New()。路由方法 GET/POST/PUT/DELETE;路径参数 :id 用 c.Param,查询参数用 c.Query/c.DefaultQuery。r.Group() 组织 API 版本与鉴权分组。生产环境设 gin.SetMode(gin.ReleaseMode)。

package main

import (
  "net/http"
  "github.com/gin-gonic/gin"
)

func main() {
  r := gin.Default()           // 含 Logger + Recovery

  r.GET("/ping", func(c *gin.Context) {
    c.JSON(http.StatusOK, gin.H{"message": "pong"})
  })

  // 路由分组:版本 + 参数
  v1 := r.Group("/api/v1")
  {
    v1.GET("/users/:id", func(c *gin.Context) {
      id := c.Param("id")                // 路径参数
      page := c.DefaultQuery("page", "1") // 查询参数
      c.JSON(http.StatusOK, gin.H{"id": id, "page": page})
    })
  }

  r.Run(":8080")                  // 监听 0.0.0.0:8080
}
Gin 的路由树要求同一层级的通配符必须同名,否则在注册时就 panic、服务根本起不来。/users/:id 和 /users/:name/posts 一起注册,panic 报 ':name' in new path '/users/:name/posts' conflicts with existing wildcard ':id';把后者也改成 :id 就正常了。这类错误好在启动瞬间就暴露,不会拖到线上。
另注意:请求方法不匹配时 Gin 默认返回 404 而不是 405(对只注册了 GET 的 /items 发 POST,拿到 404),排查时别被这个 404 带偏、去怀疑路由没注册。
gin.H 是 map[string]any 的快捷别名,用来临时拼 JSON。返回结构体时直接 c.JSON(200, user) 即可,Gin 会按字段的 json tag 自动序列化。

把「从请求里取数据」和「校验数据合不合法」分成两步,是所有 Go Web 框架的共同形状。

绑定与校验

Gin 内置 go-playground/validator。c.ShouldBindJSON(&req) 绑定并校验请求体,ShouldBindQuery/ShouldBindUri 绑定查询/路径。结构体用 binding:"required,email,gte=0" 等标签声明约束。优先用 ShouldBind* 而非 Bind*:后者校验失败会自动写 400 并 abort,前者把控制权留给你。

type CreateUserReq struct {
  Name  string `json:"name"  binding:"required,min=2"`
  Email string `json:"email" binding:"required,email"`
  Age   int    `json:"age"   binding:"gte=0,lte=130"`
  Role  string `json:"role"  binding:"oneof=admin user"`
}

func createUser(c *gin.Context) {
  var req CreateUserReq
  if err := c.ShouldBindJSON(&req); err != nil {
    c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
    return                     // 别忘了 return!
  }
  // req 已通过校验,安全使用
  c.JSON(http.StatusCreated, req)
}
绑定目标的字段必须导出(大写),否则无法赋值。binding:"required" 对零值敏感:int 的 0、bool 的 false 会被当成"缺失"而报错。要区分"传了 0"和"没传",把字段改成指针 *int。
想给前端返回字段级的错误提示,别把 err.Error() 直接甩出去(那串 Key: 'Req.Name' Error:Field validation for ... 没人看得懂)。用 var ve validator.ValidationErrors; if errors.As(err, &ve) 取出结构化信息,每一项都能拿到 fe.Field() 为 "Name"、fe.Tag() 为 "min"、fe.Param() 为 "2",拼成自己的文案正合适。
注意分流:JSON 本身语法就错时(比如只发一个 {)返回的不是 ValidationErrors 而是 unexpected EOF。另外请求体只能读一次,连续两次 ShouldBindJSON 第二次必得 EOF,确需绑定多个结构体请用 ShouldBindBodyWith。

中间件就是「包一层的 Handler」——Go 里它不需要任何框架支持,函数签名本身就够了。

洋葱模型与请求级数据

中间件是 func(c *gin.Context)。c.Next() 之前的代码在请求阶段执行、之后的代码在响应阶段执行;c.Abort() 中断后续 handler。用 c.Set/c.Get 在中间件与 handler 间传值(如认证后存 userID)。r.Use() 全局注册,也可只挂到某个路由组。

// 计时中间件
func Timer() gin.HandlerFunc {
  return func(c *gin.Context) {
    start := time.Now()
    c.Next()                   // 执行后续 handler
    log.Printf("%s %v", c.Request.URL.Path, time.Since(start))
  }
}

// 鉴权中间件
func Auth() gin.HandlerFunc {
  return func(c *gin.Context) {
    token := c.GetHeader("Authorization")
    uid, err := verify(token)
    if err != nil {
      c.AbortWithStatusJSON(http.StatusUnauthorized,
        gin.H{"error": "未授权"})
      return
    }
    c.Set("userID", uid)         // 传给后续 handler
    c.Next()
  }
}

r.Use(Timer())                 // 全局
authorized := r.Group("/admin", Auth())  // 仅该组
中间件里写完响应就 return、忘了 c.Abort(),后续 handler 照样会执行。一个鉴权中间件在无 token 时 c.JSON(401, gin.H{"error":"unauthorized"}) 然后 return,实际响应体是 {"error":"unauthorized"}{"secret":"TOP-SECRET-DATA"}——状态码确实是 401,机密数据却原样拼在后面泄露了出去。只断言状态码的测试用例会全部通过,这就是典型的「测试全绿、线上漏数据」。
拦截一律用 c.AbortWithStatusJSON(...)(响应干净地只有 401 那一段),或者 c.JSON(...) 后紧跟一行 c.Abort()。
从 c.Get 取出的值是 any,需类型断言:uid := c.MustGet("userID").(string)。c.MustGet 在 key 不存在时会 panic,仅在确信前置中间件已 Set 时使用。

错误处理集中到一处,handler 的正常路径才干净——这与 05 章「错误是值」是同一条思路的工程化落地。

统一响应与错误映射

响应用 c.JSON/c.String/c.Data 等。错误处理两种风格:① handler 内直接 c.AbortWithStatusJSON 就地返回;② 调 c.Error(err) 把错误塞进 c.Errors,由一个收尾中间件统一读取、映射状态码并记日志。大型项目用第二种集中治理。

type APIError struct {
  Code int
  Msg  string
}
func (e *APIError) Error() string { return e.Msg }

// 收尾中间件:统一处理 c.Errors
func ErrorHandler() gin.HandlerFunc {
  return func(c *gin.Context) {
    c.Next()                          // 先跑完所有 handler
    if len(c.Errors) == 0 { return }
    err := c.Errors.Last().Err
    var ae *APIError
    if errors.As(err, &ae) {
      c.JSON(ae.Code, gin.H{"error": ae.Msg})
      return
    }
    c.JSON(http.StatusInternalServerError, gin.H{"error": "内部错误"})
  }
}

// handler 里只管把错误抛给收尾中间件
func getItem(c *gin.Context) {
  item, err := repo.Find(c.Param("id"))
  if err != nil { c.Error(err); return }
  c.JSON(http.StatusOK, item)
}
写完响应后必须 return。Gin 不会因为你调了 c.JSON 就停下——忘记 return 会继续往下执行,可能重复写响应、触发 headers already written 警告,甚至覆盖掉已发的状态码。
收尾中间件里 c.Next() 之后的代码只能用来「观测」——记日志、埋点、读 c.Errors——不能再改响应。handler 已经 c.JSON(200, ...) 之后,中间件再 c.JSON(500, ...),最终响应是状态码仍为 200、body 变成 {"ok":1}{"late":true} 的两段拼接。
所以统一错误处理要真正成立,前提是 handler 出错时只调 c.Error(err) 然后 return,绝不自己写响应体,把「谁来写响应」收敛到唯一一处。中间件里可用 c.Writer.Written() 判断响应是否已经发出。

Go 圈很少用 DI 框架:依赖当参数传进去就够了,编译器会替你检查有没有传漏。

手工注入与数据库访问

Gin 不内置依赖注入。惯用法:定义持有依赖(DB、配置、服务)的 handler 结构体,把它的方法当路由处理函数;或用闭包注入。这样依赖显式、可单测,避免到处用全局变量。

type UserHandler struct {
  db *sql.DB                         // 注入的依赖
}

func (h *UserHandler) Get(c *gin.Context) {
  id := c.Param("id")
  row := h.db.QueryRow("SELECT name FROM users WHERE id=$1", id)
  var name string
  if err := row.Scan(&name); err != nil {
    c.JSON(http.StatusNotFound, gin.H{"error": "不存在"})
    return
  }
  c.JSON(http.StatusOK, gin.H{"id": id, "name": name})
}

func main() {
  db := openDB()                     // 启动时初始化一次
  h := &UserHandler{db: db}          // 注入

  r := gin.Default()
  r.GET("/users/:id", h.Get)        // 方法即 handler
  r.Run(":8080")
}
sql.Open 不会真的去连数据库,它只是造了个连接池对象。用一个必然失败的驱动,sql.Open 返回的 err 是 nil,直到 db.PingContext(ctx) 才暴露出 connection refused——只检查 Open 返回值的启动代码会「启动成功」,然后在第一个真实请求上返回 500,配置写错、密码过期全都要等到线上才发现。所以 main 里初始化完必须 Ping 一次再往下走。
另外连接池的 MaxOpenConns 默认值是 0,即不限制,流量一冲就可能打满数据库的最大连接数,生产务必显式设置 SetMaxOpenConns / SetMaxIdleConns / SetConnMaxLifetime。
在 main 里初始化数据库连接池、配置、外部客户端各一次,注入到 handler 结构体。请求间共享的只读依赖放结构体字段;每请求独立的状态留在 gin.Context 里。

能跑和能上线之间隔着几样固定动作,优雅关闭是其中最容易被忽略、后果又最直接的一个。

上线前的固定动作

r.Run() 方便但不优雅关闭。生产用 http.Server{Handler: r} + signal.NotifyContext 监听 SIGINT/SIGTERM,收到信号后 srv.Shutdown(ctx) 等在途请求处理完再退出。跨域用 gin-contrib/cors;配置走环境变量或 viper。

func main() {
  r := gin.Default()
  r.Use(cors.Default())              // gin-contrib/cors
  r.GET("/healthz", func(c *gin.Context) { c.Status(200) })

  srv := &http.Server{Addr: ":8080", Handler: r}

  go func() {
    if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
      log.Fatal(err)
    }
  }()

  // 监听中断信号
  ctx, stop := signal.NotifyContext(context.Background(),
    os.Interrupt, syscall.SIGTERM)
  defer stop()
  <-ctx.Done()                       // 阻塞直到收到信号

  // 给在途请求 5 秒收尾
  shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
  defer cancel()
  srv.Shutdown(shutCtx)              // 优雅关闭
}
优雅关闭最常见的错误是启动 goroutine 里写了 log.Fatal(srv.ListenAndServe()):Shutdown 一触发,ListenAndServe 会正常返回 http: Server closed(errors.Is(err, http.ErrServerClosed) 为 true),而 log.Fatal 只看 err 非 nil 就 os.Exit(1) 把进程立刻掐死——在途请求全被截断,优雅关闭形同虚设。必须显式排除:if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { log.Fatal(err) }。
另外 http.Server 的 ReadTimeout / WriteTimeout 零值就是 0,也就是永不超时,一条慢连接能一直占着,生产必须显式设置。
上线清单常客:gin.SetMode(gin.ReleaseMode) 关调试日志、设 ReadTimeout/WriteTimeout 防慢连接、用 slog 替换默认 Logger 输出结构化日志、加 /healthz 健康检查与限流中间件。

反射与元编程

reflect 的 Kind 全表、Type / Value 的常用方法、可设置性规则一张卡列全。JSON 编解码(11 章)、Gin 的绑定校验(13 章)、ORM,底层全靠它——但业务代码里用到反射,通常说明设计可以更简单。

反射三定律:接口值 → 反射对象(ValueOf)、反射对象 → 接口值(Interface())、要改必须可设置(拿到的得是指针的 Elem())。

入口函数

函数作用
reflect.TypeOf(x) / ValueOf(x)拿类型 / 值
reflect.TypeFor[T]()Go 1.22:不用造一个零值就能拿类型
reflect.New(t) / Zero(t)造指向新零值的指针 / 造零值
reflect.MakeSlice/MakeMap/MakeChan/MakeFunc动态构造
reflect.Indirect(v)是指针就解引用
reflect.DeepEqual(a, b)深比较;慢,且 nil 切片 ≠ 空切片。测试里优先 go-cmp

Kind 全表(v.Kind() 的取值)

组取值
无效Invalid(零值 Value,IsValid() 为 false)
布尔与数字Bool、Int…Int64、Uint…Uint64、Uintptr、Float32/64、Complex64/128
聚合Array、Slice、Map、Struct、String
引用Pointer(旧名 Ptr)、Chan、Func、Interface、UnsafePointer

Kind 不是 Type:type MyID int 的 Type().Name() 是 MyID,而 Kind() 是 Int。判断底层长相用 Kind,判断具体类型用 Type。

reflect.Type 常用方法

方法作用
Name() / String() / PkgPath()类型名 / 完整串 / 所属包
Kind() / Size() / Align()底层种类 / 字节数 / 对齐(15 章)
Elem()指针、切片、数组、map、chan 的元素类型
NumField() / Field(i) / FieldByName(n)结构体字段;StructField.Tag 是标签
NumMethod() / Method(i)只看得到导出方法
Implements(t) / ConvertibleTo / AssignableTo类型关系判断
tag.Get("json") / tag.Lookup("json")读标签;Get 分不清「没有」与「空」,要区分用 Lookup

reflect.Value:读、写与可设置性

方法说明
Interface()变回 any;未导出字段上调用会 panic
Int()/String()/Bool()/Float()取值;Kind 不匹配直接 panic
IsValid() / IsNil() / IsZero()三个都不一样:无效 / 是 nil / 是零值
Len() / Index(i) / MapKeys() / MapIndex(k)集合遍历
NumField() / Field(i)结构体字段值
CanSet()先问这个再写
Set(v) / SetInt / SetString…写值
Call(args) / MethodByName(n)动态调用

CanSet() 为真需要两个条件:① 是从指针 Elem() 出来的(ValueOf(&x).Elem(),直接 ValueOf(x) 拿到的是副本);② 字段首字母大写。

反射把编译期检查换成了运行期 panic:类型对不上、字段名拼错、值不可设置,全都要等到跑起来才炸。所以凡是反射写的解析器,都该在启动时就跑一遍(比如配置解析放在 init 或 main 开头),而不是等第一个请求进来才发现。能用 go generate 生成代码解决的,就别用反射——本章末卡讲这个取舍。
反射代码的调试顺序固定:先打 v.Kind() 和 v.Type(),再问 CanSet(),最后才动手写。reflect 的 panic 信息很直白(using value obtained using unexported field、reflect: call of reflect.Value.Int on string Value),照着改即可。

反射把「编译期已知的类型信息」搬到运行时——能力很大,代价也很大,所以先记住它的三条定律划定的边界。

三定律与两个核心类型

探源:还记得 04 章说接口值 = (动态类型, 动态值) 一对指针吗?reflect 就是把这一对拆开来看、甚至改回去的官方通道。Rob Pike 归纳的反射三定律正是围绕这一对:① 接口 → 反射对象:reflect.TypeOf(x) 取出「类型」格,reflect.ValueOf(x) 取出「值」格;② 反射对象 → 接口:v.Interface() 把它塞回一个 any;③ 要改值,反射对象必须「可设置」(settable)——而 ValueOf(x) 拿到的只是 x 的副本,改它没意义,所以 Go 干脆禁止。想改就得传指针、再 .Elem() 解引用到它指向的真身,这个才可设置。Kind()(底层种类:Struct/Slice/Int…)与 Type()(具体类型名)要分清——反射逻辑基本都按 Kind 分派。

type User struct {
  Name string `json:"name"`
  Age  int    `json:"age"`
}

u := User{"Ann", 28}
t := reflect.TypeOf(u)          // 类型格:main.User
v := reflect.ValueOf(u)         // 值格:{Ann 28}

// 按字段遍历(Kind 是 Struct 才能 NumField)
for i := 0; i < t.NumField(); i++ {
  f := t.Field(i)               // StructField:名字、类型、标签
  fmt.Printf("%s=%v tag=%q\n", f.Name, v.Field(i), f.Tag.Get("json"))
}

// 第三定律:要改值,必须传指针再 Elem(),否则改的是副本
p := reflect.ValueOf(&u).Elem() // 指向 u 本体,可设置
if p.Field(0).CanSet() {
  p.Field(0).SetString("Bob")  // u.Name = "Bob"
}
fmt.Println(u.Name)             // Bob
三个高频 panic:① 对 reflect.ValueOf(x)(非指针)调 Set*——不可设置,直接 panic,必须 ValueOf(&x).Elem();② 用错动词,如对非结构体调 NumField()、对非切片调 Index()——先用 Kind() 判种类再操作;③ 改未导出字段:CanSet() 会返回 false(StructField.PkgPath 非空即未导出),强设 panic。
反射慢(每次都查类型表、绕过编译期检查),可读性也差——热路径别碰。能用泛型(08 章)表达的通用逻辑一律优先泛型:编译期展开、零运行时代价、类型安全。反射只留给「编译期根本不知道类型」的场景(见本章后面)。

结构体标签本身只是一串字符串,是反射把它变成了 JSON 序列化、ORM 映射、参数校验的共同基础设施。

标签怎么被读出来

结构体标签(04 章)本身只是一串字符串元数据,是反射把它变成了「行为」。encoding/json 读 json:"name" 决定键名(11 章)、Gin 读 binding:"required" 决定校验(13 章)、GORM 读 gorm: 决定列名——套路完全一样:反射遍历字段 → 取标签 → 按标签驱动逻辑。下面用这套路写一个迷你库:把环境变量按 env 标签填进结构体(这正是 envconfig/viper 的内核)。

type Config struct {
  Port    int    `env:"PORT"`
  Host    string `env:"HOST"`
  Debug   bool   `env:"DEBUG"`
}

// 反射遍历字段 → 读 env 标签 → 从环境变量填值
func Load(cfg any) error {
  v := reflect.ValueOf(cfg).Elem()   // 传 &Config,Elem 得可设置本体
  t := v.Type()
  for i := 0; i < t.NumField(); i++ {
    key := t.Field(i).Tag.Get("env")
    val, ok := os.LookupEnv(key)
    if key == "" || !ok { continue }
    f := v.Field(i)
    switch f.Kind() {              // 按底层种类转换
    case reflect.String:
      f.SetString(val)
    case reflect.Int:
      n, _ := strconv.Atoi(val)     // strconv 见 11 章
      f.SetInt(int64(n))
    case reflect.Bool:
      b, _ := strconv.ParseBool(val)
      f.SetBool(b)
    }
  }
  return nil
}

var cfg Config
Load(&cfg)                          // PORT=8080 DEBUG=1 → 自动填入
① 只能设置导出字段——把 port int 写成小写,反射静默跳过(CanSet 为 false),配置永远是零值却不报错,极难排查。② 标签拼写错(env 写成 ENV)同样静默失效——反射把编译期错误推迟成了运行期沉默。写这类库务必对「字段无标签 / 不可设置」给出显式告警。
看懂这张表,json/gorm/viper/Gin 绑定就都不再神秘——它们只是这套「遍历字段 + 读标签 + 反射赋值」的工业级版本,额外处理了嵌套、指针、切片、默认值和错误聚合。

反射灵活但慢且不安全;代码生成啰嗦但零开销且编译期可查——Go 生态整体在往后者迁移。

两条路的取舍

反射的三宗罪——慢(运行时查类型表)、无编译期安全(类型错误拖到运行时)、难读难维护。Go 社区对「需要按类型定制逻辑」的主流答案不是反射,而是代码生成:go generate + 模板在编译前生成出针对具体类型的普通 Go 代码,于是性能与类型安全都拿回来了,代价只是多一步生成。你其实早见过——04 章的 stringer(自动生成枚举的 String())就是代码生成。生态里的重量级玩家全走这条路:easyjson(比反射版 json 快数倍)、sqlc(从 SQL 生成类型安全的查询代码,见 16 章)、protoc-gen-go(从 .proto 生成结构体)。

两条路的完整对照

反射代码生成
运行时开销明显(类型查找、装箱、动态调用)零(就是普通代码)
出错时机运行时 panic编译期
可读性调用处简洁,内部难懂产物啰嗦但直白可调试
构建流程无额外步骤多一步 go generate
典型代表encoding/json、ORMstringer、sqlc、protobuf
  • Go 生态整体在往代码生成迁移:go generate 是标准工具链的一部分,产物是普通 .go 文件,能被正常审阅、调试、打断点;
  • 反射仍然不可替代的场合只有一个:类型在编译期确实未知(通用序列化、插件系统)。除此之外,先问一句「能不能生成」。
// 在源码里写一行指令(不是注释,go generate 会扫描它)
//go:generate stringer -type=Status
type Status int

const (
  Pending Status = iota
  Active
  Closed
)

// 跑一次生成,产出 status_string.go(含高效的 String())
// $ go generate ./...

// 生成的代码是普通 Go:编译期检查、零反射、可读
// func (s Status) String() string {
//   ... return _Status_name[...] // 查表,纳秒级
// }
//go:generate 指令里 // 和 go:generate 之间不能有空格,多打一个它就退化成一条普通注释,go generate 完全无视——不报错、不警告、不提示。在同一个文件里放四条指令,只有紧贴着写的两条执行了,写成 // go:generate 和 // go:generate 的那两条一次都没跑。
症状是「生成的代码莫名其妙不更新」,而你会去改半天模板、怀疑工具版本,根本想不到是那个空格。gofmt 也不会帮你修它,代码评审时盯紧这一处。
一条决策树:能用泛型 → 泛型(编译期、零代价、08 章);不能泛型且性能敏感 / 需按类型定制大量代码 → 代码生成(go generate);只在启动或配置期跑一次、类型编译期未知 → 反射(如本章前面的配置加载)。把反射留在冷路径,热路径交给前两者。

运行时与调优

调优要用到的旋钮一次列全:运行时环境变量、GODEBUG 选项、runtime / runtime/debug API、pprof 的七种剖面与端点。后面几张卡解释这些数字背后的机制:GMP、逃逸分析、三色标记 GC 与内存布局。

Go 的性能旋钮少得出奇——真正常用的就三个环境变量,其余都是观测手段。

运行时环境变量

变量作用默认
GOMAXPROCS同时执行用户代码的系统线程数上限(GMP 里的 P 数)CPU 核数;Go 1.25 起感知容器 CPU 配额
GOGC堆增长多少百分比触发一次 GC;off 关闭100(翻倍即回收)
GOMEMLIMIT软内存上限,逼近时 GC 变激进无限;容器里强烈建议设成内存上限的 ~90%
GOTRACEBACKnone|single|all|system|crash:panic 时打多少栈single
GODEBUG逗号分隔的调试开关,见下表空
GORACE竞争检测器选项(halt_on_error=1 等)空

GOGC 与 GOMEMLIMIT 配合使用是容器里的标准配方:GOGC=off 加一个 GOMEMLIMIT,等于「平时不回收,逼近上限才回收」,吞吐最高但要确信上限设得对。

常用 GODEBUG 选项

选项输出什么
gctrace=1每次 GC 一行:耗时、堆大小、目标
schedtrace=1000每秒一行调度器概况(P / M / G 数与运行队列)
scheddetail=1配合上一个,逐 P 逐 G 详列
inittrace=1每个包的 init 耗时与分配——查启动慢
asyncpreemptoff=1关掉异步抢占,排查与信号相关的诡异问题
cgocheck2=1更严的 cgo 指针检查(Go 1.21 起的写法,旧写法是 cgocheck=2);默认的 cgocheck=1 只做轻量检查(16 章)
netdns=go / netdns=cgo强制用哪套 DNS 解析实现
tracebackancestors=5栈里带上创建这个 goroutine 的那条栈
http2debug=1/2HTTP/2 帧级日志

兼容性开关也走 GODEBUG(如 panicnil=1),可以写进 go.mod 的 godebug 指令长期固定(09 章)。

runtime / runtime/debug API

API作用
runtime.NumGoroutine()查泄漏最直接的指标,接进监控
runtime.NumCPU() / GOMAXPROCS(n)核数 / 读写 P 数
runtime.ReadMemStats(&m)堆细节;会 STW,别高频调
runtime.GC() / debug.FreeOSMemory()强制一次 GC / 并把内存还给系统
runtime.Gosched() / Goexit()让出 / 结束当前 goroutine
runtime.Stack(buf, all) / debug.PrintStack()取栈 / 打栈
runtime.SetFinalizer / KeepAlive终结器 / 阻止过早回收;都属于最后手段
debug.SetGCPercent / SetMemoryLimit代码里改 GOGC / GOMEMLIMIT
debug.ReadBuildInfo()读版本与依赖信息,打 --version 用得上
debug.SetMaxStack / SetMaxThreads栈与线程上限

pprof:七种剖面与端点

剖面回答什么问题怎么开
profile(CPU)时间花在哪个函数/debug/pprof/profile?seconds=30
heap当前还活着的内存在哪分配的/debug/pprof/heap
allocs累计分配——查 GC 压力看它/debug/pprof/allocs
goroutine有多少、卡在哪/debug/pprof/goroutine?debug=2
block阻塞在 channel / 锁上多久先 runtime.SetBlockProfileRate(1)
mutex锁竞争先 runtime.SetMutexProfileFraction(1)
threadcreate创建了多少系统线程/debug/pprof/threadcreate
trace时间线:调度、GC、网络各发生在何时/debug/pprof/trace?seconds=5 → go tool trace

接入只要一行:import _ "net/http/pprof" 再起一个 http.ListenAndServe("localhost:6060", nil)。分析:go tool pprof -http=:8080 http://localhost:6060/debug/pprof/heap,交互命令是 top / list 函数名 / web。

把 net/http/pprof 挂在对外端口上等于把 profile 和全部 goroutine 栈公开。它注册的是 http.DefaultServeMux,所以只要你的业务服务也用默认 mux,路径就一起暴露了。正确做法是单起一个只监听 localhost 的调试端口,或者在网关层拦掉 /debug/。
优化前先量,量完再改,改完再量。基准测试里用 b.ReportAllocs() 看每次操作的分配数,比看 CPU 火焰图更早能发现问题——Go 服务的性能问题里,大头往往是内存分配带来的 GC 压力,而不是算法本身。benchstat 能对比两次基准结果并给出显著性。

「goroutine 很便宜」这句话的根据在调度器:G、M、P 三者的分工让成千上万条协程能挤在少数几个系统线程上跑。

三个角色与它们的协作

探源:06 章说 goroutine 廉价,是因为它被「M:N 复用到少数 OS 线程」——下面给出那台机器。三个角色:G = goroutine(一个任务 + 它的栈);M = machine,即真正的 OS 线程;P = processor,逻辑处理器,数量 = GOMAXPROCS(默认 = CPU 核数)。规则:M 必须先绑定一个 P 才能执行 G;每个 P 挂一条本地可运行队列。从这套结构直接推出三件事:① work-stealing——某个 P 的队列空了,它会去别的 P「偷」一半 G 过来,负载自动均衡;② syscall 不卡别人——G 陷入阻塞系统调用时,M 与 P 解绑,P 立刻转交给另一个空闲 M 继续跑队列里其他 G(这就是「一个 goroutine 阻塞不拖累其余」的机制);③ 异步抢占(1.14+)——靠信号打断,连没有函数调用的死循环 G 也能被暂停,不再饿死同 P 的其他 G。联动 OS 页:这是用户态调度,切换不进内核,所以比 OS 线程切换便宜得多。

三个角色各是什么

是什么数量
G(goroutine)你 go 出来的任务,栈很小且可增长可以几十万
M(machine)真正的系统线程按需增减
P(processor)执行 G 所需的上下文与本地队列,M 必须持有 P 才能跑 G默认 = CPU 核数(GOMAXPROCS)
  • P 的存在是这套模型的关键:它把「可并行度」与「线程数」解耦——某个 M 因系统调用被阻塞时,它手里的 P 会被转交给另一个 M,剩下的 G 照常跑;
  • 这也解释了两件事:goroutine 阻塞在 IO 上不会占住线程(调度器会换走);而goroutine 里跑纯计算死循环则可能占住 P(Go 1.14 起有异步抢占,但代价仍在);
  • 「work stealing」也在这一层:P 的本地队列空了,会去别的 P 那里偷一半任务过来——负载均衡不需要你操心。
// P 的数量 = GOMAXPROCS,决定真正的并行度
fmt.Println(runtime.GOMAXPROCS(0))  // 传 0 只查询不修改
fmt.Println(runtime.NumCPU())        // 机器逻辑核数
fmt.Println(runtime.NumGoroutine())  // 当前活跃 goroutine 数

// 主动让出当前 P,把 M 交还给调度器(极少需要手动调)
runtime.Gosched()

// 并行 ≠ 并发:GOMAXPROCS=1 时 goroutine 仍并发(交替),
// 但同一时刻只有一个在真正执行——并行度由 P 的数量决定。
容器里的经典坑:GOMAXPROCS 默认按宿主机核数设置,但 cgroup 可能只给容器 2 核。结果 Go 开了 32 个 P 去抢 2 核,疯狂上下文切换、延迟劣化。解法:容器里显式设 GOMAXPROCS,或引入 uber-go/automaxprocs(自动读 cgroup 配额校准)。
goroutine 泄漏是这一层最常见的生产问题:启动了却因 channel 无人收/context 没取消而永久阻塞(回忆 06、07 章)。runtime.NumGoroutine() 持续攀升就是信号,用 pprof 的 goroutine profile(本章后面)能一眼定位卡在哪一行。

同一个变量分配在栈上还是堆上,由编译器的逃逸分析决定——而这个决定直接影响 GC 压力。

什么会导致逃逸

探源:C 里返回局部变量的地址是「悬垂指针」的经典 bug,但 Go 里 return &T{} 完全安全——根源在逃逸分析:编译器在编译期分析每个变量的「生命周期是否超出当前函数」。不逃逸(只在函数内用)就放栈上,随函数返回自动回收、零 GC 成本;逃逸(地址被返回、存入堆对象、被闭包捕获、赋给接口等)就自动改分配到堆,交给 GC 管理。所以「栈还是堆」不由你写 var 还是 new 决定,而由编译器按用途自动裁定——这正是 Go 既能用指针、又无需手动内存管理的底气。

// 返回局部变量地址:Go 里安全,u 自动逃逸到堆
func newUser(name string) *User {
  u := User{Name: name}   // 看似栈变量
  return &u               // 地址逃逸 → 编译器改分配到堆,不是悬垂指针
}

// 用 -gcflags=-m 让编译器打印逃逸决策
// $ go build -gcflags=-m ./...
//   ./main.go:3:2: moved to heap: u        ← u 逃逸了
//   ./main.go:8:13: ... does not escape     ← 未逃逸,留栈上

// 常见逃逸诱因:赋给接口 / 被闭包捕获 / 存入更长命的对象
func logv(v any) { fmt.Println(v) } // 传入接口,实参常逃逸
别为「避免逃逸」过早优化、把代码拧巴。逃逸分析是编译器的活,多数时候不用管;只有当 profile 指出某热点因大量堆分配拖慢 GC 时,才用 -gcflags=-m 定位、针对性优化(如复用 buffer,见 07 章 sync.Pool)。
一句话记牢:是否用指针看语义(要不要共享 / 改原值),是否上堆交给编译器。栈分配比堆快、也不给 GC 添活,但这是自动发生的红利,不该反过来指导你写别扭的代码。

Go 的 GC 目标是低延迟而不是高吞吐:它宁可多占点 CPU,也要把停顿压到亚毫秒级。

三色标记与两个旋钮

探源:00 章说 Go 拿 GC 换「并发人人写得对」,代价是 STW 停顿——下面讲 Go 怎么把这代价压到亚毫秒。算法是并发的三色标记-清除:把对象染成白(待回收)/灰(存活但引用还没扫)/黑(存活且引用已扫完),从根出发把灰逐步转黑,最后剩下的白色即垃圾。关键在并发:标记时你的程序还在跑、还在改指针,靠混合写屏障(1.8+)拦截指针写入、保证不漏标存活对象,于是 STW 只发生在标记开始/结束的极短瞬间。调优就两个旋钮:GOGC(默认 100 = 堆相比上次存活量增长 100% 时触发下次 GC;调大 → GC 更少但吃更多内存,是用内存换 CPU);GOMEMLIMIT(1.19+,软内存上限,逼近时 GC 更激进以防 OOM)。

两个旋钮,方向相反

调大调小
GOGC(默认 100)堆增长到上次的 2 倍以上才回收 → GC 更少、内存更多回收更勤 → 内存更省、CPU 更忙
GOMEMLIMIT(软上限)—逼近上限时主动加频回收,避免 OOM
  • 两者配合才是现代做法:GOGC 管常态节奏,GOMEMLIMIT 管兜底——容器里跑服务时设一个略低于内存配额的软上限,能把「平时省 CPU、极端时不 OOM」两件事同时拿到;
  • Go 的 GC 明确以低延迟为目标:标记与用户代码并发进行,停顿只发生在两次很短的屏障切换上;代价是吞吐不如「停下来一次性回收」的设计,以及需要写屏障的额外开销;
  • 调优前先量:GODEBUG=gctrace=1 会把每轮 GC 的耗时与堆大小打出来,比凭感觉调旋钮可靠得多。
// 环境变量调优(无需改代码)
// GOGC=200      触发阈值放宽一倍:GC 更少、内存更高
// GOGC=off      关闭比例触发(配合 GOMEMLIMIT 用)
// GOMEMLIMIT=512MiB  软上限,容器里配合内存 limit 设

// 代码内等价旋钮
debug.SetGCPercent(200)          // = GOGC=200
debug.SetMemoryLimit(512 << 20)  // = GOMEMLIMIT=512MiB

// 观测:真实内存与 GC 数据
var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Printf("堆占用=%vMB GC次数=%v 累计STW=%v\n",
  m.HeapInuse>>20, m.NumGC, time.Duration(m.PauseTotalNs))
GC 压力的根因几乎总是「分配速率」,不是 GOGC 设小了(回忆本章逃逸分析卡)。GC 频繁、CPU 被 GC 吃掉,先别急着调大 GOGC——那只是拿内存掩盖问题。正确顺序:用 pprof 的 heap profile(见本章最后一卡)找出谁在热路径疯狂堆分配,用 sync.Pool(07 章)、预分配 slice 容量、减少逃逸去降低分配,才是治本。
GOMEMLIMIT + GOGC=off 是一个经典组合:平时几乎不按比例触发 GC(省 CPU),只在逼近内存软上限时才回收——在「内存管够、要极致吞吐」的服务上很划算。但务必给 GOMEMLIMIT 留出栈/非堆内存的余量,别贴着容器硬 limit 设。

字段顺序会影响结构体大小——同样的五个字段,换个顺序可能省下三分之一内存。

对齐规则与 unsafe 的边界

探源:结构体(04 章)在内存里不是把字段简单挨着摆,而是按对齐规则排布——每个字段要落在其大小的整数倍地址上,编译器为此在字段间/尾部插入填充字节(padding)。直接后果:字段顺序会改变整个结构体的大小。把 bool(1 字节)夹在两个 int64(8 字节)之间,会浪费大量 padding;把同大小字段聚在一起、大的排前面,往往能显著缩小。unsafe.Sizeof/Alignof/Offsetof 让你精确查看布局。unsafe.Pointer 则是绕过类型系统的后门——能做 []byte ↔ string 零拷贝转换、与 cgo 交互(16 章),但你要自己保证内存安全,且不受 Go1 兼容性保护。

// 字段顺序影响大小:对齐 + padding 在作祟
type Bad  struct { a bool; b int64; c bool } // 24 字节(两处 padding)
type Good struct { b int64; a bool; c bool } // 16 字节(尾部一处)

fmt.Println(unsafe.Sizeof(Bad{}))   // 24
fmt.Println(unsafe.Sizeof(Good{}))  // 16
fmt.Println(unsafe.Alignof(int64(0)))  // 8:对齐边界

// unsafe 的正当用途之一:[]byte -> string 零拷贝(避免复制大缓冲)
func b2s(b []byte) string {
  return unsafe.String(unsafe.SliceData(b), len(b)) // Go 1.20+
}
unsafe 名副其实:① 不受 Go1 兼容性保证,且 Sizeof 跨平台不同(32/64 位、不同架构对齐不一样),别把结果硬编码;② 用 b2s 零拷贝出的 string 与原 []byte 共享内存——之后再写那段 []byte,会违反「string 不可变」、引发诡异 bug;③ 乱转 unsafe.Pointer 会让 GC 误判存活、内存损坏。仅在 profile 证明值得、且完全理解规则时才用。
要在不使用 unsafe 的前提下压缩结构体,把字段按大小从大到小排,通常就能自动最小化 padding。go vet 的 fieldalignment 分析器(go run golang.org/x/tools/go/analysis/passes/fieldalignment/cmd/fieldalignment)能自动检查并给出最优字段顺序。

优化之前先量:pprof 回答「时间和内存花在哪」,trace 回答「goroutine 到底在等什么」。

两个工具,两类问题

优化的铁律是「先测量,再动手」(本章逃逸分析卡就警告过别为逃逸过早优化)。pprof 是官方剖析器,四类主 profile 各答一个问题:CPU(谁在烧 CPU)、heap(谁在占用/分配内存 → 直连本章 GC 卡)、goroutine(泄漏/都阻塞在哪 → 直连 GMP 卡)、block / mutex(锁竞争卡在哪,07 章)。两种接入方式:给线上服务匿名 import net/http/pprof,自动挂出 /debug/pprof 端点;给基准测试(12 章)加 -cpuprofile/-memprofile 直接出文件。采集完用 go tool pprof 交互分析——top 看热点、list 函数 看逐行开销、web 出火焰图。要看调度/GC/goroutine 的时间线则用 runtime/trace。

// ① 线上服务:匿名 import 即自动挂 /debug/pprof
import _ "net/http/pprof"
go func() { log.Println(http.ListenAndServe("localhost:6060", nil)) }()

// 采集并分析(30s CPU 火焰图 / 当前堆 / goroutine 栈)
// $ go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30
// $ go tool pprof http://localhost:6060/debug/pprof/heap
// $ curl localhost:6060/debug/pprof/goroutine?debug=2   # 所有 goroutine 栈

// ② 基准测试直接出 profile(配合 12 章 benchmark)
// $ go test -bench=. -cpuprofile=cpu.out -memprofile=mem.out
// $ go tool pprof -http=:8000 cpu.out   # 浏览器看火焰图

// pprof 交互里最常用三招:
//   top      按累计开销排热点函数
//   list Foo 看 Foo 逐行的耗时/分配
//   web      生成调用图 SVG
安全 & 开销两件事:① /debug/pprof 端点绝不能裸暴露公网——它会泄漏内存内容、调用栈,还能被 ?seconds= 拉满做 DoS;绑到 localhost 或内网、加鉴权。② CPU profiling 本身有约 5% 开销、采样制,别长期常开;heap 是快照,看的是采样后的近似值。
排查内存分两种看法:inuse_space(当前仍占用)用来抓内存泄漏 / 常驻过高;alloc_space(累计分配总量)用来抓GC 压力源——即使很快被释放,高频分配照样把 GC 累垮(回本章 GC 卡)。go tool pprof 里用 -sample_index=alloc_space 切换。

工程与互操作

四张表:go:embed 规则、//go:build 约束语法与文件名后缀、交叉编译矩阵与常用 ldflags、cgo 类型映射,外加 database/sql 的连接池与事务 API。这些是把 Go 嵌进真实工程的边界。

「编译成一个二进制扔过去就能跑」是 Go 的招牌,下面是维持这块招牌要知道的全部开关。

go:embed 规则

写法要求
//go:embed a.txt + var s string单文件 → string 或 []byte
//go:embed static/* + var f embed.FS多文件 → embed.FS,需 import "embed"
只用 string / []byte 时写 import _ "embed"
指令与变量之间不能有空行,且变量必须是包级的
写目录名 vs dir/*写目录名递归嵌入但跳过 . / _ 开头的文件;static/* 会把这一层的 . 文件也收进去;递归全收写 all:static
路径限制只能是本包目录下的相对路径,不能 ..、不能符号链接
配 http.FileServerhttp.FS(f);想去掉前缀用 fs.Sub(f, "static")

//go:build 约束

写法含义
//go:build linux && amd64与;|| 或、! 非、() 分组
位置必须在 package 之前,且后面空一行
//go:build ignore惯例:这个文件不参与构建(脚本、生成器)
//go:build cgo / !cgo按是否启用 cgo 分流
//go:build go1.25按工具链版本分流(1.25 及以上满足),写兼容代码用
-tags=prod,debug自定义标签,构建时传入
文件名后缀x_linux.go、x_windows_amd64.go 自动生效,等价于写约束
x_test.go只参与测试构建

老式的 // +build 行已被 //go:build 取代(Go 1.17 起),gofmt 会自动补齐。

交叉编译与构建参数

目标 / 参数说明
GOOS=linux GOARCH=amd64 go build最常见的服务端目标
linux/arm64ARM 服务器、树莓派、Apple 芯片上的容器
darwin/arm64 / darwin/amd64Apple 芯片 / Intel Mac
windows/amd64产物自动带 .exe
js/wasm / wasip1/wasm浏览器 / WASI 运行时
go tool dist list列出全部受支持组合
CGO_ENABLED=0纯静态产物,容器里不依赖 glibc;交叉编译几乎都要设它
-ldflags "-s -w"去符号表与调试信息,体积约省三成(代价:panic 栈没行号)
-ldflags "-X main.version=1.2.3"注入版本号到包级字符串变量
-trimpath去掉构建机的绝对路径,产物可复现
-tags netgo,osusergo强制用纯 Go 的 DNS / user 实现

cgo 类型映射与规则

CGo
char / signed char / unsigned charC.char / C.schar / C.uchar
int / long / long longC.int / C.long / C.longlong
float / doubleC.float / C.double
char* → Go 字符串C.GoString(p)
Go 字符串 → char*C.CString(s),必须 defer C.free(unsafe.Pointer(p))
void* + 长度 → []byteC.GoBytes(p, n)
指针规则C 侧不能长期持有 Go 指针;默认就有轻量检查,GODEBUG=cgocheck2=1 查得更严
调用开销每次跨界约几十到上百纳秒,且会占住一个系统线程——热路径上别跨
go:embed 嵌的是构建时的文件:改了 static/ 里的内容必须重新 go build,开发时热更新会失效。常见做法是用构建标签分流——开发构建从磁盘读 os.DirFS("static"),发布构建用 embed.FS,两者都满足 fs.FS 接口,业务代码一行都不用改。
能不用 cgo 就别用:一旦启用,交叉编译要目标平台的 C 工具链、静态产物变成动态依赖、构建变慢、panic 栈跨不过 C 帧。实践中多数「必须用 C 库」的场景都有纯 Go 替代(SQLite 有 modernc.org/sqlite,加密有标准库)。先找纯 Go 实现,找不到再谈 cgo。

//go:embed 把文件编译进可执行文件——部署时只需要拷一个二进制,这是 Go 交付体验的一部分。

怎么嵌,嵌什么

探源:Go 的招牌是「一个二进制扔过去就能跑」(00 章)——可网页模板、数据库迁移 SQL、默认配置这些静态文件怎么一起带走?//go:embed(1.16+)在编译期把文件或整个目录直接烤进二进制,从此交付物真的只有一个文件。三种承接形态:嵌成 string(单个文本文件)、[]byte(单个二进制文件)、或 embed.FS(一个只读文件系统,可交给 http.FileServer 建静态站、或 template.ParseFS 解析一整套模板)。

import "embed"

// ① 单个文件 → string / []byte
//go:embed version.txt
var version string

// ② 整个目录 → embed.FS(只读文件系统)
//go:embed templates/* static/*
var assets embed.FS

func main() {
  // 把嵌入的 static/ 当静态站点提供
  http.Handle("/static/", http.FileServer(http.FS(assets)))

  // 解析嵌入的整套 HTML 模板
  tmpl := template.Must(template.ParseFS(assets, "templates/*.html"))
  _ = tmpl
  http.ListenAndServe(":8080", nil)
}
路径规则容易踩:① 路径相对于该 .go 文件所在目录,不能用 .. 跳出包目录;② 默认不嵌以 . 或 _ 开头的文件,要连它们一起嵌得写 //go:embed all:dir;③ 嵌入的是编译那一刻的快照——改了源文件必须重新编译,热更新在这里不成立。
最实用的三个场景:把前端构建产物 dist/ 嵌进后端二进制(前后端合一交付)、把数据库迁移 SQL 嵌进来(配合 golang-migrate)、把默认配置模板嵌进来。从此部署不再需要「别忘了把 static 目录一起拷过去」。

同一份代码要在不同平台上编出不同实现时,用 build tag 分文件——比在代码里写一堆 if 判断干净得多。

条件编译与一行交叉编译

一套代码按平台 / 环境裁剪,靠两种机制:① 文件名后缀——store_linux.go、store_windows.go、store_amd64.go 会被工具链自动按当前 GOOS/GOARCH 选择性编译,连 tag 都不用写;② //go:build 约束行(1.17+ 新语法,旧写法是 // +build)——按自定义标签(如 integration、prod)决定某文件是否参与编译。而 交叉编译是 Go 的杀手锏:设一对环境变量,一条命令就能在你的 Mac 上产出 Linux/ARM 的二进制,完全不需要目标机器或虚拟机。

// —— 文件顶部的约束行:必须在 package 之前、后跟一个空行 ——
//go:build linux && amd64

package store

// 慢的集成测试单独打 tag,日常快测不带它
//go:build integration
// $ go test -tags=integration ./...   # 显式带 tag 才编译/运行

// —— 交叉编译:一台机器产出所有平台 ——
// $ GOOS=linux   GOARCH=amd64 go build -o app-linux
// $ GOOS=windows GOARCH=amd64 go build -o app.exe
// $ GOOS=darwin  GOARCH=arm64 go build -o app-mac-m1
// 查看所有支持的平台组合:go tool dist list
两个卡人的点:① //go:build 行必须在文件最顶部、package 之前,且和 package 之间空一行——位置错了整行被当普通注释、约束静默失效。② 交叉编译遇上 cgo(见本章后面)会出错:CGO_ENABLED=1 时需要目标平台的 C 工具链。纯 Go 项目记得设 CGO_ENABLED=0——既让交叉编译畅通,又产出静态链接的二进制(不依赖目标机的 libc,真正随处可跑,Docker scratch 镜像的前提)。
Dockerfile 里的标准姿势:CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /app——关 cgo 得静态二进制、-s -w 去掉调试信息缩小体积,再 COPY 进 scratch 或 distroless 空镜像,最终镜像可小到几 MB。

cgo 能调 C 库,但它不是免费的:每次跨界都有开销,还会把 Go 的两大优势(快速编译、简单交叉编译)一并让出去。

什么时候值得用 cgo

cgo 让 Go 调用 C 库——复用成熟的 C 生态(SQLite、图像/加密/硬件 SDK)。写法:import "C" 紧邻其上的注释块就是 C 代码/头文件,之后用 C.xxx 调用。但代价很重,几乎条条戳中 Go 的优点:① 每次 Go↔C 调用要切换到系统栈、有固定开销,且 C 代码不能被 Go 调度器抢占(长 C 调用会占住一个 M);② 破坏交叉编译(要目标平台 C 工具链)、明显拖慢编译;③ 跨越边界的内存要手动管——C.CString 分配的内存 Go GC 不管,必须 C.free。所以原则很简单:能纯 Go 就纯 Go。

/*
#include <stdlib.h>
#include <string.h>
*/
import "C"          // 必须紧跟在上面的 C 注释块之后,独占一行
import "unsafe"

func cLen(s string) int {
  cs := C.CString(s)          // Go string → C char*(在 C 堆上分配)
  defer C.free(unsafe.Pointer(cs)) // 必须手动释放,GC 不管这块!
  return int(C.strlen(cs))     // 调 C 的 strlen,返回值转回 Go int
}
cgo 不是 Go:CGO_ENABLED=0 时,任何含 import "C" 的包直接编译不过——这也是本章前面说交叉编译要关 cgo 的原因。另有指针传递铁律:传给 C 的 Go 指针,其指向的内存里不能再含有 Go 指针,否则运行时报「cgo argument has Go pointer to Go pointer」并崩溃——因为 GC 可能在 C 持有期间移动/回收它。
动手前先找纯 Go 替代:需要 SQLite 有 modernc.org/sqlite(纯 Go 移植,无需 cgo)、需要图像/压缩多半也有纯 Go 实现。用上它们就一次性甩掉 cgo 的全部麻烦——交叉编译、静态二进制、快编译、无手动内存——通常远比省下的那点性能划算。

database/sql 里的 *DB 不是一个连接,而是一个连接池——很多「连接数爆了」的问题都源自没意识到这一点。

连接池、事务与超时

13 章的依赖注入卡顺带见过 sql.DB,这里讲透一个最关键的认知:sql.DB 不是「一条连接」,而是一个连接池——所以它该在启动时建一次、全程复用(别每次请求都 Open)。三根调优旋钮:SetMaxOpenConns(最大并发连接,挡住打爆数据库)、SetMaxIdleConns(空闲保活,减少反复建连)、SetConnMaxLifetime(连接最长寿命,躲开数据库/中间件的空闲超时)。查询一律走 QueryContext/ExecContext 带上 context(07 章)实现超时与取消;写入用参数占位符($1/?)而非字符串拼接,从根上杜绝 SQL 注入。事务 BeginTx + defer tx.Rollback() 兜底(提交后再 Rollback 是安全的空操作)。

db, err := sql.Open("postgres", dsn) // 启动时一次;返回的是连接池
if err != nil { log.Fatal(err) }
db.SetMaxOpenConns(25)          // 并发上限
db.SetMaxIdleConns(25)          // 空闲保活
db.SetConnMaxLifetime(5 * time.Minute) // 定期换新连接

// 查询:带 context 超时 + 参数化(防注入),rows 必须 Close
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
rows, err := db.QueryContext(ctx,
  "SELECT id, name FROM users WHERE age > $1", 18)
if err != nil { return err }
defer rows.Close()             // 忘了它 → 连接泄漏、池耗尽!
for rows.Next() {
  var id int; var name string
  if err := rows.Scan(&id, &name); err != nil { return err }
}

// 事务:defer Rollback 兜底,成功路径 Commit(提交后 Rollback 是 no-op)
tx, err := db.BeginTx(ctx, nil)
if err != nil { return err }
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, "UPDATE ..."); err != nil { return err }
return tx.Commit()
连接泄漏三连:① rows 忘了 defer rows.Close()——每次泄漏一条连接,很快 SetMaxOpenConns 耗尽、后续查询全部阻塞;② 不设 SetConnMaxLifetime——数据库重启或经过会杀空闲连接的中间件(如云 RDS 代理)后,池里攒着死连接,下次拿到就报错;③ QueryRow(...).Scan() 的错误必须查,尤其 errors.Is(err, sql.ErrNoRows) 才能区分「查无此行」与真正的错误。
database/sql 面向接口,换数据库只改驱动 import、SQL 基本不动。往上一层想少写样板:sqlc(编译期从你的 SQL 生成类型安全的查询代码——正是 14 章「代码生成」思路的落地)、或 sqlx(给标准库加 StructScan 等便利)。ORM(GORM)适合 CRUD 密集但复杂查询会失控,按团队权衡。

Go 的界面:TUI 与 GUI

TUI 与 GUI 两侧的库一张表列全(含渲染方式、是否要 cgo、各自的代价),外加一张「界面要付多少体积」的体积对照。标准库里没有一行界面代码,本章列的全是社区生态与取舍。

Go 的界面生态偏斜得很明显:终端这一侧异常繁荣,桌面 GUI 每条路都要付 cgo 的代价。

TUI:终端界面

库定位要点
Bubble TeaElm 架构的 TUI 框架Model / Update / View 三件套,社区事实标准(本章有专卡)
Lip Gloss样式与布局给字符串上色、加边框、做盒模型
Bubbles现成组件输入框、列表、表格、分页、进度条、spinner
Huh表单与交互提问不想写完整 TUI 时最省事
Glamour终端里渲染 Markdown做帮助文档、发布说明
tview / tcell控件式 TUI / 底层终端库更像传统 GUI 的写法;tcell 是它与很多库的底座
pterm / uiprogress输出美化、进度条只想让 CLI 好看点,不做交互
cobra / urfave/cli / kong命令行骨架多数工具止步于此就够

TUI 这侧几乎全是纯 Go、无 cgo,所以「一条命令交叉编译到任何平台」依然成立。

GUI:桌面界面(版本为 2026-07 现查)

方案版本怎么画主要代价
Fynev2.8.0自绘(OpenGL),Material 风格需 cgo 与图形驱动;外观不原生;产物较大
Giov0.10.1自绘,即时模式,支持移动端API 抽象层次低,曲线陡
Wailsv2.13.0(v3 仍 alpha)系统 WebView,前端写界面要会前端;用户机器需 WebView 运行时
本地 HTTP + 浏览器标准库起个服务,浏览器打开零 GUI 依赖,只是想要个配置界面时最划算

体积对照(Go 1.25.5 / windows-amd64)

程序默认构建加 -ldflags "-s -w"
hello world(只用 fmt)2.22 MB1.51 MB
Bubble Tea v2 + Lipgloss v2 的计数器 TUI5.06 MB3.54 MB

Go 的起步体积就有 2 MB 左右(运行时与 GC 静态链接进去了),一个完整 TUI 框架只多约 3 MB。

选型判据

用户是谁做什么
开发者 / 运维别做 GUI:做好 CLI,必要时加 TUI
普通用户,要双击就能用Wails(有前端能力)或 Fyne(想纯 Go)
要上移动端 / 图形密集Gio
只是要个配置界面本地 HTTP 服务 + 浏览器
GUI 方案的代价集中在同一处:要画窗口就要碰系统图形库,也就意味着 cgo——交叉编译不再是一条命令(需要目标平台的 C 交叉工具链,实践上多数团队改成「在各平台的 CI runner 上各构建一次」),CGO_ENABLED=0 的静态产物优势消失,glibc 版本不匹配成为最常见的运行期报错。选 GUI 之前先确认这些成本能接受。
做 TUI 一定要保留一个非交互模式:检测到输出不是终端(term.IsTerminal(int(os.Stdout.Fd())))就退回纯文本,否则程序在 CI 日志与管道里会输出一堆控制字符。同理,尊重 NO_COLOR 环境变量。

Go 的定位是「写服务与命令行工具」,标准库因此完全不涉及界面。社区补上的部分呈现出很明显的偏斜:终端 UI 这一侧异常繁荣,桌面 GUI 这一侧每个方案都在做取舍。

为什么 TUI 这一侧特别强

  • Go 的用户群大量在写开发者工具与运维工具,这类程序的用户本来就在终端里;
  • Go 的单文件静态二进制 + 交叉编译特性,让「一个可执行文件扔到任何机器上就能跑」成立——这正是命令行工具最需要的分发方式;
  • 没有 cgo 依赖:纯 Go 的 TUI 库可以交叉编译到任何平台,而 GUI 方案基本都要绑定系统图形库。

界面要付多少体积

程序默认构建加 -ldflags "-s -w"
hello world(只用 fmt)2.22 MB1.51 MB
Bubble Tea v2 + Lipgloss v2 的计数器 TUI5.06 MB3.54 MB

(Go 1.25.5 / windows-amd64;数字随版本与平台变化,看量级即可。)三点值得记住:① Go 的起步体积就有 2 MB 左右(运行时与 GC 都静态链接进去了);② 一个完整 TUI 框架只多了约 3 MB;③ -s -w 去掉符号表与调试信息能省三成,代价是 panic 栈里没有行号——发布构建常用,但要同时保留一份带符号的产物用于排查。

三条路线的选择

  • 纯命令行(无界面):flag / cobra + 结构化输出。最容易测试、最容易被别的程序调用,也最容易被脚本编排——多数工具应该止步于此;
  • TUI:需要持续展示状态(进度、日志、列表选择)时才升级。注意保留一个非交互模式,否则在 CI 与管道里会出问题(本章后面的 pitfall);
  • GUI:要给非技术用户使用时才考虑,并且先接受 cgo 与打包的代价(本章末卡)。
# (Go 1.25.5, windows/amd64)
$ go build -o hello.exe hello.go            && ls -l hello.exe
2328064  # 2.22 MB —— 只是一句 fmt.Println
$ go build -ldflags "-s -w" -o hello-sw.exe hello.go
1586688  # 1.51 MB —— 去掉符号表与 DWARF

$ go build -o tui.exe .                      # Bubble Tea v2 + Lipgloss v2
5309440  # 5.06 MB
$ go build -ldflags "-s -w" -o tui-sw.exe .
3716608  # 3.54 MB

# 交叉编译(纯 Go 依赖时一条命令换平台,这是 Go 的最大优势之一)
$ GOOS=linux GOARCH=arm64 go build -o tool-linux-arm64 .
「Go 程序体积大」这句抱怨的常见误判是去砍依赖。一个只用 fmt 的 hello world 就是 2.22 MB——大头是运行时与 GC,而不是你的代码或依赖。真正能显著减小体积的只有三招:-s -w(省约三成)、避免引入巨型依赖(比如整个 cloud SDK)、以及必要时用 UPX 之类压缩(但会触发某些杀毒软件误报)。为了几百 KB 去重构依赖树,是最典型的白干。
发布二进制时的常用组合是 -ldflags "-s -w" 再加 -trimpath(去掉产物里的本机绝对路径,既减小体积又避免泄漏目录结构)。同时把版本号也用 ldflags 注进去:-X main.version=$(git describe --tags)——这样 tool --version 打印的就是构建时的真实版本,不用维护一个手写常量。

Go 的 TUI 这几年基本被 Charm 系收拢:Bubble Tea(框架)+ Lipgloss(样式)+ Bubbles(现成组件)。它的模型是从 Elm 借来的,与 Go 的并发模型意外地契合。

三件套与心智模型

  • Model:一个结构体,装着界面的全部状态;
  • Update(msg):收到消息(按键、窗口尺寸变化、定时器、自定义事件)后返回新的 model 和一个可选的命令;
  • View():把 model 渲染成一个字符串,框架负责算差异并刷新终端;
  • 命令(Cmd)就是一个返回消息的函数,框架会在 goroutine 里执行它——于是「发起 HTTP 请求,完成后更新界面」这件事天然是并发安全的:耗时操作在别的 goroutine,状态修改只发生在 Update 里,不需要任何锁。

v2 的两处硬变化(踩过)

  • 模块路径换了:v2 起 Charm 的包用 charm.land/...。照旧写 github.com/charmbracelet/bubbletea/v2 会直接失败,报的是 module declares its path as: charm.land/lipgloss/v2 but was required as: github.com/charmbracelet/lipgloss/v2;
  • 接口签名变了:v1 的 Init() tea.Cmd / View() string,在 v2 里是 Init() tea.Cmd 与 View() tea.View(用 tea.NewView(s) 包一层);按键消息也从 tea.KeyMsg 变成 tea.KeyPressMsg;
  • 照抄网上的 v1 教程会全线编译失败,而报错信息(model does not implement tea.Model (wrong type for method View))指向的是接口不匹配,不会告诉你「你看的是旧版教程」。先看清自己装的是哪个大版本,再找对应文档。

配套生态

  • Bubbles:文本框、列表、表格、进度条、分页器等现成组件;
  • Lipgloss:声明式样式(边框、内边距、颜色、布局),自动适配终端的颜色能力;
  • Huh:交互式表单/问卷,写 CLI 向导极省事;
  • 另一条路线:tview(基于 tcell)是传统的控件树式 TUI,更接近老式 GUI 的写法,适合不想接受 Elm 心智的团队。
// go get charm.land/bubbletea/v2 charm.land/lipgloss/v2  ← 注意是 charm.land
package main

import (
    "fmt"
    tea "charm.land/bubbletea/v2"
    "charm.land/lipgloss/v2"
)

type model struct{ count int }

func (m model) Init() tea.Cmd { return nil }        // v2:只返回 Cmd

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.KeyPressMsg:                          // v2:不再是 tea.KeyMsg
        switch msg.String() {
        case "q", "ctrl+c":
            return m, tea.Quit
        case "+":
            m.count++
        }
    }
    return m, nil
}

func (m model) View() tea.View {                     // v2:返回 tea.View
    s := lipgloss.NewStyle().Bold(true).Padding(1, 2)
    return tea.NewView(s.Render(fmt.Sprintf("计数 %d(+ 加一,q 退出)", m.count)))
}

func main() { tea.NewProgram(model{}).Run() }
TUI 程序必须保留一个非交互模式。接管终端的程序在没有 TTY 的环境(CI、cmd | grep、cron、Docker 无 tty)里行为会很糟:要么直接报错退出,要么把控制序列原样吐进管道,让下游拿到一堆乱码。标准做法是检测 os.Stdout 是不是终端(term.IsTerminal(int(os.Stdout.Fd()))),不是就退回到纯文本输出,并提供 --no-tui / --json 开关让用户强制指定。
Bubble Tea 的 Cmd 机制是它和 Go 并发模型的接缝:任何耗时操作都写成一个返回 tea.Msg 的函数交给框架,框架在单独的 goroutine 里跑完后把消息送回 Update。于是整个程序里只有一个地方会修改状态——这条约束让 TUI 程序几乎不会出现数据竞争,也让 go test -race 能真正查出问题(本页 11 章讲的竞态检测在这里非常好用)。

Go 做桌面 GUI 是可行的,但没有一条路是没有代价的——代价集中在同一处:要画窗口就要碰系统图形库,而那意味着 cgo,也就意味着失去「一条命令交叉编译」这个 Go 最大的便利。

三条路线(版本为 2026-07 现查)

方案版本怎么画主要代价
Fynev2.8.0自绘(OpenGL),Material 风格需要 cgo 与图形驱动;外观不原生;产物较大
Giov0.10.1自绘,即时模式,支持移动端API 抽象层次低,学习曲线陡
Wailsv2.13.0(v3 仍在 alpha)系统 WebView 渲染,前端写界面要写前端;依赖各平台 WebView 运行时

cgo 的连锁反应

  • 交叉编译不再是一条命令:需要目标平台的 C 交叉工具链,实践上多数团队改用「在目标平台的 CI runner 上各构建一次」;
  • CGO_ENABLED=0 的静态二进制优势消失:产物开始依赖系统库版本(glibc 版本不匹配是最常见的运行时报错);
  • 构建时间变长,首次构建尤其明显;
  • 这也是为什么 Wails 这条路在 Go 社区接受度高:界面交给 WebView,Go 侧只负责逻辑,跨平台差异被浏览器内核吸收掉了——代价是团队要会前端,且用户机器上要有 WebView 运行时(Windows 需要 WebView2、Linux 需要 WebKitGTK)。

务实的判据

  • 用户是开发者/运维 → 别做 GUI,做好 CLI 和 TUI,收益更高;
  • 要给普通用户一个双击就能用的东西 → Wails(团队有前端能力)或 Fyne(想纯 Go);
  • 要上移动端或做图形密集应用 → Gio;
  • 只是想要个配置界面 → 起一个本地 HTTP 服务 + 浏览器打开,零 GUI 依赖,也是 Go 最擅长的形态。
# cgo 一旦启用,构建方式就变了
$ CGO_ENABLED=0 GOOS=linux go build .     # 纯 Go:任何机器上都能交叉编译
$ CGO_ENABLED=1 GOOS=linux go build .     # 带 cgo:需要目标平台的 C 工具链
#   常见报错:cgo: C compiler "gcc" not found
#   或运行时:/lib/x86_64-linux-gnu/libc.so.6: version 'GLIBC_2.38' not found

# 实践上的解法:让 CI 在每个目标平台各构建一次
jobs:
  build:
    strategy:
      matrix:
        os: [ubuntu-latest, windows-latest, macos-latest]

# 「配置界面」的零依赖方案:本地起个 HTTP 服务,浏览器当界面
http.Handle("/", http.FileServer(http.FS(assets)))   // embed 进二进制
go openBrowser("http://127.0.0.1:8765")
http.ListenAndServe("127.0.0.1:8765", nil)
把本地服务绑到 0.0.0.0 是这条路上最危险的一个疏忽。ListenAndServe(":8765", nil) 会监听所有网卡——同一局域网内的任何人都能访问你的「本地界面」,而这个界面通常没有任何认证(因为「反正只有本机」)。一律显式绑 127.0.0.1,需要远程访问时再加认证与 TLS,而不是靠「没人知道端口」。
「本地 HTTP 服务 + 浏览器界面」这条路在 Go 里特别顺:embed 包能把前端产物打进同一个二进制,于是分发的仍然是单个可执行文件,双击后自动打开浏览器。它避开了 cgo、避开了打包、避开了跨平台外观差异,代价只是「界面跑在浏览器里」。很多 Go 写的开发工具(数据库客户端、性能分析器、本地面板)都是这么做的。

版本、生态与资料索引

收尾三张表:按领域的生态选型(每类事情社区用哪个库)、官方资料与规范索引(查语义去哪、查提案去哪)、工具链清单。外加一条能检验「这一页学没学会」的自测标准。

Go 生态的特点是标准库够用的地方社区就不造轮子。下表每一行先列标准库方案,再列社区主流——没有强需求就用第一列。

Web 与 API

做什么标准库社区主流
HTTP 服务net/http(1.22 起路由支持方法与路径参数)Gin(13 章)、Echo、Fiber、Chi
HTTP 客户端http.Client(务必设 Timeout)resty
参数校验手写go-playground/validator(Gin 内置)
RPCnet/rpc(已冻结)gRPC-Go + protobuf + buf、ConnectRPC
OpenAPI—oapi-codegen、ogen(从规范生成代码)
JSONencoding/jsonsonic、go-json(极端热路径才值得换)

数据与存储

做什么标准库社区主流
SQL 访问database/sql(16 章)sqlx(薄封装)、sqlc(SQL 生成代码)、GORM、ent
驱动—pgx(PG)、go-sql-driver/mysql、modernc.org/sqlite(纯 Go 无 cgo)
迁移—goose、golang-migrate、atlas
Redis / 缓存—go-redis、ristretto / bigcache(进程内)
消息队列—kafka-go / sarama、nats.go、amqp091-go
后台任务time.Tickerasynq、river、robfig/cron

工程周边

做什么标准库社区主流
日志log/slog(1.21 起,首选)zap、zerolog(追求零分配时)
配置flag + os.Getenvviper、koanf、caarlos0/env
命令行flagcobra、urfave/cli、kong
断言与差异testing(12 章)testify、go-cmp(cmp.Diff 打差异最好读)
Mock 与集成测试接口 + 手写假实现uber-go/mock、mockery、testcontainers-go
可观测expvar、net/http/pprofOpenTelemetry-Go、prometheus/client_golang
泛型小工具slices / maps(08 章)samber/lo(能用标准库就别引)
精确小数—shopspring/decimal(钱别用 float64)
别按「星数」选 Web 框架:Gin / Echo / Chi 的处理器都能与 net/http 互通,换框架成本有限;但 Fiber 基于 fasthttp,不兼容 net/http 的接口,一旦选了它,整个中间件生态、httptest、大量库的集成都要另找方案。这是这一类选型里最难回头的一个分叉。
选库先看三件事:最近一次提交、是否有 v2 以上的路径规范、以及 go.mod 里的依赖数量。Go 生态的健康标志是依赖极浅——一个库拉进来几十个间接依赖,本身就是警号。go mod graph 与 go mod why 能在引入前先看清代价。

「这个语义到底怎么定义的」——下面这些才是权威出处,博客与问答只是转述。

规范与官方文档

资料回答什么问题
Go 语言规范(go.dev/ref/spec)语法与语义的最终依据,篇幅出奇地短
Go 内存模型(go.dev/ref/mem)happens-before:什么样的并发访问才有定义(07 章)
Effective Go「地道 Go」的官方说法
pkg.go.dev任何包的文档、示例与被依赖情况
发布说明(go.dev/doc/devel/release)每版到底改了什么(00 章有精简年表)
Go 博客(go.dev/blog)泛型、GC、迭代器等设计的一手解释
Go Wiki(go.dev/wiki)CodeReviewComments、常见问题的社区共识
提案仓库(github.com/golang/proposal)某个特性为什么是这样、将来会不会变
Google Go Style Guide / Uber Go Style Guide团队规范的两份常见模板
A Tour of Go / Go by Example交互式入门 / 按任务查小例子

值得装的工具

工具作用
gopls官方语言服务器,编辑器智能提示的来源
golangci-lint把几十个 linter 合成一次运行,CI 的标配
staticcheck最有价值的单个 linter,golangci-lint 里也含它
gofumpt比 gofmt 更严格的格式化
dlv(Delve)调试器(01 章)
benchstat对比两次基准结果并判断是否显著
goreleaser一条命令出多平台产物、校验和与发布说明
air / wgo开发时热重载
govulncheck官方漏洞扫描,只报你真正调用到的漏洞路径

一条自测标准

题目要用到
抓取 100 个 URL,并发上限 10、总超时 5 秒、失败重试 1 次goroutine + channel(06)、context 与 errgroup(07)、http.Client(11)
验收go test -race 通过,且 runtime.NumGoroutine() 回到基线(无泄漏)

一次写对,这一页就算毕业了。

自测清单里的 go test -race 有个常被忽略的前置条件:竞态检测依赖 cgo,机器上必须有 C 编译器。没装 gcc 的环境里执行 go test -race ./... 会直接失败在 go: -race requires cgo; enable cgo by setting CGO_ENABLED=1,打开后又报 cgo: C compiler "gcc" not found。而部署侧为了产出静态单二进制普遍设 CGO_ENABLED=0——两边配置一撞,CI 里的 -race 就可能长期处在「根本没跑起来」的状态,你却以为有一道并发防线。先确认它真的执行过,再谈「通过」。
查语义时的优先级:规范 > 标准库源码 > 官方博客 > 其它。Go 标准库的源码是公认好读的一份材料,go doc -src pkg.Func 或编辑器里「跳转到定义」就能看——很多「这函数到底怎么处理边界」的问题,读十行源码比搜十篇文章快。