Porównanie 2 dużych plików

0

Wygenerowałem sobie 2 duże pliki (ok. 100 MB) z tą samą zawartością:

static void Main(string[] args)
{
    var data = GenerateContent();
    
    File.WriteAllText("data1.txt", data);
    File.WriteAllText("data2.txt", data);
}

static string GenerateContent()
{
    var rnd = new Random();
    var sb = new StringBuilder();

    for (int i = 0; i < 1000000; i++)
    {
        sb.Append(rnd.Next(0, 9));
    }

    return sb.ToString();
}

Teraz chciałbym je porównać (sprawdzić, czy są identyczne). Mógłbym zrobić to tak:

static bool AreFilesTheSame(string file1, string file2)
{
	if (new FileInfo(file1).Length != new FileInfo(file2).Length)
		return false;
				
    return File
        .ReadAllBytes(file1)
        .SequenceEqual(File.ReadAllBytes(file2));
}

Ale to spowoduje, że do pamięci zostanie wczytane 2x100 MB. Sprawdziłem to z wykorzystaniem bilbioteki BenchmarkDotNet i faktycznie tak jest:

|          Method |     Mean |    Error |  StdDev |     Gen 0 |     Gen 1 |     Gen 2 | Allocated |
|---------------- |---------:|---------:|--------:|----------:|----------:|----------:|----------:|
| AreFilesTheSame | 555.2 ms | 11.08 ms | 9.82 ms | 1000.0000 | 1000.0000 | 1000.0000 |    191 MB |

Rozwiązaniem na to byłoby wczytywanie zwartości tych plików w kawałkach. Wymyśliłem coś takiego:

public static bool AreFilesTheSame_Chunk(string file1, string file2)
{
    var bytesToRead = 4096;

    var buffer1 = new byte[bytesToRead];
    var buffer2 = new byte[bytesToRead];

    using (var fs1 = File.OpenRead(file1))
    using (var fs2 = File.OpenRead(file2))
    {
        int f1readBytes;
        int f2readBytes;

        do
        {
            f1readBytes = fs1.Read(buffer1);
            f2readBytes = fs2.Read(buffer2);
        }
        while (buffer1.SequenceEqual(buffer2) && (f1readBytes != 0));

        return f1readBytes == f2readBytes && f1readBytes == 0;
    }
}

No i wyniki są świetne!

|                Method |     Mean |   Error |  StdDev | Allocated |
|---------------------- |---------:|--------:|--------:|----------:|
| AreFilesTheSame_Chunk | 635.3 ms | 4.81 ms | 4.50 ms |     10 KB |

Problem tylko w tym, ze to nie zawsze zadziała, ponieważ nie mam gwarancji, że Read zwróci 4096 bajtów. Zwraca on tyle ile w danym momencie może zwrócić; w dokumentacji jest napisane:

The total number of bytes read into the buffer. This might be less than the number of bytes requested if that number of bytes are not currently available, or zero if the end of the stream is reached.

Przykładowo, w przypadku, gdy poniższy program odpalimy w .net core 3.1, metoda porównująca zwróci true, natomiast, gdy odpalimy to w .net 6.0, metoda porównująca zwróci false:

internal class Program
{
    const string input = @"c:\data\in.txt";
    const string output = @"c:\data\out.gz";

    static void Main(string[] args)
    {
        var data = GenerateContent();

        Directory.CreateDirectory(@"c:\data");
        File.WriteAllText(input, data);

        Compress(input, output);

        var areFilesTheSame = AreFilesTheSame_Chunk(input, output);
		Console.WriteLine(areFilesTheSame);
    }

    static string GenerateContent()
    {
        var rnd = new Random();
        var sb = new StringBuilder();

        for (int i = 0; i < 100000000; i++)
        {
            sb.Append(rnd.Next(0, 9));
        }

        return sb.ToString();
    }

    static void Compress(string input, string output)
    {
        using (var originalFileStream = File.OpenRead(input))
        using (var compressedFileStream = File.OpenWrite(output))
        using (var compressor = new GZipStream(compressedFileStream, CompressionMode.Compress))
            originalFileStream.CopyTo(compressor);
    }
	
    static bool AreFilesTheSame_Chunk(string file1, string file2)
    {
        var bytesToRead = 4096;

        var buffer1 = new byte[bytesToRead];
        var buffer2 = new byte[bytesToRead];

        using (var gs = new GZipStream(File.OpenRead(file2), CompressionMode.Decompress))
        using (var fs = File.OpenRead(file1))
        {
            int f1readBytes;
            int f2readBytes;

            do
            {
                f1readBytes = fs.Read(buffer1);
                f2readBytes = gs.Read(buffer2);
            }
            while (buffer1.SequenceEqual(buffer2) && (f1readBytes != 0));

            return f1readBytes == f2readBytes && f1readBytes == 0;
        }
    }
}

