Golang入门学习笔记

嗯好的,因为个人原因变动所以决定从公司跑路转向Golang后端/大模型应用所以有了这篇文章

大纲参考B站枫枫讲Go语言-8小时入门GO

一、变量定义

Go 使用 var 定义变量,也支持在函数内部使用短变量声明 :=

1. 显式指定类型

Go 复制代码
var age int = 25

含义:

  • 变量名是 age
  • 类型是 int
  • 初始值是 25

Go 的类型写在变量名后面,这一点和 Java、C# 不同。

Java / C# 写法:

java 复制代码
int age = 25;

Go 写法:

复制代码
var age int = 25

2. 省略类型,让编译器推断

复制代码
var name = "Alice"

Go 会根据右侧的值推断变量类型。

上面的代码中,name 会被推断为 string 类型。

这和 C# 的 var 有相似之处:

复制代码
var name = "Alice";

但需要注意,Go 仍然是静态类型语言。变量类型一旦确定,后续不能改成其他类型。

复制代码
var age = 25
// age = "18" // 编译错误

3. 只声明,不赋值

Go 复制代码
var score int
var enabled bool
var message string

Go 中变量只声明不赋值时,会自动获得对应类型的零值。

常见零值:

类型 零值
int / float64 等数字类型 0
bool false
string ""
指针、切片、map、channel、函数、接口 nil

示例:

复制代码
var score int
var enabled bool
var message string

fmt.Println(score)   // 0
fmt.Println(enabled) // false
fmt.Println(message) // ""

Java、C# 中字段也有默认值,Go 的零值机制和它们有相似点。区别是 Go 的局部变量如果声明了,就必须被使用,否则编译失败。

4. 短变量声明

复制代码
city := "Shanghai"

:= 是 Go 的短变量声明语法,等价于声明变量并赋初始值。

复制代码
city := "Shanghai"

可以理解为:

复制代码
var city = "Shanghai"

限制:

  • := 只能在函数内部使用。
  • := 不能用于包级变量。
  • := 左边至少要有一个新变量。

错误示例:

复制代码
package main

appName := "GoStudy" // 编译错误

正确写法:

复制代码
package main

var appName = "GoStudy"

5. 一次声明多个变量

复制代码
var x, y int = 10, 20

也可以使用短变量声明:

复制代码
width, height := 1920, 1080

如果变量较多,也可以使用分组声明:

复制代码
var (
	price    float64 = 99.9
	quantity int     = 3
	inStock  bool    = true
)

分组声明常用于定义一组相关变量。

6. 变量可以重新赋值

复制代码
var age int = 25
age = age + 1

变量可以重新赋值,但不能改变类型。

复制代码
var age int = 25
// age = "Alice" // 编译错误

这一点和 Python 不同。

Python 中变量名更像是对象引用:

复制代码
age = 25
age = "Alice"

Go 中变量有明确类型:

复制代码
age := 25
// age = "Alice" // 编译错误

7. 包级变量

定义在函数外部的变量叫包级变量。

复制代码
package main

var appName = "GoStudy"

func main() {
	fmt.Println(appName)
}

包级变量可以近似理解为 Go 中的"全局变量",但更准确的说法是"包级变量"。

它属于当前 package,同一个包里的其他 .go 文件也可以访问它。

和 Java / C# 类比,包级变量接近类上的静态字段:

复制代码
class App {
    static String appName = "GoStudy";
}

区别是 Go 没有 class,函数外定义的变量直接属于当前 package。

包级变量的访问范围和首字母有关:

复制代码
var appName = "GoStudy" // 小写开头:只在当前包内可见
var AppName = "GoStudy" // 大写开头:其他包也可以访问

8. 本节小结

变量定义需要重点掌握:

  • var name type = value:显式类型声明。
  • var name = value:省略类型,由编译器推断。
  • var name type:只声明不赋值,使用零值。
  • name := value:短变量声明,只能在函数内部使用。
  • 包级变量定义在函数外,属于当前 package。
  • Go 是静态类型语言,变量类型确定后不能改成其他类型。

二、输入输出

Go 中最常用的基础输入输出来自标准库 fmt 包。

常见输出函数:

  • fmt.Print:输出内容,不自动换行。
  • fmt.Println:输出内容,并自动换行。
  • fmt.Printf:按照格式化模板输出。
  • fmt.Sprintf:按照格式化模板生成字符串,但不直接输出。

常见输入方式:

  • fmt.Scan:从标准输入读取内容。
  • fmt.Scanln:读取一行中的内容。
  • fmt.Scanf:按指定格式读取内容。
  • fmt.Fscan:从指定输入源读取内容,常和 bufio.Reader 搭配使用。
  • bufio.Reader:适合读取一整行文本。

1. 基本输出

复制代码
fmt.Print("Hello")
fmt.Print(" Go\n")

Print 不会自动换行。如果需要换行,要自己写 \n

复制代码
fmt.Println("Hello Go")

Println 会自动换行,并且多个参数之间会自动加空格。

复制代码
name := "Alice"
age := 25

fmt.Println("name:", name, "age:", age)

输出:

复制代码
name: Alice age: 25

2. 格式化输出

Printf 用于格式化输出。

复制代码
name := "Alice"
age := 25
score := 95.678

fmt.Printf("name=%s, age=%d, score=%.2f\n", name, age, score)

输出:

复制代码
name=Alice, age=25, score=95.68

常用占位符:

占位符 含义
%s 字符串
%d 十进制整数
%f 浮点数
%.2f 保留两位小数
%t 布尔值
%v 按默认格式输出任意值
%T 输出值的类型
%q 带引号的字符串

3. 生成字符串

SprintfPrintf 类似,但它不会直接输出,而是返回一个字符串。

复制代码
name := "Alice"
age := 25

message := fmt.Sprintf("%s is %d years old", name, age)
fmt.Println(message)

PrintfSprintf 的核心区别:

函数 是否直接输出 是否返回字符串
fmt.Printf
fmt.Sprintf

Printf 适合直接把内容打印到控制台。

复制代码
fmt.Printf("name=%s, age=%d\n", name, age)

Sprintf 适合先生成一个字符串,再赋值给变量、写入文件、作为函数参数传递。

复制代码
message := fmt.Sprintf("name=%s, age=%d", name, age)
fmt.Println(message)

和 C# 类比:

复制代码
string message = string.Format("{0} is {1} years old", name, age);

和 Python 类比:

复制代码
message = f"{name} is {age} years old"

4. 基本输入

可以使用 fmt.Scan 读取用户输入。

复制代码
var name string
var age int

fmt.Scan(&name, &age)
fmt.Printf("name=%s, age=%d\n", name, age)

输入:

复制代码
Tom 18

输出:

复制代码
name=Tom, age=18

注意这里传入的是 &name&age,不是 nameage

& 表示取变量地址。因为输入函数需要把读取到的值写回变量,所以必须传变量地址。

这一点和 Java、C#、Python 的普通输入函数不同。Go 在这里显式要求把"要被修改的变量地址"传进去。

5. 按空白分隔读取

fmt.Scanfmt.Fscan 默认按空白字符分隔输入。

空格、Tab、换行都可以作为分隔符。

复制代码
var name string
var age int

fmt.Scan(&name, &age)

下面两种输入效果相同:

复制代码
Tom 18

Tom
18

如果要读取一句完整的话,仅使用 fmt.Scan 不合适。

例如输入:

复制代码
hello golang

使用 fmt.Scan(&text) 只能读到:

复制代码
hello

因为空格会被当作分隔符。

6. 读取一整行

读取一整行文本,可以使用 bufio.Reader

复制代码
reader := bufio.NewReader(os.Stdin)

line, err := reader.ReadString('\n')
if err != nil {
	fmt.Println("read error:", err)
	return
}

line = strings.TrimSpace(line)
fmt.Println("输入内容:", line)

需要导入:

复制代码
import (
	"bufio"
	"fmt"
	"os"
	"strings"
)

ReadString('\n') 会一直读取到换行符为止。

strings.TrimSpace 用于去掉字符串前后的空白字符,包括换行符。

7. 输入代码中的常见概念

nil

nil 表示"没有值",可以类比 Java、C#、Python 中的 null / None,但不能完全等同。

在 Go 中,只有某些类型可以是 nil,例如:

  • 指针
  • 切片
  • map
  • channel
  • 函数
  • 接口
  • error

普通的 intboolstring 不能是 nil

在输入输出代码里,常见写法是:

复制代码
line, err := reader.ReadString('\n')
if err != nil {
	fmt.Println("read error:", err)
	return
}

这里的 err == nil 通常表示没有错误,err != nil 表示发生了错误。

err

err 不是关键字,只是一个普通变量名。

Go 里很多函数会把错误作为最后一个返回值返回,所以大家习惯把这个变量命名为 err

复制代码
line, err := reader.ReadString('\n')

这行代码的意思是:

  • line 接收读取到的字符串。
  • err 接收读取过程中可能发生的错误。
_

_ 叫空白标识符,用来接收但丢弃某个值。

Go 要求函数内声明的变量必须被使用。如果某个返回值不需要,可以用 _ 丢弃。

复制代码
_, err := fmt.Scan(&name)

上面代码中,fmt.Scan 的第一个返回值被丢弃,只保留错误信息 err

如果两个返回值都不关心,也可以写:

复制代码
_, _ = reader.ReadString('\n')

_ 不是普通变量,后面不能再使用它。

reader

reader 是变量名,不是关键字。

复制代码
reader := bufio.NewReader(os.Stdin)

这行代码创建了一个从标准输入读取内容的缓冲读取器。

os.Stdin 表示标准输入,通常就是键盘输入。

bufio.NewReader(os.Stdin) 表示在标准输入外面包一层缓冲读取能力,使它可以更方便地读取一整行。

缓冲读取器

缓冲读取器可以理解为:程序不直接一点一点从输入源读取,而是先从输入源拿一批数据放到内存缓冲区里,后续读取时优先从这块缓冲区取数据。

普通读取可以理解为:

复制代码
程序 -> 输入源

缓冲读取可以理解为:

复制代码
程序 -> 缓冲区 -> 输入源

这样做有两个好处:

  • 减少频繁访问底层输入源的次数。
  • 提供更方便的读取方法,例如按行读取。

在 Go 中:

复制代码
reader := bufio.NewReader(os.Stdin)

含义是:基于标准输入 os.Stdin 创建一个带缓冲能力的读取器。

之后可以使用:

复制代码
line, err := reader.ReadString('\n')

这表示从缓冲读取器里读取内容,直到遇到换行符。

和 Java 类比:

复制代码
BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));

Go 的 bufio.Reader 和 Java 的 BufferedReader 思路类似,都是在原始输入流外面包一层缓冲能力。

*bufio.Reader

bufio.Reader 是 Go 标准库 bufio 包里的一个类型。

*bufio.Reader 表示"指向 bufio.Reader 的指针"。

复制代码
func discardRemainingLine(reader *bufio.Reader) {
	_, _ = reader.ReadString('\n')
}

这里参数类型写成 *bufio.Reader,表示函数接收的是一个 Reader 对象的地址。

和 Java / C# 的引用对象类比:

复制代码
BufferedReader reader

Go 里用 *bufio.Reader 明确表示这是一个指针。函数内部通过这个指针继续读取同一个输入流。

输入场景中的指针

Go 中的指针是显式的。

在输入场景里,最常见的是:

复制代码
fmt.Scan(&name)

这里的 &name 表示取 name 变量的地址。

输入函数需要把用户输入写回变量,所以必须知道变量的地址。

如果写成:

复制代码
fmt.Scan(name)

传进去的只是 name 当前的值,输入函数无法修改外面的 name 变量。

可以先记住这条规则:

复制代码
函数如果需要修改变量,就传 `&变量`

reader := bufio.NewReader(os.Stdin) 创建出来的 reader 本身就是 *bufio.Reader 类型。

所以如果函数参数需要的是:

复制代码
func readLine(reader *bufio.Reader) {
	// ...
}

调用时直接传:

复制代码
readLine(reader)

不需要写成:

复制代码
readLine(&reader)

8. Scan 和 Fscan 的区别

ScanFscan 的核心区别是读取来源不同。

复制代码
fmt.Scan(&name, &age)

Scan 默认从标准输入读取,也就是 os.Stdin

复制代码
fmt.Fscan(reader, &name, &age)

Fscan 从指定的输入源读取。这里指定的输入源是 reader

可以简单理解为:

函数 读取来源
fmt.Scan 默认从 os.Stdin 读取
fmt.Fscan 从指定的 io.Reader 读取

如果已经创建了缓冲读取器:

复制代码
reader := bufio.NewReader(os.Stdin)

后续建议统一使用:

复制代码
fmt.Fscan(reader, &name, &age)
line, err := reader.ReadString('\n')

这样所有输入都从同一个 reader 读取,缓冲状态更一致。

9. ReadString 的作用

ReadString('\n')bufio.Reader 的方法,用于一直读取到指定字符为止。

复制代码
line, err := reader.ReadString('\n')

这里的 '\n' 表示换行符。

所以这行代码的意思是:

复制代码
从 reader 中读取内容,直到遇到换行符

ReadString 返回两个值:

返回值 含义
line 读取到的字符串
err 读取过程中可能发生的错误

如果只是想清理当前行剩余内容,可以写:

复制代码
_, _ = reader.ReadString('\n')

这里的两个 _ 表示两个返回值都丢弃。

这个写法常用于处理 Fscan 之后残留的换行符。

复制代码
fmt.Fscan(reader, &name, &age)
_, _ = reader.ReadString('\n')
line, err := reader.ReadString('\n')

第一行按空白读取 nameage

第二行清理当前行剩余内容,包括回车产生的换行符。

第三行再读取下一整行文本。

10. 多个输入函数共用同一个 Reader

如果一个程序中有多个函数都要读取用户输入,建议在外层创建一个 reader,然后传给每个函数。

推荐写法:

复制代码
func main() {
	reader := bufio.NewReader(os.Stdin)

	readUser(reader)
	readProduct(reader)
	readLine(reader)
}

函数参数:

复制代码
func readUser(reader *bufio.Reader) {
	var name string
	var age int
	fmt.Fscan(reader, &name, &age)
}

不推荐每个函数内部都重新创建:

复制代码
func readUser() {
	reader := bufio.NewReader(os.Stdin)
}

func readProduct() {
	reader := bufio.NewReader(os.Stdin)
}

原因是缓冲读取器内部会维护自己的缓冲区和读取位置。多个 Reader 同时包在同一个 os.Stdin 上,容易导致输入状态不一致。

一个程序中如果要连续读取多段输入,使用同一个 Reader 更清晰。

常见现象:

  • 前一个输入函数看起来"多读了"后续输入。
  • 后一个输入函数还没真正等待输入,就直接报读取失败。
  • 提示顺序和报错顺序看起来混乱。

这类问题通常不是 if 判断写错,而是输入源被多个 Reader 分别维护缓冲导致的。

排查与处理建议:

  1. 优先确认是否在多个函数里重复 bufio.NewReader(os.Stdin)
  2. 如果是,改为在 main 创建一个 Reader,并作为参数传下去。
  3. 如果同时使用了 FscanReadString,注意清理换行符,避免把上一段输入残留给下一段逻辑。

11. 本节小结

输入输出需要重点掌握:

  • Print 不自动换行。
  • Println 自动换行。
  • Printf 用格式化模板输出。
  • Sprintf 返回格式化后的字符串。
  • Scan 读取输入时要传变量地址,例如 &name
  • Scan 默认按空白字符分隔,不适合读取整句话。
  • 读取一整行文本可以使用 bufio.Reader
  • Scan 默认从标准输入读取,Fscan 可以指定读取来源。
  • ReadString('\n') 表示读取到换行符为止。
  • 多个输入函数建议共用同一个 reader
  • err 是普通变量名,通常用于接收错误。
  • nil 通常表示没有值,err == nil 表示没有错误。
  • _ 用于丢弃不需要的返回值。

三、基本数据类型

Go 是静态类型语言,变量一旦确定类型,就不能再改成其他类型。

Go 中常见的基本数据类型包括:

  • 布尔类型:bool
  • 整数类型:intint8int16int32int64
  • 无符号整数类型:uintuint8uint16uint32uint64
  • 浮点数类型:float32float64
  • 复数类型:complex64complex128
  • 字符串类型:string
  • 字节类型:byte
  • 字符类型:rune

1. bool

bool 表示布尔值,只有两个值:

复制代码
true
false

示例:

复制代码
var enabled bool = true
var finished bool

fmt.Println(enabled)  // true
fmt.Println(finished) // false

bool 的零值是 false

Go 中不能把数字当成布尔值使用。

复制代码
// if 1 { } // 编译错误

这一点和 Python 不同。Python 中 0、空字符串、空列表等可以作为假值判断,Go 不允许这样做,条件表达式必须是 bool

2. 整数类型

Go 的整数类型分为有符号整数和无符号整数。

有符号整数可以表示正数、负数和零:

复制代码
int
int8
int16
int32
int64

无符号整数只能表示零和正数:

复制代码
uint
uint8
uint16
uint32
uint64

无符号整数也可以叫 unsigned integer。

复制代码
var count uint = 100
// count = -1 // 编译错误

常用的是 int

复制代码
var age int = 18

int 的具体大小和平台有关。在 64 位系统上通常是 64 位,在 32 位系统上通常是 32 位。

如果需要明确位数,可以使用:

复制代码
var a int32 = 100
var b int64 = 10000000000

业务代码中通常优先使用 int。只有在明确需要固定范围、二进制协议、文件格式、网络协议,或者需要表达"不能为负"时,才更常使用 int8uint32 这类具体位数类型。

注意:不同整数类型之间不能直接运算。

复制代码
var a int = 10
var b int64 = 20

// result := a + b // 编译错误
result := int64(a) + b
fmt.Println(result)

Go 不会自动把 int 转成 int64,必须显式转换。

3. byte

byteuint8 的别名,通常用于表示一个字节。

复制代码
var b byte = 'A'
fmt.Println(b)      // 65
fmt.Printf("%c", b) // A

'A' 是字符字面量,底层对应 Unicode 编码值。因为 A 的编码值是 65,所以打印数值时会看到 65

处理二进制数据、文件内容、网络数据时,经常会看到 []byte

复制代码
data := []byte("hello")
fmt.Println(data)

[]byte 表示 byte 切片。切片后面会单独学习,这里可以先理解为"一组 byte"。

把字符串转成 []byte,看到的是字符串的 UTF-8 字节表示。

复制代码
s := "Go语言"
bytes := []byte(s)
fmt.Println(len(bytes)) // 8

其中:

复制代码
G  占 1 字节
o  占 1 字节
语 占 3 字节
言 占 3 字节

4. rune

runeint32 的别名,通常用于表示一个 Unicode 字符。

复制代码
var r rune = '语'
fmt.Println(r)
fmt.Printf("%c", r)

Go 的字符串底层是 UTF-8 编码,一个中文字符通常会占多个字节。

复制代码
s := "Go语言"

fmt.Println(len(s))         // 字节长度
fmt.Println(len([]rune(s))) // 字符数量

len(s) 返回的是字节数量,不是字符数量。

如果要按字符处理字符串,通常需要转成 []rune

[]rune 表示 rune 切片。这里可以先理解为"一组 Unicode 字符"。

复制代码
s := "Go语言"
runes := []rune(s)
fmt.Println(len(runes)) // 4

byterune 的区别可以简单记为:

复制代码
byte 看底层字节
rune 看 Unicode 字符

5. 浮点数类型

Go 的浮点数类型有:

复制代码
float32
float64

常用的是 float64

float32float64 的区别类似 Java、C# 中 floatdouble 的区别。

Go Java / C# 类比 说明
float32 float 单精度浮点数,精度较低
float64 double 双精度浮点数,精度较高

float64 更常用,因为精度更高,标准库中很多数学函数也使用 float64

复制代码
var price float64 = 19.99
fmt.Printf("%.2f\n", price)

%.2f 表示保留两位小数输出。

注意:浮点数适合表示近似小数,不适合直接用于高精度金额计算。

float32float64 不能直接运算。

复制代码
var a float32 = 1.2
var b float64 = 3.4

// result := a + b // 编译错误
result := float64(a) + b

Go 要求显式类型转换。

6. 复数类型

复数是数学中的 complex number,由实部和虚部组成。

Go 内置复数类型:

复制代码
complex64
complex128

示例:

复制代码
var c complex128 = 3 + 4i

fmt.Println(real(c)) // 3
fmt.Println(imag(c)) // 4

其中:

复制代码
3 是实部
4 是虚部
i 是虚数单位

大多数业务开发中复数类型用得较少,主要出现在数学、科学计算等场景。

7. string

string 表示字符串。

复制代码
var name string = "Alice"

字符串可以用双引号:

复制代码
s := "hello"

也可以用反引号表示原始字符串:

复制代码
text := `第一行
第二行`

反引号字符串会保留换行和普通字符,不会处理转义。

Go 字符串是不可变的。

复制代码
s := "hello"
// s[0] = 'H' // 编译错误

如果要修改字符串,需要创建新字符串。

复制代码
s = "H" + s[1:]

这里的 s[1:] 是切片表达式,表示从下标 1 开始截取到字符串末尾。

复制代码
h e l l o
0 1 2 3 4

所以:

复制代码
s[1:]

得到:

复制代码
ello

再拼接:

复制代码
"H" + s[1:]

得到新的字符串:

复制代码
Hello

这不是原地修改 "hello",而是创建新字符串 "Hello",然后让变量 s 指向新字符串。

原来的 "hello" 如果没有其他地方再使用,就会变成不可达数据,之后可以由运行时回收。不过字符串字面量也可能被编译器放在只读数据区,实际是否立即回收不需要依赖。

从语言语义上,只需要记住:

复制代码
字符串不可变
修改字符串的效果通常是创建新字符串
变量重新指向新字符串

8. 零值

Go 中变量声明后如果没有赋值,会自动获得零值。

常见基本类型零值:

类型 零值
bool false
整数类型 0
浮点数类型 0
复数类型 0+0i
string ""

示例:

复制代码
var number int
var price float64
var ok bool
var message string

fmt.Println(number)  // 0
fmt.Println(price)   // 0
fmt.Println(ok)      // false
fmt.Println(message) // ""

9. 显式类型转换

Go 不会自动进行不同类型之间的隐式转换。

复制代码
var age int = 18
var score float64 = 95.8

fmt.Println(float64(age))
fmt.Println(int(score))

把浮点数转成整数时,小数部分会被截断。

复制代码
var price float64 = 19.9
fmt.Println(int(price)) // 19

注意:这是截断,不是四舍五入。

10. Go 没有包装类型和装箱拆箱

Go 没有 Java 中 int / Integer 这种包装类型体系,也没有 Java、C# 意义上的自动装箱和拆箱。

Java 示例:

复制代码
int a = 10;
Integer b = a;
int c = b;

Go 中只有:

复制代码
var a int = 10

如果需要传递地址,使用指针:

复制代码
p := &a // p 的类型是 *int

如果需要接收任意类型,可以使用 any

复制代码
var x any = a

any 中取回具体类型时,需要使用类型断言。

复制代码
value, ok := x.(int)
if ok {
	fmt.Println(value)
}

any 可以类比 Java 中的 Object,但 Go 通常不会把这个过程称为装箱拆箱,而是称为把具体类型赋给接口类型,再通过类型断言取回具体类型。

11. 本节小结

基本数据类型需要重点掌握:

  • bool 只有 truefalse,不能用数字代替布尔值。
  • int 是最常用的整数类型。
  • uint 是无符号整数,只能表示零和正数。
  • 不同数字类型之间不能直接运算,需要显式转换。
  • byteuint8 的别名,常用于表示字节。
  • runeint32 的别名,常用于表示 Unicode 字符。
  • len(string) 返回字节长度,不是字符数量。
  • 字符串不可变,不能直接修改某个字符。
  • 修改字符串通常是生成新字符串,再让变量指向新字符串。
  • float32 类似 floatfloat64 类似 double,Go 中更常用 float64
  • float64 是常用浮点类型,但不适合直接做高精度金额计算。
  • complex64complex128 是复数类型,普通业务开发较少使用。
  • Go 没有 Java 风格的包装类型和自动装箱拆箱。

四、数组和切片

数组和切片都可以保存一组相同类型的数据,但它们在 Go 中是两个不同概念。

简单区分:

复制代码
数组:长度固定,长度属于类型的一部分
切片:长度可变,更常用于日常开发

1. 数组

数组使用 [长度]类型 表示。

复制代码
var scores [3]int = [3]int{90, 85, 100}

也可以简写:

复制代码
scores := [3]int{90, 85, 100}

如果希望编译器根据元素数量推断数组长度,可以使用 ...

复制代码
scores := [...]int{90, 85, 100}

数组的长度可以用 len 获取。

复制代码
fmt.Println(len(scores)) // 3

2. 数组长度属于类型

Go 中数组长度是类型的一部分。

复制代码
var a [3]int
var b [4]int

a 的类型是 [3]intb 的类型是 [4]int。它们不是同一种类型。

复制代码
// a = b // 编译错误

这一点和 Java、C# 中的数组差别较大。

Java / C# 中通常更关注数组元素类型,而 Go 中数组长度也参与类型判断。

3. 数组遍历

普通 for 循环:

复制代码
scores := [3]int{90, 85, 100}

for i := 0; i < len(scores); i++ {
	fmt.Println(scores[i])
}

for range 遍历:

复制代码
for index, value := range scores {
	fmt.Println(index, value)
}

如果不需要下标,可以用 _ 丢弃:

复制代码
for _, value := range scores {
	fmt.Println(value)
}

如果在遍历中要计算平均值,建议直接使用浮点除法,避免整数除法丢失小数部分:

复制代码
total := 381
count := 5
avg := float64(total) / float64(count) // 76.2

4. 切片

切片使用 []类型 表示。

复制代码
numbers := []int{10, 20, 30}

注意和数组的区别:

复制代码
array := [3]int{10, 20, 30}
slice := []int{10, 20, 30}

区别在于:

复制代码
[3]int  是数组,长度固定为 3
[]int   是切片,长度可变

切片更接近 Java、C# 中日常使用的动态列表。

