C# Tips | ファイル・ディレクトリ操作:ファイル差分検出

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

はじめに 「違うかどうか」から一歩進んで「どこが違うか」

前回の「ファイル内容比較」は、
「同じか」「違うか」を true / false で判定する話でした。

でも、業務ではそれだけでは足りない場面がよくあります。

「どこが違うのか知りたい」
「どの行が変わったのか知りたい」
「どのファイルが追加・削除・変更されたのか知りたい」

こういうときに必要になるのが「ファイル差分検出」です。
ここでは、プログラミング初心者向けに、まずはシンプルな差分検出から始めて、
実務でよく使う「テキスト差分」「ディレクトリ差分」まで、段階的に解説していきます。


基本の考え方 「差分検出」と「一致判定」の違い

「一致判定」は、「同じかどうか」だけが分かればよい処理です。
一方、「差分検出」は、「どこが違うのか」「何が変わったのか」まで知りたい処理です。

例えば、次のようなイメージです。

一致判定
「この 2 つのファイルは同じです」または「違います」

差分検出
「3 行目が違います」
「A.txt は削除され、B.txt は追加されました」
「同じファイル名だが内容が変わっています」

つまり、「一致判定」は yes/no の世界、「差分検出」は「差分の内容」を扱う世界です。
当然、後者のほうが情報量が多く、実装も少し複雑になります。


バイナリファイルの差分検出 「最初に違う位置」を見つける

どこで違い始めるかを知る

バイナリファイル(画像、PDF、バイナリデータなど)の場合、
「どのバイト位置から違うのか」を知るだけでも役に立つことがあります。

例えば、「途中までは同じだが、後半だけ違う」といった状況をログに残したい場合です。

まずは、「最初に違いが出るオフセット(バイト位置)」を返すユーティリティを書いてみます。

using System;
using System.IO;

public static class BinaryDiffUtil
{
    public static long FindFirstDifferenceOffset(string path1, string path2)
    {
        var file1 = new FileInfo(path1);
        var file2 = new FileInfo(path2);

        if (!file1.Exists || !file2.Exists)
        {
            throw new FileNotFoundException("どちらかのファイルが存在しません。");
        }

        long minLength = Math.Min(file1.Length, file2.Length);

        const int bufferSize = 8192;

        using (var fs1 = new FileStream(path1, FileMode.Open, FileAccess.Read, FileShare.Read))
        using (var fs2 = new FileStream(path2, FileMode.Open, FileAccess.Read, FileShare.Read))
        {
            var buffer1 = new byte[bufferSize];
            var buffer2 = new byte[bufferSize];

            long offset = 0;

            while (offset < minLength)
            {
                int toRead = (int)Math.Min(bufferSize, minLength - offset);

                int read1 = fs1.Read(buffer1, 0, toRead);
                int read2 = fs2.Read(buffer2, 0, toRead);

                if (read1 != read2)
                {
                    return offset;
                }

                for (int i = 0; i < read1; i++)
                {
                    if (buffer1[i] != buffer2[i])
                    {
                        return offset + i;
                    }
                }

                offset += read1;
            }

            if (file1.Length != file2.Length)
            {
                return minLength;
            }

            return -1;
        }
    }
}
C#

このメソッドは、次のような意味を持ちます。

戻り値が -1 のとき
内容もサイズも完全に一致している。

戻り値が 0 以上のとき
そのバイト位置から違いが始まっている。

例えば、次のように使えます。

string a = @"C:\data\a.bin";
string b = @"C:\data\b.bin";

long diffOffset = BinaryDiffUtil.FindFirstDifferenceOffset(a, b);

if (diffOffset < 0)
{
    Console.WriteLine("完全に一致しています。");
}
else
{
    Console.WriteLine($"最初の差分は {diffOffset} バイト目にあります。");
}
C#

ここでの重要ポイントは、「全部の差分を取るのではなく、まずは“最初の差分”だけに絞る」と割り切っていることです。
これだけでも、「どの程度違うのか」を把握するには十分なことが多いです。


テキストファイルの差分検出 「どの行が違うか」を知る

