C# Tips | ファイル・ディレクトリ操作:再帰的フォルダコピー

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

はじめに なぜ「再帰的フォルダコピー」が業務で重要なのか

業務システムでは、「あるフォルダ一式を丸ごとバックアップしたい」「テンプレートフォルダをそっくり複製して新しい案件用フォルダを作りたい」「旧サーバーから新サーバーへデータ構造をそのままコピーしたい」といった場面がよくあります。
こういうときに必要になるのが、「フォルダの中のファイルだけでなく、サブフォルダも含めて全部コピーする=再帰的フォルダコピー」です。

C# には「フォルダを丸ごとコピーする」標準メソッドはないので、自分でユーティリティを作ることになります。
ここでは、プログラミング初心者でも理解できるように、「再帰って何?」というところから、実務で使えるフォルダコピー用ユーティリティの実装まで、丁寧に解説していきます。


再帰的フォルダコピーの全体イメージ

「再帰」のイメージをざっくりつかむ

まず、「再帰」という言葉のイメージを簡単に押さえておきます。
再帰とは、「自分自身を呼び出す関数(メソッド)」のことです。
フォルダコピーでいうと、「あるフォルダをコピーする処理の中で、そのフォルダのサブフォルダに対しても同じ『フォルダをコピーする処理』を呼び出す」という形になります。

言い換えると、「このフォルダをコピーする」
その中にサブフォルダがあったら、「そのサブフォルダも同じルールでコピーする」
さらにその中にサブフォルダがあったら…と、階層が深くなっても同じ処理を繰り返す、という考え方です。

やりたいことを日本語で分解してみる

「フォルダを丸ごとコピーする」という処理を、日本語で分解すると次のようになります。

あるフォルダをコピー先に作る。
そのフォルダの中のファイルを全部コピーする。
そのフォルダの中のサブフォルダについて、「同じこと」を繰り返す。

この「同じことを繰り返す」の部分が、再帰メソッドで表現されるところです。
このイメージを頭に置いたまま、実際のコードを見ていきましょう。


基本形 再帰的フォルダコピーのユーティリティ

最小限の再帰コピーメソッド

まずは、上で分解した日本語をそのままコードにしたような、最小限の再帰コピーメソッドを作ってみます。

using System;
using System.IO;

public static class DirectoryCopyUtil
{
    public static void CopyDirectoryRecursive(string sourceDir, string destDir)
    {
        if (!Directory.Exists(sourceDir))
        {
            throw new DirectoryNotFoundException($"コピー元ディレクトリが見つかりません: {sourceDir}");
        }

        Directory.CreateDirectory(destDir);

        foreach (string filePath in Directory.GetFiles(sourceDir))
        {
            string fileName = Path.GetFileName(filePath);
            string destFilePath = Path.Combine(destDir, fileName);

            File.Copy(filePath, destFilePath, overwrite: true);
        }

        foreach (string subDir in Directory.GetDirectories(sourceDir))
        {
            string dirName = Path.GetFileName(subDir);
            string destSubDir = Path.Combine(destDir, dirName);

            CopyDirectoryRecursive(subDir, destSubDir);
        }
    }
}
C#

使い方の例は次の通りです。

class Program
{
    static void Main()
    {
        string source = @"C:\data\projectA";
        string dest   = @"C:\backup\projectA";

        DirectoryCopyUtil.CopyDirectoryRecursive(source, dest);

        Console.WriteLine("フォルダを再帰的にコピーしました。");
    }
}
C#

ここで重要なポイントを順番に深掘りしていきます。


重要ポイント① ファイルとディレクトリの列挙

Directory.GetFiles と Directory.GetDirectories

再帰コピーの中核になっているのが、Directory.GetFilesDirectory.GetDirectories です。
Directory.GetFiles(sourceDir) は、「指定したフォルダ直下にあるファイルのパス一覧」を配列で返します。
Directory.GetDirectories(sourceDir) は、「指定したフォルダ直下にあるサブフォルダのパス一覧」を返します。

先ほどのコードでは、まず GetFiles でファイルを全部コピーし、その後 GetDirectories でサブフォルダを列挙して、サブフォルダごとに再帰呼び出しをしています。

foreach (string filePath in Directory.GetFiles(sourceDir))
{
    string fileName = Path.GetFileName(filePath);
    string destFilePath = Path.Combine(destDir, fileName);

    File.Copy(filePath, destFilePath, overwrite: true);
}

foreach (string subDir in Directory.GetDirectories(sourceDir))
{
    string dirName = Path.GetFileName(subDir);
    string destSubDir = Path.Combine(destDir, dirName);

    CopyDirectoryRecursive(subDir, destSubDir);
}
C#

ここでのポイントは、「ファイルは File.Copy でコピー」「サブフォルダは『フォルダコピー処理』をもう一度呼ぶ」という役割分担です。
これが「再帰的フォルダコピー」の基本パターンになります。

Path.GetFileName と Path.Combine の役割

