将一个非常大的二进制文件逐步转换为Base64字符串

12

我需要帮助将一个非常大的二进制文件(ZIP 文件)转换为 Base64String 并再次转换回来。这些文件太大了,无法一次性加载到内存中(会抛出 OutOfMemoryExceptions 异常),否则这将是一个简单的任务。我不想逐个处理 ZIP 文件的内容,而是要处理整个 ZIP 文件。

问题:

我可以将整个 ZIP 文件(测试大小目前变化从 1 MB 到 800 MB 不等)转换为 Base64String,但当我将其转换回来时,它就被损坏了。新的 ZIP 文件大小正确,Windows 和 WinRAR / 7-Zip 等软件也能识别它为 ZIP 文件,并且我甚至可以查看 ZIP 文件中的内容以及其正确的大小/属性,但是当我尝试从 ZIP 文件中提取时,我会收到:“ Error: 0x80004005 ”的错误代码。

我不确定损坏发生在哪里或为什么会发生。我已经进行了一些调查,并注意到以下事项:

如果您有一个大文本文件,可以逐步将其转换为 Base64String,而没有任何问题。如果对整个文件调用 Convert.ToBase64String 得到的结果是:"abcdefghijklmnopqrstuvwx",那么将其分成两部分并分别调用会得到:"abcdefghijkl""mnopqrstuvwx"

不幸的是,如果文件是二进制的,则结果会有所不同。虽然整个文件可能会得到:"abcdefghijklmnopqrstuvwx",但尝试在两个部分中处理它将产生类似于:"oiweh87yakgb""kyckshfguywp" 的东西。

有没有一种方式可以逐步对二进制文件进行 base64 编码而避免此类损坏?

我的代码:

        private void ConvertLargeFile()
        {
           FileStream inputStream  = new FileStream("C:\\Users\\test\\Desktop\\my.zip", FileMode.Open, FileAccess.Read);
           byte[] buffer = new byte[MultipleOfThree];
           int bytesRead = inputStream.Read(buffer, 0, buffer.Length);
           while(bytesRead > 0)
           {
              byte[] secondaryBuffer = new byte[buffer.Length];
              int secondaryBufferBytesRead = bytesRead;
              Array.Copy(buffer, secondaryBuffer, buffer.Length);
              bool isFinalChunk = false;
              Array.Clear(buffer, 0, buffer.Length);
              bytesRead = inputStream.Read(buffer, 0, buffer.Length);
              if(bytesRead == 0)
              {
                 isFinalChunk = true;
                 buffer = new byte[secondaryBufferBytesRead];
                 Array.Copy(secondaryBuffer, buffer, buffer.length);
              }

              String base64String = Convert.ToBase64String(isFinalChunk ? buffer : secondaryBuffer);
              File.AppendAllText("C:\\Users\\test\\Desktop\\Base64Zip", base64String); 
            }
            inputStream.Dispose();
        }

解码部分与之前相同。我使用上面的base64String变量的大小(它取决于我测试时原始缓冲区的大小)作为解码的缓冲区大小。然后,我调用Convert.FromBase64String()而不是Convert.ToBase64String(),并将结果写入不同的文件名/路径。

编辑:

为了减少代码(我将其重构为一个新项目,与其他处理分开以消除与问题无关的代码),我匆忙地引入了一个错误。对于除最后一次迭代(通过isFinalChunk标识)以外的所有迭代,应在secondaryBuffer上执行base64转换,而在最后一次迭代时应使用buffer。我已经更正了上面的代码。

编辑#2:

感谢大家的评论/反馈。在更正了错误之后(请参见上面的编辑),我重新测试了我的代码,现在它确实可以工作了。我打算测试和实施@rene的解决方案,因为它似乎是最好的,但我认为我也应该让每个人知道我的发现。


你在处理辅助缓冲区和 isFinalChunk 吗?看起来你正在对已清除的缓冲区调用 ToBase64String,除非它是最后一个块。 - Blorgbeard
3
问题可能在将文件从base64转回二进制文件的代码中。你是以四个字符为一组还是以四的倍数为一组来读取字符? - Vova
@Blorgbeard - 我正在使用secondaryBuffer来保存从文件中读取的第一个/当前内容。然后我再次读取,寻找返回值为“0”以指示我正在处理最终块。最终块被调整大小,以便它只足够大以容纳正在编码的数据。例如-如果缓冲区设置为600,000,但最后一次读取长度为1000字节,则无需传递包含600,000个元素的byte[]。如果我不在最后一块上,则处理secondaryBuffer,其中包含所需的数据。 - CaptainCobol
@Vova - 我使用编码期间创建的块的大小。如果块大小为262,144,则会生成长度为349,528个字符的Base64字符串,因此在解码时我将使用349,528作为缓冲区大小。 - CaptainCobol
1
@CaptainCobol,传递一个大数组没有额外的开销,你只是传递了一个引用。你可以按照我的答案传递索引和偏移量,以避免重新处理旧数据,然后可以消除二级缓冲区。 - Blorgbeard
灯泡!在块中转换为Base64对块的大小非常敏感。如果块大小不是6位的倍数,则块的最后一个字符可能不正确,因为它没有编码所有数据;它将不包括下一个块的最后几位。此外,奇数块大小将产生更多的字符,因为每个块的转换将添加一个字符。简而言之:奇数块大小将导致数据损坏。除非块大小是6位的倍数,否则必须从连续的比特流中对数据进行编码。 - Suncat2000
3个回答

16