行単位で比較して、差分行を列挙する

CSV や設定ファイル、ログなど、テキストファイルの差分は「行単位」で見ることが多いです。
ここでは、「どの行番号で内容が違うか」を検出するシンプルなユーティリティを書いてみます。

using System;
using System.Collections.Generic;
using System.IO;
using System.Text;

public sealed class TextLineDiff
{
    public int LineNumber { get; }
    public string? Left { get; }
    public string? Right { get; }

    public TextLineDiff(int lineNumber, string? left, string? right)
    {
        LineNumber = lineNumber;
        Left = left;
        Right = right;
    }
}

public static class TextDiffUtil
{
    public static IEnumerable<TextLineDiff> GetLineDiffs(
        string path1,
        string path2,
        Encoding encoding)
    {
        using var reader1 = new StreamReader(path1, encoding, detectEncodingFromByteOrderMarks: true);
        using var reader2 = new StreamReader(path2, encoding, detectEncodingFromByteOrderMarks: true);

        int lineNumber = 0;

        while (true)
        {
            string? line1 = reader1.ReadLine();
            string? line2 = reader2.ReadLine();

            lineNumber++;

            if (line1 == null && line2 == null)
            {
                yield break;
            }

            if (!string.Equals(line1, line2, StringComparison.Ordinal))
            {
                yield return new TextLineDiff(lineNumber, line1, line2);
            }
        }
    }
}
C#

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

string oldFile = @"C:\data\old.csv";
string newFile = @"C:\data\new.csv";

foreach (var diff in TextDiffUtil.GetLineDiffs(oldFile, newFile, Encoding.UTF8))
{
    Console.WriteLine($"行 {diff.LineNumber} が異なります。");
    Console.WriteLine($"  左: {diff.Left}");
    Console.WriteLine($"  右: {diff.Right}");
}
C#

この実装は、あくまで「同じ行番号同士を比較して、違う行を列挙する」だけです。
行の挿入・削除(いわゆる diff ツールのような「+」「-」の表現)は扱っていません。

それでも、業務では十分役に立つ場面が多いです。
例えば、「設定ファイルのどの行が変わったか」「CSV のどの行が前回と違うか」をログに残す用途などです。


ディレクトリの差分検出 「追加・削除・変更されたファイル」を見つける

2 つのフォルダを比較して差分を取る

バックアップや同期処理では、「フォルダ A とフォルダ B の中身の差分」を取りたいことがよくあります。
ここで知りたいのは、例えば次のような情報です。

A にはあるが B にはないファイル(削除された、または未コピー)
B にはあるが A にはないファイル(新規追加)
両方にあるが内容が違うファイル(更新された)

これをシンプルに実装してみます。

using System;
using System.Collections.Generic;
using System.IO;

public enum FileDiffKind
{
    OnlyInLeft,
    OnlyInRight,
    DifferentContent,
    Same
}

public sealed class FileDiffEntry
{
    public string RelativePath { get; }
    public FileDiffKind Kind { get; }

    public FileDiffEntry(string relativePath, FileDiffKind kind)
    {
        RelativePath = relativePath;
        Kind = kind;
    }
}

public static class DirectoryDiffUtil
{
    public static IEnumerable<FileDiffEntry> GetDirectoryDiff(
        string leftRoot,
        string rightRoot)
    {
        var leftFiles = GetRelativeFileMap(leftRoot);
        var rightFiles = GetRelativeFileMap(rightRoot);

        var allKeys = new HashSet<string>(leftFiles.Keys, StringComparer.OrdinalIgnoreCase);
        allKeys.UnionWith(rightFiles.Keys);

        foreach (var relativePath in allKeys)
        {
            bool inLeft = leftFiles.TryGetValue(relativePath, out string? leftFull);
            bool inRight = rightFiles.TryGetValue(relativePath, out string? rightFull);

            if (inLeft && !inRight)
            {
                yield return new FileDiffEntry(relativePath, FileDiffKind.OnlyInLeft);
            }
            else if (!inLeft && inRight)
            {
                yield return new FileDiffEntry(relativePath, FileDiffKind.OnlyInRight);
            }
            else if (inLeft && inRight)
            {
                bool same = FileCompareUtil.AreFilesEqual(leftFull!, rightFull!);

                yield return new FileDiffEntry(
                    relativePath,
                    same ? FileDiffKind.Same : FileDiffKind.DifferentContent);
            }
        }
    }

