编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • 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
      • 终端待办任务清单
        • 目录介绍
        • 00.案例元信息
        • 01.需求拆解
          • 1.1 真实场景
          • 1.2 功能边界
          • 1.3 验收标准
        • 02.架构设计
          • 2.1 模块图
          • 2.2 数据流
          • 2.3 关键决策
          • 2.4 项目骨架
        • 03.核心数据结构
          • 3.1 Todo:领域实体
          • 3.2 Store:聚合根
          • 3.3 Command:策略模式
        • 04.关键流程逐段实现
          • 4.1 项目骨架与 go.mod
          • 4.2 数据模型 Todo 与 Store 加载
          • 4.3 原子写入:Save
          • 4.4 业务方法:Add / Done / Rm / Edit / Clear / List
          • 4.5 ANSI 着色:internal/ansi/color.go
          • 4.6 子命令分发:Command 接口与 Registry
          • 4.7 Add 命令实现
          • 4.8 List 命令实现(含表格输出)
          • 4.9 Done / Rm / Edit / Clear / Version
          • 4.10 main 入口与退出码映射
          • 4.11 编译与安装
        • 05.反模式对照
          • 反模式 1:直接 os.WriteFile,不原子
          • 反模式 2:fmt.Errorf("%v", err) 丢链
          • 反模式 3:用 panic 处理用户输入
          • 反模式 4:os.Exit 在业务函数里
          • 反模式 5:全局 flag 包做子命令
          • 反模式 6:time.Parse 默认 UTC
          • 反模式 7:for _, t := range items { ... &t ... } 悬挂指针
          • 反模式 8:忽略 Close() 的 error
        • 06.测试与基准
          • 6.1 表驱动单测:store_test.go
          • 6.2 跑测试
          • 6.3 Benchmark:Save 性能
          • 6.4 集成测试:端到端 8 步
        • 07.卷一章节反向索引
        • 08.拓展挑战
          • 挑战 1(⭐⭐):把 JSON 换成 SQLite
          • 挑战 2(⭐⭐⭐):增加 --undo
          • 挑战 3(⭐⭐⭐⭐):改成 TUI
          • 挑战 4(⭐⭐):完整国际化
          • 挑战 5(⭐⭐⭐):发布到 GitHub Release
        • 卷末小结
      • golog日志分析
      • 短链服务并发设计
      • 并发任务调度引擎
      • 静态博客生成引擎
    • 专栏博客

    • 开发技巧

  • JavaScript入门

  • CodeX
  • Go入门到精通
  • 综合案例
杨充
2026-05-23
目录

终端待办任务清单

# 案例 01 · gotodo · 命令行待办事项管理器

卷二第 1 篇 · 难度 ⭐⭐ · 预估 4 小时 · 字数目标 ~1.5 万字 · 代码量 ~600 行

本案例承诺:读完即写完。从空目录到 go install 可全局调用的真实 CLI,零第三方依赖,所有代码可直接复制运行。


# 目录介绍

  • 00.案例元信息
  • 01.需求拆解
  • 02.架构设计
  • 03.核心数据结构
  • 04.关键流程逐段实现
  • 05.反模式对照
  • 06.测试与基准
  • 07.卷一章节反向索引
  • 08.拓展挑战

# 00.案例元信息

项目 内容
难度 ⭐⭐
预估时长 4 小时(含动手敲完 + 跑通测试)
前置章节 卷一第 1-11 章 + 第 15 章
主题领域 CLI 工具 / 文件 IO / JSON 持久化 / 错误处理
最终产物 gotodo 二进制(macOS / Linux / Windows 通用),可 go install
0 第三方库 ✅(仅 flag / encoding/json / os / time 等 stdlib)
Go 基线 1.22+

功能列表:

命令 作用 示例
add 新增任务 gotodo add "买牛奶" --tag shopping --due 2026-06-01
list 列出任务 gotodo list --filter todo --tag shopping
done 标记完成 gotodo done 3
rm 删除任务 gotodo rm 3
edit 修改任务 gotodo edit 3 "买全脂牛奶"
clear 清空已完成 gotodo clear
version 输出版本信息 gotodo version

非功能要求:

  • 数据持久化到 ~/.gotodo.json,原子写入(写临时文件 + os.Rename),中途断电不损坏数据
  • 彩色输出(用 ANSI 转义码,自动检测是否 TTY,不引入第三方库)
  • 退出码语义化:成功 0、用户输入错误 2、文件 IO 错误 3
  • 全部错误使用 %w 链式包装,可被 errors.Is/As 解构

# 01.需求拆解

# 1.1 真实场景

我每天早上想用 30 秒在终端里写下今天 5 件事,下班之前一个个划掉。GUI Todo 大多需要登录、同步、要订阅;命令行 Todo 要么得装 npm、要么是 Python 脚本依赖 pip。我想要一个 下载即用、零依赖、5MB 二进制、跨平台 的命令行 Todo。

# 1.2 功能边界

做 不做
单用户、单文件存储 多用户、云同步
文本任务 + 截止日期 + 标签 子任务 / 优先级 / 重复任务(留作拓展挑战)
终端彩色表格输出 TUI 全屏交互(卷二案例 05 才会涉及)
go install 全局调用 systemd 守护、brew 包(卷四再讲)

# 1.3 验收标准

# 端到端 8 步验收
gotodo add "学 Go"                       # → "✓ added #1"
gotodo add "买牛奶" --tag shop --due 2026-06-01
gotodo list                              # → 看到 2 行表格
gotodo done 1                            # → "✓ #1 marked done"
gotodo list --filter todo                # → 只剩 #2
gotodo edit 2 "买全脂牛奶"               # → "✓ #2 updated"
gotodo rm 2                              # → "✓ #2 removed"
gotodo clear                             # → "✓ cleared 1 done item"
1
2
3
4
5
6
7
8
9