Path.GetFileName(filePath) は、「フルパスからファイル名だけを取り出す」メソッドです。
Path.Combine(destDir, fileName) は、「コピー先フォルダとファイル名を安全に結合して、新しいパスを作る」メソッドです。

文字列連結で destDir + "\\" + fileName のように書くと、区切り文字の重複や抜けでミスしやすくなります。
フォルダコピーのような処理では、「パスの組み立てミス=誤った場所にコピーする」につながるので、Path.Combine を使うことがとても重要です。


重要ポイント② 再帰呼び出しの流れを理解する

再帰の流れを具体例で追ってみる

具体的なフォルダ構造を想像して、再帰の流れを追ってみます。

C:\data\projectA
その中に docs フォルダと images フォルダがあり、それぞれの中にもさらにサブフォルダがある、というイメージです。

CopyDirectoryRecursive("C:\\data\\projectA", "C:\\backup\\projectA") を呼ぶと、まず projectA 直下のファイルをコピーし、その後 docsimages に対して、それぞれ

CopyDirectoryRecursive("C:\\data\\projectA\\docs", "C:\\backup\\projectA\\docs")
CopyDirectoryRecursive("C:\\data\\projectA\\images", "C:\\backup\\projectA\\images")

を呼び出します。
さらに docs の中に old フォルダがあれば、docs の処理の中で

CopyDirectoryRecursive("C:\\data\\projectA\\docs\\old", "C:\\backup\\projectA\\docs\\old")

が呼ばれます。
このように、「サブフォルダがある限り、同じ処理が繰り返される」というのが再帰のイメージです。

再帰が怖いときの考え方

初心者のうちは、「自分自身を呼び出すメソッド」と聞くと少し怖く感じるかもしれません。
ただ、フォルダコピーの場合は、「サブフォルダがなくなったところで自然に終わる」ので、無限ループにはなりません。

「このメソッドは『一つのフォルダ』をコピーする」
「その中にサブフォルダがあったら、『そのサブフォルダ』に対しても同じメソッドを呼ぶ」
という二段構えで考えると、だいぶ分かりやすくなります。


重要ポイント③ 上書き・フィルタ・エラー処理をどう設計するか

上書きするかどうかを制御する

先ほどの基本実装では、File.Copy(filePath, destFilePath, overwrite: true); として、常に上書きするようにしていました。
業務によっては、「既にあるファイルは上書きしたくない」「上書きしようとしたらエラーにしたい」といった要件もあります。

その場合は、上書きフラグを引数で受け取るようにしておくと柔軟になります。

public static void CopyDirectoryRecursive(string sourceDir, string destDir, bool overwrite)
{
    if (!Directory.Exists(sourceDir))
    {
        throw new DirectoryNotFoundException($"コピー元ディレクトリが見つかりません: {sourceDir}");
    }

    Directory.CreateDirectory(destDir);

    foreach (string filePath in Directory.GetFiles(sourceDir))
    {
        string fileName = Path.GetFileName(filePath);
        string destFilePath = Path.Combine(destDir, fileName);

        File.Copy(filePath, destFilePath, overwrite);
    }

    foreach (string subDir in Directory.GetDirectories(sourceDir))
    {
        string dirName = Path.GetFileName(subDir);
        string destSubDir = Path.Combine(destDir, dirName);

        CopyDirectoryRecursive(subDir, destSubDir, overwrite);
    }
}
C#

使う側は、「バックアップだから上書きでよい」「履歴を残したいから別名にする」など、運用ルールに合わせて設計できます。

特定の拡張子だけコピーするフィルタ

実務では、「ログファイルだけコピーしたい」「画像ファイルだけコピーしたい」といった要件もよくあります。
その場合は、Directory.GetFiles に検索パターンを渡したり、拡張子を見てフィルタしたりします。

public static void CopyDirectoryRecursiveFilter(string sourceDir, string destDir, string searchPattern)
{
    if (!Directory.Exists(sourceDir))
    {
        throw new DirectoryNotFoundException($"コピー元ディレクトリが見つかりません: {sourceDir}");
    }

    Directory.CreateDirectory(destDir);

    foreach (string filePath in Directory.GetFiles(sourceDir, searchPattern))
    {
        string fileName = Path.GetFileName(filePath);
        string destFilePath = Path.Combine(destDir, fileName);

        File.Copy(filePath, destFilePath, overwrite: true);
    }

    foreach (string subDir in Directory.GetDirectories(sourceDir))
    {
        string dirName = Path.GetFileName(subDir);
        string destSubDir = Path.Combine(destDir, dirName);

        CopyDirectoryRecursiveFilter(subDir, destSubDir, searchPattern);
    }
}
C#

searchPattern"*.log""*.csv" を渡すことで、対象を絞ったコピーができます。
こうしたフィルタリングは、バックアップ容量を抑えたいときや、不要な一時ファイルを含めたくないときに役立ちます。

例外とエラー処理をどう扱うか