复制代码
Go:   []int
Java: ArrayList<Integer> / int[] 的部分使用场景
C#:   List<int> / int[] 的部分使用场景

严格说,Go 的切片不是 Java/C# 的 List。切片是一个描述结构,内部指向一个底层数组。

5. len 和 cap

切片有两个重要概念:

复制代码
len:当前切片长度,也就是能访问的元素数量
cap:当前切片容量,也就是从切片起点到底层数组末尾最多能容纳多少元素

示例:

复制代码
numbers := []int{10, 20, 30}

fmt.Println(len(numbers)) // 3
fmt.Println(cap(numbers)) // 3

len 决定当前能访问哪些下标。

复制代码
fmt.Println(numbers[0])
fmt.Println(numbers[2])
// fmt.Println(numbers[3]) // 越界

6. append

切片可以使用 append 追加元素。

复制代码
numbers := []int{10, 20, 30}
numbers = append(numbers, 40)

注意:append 会返回新的切片,所以通常要接收返回值。

复制代码
numbers = append(numbers, 50)

不能只写:

复制代码
// append(numbers, 50) // 编译错误:返回值没有使用

append 时,如果底层数组容量够用,会直接在原底层数组上追加。如果容量不够,Go 会分配新的底层数组,并把原来的元素复制过去。

容量增长不是固定"永远翻倍"。在较小容量阶段常见翻倍增长;容量变大后,增长比例会下降并逐渐接近约 1.25x。另外,最终 cap 还会受到内存分配对齐影响,实际值可能比理论值略大。

7. make 创建切片

可以使用 make 创建切片。

复制代码
numbers := make([]int, 2, 5)

含义:

复制代码
创建一个 []int 切片
当前长度 len 是 2
当前容量 cap 是 5

fmt.Println(len(numbers)) // 2
fmt.Println(cap(numbers)) // 5

长度范围内的元素可以直接访问:

复制代码
numbers[0] = 10
numbers[1] = 20

但是不能访问超过 len 的下标:

复制代码
// numbers[2] = 30 // 编译能过,运行时报错:index out of range

如果要新增元素,使用 append

复制代码
numbers = append(numbers, 30)

8. 切片表达式

切片表达式用于从数组、切片或字符串中截取一段。

复制代码
numbers := []int{10, 20, 30, 40, 50}

常见写法:

复制代码
numbers[0:2] // 下标 0 到 2,不包含 2
numbers[:2]  // 从开头到下标 2,不包含 2
numbers[2:]  // 从下标 2 到最后

示例:

复制代码
fmt.Println(numbers[0:2]) // [10 20]
fmt.Println(numbers[:2])  // [10 20]
fmt.Println(numbers[2:])  // [30 40 50]

规则:

复制代码
左闭右开:包含起始下标,不包含结束下标

9. 切片共享底层数组

切片本身不是数组,它是对底层数组某一段的描述。

复制代码
numbers := []int{10, 20, 30, 40}
part := numbers[1:3]

此时:

复制代码
fmt.Println(part) // [20 30]

如果修改 part

复制代码
part[0] = 200

原切片 numbers 也会变化:

复制代码
fmt.Println(numbers) // [10 200 30 40]

原因是 partnumbers 共享同一个底层数组。

这一点非常重要。切片值本身可以复制,但复制后的切片仍可能指向同一个底层数组,因此修改元素可能互相可见。

10. nil 切片和空切片

nil 切片:

复制代码
var a []int

空切片:

复制代码
b := []int{}
c := make([]int, 0)

区别:

复制代码
fmt.Println(a == nil) // true
fmt.Println(b == nil) // false
fmt.Println(c == nil) // false

它们的长度都是 0:

复制代码
fmt.Println(len(a)) // 0
fmt.Println(len(b)) // 0
fmt.Println(len(c)) // 0

nil 切片也可以直接 append

复制代码
var a []int
a = append(a, 10)

所以在很多场景下,nil 切片和空切片都可以正常使用。

11. 数组和切片的选择

日常开发中更常用切片。

数组适合:

  • 长度固定且非常明确的场景。
  • 底层实现、性能敏感代码。
  • 需要把长度作为类型约束的一部分。

切片适合:

  • 大多数列表数据。
  • 需要追加元素。
  • 需要截取一段数据。
  • 函数参数中传递一组数据。

一般可以先记:

复制代码
不确定用哪个时,优先用切片

12. 本节小结

数组和切片需要重点掌握:

  • 数组写法是 [长度]类型,例如 [3]int
  • 切片写法是 []类型,例如 []int
  • 数组长度属于类型,[3]int[4]int 是不同类型。
  • 切片长度可变,更常用于日常开发。
  • len 表示长度,cap 表示容量。
  • append 会返回新的切片,通常要用原变量接收。
  • make([]int, len, cap) 可以创建指定长度和容量的切片。
  • 切片表达式是左闭右开,例如 numbers[1:3]
  • 切片可能共享同一个底层数组,修改一个切片可能影响另一个切片。
  • nil 切片可以直接 append

五、map(键值对)

map 是 Go 中用于保存键值对的数据结构,适合按 key 快速查找 value。

复制代码
key -> value

1. map 的定义与初始化

map 的基本写法:

复制代码
var m map[string]int

这种写法只声明了 map 类型变量,默认值是 nil,还不能直接写入键值对。

常见可用初始化方式有两种:

复制代码
// 方式 1:字面量初始化
scores := map[string]int{
	"Tom":  88,
	"Lucy": 92,
}

// 方式 2:make 初始化
prices := make(map[string]float64)
prices["book"] = 19.9

如果需要"空 map 但可写",下面两种都可以:

复制代码
m1 := map[string]int{}
m2 := make(map[string]int)

两者都不是 nil map,都可以直接写入键值对。

2. 读取 map

读取语法:

复制代码
value := m[key]

如果 key 不存在,不会报错,会返回 value 类型的零值。

复制代码
scores := map[string]int{"Tom": 88}
fmt.Println(scores["Tom"])   // 88
fmt.Println(scores["Alice"]) // 0

只看返回值时,无法区分"key 不存在"与"key 对应值刚好是零值"。

3. 判断 key 是否存在

推荐使用:

复制代码
value, ok := m[key]
  • ok == true:key 存在。
  • ok == false:key 不存在。

示例:

复制代码
score, ok := scores["Tom"]
fmt.Println(score, ok) // 88 true

score2, ok2 := scores["Alice"]
fmt.Println(score2, ok2) // 0 false

4. 新增、更新、删除

map 写入语法:

复制代码
m[key] = value

如果 key 原本不存在,就是新增;如果已存在,就是更新。

删除使用 delete

复制代码
delete(m, key)

即使 key 不存在,delete 也不会报错。

5. 遍历 map

使用 for range

复制代码
for key, value := range m {
	fmt.Println(key, value)
}

如果只需要 key:

复制代码
for key := range m {
	fmt.Println(key)
}

如果只需要 value:

复制代码
for _, value := range m {
	fmt.Println(value)
}

说明:map 的遍历顺序是无序的,不应依赖固定输出顺序。

6. nil map 和 make

复制代码
var m map[string]int

m 是 nil map,特点:

  • 可以读取:m["x"] 返回零值。
  • 不能写入:m["x"] = 1 会 panic。

读取 nil map 时同样可以用 value, ok := m[key],其中 ok 会是 false

所以需要写入时,必须先初始化:

复制代码
m = make(map[string]int)
m["x"] = 1

7. 本节小结

  • map 用于保存键值对,适合按 key 查找 value。
  • 读取不存在的 key 会返回零值,不会报错。
  • 判断 key 是否存在,使用 value, ok := m[key]
  • m[key] = value 可用于新增或更新。
  • delete(m, key) 用于删除 key。
  • map 遍历顺序无序,不应依赖顺序。
  • nil map 可以读不能写;写入前需要 make 初始化。

六、if 条件语句

if 用于根据条件是否成立执行不同分支逻辑,是 Go 中最常用的流程控制语句之一。

1. 基本 if

基本写法:

复制代码
if condition {
	// 条件成立时执行
}

示例:

复制代码
score := 92
if score >= 60 {
	fmt.Println("及格")
}

2. if else

当条件不成立时,可以走 else 分支:

复制代码
age := 16
if age >= 18 {
	fmt.Println("成年")
} else {
	fmt.Println("未成年")
}

3. if else if else

多分支判断使用 else if

复制代码
score := 78
if score >= 90 {
	fmt.Println("A")
} else if score >= 80 {
	fmt.Println("B")
} else if score >= 70 {
	fmt.Println("C")
} else {
	fmt.Println("D/E")
}

建议把区间从高到低写,逻辑更清晰,也能避免条件重叠带来的误判。

4. if 初始化语句

Go 支持在 if 中先执行一条初始化语句,再判断条件:

复制代码
if length := len("Golang"); length > 5 {
	fmt.Println(length)
}

语法结构:

复制代码
if initStatement; condition {
	// ...
}

这种写法常用于"只在本次判断中使用"的临时变量。

5. 条件中的逻辑运算符

常见逻辑运算符:

  • &&:并且,两边都为 true 才为 true。
  • ||:或者,任一边为 true 就为 true。
  • !:取反,true 变 false,false 变 true。

示例:

复制代码
age := 25
hasTicket := true

if age >= 18 && hasTicket {
	fmt.Println("可以入场")
}

6. if 中变量的作用域

if init; condition 中声明的变量,只在这个 if/else 语句块内有效:

复制代码
if n := 10; n%2 == 0 {
	fmt.Println(n)
}

// fmt.Println(n) // 编译错误:undefined: n

这类临时变量不会泄漏到外层作用域,适合减少变量污染。

7. Go 的 if 语法特点

