CodeRoadMap
首页路线题库资源库文章社区
CodeRoadMap

程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。

由 程序员柏风 独立维护 · 关于作者

学习

  • 路线图
  • 课程
  • 面试题
  • 文章
  • 我

更多

  • 资源
  • 社区
  • 关于作者
  • 内容说明

© 2026 CodeRoadMap

津ICP备2026012044号-1·津公网安备12011002026155号·coderoadmap@126.com
Go 语言入门/课时/I/O 与 CLI
RawBlame
课时目录12 节

Go 语言入门

  1. 01Go 是什么
  2. 02基础语法与类型
  3. 03数组、切片与 map
  4. 04结构体与接口
  5. 05模块与包管理
  6. 06并发模型
  7. 07错误处理
  8. 08泛型入门
  9. 09I/O 与 CLI
  10. 10Web 与数据库
  11. 11测试与基准
  12. 12综合练习

I/O 与 CLI

flag、os、bufio、json、cobra、文件处理。

35 分钟

I/O 与 CLI

这一课你将能做到

  1. 用 flag 和 os 编写带子命令式参数的命令行工具,并正确处理 -h 帮助输出
  2. 用 os / bufio 完成文件的整读、整写、逐行流式处理,说出 io.Reader/Writer 的组合意义
  3. 用 encoding/json 在结构体与 JSON 之间互转,掌握 tag 常用规则

为什么要学

Go 的看家本领之一就是写工具:Docker、kubectl、hugo、terraform 全是 Go 写的 CLI。原因很简单——单二进制 + 静态编译 + 跨平台,分发即运行。

这一课同时打通两个通用能力:一是命令行参数解析(flag 起步,cobra 是业界进阶标准),二是文件与流的读写。核心思想一句话:Go 把一切输入输出抽象成 io.Reader / io.Writer 两个单方法接口,文件、网络、内存、压缩全部围绕它们组合。

先建立图像

                 io.Reader(读进来)            io.Writer(写出去)
磁盘文件 ──▶ os.File ──┐                        ┌── os.File ──▶ 磁盘文件
网络连接 ──▶ conn   ──┤   ┌──────────────┐     ├── conn   ──▶ 网络连接
字符串   ──▶ strings.Reader ─┤   你的程序    │ ──▶ bytes.Buffer ─▶ 内存
标准输入 ──▶ os.Stdin ──┘   └──────────────┘     └── os.Stdout ─▶ 终端

所有源/目的地都实现同一对接口 → 中间处理代码可以无痛换数据源
bufio 是加在 Reader/Writer 上的「缓冲层」,攒够一批再搬运

跟着做

步骤 1:os.Args——最朴素的参数

package main

import (
	"fmt"
	"os"
)

func main() {
	fmt.Println("程序名:", os.Args[0])
	fmt.Println("参数个数:", len(os.Args)-1)
	for i, arg := range os.Args[1:] {
		fmt.Printf("第 %d 个参数: %s\n", i+1, arg)
	}
}

运行:go run . hello 42。能用,但要自己处理顺序、默认值、类型转换——所以有了 flag。

步骤 2:flag 包——声明式参数

package main

import (
	"flag"
	"fmt"
)

func main() {
	// 参数名、默认值、帮助说明
	name := flag.String("name", "世界", "称呼")
	times := flag.Int("times", 1, "重复次数")
	verbose := flag.Bool("v", false, "详细输出")

	flag.Parse() // 解析,必须在取值之前

	for i := 0; i < *times; i++ {
		fmt.Printf("你好, %s!\n", *name)
	}
	if *verbose {
		fmt.Println("verbose 模式已开启")
	}
}
$ go run . -name=Gopher -times=2 -v
你好, Gopher!
你好, Gopher!
verbose 模式已开启

$ go run . -h     # 自动生成帮助

返回的是指针(*string),因为 flag 在 Parse 时才能填值。

步骤 3:读文件——整读与流式

package main

import (
	"bufio"
	"fmt"
	"os"
	"strings"
)