    private static Dictionary<string, string> GetRelativeFileMap(string root)
    {
        var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);

        foreach (var fullPath in Directory.GetFiles(root, "*", SearchOption.AllDirectories))
        {
            string relative = Path.GetRelativePath(root, fullPath);
            map[relative] = fullPath;
        }

        return map;
    }
}
C#

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

string oldDir = @"C:\backup\old";
string newDir = @"C:\backup\new";

foreach (var entry in DirectoryDiffUtil.GetDirectoryDiff(oldDir, newDir))
{
    switch (entry.Kind)
    {
        case FileDiffKind.OnlyInLeft:
            Console.WriteLine($"削除された(または未コピー): {entry.RelativePath}");
            break;
        case FileDiffKind.OnlyInRight:
            Console.WriteLine($"新規追加: {entry.RelativePath}");
            break;
        case FileDiffKind.DifferentContent:
            Console.WriteLine($"内容変更: {entry.RelativePath}");
            break;
        case FileDiffKind.Same:
            break;
    }
}
C#

ここでの重要ポイントは、「相対パスで比較している」ことです。
GetRelativePath を使って、ルートフォルダからの相対パスに変換してから比較することで、
「ルートが違っても同じ構造なら同じファイル」とみなせます。


差分検出の「深掘りポイント」 実務で気を付けたいこと

「差分がある=悪い」ではない

差分検出は、「変化を見つける」ための仕組みです。
業務では、「差分があること自体は正常」というケースも多いです。

例えば、毎日更新されるレポートファイルの差分を取って、
「どの行が変わったか」をログに残す、というのは正常な動きです。

大事なのは、「どの種類の差分を、どう扱うか」を決めることです。

追加されたファイルはコピーするのか、無視するのか。
削除されたファイルは、相手側でも削除するのか、残しておくのか。
内容が変わったファイルは、上書きするのか、別名で保存するのか。

差分検出ユーティリティは、「変化を見える化する」役割で、
その後のアクションは業務ロジック側で決める、という分担を意識すると設計しやすくなります。

パフォーマンスと精度のバランス

ディレクトリ差分や大きなファイルの差分を取るとき、
「どこまで厳密にやるか」と「どこまで速さを優先するか」のバランスを取る必要があります。

例えば、ディレクトリ差分で「内容が違うかどうか」を判定するときに、
毎回全バイト比較をすると時間がかかります。

そこで、次のような段階的な判定をすることもあります。

ファイルサイズが違う → 即「内容変更」
サイズが同じで更新日時も同じ → 「同じとみなす」
サイズが同じだが更新日時が違う → バイト比較して最終判断

どこまでやるかは要件次第ですが、「全部厳密にやる」と決める前に、
「どこまで厳密さが必要か」を一度立ち止まって考えると、無駄な処理を減らせます。


まとめ 実務で使える「ファイル差分検出」ユーティリティの考え方

ファイル差分検出は、「同じかどうか」から一歩進んで、「どこがどう違うのか」を知るための仕組みです。

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

バイナリファイルなら、「最初に違いが出るバイト位置」を返すだけでも十分役に立つ。
テキストファイルなら、「行番号とその内容の差分」を列挙するだけでも、ログや調査に大きく貢献する。
ディレクトリ差分では、「追加」「削除」「内容変更」「同じ」を相対パスで判定するのが基本パターン。
差分検出ユーティリティは「変化を見える化する」役割で、その後のアクションは業務ロジック側で決める。
パフォーマンスと精度のバランスを意識して、「どこまで厳密にやるか」を設計段階で決めておく。

ここまで押さえておけば、「なんとなく違う気がする」ではなく、
「どこがどう違うのか」をコードでちゃんと説明できるようになります。

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