和 C、Java 等语言相比,Go 的 if 有两个常见语法差异:

  • 条件外不需要也不允许写括号:if score > 60 { ... }
  • 左花括号 { 必须和 if 在同一行

错误写法示例(会报语法错误):

复制代码
// if (score > 60) { } // 不推荐这种风格
// if score > 60
// {                    // 左花括号不能单独换行
// }

8. 本节小结

  • if 用于条件判断,if else 用于二选一分支。
  • 多分支使用 if else if else,建议从高到低写条件。
  • if init; condition 可在判断前声明临时变量。
  • 逻辑运算符 &&||! 可组合复杂条件。
  • if 初始化语句里的变量只在当前语句块内有效。
  • Go 的 if 条件不用括号,且 { 必须和 if 同行。

七、switch 分支语句

switch 适合处理"一个值对应多个分支"或"多条件分支"场景。和连续 if else if 相比,可读性通常更好。

1. 基本 switch

基本写法:

复制代码
switch expression {
case value1:
	// ...
case value2:
	// ...
default:
	// ...
}

示例:

复制代码
day := 3
switch day {
case 1:
	fmt.Println("Monday")
case 2:
	fmt.Println("Tuesday")
case 3:
	fmt.Println("Wednesday")
default:
	fmt.Println("Other day")
}

2. 一个 case 匹配多个值

同一个 case 可以写多个匹配值,用逗号分隔:

复制代码
score := 95
switch score / 10 {
case 10, 9:
	fmt.Println("A")
case 8:
	fmt.Println("B")
default:
	fmt.Println("Other")
}

3. 无表达式 switch

switch 后面可以不写表达式,等价于 switch true,适合写区间判断:

复制代码
temp := 28
switch {
case temp >= 35:
	fmt.Println("炎热")
case temp >= 25:
	fmt.Println("温暖")
default:
	fmt.Println("寒冷")
}

这种写法在"按区间判断"的可读性上通常优于长链 if else if

4. switch 初始化语句

if 一样,switch 也支持初始化语句:

复制代码
switch n := len("Golang"); {
case n > 5:
	fmt.Println("长度大于 5")
default:
	fmt.Println("长度不大于 5")
}

这里 n 的作用域仅在当前 switch 语句内。

5. break 与 fallthrough

Go 的 switch 默认在每个 case 结束后自动跳出,不需要手动写 break

如果希望"继续执行下一个 case",可以使用 fallthrough

复制代码
level := 1
switch level {
case 1:
	fmt.Println("level 1")
	fallthrough
case 2:
	fmt.Println("level 2")
default:
	fmt.Println("default")
}

说明:fallthrough 会直接进入下一个 case 的语句块,不会再次判断下一个 case 的条件。

6. switch 与 if 的选择

  • 当判断"一个值对应多个离散分支"时,优先考虑 switch
  • 当条件是复杂布尔表达式且分支不多时,if else 也很合适。
  • 区间判断可以用 switch { ... },可读性通常更好。

7. 典型业务写法

分数分级(按十位分段)

当分级规则是固定区间时,常见写法是先整除再 switch

复制代码
switch score / 10 {
case 10, 9:
	fmt.Println("A")
case 8:
	fmt.Println("B")
case 7:
	fmt.Println("C")
case 6:
	fmt.Println("D")
default:
	fmt.Println("E")
}

这种写法分支短、边界清晰,维护成本较低。

月份天数(分组 case)

一个 case 放多个月份,可以减少重复判断:

复制代码
switch month {
case 1, 3, 5, 7, 8, 10, 12:
	fmt.Println("31 天")
case 4, 6, 9, 11:
	fmt.Println("30 天")
case 2:
	fmt.Println("28 天")
default:
	fmt.Println("输入无效")
}

如果前面已经做了输入校验(例如必须是 1~12),建议在非法输入时直接 return,避免后续逻辑继续执行。

8. 本节小结

  • switch 适合多分支判断,代码更清晰。
  • case 可以一次匹配多个值。
  • switch { ... } 适合区间条件判断。
  • switch 支持初始化语句,变量作用域局限在当前语句内。
  • Go 的 switch 默认不会贯穿到下一个 case
  • 使用 fallthrough 才会继续执行下一个 case 语句块。

八、for 循环

Go 只有一种循环语句:for

通过不同写法,for 可以覆盖其他语言中的 forwhiledo while 的大部分场景。

1. 三段式 for

标准写法:

复制代码
for init; condition; post {
	// 循环体
}

示例:

复制代码
sum := 0
for i := 1; i <= 5; i++ {
	sum += i
}
fmt.Println(sum) // 15

说明:

  • init:循环开始前执行一次。
  • condition:每轮开始前判断,true 才继续。
  • post:每轮结束后执行。

2. 把 for 当作 while

Go 没有单独的 while 关键字,直接省略三段式中的 initpost

复制代码
n := 1
for n < 20 {
	n *= 2
}

3. 无限循环

复制代码
for {
	// ...
}

通常配合 break 在满足条件时退出:

复制代码
for {
	if done {
		break
	}
}

4. break 与 continue

  • break:立即结束当前循环。
  • continue:跳过本轮剩余语句,直接进入下一轮。

示例(只打印奇数):

复制代码
for i := 1; i <= 5; i++ {
	if i%2 == 0 {
		continue
	}
	fmt.Println(i)
}

5. 嵌套循环

循环内部还可以再写循环:

复制代码
for i := 1; i <= 3; i++ {
	for j := 1; j <= 2; j++ {
		fmt.Printf("i=%d, j=%d\n", i, j)
	}
}

常用于二维数据处理、表格输出(如九九乘法表)等场景。

6. for range

for range 常用于遍历切片、数组、字符串、map、channel。

遍历切片:

复制代码
nums := []int{10, 20, 30}
for index, value := range nums {
	fmt.Println(index, value)
}

遍历字符串:

复制代码
text := "Go语言"
for index, ch := range text {
	fmt.Println(index, ch)
}

注意:遍历字符串时,range 返回的是 rune(Unicode 码点),index 是字节下标。

7. 常见边界问题

  • 循环边界写错(<<=)。
  • 忘记更新循环变量,导致死循环。
  • 在嵌套循环中 break 只会退出当前内层循环。

写循环时建议先明确:

  1. 循环起点是什么。
  2. 结束条件是什么。
  3. 每轮如何推进到下一轮。

8. 本节小结

  • Go 只提供 for,但可表达多种循环场景。
  • 三段式 for 适合固定次数循环。
  • 条件式 for 可替代 while
  • for {} 是无限循环,常配合 break
  • continue 用于跳过当前轮次。
  • for range 是遍历容器的常用写法。

九、函数(func)

函数用于封装可复用的逻辑。

把重复代码抽成函数后,代码更清晰、可维护性更高。

1. 函数定义

Go 使用 func 定义函数:

复制代码
func functionName(params) returnType {
	// ...
}

无参无返回值函数示例:

复制代码
func sayHello() {
	fmt.Println("Hello, Golang")
}

调用方式:

复制代码
sayHello()

2. 参数

函数可以接收参数:

复制代码
func printUser(name string, age int) {
	fmt.Printf("name=%s, age=%d\n", name, age)
}

调用时按顺序传值:

复制代码
printUser("Alice", 25)

多个相邻参数类型相同,也可以简写为:

复制代码
func add(a, b int) int {
	return a + b
}

3. 返回值

单返回值:

复制代码
func add(a int, b int) int {
	return a + b
}

多返回值:

复制代码
func divMod(a int, b int) (int, int) {
	return a / b, a % b
}

调用处:

复制代码
q, r := divMod(17, 5)
fmt.Println(q, r) // 3 2

Go 的多返回值在错误处理场景中非常常见,例如:

复制代码
value, err := someFunc()

4. 可变参数

可变参数写法:

复制代码
func sumAll(nums ...int) int {
	total := 0
	for _, n := range nums {
		total += n
	}
	return total
}

调用时可以传任意数量参数:

复制代码
sumAll(1, 2, 3)
sumAll(10, 20, 30, 40)

如果已有切片,也可以展开传入:

复制代码
nums := []int{1, 2, 3}
sumAll(nums...)

5. 函数是一等值

Go 中函数可以赋值给变量,也可以作为参数传递:

复制代码
op := add
fmt.Println(op(3, 4)) // 7

这为后续学习匿名函数、闭包和高阶函数打下基础。

6. 函数拆分建议

写函数时建议遵循:

  1. 一个函数尽量只做一件事。
  2. 函数名体现动作和意图(如 calcTotalprintReport)。
  3. main 主要负责流程组织,不要堆太多细节。

这样在后续调试和重构时会更轻松。

7. 本节小结

  • 使用 func 定义函数。
  • 参数类型写在参数名后面。
  • 函数可以有一个或多个返回值。
  • ... 用于可变参数。
  • 函数可以赋值给变量并像普通值一样传递。
  • 合理拆分函数有助于提升代码可读性和可维护性。

十、值传递和"引用效果"

先给结论:Go 的参数传递是值传递

调用函数时,传入的是实参的副本。

很多同学会觉得 Go 里也有"引用传递",通常是因为切片、map、指针等类型在函数内修改后,外部也能看到变化。

这类现象更准确的说法是:传递了"值的副本",但这个值本身可能指向同一份底层数据。

因此在 Go 语境里,更推荐使用"值传递""指针传参""共享底层数据"这些表述,而不是把它直接称为"引用传递"。

1. 基本类型:典型值传递

复制代码
func changeInt(n int) {
	n = 100
}

调用后外部变量不变,因为 n 是外部变量的拷贝。

2. 指针参数:通过地址间接修改

复制代码
func changeIntByPointer(p *int) {
	*p = 100
}

这里仍是值传递:传入的是"指针值"的副本。

但该指针副本和原指针都指向同一个地址,所以通过 *p 修改会影响外部数据。

3. 切片参数:看起来像"引用"

切片值包含指向底层数组的指针、长度、容量。函数参数接收的是这个切片头部的副本。

复制代码
func changeSliceElement(nums []int) {
	nums[0] = 99
}

修改元素通常会影响外部,因为内外切片头都指向同一底层数组。

但要注意 append

复制代码
func appendInside(nums []int) {
	nums = append(nums, 100)
}

append 可能触发扩容并让函数内切片指向新数组。此时外部切片不一定能看到新增元素。

另外,即使没有触发扩容,函数内 append 后外部切片的 len 也不会自动变化。

如果要在外部看到新增元素,通常需要接收 append 返回的新切片。

4. map 参数:修改可见

map 值内部也包含对底层哈希结构的引用信息。

把 map 传入函数后,修改 key/value 在外部通常可见:

复制代码
func changeMap(m map[string]int) {
	m["Tom"] = 95
}

这不是"按引用传参",而是"值传递 + 值里包含共享底层结构"。

5. struct:值传参与指针传参的差异

值传参:

复制代码
func changeUserByValue(u User) {
	u.Name = "Bob"
}

只改了副本,外部结构体不变。

指针传参:

复制代码
func changeUserByPointer(u *User) {
	u.Name = "Bob"
}

可修改外部原对象。

6. 实战判断规则

判断"函数内修改是否影响外部"时,优先看两件事:

  1. 修改的是"参数副本本身",还是"参数副本指向的底层数据"。
  2. 该类型是否共享底层结构(如切片底层数组、map 底层哈希表、指针指向对象)。

7. 本节小结

  • Go 参数传递语义是值传递。
  • 基本类型值传参,函数内修改通常不影响外部。
  • 指针可通过解引用修改外部对象。
  • 切片/map 常出现"外部可见修改",本质是共享底层数据。
  • append 可能导致切片重新分配,影响是否对外可见。
  • 即使不扩容,append 后也要在外部接收返回的新切片,才能稳定拿到新增元素。

十一、init 函数和 defer 语句

initdefer 都与函数执行时机相关:

  • init 关注"程序启动时的初始化顺序"。
  • defer 关注"函数返回前要做的收尾动作"。

1. init 函数是什么

init 是 Go 的特殊函数,特点:

  1. 没有参数、没有返回值。
  2. 不能被手动调用。
  3. main 执行前自动执行。

示例:

复制代码
func init() {
	fmt.Println("init running")
}

2. 初始化执行顺序

在同一个 package 中,通常可按下面顺序理解:

  1. 包级变量初始化(包括由函数参与的初始化)。
  2. init 函数执行。
  3. main 函数执行。

例如:

复制代码
var appName = initAppName()

func initAppName() string {
	fmt.Println("var init")
	return "GoStudy"
}

func init() {
	fmt.Println("init")
}

func main() {
	fmt.Println("main")
}

3. 多个 init

一个文件里可以有多个 init,同一 package 下也可以分散在多个文件。

它们会在 main 前依次执行。

实践建议:即使语法允许多个 init,也不要把初始化逻辑拆得太碎,避免维护困难。

4. defer 基本语义

defer 用于把一个调用延迟到"当前函数即将返回"时执行。

复制代码
func doWork() {
	defer fmt.Println("cleanup")
	fmt.Println("working")
}

输出顺序是先 working,函数返回前再执行 cleanup

5. defer 的执行顺序(LIFO)

同一个函数里多个 defer,按后进先出执行:

复制代码
defer fmt.Println("1")
defer fmt.Println("2")
defer fmt.Println("3")

最终输出顺序是:3 -> 2 -> 1

6. defer 参数求值时机

defer 后面的函数调用,其参数会在"defer 语句出现时"就完成求值。

复制代码
x := 10
defer fmt.Println(x)
x = 20

最终 defer 打印的通常是 10,不是 20

7. 常见用途

defer 最常见用途是资源释放,确保函数中途 return 时也能收尾:

复制代码
f, err := os.Open("a.txt")
if err != nil {
	return
}
defer f.Close()

这样可以减少"忘记关闭资源"的风险。

实践中,常见推荐写法是:

  1. 资源一旦成功打开,立即写 defer 关闭逻辑。
  2. 不要把关闭逻辑拖到函数末尾手动写。

例如:

复制代码
f, err := os.Open("a.txt")
if err != nil {
	return
}
defer f.Close()

// 后面继续处理文件

这样做有两个直接好处:

  • 打开和关闭逻辑距离很近,可读性更好。
  • 即使中间出现提前 return,关闭逻辑仍然会执行,能降低资源泄露风险。

8. 本节小结

  • initmain 前自动执行,不能手动调用。
  • 初始化顺序可理解为:包级变量 -> init -> main
  • defer 在函数返回前执行,常用于收尾逻辑。
  • 多个 defer 按后进先出执行。
  • defer 参数在语句出现时求值,不是函数结束时才求值。
  • 资源打开成功后,通常应立即 defer 对应的关闭逻辑。

十二、结构体(struct)

结构体用于把多个不同类型的字段组合成一个整体,适合表示"一个对象"的多项属性。

1. 结构体定义

Go 使用 type + struct 定义结构体:

复制代码
type User struct {
	Name string
	Age  int
	City string
}

这里 User 是自定义类型,包含 3 个字段。

2. 结构体初始化

常见初始化方式一:使用字段名初始化,推荐这种写法,可读性更好。

复制代码
u := User{
	Name: "Alice",
	Age:  20,
	City: "Shanghai",
}

常见初始化方式二:先声明,再逐个赋值。

复制代码
var u User
u.Name = "Bob"
u.Age = 25
u.City = "Beijing"

Go 没有 Java / C# 那种语言内建的"构造函数"语法。

如果确实需要统一初始化逻辑,Go 中常见做法是写一个普通函数,例如:

复制代码
func NewUser(name string, age int, city string) User {
	return User{
		Name: name,
		Age:  age,
		City: city,
	}
}

这种 NewXxx 写法在 Go 里很常见,本质上只是普通函数,不是语言关键字级别的构造器。

3. 字段访问

使用点号访问结构体字段:

复制代码
fmt.Println(u.Name)
fmt.Println(u.Age)

结构体字段和普通变量一样,可以读取、修改。

4. 匿名结构体

如果某个结构体只临时使用一次,可以直接写匿名结构体:

复制代码
config := struct {
	Host string
	Port int
}{
	Host: "localhost",
	Port: 8080,
}

匿名结构体适合临时数据组合,不适合长期复用的领域对象。

5. Go 没有内建 getter / setter

Go 不会像某些语言那样自动生成 getName()setName()

如果需要封装访问逻辑,可以自己写方法:

复制代码
func (u User) GetName() string {
	return u.Name
}

func (u *User) SetName(name string) {
	u.Name = name
}

很多场景下,如果字段本身就适合直接访问,Go 代码也常直接使用 u.Name,不强制包一层 getter/setter。

6. 方法与接收者

Go 可以把函数"挂"到某个类型上,这样的函数叫方法。

写法:

复制代码
func (u User) Introduce() string {
	return "Hello, I'm " + u.Name
}

其中 (u User) 这一部分就叫接收者(receiver)。

它表示:这个方法属于 User 类型。

调用时写法和访问字段类似:

复制代码
u := User{Name: "Alice"}
fmt.Println(u.Introduce())
值接收者

如果接收者写成:

复制代码
func (u User) Rename(name string) {
	u.Name = name
}

这表示方法拿到的是结构体副本。

在方法内部修改 u.Name,通常不会影响外部原对象。

可以把它理解成:

  • 方法逻辑更偏"只读"或"基于当前值做计算"。
  • 即使内部改了字段,改到的也是副本。
指针接收者

如果接收者写成:

复制代码
func (u *User) Rename(name string) {
	u.Name = name
}

这表示方法拿到的是结构体地址。

在方法内部修改字段,通常会影响外部原对象。

可以把它理解成:

  • 方法逻辑需要修改原对象状态。
  • 或者结构体比较大,不想每次调用都复制整个结构体。
如何选择

现阶段可以先用一个简单规则判断:

  1. 只读、计算、不改外部对象:优先考虑值接收者。
  2. 需要修改外部对象:使用指针接收者。

从效果上看,它和前面讲过的"结构体值传参 / 结构体指针传参"本质是一回事,只不过这里换成了"挂在类型上的函数"。

7. 结构体指针

结构体也可以取地址:

复制代码
u := User{Name: "Tom", Age: 18, City: "Hangzhou"}
p := &u

通过指针访问字段时,Go 允许直接写:

复制代码
p.Age = 20

虽然 p 是指针,但这里不需要手动写成 (*p).Age,Go 会自动完成解引用。

8. 结构体参数传递

结构体值传参时,会复制整个结构体副本:

复制代码
func changeUser(u User) {
	u.Name = "Bob"
}

调用后外部原结构体通常不变。

如果希望函数内修改影响外部,可以传结构体指针:

复制代码
func changeUserByPointer(u *User) {
	u.Name = "Bob"
}

这也是理解结构体行为时很重要的观察点之一:

  • 值传参:函数里改的是副本,外部原对象不变。
  • 指针传参:函数里可以改到原对象。

9. 结构体比较

如果结构体的所有字段都支持比较,那么整个结构体也可以直接使用 ==!= 比较:

复制代码
u1 := User{Name: "Tom", Age: 18, City: "Hangzhou"}
u2 := User{Name: "Tom", Age: 18, City: "Hangzhou"}
fmt.Println(u1 == u2) // true

如果结构体中包含切片、map、函数等不可比较字段,则该结构体不能直接比较。

10. 使用场景

结构体常用于:

  • 表示一个用户、一条订单、一本书等业务对象。
  • 组织函数参数和返回值。
  • 给后续方法、接口、JSON 编解码等内容打基础。

11. 本节小结

  • struct 用于把多个字段组织成一个整体。
  • 推荐优先使用带字段名的初始化方式。
  • Go 没有语言内建构造函数,常用 NewXxx 普通函数封装初始化。
  • 使用点号访问和修改字段。
  • Go 没有强制的 getter/setter 语法,需要时可自己写方法。
  • 方法通过接收者绑定到具体类型上。
  • 值接收者通常操作副本,指针接收者通常可以修改原对象。
  • 匿名结构体适合临时数据。
  • 结构体值传参会复制副本,指针传参可修改原对象。
  • 字段都可比较时,结构体也可直接比较。

十三、结构体嵌入、结构体指针与 tag

这一节有 3 个常见但容易混淆的点:

  1. Go 没有传统面向对象里的"类继承"。
  2. Go 可以通过结构体嵌入(embedding)实现字段和方法复用。
  3. 结构体 tag 是附加在字段上的元数据,常用于 JSON、数据库映射、校验等场景。

1. Go 没有传统继承

很多语言里会写:

复制代码
Child extends Parent

Go 没有这样的继承语法。

在 Go 中,更常用结构体嵌入来复用字段和方法。

2. 结构体嵌入(embedding)

示例:

复制代码
type Person struct {
	Name string
	Age  int
}

type Employee struct {
	Person
	Company string
}

这里 Employee 嵌入了 Person

这表示 Employee 内部包含一个 Person 字段,只是这个字段使用了简写形式。

初始化时仍然可以显式写出嵌入字段:

复制代码
e := Employee{
	Person: Person{
		Name: "Alice",
		Age:  28,
	},
	Company: "OpenAI",
}

3. 字段提升

嵌入后,可以直接访问内部结构体的字段:

复制代码
fmt.Println(e.Name)
fmt.Println(e.Age)
fmt.Println(e.Company)

这里之所以可以直接写 e.Name,是因为嵌入字段会发生字段提升(promoted fields)。

本质上它仍然来自 e.Person.Name,只是 Go 允许省略中间层。

需要注意:字段提升不等于真正的继承。

Employee 并没有变成"就是一个 Person",它只是内部组合了一个 Person 字段,并把其中一部分访问路径简化了。

4. 结构体指针

结构体取地址后,可以通过指针直接访问和修改字段:

复制代码
p := &Person{Name: "Tom", Age: 18}
p.Age = 19

这里 p.Age = 19 是语法糖,本质等价于:

复制代码
(*p).Age = 19

5. 什么是结构体 tag

结构体字段后面可以写反引号包裹的标签:

复制代码
type Product struct {
	Name  string  `json:"name"`
	Price float64 `json:"price"`
}

这些 tag 不会直接改变 Go 语言本身的字段行为,但很多库会读取这些 tag 做额外处理。

6. tag 的常见用途

最常见的是 JSON 编解码:

复制代码
type Product struct {
	Name  string  `json:"name"`
	Price float64 `json:"price"`
}

当转成 JSON 时,字段名可以变成 tag 指定的名字,而不是默认的 Go 字段名。

复制代码
data, _ := json.Marshal(Product{Name: "Keyboard", Price: 199.9})
fmt.Println(string(data))

输出通常类似:

复制代码
{"name":"Keyboard","price":199.9}

如果 tag 写在嵌入字段上,例如:

复制代码
type Student struct {
	Person `json:"person"`
	School string `json:"school"`
}

那么序列化时通常会得到嵌套结构:

复制代码
{"person":{"name":"李华","age":20},"school":"..."}

也就是说,这里的 json:"person" 不是把 NameAge 直接改名,而是把整个嵌入字段作为一个名为 person 的子对象输出。

7. 读取 tag

tag 本质上是字段元数据,可以通过 reflect 读取:

复制代码
t := reflect.TypeOf(Product{})
field, _ := t.FieldByName("Name")
fmt.Println(field.Tag.Get("json"))

这会输出:

复制代码
name

8. 本节小结

  • Go 没有传统继承,常用结构体嵌入做复用。
  • 结构体嵌入后,内部字段会发生字段提升,可直接访问。
  • 结构体指针可用 p.Field 直接访问字段,Go 会自动解引用。
  • 结构体 tag 是字段的元数据,常用于 JSON 等库。
  • json:"name" 这类 tag 会影响序列化时的字段名。
  • 嵌入字段上的 tag 可能让序列化结果变成嵌套对象。

十四、自定义类型与类型别名

Go 里 type 有两种常见写法:

  1. 定义自定义类型
  2. 定义类型别名

它们长得很像,但语义完全不同。

1. 自定义类型

写法:

复制代码
type MyInt int

这表示:基于 int 定义了一个新类型 MyInt

虽然它底层还是 int,但从类型系统角度看,MyIntint 已经不是同一个类型。

例如:

复制代码
var a MyInt = 10
var b int = 20

// a + b // 编译错误
total := int(a) + b

这里必须显式转换。

2. 类型别名

写法:

复制代码
type UserID = int

这表示:UserID 只是 int 的另一个名字,不会创建新类型。

例如:

复制代码
var id UserID = 1001
var raw int = id

这里通常可以直接赋值,因为 UserID 本质上就是 int

3. 两者的根本区别

可以用一句话记:

复制代码
type T U   -> 新类型
type T = U -> 旧类型的别名

自定义类型强调"类型隔离"和"业务语义"。

类型别名强调"换个名字,但还是同一个类型"。

4. 为什么要自定义类型

自定义类型常用于提升语义清晰度:

复制代码
type Celsius float64
type OrderID int
type PhoneNumber string

这些类型虽然底层分别是 float64intstring,但读代码时能更清楚地表达业务含义。

除了语义更清晰,自定义类型还有一个很实际的价值:

它能在编译期减少"本来都是 int / string,结果被随手混用"的问题。

例如 OrderIDUserID 底层都可能是 int,但如果它们是两个不同的自定义类型,编译器就不会允许随意直接混用。

5. 为什么要类型别名

类型别名常见用途:

  • 给已有类型换一个更贴近业务的名字。
  • 兼容旧代码或重构过程中平滑迁移类型名。

例如:

复制代码
type Username = string

它不会引入新的类型转换成本,但能提升命名可读性。

6. 使用时的判断思路

如果想要:

  • 一个"新的独立类型",用来自定义语义、限制混用:用自定义类型。
  • 只是"换个名字",不想改变原类型行为:用类型别名。

可以再记一条更实战的经验:

  • 面向业务约束时,优先考虑自定义类型。
  • 面向重命名和兼容时,优先考虑类型别名。

7. 本节小结

  • type T U 会定义一个新类型。
  • type T = U 只是定义别名,不会创建新类型。
  • 自定义类型和原始类型通常不能直接混用,需要显式转换。
  • 类型别名和原始类型通常可以直接配合使用。
  • 自定义类型常用于表达更清晰的业务语义。
  • 自定义类型还可以在编译期减少业务上不该混用的类型误用。

十五、接口(interface)

接口用于定义一组行为规范。

谁实现了这些方法,谁就可以被当作这个接口类型来使用。

1. 接口定义

Go 使用 interface 定义接口:

复制代码
type Speaker interface {
	Speak() string
}

这个接口要求:实现者必须提供一个 Speak() string 方法。

2. 隐式实现

Go 的接口实现是隐式的。

不需要写 implements 之类的关键字,只要某个类型实现了接口要求的方法,它就自动实现了这个接口。

例如:

复制代码
type Dog struct {
	Name string
}

func (d Dog) Speak() string {
	return d.Name + " says: wang"
}

因为 DogSpeak() string 方法,所以它实现了 Speaker 接口。

如果一个接口里定义了多个方法,那么某个类型必须把这些方法全部实现,才算实现这个接口。

例如:

复制代码
type Runner interface {
	Run() string
	Introduce() string
}

此时如果某个类型只实现了 Run(),没有实现 Introduce(),它就不能赋值给 Runner 类型变量。

3. 接口变量

接口也可以作为变量类型使用:

复制代码
var s Speaker
s = Dog{Name: "Buddy"}
fmt.Println(s.Speak())

此时 s 的静态类型是 Speaker,但它内部保存的是具体值 Dog

4. 接口作为参数

接口常用于函数参数,表示"只关心行为,不关心具体类型":

复制代码
func printSpeak(s Speaker) {
	fmt.Println(s.Speak())
}

这样只要实现了 Speaker 的类型,都可以传入这个函数。

5. any 与空接口

Go 里的 anyinterface{} 的别名,表示"可以接收任意类型的值"。

复制代码
var v any
v = 123
v = "hello"
v = Dog{Name: "Tom"}

因为所有类型都实现了空接口,所以 any 可以装任意值。

6. 类型断言

当一个接口变量内部装的是具体类型值时,可以通过类型断言取回具体类型:

复制代码
var v any = "golang"

text, ok := v.(string)
fmt.Println(text, ok) // golang true

如果断言失败:

复制代码
number, ok := v.(int)
fmt.Println(number, ok) // 0 false

更稳妥的写法通常是 value, ok := v.(T),避免断言失败直接 panic。

7. 接口的价值

接口最大的价值在于解耦:

  • 调用方只依赖行为,不依赖具体实现。
  • 不同类型只要满足同一接口,就可以被统一处理。
  • 后续更容易扩展和替换实现。

实践中,Go 通常更鼓励"小接口":

  • 接口方法越少,实现成本越低。
  • 越容易让不同类型复用同一接口。
  • 越能减少"为了实现接口而被迫补很多无关方法"的问题。

这也是为什么很多 Go 代码里的接口都很小,甚至只有 1 个方法。

8. 本节小结

  • 接口定义的是行为,不是数据。
  • Go 接口实现是隐式的,不需要显式声明。
  • 接口变量可以保存实现该接口的具体值。
  • any 可以接收任意类型。
  • 类型断言用于从接口值中取回具体类型。
  • 接口中的方法通常需要全部实现,类型才能被当成该接口使用。
  • Go 更鼓励小接口,而不是方法很多的大接口。

十六、协程(goroutine)

goroutine 是 Go 用来执行并发任务的轻量级执行单元。

可以把它先理解为:一段可以和当前代码"并发执行"的函数调用。

1. 启动 goroutine

使用 go 关键字启动一个 goroutine:

复制代码
go someFunc()

示例:

复制代码
go func() {
	fmt.Println("goroutine: hello")
}()

这表示把这个函数放到新的 goroutine 中执行,主 goroutine 不会等待它自动结束。

2. main 结束会带走其他 goroutine

Go 程序入口是 main 函数。

main 结束时,整个进程就会退出,即使其他 goroutine 还没执行完。

这也是为什么刚开始写 goroutine 时,经常会看到"明明开了 goroutine,但没有输出"的现象。

3. 用 Sleep 暂时等待

入门阶段,最简单的等待方式是:

复制代码
time.Sleep(100 * time.Millisecond)

这样主 goroutine 会暂停一小段时间,给其他 goroutine 执行机会。

Sleep 只是"猜一个等待时间",并不精确,也不可靠。

4. goroutine 传参

goroutine 启动时可以直接传参数:

复制代码
go printTask("task A")
go printTask("task B")

这和普通函数调用参数传递方式一致,只是执行位置变成了新的 goroutine。

5. 使用 WaitGroup

更常见、也更可靠的等待方式是 sync.WaitGroup

典型用法:

复制代码
var wg sync.WaitGroup
wg.Add(2)

go func() {
	defer wg.Done()
	fmt.Println("worker 1")
}()

go func() {
	defer wg.Done()
	fmt.Println("worker 2")
}()

wg.Wait()

可以这样理解:

  • Add(n):告诉 WaitGroup 还要等几个任务。
  • Done():某个任务完成,数量减 1。
  • Wait():阻塞等待,直到数量减到 0。

6. 输出顺序不一定固定

多个 goroutine 并发执行时,输出顺序通常不稳定:

复制代码
go printTask("task A")
go printTask("task B")

不能假设一定先看到 task A 再看到 task B

调度顺序由运行时决定。

即使代码顺序固定,实际输出顺序也可能每次运行都不同。

这不是程序写错了,而是并发调度本来就不保证固定先后。

7. Sleep 和 WaitGroup 的区别

  • time.Sleep:按时间猜测等待多久,简单但不可靠。
  • WaitGroup:按任务完成状态等待,更适合正式并发代码。

如果任务执行时间变长,Sleep 可能等得不够;如果任务很快结束,Sleep 又可能白白多等。

这也是为什么在正式代码里,WaitGroup 通常比 Sleep 更值得优先使用:

  • Sleep 等的是时间,不是任务状态。
  • WaitGroup 等的是"任务真的完成了没有"。
  • 任务执行时间不稳定时,WaitGroup 更稳妥。

8. 本节小结

  • go 关键字用于启动 goroutine。
  • goroutine 会和当前代码并发执行。
  • main 结束时,未完成的 goroutine 也会一起结束。
  • time.Sleep 适合临时演示,不适合作为正式等待方案。
  • sync.WaitGroup 是等待多个 goroutine 完成的常用工具。
  • 并发执行时,输出顺序通常不固定,不应依赖打印顺序推断严格时序。

十七、频道(channel)

channel 是 Go 里专门用于 goroutine 之间通信的机制。

如果 goroutine 是"并发执行的任务",那么 channel 可以理解成"任务之间传数据的通道"。

它最常见的用途有两个:

  • 在不同 goroutine 之间传递数据。
  • 在收发过程中顺便完成同步。

1. 创建 channel

channel 使用 make 创建:

复制代码
ch := make(chan int)

这表示创建了一个可以传 int 的 channel。

2. 发送和接收

channel 的发送和接收都使用 <-

复制代码
ch <- 10      // 发送
value := <-ch // 接收

可以先把它简单记成:

  • ch <- 数据:往 channel 里放数据。
  • <-ch:从 channel 里取数据。

这里的 <- 不是指针,也不是取地址,而是 channel 的专用收发符号。

3. 为什么 channel 常和 goroutine 一起出现

单看语法,channel 不一定非要和 goroutine 搭配。

但在实际使用里,它最常见的场景就是"一个 goroutine 发,另一个 goroutine 收"。

例如:

复制代码
ch := make(chan string)

go func() {
	ch <- "hello"
}()

msg := <-ch
fmt.Println(msg)

这里就形成了一次很典型的并发通信:

  • 子 goroutine 负责发送。
  • 主 goroutine 负责接收。

4. 无缓冲 channel 的阻塞特性

默认创建出来的 channel 是无缓冲 channel:

复制代码
ch := make(chan int)

无缓冲 channel 有一个非常重要的特点:收发双方要"对上"。

可以先这样理解:

  • 发送时,如果暂时没人接收,发送方会阻塞。
  • 接收时,如果暂时没人发送,接收方会阻塞。

这也是为什么很多初学者第一次写 channel 时,会遇到"程序卡住"或者死锁报错。

本质上不是语法错,而是收发两边没有配对成功。

5. 有缓冲 channel

有缓冲 channel 可以在创建时指定容量:

复制代码
ch := make(chan int, 3)

这表示这个 channel 最多可以先缓存 3 个值。

例如:

复制代码
ch := make(chan string, 2)
ch <- "A"
ch <- "B"

在缓冲区没满之前,发送方可以先把值放进去,不必立刻等接收方来取。

但要注意:

  • 缓冲区满了,再发送仍然会阻塞。
  • 缓冲区空了,再接收仍然会阻塞。

所以有缓冲并不代表"永不阻塞",只是多了一段可缓存空间。

6. close 和 range

如果已经确定后面不会再往某个 channel 发送数据,可以关闭它:

复制代码
close(ch)

关闭后,常见写法是配合 for range 读取剩余数据:

复制代码
for value := range ch {
	fmt.Println(value)
}

这种写法很适合"发送多个值,接收方逐个处理"的场景。

需要注意两点:

  • close 表示"不再发送新数据",不是"立刻清空已有数据"。
  • 已经放进 channel 的数据,接收方仍然可以继续取出来。

for range 会持续接收,直到满足两个条件才结束:

  • 这个 channel 已经被关闭。
  • channel 里原本剩余的数据也已经全部取完。

也就是说,close(ch) 之后并不是立刻结束遍历,而是会先把已经发送进去的值继续读完。

如果 channel 一直不关闭,而接收方又使用 for range 等待后续数据,那么循环就可能一直阻塞下去。

7. 单向 channel

有时为了让函数职责更明确,可以限制 channel 的使用方向。

例如:

复制代码
func send(ch chan<- string) {
	ch <- "hello"
}

func receive(ch <-chan string) {
	fmt.Println(<-ch)
}

这里:

  • chan<- string 表示只发送。
  • <-chan string 表示只接收。

这样做的好处是函数边界更清晰,也更不容易误用。

需要特别注意一点:变量本身如果是普通的双向 channel,例如:

复制代码
ch := make(chan string)

那么它在传参时,可以自动匹配成更窄的单向 channel:

复制代码
send(ch)
receive(ch)

也就是说:

  • 双向 channel 可以传给 chan<- T 参数。
  • 双向 channel 也可以传给 <-chan T 参数。

可以把它理解成"传参时自动收窄为单向能力"。

但这种收窄只发生在使用位置上,ch 这个变量本身依然还是双向 channel。

反过来则不行:

  • 只发送 channel 不能当成可接收 channel 使用。
  • 只接收 channel 也不能当成可发送 channel 使用。

这也是为什么很多示例代码里会先定义普通 channel,再在函数参数处限制方向。

这样既保留了主流程里的灵活性,也能让具体函数职责更明确。

8. close 的边界规则

close 虽然语法很简单,但它有几个很重要的边界规则:

  • 关闭的是 channel 本身,不是某个具体值。
  • 关闭后不能再向这个 channel 发送数据。
  • 对已经关闭的 channel 再发送数据,会直接 panic。

所以在实际代码里,close 一般应该由发送方负责,或者由"最后一个发送者"负责。

接收方通常只负责读取,不负责随意关闭 channel。

这背后的思路很直接:

谁最清楚"后面不会再发数据了",谁就更适合执行 close

9. channel 和 WaitGroup 的分工不同

学习到这里,容易把 channelWaitGroup 混在一起。

它们都和并发有关,但职责并不一样。

  • WaitGroup 更偏向"等任务结束"。
  • channel 更偏向"传数据 / 做同步协作"。

可以粗略理解成:

  • 只想等几个 goroutine 全部跑完,用 WaitGroup 更直接。
  • 想让 goroutine 之间交换数据、通知结果,用 channel 更合适。

实际代码里,两者也经常一起出现,并不是二选一关系。

10. 常见风险:死锁

如果 channel 的发送和接收关系没配好,就可能出现死锁。

例如下面这种情况:

复制代码
ch := make(chan int)
ch <- 1

如果这时没有其他 goroutine 正在接收,这里就会阻塞住。

同理:

复制代码
ch := make(chan int)
value := <-ch
fmt.Println(value)

如果没有发送方,这里也会一直等。

所以学习 channel 时,脑子里最好始终问一句:

"这个值是谁发的?谁来收?两边什么时候对上?"

11. 本节小结

  • channel 是 Go 中用于 goroutine 之间通信的重要机制。
  • 使用 make(chan T) 创建无缓冲 channel。
  • 使用 make(chan T, n) 创建有缓冲 channel。
  • ch <- value 表示发送,<-ch 表示接收。
  • 无缓冲 channel 的发送和接收通常需要同步配对。
  • close 常和 for range 一起使用,用来处理一组发送完成的数据。
  • for range 会在 channel 关闭且剩余数据读完后结束。
  • 单向 channel 可以限制函数只发送或只接收。
  • 双向 channel 在传参时可以自动匹配成单向 channel,但反过来不行。
  • close 后不能继续发送数据,通常应由发送方负责关闭。
  • WaitGroup 更偏向等待任务结束,channel 更偏向传数据和协作同步。
  • 如果收发关系没配好,程序可能出现阻塞甚至死锁。

十八、select 与协程超时处理

学完 channel 之后,下一步通常就是 select

因为一旦程序里不只一个 channel,或者想给等待过程加上超时控制,select 就会变得很常用。

可以先把它理解成:

select 是"专门给 channel 用的分支选择语句"。

1. select 是干什么的

select 用来同时等待多个 channel 操作。

哪个 channel 的操作先准备好,就执行对应的 case

基本形式:

复制代码
select {
case v := <-ch1:
	fmt.Println(v)
case ch2 <- 10:
	fmt.Println("sent")
}

这里要注意,select 里的 case 不是普通布尔条件,

而是 channel 的发送或接收操作。

2. select 和 switch 的区别

虽然 selectswitch 看起来有点像,但它们判断的东西完全不同:

  • switch 判断的是值或条件。
  • select 判断的是哪个 channel 操作先就绪。

所以 select 不是通用分支语句,而是并发通信场景下的专用工具。

3. 最基础的 select 接收

例如:

复制代码
select {
case msg := <-ch:
	fmt.Println(msg)
}

如果这个 ch 还没有值可读,那么这段代码会阻塞等待。

所以只写一个 caseselect,在效果上很像直接写:

复制代码
msg := <-ch
fmt.Println(msg)

区别在于:select 的结构更容易扩展到多个 channel 或超时处理场景。

4. 同时监听多个 channel

select 最典型的价值,就是同时监听多个 channel:

复制代码
select {
case msg1 := <-ch1:
	fmt.Println("from ch1:", msg1)
case msg2 := <-ch2:
	fmt.Println("from ch2:", msg2)
}

谁先准备好,就先进入谁的分支。

可以先这样理解:

  • 不是按代码写的先后顺序选。
  • 也不是固定优先选第一个 case
  • 而是谁先就绪,就执行谁。

如果多个 case 在同一时刻都已经可以执行,运行时会从中选一个执行。

因此实际结果不应依赖某个固定分支顺序。

还要补一个很关键的执行特点:

一次 select 执行下来,通常只会进入一个分支,不会把所有 ready 的 case 全部执行一遍。

也就是说:

  • 单次 select = 从当前可执行的分支里选一个执行。
  • 如果后面还想继续处理更多消息,通常需要下一轮新的 select

这也是为什么在"要连续接收多个结果"的场景里,select 经常会和 for 搭配使用。

例如:

复制代码
for i := 0; i < 2; i++ {
	select {
	case msg1 := <-ch1:
		fmt.Println(msg1)
	case msg2 := <-ch2:
		fmt.Println(msg2)
	}
}

这里不是说一次 select 会把两个 channel 都处理完,

而是"循环两次,每次执行一个分支",这样才能把两个消息都收完。

5. default 分支

select 还可以写 default

复制代码
select {
case msg := <-ch:
	fmt.Println(msg)
default:
	fmt.Println("no value ready")
}

它的含义是:

  • 如果有可执行的 case,就执行对应分支。
  • 如果当前没有任何 case 能立刻执行,就直接走 default

这意味着:

加了 default 之后,这个 select 通常就不会阻塞等待,而是立刻给出一个结果。

所以 default 经常被用来做"非阻塞检查"。

6. 为什么超时处理常和 select 一起用

并发代码里一个很常见的问题是:

"我在等某个 goroutine 的结果,但它迟迟不返回怎么办?"

这时候如果只写:

复制代码
result := <-resultCh

那么只要结果一直不来,这里就会一直等下去。

select 可以让"任务结果"和"超时信号"同时参与竞争。

谁先到,就处理谁。

7. 使用 time.After 做超时控制

Go 里最常见的超时写法之一是:

复制代码
select {
case result := <-resultCh:
	fmt.Println("result:", result)
case <-time.After(1 * time.Second):
	fmt.Println("timeout")
}

可以把 time.After(d) 先简单理解成:

  • 过了指定时间后,会有一个"时间到了"的信号可接收。

因此这个 select 的意思就是:

  • 如果结果先回来,就处理结果。
  • 如果 1 秒先到了,就走超时分支。

这也是为什么很多 Go 并发代码里,超时处理看起来都是 select + time.After 的组合。

8. 超时控制的是等待方,不一定是任务本身

这是一个非常关键、也非常容易误解的点。

当超时分支先执行时,通常只表示:

"当前这段等待逻辑不再继续等了"。

它不等于:

  • 原来的 goroutine 自动被杀掉了。
  • 原来的任务自动停止执行了。

也就是说,很多情况下超时控制的是"等待方",不是"任务本身"。

例如:

复制代码
select {
case result := <-resultCh:
	fmt.Println(result)
case <-time.After(1 * time.Second):
	fmt.Println("timeout")
}

如果此时慢任务 goroutine 还在后台执行,它很可能仍然会继续跑完。

只是当前这段代码已经不想继续等它了。

如果这个慢任务后面还要往无缓冲 channel 发送结果,而此时已经没有接收方了,

那么它甚至可能继续阻塞在发送语句上。

例如:

复制代码
select {
case msg := <-ch:
	fmt.Println(msg)
case <-time.After(2 * time.Second):
	fmt.Println("timeout")
}

如果超时先发生,接收方结束;

而发送方过一会儿才执行:

复制代码
ch <- "slow result"

这时因为已经没人再接收,它就可能卡在这里。

这点很重要,因为它直接关系到后面更深入的并发控制问题:

超时了以后,任务要不要取消?怎么通知它停止?资源怎么回收?

9. select 只是选择"当前可执行"的分支

select 的核心不是"预测未来",而是看"现在谁能执行"。

所以可以这样记:

  • 有 ready 的 channel 操作,就选其中一个执行。
  • 没有 ready 的操作,又没有 default,就阻塞等待。
  • 没有 ready 的操作,但有 default,就立即执行 default

这个理解方式比死记语法更重要。

10. select 和 channel 的关系

可以把它们的关系看成这样:

  • channel 负责通信。
  • select 负责在多个通信机会里做选择。

如果只有一个 channel,很多时候直接收发就够了。

如果有多个 channel,或者还想监听超时、退出信号,那么 select 就会很自然地出现。

11. 超时现象里为什么有时会"刚好收到结果"

如果 select 被放进循环里,而且每一轮都重新写了 time.After(...)

那么要特别注意:超时时间是"从这一轮 select 开始时重新计算"的。

例如第一轮先等到了一个结果,第二轮才开始,那么第二轮里的 time.After(3 * time.Second)

并不是从整个函数一开始就算 3 秒,而是从第二轮开始再重新算 3 秒。

这就可能出现一种现象:

  • 慢任务结果到达的时间点
  • 当前这一轮超时到达的时间点

两者非常接近,甚至几乎同时 ready。

一旦发生这种情况,select 选中哪个分支就不应该被当成固定结果。

所以看到"有时超时,有时刚好拿到结果",很多时候不是程序随机坏了,

而是时间点刚好撞在一起了。

12. 本节小结

  • select 是 Go 中专门配合 channel 使用的分支语句。
  • selectcase 写的是 channel 的发送或接收操作,不是普通条件判断。
  • 同时监听多个 channel 时,哪个先就绪,就先执行哪个分支。
  • 如果多个 case 同时就绪,执行哪个分支通常不应被写死依赖。
  • 单次 select 通常只执行一个分支;要连续处理多个结果,常常需要配合 for
  • default 可以让 select 变成一次非阻塞检查。
  • time.After 常和 select 一起使用,用来实现超时等待。
  • 超时分支先执行,通常只表示"不再继续等结果",不代表原 goroutine 一定自动停止。
  • 如果超时后已经没有接收方,慢发送方后续还可能阻塞在发送语句上。
  • channel 负责通信,select 负责在多个通信机会之间做选择。

十九、线程安全与 sync.Map

学到 goroutine、channel、select 之后,很自然就会碰到一个问题:

多个 goroutine 同时操作同一份数据时,这段代码到底安不安全?

很多时候大家会说"线程安全"。

放到 Go 这里,更贴切的说法通常是"并发安全":

也就是多个 goroutine 同时访问共享数据时,结果是否可靠、程序是否稳定。

1. 为什么会有线程安全问题

只要一份数据会被多个 goroutine 共同访问,就可能出现并发安全问题。

例如:

  • 两个 goroutine 同时写同一个 map。
  • 一个 goroutine 在写,另一个 goroutine 在读。
  • 多个 goroutine 同时修改同一个计数器。

如果没有额外保护,就可能出现:

  • 数据结果不正确。
  • 数据状态互相覆盖。
  • 程序直接报错甚至 panic。

所以这里的重点不是"map 这种类型奇怪",

而是"共享数据在并发访问时需要保护"。

2. 普通 map 默认不是并发安全的

Go 里的普通 map 在默认情况下,不应该被多个 goroutine 直接同时读写。

例如下面这种思路就是危险的:

复制代码
m := make(map[string]int)

go func() {
	m["go"]++
}()

go func() {
	m["go"]++
}()

这类写法不是"有点风险",而是从设计上就不应该依赖它。

尤其是并发写 map,实际运行时很可能直接报错。

所以看到"多个 goroutine 共用同一个 map"时,第一反应应该是:

"这里有没有加保护?"

3. 用 sync.Mutex 保护共享数据

最基础、也最常见的保护方式,就是互斥锁 sync.Mutex

可以先把它理解成:

同一时刻只允许一个 goroutine 进入这段临界区代码。

典型写法:

复制代码
type SafeCounter struct {
	mu   sync.Mutex
	data map[string]int
}

func (c *SafeCounter) Add(key string) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.data[key]++
}

这里的意思就是:

  • 进入修改逻辑前先上锁。
  • 修改结束后解锁。
  • 其他 goroutine 如果也想同时进来,就得先等。

这是一种非常基础但非常实用的并发保护方式。

4. Mutex 保护的不是"变量名",而是那段共享访问过程

初学时容易把锁想成"给某个变量贴标签"。

更准确一点的理解是:

锁保护的是"访问共享数据的这段过程"。

也就是说,真正重要的不是"我定义了一个锁",

而是"所有读写共享数据的路径,是否都走了同一把锁"。

如果只有一部分代码加了锁,另一部分代码还在绕过锁直接操作,

那这份数据依然不算安全。

还要补一个很关键的点:

如果业务逻辑是"先读,再判断,再修改",那么锁不能只分别包住"读方法"和"写方法",

而要看这整个过程是不是一个原子整体。

例如下面这种思路:

复制代码
if stock > 0 {
	stock--
}

如果"读取库存"和"扣减库存"分散在两个独立加锁的方法里,

那多个 goroutine 仍然可能同时读到同一个旧值,然后都通过判断。

这类问题和 map 会不会崩溃不是一回事,它属于更上层的业务竞态问题。

所以锁不仅要"有",还要看它包住的范围够不够大。

5. sync.RWMutex 是什么

如果某份共享数据"读很多,写较少",Go 还提供了 sync.RWMutex

它可以简单理解成两种锁:

  • 读锁:RLock() / RUnlock()
  • 写锁:Lock() / Unlock()

常见思路是:

  • 读操作走读锁。
  • 写操作走写锁。

示例:

复制代码
func (c *SafeConfig) Get(key string) string {
	c.mu.RLock()
	defer c.mu.RUnlock()
	return c.data[key]
}

func (c *SafeConfig) Set(key, value string) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.data[key] = value
}

