C# Tips | ファイル・ディレクトリ操作:世代管理バックアップ

C sharp C#
スポンサーリンク

はじめに 「世代管理バックアップ」は“時間を巻き戻すための仕組み”

バックアップを「とりあえずコピーしておく」だけで終わらせると、すぐにこうなります。

バックアップフォルダがファイルだらけでカオスになる。
どれが最新で、どれが古いのか分からない。
ディスクがいっぱいになってサーバーが止まる。

そこで出てくるのが「世代管理バックアップ」です。
これは一言でいうと、

「バックアップを世代(バージョン)として管理し、古いものから順に自動で捨てていく仕組み」

です。

例えば、「最新 5 世代だけ残す」「7 日分だけ残す」といったルールを決めて、
それをユーティリティとしてコードにしておくと、運用がものすごく楽になります。

ここでは、プログラミング初心者向けに、

世代管理バックアップの考え方
ファイル単位の世代管理
フォルダ単位の世代管理
日付・時刻と世代数の組み合わせ
実務での設計ポイント

を、例題付きでかみ砕いて説明していきます。


世代管理バックアップの基本発想

「残す数を決めて、古いものから捨てる」

世代管理のコアはとてもシンプルです。

バックアップを取るたびに新しい世代を追加する。
世代数が上限を超えたら、一番古いものから削除する。

例えば、「最大 5 世代」と決めた場合のイメージはこうです。

1 回目のバックアップ → 世代 1
2 回目 → 世代 1, 2
3 回目 → 世代 1, 2, 3

6 回目 → 世代 2, 3, 4, 5, 6(1 は削除)

この「古いものから順に消す」を、毎回のバックアップ処理の中に組み込んでおくのがポイントです。

「名前で管理する」か「フォルダで管理する」か

世代管理には大きく分けて二つのパターンがあります。

同じフォルダに、世代を表す名前のファイルを並べる。
専用のバックアップフォルダの中に、世代ごとのサブフォルダを作る。

ここではまず「ファイル名で世代を表す」パターンから入り、
次に「フォルダごと世代管理する」パターンを見ていきます。


ファイル単位の世代管理バックアップ

命名ルールを決める

まずは、バックアップファイルの名前のルールを決めます。
分かりやすいのは、次のような形式です。

config.json のバックアップを、同じフォルダに

config.json.bak.1
config.json.bak.2
config.json.bak.3

のように置いていくパターンです。
数字が大きいほど新しい世代、と決めておきます。

実装の全体像

やりたいことはこうです。

元ファイルをコピーして、新しい世代(例: .bak.1)を作る。
既存のバックアップファイルを列挙して、古い順に並べる。
上限世代数を超えている分を削除する。

これを 1 本のユーティリティメソッドにまとめます。

using System;
using System.IO;
using System.Linq;

public static class GenerationBackup
{
    public static string CreateFileGenerationBackup(
        string originalPath,
        int maxGenerations = 5)
    {
        if (originalPath is null)
        {
            throw new ArgumentNullException(nameof(originalPath));
        }

        if (!File.Exists(originalPath))
        {
            throw new FileNotFoundException("バックアップ元のファイルが存在しません。", originalPath);
        }

        if (maxGenerations <= 0)
        {
            throw new ArgumentOutOfRangeException(nameof(maxGenerations), "1 以上を指定してください。");
        }

        string directory = Path.GetDirectoryName(originalPath)
            ?? throw new ArgumentException("ディレクトリを含まないパスです。", nameof(originalPath));

        string fileName = Path.GetFileName(originalPath);

        string pattern = fileName + ".bak.";

        var existingBackups = Directory
            .GetFiles(directory, fileName + ".bak.*", SearchOption.TopDirectoryOnly)
            .Select(path => new
            {
                Path = path,
                Generation = ParseGenerationNumber(path, pattern)
            })
            .Where(x => x.Generation.HasValue)
            .OrderBy(x => x.Generation.Value)
            .ToList();

        int newGeneration = existingBackups.Any()
            ? existingBackups.Max(x => x.Generation!.Value) + 1
            : 1;

        string newBackupName = fileName + ".bak." + newGeneration;
        string newBackupPath = Path.Combine(directory, newBackupName);

        File.Copy(originalPath, newBackupPath, overwrite: false);

        var toDelete = existingBackups
            .Where(x => x.Generation <= newGeneration - maxGenerations)
            .ToList();

        foreach (var item in toDelete)
        {
            try
            {
                File.Delete(item.Path);
            }
            catch
            {
            }
        }

        return newBackupPath;
    }

