1-7. CLI アプリを作る - os.Args, flag, ファイル I/O, 実践 TODO

所要時間: 40-60分(がっつりなら2-3セッション分) コミット内容: ~/learn/go/day07/todo/ 一式(動く CLI)


このレッスンのゴール

  • os.Argsflag パッケージで CLI 引数を受け取れる
  • サブコマンド分岐(add/list/done/delete)を switch で書ける
  • os.ReadFile / os.WriteFile で JSON 永続化できる
  • エラーを os.Stderr に出して os.Exit(1) できる
  • go build~/bin 配置でグローバルに使えるツールが作れる

なぜ学ぶか - Level 1 の集大成として

Web API より先に CLI?」と思うかもしれないが、実務で一番すぐ作るのは CLI ツール。ログ集計、データ移行、ワンショット運用、CI のヘルパー - すべて CLI で書く。さらに Level 1 で学んだ全要素(struct/JSON タグ/エラー処理/関数分割/ファイル I/O)が一気に1つのアプリに集約される 集大成のレッスン

前章とのつながり(間隔反復)

Level 1 の全要素を再活用 するメタ的な復習でもある。

これができると何が嬉しいか

  • 自分専用のスクリプトを Go で書く 習慣ができる(シェルスクリプトより堅い)
  • go install github.com/foo/bar@latest で全世界に配布 できる土台
  • シングルバイナリを Docker に置く デプロイの基本パターンを体感

ストーリー導入: CLI は「Unix の流儀に乗る」儀式

CLI ツールを書く = Unix の「入力を受け、結果を出し、終了コードを返す」哲学に乗るtodo list | grep "PR" | wc -l のように パイプで他のコマンドと繋げる には、stdout / stderr / exit code を正しく扱う必要がある。本章で身につくのは「Goの書き方」だけでなく、Linux の作法そのもの


大前提: なぜ最初に CLI を作るか

JS/TS 出身の場合、Webアプリ(フロント or API)から入りたくなる。でも Go の入門で CLI を最初に通す のには理由がある:

  • HTTP・JS のような「ブラウザ依存」が無い → 環境差で詰まらない
  • 入出力が単純 → 引数を受け、結果を出す、それだけ
  • 本物のバックエンドエンジニアは CLI ツールを自作する → ログ集計、データ移行、ワンショット運用ツールなど
  • Go の強みが活きる → シングルバイナリ・高速起動・クロスコンパイル

今日作る TODO CLI は単純だが、ファイル I/O + コマンドライン引数 + エラー処理 が全部入る。これが書ければ「Go で動くものが作れる人」になる。


セッション①: os.Args と flag パッケージ(25-30分)

0. 録画スタート&作業ディレクトリ

mkdir -p ~/log ~/learn/go/day07/todo
cd ~/learn/go/day07/todo
go mod init example.com/todo
script ~/log/go_day07.log

1. os.Args - 生の引数

package main
 
import (
	"fmt"
	"os"
)
 
func main() {
	fmt.Println("全引数:", os.Args)
	fmt.Println("プログラム名:", os.Args[0])
	if len(os.Args) > 1 {
		fmt.Println("第1引数:", os.Args[1])
	}
}

実行:

go run . hello world
# 全引数: [/tmp/go-build123/exe/todo hello world]
# プログラム名: /tmp/go-build123/exe/todo
# 第1引数: hello

os.Args の本質

プログラムに渡されたコマンドライン引数のスライス。[]string 型。

  • os.Args[0] : プログラム自身のパス
  • os.Args[1] 以降 : ユーザーが渡した引数

JS で言うと: Node.js の process.argv とほぼ同じ。argv[0] が node 本体、argv[1] がスクリプトパス、argv[2] 以降がユーザー引数だったのに対し、Go の os.Args[0] はプログラム自身。

os.Args の使い所と限界

  • 使い所: 単純な引数1個だけ受け取るスクリプト的なツール
  • 限界: --flag value-n のような オプション解析 は自前でやると面倒
  • 解決: 標準ライブラリの flag パッケージを使う(次の節)

