[Goのはじめ方] Part30: CLIツールの開発

Go

はじめに

コマンドラインインターフェース(CLI)ツールは、ターミナルからコマンドを実行して特定のタスクを行うためのプログラムです。開発作業の自動化や日々の定型作業の効率化など、様々な場面で活躍します。

Go言語は、CLIツール開発において多くのメリットがあります。

  • シンプルで高速: Go言語のコードは簡潔で読みやすく、コンパイル後の実行速度も非常に高速です。
  • クロスコンパイルが容易: 一つのコードから、Windows, macOS, Linuxなど、様々なOS向けの実行ファイルを簡単に作成できます。
  • 豊富な標準パッケージ: ファイル操作、ネットワーク通信、JSON処理など、CLIツール開発に必要な機能の多くが標準パッケージで提供されています。
  • シングルバイナリ: 依存関係を含めて単一の実行ファイルにコンパイルできるため、配布やデプロイが非常に簡単です。

このステップでは、Go言語を使って簡単なCLIツールを作成するプロセスを学びます。コマンドライン引数の処理、ファイルへのデータ保存、そしてツールのビルド方法などをカバーします。簡単なタスク管理ツールを例に進めていきましょう!

環境準備

まず、プロジェクト用のディレクトリを作成し、Go Modulesを使ってプロジェクトを初期化します。ターミナルで以下のコマンドを実行してください。


mkdir my-cli-app
cd my-cli-app
go mod init my-cli-app
    

これで、my-cli-app ディレクトリ内に go.mod ファイルが作成され、Goプロジェクトの準備が整いました。main.go という名前でソースファイルを作成し、コーディングを始めましょう。

💡 Go言語のインストールや基本的な設定がまだの方は、Step 1 の内容を先に確認してくださいね。

基本的なCLIツールの作成: コマンドライン引数を扱ってみよう

CLIツールの基本的な機能の一つが、コマンドラインから渡された引数やオプション(フラグとも呼ばれます)を処理することです。Go言語の標準パッケージ flag を使うと、これを簡単に行うことができます。

例として、メッセージ文字列、繰り返し回数、詳細表示モードを受け取る簡単なプログラムを作成してみましょう。main.go に以下のコードを記述します。


package main

import (
	"flag" // コマンドライン引数処理パッケージ
	"fmt"
	"os" // os.Args を使う場合などに必要
)

func main() {
	// 文字列型のフラグ "-m" を定義します。デフォルト値と説明も指定します。
	// flag.String(フラグ名, デフォルト値, 説明文)
	msg := flag.String("m", "デフォルトメッセージ", "表示するメッセージを指定します")

	// 数値型のフラグ "-c" を定義します。
	// flag.Int(フラグ名, デフォルト値, 説明文)
	count := flag.Int("c", 1, "メッセージを繰り返す回数を指定します")

	// bool型のフラグ "-v" を定義します。指定されるとtrueになります。
	// flag.Bool(フラグ名, デフォルト値, 説明文)
	verbose := flag.Bool("v", false, "詳細な情報を表示します (verboseモード)")

	// 定義したフラグを実際にコマンドライン引数から解析(パース)します。
	// この呼び出しは、全てのフラグ定義の後に行う必要があります。
	flag.Parse()

	// ポインタ経由でフラグの値にアクセスします (*変数名)
	if *verbose {
		fmt.Println("--- 詳細表示モード ---")
	}

	for i := 0; i < *count; i++ {
		fmt.Printf("[%d] メッセージ: %s\n", i+1, *msg)
	}

	// flag.Parse() の後に残った引数 (フラグ以外の引数) を取得します。
	// flag.Args() は []string スライスを返します。
	otherArgs := flag.Args()
	if len(otherArgs) > 0 {
		fmt.Println("--- フラグ以外の引数 ---")
		for i, arg := range otherArgs {
			fmt.Printf("引数%d: %s\n", i+1, arg)
		}
	}
}
    