    private static int? ParseGenerationNumber(string path, string pattern)
    {
        string fileName = Path.GetFileName(path);

        if (!fileName.StartsWith(pattern, StringComparison.OrdinalIgnoreCase))
        {
            return null;
        }

        string suffix = fileName.Substring(pattern.Length);

        if (int.TryParse(suffix, out int gen))
        {
            return gen;
        }

        return null;
    }
}
C#

使い方の例

string original = @"C:\config\appsettings.json";

string backup1 = GenerationBackup.CreateFileGenerationBackup(original, maxGenerations: 3);
Console.WriteLine(backup1);

string backup2 = GenerationBackup.CreateFileGenerationBackup(original, maxGenerations: 3);
Console.WriteLine(backup2);

string backup3 = GenerationBackup.CreateFileGenerationBackup(original, maxGenerations: 3);
Console.WriteLine(backup3);

string backup4 = GenerationBackup.CreateFileGenerationBackup(original, maxGenerations: 3);
Console.WriteLine(backup4);
C#

このとき、フォルダの中身は最終的に

appsettings.json
appsettings.json.bak.2
appsettings.json.bak.3
appsettings.json.bak.4

のようになります。
.bak.1 は、4 世代目を作るタイミングで削除されています。


重要ポイント① 既存バックアップの「世代番号」を解析する

ファイル名から世代番号を取り出す

ParseGenerationNumber がやっていることを丁寧に見ていきます。

private static int? ParseGenerationNumber(string path, string pattern)
{
    string fileName = Path.GetFileName(path);

    if (!fileName.StartsWith(pattern, StringComparison.OrdinalIgnoreCase))
    {
        return null;
    }

    string suffix = fileName.Substring(pattern.Length);

    if (int.TryParse(suffix, out int gen))
    {
        return gen;
    }

    return null;
}
C#

例えば、元ファイルが appsettings.json の場合、pattern

"appsettings.json.bak."

になります。

appsettings.json.bak.3 というファイル名なら、

StartsWith(pattern) が true
pattern の長さ以降の文字列は "3"
int.TryParse("3") に成功して、世代番号 3 が取れる

という流れです。

逆に、appsettings.json.bak.old のような名前は、
"old" が数字に変換できないので、世代番号なし(null)として扱われます。

このように、「自分が決めた命名ルールに合うものだけを世代管理の対象にする」ことが大事です。


重要ポイント② 削除対象の判定ロジック

「新しい世代番号 − maxGenerations」より小さいものを消す

削除対象の判定はここです。

var toDelete = existingBackups
    .Where(x => x.Generation <= newGeneration - maxGenerations)
    .ToList();
C#

例えば、maxGenerations = 3 で、新しい世代番号が 4 の場合、

newGeneration - maxGenerations = 1

となります。

つまり、「世代番号が 1 以下のものを削除する」という意味になります。
結果として、2, 3, 4 の 3 世代が残るわけです。

このロジックを変えれば、「最新だけ残す」「偶数世代だけ残す」などもできますが、
まずは「連番で古いものから消す」という素直な形を押さえておくのがよいです。


フォルダ単位の世代管理バックアップ

「バックアップフォルダの中に世代ごとのサブフォルダを作る」

設定一式やデータ一式をフォルダごとバックアップしたい場合は、
「バックアップルートフォルダの中に、世代ごとのサブフォルダを作る」パターンが分かりやすいです。

例えば、バックアップルートが C:\backup\data の場合、

C:\backup\data\001
C:\backup\data\002
C:\backup\data\003

のように、数字フォルダを世代として扱います。

実装例

フォルダコピー用のユーティリティを使って、世代管理を組み合わせます。

using System;
using System.IO;
using System.Linq;

public static class DirectoryGenerationBackup
{
    public static string CreateDirectoryGenerationBackup(
        string sourceDirectory,
        string backupRootDirectory,
        int maxGenerations = 5)
    {
        if (!Directory.Exists(sourceDirectory))
        {
            throw new DirectoryNotFoundException($"バックアップ元ディレクトリが存在しません: {sourceDirectory}");
        }

        if (maxGenerations <= 0)
        {
            throw new ArgumentOutOfRangeException(nameof(maxGenerations), "1 以上を指定してください。");
        }

        Directory.CreateDirectory(backupRootDirectory);

        var existingGenerations = Directory
            .GetDirectories(backupRootDirectory)
            .Select(dir => new
            {
                Path = dir,
                Generation = ParseGenerationFromDirectoryName(dir)
            })
            .Where(x => x.Generation.HasValue)
            .OrderBy(x => x.Generation.Value)
            .ToList();

        int newGeneration = existingGenerations.Any()
            ? existingGenerations.Max(x => x.Generation!.Value) + 1
            : 1;

        string newDirName = newGeneration.ToString("D3");
        string newBackupDir = Path.Combine(backupRootDirectory, newDirName);

        CopyDirectoryRecursive(sourceDirectory, newBackupDir);

        var toDelete = existingGenerations
            .Where(x => x.Generation <= newGeneration - maxGenerations)
            .ToList();

        foreach (var item in toDelete)
        {
            try
            {
                Directory.Delete(item.Path, recursive: true);
            }
            catch
            {
            }
        }

        return newBackupDir;
    }