フォルダコピーでは、権限不足、ディスク容量不足、ファイルロック、パス不正など、さまざまな理由で例外が発生する可能性があります。
初心者のうちは、「とりあえず上位で try-catch (Exception) で受ける」でも構いませんが、実務ではある程度原因を区別できるようにしておくと運用が楽になります。

たとえば、コピー全体を呼び出す側で次のように書きます。

class Program
{
    static void Main()
    {
        string source = @"C:\data\projectA";
        string dest   = @"C:\backup\projectA";

        try
        {
            DirectoryCopyUtil.CopyDirectoryRecursive(source, dest, overwrite: true);
            Console.WriteLine("フォルダコピーに成功しました。");
        }
        catch (UnauthorizedAccessException ex)
        {
            Console.WriteLine("権限エラーが発生しました: " + ex.Message);
        }
        catch (IOException ex)
        {
            Console.WriteLine("入出力エラーが発生しました: " + ex.Message);
        }
        catch (Exception ex)
        {
            Console.WriteLine("想定外のエラーが発生しました: " + ex.Message);
        }
    }
}
C#

実務では、これらのメッセージをログに残しておくことで、「どのフォルダのコピーに、どんな理由で失敗したか」を後から追跡できます。


実務寄りのユーティリティ設計例

ログ出力を組み込んだフォルダコピー

業務システムでは、「どのファイルをコピーしたか」「どこで失敗したか」をログに残しておくことが重要です。
そこで、ログ出力用のデリゲートを受け取る形にしておくと、再利用性が高くなります。

using System;
using System.IO;

public static class DirectoryCopyUtil
{
    public static void CopyDirectoryRecursive(
        string sourceDir,
        string destDir,
        bool overwrite,
        Action<string>? log = null)
    {
        if (!Directory.Exists(sourceDir))
        {
            throw new DirectoryNotFoundException($"コピー元ディレクトリが見つかりません: {sourceDir}");
        }

        Directory.CreateDirectory(destDir);
        log?.Invoke($"[INFO] ディレクトリ作成または存在確認: {destDir}");

        foreach (string filePath in Directory.GetFiles(sourceDir))
        {
            string fileName = Path.GetFileName(filePath);
            string destFilePath = Path.Combine(destDir, fileName);

            File.Copy(filePath, destFilePath, overwrite);
            log?.Invoke($"[INFO] ファイルコピー: {filePath} -> {destFilePath}");
        }

        foreach (string subDir in Directory.GetDirectories(sourceDir))
        {
            string dirName = Path.GetFileName(subDir);
            string destSubDir = Path.Combine(destDir, dirName);

            CopyDirectoryRecursive(subDir, destSubDir, overwrite, log);
        }
    }
}
C#

使い方の例は次の通りです。

class Program
{
    static void Main()
    {
        string source = @"C:\data\projectA";
        string dest   = @"D:\backup\projectA";

        DirectoryCopyUtil.CopyDirectoryRecursive(
            source,
            dest,
            overwrite: true,
            log: message => Console.WriteLine(message)
        );
    }
}
C#

ここでのポイントは、「コピーの進捗や内容を外から観察できるようにしている」ことです。
コンソールではなくファイルログに書きたい場合は、log に別の処理を渡せばよく、ユーティリティ本体は変更不要になります。

大量データ・長時間コピーを意識した設計の入り口

本格的な業務システムでは、コピー対象が数十万ファイル、数百GBといった規模になることもあります。
その場合は、進捗表示、途中での中断、リトライ、コピー対象の絞り込みなど、さらに多くの工夫が必要になります。

ただ、最初の一歩としては、ここまで説明してきた「再帰的フォルダコピーの基本形」をしっかり理解しておくことが大切です。
その上で、「どこを改善すれば現場の要件に合うか」を少しずつ足していくイメージで設計していくと、無理なくレベルアップできます。


まとめ 実務で使える再帰的フォルダコピーの考え方

再帰的フォルダコピーは、「フォルダ構造を丸ごと扱う」ための強力なテクニックであり、業務システムではバックアップやテンプレート複製などで頻出します。
だからこそ、「とりあえず動く」だけでなく、「安全に・意図通りに・運用しやすく」動くように設計することが大切です。

一つのフォルダに対して「フォルダを作る→ファイルをコピーする→サブフォルダに対して同じ処理を呼ぶ」という流れが、再帰的フォルダコピーの基本であること。
Directory.GetFilesDirectory.GetDirectoriesPath.GetFileNamePath.Combine を組み合わせて、「正しい場所に・正しい名前で」コピーすること。
上書き可否や拡張子フィルタ、ログ出力、例外処理などをユーティリティに組み込み、業務のルールをコードに落とし込むこと。
大規模なコピーや長時間処理を見据えるなら、まずはこの基本形をしっかり理解し、その上で進捗管理やリトライなどを足していくこと。

ここまで押さえておけば、「フォルダを手作業でコピーしていた作業」を、安心して自動化できるようになります。

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