このコードを実行してみましょう。ターミナルで go run main.go の後に、定義したフラグを使って様々なオプションを指定します。


# ヘルプメッセージを表示 (-h または --help)
go run main.go -h

# オプションを指定して実行
go run main.go -m "Go言語楽しい!" -c 3 -v これはフラグ以外の引数です

# オプションを指定しない場合 (デフォルト値が使われる)
go run main.go
    

flag パッケージを使うことで、コマンドラインからの入力を簡単に扱えるようになりましたね! ✨

機能の追加: サブコマンドとファイル操作

より複雑なCLIツールでは、git addgit commit のように、「サブコマンド」を使って機能を切り替えることがよくあります。また、データを永続化するためにファイル操作も必要になります。

サブコマンドの実装

標準の flag パッケージでも、工夫すればサブコマンドを実装できます。基本的な考え方は、まず最初の引数をサブコマンド名として解釈し、その後、サブコマンドごとに異なるフラグセットをパースするというものです。

ただし、サブコマンドが増えてくると管理が複雑になりがちです。そのため、実際の開発では以下のようなサードパーティ製のライブラリがよく利用されます。これらのライブラリは、サブコマンド、フラグ、ヘルプメッセージの生成などをより簡単に、構造的に扱えるように設計されています。

  • Cobra: 高機能で柔軟性が高く、多くの有名なGoプロジェクト(Docker, Kubernetesなど)で採用されています。コマンドの自動生成機能などもあります。
  • urfave/cli: Cobraと並んで人気のあるライブラリで、比較的シンプルに使い始められます。

ここでは、標準の flag パッケージを使ってシンプルなサブコマンド(例: addlist)を実装する方法を見てみましょう。flag.NewFlagSet を使ってサブコマンドごとのフラグセットを作成します。


package main

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

func main() {
	// --- サブコマンドの定義 ---
	// "add" サブコマンド用のフラグセットを作成
	addCmd := flag.NewFlagSet("add", flag.ExitOnError)
	// "add" サブコマンド用のフラグ "-m" (メッセージ) を定義
	addMsg := addCmd.String("m", "", "追加するタスクの内容 (必須)")

	// "list" サブコマンド用のフラグセットを作成
	listCmd := flag.NewFlagSet("list", flag.ExitOnError)
	// "list" サブコマンド用のフラグ "-a" (すべて表示) を定義
	listAll := listCmd.Bool("a", false, "完了済みタスクも含めてすべて表示する")

	// --- メイン処理 ---
	// コマンドライン引数が少なすぎる場合 (プログラム名しか無い or サブコマンドが無い)
	if len(os.Args) < 2 {
		fmt.Println("エラー: サブコマンドが必要です ('add' または 'list')")
		fmt.Println("\n使い方:")
		fmt.Println("  my-cli-app add -m \"タスク内容\"")
		fmt.Println("  my-cli-app list [-a]")
		// 各サブコマンドのヘルプを表示する例
		fmt.Println("\nadd サブコマンドのオプション:")
		addCmd.PrintDefaults() // addCmd に定義されたフラグのヘルプを表示
		fmt.Println("\nlist サブコマンドのオプション:")
		listCmd.PrintDefaults() // listCmd に定義されたフラグのヘルプを表示
		os.Exit(1)          // エラーで終了
	}

	// os.Args[1] (プログラム名の次の引数) をサブコマンドとして判定
	switch os.Args[1] {
	case "add":
		// "add" サブコマンドの処理
		// os.Args[2:] (サブコマンド名の後) を addCmd でパース
		addCmd.Parse(os.Args[2:])
		if *addMsg == "" {
			fmt.Println("エラー: 'add' サブコマンドには -m オプションが必須です。")
			addCmd.PrintDefaults()
			os.Exit(1)
		}
		fmt.Printf("タスクを追加します: '%s'\n", *addMsg)
		// !!! ここで実際にタスクを追加する処理を実装します (後のファイル操作で) !!!

	case "list":
		// "list" サブコマンドの処理
		// os.Args[2:] を listCmd でパース
		listCmd.Parse(os.Args[2:])
		fmt.Println("タスク一覧を表示します。")
		if *listAll {
			fmt.Println("(完了済みタスクも表示)")
		}
		// !!! ここで実際にタスク一覧を表示する処理を実装します (後のファイル操作で) !!!

	default:
		// 不明なサブコマンドの場合
		fmt.Printf("エラー: 不明なサブコマンド '%s'\n", os.Args[1])
		fmt.Println("利用可能なサブコマンド: 'add', 'list'")
		os.Exit(1)
	}
}
    

