【第2回】CLI ツールを作りながら学ぶ ── ファイル I/O・JSON・テスト

Go
B!

シリーズ構成

  1. Go の基本文法
  2. CLI ツールを作る(本記事)
  3. HTTP サーバーと REST API
  4. データベース連携
  5. 実務的な周辺技術
  6. ポートフォリオを作る
  7. 実践知識を深める

この回の目標は 「実用的な CLI ツールを一つ完成させ、Go のテストを自然に書けるようになる」 ことです。

CLI から始める理由は 3 つあります。HTTP や DB という「外部要素」を排除して Go そのものに集中できること、io.Reader / io.Writer という Go で最重要の抽象を体で覚えられること、そしてバックエンドの実務では DB マイグレーションやバッチ処理など CLI ツールを頻繁に書くことです。


目次

  1. 作るもの:Todo CLI
  2. プロジェクトの初期化と構成
  3. ファイル I/O の基本
  4. JSON の読み書き
  5. データ層の実装
  6. コマンド引数の処理
  7. 標準入出力と io.Reader / io.Writer
  8. テストの書き方
  9. テーブル駆動テスト
  10. testdata とゴールデンファイル
  11. ベンチマーク
  12. cobra でサブコマンドを整える
  13. 配布:go install とクロスコンパイル
  14. よくある落とし穴
  15. 演習問題

1. 作るもの:Todo CLI

以下のコマンドを持つツールを作ります。

todo add "Go を学ぶ"       # 追加
todo list                  # 一覧
todo list --all            # 完了済みも含めて一覧
todo done 1                # 完了にする
todo rm 1                  # 削除
todo import < tasks.txt    # 標準入力から一括追加

データは tasks.json に保存します。


2. プロジェクトの初期化と構成

mkdir todo && cd todo
go mod init github.com/yourname/todo

最終的な構成です。最初から全部作らず、進めながら増やします。

todo/
├── go.mod
├── go.sum
├── main.go              # エントリポイント
├── cmd.go               # コマンド分岐
├── task.go              # Task 型と Store
├── task_test.go
├── cmd_test.go
└── testdata/            # テスト用ファイル
    └── sample.json

小規模なうちは すべて package main に置いて構いません。パッケージ分割は必要になってからで十分です(第6回で扱います)。


3. ファイル I/O の基本

まるごと読み書き

小さなファイルなら一発で読み書きできます。

data, err := os.ReadFile("tasks.json")   // []byte
if err != nil {
    return err
}

err = os.WriteFile("tasks.json", data, 0o644) // パーミッション

0o644 は 8 進数リテラル(所有者は読み書き、他は読みのみ)。

ファイルの存在チェック

_, err := os.Stat(path)
if errors.Is(err, os.ErrNotExist) {
    // 存在しない
}

開いて操作する

大きなファイルや部分読み込みでは os.Open / os.Create を使います。

f, err := os.Open("input.txt")   // 読み取り専用
if err != nil {
    return err
}
defer f.Close()

// 書き込み用(存在すれば切り詰め、なければ作成)
out, err := os.Create("output.txt")

