面向零基础读者:读完你应该能看懂绝大多数 Go 代码,并能动手写出符合 Go 习惯的程序。 本笔记只讲 Go 语言本身的语法与工程习惯,所有示例都是与具体业务无关的通用代码,便于随时查阅。 所有代码块都是普通代码块,任何 Markdown 查看器都能直接阅读。
目录
- Go 是一门什么样的语言
- 环境与第一个程序
- 变量、常量与基本类型
- 控制流:if / for / switch
- 函数:多返回值、闭包与泛型
- 复合类型:array / slice / map
- struct 与方法
- 接口:Go 的抽象方式
- 指针:值语义与引用语义
- 错误处理:Go 的核心哲学
- defer:延迟执行与资源释放
- goroutine 与 channel
- context:取消与超时
- sync 包与锁
- 包、模块与工程结构
- //go:embed:把静态文件编进二进制
- //go:build:构建标签
- 标准写法与设计规范
- 测试:表驱动与子测试
- 工具链与门禁
- 新手上手清单与常见坑
- 进阶阅读
1. Go 是一门什么样的语言
Go(又称 Golang)是 Google 2009 年发布的一门编译型、静态类型、自带垃圾回收的语言。它的设计哲学是:
- 简单:关键字只有 25 个,语法极少,读别人的代码不需要学一堆”黑魔法”。
- 显式:错误是返回值而不是异常,类型转换必须显式写,没有运算符重载,没有类继承。
- 并发原生:
go一个函数就是一个 goroutine,channel 是类型安全的通信方式。 - 工具统一:
gofmt强制格式,go test自带测试,编译产物是单个静态二进制。 - 组合优于继承:通过接口和结构体嵌入实现复用,而不是类继承树。
一句话感受 Go 的风格:“让代码 boring(朴素),让工程 predictable(可预测)。”
Go 特别适合写网络服务、CLI 工具和高并发后端——它对延迟、并发和可维护性的照顾,正是它在工业界流行的原因。
2. 环境与第一个程序
2.1 安装
到 https://go.dev/dl/ 下载安装包,或用 Homebrew:
brew install go
go version
2.2 第一个程序
新建一个目录,写一个 main.go:
package main
import "fmt"
func main() {
fmt.Println("hello, world")
}
运行:
go run main.go
编译成单个二进制:
go build -o hello main.go
./hello
2.3 三个最基本的概念
- 包(package):每个
.go文件开头都要声明package xxx。package main且包含func main()的包编译后生成可执行程序。 - 导入(import):用
"fmt"这样的字符串导入标准库或第三方包。 - 可见性:标识符首字母大写表示”导出”(对其他包可见),小写表示”包内私有”。Go 没有
public/private关键字。
package store
// Get 是导出的(首字母大写),其他包可以调用 store.Get
func Get(key string) (string, bool) { ... }
// normalize 是包内私有的(小写),只有 store 包内部能调用
func normalize(key string) string { ... }
这种规则简单到看一眼就知道一个符号是不是公开 API。
3. 变量、常量与基本类型
3.1 变量声明
Go 提供多种声明方式,核心原则是:局部变量优先用 := 短声明,包级变量用 var。
var name string // 声明,零值为 ""
var age int = 18 // 声明并初始化
var x, y int = 1, 2 // 多变量
name := "gopher" // 短变量声明,类型自动推断(只能在函数内用)
age := 18 // int
price := 1.25 // float64
active := true // bool
零值(zero value):Go 中声明变量但不赋值,它会有一个确定的零值,而不是野指针:
| 类型 | 零值 |
|---|---|
| int / float | 0 |
| bool | false |
| string | "" |
| pointer / slice / map / channel / interface / func | nil |
| struct | 每个字段都是其零值 |
利用零值让代码更简单是 Go 的常见做法。一个好习惯是把类型设计成”零值即为安全的默认状态”,这样即使有人忘了给字段赋值,程序也表现正常:
type Config struct {
Verbose bool
Retries int
Timeout time.Duration
}
// Config{} 的零值就是 Verbose=false、Retries=0、Timeout=0,
// 只要各字段的零值本身就是安全默认,构造时就无需强制填满每个字段。
3.2 常量与 iota
const Pi = 3.14159
const (
StatusOK = 200
StatusError = 500
)
iota 用在 const 块里,从 0 开始逐行自增,常用来定义枚举:
type Color int
const (
Red Color = iota // 0
Green // 1
Blue // 2
)
如果一个枚举的数字要对外稳定(写进日志、落库、走网络协议),就不要用 iota,而是命名类型 + 显式常量把值固定下来:
type ErrorCode int32
const (
ErrCodeNone ErrorCode = 0
ErrCodeUnknown ErrorCode = 99999
ErrCodeTimeout ErrorCode = 10001
ErrCodeRefused ErrorCode = 10002
)
什么时候用 iota、什么时候用显式值?数字只在程序内部使用,用 iota;数字要对外稳定(序列化、存储、协议),写死。
3.3 基本类型
int, int8, int16, int32, int64
uint, uint8 (byte), uint16, uint32, uint64
float32, float64
bool
string
rune // int32 的别名,表示一个 Unicode 码点
注意:
int在 64 位机器上是 64 位,但不要假设它和 int64 是同一个类型,跨类型传参必须显式转换。- 字符串是不可变的字节序列,
len(s)返回字节数不是字符数。遍历字符用for _, r := range s。 - Go 没有隐式类型转换,
int32和int之间必须写int32(x)。
4. 控制流:if / for / switch
Go 只有三种循环/分支结构,没有 while,但 for 可以扮演所有角色。
4.1 if
if err != nil {
return err
}
// if 可以带一个初始化语句,变量作用域限于 if/else 块内
if err := cfg.Validate(); err != nil {
return fmt.Errorf("validate config: %w", err)
}
Go 约定:“快乐路径(happy path)靠左”,错误处理后立刻 return,不要写大段 else 嵌套。
// ✅ 推荐:主逻辑一路往下
if err != nil {
return err
}
doSomething()
doMore()
// ❌ 不推荐:主逻辑被包在 else 里越缩越深
if err == nil {
doSomething()
} else {
return err
}
4.2 for
// 经典三段式
for i := 0; i < 10; i++ { ... }
// 当 while 用
for len(queue) > 0 { ... }
// 死循环
for { ... }
// range 遍历切片/映射/字符串/channel
for i, v := range items { ... } // slice: 下标, 值
for k, v := range m { ... } // map: 键, 值
for i, r := range "你好" { ... } // string: 字节下标, rune
for v := range ch { ... } // channel: 接收直到关闭
只想要下标或只想要值时,用 _ 丢弃另一个:
for _, name := range names { ... }
for i := range names { ... }
4.3 switch
switch env {
case "dev":
setupDev()
case "prod", "staging": // 一个 case 可以有多个值
setupProd()
default:
setupDefault()
}
Go 的 switch 默认不贯穿(不需要写 break)。如果需要继续匹配下一个 case,用 fallthrough。
switch 还可以不带表达式,当作 if-else 链来写:
switch {
case score < 0.3:
return "low"
case score < 0.7:
return "mid"
default:
return "high"
}
5. 函数:多返回值、闭包与泛型
5.1 基本函数
func add(a, b int) int {
return a + b
}
Go 函数可以返回多个值,这是错误处理的基础:
func divide(a, b float64) (float64, error) {
if b == 0 {
return 0, errors.New("divide by zero")
}
return a / b, nil
}
result, err := divide(10, 2)
if err != nil {
log.Fatal(err)
}
fmt.Println(result)
5.2 命名返回值
可以给返回值起名,它们在函数开头被初始化为零值,并能配合裸 return 使用:
func parse(s string) (n int, err error) {
n, err = strconv.Atoi(s)
return // 裸 return,自动返回当前的 n 和 err
}
建议:命名返回值在短函数里能让签名自带文档,但裸 return 不要滥用,否则读者要回头找变量。
5.3 函数是一等公民
函数可以赋值给变量、作为参数、作为返回值:
func double(n int) int { return n * 2 }
f := double
fmt.Println(f(3)) // 6
// 高阶函数
func apply(n int, f func(int) int) int {
return f(n)
}
一个常见用法是用**函数字面量(闭包)**把”怎么构造”的逻辑作为参数传进去,让通用装配代码保持整洁:
// build 接受一个工厂函数,负责在需要时创建资源
func build[T any](make func() (T, error)) (T, error) {
return make()
}
conn, err := build(func() (*Conn, error) {
return Dial("127.0.0.1:6379", 2*time.Second)
})
func() (*Conn, error) { ... } 就是一个匿名函数,被当作参数传给 build。
闭包会捕获外层变量,这让回调和工厂函数非常方便,但并发时要注意捕获的变量是否被共享。
5.4 泛型(Go 1.18+)
上面代码里的 build[T any] 是一个泛型函数。T 是类型参数,any 表示任意类型(等价于 interface{})。
// Map 对切片每个元素应用 f,返回新切片
func Map[T, U any](s []T, f func(T) U) []U {
out := make([]U, len(s))
for i, v := range s {
out[i] = f(v)
}
return out
}
nums := []int{1, 2, 3}
strs := Map(nums, func(n int) string { return strconv.Itoa(n) })
// []string{"1", "2", "3"}
泛型适合写与元素类型无关的容器和算法(Map/Filter/Reduce、缓存、只读数据源封装等)。新手先会用,写业务时再深入——大多数日常代码用不到自定义泛型。
6. 复合类型:array / slice / map
6.1 array(数组)
数组长度是类型的一部分,[3]int 和 [4]int 是不同类型。实际开发中很少直接用数组,更多用切片。
var a [3]int // [0, 0, 0]
b := [3]int{1, 2, 3}
c := [...]int{1, 2, 3} // 编译器自动数长度
6.2 slice(切片)
切片是”动态数组”,是 Go 里最常用的数据结构。它本质是一个三元组:指针 + 长度(len) + 容量(cap)。
var s []int // nil 切片,len=0 cap=0,可以 append
s := []int{1, 2, 3} // 字面量
s := make([]int, 3, 10) // len=3, cap=10
s = append(s, 4) // 追加,必要时扩容
关键认知:slice 是引用语义的头部,但它本身是值拷贝。 函数传 slice 会拷贝这个头部,但底层数组共享。
func fill(s []int) {
s[0] = 99 // 会改到底层数组,调用方能看到
s = append(s, 1) // 只改了局部头部,调用方看不到(除非返回)
}
预分配是性能好习惯:知道长度就用 make,避免反复扩容:
rates := make(map[string]float64, len(names))
out := make([]string, 0, len(names))
原地过滤是常见技巧:复用底层数组,不分配新切片:
kept := items[:0]
for _, it := range items {
if it.Active {
kept = append(kept, it)
}
}
items = kept
6.3 map
map 是键值对集合。必须 make 后才能写入,写 nil map 会 panic。
var m map[string]int // nil map,可读但写会 panic
m = make(map[string]int) // 这样才能写
m := map[string]int{
"alice": 90,
"bob": 85,
}
score, ok := m["alice"] // 逗号-ok 惯用法:ok 表示键是否存在
if !ok {
fmt.Println("not found")
}
delete(m, "bob")
for k, v := range m { ... }
注意:
- map 遍历顺序是随机的,不要依赖。
- map 不是并发安全的,多 goroutine 读写需要加锁或用
sync.Map。 - 切片、函数、map 本身不能作为 map 的 key(不可比较的类型不能)。
6.4 new 与 make 的区别
这是新手经典困惑。Go 有两个分配内置函数,用途完全不同:
make(T, args)只用于 slice、map、channel 这三种引用类型,返回的是已初始化的 T 本身(不是指针),并且会分配好底层结构。new(T)用于任意类型,分配一块零值内存,返回指向它的指针*T。
// make:初始化内部结构,返回值可以直接用
s := make([]int, 3, 10) // len=3 cap=10 的切片
m := make(map[string]int) // 可以直接写的 map
ch := make(chan int, 5) // 容量 5 的 channel
// new:返回零值指针
p := new(int) // *int,指向 0
u := new(User) // *User,所有字段是零值
对 slice/map/channel 用 new 会得到一个 nil 指针,不能直接用:
m := new(map[string]int) // m 是 *map,指向 nil map
(*m)["a"] = 1 // panic:写 nil map
实际工程里,struct 几乎不用 new(Config),而是用字面量 &Config{}——它既能表达”创建并初始化字段”,也能顺便设置字段值。new 主要在需要一个零值指针、又没有字段要设时出现(例如 sync.Pool 的 New: func() any { return new(strings.Builder) })。
一句话记忆:slice/map/channel 用 make,其他类型用字面量或 &T{},new 只在要零值指针时用。
7. struct 与方法
7.1 struct 定义
type User struct {
ID int64
Name string
Email string
Score float64
Role string
}
u := User{ID: 1, Name: "Ada"}
u.Score = 0.8
结构体字面量推荐用”带字段名”的写法,这样字段顺序调整也不会破坏代码:
// ✅ 带字段名,清晰且抗变更
u := User{ID: 1, Name: "Ada"}
// ❌ 靠位置,字段一改顺序就错
u := User{1, "Ada", "ada@example.com", 0.8, "admin"}
7.2 方法
Go 没有类,但可以给任意命名类型绑定方法。方法有一个特殊的参数叫接收者(receiver):
func (u User) IsScored() bool {
return u.Score > 0
}
func (u *User) SetScore(s float64) {
u.Score = s
}
调用:
u.IsScored()
u.SetScore(0.9)
7.3 值接收者 vs 指针接收者
这是新手最容易困惑的点,记住几条:
- 值接收者
func (u User):操作的是副本,不影响原对象。适合小的、不可变的值类型。 - 指针接收者
func (u *User):操作原对象,能修改字段;也避免拷贝大结构体。
实际建议(来自 Go Code Review Comments):
- 如果方法要修改接收者,必须用指针。
- 如果结构体很大,用指针避免拷贝。
- 如果有一个方法用了指针接收者,为了一致性,其余方法也用指针接收者。
- 小的、不可变的值类型(如
time.Time、纯函数)可以全用值接收者。
例如一个带内部 map/list 的缓存结构体,因为方法都要改内部状态,所有方法都用指针接收者:
func (c *cache) get(key string) (string, bool) {
c.mu.Lock()
defer c.mu.Unlock()
...
}
而一个小的、不可变配置值,用值接收者返回新值:
func (c Config) Normalize() Config {
if c.Timeout <= 0 {
c.Timeout = defaultTimeout
}
return c
}
7.4 结构体嵌入(embedding)
Go 通过嵌入实现”组合”,而不是继承:
type Logger struct {
Level string
}
func (l *Logger) Log(msg string) { fmt.Println(msg) }
type Server struct {
*Logger // 嵌入,Server 直接拥有 Log 方法
Addr string
}
s := &Server{Logger: &Logger{Level: "info"}, Addr: ":8080"}
s.Log("starting") // 直接调用,相当于 s.Logger.Log("starting")
嵌入是”委托”而不是”是一个”,它不会带来多态,真正的多态靠接口。
8. 接口:Go 的抽象方式
8.1 接口是什么
接口是一组方法签名的集合。Go 的接口是隐式实现的:只要一个类型拥有接口要求的所有方法,它就自动实现了该接口,不需要 implements 关键字。
type Shape interface {
Area() float64
}
任何有 Area() float64 方法的类型都满足 Shape。
8.2 为什么这样设计
这种”鸭子类型”风格让你先写具体类型,后抽象接口。一条被广泛认可的经验:
先写具体类型。只有当出现第二个实现,或者它标记着一个刻意保留的缝合点时,才引入接口。
对比一下:
// ❌ Java 风格:为了 mock 提前抽接口,只有一个实现
type UserServiceInterface interface {
Get(id int64) (*User, error)
}
type UserServiceImpl struct{ ... }
// ✅ Go 风格:先写具体类型;当需要第二个实现时再抽象
type FileStore struct{ ... }
func (s *FileStore) Save(key string, r io.Reader) error { ... }
8.3 接口通常放在消费方
Go 约定:“接口属于使用它的包,而不是实现它的包。“
// 包 report 定义它需要的接口
type Store interface {
Get(key string) (string, bool)
}
// 另一个包提供实现,不需要知道 report.Store 的存在
type memStore struct{ ... }
func (s *memStore) Get(key string) (string, bool) { ... }
8.4 编译期断言
当一个类型声称要实现某接口,加一行编译期检查,让签名不符时在定义处报错:
var _ Shape = (*Circle)(nil)
var _ io.Writer = (*bytes.Buffer)(nil)
如果 Circle 少一个方法,编译立刻失败,而不是等到装配处才暴露。
8.5 io.Reader / io.Writer:最经典的接口
标准库最成功的接口只有一个方法:
type Reader interface {
Read(p []byte) (n int, err error)
}
type Writer interface {
Write(p []byte) (n int, err error)
}
文件、网络连接、字符串、压缩流、HTTP body……全都实现了这两个接口。学会这两个接口,你就理解了 Go 组合的精髓。
8.6 fmt.Stringer:最常用的自定义接口
标准库定义了一个只有一个方法的接口,你的类型实现它之后,fmt.Println、%s、%v、日志库都会自动调用:
type Stringer interface {
String() string
}
这是 Go 里最常被实现的接口。给枚举实现它,日志里就能打印出可读名字而不是冰冷的数字:
type Color int
const (
Red Color = iota
Green
Blue
)
var colorName = map[Color]string{Red: "red", Green: "green", Blue: "blue"}
func (c Color) String() string {
if name, ok := colorName[c]; ok {
return name
}
return "color(" + strconv.Itoa(int(c)) + ")"
}
这样日志里打 Green 输出的是 "green"。注意它对未知值返回 "color(7)" 而不是 "unknown",这样日志里能区分”漏过校验的值”和”真的是某个已知值”。
什么时候写 String(): 当类型要出现在日志、错误信息、格式化输出里,且默认的 {字段 值} 打印不够好懂时。枚举、有人类可读含义的命名类型几乎都该实现。
注意:String() 方法里不要用会再次触发 String() 的写法(比如 fmt.Sprint(c) 里又包含 c 本身),否则会无限递归。另外 String() 要做到廉价、无副作用、不 panic,因为它可能在日志、错误包装等很多地方被隐式调用。
和 Stringer 类似的常见单方法接口还有:
type error interface { Error() string } // 可作为错误
type GoStringer interface { GoString() string } // %#v 用
8.7 空接口 any 与类型断言
interface{}(别名 any)可以承载任意类型,但取出时要做类型断言:
var v any = 42
n, ok := v.(int) // 安全断言,ok=false 时不 panic
n := v.(int) // 不安全断言,类型不匹配会 panic
switch t := v.(type) {
case int:
fmt.Println("int", t)
case string:
fmt.Println("string", t)
default:
fmt.Println("unknown")
}
不要滥用 any,它丢掉了类型安全。只有在确实需要接受任意类型(如 JSON 解码、格式化)时才用。
9. 指针:值语义与引用语义
Go 有指针,但没有指针运算(不能 p++)。指针主要用于:
- 修改调用方传入的值。
- 避免拷贝大结构体。
- 表示”可能为 nil”的引用。
n := 10
p := &n // p 是 *int,指向 n
*p = 20 // 修改 p 指向的值,n 变成 20
func increment(n *int) {
*n++
}
increment(&n)
指针接收者 vs 值参数再强调一次:
- 看到
func (s *Store) Reload(),说明方法可能修改s,且s比较大。 - 看到
func FormatPrice(price float64) string,小值直接传值。 - 不要为了”性能”对所有东西都传指针,小值传值反而更快(减少逃逸和 GC 压力)。
nil 指针和 nil map/slice/interface 是不同的东西。新手常踩的坑:一个 nil 指针赋给接口后,接口 != nil。
var p *MyError = nil
var err error = p
fmt.Println(err == nil) // false!
原因是接口值内部存了”类型+值”,类型是 *MyError 所以接口非空。返回 error 时,直接写 return nil,不要返回一个 nil 的具体指针。
10. 错误处理:Go 的核心哲学
Go 没有 try/catch。错误是普通的值,通过返回值返回。这是 Go 最被讨论的设计,也是它让代码更可靠的原因:失败路径在代码里是显式可见的。
10.1 基本写法
f, err := os.Open("config.yaml")
if err != nil {
return fmt.Errorf("open config: %w", err)
}
defer f.Close()
%w 动词用 fmt.Errorf 包装错误,保留原因链,调用方可以用 errors.Is / errors.As 解包。
10.2 errors.Is / errors.As
var ErrNotFound = errors.New("not found")
if errors.Is(err, ErrNotFound) {
// 是这个哨兵错误(即使被包装过)
}
var pathErr *os.PathError
if errors.As(err, &pathErr) {
// err 链里有 *os.PathError,pathErr 被赋值
}
永远不要比较 err.Error() 字符串,那是给人看的,不是给程序判断的。
10.3 errors.Join:合并多个错误
Go 1.20+ 支持一次返回多个错误,常用于优雅退出时收集多个关闭动作的错误:
var errs []error
if err := srv.Shutdown(ctx); err != nil {
errs = append(errs, fmt.Errorf("shutdown http server: %w", err))
}
if err := db.Close(); err != nil {
errs = append(errs, fmt.Errorf("close db: %w", err))
}
return errors.Join(errs...)
10.4 Sentinel 错误 vs 自定义错误类型
// 哨兵错误:包级变量,调用方用 errors.Is 判断
var ErrMalformed = errors.New("malformed request")
// 自定义错误类型:携带上下文,调用方用 errors.As 解出
type ConfigError struct {
Field string
Err error
}
func (e *ConfigError) Error() string {
return fmt.Sprintf("config %s: %v", e.Field, e.Err)
}
func (e *ConfigError) Unwrap() error { return e.Err }
10.5 “预期内的结果不是错误”——领域结果 vs 系统故障
一个很值得学的设计:区分业务上的正常结果和系统故障。
假设有一个校验函数,“这条数据不通过”是完全正常的结果,不是失败,所以它不使用 error,而用一个值类型来表达原因:
type RejectReason int
const (
RejectNone RejectReason = 0
RejectEmptyName RejectReason = 101
RejectDuplicate RejectReason = 102
RejectOutOfRange RejectReason = 103
)
// 校验结果也是一个值,不是 error
type CheckResult struct {
OK bool
Reason RejectReason
}
func Check(u User) CheckResult {
if u.Name == "" {
return CheckResult{OK: false, Reason: RejectEmptyName}
}
return CheckResult{OK: true}
}
如果用 error 表示”没通过”,那么在每秒成千上万次的正常校验里都会分配 error 对象,给 GC 添负担,还会把”正常的业务判定”误报成”系统故障”。
原则:
- 预期内的、可解释的业务结果 → 用值(枚举、结构体)。
- 真正的异常/故障(IO 失败、解析失败、依赖不可达)→ 用
error。
10.6 panic 和 recover
panic会中断当前流程并沿调用栈展开,除非被recover捕获。- 不要把 panic 当作普通错误处理。Go 里正常的错误返回用 error。
- panic 只在两类场景合理:
- 程序启动时发现不可恢复的程序员错误(配置完全非法、依赖缺失)。
- 在最外层中间件用
recover兜底,把 goroutine 里的意外 panic 转成日志和响应,而不是让整个进程崩溃。
一个典型做法是在 HTTP 中间件里 recover,防止某个 handler 的 panic 拖垮整个服务。
10.7 错误处理规范小结
- 每个 error 都要处理,不能静默丢弃(用
_ =并注释为什么安全是允许的)。 - 用
%w包装,加上下文(操作名、关键 ID)。 - 错误字符串小写、不加句号、以操作名为前缀。
- 用
errors.Is/errors.As判断,不比较字符串。 - 别在热路径里为正常情况分配 error。
11. defer:延迟执行与资源释放
defer 把一个函数调用推迟到当前函数返回之前执行,常用于释放资源、解锁、关闭文件。
11.1 基本用法
f, err := os.Open("data.txt")
if err != nil {
return err
}
defer f.Close() // 无论函数从哪里返回,都会执行 Close
用 defer 归还从对象池借来的缓冲区:
buf := pool.Get()
defer pool.Put(buf)
用 defer 解锁:
func (c *cache) get(key string) (string, bool) {
c.mu.Lock()
defer c.mu.Unlock()
...
}
11.2 三个关键规则
规则 1:defer 的参数在注册时就求值,不是执行时。
i := 1
defer fmt.Println(i) // 打印 1,因为此刻就拷贝了 i
i = 2
return
如果想在执行时取值,用闭包:
defer func() { fmt.Println(i) }() // 打印 2
i = 2
规则 2:多个 defer 按 LIFO(后进先出)顺序执行,像栈一样。
defer fmt.Println("first")
defer fmt.Println("second")
defer fmt.Println("third")
// 输出: third second first
规则 3:defer 在返回值赋值之后、函数真正返回之前执行,所以它能修改命名返回值:
func double(n int) (result int) {
defer func() { result *= 2 }()
result = n
return // result 先被赋成 n,defer 再翻倍
}
double(5) // 10
11.3 defer 的典型场景
- 关闭资源:文件、连接、响应体。
- 解锁:
mu.Lock(); defer mu.Unlock()。 - 收尾/通知:
defer close(ch)、defer ticker.Stop()、defer cancel()。 - 计时/埋点:
defer observe(time.Since(start))。 - panic 恢复:在中间件里
defer func() { if r := recover(); ... }()。
在 main 里,defer 常被用于生命周期管理:
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
client, err := newClient(cfg)
...
defer func() {
if err := client.Close(); err != nil {
log.Warn("closing client", "error", err)
}
}()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
11.4 注意事项
- defer 有微小开销,不要在最内层的紧循环里用(每次迭代都执行的热循环里避免,按请求/按批次使用没问题)。
- 在循环里 defer 会累积到函数结束才执行,循环内释放资源应改成独立函数或显式调用:
// ❌ 所有文件到函数结束才关闭
for _, path := range paths {
f, _ := os.Open(path)
defer f.Close()
process(f)
}
// ✅ 每次迭代独立函数
for _, path := range paths {
func() {
f, _ := os.Open(path)
defer f.Close()
process(f)
}()
}
12. goroutine 与 channel
12.1 goroutine:轻量级线程
在函数调用前加 go,它就会在一个新的 goroutine 里并发执行:
go func() {
log.Info("running in background")
}()
goroutine 非常轻量(初始栈仅几 KB),一个程序可以轻松跑几万甚至几十万个。但它由 Go runtime 调度,不是操作系统线程。
铁律:每个 goroutine 都必须有明确的退出方式——通过 ctx、关闭的 channel 或 WaitGroup。禁止”即发即忘”:
// ❌ 不知道它何时退出,也无法捕获错误或等待
go doWork()
// ✅ 通过 ctx 控制生命周期,通过 channel 上报完成
go func() {
jobDone <- worker.Run(ctx)
}()
启动后台任务的典型模式:
jobDone := make(chan error, 1)
go func() { jobDone <- worker.Run(jobsCtx) }()
12.2 channel:goroutine 间的类型安全管道
ch := make(chan int) // 无缓冲 channel
ch := make(chan int, 10) // 带缓冲 channel
ch <- 42 // 发送
v := <-ch // 接收
close(ch) // 关闭(由发送方关闭)
v, ok := <-ch // ok=false 表示 channel 已关闭且无数据
无缓冲 channel:发送和接收必须同时就绪,否则阻塞。它天然带同步语义。 带缓冲 channel:缓冲未满时发送不阻塞,适合做任务队列、信号量。
channel 所有权约定:
- 发送方负责关闭,接收方永远不要关闭。
- 不要重复关闭,不要关闭已关闭的 channel(会 panic)。
12.3 select:同时等待多个 channel
select {
case <-ctx.Done():
return ctx.Err()
case err := <-jobDone:
return err
case v := <-dataCh:
process(v)
}
select 会随机选择一个就绪的 case。加 default 变成非阻塞:
select {
case v := <-ch:
handle(v)
default:
// ch 没数据,不阻塞
}
用 time.Ticker + select 做周期任务是经典模式:
func sampleEvery(ctx context.Context, d time.Duration, record func()) {
tick := time.NewTicker(d)
defer tick.Stop()
record()
for {
select {
case <-ctx.Done():
return
case <-tick.C:
record()
}
}
}
12.4 常见并发模式
扇出 + 等待:
var wg sync.WaitGroup
for _, item := range items {
wg.Add(1)
go func(it Item) {
defer wg.Done()
process(it)
}(item) // 注意把循环变量作为参数传进去
}
wg.Wait()
重要坑(Go 1.22 之前):循环变量在整个循环中是同一个变量,goroutine 闭包可能读到错误的值。解决办法是显式传参(如上)。Go 1.22+ 已修复,但很多项目里仍普遍用传参写法以保持兼容和清晰。
有界并发(信号量模式):请求路径上不能为每个任务开无限 goroutine:
sem := make(chan struct{}, 4) // 最多 4 个并发
var wg sync.WaitGroup
for _, item := range items {
wg.Add(1)
sem <- struct{}{} // 获取令牌
go func(it Item) {
defer wg.Done()
defer func() { <-sem }()
process(it)
}(item)
}
wg.Wait()
生产代码里更常用 golang.org/x/sync/errgroup,它还能收集第一个错误并支持 ctx 取消。
生产者-消费者:
func producer(ch chan<- int) { // 只发送
defer close(ch)
for i := 0; i < 10; i++ {
ch <- i
}
}
func consumer(ch <-chan int) { // 只接收
for v := range ch { // channel 关闭后自动退出
fmt.Println(v)
}
}
把 channel 方向写进参数类型(chan<- / <-chan)是好习惯,编译器能帮你抓错。
13. context:取消与超时
context.Context 是 Go 并发编程的”总线”,用来传递:
- 取消信号(任务该停了)。
- 截止时间/超时(必须在某时间前完成)。
- 请求范围的值(request ID、trace ID、logger)。
13.1 基本用法
// 500ms 超时
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel() // 一定要调用,避免上下文泄漏
select {
case <-time.After(300 * time.Millisecond):
fmt.Println("done")
case <-ctx.Done():
fmt.Println("timeout:", ctx.Err())
}
13.2 ctx 必须是第一个参数
Go 约定:任何可能阻塞、做 I/O 或需要被取消的函数,ctx context.Context 都是第一个参数,通常命名为 ctx:
func Fetch(ctx context.Context, url string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
...
}
不要把 ctx 塞进结构体字段。它是请求作用域的,显式传递。
13.3 传播取消
父 ctx 取消,所有从它派生的 ctx 都会被取消:
func parent() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
child(ctx) // 子操作也会在 cancel 时停止
}
在 main 里用信号派生 ctx,Ctrl+C 或收到 SIGTERM 时整个进程开始优雅退出:
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
jobsCtx, stopJobs := context.WithCancel(ctx)
defer stopJobs()
13.4 检查 ctx 是否被取消
在循环或长操作中,定期检查 ctx.Err():
for _, item := range items {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
process(item)
}
13.5 WithValue:谨慎使用
context.WithValue 只用于横切的请求范围数据(request ID、trace、logger),不要把业务参数塞进去。业务参数走函数签名。
type ctxKey int
const requestIDKey ctxKey = 0
ctx = context.WithValue(ctx, requestIDKey, "abc-123")
if id, ok := ctx.Value(requestIDKey).(string); ok { ... }
Key 要用自定义未导出类型,避免和其他包的 key 冲突(不要用字符串)。
14. sync 包与锁
并发访问共享数据时需要同步。Go 鼓励”通过通信共享内存,而不是通过共享内存通信”,但在某些场景下锁更直接。
14.1 sync.Mutex:互斥锁
type Counter struct {
mu sync.Mutex
n int
}
func (c *Counter) Inc() {
c.mu.Lock()
defer c.mu.Unlock()
c.n++
}
func (c *Counter) Get() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.n
}
用 Mutex 保护内部的 map 和 list 是常见写法:
type cache struct {
mu sync.Mutex
byKey map[string]*list.Element
order *list.List
}
func (c *cache) get(key string) (string, bool) {
c.mu.Lock()
defer c.mu.Unlock()
...
}
规则:
Lock和Unlock必须成对,用defer Unlock()防止遗漏。- 不要在持有锁时做 I/O(网络、文件、慢函数),会把其他等待者全堵住。
- 锁要尽量晚拿、尽早放。
14.2 sync.RWMutex:读写锁
读多写少的场景用 RWMutex:
var mu sync.RWMutex
var config Config
func GetConfig() Config {
mu.RLock()
defer mu.RUnlock()
return config
}
func UpdateConfig(c Config) {
mu.Lock()
defer mu.Unlock()
config = c
}
但要注意:Go 里更推荐不可变快照 + atomic.Pointer(见 14.5),因为它完全避免读锁。
14.3 sync.WaitGroup:等待一组 goroutine
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
doWork(id)
}(i)
}
wg.Wait()
Add(n)在启动 goroutine 之前调用。Done()在 goroutine 里用 defer 调用。Wait()阻塞直到计数归零。
14.4 sync.Once:一次性初始化
sync.Once 保证某段代码在整个程序生命周期里只执行一次,即使有多个 goroutine 同时调用。它的典型场景是懒加载(lazy init):资源只在第一次真正用到时才初始化,而不是程序启动时。
var (
once sync.Once
cfg *Config
)
func loadConfig() *Config {
once.Do(func() {
cfg = loadFromDisk()
})
return cfg
}
关键语义(新手必须理解):
- 所有
Do调用者都会等待 f 返回:如果 goroutine A 正在执行 f,B、C 调用Do会阻塞在那里,直到 f 结束,然后和 A 一起拿到结果。所以 f 里不要做可能永久阻塞的事。 - f 只执行一次,即使 f 里 panic 了:f panic 时,
Do会把 panic 继续抛给调用者,但 Once 仍然被标记为”已完成”,后续调用不会重试。如果初始化可能失败,不要把失败路径放进 Once——要么在 f 里 panic(真不可恢复),要么用”指针 + 错误 + Mutex”自己实现可重试的懒加载。 once.Do不能接收参数:f 是func(),需要的配置通过闭包或包级变量传入。sync.Once的零值即可用,不要拷贝它(它内部含 Mutex,拷贝会失效)。
典型使用场景:
- 懒加载只读字典、编译后的正则、模板、编解码器。
- 单例客户端(如某些全局 SDK)的首次初始化。
- 延迟到首次使用才做的重型计算。
和 init() 怎么选?
| 维度 | init() | sync.Once |
|---|---|---|
| 执行时机 | 包被导入时,自动执行 | 代码显式调用 Do 时 |
| 失败处理 | 只能 panic,无法返回 error | 同样无法返回 error,错误需外提 |
| 适合 | 无依赖、必须就绪的包级状态(注册表、码表) | 重型、可能用不到的资源,懒加载 |
| 可控性 | 不可控,导入即执行 | 可控,可推迟到首次使用 |
一个经验法则:程序启动一定要有的东西用 init() 或显式 NewXxx();“也许用得到、构造很贵”的东西用 sync.Once。 不要在 init() 里做网络请求、读可选配置——那会让导入你的包的人无法控制失败和超时。
注意:如果懒加载需要返回 error,sync.Once 不够用,常见写法是:
var (
once sync.Once
client *Client
err error
)
func getClient() (*Client, error) {
once.Do(func() {
client, err = dial() // err 被闭包捕获
})
return client, err
}
这样 dial 失败后所有调用都拿到同一个错误,不会重试。需要失败重试时就不能用 Once,要自己用 sync.Mutex + 状态判断实现。
14.5 atomic 与不可变快照模式
对于”读多写少、读路径要极致快”的数据,一个高效模式是快照切换:
- 写路径:在后台构建完整的新数据结构。
- 发布:用
atomic.Pointer[T]原子地替换指针。 - 读路径:
Load()拿到指针后无锁读取,整个请求都用同一个快照。
一个参考实现:
type Store struct {
mu sync.Mutex // 只串行化"写者",读者不碰它
cur atomic.Pointer[snapshot]
ready atomic.Bool
}
// 读路径:无锁
func (s *Store) Current() *snapshot { return s.cur.Load() }
// 写路径:拿锁,构建新值,原子替换
func (s *Store) Rebuild(ctx context.Context) error {
s.mu.Lock()
defer s.mu.Unlock()
next := &snapshot{table: buildTable(), index: buildIndex()}
s.cur.Store(next)
s.ready.Store(true)
return nil
}
关键约束:已经发布的快照必须被视为不可变。如果你原地修改它,所有正在读它的 goroutine 都会发生数据竞争。要改就构建新版本再整体替换。
这是高性能 Go 服务里极其重要的模式。相比”一个大锁保护所有字段”,它让读路径几乎零成本。
14.6 sync.Pool:对象复用
高吞吐服务里,每个请求都 new 一个临时对象(bytes.Buffer、gzip.Writer、切片)会产生大量垃圾,给 GC 造成压力。sync.Pool 是一个临时对象池:用 Get 取一个对象,用完 Put 还回去,后续请求可以复用,避免重复分配。
复用 gzip writer 的例子:
var gzipWriterPool = sync.Pool{
New: func() any { return gzip.NewWriter(nil) },
}
func gzipJSON(dst io.Writer, data []byte) error {
zw := gzipWriterPool.Get().(*gzip.Writer) // 取:池空时调用 New
defer gzipWriterPool.Put(zw) // 还:归还后别人可复用
zw.Reset(dst) // 重置到新目标,清掉旧状态
if _, err := zw.Write(data); err != nil {
return err
}
return zw.Close()
}
复用 strings.Builder 和小切片:
var (
builderPool = sync.Pool{New: func() any { return new(strings.Builder) }}
slicePool = sync.Pool{New: func() any { s := make([]int, 0, 4); return &s }}
)
使用规则:
Get后必须Reset/清空状态:池里的对象带有上次使用的数据,不能直接用。Put回去的对象不能再被引用:归还后它可能被别的 goroutine 取走,继续使用会产生数据竞争。所以常见写法是Get之后立刻defer Put。- 不要放入”打开的连接”这类带生命周期的资源:Pool 里的对象随时可能被 GC 回收(每次 GC 会清空池),它不保证对象一直在。它适合无状态的、重建成本高但随时可丢弃的临时对象,不适合数据库连接池(那要用专门的连接池库)。
- Pool 本身是并发安全的,不需要加锁。
New字段是一个函数,池为空时Get会调用它造一个新对象;不设New时Get返回nil。
一个最小模式:
var bufPool = sync.Pool{
New: func() any { return new(bytes.Buffer) },
}
func render(w io.Writer, data []byte) error {
buf := bufPool.Get().(*bytes.Buffer)
buf.Reset()
defer bufPool.Put(buf)
// 用 buf 做中间缓冲...
_, err := buf.WriteTo(w)
return err
}
不要为了”性能”在所有地方都上 Pool。先用 go test -bench . -benchmem 证明某个分配确实是瓶颈,再针对性地池化——池化代码有额外心智负担,也可能因为对象状态没清干净引入 bug。
14.7 什么时候用哪种
| 场景 | 选择 |
|---|---|
| 读路径高频、写是整体替换 | atomic.Pointer[T] 快照切换 |
| 读多写少、需要原地改部分字段 | sync.RWMutex |
| 写互斥、结构需要原地修改 | sync.Mutex |
| 等待一组 goroutine | sync.WaitGroup |
| 一次性初始化 / 懒加载 | sync.Once |
| 热路径复用临时对象、减少 GC | sync.Pool |
| 键稳定、读多写少的并发 map | sync.Map(否则用普通 map + 锁) |
数据竞争零容忍:CI 必须跑 go test -race ./...,任何 race 报告都要修。
15. 包、模块与工程结构
15.1 module 与 go.mod
一个仓库就是一个 module。go.mod 文件定义模块名和依赖:
module example.com/app
go 1.22
require (
github.com/redis/go-redis/v9 v9.7.0
gopkg.in/yaml.v3 v3.0.1
)
模块名就是所有内部 import 的前缀。上面这个 module 里 internal/domain 包的 import 路径就是 example.com/app/internal/domain。
常用命令:
go mod tidy # 整理依赖,补全/删除 go.mod 和 go.sum
go get pkg@v1.2.3 # 添加/升级依赖
go mod download # 下载依赖
15.2 一个常见的目录布局
cmd/app/ # main 包,程序入口,只做装配,不含业务逻辑
internal/ # 私有代码,其他 module 不能导入
domain/ # 纯领域类型和缝合点接口,不依赖任何其他内部包
service/ # 业务编排
adapter/ # 外部协议与领域模型之间的转换
repository/ # 外部状态(数据库、HTTP 等)的访问,藏在接口后
server/ # HTTP 接入、中间件
pkg/ # 服务内可复用工具
config/ # 配置加载与强类型结构
configs/ # 各环境配置(不含真实密钥)
docs/ # 文档
(这只是一种参考组织方式,不必照搬;小项目一个 main.go 就够。)
15.3 internal 目录
Go 工具链特殊对待 internal/:internal 目录下的包只能被以 internal 的父目录为根的代码导入。这是 Go 强制的”私有包”机制,防止外部 module 依赖你的内部实现。
15.4 包命名规范
- 简短、全小写、单数、无下划线:
store、report、adapter。 - 不要
utils、helpers、common、base——这些名字没有信息量。按职责拆成httpx、timex、stringx。 - 包名不要和标准库重名:用
httpx而不是http。 - 包名是调用点的一部分,目标是
interceptor.Chain而不是interceptor.InterceptorChain。
15.5 import 分组
import 分三组,组间空行,goimports 会自动整理:
import (
"context"
"errors"
"fmt"
"log/slog"
"github.com/redis/go-redis/v9"
"example.com/app/internal/domain"
"example.com/app/internal/store"
)
顺序:标准库 → 第三方 → 本模块。只在真正命名冲突时才用别名。
15.6 依赖方向
一个健康的项目会强制单向依赖,例如:
cmd → server / service → domain
domain 是最内层,不导入任何其他内部包。其他包通过在 domain 或调用方声明的接口来反转依赖。循环依赖是设计缺陷,要靠把共享类型移到 domain 或引入接口解决,而不是建一个 xxxutil 包。
15.7 init() 与包初始化
每个包可以定义一个或多个 func init(),它在包被导入时、main 执行之前自动运行,不能被手动调用:
func init() {
// 注册驱动、填充查找表、设置默认值
drivers["postgres"] = &postgresDriver{}
}
包初始化顺序:
- 按依赖顺序初始化被导入的包(深度优先)。
- 同一个包内,先初始化包级变量(按声明顺序,解析依赖后),再按文件名字母序、文件内顺序执行各文件的
init()。 - 所有包初始化完才执行
main.main()。
所以一个包的 init() 运行时,它导入的所有包都已经初始化好了。
合理用途:
- 注册实现(
database/sql驱动、编解码格式、插件自注册)。 - 构建编译期已知的查找表/索引。
- 对生成代码做一次性设置。
不要滥用 init():
- 不要在
init()里做 I/O、网络请求、读可选配置——它无法返回 error,失败只能 panic,且调用方无法控制时机和超时。 - 不要在
init()里做重型计算拖慢启动;重型懒加载用sync.Once。 - 包级变量的初始化优先用”声明时直接赋值”,复杂逻辑才放
init()。 - 能在
main里显式调用NewXxx()装配的,就不要藏在init()里——隐式初始化让代码难以测试和理解。
一个典型的”注册模式”:
package drivers
var registry = map[string]Driver{}
func Register(name string, d Driver) { registry[name] = d }
// 在各驱动子包的 init() 里自注册
package postgres
func init() { drivers.Register("postgres", &Driver{}) }
这样 main 只要用空白导入触发注册:import _ "example.com/app/drivers/postgres"。
16. //go:embed:把静态文件编进二进制
Go 1.16+ 提供 //go:embed 指令,能把静态文件在编译时打进二进制,部署时不再需要携带外部文件。
16.1 基本用法
import (
_ "embed"
)
//go:embed version.txt
var version string
注意三件事:
- 必须空白导入
embed包(import _ "embed"),即使你代码里不直接用它。这是编译器识别指令的开关。 //go:embed是编译器指令,//和go:embed之间没有空格。- 指令紧贴在变量声明上方,中间不能有空行或其他语句。
16.2 支持的变量类型
//go:embed config.yaml
var configYAML string
//go:embed data.json
var dataJSON []byte
//go:embed templates/*.html
var templatesFS embed.FS // 嵌入一组文件成文件系统
embed.FS 实现了 fs.FS 接口,可以像读目录一样读嵌入文件,也能配合 http.FileServer、template.ParseFS 使用。
16.3 一个真实场景
把一张静态参考表(例如 ISO 3166-1 国家码表)编进二进制,避免启动时读文件失败:
package geo
import (
_ "embed"
"strings"
)
// table 是一张静态参考数据表,每行一条记录。
// 之所以编进二进制而不是运行时读文件,是因为它是各环境不变的静态数据,
// embed 之后查找不会因为启动时缺文件而失败,也不会和磁盘上的文件漂移。
//
//go:embed table.tsv
var table string
var index = buildIndex()
func buildIndex() map[string]string {
m := make(map[string]string, 256)
for _, line := range strings.Split(table, "\n") {
fields := strings.Split(line, "\t")
if len(fields) < 2 {
continue
}
key := strings.TrimSpace(fields[0])
val := strings.TrimSpace(fields[1])
if key == "" || val == "" {
continue
}
m[key] = val
}
return m
}
判断标准:
- 各环境不变的参考数据(码表、模板、默认配置、静态资源)→ embed。
- 随环境变化、需要热更新的配置 → 放配置文件或配置中心。
16.4 路径规则
- 路径相对当前
.go文件所在目录。 - 不能用
.或..,不能嵌入软链接指向的外部文件。 - 可以用 glob:
//go:embed static/*。 - 文件名以
.或_开头的会被*忽略,要显式写或用all:前缀(//go:embed all:static)。
17. //go:build:构建标签
构建标签(build constraints)让你按条件编译某些文件——按操作系统、架构、Go 版本、自定义标签等。
17.1 语法
在文件最顶部(在 package 声明之前)写:
//go:build linux
package sysinfo
这个文件只在 Linux 下编译。也可以组合:
//go:build linux && amd64
//go:build darwin || windows
//go:build !race
//go:build go1.21
17.2 必须有空行
//go:build 行和 package 行之间必须有空行,否则它会被当成包注释的一部分。
//go:build integration
package repository_test
17.3 运行时指定标签
go test -tags=integration ./...
go build -tags=prod .
race 是特殊内置标签,用 -race 启用竞态检测时自动定义。
17.4 一个真实例子
某些测试要在 -race 下跳过(因为 race 检测器本身会分配额外内存,断言分配次数会不准)。可以用一对文件提供同一个常量:
race_test.go(只在开启 race 时编译):
//go:build race
package mypkg_test
// raceEnabled reports whether the race detector is instrumenting this build.
const raceEnabled = true
norace_test.go(只在未开启 race 时编译):
//go:build !race
package mypkg_test
const raceEnabled = false
然后测试代码根据 raceEnabled 决定是否跳过分配计数断言:
func TestAllocation(t *testing.T) {
if raceEnabled {
t.Skip("allocation counts are not meaningful under -race")
}
// ... 断言分配次数
}
17.5 常见用途
- 按 OS/arch 拆分平台相关代码:
file_linux.go、file_windows.go(文件名后缀也能触发,无需写标签)。 - 集成测试:
//go:build integration,默认不跑,CI 显式开启。 - race 专属/排除测试。
- 不同环境的实现(mock vs real)。
注意://go:build 是 Go 1.17+ 的新语法,旧的 // +build 已废弃,新代码一律用 //go:build。
17.6 //go:generate:代码生成指令
//go:generate 是给 go generate 工具看的指令(不是编译器指令,普通 go build 会忽略它)。它把”如何生成这部分代码”的命令记录在源码旁边,方便团队统一执行。
//go:generate stringer -type=Color -type=Status
运行:
go generate ./... # 扫描所有文件里的 //go:generate 并执行
go generate ./internal/ # 只跑某个包
常见用途:
- 用
stringer为枚举自动生成String()方法(避免手写一堆 switch)。 - 用
mockgen生成接口的测试 mock。 - 用
protoc+protoc-gen-go从.proto生成 Go 代码。
注意几点:
//go:generate不是自动触发的,必须显式跑go generate,CI/发布流程里要记得加。- 生成的文件通常在开头带
// Code generated by ... DO NOT EDIT.,不要手改。 - 指令里用的工具(
stringer、mockgen等)要先go install,团队最好在文档或 Makefile 里固定版本。
18. 标准写法与设计规范
这一章把 Go 社区公认的工程规约浓缩成新手最该养成的习惯。
18.1 命名
- 使用 MixedCaps,不用下划线:
maxRetries、MaxConns。 - 缩写词保持全大写或全小写:
ID、URL、HTTP、API。所以是userID、avatarURL,不是userId、avatarUrl。 - 名字长度随作用域伸缩:
i、n在短循环没问题;跨函数的名字要写全。 - 接收者用一到两个字母,同一类型所有方法保持一致:
func (c *cache) get(...)、func (s *Store) Current()。 - 接口按行为命名,通常用
-er后缀:Reader、Writer、Formatter、Notifier。不要IReader、ReaderInterface。 - 实现按角色命名,不用设计模式名:
FileStore、MemStore,不要StoreImpl、DefaultStore。 - getter 不带
Get:u.ID()而不是u.GetID();setter 可以带Set。 - 错误值用
ErrXxx,错误类型用XxxError。 - 枚举常量以类型名为前缀:
ColorRed、StatusActive。
18.2 注释
- 每个导出标识符都要有文档注释,且以标识符名字开头:
// Normalize returns a copy of c with defaults filled in.
func (c Config) Normalize() Config { ... }
- 注释解释为什么和代码本身表达不了的约束(单位、所有权、并发安全、协议怪癖、取舍),不要复述代码。
- 注释要短。一句话能说清就别写三句。
- 不要写作者、日期、变更记录(git 已经记录了)。
- 包注释放在
doc.go或主文件顶部,以// Package xxx ...开头。
// ✅ 好:说清签名表达不了的含义
// Expired reports whether the token is past its TTL relative to now.
func (t Token) Expired(now time.Time) bool
// ❌ 差:复述名字和参数,纯属灌水
// Expired is a method on the Token struct.
// It takes one parameter now of type time.Time and returns bool.
func (t Token) Expired(now time.Time) bool
18.3 零值可用
尽量让类型的零值就能正常工作,减少强制构造函数:
// sync.Mutex 的零值就是未上锁的互斥锁,直接用
var mu sync.Mutex
mu.Lock()
// bytes.Buffer 零值即可用
var buf bytes.Buffer
buf.WriteString("hello")
当构造涉及不变式校验时,才提供 NewXxx(...) (*Xxx, error)。MustXxx 只允许在启动期用(失败即终止进程),绝不出现在请求路径。
18.4 接口的克制
- 先写具体类型,等出现第二个实现或明确的缝合点再抽接口。
- 不要为了 mock 而给单一实现提前建接口。
- 接口放在消费方包里,而不是实现方。
- 每个实现加编译期断言:
var _ Interface = (*Impl)(nil)。 - 可选依赖提供显式的空实现(Nop/Static),而不是在调用点到处
if xxx != nil。
18.5 错误处理规范
见第 10 章,核心要点:
- 错误是值,显式处理。
- 用
%w包装加上下文。 - 用
errors.Is/errors.As,不比字符串。 - 业务判定用值,系统故障用 error。
- panic 只用于启动期不可恢复错误和最外层 recover。
18.6 context 规范
- ctx 是第一个参数。
- 不存进结构体。
- 传播给所有下游 I/O。
context.Background()只在 main、job 根、测试里出现,请求路径上不要用。WithValue只放横切数据,不放业务参数。
18.7 并发规范
- 每个 goroutine 有明确退出条件。
- 请求路径扇出必须有界(worker 池 / errgroup 带 limit)。
- channel 发送方负责关闭。
- 共享可变 map 要保护;优先用不可变快照 + atomic,而不是大锁。
- 不持锁做 I/O。
-race必须通过。
18.8 性能与热路径
在高吞吐服务里,这些规则在热路径(请求可达的代码)上尤其重要:
- 不要在热路径用反射、把 JSON 解进
map[string]any、用fmt.Sprintf拼键。 - 不要在热路径编译正则、解析模板、读文件——这些放启动期或后台任务。
- 已知长度的切片预分配:
make([]T, 0, n)。 - 收窄切片用原地过滤
s[:0],不新建切片。 - 成功路径不按请求打 Info 以上日志,用 Debug 或采样。
- 性能优化要有
go test -bench . -benchmem数据支撑,不要”感觉更快”。
18.9 配置与密钥
- 配置启动时加载进强类型结构体,
map[string]any不要泄漏出 config 包。 - 密钥不提交,来自环境变量或密钥系统;仓库里只放占位符。
- 热更新通过切换不可变快照生效,不能原地改正在被读取的结构。
- 每个可调参数都要有安全默认值和校验,非法配置启动即失败。
18.10 可观测性
- 用结构化日志(
log/slog),字段名 snake_case 且稳定。 - 日志级别语义:Error 要人介入,Warn 是一次成功降级,Info 是生命周期事件,单请求成功用 Debug/采样。
- 指标标签基数要有界:request ID、user ID 这类高基数值不能做标签。
- 每个外部依赖都要有延迟、错误、超时指标。
18.11 代码格式
格式不是品味问题,gofmt 的输出就是标准。 提交前跑:
gofmt -w . # 格式化
goimports -w . # 比 gofmt 多了 import 整理
所有 Go 代码看起来都一样,这是 Go 团队认为”让大型团队高效协作”的关键决策。把编辑器配置成保存时自动 goimports。
19. 测试:表驱动与子测试
19.1 基本测试
测试文件以 _test.go 结尾,函数以 TestXxx(t *testing.T) 开头:
func TestAdd(t *testing.T) {
got := add(1, 2)
want := 3
if got != want {
t.Errorf("add(1,2) = %d, want %d", got, want)
}
}
运行:
go test ./...
go test -run TestAdd ./...
go test -v -race ./...
19.2 表驱动测试(table-driven)
这是 Go 最主流的测试风格——把多组输入输出放进一个切片,循环跑:
func TestParseColor(t *testing.T) {
tests := []struct {
name string
input string
want Color
wantErr bool
}{
{"green", "green", Green, false},
{"unknown", "teal", 0, true},
{"empty", "", 0, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseColor(tt.input)
if (err != nil) != tt.wantErr {
t.Fatalf("err = %v, wantErr %v", err, tt.wantErr)
}
if got != tt.want {
t.Errorf("got %v, want %v", got, tt.want)
}
})
}
}
t.Run(name, fn) 创建具名子测试,好处:
- 每个用例独立显示,可以单独跑:
go test -run TestParseColor/green。 - 一个用例失败不影响其他用例。
- 输出清晰,定位快。
19.3 测试规范
- 单元测试与代码同目录,不走网络、不连真实外部依赖。
- 测试要确定性,不依赖真实时钟——需要时间就注入
time.Time参数或 clock 接口。 - 只在缝合点(接口)上做测试替身,不要 mock 被测包的未导出函数。
- 缺陷修复要附带一个”修复前会失败”的测试。
- 核心逻辑要有实质覆盖,不要写那种只调 getter 的空测试。
- 集成测试放单独目录,加
//go:build integration。
19.4 示例测试与基准测试
// 示例测试,会出现在文档里,Output 注释用于校验
func ExampleColor_String() {
fmt.Println(Green)
// Output: green
}
// 基准测试
func BenchmarkParseColor(b *testing.B) {
for i := 0; i < b.N; i++ {
_, _ = ParseColor("green")
}
}
跑基准:
go test -bench=. -benchmem ./...
20. 工具链与门禁
20.1 常用命令
go build ./... # 编译所有包
go test ./... # 跑测试
go test -race ./... # 带竞态检测
go vet ./... # 静态检查
go fmt ./... # 格式化
go mod tidy # 整理依赖
go doc fmt.Println # 查文档
go env # 看环境变量
20.2 一套典型的 CI 门禁
每次合并前必须通过:
gofmt -l . # 必须无输出(没有未格式化文件)
goimports -l .
go vet ./...
golangci-lint run
go build ./...
go test -race ./...
golangci-lint 是一组 linter 的聚合,至少启用 errcheck(检查未处理错误)、govet、staticcheck、ineffassign、unused、gosimple、gosec 等。
新手最容易被 errcheck 挡住:几乎所有返回 error 的调用都必须显式处理,要么检查返回值,要么明确用 _ = 丢弃并注释原因。这不是刁难,而是 Go 哲学——失败路径必须在代码里可见。
20.3 推荐本地配置
把编辑器保存动作绑定到 goimports,并在提交前跑一次:
go vet ./... && go test -race ./...
推荐安装的工具:
go install golang.org/x/tools/cmd/goimports@latest
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
20.4 交叉编译
Go 可以轻松为其他平台编译二进制,不需要虚拟机:
GOOS=linux GOARCH=amd64 go build -o app-linux-amd64 ./cmd/app
GOOS=darwin GOARCH=arm64 go build -o app-darwin-arm64 ./cmd/app
这也是 Go 适合写后端服务和 CLI 的原因之一——交付一个静态文件就能跑。
21. 新手上手清单与常见坑
21.1 第一周要养成的习惯
- 保存时自动跑
goimports,代码永远是 gofmt 风格。 - 见到
if err != nil立刻处理,不要写_ =假装没看见。 - 局部变量用
:=,包级变量用var。 - 打开资源后第一行写
defer Close()。 - 结构体字面量永远带字段名。
- 启动 goroutine 之前先问自己:它怎么退出?
- 知道长度的切片用
make([]T, 0, n)。 - 每个导出标识符写一句以它名字开头的注释。
- 提交前本地跑
go test -race ./...。
21.2 最常见的坑(按踩中频率排序)
1. nil map 写入 panic
var m map[string]int
m["a"] = 1 // panic: assignment to entry in nil map
必须 m = make(map[string]int)。读 nil map 不会 panic,返回零值。
2. 循环变量捕获(Go 1.22 之前)
for _, item := range items {
go func() {
process(item) // 所有 goroutine 可能拿到同一个 item(最后一个)
}()
}
修法:go func(it Item) { process(it) }(item)。Go 1.22+ 每次迭代有独立变量,但保留传参写法更安全、可读性更好。
3. 接口持有 nil 指针却 != nil
见第 9 章。函数返回 error 时,错误路径返回具体错误,成功路径直接写 return nil,不要返回一个值为 nil 的具体错误指针。
4. defer 在循环里堆积
见 11.4。循环里打开多个资源时,把循环体抽成独立函数,或显式 Close。
5. range 切片拿到的是副本
for _, u := range users {
u.Score = 0 // 改的是副本,原切片不变
}
要改原元素用下标:users[i].Score = 0,或切片里存指针。
6. string 与 []byte 转换会拷贝
热路径不要反复 string(b)、[]byte(s)。能直接用 []byte 就别转。
7. 以为 map / slice 是并发安全的
它们都不是。多 goroutine 访问要加锁、用 channel 串行化,或用 sync.Map。
8. time.Format 用错模板
Go 的参考时间是固定的”魔法时间” 2006-01-02 15:04:05,不是 YYYY-MM-DD:
fmt.Println(time.Now().Format("2006-01-02 15:04:05"))
9. 结构体字段没导出,JSON 编解码忽略
type resp struct {
name string `json:"name"` // 小写字段 encoding/json 看不到,永远是空
}
需要被 JSON、反射相关库访问的字段必须首字母大写。
10. 用 encoding/json 解码数字到 interface{} 得到 float64
解到 map[string]any 后,所有数字默认是 float64,大整数会丢精度。强类型结构体是更好的选择。
11. for range string 按 rune 但下标是字节
s := "你好"
for i, r := range s {
fmt.Println(i, r) // i 是字节位置 0、3,不是 0、1
}
12. select 没有 default 时会阻塞
如果没有任何 case 就绪,select 会一直等,这通常是期望行为;但要确认 ctx.Done() 分支存在,否则会永久泄漏 goroutine。
21.3 如何阅读一个 Go 代码库
建议按依赖从内到外看:
- 最内层的领域类型与接口(通常在
domain/ 核心包):没有外部依赖,最容易读懂,先看这里建立词汇。 - 无状态小工具包:熟悉这个项目的语法风格与惯用法。
- 核心数据结构与并发模型:找到
atomic.Pointer快照、锁、对象池等关键模式,理解它对并发和性能的处理。 - 业务编排层:看一个请求怎么被一步步处理。
- 协议/适配层:看外部协议和领域模型之间怎么转换。
- 接入层:HTTP/RPC 入口、中间件。
main(装配):最后看它把所有部件连起来——理解 main 就理解了整个系统。
读代码时遵循一条:从类型定义开始,再看方法。Go 的类型就是文档。
21.4 遇到问题怎么办
- 先读编译器错误——Go 的错误信息通常直接告诉你怎么改。
go doc <pkg>或 https://pkg.go.dev 查标准库。- 跑
go vet ./...,它能抓出很多真实 bug。 - 怀疑并发问题就加
-race。 - 看 Effective Go 和 Go Code Review Comments,这两份是官方风格来源。
22. 进阶阅读
入门之后,下面这些材料帮你建立”地道的 Go 判断力”:
官方与经典
- A Tour of Go —— 交互式入门,边学边练。
- Effective Go —— 地道写法的权威指南。
- Go Code Review Comments —— 很多团队规约直接来自这里。
- Go Proverbs —— Rob Pike 的 Go 设计箴言。
- Concurrency in Go —— 官方博客的并发系列。
深入主题
- The Go Memory Model —— 想真正理解并发和 atomic 必读。
- Go Data Race Detector ——
-race怎么工作、能抓什么。 - Profiling Go Programs —— pprof 性能分析入门。
- Error Values and Inspection ——
errors.Is/As/%w设计动机。
风格与设计
- Standard Go Project Layout —— 目录怎么组织(参考即可,不必照搬)。
- Structuring Applications in Go —— Ben Johnson 的包设计文章。
- Avoiding Names in Go —— Andrew Gerrand 的命名演讲。
源码阅读
- 标准库本身就是最好的教材:
net/http、encoding/json、sync、context、io。 - 读代码时记住:Go 标准库风格就是工业标准,模仿它不会错。
结语
Go 不是一门以”特性多”取胜的语言。它的力量来自克制:很少的语法、统一的格式、显式的错误、清晰的并发原语。这些约束让你在写代码时少做选择,让团队在读别人代码时少花精力,让系统在高负载下表现可预测。
入门最快的方式不是读完所有资料,而是:
- 写一个能跑的小程序。
- 读一段真实的项目代码,从最内层的类型定义看起。
- 改一个小 bug 或加一个小字段,跑通测试。
- 在 code review 里对照第 18 章的规范修正自己的写法。
祝你写得开心,也写得 boring。