func main() {
	// 先造一个演示文件
	err := os.WriteFile("demo.txt",
		[]byte("第一行\n第二行\n第三行\n"), 0644)
	if err != nil {
		fmt.Println("写文件失败:", err)
		return
	}

	// 方式一:os.ReadFile 整读(小文件,一把梭)
	data, err := os.ReadFile("demo.txt")
	if err != nil {
		fmt.Println("读文件失败:", err)
		return
	}
	fmt.Println("整读字节量:", len(data))

	// 方式二:bufio.Scanner 逐行流式(大文件友好,内存恒定)
	f, err := os.Open("demo.txt")
	if err != nil {
		fmt.Println("打开失败:", err)
		return
	}
	defer f.Close() // 打开的文件必须关闭

	scanner := bufio.NewScanner(f)
	lineNo := 0
	for scanner.Scan() { // 逐行推进
		lineNo++
		fmt.Printf("第%d行: %s\n", lineNo, scanner.Text())
	}
	// Scan 循环结束后必须检查 scanner.Err(),否则读错误会被静默吞掉
	if err := scanner.Err(); err != nil {
		fmt.Println("扫描出错:", err)
	}

	// Scanner 也能扫内存:strings.Reader 同样是 io.Reader
	sc := bufio.NewScanner(strings.NewReader("a b c"))
	sc.Split(bufio.ScanWords) // 按词切分
	for sc.Scan() {
		fmt.Print(sc.Text(), "|")
	}
	fmt.Println()
}

选型:小文件(几 MB 内)os.ReadFile;日志、大文件用 Scanner 流式;Scanner 默认按行切分,单行超过 64KB 需 scanner.Buffer(...) 调大。

步骤 4:写文件——覆盖与追加

package main

import (
	"bufio"
	"os"
)

