課題11: エラー処理の実装

課題概要

この課題では、実用的なファイル処理システムを構築し、Goのエラー処理機能を包括的に活用します。センチネルエラー、カスタムエラー型、エラーラッピング、そしてerrors.IsとErrors.Asを使った高度なエラーハンドリングを実装します。

マンダトリー要件

要件1: カスタムエラー型の実装(20点)

ファイル操作に関するカスタムエラー型を実装してください。

ファイル: errors.go

package fileops

import "fmt"

// FileError はファイル操作のエラーを表す
type FileError struct {
    Op       string // 操作名("read", "write", "delete"など)
    Path     string // ファイルパス
    Err      error  // 元のエラー
}

func (e *FileError) Error() string {
    return fmt.Sprintf("ファイル操作エラー [%s] %s: %v", e.Op, e.Path, e.Err)
}

func (e *FileError) Unwrap() error {
    return e.Err
}

// ValidationError は入力検証のエラーを表す
type ValidationError struct {
    Field   string
    Value   string
    Message string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("検証エラー [%s]: %s (値: %s)", e.Field, e.Message, e.Value)
}

実装すべき内容

  • FileError: ファイル操作のエラー(操作種別、パス、元エラーを保持)
  • ValidationError: 入力検証のエラー(フィールド名、値、メッセージを保持)
  • 両方ともError()メソッドを実装
  • FileErrorUnwrap()メソッドも実装

要件2: センチネルエラーの定義(15点)

よくあるエラー状態をセンチネルエラーとして定義してください。

ファイル: errors.go(続き)

import "errors"

// センチネルエラー
var (
    ErrFileNotFound    = errors.New("ファイルが見つかりません")
    ErrPermissionDenied = errors.New("権限が拒否されました")
    ErrInvalidPath     = errors.New("無効なパスです")
    ErrFileExists      = errors.New("ファイルは既に存在します")
    ErrEmptyFile       = errors.New("ファイルが空です")
)

実装すべき内容

  • 5つ以上のセンチネルエラーを定義
  • ファイル操作で発生しうる一般的なエラー状態を網羅

要件3: ファイル処理システムの実装(25点)

エラー処理を含む包括的なファイル操作システムを実装してください。

ファイル: fileops.go

package fileops

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

// FileManager はファイル操作を管理する
type FileManager struct {
    baseDir string
}

func NewFileManager(baseDir string) (*FileManager, error) {
    // baseDirの検証
    if baseDir == "" {
        return nil, &ValidationError{
            Field:   "baseDir",
            Value:   baseDir,
            Message: "ベースディレクトリが空です",
        }
    }

    // ディレクトリの存在確認
    info, err := os.Stat(baseDir)
    if err != nil {
        if os.IsNotExist(err) {
            return nil, &FileError{
                Op:   "stat",
                Path: baseDir,
                Err:  ErrFileNotFound,
            }
        }
        return nil, &FileError{
            Op:   "stat",
            Path: baseDir,
            Err:  fmt.Errorf("ディレクトリ情報取得に失敗: %w", err),
        }
    }

    if !info.IsDir() {
        return nil, &ValidationError{
            Field:   "baseDir",
            Value:   baseDir,
            Message: "ディレクトリではありません",
        }
    }

    return &FileManager{baseDir: baseDir}, nil
}

// ReadFile はファイルを読み込む
func (fm *FileManager) ReadFile(filename string) (string, error) {
    // ファイル名の検証
    if err := fm.validateFilename(filename); err != nil {
        return "", err
    }

    // フルパスの構築
    fullPath := filepath.Join(fm.baseDir, filename)

    // ファイル読み込み
    data, err := os.ReadFile(fullPath)
    if err != nil {
        if os.IsNotExist(err) {
            return "", &FileError{
                Op:   "read",
                Path: filename,
                Err:  ErrFileNotFound,
            }
        }
        if os.IsPermission(err) {
            return "", &FileError{
                Op:   "read",
                Path: filename,
                Err:  ErrPermissionDenied,
            }
        }
        return "", &FileError{
            Op:   "read",
            Path: filename,
            Err:  fmt.Errorf("ファイル読み込みに失敗: %w", err),
        }
    }

    // 空ファイルチェック
    if len(data) == 0 {
        return "", &FileError{
            Op:   "read",
            Path: filename,
            Err:  ErrEmptyFile,
        }
    }

    return string(data), nil
}