このコードを実行してみます。


# add サブコマンドを実行
go run main.go add -m "牛乳を買う"

# list サブコマンドを実行 (-a オプション付き)
go run main.go list -a

# サブコマンドを指定しない、または不明なサブコマンドを指定するとエラーメッセージが表示される
go run main.go
go run main.go unknown
    

これでサブコマンドの基本的な骨組みができました。

ファイルへのデータ保存 (JSON形式)

CLIツールで扱ったデータを次回起動時にも使えるように、ファイルに保存しましょう。ここでは、タスクリストをJSON (JavaScript Object Notation) 形式でファイルに保存・読み込みする方法を学びます。JSONは人間にも読みやすく、プログラムでも扱いやすいデータ形式です。

Goの標準パッケージ encoding/json を使うと、Goの構造体とJSONデータの相互変換が簡単に行えます。また、ファイルの読み書きには os パッケージを使います。

まず、タスクを表す構造体を定義します。json:"..." というタグを付けることで、JSONでのキー名を指定できます。


package main

import (
	"encoding/json" // JSON処理用パッケージ
	"fmt"
	"os" // ファイル操作用パッケージ
	// "flag" // (前のコードから引き続き使う)
	// "path/filepath" // ファイルパス操作に便利 (今回は使わないが紹介)
)

// タスクデータを表す構造体
type Task struct {
	ID   int    `json:"id"`   // JSONでのキー名を "id" にする
	Text string `json:"text"` // JSONでのキー名を "text" にする
	Done bool   `json:"done"` // JSONでのキー名を "done" にする
}

// タスクデータを保存するファイル名
const dataFile = "tasks.json"

// ファイルからタスクリストを読み込む関数
func loadTasks() ([]Task, error) {
	// dataFile を読み込みます。
	data, err := os.ReadFile(dataFile)
	if err != nil {
		// ファイルが存在しないエラー (os.IsNotExist) の場合は、
		// 初回起動などと考えられるため、空のタスクリストと nil エラーを返します。
		if os.IsNotExist(err) {
			return []Task{}, nil // 空のスライスを返す
		}
		// それ以外の読み込みエラーの場合
		return nil, fmt.Errorf("ファイル '%s' の読み込みに失敗しました: %w", dataFile, err)
	}

	// 読み込んだJSONデータを []Task スライスに変換 (Unmarshal) します。
	var tasks []Task
	err = json.Unmarshal(data, &tasks) // &tasks でポインタを渡す
	if err != nil {
		return nil, fmt.Errorf("ファイル '%s' のJSON解析に失敗しました: %w", dataFile, err)
	}

	return tasks, nil // 成功したらタスクリストと nil エラーを返す
}

// タスクリストをファイルに保存する関数
func saveTasks(tasks []Task) error {
	// []Task スライスをJSONデータ (バイトスライス) に変換 (Marshal) します。
	// MarshalIndent を使うと、人間が読みやすいようにインデントされたJSONになります。
	data, err := json.MarshalIndent(tasks, "", "  ") // 第2, 第3引数はプレフィックスとインデント
	if err != nil {
		return fmt.Errorf("タスクデータのJSON変換に失敗しました: %w", err)
	}

	// JSONデータをファイル (dataFile) に書き込みます。
	// os.WriteFile はファイルを新規作成または上書きします。
	// 0600 はファイルのパーミッション (所有者のみ読み書き可能) です。
	err = os.WriteFile(dataFile, data, 0600)
	if err != nil {
		return fmt.Errorf("ファイル '%s' への書き込みに失敗しました: %w", dataFile, err)
	}

	return nil // 成功したら nil エラーを返す
}