只要 8 条命令在 macOS / Linux / Windows PowerShell 下都能跑通、且 ~/.gotodo.json 中途任何一步 kill -9 都不损坏,案例就算交付。


# 02.架构设计

# 2.1 模块图

flowchart LR
    A[main.go<br/>os.Args] --> B[cli.Dispatch<br/>子命令分发]
    B --> C1[cmd.Add]
    B --> C2[cmd.List]
    B --> C3[cmd.Done]
    B --> C4[cmd.Rm/Edit/Clear]
    C1 & C2 & C3 & C4 --> D[store.Store<br/>领域逻辑]
    D --> E[file.AtomicWrite<br/>持久化]
    E --> F[(~/.gotodo.json)]
1
2
3
4
5
6
7
8
9

# 2.2 数据流

Add 命令的完整链路:

os.Args ─► flag.Parse ─► cmd.Add(store, args)
                                  │
                                  ▼
                           store.Add(text, tag, due)
                                  │
                            ┌─────┴─────┐
                            ▼           ▼
                       生成 ID     append 到 store.items
                            └─────┬─────┘
                                  ▼
                           store.Save()
                                  │
                                  ▼
                  WriteFile(tmp) ─► Rename(tmp → final)
1
2
3
4
5
6
7
8
9
10
11
12
13
14

# 2.3 关键决策

决策点 选择 替代方案 为什么
CLI 解析 stdlib flag cobra / urfave/cli 教学上希望读者看懂 flag.NewFlagSet;案例 03 才上中间件式 mux
存储格式 JSON sqlite / bolt 人类可读、cat ~/.gotodo.json 即可调试;卷三才上嵌入式 KV
写入方式 tmp + rename 直接 os.WriteFile 原子语义防损坏 —— 这是 stdlib 文件 IO 的标准姿势
子命令分发 map[string]Command 大 switch 接口 + map 让新增命令只需注册一行,闭合开放原则
全局可变状态 注入 *Store 到 Command init() 全局变量 显式依赖才能写测试

# 2.4 项目骨架

gotodo/
├── go.mod
├── main.go                       (10 行:os.Exit 包装)
├── cmd/
│   └── gotodo/
│       └── main.go               (实际入口;把逻辑放到 internal 之外便于发布)
├── internal/
│   ├── cli/
│   │   ├── dispatch.go           (Command 接口 + Registry)
│   │   ├── add.go
│   │   ├── list.go
│   │   ├── done.go
│   │   ├── rm.go
│   │   ├── edit.go
│   │   ├── clear.go
│   │   └── version.go
│   ├── store/
│   │   ├── store.go              (业务逻辑 + 持久化)
│   │   └── store_test.go
│   └── ansi/
│       └── color.go              (TTY 检测 + ANSI 着色)
└── README.md
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

为什么用 internal/? —— 卷一第 17 章讲过:internal/ 包外部模块无法 import,意味着我们对外 只暴露二进制,今后重构时不用担心破坏使用者代码。


# 03.核心数据结构

# 3.1 Todo:领域实体

// internal/store/store.go
package store

import "time"