// WriteFile はファイルに書き込む
func (fm *FileManager) WriteFile(filename, content string) error {
    // ファイル名の検証
    if err := fm.validateFilename(filename); err != nil {
        return err
    }

    // コンテンツの検証
    if content == "" {
        return &ValidationError{
            Field:   "content",
            Value:   content,
            Message: "コンテンツが空です",
        }
    }

    // フルパスの構築
    fullPath := filepath.Join(fm.baseDir, filename)

    // ファイルの存在確認
    if _, err := os.Stat(fullPath); err == nil {
        return &FileError{
            Op:   "write",
            Path: filename,
            Err:  ErrFileExists,
        }
    }

    // ファイル書き込み
    if err := os.WriteFile(fullPath, []byte(content), 0644); err != nil {
        if os.IsPermission(err) {
            return &FileError{
                Op:   "write",
                Path: filename,
                Err:  ErrPermissionDenied,
            }
        }
        return &FileError{
            Op:   "write",
            Path: filename,
            Err:  fmt.Errorf("ファイル書き込みに失敗: %w", err),
        }
    }

    return nil
}

// DeleteFile はファイルを削除する
func (fm *FileManager) DeleteFile(filename string) error {
    // ファイル名の検証
    if err := fm.validateFilename(filename); err != nil {
        return err
    }

    // フルパスの構築
    fullPath := filepath.Join(fm.baseDir, filename)

    // ファイル削除
    if err := os.Remove(fullPath); err != nil {
        if os.IsNotExist(err) {
            return &FileError{
                Op:   "delete",
                Path: filename,
                Err:  ErrFileNotFound,
            }
        }
        if os.IsPermission(err) {
            return &FileError{
                Op:   "delete",
                Path: filename,
                Err:  ErrPermissionDenied,
            }
        }
        return &FileError{
            Op:   "delete",
            Path: filename,
            Err:  fmt.Errorf("ファイル削除に失敗: %w", err),
        }
    }

    return nil
}

// validateFilename はファイル名を検証する
func (fm *FileManager) validateFilename(filename string) error {
    if filename == "" {
        return &ValidationError{
            Field:   "filename",
            Value:   filename,
            Message: "ファイル名が空です",
        }
    }

    // パストラバーサル攻撃の防止
    if strings.Contains(filename, "..") {
        return &FileError{
            Op:   "validate",
            Path: filename,
            Err:  ErrInvalidPath,
        }
    }

    // 絶対パスの禁止
    if filepath.IsAbs(filename) {
        return &FileError{
            Op:   "validate",
            Path: filename,
            Err:  ErrInvalidPath,
        }
    }

    return nil
}

実装すべき内容

  • NewFileManager: ベースディレクトリを指定してマネージャーを作成
  • ReadFile: ファイル読み込み(エラー処理含む)
  • WriteFile: ファイル書き込み(エラー処理含む)
  • DeleteFile: ファイル削除(エラー処理含む)
  • validateFilename: ファイル名の検証(セキュリティチェック含む)

要件4: エラー処理とハンドリング(20点)

エラーを適切に処理し、詳細な情報を提供するメインプログラムを実装してください。

ファイル: main.go

package main

import (
    "errors"
    "fmt"
    "os"

    "yourmodule/fileops"
)

