Skip to content
Charles Shao
Go back

Go 语言基础:核心语法与工程规范系统梳理

–views

面向零基础读者:读完你应该能看懂绝大多数 Go 代码,并能动手写出符合 Go 习惯的程序。 本笔记只讲 Go 语言本身的语法与工程习惯,所有示例都是与具体业务无关的通用代码,便于随时查阅。 所有代码块都是普通代码块,任何 Markdown 查看器都能直接阅读。


目录

  1. Go 是一门什么样的语言
  2. 环境与第一个程序
  3. 变量、常量与基本类型
  4. 控制流:if / for / switch
  5. 函数:多返回值、闭包与泛型
  6. 复合类型:array / slice / map
  7. struct 与方法
  8. 接口:Go 的抽象方式
  9. 指针:值语义与引用语义
  10. 错误处理:Go 的核心哲学
  11. defer:延迟执行与资源释放
  12. goroutine 与 channel
  13. context:取消与超时
  14. sync 包与锁
  15. 包、模块与工程结构
  16. //go:embed:把静态文件编进二进制
  17. //go:build:构建标签
  18. 标准写法与设计规范
  19. 测试:表驱动与子测试
  20. 工具链与门禁
  21. 新手上手清单与常见坑
  22. 进阶阅读

1. Go 是一门什么样的语言

Go(又称 Golang)是 Google 2009 年发布的一门编译型、静态类型、自带垃圾回收的语言。它的设计哲学是:

一句话感受 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 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 / float0
boolfalse
string""
pointer / slice / map / channel / interface / funcnil
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 码点

注意:


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 { ... }

注意:

6.4 new 与 make 的区别

这是新手经典困惑。Go 有两个分配内置函数,用途完全不同:

// 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 指针接收者

这是新手最容易困惑的点,记住几条:

实际建议(来自 Go Code Review Comments):

  1. 如果方法要修改接收者,必须用指针。
  2. 如果结构体很大,用指针避免拷贝。
  3. 如果有一个方法用了指针接收者,为了一致性,其余方法也用指针接收者。
  4. 小的、不可变的值类型(如 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++)。指针主要用于:

  1. 修改调用方传入的值。
  2. 避免拷贝大结构体。
  3. 表示”可能为 nil”的引用。
n := 10
p := &n        // p 是 *int,指向 n
*p = 20        // 修改 p 指向的值,n 变成 20

func increment(n *int) {
    *n++
}
increment(&n)

指针接收者 vs 值参数再强调一次:

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 添负担,还会把”正常的业务判定”误报成”系统故障”。

原则:

10.6 panic 和 recover

一个典型做法是在 HTTP 中间件里 recover,防止某个 handler 的 panic 拖垮整个服务。

10.7 错误处理规范小结


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 的典型场景

  1. 关闭资源:文件、连接、响应体。
  2. 解锁:mu.Lock(); defer mu.Unlock()。
  3. 收尾/通知:defer close(ch)、defer ticker.Stop()、defer cancel()。
  4. 计时/埋点:defer observe(time.Since(start))。
  5. 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 注意事项

// ❌ 所有文件到函数结束才关闭
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 所有权约定:

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 并发编程的”总线”,用来传递:

  1. 取消信号(任务该停了)。
  2. 截止时间/超时(必须在某时间前完成)。
  3. 请求范围的值(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()
    ...
}

规则:

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()

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
}

关键语义(新手必须理解):

  1. 所有 Do 调用者都会等待 f 返回:如果 goroutine A 正在执行 f,B、C 调用 Do 会阻塞在那里,直到 f 结束,然后和 A 一起拿到结果。所以 f 里不要做可能永久阻塞的事。
  2. f 只执行一次,即使 f 里 panic 了:f panic 时,Do 会把 panic 继续抛给调用者,但 Once 仍然被标记为”已完成”,后续调用不会重试。如果初始化可能失败,不要把失败路径放进 Once——要么在 f 里 panic(真不可恢复),要么用”指针 + 错误 + Mutex”自己实现可重试的懒加载。
  3. once.Do 不能接收参数:f 是 func(),需要的配置通过闭包或包级变量传入。
  4. sync.Once 的零值即可用,不要拷贝它(它内部含 Mutex,拷贝会失效)。

典型使用场景:

和 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 与不可变快照模式

对于”读多写少、读路径要极致快”的数据,一个高效模式是快照切换:

一个参考实现:

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 }}
)