可以先这样理解它和 Mutex 的区别:

  • Mutex 不区分读和写,进来就互斥。
  • RWMutex 把"读"和"写"区分开了。

入门阶段只要先记住这个原则就够了:

只读用 RLock,修改用 Lock

但这里也别把它想成"写锁永远绝对优先"。

更稳妥的理解是:

  • 读锁和读锁可以并发共存。
  • 写锁和任何锁都不能共存。
  • 如果已经有写者在等待,后续新的读者通常不会再一直插队。

所以当"读和写撞在一起时,往往先完成写再继续读"这种现象出现时,

这是可以观察到的现象;但它更准确反映的是读写互斥和调度时机,

而不是一句简单的"写锁绝对优先"。

6. 为什么带锁结构体的方法通常用指针接收者

只要结构体里带了 sync.Mutexsync.RWMutex,方法通常都应该优先使用指针接收者。

例如:

复制代码
func (c *SafeConfig) Get(key string) string {
	c.mu.RLock()
	defer c.mu.RUnlock()
	return c.data[key]
}

原因不只是"方法里可能要改数据",

更重要的是:不能随便把锁复制一份。

如果把这类方法写成值接收者:

复制代码
func (c SafeConfig) Get(key string) string

那么调用时就可能复制整个结构体。

这意味着:

  • 锁也被复制了一份。
  • map 头部也被复制了一份。

而 map 底层数据通常还是共享的,

结果就可能变成"多个副本锁着不同的锁,却访问同一份底层数据"。

所以对于带锁结构体,可以先记一条很实用的经验:

  • 结构体里有 Mutex / RWMutex,方法基本都用指针接收者。

7. 为什么示例里经常把 map 和锁封装进结构体

会发现很多 Go 代码不会把 map 和锁散着放,

而是经常写成:

复制代码
type SafeCounter struct {
	mu   sync.Mutex
	data map[string]int
}

这样做有两个好处:

  • 锁和它要保护的数据放在一起,关系更清楚。
  • 外部更容易只通过方法访问,减少绕过锁直接操作的机会。

这也是为什么并发安全的数据结构,常常会通过结构体 + 方法的方式来组织。

但这里也要注意 Go 的一个特点:

可见性边界是"包",不是"对象本身"。

所以即使字段是小写,只要还在同一个包里,理论上还是能直接访问。

也就是说,并发安全并不是靠"小写字段名自动保护"出来的,

而是靠大家统一走加锁方法、不要绕过封装。

8. sync.Map 的基本定位

除了"普通 map + 锁"之外,Go 标准库还提供了 sync.Map

sync.Map 是一个已经内置并发安全能力的 map 结构。

它的目的不是替代所有普通 map,而是为特定并发场景提供更方便的写法。

最常见的基本操作有:

  • Store(key, value):存值
  • Load(key):取值
  • Delete(key):删值
  • Range(func(key, value any) bool):遍历

例如:

复制代码
var m sync.Map
m.Store("name", "gopher")

value, ok := m.Load("name")
fmt.Println(value, ok)

这里的 ok 用法和普通 map 取值时的"是否存在"思路比较接近。

9. LoadOrStore 是什么意思

sync.Map 里一个很常见的方法是 LoadOrStore

复制代码
actual, loaded := m.LoadOrStore("topic", "sync.Map")

它的意思可以理解成:

  • 如果 key 已经存在,就直接把已有值取出来。
  • 如果 key 不存在,就把新值存进去。

返回值里的 loaded 很关键:

  • loaded == true:说明原来就有值,这次没有新建。
  • loaded == false:说明原来没有值,这次是新存进去的。

这个方法很适合"如果没有就初始化,有的话就复用"的场景。

10. sync.Map 的 Range 和普通 map 的 for range 不一样

普通 map 的遍历通常是:

复制代码
for k, v := range m {
	fmt.Println(k, v)
}

sync.Map 不能直接这样写,它要用回调形式的 Range

复制代码
m.Range(func(key, value any) bool {
	fmt.Println(key, value)
	return true
})

这里传进去的是一个匿名函数,同时它也作为回调函数被 Range 调用。

可以这样理解:

  • 匿名函数,强调的是"它没有函数名"。
  • 回调函数,强调的是"它作为参数传进去,后面由 Range 在内部遍历时反过来调用"。

也就是说,sync.Map 不是要求手写循环体,

而是它内部负责遍历,只需要提供"每遍历到一项时要做什么"。

这里返回的 bool 可以控制是否继续遍历:

  • 返回 true:继续遍历
  • 返回 false:停止遍历

这也是为什么 sync.Map 的使用手感和普通 map 不完全一样。

11. 为什么会看到 concurrent map writes

当多个 goroutine 同时直接去写普通 map 时,

Go 运行时很可能直接报:

复制代码
fatal error: concurrent map writes

这说明已经正确触发了"普通 map 不能直接并发写"这个问题。

它验证到的是 map 本身的并发不安全。

而如果原本还想进一步观察"先判断再修改导致多个 goroutine 同时越界"这类现象,

那么要知道:很多时候程序会先被 map 自身的并发写错误打断,

还来不及稳定展示更上层的业务竞态。

所以这两类问题最好分开理解:

  • concurrent map writes:map 本身并发访问就已经不安全。
  • 判断后再修改被多个 goroutine 同时穿透:属于业务逻辑层面的竞态。

12. 是不是以后都该直接用 sync.Map

不是。

这是一个很容易走偏的点:

sync.Map 是并发安全的,不代表它就应该替代所有 map。

更稳妥的理解是:

  • 如果只是普通业务数据结构,很多时候"普通 map + 锁"更直接、更清晰。
  • 如果确实是并发访问场景,而且 sync.Map 的接口形式刚好合适,它会更方便。

所以重点不应该变成"以后只用哪一种 map",

而应该先问:

"这份数据有没有并发访问?访问模式是什么?哪种写法更清晰、更合适?"

13. 如何判断自己是否遇到了并发安全问题

当代码同时满足下面两个条件时,就应该立刻提高警惕:

  • 有多个 goroutine 同时运行
  • 它们访问的是同一份可变数据

这时脑子里最好立刻补一句:

  • 这份数据有没有锁保护?
  • 是否所有访问路径都统一走了这层保护?
  • 如果是 map,这里能不能直接并发访问?

这种思考习惯比死记某一个 API 更重要。

14. 本节小结

  • 线程安全放到 Go 并发学习里,通常更准确地说是"并发安全"。
  • 只要多个 goroutine 同时访问共享数据,就需要考虑保护问题。
  • 普通 map 默认不是并发安全的,不能随便并发读写。
  • sync.Mutex 可以用来保护共享数据的访问过程。
  • 锁不仅要"有",还要看是否把"读 / 判断 / 修改"这整个关键过程包成了原子操作。
  • sync.RWMutex 区分读锁和写锁,适合读写分离的场景理解。
  • 带锁结构体的方法通常应该用指针接收者,避免复制锁。
  • 锁最好和被保护的数据放在同一个结构体里统一管理。
  • sync.Map 自带并发安全能力,提供 StoreLoadDeleteRange 等方法。
  • sync.Map.Range 传入的是匿名函数,也是在遍历过程中被调用的回调函数。
  • LoadOrStore 适合"存在就复用,不存在就初始化"的场景。
  • fatal error: concurrent map writes 说明普通 map 的并发写已经被错误触发出来了。
  • sync.Map 不是所有 map 的默认替代品,很多场景下普通 map + 锁仍然更合适。

二十、错误处理与 panic/recover

很多语言会把"异常处理"当成一整套主流流程。

但在 Go 里,更常见、更核心的日常错误处理方式其实是:返回 error 值。

也就是说,Go 处理错误时更强调:

  • 函数把错误显式返回出来
  • 调用方显式检查并决定怎么处理

因此学习这个知识点时,最好先把概念分开:

  • error:日常错误处理的主流方式
  • panic / recover:更偏异常式中断和兜底恢复

1. Go 里的 error 是什么

error 是 Go 标准库里约定好的一个接口类型。

只要某个值实现了 Error() string 方法,它就可以当成错误使用。

最常见的函数写法是:

复制代码
func divide(a, b int) (int, error)

这表示函数除了正常结果之外,还可能返回一个错误值。

2. 为什么 Go 常写 result, err

Go 里一个非常常见的写法是:

复制代码
result, err := divide(10, 2)
if err != nil {
	fmt.Println("error:", err)
	return
}

fmt.Println(result)

可以先把它理解成一个固定套路:

  • 先调用函数
  • 拿到结果和错误
  • 先检查 err
  • 没错再继续处理正常结果

这也是为什么在 Go 代码里会频繁看到 if err != nil

3. nil 表示没有错误

在 Go 里,错误值通常遵循一个非常重要的约定:

  • err == nil:说明没有错误
  • err != nil:说明出现了错误

例如:

复制代码
if err != nil {
	return err
}

这不是语法硬性规定,而是 Go 里非常核心的错误处理习惯。

4. 使用 errors.New 创建简单错误

如果只是想返回一个简单的错误信息,最常见的写法之一就是:

复制代码
return errors.New("divisor cannot be 0")

这种方式适合:

  • 错误原因比较直接
  • 只需要一段固定错误消息

例如除数不能为 0、参数为空、状态不合法等场景。

还可以进一步把它理解成:

errors.New("xxx") 更像是在创建一个"有固定语义的错误值"。

如果只是直接把它打印出来,那么最终看到的通常也就是这段错误文字本身。

所以它非常适合拿来定义一些固定、可复用的基础错误。

例如:

复制代码
var ErrUserNotFound = errors.New("user not found")
var ErrStockNotEnough = errors.New("stock not enough")

这种错误在 Go 里常被称为"哨兵错误"。

它们的价值不只是能打印信息,更重要的是后面可以被稳定识别和判断。

5. 使用 fmt.Errorf 补充上下文

很多时候,仅仅返回一句固定错误信息还不够。

调用方往往还想知道:到底是哪个步骤、哪个参数、哪个场景出了问题。

这时候常见写法是:

复制代码
return fmt.Errorf("query user failed: userID=%d", userID)

它的价值在于:

可以把更多上下文信息拼进错误消息里,方便排查问题。

所以可以先这样区分:

  • errors.New:更适合定义一个固定错误。
  • fmt.Errorf:更适合在返回时补充当前场景的信息。

6. 错误包装和 %w

Go 里还经常会遇到"下层已经有一个错误,上层想补充上下文后继续往外返回"的场景。

例如:

复制代码
return fmt.Errorf("query user failed: %w", ErrInvalidUserID)

这里的 %w 表示:

在保留原始错误的同时,再包上一层新的错误说明。

可以把它理解成:

  • 原始错误负责表达"底层到底错了什么"
  • 包装后的错误负责表达"这个错误是在什么场景里发生的"

这比单纯拼字符串更有价值,因为后面还可以继续判断底层错误类型。

这也是为什么"只是拼一个错误字符串"和"真正包装一个底层错误"要分开理解:

  • fmt.Errorf("xxx: %v", err):只是把错误内容拼进字符串里。
  • fmt.Errorf("xxx: %w", err):不仅补充了上下文,还保留了底层错误身份。

7. errors.Is 的作用

当错误被包装过之后,直接拿字符串比较通常不是好办法。

Go 更推荐通过 errors.Is 判断某个错误链里是否包含目标错误。

例如:

复制代码
errors.Is(err, ErrInvalidUserID)

如果返回 true,说明这个错误虽然可能已经被包了一层甚至多层,

但底层本质上还是 ErrInvalidUserID

所以可以先这样记:

  • fmt.Errorf(... %w ...):包装错误
  • errors.Is(...):判断底层是不是某个目标错误

8. 自定义业务错误常见怎么做

当代码开始进入更真实的业务场景时,错误往往不只是"打印一句话"这么简单。

这时候常见思路通常有三种:

  • 固定业务错误:直接定义哨兵错误
  • 需要补充上下文:在外层用 fmt.Errorf 包装
  • 需要携带更多业务字段:自定义错误类型

例如固定业务错误:

复制代码
var ErrUserNotFound = errors.New("user not found")

再例如补充上下文:

复制代码
return fmt.Errorf("create order failed: %w", ErrStockNotEnough)

如果还想携带错误码、订单号之类的额外信息,

就更适合定义自己的错误类型,而不是只靠一段字符串。

所以可以先把业务错误设计理解成三层:

  • 错误身份
  • 错误上下文
  • 错误附加字段

并不是所有情况都只靠 errors.New 或一层 fmt.Errorf 就能表达完整。

9. panic 是什么

error 不同,panic 不是"正常业务错误返回值",

而是一种更激烈的中断方式。

例如:

复制代码
panic("something went wrong")

一旦发生 panic,当前函数会立刻停止继续往后正常执行,

然后开始沿调用栈向外层展开。

如果没有被恢复,程序最终会直接崩掉。

10. recover 是怎么用的

recover 的作用是:在 panic 已经发生时,尝试把这次崩溃拦下来。

但它不是随便写在哪都行。

最常见、也最重要的使用方式是放在 defer 里:

复制代码
defer func() {
	if r := recover(); r != nil {
		fmt.Println("recovered:", r)
	}
}()

可以先记住这个硬规则:

  • recover 一般放在 defer 的函数里使用

如果成功恢复,那么程序就不会因为这次 panic 直接整体退出。

11. 把函数当参数传进去是什么意思

在 Go 里,函数本身也可以作为参数传给另一个函数。

例如:

复制代码
func runSafe(fn func()) {
	fn()
}

这里的:

复制代码
fn func()

意思是:

  • fn 是参数名
  • func() 是参数类型
  • 这个类型表示"一个无参数、无返回值的函数"

所以如果调用时这样写:

复制代码
runSafe(func() {
	panic("something went wrong")
})

本质上就是:

  • 把一个匿名函数当成参数传进去
  • 再由 runSafe 在内部执行这个函数

这类写法本质上是一种"包装执行"的思路:

把真正要做的事情交给外层函数统一包起来执行。

12. 这种包装写法为什么有点像切面思路

像下面这种结构:

复制代码
func runSafe(fn func()) {
	defer func() {
		if r := recover(); r != nil {
			fmt.Println("recovered:", r)
		}
	}()

	fn()
}

可以理解成:

  • fn:真正的业务动作
  • runSafe:外层包装器
  • defer + recover:统一加上的保护逻辑

所以它在思想上,确实有点像"把横切逻辑包在外层统一处理"。

不过这里仍然只是显式的函数传参和包装执行,不是完整的 AOP 体系。

13. panic 和 error 的分工

这是学习这一章最关键的边界之一。

大多数普通业务错误,其实都应该优先用 error 返回,而不是直接 panic

例如下面这些场景,通常更适合返回 error

  • 参数不合法
  • 文件打开失败
  • 查询失败
  • 网络请求失败

panic 更适合:

  • 非常严重、已经不适合按正常流程继续处理的问题
  • 明显违反程序假设的情况
  • 需要在极少数位置统一兜底恢复的情况

所以不要把 panic 理解成"更高级的 error"。

它们不是同一层次的东西。

换句话说:

  • 普通业务错误优先返回 error
  • 只有当问题已经超出正常业务处理范围,继续执行没有意义时,才考虑 panic

如果 panic 没有被恢复,程序通常会直接退出。

所以它不应该拿来替代日常错误处理。

14. recover 不会自动处理所有问题

即使写了 recover,也不代表错误就真的"解决了"。

它更像是把程序从直接崩溃里先拉回来。

恢复之后,往往还应该继续思考:

  • 当前状态是不是还可靠
  • 资源有没有正确释放
  • 是否应该记录日志
  • 是否应该向上继续返回错误

也就是说,recover 更偏向兜底,而不是日常错误处理主流方案。

15. 错误处理为什么强调显式判断

很多初学者刚接触 Go 时,会觉得频繁写 if err != nil 很啰嗦。

但这恰恰体现了 Go 的风格:

  • 错误不隐藏
  • 调用方明确知道这里可能失败
  • 每一步由当前代码自己决定怎么处理

这种方式虽然没有"异常自动上抛"那样省代码,

但控制流通常会更直白,也更容易看出错误是在哪一层被处理掉的。

16. 本节小结

  • Go 的日常错误处理主流方式是返回 error,而不是依赖异常机制。
  • error 通常和正常结果一起返回,调用方显式检查 err
  • err == nil 表示没有错误,err != nil 表示发生了错误。
  • errors.New 适合创建简单固定错误。
  • fmt.Errorf 适合补充更多错误上下文。
  • %v%w 不是一回事,只有 %w 才表示包装并保留底层错误身份。
  • %w 可以用来包装底层错误。
  • errors.Is 可以判断包装后的错误链里是否包含目标错误。
  • 固定业务错误、上下文包装、以及自定义错误类型,是常见的三层错误设计思路。
  • func() 可以作为参数类型,表示"把一个函数当参数传进去再执行"。
  • panic 是更激烈的中断方式,不应替代普通业务错误处理。
  • recover 一般放在 defer 中使用,用来兜底恢复 panic

二十一、泛型

在没有泛型时,如果一段逻辑既想支持 int,又想支持 float64

往往就只能:

  • 写多份几乎一样的函数
  • 或者退回到 interface{} / any 再做类型断言

泛型解决的核心问题就是:

让一份逻辑可以在"保持类型安全"的前提下复用于多种类型。

1. 什么是泛型

可以先把泛型理解成:

"把类型也当成参数传给函数或结构体"。

例如:

复制代码
func add[T Number](a, b T) T

这里的 T 就不是普通值参数,而是类型参数。

也就是说,这个函数真正运行时,T 会被具体类型替换掉。

2. 泛型函数长什么样

最常见的泛型函数写法是:

复制代码
func first[T any](items []T) T {
	return items[0]
}

这里可以拆开看:

  • first:函数名
  • [T any]:类型参数列表
  • items []T:参数类型里使用了类型参数 T
  • T:返回值类型也使用了 T

也就是说,这个函数不是只给 []int 用,也不是只给 []string 用,

而是"只要是某种切片,都可以把第一个元素取出来"。

这里还可以进一步按语法拆得更细一点:

复制代码
func first[T any](items []T) T
  • T:类型参数名
  • [T any]:类型参数列表
  • items []T:普通函数参数,只不过参数类型里用了 T
  • 最后的 T:返回值类型里也用了 T

所以泛型里并不是"方括号本身表示切片",

这里的方括号表示的是"声明类型参数列表"。

3. any 在泛型里是什么意思

any 在这里表示:

这个类型参数没有额外限制,可以是任意类型。

例如:

复制代码
func first[T any](items []T) T

它的含义可以先理解成:

  • T 可以是 int
  • 也可以是 string
  • 也可以是结构体