// --- main 関数 (サブコマンド処理部分を修正) ---
/*
func main() {
	// ... (フラグとサブコマンドの定義は省略) ...

	if len(os.Args) < 2 {
		// ... (エラー処理は省略) ...
		os.Exit(1)
	}

	// 最初にタスクをファイルから読み込む
	tasks, err := loadTasks()
	if err != nil {
		fmt.Fprintf(os.Stderr, "エラー: タスクの読み込みに失敗しました: %v\n", err)
		os.Exit(1)
	}

	switch os.Args[1] {
	case "add":
		addCmd.Parse(os.Args[2:])
		if *addMsg == "" {
			// ... (エラー処理) ...
			os.Exit(1)
		}

		// 新しいタスクを作成
		newID := 1 // デフォルトID
		if len(tasks) > 0 {
			// 既存タスクがあれば、最後のタスクID+1を新しいIDにする
			newID = tasks[len(tasks)-1].ID + 1
		}
		newTask := Task{ID: newID, Text: *addMsg, Done: false}

		// タスクリストに追加
		tasks = append(tasks, newTask)

		// ファイルに保存
		err = saveTasks(tasks)
		if err != nil {
			fmt.Fprintf(os.Stderr, "エラー: タスクの保存に失敗しました: %v\n", err)
			os.Exit(1)
		}
		fmt.Printf("タスクを追加しました: ID=%d, Text='%s'\n", newTask.ID, newTask.Text)

	case "list":
		listCmd.Parse(os.Args[2:])

		fmt.Println("--- タスク一覧 ---")
		if len(tasks) == 0 {
			fmt.Println("現在、タスクはありません。")
		} else {
			for _, task := range tasks {
				// Done フラグに応じて表示を変える
				status := "[ ]" // 未完了
				if task.Done {
					status = "[x]" // 完了
				}
				// listAll フラグが false で、タスクが完了済みの場合は表示しない
				if !*listAll && task.Done {
					continue
				}
				fmt.Printf("%s %d: %s\n", status, task.ID, task.Text)
			}
		}
		fmt.Println("------------------")

	default:
		// ... (エラー処理) ...
		os.Exit(1)
	}
}
*/
    

💡 ポイント: loadTasks 関数では、ファイルが存在しない場合にエラーとせず、空のリストを返すようにしています。これにより、初回起動時にエラーなく処理を進めることができます。saveTasks 関数では、json.MarshalIndent を使って整形されたJSONを出力し、os.WriteFile でファイルに書き込んでいます。

上記の main 関数のコメントアウト部分を実際のコードに反映させれば、タスクの追加と一覧表示がファイル永続化と連携するようになります。

エラーハンドリング

ユーザーが予期しない入力をしたり、ファイルアクセスに失敗したりするなど、プログラム実行中には様々なエラーが発生する可能性があります。堅牢なCLIツールを作成するためには、これらのエラーを適切に処理(エラーハンドリング)することが非常に重要です。

Go言語では、エラーが発生する可能性のある関数は、戻り値の最後に error 型の値を含めるのが慣習です。関数呼び出し元は、この errornil かどうかを確認し、nil でなければエラーが発生したと判断して対応します。