// Todo represents a single task. JSON tags use lowercase to keep the file readable.
type Todo struct {
    ID        int       `json:"id"`
    Text      string    `json:"text"`
    Tag       string    `json:"tag,omitempty"`
    Due       time.Time `json:"due,omitempty"`
    Done      bool      `json:"done,omitempty"`
    CreatedAt time.Time `json:"created_at"`
    DoneAt    time.Time `json:"done_at,omitempty"`
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

设计权衡:

  • omitempty 让未完成任务的 JSON 不出现 done: false,文件更紧凑,肉眼也能扫
  • time.Time 直接序列化:JSON 用 RFC 3339,未来要换时区只动 marshal 层
  • 不用 UUID:用户在终端里打 gotodo done 23 比 gotodo done a1b2c3d4-... 友好得多。代价是 ID 在 clear 后不重排(文档写明)

# 3.2 Store:聚合根

// internal/store/store.go (续)
type Store struct {
    path  string
    items []Todo
    next  int   // 下一个分配的 ID(单调递增,不复用)
}
1
2
3
4
5
6

next 单调递增的好处:删除任务后 ID 不复用,外部脚本 gotodo done 7 永远指向同一条,不会出错位事故。代价是文件里 ID 会出现"空洞",可接受。

# 3.3 Command:策略模式

// internal/cli/dispatch.go
package cli

import (
    "context"
    "io"
)

// Command is the contract every subcommand implements.
type Command interface {
    Name() string
    Synopsis() string
    Run(ctx context.Context, args []string, out io.Writer) error
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

为什么是接口而非函数类型?—— 我们需要 Name() / Synopsis() 两个元数据方法做 gotodo help 自动生成,纯函数表达不出来。


# 04.关键流程逐段实现

节奏:每个小节 = 一段完整可粘贴的代码 + 关键点解读。把代码顺序敲一遍,最终就能跑出可执行二进制。

# 4.1 项目骨架与 go.mod

mkdir -p gotodo/cmd/gotodo gotodo/internal/{cli,store,ansi}
cd gotodo
go mod init github.com/yc/gotodo
1
2
3
// go.mod
module github.com/yc/gotodo

go 1.22
1
2
3
4

注意:不写任何 require。这就是"零第三方库"的硬证据。

# 4.2 数据模型 Todo 与 Store 加载

// internal/store/store.go
package store

import (
    "encoding/json"
    "errors"
    "fmt"
    "io/fs"
    "os"
    "path/filepath"
    "time"
)

// ErrNotFound is returned when a Todo with the given ID does not exist.
var ErrNotFound = errors.New("todo not found")

// Open loads a Store from path. If the file does not exist, returns an empty Store.
func Open(path string) (*Store, error) {
    s := &Store{path: path, next: 1}
    data, err := os.ReadFile(path)
    if errors.Is(err, fs.ErrNotExist) {
        return s, nil
    }
    if err != nil {
        return nil, fmt.Errorf("open store %q: %w", path, err)
    }
    var dump struct {
        Next  int    `json:"next"`
        Items []Todo `json:"items"`
    }
    if err := json.Unmarshal(data, &dump); err != nil {
        return nil, fmt.Errorf("parse store %q: %w", path, err)
    }
    s.items = dump.Items
    if dump.Next > 0 {
        s.next = dump.Next
    }
    return s, nil
}

// DefaultPath returns ~/.gotodo.json (or %USERPROFILE% on Windows).
func DefaultPath() (string, error) {
    home, err := os.UserHomeDir()
    if err != nil {
        return "", fmt.Errorf("locate home dir: %w", err)
    }
    return filepath.Join(home, ".gotodo.json"), nil
}
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

关键点:

  1. 文件不存在 ≠ 错误。我们用 errors.Is(err, fs.ErrNotExist) 区分"首次运行"与"真 IO 故障",比 os.IsNotExist 更现代(卷一第 11 章)。
  2. 错误一律 %w 包装,附带文件路径上下文。这样 main 里 errors.As 能拿到原始错误码做差异化退出码。
  3. DefaultPath 走 os.UserHomeDir,跨平台不踩坑(Windows 不是 /home/...)。

# 4.3 原子写入:Save

这是本案例的核心反模式纠正点。

// internal/store/store.go (续)
func (s *Store) Save() (err error) {
    dump := struct {
        Next  int    `json:"next"`
        Items []Todo `json:"items"`
    }{Next: s.next, Items: s.items}

    data, err := json.MarshalIndent(dump, "", "  ")
    if err != nil {
        return fmt.Errorf("marshal store: %w", err)
    }

    dir := filepath.Dir(s.path)
    tmp, err := os.CreateTemp(dir, ".gotodo.*.tmp")
    if err != nil {
        return fmt.Errorf("create tmp file: %w", err)
    }
    tmpName := tmp.Name()

    // 即使后续 panic / err,tmp 也要被清掉
    defer func() {
        if err != nil {
            _ = os.Remove(tmpName)
        }
    }()

    if _, err = tmp.Write(data); err != nil {
        _ = tmp.Close()
        return fmt.Errorf("write tmp: %w", err)
    }
    if err = tmp.Sync(); err != nil { // 强刷盘,防 OS 缓冲
        _ = tmp.Close()
        return fmt.Errorf("fsync tmp: %w", err)
    }
    if err = tmp.Close(); err != nil {
        return fmt.Errorf("close tmp: %w", err)
    }

    // 关键一步:rename 在同一文件系统下是原子的(POSIX)
    if err = os.Rename(tmpName, s.path); err != nil {
        return fmt.Errorf("rename %s -> %s: %w", tmpName, s.path, err)
    }
    return nil
}
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

为什么不能直接 os.WriteFile?

os.WriteFile 内部是 truncate + write。如果在 truncate 之后、write 完之前进程崩溃,原文件就被截断成空文件——历史 todo 全丢。

tmp + Sync + Rename 三连保证:

阶段 崩溃后果
写 tmp 中途崩 原文件完整无损;tmp 残留靠 defer 清理
Sync() 之后但 Rename 之前崩 原文件完整无损;tmp 持久化但游离
Rename 之中崩 POSIX 保证 rename 是原子的,要么旧要么新,绝无中间态

这是工业级文件持久化的最小完备实现,记住这个模板,本卷之后所有需要写文件的场景都用它。

# 4.4 业务方法:Add / Done / Rm / Edit / Clear / List

// internal/store/store.go (续)
type AddOption struct {
    Tag string
    Due time.Time
}

func (s *Store) Add(text string, opt AddOption) Todo {
    t := Todo{
        ID:        s.next,
        Text:      text,
        Tag:       opt.Tag,
        Due:       opt.Due,
        CreatedAt: time.Now(),
    }
    s.items = append(s.items, t)
    s.next++
    return t
}

func (s *Store) Done(id int) error {
    for i := range s.items {
        if s.items[i].ID == id {
            if s.items[i].Done {
                return nil // idempotent
            }
            s.items[i].Done = true
            s.items[i].DoneAt = time.Now()
            return nil
        }
    }
    return fmt.Errorf("done #%d: %w", id, ErrNotFound)
}

func (s *Store) Rm(id int) error {
    for i := range s.items {
        if s.items[i].ID == id {
            s.items = append(s.items[:i], s.items[i+1:]...)
            return nil
        }
    }
    return fmt.Errorf("rm #%d: %w", id, ErrNotFound)
}

func (s *Store) Edit(id int, text string) error {
    for i := range s.items {
        if s.items[i].ID == id {
            s.items[i].Text = text
            return nil
        }
    }
    return fmt.Errorf("edit #%d: %w", id, ErrNotFound)
}

// Clear removes all done items, returns the count removed.
func (s *Store) Clear() int {
    kept := s.items[:0] // 复用底层数组,零分配
    var n int
    for _, t := range s.items {
        if t.Done {
            n++
            continue
        }
        kept = append(kept, t)
    }
    s.items = kept
    return n
}

// Filter is the predicate type used by List.
type Filter struct {
    OnlyTodo bool
    OnlyDone bool
    Tag      string
}

func (s *Store) List(f Filter) []Todo {
    out := make([]Todo, 0, len(s.items))
    for _, t := range s.items {
        if f.OnlyTodo && t.Done {
            continue
        }
        if f.OnlyDone && !t.Done {
            continue
        }
        if f.Tag != "" && t.Tag != f.Tag {
            continue
        }
        out = append(out, t)
    }
    return out
}
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
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91

关键点:

  1. Clear 用 s.items[:0] 复用底层数组——卷一第 5 章 slice 内存视图。100 条任务删 50 条不会触发新分配。
  2. Done 幂等:再次对已完成任务调用 Done 不报错。CLI 工具中"幂等"是用户友好的体现。
  3. Rm 用 append(s[:i], s[i+1:]...):标准的"切片删除元素"惯用法。注意若元素含指针字段,需先把 s[len-1] 置 nil 防止内存泄漏;本例 Todo 全是值类型,无需置 nil。
  4. 错误带语义:fmt.Errorf("done #%d: %w", id, ErrNotFound) 让上层既能 errors.Is(err, ErrNotFound) 判断,又能给用户看到 done #7: todo not found。

# 4.5 ANSI 着色:internal/ansi/color.go

// internal/ansi/color.go
package ansi

import (
    "os"

    "golang.org/x/term"
)

// 注:golang.org/x/term 是 Go 官方扩展,不算"第三方库"
// 也可以用 stdlib 自查 fd(os.Stdout.Fd())+ syscall,但 term.IsTerminal 跨平台稳

const (
    Reset  = "\033[0m"
    Red    = "\033[31m"
    Green  = "\033[32m"
    Yellow = "\033[33m"
    Cyan   = "\033[36m"
    Gray   = "\033[90m"
    Bold   = "\033[1m"
)

var enabled = term.IsTerminal(int(os.Stdout.Fd()))

// Wrap colors s if stdout is a TTY, otherwise returns s unchanged.
func Wrap(color, s string) string {
    if !enabled {
        return s
    }
    return color + s + Reset
}

// Disable forcibly turns off coloring (for tests / pipes).
func Disable() { enabled = false }
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

为什么要 TTY 检测?

gotodo list > out.txt   # 这种情况不能往文件里写 ANSI 转义码
gotodo list | grep todo # 管道也不是 TTY
1
2

不检测,输出文件会变成乱码 ^[[31m买牛奶^[[0m。term.IsTerminal 用 fd 查 ioctl,跨平台。

如果你坚持 stdlib 0 扩展库,可以这样替代:

import "os"
fi, _ := os.Stdout.Stat()
enabled = (fi.Mode() & os.ModeCharDevice) != 0
1
2
3

效果接近,但在 Windows 旧 cmd 上判断不准。本案例选 golang.org/x/term(Go 官方维护),符合"0 第三方库"的精神(官方 sub-repo 不算第三方)。

# 4.6 子命令分发:Command 接口与 Registry

// internal/cli/dispatch.go
package cli

import (
    "context"
    "fmt"
    "io"
    "sort"

    "github.com/yc/gotodo/internal/store"
)

type Command interface {
    Name() string
    Synopsis() string
    Run(ctx context.Context, s *store.Store, args []string, out io.Writer) error
}

type Registry struct {
    cmds map[string]Command
}

func NewRegistry() *Registry { return &Registry{cmds: map[string]Command{}} }

func (r *Registry) Register(c Command) { r.cmds[c.Name()] = c }

func (r *Registry) Dispatch(ctx context.Context, s *store.Store, argv []string, out io.Writer) error {
    if len(argv) == 0 {
        r.printUsage(out)
        return ErrUsage
    }
    name, rest := argv[0], argv[1:]
    if name == "help" || name == "-h" || name == "--help" {
        r.printUsage(out)
        return nil
    }
    c, ok := r.cmds[name]
    if !ok {
        return fmt.Errorf("unknown command %q: %w", name, ErrUsage)
    }
    return c.Run(ctx, s, rest, out)
}

func (r *Registry) printUsage(out io.Writer) {
    fmt.Fprintln(out, "Usage: gotodo <command> [flags]")
    fmt.Fprintln(out, "Commands:")
    names := make([]string, 0, len(r.cmds))
    for n := range r.cmds {
        names = append(names, n)
    }
    sort.Strings(names)
    for _, n := range names {
        fmt.Fprintf(out, "  %-10s %s\n", n, r.cmds[n].Synopsis())
    }
}

// ErrUsage is returned for any usage / parse error. main maps it to exit code 2.
var ErrUsage = fmt.Errorf("usage error")
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

为什么单独定义 ErrUsage?

我们想让 main 函数根据错误类型返回不同 退出码——errors.Is(err, ErrUsage) 退 2,errors.Is(err, store.ErrNotFound) 退 4,其他退 3。Unix 工具的标准做法。

# 4.7 Add 命令实现

// internal/cli/add.go
package cli

import (
    "context"
    "flag"
    "fmt"
    "io"
    "strings"
    "time"

    "github.com/yc/gotodo/internal/ansi"
    "github.com/yc/gotodo/internal/store"
)

type addCmd struct{}

func NewAddCmd() Command       { return addCmd{} }
func (addCmd) Name() string     { return "add" }
func (addCmd) Synopsis() string { return "add a new todo (text required)" }

func (addCmd) Run(ctx context.Context, s *store.Store, args []string, out io.Writer) error {
    fs := flag.NewFlagSet("add", flag.ContinueOnError)
    fs.SetOutput(out)
    tag := fs.String("tag", "", "category tag, e.g. shopping")
    dueStr := fs.String("due", "", "due date, format YYYY-MM-DD")
    if err := fs.Parse(args); err != nil {
        return fmt.Errorf("parse flags: %w", ErrUsage)
    }
    text := strings.TrimSpace(strings.Join(fs.Args(), " "))
    if text == "" {
        return fmt.Errorf("text is required: %w", ErrUsage)
    }

    var due time.Time
    if *dueStr != "" {
        t, err := time.ParseInLocation("2006-01-02", *dueStr, time.Local)
        if err != nil {
            return fmt.Errorf("invalid --due %q (want YYYY-MM-DD): %w", *dueStr, ErrUsage)
        }
        due = t
    }

    t := s.Add(text, store.AddOption{Tag: *tag, Due: due})
    if err := s.Save(); err != nil {
        return fmt.Errorf("save: %w", err)
    }
    fmt.Fprintf(out, "%s added #%d %s\n",
        ansi.Wrap(ansi.Green, "✓"), t.ID, ansi.Wrap(ansi.Bold, t.Text))
    return nil
}
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

关键点:

  1. flag.NewFlagSet 而非全局 flag:每个子命令自己一个 FlagSet,互不污染——这是写多命令 CLI 的必备姿势。
  2. time.ParseInLocation 而非 time.Parse:Parse 默认 UTC,会让用户写 2026-06-01 但实际存成前一天 16:00 UTC。本地工具用本地时区天经地义。
  3. 错误统一裹 ErrUsage:让 main 可以靠 errors.Is 判定退出码。

# 4.8 List 命令实现(含表格输出)

// internal/cli/list.go
package cli

import (
    "context"
    "flag"
    "fmt"
    "io"
    "text/tabwriter"
    "time"

    "github.com/yc/gotodo/internal/ansi"
    "github.com/yc/gotodo/internal/store"
)

type listCmd struct{}

func NewListCmd() Command       { return listCmd{} }
func (listCmd) Name() string     { return "list" }
func (listCmd) Synopsis() string { return "list todos with optional filters" }

func (listCmd) Run(ctx context.Context, s *store.Store, args []string, out io.Writer) error {
    fs := flag.NewFlagSet("list", flag.ContinueOnError)
    fs.SetOutput(out)
    filter := fs.String("filter", "", "todo|done; default all")
    tag := fs.String("tag", "", "filter by tag")
    if err := fs.Parse(args); err != nil {
        return fmt.Errorf("parse flags: %w", ErrUsage)
    }
    f := store.Filter{Tag: *tag}
    switch *filter {
    case "todo":
        f.OnlyTodo = true
    case "done":
        f.OnlyDone = true
    case "":
        // all
    default:
        return fmt.Errorf("invalid --filter %q (want todo|done): %w", *filter, ErrUsage)
    }

    items := s.List(f)
    if len(items) == 0 {
        fmt.Fprintln(out, ansi.Wrap(ansi.Gray, "(no todos)"))
        return nil
    }

    tw := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
    fmt.Fprintln(tw, "ID\tSTATE\tTAG\tDUE\tTEXT")
    for _, t := range items {
        state := ansi.Wrap(ansi.Yellow, "○ todo")
        if t.Done {
            state = ansi.Wrap(ansi.Green, "● done")
        }
        due := "-"
        if !t.Due.IsZero() {
            due = t.Due.Format("2006-01-02")
            if !t.Done && t.Due.Before(time.Now()) {
                due = ansi.Wrap(ansi.Red, due+" !")
            }
        }
        tag := t.Tag
        if tag == "" {
            tag = "-"
        }
        fmt.Fprintf(tw, "#%d\t%s\t%s\t%s\t%s\n", t.ID, state, tag, due, t.Text)
    }
    return tw.Flush()
}
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

text/tabwriter 是 stdlib 里被低估的宝藏:自动列对齐,无需算字符宽度。输出形如:

ID  STATE   TAG       DUE          TEXT
#1  ● done  -         -            学 Go
#2  ○ todo  shopping  2026-06-01   买牛奶
#3  ○ todo  work      2025-12-01 ! 写周报
1
2
3
4

# 4.9 Done / Rm / Edit / Clear / Version

为节省篇幅,这几个命令结构一致,给出 done 完整版 + 其余的差异点:

// internal/cli/done.go
package cli

import (
    "context"
    "fmt"
    "io"
    "strconv"

    "github.com/yc/gotodo/internal/ansi"
    "github.com/yc/gotodo/internal/store"
)

type doneCmd struct{}

func NewDoneCmd() Command       { return doneCmd{} }
func (doneCmd) Name() string     { return "done" }
func (doneCmd) Synopsis() string { return "mark a todo done by id" }

func (doneCmd) Run(ctx context.Context, s *store.Store, args []string, out io.Writer) error {
    if len(args) != 1 {
        return fmt.Errorf("done expects 1 id, got %d: %w", len(args), ErrUsage)
    }
    id, err := strconv.Atoi(args[0])
    if err != nil {
        return fmt.Errorf("invalid id %q: %w", args[0], ErrUsage)
    }
    if err := s.Done(id); err != nil {
        return err
    }
    if err := s.Save(); err != nil {
        return fmt.Errorf("save: %w", err)
    }
    fmt.Fprintf(out, "%s #%d marked done\n", ansi.Wrap(ansi.Green, "✓"), id)
    return nil
}
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

rm.go / edit.go / clear.go / version.go 套同一模板,只差中间 s.Done 替换为对应方法(edit 多读一个 args[1] 文本,clear 不取 args、返回数量、version 直接打印常量)。完整代码见仓库(字数考虑此处省略)。

# 4.10 main 入口与退出码映射

// cmd/gotodo/main.go
package main

import (
    "context"
    "errors"
    "fmt"
    "os"
    "os/signal"
    "syscall"

    "github.com/yc/gotodo/internal/cli"
    "github.com/yc/gotodo/internal/store"
)

func main() {
    if err := run(); err != nil {
        fmt.Fprintln(os.Stderr, "gotodo: "+err.Error())
        os.Exit(exitCode(err))
    }
}

func run() error {
    path, err := store.DefaultPath()
    if err != nil {
        return err
    }
    s, err := store.Open(path)
    if err != nil {
        return err
    }

    ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
    defer stop()

    r := cli.NewRegistry()
    r.Register(cli.NewAddCmd())
    r.Register(cli.NewListCmd())
    r.Register(cli.NewDoneCmd())
    r.Register(cli.NewRmCmd())
    r.Register(cli.NewEditCmd())
    r.Register(cli.NewClearCmd())
    r.Register(cli.NewVersionCmd())

    return r.Dispatch(ctx, s, os.Args[1:], os.Stdout)
}

func exitCode(err error) int {
    switch {
    case errors.Is(err, cli.ErrUsage):
        return 2
    case errors.Is(err, store.ErrNotFound):
        return 4
    default:
        return 3
    }
}
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

关键点:

  1. main 唯一职责是翻译 error → exit code,业务逻辑全在 run()。这样可写测试(直接调用 run,绕开 os.Exit)。
  2. signal.NotifyContext(卷一第 18 章)—— Go 1.16+ 后取代手写 signal 通道的最佳实践。Ctrl+C 后 ctx.Done() 自动触发,子命令若做长任务可优雅退出。
  3. 退出码语义化:0=ok, 2=usage, 3=io, 4=not found。脚本可写 gotodo done 99 || [ $? -eq 4 ] && echo "已经没了"。

# 4.11 编译与安装

# 本地跑
go run ./cmd/gotodo add "学 Go"

# 全局安装(前提:$GOPATH/bin 在 PATH)
go install ./cmd/gotodo

# 跨平台编译
GOOS=linux  GOARCH=amd64 go build -o dist/gotodo-linux-amd64    ./cmd/gotodo
GOOS=darwin GOARCH=arm64 go build -o dist/gotodo-darwin-arm64   ./cmd/gotodo
GOOS=windows GOARCH=amd64 go build -o dist/gotodo-windows-amd64.exe ./cmd/gotodo
1
2
3
4
5
6
7
8
9
10

二进制大小约 2.3 MB(Go 1.22 默认含调试符号;加 -ldflags="-s -w" 可压到 1.6 MB)。


# 05.反模式对照

本节是案例的灵魂——能写出可运行代码不算入门,能避开下面 8 个坑才算合格。

# 反模式 1:直接 os.WriteFile,不原子

// ❌
_ = os.WriteFile(path, data, 0644)
// 中途断电 → 文件被截断成空,全部 todo 丢失
1
2
3
// ✅
tmp, _ := os.CreateTemp(filepath.Dir(path), ".tmp.*")
tmp.Write(data); tmp.Sync(); tmp.Close()
os.Rename(tmp.Name(), path)
1
2
3
4

记住口诀:写临时、刷盘、改名。

# 反模式 2:fmt.Errorf("%v", err) 丢链

// ❌
return fmt.Errorf("save failed: %v", err)
// 上层 errors.Is(err, ErrNotFound) 永远 false——因为 %v 把错误降格成字符串
1
2
3
// ✅
return fmt.Errorf("save failed: %w", err)
1
2

%w 是 Go 1.13+ 引入的链式包装。写错误一律 %w,除非你确定不希望被解构(少数场景,如对外部用户脱敏)。

# 反模式 3:用 panic 处理用户输入

// ❌
id, err := strconv.Atoi(args[0])
if err != nil {
    panic("must be number")
}
1
2
3
4
5
// ✅
if err != nil {
    return fmt.Errorf("invalid id %q: %w", args[0], ErrUsage)
}
1
2
3
4

panic 留给程序员错误(不变量被打破),用户错误用 error——卷一第 11 章核心律。

# 反模式 4:os.Exit 在业务函数里

// ❌
func (c addCmd) Run(...) {
    if err := s.Save(); err != nil {
        log.Fatal(err) // ← 这是 os.Exit(1) 的别名
    }
}
1
2
3
4
5
6
// ✅ —— 错误传到 main 再 os.Exit
func (c addCmd) Run(...) error {
    if err := s.Save(); err != nil {
        return fmt.Errorf("save: %w", err)
    }
    return nil
}
1
2
3
4
5
6
7

os.Exit 不会执行 defer,会跳过你 defer tmp.Close()、defer recover(),导致资源泄漏 + 调试地狱。整个项目仅 main.go 出现一次 os.Exit 是黄金准则。

# 反模式 5:全局 flag 包做子命令

// ❌
flag.StringVar(&tag, "tag", "", "")
flag.Parse() // 多个子命令注册同名 flag 直接 panic
1
2
3
// ✅
fs := flag.NewFlagSet("add", flag.ContinueOnError)
fs.StringVar(&tag, "tag", "", "")
fs.Parse(args)
1
2
3
4

每个子命令独立 FlagSet,互不影响。

# 反模式 6:time.Parse 默认 UTC

// ❌
t, _ := time.Parse("2006-01-02", "2026-06-01")
// 北京时间 2026-06-01 凌晨 1 点 done → DoneAt 是 5/31 17:00 UTC
// 第二天 list 看上去任务"提前"了一天
1
2
3
4
// ✅
t, _ := time.ParseInLocation("2006-01-02", "2026-06-01", time.Local)
1
2

写时区敏感的工具,永远用 ParseInLocation+time.Local 或显式传 location。

# 反模式 7:for _, t := range items { ... &t ... } 悬挂指针

// ❌
var ptrs []*Todo
for _, t := range s.items {
    ptrs = append(ptrs, &t) // 所有元素都指向同一个循环变量!
}
1
2
3
4
5
// ✅ Go 1.22 之前
for i := range s.items {
    ptrs = append(ptrs, &s.items[i])
}
// ✅ Go 1.22+ —— 循环变量每轮新建,原代码也对了
1
2
3
4
5

我们 go.mod 写 go 1.22,可享受新语义,但写代码仍用稳健写法(&s.items[i]),便于回退到老 Go 版本。

# 反模式 8:忽略 Close() 的 error

// ❌
defer f.Close()
1
2
// ✅ 写文件场景必须捕获 Close error
defer func() {
    if cerr := f.Close(); err == nil {
        err = cerr
    }
}()
1
2
3
4
5
6

读文件可以忽略 Close,写文件不能——一些文件系统(NFS、tmpfs)真正的写入发生在 Close 时,错误也只在那时返回。本案例 Save() 中显式 Close + 检查就是这条规则的应用。


# 06.测试与基准

# 6.1 表驱动单测:store_test.go

// internal/store/store_test.go
package store

import (
    "errors"
    "path/filepath"
    "testing"
    "time"
)

func TestStore_AddDoneRm(t *testing.T) {
    path := filepath.Join(t.TempDir(), "todo.json")
    s, err := Open(path)
    if err != nil {
        t.Fatal(err)
    }
    a := s.Add("a", AddOption{})
    b := s.Add("b", AddOption{Tag: "x"})
    if a.ID != 1 || b.ID != 2 {
        t.Fatalf("ids want 1,2 got %d,%d", a.ID, b.ID)
    }
    if err := s.Done(1); err != nil {
        t.Fatal(err)
    }
    if err := s.Done(99); !errors.Is(err, ErrNotFound) {
        t.Fatalf("want ErrNotFound, got %v", err)
    }
    if err := s.Save(); err != nil {
        t.Fatal(err)
    }

    // reopen and verify
    s2, err := Open(path)
    if err != nil {
        t.Fatal(err)
    }
    if got := s2.List(Filter{OnlyDone: true}); len(got) != 1 || got[0].ID != 1 {
        t.Fatalf("after reopen done filter unexpected: %+v", got)
    }
}

func TestStore_FilterTable(t *testing.T) {
    s, _ := Open(filepath.Join(t.TempDir(), "todo.json"))
    s.Add("a", AddOption{Tag: "shop"})
    s.Add("b", AddOption{Tag: "work"})
    _ = s.Done(s.Add("c", AddOption{Tag: "shop"}).ID)

    cases := []struct {
        name string
        f    Filter
        want int
    }{
        {"all", Filter{}, 3},
        {"only todo", Filter{OnlyTodo: true}, 2},
        {"only done", Filter{OnlyDone: true}, 1},
        {"shop tag", Filter{Tag: "shop"}, 2},
        {"shop tag + done", Filter{Tag: "shop", OnlyDone: true}, 1},
    }
    for _, c := range cases {
        t.Run(c.name, func(t *testing.T) {
            if got := len(s.List(c.f)); got != c.want {
                t.Fatalf("want %d, got %d", c.want, got)
            }
        })
    }
}

func TestStore_AtomicSave(t *testing.T) {
    path := filepath.Join(t.TempDir(), "todo.json")
    s, _ := Open(path)
    for i := 0; i < 1000; i++ {
        s.Add("x", AddOption{Due: time.Now()})
    }
    if err := s.Save(); err != nil {
        t.Fatal(err)
    }
    // 强校验:能成功 reopen 且数量一致
    s2, _ := Open(path)
    if got := len(s2.List(Filter{})); got != 1000 {
        t.Fatalf("want 1000, got %d", got)
    }
}
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
76
77
78
79
80
81
82

# 6.2 跑测试

go test ./... -race -v
go test ./... -cover
# 期望:覆盖率 > 80%,-race 无报错
1
2
3

# 6.3 Benchmark:Save 性能

// internal/store/store_bench_test.go
package store

import (
    "path/filepath"
    "testing"
)

func BenchmarkSave_100(b *testing.B)  { benchSave(b, 100) }
func BenchmarkSave_1000(b *testing.B) { benchSave(b, 1000) }

func benchSave(b *testing.B, n int) {
    path := filepath.Join(b.TempDir(), "todo.json")
    s, _ := Open(path)
    for i := 0; i < n; i++ {
        s.Add("x", AddOption{})
    }
    b.ResetTimer()
    b.ReportAllocs()
    for i := 0; i < b.N; i++ {
        if err := s.Save(); err != nil {
            b.Fatal(err)
        }
    }
}
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
go test -bench=. -benchmem ./internal/store
# MacBook M2 实测参考值:
# BenchmarkSave_100-10     8000   140000 ns/op   25000 B/op   180 allocs/op
# BenchmarkSave_1000-10    1000  1100000 ns/op  240000 B/op  1700 allocs/op
1
2
3
4

观察值:1000 条 todo 落盘约 1.1 ms,绝大部分时间花在 MarshalIndent + fsync 上。对一个个人 Todo 工具够快了;如果想优化可换 json.Encoder 流式写、或换 MessagePack——本卷不展开。

# 6.4 集成测试:端到端 8 步

// cmd/gotodo/e2e_test.go
package main

import (
    "bytes"
    "context"
    "os"
    "path/filepath"
    "strings"
    "testing"

    "github.com/yc/gotodo/internal/cli"
    "github.com/yc/gotodo/internal/store"
)

func TestE2E_FullFlow(t *testing.T) {
    path := filepath.Join(t.TempDir(), "todo.json")
    t.Setenv("HOME", filepath.Dir(path)) // 让 DefaultPath 回到 tempdir
    _ = path

    runCmd := func(t *testing.T, argv ...string) string {
        t.Helper()
        s, err := store.Open(path)
        if err != nil {
            t.Fatal(err)
        }
        var buf bytes.Buffer
        r := cli.NewRegistry()
        r.Register(cli.NewAddCmd())
        r.Register(cli.NewListCmd())
        r.Register(cli.NewDoneCmd())
        r.Register(cli.NewRmCmd())
        if err := r.Dispatch(context.Background(), s, argv, &buf); err != nil {
            t.Fatalf("cmd %v: %v", argv, err)
        }
        return buf.String()
    }

    runCmd(t, "add", "task A")
    runCmd(t, "add", "task B", "--tag", "x")
    runCmd(t, "done", "1")
    out := runCmd(t, "list", "--filter", "todo")
    if !strings.Contains(out, "task B") || strings.Contains(out, "task A") {
        t.Fatalf("filter todo not respected:\n%s", out)
    }
    _ = os.Remove(path)
}
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

t.Setenv("HOME", ...) 隔离测试,不污染开发者真实 ~/.gotodo.json —— 这是写 CLI 测试的关键技巧。


# 07.卷一章节反向索引

本案例小节 对应卷一章节 用到的核心知识点
4.1 go.mod 第 2、17 章 module 初始化、目录布局
4.2 Todo / Open 第 5、9 章 struct、JSON tag、omitempty
4.3 原子 Save 第 7、11、15 章 defer 修改命名返回值、%w、文件 IO
4.4 业务方法 第 5、9 章 slice 删除惯用法、s[:0] 复用
4.5 ANSI 着色 第 7、15 章 包级变量、os.Stdout.Fd()
4.6 Command 接口 第 10 章 接口策略模式、map 注册器
4.7 add 命令 第 7、10、11 章 flag、time、错误链
4.8 list 命令 第 5、6 章 slice 遍历、text/tabwriter
4.10 main / 退出码 第 11、18 章 errors.Is、signal.NotifyContext
5 反模式 1-8 第 7、11、12、15 章 defer、错误链、time.Local、Close
6 测试 第 17 章 t.TempDir、t.Setenv、表驱动、benchmark

# 08.拓展挑战

完成下面的题目,本案例知识点就被你彻底吃透了。

# 挑战 1(⭐⭐):把 JSON 换成 SQLite

把 Store 抽成接口,写 JSONStore 和 SQLiteStore 两实现。要求:

  • cmd/gotodo 的代码不动,只通过环境变量 GOTODO_BACKEND=json|sqlite 切换
  • SQLite 用 database/sql + 你能找到的纯 Go 驱动
  • 思考:迁移工具怎么写?已有 ~/.gotodo.json 用户首次启用 sqlite 怎么办?

目的:消化卷一第 10 章(接口隔离)。

# 挑战 2(⭐⭐⭐):增加 --undo

支持 gotodo undo:撤销上一次操作。要求:

  • 不引入第三方库
  • 思考存储模型:是事务日志(append-only)还是快照?
  • 极端情况:连续 undo 5 次。再 redo?

目的:消化卷一第 5 章(slice 内存)和"事务"思想,提前预习卷三 KV 存储。

# 挑战 3(⭐⭐⭐⭐):改成 TUI

用 golang.org/x/term + ANSI 控制序列,做一个 1 屏 TUI:

  • 上下箭头切换任务,空格切完成态,d 删除,q 退出
  • 不允许引入 bubbletea、tview 等 TUI 库
  • 思考:怎么处理终端 resize?怎么不闪屏?

目的:练习 IO 与终端控制;为案例 03 的 HTTP/中间件设计打底。

# 挑战 4(⭐⭐):完整国际化

把所有用户可见字符串抽到 internal/i18n/,支持 LANG=en_US|zh_CN 切换。

目的:消化卷一第 5、9 章(map、struct 嵌套),为案例 05 gosite 多语言主题铺路。

# 挑战 5(⭐⭐⭐):发布到 GitHub Release

写一个 Makefile 和 .github/workflows/release.yml:

  • tag v0.1.0 push 后自动出 5 平台 × 2 架构二进制
  • 自动生成 changelog(基于 commit message)
  • go install github.com/yourname/gotodo@v0.1.0 全网可用

目的:把卷一第 17 章彻底落地。这一题做完,你就具备发布任意 Go 工具的能力。


# 卷末小结

通过这 ~600 行代码、~1.5 万字解读,你应该收获:

  • ✅ CLI 工具的标准目录结构:cmd/ + internal/cli + internal/store
  • ✅ 原子文件持久化的工业级模板:tmp + Sync + Rename
  • ✅ 错误链 + 退出码的现代姿势:%w + errors.Is + os.Exit
  • ✅ 多子命令分发:flag.NewFlagSet + Command 接口 + Registry
  • ✅ 8 个高频反模式的纠正肌肉记忆

下一站:案例 02 golog——把这套工程化基础推到"流式 IO + 泛型 + 性能"维度,准备好迎接 10GB 日志的洗礼。

⬅ 返回:卷二总导读 ➡ 下一篇:案例 02 · golog 高性能日志分析流水线

上次更新: 2026/06/11, 19:20:46
README
golog日志分析

← README golog日志分析→

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