终端待办任务清单
# 案例 01 · gotodo · 命令行待办事项管理器
卷二第 1 篇 · 难度 ⭐⭐ · 预估 4 小时 · 字数目标 ~1.5 万字 · 代码量 ~600 行
本案例承诺:读完即写完。从空目录到
go install可全局调用的真实 CLI,零第三方依赖,所有代码可直接复制运行。
# 目录介绍
# 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"
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)]
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)
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
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"`
}
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(单调递增,不复用)
}
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
}
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
2
3
// go.mod
module github.com/yc/gotodo
go 1.22
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
}
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
关键点:
- 文件不存在 ≠ 错误。我们用
errors.Is(err, fs.ErrNotExist)区分"首次运行"与"真 IO 故障",比os.IsNotExist更现代(卷一第 11 章)。 - 错误一律
%w包装,附带文件路径上下文。这样main里errors.As能拿到原始错误码做差异化退出码。 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
}
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
}
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
关键点:
Clear用s.items[:0]复用底层数组——卷一第 5 章 slice 内存视图。100 条任务删 50 条不会触发新分配。Done幂等:再次对已完成任务调用Done不报错。CLI 工具中"幂等"是用户友好的体现。Rm用append(s[:i], s[i+1:]...):标准的"切片删除元素"惯用法。注意若元素含指针字段,需先把s[len-1]置 nil 防止内存泄漏;本例Todo全是值类型,无需置 nil。- 错误带语义:
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 }
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
2
不检测,输出文件会变成乱码 ^[[31m买牛奶^[[0m。term.IsTerminal 用 fd 查 ioctl,跨平台。
如果你坚持 stdlib 0 扩展库,可以这样替代:
import "os"
fi, _ := os.Stdout.Stat()
enabled = (fi.Mode() & os.ModeCharDevice) != 0
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")
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
}
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
关键点:
flag.NewFlagSet而非全局flag:每个子命令自己一个 FlagSet,互不污染——这是写多命令 CLI 的必备姿势。time.ParseInLocation而非time.Parse:Parse默认 UTC,会让用户写2026-06-01但实际存成前一天 16:00 UTC。本地工具用本地时区天经地义。- 错误统一裹
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()
}
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 ! 写周报
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
}
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
}
}
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
关键点:
main唯一职责是翻译 error → exit code,业务逻辑全在run()。这样可写测试(直接调用run,绕开os.Exit)。signal.NotifyContext(卷一第 18 章)—— Go 1.16+ 后取代手写 signal 通道的最佳实践。Ctrl+C 后ctx.Done()自动触发,子命令若做长任务可优雅退出。- 退出码语义化:
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
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 丢失
2
3
// ✅
tmp, _ := os.CreateTemp(filepath.Dir(path), ".tmp.*")
tmp.Write(data); tmp.Sync(); tmp.Close()
os.Rename(tmp.Name(), path)
2
3
4
记住口诀:写临时、刷盘、改名。
# 反模式 2:fmt.Errorf("%v", err) 丢链
// ❌
return fmt.Errorf("save failed: %v", err)
// 上层 errors.Is(err, ErrNotFound) 永远 false——因为 %v 把错误降格成字符串
2
3
// ✅
return fmt.Errorf("save failed: %w", err)
2
%w 是 Go 1.13+ 引入的链式包装。写错误一律 %w,除非你确定不希望被解构(少数场景,如对外部用户脱敏)。
# 反模式 3:用 panic 处理用户输入
// ❌
id, err := strconv.Atoi(args[0])
if err != nil {
panic("must be number")
}
2
3
4
5
// ✅
if err != nil {
return fmt.Errorf("invalid id %q: %w", args[0], ErrUsage)
}
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) 的别名
}
}
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
}
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
2
3
// ✅
fs := flag.NewFlagSet("add", flag.ContinueOnError)
fs.StringVar(&tag, "tag", "", "")
fs.Parse(args)
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 看上去任务"提前"了一天
2
3
4
// ✅
t, _ := time.ParseInLocation("2006-01-02", "2026-06-01", time.Local)
2
写时区敏感的工具,永远用 ParseInLocation+time.Local 或显式传 location。
# 反模式 7:for _, t := range items { ... &t ... } 悬挂指针
// ❌
var ptrs []*Todo
for _, t := range s.items {
ptrs = append(ptrs, &t) // 所有元素都指向同一个循环变量!
}
2
3
4
5
// ✅ Go 1.22 之前
for i := range s.items {
ptrs = append(ptrs, &s.items[i])
}
// ✅ Go 1.22+ —— 循环变量每轮新建,原代码也对了
2
3
4
5
我们 go.mod 写 go 1.22,可享受新语义,但写代码仍用稳健写法(&s.items[i]),便于回退到老 Go 版本。
# 反模式 8:忽略 Close() 的 error
// ❌
defer f.Close()
2
// ✅ 写文件场景必须捕获 Close error
defer func() {
if cerr := f.Close(); err == nil {
err = cerr
}
}()
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)
}
}
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 无报错
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)
}
}
}
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
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)
}
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.0push 后自动出 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 高性能日志分析流水线