1-7. CLI アプリを作る - os.Args, flag, ファイル I/O, 実践 TODO
所要時間: 40-60分(がっつりなら2-3セッション分) コミット内容:
~/learn/go/day07/todo/一式(動く CLI)
このレッスンのゴール
-
os.Argsとflagパッケージで 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つのアプリに集約される 集大成のレッスン。
前章とのつながり(間隔反復)
- 1-1_HelloWorld の
go mod init→ 新規プロジェクト作成 - 1-2_変数と型 の
strconv.Atoi→ ID を文字列から数値に変換 - 1-3_制御構文 の
defer→ ファイル Close で(簡易API使うので最小限) - 1-3_制御構文 の
switch→ サブコマンド分岐 - 1-4_関数 の
(result, err)→ 全コマンド関数がerrorを返す - 1-5_スライスとマップ の
append→ TODO リストへの追加 - 1-6_構造体とメソッド の JSON タグ → 永続化フォーマット
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.log1. 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形式のオプション解析をサポートする。使い方の流れ
flag.String(名前, デフォルト, 説明)等で ポインタ を受け取るflag.Parse()を呼ぶ(忘れると何も解析されない)*nameのように デリファレンス して値を使う対応する型
flag.String→*stringflag.Int→*intflag.Bool→*boolflag.Float64→*float64flag.Duration→*time.DurationJS で言うと: Node の
commanderyargsminimistのようなライブラリ相当が標準で入っている。
-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.Createvsos.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(...)のような書き方は本番では NGos.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 installgo 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個を選んで自分で実装してみる。
改造アイデア
初級
--allフラグ追加:todo list --allで完了済みも表示、デフォルトは未完了のみ- 削除の確認プロンプト:
todo delete 1で「本当に削除?(y/n)」を表示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.goでcmdAddの動作を検証 9. cobra ライブラリに置き換える:go get github.com/spf13/cobraで本格構造「動くものに自分で機能を足す」が一番身につく。エラーで詰まったら ChatGPT/Claude に聞いていい。聞き方の練習にもなる。
締め: 振り返り(10分)
1. セッション録画を終了
exit2. 今日の発見(このノートに追記)
- 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) // OK5. ホームディレクトリパスをハードコード
path := "/Users/takato/.todo.json" // ← 他人の環境で死ぬ
os.UserHomeDir()+filepath.Joinで OS / ユーザー非依存に。
対比表で違いを明確化
os.Argsvsflag
観点 os.Argsflag位置引数 OK flag.Args()で取得-name value形式自前パース必要 自動 ヘルプ自動生成 なし -hで出る用途 単純なサブコマンド分岐 複数オプション 上級 - flag.FlagSetでサブコマンド対応
os.ReadFilevsbufio.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.Stderrとos.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.Stderrとos.Exitで Unix らしいコマンドが書ける: シェルとの相性が良い- 「写経じゃなく自分で改造する」習慣: 読むだけでは身につかない
Level 1 振り返り
ここまでで Go の基本構文がひと通り体に入った状態。
書けるようになったこと
- Hello World とプロジェクト作成
- 変数・型・const・iota
- defer
- 関数・多値返却・クロージャ
- スライス・マップ
- struct・メソッド・embedding・タグ
- CLI アプリ実践
これで 「動くGoツールが自分で作れる」段階まで来た。
次のレッスン
Level 2 へ。
次は interface・エラーハンドリング・パッケージ分割・テスト を扱う。CLI ツール1個から、複数ファイル・複数パッケージで構成される「もう少し本格的なツール」が書ける段階を目指す。
具体的には:
interfaceと「ダックタイピング」- 標準ライブラリ
errorsパッケージ・カスタムエラー型 - パッケージ分割と
internal/の規約 go testでのテストgoroutineとchannelの入り口