エラーハンドリングの主なポイント:

  • エラーチェック: エラーを返す可能性のある関数を呼び出した後は、必ず戻り値のエラーを確認します。
  • エラー情報の伝達: エラーが発生した場合、その情報を呼び出し元に適切に伝達します。fmt.Errorf を使ってエラーメッセージにコンテキスト(どのような操作でエラーが発生したか)を追加したり、%w を使って元のエラーをラップ(Wrap)したりすることが推奨されます。
  • ユーザーへのフィードバック: ユーザーにエラーが発生したことを分かりやすく伝えます。エラーメッセージは、標準エラー出力 (os.Stderr) に出力するのが一般的です。fmt.Fprintf(os.Stderr, ...) を使用します。
  • プログラムの終了: 致命的なエラーが発生し、処理を続行できない場合は、os.Exit(1) のように非ゼロのステータスコードでプログラムを終了します。(正常終了は os.Exit(0) ですが、main 関数が正常に終了すれば暗黙的に 0 となります)。
  • エラーの種類に応じた処理: errors.Iserrors.As (Go 1.13以降) を使うことで、特定のエラータイプや、ラップされたエラーチェーン内の特定のエラーに基づいて処理を分岐させることができます。例えば、ファイルが見つからないエラーの場合と、ファイルの内容が不正な場合とで、ユーザーへのメッセージを変えることができます。

package main

import (
	"errors" // errors.Is, errors.As を使うために必要
	"fmt"
	"os"
	// "strconv" // 例で使用
)

// 何らかの処理を行い、エラーを返す可能性のある関数 (例)
func processData(input string) error {
	if input == "" {
		// 新しいエラーを作成
		return errors.New("入力データが空です")
	}
	if input == "error" {
		// fmt.Errorf で書式付きのエラーメッセージを作成
		return fmt.Errorf("無効な入力値 '%s' が指定されました", input)
	}
	/*
	// 他の関数 (エラーを返す可能性のあるもの) を呼び出す例
	_, err := strconv.Atoi(input)
	if err != nil {
		// 元のエラー(err)をラップして、コンテキスト情報を追加 (%w)
		return fmt.Errorf("数値への変換に失敗しました (入力: '%s'): %w", input, err)
	}
	*/
	fmt.Printf("データ '%s' を正常に処理しました。\n", input)
	return nil // 成功時は nil を返す
}

func main() {
	if len(os.Args) < 2 {
		// エラーメッセージは標準エラー出力へ
		fmt.Fprintln(os.Stderr, "エラー: 処理するデータを引数で指定してください。")
		os.Exit(1) // エラーステータスで終了
	}

	inputData := os.Args[1]
	err := processData(inputData)

	// エラーチェック
	if err != nil {
		fmt.Fprintf(os.Stderr, "エラーが発生しました: %v\n", err)

		// --- エラーの種類に応じた処理 (例) ---
		// var targetErr *strconv.NumError // errors.As で使うための型変数
		// if errors.As(err, &targetErr) {
		// 	fmt.Fprintln(os.Stderr, "詳細: 入力データを数値に変換できませんでした。")
		// } else if errors.Is(err, errors.New("入力データが空です")) { // これは errors.New と同じインスタンスでないと true にならないので注意
        //     fmt.Fprintln(os.Stderr, "詳細: 何かデータを入力してください。")
        // }

		os.Exit(1) // エラーステータスで終了
	}

	fmt.Println("プログラムは正常に終了しました。")
}
    

⚠️ 注意: 適切なエラーハンドリングは、ツールの信頼性と使いやすさを向上させるために不可欠です。面倒に感じるかもしれませんが、エラーケースを考慮して丁寧に対応しましょう。

ビルドと実行

開発したGoプログラムは、go build コマンドを使って実行可能なバイナリファイルにコンパイル(ビルド)できます。これにより、Goがインストールされていない環境でもツールを実行できるようになります。

基本的なビルド

プロジェクトのルートディレクトリ(go.mod があるディレクトリ)で、以下のコマンドを実行します。


# カレントディレクトリの main パッケージをビルド
go build
    

これにより、現在のOSとCPUアーキテクチャ向けの実行ファイルが生成されます。ファイル名は通常、モジュール名(go.mod ファイルの module 行で指定した名前、この例では my-cli-app)になります。Windowsの場合は .exe 拡張子が付くことがあります。

生成された実行ファイルを直接実行できます。


# Linux / macOS の場合
./my-cli-app list

# Windows の場合
.\my-cli-app.exe list
    

