【Go】実践:ファイル一括処理CLIツールを作る

Go

こんにちは、かつコーチです。

前回の記事では、flagパッケージでコマンドライン引数を受け取る基本を解説しました。

今回はその知識を使って、実際に手元のフォルダで使えるCLIツール(コマンドラインから操作するツール)を1本作ります。

作るのは、指定したディレクトリの中のファイルに、連番付きのプレフィックスを一括で付けてリネームするツールです。

写真の整理や、スクリーンショットの命名ルール統一など、実務でもそのまま使える題材です。

読み終える頃には、osパッケージとfilepathパッケージを使ったファイル操作の基本が身に付きます。

今回作るツールの全体像

どんなツールを作るのか

今回作るのは、次のようなコマンドで動くリネームツールです。

go run main.go -dir ./photos -ext .jpg -prefix trip -dry-run

-dirで対象フォルダ、-extで対象拡張子、-prefixで付ける接頭辞、-dry-runで「実際には変更せず確認だけする」動作を指定します。

実行すると、photosフォルダの中の.jpgファイルが、trip_001.jpgtrip_002.jpgのように連番付きの名前に変わります。

使うパッケージの役割分担

  • flag:コマンドラインオプションを受け取る(前回の記事で解説済み)
  • os:ファイルの存在確認やリネーム処理を行う
  • path/filepath:ディレクトリを走査してファイル一覧を取得する

filepath.WalkDirとは、指定したディレクトリ以下のファイル・フォルダを再帰的に1つずつ処理してくれる関数のことです。

サブフォルダの中まで自動でたどってくれるので、自分でループを何重にも書く必要がありません。

実装手順

手順1:オプションを設計する

まずは受け取りたいオプションをflagで定義します。

package main

import (
	"flag"
	"fmt"
	"os"
	"path/filepath"
	"sort"
	"strings"
)

func main() {
	dir := flag.String("dir", ".", "対象ディレクトリ")
	ext := flag.String("ext", "", "対象拡張子(例: .jpg)")
	prefix := flag.String("prefix", "file", "リネーム後の接頭辞")
	dryRun := flag.Bool("dry-run", false, "実際には変更せず確認だけする")
	flag.Parse()

	if *ext == "" {
		fmt.Println("エラー: -ext で対象拡張子を指定してください")
		os.Exit(1)
	}

	if err := renameFiles(*dir, *ext, *prefix, *dryRun); err != nil {
		fmt.Println("エラー:", err)
		os.Exit(1)
	}
}

-extが空のまま実行された場合は、os.Exit(1)で異常終了させ、シェルスクリプトから呼んだときにもエラーが検知できるようにしています。

手順2:対象ファイルを一覧化する

次に、対象ディレクトリの直下から、指定拡張子のファイルだけを集める処理を書きます。

func collectFiles(dir, ext string) ([]string, error) {
	var targets []string

	entries, err := os.ReadDir(dir)
	if err != nil {
		return nil, fmt.Errorf("ディレクトリの読み込みに失敗しました: %w", err)
	}

	for _, entry := range entries {
		if entry.IsDir() {
			continue
		}
		if strings.EqualFold(filepath.Ext(entry.Name()), ext) {
			targets = append(targets, entry.Name())
		}
	}

	sort.Strings(targets)
	return targets, nil
}

今回はサブフォルダまでは対象にしないため、filepath.WalkDirではなくos.ReadDirで直下だけを見ています(再帰対応は「応用」で扱います)。

strings.EqualFoldで拡張子を比較しているのは、大文字小文字を区別しないほうが実用的だからです。

macOSやWindowsでは、.JPG.jpgが同じファイルとして扱われるケースがあります。

手順3:リネーム処理本体を書く

集めたファイル一覧に対して、連番付きの新しい名前を組み立て、os.Renameでリネームします。

func renameFiles(dir, ext, prefix string, dryRun bool) error {
	targets, err := collectFiles(dir, ext)
	if err != nil {
		return err
	}

	if len(targets) == 0 {
		fmt.Println("対象ファイルが見つかりませんでした")
		return nil
	}

	for i, name := range targets {
		newName := fmt.Sprintf("%s_%03d%s", prefix, i+1, ext)
		oldPath := filepath.Join(dir, name)
		newPath := filepath.Join(dir, newName)

		if dryRun {
			fmt.Printf("[dry-run] %s -> %s\n", name, newName)
			continue
		}

		if err := os.Rename(oldPath, newPath); err != nil {
			return fmt.Errorf("%s のリネームに失敗しました: %w", name, err)
		}
		fmt.Printf("%s -> %s\n", name, newName)
	}

	return nil
}

