需要预先了解:


1. 标准库(一):IO 与系统交互

io.Reader 和 io.Writer 是 Go 标准库最重要的两个接口,几乎所有"数据从哪里来、到哪里去"的 API 都建立在这两个接口上。这一篇讲清楚它们的语义、如何用它们拼装数据流,以及 fmt、os、bufio、time、flag 的常用部分。


1.1 io.Reader 与 io.Writer

两个接口的签名极简:

type Reader interface {
	Read(p []byte) (n int, err error)
}

type Writer interface {
	Write(p []byte) (n int, err error)
}

Read 的语义:最多读 len(p) 个字节到 p 里,返回实际读到的字节数 n。

  • n > 0:读到了数据,err 可能是 nil,也可能是 io.EOF(最后一个数据块同时到达末尾);
  • n == 0, err == io.EOF:正常结束;
  • n == 0, err == nil:允许发生,表示这次没读到数据但不是错误(调用方不应该把它当作结束)。
r := strings.NewReader("hello world")
buf := make([]byte, 5)
n, err := r.Read(buf)
fmt.Printf("读取 %d 字节: %q err=%v\n", n, buf[:n], err)

io.Copy(io.Discard, r) // 读到底
n, err = r.Read(buf)
fmt.Printf("结尾处: n=%d err=%v\n", n, err)
读取 5 字节: "hello" err=<nil>
结尾处: n=0 err=EOF

必须用 n 而不是 len(p)——Read 不保证读满。想读满要用 io.ReadFull 或 io.ReadAll。

io.EOF 是"正常结束"的约定信号,不是错误。读到它应该停止循环,而不是报错。


1.2 组合的艺术

标准库提供了一批"包装器",把 Reader/Writer 像管道一样拼起来。

io.Copy(dst, src):从 src 读到 dst,直到 EOF,返回写入的字节数:

src := strings.NewReader("复制这段内容")
var dst bytes.Buffer
written, _ := io.Copy(&dst, src)
fmt.Printf("Copy 写了 %d 字节: %s\n", written, dst.String())

io.TeeReader(r, w):读取时顺便把读到的内容写到 w,适合"一边处理一边记录":

var teeBuf bytes.Buffer
tee := io.TeeReader(strings.NewReader("tee 测试"), &teeBuf)
data, _ := io.ReadAll(tee)
fmt.Printf("读到的: %s,同时写入的: %s\n", data, teeBuf.String())

io.MultiReader(rs...):把多个 Reader 串成一个,依次读:

mr := io.MultiReader(
	strings.NewReader("第一段 "),
	strings.NewReader("第二段 "),
	strings.NewReader("第三段"),
)
all, _ := io.ReadAll(mr)
fmt.Println(string(all)) // 第一段 第二段 第三段

io.LimitReader(r, n):只读前 n 个字节,常用于限制请求体大小:

lr := io.LimitReader(strings.NewReader("0123456789"), 4)
limited, _ := io.ReadAll(lr)
fmt.Println(string(limited)) // 0123

这些组合器让"数据流处理"变成搭积木,不需要自己写循环。


1.3 fmt 的接口体系

fmt 在打印时会检查值是否实现了这些接口:

接口 方法 作用
fmt.Stringer String() string %v、%s 时调用
fmt.Formatter Format(f State, c rune) 完全接管格式化逻辑
fmt.GoStringer GoString() string %#v 时调用
error Error() string 作为错误打印时调用
func (p Point) String() string   { return fmt.Sprintf("(%d, %d)", p.X, p.Y) }
func (p Point) GoString() string { return fmt.Sprintf("Point{X: %d, Y: %d}", p.X, p.Y) }
p := Point{1, 2}
fmt.Printf("%%v   = %v\n", p)   // (1, 2)
fmt.Printf("%%#v  = %#v\n", p)  // Point{X: 1, Y: 2}

占位符速查

占位符 含义 示例输出
%v 默认格式 (1, 2)
%+v 结构体带字段名 / 数值带符号 (1, 2)
%#v Go 语法表示 Point{X: 1, Y: 2}
%T 动态类型 main.Point
%q 带引号的字符串 / 字符 "文本"
%d 十进制 42
%05d 补零到 5 位 00042
%x 十六进制 ff
%f 浮点数 3.140000
%8.2f 宽 8、保留 2 位小数 3.14
%t 布尔 true
%c Unicode 字符 A
%p 指针地址 0x17703b3aa0e0
%w 包装错误(仅 fmt.Errorf) —

实现 Formatter 可以完全自定义输出:

func (f Fancy) Format(s fmt.State, verb rune) {
	switch verb {
	case 'v':
		fmt.Fprintf(s, "Fancy[%s]", f.Name)
	case 's':
		fmt.Fprintf(s, "%s", f.Name)
	}
}

1.4 os 文件操作

最常用的四个函数:

data, err := os.ReadFile(path)          // 一次性读整个文件
err = os.WriteFile(path, data, 0o644)   // 一次性写整个文件
f, err := os.Open(path)                 // 打开用于读取
f, err := os.Create(path)               // 创建/截断用于写入

os.Open/os.Create 返回的 *os.File 用完必须关闭:

f, err := os.Open(path)
if err != nil {
	return err
}
defer f.Close()

