Go のファイル操作とエラーハンドリング

ファイル操作はエラーが起きやすい処理の代表格だ。ファイルが存在しない、権限がない、ディスク容量が足りない、別のプロセスがロックしている――原因は多岐にわたり、それぞれで適切な対処が異なる。Go の os パッケージと errors パッケージを組み合わせることで、これらのエラーを構造的に扱える。

os パッケージの sentinel エラー

Go の os パッケージには、ファイル操作でよく発生するエラーに対応する sentinel エラーが用意されている。

import (
	"errors"
	"os"
)

// 主要な sentinel エラー
_ = os.ErrNotExist   // ファイルが存在しない
_ = os.ErrExist      // ファイルが既に存在する
_ = os.ErrPermission // 権限がない
_ = os.ErrClosed     // 既に閉じられている

これらは errors.Is で判定できる。たとえば、ファイルを開こうとして存在しなかった場合は以下のように書く。

f, err := os.Open("config.json")
if err != nil {
	if errors.Is(err, os.ErrNotExist) {
		fmt.Println("設定ファイルが見つかりません")
		return
	}
	return fmt.Errorf("failed to open config: %w", err)
}
defer f.Close()

os.Open が返すエラーは *os.PathError 型でラップされているが、errors.Is はチェーンを辿るため os.ErrNotExist との比較が正しく機能する。

*os.PathError から詳細を取り出す

os パッケージのファイル操作関数は、失敗時に *os.PathError を返すことが多い。この型にはどの操作がどのパスで失敗したかの情報が含まれている。

type PathError struct {
	Op   string // "open", "read", "write" など
	Path string // 対象のファイルパス
	Err  error  // 根本原因
}

errors.As を使えば、この構造体から詳細を取り出せる。

f, err := os.Open("/etc/shadow")
if err != nil {
	var pathErr *os.PathError
	if errors.As(err, &pathErr) {
		fmt.Printf("操作: %s\n", pathErr.Op)
		fmt.Printf("パス: %s\n", pathErr.Path)
		fmt.Printf("原因: %v\n", pathErr.Err)
	}
	return
}
defer f.Close()

ログに記録する際、単にエラーメッセージだけでなく、操作の種類やパスを構造化して出力できるのが利点だ。

errors.Is で判定

エラーの「種類」を知りたいときに使う。os.ErrNotExist かどうか、os.ErrPermission かどうかといった分岐に適している

errors.As で詳細取得

エラーに付随する情報(パス、操作名など)を取り出したいときに使う。ログ出力やユーザーへの詳細なフィードバックに適している

ファイルの存在確認

ファイルの存在確認は os.Stat と errors.Is の組み合わせで行うのが定番のパターンだ。

func fileExists(path string) (bool, error) {
	_, err := os.Stat(path)
	if err == nil {
		return true, nil
	}
	if errors.Is(err, os.ErrNotExist) {
		return false, nil
	}
	// 権限エラーなど、存在確認とは別の問題
	return false, fmt.Errorf("stat %s: %w", path, err)
}

ここで注意すべきは、os.Stat が返すエラーが os.ErrNotExist でない場合の扱いだ。たとえば権限がなくてファイル情報を取得できないケースでは、ファイルが存在するかどうかの判断自体ができない。「存在しない」と断定するのは誤りなので、エラーとして返すのが正しい。

defer と Close のエラー処理

ファイルを閉じる際のエラーは見落とされがちだが、書き込み操作では特に重要になる。バッファに残っているデータがディスクに書き込まれるのは Close 時であり、ここで失敗するとデータが消失する。

// 書き込みの Close エラーを無視する危険な例
func writeFile(path string, data []byte) error {
	f, err := os.Create(path)
	if err != nil {
		return err
	}
	defer f.Close() // Close のエラーが無視される

	_, err = f.Write(data)
	return err
}

defer f.Close() だけでは Close が返すエラーを捕捉できない。名前付き戻り値を使って Close のエラーを反映させるパターンが広く使われている。

func writeFile(path string, data []byte) (retErr error) {
	f, err := os.Create(path)
	if err != nil {
		return fmt.Errorf("create %s: %w", path, err)
	}
	defer func() {
		closeErr := f.Close()
		if retErr == nil {
			retErr = closeErr
		}
	}()

	if _, err := f.Write(data); err != nil {
		return fmt.Errorf("write %s: %w", path, err)
	}
	return nil
}

