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..