也就是说,any 是最宽松的类型约束。

这里要注意一个容易混淆的点:

  • 以前学到的 any,常常是"一个值可以装任意类型"
  • 泛型里的 T any,表达的是"类型参数 T 可以取任意类型"

两者都和"任意类型"有关,但语境并不一样。

4. 为什么泛型比 any + 类型断言更自然

如果没有泛型,很多通用逻辑只能写成:

复制代码
func first(items []any) any

这样虽然也能接收各种类型,

但调用方拿到结果后通常还要再做类型断言。

而泛型的优势就在于:

  • 传入什么类型,返回通常就还是那个类型
  • 编译期就能知道类型信息
  • 少掉很多不必要的类型断言

所以泛型提供的不是"更自由",而是"更通用同时还更类型安全"。

5. 泛型里参数和返回值是不是都要写 T

不一定。

很多入门示例里,参数和返回值确实会一起使用 T,例如:

复制代码
func max[T Ordered](a, b T) T

因为这类函数的含义通常就是:

  • 传入什么类型
  • 返回也还是那个类型

但泛型函数并不要求一定"参数和返回值都用泛型"。

例如只在参数里用:

复制代码
func printValue[T any](v T)

例如参数里用泛型,返回固定类型:

复制代码
func isZero[T comparable](v T) bool

所以更准确的理解是:

  • 哪个位置和"类型变化"有关,就在哪个位置使用类型参数
  • 不是所有参数、返回值都必须机械地写成 T

6. 泛型结构体是什么

泛型不只可以用在函数上,也可以用在结构体上。

例如:

复制代码
type Box[T any] struct {
	Value T
}

这里表示 Box 里有一个字段 Value

但这个字段的具体类型由 T 决定。

所以可以这样实例化:

复制代码
intBox := Box[int]{Value: 100}
stringBox := Box[string]{Value: "hello"}

这相当于同一个结构体模板,可以生成不同类型版本。

7. 泛型很适合做通用返回结构

泛型结构体一个很典型、也很实用的场景,就是后端里的统一返回结果结构。

例如:

复制代码
type Result[T any] struct {
	Code int
	Msg  string
	Data T
}

它的好处很直接:

  • CodeMsg 这类固定字段只写一份
  • Data 可以根据不同接口替换成不同类型
  • 依然保留类型安全,不必把 Data 一律写成 any

例如:

复制代码
Result[string]
Result[int]
Result[User]
Result[[]User]

这类场景正好符合泛型最擅长的模式:

  • 外层结构固定
  • 里面承载的数据类型变化很大

8. 类型约束是什么

泛型并不总是"什么类型都能传"。

很多时候,一段逻辑只适用于一部分类型。

例如加法:

  • int 可以相加
  • float64 可以相加
  • 但有些自定义类型未必适合直接这么写

这时就需要类型约束。

例如:

复制代码
type Number interface {
	~int | ~int32 | ~int64 | ~float32 | ~float64
}

然后函数可以写成:

复制代码
func add[T Number](a, b T) T {
	return a + b
}

它的含义就是:

  • T 不是任意类型
  • T 必须属于 Number 约束允许的那一组类型

9. 约束接口和普通接口有什么不同

这里的 interface 虽然也写成接口形式,

但它的用途和前面学的"行为接口"不完全一样。

前面学接口时,常见的是:

复制代码
type Speaker interface {
	Speak() string
}

这种接口定义的是"方法集合"。

而泛型约束里常见的是:

复制代码
type Number interface {
	~int | ~float64
}

这里描述的不是"要实现哪些方法",

而是"允许哪些底层类型参与这个泛型"。

所以泛型约束里的接口,更像是在描述"类型范围"。

10. 波浪线 ~ 是什么意思

在类型约束里看到:

复制代码
~int

它的意思可以先理解成:

  • 不只是字面上的 int
  • 底层类型是 int 的自定义类型也可以匹配进来

例如:

复制代码
type MyInt int

如果约束写的是 ~int,那么 MyInt 也能满足。

如果只写 int,那就只匹配字面上的 int 本身。

11. max 这类函数为什么需要可比较约束

像下面这种逻辑:

复制代码
if a > b {
	return a
}

并不是所有类型都能直接用 > 比较。

所以这类泛型函数必须给 T 加上足够明确的约束。

例如:

复制代码
type Ordered interface {
	~int | ~int32 | ~int64 | ~float32 | ~float64 | ~string
}

然后:

复制代码
func max[T Ordered](a, b T) T

这样编译器才能知道:

T 是一组支持大小比较的类型。

12. 类型推断是什么

调用泛型函数时,不一定每次都要手写类型参数。

例如:

复制代码
fmt.Println(add(10, 20))

这里虽然没有显式写 [int]

但编译器可以从参数 1020 推断出 Tint

当然,也可以显式写:

复制代码
fmt.Println(add[int](10, 20))

所以可以先这样理解:

  • 编译器能从实参看出来时,通常可以省略类型参数
  • 如果推断不出来,或者想写得更明确,也可以手动写出来

也就是说,下面两种写法在很多场景里都成立:

复制代码
add[int](10, 20)
add(10, 20)

前者是显式写出类型参数,

后者是让编译器自动推断。

13. 泛型是不是意味着以后都该泛型化

不是。

泛型的价值在于"同一份逻辑确实需要复用于多种类型",

而不是"只要看到重复就立刻改成泛型"。

如果一个函数本来就只服务于很具体的业务类型,

那普通函数往往反而更直接、更清晰。

所以泛型更适合:

  • 明显可抽象成通用算法的逻辑
  • 只是类型不同,但处理流程本质一样

而不是把所有代码都追求成"可泛型化"。

14. 本节小结

  • 泛型的核心是让一份逻辑在保持类型安全的前提下复用于多种类型。
  • 泛型函数和泛型结构体都可以声明类型参数。
  • [T any] 这种写法表示类型参数列表,T 是类型参数名。
  • any 表示最宽松的类型约束。
  • 泛型通常比 any + 类型断言 更自然,也更类型安全。
  • 参数和返回值不一定都要用 T,哪个位置涉及类型变化,哪个位置再使用类型参数。
  • Result[T] 这类外层结构固定、内部数据类型变化的场景,非常适合用泛型。
  • 类型约束用于限制类型参数不是"任意类型",而是一组允许的类型。
  • 泛型约束里的接口常常描述的是类型范围,而不只是方法集合。
  • ~int 这类写法表示"底层类型是 int 的类型也可以匹配"。
  • max 这种依赖比较运算的泛型函数,需要更明确的约束。
  • 编译器很多时候可以自动推断类型参数,不一定要每次手写。
  • 泛型不是越多越好,只有在"逻辑相同、类型不同"的场景下才最有价值。

二十二、文件读取

学完前面的语言基础之后,文件操作通常就是非常实用的一章。

因为很多程序都需要把配置、日志、文本数据或者本地资源从文件里读出来。

Go 做文件读取时,最常见的问题通常不是语法本身,

而是下面这些实际点:

  • 文件路径是否正确
  • 打开文件时是否处理了错误
  • 是要一次读完整个文件,还是逐行读取

1. 最常见的整文件读取:os.ReadFile

如果只是想把一个文件整体读出来,最直接的方式之一就是:

复制代码
data, err := os.ReadFile("sample.txt")

这里:

  • data 是读取到的内容,类型是 []byte
  • err 表示读取过程是否出错

这也是为什么文件读取时通常第一步还是:

复制代码
if err != nil {
	...
}

2. 为什么读出来是 \[\]byte

os.ReadFile 返回的是 []byte,不是 string

这是因为文件本质上读出来的是一串字节数据。

如果确定它是文本内容,再把它转成字符串通常就可以:

复制代码
fmt.Println(string(data))

所以可以先这样理解:

  • 文件读取的底层结果先是字节
  • 文本展示时常常再转成字符串

3. 文件读取为什么必须先判断 err

文件操作比很多内存内的普通变量操作更容易失败。

常见原因有:

  • 路径写错了
  • 文件不存在
  • 没有权限
  • 文件正在被别的程序占用

所以文件读取里,错误判断不是可有可无,而是核心步骤之一。

例如:

复制代码
data, err := os.ReadFile("sample.txt")
if err != nil {
	fmt.Println("read file error:", err)
	return
}

4. 获取文件内容长度

如果已经读到了 []byte,那就可以直接:

复制代码
len(data)

这里得到的是字节长度,不一定等于"字符个数"。

特别是文本里如果含有中文、emoji 或其他多字节字符时,

字节数和肉眼看到的字符数量往往不是一回事。

所以文件处理里最好先明确:

统计的到底是字节长度,还是文本字符数量。

5. 使用 os.Open 打开文件

除了直接 ReadFile 一次读完之外,也可以先手动打开文件:

复制代码
file, err := os.Open("sample.txt")

这种方式更适合后面继续做"流式处理",例如:

  • 逐行读取
  • 按块读取
  • 配合扫描器处理内容

如果文件成功打开,通常还要记得:

复制代码
defer file.Close()

这样函数结束前会自动关闭文件句柄。

6. 为什么打开文件后要 Close

文件不像普通变量,用完就自然没事。

它背后通常对应着操作系统资源。

如果文件打开后长期不关闭,可能会带来:

  • 资源占用
  • 文件句柄泄漏
  • 后续读写受影响

所以常见习惯是:

  • 打开成功后,尽快 defer file.Close()

7. 逐行读取:bufio.Scanner

如果文件更适合一行一行处理,Go 里很常见的方式是:

复制代码
scanner := bufio.NewScanner(file)
for scanner.Scan() {
	fmt.Println(scanner.Text())
}

这里可以先这样理解:

  • Scan():尝试继续往下读一行
  • Text():拿到当前读到的这一行文本

这种写法很适合日志文件、配置文件、逐行文本处理等场景。

8. 逐行读取后为什么还要检查 scanner.Err

很多人看到:

复制代码
for scanner.Scan() {
	...
}

会以为循环结束就万事大吉了。

但更稳妥的写法通常还会补一句:

复制代码
if err := scanner.Err(); err != nil {
	fmt.Println(err)
}

原因是:

  • Scan() 结束不一定只代表"读完了"
  • 也可能是在扫描过程中发生了错误

所以逐行读取的完整思路通常是:

  • 循环里处理每一行
  • 循环后补查一次扫描错误

9. 一次读完整个文件 vs 逐行读取

这两种方式并不是谁绝对更好,而是适用场景不同。

一次读完整个文件更适合:

  • 文件不大
  • 需要整体处理内容
  • 配置文件、模板、小文本文件

逐行读取更适合:

  • 文件较大
  • 一行一行处理更自然
  • 日志、数据导出文本、逐行解析任务

可以先把它理解成:

  • 小而整体性的内容,用 ReadFile
  • 大而流式处理的内容,用 Open + Scanner

10. 文件不存在时为什么会直接报错

如果路径不对,或者目标文件根本不存在,

那么在执行:

复制代码
os.ReadFile(...)

或:

复制代码
os.Open(...)

时就会直接返回错误。

这类错误不是"程序自己逻辑写坏了",

而是操作系统层面表明:这个资源当前拿不到。

所以文件读取时,路径问题是最先应该排查的现实问题之一。

11. 为什么示例里的路径是从 learn 开始

例如下面这种写法:

复制代码
os.ReadFile("learn/022_file_reading/sample.txt")

很多人第一次看到时会疑惑:

为什么路径是从 learn 开始,而不是直接写 sample.txt

这里的关键不是包名,而是"程序运行时的工作目录"。

如果命令是在项目根目录执行:

复制代码
go run ./learn/022_file_reading

那么相对路径通常就是相对于项目根目录来算。

于是:

复制代码
learn/022_file_reading/sample.txt

实际上表示的是:

复制代码
项目根目录/learn/022_file_reading/sample.txt

所以这类路径写法的核心不是 Go 特殊规定,

而是"当前工作目录 + 相对路径"的组合结果。

12. 文件路径和 package main 有什么关系

文件读取路径和 package main 没有直接关系。

这里最好把两件事拆开:

  • package main:表示这一组代码要编译成可执行程序。
  • os.ReadFile("..."):表示运行时去操作系统里按路径找文件。

也就是说:

  • 包结构,和 Go 源码目录组织有关。
  • 文件读取路径,和程序运行时的工作目录有关。

不要把"代码所在目录"和"运行时相对路径基准目录"当成同一件事。

13. 为什么同样是相对路径,有时写 sample.txt 也行

如果是在当前知识点目录里运行:

复制代码
cd learn/022_file_reading
go run .

那么相对路径基准就变成了这个目录本身。

这时直接写:

复制代码
os.ReadFile("sample.txt")

通常就能找到文件。

所以相对路径该怎么写,不是看 .go 文件放在哪,

而是看程序启动时当前工作目录在哪里。

14. 为什么第一行前面会多出一个奇怪字符

有时读取文本文件后,第一行开头会多出一个看起来奇怪的字符。

如果文件本身带了 UTF-8 BOM,那么就可能出现这种现象。

UTF-8 BOM 本质上也是文件开头的几个字节。

既然 Go 读文件时拿到的是原始字节数据,那么这些字节也会一起被读出来。

这意味着:

  • Go 不是"额外多读了东西"
  • 而是文件本身开头就带着那几个字节

如果后续需要更严格处理文本内容,就要意识到:

某些文本文件不是"纯正文",还可能带有编码标记。

15. 空文件读取会怎么样

如果文件存在,但内容为空,那么通常并不会因为"空"就报错。

更常见的现象是:

  • ReadFile 读到一个长度为 0 的 []byte
  • 逐行扫描时,循环体一次也不会进入

也就是说,"空文件"和"读取失败"不是一回事。

16. 本节小结

  • os.ReadFile 适合一次性把整个文件读出来。
  • 文件读取结果常常先是 []byte,文本展示时再转成 string
  • 文件操作里必须重视 err 判断,因为路径、权限、文件状态都可能导致失败。
  • len(data) 得到的是字节长度,不一定等于字符个数。
  • os.Open 更适合后续做流式处理。
  • 文件打开成功后,通常要及时 defer file.Close()
  • bufio.Scanner 很适合逐行读取文本内容。
  • 逐行扫描结束后,通常还应继续检查 scanner.Err()
  • 小文件、整体处理内容时,ReadFile 更直接;大文件、逐行处理时,Scanner 更合适。
  • 相对路径通常是相对于程序运行时的工作目录,不是相对于当前 .go 文件所在目录。
  • 文件读取路径和 package main 没有直接关系。
  • 某些文本文件如果带有 UTF-8 BOM,读取后第一行开头可能会连同 BOM 一起显示出来。
  • 空文件不一定报错,它和"文件不存在"是两种完全不同的情况。

二十三、文件写入

学会文件读取之后,文件写入通常就是顺着要掌握的一章。

很多程序都需要把结果、日志、配置或者导出内容写到本地文件中。

文件写入时最重要的几个问题通常是:

  • 这次是覆盖原文件,还是追加到末尾
  • 写入过程有没有报错
  • 如果用了缓冲写入,是否已经真正刷到文件里

1. 最直接的写法:os.WriteFile

如果只是想把一段内容直接写进文件里,最简单的方式之一就是:

复制代码
err := os.WriteFile("output.txt", []byte("hello"), 0644)

这里可以先这样理解:

  • 第一个参数:目标文件路径
  • 第二个参数:要写入的字节内容
  • 第三个参数:文件权限

如果写入失败,同样会通过 err 返回出来。

2. 为什么写入内容常常还是 \[\]byte

和读取一样,文件操作底层仍然是字节。

所以写入时经常会看到:

复制代码
[]byte("hello golang")

也就是说:

  • 平时写的是字符串
  • 真正写进文件前,常常会转成字节

3. 覆盖写入是什么意思

os.WriteFile 一个很重要的特点是:

通常会把目标文件内容直接按当前给出的数据重写掉。

也就是说,如果原文件里已经有内容,再执行新的 WriteFile

最终文件内容通常以这次的新内容为准。

连续两次对同一路径调用 os.WriteFile 时,

第二次写入通常会直接替换第一次写入后的文件内容,

而不是自动把两次内容拼接起来。

所以可以先把它理解成:

  • WriteFile 更适合"一次性写出最终结果"
  • 它不是天然的"往后继续追加"

4. 为什么写文件也必须判断 err

写文件时同样可能失败。

常见原因包括:

  • 路径不存在
  • 没有写权限
  • 文件被占用
  • 磁盘或系统状态异常

所以写文件和读文件一样,

if err != nil 不是可选项,而是基础操作。

5. 追加写入:os.OpenFile

如果不是想覆盖,而是想在文件末尾继续写,

常见方式是:

复制代码
file, err := os.OpenFile("output.txt", os.O_APPEND|os.O_WRONLY, 0644)

这里的关键是:

  • os.O_APPEND:追加模式
  • os.O_WRONLY:只写模式

这样后续再写入时,内容就会加到文件末尾,而不是从头覆盖。

如果后面使用的是:

复制代码
n, err := file.WriteString("next line\n")

那么返回值里的 n 表示本次成功写入的字节数。

这里统计的是字节,不是"看起来写了几段文本"。

6. OpenFile 和 WriteFile 的使用区别

可以先这样理解:

  • WriteFile:更适合一次性把完整内容直接写进去
  • OpenFile:更适合后续继续控制写入方式,例如追加、分多次写

如果场景是:

  • 输出一个完整小文件
  • 一次生成一次写完

WriteFile 很直接。

如果场景是:

  • 日志持续追加
  • 文件需要多次写入
  • 需要更细粒度控制写入方式

OpenFile 通常更合适。

7. 写入多行文本时要自己处理换行

文件写入不会自动补换行。

如果想写多行内容,通常要自己把换行符写进去。

例如:

复制代码
"line one\nline two\n"

也就是说,文件里一行怎么结束、下一行怎么开始,

通常由写入内容本身决定。

8. 使用 bufio.Writer 做带缓冲写入

如果不想每写一点内容就立刻直接落盘,

可以用带缓冲的写入器:

复制代码
writer := bufio.NewWriter(file)

然后:

复制代码
writer.WriteString("line 1\n")
writer.WriteString("line 2\n")

这种方式适合:

  • 分多次写内容
  • 减少频繁直接写文件的次数
  • 用更顺手的方式组织输出

9. 为什么用了 Writer 还要 Flush

带缓冲写入最关键的一点就是:

写进去的内容,可能先只是进入内存缓冲区,还没有真正落到文件里。

所以常见写法最后还会有:

复制代码
writer.Flush()

可以先把 Flush 理解成:

  • 把缓冲区里暂存的内容,真正刷到文件中

如果忘了 Flush,那就可能出现:

  • 程序以为已经写了
  • 但文件里实际内容并不完整

如果在 Flush() 之前立刻去读取这个文件,

读到的结果可能是空内容,也可能还是旧内容,

因为数据此时还停留在 bufio.Writer 的内存缓冲区里。

10. 打开文件写入后为什么也要 Close

和读取一样,写入文件时也涉及操作系统资源。

所以文件成功打开之后,通常同样要记得:

复制代码
defer file.Close()

它的价值包括:

  • 释放文件句柄
  • 避免资源长期占用
  • 让文件操作流程更完整

如果使用的是 bufio.Writer

更稳妥的顺序通常是先 Flush(),再 Close()

前者负责把缓冲区里的数据推进文件,后者负责关闭文件资源。

11. 文件写入路径和读取路径的规则一样

写文件时的相对路径规则,和读文件时是一样的。

关键仍然是程序运行时的工作目录。

例如:

复制代码
os.WriteFile("learn/023_file_writing/output.txt", ...)

如果程序是在项目根目录下执行:

复制代码
go run ./learn/023_file_writing

那么这个路径就是相对于项目根目录来找和写入。

所以文件写入路径也和 package main 没有直接关系。

12. 一次性写入、追加写入、缓冲写入分别适合什么

可以先做一个很实用的场景划分:

  • 一次性写完整个小文件:os.WriteFile
  • 持续往文件末尾补内容:os.OpenFile + O_APPEND
  • 多次写入并希望先经过缓冲:bufio.Writer

这三种不是谁更高级,

而是文件写入方式不同,对应选择也不同。

13. 本节小结

  • os.WriteFile 适合一次性把内容直接写进文件。
  • 写文件时常常仍然是把内容先写成 []byte
  • WriteFile 通常是覆盖写入,不是天然追加。
  • 连续多次对同一路径执行 WriteFile 时,后一次内容通常会覆盖前一次结果。
  • 文件写入同样必须认真检查 err
  • 如果要在末尾继续补内容,通常使用 os.OpenFile 的追加模式。
  • WriteString 返回的 n 表示本次成功写入的字节数。
  • 写入多行文本时,换行符需要自己控制。
  • bufio.Writer 适合多次写入和带缓冲输出。
  • 使用带缓冲写入后,通常要记得 Flush()
  • Flush() 之前去读文件,读到的内容可能还是空的或者不完整。
  • 写入文件成功打开后,也要及时 Close() 释放资源。
  • 文件写入路径的相对路径规则,和文件读取时是一致的。

二十四、单元测试

写完函数之后,下一步经常不是立刻继续堆业务,

而是先验证这个函数在不同输入下是否真的符合预期。

这就是单元测试最核心的价值。

单元测试可以先简单理解成:

  • 针对一个函数或一小段逻辑做独立验证
  • 给它一组输入
  • 检查返回结果是否和预期一致

对于后端开发来说,这类测试会非常常见。

像参数判断、金额计算、状态流转、权限判断、字符串处理、时间范围判断,

都很适合先用单元测试把逻辑固定下来。

1. Go 的单元测试主要依赖 testing 包

Go 标准库已经内置了测试能力,

最常见的就是:

复制代码
import "testing"

也就是说,写 Go 单元测试通常不需要先引入第三方测试框架,

只用标准库就可以完成最基础、最常用的测试工作。

2. 测试文件为什么通常要写成 _test.go

Go 里测试代码通常单独放在:

复制代码
xxx_test.go

这样的文件里。

例如:

  • calc.go:放被测试的函数
  • calc_test.go:放测试代码

这样做的价值是:

  • 业务代码和测试代码分开
  • 平时运行程序时不会把测试当成正常入口执行
  • go test 会自动识别这些测试文件

3. 最基础的测试函数长什么样

Go 里最基础的测试函数通常写成这样:

复制代码
func TestAdd(t *testing.T) {
	got := add(10, 20)
	want := 30

	if got != want {
		t.Errorf("add(10, 20) = %d, want %d", got, want)
	}
}

这里可以先这样理解:

  • 函数名要以 Test 开头
  • 参数通常是 t *testing.T
  • got 表示实际结果
  • want 表示期望结果

如果结果不一致,就通过 t.Errorft.Fatal 报出问题。

4. got 和 want 这种写法为什么很常见

单元测试里经常会看到:

  • got
  • want

这是非常常见的一种约定写法。

它的好处是很直接:

  • got:程序这次真正算出来的结果
  • want:预期应该得到的结果

测试失败时,直接把这两个值一起打印出来,

就能很快看出到底是逻辑算错了,还是测试预期写错了。

5. 运行单元测试通常不用 go run,而是 go test

平时执行示例代码时常用的是:

复制代码
go run ./learn/024_unit_testing

但执行测试时,常见命令是:

复制代码
go test ./learn/024_unit_testing

如果想看更详细的测试过程,可以加:

复制代码
go test ./learn/024_unit_testing -v

这里的 -v 可以理解成 verbose,

也就是把每个测试、子测试的执行过程打印得更详细一些。

6. 为什么说单元测试更适合测小范围逻辑

单元测试最适合的对象通常是:

  • 输入清晰
  • 输出清晰
  • 副作用少
  • 不强依赖数据库、网络、文件、外部服务

例如:

  • 两个数相加
  • 年龄是否成年
  • 某个状态是否允许流转
  • 某个参数是否有效

因为这类函数更容易控制输入,也更容易验证输出。

如果一个函数内部同时掺杂了:

  • 数据库查询
  • HTTP 请求
  • 文件读写
  • 日志、缓存、消息发送

那测试难度通常会明显上升。

不是说完全不能测,而是它已经不再只是一个"简单单元"的问题了。

7. 表驱动测试为什么在 Go 里很常见

当一个函数有多组输入、多组预期结果时,

如果每一组都单独写一份重复测试代码,会比较啰嗦。

所以 Go 里很常见的一种方式是表驱动测试。

可以先把它理解成:

  • 先定义一组测试数据
  • 再用循环把这些测试数据逐个跑一遍

典型写法类似这样:

复制代码
testCases := []struct {
	name string
	age  int
	want bool
}{
	{name: "minor", age: 17, want: false},
	{name: "adult edge", age: 18, want: true},
	{name: "adult", age: 25, want: true},
}

然后:

复制代码
for _, tc := range testCases {
	got := isAdult(tc.age)
	if got != tc.want {
		t.Errorf("isAdult(%d) = %v, want %v", tc.age, got, tc.want)
	}
}

这种方式的优点是:

  • 多组测试数据放在一起更集中
  • 扩展新测试用例更方便
  • 特别适合边界值和分支较多的函数

像分数分级、年龄判断、状态切换这类逻辑,

真正容易出错的地方往往不是"普通值",

而是阈值附近的边界。

例如分数分级里常见的边界点可能就是:

  • 5960
  • 6970
  • 7980
  • 8990

所以表驱动测试一个很实用的价值就是:

可以把这些边界值系统地排在一起测,而不是只随手测几个中间值。

8. t.Run 的作用是什么

在表驱动测试基础上,经常还会看到:

复制代码
t.Run(tc.name, func(t *testing.T) {
	...
})

t.Run 可以把每一组测试数据再包装成一个独立的子测试。

这样做的好处主要有:

  • 每组测试会有自己的名字
  • 失败时更容易定位是哪一组数据出了问题
  • 输出结构更清晰

例如:

  • TestIsAdult/minor
  • TestIsAdult/adult edge

看到名字就能知道是哪一组场景没过。

不过子测试层级也不是越深越好。

如果只是把同一组测试数据重复包上多层、但行为并没有区别,

那输出虽然更长,却不会带来新的信息。