2. flag パッケージ - オプション解析の標準

package main
 
import (
	"flag"
	"fmt"
)
 
func main() {
	// flag を定義
	name := flag.String("name", "world", "名前を指定")
	count := flag.Int("count", 1, "繰り返し回数")
	verbose := flag.Bool("verbose", false, "詳細出力")
 
	// 解析(必ず呼ぶ)
	flag.Parse()
 
	if *verbose {
		fmt.Printf("name=%s count=%d\n", *name, *count)
	}
 
	for i := 0; i < *count; i++ {
		fmt.Printf("Hello, %s!\n", *name)
	}
 
	// 残った引数(オプション以外)
	fmt.Println("残り引数:", flag.Args())
}

実行:

go run . -name Takato -count 3
# Hello, Takato!
# Hello, Takato!
# Hello, Takato!
 
go run . -name Alice -count 2 -verbose
# name=Alice count=2
# Hello, Alice!
# Hello, Alice!
 
go run . -h
# Usage of /tmp/.../todo:
#   -count int
#         繰り返し回数 (default 1)
#   -name string
#         名前を指定 (default "world")
#   -verbose
#         詳細出力

flag パッケージの本質

標準ライブラリで --flag value 形式のオプション解析をサポートする。

使い方の流れ

  1. flag.String(名前, デフォルト, 説明) 等で ポインタ を受け取る
  2. flag.Parse() を呼ぶ(忘れると何も解析されない
  3. *name のように デリファレンス して値を使う

対応する型

  • flag.String*string
  • flag.Int*int
  • flag.Bool*bool
  • flag.Float64*float64
  • flag.Duration*time.Duration

JS で言うと: Node の commander yargs minimist のようなライブラリ相当が標準で入っている。

-h / -help で自動的に USAGE が出る

flag はオプションの定義から自動でヘルプを生成する。-h-help を打つだけで、定義した説明文込みのヘルプが表示される。

メリット: ヘルプメッセージを別途書く必要がない。flag.String の第3引数(説明)が即座にユーザーに伝わる。

flag の落とし穴

  • flag.Parse() を忘れる: 一番のハマりポイント。デフォルト値しか取れない
  • *name のデリファレンスを忘れる: *name ではなく name を使うと *string 型のままで型エラー
  • オプションは引数の に書く: go run . pos1 -name x だと -name x がオプションとして認識されない場合がある(Go 1.20以前)
  • --flag でも -flag でも動く: 2形態OK。慣習はプロジェクト依存
  • 「クォートなしの空白入り」は分割される: -name "Tak Ato" のようにクォート必須

サブコマンド対応の自前パターン

flag パッケージは git commit / git push のような サブコマンド を直接サポートしない。自前で分岐する:

if len(os.Args) < 2 {
	fmt.Println("subcommand required")
	os.Exit(1)
}
switch os.Args[1] {
case "add":
	addCmd := flag.NewFlagSet("add", flag.ExitOnError)
	title := addCmd.String("title", "", "TODO のタイトル")
	addCmd.Parse(os.Args[2:])
	// title を使って処理
case "list":
	// ...
}

強力なサブコマンド対応が必要ならspf13/cobra ライブラリ(Kubernetes・GitHub CLI 等が採用)。


セッション②: ファイル読み書きと TODO CLI 実装(30-40分)

3. ファイル読み書きの基本

package main
 
import (
	"fmt"
	"os"
)
 
func main() {
	// 書き込み
	f, err := os.Create("hello.txt")
	if err != nil {
		fmt.Println("作成失敗:", err)
		return
	}
	defer f.Close() // 関数終了時に必ず閉じる
 
	_, err = f.WriteString("Hello, file!\n")
	if err != nil {
		fmt.Println("書き込み失敗:", err)
		return
	}
 
	// 読み込み(一括)
	data, err := os.ReadFile("hello.txt")
	if err != nil {
		fmt.Println("読み込み失敗:", err)
		return
	}
	fmt.Println("内容:", string(data))
}

ファイル操作の関数たち

関数用途
os.Create(path)ファイルを作成(既存なら上書き)
os.Open(path)読み取り用に開く
os.OpenFile(path, flag, perm)細かい指定(追記モード等)
os.ReadFile(path)ファイル全体を []byte で読む(簡易)
os.WriteFile(path, data, perm)バイトをファイルに書く(簡易)
f.Close()閉じる(必ず defer で

os.Create vs os.OpenFile

// 上書き
f, _ := os.Create("a.txt")
 
// 追記モード(既存に書き足す)
f, _ := os.OpenFile("a.txt", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)

os.OpenFile のフラグ

  • os.O_RDONLY: 読み込みのみ
  • os.O_WRONLY: 書き込みのみ
  • os.O_RDWR: 読み書き
  • os.O_APPEND: 追記
  • os.O_CREATE: 無ければ作る
  • os.O_TRUNC: 開く時に空にする

ビット OR | で組み合わせる。ログファイル追記O_APPEND|O_CREATE|O_WRONLY の3点セットが定番。

os.ReadFile / os.WriteFile (簡易API)

全体を一括で読み書き」する手っ取り早い関数。Go 1.16 から ioutil パッケージから os に移った。

data, err := os.ReadFile("config.json")
// data は []byte
 
err := os.WriteFile("out.txt", []byte("hello"), 0644)

使い所

  • 小〜中サイズのファイル(数 MB まで)
  • 設定ファイル、データダンプ

使うべきでない場面

  • 巨大ファイル(メモリに乗り切らない) → ストリーミング読み込み(bufio.Scanner 等)
  • リアルタイム更新 → tail -f 相当には別の仕組みが必要

ファイル I/O の落とし穴

  • defer f.Close() 忘れ: ファイルディスクリプタが漏れる。長時間動くプログラムで OS の上限に引っかかる
  • エラー無視: _, _ = f.Write(...) のような書き方は本番では NG
  • os.Open から defer Close にエラー return すると defer は走らない: Open のエラーチェックを Close の defer より前に
  • 権限(mode)の値: 0644 のような8進数。0 始まりが必要(10進数の 644 と全く違う)

4. TODO CLI を作る - 設計

コマンド:
  todo add <タイトル>     - TODO を追加
  todo list                - TODO 一覧を表示
  todo done <ID>          - TODO を完了済みにする
  todo delete <ID>        - TODO を削除

データ保存: ~/.todo.json
形式: JSON 配列

ファイル: main.go

package main
 
import (
	"encoding/json"
	"fmt"
	"os"
	"path/filepath"
	"strconv"
	"time"
)
 
// TODO 1件
type Todo struct {
	ID        int       `json:"id"`
	Title     string    `json:"title"`
	Done      bool      `json:"done"`
	CreatedAt time.Time `json:"created_at"`
}
 
// 保存ファイルのパス
func dataPath() (string, error) {
	home, err := os.UserHomeDir()
	if err != nil {
		return "", err
	}
	return filepath.Join(home, ".todo.json"), nil
}
 
// 読み込み
func loadTodos() ([]Todo, error) {
	path, err := dataPath()
	if err != nil {
		return nil, err
	}
 
	data, err := os.ReadFile(path)
	if err != nil {
		if os.IsNotExist(err) {
			// 初回起動でファイル無し: 空リストで OK
			return []Todo{}, nil
		}
		return nil, err
	}
 
	var todos []Todo
	if err := json.Unmarshal(data, &todos); err != nil {
		return nil, fmt.Errorf("JSON 解析エラー: %w", err)
	}
	return todos, nil
}
 
// 保存
func saveTodos(todos []Todo) error {
	path, err := dataPath()
	if err != nil {
		return err
	}
 
	data, err := json.MarshalIndent(todos, "", "  ")
	if err != nil {
		return err
	}
	return os.WriteFile(path, data, 0644)
}
 
// 次の ID を採番
func nextID(todos []Todo) int {
	max := 0
	for _, t := range todos {
		if t.ID > max {
			max = t.ID
		}
	}
	return max + 1
}
 
// コマンド: add
func cmdAdd(args []string) error {
	if len(args) < 1 {
		return fmt.Errorf("使い方: todo add <タイトル>")
	}
	title := args[0]
 
	todos, err := loadTodos()
	if err != nil {
		return err
	}
 
	todos = append(todos, Todo{
		ID:        nextID(todos),
		Title:     title,
		Done:      false,
		CreatedAt: time.Now(),
	})
 
	if err := saveTodos(todos); err != nil {
		return err
	}
 
	fmt.Println("追加しました:", title)
	return nil
}
 
// コマンド: list
func cmdList() error {
	todos, err := loadTodos()
	if err != nil {
		return err
	}
 
	if len(todos) == 0 {
		fmt.Println("TODO はまだありません")
		return nil
	}
 
	for _, t := range todos {
		mark := "[ ]"
		if t.Done {
			mark = "[x]"
		}
		fmt.Printf("%s #%d %s (%s)\n",
			mark, t.ID, t.Title, t.CreatedAt.Format("2006-01-02 15:04"))
	}
	return nil
}
 
// コマンド: done
func cmdDone(args []string) error {
	if len(args) < 1 {
		return fmt.Errorf("使い方: todo done <ID>")
	}
	id, err := strconv.Atoi(args[0])
	if err != nil {
		return fmt.Errorf("ID は数値で: %w", err)
	}
 
	todos, err := loadTodos()
	if err != nil {
		return err
	}
 
	found := false
	for i := range todos {
		if todos[i].ID == id {
			todos[i].Done = true
			found = true
			break
		}
	}
	if !found {
		return fmt.Errorf("ID %d が見つかりません", id)
	}
 
	if err := saveTodos(todos); err != nil {
		return err
	}
	fmt.Printf("完了マーク: #%d\n", id)
	return nil
}
 
// コマンド: delete
func cmdDelete(args []string) error {
	if len(args) < 1 {
		return fmt.Errorf("使い方: todo delete <ID>")
	}
	id, err := strconv.Atoi(args[0])
	if err != nil {
		return fmt.Errorf("ID は数値で: %w", err)
	}
 
	todos, err := loadTodos()
	if err != nil {
		return err
	}
 
	result := []Todo{}
	found := false
	for _, t := range todos {
		if t.ID == id {
			found = true
			continue
		}
		result = append(result, t)
	}
	if !found {
		return fmt.Errorf("ID %d が見つかりません", id)
	}
 
	if err := saveTodos(result); err != nil {
		return err
	}
	fmt.Printf("削除しました: #%d\n", id)
	return nil
}
 
func usage() {
	fmt.Println("使い方:")
	fmt.Println("  todo add <タイトル>")
	fmt.Println("  todo list")
	fmt.Println("  todo done <ID>")
	fmt.Println("  todo delete <ID>")
}
 
func main() {
	if len(os.Args) < 2 {
		usage()
		os.Exit(1)
	}
 
	cmd := os.Args[1]
	args := os.Args[2:]
 
	var err error
	switch cmd {
	case "add":
		err = cmdAdd(args)
	case "list":
		err = cmdList()
	case "done":
		err = cmdDone(args)
	case "delete":
		err = cmdDelete(args)
	case "-h", "--help", "help":
		usage()
	default:
		fmt.Printf("不明なコマンド: %s\n", cmd)
		usage()
		os.Exit(1)
	}
 
	if err != nil {
		fmt.Fprintln(os.Stderr, "エラー:", err)
		os.Exit(1)
	}
}

5. 動作確認

# 追加
go run . add "Go の学習を続ける"
go run . add "リファクタの本を読む"
go run . add "PR レビュー"
 
# 一覧
go run . list
# [ ] #1 Go の学習を続ける (2026-05-14 16:00)
# [ ] #2 リファクタの本を読む (2026-05-14 16:00)
# [ ] #3 PR レビュー (2026-05-14 16:00)
 
# 完了
go run . done 1
go run . list
 
# 削除
go run . delete 2
go run . list
 
# 永続化されているか確認(一度プログラムを終了しても残るはず)
cat ~/.todo.json

動いたら何ができている?

  • 引数解析: サブコマンドと残り引数を分けて処理
  • 構造化データ: struct + JSON タグでファイルに保存
  • 永続化: ホームディレクトリの隠しファイルに JSON 保存
  • エラー処理: 各操作のエラーを上に伝えて main で一括処理
  • defer なしでも壊れない: os.ReadFile / os.WriteFile は内部で Close している

これは 本格的な CLI ツールの骨格 そのもの。

6. ビルドしてグローバルに使う

# バイナリ作成
go build -o todo
 
# ~/bin にコピー(PATH 通っているなら)
mkdir -p ~/bin
cp todo ~/bin/
 
# どこからでも使える
cd /tmp
todo add "tmp から追加"
todo list

グローバルインストールのもう1つの手段: go install

go install .

これで $GOPATH/bin(だいたい ~/go/bin)にバイナリが置かれる。~/go/bin を PATH に追加すれば、コピー不要で使える。

公開リポジトリなら、別マシンから:

go install github.com/<user>/todo@latest

でインストールも可能。Goツールの配布はこれが一番楽

7. エラー処理のおさらい

このプログラムのエラー処理パターン

  • cmdXxx 関数は error を返す
  • main が エラーを os.Stderr に出力 + 非ゼロ終了コード
  • これが Unix コマンドの作法(echo $? で確認可能)
if err != nil {
	fmt.Fprintln(os.Stderr, "エラー:", err)
	os.Exit(1)
}

重要なポイント

  • エラーメッセージは os.Stderr へ(標準出力ではない)
  • 終了コードは 0 で成功、それ以外で失敗
  • これにより todo list && echo "OK" のようにシェルでチェーンできる

改造課題(写経しないための「自分の技」)

このまま終わると写経で終わる。下のうち2-3個を選んで自分で実装してみる。

改造アイデア

初級

  1. --all フラグ追加: todo list --all で完了済みも表示、デフォルトは未完了のみ
  2. 削除の確認プロンプト: todo delete 1 で「本当に削除?(y/n)」を表示
  3. clear コマンド: 完了済みを一括削除

中級 4. タグ機能: todo add "買い物" --tag shopping でタグ付け、todo list --tag shopping で絞り込み 5. 期限機能: --due 2026-05-20 で期限設定、期限切れを赤字で表示(ターミナルカラー) 6. csv エクスポート: todo export todos.csv

上級 7. flag.FlagSet でサブコマンドごとのオプション解析を書き直す 8. テストを書く: add_test.gocmdAdd の動作を検証 9. cobra ライブラリに置き換える: go get github.com/spf13/cobra で本格構造

「動くものに自分で機能を足す」が一番身につく。エラーで詰まったら ChatGPT/Claude に聞いていい。聞き方の練習にもなる。


締め: 振り返り(10分)

1. セッション録画を終了

exit

2. 今日の発見(このノートに追記)

- TODO CLI を作って一番「Goっぽい」と感じた部分:
- ハマったエラーとその解消:
- 改造で追加した機能:
- ここまでで Go で書けるようになった実感:

アンチパターン集 - やらかし事例

CLI 開発のやらかし

1. エラーを stdout に出す

fmt.Println("エラー:", err)  // ← stdout に出ている

todo list 2>/dev/null でエラーだけ抑制したい時に困る。fmt.Fprintln(os.Stderr, ...) が正解。

2. 失敗時に exit code 0 で終わる

if err != nil {
    fmt.Println(err)
    return  // ← exit 0 で終了。シェルから見ると成功
}

todo add ... && echo OK のチェーンが意図しない動作になる。os.Exit(1) で非ゼロ終了。

3. ファイル権限の8進数を10進数で書く

os.WriteFile(path, data, 644)   // ← 10進数 644 = 8進数 01204、意味不明
os.WriteFile(path, data, 0644)  // ← 正しい(rw-r--r--)

4. JSON Marshal / Unmarshal でポインタを忘れる

json.Unmarshal(data, todos)   // NG: 値渡しで反映されない
json.Unmarshal(data, &todos)  // OK

5. ホームディレクトリパスをハードコード

path := "/Users/takato/.todo.json"  // ← 他人の環境で死ぬ

os.UserHomeDir() + filepath.Join で OS / ユーザー非依存に。

対比表で違いを明確化

os.Args vs flag

観点os.Argsflag
位置引数OKflag.Args() で取得
-name value 形式自前パース必要自動
ヘルプ自動生成なし-h で出る
用途単純なサブコマンド分岐複数オプション
上級-flag.FlagSet でサブコマンド対応

os.ReadFile vs bufio.Scanner

観点os.ReadFilebufio.Scanner
読み方一括 []byte行ごとストリーミング
メモリ全部に乗せる行単位
用途小〜中ファイル(数MB)巨大ファイル・ストリーム

自己評価チェックリスト

手を動かせた

  • os.Args で引数を読んだ
  • flag.String/Int/Bool を定義し flag.Parse() で解析
  • サブコマンド分岐(switch)を書いた
  • json.MarshalIndent で見やすい JSON を保存
  • ~/.todo.json に永続化された
  • go build -o todo でバイナリを作った
  • ~/bin 等に置いてどこからでも使えた
  • 自分で改造を1つ以上追加した

説明できる

  • os.Stderros.Stdout の使い分け
  • exit code 0 / 非ゼロ の意味
  • os.UserHomeDir() + filepath.Join を使う理由
  • os.IsNotExist(err) で「初回起動」を判定する設計

やらかし回避

  • パーミッションは8進数 (0644) で書く
  • Unmarshal には &v のポインタを渡す
  • ホームパスはハードコードしない
  • エラーは os.Stderr + os.Exit(非ゼロ)

詰まった時のチートシート

やりたいこと書き方
引数全部os.Args
flag 定義name := flag.String("name", "default", "説明")
flag 解析flag.Parse()
flag 値取得*name
flag 以外の引数flag.Args()
ファイル一括読み込みdata, err := os.ReadFile(path)
ファイル一括書き込みos.WriteFile(path, data, 0644)
ホームディレクトリos.UserHomeDir()
パス結合filepath.Join(a, b)
存在チェックos.IsNotExist(err)
JSON 化json.Marshal(v) / json.MarshalIndent(v, "", " ")
JSON 解析json.Unmarshal(data, &v)
エラー出力fmt.Fprintln(os.Stderr, err)
終了コードos.Exit(1)
エラーラップfmt.Errorf("...: %w", err)

「実務OK」基準

このレッスンで身に付くべき感覚:

  • 新しい CLI ツールが必要になっても「型」が頭にある: 「サブコマンド → flag → 本体 → エラー処理」
  • JSON ファイル経由のデータ永続化が書ける: 軽量な状態保持の鉄板
  • エラーは戻り値で上に伝える: 例外を投げる文化に逆らえる
  • os.Stderros.Exit で Unix らしいコマンドが書ける: シェルとの相性が良い
  • 「写経じゃなく自分で改造する」習慣: 読むだけでは身につかない

Level 1 振り返り

ここまでで Go の基本構文がひと通り体に入った状態。

書けるようになったこと

これで 「動くGoツールが自分で作れる」段階まで来た。

次のレッスン

Level 2 へ。

次は interface・エラーハンドリング・パッケージ分割・テスト を扱う。CLI ツール1個から、複数ファイル・複数パッケージで構成される「もう少し本格的なツール」が書ける段階を目指す。

具体的には:

  • interface と「ダックタイピング」
  • 標準ライブラリ errors パッケージ・カスタムエラー型
  • パッケージ分割と internal/ の規約
  • go test でのテスト
  • goroutinechannel の入り口