名前付き戻り値 retErr を使い、defer 内で Close のエラーを代入している。Write が既に失敗している場合は Close のエラーで上書きしないようにしているのがポイントだ。Write のエラーの方が根本原因として重要なことが多いため、最初のエラーを優先する。

読み取り操作の実践パターン

設定ファイルの読み込みを例に、エラーの種類に応じた処理分岐を見てみよう。

type Config struct {
	Port    int    `json:"port"`
	DBHost  string `json:"db_host"`
	LogFile string `json:"log_file"`
}

func loadConfig(path string) (*Config, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		if errors.Is(err, os.ErrNotExist) {
			return defaultConfig(), nil
		}
		if errors.Is(err, os.ErrPermission) {
			return nil, fmt.Errorf(
				"permission denied reading %s: %w", path, err,
			)
		}
		return nil, fmt.Errorf("read config: %w", err)
	}

	var cfg Config
	if err := json.Unmarshal(data, &cfg); err != nil {
		return nil, fmt.Errorf(
			"parse config %s: %w", path, err,
		)
	}
	return &cfg, nil
}

ファイルが存在しなければデフォルト設定で動作し、権限エラーなら明確なメッセージを返し、その他のエラーはラップして上位に伝播させている。加えて、JSON パースの失敗もファイルパスを含めたメッセージでラップしている。エラーの種類ごとに戦略を分けることで、呼び出し側が適切に対処できるようになる。

一時ファイルの安全な書き込み

本番環境では、書き込み途中でクラッシュした場合にファイルが中途半端な状態で残るリスクがある。一時ファイルに書き込んでからリネームするパターンを使えば、アトミックに近い書き込みが実現できる。

func safeWriteFile(path string, data []byte) (retErr error) {
	dir := filepath.Dir(path)
	tmp, err := os.CreateTemp(dir, "*.tmp")
	if err != nil {
		return fmt.Errorf("create temp file: %w", err)
	}
	tmpPath := tmp.Name()

	defer func() {
		if retErr != nil {
			tmp.Close()
			os.Remove(tmpPath)
		}
	}()

	if _, err := tmp.Write(data); err != nil {
		return fmt.Errorf("write temp file: %w", err)
	}
	if err := tmp.Close(); err != nil {
		return fmt.Errorf("close temp file: %w", err)
	}
	if err := os.Rename(tmpPath, path); err != nil {
		return fmt.Errorf("rename %s to %s: %w",
			tmpPath, path, err)
	}
	return nil
}

一時ファイルを同じディレクトリに作成

データを一時ファイルに書き込み・Close

os.Rename で一時ファイルを本来のパスに移動

処理のどの段階で失敗しても、defer 内で一時ファイルを削除するため、中途半端なファイルが残ることはない。Rename が成功した場合のみ、一時ファイルの削除はスキップされる。同じディレクトリに一時ファイルを作るのは、os.Rename がファイルシステムをまたぐ移動をサポートしないためだ。

ディレクトリ操作のエラー処理

ディレクトリの作成でも、エラーの種類に応じた処理が必要になる場面がある。

func ensureDir(path string) error {
	err := os.MkdirAll(path, 0755)
	if err != nil {
		if errors.Is(err, os.ErrPermission) {
			return fmt.Errorf(
				"cannot create directory %s: permission denied",
				path,
			)
		}
		return fmt.Errorf("mkdir %s: %w", path, err)
	}
	return nil
}

os.MkdirAll は既にディレクトリが存在する場合にはエラーを返さないため、「既に存在する」ケースの分岐は不要だ。一方で os.Mkdir(All なし)は既存ディレクトリに対してエラーを返すため、用途に応じて使い分ける。

os.MkdirAll と os.Mkdir の違いは、mkdir -pコマンドの有無に相当する。

中間ディレクトリも含めて再帰的に作成するか、1 階層だけ作成するかの違い。

エラーメッセージの設計

ファイル操作のエラーメッセージには、操作の種類とファイルパスを含めるのが鉄則だ。

操作を明示する

“failed: permission denied” ではなく “read config.json: permission denied” のように、何をしようとして失敗したかを含める。デバッグ時に問題箇所の特定が格段に速くなる。

パスを含める

相対パスか絶対パスかはケースバイケースだが、少なくともファイル名は含める。複数のファイルを扱う処理では、どのファイルで失敗したか分からないと調査が困難になる。

fmt.Errorf のフォーマットとしては “動詞 + パス: %w” が Go らしい簡潔な形になる。標準ライブラリのエラーメッセージもこのパターンに従っていることが多い。