func main() {
    // 作業ディレクトリの作成
    workDir := "./testdata"
    if err := os.MkdirAll(workDir, 0755); err != nil {
        fmt.Fprintf(os.Stderr, "作業ディレクトリ作成に失敗: %v\n", err)
        os.Exit(1)
    }

    // FileManagerの作成
    fm, err := fileops.NewFileManager(workDir)
    if err != nil {
        handleError(err)
        os.Exit(1)
    }

    // テストファイルの書き込み
    fmt.Println("=== ファイル書き込みテスト ===")
    if err := fm.WriteFile("test.txt", "Hello, Go!"); err != nil {
        handleError(err)
    } else {
        fmt.Println("✓ ファイル書き込み成功")
    }

    // ファイル読み込み
    fmt.Println("\n=== ファイル読み込みテスト ===")
    content, err := fm.ReadFile("test.txt")
    if err != nil {
        handleError(err)
    } else {
        fmt.Printf("✓ ファイル読み込み成功: %s\n", content)
    }

    // 存在しないファイルの読み込み
    fmt.Println("\n=== エラーケース: 存在しないファイル ===")
    _, err = fm.ReadFile("notfound.txt")
    if err != nil {
        handleError(err)
    }

    // 無効なファイル名
    fmt.Println("\n=== エラーケース: 無効なファイル名 ===")
    err = fm.WriteFile("../../../etc/passwd", "malicious")
    if err != nil {
        handleError(err)
    }

    // 空のコンテンツ
    fmt.Println("\n=== エラーケース: 空のコンテンツ ===")
    err = fm.WriteFile("empty.txt", "")
    if err != nil {
        handleError(err)
    }

    // ファイル削除
    fmt.Println("\n=== ファイル削除テスト ===")
    if err := fm.DeleteFile("test.txt"); err != nil {
        handleError(err)
    } else {
        fmt.Println("✓ ファイル削除成功")
    }

    // クリーンアップ
    os.RemoveAll(workDir)
}

// handleError はエラーを詳細に処理する
func handleError(err error) {
    fmt.Println("エラーが発生しました:")

    // FileErrorの処理
    var fileErr *fileops.FileError
    if errors.As(err, &fileErr) {
        fmt.Printf("  種類: ファイルエラー\n")
        fmt.Printf("  操作: %s\n", fileErr.Op)
        fmt.Printf("  パス: %s\n", fileErr.Path)

        // センチネルエラーのチェック
        if errors.Is(err, fileops.ErrFileNotFound) {
            fmt.Println("  原因: ファイルが見つかりません")
        } else if errors.Is(err, fileops.ErrPermissionDenied) {
            fmt.Println("  原因: 権限が不足しています")
        } else if errors.Is(err, fileops.ErrInvalidPath) {
            fmt.Println("  原因: パスが無効です")
        } else if errors.Is(err, fileops.ErrFileExists) {
            fmt.Println("  原因: ファイルは既に存在します")
        } else if errors.Is(err, fileops.ErrEmptyFile) {
            fmt.Println("  原因: ファイルが空です")
        }
    }

    // ValidationErrorの処理
    var valErr *fileops.ValidationError
    if errors.As(err, &valErr) {
        fmt.Printf("  種類: 検証エラー\n")
        fmt.Printf("  フィールド: %s\n", valErr.Field)
        fmt.Printf("  値: %s\n", valErr.Value)
        fmt.Printf("  メッセージ: %s\n", valErr.Message)
    }

    fmt.Printf("  詳細: %v\n", err)
}