所以更实用的原则通常是:

  • 每一层 t.Run 都应该表达一个明确的分类
  • 子测试名字尽量直接对应具体场景
  • 没有新含义的重复嵌套,通常可以省掉

9. 测试 error 场景时应该怎么想

很多函数不只是返回一个正常结果,

还可能在异常输入下返回 error

例如:

复制代码
func divide(a, b int) (int, error)

这类函数测试时,通常至少要覆盖两条路径:

  • 正常路径:返回正确结果,err == nil
  • 异常路径:返回错误,err != nil

也就是说,单元测试不只是测试"成功时对不对",

也要测试"失败时是不是按预期失败"。

如果函数签名是这种形式:

复制代码
value, err := fn(...)

那测试时一个很常见的顺序是:

  1. 先判断 err 是否符合预期
  2. 再判断返回值 value 是否符合预期

原因很直接:

如果本来就应该报错,那这时再去比较正常结果通常没有意义;

反过来,如果本来应该成功,但 err 已经不对,

那后面的结果比较也会失去参考价值。

10. t.Errorf、t.Fatal、t.Fatalf 有什么区别

可以先这样区分:

  • t.Errorf:记录错误,当前测试函数后续代码还会继续执行
  • t.Fatal:记录错误后立刻结束当前测试
  • t.Fatalf:相当于带格式化输出的 t.Fatal

什么时候更适合用 t.Fatal

通常是当后续逻辑已经依赖前面的结果时。

例如本来应该先成功拿到返回值和 err == nil

结果这里已经失败了,那么后面再继续比较数值就没有意义了。

11. 覆盖率是什么意思

Go 里可以直接查看测试覆盖率:

复制代码
go test ./learn/024_unit_testing -cover

覆盖率可以先简单理解成:

  • 当前测试执行到了多少代码语句

它能帮助发现一个问题:

有些函数看起来已经测了,

但实际上某些分支根本没有跑到。

不过覆盖率高,不等于逻辑一定没有问题。

它更像是一个辅助指标,而不是正确性的唯一标准。

12. 为什么覆盖率有时不是 100%

覆盖率统计的是整个 package 里的代码。

如果某些函数、某些分支、某些演示入口没有被测试跑到,

那覆盖率就不会是 100%。

例如在学习示例里:

  • main.go 可能只是一个演示入口
  • 真正重点测试的是 calc.go 里的函数

这种情况下,即使核心逻辑已经覆盖到,

整体 package 覆盖率也未必满分。

所以看覆盖率时,要结合具体代码结构一起判断。

13. 哪些函数更适合先写单元测试

如果一个函数具备这些特点,通常就比较适合优先写单元测试:

  • 逻辑相对独立
  • 输入输出明确
  • 分支比较多
  • 有边界条件
  • 容易因为改动而出错

例如:

  • 年龄判断 age >= 18
  • 除法里除数不能为 0
  • 某个字符串是否满足格式要求
  • 某个订单状态是否允许取消

这类函数往往不长,

但一旦判断写错,就会直接影响业务结果。

所以越是这种小而关键的逻辑,越适合及时补测试。

还有一个很实际的原则是:

测试数据必须和函数真实采用的规则保持一致。

例如某个字符串校验函数如果底层使用的是:

复制代码
unicode.IsLetter(r)

那它判断的是"是否属于字母类字符",

而不只是"是否属于英文大小写字母"。

这时中文字符通常也会被视为合法字母。

所以测试时不能只凭直觉判断某个输入应不应该失败,

而要先看清楚函数真正用了什么规则。

14. 本节小结

  • Go 标准库里的 testing 包可以直接完成基础单元测试。
  • 测试代码通常写在 _test.go 文件里。
  • 最基础的测试函数命名通常以 Test 开头,并接收 t *testing.T
  • gotwant 是单元测试里非常常见的一组命名习惯。
  • 执行测试常用的是 go test,不是 go run
  • go test -v 可以看到更详细的测试输出。
  • 表驱动测试适合一组函数对应多组输入输出的场景。
  • 表驱动测试特别适合把边界值和多个分支系统地放在一起验证。
  • t.Run 可以把每组数据拆成更清晰的子测试。
  • 子测试层级应当有实际含义,重复嵌套同一组场景通常没有必要。
  • 返回 error 的函数,通常要同时覆盖正常路径和异常路径。
  • 同时返回结果和 error 时,通常先判断 err,再判断结果值。
  • t.Errorft.Fatalt.Fatalf 的中断行为并不一样。
  • go test -cover 可以帮助查看当前测试覆盖到了多少代码。
  • 覆盖率是辅助指标,不是判断代码正确性的唯一标准。
  • 测试数据要和函数真实采用的判断规则保持一致,不能只靠直觉设预期。

二十五、反射(一):获取值与修改值

Go 里的反射,第一次接触时很容易觉得绕。

因为平时写代码时,我们通常是:

  • 直接用变量
  • 直接访问字段
  • 直接调用方法

但反射做的事情是:

把"一个变量"当成"一个可以被程序继续分析和操作的对象"来看。

这一节先不展开讲完整反射体系,

只先抓最基础的一条主线:

  • 怎么通过反射拿到类型
  • 怎么通过反射拿到值
  • 为什么有的值能改,有的值不能改
  • 为什么反射改值时通常要配合指针

1. 反射最常从 TypeOf 和 ValueOf 开始

Go 里最常见的两个入口就是:

复制代码
reflect.TypeOf(x)
reflect.ValueOf(x)

可以先这样理解:

  • TypeOf:更偏向"这个变量的类型信息是什么"
  • ValueOf:更偏向"这个变量当前持有的值是什么"

例如:

复制代码
age := 18

fmt.Println(reflect.TypeOf(age))
fmt.Println(reflect.ValueOf(age))

运行后通常会看到类似:

  • 类型是 int
  • 值是 18

2. Type 和 Kind 不是一回事

刚开始学反射时,一个很容易混的点就是:

  • Type
  • Kind

它们相关,但不是同一个层面。

可以先粗略这样理解:

  • Type:更具体,看到的是完整类型
  • Kind:更抽象,看到的是底层类别

例如一个变量如果是:

复制代码
user := User{Name: "tom", Age: 18}

那么:

  • reflect.TypeOf(user) 更接近说明"它是 main.User"
  • reflect.TypeOf(user).Kind() 更接近说明"它本质上属于 struct"

所以后面写反射代码时,

经常会先看 Kind(),因为很多反射操作取决于"它到底是指针、结构体、切片,还是基础类型"。

3. ValueOf 拿到的是反射值,不是普通值本身

例如:

复制代码
score := 95
value := reflect.ValueOf(score)

这里的 value 不是普通的 int

而是一个 reflect.Value

也就是说,反射拿到的往往不是"直接可用的原值",

而是一个"包了一层的反射对象"。

后续如果要继续判断类型、判断能不能改、取出原值,

通常都要基于这个 reflect.Value 再继续操作。

4. Interface() 的作用是什么

如果已经拿到了:

复制代码
value := reflect.ValueOf(score)

那可以通过:

复制代码
raw := value.Interface()

把它重新取回成一个普通接口值。

可以先这样理解 Interface()

  • reflect.Value 里包着的原始值再拿出来

这时如果打印:

复制代码
fmt.Printf("%v %T\n", raw, raw)

就能看到它对应的实际值和实际类型。

5. 为什么直接传普通值时通常改不了

这是反射里第一个真正关键的点。

例如:

复制代码
count := 10
value := reflect.ValueOf(count)

这时如果查看:

复制代码
value.CanSet()

结果通常是 false

原因可以先简单理解成:

此时交给反射的是"这个值当前的一份副本信息",

而不是一个可以回写到原变量上的地址入口。

所以这里虽然"看得到值",

但并不代表"能改回原变量"。

6. CanSet() 到底在判断什么

CanSet() 可以先这样理解:

  • 当前这个反射值,是否允许被修改

如果返回 false

后面直接调用 SetIntSetString 这类方法通常就会报错或 panic。

所以一个比较稳妥的反射思路通常是:

  1. 先拿到 reflect.Value
  2. 先看 Kind()
  3. 再看 CanSet()
  4. 确认可以修改后,再调用对应的 SetXXX()

7. 为什么改值时通常要传指针

如果想真正改掉原变量,

通常要把变量地址交给反射。

例如:

复制代码
count := 10
ptrValue := reflect.ValueOf(&count)

这时拿到的是一个指针反射值,

它的 Kind() 通常会是:

复制代码
ptr

也就是说,

反射现在拿到的不是 count 的一份普通值信息,

而是指向原变量的地址入口。

这一点和之前学过的指针本质上是连着的:

想改外部原值,前提通常就是先拿到它的地址。

8. Elem() 是做什么的

如果拿到的是指针反射值:

复制代码
ptrValue := reflect.ValueOf(&count)

那它本身还不是最终要改的那个值,

而是"指向那个值的指针"。

这时通常还要继续:

复制代码
elemValue := ptrValue.Elem()

Elem() 可以先这样理解:

  • 从指针再往里取一层
  • 取到这个指针真正指向的那个值

所以反射里经常会出现这套组合:

复制代码
reflect.ValueOf(&x).Elem()

它背后的意思其实就是:

  • 先把地址交给反射
  • 再找到这个地址真正指向的原值

9. 修改基础类型时常见的写法

如果已经拿到了一个可修改的 reflect.Value

那就可以调用对应类型的 SetXXX() 方法。

例如整数:

复制代码
elemValue.SetInt(99)

例如字符串:

复制代码
elemValue.SetString("hello")

这里要注意一个点:

反射修改值不是一个通用的 Set 就完事了,

而是通常要根据目标值类型,调用对应的 SetIntSetStringSetBool 等方法。

10. 结构体字段也可以通过反射修改

如果变量是结构体,例如:

复制代码
user := User{Name: "tom", Age: 18}

那么常见思路是:

  1. 先传结构体指针给 ValueOf
  2. 再通过 Elem() 拿到结构体本身
  3. 再取字段
  4. 再修改字段值

例如:

复制代码
value := reflect.ValueOf(&user).Elem()
nameField := value.FieldByName("Name")
ageField := value.FieldByName("Age")

然后:

复制代码
nameField.SetString("alice")
ageField.SetInt(25)

这种写法的核心并不在"结构体有多特殊",

而在于:

最终仍然要先拿到一个"可设置"的字段反射值。

这里还可以顺手理清两个点:

  • FieldByName("Name") 取出来的仍然是一个 reflect.Value
  • 只是这个 reflect.Value 现在对应的是结构体里的某个具体字段

也就是说,

结构体字段并不是"跳出了反射体系",

而是反射操作目标从"整个结构体"继续缩小到了"结构体里的某个字段"。

如果结构体本身是通过:

复制代码
reflect.ValueOf(&user).Elem()

这种方式拿到的,

那它本身通常是可设置的;

继续通过 FieldByName 取到的导出字段,通常也会是可设置的。

所以实际写反射修改结构体字段时,经常会连着看:

  • 结构体本身的 CanSet()
  • 某个字段的 CanSet()

11. 反射修改值这件事,本质上还是绕不开指针和可修改性

很多人第一次学反射时会觉得:

  • ValueOf
  • Elem
  • CanSet
  • SetXXX

这些 API 很碎。

但把它们串起来看,主线其实很统一:

  1. 先拿到反射值
  2. 判断它是什么类别
  3. 如果要修改原值,先确保拿到的是地址链路
  4. 再进入真实目标值
  5. 最后调用对应的设置方法

也就是说,反射虽然看起来新,

但底层仍然没有脱离前面已经接触过的:

  • 值传递
  • 指针
  • 类型判断

12. 什么时候反射容易出问题

这一节先不讲完整的反射风险清单,

但最常见的坑可以先记住两个:

  1. 明明拿到了 Value,却直接想改,结果 CanSet()false
  2. 明明传了指针,却忘了 Elem(),最后拿到的还是指针本身,不是目标值

所以刚开始写反射时,

一个很实用的排查顺序就是:

  • 它的 Type 是什么
  • 它的 Kind 是什么
  • CanSet()true 还是 false
  • 现在手里拿到的是值本身,还是指针,还是字段

13. 本节小结

  • 反射最常见的两个入口是 reflect.TypeOfreflect.ValueOf
  • Type 更偏向具体类型,Kind 更偏向底层类别。
  • reflect.Value 是反射值,不是普通变量本身。
  • Interface() 可以把反射值再取回成普通接口值。
  • 直接把普通值传给 ValueOf 时,通常只能看,不能改。
  • CanSet() 用来判断当前反射值是否允许被修改。
  • 如果想改原变量,通常要先把指针交给反射。
  • Elem() 用来继续拿到指针真正指向的那个值。
  • 修改值时通常要根据目标类型调用对应的 SetXXX() 方法。
  • 结构体字段也可以通过反射取出并修改,但前提仍然是这个字段可设置。
  • FieldByName 取出来的字段本质上仍然是 reflect.Value,只是目标缩小成了具体字段。
  • 反射改值这条主线,本质上仍然和指针、值传递、类型判断密切相关。

二十六、通过反射实现转 SQL(教学版)

学完通过反射获取值、修改值之后,

下一步比较自然的应用,就是让反射去读取结构体,再把结构体信息拼成 SQL。

这类能力本质上很像一个极简 ORM 的起点。

例如:

  • 结构体字段对应数据库列
  • 结构体字段值对应 SQL 参数
  • 主键字段决定 WHERE 条件
  • 字段 tag 决定列名映射规则

这一节先只做教学版,重点放在:

  • 通过反射读取结构体字段和 tag
  • 生成 MySQL 风格的 SQL 字符串
  • 生成和 SQL 对应顺序一致的参数切片

这里先不真正连接数据库,

也不展开讲完整 ORM 的全部能力。

1. 反射转 SQL 的核心思路是什么

如果有这样一个结构体:

复制代码
type User struct {
	ID    int    `db:"id" orm:"pk,auto"`
	Name  string `db:"name"`
	Age   int    `db:"age"`
	Email string `db:"email"`
}

那么反射转 SQL 的主线通常就是:

  1. 先拿到结构体的反射值
  2. 遍历结构体字段
  3. 读取每个字段的字段名、tag、字段值
  4. 决定哪些字段参与 SQL
  5. 拼出 SQL 字符串
  6. 按 SQL 顺序收集参数

也就是说,这一节的重点并不只是"字符串拼接",

而是"如何从结构体里稳定地提取出足够的信息"。

2. 为什么这件事常常和 tag 一起出现

如果完全不使用 tag,

那结构体字段名和数据库列名就只能强行直接对应。

例如:

  • 结构体字段叫 UserName
  • 数据库列可能叫 user_name

这时通常就需要一个中间映射规则。

最常见的方式之一就是 tag:

复制代码
Name string `db:"name"`

可以先把 db tag 理解成:

  • 这个结构体字段在数据库里的列名是什么

所以反射在这里做的事情并不复杂,

本质上就是在运行时读取:

  • 字段名
  • 字段类型
  • 字段值
  • 字段 tag

然后再据此做后续拼接。

3. 为什么还要额外区分主键字段

如果只是生成 INSERT

很多时候只需要知道:

  • 哪些列要写进去
  • 对应值是什么

但如果是 UPDATEDELETESELECT

通常还需要知道:

  • 哪个字段应该拿来做条件

所以教学版里通常会额外约定一个主键标记,例如:

复制代码
orm:"pk"

如果字段还带有自增含义,

还可以再补一个标记,例如:

复制代码
orm:"pk,auto"

这样后续生成 SQL 时,就能把主键字段和普通字段区分开来。

4. INSERT SQL 一般怎么拼

教学版 INSERT 的思路通常是:

  1. 遍历字段
  2. 跳过自增主键
  3. 收集列名
  4. 为每一列补一个 ?
  5. 把字段值按顺序放进参数切片

最终结果通常类似:

复制代码
INSERT INTO users (name, age, email) VALUES (?, ?, ?)

这里的重点有两个:

  • SQL 里的列顺序要和参数顺序一致
  • 如果字段被跳过,例如自增主键,也要同步从参数里跳过

5. UPDATE SQL 和 INSERT 的区别

UPDATEINSERT 的最大区别,不在于都是"拼字符串",

而在于职责分工不同:

  • 普通字段:放进 SET
  • 主键字段:放进 WHERE

例如:

复制代码
UPDATE users SET name = ?, age = ?, email = ? WHERE id = ?

这里参数顺序通常也要和 SQL 保持完全一致,例如:

复制代码
[nameValue, ageValue, emailValue, idValue]

如果顺序乱了,

即使 SQL 字符串表面看起来对,真正执行时也会把值绑定错位置。

6. DELETE SQL 的重点其实是条件来源

教学版 DELETE 往往最短,例如:

复制代码
DELETE FROM users WHERE id = ?

但它背后的关键仍然是同一个问题:

  • WHERE 条件到底从哪个字段来

如果前面已经通过 tag 识别出主键字段,

那这一步就会变得很直接:

取主键字段列名,取主键字段值,再拼删除语句和参数。

7. SELECT SQL 为什么也可以复用同一套反射信息

教学版 SELECT 常见写法类似:

复制代码
SELECT id, name, age, email FROM users WHERE id = ?

这里其实又复用了前面已经拿到的同一批结构体信息:

  • 所有字段列名,用来拼 SELECT 的列列表
  • 主键字段列名,用来拼 WHERE
  • 主键字段值,用来作为参数

这也是为什么反射在 ORM 这类场景里会很常见。

同一份结构体元信息,往往可以同时服务于多类 SQL 生成。

8. 为什么参数通常要单独放进切片

教学版里经常不是直接把值硬拼进 SQL,

而是会得到两份结果:

  1. SQL 字符串
  2. 参数切片

例如:

复制代码
sql := "INSERT INTO users (name, age) VALUES (?, ?)"
args := []any{"alice", 20}

这样做至少有两个直接好处:

  • SQL 模板和参数值分离,结构更清晰
  • 后面如果真的接入 database/sql,也更容易直接复用

也就是说,即使当前这一节不连数据库,

这种返回形式也更接近真实数据库编程习惯。

9. 为什么这里先按 MySQL 风格来写

这一节为了聚焦反射本身,

先统一按 MySQL 常见占位符风格来演示:

复制代码
?

这样可以把注意力放在:

  • 字段怎么读
  • tag 怎么读
  • SQL 怎么拼
  • 参数顺序怎么保持一致

如果后面换到其他数据库,例如 PostgreSQL,

就不一定还是 ?,而可能会变成:

复制代码
$1, $2, $3

所以数据库切换并不总是"只换驱动"这么简单,

SQL 生成规则本身也可能随之变化。

10. 为什么说这还不等于完整 ORM

通过反射把结构体转成 SQL,

确实已经很像 ORM 的核心基础能力之一了。

但这距离完整 ORM 还差不少东西,例如:

  • 表名映射规则
  • 更复杂的条件拼接
  • 查询结果扫描回结构体
  • 不同数据库方言差异
  • 批量操作
  • 关联关系处理
  • hook、事务、缓存等能力

所以这一节更准确的定位是:

  • 用反射手写一个教学版 SQL 生成器

而不是:

  • 完整复刻 GORM 这类 ORM 框架

11. 这一节里最容易写错的地方

教学版反射转 SQL 时,最常见的错误通常有这些:

  1. 没有先判断输入是不是结构体或结构体指针
  2. 传入指针后忘了 Elem()
  3. 列顺序和参数顺序不一致
  4. UPDATE 时把主键也写进了 SET
  5. DELETE / SELECT 时没有稳定找到主键字段
  6. 忽略自增主键,导致 INSERT 把本来不该手动写入的字段也带进去了

这类问题里,

真正最关键的并不是"某个字符串有没有拼对",

而是反射读出来的信息有没有被正确分类和组织。

12. 阅读这类代码时容易困惑的几个点

这一节虽然最后落点是"反射转 SQL",

但真正容易把人卡住的,往往不是 SQL 本身,

而是 Go 里几个基础但不太直觉的细节。

12.1 为什么 UPDATE 示例里会用 *fieldMeta

教学代码里曾出现过这样的写法:

复制代码
var pkField *fieldMeta

后面在遍历字段时,如果遇到主键字段,就把它保存下来。

这种写法的核心目的不是为了修改这个字段,

而是为了让"没找到主键"这件事可以直接用 nil 表示。

也就是说,这里的重点不是"必须用指针才能完成功能",

而是:

  • 找到了主键:pkField != nil
  • 没找到主键:pkField == nil

如果不想用指针,也完全可以写成:

  • 一个普通 fieldMeta 变量
  • 再配一个 found bool

这两种写法都成立。

只是指针版把"是否找到"的状态折叠进了 nil 判断里。

12.2 为什么结构体值不能直接和 nil 比较

fieldMeta 本身是一个 struct,属于值类型。

Go 里的普通值类型,例如:

  • int
  • string
  • bool
  • struct

都不能直接和 nil 比较。

能和 nil 比较的,通常是这些类型:

  • 指针
  • slice
  • map
  • chan
  • func
  • interface

所以:

  • var pkField *fieldMeta 可以判断 pkField == nil
  • var pkField fieldMeta 不能判断 pkField == nil

如果使用值类型版本,就需要额外加一个 foundok 变量来表示"有没有找到"。

12.3 为什么 range 时有时不能直接拿遍历变量来取地址

这一点在切片遍历里很容易踩坑。

例如:

复制代码
for _, field := range fields {
    // field 是遍历变量
}

这里的 field 并不是切片中元素本身,

而是每次循环拿到的一个副本。

所以如果后面想保存"切片里原始元素的地址",

就不能写:

复制代码
&field

因为这拿到的是遍历变量的地址。

这时通常要改用下标遍历:

复制代码
for i := range fields {
    pkField = &fields[i]
}

这样拿到的才是切片元素自己的地址。

不过,如果后面根本不需要地址,

只是想读取字段值,

那直接使用 for _, field := range fields 往往更简单。

12.4 fmt.Sprintf 不会自动帮你补空格

很多人第一次看拼 SQL 的代码时,会误以为:

  • fmt.Sprintf 会自动处理空格
  • 或者 Go 会自动把 SQL 拼得更"好看"

实际上都不会。

例如:

复制代码
fmt.Sprintf("%s = ?", field.column)

如果字段名是 name,结果就是:

复制代码
name = ?

这里 = 两边的空格,

完全来自格式字符串 "%s = ?" 本身。

再比如:

复制代码
strings.Join(setParts, ", ")

这里的分隔符写的是 ", "

也就是"逗号 + 空格",

所以最后才会得到:

复制代码
name = ?, age = ?, email = ?

因此,生成 SQL 时中间那些空格,

不是 Go 自动补的,

而是我们在模板字符串和 Join 分隔符里手动写进去的。

12.5 这一节更重要的是看懂流程,而不是强行默写

反射转 SQL 这一节,已经不是单纯的语法入门。

它本质上是在同时使用:

  • struct
  • tag
  • any
  • 反射
  • 字符串拼接
  • 参数组织

所以这一节更合理的学习目标通常不是:

  • 从零默写完整版本

而是:

  • 能看懂代码整体流程
  • 知道每一步为什么存在
  • 能解释 INSERTUPDATEDELETESELECT 分别在拼什么

只要已经能够把流程说清楚,

这一节就算达到学习目的了。

后面随着对反射和结构体理解更深,再回头自己实现,会轻松很多。

13. 本节小结

  • 通过反射读取结构体字段和 tag,可以实现一个教学版 SQL 生成器。
  • db tag 常用来描述列名映射。
  • 额外的主键标记可以帮助区分普通字段和条件字段。
  • INSERT 常见是收集列名、占位符和参数。
  • UPDATE 通常把普通字段放进 SET,把主键字段放进 WHERE
  • DELETESELECT 的关键在于稳定找到条件字段。
  • SQL 字符串和参数切片分开返回,更接近真实数据库编程方式。
  • MySQL 常见使用 ? 占位符,不同数据库的 SQL 风格并不完全一致。
  • 反射转 SQL 是 ORM 的基础能力之一,但还不等于完整 ORM。
  • 指针版"保存主键字段"和"值 + found 标记"这两种写法都成立,只是表达方式不同。
  • range 变量默认是副本;只有需要原元素地址时,才需要回到下标写法。
  • SQL 中出现的空格和分隔符都来自我们自己写的模板,而不是 fmt.Sprintf 自动补出来的。

二十七、网络编程(一):TCP

学完前面的语法、结构体、接口、协程、反射之后,

接下来开始进入 Go 里非常重要的一块内容:网络编程。

这一节先只看 TCP。

先把最核心的连接模型建立起来,

暂时不急着上 HTTP、WebSocket、RPC 这些更高层的东西。

1. 什么是 TCP

TCP 是一种面向连接的传输协议。

"面向连接"可以先简单理解成:

  • 通信前,客户端和服务端要先建立连接
  • 连接建立后,双方才能持续收发数据
  • 通信结束后,再关闭连接

如果类比成打电话,

TCP 更像是:

  1. 先拨通
  2. 接通后开始说话
  3. 说完挂断

它和"发一封就走的短消息"式通信不太一样。

2. TCP 编程里最基本的两个角色

TCP 示例里通常有两个角色:

  1. 服务端
  2. 客户端

它们的职责不同。

服务端通常负责:

  • 监听某个端口
  • 等待别人连进来
  • 接收请求
  • 回写响应

客户端通常负责:

  • 主动发起连接
  • 发送数据
  • 读取服务端返回结果

这和 Java、C#、Python 里的 socket 编程思路本质上是一样的,

只是 Go 的标准库 API 更直接一些。

3. 服务端为什么先 Listen

服务端第一步通常是:

复制代码
listener, err := net.Listen("tcp", "127.0.0.1:9090")

这里可以拆成两部分理解:

  • "tcp":表示使用 TCP 协议
  • "127.0.0.1:9090":表示监听本机 9090 端口

Listen 的含义不是"已经连上客户端",

而是:

  • 在这个地址和端口上开始等待连接

也就是说,

服务端先把门打开,

但这时候还没有访客真正进门。

4. Accept 是什么

仅仅 Listen 还不够。

服务端还要真正接收客户端连接:

复制代码
conn, err := listener.Accept()

Accept 可以理解成:

  • 从已经监听的端口上,取出一个真正连进来的客户端连接