出力される実行ファイル名を指定したい場合は、-o オプションを使用します。


# main.go をビルドして、実行ファイル名を "taskcli" にする
go build -o taskcli main.go

# 実行
./taskcli add -m "ビルドしたツールでタスク追加"
    

クロスコンパイル

Go言語の強力な機能の一つが「クロスコンパイル」です。これは、開発している環境(例: macOS)とは異なるOSやCPUアーキテクチャ(例: Linux, Windows)向けの実行ファイルを簡単に作成できる機能です。

ビルド時に環境変数 GOOS(ターゲットOS)と GOARCH(ターゲットCPUアーキテクチャ)を指定します。

例1: Linux (amd64) 向けの実行ファイルをビルドする


# macOS や Linux で実行する場合
GOOS=linux GOARCH=amd64 go build -o taskcli-linux main.go

# Windows (PowerShell) で実行する場合
$env:GOOS="linux"; $env:GOARCH="amd64"; go build -o taskcli-linux main.go
    

例2: Windows (amd64) 向けの実行ファイルをビルドする


# macOS や Linux で実行する場合
GOOS=windows GOARCH=amd64 go build -o taskcli.exe main.go

# Windows (PowerShell) で実行する場合
$env:GOOS="windows"; $env:GOARCH="amd64"; go build -o taskcli.exe main.go
    

主な GOOSGOARCH の組み合わせ:

GOOS (OS) GOARCH (Architecture) 説明
linux amd64 Linux 64-bit (一般的)
linux arm64 Linux ARM 64-bit (Raspberry Pi 4 など)
windows amd64 Windows 64-bit
darwin amd64 macOS Intel 64-bit
darwin arm64 macOS Apple Silicon (M1, M2 など)

利用可能な組み合わせの完全なリストは、go tool dist list コマンドで確認できます。

🎉 クロスコンパイルを使えば、作成したCLIツールを様々な環境のユーザーに簡単に配布できますね!

まとめと次のステップ

お疲れ様でした!このステップでは、Go言語を使ってCLIツールを開発する基本的な流れを学びました。

  • 標準パッケージ flag を使ったコマンドライン引数とオプションの処理
  • サブコマンドの基本的な実装方法と考え方(および便利なサードパーティライブラリの紹介)
  • encoding/jsonos パッケージを使ったファイルへのデータ永続化
  • エラーハンドリングの重要性と基本的な方法 (error インターフェース, fmt.Errorf, os.Stderr, os.Exit)
  • go build コマンドによるビルドと、GOOS/GOARCH を使ったクロスコンパイル

CLIツール開発は、Go言語の文法や標準パッケージの知識を実践的に活用する絶好の機会です。今回作成した基本的なツールをベースに、さらに機能を追加していくことができます。

次のステップへのアイデア:

  • タスクの完了/削除機能: add, list に加えて、タスクを完了状態にする done サブコマンドや、タスクを削除する delete サブコマンドを追加してみましょう。
  • テストコードの作成: testing パッケージを使って、loadTasks, saveTasks や各サブコマンドのロジックに対するユニットテストを作成し、ツールの信頼性を高めましょう。
  • Cobra や urfave/cli の導入: より本格的なCLIツールを目指して、これらのライブラリを使った実装に挑戦してみましょう。サブコマンドやフラグの管理がより簡単になります。
  • 設定ファイルのサポート: データの保存場所などを設定ファイル(例: config.jsonconfig.yaml)から読み込めるようにしてみましょう。(Viper などのライブラリが役立ちます)
  • 外部API連携: 例えば、GitHub APIと連携してIssueを管理するCLIツールや、天気予報APIを叩いて結果を表示するツールなど、外部サービスと連携する機能を追加するのも面白いでしょう。

このミニプロジェクトを通して、Go言語による開発の楽しさと可能性を感じていただけたら嬉しいです。ぜひ、自分だけの便利なCLIツール作りに挑戦してみてください! 💪

コメント

タイトルとURLをコピーしました