Wygląda na to, że w zależności od tego, czy mamy styczność z FileStream, czy z GZipStream, a także, z jakiej wersji frameworka korzystamy, szanse na wystąpienie błędu są większe, albo mniejsze. Jednym z rozwiązań byłoby wczytywanie bajt po bajdzie (tak jak opisane jest to tutaj), ale ReadByte() na GZipStream trwa nieakceptowalnie długo.

Rozwiązałem to wprowadzając rozszerzenie, które polega na wymuszeniu doczytywania brakujących bajtów:

public static class Extensions
{
    public static int ForceRead(this Stream stream, byte[] buffer)
    {
        var totalReadBytes = 0;

        do
        {
            var readBytes = stream.Read(buffer, totalReadBytes, buffer.Length - totalReadBytes);

            if (readBytes == 0)
                return totalReadBytes;

            totalReadBytes += readBytes;
        }
        while (totalReadBytes < buffer.Length);

        return totalReadBytes;
    }
}

Zastanawiam się, czy istnieje jakieś lepsze podejście; ma ktoś jakiś pomysł? Mam wrażenie, że porównanie dużych plików nie powinno być AŻ TAK skomplikowane (i błędogenne). Nie chcę, by po aktualizacji frameworka nagle przestało to działać, bo coś tam się znowu zmieniło..

0

Może zacznij od sprawdzenia długości plików, nie ma co porównywać pliki o różnym rozmiarze.
Po czym nie przejmuj się końcówkami, jeżeli doszło do odczytu końcówek to znaczy że po poprzednim odczycie bufory byli identyczne.

2

Zainteresuj się sumami kontrolnymi: https://pl.wikipedia.org/wiki/Suma_kontrolna

0

IMO podejście z ForceRead wygląda prawidłowo - nie dostrzegam, co tam może być aż tak złożone :-)

Ewentualnie możesz się pobawić w coś bardziej smart w stylu odpal x=fs.read() dla pliku A, odpal y=fs.read() dla pliku B, porównaj bajty od 0 do min(x,y) itd., ale imo nie jest to warte świeczki.

0
Patryk27 napisał(a):

Ewentualnie możesz się pobawić w coś bardziej smart w stylu odpal x=fs.read() dla pliku A, odpal y=fs.read() dla pliku B, porównaj bajty od 0 do min(x,y) itd., ale imo nie jest to warte świeczki.

Nie, jak x!=y to już pliki są rózne.

0

Nie, jak x != y to z dowolnego powodu system stwierdził, że nie jest w stanie zapełnić całego bufora za jednym razem (bo na przykład jeden z plików jest wczytywany zza NFSa, księżyc jest w pełni, albo pojawił się dowolny inny z setek powodów, dla których dokumentacja opisuje wprost, że stream zakończył się tylko w momencie, gdy funkcja zwraca dokładnie zero; kombinowanie dookoła w stylu "a bo w praktyce 90% przypadków będzie tak i tak" to zwyczajnie zła inżynieria).

1
dasek napisał(a):

Zastanawiam się, czy istnieje jakieś lepsze podejście; ma ktoś jakiś pomysł? Mam wrażenie, że porównanie dużych plików nie powinno być AŻ TAK skomplikowane (i błędogenne). Nie chcę, by po aktualizacji frameworka nagle przestało to działać, bo coś tam się znowu zmieniło..

Dziwiłbym się gdyby >50 linii metod statycznych NIE BYŁO skomplikowane i błędogenne.

Mi to się kojarzy z ludźmi z Delphi, wejście w szczegół na max, (u niektórych) do granic geniuszu - i totalna nieporadność żeby kod jakoś zaplanować, zorganizować (piję do obiektówki)
Klasa XxxxxComparerer, jedna publiczna instancyjna metoda IsJednakowe ;) a flaki sobie rozwijasz i zmieniasz. Buforujesz, chunkujesz, implementujesz interfejsy (dziedziczysz), NAZYWASZ abstrakcje, itd TESTUJESZ
BTW dla mnie klasy extenszynowe to ostateczność, np do typów obcych.

Zauważ, o implementacji nie mówię totalnie, tylko o organizacji kodu np obiektowej.

0

Ale po co robisz dekompresję danych? Chcesz porównywać pliki, to rób to binarnie. Chcesz porównywać skompresowane dane? No to bardziej ok, ale licz się ze stratą wydajności.

0

Jeśli chodzi o CRC32, wystarczy napisać byle co, dodać na końcu wyliczone 4 bajty i mamy kolizje, nie jest to kryptograficznie bezpieczne i na kartce papieru da się wyliczyć kolizje, crc32 wykonuje bajt po bajcie kodowanie, co wystarczy zrobić lookup table dla wszystkich 256, całego bajta i później wyliczając wyszukując odpowiednie bajty sumy kontrolnej i podkładać pod równanie.

Ale nadaje się żeby szybko wychwycić czy nie było jakiegoś błędu transmisji.

1 użytkowników online, w tym zalogowanych: 0, gości: 1