シリーズ構成
- Go の基本文法
- CLI ツールを作る(本記事)
- HTTP サーバーと REST API
- データベース連携
- 実務的な周辺技術
- ポートフォリオを作る
- 実践知識を深める
この回の目標は 「実用的な CLI ツールを一つ完成させ、Go のテストを自然に書けるようになる」 ことです。
CLI から始める理由は 3 つあります。HTTP や DB という「外部要素」を排除して Go そのものに集中できること、io.Reader / io.Writer という Go で最重要の抽象を体で覚えられること、そしてバックエンドの実務では DB マイグレーションやバッチ処理など CLI ツールを頻繁に書くことです。
目次
- 作るもの:Todo CLI
- プロジェクトの初期化と構成
- ファイル I/O の基本
- JSON の読み書き
- データ層の実装
- コマンド引数の処理
- 標準入出力と io.Reader / io.Writer
- テストの書き方
- テーブル駆動テスト
- testdata とゴールデンファイル
- ベンチマーク
- cobra でサブコマンドを整える
- 配布:go install とクロスコンパイル
- よくある落とし穴
- 演習問題
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. 演習問題
基礎
todo edit <id> <title>を実装し、テーブル駆動テストを書くtodo list --jsonを追加し、JSON 形式で出力する。json.NewEncoder(a.out)を使うことtodo clearで完了済みを一括削除する。削除件数を出力する
中級
- wc クローン:標準入力または引数のファイルを読み、行数・単語数・バイト数を出力する。
io.Readerを受け取る関数として実装し、strings.NewReaderでテストする - CSV 集計:
name,amount形式の CSV(encoding/csv)を読み、名前ごとの合計を降順で表示する。ヘッダ行のスキップと不正な行のエラー処理を含める - 設定ファイルローダー:JSON の設定ファイルを読み込む
LoadConfig(path string) (Config, error)を書く。ファイルがなければデフォルト値、あればマージする。testdata/に正常・異常のサンプルを置いてテストする
応用
- ログ解析ツール:
2025-01-01T12:00:00Z INFO message形式のログをbufio.Scannerで読み、レベル別の件数と、指定した時間範囲の行だけを抽出するツールを作る。1GB のログでもメモリを食わないよう、全行をスライスに溜めないこと - ゴールデンテスト:演習 5 の出力に対してゴールデンファイルテストを書く。
-updateフラグで再生成できるようにする - ベンチマーク改善:
Store.Addを 1000 回呼ぶベンチマークを取り、「保存を遅延させるFlush()メソッド」を追加して性能差を計測する - cobra 化:Todo CLI 全体を cobra に移行し、
todo completion zshでシェル補完が動くことを確認する
次回予告
第3回では、この Store を HTTP 経由で操作する REST API に変えていきます。net/http の仕組み、ハンドラ、ミドルウェア、httptest を使ったテストを扱います。
Analyzegear