一旦 Accept 成功,

服务端就拿到了一个 conn

后面的读写,都是围绕这个连接对象进行的。

所以服务端的大致顺序通常是:

  1. Listen
  2. Accept
  3. Read / Write
  4. Close

5. 客户端为什么用 Dial

客户端和服务端不同,

客户端不是等待连接,

而是主动发起连接:

复制代码
conn, err := net.Dial("tcp", "127.0.0.1:9090")

Dial 可以理解成"拨号"。

它会去尝试连接目标地址。

如果连接成功,

客户端也会拿到一个 conn

注意这里有一个很重要的点:

  • 服务端和客户端最后都拿到 conn

也就是说,

连接建立之后,

双方对"这条连接"的后续操作是很相似的:

  • 都可以读
  • 都可以写

6. net.Conn 可以把它当成什么

net.Conn 可以先把它理解成一个"网络连接对象",

也可以把它类比成一个"面向网络的流"。

这一点和之前学过的文件读写非常像:

  • 文件对象可以读写字节流
  • 网络连接对象也可以读写字节流

区别只是在于:

  • 文件通常连的是磁盘
  • net.Conn 连的是网络另一端

所以很多读写操作的思维方式,其实是相通的。

7. 为什么这里经常搭配 bufio.Reader

连接对象本身支持读取,

但很多时候直接裸读并不够方便。

例如我们想"按一行一行地读",

就很适合包一层:

复制代码
reader := bufio.NewReader(conn)
line, err := reader.ReadString('\n')

这表示:

  • 从连接里持续读取
  • 直到遇到换行符 \n
  • 把这一整行作为字符串返回

这和前面输入输出章节里读控制台输入时使用 bufio.Reader

思路其实是类似的。

也就是说,

bufio.Reader 并不关心底层一定是键盘、文件还是网络。

只要底层对象满足读取接口,

它就能帮我们做更方便的缓冲读取。

8. 为什么示例里消息后面要加 \n

如果服务端使用的是:

复制代码
ReadString('\n')

那它就会一直读,直到遇到换行符为止。

所以客户端发送时通常会写成:

复制代码
conn.Write([]byte(message + "\n"))

这样服务端才能知道:

  • 这一条消息到这里就结束了

否则如果一直没有 \n

按行读取就可能继续等下去。

这背后的本质是:

TCP 只负责传输字节流,

并不会天然帮我们划分"这一条消息到哪里结束"。

消息边界需要应用层自己约定。

9. 为什么要关闭连接

无论是客户端还是服务端,

通常都要记得在用完连接后关闭:

复制代码
defer conn.Close()

关闭连接至少有几个直接意义:

  • 释放系统资源
  • 告诉对方"这边已经结束了"
  • 避免连接长期挂着不释放

如果连接不关,

轻则程序行为变得不明确,

重则长期运行时把资源慢慢耗掉。

所以 Close() 在网络编程里不是一个可有可无的收尾动作,

而是连接生命周期的重要一环。

10. 这节示例为什么只处理一个连接

教学版 TCP 示例通常会先写成:

  • 服务端只 Accept 一次
  • 只处理一个客户端

这样做不是因为真实项目只能这么写,

而是为了先把最小模型看清楚:

  1. 监听
  2. 建连
  3. 收发
  4. 关闭

等这个闭环清楚之后,

下一步才会自然过渡到:

  • 循环 Accept
  • 每个连接交给 goroutine 处理

也就是说,

"并发处理多个客户端"是下一层问题,

不是 TCP 入门第一步就必须一起塞进来的内容。

11. 这一节最应该先记住什么

TCP 入门时,最重要的不是先背住所有 API 名字,

而是把这个模型记住:

服务端:

  1. Listen
  2. Accept
  3. 读取客户端消息
  4. 写回响应
  5. 关闭连接

客户端:

  1. Dial
  2. 发送消息
  3. 读取响应
  4. 关闭连接

只要这个顺序在脑子里是稳定的,

后面再看更复杂的 socket 代码时就不容易迷路。

12. TCP 常见应用场景

TCP 最典型的适用场景可以概括为一句话:

  • 需要可靠传输的网络通信

这里的"可靠"主要体现在:

  • 通信前先建立连接
  • 数据按顺序到达
  • 丢失的数据可以重传

因此,TCP 常见于这些场景:

  1. Web 服务

    例如 HTTP、HTTPS 底层都大量依赖 TCP。

  2. 数据库连接

    例如 MySQL、PostgreSQL、Redis 等客户端与服务端之间的通信。

  3. 文件传输

    文件内容要求完整,不能随便丢字节。

  4. 即时通信

    例如很多聊天系统、消息推送系统的底层连接。

  5. 远程登录

    例如 SSH 这类远程终端通信。

也就是说,

如果业务要求"数据不能乱、不能少",

通常就更适合优先考虑 TCP。

13. 这一节代码里最容易混淆的两个对象

在 TCP 示例里,

很多初学者最容易把这两个对象混在一起:

  1. listener
  2. conn

它们职责完全不同。

13.1 listener 是监听入口

例如:

复制代码
listener, err := net.Listen("tcp", "127.0.0.1:9090")

这里得到的 listener

表示服务端已经在某个端口上开始等待连接。

它更像一个"接线台"或"入口"。

它负责的事情是:

  • 占住某个地址和端口
  • 等待客户端连进来
13.2 conn 是已经建立好的具体连接

例如:

复制代码
conn, err := listener.Accept()

或者客户端侧:

复制代码
conn, err := net.Dial("tcp", "127.0.0.1:9090")

这里得到的 conn

表示"某一条已经建立好的连接"。

真正的数据收发,都是围绕 conn 完成的。

所以可以把它们记成:

  • listener:负责等别人进来
  • conn:负责和某个已经连上的对象通信

14. 服务端里的 for {} 到底是什么

Go 里的:

复制代码
for {
    ...
}

就是一个无限循环。

它的作用等价于很多语言里的:

复制代码
while (true) {
    ...
}

在 TCP 服务端里这样写的原因是:

  • 服务端通常不只处理一条消息
  • 它要持续读取客户端发来的内容
  • 读一条,处理一条,再回一条

因此,示例里才会写成:

复制代码
for {
    line, err := reader.ReadString('\n')
    ...
}

这段逻辑的实际含义是:

  • 只要连接还在,就持续读消息
  • 如果读失败、客户端断开、或者收到退出指令,就结束循环

也就是说,

这不是"Go 特有的神秘语法",

本质上就是一个 while (true) 风格的持续处理循环。

15. 为什么服务端看起来一直在"卡住等消息"

这一点其实是网络编程里的正常现象。

例如:

复制代码
conn, err := listener.Accept()

会阻塞等待客户端连接。

而:

复制代码
line, err := reader.ReadString('\n')

又会阻塞等待客户端发来一整行消息。

所谓"阻塞",

可以先简单理解成:

  • 代码会停在这里等
  • 条件满足了才继续往下执行

所以 TCP 服务端经常看起来像在"等":

  • 等客户端连接
  • 等客户端发消息
  • 等下一条消息

这正是网络服务端程序的常见工作方式。

16. 本节小结

  • TCP 是面向连接的传输协议。
  • TCP 编程里最基本的两个角色是服务端和客户端。
  • 服务端通常先 Listen,再 Accept
  • 客户端通常通过 Dial 主动连接服务端。
  • 连接建立后,双方都会拿到 net.Conn,都可以进行读写。
  • listener 负责监听端口,conn 负责具体连接上的通信。
  • net.Conn 可以理解成一个面向网络的流。
  • bufio.Reader 常用于按行读取网络数据。
  • 如果用 ReadString('\n'),发送方通常就要约定用换行符作为消息结束标记。
  • for {} 在 Go 里就是无限循环,常用于持续处理连接上的消息。
  • AcceptReadString('\n') 这类操作都会阻塞等待。
  • Close() 是连接生命周期的重要一环。
  • 教学版示例先只处理一个连接,更适合把 TCP 的最小闭环看清楚。
  • TCP 常见于 Web、数据库、文件传输、聊天、远程登录等可靠传输场景。

二十八、网络编程(二):HTTP

在上一节 TCP 中,

已经看到了网络通信最底层的一个最小模型:

  • 服务端监听端口
  • 客户端主动连接
  • 双方拿到连接后读写字节流

HTTP 则是在这个基础上,再往上抽象出一层更适合 Web 开发的通信规则。

1. HTTP 和 TCP 是什么关系

理解 HTTP 时,

最容易搞混的一点就是:

  • HTTP 不是用来替代 TCP 的

更准确的关系是:

  • TCP 负责可靠传输
  • HTTP 负责约定请求和响应的格式

可以把它理解成:

  • TCP 是运输通道
  • HTTP 是通道里双方约定好的交流格式

因此,

当客户端访问一个普通 Web 服务时,

底层通常仍然是先通过 TCP 建立连接,

然后再在这条连接之上收发 HTTP 请求和响应。

2. HTTP 最核心的模型是什么

HTTP 最核心的模型可以压缩成一句话:

  • 客户端发送请求
  • 服务端返回响应

这和上一节 TCP 里的"双方直接对着连接读写字节"相比,

已经更具体了。

因为 HTTP 把通信内容组织成了更明确的结构。

请求里常见包含:

  • 请求方法,例如 GETPOST
  • 请求路径,例如 /hello
  • 查询参数
  • 请求头
  • 请求体

响应里常见包含:

  • 状态码,例如 200 OK
  • 响应头
  • 响应体

这套规则一旦固定下来,

客户端和服务端的沟通成本就会大幅下降。

3. 为什么日常 Web 开发更常直接写 HTTP,而不是裸 TCP

裸 TCP 的优点是更底层、更灵活。

但对常见 Web 接口开发来说,

自己处理消息边界、协议格式、状态码、路径分发,

成本会很高。

HTTP 则已经帮我们约定好了很多东西,例如:

  • 请求方法
  • 请求路径
  • 状态码
  • 请求头与响应头
  • 请求体和响应体的组织方式

所以在做接口开发时,

通常不用自己重新发明一套通信规则。

直接基于 HTTP,就能更快进入业务处理本身。

4. Go 里 HTTP 服务端通常长什么样

Go 标准库里的 HTTP 服务端,

一般会围绕这几样东西展开:

  1. 路由
  2. 处理函数
  3. 服务对象

例如:

复制代码
mux := http.NewServeMux()
mux.HandleFunc("/hello", helloHandler)

这里的 mux 可以理解成一个路由分发器。

它的作用是:

  • 根据请求路径
  • 把请求交给对应的处理函数

也就是说:

  • 请求 /hello
  • 就走 helloHandler

5. 处理函数里的两个参数分别是什么

Go 标准库 HTTP 处理函数常见签名是:

复制代码
func handler(w http.ResponseWriter, r *http.Request) {
}

这里的两个参数非常关键。

5.1 r *http.Request

r 代表客户端发来的请求。

常见可以从里面拿到:

  • 请求方法 r.Method
  • 请求路径 r.URL.Path
  • 查询参数 r.URL.Query()
  • 请求体 r.Body

也就是说,

只要是"客户端发过来的东西",

大多都是从 r 里面取。

5.2 w http.ResponseWriter

w 代表服务端要写回给客户端的响应。

常见操作包括:

  • 写响应体 w.Write(...)
  • 写状态码 w.WriteHeader(...)
  • 设置响应头

也就是说,

只要是"服务端回给客户端的东西",

大多都是通过 w 写出去。

所以处理函数本质上就在做三件事:

  1. 读取请求
  2. 处理逻辑
  3. 写回响应

6. 什么是路由

路由可以先简单理解成:

  • 路径和处理逻辑的对应关系

例如:

  • /hello 返回欢迎语
  • /echo 返回处理后的请求体
  • /ping 返回健康检查结果

如果没有路由,

服务端就很难根据不同 URL 路径执行不同逻辑。

所以在 HTTP 服务端里,

路由几乎是最基础的一层组织方式。

7. GET 和 POST 可以先怎么理解

初学 HTTP 时,

先不用把所有方法都背全。

先抓住最常见的两个:

  1. GET
  2. POST

GET 常用于:

  • 获取资源
  • 读取信息
  • 通过 URL 查询参数传递简单数据

例如:

复制代码
/hello?name=golang

POST 常用于:

  • 提交数据
  • 把内容放进请求体里发送给服务端

例如:

  • 发送一段文本
  • 提交表单
  • 提交 JSON

当然这只是入门阶段的常见理解方式。

后面学 REST 风格接口时,还会接触更多语义化约定。

8. 为什么这里要区分查询参数和请求体

HTTP 里常见的输入位置至少有两种:

  1. URL 查询参数
  2. 请求体

例如:

复制代码
GET /hello?name=golang

这里的 name=golang 是查询参数。

Go 里通常通过:

复制代码
r.URL.Query().Get("name")

来读取。

而如果是 POST 请求体里的内容,

通常需要从:

复制代码
r.Body

里读取。

所以这两者不要混成一件事:

  • 查询参数在 URL 上
  • 请求体在 Body 里

9. 客户端在 HTTP 里通常怎么发请求

Go 标准库已经把很多常用请求封装好了。

例如:

复制代码
http.Get("http://127.0.0.1:8081/hello?name=golang")

和:

复制代码
http.Post("http://127.0.0.1:8081/echo", "text/plain", strings.NewReader("hello http"))

对于当前学习阶段,可以先这样理解:

  • Get:发 GET 请求
  • Post:发 POST 请求

请求发出去之后,

客户端会拿到一个响应对象 resp

后面就可以从里面读取:

  • 状态码
  • 响应体

10. 为什么响应体和请求体都常常要记得关闭

在 Go 里,

无论是请求体 r.Body

还是客户端收到的响应体 resp.Body

通常都要在用完后关闭。

例如:

复制代码
defer r.Body.Close()
defer resp.Body.Close()

这样做的意义和前面学过的文件关闭、连接关闭很类似:

  • 释放资源
  • 让底层生命周期更明确

因此,

读完 Body 后顺手关闭,

应该逐渐形成习惯。

11. 这一节最应该先记住什么

HTTP 入门时,

最重要的不是先背框架,

而是把这个最小模型记住:

服务端:

  1. 注册路由
  2. 写处理函数
  3. 启动 HTTP 服务
  4. 从请求里拿数据
  5. 把响应写回去

客户端:

  1. 发请求
  2. 拿到响应
  3. 读取状态码和响应体

只要这个骨架清楚,

后面切换到 Gin、Echo、Fiber 这类框架时,

你也能知道它们本质上是在对哪一层做封装。

12. 什么是状态码

HTTP 响应里除了响应体,

还有一个很重要的信息:状态码。

例如:

  • 200 OK
  • 404 Not Found
  • 500 Internal Server Error

状态码可以先理解成:

  • 服务端对这次请求处理结果的一个简短标记

例如:

  • 200 往往表示请求处理成功
  • 404 往往表示路径不存在
  • 500 往往表示服务端内部出错

在 Go 标准库里,

如果想手动写状态码,

通常会使用:

复制代码
w.WriteHeader(http.StatusOK)

或者:

复制代码
w.WriteHeader(http.StatusInternalServerError)

然后客户端就可以从:

复制代码
resp.Status

或:

复制代码
resp.StatusCode

中读取这次响应的状态信息。

13. 为什么处理函数里经常会写默认值分支

HTTP 请求里的输入,

并不一定总是完整的。

例如:

复制代码
GET /hello?name=golang

这里可能有 name 参数,

也可能没有。

所以处理函数里经常会写这种逻辑:

复制代码
name := r.URL.Query().Get("name")
if name == "" {
    name = "guest"
}

这背后的思路其实很普通:

  • 先尝试读取请求参数
  • 如果没有,就使用默认值

这种写法在真实接口开发里非常常见。

因为客户端发来的请求,不一定总是完全按预期组织好的。

14. 新增路由其实就是"再注册一个处理函数"

很多初学者第一次看 HTTP 服务端时,

会觉得"新增一个接口"像是一件很大的事。

但在最基本的标准库模型里,它其实很直接:

  1. 再定义一个处理函数
  2. 再注册一个路径

例如:

复制代码
mux.HandleFunc("/ping", pingHandler)

这就表示:

  • 当客户端请求 /ping
  • 就交给 pingHandler 去处理

因此,

在最小 HTTP 示例里,

一个"新接口"本质上往往就是:

  • 多一个路由
  • 多一段处理逻辑

15. net/http 和 Gin 是什么关系

这一点在后面学框架时非常重要。

可以把层级关系先理解成这样:

  1. TCP
  2. HTTP
  3. Go 标准库 net/http
  4. Gin 这类 Web 框架

也就是说:

  • TCP 负责底层可靠传输
  • HTTP 负责请求和响应规则
  • net/http 提供 Go 里的标准 HTTP 编程接口
  • Gin 再在 net/http 这一层之上做进一步封装和简化

所以 Gin 并不是"另起炉灶重新发明 HTTP",

而更像是:

  • 帮你把路由写得更顺
  • 帮你把参数读取做得更方便
  • 帮你把 JSON 响应、中间件、分组路由这些常见能力包装得更好用

因此,

现在先学 net/http 是很有价值的。

因为以后学 Gin 时,你不会只是背框架 API,

而会知道它到底帮你省掉了哪些原始步骤。

16. 这一节更重要的是掌握 HTTP 的最小骨架

HTTP 这一节和前面的反射转 SQL 有点类似:

  • 重点不一定是把所有练习都写花哨
  • 更重要的是先把主骨架看清楚

也就是:

服务端:

  1. 注册路由
  2. 启动服务
  3. 读取请求
  4. 写回响应

客户端:

  1. 发请求
  2. 拿响应
  3. 读状态码
  4. 读响应体

只要这个骨架是稳定的,

后面无论是继续写标准库 HTTP,

还是切换到 Gin,

都会顺很多。

17. 本节小结

  • HTTP 建立在 TCP 之上。
  • TCP 负责可靠传输,HTTP 负责约定请求和响应格式。
  • HTTP 的核心模型是"客户端发请求,服务端返回响应"。
  • Go 标准库里常用 ServeMux 做路径分发。
  • HTTP 处理函数通常接收 http.ResponseWriter*http.Request 两个参数。
  • *http.Request 主要用来读取客户端请求信息。
  • http.ResponseWriter 主要用来写回服务端响应。
  • GET 常见用于获取数据,POST 常见用于提交数据。
  • 查询参数通常从 URL 中读取,请求体通常从 Body 中读取。
  • 客户端发起请求后,会拿到响应对象,再读取状态码和响应体。
  • 请求体和响应体在使用完成后通常都要关闭。
  • 状态码用于表达服务端对本次请求的处理结果。
  • 处理函数里经常需要对缺失参数设置默认值。
  • 新增一个 HTTP 接口,在最基本模型里通常就是"新增路由 + 新增处理函数"。
  • Gin 这类框架本质上是在 net/http 之上继续做封装和简化。

二十九、Go 部署

学到这里,

前面已经写过命令行程序、文件读写、TCP、HTTP 服务。

接下来就可以开始看一个很现实的问题:

  • Go 程序写完之后,怎么交付和运行

这就是"部署"要解决的核心。

1. 什么叫部署

部署可以先简单理解成:

  • 把程序从"本地开发状态"
  • 变成"目标环境中可运行状态"

这通常至少包含几件事:

  1. 把代码构建成可执行文件
  2. 准备程序运行所需配置
  3. 启动程序
  4. 确认程序是否正常对外提供服务

也就是说,

部署关注的重点已经不只是"代码能不能编译通过",

而是:

  • 这份程序能不能在别的环境稳定跑起来

2. go rungo build 在部署里的角色不同

学习 Go 时最常见的命令之一就是:

复制代码
go run ./learn/xxx

它的特点是:

  • 临时编译
  • 立即运行

所以它更适合:

  • 学习
  • 调试
  • 本地快速验证

而部署时更常见的是:

复制代码
go build -o app.exe ./learn/xxx

它会生成一个真正的可执行文件。

后面可以把这个文件拷贝到目标机器上运行。

因此可以先这样理解:

  • go run 更偏开发阶段
  • go build 更偏交付阶段

3. 为什么 Go 程序很适合做单文件交付

Go 的一个很常见优势就是:

  • 可以直接构建出可执行文件

这意味着很多时候部署一个小型 Go 服务时,

交付形式会比较直接:

  1. 编译出 exe 或二进制文件
  2. 带上必要配置
  3. 运行它

和某些依赖复杂运行时环境的语言相比,

Go 程序在交付和迁移时通常更轻一些。

当然,

真实项目里仍然可能配合:

  • 配置文件
  • 环境变量
  • 日志目录
  • 数据目录
  • 容器镜像

但"有一个可直接执行的二进制文件"这件事,

本身就是 Go 部署体验里很重要的一点。

4. 为什么部署时常把配置放进环境变量

程序部署到不同环境时,

常常并不是所有配置都一样。

例如:

  • 本地开发环境端口是 8082
  • 测试环境端口可能是 9000
  • 生产环境端口可能又不同

再比如:

  • 服务名不同
  • 版本号不同
  • 数据库地址不同

如果把这些值全部写死在代码里,

每换一个环境都改代码,就会很麻烦。

因此部署时很常见的一种方式是:

  • 代码里写默认值
  • 运行时通过环境变量覆盖

例如:

复制代码
os.Getenv("PORT")

这种做法的核心好处是:

  • 同一份代码可以部署到多个环境
  • 配置和代码分离

5. 为什么很多服务会提供 /health

部署完成后,

第一件经常要确认的事就是:

  • 服务现在活着吗
  • 服务能正常响应吗

所以很多 Web 服务都会提供一个很轻量的接口:

  • /health

它通常不做复杂业务,

只是快速返回一个简单结果,例如:

复制代码
ok

它的价值在于:

  • 方便人工排查
  • 方便监控系统探测
  • 方便负载均衡或编排系统判断服务状态

6. 为什么很多服务还会提供 /version

另一个常见问题是:

  • 线上当前运行的到底是哪一版程序

这时如果服务提供一个:

  • /version

就很方便快速确认:

  • 新版本有没有成功发布
  • 多台机器跑的是不是同一个版本

所以 /version 这类接口虽然简单,

但在排查部署问题时很有用。

7. 什么叫优雅关闭

如果一个服务正在处理请求,

这时程序被直接强行结束,

就可能出现一些问题:

  • 请求处理到一半被打断
  • 日志还没写完
  • 连接还没正常收尾

因此,部署场景里经常会提到一个概念:

  • 优雅关闭

Go 标准库里常见写法是:

复制代码
server.Shutdown(ctx)

它的思路不是"立刻暴力断电",

而是:

  • 通知服务准备停止
  • 给它一点时间,把正在处理的事情收尾

这对线上服务尤其重要。

8. 这节示例为什么还是用 HTTP 服务来演示部署

"部署"本身不是一种语法。

它更像一组工程实践问题。

所以最适合拿来演示部署的,

往往是一个可运行的服务程序。

而 HTTP 服务正好具备这些特点:

  • 容易启动
  • 容易访问
  • 容易观察端口、接口、状态码、响应内容

因此用一个最小 HTTP 服务来讲部署,

比继续拿纯语法示例来讲会自然很多。

9. 这一节最应该先记住什么

Go 部署入门时,

先不用一下子把 Docker、systemd、CI/CD、云平台全塞进来。

先把最小骨架记住:

  1. 写好服务程序
  2. go build 生成可执行文件
  3. 让程序通过环境变量读取配置
  4. 启动服务并监听端口
  5. 提供最基本的健康检查接口
  6. 在停止服务时尽量优雅关闭

只要这个骨架是清楚的,

后面再接容器化、进程托管、自动发布,

都只是往这套基础上继续加层次。

10. 代码和配置为什么要分开

这是部署里非常核心的一条原则。

如果把端口、地址、账号等全部写死在代码里,

那每换一个环境就可能要改代码、重新提交、重新构建。

而如果把这些值放到环境变量或配置文件里,

就可以做到:

  • 代码尽量保持不变
  • 不同环境只改配置

这样会带来几个直接好处:

  • 发布流程更稳定
  • 环境切换更方便
  • 降低因为手动改代码带来的风险

11. 本节小结

  • 部署的核心是让程序在目标环境中可运行并可维护。
  • go run 更适合开发验证,go build 更适合交付可执行文件。
  • Go 程序常见部署形态之一是构建出单独的可执行文件。
  • 环境变量常用于让同一份程序适配不同运行环境。
  • /health 常用于健康检查,/version 常用于确认当前程序版本。
  • 优雅关闭可以减少请求中断和资源收尾不完整的问题。
  • 代码与配置分离,是部署和运维里非常重要的一条原则。

