07. os、io 与 bufio:文件和流式 I/O
07. os、io 与 bufio:文件和流式 I/O
前言
Go 将文件、终端、网络连接和内存缓冲区统一看作字节流。理解 io.Reader 与 io.Writer 这两个接口后,读取、写入、复制和重定向不再是彼此孤立的 API,而是同一种数据传递模型的不同组合。
I/O 的核心抽象
Go 的 I/O API 不只面向文件。文件、标准输入输出、网络连接和内存缓冲区等对象通常通过两个基础接口统一起来:
type Reader interface {
// Read 将读取的数据写入调用者提供的 p,n 是实际读取的字节数。
// 到达输入末尾时通常返回 io.EOF;n > 0 时仍应先处理数据。
Read(p []byte) (n int, err error)
}
type Writer interface {
// Write 尝试写入 p 中的数据,n 是实际写入字节数。
Write(p []byte) (n int, err error)
}io.Reader表示可以读取字节流的数据源;io.Writer表示可以写入字节流的目标。
只要函数接收 io.Reader 或 io.Writer,它通常就能同时处理文件、终端、网络连接和内存数据。
标准输入、标准输出和标准错误
os 包提供三个已经打开的标准文件:
| 变量 | 含义 | 常见用途 |
|---|---|---|
os.Stdin | 标准输入 | 读取终端或管道输入 |
os.Stdout | 标准输出 | 输出程序的正常结果 |
os.Stderr | 标准错误 | 输出错误、警告和诊断信息 |
// Stdout 用于正常的程序输出,便于调用方接收或重定向。
fmt.Fprintln(os.Stdout, "正常结果")
// Stderr 用于诊断信息,通常不应与正常结果混在一起。
fmt.Fprintln(os.Stderr, "错误信息")原教程中直接调用 os.Stdin.Read 的示例可以帮助理解“终端也可以作为文件读写”,但普通交互输入更适合使用 bufio.Reader、bufio.Scanner 或 fmt.Fscan。
一次性读取和写入整个文件
os.ReadFile
文件较小,并且确实需要一次获得全部内容时,可以使用 os.ReadFile:
package main
import (
"fmt"
"os"
)
func main() {
// ReadFile 一次性把文件全部读入内存,data 保存原始字节。
data, err := os.ReadFile("config.json")
if err != nil {
// 错误写到标准错误,正常结果不会被污染。
fmt.Fprintln(os.Stderr, "读取文件失败:", err)
return
}
// 文本文件可以显式转换为 string 后输出。
fmt.Println(string(data))
}os.ReadFile 会把整个文件读入内存,不适合体积很大或大小不受信任的文件。
os.WriteFile
package main
import (
"fmt"
"os"
)
func main() {
// 要写入文件的内容使用字节切片表示。
data := []byte("hello, Go\n")
// 0o644 是新建文件的权限;已有文件会被截断后写入。
if err := os.WriteFile("output.txt", data, 0o644); err != nil {
fmt.Fprintln(os.Stderr, "写入文件失败:", err)
}
}目标文件已存在时,os.WriteFile 会先截断原内容再写入。
io/ioutil从 Go 1.16 起已经弃用。新代码应使用os.ReadFile、os.WriteFile、os.ReadDir、io.ReadAll等对应 API。
打开和关闭文件
只读打开
// Open 以只读方式打开文件。
file, err := os.Open("data.txt")
if err != nil {
return fmt.Errorf("打开文件失败:%w", err)
}
// 只有成功打开后才能安排关闭,释放文件描述符。
defer file.Close()创建或截断文件
// Create 不存在时创建、存在时清空,因此不适合追加写入。
file, err := os.Create("result.txt")
if err != nil {
return fmt.Errorf("创建文件失败:%w", err)
}
defer file.Close()os.Create 以读写方式打开文件:文件不存在时创建,文件已经存在时清空原内容。
自定义打开方式
file, err := os.OpenFile(
"app.log",
// 创建、只写并从文件末尾追加,适合日志文件。
os.O_CREATE|os.O_WRONLY|os.O_APPEND,
// 仅在创建新文件时使用该权限。
0o644,
)常用标志:
| 标志 | 作用 |
|---|---|
os.O_RDONLY | 只读 |
os.O_WRONLY | 只写 |
os.O_RDWR | 读写 |
os.O_CREATE | 文件不存在时创建 |
os.O_TRUNC | 打开时清空已有内容 |
os.O_APPEND | 每次从文件末尾追加写入 |
权限 0o644 表示文件所有者可读写,其他用户只读。实际权限还会受到操作系统和 umask 的影响。
使用 Read 读取文件
Read 将数据读入调用者提供的字节切片:
package main
import (
"fmt"
"io"
"os"
)
func main() {
// 先获取只读文件句柄;失败时不能继续读取。
file, err := os.Open("data.txt")
if err != nil {
fmt.Fprintln(os.Stderr, "打开文件失败:", err)
return
}
defer file.Close()
// 每轮最多读取 4 KiB,缓冲区由调用方复用。
buf := make([]byte, 4*1024)
// content 收集每次实际读到的有效字节。
var content []byte
for {
n, err := file.Read(buf)
if n > 0 {
// 只追加 buf 中 [0,n) 的有效部分;其余位置可能是旧数据。
content = append(content, buf[:n]...)
}
if err == io.EOF {
// EOF 表示所有数据已读完,是正常结束条件。
break
}
if err != nil {
// 非 EOF 的错误表示读取过程失败。
fmt.Fprintln(os.Stderr, "读取文件失败:", err)
return
}
}
fmt.Println(string(content))
}一次 Read 不保证填满整个缓冲区,并且可能同时返回 n > 0 和非空错误,因此应先处理已读取的数据,再处理错误。
普通文本和文件复制场景通常不需要手写这个循环,应优先使用 Scanner、io.Copy 或相应的解码器。
使用 bufio.Scanner 按行读取
package main
import (
"bufio"
"fmt"
"os"
)
func main() {
file, err := os.Open("data.txt")
if err != nil {
fmt.Fprintln(os.Stderr, "打开文件失败:", err)
return
}
defer file.Close()
// 默认 SplitFunc 是 ScanLines,因此每次 Scan 得到一行。
scanner := bufio.NewScanner(file)
for scanner.Scan() {
// Text 返回当前 token;默认情况下不含行尾换行符。
fmt.Println(scanner.Text())
}
// 循环结束既可能是正常 EOF,也可能是扫描错误,必须再检查。
if err := scanner.Err(); err != nil {
fmt.Fprintln(os.Stderr, "读取文件失败:", err)
}
}Scanner 默认按行切分,并去掉行尾换行符。单个 token 默认存在大小限制,如果一行可能很长,可以调整缓冲区:
// 初始缓冲区为 64 KiB,允许单行最多增长到 1 MiB。
scanner.Buffer(make([]byte, 64*1024), 1024*1024)超大行或需要精确控制缓冲和分隔符时,可以改用 bufio.Reader。
带缓冲写入
频繁执行小块写入时,可以使用 bufio.Writer 减少底层写操作次数:
package main
import (
"bufio"
"fmt"
"os"
)
func writeLines(path string, lines []string) error {
// Create 会创建或清空目标文件,适合重新生成文件内容。
file, err := os.Create(path)
if err != nil {
return fmt.Errorf("创建文件失败:%w", err)
}
defer file.Close()
// Writer 先把小块内容保存在内存中,减少底层 Write 次数。
writer := bufio.NewWriter(file)
for _, line := range lines {
// 每个元素写成独立的一行;WriteString 也可能失败。
if _, err := writer.WriteString(line + "\n"); err != nil {
return fmt.Errorf("写入文件失败:%w", err)
}
}
// Flush 将尚未写出的缓冲数据提交到底层文件。
if err := writer.Flush(); err != nil {
return fmt.Errorf("刷新缓冲区失败:%w", err)
}
return nil
}Flush 会把仍在内存缓冲区中的数据写入底层 io.Writer。忘记调用 Flush 可能导致文件末尾的数据没有真正写出。
使用 io.Copy 复制文件
io.Copy 会持续从 io.Reader 读取数据并写入 io.Writer,直到遇到 EOF 或错误:
func Copy(dst io.Writer, src io.Reader) (written int64, err error)package main
import (
"fmt"
"io"
"os"
)
func copyFile(dstPath, srcPath string) error {
// 源文件只读打开,复制完成后关闭。
src, err := os.Open(srcPath)
if err != nil {
return fmt.Errorf("打开源文件失败:%w", err)
}
defer src.Close()
// 目标文件会被新建或截断,避免遗留旧内容。
dst, err := os.Create(dstPath)
if err != nil {
return fmt.Errorf("创建目标文件失败:%w", err)
}
// Copy 持续从 src 读取并写入 dst,直到 EOF 或发生错误。
if _, err := io.Copy(dst, src); err != nil {
// 复制失败也要主动关闭已经打开的目标文件。
dst.Close()
return fmt.Errorf("复制文件失败:%w", err)
}
// 显式关闭可以把关闭阶段的错误返回给调用方。
if err := dst.Close(); err != nil {
return fmt.Errorf("关闭目标文件失败:%w", err)
}
return nil
}复制、代理、转发和下载等流式场景应优先考虑 io.Copy,不要重复编写容易出错的读取循环。
简化版 cat 命令
package main
import (
"fmt"
"io"
"os"
)
func printFile(path string) error {
// 将命令行给出的路径打开为 Reader。
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()
// 文件和标准输出分别满足 Reader、Writer,因此可直接复制。
_, err = io.Copy(os.Stdout, file)
return err
}
func main() {
// 跳过 os.Args[0](程序自身路径),其余元素都是待输出文件。
paths := os.Args[1:]
if len(paths) == 0 {
// 没有路径时从管道或终端读取,行为类似 cat 的标准输入模式。
if _, err := io.Copy(os.Stdout, os.Stdin); err != nil {
fmt.Fprintln(os.Stderr, "读取标准输入失败:", err)
os.Exit(1)
}
return
}
// 即使某个文件失败,也继续处理其他文件,并在最后返回失败状态。
exitCode := 0
for _, path := range paths {
if err := printFile(path); err != nil {
fmt.Fprintf(os.Stderr, "读取 %s 失败:%v\n", path, err)
exitCode = 1
}
}
os.Exit(exitCode)
}这个例子体现了 io.Reader、io.Writer、标准输入输出和文件之间的统一抽象。
判断常见文件错误
package main
import (
"errors"
"fmt"
"os"
)
func main() {
// 这里故意打开不存在的文件,用 errors.Is 判断可包装的错误链。
_, err := os.Open("missing.txt")
switch {
case err == nil:
fmt.Println("文件存在")
case errors.Is(err, os.ErrNotExist):
fmt.Println("文件不存在")
case errors.Is(err, os.ErrPermission):
fmt.Println("没有访问权限")
default:
fmt.Println("打开文件失败:", err)
}
}新代码优先使用 errors.Is(err, os.ErrNotExist),而不是旧式的 os.IsNotExist(err)。
文件 I/O 使用建议
- 小文件整体读写使用
os.ReadFile和os.WriteFile,大文件或大小不可信的数据使用流式 API。 - 打开文件后及时关闭,一般在错误检查之后立即安排
defer file.Close()。 - 复制数据优先使用
io.Copy。 - 逐行文本优先使用
bufio.Scanner,并注意单个 token 的大小限制。 - 频繁小写入使用
bufio.Writer,写完必须检查Flush的错误。 - 不要继续使用已弃用的
io/ioutil。 ReadAt、WriteAt、Seek和NewFile等 API 适用于随机访问或文件描述符等特定场景,需要时再查阅文档。
总结
io.Reader 与 io.Writer 是 Go I/O 的共同语言。小且可信的文件可以整体读写;数据量大、来源未知或需要逐步处理时应使用流式 API。打开资源后及时关闭,缓冲写入后执行 Flush,读取时正确处理 io.EOF 和其他错误;仅需搬运数据时,io.Copy 往往是最可靠、最简洁的选择。
