C# 批量下载文件并打包后返回

C# 批量下载文件并打包后返回

infographics-zip

虽然说起来有点丢脸, 但是这个功能确实花了自己一个整个下午才搞定。 这篇文章,除了记录实现的方法, 主要还是复盘一下自己踩的坑以及一点经验教训吧。

核心代码:

public async Task<byte[]> ZipFiles(IEnumerable<string> paths)
{
	byte[] result;
	var downloadTasks = paths.Select(async url =>
	{
		try
		{
			return new
			{
				Name = Path.GetFileName(url),
				Data = await new HttpClient().GetStreamAsync(url)
				//Data = await new HttpClient().GetByteArrayAsync(url) // 方案2, 读字节数组
			};
		}
		catch
		{
			throw new Exception("下载文件失败");
		}
	});

	using (var zipStream = new MemoryStream())
	{
		using (var zip = new ZipArchive(zipStream, ZipArchiveMode.Create))
		{
			var filesData = await Task.WhenAll(downloadTasks);
			foreach (var file in filesData)
			{
				var entry = zip.CreateEntry(file.Name, CompressionLevel.Optimal);
				using (var entryStream = entry.Open())
				{
					await file.Data.CopyToAsync(entryStream);
					//await entryStream.WriteAsync(file.Data, 0, file.Data.Length);
				}
			}
		}
		result = zipStream.GetBuffer();
	}
	return result;
}

downloadTasks 是为了防止文件太大导致下载慢, 将下载的过程并行执行,然后逐个将文件打包到压缩包中。 以上代码没有依赖第三方包,而是使用了 System.IO.Compression 包下面的 ZipArchive 类,并且将下载的文件直接存在内存中使用 MemoryStream 而不适用文件流。

上面这个方法返回了压缩后的 zip 包的 buffer 数组。 在调用方可以直接保存问文件, 例如:

var bytes = await new FileHelper().ZipFiles(filesUrl);
await File.WriteAllBytesAsync("./files.zip", bytes);

或者返回API接口:

public async Task<IActionResult> DownloadFiles()
{
	var filesUrl = new[]{ "https://github.com/favicon.ico"};
	var bytes = await new FileHelper().ZipFiles(filesUrl);
	return File(bytes, "application/zip");
}

踩坑复盘

事实上上面这个段代码写出来并没有花太多时间, 稍微了解了一下 ZipArchive 的用法之后, 不难便写出来了了个大概, 然而等调试的是时候发现有两个问题:

  • 解压出来的文件格式和大小看起来都正确,但是就是打不开,提示文件损坏,即便是文本文件。

  • 下载下来的压缩包总是提示有损坏,但是可以解压出来,文件也正常。

文件打不开

这个问题大概没有共性, 教训就是在开发的时候要先了解完整整个流程以及跟同事沟通清楚。

问题的表现是, 解压出来的文件,比如一张图片解压出来看起来也是正常的大小,文件格式也是正常的,可是无论怎么改就是显示文件损坏, 也不知道哪里损坏。 折腾了相当一段时间终于意识到自己的程序可能没有问题的时候, 尝试换网络上的图片来测试。 结果网络上的图片是正常的,内部系统保存起来的就损坏。

于是跟做文件上传的同事一起排查, 结果是: 在上传文件的时候对文件做了gzip压缩处理,而我下载文件的时候并没有解压,而是直接保存起来了, 所以导致大小看起来“正常”,却一直显示损坏。

压缩包损坏

本以为是的问题,查了一通之后发现是ZipArchive的原因, 且慢慢道来。

起初,本以为这个问题跟上面的是同一个问题。 结果等上面的问题解决,文件可以正常下载打开之后, 发现下载下来的压缩包依旧显示损坏 —— 但是可以正常解压和使用。这说明程序大抵上是没有问题的, 可能是少了一些格式化的字节之类。

出问题的程序是这样写的:

using (var zipStream = new MemoryStream())
using (var zip = new ZipArchive(zipStream, ZipArchiveMode.Create))
{
    // ...
    result = zipStream.GetBuffer();
}

自然为了图省事, 把两个 using 连在了一起写,而且从逻辑上说: 在压缩处理完成之后, 获取 zipStream 的数据。 看起来也没有什么问题。 最后通过各种 StackOverflow 和瞎尝试, 终于把问题改好了之后, 依旧觉得费解。

两个代码之间唯一的区别就是 GetBuffer 的时机: 1. 在 ZipArchive 释放之后获取, 正确。 2. 在 ZipArchive 释放之前获取, 错误。

然后测试一下:

string b, a;
using (var stream = new MemoryStream())
{
    using (var zip = new ZipArchive(stream, ZipArchiveMode.Create))
    {
        var entry = zip.CreateEntry("a", CompressionLevel.Optimal);
        //using (var entryStream = entry.Open())
        //{
        //	entryStream.Write(new[] { (byte)1 });
        //}
        a = JsonSerializer.Serialize(stream.GetBuffer());
    }
    b = JsonSerializer.Serialize(stream.GetBuffer());
}