// 追記モード
log, err := os.OpenFile("app.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)

行単位で読む:bufio.Scanner

f, _ := os.Open("input.txt")
defer f.Close()

sc := bufio.NewScanner(f)
for sc.Scan() {
    line := sc.Text()
    fmt.Println(line)
}
if err := sc.Err(); err != nil {   // ループ後に必ずエラー確認
    return err
}

デフォルトの 1 行上限は 64KB。超える場合は sc.Buffer() で拡張します。

バッファ付き書き込み

w := bufio.NewWriter(f)
defer w.Flush()   // 忘れると書き込まれない!

fmt.Fprintf(w, "line %d\n", i)

ディレクトリ操作

os.MkdirAll("data/backup", 0o755)     // 中間ディレクトリも作る
entries, _ := os.ReadDir(".")
for _, e := range entries {
    fmt.Println(e.Name(), e.IsDir())
}
os.Remove("file.txt")
os.RemoveAll("dir")                    // 再帰削除

// パス操作は path/filepath(OS の区切り文字に対応)
p := filepath.Join("data", "tasks.json")
ext := filepath.Ext(p)                 // .json
dir := filepath.Dir(p)                 // data
home, _ := os.UserHomeDir()

安全な書き込み(アトミック保存)

書き込み途中でクラッシュするとファイルが壊れます。一時ファイルに書いてリネームする方法が安全です。

func writeFileAtomic(path string, data []byte) error {
    tmp := path + ".tmp"
    if err := os.WriteFile(tmp, data, 0o644); err != nil {
        return err
    }
    return os.Rename(tmp, path) // 同一ファイルシステム上ならアトミック
}

4. JSON の読み書き

構造体 ⇄ JSON

type Task struct {
    ID        int       `json:"id"`
    Title     string    `json:"title"`
    Done      bool      `json:"done"`
    Tags      []string  `json:"tags,omitempty"`   // 空なら省略
    CreatedAt time.Time `json:"created_at"`
    secret    string                              // 小文字 → 無視される
}

// エンコード
t := Task{ID: 1, Title: "test"}
data, err := json.Marshal(t)
// {"id":1,"title":"test","done":false,"created_at":"0001-01-01T00:00:00Z"}

data, err = json.MarshalIndent(t, "", "  ")   // 整形

// デコード
var t2 Task
err = json.Unmarshal(data, &t2)   // ポインタを渡す

タグのオプション

タグ意味
json:"name"キー名を指定
json:"name,omitempty"ゼロ値なら省略
json:"-"常に無視
json:"id,string"数値を文字列として出力

ストリームで扱う:Encoder / Decoder

io.Reader / io.Writer を直接扱えるので、ファイルや HTTP ボディに対してメモリ効率よく処理できます。

// ファイルから直接デコード
f, _ := os.Open("tasks.json")
defer f.Close()
var tasks []Task
if err := json.NewDecoder(f).Decode(&tasks); err != nil {
    return err
}

// ファイルへ直接エンコード
out, _ := os.Create("tasks.json")
defer out.Close()
enc := json.NewEncoder(out)
enc.SetIndent("", "  ")
enc.Encode(tasks)

未知の構造を扱う

var v map[string]any
json.Unmarshal(data, &v)
// 数値はすべて float64 になる点に注意

time.Time のフォーマット

デフォルトは RFC3339。カスタムしたい場合は MarshalJSON / UnmarshalJSON を実装します。

type Date time.Time

func (d Date) MarshalJSON() ([]byte, error) {
    return json.Marshal(time.Time(d).Format("2006-01-02"))
}

func (d *Date) UnmarshalJSON(b []byte) error {
    var s string
    if err := json.Unmarshal(b, &s); err != nil {
        return err
    }
    t, err := time.Parse("2006-01-02", s)
    if err != nil {
        return err
    }
    *d = Date(t)
    return nil
}

Go の日時フォーマットは 2006-01-02 15:04:05 という 特定の日時そのもの をレイアウト文字列として使います(1〜7 が順に並んでいると覚えます)。


5. データ層の実装

task.go を書きます。保存先を抽象化して、テストしやすくする のがポイントです。

package main

import (
    "encoding/json"
    "errors"
    "fmt"
    "os"
    "time"
)

type Task struct {
    ID        int       `json:"id"`
    Title     string    `json:"title"`
    Done      bool      `json:"done"`
    CreatedAt time.Time `json:"created_at"`
}

var (
    ErrTaskNotFound = errors.New("task not found")
    ErrEmptyTitle   = errors.New("title must not be empty")
)

// Store はタスクの永続化を担う
type Store struct {
    path  string
    tasks []Task
    now   func() time.Time // テストで差し替え可能にする
}

func NewStore(path string) (*Store, error) {
    s := &Store{path: path, now: time.Now}
    if err := s.load(); err != nil {
        return nil, fmt.Errorf("load %s: %w", path, err)
    }
    return s, nil
}

func (s *Store) load() error {
    data, err := os.ReadFile(s.path)
    if errors.Is(err, os.ErrNotExist) {
        return nil
    }
    if err != nil {
        return err
    }
    if len(data) == 0 {
        return nil
    }
    return json.Unmarshal(data, &s.tasks)
}

func (s *Store) save() error {
    data, err := json.MarshalIndent(s.tasks, "", "  ")
    if err != nil {
        return err
    }
    tmp := s.path + ".tmp"
    if err := os.WriteFile(tmp, data, 0o644); err != nil {
        return err
    }
    return os.Rename(tmp, s.path)
}

func (s *Store) nextID() int {
    max := 0
    for _, t := range s.tasks {
        if t.ID > max {
            max = t.ID
        }
    }
    return max + 1
}

func (s *Store) Add(title string) (Task, error) {
    if title == "" {
        return Task{}, ErrEmptyTitle
    }
    t := Task{ID: s.nextID(), Title: title, CreatedAt: s.now()}
    s.tasks = append(s.tasks, t)
    if err := s.save(); err != nil {
        return Task{}, err
    }
    return t, nil
}

func (s *Store) List(includeDone bool) []Task {
    out := make([]Task, 0, len(s.tasks))
    for _, t := range s.tasks {
        if includeDone || !t.Done {
            out = append(out, t)
        }
    }
    return out
}

func (s *Store) find(id int) (int, error) {
    for i, t := range s.tasks {
        if t.ID == id {
            return i, nil
        }
    }
    return -1, fmt.Errorf("id %d: %w", id, ErrTaskNotFound)
}

func (s *Store) Complete(id int) error {
    i, err := s.find(id)
    if err != nil {
        return err
    }
    s.tasks[i].Done = true
    return s.save()
}

func (s *Store) Delete(id int) error {
    i, err := s.find(id)
    if err != nil {
        return err
    }
    s.tasks = append(s.tasks[:i], s.tasks[i+1:]...)
    return s.save()
}

設計のポイント

  • now を関数フィールドにすることで、テスト時に固定時刻を注入できる
  • エラーにはセンチネルを使い、呼び出し側が errors.Is で判定できる
  • List は内部スライスをそのまま返さず、コピーを返す(外から壊されない)

6. コマンド引数の処理

os.Args と flag

// os.Args[0] はプログラム名、[1:] が引数
fmt.Println(os.Args)

flag パッケージは標準のオプション解析です。

package main

import (
    "flag"
    "fmt"
)

func main() {
    file := flag.String("file", "tasks.json", "path to tasks file")
    verbose := flag.Bool("v", false, "verbose output")
    limit := flag.Int("limit", 10, "max items")
    flag.Parse()

    fmt.Println(*file, *verbose, *limit)
    fmt.Println(flag.Args()) // オプション以外の残り引数
}
go run . -file=my.json -v add "task"
go run . -h    # 自動生成されるヘルプ

サブコマンドごとの FlagSet

list --all のようにサブコマンド固有のオプションには flag.NewFlagSet を使います。

// cmd.go
package main

import (
    "bufio"
    "errors"
    "flag"
    "fmt"
    "io"
    "strconv"
    "strings"
)

// App は依存を束ねる。出力先を差し替え可能にしてテストしやすくする
type App struct {
    store *Store
    out   io.Writer
    in    io.Reader
}

func (a *App) Run(args []string) error {
    if len(args) == 0 {
        a.usage()
        return errors.New("no command")
    }

    switch cmd, rest := args[0], args[1:]; cmd {
    case "add":
        return a.add(rest)
    case "list":
        return a.list(rest)
    case "done":
        return a.done(rest)
    case "rm":
        return a.remove(rest)
    case "import":
        return a.importTasks()
    case "help", "-h", "--help":
        a.usage()
        return nil
    default:
        a.usage()
        return fmt.Errorf("unknown command %q", cmd)
    }
}

func (a *App) add(args []string) error {
    if len(args) == 0 {
        return errors.New("usage: todo add <title>")
    }
    t, err := a.store.Add(strings.Join(args, " "))
    if err != nil {
        return err
    }
    fmt.Fprintf(a.out, "added [%d] %s\n", t.ID, t.Title)
    return nil
}

func (a *App) list(args []string) error {
    fs := flag.NewFlagSet("list", flag.ContinueOnError)
    all := fs.Bool("all", false, "include completed tasks")
    if err := fs.Parse(args); err != nil {
        return err
    }

    tasks := a.store.List(*all)
    if len(tasks) == 0 {
        fmt.Fprintln(a.out, "no tasks")
        return nil
    }
    for _, t := range tasks {
        mark := " "
        if t.Done {
            mark = "x"
        }
        fmt.Fprintf(a.out, "[%s] %3d  %s\n", mark, t.ID, t.Title)
    }
    return nil
}

func parseID(args []string) (int, error) {
    if len(args) == 0 {
        return 0, errors.New("id is required")
    }
    id, err := strconv.Atoi(args[0])
    if err != nil {
        return 0, fmt.Errorf("invalid id %q", args[0])
    }
    return id, nil
}

func (a *App) done(args []string) error {
    id, err := parseID(args)
    if err != nil {
        return err
    }
    if err := a.store.Complete(id); err != nil {
        return err
    }
    fmt.Fprintf(a.out, "completed %d\n", id)
    return nil
}

func (a *App) remove(args []string) error {
    id, err := parseID(args)
    if err != nil {
        return err
    }
    if err := a.store.Delete(id); err != nil {
        return err
    }
    fmt.Fprintf(a.out, "removed %d\n", id)
    return nil
}

// 標準入力から 1 行 1 タスクで読み込む
func (a *App) importTasks() error {
    sc := bufio.NewScanner(a.in)
    n := 0
    for sc.Scan() {
        line := strings.TrimSpace(sc.Text())
        if line == "" || strings.HasPrefix(line, "#") {
            continue
        }
        if _, err := a.store.Add(line); err != nil {
            return fmt.Errorf("line %d: %w", n+1, err)
        }
        n++
    }
    if err := sc.Err(); err != nil {
        return err
    }
    fmt.Fprintf(a.out, "imported %d tasks\n", n)
    return nil
}

func (a *App) usage() {
    fmt.Fprint(a.out, `usage: todo <command> [args]

commands:
  add <title>      add a task
  list [--all]     list tasks
  done <id>        mark as done
  rm <id>          remove a task
  import           read tasks from stdin (one per line)
`)
}

main.go

main は 依存を組み立てて Run を呼ぶだけ にします。ロジックを main に書かないことでテストできるようになります。

package main

import (
    "flag"
    "fmt"
    "os"
)

func main() {
    file := flag.String("file", "tasks.json", "path to tasks file")
    flag.Parse()

    store, err := NewStore(*file)
    if err != nil {
        fmt.Fprintln(os.Stderr, "error:", err)
        os.Exit(1)
    }

    app := &App{store: store, out: os.Stdout, in: os.Stdin}
    if err := app.Run(flag.Args()); err != nil {
        fmt.Fprintln(os.Stderr, "error:", err)
        os.Exit(1)
    }
}

終了コード:成功は 0、エラーは 1 以上。シェルスクリプトから使うツールでは重要です。エラーメッセージは stderr に、通常出力は stdout に分けます。

go run . add "Go を学ぶ"
go run . add "API を作る"
go run . list
go run . done 1
go run . list --all
printf "task A\ntask B\n" | go run . import

7. 標準入出力と io.Reader / io.Writer

上のコードで App が io.Writer / io.Reader を持っている理由を掘り下げます。

type Reader interface {
    Read(p []byte) (n int, err error)
}
type Writer interface {
    Write(p []byte) (n int, err error)
}

os.Stdout、os.File、bytes.Buffer、strings.Builder、HTTP のレスポンス、gzip 圧縮器──すべてこの 2 つのインターフェースを満たします。具象型ではなくこのインターフェースに依存しておけば、テスト時に bytes.Buffer を差し込んで出力を検証できます。

便利な関数

io.Copy(dst, src)                    // src から dst へ全部流す
io.ReadAll(r)                        // 全部読んで []byte
io.WriteString(w, "text")
io.MultiWriter(os.Stdout, logFile)   // 複数に同時書き込み
io.TeeReader(r, w)                   // 読みながら w にも書く
io.LimitReader(r, 1024)              // 最大 1024 バイト
io.Discard                           // 捨てる Writer

自作 Writer の例

// 書き込んだバイト数を数える Writer
type CountingWriter struct {
    W io.Writer
    N int64
}

func (c *CountingWriter) Write(p []byte) (int, error) {
    n, err := c.W.Write(p)
    c.N += int64(n)
    return n, err
}

8. テストの書き方

基本ルール

  • ファイル名は xxx_test.go(ビルド対象から除外される)
  • 関数名は TestXxx(t *testing.T)
  • t.Errorf:失敗を記録して続行 / t.Fatalf:失敗して即終了
  • 同じパッケージ名にすると非公開関数もテストできる
// task_test.go
package main

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

// テスト用の Store を作るヘルパー
func newTestStore(t *testing.T) *Store {
    t.Helper() // 失敗時の行番号を呼び出し元にする
    path := filepath.Join(t.TempDir(), "tasks.json") // 自動で削除される
    s, err := NewStore(path)
    if err != nil {
        t.Fatalf("NewStore: %v", err)
    }
    // 時刻を固定
    s.now = func() time.Time {
        return time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
    }
    return s
}

func TestStore_Add(t *testing.T) {
    s := newTestStore(t)

    task, err := s.Add("first")
    if err != nil {
        t.Fatalf("Add: %v", err)
    }
    if task.ID != 1 {
        t.Errorf("ID = %d, want 1", task.ID)
    }
    if task.Title != "first" {
        t.Errorf("Title = %q, want %q", task.Title, "first")
    }
    if task.Done {
        t.Error("new task should not be done")
    }
}

func TestStore_Add_EmptyTitle(t *testing.T) {
    s := newTestStore(t)
    _, err := s.Add("")
    if !errors.Is(err, ErrEmptyTitle) {
        t.Errorf("err = %v, want ErrEmptyTitle", err)
    }
}

func TestStore_Persistence(t *testing.T) {
    path := filepath.Join(t.TempDir(), "tasks.json")

    s1, _ := NewStore(path)
    s1.Add("persisted")

    // 別インスタンスで読み直す
    s2, err := NewStore(path)
    if err != nil {
        t.Fatal(err)
    }
    got := s2.List(true)
    if len(got) != 1 || got[0].Title != "persisted" {
        t.Errorf("got %+v", got)
    }
}
go test ./...             # 全部
go test -v ./...          # 各テストの名前を表示
go test -run Add          # 名前が Add を含むテスト
go test -run 'TestStore_Add$'
go test -cover ./...      # カバレッジ
go test -coverprofile=c.out && go tool cover -html=c.out  # HTML で確認

何をテストするか

  • 正常系(期待する結果)
  • 異常系(エラーが返る、正しい種類か)
  • 境界(空、0、最大値)
  • 永続化(保存 → 再読み込みで同じか)

「実装の詳細」ではなく「振る舞い」をテストします。nextID のような非公開関数を直接テストするより、Add を 2 回呼んで ID が増えることを確認する方が、リファクタリングに強いテストになります。


9. テーブル駆動テスト

同じロジックに複数の入力を試すときの Go の定番パターン です。

func TestStore_Complete(t *testing.T) {
    tests := []struct {
        name    string
        setup   func(*Store)
        id      int
        wantErr error
    }{
        {
            name:    "existing task",
            setup:   func(s *Store) { s.Add("a") },
            id:      1,
            wantErr: nil,
        },
        {
            name:    "missing task",
            setup:   func(s *Store) {},
            id:      99,
            wantErr: ErrTaskNotFound,
        },
        {
            name:    "already completed is idempotent",
            setup:   func(s *Store) { s.Add("a"); s.Complete(1) },
            id:      1,
            wantErr: nil,
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            s := newTestStore(t)
            tt.setup(s)

            err := s.Complete(tt.id)
            if !errors.Is(err, tt.wantErr) {
                t.Errorf("Complete(%d) err = %v, want %v", tt.id, err, tt.wantErr)
            }
        })
    }
}

func TestParseID(t *testing.T) {
    tests := []struct {
        name    string
        args    []string
        want    int
        wantErr bool
    }{
        {"valid", []string{"42"}, 42, false},
        {"empty", []string{}, 0, true},
        {"not a number", []string{"abc"}, 0, true},
        {"negative", []string{"-1"}, -1, false},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            got, err := parseID(tt.args)
            if (err != nil) != tt.wantErr {
                t.Fatalf("err = %v, wantErr %v", err, tt.wantErr)
            }
            if got != tt.want {
                t.Errorf("got %d, want %d", got, tt.want)
            }
        })
    }
}

t.Run でサブテストにすると、失敗したケースだけが名前で分かり、-run 'TestParseID/empty' で個別実行もできます。

App のテスト:出力を検証する

io.Writer を注入した設計が生きます。

// cmd_test.go
package main

import (
    "bytes"
    "strings"
    "testing"
)

func newTestApp(t *testing.T, stdin string) (*App, *bytes.Buffer) {
    t.Helper()
    out := &bytes.Buffer{}
    app := &App{
        store: newTestStore(t),
        out:   out,
        in:    strings.NewReader(stdin),
    }
    return app, out
}

func TestApp_AddAndList(t *testing.T) {
    app, out := newTestApp(t, "")

    if err := app.Run([]string{"add", "buy", "milk"}); err != nil {
        t.Fatal(err)
    }
    if !strings.Contains(out.String(), "added [1] buy milk") {
        t.Errorf("unexpected output: %q", out.String())
    }

    out.Reset()
    if err := app.Run([]string{"list"}); err != nil {
        t.Fatal(err)
    }
    if !strings.Contains(out.String(), "buy milk") {
        t.Errorf("list output: %q", out.String())
    }
}

func TestApp_Import(t *testing.T) {
    app, out := newTestApp(t, "task A\n\n# comment\ntask B\n")

    if err := app.Run([]string{"import"}); err != nil {
        t.Fatal(err)
    }
    if got := out.String(); !strings.Contains(got, "imported 2 tasks") {
        t.Errorf("got %q", got)
    }
    if n := len(app.store.List(true)); n != 2 {
        t.Errorf("stored %d tasks, want 2", n)
    }
}

func TestApp_UnknownCommand(t *testing.T) {
    app, _ := newTestApp(t, "")
    err := app.Run([]string{"frobnicate"})
    if err == nil || !strings.Contains(err.Error(), "unknown command") {
        t.Errorf("err = %v", err)
    }
}

10. testdata とゴールデンファイル

テスト用の入力ファイルは testdata/ ディレクトリに置きます。Go ツールチェーンはこのディレクトリを パッケージとして扱いません。

testdata/
├── sample.json
└── list_output.golden

出力が長い場合は「期待する出力をファイルに保存して比較する」ゴールデンファイル が便利です。

import (
    "flag"
    "os"
)

var update = flag.Bool("update", false, "update golden files")

func TestApp_ListOutput_Golden(t *testing.T) {
    app, out := newTestApp(t, "")
    app.Run([]string{"add", "first"})
    app.Run([]string{"add", "second"})
    app.Run([]string{"done", "1"})
    out.Reset()
    app.Run([]string{"list", "--all"})

    golden := "testdata/list_output.golden"
    if *update {
        os.WriteFile(golden, out.Bytes(), 0o644)
    }
    want, err := os.ReadFile(golden)
    if err != nil {
        t.Fatal(err)
    }
    if got := out.String(); got != string(want) {
        t.Errorf("output mismatch\ngot:\n%s\nwant:\n%s", got, want)
    }
}
go test -update   # 期待値を再生成
go test           # 比較

11. ベンチマーク

BenchmarkXxx(b *testing.B) で性能を測れます。

func BenchmarkStore_Add(b *testing.B) {
    path := filepath.Join(b.TempDir(), "tasks.json")
    s, _ := NewStore(path)

    b.ResetTimer()
    for i := 0; i < b.N; i++ {
        s.Add("task")
    }
}
go test -bench=. -benchmem
# BenchmarkStore_Add-8   1234   987654 ns/op   12345 B/op   67 allocs/op

毎回ファイル保存しているので遅いことが分かります。「N 件追加してから 1 回保存」のような設計変更を検討する材料になります。推測せず計測する 習慣をここで作ります。


12. cobra でサブコマンドを整える

手書きの switch でも動きますが、コマンドが増えると cobra が楽になります。kubectl、docker、gh などの CLI で使われている標準的なライブラリです。

go get github.com/spf13/cobra
package main

import (
    "fmt"
    "os"

    "github.com/spf13/cobra"
)

func newRootCmd(app *App) *cobra.Command {
    root := &cobra.Command{
        Use:   "todo",
        Short: "A simple todo manager",
    }

    root.AddCommand(&cobra.Command{
        Use:   "add <title>",
        Short: "Add a task",
        Args:  cobra.MinimumNArgs(1),
        RunE: func(cmd *cobra.Command, args []string) error {
            return app.add(args)
        },
    })

    var all bool
    listCmd := &cobra.Command{
        Use:   "list",
        Short: "List tasks",
        RunE: func(cmd *cobra.Command, args []string) error {
            if all {
                return app.list([]string{"--all"})
            }
            return app.list(nil)
        },
    }
    listCmd.Flags().BoolVar(&all, "all", false, "include completed")
    root.AddCommand(listCmd)

    return root
}

