标准库(一):IO 与系统交互
需要预先了解:
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 练习
- 用
io.MultiReader+io.TeeReader+bufio.Scanner写一个程序:把两段文本当作输入,逐行处理,同时把处理过的内容写入一个文件。 - 用
os.ReadDir统计某个目录下.go文件的数量和总大小,再用filepath.WalkDir递归统计,比较两者的适用场景。 - 写一个命令行工具,用
flag接收一个目录路径和文件后缀,打印该目录下所有匹配文件的绝对路径。