结果:

a: ""

b: "UEsDBBQAAAAAAIK4JlAAAAAAAAAAAAAAAAABAAAAYVBLAQIUABQAAAAAAIK4JlAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAABhUEsFBgAAAAABAAEALwAAAB8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=="

破案了: 在没有释放zip 的时候, stream 是一个空的流,getbuffer 的结果是空数组, 而释放之后, 会往流里面写入zip 本身的数据: 别忘了, 压缩包本身也是有格式的。

过程有些曲折实际上, 一开始没想到可以做空压缩包,于是往压缩包里面写了一个字母, 然后各种比较字节数组,字符穿等等,因为写的东西比较少,两个数组都是256, 上面这些字母不一样也看不出个二三四五六来。

为了验证这个想法,我们继续往下走。

  1. 上面的代码中, 将里面的 zip 换成正常的 StreamWriter
string a, b;
using(var stream = new MemoryStream())
{
    using(var writer = new BinaryWriter(stream))
    {
        a = JsonSerializer.Serialize(stream.GetBuffer());
    }
    b = JsonSerializer.Serialize(stream.GetBuffer());
}
Assert.Equal(b, a);

结果: 测试通过。 a = b = "" (byte[0]) 大概就是这个原因了!!

  1. 看文档:官方文档上面对 Dispose() 方法的描述是这样的:
此方法完成写入存档并释放由该[ZipArchive](https://docs.microsoft.com/zh-cn/dotnet/api/system.io.compression.ziparchive?view=netframework-4.8)对象使用的所有资源。 除非使用[ZipArchive(Stream, ZipArchiveMode, Boolean)](https://docs.microsoft.com/zh-cn/dotnet/api/system.io.compression.ziparchive.-ctor?view=netframework-4.8#System_IO_Compression_ZipArchive__ctor_System_IO_Stream_System_IO_Compression_ZipArchiveMode_System_Boolean_)构造函数重载构造对象并将其`leaveOpen`参数设置为`true`, 否则所有基础流都将关闭, 并且不再可用于后续写入操作。 

This method finishes writing the archive and releases all resources used by the ZipArchive object. Unless you construct the object by using the ZipArchive(Stream, ZipArchiveMode, Boolean) constructor overload and set its leaveOpen parameter to true, all underlying streams are closed and no longer available for subsequent write operations.

大概猜测“ writing the archive” 就是上面所表现出来的过程, 但它也没有说具体做了什么。

  1. 查看源码: https://github.com/dotnet/runtime/blob/master/src/libraries/System.IO.Compression/src/System/IO/Compression/ZipArchive.cs

先看Dispose()方法, 因为 using 块结束之后,会默认调用这个方法来释放资源:

public void Dispose()
{
    Dispose(true);
    GC.SuppressFinalize(this);
}

好嘛,优秀的设计, 继续看:

 protected virtual void Dispose(bool disposing)
 {
     if (disposing && !_isDisposed)
     {
         try
         {
             switch (_mode)
             {
                 case ZipArchiveMode.Read:
                     break;
                 case ZipArchiveMode.Create:
                 case ZipArchiveMode.Update:
                 default:
                     Debug.Assert(_mode == ZipArchiveMode.Update || _mode == ZipArchiveMode.Create);
                     WriteFile();
                     break;
             }
         }
         finally
         {
             CloseStreams();
             _isDisposed = true;
         }
     }
 }

CreateUpdate 模式下, 会有一个 WriteFile 方法, 也就是文档中提到的 write archive, 继续看这个方法:

private void WriteFile()
{
    // ... 
    long startOfCentralDirectory = _archiveStream.Position;
    foreach (ZipArchiveEntry entry in _entries)
    {
        entry.WriteCentralDirectoryFileHeader();
    }
    long sizeOfCentralDirectory = _archiveStream.Position - startOfCentralDirectory;

    WriteArchiveEpilogue(startOfCentralDirectory, sizeOfCentralDirectory);
}

看到这里大概就🆗了, 显然可以看到,在Dispose之前, 会往_archiveStream_里面写入相关的信息,这些信息或是 Header 或者是 Epilogue , 都是一些于文件内容无关的数据, 如果继续往下看,发现这些头部和尾部信息会包括文件的CRC校验, 压缩包的“目录结束标识”**(EOCD)**信息等等,者才导致了压缩包打开会显示损坏, 但是里面的内容却可以正常解压显示的情况。

具体拿我们测试例子来说, 因为我们没有往压缩包里面写任何信息,所以上面WriteFile()方法里面只会执行最后一句,而这个所写的信息, 正是EOCD信息。

// writes eocd, and if needed, zip 64 eocd, zip64 eocd locator
// should only throw an exception in extremely exceptional cases because it is called from dispose
private void WriteArchiveEpilogue(long startOfCentralDirectory, long sizeOfCentralDirectory)
{
    //....
}

DONE!

本文源码: https://github.com/zhaokuohaha/SimpleTest/blob/master/SimpleTests/FileTest.cs

参考链接: