C#でディレクトリをコピーする方法|サブフォルダ・ファイルごと再帰的に複製する実装例
本記事では、C#でディレクトリを丸ごとコピーする基本実装から、同名ファイルの上書き、例外処理、条件付きコピー、非同期処理、進捗通知まで詳しく解説します。
1. C#でディレクトリをコピーする方法
1-1. Directory.Copyメソッドが存在しない理由
C#のSystem.IO.Directoryクラスには、次のようなディレクトリ操作用メソッドが用意されています。
Directory.CreateDirectory(path);Directory.Delete(path, recursive: true);Directory.Move(sourcePath, destinationPath);一方、ファイルをコピーするFile.Copyに相当するDirectory.Copyメソッドは存在しません。
ディレクトリのコピーには、次のような複数の仕様が関係するためです。
サブディレクトリもコピーするか
同名ファイルを上書きするか
空のディレクトリをコピーするか
隠しファイルやシンボリックリンクをどう扱うか
エラーが発生したときに処理を中断するか
コピー先だけに存在するファイルを削除するか
そのため、C#でディレクトリをコピーするときは、用途に合わせた処理を実装します。
1-2. サブフォルダとファイルを再帰的にコピーする基本方針
ディレクトリを丸ごとコピーする基本的な流れは、次のとおりです。
コピー元ディレクトリが存在するか確認する
コピー先ディレクトリを作成する
コピー元直下のファイルをコピーする
コピー元直下のサブディレクトリを取得する
各サブディレクトリに対して同じ処理を再帰的に実行する
再帰処理を利用すると、階層の深さが異なるディレクトリでも同じメソッドでコピーできます。
1-3. コピー処理に使用するDirectory・DirectoryInfo・Fileクラス
ディレクトリコピーでは、主に次のクラスを使用します。
| クラス | 主な用途 |
|---|---|
Directory | ディレクトリの作成、列挙、削除、移動 |
DirectoryInfo | ディレクトリ情報をオブジェクトとして取得・操作 |
File | ファイルのコピー、削除、読み書き |
FileInfo | ファイル情報をオブジェクトとして取得・操作 |
Path | パスの結合、正規化、相対パスの取得 |
DirectoryとFileは静的メソッドを使用します。複数回同じディレクトリやファイルを操作する場合は、DirectoryInfoやFileInfoを使うとコードを整理しやすくなります。
2. ディレクトリを丸ごとコピーする基本実装
2-1. コピー元とコピー先を指定するサンプルコード
次のDirectoryCopyメソッドは、コピー元ディレクトリ内のファイルとサブディレクトリをコピーする基本実装です。
using System;using System.IO;public static class DirectoryCopySample{public static void DirectoryCopy(string sourceDirectory,string destinationDirectory,bool copySubDirectories){var source = new DirectoryInfo(sourceDirectory);
if (!source.Exists){throw new DirectoryNotFoundException($"コピー元ディレクトリが見つかりません: {source.FullName}");}Directory.CreateDirectory(destinationDirectory);foreach (FileInfo file in source.GetFiles()){string destinationFilePath = Path.Combine(destinationDirectory,file.Name);file.CopyTo(destinationFilePath, overwrite: false);}if (!copySubDirectories){return;}foreach (DirectoryInfo subDirectory in source.GetDirectories()){string destinationSubDirectory = Path.Combine(destinationDirectory,subDirectory.Name);DirectoryCopy(subDirectory.FullName,destinationSubDirectory,copySubDirectories: true);}}
}
第3引数のcopySubDirectoriesにtrueを指定すると、サブフォルダを含めて再帰的にコピーします。falseを指定した場合は、コピー元直下のファイルだけが対象です。
2-2. コピー先ディレクトリを作成する処理
コピー先ディレクトリは、ファイルをコピーする前に作成しておく必要があります。
Directory.CreateDirectory(destinationDirectory);Directory.CreateDirectoryには、次の特徴があります。
指定したディレクトリが存在しない場合は新規作成する
親ディレクトリが存在しない場合は親階層も作成する
指定したディレクトリがすでに存在する場合も基本的に例外にならない
そのため、事前にDirectory.Existsで確認してから作成する必要はありません。
2-3. ディレクトリ内のファイルをコピーする処理
コピー元直下のファイルは、DirectoryInfo.GetFilesで取得できます。
foreach (FileInfo file in source.GetFiles()){string destinationFilePath = Path.Combine(destinationDirectory,file.Name);file.CopyTo(destinationFilePath, overwrite: false);
}
コピー先のファイルパスは、文字列を直接連結せず、Path.Combineで作成します。
string path = destinationDirectory + "\" + file.Name;上記のような直接連結は、OSによる区切り文字の違いや、区切り文字の重複を招く可能性があります。パスの組み立てにはPath.Combineを使用するのが基本です。
2-4. サブディレクトリを再帰的にコピーする処理
サブディレクトリは、DirectoryInfo.GetDirectoriesで取得します。
foreach (DirectoryInfo subDirectory in source.GetDirectories()){string destinationSubDirectory = Path.Combine(destinationDirectory,subDirectory.Name);DirectoryCopy(subDirectory.FullName,destinationSubDirectory,copySubDirectories: true);
}
取得した各サブディレクトリに対してDirectoryCopyを再度呼び出すことで、階層を再帰的にたどれます。
空のサブディレクトリについても、再帰先でDirectory.CreateDirectoryが実行されるため、コピー先に作成されます。
2-5. 実装したDirectoryCopyメソッドの呼び出し方
作成したメソッドは、次のように呼び出します。
string sourceDirectory = @"C:\Data\Source";string destinationDirectory = @"C:\Data\Backup";DirectoryCopySample.DirectoryCopy(sourceDirectory,destinationDirectory,copySubDirectories: true);
サブディレクトリをコピーせず、直下のファイルだけをコピーする場合はfalseを指定します。
DirectoryCopySample.DirectoryCopy(sourceDirectory,destinationDirectory,copySubDirectories: false);3. 同名ファイルを上書きしてディレクトリをコピーする方法
3-1. File.Copyのoverwrite引数を使用する
File.Copyには、コピー先の同名ファイルを上書きするかどうかを指定するオーバーロードがあります。
File.Copy(sourceFileName,destinationFileName,overwrite: true);第3引数の意味は次のとおりです。
| 値 | 動作 |
true | コピー先の同名ファイルを上書きする |
false | 同名ファイルがある場合は例外が発生する |
FileInfo.CopyToを使う場合も、第2引数で上書きの有無を指定できます。
fileInfo.CopyTo(destinationPath, overwrite: true);3-2. 上書きするかどうかを引数で切り替える実装例
上書きの有無をメソッドの引数で切り替えられるようにすると、さまざまな用途で再利用できます。
using System;using System.IO;public static class DirectoryCopyUtility{public static void Copy(string sourceDirectory,string destinationDirectory,bool copySubDirectories,bool overwrite){var source = new DirectoryInfo(sourceDirectory);
if (!source.Exists){throw new DirectoryNotFoundException($"コピー元ディレクトリが存在しません: {source.FullName}");}Directory.CreateDirectory(destinationDirectory);foreach (FileInfo file in source.GetFiles()){string destinationFilePath = Path.Combine(destinationDirectory,file.Name);file.CopyTo(destinationFilePath, overwrite);}if (!copySubDirectories){return;}foreach (DirectoryInfo subDirectory in source.GetDirectories()){string destinationSubDirectory = Path.Combine(destinationDirectory,subDirectory.Name);Copy(subDirectory.FullName,destinationSubDirectory,copySubDirectories: true,overwrite);}}
}
呼び出し側では、次のように指定します。
DirectoryCopyUtility.Copy(sourceDirectory: @"C:\Data\Source",destinationDirectory: @"C:\Data\Backup",copySubDirectories: true,overwrite: true);3-3. コピー先に同名ファイルがある場合の動作
overwriteの値によって、同名ファイルが存在するときの動作が変わります。
File.Copy(sourcePath, destinationPath, overwrite: false);falseの場合、コピー先に同名ファイルがあるとIOExceptionが発生します。
File.Copy(sourcePath, destinationPath, overwrite: true);trueの場合、コピー先のファイルはコピー元の内容で上書きされます。ただし、コピー先ファイルが読み取り専用である場合や、別のプロセスによって使用されている場合は、上書きを指定していてもコピーに失敗することがあります。
3-4. コピー先にだけ存在するファイルは削除されない点に注意する
上書き対応のディレクトリコピーは、コピー元の内容をコピー先へ追加または更新する処理です。
例えば、次の状態を考えます。
コピー元├─ A.txt└─ B.txtコピー先├─ A.txt├─ B.txt└─ C.txt
コピー後も、コピー先だけに存在するC.txtは削除されません。
コピー先├─ A.txt├─ B.txt└─ C.txtコピー元とコピー先を完全に同じ状態にしたい場合は、コピー処理だけでなく、コピー先にだけ存在するファイルやディレクトリを検出して削除する同期処理が必要です。
4. DirectoryInfoを使ってディレクトリを再帰コピーする方法
4-1. DirectoryInfo.GetFilesでファイルを取得する
DirectoryInfoを使用すると、ディレクトリ内のファイルをFileInfoの配列として取得できます。
var sourceDirectory = new DirectoryInfo(sourcePath);FileInfo[] files = sourceDirectory.GetFiles();
列挙結果を必要になった時点で順番に処理したい場合は、EnumerateFilesも利用できます。
IEnumerable<FileInfo> files = sourceDirectory.EnumerateFiles();大量のファイルを処理する場合は、すべての要素を一度に配列化するGetFilesよりも、遅延列挙を行うEnumerateFilesが適していることがあります。
4-2. DirectoryInfo.GetDirectoriesでサブフォルダを取得する
直下のサブディレクトリは、GetDirectoriesで取得できます。
DirectoryInfo[] subDirectories =sourceDirectory.GetDirectories();取得した各DirectoryInfoを再帰処理へ渡すことで、ディレクトリ階層をたどれます。
4-3. FileInfo.CopyToでファイルを複製する
FileInfo.CopyToを使うと、コピー元のパスを毎回指定する必要がありません。
FileInfo file = new FileInfo(sourceFilePath);file.CopyTo(destinationFilePath, overwrite: true);
第2引数を省略するオーバーロードでは、同名ファイルを上書きできません。
file.CopyTo(destinationFilePath);上書きの要否を明確にするため、通常はbool引数を指定する形式が分かりやすいでしょう。
4-4. DirectoryInfoを使用した完成コード
次のコードは、DirectoryInfoを引数として受け取る再帰コピーの完成例です。
using System;using System.IO;public static class DirectoryInfoCopyUtility{public static void CopyDirectory(DirectoryInfo source,DirectoryInfo destination,bool overwrite){if (!source.Exists){throw new DirectoryNotFoundException($"コピー元ディレクトリが存在しません: {source.FullName}");}
destination.Create();foreach (FileInfo sourceFile in source.EnumerateFiles()){string destinationFilePath = Path.Combine(destination.FullName,sourceFile.Name);sourceFile.CopyTo(destinationFilePath, overwrite);}foreach (DirectoryInfo sourceSubDirectoryin source.EnumerateDirectories()){if ((sourceSubDirectory.Attributes &FileAttributes.ReparsePoint) != 0){continue;}DirectoryInfo destinationSubDirectory =destination.CreateSubdirectory(sourceSubDirectory.Name);CopyDirectory(sourceSubDirectory,destinationSubDirectory,overwrite);}}
}
呼び出し例は次のとおりです。
var source = new DirectoryInfo(@"C:\Data\Source");var destination = new DirectoryInfo(@"C:\Data\Backup");DirectoryInfoCopyUtility.CopyDirectory(source,destination,overwrite: true);
この実装では、ReparsePoint属性を持つサブディレクトリを除外しています。これにより、シンボリックリンクやジャンクションによる意図しない再帰を防ぎやすくなります。
5. ディレクトリコピーで発生しやすい例外と対処法
5-1. コピー元ディレクトリが存在しない場合
存在しないディレクトリをコピー元に指定すると、列挙処理などでDirectoryNotFoundExceptionが発生します。
コピー開始前に明示的に確認しておくと、原因を特定しやすいメッセージを返せます。
if (!Directory.Exists(sourceDirectory)){throw new DirectoryNotFoundException($"コピー元が見つかりません: {sourceDirectory}");}呼び出し元で処理を継続したい場合は、例外を投げる代わりにfalseを返す設計も可能です。
5-2. コピー先へのアクセス権限がない場合
書き込み権限のないディレクトリへコピーしようとすると、UnauthorizedAccessExceptionが発生する可能性があります。
主な原因は次のとおりです。
コピー先ディレクトリへの書き込み権限がない
保護されたシステムディレクトリを指定している
コピー先ファイルが読み取り専用になっている
実行ユーザーに対象ファイルへのアクセス権限がない
管理者権限での実行を安易な前提にせず、アプリケーションが書き込み可能な保存先を指定することが重要です。
5-3. コピー先に同名ファイルが存在する場合
上書きを無効にした状態で同名ファイルをコピーすると、IOExceptionが発生します。
File.Copy(sourcePath, destinationPath, overwrite: false);同名ファイルを更新してよい場合は、上書きを有効にします。
File.Copy(sourcePath, destinationPath, overwrite: true);上書きしてはいけない場合は、ファイル名を変更する方法もあります。
string fileNameWithoutExtension =Path.GetFileNameWithoutExtension(destinationPath);string extension = Path.GetExtension(destinationPath);string directory = Path.GetDirectoryName(destinationPath)!;
string renamedPath = Path.Combine(directory,$"{fileNameWithoutExtension}_{DateTime.Now:yyyyMMddHHmmss}{extension}");
5-4. 使用中のファイルをコピーできない場合
別のアプリケーションがファイルを排他的に開いている場合、コピー時にIOExceptionが発生することがあります。
一時的なロックであることが想定されるなら、回数と待機時間に上限を設けて再試行できます。
public static void CopyWithRetry(string sourcePath,string destinationPath,bool overwrite,int maxAttempts = 3){if (maxAttempts <= 0){throw new ArgumentOutOfRangeException(nameof(maxAttempts));}for (int attempt = 1; attempt <= maxAttempts; attempt++){try{File.Copy(sourcePath, destinationPath, overwrite);return;}catch (IOException) when (attempt < maxAttempts){Thread.Sleep(TimeSpan.FromMilliseconds(500 * attempt));}}
}
永続的な権限不足や無効なパスは、再試行しても解決しません。すべての例外を無条件で再試行するのではなく、一時的に解消する可能性があるエラーだけを対象にします。
5-5. パスが長すぎる場合や無効な文字を含む場合
パスに問題があると、次のような例外が発生する可能性があります。
PathTooLongExceptionArgumentExceptionNotSupportedExceptionDirectoryNotFoundException
入力されたパスは、Path.GetFullPathで絶対パスへ変換すると検証しやすくなります。
string fullPath = Path.GetFullPath(inputPath);ただし、Path.GetFullPathが成功しても、対象パスが実際に存在するとは限りません。存在確認はDirectory.ExistsやFile.Existsで別途行います。
利用できるパスの長さや文字には、OS、ファイルシステム、実行環境による違いがあります。外部入力からパスを受け取る場合は、アプリケーションが想定するルートディレクトリの外へ移動できないようにする対策も必要です。
5-6. IOException・UnauthorizedAccessExceptionを処理する実装例
ディレクトリコピー全体を例外処理で囲む例は次のとおりです。
try{DirectoryCopyUtility.Copy(sourceDirectory: @"C:\Data\Source",destinationDirectory: @"C:\Data\Backup",copySubDirectories: true,overwrite: true);Console.WriteLine("コピーが完了しました。");
}catch (DirectoryNotFoundException ex){Console.Error.WriteLine($"ディレクトリが見つかりません: {ex.Message}");}catch (UnauthorizedAccessException ex){Console.Error.WriteLine($"アクセス権限がありません: {ex.Message}");}catch (PathTooLongException ex){Console.Error.WriteLine($"パスが長すぎます: {ex.Message}");}catch (IOException ex){Console.Error.WriteLine($"入出力エラーが発生しました: {ex.Message}");}
例外を処理するときは、何も記録せずに無視しないことが重要です。少なくとも対象パス、例外の種類、メッセージをログへ残します。
6. 安全にディレクトリをコピーするための注意点
6-1. コピー元とコピー先が同じパスでないか確認する
コピー元とコピー先が同じディレクトリの場合、同じファイルを自分自身へコピーすることになり、処理に失敗します。
文字列をそのまま比較すると、相対パスや末尾の区切り文字によって同じパスを異なるものと判定する可能性があります。
C:\Data\SourceC:\Data\Source
C:\Data\Work..\Source比較前に絶対パスへ変換し、末尾の区切り文字を正規化します。
6-2. コピー先をコピー元の配下に指定しない
次のような指定は避けなければなりません。
コピー元: C:\Data\Sourceコピー先: C:\Data\Source\Backupコピー処理中に作成されたBackupディレクトリが再びコピー対象として列挙されると、次のような階層が生成される可能性があります。
Source└─ Backup└─ Backup└─ Backup└─ ...処理を開始する前に、コピー先がコピー元の配下でないことを検証します。
6-3. 相対パスを絶対パスへ変換して比較する
次のメソッドは、コピー元とコピー先の関係を検証します。
using System;using System.IO;public static class CopyPathValidator{public static void Validate(string sourceDirectory,string destinationDirectory){string sourceFullPath =Path.TrimEndingDirectorySeparator(Path.GetFullPath(sourceDirectory));
string destinationFullPath =Path.TrimEndingDirectorySeparator(Path.GetFullPath(destinationDirectory));StringComparison comparison =OperatingSystem.IsWindows()? StringComparison.OrdinalIgnoreCase: StringComparison.Ordinal;if (string.Equals(sourceFullPath,destinationFullPath,comparison)){throw new IOException("コピー元とコピー先に同じディレクトリは指定できません。");}string sourcePrefix =EndsWithDirectorySeparator(sourceFullPath)? sourceFullPath: sourceFullPath + Path.DirectorySeparatorChar;if (destinationFullPath.StartsWith(sourcePrefix,comparison)){throw new IOException("コピー先をコピー元ディレクトリの配下には指定できません。");}}private static bool EndsWithDirectorySeparator(string path){return path.EndsWith(Path.DirectorySeparatorChar.ToString(),StringComparison.Ordinal) ||path.EndsWith(Path.AltDirectorySeparatorChar.ToString(),StringComparison.Ordinal);}
}
コピー開始前に呼び出します。
CopyPathValidator.Validate(sourceDirectory,destinationDirectory);DirectoryCopyUtility.Copy(sourceDirectory,destinationDirectory,copySubDirectories: true,overwrite: true);
Windowsのパスは通常、大文字と小文字を区別しないためOrdinalIgnoreCaseで比較しています。一方、Linuxなどでは区別されることがあるため、実行OSに応じて比較方法を切り替えています。
6-4. 空のディレクトリもコピー対象に含める
ファイルだけを列挙してコピーすると、空のディレクトリはコピーされません。
再帰処理の各階層で、ファイルの有無にかかわらずコピー先ディレクトリを作成します。
Directory.CreateDirectory(destinationDirectory);SearchOption.AllDirectoriesを使ってファイルだけを一括列挙する実装では、空のディレクトリを別途列挙して作成する必要があります。
6-5. 隠しファイル・読み取り専用ファイルを扱う際の注意点
GetFilesやEnumerateFilesは、通常、隠し属性を持つファイルも列挙します。ただし、アクセス権限がなければ列挙またはコピーに失敗します。
コピー先にある読み取り専用ファイルを上書きしようとすると、overwrite: trueでもエラーになる場合があります。
読み取り専用属性を解除して上書きする場合は、対象ファイルを本当に変更してよいか確認したうえで処理します。
if (File.Exists(destinationPath)){FileAttributes attributes =File.GetAttributes(destinationPath);if ((attributes & FileAttributes.ReadOnly) != 0){File.SetAttributes(destinationPath,attributes & ~FileAttributes.ReadOnly);}
}
属性を変更するとコピー先の状態に影響するため、ライブラリ内で無条件に解除するのではなく、オプションとして切り替えられる設計が適しています。
6-6. シンボリックリンクによる無限再帰を防ぐ
シンボリックリンクやWindowsのジャンクションが親ディレクトリを指していると、再帰処理が循環する可能性があります。
リンク先をたどらない方針であれば、ReparsePoint属性を確認して除外します。
foreach (DirectoryInfo subDirectoryin source.EnumerateDirectories()){bool isReparsePoint =(subDirectory.Attributes &FileAttributes.ReparsePoint) != 0;if (isReparsePoint){continue;}// 通常のサブディレクトリだけを再帰コピーする
}
シンボリックリンクそのものを複製する処理と、リンク先の内容をコピーする処理は意味が異なります。アプリケーションの要件に応じて、次のいずれかを明確に決めておきます。
リンクを除外する
リンクそのものを再作成する
リンク先の実体をコピーする
リンク先をたどる場合は、訪問済みディレクトリを記録するなど、循環を検出する仕組みが必要です。
7. 条件を指定してファイルやサブフォルダをコピーする方法
7-1. 特定の拡張子だけをコピーする
特定の拡張子だけをコピーする場合は、検索パターンを指定します。
foreach (string sourceFilePath in Directory.EnumerateFiles(sourceDirectory,"*.csv",SearchOption.TopDirectoryOnly)){string fileName = Path.GetFileName(sourceFilePath);string destinationFilePath = Path.Combine(destinationDirectory,fileName);File.Copy(sourceFilePath,destinationFilePath,overwrite: true);
}
複数の拡張子を対象にする場合は、拡張子を集合として保持すると判定しやすくなります。
var allowedExtensions = new HashSet<string>(StringComparer.OrdinalIgnoreCase){".jpg",".jpeg",".png"};foreach (string sourceFilePath in Directory.EnumerateFiles(sourceDirectory,"*",SearchOption.TopDirectoryOnly)){string extension = Path.GetExtension(sourceFilePath);
if (!allowedExtensions.Contains(extension)){continue;}string destinationFilePath = Path.Combine(destinationDirectory,Path.GetFileName(sourceFilePath));File.Copy(sourceFilePath,destinationFilePath,overwrite: true);
}
7-2. 不要なファイルやフォルダを除外する
bin、obj、.gitなどを除外する場合は、ディレクトリ名を判定しながら再帰処理します。
private static readonly HashSet<string> ExcludedDirectories =new(StringComparer.OrdinalIgnoreCase){"bin","obj",".git"};public static void CopyWithExclusions(string sourceDirectory,string destinationDirectory,bool overwrite){Directory.CreateDirectory(destinationDirectory);
foreach (string sourceFilePath inDirectory.EnumerateFiles(sourceDirectory)){string fileName = Path.GetFileName(sourceFilePath);if (fileName.EndsWith(".tmp",StringComparison.OrdinalIgnoreCase)){continue;}string destinationFilePath = Path.Combine(destinationDirectory,fileName);File.Copy(sourceFilePath,destinationFilePath,overwrite);}foreach (string sourceSubDirectory inDirectory.EnumerateDirectories(sourceDirectory)){string directoryName =Path.GetFileName(sourceSubDirectory);if (ExcludedDirectories.Contains(directoryName)){continue;}var info = new DirectoryInfo(sourceSubDirectory);if ((info.Attributes &FileAttributes.ReparsePoint) != 0){continue;}string destinationSubDirectory = Path.Combine(destinationDirectory,directoryName);CopyWithExclusions(sourceSubDirectory,destinationSubDirectory,overwrite);}
}
ファイル名だけでなく、相対パスやファイルサイズ、更新日時を条件にすることもできます。
7-3. SearchOption.AllDirectoriesを使用する場合の実装
SearchOption.AllDirectoriesを指定すると、配下にあるすべてのファイルを列挙できます。
IEnumerable<string> files = Directory.EnumerateFiles(sourceDirectory,"*",SearchOption.AllDirectories);ただし、取得したファイル名だけをコピー先へ結合すると、元のディレクトリ構造が失われます。また、同名ファイルが別々のフォルダに存在すると競合します。
そのため、コピー元からの相対パスを取得してコピー先へ結合します。
7-4. ディレクトリ構造を維持してファイルをコピーする
次の実装は、SearchOption.AllDirectoriesを使用しながら元のディレクトリ構造を維持します。
using System;using System.IO;public static void CopyAllFiles(string sourceDirectory,string destinationDirectory,bool overwrite){string sourceRoot = Path.GetFullPath(sourceDirectory);string destinationRoot =Path.GetFullPath(destinationDirectory);
CopyPathValidator.Validate(sourceRoot,destinationRoot);Directory.CreateDirectory(destinationRoot);// 空のディレクトリも作成するforeach (string sourceSubDirectoryin Directory.EnumerateDirectories(sourceRoot,"*",SearchOption.AllDirectories)){var info = new DirectoryInfo(sourceSubDirectory);if ((info.Attributes &FileAttributes.ReparsePoint) != 0){continue;}string relativeDirectory =Path.GetRelativePath(sourceRoot,sourceSubDirectory);string destinationSubDirectory = Path.Combine(destinationRoot,relativeDirectory);Directory.CreateDirectory(destinationSubDirectory);}foreach (string sourceFilePathin Directory.EnumerateFiles(sourceRoot,"*",SearchOption.AllDirectories)){string relativeFilePath =Path.GetRelativePath(sourceRoot,sourceFilePath);string destinationFilePath = Path.Combine(destinationRoot,relativeFilePath);string? destinationParent =Path.GetDirectoryName(destinationFilePath);if (destinationParent is not null){Directory.CreateDirectory(destinationParent);}File.Copy(sourceFilePath,destinationFilePath,overwrite);}
}
SearchOption.AllDirectoriesによる一括列挙は簡潔ですが、アクセスできないディレクトリが1つあるだけで列挙が中断することがあります。ディレクトリごとに例外を記録して処理を継続したい場合は、独自の再帰処理が適しています。
また、シンボリックリンクの除外を厳密に行う場合も、各階層を確認できる再帰処理のほうが制御しやすくなります。
7-5. 更新日時が新しいファイルだけをコピーする
コピー元の更新日時がコピー先より新しい場合だけコピーすると、不要な上書きを減らせます。
public static bool ShouldCopy(string sourceFilePath,string destinationFilePath){if (!File.Exists(destinationFilePath)){return true;}DateTime sourceLastWriteTime =File.GetLastWriteTimeUtc(sourceFilePath);DateTime destinationLastWriteTime =File.GetLastWriteTimeUtc(destinationFilePath);return sourceLastWriteTime >destinationLastWriteTime;
}
使用例は次のとおりです。
if (ShouldCopy(sourceFilePath, destinationFilePath)){File.Copy(sourceFilePath,destinationFilePath,overwrite: true);}更新日時だけで完全な同一性を判定できるとは限りません。より厳密な判定が必要な場合は、次の情報も比較します。
ファイルサイズ
更新日時
ハッシュ値
ハッシュ値の比較は精度が高い一方、両方のファイルを読み込む必要があるため、ファイル数や容量が多い環境では負荷が増えます。
8. 非同期でディレクトリをコピーする方法
8-1. FileStreamとCopyToAsyncを使用する
File.Copyには非同期版がないため、非同期でファイルをコピーするときはFileStreamとCopyToAsyncを使用します。
public static async Task CopyFileAsync(string sourceFilePath,string destinationFilePath,bool overwrite,CancellationToken cancellationToken){FileMode destinationMode =overwrite? FileMode.Create: FileMode.CreateNew;await using var sourceStream = new FileStream(sourceFilePath,FileMode.Open,FileAccess.Read,FileShare.Read,bufferSize: 81920,options:FileOptions.Asynchronous |FileOptions.SequentialScan);await using var destinationStream = new FileStream(destinationFilePath,destinationMode,FileAccess.Write,FileShare.None,bufferSize: 81920,options:FileOptions.Asynchronous |FileOptions.SequentialScan);await sourceStream.CopyToAsync(destinationStream,bufferSize: 81920,cancellationToken);
}
FileMode.Createは、コピー先ファイルが存在する場合に上書きします。FileMode.CreateNewは、コピー先が存在する場合にIOExceptionを発生させます。
8-2. async・awaitに対応した再帰コピーの実装例
次のコードは、サブディレクトリを含めて非同期コピーする実装例です。
using System;using System.IO;using System.Threading;using System.Threading.Tasks;public static class AsyncDirectoryCopyUtility{public static async Task CopyDirectoryAsync(string sourceDirectory,string destinationDirectory,bool overwrite,CancellationToken cancellationToken = default){CopyPathValidator.Validate(sourceDirectory,destinationDirectory);
await CopyDirectoryCoreAsync(new DirectoryInfo(sourceDirectory),destinationDirectory,overwrite,cancellationToken);}private static async Task CopyDirectoryCoreAsync(DirectoryInfo source,string destinationDirectory,bool overwrite,CancellationToken cancellationToken){cancellationToken.ThrowIfCancellationRequested();if (!source.Exists){throw new DirectoryNotFoundException($"コピー元ディレクトリが存在しません: {source.FullName}");}Directory.CreateDirectory(destinationDirectory);foreach (FileInfo sourceFilein source.EnumerateFiles()){cancellationToken.ThrowIfCancellationRequested();string destinationFilePath = Path.Combine(destinationDirectory,sourceFile.Name);await CopyFileAsync(sourceFile.FullName,destinationFilePath,overwrite,cancellationToken);}foreach (DirectoryInfo sourceSubDirectoryin source.EnumerateDirectories()){cancellationToken.ThrowIfCancellationRequested();if ((sourceSubDirectory.Attributes &FileAttributes.ReparsePoint) != 0){continue;}string destinationSubDirectory = Path.Combine(destinationDirectory,sourceSubDirectory.Name);await CopyDirectoryCoreAsync(sourceSubDirectory,destinationSubDirectory,overwrite,cancellationToken);}}private static async Task CopyFileAsync(string sourceFilePath,string destinationFilePath,bool overwrite,CancellationToken cancellationToken){FileMode destinationMode =overwrite? FileMode.Create: FileMode.CreateNew;await using var sourceStream = new FileStream(sourceFilePath,FileMode.Open,FileAccess.Read,FileShare.Read,bufferSize: 81920,options:FileOptions.Asynchronous |FileOptions.SequentialScan);await using var destinationStream = new FileStream(destinationFilePath,destinationMode,FileAccess.Write,FileShare.None,bufferSize: 81920,options:FileOptions.Asynchronous |FileOptions.SequentialScan);await sourceStream.CopyToAsync(destinationStream,bufferSize: 81920,cancellationToken);}
}
呼び出し例は次のとおりです。
await AsyncDirectoryCopyUtility.CopyDirectoryAsync(sourceDirectory: @"C:\Data\Source",destinationDirectory: @"C:\Data\Backup",overwrite: true);ディレクトリの列挙や作成自体は同期処理です。ファイルの読み書き部分をCopyToAsyncで非同期化しています。
8-3. CancellationTokenでコピーをキャンセルする
CancellationTokenSourceを作成し、そのトークンをコピー処理へ渡します。
using var cancellationTokenSource =new CancellationTokenSource();try{await AsyncDirectoryCopyUtility.CopyDirectoryAsync(@"C:\Data\Source",@"C:\Data\Backup",overwrite: true,cancellationTokenSource.Token);}catch (OperationCanceledException){Console.WriteLine("コピーがキャンセルされました。");}
画面のキャンセルボタンなどから、次の処理を呼び出します。
cancellationTokenSource.Cancel();キャンセルされた時点によっては、コピー途中のファイルがコピー先に残る可能性があります。中途半端なファイルを残したくない場合は、一時ファイルへコピーして、完了後に正式なファイル名へ移動する方式が有効です。
string temporaryPath =destinationFilePath + "." +Guid.NewGuid().ToString("N") +".tmp";コピーが完了した場合だけ、正式なパスへ移動します。
File.Move(temporaryPath,destinationFilePath,overwrite: true);例外やキャンセルが発生した場合は、一時ファイルを削除します。
8-4. 大容量ファイルを非同期コピーする際の注意点
非同期コピーには、アプリケーションの応答性を維持しやすいという利点があります。ただし、非同期化しただけでディスクの転送速度が必ず上がるわけではありません。
大容量ファイルを扱う場合は、次の点に注意します。
同時コピー数を増やしすぎない
ストレージの読み書き性能を考慮する
ネットワーク先では切断やタイムアウトを考慮する
コピー途中のファイルをどう扱うか決める
キャンセル時に一時ファイルを削除する
コピー前後で空き容量を確認する
UIスレッドで同期的な待機をしない
多数のファイルを無制限に並列コピーすると、ディスクアクセスが競合し、かえって遅くなることがあります。並列化する場合は、SemaphoreSlimやParallelOptions.MaxDegreeOfParallelismなどで同時実行数を制限します。
9. コピー処理の進捗を取得する方法
9-1. コピー対象のファイル数と合計サイズを事前に取得する
進捗率を計算するには、コピー開始前に対象ファイルの総数または合計サイズを取得します。
FileInfo[] files = Directory.EnumerateFiles(sourceDirectory,"*",SearchOption.AllDirectories).Select(path => new FileInfo(path)).ToArray();int totalFiles = files.Length;long totalBytes = files.Sum(file => file.Length);
事前列挙には、次の注意点があります。
コピー開始までに列挙時間がかかる
ファイル数が多いとメモリ使用量が増える
列挙後にファイルが追加、削除、更新される可能性がある
アクセスできないディレクトリがあると列挙に失敗する可能性がある
厳密な進捗率が不要なら、事前列挙を行わず「処理済みファイル数」だけを表示する方法もあります。
9-2. コピー済みファイル数から進捗率を計算する
ファイル数を基準にした進捗率は、次の式で計算できます。
double percentage =totalFiles == 0? 100.0: (double)completedFiles /totalFiles * 100.0;ただし、1KBのファイルと10GBのファイルが同じ1件として扱われます。容量の差が大きい場合は、コピー済みバイト数を基準にしたほうが実際の進行状況に近くなります。
double percentage =totalBytes == 0? 100.0: (double)copiedBytes /totalBytes * 100.0;9-3. IProgressを使って画面へ進捗を通知する
進捗情報を表す型を定義します。
public sealed record CopyProgress(int CompletedFiles,int TotalFiles,long CopiedBytes,long TotalBytes,int FailedFiles,string CurrentFile){public double Percentage =>TotalBytes > 0? (double)CopiedBytes /TotalBytes * 100.0: TotalFiles > 0? (double)CompletedFiles /TotalFiles * 100.0: 100.0;}ファイル単位で進捗を通知する実装例は次のとおりです。
using System;using System.Collections.Generic;using System.IO;using System.Linq;using System.Threading;using System.Threading.Tasks;public sealed record CopyError(string SourcePath,string DestinationPath,string Message);
public static class ProgressDirectoryCopyUtility{public static async Task<IReadOnlyList<CopyError>>CopyDirectoryAsync(string sourceDirectory,string destinationDirectory,bool overwrite,IProgress<CopyProgress>? progress = null,CancellationToken cancellationToken = default){CopyPathValidator.Validate(sourceDirectory,destinationDirectory);
string sourceRoot =Path.GetFullPath(sourceDirectory);string destinationRoot =Path.GetFullPath(destinationDirectory);FileInfo[] files = Directory.EnumerateFiles(sourceRoot,"*",SearchOption.AllDirectories).Select(path => new FileInfo(path)).ToArray();long totalBytes = files.Sum(file => file.Length);long copiedBytes = 0;int completedFiles = 0;int failedFiles = 0;var errors = new List<CopyError>();Directory.CreateDirectory(destinationRoot);foreach (FileInfo sourceFile in files){cancellationToken.ThrowIfCancellationRequested();string relativePath = Path.GetRelativePath(sourceRoot,sourceFile.FullName);string destinationFilePath = Path.Combine(destinationRoot,relativePath);string? destinationParent =Path.GetDirectoryName(destinationFilePath);if (destinationParent is not null){Directory.CreateDirectory(destinationParent);}try{await CopyFileAsync(sourceFile.FullName,destinationFilePath,overwrite,cancellationToken);completedFiles++;copiedBytes += sourceFile.Length;}catch (OperationCanceledException){throw;}catch (Exception ex) when (ex is IOException ||ex is UnauthorizedAccessException ||ex is NotSupportedException){failedFiles++;errors.Add(new CopyError(sourceFile.FullName,destinationFilePath,ex.Message));}progress?.Report(new CopyProgress(completedFiles,files.Length,copiedBytes,totalBytes,failedFiles,sourceFile.FullName));}return errors;}private static async Task CopyFileAsync(string sourceFilePath,string destinationFilePath,bool overwrite,CancellationToken cancellationToken){FileMode destinationMode =overwrite? FileMode.Create: FileMode.CreateNew;await using var sourceStream = new FileStream(sourceFilePath,FileMode.Open,FileAccess.Read,FileShare.Read,bufferSize: 81920,useAsync: true);await using var destinationStream = new FileStream(destinationFilePath,destinationMode,FileAccess.Write,FileShare.None,bufferSize: 81920,useAsync: true);await sourceStream.CopyToAsync(destinationStream,bufferSize: 81920,cancellationToken);}
}
コンソールアプリケーションでの使用例は次のとおりです。
var progress = new Progress<CopyProgress>(value =>{Console.WriteLine($"{value.Percentage:F1}% " +$"({value.CompletedFiles}/{value.TotalFiles}) " +$"{value.CurrentFile}");});IReadOnlyList<CopyError> errors =await ProgressDirectoryCopyUtility.CopyDirectoryAsync(@"C:\Data\Source",@"C:\Data\Backup",overwrite: true,progress);
WPF、Windows Forms、その他のUIアプリケーションでは、適切なコンテキスト上でProgress<T>を作成すると、進捗バーやラベルの更新に利用できます。
9-4. エラーが発生したファイルを記録して処理を継続する
大量のファイルをコピーする処理では、1件のエラーですべてを中断するのではなく、失敗したファイルを記録して残りを処理したい場合があります。
前述の実装では、次の例外をファイル単位で記録しています。
catch (Exception ex) when (ex is IOException ||ex is UnauthorizedAccessException ||ex is NotSupportedException){errors.Add(new CopyError(sourceFile.FullName,destinationFilePath,ex.Message));}処理完了後、エラー一覧を確認できます。
foreach (CopyError error in errors){Console.Error.WriteLine($"コピー失敗: {error.SourcePath}");Console.Error.WriteLine($"コピー先: {error.DestinationPath}");Console.Error.WriteLine($"理由: {error.Message}");}OperationCanceledExceptionは通常のコピー失敗として記録せず、上位へ再スローしてキャンセルとして扱います。
また、すべてのExceptionを一律に握りつぶすと、プログラム上の不具合まで見逃す可能性があります。継続可能と判断した例外だけを捕捉するのが適切です。
10. ディレクトリコピーに関するよくある質問
10-1. Directory.MoveとDirectory.Copyの違いは何ですか
Directory.Moveは、ディレクトリを別の場所へ移動するメソッドです。
Directory.Move(sourceDirectory,destinationDirectory);移動後は、原則としてコピー元の場所にディレクトリが残りません。
一方、ディレクトリのコピーでは、コピー元を残したままコピー先に同じ内容を作成します。C#には標準のDirectory.Copyがないため、ファイルコピーと再帰処理を組み合わせて実装します。
用途の違いは次のとおりです。
| 操作 | コピー元 | コピー先 |
| 移動 | 元の場所からなくなる | 移動した内容が作成される |
| コピー | そのまま残る | 複製した内容が作成される |
10-2. コピー先ディレクトリが存在していてもコピーできますか
コピーできます。
Directory.CreateDirectory(destinationDirectory);Directory.CreateDirectoryは、対象ディレクトリがすでに存在していても基本的にそのディレクトリを利用します。
ただし、コピー先に同名ファイルがある場合の動作は、File.CopyまたはFileInfo.CopyToのoverwrite引数によって変わります。
File.Copy(sourceFilePath,destinationFilePath,overwrite: true);コピー先ディレクトリにだけ存在するファイルは、自動では削除されません。
10-3. フォルダ内のファイルだけをコピーできますか
サブフォルダを列挙せず、直下のファイルだけをコピーすれば実現できます。
Directory.CreateDirectory(destinationDirectory);foreach (string sourceFilePathin Directory.EnumerateFiles(sourceDirectory,"*",SearchOption.TopDirectoryOnly)){string destinationFilePath = Path.Combine(destinationDirectory,Path.GetFileName(sourceFilePath));
File.Copy(sourceFilePath,destinationFilePath,overwrite: true);
}
この処理では、サブディレクトリとその中のファイルはコピーされません。
10-4. ファイルの作成日時や属性も引き継げますか
ファイル内容をコピーしただけでは、アプリケーションが必要とするすべての日時や属性が、必ずしも期待どおりに引き継がれるとは限りません。
必要なメタデータは、コピー完了後に明示的に設定できます。
File.Copy(sourceFilePath,destinationFilePath,overwrite: true);File.SetCreationTimeUtc(destinationFilePath,File.GetCreationTimeUtc(sourceFilePath));
File.SetLastWriteTimeUtc(destinationFilePath,File.GetLastWriteTimeUtc(sourceFilePath));
File.SetLastAccessTimeUtc(destinationFilePath,File.GetLastAccessTimeUtc(sourceFilePath));
File.SetAttributes(destinationFilePath,File.GetAttributes(sourceFilePath));
ディレクトリの日時も同様に設定できます。
Directory.SetCreationTimeUtc(destinationDirectory,Directory.GetCreationTimeUtc(sourceDirectory));Directory.SetLastWriteTimeUtc(destinationDirectory,Directory.GetLastWriteTimeUtc(sourceDirectory));
作成日時や一部の属性に対応しているかどうかは、OSやファイルシステムによって異なります。権限情報、アクセス制御リスト、所有者、代替データストリームなどを完全に複製するには、追加の処理が必要です。
10-5. コピー元とコピー先を完全に同期するにはどうすればよいですか
完全同期では、通常のコピーに加えて、コピー先にだけ存在する項目を削除する必要があります。
基本的な流れは次のとおりです。
コピー元のディレクトリとファイルを列挙する
新規または更新されたファイルをコピーする
コピー先のディレクトリとファイルを列挙する
コピー元に存在しないコピー先ファイルを削除する
コピー元に存在しないコピー先ディレクトリを削除する
削除を伴う同期処理は、パスの指定ミスによる影響が大きくなります。実行前に削除対象を一覧表示するドライラン機能や、同期対象ルートの厳密な検証を用意すると安全です。
OSの機能を利用できる環境では、Windowsのrobocopyや、Linuxなどのrsyncといった同期用ツールも選択肢になります。
10-6. 大量のファイルを高速にコピーするにはどうすればよいですか
大量のファイルを効率よくコピーするには、次の点を検討します。
EnumerateFilesで遅延列挙する不要なファイルを早い段階で除外する
更新されていないファイルをスキップする
適切なバッファーサイズを使用する
非同期I/Oを利用する
並列数に上限を設定する
コピー元とコピー先のストレージ特性を考慮する
ファイル単位の過剰なログ出力を避ける
必要に応じてOS標準のコピー・同期ツールを利用する
小さなファイルが大量にある場合は、ファイルのオープンやメタデータ取得のコストが大きくなります。大きなファイルが中心の場合は、ストレージやネットワークの転送速度が主な制約になります。
並列処理を使う場合も、すべてのファイルを同時にコピーしてはいけません。例えば、Parallel.ForEachAsyncで最大同時実行数を制限できます。
var options = new ParallelOptions{MaxDegreeOfParallelism = 4,CancellationToken = cancellationToken};await Parallel.ForEachAsync(sourceFiles,options,async (sourceFilePath, token) =>{string relativePath = Path.GetRelativePath(sourceDirectory,sourceFilePath);
string destinationFilePath = Path.Combine(destinationDirectory,relativePath);string? parent =Path.GetDirectoryName(destinationFilePath);if (parent is not null){Directory.CreateDirectory(parent);}await CopyFileAsync(sourceFilePath,destinationFilePath,overwrite: true,token);});</span></code></pre><p><span>最適な並列数は、HDD、SSD、ネットワークストレージ、ファイルサイズなどによって異なります。固定値を過信せず、実際の環境で測定して調整することが重要です。</span></p><h2><span>まとめ</span></h2><p><span>C#には、ディレクトリ全体をコピーする</span><code dir="ltr"><span>Directory.Copy</span></code><span>メソッドがありません。そのため、コピー先ディレクトリの作成、ファイルのコピー、サブディレクトリへの再帰処理を組み合わせて実装します。</span></p><p><span>基本的なディレクトリコピーでは、次のクラスとメソッドを使用します。</span></p><ul data-spread="false"><li><p><code dir="ltr"><span>Directory.CreateDirectory</span></code><span>でコピー先を作成する</span></p></li><li><p><code dir="ltr"><span>DirectoryInfo.GetFiles</span></code><span>または</span><code dir="ltr"><span>EnumerateFiles</span></code><span>でファイルを取得する</span></p></li><li><p><code dir="ltr"><span>File.Copy</span></code><span>または</span><code dir="ltr"><span>FileInfo.CopyTo</span></code><span>でファイルをコピーする</span></p></li><li><p><code dir="ltr"><span>GetDirectories</span></code><span>または</span><code dir="ltr"><span>EnumerateDirectories</span></code><span>でサブフォルダを取得する</span></p></li><li><p><code dir="ltr"><span>Path.Combine</span></code><span>でコピー先パスを作成する</span></p></li><li><p><code dir="ltr"><span>Path.GetFullPath</span></code><span>と</span><code dir="ltr"><span>Path.GetRelativePath</span></code><span>でパスを安全に扱う</span></p></li></ul><p><span>実運用では、単に再帰コピーするだけでなく、次の点も考慮する必要があります。</span></p><ul data-spread="false"><li><p><span>同名ファイルを上書きするか</span></p></li><li><p><span>コピー元とコピー先が同じパスでないか</span></p></li><li><p><span>コピー先がコピー元の配下に入っていないか</span></p></li><li><p><span>シンボリックリンクをたどるか</span></p></li><li><p><span>例外発生時に中断するか継続するか</span></p></li><li><p><span>キャンセルや進捗通知に対応するか</span></p></li><li><p><span>コピー先だけに存在するファイルを削除するか</span></p></li></ul><p><span>用途に応じて上書き、除外条件、非同期処理、進捗通知などを追加すれば、安全で再利用しやすいC#のディレクトリコピー処理を実装できます。</span></p></div></div>

