IO和文件
# 第 15 章 IO 与文件
Go 的 IO 哲学:用
io.Reader/io.Writer两个小接口组合一切。 关键词:io.Reader/io.Writer、bufio、os.File、io/fs、embed、filepath
# 目录介绍
- 15.1 本章学习目标
- 15.2
io.Reader/io.Writer两个小接口 - 15.3
os包:文件操作 - 15.4
bufio:缓冲 IO - 15.5
io包工具函数 - 15.6
io/fs(Go 1.16+):抽象文件系统 - 15.7
embed(Go 1.16+):嵌入静态资源 - 15.8
path/filepath:路径操作 - 15.9 综合示例:实现 grep 与 wc
- 15.10 本章底层原理(简介)
- 15.11 Go 新手陷阱 Top 5
- 15.12 思考题
- 15.13 训练题
- 15.14 推荐阅读
# 15.1 本章学习目标
学完本章你应当能够:
- ✅ 能解释
io.Reader/io.Writer的接口设计——为什么只有一两个方法 - ✅ 能用
os.Open/os.Create/os.ReadFile/os.WriteFile做文件读写 - ✅ 知道
os.ReadFile替代了已废弃的ioutil.ReadFile - ✅ 能用
bufio.Scanner逐行处理大文件——不爆内存 - ✅ 能用
io.Copy+TeeReader实现"边读边算哈希" - ✅ 能用
embed把静态资源打进二进制——单文件部署 - ✅ 能写出
grep和wc的 Go 实现
本章是 Go 标准库 IO 的入门全景。第 32 篇章专栏博客会深入
io.Copy的零拷贝和bufio的批量 syscall 原理。
# 15.2 io.Reader / io.Writer 两个小接口
# 15.2.1 Reader 接口与四种返回情况
io.Reader 是 Go 最重要的接口——只有一个方法:
type Reader interface {
Read(p []byte) (n int, err error)
}
2
3
四种返回情况——调用方必须按此顺序处理:
buf := make([]byte, 1024)
for {
n, err := r.Read(buf)
// 情况 1:读到数据,没有错误
// n > 0, err == nil → 处理 buf[:n],继续读
if err == nil {
process(buf[:n])
continue
}
// 情况 2:读到末尾带数据
// n > 0, err == io.EOF → 先处理 buf[:n]
if err == io.EOF {
process(buf[:n])
break
}
// 情况 3:读到末尾无数据
// n == 0, err == io.EOF → 结束
// 情况 4:出错
// err != nil → 处理 buf[:n](可能有数据),返回错误
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
关键约定:Read 返回的 n 可能小于 len(p)——不要假设一次 Read 能填满缓冲区。
# 15.2.2 Writer 接口
type Writer interface {
Write(p []byte) (n int, err error)
}
2
3
和 Reader 对称——Write 必须处理全部 p,或者返回错误 + 已写字节数。
io.ReadAll vs io.Copy 的内存对比:
// ❌ ReadAll——整个文件进内存
data, _ := io.ReadAll(file) // 1GB 文件 → 1GB 内存
// ✅ Copy——32KB 缓冲区流式读写
io.Copy(dst, src) // 1GB 文件 → 32KB 内存
2
3
4
5
# 15.2.3 接口组合——装饰器模式
Reader/Writer 的威力在于组合——一层包一层:
// 文件 → gzip 解压 → 缓冲读取 → 按行扫描
file, _ := os.Open("data.gz")
gzReader, _ := gzip.NewReader(file) // 包一层解压
bufReader := bufio.NewReader(gzReader) // 包一层缓冲
scanner := bufio.NewScanner(bufReader) // 包一层扫描
for scanner.Scan() {
line := scanner.Text()
fmt.Println(line)
}
2
3
4
5
6
7
8
9
10
每一步都是"一个 Reader 包在另一个外面"。每层只做一件事——解压、缓冲、分行。
# 15.2.4 综合案例与思考
综合案例:实现一个"带进度条的 Reader"
package main
import (
"fmt"
"io"
"os"
)
// ProgressReader 包装一个 Reader,记录已读字节数
type ProgressReader struct {
r io.Reader
total int64
onRead func(total int64)
}
func (pr *ProgressReader) Read(p []byte) (int, error) {
n, err := pr.r.Read(p)
pr.total += int64(n)
if pr.onRead != nil {
pr.onRead(pr.total) // 通知进度
}
return n, err
}
func main() {
file, _ := os.Open("large_file.bin")
defer file.Close()
pr := &ProgressReader{
r: file,
onRead: func(total int64) {
fmt.Printf("\r已读: %d MB", total/(1024*1024))
},
}
io.Copy(io.Discard, pr) // Discard = /dev/null
fmt.Println("\n完成")
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
案例知识融合:这个案例展示了装饰器模式的核心——实现 io.Reader 接口,包装另一个 Reader。ProgressReader 不需要知道底层是文件还是网络——它只关心 Read 返回的字节数。
思考题:
- 为什么
io.Reader只定义了一个方法?如果加上Close()会有什么问题? io.EOF是错误吗?为什么 Go 把它设计成包级变量而不是类型?
# 15.3 os 包:文件操作
# 15.3.1 os.Open / os.Create / os.OpenFile
三个函数打开文件的三种方式:
// ① Open——只读打开(存在才成功)
f, err := os.Open("data.txt")
// 等价于 os.OpenFile("data.txt", os.O_RDONLY, 0)
// ② Create——创建或截断(读写)
f, err := os.Create("output.txt")
// 等价于 os.OpenFile("output.txt", os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0666)
// ③ OpenFile——完全控制
f, err := os.OpenFile("log.txt",
os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
2
3
4
5
6
7
8
9
10
11
OpenFile 常用 flag 组合:
| 场景 | flag | 含义 |
|---|---|---|
| 只读 | os.O_RDONLY | 文件必须存在 |
| 只写(覆盖) | os.O_WRONLY \| os.O_CREATE \| os.O_TRUNC | 新建或清空 |
| 追加写 | os.O_WRONLY \| os.O_CREATE \| os.O_APPEND | 追加到末尾 |
| 读写 | os.O_RDWR \| os.O_CREATE | 不存在则创建 |
| 排他创建 | os.O_RDWR \| os.O_CREATE \| os.O_EXCL | 已存在则失败 |
# 15.3.2 os.ReadFile / os.WriteFile(替代 io/ioutil)
Go 1.16 起 ioutil 包已废弃——所有函数迁移到 os 和 io 包:
// ❌ 旧版(已废弃)
import "io/ioutil"
data, _ := ioutil.ReadFile("config.json")
ioutil.WriteFile("output.txt", data, 0644)
// ✅ 新版(Go 1.16+)
import "os"
data, _ := os.ReadFile("config.json")
os.WriteFile("output.txt", data, 0644)
2
3
4
5
6
7
8
9
迁移对照表:
| 废弃 API | 新 API |
|---|---|
ioutil.ReadFile | os.ReadFile |
ioutil.WriteFile | os.WriteFile |
ioutil.ReadAll | io.ReadAll |
ioutil.ReadDir | os.ReadDir |
ioutil.NopCloser | io.NopCloser |
ioutil.TempFile | os.CreateTemp |
ioutil.TempDir | os.MkdirTemp |
ioutil.Discard | io.Discard |
# 15.3.3 文件权限与模式
Unix 文件权限用三位八进制表示:
0644 = 110 100 100
│ │ │
│ │ └── 其他用户:r--
│ └────── 同组用户:r--
└────────── 文件所有者:rw-
0755 = 可执行文件(所有者 rwx,其他 r-x)
0600 = 敏感文件(仅所有者 rw)
2
3
4
5
6
7
8
// 数据文件——只读
os.WriteFile("config.json", data, 0644)
// 可执行脚本
os.WriteFile("script.sh", script, 0755)
// 密钥文件——仅当前用户可读写
os.WriteFile(".secret", key, 0600)
2
3
4
5
6
7
8
# 15.3.4 综合案例与思考
综合案例:原子写入——先写临时文件再 rename
package main
import (
"fmt"
"os"
"path/filepath"
)
// AtomicWrite 原子写入——要么完整写入,要么旧文件不变
func AtomicWrite(path string, data []byte) error {
// ① 在同目录创建临时文件(确保同文件系统——rename 才是原子的)
dir := filepath.Dir(path)
tmp, err := os.CreateTemp(dir, ".tmp-*")
if err != nil {
return err
}
tmpName := tmp.Name()
// ② 写入数据 + fsync 确保落盘
if _, err := tmp.Write(data); err != nil {
tmp.Close()
os.Remove(tmpName)
return err
}
if err := tmp.Sync(); err != nil {
tmp.Close()
os.Remove(tmpName)
return err
}
tmp.Close()
// ③ 原子 rename——旧文件瞬间被替换
if err := os.Rename(tmpName, path); err != nil {
os.Remove(tmpName)
return err
}
return nil
}
func main() {
AtomicWrite("/tmp/important.data", []byte("hello"))
fmt.Println("写入完成")
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
案例知识融合:这个案例展示了生产级的文件写入——不用 os.WriteFile 直接写,而用"临时文件 + rename"模式。理由:WriteFile 不是原子的——写到一半崩溃会留下半截文件。Rename 是原子操作——要么看到旧文件,要么看到新文件。
思考题:
- 为什么
CreateTemp要在同目录下创建临时文件——不能放在/tmp吗? tmp.Sync()做了什么?不调用会有什么风险?
# 15.4 bufio:缓冲 IO
# 15.4.1 bufio.Scanner 按行读取
Scanner 是按分隔符切分的扫描器——默认按行:
file, _ := os.Open("data.log")
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
fmt.Println(line)
}
if err := scanner.Err(); err != nil {
log.Fatal(err)
}
2
3
4
5
6
7
8
9
10
11
按单词扫描——自定义分隔符:
scanner := bufio.NewScanner(file)
scanner.Split(bufio.ScanWords) // 按空格分词
for scanner.Scan() {
word := scanner.Text()
fmt.Println(word)
}
2
3
4
5
6
7
内置分割函数:
| 函数 | 分隔符 |
|---|---|
bufio.ScanLines | \n(默认) |
bufio.ScanWords | 空格/制表符 |
bufio.ScanRunes | 单个 UTF-8 字符 |
bufio.ScanBytes | 单个字节 |
⚠️ Scanner 默认最大 64KB 一行——超长行报 bufio: token too long:
// 增大缓冲区——处理超长行
scanner := bufio.NewScanner(file)
buf := make([]byte, 0, 1<<20)
scanner.Buffer(buf, 10<<20) // 最大 10MB 一行
2
3
4
# 15.4.2 bufio.Reader / bufio.Writer
Scanner 好用于按行/词切分。需要更灵活的控制(Peek、ReadByte)时用 Reader:
// Reader——预读 + 灵活读取
r := bufio.NewReader(file)
line, _ := r.ReadString('\n') // 读到换行
b, _ := r.ReadByte() // 读一个字节
peek, _ := r.Peek(4) // 预读 4 字节(不消费)
// Writer——攒够一批再 flush
w := bufio.NewWriter(file)
w.WriteString("hello")
w.WriteString(" world")
w.Flush() // ★ 必须 flush——否则数据在缓冲区里
2
3
4
5
6
7
8
9
10
11
何时套 bufio:
| 场景 | 建议 |
|---|---|
| 逐行读文件 | bufio.Scanner |
| 逐字节/逐字段读 | bufio.NewReader |
| 大量小数据写磁盘 | bufio.NewWriter + Flush |
| 一次读整文件 | os.ReadFile —不用 bufio |
io.Copy 流式拷贝 | 直接 io.Copy —自带 32KB 缓冲 |
# 15.4.3 综合案例与思考
综合案例:大文件日志分析——统计 Top N IP
package main
import (
"bufio"
"fmt"
"os"
"sort"
"strings"
)
func topIPs(filename string, n int) ([]string, error) {
file, err := os.Open(filename)
if err != nil {
return nil, err
}
defer file.Close()
counts := make(map[string]int)
scanner := bufio.NewScanner(file)
// 增大缓冲区——日志行可能很长
buf := make([]byte, 0, 256<<10)
scanner.Buffer(buf, 1<<20)
for scanner.Scan() {
// 按空格取第一个字段——假设是 IP
fields := strings.Fields(scanner.Text())
if len(fields) > 0 {
counts[fields[0]]++
}
}
if err := scanner.Err(); err != nil {
return nil, err
}
// 排序取 Top N
type kv struct{ k string; v int }
var sorted []kv
for k, v := range counts {
sorted = append(sorted, kv{k, v})
}
sort.Slice(sorted, func(i, j int) bool {
return sorted[i].v > sorted[j].v
})
result := make([]string, 0, n)
for i := 0; i < n && i < len(sorted); i++ {
result = append(result, fmt.Sprintf("%s: %d", sorted[i].k, sorted[i].v))
}
return result, nil
}
func main() {
ips, _ := topIPs("access.log", 5)
for _, s := range ips {
fmt.Println(s)
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
案例知识融合:这个案例综合运用了 bufio.Scanner 逐行读取、Scanner.Buffer 调整缓冲区、流式处理不爆内存。一个 10GB 的日志文件——内存只用 Scanner 的 1MB 缓冲区 + map 的 IP 计数。
思考题:
Scanner.Buffer的第二个参数(最大 token 大小)如果小于某行长度——会发生什么?- 上面的
topIPs的 map 会随着 IP 种类增多而增长——如果 IP 种类有 1000 万,内存够吗?如何优化?
# 15.5 io 包工具函数
# 15.5.1 io.Copy / io.CopyN / io.CopyBuffer
// Copy——流式拷贝,32KB 默认缓冲区
io.Copy(dst, src)
// CopyN——拷贝恰好 N 字节
io.CopyN(dst, src, 1024)
// CopyBuffer——自定义缓冲区大小
buf := make([]byte, 1<<20) // 1MB
io.CopyBuffer(dst, src, buf)
2
3
4
5
6
7
8
9
# 15.5.2 io.MultiReader / io.TeeReader / io.MultiWriter
// MultiReader——合并多个 Reader(文件拼接)
r1 := strings.NewReader("Hello ")
r2 := strings.NewReader("World")
mr := io.MultiReader(r1, r2)
data, _ := io.ReadAll(mr)
fmt.Println(string(data)) // "Hello World"
// TeeReader——读的同时写副本(计算哈希)
hasher := sha256.New()
tee := io.TeeReader(file, hasher)
io.Copy(dst, tee)
hash := hasher.Sum(nil) // 边读边算——不额外读文件
// MultiWriter——同时写多个目标
logFile, _ := os.OpenFile("app.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
mw := io.MultiWriter(os.Stdout, logFile)
fmt.Fprintln(mw, "同时输出到终端和文件")
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 15.5.3 io.LimitReader / io.SectionReader
// LimitReader——限制读取字节数(防超大请求)
limited := io.LimitReader(req.Body, 10<<20) // 最多 10MB
io.Copy(file, limited)
// SectionReader——读取文件的一段(断点续传)
sr := io.NewSectionReader(file, offset, chunkSize)
io.Copy(chunkWriter, sr)
2
3
4
5
6
7
# 15.5.4 综合案例与思考
综合案例:下载文件 + 计算哈希——全流式,零额外内存
package main
import (
"crypto/sha256"
"fmt"
"io"
"net/http"
"os"
)
func downloadWithHash(url, filePath string) (string, error) {
resp, err := http.Get(url)
if err != nil {
return "", err
}
defer resp.Body.Close()
file, err := os.Create(filePath)
if err != nil {
return "", err
}
defer file.Close()
hasher := sha256.New()
// ★ TeeReader:读一次,同时写给文件和哈希
tee := io.TeeReader(resp.Body, hasher)
if _, err := io.Copy(file, tee); err != nil {
return "", err
}
return fmt.Sprintf("%x", hasher.Sum(nil)), nil
}
func main() {
hash, _ := downloadWithHash("https://example.com/data.bin", "/tmp/data.bin")
fmt.Println("SHA256:", hash)
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
案例知识融合:TeeReader + io.Copy 实现"读一次,两个目的地"——下载和哈希同时进行。不需要把文件读进内存——整个流程只有 io.Copy 的 32KB 缓冲区。
思考题:
- 如果下载过程中网络断开——
TeeReader传给hasher的数据是完整的吗? io.MultiWriter写多个目标时——如果第一个成功、第二个失败——第一个已写的数据会回滚吗?
# 15.6 io/fs(Go 1.16+):抽象文件系统
io/fs 把"文件系统"抽象为接口——让 os.DirFS(真实磁盘)和 embed.FS(嵌入资源)用同一套代码:
// 函数接受 fs.FS 接口——不关心底层是磁盘还是嵌入资源
func listFiles(fsys fs.FS) error {
return fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() {
fmt.Println(path)
}
return nil
})
}
// ① 遍历真实磁盘
listFiles(os.DirFS("/tmp"))
// ② 遍历嵌入资源(见 §15.7)
//go:embed static/*
var staticFiles embed.FS
listFiles(staticFiles)
// ③ 遍历测试用的内存文件系统
listFiles(fstest.MapFS{
"a.txt": &fstest.MapFile{Data: []byte("hello")},
})
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# 15.6.1 综合案例与思考
// 实现一个通用文件读取——支持磁盘和嵌入资源
func readConfig(fsys fs.FS, name string) ([]byte, error) {
f, err := fsys.Open(name)
if err != nil {
return nil, err
}
defer f.Close()
return io.ReadAll(f)
}
2
3
4
5
6
7
8
9
案例知识融合:fs.FS 接口让代码从"依赖具体实现"变成"依赖抽象"——测试时注入 fstest.MapFS,生产时注入 os.DirFS,零改动。
思考题:
os.DirFS("/")会暴露文件系统的根目录——传给你的函数如果通过../能逃逸出/tmp吗?
# 15.7 embed(Go 1.16+):嵌入静态资源
//go:embed 把文件直接编译进二进制——单文件部署、零外部依赖:
import (
"embed"
"net/http"
)
// ① 嵌入单个文件
//go:embed config.yaml
var configData string // 或 []byte
// ② 嵌入整个目录
//go:embed static/*
var staticFiles embed.FS
func main() {
// 读配置
fmt.Println(configData)
// 启动静态文件服务——不需要磁盘上真的有 static/ 目录
http.Handle("/", http.FileServer(http.FS(staticFiles)))
http.ListenAndServe(":8080", nil)
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
embed 规则:
- 只能嵌入同目录或子目录的文件——
../不允许 - 嵌入目录时
*匹配所有文件,但不包括子目录 - 嵌入全部子目录用
**/*
//go:embed templates/*.html // 只嵌 HTML
//go:embed assets/**/* // 嵌所有文件和子目录
var templates embed.FS
2
3
4
# 15.7.1 综合案例与思考
综合案例:单二进制 Web 服务器——HTML + CSS + JS 全嵌入
package main
import (
"embed"
"net/http"
)
//go:embed index.html
//go:embed style.css
//go:embed script.js
var assets embed.FS
func main() {
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
data, _ := assets.ReadFile("index.html")
w.Header().Set("Content-Type", "text/html")
w.Write(data)
})
http.Handle("/static/", http.FileServer(http.FS(assets)))
http.ListenAndServe(":8080", nil)
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
案例知识融合:编译后的二进制包含了所有 HTML/CSS/JS——部署时只需要一个文件。不再需要"Docker 里挂载静态目录"或"CDN 分发静态资源"。
思考题:
embed.FS是只读的——如果运行时需要修改嵌入的配置文件怎么办?- 嵌入一个 100MB 的大文件——编译后的二进制会变大 100MB 吗?
# 15.8 path/filepath:路径操作
# 15.8.1 综合案例与思考
import "path/filepath"
// Join——跨平台拼接路径
filepath.Join("/data", "users", "profile.json") // → "/data/users/profile.json"
filepath.Join("/data/", "/users/") // → "/data/users"(清理冗余)
// Clean——词法规范化
filepath.Clean("a/b/../c") // → "a/c"
filepath.Clean("/a//b") // → "/a/b"
// Abs——转为绝对路径
abs, _ := filepath.Abs("./config.yaml")
// Base / Dir / Ext——路径分解
filepath.Base("/data/users/profile.json") // → "profile.json"
filepath.Dir("/data/users/profile.json") // → "/data/users"
filepath.Ext("profile.json") // → ".json"
// WalkDir——遍历目录树
filepath.WalkDir("/data", func(path string, d fs.DirEntry, err error) error {
if d.IsDir() {
return nil
}
fmt.Println(path)
return nil
})
// Glob——通配符匹配
matches, _ := filepath.Glob("/data/*.json") // → ["/data/a.json", "/data/b.json"]
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
⚠️ 路径安全——防止穿越攻击:
// ❌ 危险:用户输入 + 直接拼接
func readUserFile(baseDir, userPath string) ([]byte, error) {
path := filepath.Join(baseDir, userPath)
return os.ReadFile(path)
}
// 用户输入: ../../etc/passwd → "/var/data/../../etc/passwd"
// ✅ 安全:Clean + 前缀检查
func readUserFileSafe(baseDir, userPath string) ([]byte, error) {
clean := filepath.Clean(filepath.Join(baseDir, userPath))
absBase, _ := filepath.Abs(baseDir)
absPath, _ := filepath.Abs(clean)
if !strings.HasPrefix(absPath, absBase+string(os.PathSeparator)) &&
absPath != absBase {
return nil, errors.New("路径穿越攻击")
}
return os.ReadFile(clean)
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
案例知识融合:filepath.Clean 只是词法清理——消除 .. 和多余斜杠,但不检查目标是否存在。真正的安全防御需要 Clean + 绝对路径前缀检查 + 符号链接解析三层。
思考题:
filepath.Clean消除了..——但如果用户输入的是符号链接(/var/data/symlink → /etc),Clean 能防御吗?- Windows 和 Linux 的路径分隔符不同(
\vs/)——filepath.Join怎么处理?
# 15.9 综合示例:实现 grep 与 wc
package main
import (
"bufio"
"flag"
"fmt"
"os"
"strings"
)
// grep:搜索文件中匹配的行
func grep(filename, pattern string) (int, error) {
file, err := os.Open(filename)
if err != nil {
return 0, err
}
defer file.Close()
count := 0
scanner := bufio.NewScanner(file)
for scanner.Scan() {
if strings.Contains(scanner.Text(), pattern) {
fmt.Println(scanner.Text())
count++
}
}
return count, scanner.Err()
}
// wc:统计文件的行数、单词数、字节数
func wc(filename string) (lines, words, bytes int, err error) {
file, err := os.Open(filename)
if err != nil {
return 0, 0, 0, err
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
lines++
words += len(strings.Fields(scanner.Text()))
bytes += len(scanner.Bytes()) + 1 // +1 for newline
}
return lines, words, bytes, scanner.Err()
}
func main() {
grepFlag := flag.Bool("grep", false, "grep mode")
wcFlag := flag.Bool("wc", false, "wc mode")
pattern := flag.String("pattern", "", "grep pattern")
flag.Parse()
if *grepFlag && *wcFlag {
fmt.Println("只能指定 -grep 或 -wc 之一")
os.Exit(1)
}
filename := flag.Arg(0)
if filename == "" {
fmt.Println("需要指定文件名")
os.Exit(1)
}
switch {
case *grepFlag:
count, _ := grep(filename, *pattern)
fmt.Printf("\n匹配 %d 行\n", count)
case *wcFlag:
l, w, b, _ := wc(filename)
fmt.Printf("%d 行 %d 词 %d 字节 %s\n", l, w, b, filename)
}
}
// 使用:
// go run main.go -wc data.txt
// go run main.go -grep -pattern "ERROR" access.log
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
程序特点:
grep:bufio.Scanner逐行读取 →strings.Contains匹配 → 流式输出wc:一次扫描统计行数/词数/字节数——不额外读文件- 两个函数都只分配 Scanner 的默认 64KB 缓冲区——大文件不爆内存
# 15.10 本章底层原理(简介)
| 本章概念 | 卷三对应篇 |
|---|---|
io.Copy 零拷贝(sendfile/splice) | 32.文件 IO 与零拷贝 |
bufio 批量 syscall 加速 | 32.文件 IO 与零拷贝 |
os.File 底层文件描述符与 poller | 32.文件 IO 与零拷贝 |
# 15.11 Go 新手陷阱 Top 5
| # | 陷阱 | 说明 |
|---|---|---|
| 1 | os.Open 后忘记 defer Close | 文件描述符泄漏——fd 是有限资源。标准写法:f, err := os.Open(...); defer f.Close() |
| 2 | 大文件用 os.ReadFile 一次读 | GB 级文件 → 内存爆炸。改用 bufio.Scanner 或 io.Copy 流式处理。 |
| 3 | bufio.Scanner 默认 64KB 一行——长行报错 | 用 scanner.Buffer(buf, maxSize) 调大。 |
| 4 | 写文件后没 Sync——进程崩溃数据丢失 | Write 后数据在 page cache——没落盘。关键数据 f.Sync() |
| 5 | 用 + 拼路径——跨平台坑 | "data/" + filename → Windows 上 \ 变 /。用 filepath.Join |
# 15.12 思考题
io.Reader的"一个方法"哲学:Java 的InputStream有 10 个方法。Go 的io.Reader只有 1 个——这带来了什么好处?什么场景下 1 个方法反而不方便?os.ReadFilevsio.Copy:读一个 10MB 的文件——os.ReadFile和io.Copy各有什么优劣?多小的文件适合ReadFile?多大的文件必须Copy?bufio.Scannervsbufio.Reader:按行读取时两个都能用——什么时候选 Scanner?什么时候选 Reader?给出两个具体场景。embedvs 外部文件:把配置嵌入二进制方便部署——但如果配置需要热更新呢?怎么设计既支持嵌入又支持热加载?io/fs的价值:os.DirFS和直接os.Open有什么不同?为什么http.FileServer接受的是fs.FS而不是os.File?
# 15.13 训练题
训练 1:实现一个"文件尾部跟踪"(tail -f)——持续监控文件新增的内容:
- 打开文件、seek 到末尾
- 每秒检查是否有新数据(
os.Stat+ 比较大小) - 有新数据就
io.Copy到 stdout - Ctrl+C 退出
训练 2:实现一个"安全路径拼接"函数——防御目录穿越攻击:
- 接受基础目录和用户输入路径
- 用
filepath.Clean+ 绝对路径前缀检查 - 用户输入
../../etc/passwd→ 返回错误而不是访问系统文件
训练 3:用 embed 实现一个"资源管理器"——通过 HTTP 接口查看嵌入的文件:
- 嵌入一个
static/目录(含 HTML/CSS/JS) - 实现
/路由——列出embed.FS中所有文件 - 实现
/view/{name}路由——显示文件内容 - 用
fs.WalkDir遍历嵌入的目录
# 15.14 推荐阅读
- 入门卷:第 13 章 channel——goroutine 间通信
- 入门卷:第 14 章 sync 包——并发安全
- 卷三:32.文件 IO 与零拷贝——
io.Copy零拷贝、bufio批量 syscall 源码级拆解 - Go embed 包文档 (opens new window)
- Go io/fs 包文档 (opens new window)