大家好,我是程序员柏风,AI 最全的 Vibecoding 学习地图 http://coderoadmap.cn 作者。
I/O 与 CLI
这一课你将能做到
- 用
flag和os编写带子命令式参数的命令行工具,并正确处理-h帮助输出 - 用
os/bufio完成文件的整读、整写、逐行流式处理,说出 io.Reader/Writer 的组合意义 - 用
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 工程效率的缩影。
讲清楚要点
- io.Reader/Writer 是单方法接口:一切 I/O 围绕它们组合,
bufio(缓冲)、io.Copy(搬运)、io.LimitReader(限流)都是装饰层。 - 打开的资源要 defer Close:os.Open 打开的文件、HTTP 响应体、数据库 rows 都遵守;
os.ReadFile/WriteFile这种一步式的内部已处理。 - bufio.Writer 必须 Flush:缓冲区没满就不会落盘;忘了 Flush 是「程序正常结束、文件却是空的」的头号原因。
- Scanner 用完查 Err:
Scan()返回 false 可能是读完,也可能是出错,不查scanner.Err()会把 IO 错误静默吞掉。 - JSON tag 规则:
json:"name"改名;omitempty零值省略(注意空切片、false、0、"" 都算零值);未导出字段永远不参与序列化。 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:实际是 float64 | int(v.(float64)) |
卡点急救
open xxx: no such file or directory:相对路径基于运行目录而非源码目录;用绝对路径或在项目根运行。permission denied:目标目录只读或文件被占用;检查路径与权限。- JSON 解析结果全零值:字段 tag 与 JSON 不匹配、忘了传指针、或解析到了未导出字段;先打印 err 确认。
- 追加写变成覆盖写: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 出错误、退出码表达成败)。
自测三问
- Scanner 逐行读完一个大文件,循环结束后还必须做什么?为什么?
omitempty会省略哪些值?写一个会被它「误伤」的场景。- 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 最主流的用武之地。