使用规则:

  1. Get 后必须 Reset/清空状态:池里的对象带有上次使用的数据,不能直接用。
  2. Put 回去的对象不能再被引用:归还后它可能被别的 goroutine 取走,继续使用会产生数据竞争。所以常见写法是 Get 之后立刻 defer Put。
  3. 不要放入”打开的连接”这类带生命周期的资源:Pool 里的对象随时可能被 GC 回收(每次 GC 会清空池),它不保证对象一直在。它适合无状态的、重建成本高但随时可丢弃的临时对象,不适合数据库连接池(那要用专门的连接池库)。
  4. Pool 本身是并发安全的,不需要加锁。
  5. 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
等待一组 goroutinesync.WaitGroup
一次性初始化 / 懒加载sync.Once
热路径复用临时对象、减少 GCsync.Pool
键稳定、读多写少的并发 mapsync.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 包命名规范

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{}
}

包初始化顺序:

  1. 按依赖顺序初始化被导入的包(深度优先)。
  2. 同一个包内,先初始化包级变量(按声明顺序,解析依赖后),再按文件名字母序、文件内顺序执行各文件的 init()。
  3. 所有包初始化完才执行 main.main()。

所以一个包的 init() 运行时,它导入的所有包都已经初始化好了。

合理用途:

不要滥用 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

注意三件事:

  1. 必须空白导入 embed 包(import _ "embed"),即使你代码里不直接用它。这是编译器识别指令的开关。
  2. //go:embed 是编译器指令,// 和 go:embed 之间没有空格。
  3. 指令紧贴在变量声明上方,中间不能有空行或其他语句。

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
}

判断标准:

16.4 路径规则


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 常见用途

注意://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/  # 只跑某个包

常见用途:

注意几点:

  1. //go:generate 不是自动触发的,必须显式跑 go generate,CI/发布流程里要记得加。
  2. 生成的文件通常在开头带 // Code generated by ... DO NOT EDIT.,不要手改。
  3. 指令里用的工具(stringer、mockgen 等)要先 go install,团队最好在文档或 Makefile 里固定版本。

18. 标准写法与设计规范

这一章把 Go 社区公认的工程规约浓缩成新手最该养成的习惯。

18.1 命名

18.2 注释

// Normalize returns a copy of c with defaults filled in.
func (c Config) Normalize() Config { ... }
// ✅ 好:说清签名表达不了的含义
// 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 接口的克制

18.5 错误处理规范

见第 10 章,核心要点:

18.6 context 规范

18.7 并发规范

18.8 性能与热路径

在高吞吐服务里,这些规则在热路径(请求可达的代码)上尤其重要:

18.9 配置与密钥

18.10 可观测性

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) 创建具名子测试,好处:

19.3 测试规范

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 第一周要养成的习惯

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 代码库

建议按依赖从内到外看:

  1. 最内层的领域类型与接口(通常在 domain / 核心包):没有外部依赖,最容易读懂,先看这里建立词汇。
  2. 无状态小工具包:熟悉这个项目的语法风格与惯用法。
  3. 核心数据结构与并发模型:找到 atomic.Pointer 快照、锁、对象池等关键模式,理解它对并发和性能的处理。
  4. 业务编排层:看一个请求怎么被一步步处理。
  5. 协议/适配层:看外部协议和领域模型之间怎么转换。
  6. 接入层:HTTP/RPC 入口、中间件。
  7. main(装配):最后看它把所有部件连起来——理解 main 就理解了整个系统。

读代码时遵循一条:从类型定义开始,再看方法。Go 的类型就是文档。

21.4 遇到问题怎么办

  1. 先读编译器错误——Go 的错误信息通常直接告诉你怎么改。
  2. go doc <pkg> 或 https://pkg.go.dev 查标准库。
  3. 跑 go vet ./...,它能抓出很多真实 bug。
  4. 怀疑并发问题就加 -race。
  5. 看 Effective Go 和 Go Code Review Comments,这两份是官方风格来源。

22. 进阶阅读

入门之后,下面这些材料帮你建立”地道的 Go 判断力”:

官方与经典

深入主题

风格与设计

源码阅读


结语

Go 不是一门以”特性多”取胜的语言。它的力量来自克制:很少的语法、统一的格式、显式的错误、清晰的并发原语。这些约束让你在写代码时少做选择,让团队在读别人代码时少花精力,让系统在高负载下表现可预测。

入门最快的方式不是读完所有资料,而是:

  1. 写一个能跑的小程序。
  2. 读一段真实的项目代码,从最内层的类型定义看起。
  3. 改一个小 bug 或加一个小字段,跑通测试。
  4. 在 code review 里对照第 18 章的规范修正自己的写法。

祝你写得开心,也写得 boring。


–views
Share this post on:

Previous Post
Go 工程目录:企业级项目结构与设计规范
Next Post
可见性与广告验证:MRC、OMID与品牌安全