小文件用 ReadFile/WriteFile 更简洁;大文件或需要流式处理时用 Open + io.Copy。


1.5 目录遍历

os.ReadDir 返回目录项列表(不递归):

entries, _ := os.ReadDir(dir)
for _, e := range entries {
	fmt.Println(e.Name(), "是目录:", e.IsDir())
}

filepath.WalkDir 递归遍历:

filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
	if err != nil {
		return err
	}
	fmt.Println(path)
	return nil
})
a.txt
b.txt
sub
sub\c.txt

回调返回 filepath.SkipDir 可以跳过整个目录,返回其他错误会终止遍历。路径拼接用 filepath.Join 而不是手写 /,保证跨平台。


1.6 bufio

bufio 提供带缓冲的读写,减少系统调用次数。

bufio.Scanner 适合按行(或自定义分隔符)读取:

scanner := bufio.NewScanner(f)
for scanner.Scan() {
	fmt.Println(scanner.Text())
}
if err := scanner.Err(); err != nil {
	// 处理错误
}

自定义分隔符:

sc := bufio.NewScanner(strings.NewReader("a,b,c"))
sc.Split(func(data []byte, atEOF bool) (int, []byte, error) {
	if i := strings.IndexByte(string(data), ','); i >= 0 {
		return i + 1, data[:i], nil
	}
	if atEOF && len(data) > 0 {
		return len(data), data, nil
	}
	return 0, nil, nil
})

Scanner 的坑:单行长度默认上限 64KB,超过会返回 bufio.Scanner: token too long 并且读到空内容:

Scanner 读到长度: 0 err: bufio.Scanner: token too long
调大 Buffer 后长度: 100000 err: <nil>

用 sc.Buffer(make([]byte, 0, 1024*1024), 1024*1024) 提高上限,或者改用 bufio.Reader.ReadString('\n')(没有这个限制,但需要自己处理最后一行没有换行符的情况)。

bufio.Writer 必须 Flush,否则数据留在缓冲区里:

w := bufio.NewWriter(out)
fmt.Fprintf(w, "写入第 %d 行\n", i)
w.Flush()

1.7 bytes 与 strings 的 Reader

strings.NewReader、bytes.NewReader、bytes.NewBuffer 都实现了 io.Reader,这让它们成为测试和管道复用的利器:

  • 测试里用 strings.NewReader("输入") 替代真实文件;
  • bytes.Buffer 既是 Reader 又是 Writer,可以当内存管道;
  • io.MultiReader(strings.NewReader(a), strings.NewReader(b)) 拼接输入。

1.8 time

time.Duration 是纳秒为单位的整数类型,用 time.Second、time.Minute 这类常量做运算:

d := 90 * time.Second
fmt.Println(d, d.Minutes()) // 1m30s 1.5

v, _ := time.ParseDuration("1h30m")
fmt.Println(v) // 1h30m0s

时间格式化用的是参考时间,不是 YYYY-MM-DD 这种占位符:

now := time.Now()
now.Format("2006-01-02 15:04:05") // 2026-09-09 13:05:32
now.Format("2006-01-02")          // 2026-09-09
now.Format("03:04:05 PM")         // 01:05:32 PM

记法是:2006 年、01 月、02 日、15 时(24 小时制)、04 分、05 秒。这是 Go 里最容易被吐槽但也最容易记的设计——记住这个具体的时间点即可。

time.Timer / time.Ticker 配合 select 使用:

timer := time.NewTimer(30 * time.Millisecond)
select {
case <-timer.C:
	fmt.Println("Timer 触发")
case <-time.After(200 * time.Millisecond):
	fmt.Println("不会走到这里")
}

time.After 内部创建 Timer,循环里频繁调用会有资源压力,长期运行的任务用 NewTimer/NewTicker 并显式 Stop。


1.9 flag

标准库自带的命令行参数解析:

name := flag.String("name", "默认值", "名字")
count := flag.Int("count", 1, "次数")
verbose := flag.Bool("v", false, "详细输出")

flag.Parse()

fmt.Printf("name=%s count=%d verbose=%v\n", *name, *count, *verbose)
go run . -name 一撮灰 -count 2 -v
name=一撮灰 count=2 verbose=true

flag 返回的是指针,取值要解引用。子命令模式用 flag.NewFlagSet。更复杂的 CLI(子命令、短选项组合)通常用 spf13/cobra 这类第三方库。


1.10 环境变量与进程信息

os.Args           // 命令行参数切片,[0] 是程序路径
os.Getenv("PATH") // 读环境变量,不存在返回空串
os.Setenv("K", "V")
os.Getpid()       // 当前进程 ID
os.Getwd()        // 当前工作目录
os.Exit(1)        // 立即退出(defer 不会执行)

1.11 练习

  1. 用 io.MultiReader + io.TeeReader + bufio.Scanner 写一个程序:把两段文本当作输入,逐行处理,同时把处理过的内容写入一个文件。
  2. 用 os.ReadDir 统计某个目录下 .go 文件的数量和总大小,再用 filepath.WalkDir 递归统计,比较两者的适用场景。
  3. 写一个命令行工具,用 flag 接收一个目录路径和文件后缀,打印该目录下所有匹配文件的绝对路径。