基于该博客中展示的代码,来自Wiktor Zychla的以下代码可以工作。正如Convert.ToBase64String的备注部分所指出的那样,这个相同的解决方案也被指出了,由Ivan Stoev

// using  System.Security.Cryptography

private void ConvertLargeFile()
{
    //encode 
    var filein= @"C:\Users\test\Desktop\my.zip";
    var fileout = @"C:\Users\test\Desktop\Base64Zip";
    using (FileStream fs = File.Open(fileout, FileMode.Create))
        using (var cs=new CryptoStream(fs, new ToBase64Transform(),
                                                     CryptoStreamMode.Write))

           using(var fi =File.Open(filein, FileMode.Open))
           {
               fi.CopyTo(cs);
           }
     // the zip file is now stored in base64zip    
     // and decode
     using (FileStream f64 = File.Open(fileout, FileMode.Open) )
         using (var cs=new CryptoStream(f64, new FromBase64Transform(),
                                                     CryptoStreamMode.Read ) ) 
           using(var fo =File.Open(filein +".orig", FileMode.Create))
           {
               cs.CopyTo(fo);
           }     
     // the original file is in my.zip.orig
     // use the commandlinetool 
     //  fc my.zip my.zip.orig 
     // to verify that the start file and the encoded and decoded file 
     // are the same
}

代码使用在 System.Security.Cryptography 命名空间中常见的类,并使用 CryptoStreamFromBase64Transform 及其对应的 ToBase64Transform


3
确实,这就是正确答案!MSDN文档中关于Convert.ToBase64String方法的(https://msdn.microsoft.com/zh-cn/library/s70ad5f6(v=vs.100).aspx)**备注**部分中有一个**重要提示**,建议使用该方法。 - Ivan Stoev

10
您可以通过向Convert.ToBase64String传递偏移量和长度来避免使用辅助缓冲区,如下所示:
private void ConvertLargeFile()
{
    using (var inputStream  = new FileStream("C:\\Users\\test\\Desktop\\my.zip", FileMode.Open, FileAccess.Read)) 
    {
        byte[] buffer = new byte[MultipleOfThree];
        int bytesRead = inputStream.Read(buffer, 0, buffer.Length);
        while(bytesRead > 0)
        {
            String base64String = Convert.ToBase64String(buffer, 0, bytesRead);
            File.AppendAllText("C:\\Users\\test\\Desktop\\Base64Zip", base64String); 
            bytesRead = inputStream.Read(buffer, 0, buffer.Length);           
        }
    }
}

上面的代码应该可以工作,但是我认为Rene的回答实际上是更好的解决方案。


stream.Read 在读取前是否会清空输入缓冲区?如果你请求读取 3 字节,但是只读取了 2 字节,那么最后一个字节是否会保留旧值? - Dave Zych
3
@DaveZych说:“它并没有清除它,但这并不重要,因为你将offsetlength传递给了Convert.ToBase64String方法。” - Ivan Stoev
@DaveZych,您是正确的,这就是为什么我在每次新读取之前清除缓冲区的原因。 - CaptainCobol
1
@CaptainCobol Ivan 也是正确的,这段代码通过告诉 Convert.ToBase64String 只处理本次迭代读取的字节来避免传递旧数据。 - Blorgbeard
1
@Blorgbeard - 是的,我同意。这是一个更干净的版本。我最初忽略了Convert参数的变化。 - CaptainCobol

1
请使用这段代码:
public void ConvertLargeFile(string source , string destination)
{
    using (FileStream inputStream = new FileStream(source, FileMode.Open, FileAccess.Read))
    { 

        int buffer_size = 30000; //or any multiple of 3

        byte[] buffer = new byte[buffer_size];
        int bytesRead = inputStream.Read(buffer, 0, buffer.Length);
        while (bytesRead > 0)
        {
            byte[] buffer2 = buffer;

            if(bytesRead < buffer_size)
            {
                buffer2 = new byte[bytesRead];
                Buffer.BlockCopy(buffer, 0, buffer2, 0, bytesRead);
            }

            string base64String = System.Convert.ToBase64String(buffer2);
            File.AppendAllText(destination, base64String);

            bytesRead = inputStream.Read(buffer, 0, buffer.Length);

        }
    }
}

在这种情况下,Buffer.BlockCopy不安全。我最初使用它,但我发现我的复制数组一半都是空的。请参阅:https://dev59.com/v3M_5IYBdhLWcg3waSX9#1390023 - CaptainCobol
为什么不安全?我肯定是安全的。无论如何,我注意到Blorgbeard的答案实际上更好,它做的和我一样,除了它不使用Buffer.BlockCopy,它使用ToBase64String方法的另一个重载。 - Yacoub Massad
如果你跟随我提供的链接,MusiGenesis会很好地解释它。我的数组一半填充了内容,另一半填充了null。Buffer.BlockCopy的参数是基于字节而不是索引的。 - CaptainCobol
在查看链接后,我猜测您提供的链接是讨论程序员尝试复制复杂对象类型(例如结构体)的一般情况。如果您的源和目标是byte[],那么使用Buffer.BlockCopy是完全安全的。在byte[]中,1个索引=1个字节。在结构体中,1个索引可能大于1个字节。 - Yacoub Massad
如果你拿我的代码,将调用 Array.Copy() 的部分替换为 Buffer.BlockCopy(),你应该就能明白我的意思了。 - CaptainCobol
但是你在问题中的代码已经有一个问题了,如果我在其中使用Buffer.BlockCopy,我将看不到任何区别。 - Yacoub Massad

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接