実装すべき内容

  • 各種ファイル操作のテスト
  • エラーケースの網羅的なテスト
  • handleError関数による詳細なエラー処理
  • errors.Asを使った型アサーション
  • errors.Isを使ったセンチネルエラーの判定
  • 期待される出力

    === ファイル書き込みテスト ===
    ✓ ファイル書き込み成功
    
    === ファイル読み込みテスト ===
    ✓ ファイル読み込み成功: Hello, Go!
    
    === エラーケース: 存在しないファイル ===
    エラーが発生しました:
      種類: ファイルエラー
      操作: read
      パス: notfound.txt
      原因: ファイルが見つかりません
      詳細: ファイル操作エラー [read] notfound.txt: ファイルが見つかりません
    
    === エラーケース: 無効なファイル名 ===
    エラーが発生しました:
      種類: ファイルエラー
      操作: validate
      パス: ../../../etc/passwd
      原因: パスが無効です
      詳細: ファイル操作エラー [validate] ../../../etc/passwd: 無効なパスです
    
    === エラーケース: 空のコンテンツ ===
    エラーが発生しました:
      種類: 検証エラー
      フィールド: content
      値:
      メッセージ: コンテンツが空です
      詳細: 検証エラー [content]: コンテンツが空です (値: )
    
    === ファイル削除テスト ===
    ✓ ファイル削除成功
    

    ボーナス課題

    > ボーナス: これらはオプションです。マンダトリー部分が完了してから取り組んでください。

    ボーナス1: エラーのログ機能(10点)

    エラーを構造化されたログとして記録する機能を追加してください。

    ファイル: errorlog.go

    package fileops
    
    import (
        "encoding/json"
        "fmt"
        "os"
        "time"
    )
    
    // ErrorLog はエラーログのエントリ
    type ErrorLog struct {
        Timestamp time.Time `json:"timestamp"`
        Type      string    `json:"type"`
        Message   string    `json:"message"`
        Details   map[string]string `json:"details"`
    }
    
    // ErrorLogger はエラーをログに記録する
    type ErrorLogger struct {
        logFile *os.File
    }
    
    func NewErrorLogger(filename string) (*ErrorLogger, error) {
        file, err := os.OpenFile(filename, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
        if err != nil {
            return nil, fmt.Errorf("ログファイルを開けません: %w", err)
        }
    
        return &ErrorLogger{logFile: file}, nil
    }
    
    func (l *ErrorLogger) Log(err error) error {
        entry := ErrorLog{
            Timestamp: time.Now(),
            Message:   err.Error(),
            Details:   make(map[string]string),
        }
    
        // エラー型に応じて詳細を追加
        var fileErr *FileError
        if errors.As(err, &fileErr) {
            entry.Type = "FileError"
            entry.Details["operation"] = fileErr.Op
            entry.Details["path"] = fileErr.Path
        }
    
        var valErr *ValidationError
        if errors.As(err, &valErr) {
            entry.Type = "ValidationError"
            entry.Details["field"] = valErr.Field
            entry.Details["value"] = valErr.Value
        }
    
        // JSON形式で書き込み
        data, err := json.Marshal(entry)
        if err != nil {
            return fmt.Errorf("ログのシリアライズに失敗: %w", err)
        }
    
        if _, err := l.logFile.Write(append(data, '\n')); err != nil {
            return fmt.Errorf("ログの書き込みに失敗: %w", err)
        }
    
        return nil
    }
    
    func (l *ErrorLogger) Close() error {
        return l.logFile.Close()
    }
    

    ボーナス2: リトライ機構(5点)

    一時的なエラーに対して自動的にリトライする機能を実装してください。

    package fileops
    
    import (
        "errors"
        "time"
    )
    
    // RetryConfig はリトライの設定
    type RetryConfig struct {
        MaxAttempts int
        Delay       time.Duration
    }
    
    // WithRetry は関数をリトライ機能付きで実行する
    func WithRetry(config RetryConfig, fn func() error) error {
        var lastErr error
    
        for attempt := 1; attempt <= config.MaxAttempts; attempt++ {
            err := fn()
            if err == nil {
                return nil
            }
    
            lastErr = err
    
            // リトライ不可能なエラーはすぐに返す
            if !isRetryable(err) {
                return err
            }
    
            if attempt < config.MaxAttempts {
                time.Sleep(config.Delay)
            }
        }
    
        return fmt.Errorf("リトライ上限に達しました (%d回): %w", config.MaxAttempts, lastErr)
    }
    
    // isRetryable はエラーがリトライ可能かを判定
    func isRetryable(err error) bool {
        // 権限エラーやファイル未検出はリトライ不可
        if errors.Is(err, ErrPermissionDenied) || errors.Is(err, ErrFileNotFound) {
            return false
        }
    
        // ValidationErrorはリトライ不可
        var valErr *ValidationError
        if errors.As(err, &valErr) {
            return false
        }
    
        return true
    }
    

    ボーナス3: エラー統計レポート(5点)

    発生したエラーの統計情報を収集し、レポートを生成してください。

    package fileops
    
    import (
        "fmt"
        "sync"
    )
    
    // ErrorStats はエラー統計を管理する
    type ErrorStats struct {
        mu      sync.Mutex
        counts  map[string]int
        samples map[string]error
    }
    
    func NewErrorStats() *ErrorStats {
        return &ErrorStats{
            counts:  make(map[string]int),
            samples: make(map[string]error),
        }
    }
    
    func (s *ErrorStats) Record(err error) {
        s.mu.Lock()
        defer s.mu.Unlock()
    
        var errType string
    
        var fileErr *FileError
        if errors.As(err, &fileErr) {
            errType = fmt.Sprintf("FileError:%s", fileErr.Op)
        } else {
            var valErr *ValidationError
            if errors.As(err, &valErr) {
                errType = fmt.Sprintf("ValidationError:%s", valErr.Field)
            } else {
                errType = "Other"
            }
        }
    
        s.counts[errType]++
        if _, exists := s.samples[errType]; !exists {
            s.samples[errType] = err
        }
    }
    
    func (s *ErrorStats) Report() string {
        s.mu.Lock()
        defer s.mu.Unlock()
    
        report := "=== エラー統計レポート ===\n"
        total := 0
    
        for errType, count := range s.counts {
            report += fmt.Sprintf("%s: %d回\n", errType, count)
            report += fmt.Sprintf("  サンプル: %v\n", s.samples[errType])
            total += count
        }
    
        report += fmt.Sprintf("\n合計: %d回\n", total)
        return report
    }
    

    評価基準

    項目 配点 詳細
    カスタムエラー型 20点 FileErrorとValidationErrorが正しく実装されている
    センチネルエラー 15点 適切なセンチネルエラーが定義されている
    ファイル処理システム 25点 全てのファイル操作が正しく動作し、エラー処理が適切
    エラーハンドリング 20点 errors.IsとErrors.Asを使った詳細な処理ができている
    **ボーナス1** 10点 構造化ログが正しく実装されている
    **ボーナス2** 5点 リトライ機構が適切に動作する
    **ボーナス3** 5点 統計レポートが有用な情報を提供している

    提出方法

    以下のファイルを提出してください:

    submission/
    ├── go.mod
    ├── errors.go           # カスタムエラー型とセンチネルエラー
    ├── fileops.go          # ファイル操作システム
    ├── main.go             # メインプログラム
    └── bonus/             # ボーナス課題(オプション)
        ├── errorlog.go    # エラーログ機能
        ├── retry.go       # リトライ機構
        └── stats.go       # エラー統計
    

    ヒント

  • エラーラッピング: fmt.Errorf("...: %w", err)で元のエラーを保持
  • errors.Is: センチネルエラーの判定に使う
  • errors.As: カスタムエラー型の取得に使う
  • パス検証: filepath.Clean()filepath.Abs()も活用
  • 並行安全性: ボーナス課題ではsync.Mutexでデータ保護
  • 学習リソース

  • Go Blog: Error handling and Go
  • Go Blog: Working with Errors in Go 1.13
  • errors package documentation
  • Dave Cheney: Don't just check errors, handle them gracefully