[Golang 学习笔记](#Golang 学习笔记)

一、变量定义

[1. 显式指定类型](#1. 显式指定类型)

[2. 省略类型,让编译器推断](#2. 省略类型,让编译器推断)

[3. 只声明,不赋值](#3. 只声明,不赋值)

[4. 短变量声明](#4. 短变量声明)

[5. 一次声明多个变量](#5. 一次声明多个变量)

[6. 变量可以重新赋值](#6. 变量可以重新赋值)

[7. 包级变量](#7. 包级变量)

[8. 本节小结](#8. 本节小结)

二、输入输出

[1. 基本输出](#1. 基本输出)

[2. 格式化输出](#2. 格式化输出)

[3. 生成字符串](#3. 生成字符串)

[4. 基本输入](#4. 基本输入)

[5. 按空白分隔读取](#5. 按空白分隔读取)

[6. 读取一整行](#6. 读取一整行)

[7. 输入代码中的常见概念](#7. 输入代码中的常见概念)

nil

err

_

reader

缓冲读取器

*bufio.Reader

输入场景中的指针

[8. Scan 和 Fscan 的区别](#8. Scan 和 Fscan 的区别)

[9. ReadString 的作用](#9. ReadString 的作用)

[10. 多个输入函数共用同一个 Reader](#10. 多个输入函数共用同一个 Reader)

[11. 本节小结](#11. 本节小结)

三、基本数据类型

[1. bool](#1. bool)

[2. 整数类型](#2. 整数类型)

[3. byte](#3. byte)

[4. rune](#4. rune)

[5. 浮点数类型](#5. 浮点数类型)

[6. 复数类型](#6. 复数类型)

[7. string](#7. string)

[8. 零值](#8. 零值)

[9. 显式类型转换](#9. 显式类型转换)

[10. Go 没有包装类型和装箱拆箱](#10. Go 没有包装类型和装箱拆箱)

[11. 本节小结](#11. 本节小结)

四、数组和切片

[1. 数组](#1. 数组)

[2. 数组长度属于类型](#2. 数组长度属于类型)

[3. 数组遍历](#3. 数组遍历)

[4. 切片](#4. 切片)

[5. len 和 cap](#5. len 和 cap)

[6. append](#6. append)

[7. make 创建切片](#7. make 创建切片)

[8. 切片表达式](#8. 切片表达式)

[9. 切片共享底层数组](#9. 切片共享底层数组)

[10. nil 切片和空切片](#10. nil 切片和空切片)

[11. 数组和切片的选择](#11. 数组和切片的选择)

[12. 本节小结](#12. 本节小结)

五、map(键值对)

[1. map 的定义与初始化](#1. map 的定义与初始化)

[2. 读取 map](#2. 读取 map)

[3. 判断 key 是否存在](#3. 判断 key 是否存在)

[4. 新增、更新、删除](#4. 新增、更新、删除)

[5. 遍历 map](#5. 遍历 map)

[6. nil map 和 make](#6. nil map 和 make)

[7. 本节小结](#7. 本节小结)

[六、if 条件语句](#六、if 条件语句)

[1. 基本 if](#1. 基本 if)

[2. if else](#2. if else)

[3. if else if else](#3. if else if else)

[4. if 初始化语句](#4. if 初始化语句)

[5. 条件中的逻辑运算符](#5. 条件中的逻辑运算符)

[6. if 中变量的作用域](#6. if 中变量的作用域)

[7. Go 的 if 语法特点](#7. Go 的 if 语法特点)

[8. 本节小结](#8. 本节小结)

[七、switch 分支语句](#七、switch 分支语句)

[1. 基本 switch](#1. 基本 switch)

[2. 一个 case 匹配多个值](#2. 一个 case 匹配多个值)

[3. 无表达式 switch](#3. 无表达式 switch)

[4. switch 初始化语句](#4. switch 初始化语句)

[5. break 与 fallthrough](#5. break 与 fallthrough)

[6. switch 与 if 的选择](#6. switch 与 if 的选择)

[7. 典型业务写法](#7. 典型业务写法)

分数分级(按十位分段)

[月份天数(分组 case)](#月份天数(分组 case))

[8. 本节小结](#8. 本节小结)

[八、for 循环](#八、for 循环)

[1. 三段式 for](#1. 三段式 for)

[2. 把 for 当作 while](#2. 把 for 当作 while)

[3. 无限循环](#3. 无限循环)

[4. break 与 continue](#4. break 与 continue)

[5. 嵌套循环](#5. 嵌套循环)

[6. for range](#6. for range)

[7. 常见边界问题](#7. 常见边界问题)

[8. 本节小结](#8. 本节小结)

九、函数(func)

[1. 函数定义](#1. 函数定义)

[2. 参数](#2. 参数)

[3. 返回值](#3. 返回值)

[4. 可变参数](#4. 可变参数)

[5. 函数是一等值](#5. 函数是一等值)

[6. 函数拆分建议](#6. 函数拆分建议)

[7. 本节小结](#7. 本节小结)

十、值传递和"引用效果"

[1. 基本类型:典型值传递](#1. 基本类型:典型值传递)

[2. 指针参数:通过地址间接修改](#2. 指针参数:通过地址间接修改)

[3. 切片参数:看起来像"引用"](#3. 切片参数:看起来像“引用”)

[4. map 参数:修改可见](#4. map 参数:修改可见)

[5. struct:值传参与指针传参的差异](#5. struct:值传参与指针传参的差异)

[6. 实战判断规则](#6. 实战判断规则)

[7. 本节小结](#7. 本节小结)

[十一、init 函数和 defer 语句](#十一、init 函数和 defer 语句)

[1. init 函数是什么](#1. init 函数是什么)

[2. 初始化执行顺序](#2. 初始化执行顺序)

[3. 多个 init](#3. 多个 init)

[4. defer 基本语义](#4. defer 基本语义)

[5. defer 的执行顺序(LIFO)](#5. defer 的执行顺序(LIFO))

[6. defer 参数求值时机](#6. defer 参数求值时机)

[7. 常见用途](#7. 常见用途)

[8. 本节小结](#8. 本节小结)

十二、结构体(struct)

[1. 结构体定义](#1. 结构体定义)

[2. 结构体初始化](#2. 结构体初始化)

[3. 字段访问](#3. 字段访问)

[4. 匿名结构体](#4. 匿名结构体)

[5. Go 没有内建 getter / setter](#5. Go 没有内建 getter / setter)

[6. 方法与接收者](#6. 方法与接收者)

值接收者

指针接收者

如何选择

[7. 结构体指针](#7. 结构体指针)

[8. 结构体参数传递](#8. 结构体参数传递)

[9. 结构体比较](#9. 结构体比较)

[10. 使用场景](#10. 使用场景)

[11. 本节小结](#11. 本节小结)

[十三、结构体嵌入、结构体指针与 tag](#十三、结构体嵌入、结构体指针与 tag)

[1. Go 没有传统继承](#1. Go 没有传统继承)

[2. 结构体嵌入(embedding)](#2. 结构体嵌入(embedding))

[3. 字段提升](#3. 字段提升)

[4. 结构体指针](#4. 结构体指针)

[5. 什么是结构体 tag](#5. 什么是结构体 tag)

[6. tag 的常见用途](#6. tag 的常见用途)

[7. 读取 tag](#7. 读取 tag)

[8. 本节小结](#8. 本节小结)

十四、自定义类型与类型别名

[1. 自定义类型](#1. 自定义类型)

[2. 类型别名](#2. 类型别名)

[3. 两者的根本区别](#3. 两者的根本区别)

[4. 为什么要自定义类型](#4. 为什么要自定义类型)

[5. 为什么要类型别名](#5. 为什么要类型别名)

[6. 使用时的判断思路](#6. 使用时的判断思路)

[7. 本节小结](#7. 本节小结)

十五、接口(interface)

[1. 接口定义](#1. 接口定义)

[2. 隐式实现](#2. 隐式实现)

[3. 接口变量](#3. 接口变量)

[4. 接口作为参数](#4. 接口作为参数)

[5. any 与空接口](#5. any 与空接口)

[6. 类型断言](#6. 类型断言)

[7. 接口的价值](#7. 接口的价值)

[8. 本节小结](#8. 本节小结)

十六、协程(goroutine)

[1. 启动 goroutine](#1. 启动 goroutine)

[2. main 结束会带走其他 goroutine](#2. main 结束会带走其他 goroutine)

[3. 用 Sleep 暂时等待](#3. 用 Sleep 暂时等待)

[4. goroutine 传参](#4. goroutine 传参)

[5. 使用 WaitGroup](#5. 使用 WaitGroup)

[6. 输出顺序不一定固定](#6. 输出顺序不一定固定)

[7. Sleep 和 WaitGroup 的区别](#7. Sleep 和 WaitGroup 的区别)

[8. 本节小结](#8. 本节小结)

十七、频道(channel)

[1. 创建 channel](#1. 创建 channel)

[2. 发送和接收](#2. 发送和接收)

[3. 为什么 channel 常和 goroutine 一起出现](#3. 为什么 channel 常和 goroutine 一起出现)

[4. 无缓冲 channel 的阻塞特性](#4. 无缓冲 channel 的阻塞特性)

[5. 有缓冲 channel](#5. 有缓冲 channel)

[6. close 和 range](#6. close 和 range)

[7. 单向 channel](#7. 单向 channel)

[8. close 的边界规则](#8. close 的边界规则)

[9. channel 和 WaitGroup 的分工不同](#9. channel 和 WaitGroup 的分工不同)

[10. 常见风险:死锁](#10. 常见风险:死锁)

[11. 本节小结](#11. 本节小结)

[十八、select 与协程超时处理](#十八、select 与协程超时处理)

[1. select 是干什么的](#1. select 是干什么的)

[2. select 和 switch 的区别](#2. select 和 switch 的区别)

[3. 最基础的 select 接收](#3. 最基础的 select 接收)

[4. 同时监听多个 channel](#4. 同时监听多个 channel)

[5. default 分支](#5. default 分支)

[6. 为什么超时处理常和 select 一起用](#6. 为什么超时处理常和 select 一起用)

[7. 使用 time.After 做超时控制](#7. 使用 time.After 做超时控制)

[8. 超时控制的是等待方,不一定是任务本身](#8. 超时控制的是等待方,不一定是任务本身)

[9. select 只是选择"当前可执行"的分支](#9. select 只是选择“当前可执行”的分支)

[10. select 和 channel 的关系](#10. select 和 channel 的关系)

[11. 超时现象里为什么有时会"刚好收到结果"](#11. 超时现象里为什么有时会“刚好收到结果”)

[12. 本节小结](#12. 本节小结)

[十九、线程安全与 sync.Map](#十九、线程安全与 sync.Map)

[1. 为什么会有线程安全问题](#1. 为什么会有线程安全问题)

[2. 普通 map 默认不是并发安全的](#2. 普通 map 默认不是并发安全的)

[3. 用 sync.Mutex 保护共享数据](#3. 用 sync.Mutex 保护共享数据)

[4. Mutex 保护的不是"变量名",而是那段共享访问过程](#4. Mutex 保护的不是“变量名”,而是那段共享访问过程)

[5. sync.RWMutex 是什么](#5. sync.RWMutex 是什么)

[6. 为什么带锁结构体的方法通常用指针接收者](#6. 为什么带锁结构体的方法通常用指针接收者)

[7. 为什么示例里经常把 map 和锁封装进结构体](#7. 为什么示例里经常把 map 和锁封装进结构体)

[8. sync.Map 的基本定位](#8. sync.Map 的基本定位)

[9. LoadOrStore 是什么意思](#9. LoadOrStore 是什么意思)

[10. sync.Map 的 Range 和普通 map 的 for range 不一样](#10. sync.Map 的 Range 和普通 map 的 for range 不一样)

[11. 为什么会看到 concurrent map writes](#11. 为什么会看到 concurrent map writes)

[12. 是不是以后都该直接用 sync.Map](#12. 是不是以后都该直接用 sync.Map)

[13. 如何判断自己是否遇到了并发安全问题](#13. 如何判断自己是否遇到了并发安全问题)

[14. 本节小结](#14. 本节小结)

[二十、错误处理与 panic/recover](#二十、错误处理与 panic/recover)

[1. Go 里的 error 是什么](#1. Go 里的 error 是什么)

[2. 为什么 Go 常写 result, err](#2. 为什么 Go 常写 result, err)

[3. nil 表示没有错误](#3. nil 表示没有错误)

[4. 使用 errors.New 创建简单错误](#4. 使用 errors.New 创建简单错误)

[5. 使用 fmt.Errorf 补充上下文](#5. 使用 fmt.Errorf 补充上下文)

[6. 错误包装和 %w](#6. 错误包装和 %w)

[7. errors.Is 的作用](#7. errors.Is 的作用)

[8. 自定义业务错误常见怎么做](#8. 自定义业务错误常见怎么做)

[9. panic 是什么](#9. panic 是什么)

[10. recover 是怎么用的](#10. recover 是怎么用的)

[11. 把函数当参数传进去是什么意思](#11. 把函数当参数传进去是什么意思)

[12. 这种包装写法为什么有点像切面思路](#12. 这种包装写法为什么有点像切面思路)

[13. panic 和 error 的分工](#13. panic 和 error 的分工)

[14. recover 不会自动处理所有问题](#14. recover 不会自动处理所有问题)

[15. 错误处理为什么强调显式判断](#15. 错误处理为什么强调显式判断)

[16. 本节小结](#16. 本节小结)

二十一、泛型

[1. 什么是泛型](#1. 什么是泛型)

[2. 泛型函数长什么样](#2. 泛型函数长什么样)

[3. any 在泛型里是什么意思](#3. any 在泛型里是什么意思)

[4. 为什么泛型比 any + 类型断言更自然](#4. 为什么泛型比 any + 类型断言更自然)

[5. 泛型里参数和返回值是不是都要写 T](#5. 泛型里参数和返回值是不是都要写 T)

[6. 泛型结构体是什么](#6. 泛型结构体是什么)

[7. 泛型很适合做通用返回结构](#7. 泛型很适合做通用返回结构)

[8. 类型约束是什么](#8. 类型约束是什么)

[9. 约束接口和普通接口有什么不同](#9. 约束接口和普通接口有什么不同)

[10. 波浪线 ~ 是什么意思](#10. 波浪线 ~ 是什么意思)

[11. max 这类函数为什么需要可比较约束](#11. max 这类函数为什么需要可比较约束)

[12. 类型推断是什么](#12. 类型推断是什么)

[13. 泛型是不是意味着以后都该泛型化](#13. 泛型是不是意味着以后都该泛型化)

[14. 本节小结](#14. 本节小结)

二十二、文件读取

[1. 最常见的整文件读取:os.ReadFile](#1. 最常见的整文件读取:os.ReadFile)

[2. 为什么读出来是 \[\]byte](#2. 为什么读出来是 []byte)

[3. 文件读取为什么必须先判断 err](#3. 文件读取为什么必须先判断 err)

[4. 获取文件内容长度](#4. 获取文件内容长度)

[5. 使用 os.Open 打开文件](#5. 使用 os.Open 打开文件)

[6. 为什么打开文件后要 Close](#6. 为什么打开文件后要 Close)

[7. 逐行读取:bufio.Scanner](#7. 逐行读取:bufio.Scanner)

[8. 逐行读取后为什么还要检查 scanner.Err](#8. 逐行读取后为什么还要检查 scanner.Err)

[9. 一次读完整个文件 vs 逐行读取](#9. 一次读完整个文件 vs 逐行读取)

[10. 文件不存在时为什么会直接报错](#10. 文件不存在时为什么会直接报错)

[11. 为什么示例里的路径是从 learn 开始](#11. 为什么示例里的路径是从 learn 开始)

[12. 文件路径和 package main 有什么关系](#12. 文件路径和 package main 有什么关系)

[13. 为什么同样是相对路径,有时写 sample.txt 也行](#13. 为什么同样是相对路径,有时写 sample.txt 也行)

[14. 为什么第一行前面会多出一个奇怪字符](#14. 为什么第一行前面会多出一个奇怪字符)

[15. 空文件读取会怎么样](#15. 空文件读取会怎么样)

[16. 本节小结](#16. 本节小结)

二十三、文件写入

[1. 最直接的写法:os.WriteFile](#1. 最直接的写法:os.WriteFile)

[2. 为什么写入内容常常还是 \[\]byte](#2. 为什么写入内容常常还是 []byte)

[3. 覆盖写入是什么意思](#3. 覆盖写入是什么意思)

[4. 为什么写文件也必须判断 err](#4. 为什么写文件也必须判断 err)

[5. 追加写入:os.OpenFile](#5. 追加写入:os.OpenFile)

[6. OpenFile 和 WriteFile 的使用区别](#6. OpenFile 和 WriteFile 的使用区别)

[7. 写入多行文本时要自己处理换行](#7. 写入多行文本时要自己处理换行)

[8. 使用 bufio.Writer 做带缓冲写入](#8. 使用 bufio.Writer 做带缓冲写入)

[9. 为什么用了 Writer 还要 Flush](#9. 为什么用了 Writer 还要 Flush)

[10. 打开文件写入后为什么也要 Close](#10. 打开文件写入后为什么也要 Close)

[11. 文件写入路径和读取路径的规则一样](#11. 文件写入路径和读取路径的规则一样)

[12. 一次性写入、追加写入、缓冲写入分别适合什么](#12. 一次性写入、追加写入、缓冲写入分别适合什么)

[13. 本节小结](#13. 本节小结)

二十四、单元测试

[1. Go 的单元测试主要依赖 testing 包](#1. Go 的单元测试主要依赖 testing 包)

[2. 测试文件为什么通常要写成 _test.go](#2. 测试文件为什么通常要写成 _test.go)

[3. 最基础的测试函数长什么样](#3. 最基础的测试函数长什么样)

[4. got 和 want 这种写法为什么很常见](#4. got 和 want 这种写法为什么很常见)

[5. 运行单元测试通常不用 go run,而是 go test](#5. 运行单元测试通常不用 go run,而是 go test)

[6. 为什么说单元测试更适合测小范围逻辑](#6. 为什么说单元测试更适合测小范围逻辑)

[7. 表驱动测试为什么在 Go 里很常见](#7. 表驱动测试为什么在 Go 里很常见)

[8. t.Run 的作用是什么](#8. t.Run 的作用是什么)

[9. 测试 error 场景时应该怎么想](#9. 测试 error 场景时应该怎么想)

[10. t.Errorf、t.Fatal、t.Fatalf 有什么区别](#10. t.Errorf、t.Fatal、t.Fatalf 有什么区别)

[11. 覆盖率是什么意思](#11. 覆盖率是什么意思)

[12. 为什么覆盖率有时不是 100%](#12. 为什么覆盖率有时不是 100%)

[13. 哪些函数更适合先写单元测试](#13. 哪些函数更适合先写单元测试)

[14. 本节小结](#14. 本节小结)

二十五、反射(一):获取值与修改值

[1. 反射最常从 TypeOf 和 ValueOf 开始](#1. 反射最常从 TypeOf 和 ValueOf 开始)

[2. Type 和 Kind 不是一回事](#2. Type 和 Kind 不是一回事)

[3. ValueOf 拿到的是反射值,不是普通值本身](#3. ValueOf 拿到的是反射值,不是普通值本身)

[4. Interface() 的作用是什么](#4. Interface() 的作用是什么)

[5. 为什么直接传普通值时通常改不了](#5. 为什么直接传普通值时通常改不了)

[6. CanSet() 到底在判断什么](#6. CanSet() 到底在判断什么)

[7. 为什么改值时通常要传指针](#7. 为什么改值时通常要传指针)

[8. Elem() 是做什么的](#8. Elem() 是做什么的)

[9. 修改基础类型时常见的写法](#9. 修改基础类型时常见的写法)

[10. 结构体字段也可以通过反射修改](#10. 结构体字段也可以通过反射修改)

[11. 反射修改值这件事,本质上还是绕不开指针和可修改性](#11. 反射修改值这件事,本质上还是绕不开指针和可修改性)

[12. 什么时候反射容易出问题](#12. 什么时候反射容易出问题)

[13. 本节小结](#13. 本节小结)

[二十六、通过反射实现转 SQL(教学版)](#二十六、通过反射实现转 SQL(教学版))

[1. 反射转 SQL 的核心思路是什么](#1. 反射转 SQL 的核心思路是什么)

[2. 为什么这件事常常和 tag 一起出现](#2. 为什么这件事常常和 tag 一起出现)

[3. 为什么还要额外区分主键字段](#3. 为什么还要额外区分主键字段)

[4. INSERT SQL 一般怎么拼](#4. INSERT SQL 一般怎么拼)

[5. UPDATE SQL 和 INSERT 的区别](#5. UPDATE SQL 和 INSERT 的区别)

[6. DELETE SQL 的重点其实是条件来源](#6. DELETE SQL 的重点其实是条件来源)

[7. SELECT SQL 为什么也可以复用同一套反射信息](#7. SELECT SQL 为什么也可以复用同一套反射信息)

[8. 为什么参数通常要单独放进切片](#8. 为什么参数通常要单独放进切片)

[9. 为什么这里先按 MySQL 风格来写](#9. 为什么这里先按 MySQL 风格来写)

[10. 为什么说这还不等于完整 ORM](#10. 为什么说这还不等于完整 ORM)

[11. 这一节里最容易写错的地方](#11. 这一节里最容易写错的地方)

[12. 阅读这类代码时容易困惑的几个点](#12. 阅读这类代码时容易困惑的几个点)

[12.1 为什么 UPDATE 示例里会用 *fieldMeta](#12.1 为什么 UPDATE 示例里会用 *fieldMeta)

[12.2 为什么结构体值不能直接和 nil 比较](#12.2 为什么结构体值不能直接和 nil 比较)

[12.3 为什么 range 时有时不能直接拿遍历变量来取地址](#12.3 为什么 range 时有时不能直接拿遍历变量来取地址)

[12.4 fmt.Sprintf 不会自动帮你补空格](#12.4 fmt.Sprintf 不会自动帮你补空格)

[12.5 这一节更重要的是看懂流程,而不是强行默写](#12.5 这一节更重要的是看懂流程,而不是强行默写)

[13. 本节小结](#13. 本节小结)

二十七、网络编程(一):TCP

[1. 什么是 TCP](#1. 什么是 TCP)

[2. TCP 编程里最基本的两个角色](#2. TCP 编程里最基本的两个角色)

[3. 服务端为什么先 Listen](#3. 服务端为什么先 Listen)

[4. Accept 是什么](#4. Accept 是什么)

[5. 客户端为什么用 Dial](#5. 客户端为什么用 Dial)

[6. net.Conn 可以把它当成什么](#6. net.Conn 可以把它当成什么)

[7. 为什么这里经常搭配 bufio.Reader](#7. 为什么这里经常搭配 bufio.Reader)

[8. 为什么示例里消息后面要加 \n](#8. 为什么示例里消息后面要加 \n)

[9. 为什么要关闭连接](#9. 为什么要关闭连接)

[10. 这节示例为什么只处理一个连接](#10. 这节示例为什么只处理一个连接)

[11. 这一节最应该先记住什么](#11. 这一节最应该先记住什么)

[12. TCP 常见应用场景](#12. TCP 常见应用场景)

[13. 这一节代码里最容易混淆的两个对象](#13. 这一节代码里最容易混淆的两个对象)

[13.1 listener 是监听入口](#13.1 listener 是监听入口)

[13.2 conn 是已经建立好的具体连接](#13.2 conn 是已经建立好的具体连接)

[14. 服务端里的 for {} 到底是什么](#14. 服务端里的 for {} 到底是什么)

[15. 为什么服务端看起来一直在"卡住等消息"](#15. 为什么服务端看起来一直在“卡住等消息”)

[16. 本节小结](#16. 本节小结)

二十八、网络编程(二):HTTP

[1. HTTP 和 TCP 是什么关系](#1. HTTP 和 TCP 是什么关系)

[2. HTTP 最核心的模型是什么](#2. HTTP 最核心的模型是什么)

[3. 为什么日常 Web 开发更常直接写 HTTP,而不是裸 TCP](#3. 为什么日常 Web 开发更常直接写 HTTP,而不是裸 TCP)

[4. Go 里 HTTP 服务端通常长什么样](#4. Go 里 HTTP 服务端通常长什么样)

[5. 处理函数里的两个参数分别是什么](#5. 处理函数里的两个参数分别是什么)

[5.1 r *http.Request](#5.1 r *http.Request)

[5.2 w http.ResponseWriter](#5.2 w http.ResponseWriter)

[6. 什么是路由](#6. 什么是路由)

[7. GET 和 POST 可以先怎么理解](#7. GET 和 POST 可以先怎么理解)

[8. 为什么这里要区分查询参数和请求体](#8. 为什么这里要区分查询参数和请求体)

[9. 客户端在 HTTP 里通常怎么发请求](#9. 客户端在 HTTP 里通常怎么发请求)

[10. 为什么响应体和请求体都常常要记得关闭](#10. 为什么响应体和请求体都常常要记得关闭)

[11. 这一节最应该先记住什么](#11. 这一节最应该先记住什么)

[12. 什么是状态码](#12. 什么是状态码)

[13. 为什么处理函数里经常会写默认值分支](#13. 为什么处理函数里经常会写默认值分支)

[14. 新增路由其实就是"再注册一个处理函数"](#14. 新增路由其实就是“再注册一个处理函数”)

[15. net/http 和 Gin 是什么关系](#15. net/http 和 Gin 是什么关系)

[16. 这一节更重要的是掌握 HTTP 的最小骨架](#16. 这一节更重要的是掌握 HTTP 的最小骨架)

[17. 本节小结](#17. 本节小结)

[二十九、Go 部署](#二十九、Go 部署)

[1. 什么叫部署](#1. 什么叫部署)

[2. go rungo build 在部署里的角色不同](#2. go run 和 go build 在部署里的角色不同)

[3. 为什么 Go 程序很适合做单文件交付](#3. 为什么 Go 程序很适合做单文件交付)

[4. 为什么部署时常把配置放进环境变量](#4. 为什么部署时常把配置放进环境变量)

[5. 为什么很多服务会提供 /health](#5. 为什么很多服务会提供 /health)

[6. 为什么很多服务还会提供 /version](#6. 为什么很多服务还会提供 /version)

[7. 什么叫优雅关闭](#7. 什么叫优雅关闭)

[8. 这节示例为什么还是用 HTTP 服务来演示部署](#8. 这节示例为什么还是用 HTTP 服务来演示部署)

[9. 这一节最应该先记住什么](#9. 这一节最应该先记住什么)

[10. 代码和配置为什么要分开](#10. 代码和配置为什么要分开)

[11. 本节小结](#11. 本节小结)

相关推荐
唐青枫15 小时前
别再把 make 和 new 搞混:Go make 从切片到通道实战详解
go
小小龙学IT18 小时前
Go 后端开发实战:从单机千QPS到十万级微服务架构的演进之路
微服务·架构·golang
l1t21 小时前
DeepSeek总结的 waddler,一个 Go 语言编写的从 YAML 文件运行的 ETL 管道
开发语言·golang·etl
协享科技1 天前
前端 SSE 流式响应处理实践:从接收、解析到渲染
前端·人工智能·程序人生·go·ai编程·sse
特立独行的猫a1 天前
鸿蒙PC搭建Go开发环境与网络服务实战全记录
华为·golang·harmonyos·homebrew·鸿蒙pc
GDAL1 天前
{}之于Go语言意味着什么
golang
李燚1 天前
Eino 的 ReAct 循环是怎么跑起来的:图、节点、分支
golang·agent·react·ai-agent
Hiter_John2 天前
Golang的运算符
开发语言·后端·golang