编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • C语言入门精通

  • Cpp入门到精通

  • Java入门精通

  • Go入门到精通

    • 入门教程

      • README
      • Go简史
      • 基础语法
      • 数据类型
      • 运算符
      • 复合类型
      • 流程语句
      • 函数
      • 指针与逃逸
      • 结构体与方法
      • 接口与多态
      • 错误处理
      • 并发goroutine
      • 通道channel
      • 同步sync包
      • IO和文件
        • 目录介绍
        • 15.1 本章学习目标
        • 15.2 io.Reader / io.Writer 两个小接口
          • 15.2.1 Reader 接口与四种返回情况
          • 15.2.2 Writer 接口
          • 15.2.3 接口组合——装饰器模式
          • 15.2.4 综合案例与思考
        • 15.3 os 包:文件操作
          • 15.3.1 os.Open / os.Create / os.OpenFile
          • 15.3.2 os.ReadFile / os.WriteFile(替代 io/ioutil)
          • 15.3.3 文件权限与模式
          • 15.3.4 综合案例与思考
        • 15.4 bufio:缓冲 IO
          • 15.4.1 bufio.Scanner 按行读取
          • 15.4.2 bufio.Reader / bufio.Writer
          • 15.4.3 综合案例与思考
        • 15.5 io 包工具函数
          • 15.5.1 io.Copy / io.CopyN / io.CopyBuffer
          • 15.5.2 io.MultiReader / io.TeeReader / io.MultiWriter
          • 15.5.3 io.LimitReader / io.SectionReader
          • 15.5.4 综合案例与思考
        • 15.6 io/fs(Go 1.16+):抽象文件系统
          • 15.6.1 综合案例与思考
        • 15.7 embed(Go 1.16+):嵌入静态资源
          • 15.7.1 综合案例与思考
        • 15.8 path/filepath:路径操作
          • 15.8.1 综合案例与思考
        • 15.9 综合示例:实现 grep 与 wc
        • 15.10 本章底层原理(简介)
        • 15.11 Go 新手陷阱 Top 5
        • 15.12 思考题
        • 15.13 训练题
        • 15.14 推荐阅读
      • 标准库与泛型
      • 工程化与模块
      • 特性图谱
    • 综合案例

    • 专栏博客

    • 开发技巧

  • JavaScript入门

  • CodeX
  • Go入门到精通
  • 入门教程
杨充
2026-05-21
目录

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.2.1 Reader 接口与四种返回情况
    • 15.2.2 Writer 接口
    • 15.2.3 接口组合——装饰器模式
    • 15.2.4 综合案例与思考
  • 15.3 os 包:文件操作
    • 15.3.1 os.Open / os.Create / os.OpenFile
    • 15.3.2 os.ReadFile / os.WriteFile(替代 io/ioutil)
    • 15.3.3 文件权限与模式
    • 15.3.4 综合案例与思考
  • 15.4 bufio:缓冲 IO
    • 15.4.1 bufio.Scanner 按行读取
    • 15.4.2 bufio.Reader / bufio.Writer
    • 15.4.3 综合案例与思考
  • 15.5 io 包工具函数
    • 15.5.1 io.Copy / io.CopyN / io.CopyBuffer
    • 15.5.2 io.MultiReader / io.TeeReader / io.MultiWriter
    • 15.5.3 io.LimitReader / io.SectionReader
    • 15.5.4 综合案例与思考
  • 15.6 io/fs(Go 1.16+):抽象文件系统
    • 15.6.1 综合案例与思考
  • 15.7 embed(Go 1.16+):嵌入静态资源
    • 15.7.1 综合案例与思考
  • 15.8 path/filepath:路径操作
    • 15.8.1 综合案例与思考
  • 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)
}
1
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](可能有数据),返回错误
}
1
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)
}
1
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 内存
1
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)
}
1
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完成")
}
1
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 返回的字节数。

思考题:

  1. 为什么 io.Reader 只定义了一个方法?如果加上 Close() 会有什么问题?
  2. 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)
1
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)
1
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)
1
2
3
4
5
6
7
8
// 数据文件——只读
os.WriteFile("config.json", data, 0644)

// 可执行脚本
os.WriteFile("script.sh", script, 0755)

// 密钥文件——仅当前用户可读写
os.WriteFile(".secret", key, 0600)
1
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("写入完成")
}
1
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 是原子操作——要么看到旧文件,要么看到新文件。

思考题:

  1. 为什么 CreateTemp 要在同目录下创建临时文件——不能放在 /tmp 吗?
  2. 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)
}
1
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)
}
1
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 一行
1
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——否则数据在缓冲区里
1
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)
    }
}
1
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 计数。

思考题:

  1. Scanner.Buffer 的第二个参数(最大 token 大小)如果小于某行长度——会发生什么?
  2. 上面的 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)
1
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, "同时输出到终端和文件")
1
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)
1
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)
}
1
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 缓冲区。

思考题:

  1. 如果下载过程中网络断开——TeeReader 传给 hasher 的数据是完整的吗?
  2. 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")},
})
1
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)
}
1
2
3
4
5
6
7
8
9

案例知识融合:fs.FS 接口让代码从"依赖具体实现"变成"依赖抽象"——测试时注入 fstest.MapFS,生产时注入 os.DirFS,零改动。

思考题:

  1. 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)
}
1
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
1
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)
}
1
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 分发静态资源"。

思考题:

  1. embed.FS 是只读的——如果运行时需要修改嵌入的配置文件怎么办?
  2. 嵌入一个 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"]
1
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)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

案例知识融合:filepath.Clean 只是词法清理——消除 .. 和多余斜杠,但不检查目标是否存在。真正的安全防御需要 Clean + 绝对路径前缀检查 + 符号链接解析三层。

思考题:

  1. filepath.Clean 消除了 ..——但如果用户输入的是符号链接(/var/data/symlink → /etc),Clean 能防御吗?
  2. 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
1
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 思考题

  1. io.Reader 的"一个方法"哲学:Java 的 InputStream 有 10 个方法。Go 的 io.Reader 只有 1 个——这带来了什么好处?什么场景下 1 个方法反而不方便?

  2. os.ReadFile vs io.Copy:读一个 10MB 的文件——os.ReadFile 和 io.Copy 各有什么优劣?多小的文件适合 ReadFile?多大的文件必须 Copy?

  3. bufio.Scanner vs bufio.Reader:按行读取时两个都能用——什么时候选 Scanner?什么时候选 Reader?给出两个具体场景。

  4. embed vs 外部文件:把配置嵌入二进制方便部署——但如果配置需要热更新呢?怎么设计既支持嵌入又支持热加载?

  5. 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)
上次更新: 2026/06/14, 15:49:50
同步sync包
标准库与泛型

← 同步sync包 标准库与泛型→

最近更新
01
信号崩溃快速排查
06-15
02
CoreDump破案
06-15
03
perf火焰图实战
06-15
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 桂ICP备2024034950号 | 桂公网安备45142202000030
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式