func main() {
	// 覆盖写:0644 是文件权限位(属主读写,其他人只读)
	err := os.WriteFile("out.txt", []byte("hello\n"), 0644)
	if err != nil {
		panic(err)
	}

	// 追加写:OpenFile 三参数模式
	f, err := os.OpenFile("out.txt", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
	if err != nil {
		panic(err)
	}
	defer f.Close()

	w := bufio.NewWriter(f) // 缓冲层,减少系统调用
	for i := 1; i <= 3; i++ {
		w.WriteString("追加的行\n")
	}
	w.Flush() // 忘了 Flush = 数据还在缓冲区,文件是空的!
}

步骤 5:io.Reader / io.Writer 的组合威力

package main

import (
	"bytes"
	"fmt"
	"io"
	"os"
	"strings"
)

func main() {
	// io.Copy:从任意 Reader 复制到任意 Writer,一行搞定
	var buf bytes.Buffer
	n, _ := io.Copy(&buf, strings.NewReader("来自内存的数据"))
	fmt.Println("拷贝了", n, "字节")

	// 同一段处理逻辑,换个数据源照样跑:
	// io.Copy(os.Stdout, resp.Body)  ← 从 HTTP 响应体搬到标准输出

	// io.ReadAll:读尽 Reader(注意:大流会占内存)
	content, _ := io.ReadAll(strings.NewReader("hello io"))
	fmt.Println(string(content))

	// io.LimitReader:只读前 N 字节,防大响应撑爆内存
	r := io.LimitReader(strings.NewReader("0123456789"), 4)
	b, _ := io.ReadAll(r)
	fmt.Println(string(b)) // 0123
}

这就是小接口大组合:Read([]byte) (int, error) 一个方法,串起了文件、网络、压缩、加密所有数据面。

步骤 6:encoding/json——结构体与 JSON 互转

package main

import (
	"encoding/json"
	"fmt"
)

type User struct {
	Name  string `json:"name"`             // tag 定义 JSON 字段名
	Age   int    `json:"age,omitempty"`     // 零值时省略该字段
	email string // 未导出字段:JSON 序列化会直接忽略
	Tags  []string `json:"tags,omitempty"`
}

func main() {
	u := User{Name: "张三", Age: 0, Tags: []string{"go", "dev"}}

	// Marshal:结构体 → JSON 字节
	data, err := json.Marshal(u)
	if err != nil {
		panic(err)
	}
	fmt.Println(string(data))
	// {"name":"张三","tags":["go","dev"]}  ← age 因 omitempty 被省略,email 没出现

	// Unmarshal:JSON → 结构体
	raw := []byte(`{"name":"李四","age":25,"email":"hidden@x.com"}`)
	var u2 User
	if err := json.Unmarshal(raw, &u2); err != nil { // 必须传指针
		panic(err)
	}
	fmt.Printf("%+v\n", u2)

	// 动态解析:结构未知时用 map[string]any
	var anyMap map[string]any
	json.Unmarshal(raw, &anyMap)
	fmt.Println(anyMap["name"])
	fmt.Printf("age 的实际类型: %T\n", anyMap["age"]) // float64!JSON 数字默认解成 float64
}

步骤 7:组合实战——JSON 持久化的 todo CLI

package main

import (
	"encoding/json"
	"flag"
	"fmt"
	"os"
)

type Todo struct {
	Text string `json:"text"`
	Done bool   `json:"done"`
}

const dataFile = "todo.json"

func load() []Todo {
	data, err := os.ReadFile(dataFile)
	if err != nil {
		return []Todo{} // 首次运行没有文件,返回空列表
	}
	var todos []Todo
	if err := json.Unmarshal(data, &todos); err != nil {
		fmt.Fprintln(os.Stderr, "数据文件损坏:", err)
		os.Exit(1)
	}
	return todos
}

func save(todos []Todo) {
	data, err := json.MarshalIndent(todos, "", "  ")
	if err != nil {
		panic(err)
	}
	if err := os.WriteFile(dataFile, data, 0644); err != nil {
		panic(err)
	}
}

func main() {
	add := flag.String("add", "", "新增事项")
	list := flag.Bool("list", false, "列出全部")
	done := flag.Int("done", 0, "完成第几项")

	flag.Parse()
	todos := load()

	switch {
	case *add != "":
		todos = append(todos, Todo{Text: *add})
		save(todos)
		fmt.Println("已添加:", *add)
	case *list:
		for i, t := range todos {
			mark := "[ ]"
			if t.Done {
				mark = "[x]"
			}
			fmt.Printf("%d. %s %s\n", i+1, mark, t.Text)
		}
	case *done > 0 && *done <= len(todos):
		todos[*done-1].Done = true
		save(todos)
		fmt.Println("已完成第", *done, "项")
	default:
		flag.Usage()
	}
}
$ go run . -add "学完 Go 入门"
$ go run . -add "写一个 CLI"
$ go run . -list
1. [ ] 学完 Go 入门
2. [ ] 写一个 CLI
$ go run . -done 1
$ go run . -list
1. [x] 学完 Go 入门

二十几行,一个可用的持久化命令行工具——Go 工程效率的缩影。

讲清楚要点

  1. io.Reader/Writer 是单方法接口:一切 I/O 围绕它们组合,bufio(缓冲)、io.Copy(搬运)、io.LimitReader(限流)都是装饰层。
  2. 打开的资源要 defer Close:os.Open 打开的文件、HTTP 响应体、数据库 rows 都遵守;os.ReadFile/WriteFile 这种一步式的内部已处理。
  3. bufio.Writer 必须 Flush:缓冲区没满就不会落盘;忘了 Flush 是「程序正常结束、文件却是空的」的头号原因。
  4. Scanner 用完查 Err:Scan() 返回 false 可能是读完,也可能是出错,不查 scanner.Err() 会把 IO 错误静默吞掉。
  5. JSON tag 规则:json:"name" 改名;omitempty 零值省略(注意空切片、false、0、"" 都算零值);未导出字段永远不参与序列化。
  6. map[string]any 里的 JSON 数字是 float64:动态解析时取整数要 int(v.(float64));精确处理大数用 json.Number。

你可能会懵的地方

  • os.ReadFile 已经能读文件,为什么还要 bufio.Scanner? ReadFile 把整个文件读进内存,100MB 的日志就占 100MB;Scanner 按行流式读取,内存占用恒定。文件小图省事,文件大必须流式。

  • 写文件的 0644 是什么? Unix 权限位:属主可读写(6=110),组和其他人只读(4=100)。Windows 下基本被忽略但必须传(API 要求)。

  • cobra 是什么?现在要学吗? 业界最流行的 CLI 框架(kubectl、docker 都用它),提供子命令、自动补全、帮助生成。flag 够用时就用 flag;当工具出现「子命令」结构(tool add / tool list)再上 cobra,迁移成本不高。

  • Unmarshal 遇到不认识的字段会报错吗? 不会,静默忽略。这是容错设计(API 加字段不破坏老客户端);想严格校验用 decoder.DisallowUnknownFields()。

  • 字符串和 []byte 怎么互转,有代价吗? []byte(s)、string(b)。转换通常发生拷贝,大字符串高频转换有性能开销;unsafe 可零拷贝但不在本课范围。

错法对照

错法问题正解
bufio.NewWriter(f) 后不 Flush文件为空或内容缺失写完 w.Flush()
Scan 循环后不查 scanner.Err()IO 错误被静默吞掉循环结束检查 err
JSON 字段名小写无 tag序列化后字段名是 Name 大写加 json:"name" tag
想序列化私有字段未导出字段不参与 JSON提供导出字段或自定义 MarshalJSON
os.Open 后忘记 Close文件句柄泄漏defer f.Close()
动态 JSON 里 int(anyMap["age"])panic:实际是 float64int(v.(float64))

卡点急救

  1. open xxx: no such file or directory:相对路径基于运行目录而非源码目录;用绝对路径或在项目根运行。
  2. permission denied:目标目录只读或文件被占用;检查路径与权限。
  3. JSON 解析结果全零值:字段 tag 与 JSON 不匹配、忘了传指针、或解析到了未导出字段;先打印 err 确认。
  4. 追加写变成覆盖写:OpenFile 少了 os.O_APPEND 标志。

面试常问

  • io.Reader 只有一个 Read 方法,为什么这样设计? 极小接口最大化组合性:任何数据源(文件/网络/内存)与任何消费者只需对齐这一个方法,就能用 bufio、io.Copy、gzip.Writer 等装饰层自由拼接,这是 Go「小接口、大组合」哲学的最佳标本。

  • bufio 的原理与收益? 在真实 Reader/Writer 外套内存缓冲,把大量小读写合并成少量系统调用。读:缓冲区空时整块预读;写:攒满或 Flush 时落盘。收益在频繁小 IO 场景可达数量级提升。

  • Go 如何处理 JSON 中的大整数/精确数字? 默认 any 解析为 float64 会丢精度;用 json.Decoder.UseNumber(),数字解析为字符串型 json.Number,再按需转 int64。

  • 设计一个 CLI 工具的思路? 参数解析(flag/cobra)→ 输入读取(os.Stdin/文件,统一按 io.Reader 处理)→ 核心逻辑与 I/O 解耦(便于测试)→ 输出与错误码(stdout 出数据、stderr 出错误、退出码表达成败)。

自测三问

  1. Scanner 逐行读完一个大文件,循环结束后还必须做什么?为什么?
  2. omitempty 会省略哪些值?写一个会被它「误伤」的场景。
  3. io.Copy(dst io.Writer, src io.Reader) 为什么能同时用于文件、网络和内存?

小结一句话

Go 的 I/O 世界由 io.Reader/Writer 两个小接口拼装而成:文件用 os、提速用 bufio、传输用 io.Copy、交换用 JSON——CLI 工具不过是把这些积木按 flag 的指令摆好。

延伸

  • Go 官方教程:使用 JSON
  • io 包文档
  • 下一课:Web 与数据库——标准库 net/http 十行起服务,Go 最主流的用武之地。
加载本节练习…
← 上一课泛型入门下一课 →Web 与数据库