%03dという書式で、1001のように3桁ゼロ埋めにしています。

まずは-dry-run付きで実行し、意図した名前になっているか確認してから、外して本実行するのがおすすめです。

go run main.go -dir ./photos -ext .jpg -prefix trip -dry-run
[dry-run] IMG_0001.JPG -> trip_001.jpg
[dry-run] IMG_0002.JPG -> trip_002.jpg
[dry-run] screenshot.jpg -> trip_003.jpg

問題なければ-dry-runを外して実行します。

よくあるつまずきポイント・エラー対処

外付けドライブとの間でinvalid cross-device linkエラーが出た

これは筆者が実際にハマった話です。

写真整理用のツールを少し改造し、リネームと同時に別ドライブへ移動する処理をos.Renameだけで実装しようとしました。

❌Before:os.Renameだけで別ドライブへの移動も済まそうとする

err := os.Rename("/Users/katsu/photos/img.jpg", "/Volumes/BackupHDD/img.jpg")

実行すると、次のエラーで止まりました。

rename /Users/katsu/photos/img.jpg /Volumes/BackupHDD/img.jpg: invalid cross-device link

原因を調べたところ、os.RenameはOSのrenameシステムコールをそのまま呼んでいました。

そのため、同一ファイルシステム内でのみ有効という制約があることが分かりました。

内蔵ディスクと外付けHDDのように、ファイルシステムをまたぐ移動では、単純な名前の付け替えでは実現できないのです。

✅After:コピーしてから元ファイルを削除する

func moveAcrossDevices(src, dst string) error {
	in, err := os.Open(src)
	if err != nil {
		return err
	}
	defer in.Close()

	out, err := os.Create(dst)
	if err != nil {
		return err
	}
	defer out.Close()

	if _, err := out.ReadFrom(in); err != nil {
		return err
	}

	return os.Remove(src)
}

同一ドライブ内のリネームならos.Renameで十分です。

ドライブをまたぐ移動が必要な場面では、「コピーしてから削除」という2段階の処理に切り替えましょう。

このエラーに初めて遭遇したときは、てっきりパスの指定ミスだと思い込んで15分ほど無駄にしました。

エラーメッセージのcross-deviceという単語に気づいてから、ようやく原因にたどり着いた経験です。

応用・一歩先の使い方

サブフォルダも対象にする(filepath.WalkDir)

直下だけでなく、サブフォルダの中まで一括処理したい場合は、filepath.WalkDirに切り替えます。

func collectFilesRecursive(root, ext string) ([]string, error) {
	var targets []string

	err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
		if err != nil {
			return err
		}
		if !d.IsDir() && strings.EqualFold(filepath.Ext(d.Name()), ext) {
			targets = append(targets, path)
		}
		return nil
	})

	return targets, err
}

filepath.WalkDirは、ディレクトリを再帰的にたどりながら、ファイルごとにコールバック関数を呼んでくれます。

Go 1.16以降では、旧来のfilepath.WalkよりWalkDirのほうが高速なため、新しく書く場合はWalkDirを選ぶのが基本です。

処理件数が多い場合はgoroutineで並列化する

ファイル数が数千件を超えるようなケースでは、リネーム処理自体をgoroutineで並列化することで高速化できます。

以前解説したsync.WaitGroupと組み合わせると、ファイルI/Oを待つ間に他の処理を進められるようになります。

ただし大量の同時書き込みはディスクI/Oがボトルネックになりやすいため、まずはシングルスレッドで動かしてから、必要に応じて並列化を検討するとよいでしょう。

まとめ

この記事のポイント

  • flagでオプションを受け取り、os.ReadDirで対象ファイルを一覧化する
  • os.Renameは同一ファイルシステム内でのみ有効で、ドライブをまたぐ移動ではinvalid cross-device linkエラーになる
  • ドライブをまたぐ移動は「コピーしてから削除」で実現する
  • -dry-runオプションを用意すると、実行前に結果を確認でき安全に運用できる
  • サブフォルダまで対象にする場合はfilepath.WalkDirを使う

次に読むべき記事

  • flagパッケージでコマンドライン引数を扱う基本
  • net/httpで最小構成のWebサーバー・JSON APIを作る

タグ: Go, 中級者向け, CLIツール

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