    private static int? ParseGenerationFromDirectoryName(string dirPath)
    {
        string name = Path.GetFileName(dirPath);

        if (int.TryParse(name, out int gen))
        {
            return gen;
        }

        return null;
    }

    private static void CopyDirectoryRecursive(string sourceDir, string destinationDir)
    {
        Directory.CreateDirectory(destinationDir);

        foreach (string filePath in Directory.GetFiles(sourceDir))
        {
            string fileName = Path.GetFileName(filePath);
            string destFilePath = Path.Combine(destinationDir, fileName);
            File.Copy(filePath, destFilePath, overwrite: true);
        }

        foreach (string subDir in Directory.GetDirectories(sourceDir))
        {
            string dirName = Path.GetFileName(subDir);
            string destSubDir = Path.Combine(destinationDir, dirName);
            CopyDirectoryRecursive(subDir, destSubDir);
        }
    }
}
C#

使い方の例

string sourceDir = @"C:\app\data";
string backupRoot = @"C:\backup\data";

string backupDir = DirectoryGenerationBackup.CreateDirectoryGenerationBackup(
    sourceDir,
    backupRoot,
    maxGenerations: 3);

Console.WriteLine($"バックアップ作成: {backupDir}");
C#

この場合、C:\backup\data の中には常に最新 3 世代だけが残るようになります。


日付・時刻と世代数を組み合わせる

「フォルダ名は日付+連番」にする

もう一歩だけ発展させると、「フォルダ名に日付と連番を組み合わせる」というパターンもあります。

例えば、

20250128_001
20250128_002
20250129_001

のような形です。

こうしておくと、「いつのバックアップか」と「その日の何回目か」が一目で分かります。

実装としては、フォルダ名の生成ロジックを

newGeneration.ToString("D3")

の代わりに

DateTime.Now.ToString("yyyyMMdd") + "_" + dailyIndex.ToString("D3")

のように変えるイメージです。

ここは少し複雑になるので、まずは「単純な連番世代」をしっかり押さえてからで十分です。


実務での設計ポイント

「何世代残すか」はビジネス側と決める

世代数は、技術的な問題というより「ビジネス上の要件」です。

どこまで遡って復元できれば安心か。
ディスク容量はどれくらい使えるか。
バックアップ 1 世代あたりのサイズはどれくらいか。

例えば、「設定ファイルなら 5 世代で十分」「重要データなら 30 世代欲しい」など、
対象によって変えるのもよくあります。

コード側では、「世代数を引数や設定で変えられるようにしておく」ことが大事です。

「失敗したときにどうするか」を決めておく

世代管理バックアップは、次のような理由で失敗することがあります。

ディスク容量不足
権限不足
ファイルロック

そのときに、

バックアップ失敗をログに残して本処理は続行するのか。
バックアップが取れないなら本処理も中止するのか。

を決めておく必要があります。

特に「上書き前バックアップ」と組み合わせる場合は、
「バックアップが取れないなら上書きしない」という方針のほうが安全です。


まとめ 「世代管理バックアップ」は“過去の自分を守るための保険”

世代管理バックアップは、単なるコピーではなく、

「いつでも、少し前の状態に戻れるようにしておく」

ための仕組みです。

押さえておきたいポイントを整理すると、こうなります。

バックアップを「世代」として扱い、上限数を超えたら古いものから自動で削除する。
ファイル単位なら、元ファイル名 + ".bak.n" のような命名ルールで世代番号を管理できる。
フォルダ単位なら、バックアップルートの中に世代ごとのサブフォルダを作り、連番や日付で管理する。
世代数はコードに固定せず、引数や設定で変えられるようにしておくと、ビジネス要件に合わせやすい。
失敗時の扱い(ログだけか、中止か)まで含めて設計すると、“業務で本当に使える世代管理バックアップ”になる。

ここまで理解できれば、「とりあえずコピーしておく」段階から一歩進んで、
「時間を巻き戻せる前提で安心して変更できる」環境を、自分の C# ユーティリティで作れるようになります。

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