func main() {
    store, err := NewStore("tasks.json")
    if err != nil {
        fmt.Fprintln(os.Stderr, err)
        os.Exit(1)
    }
    app := &App{store: store, out: os.Stdout, in: os.Stdin}
    if err := newRootCmd(app).Execute(); err != nil {
        os.Exit(1)
    }
}

--help の自動生成、シェル補完、サブコマンドのネストが手に入ります。学習の観点では、まず flag で仕組みを理解してから cobra に移るのがおすすめです。


13. 配布:go install とクロスコンパイル

# $GOPATH/bin(通常 ~/go/bin)にインストール
go install .
todo list

# 他人のツールもインストールできる
go install github.com/someone/tool@latest

Go は 1 コマンドで他 OS 向けバイナリ が作れます。

GOOS=linux   GOARCH=amd64 go build -o dist/todo-linux .
GOOS=darwin  GOARCH=arm64 go build -o dist/todo-mac .
GOOS=windows GOARCH=amd64 go build -o dist/todo.exe .

バイナリを小さくしたい場合:

go build -ldflags="-s -w" .   # デバッグ情報を削除

バージョン情報を埋め込む:

var version = "dev"

// go build -ldflags="-X main.version=1.2.3"

embed:ファイルをバイナリに同梱

テンプレートや設定のデフォルト値を埋め込めます。

import _ "embed"

//go:embed default_config.json
var defaultConfig []byte

//go:embed templates/*
var templates embed.FS

14. よくある落とし穴

defer f.Close() のエラーを捨てている
読み取りでは問題ありませんが、書き込みでは Close がエラーを返すことがあります。厳密には次のように書きます。

func write(path string) (err error) {
    f, err := os.Create(path)
    if err != nil {
        return err
    }
    defer func() {
        if cerr := f.Close(); cerr != nil && err == nil {
            err = cerr
        }
    }()
    // ...
}

bufio.Writer の Flush 忘れ
defer w.Flush() を必ず書きます。

Scanner の Err 未確認
ループ後に sc.Err() を見ないと、途中の読み取りエラーを見逃します。

テストで実際のファイルパスを使う
t.TempDir() を使えば後片付け不要で並列実行も安全です。

t.Parallel() と共有状態
テストを並列化する場合、グローバル変数やカレントディレクトリに依存しないようにします。

os.Exit をライブラリ関数で呼ぶ
main 以外で os.Exit を呼ぶとテストできなくなります。エラーを返して main で終了コードを決めます。


15. 演習問題

基礎

  1. todo edit <id> <title> を実装し、テーブル駆動テストを書く
  2. todo list --json を追加し、JSON 形式で出力する。json.NewEncoder(a.out) を使うこと
  3. todo clear で完了済みを一括削除する。削除件数を出力する

中級

  1. wc クローン:標準入力または引数のファイルを読み、行数・単語数・バイト数を出力する。io.Reader を受け取る関数として実装し、strings.NewReader でテストする
  2. CSV 集計:name,amount 形式の CSV(encoding/csv)を読み、名前ごとの合計を降順で表示する。ヘッダ行のスキップと不正な行のエラー処理を含める
  3. 設定ファイルローダー:JSON の設定ファイルを読み込む LoadConfig(path string) (Config, error) を書く。ファイルがなければデフォルト値、あればマージする。testdata/ に正常・異常のサンプルを置いてテストする

応用

  1. ログ解析ツール:2025-01-01T12:00:00Z INFO message 形式のログを bufio.Scanner で読み、レベル別の件数と、指定した時間範囲の行だけを抽出するツールを作る。1GB のログでもメモリを食わないよう、全行をスライスに溜めないこと
  2. ゴールデンテスト:演習 5 の出力に対してゴールデンファイルテストを書く。-update フラグで再生成できるようにする
  3. ベンチマーク改善:Store.Add を 1000 回呼ぶベンチマークを取り、「保存を遅延させる Flush() メソッド」を追加して性能差を計測する
  4. cobra 化:Todo CLI 全体を cobra に移行し、todo completion zsh でシェル補完が動くことを確認する

次回予告

第3回では、この Store を HTTP 経由で操作する REST API に変えていきます。net/http の仕組み、ハンドラ、ミドルウェア、httptest を使ったテストを扱います。

B!
← 一覧へ戻る