使用HttpContext OutputStream写入ZipArchive

43

我一直在尝试让.NET 4.5中包含的“新”ZipArchive(System.IO.Compression.ZipArchive)在ASP.NET网站上运行。但似乎它不喜欢写入HttpContext.Response.OutputStream的流。

我的下面示例代码将抛出

System.NotSupportedException: 指定的方法不受支持

只要尝试在流上进行写操作,就会出现上述异常。

该流的CanWrite属性返回true。

如果我使用指向本地目录的文件流替换OutputStream,则可以正常工作。这是为什么呢?

ZipArchive archive = new ZipArchive(HttpContext.Response.OutputStream, ZipArchiveMode.Create, false);

ZipArchiveEntry entry = archive.CreateEntry("filename");

using (StreamWriter writer = new StreamWriter(entry.Open()))
{
    writer.WriteLine("Information about this package.");
    writer.WriteLine("========================");
}

堆栈跟踪:

[NotSupportedException: Specified method is not supported.]
System.Web.HttpResponseStream.get_Position() +29
System.IO.Compression.ZipArchiveEntry.WriteLocalFileHeader(Boolean isEmptyFile) +389
System.IO.Compression.DirectToArchiveWriterStream.Write(Byte[] buffer, Int32 offset, Int32 count) +94
System.IO.Compression.WrappedStream.Write(Byte[] buffer, Int32 offset, Int32 count) +41

我目前正在本地运行它,所以是开发环境。我从IHttpHandler中获取HttpContext。 - Daniel Sørensen
好的,MVC还是Web Forms? - Alok
我不确定你所说的线性编码是什么意思。我已经尝试设置不同的ContentTypes,但都没有成功。虽然我不确定它是否与outputStream有关。 - Daniel Sørensen
你能从异常中获取堆栈跟踪吗?NotSupportedException 实际来自哪个方法?是哪个对象? - Jonathan Myers
你不能直接访问OutputStream。如果你在OutputStream上按F12,你会发现OutputStream是只读的(Get)。 - Carlos Ferreira
显示剩余6条评论
6个回答

60

注: 这个问题已在 .Net Core 2.0 中得到解决。我不确定 .Net Framework 是否已经修复了该问题。


Calbertoferreira 的回答包含了一些有用的信息,但结论大多数是错误的。要创建一个归档文件,你不需要寻找 seek,但你需要能够读取 Position

根据 文档,仅支持可寻址流对于读取 Position,但 ZipArchive 似乎即使从不可寻址流中也需要这个功能,这是一个 bug

所以,为了支持直接将 ZIP 文件写入 OutputStream,你需要在一个自定义的 Stream 中包装它并支持获取 Position。类似这样:

class PositionWrapperStream : Stream
{
    private readonly Stream wrapped;

    private long pos = 0;

    public PositionWrapperStream(Stream wrapped)
    {
        this.wrapped = wrapped;
    }

    public override bool CanSeek { get { return false; } }

    public override bool CanWrite { get { return true; } }

    public override long Position
    {
        get { return pos; }
        set { throw new NotSupportedException(); }
    }

    public override void Write(byte[] buffer, int offset, int count)
    {
        pos += count;
        wrapped.Write(buffer, offset, count);
    }

    public override void Flush()
    {
        wrapped.Flush();
    }

    protected override void Dispose(bool disposing)
    {
        wrapped.Dispose();
        base.Dispose(disposing);
    }

    // all the other required methods can throw NotSupportedException
}

使用此代码,以下代码将向 OutputStream 写入 ZIP 归档:

using (var outputStream = new PositionWrapperStream(Response.OutputStream))
using (var archive = new ZipArchive(outputStream, ZipArchiveMode.Create, false))
{
    var entry = archive.CreateEntry("filename");

    using (var writer = new StreamWriter(entry.Open()))
    {
        writer.WriteLine("Information about this package.");
        writer.WriteLine("========================");
    }
}

1
很好,我确实想知道是否只是获取位置的问题,并且假设还会有其他问题。我没有尝试过,你做了,所以感谢你找出来!我同意,ZipArchive代码可以轻松跟踪它已经写入多少字节,而不需要像这样的包装器。 - AndyD
确实不错,当时找不到解决方案,所以我使用了第三方的压缩库。但我会在有时间的时候回来尝试使用你的想法。如果有效的话,我会回来标记它已回答! :) - Daniel Sørensen
在这个问题上花费了超过一天的时间后,你通过使用我实现的自定义非可寻址流帮助我解决了ZipArchive的一个问题。谢谢! - Brandon Tull
@svick 看起来不错,唯一的问题是最好将“pos”变量类型更改为long,因为Position属性具有long类型。 - Andrey Burykin

7
如果您将您的代码适应与MSDN网页中呈现的版本进行比较,您会发现未使用ZipArchiveMode.Create,而是使用了ZipArchiveMode.Update。
尽管如此,主要问题在于OutputStream不支持ZipArchive中Update模式所需的Read和Seek操作:

当您将模式设置为Update时,底层文件或流必须支持读取、写入和寻址。整个存档的内容都保存在内存中,直到存档被处理之前,没有数据被写入底层文件或流。

来源:MSDN

由于Create模式仅需要写入,因此您不会收到任何异常:

当您将模式设置为Create时,底层文件或流必须支持写入,但不必支持寻址。存档中的每个条目只能打开一次进行写入。如果您创建单个条目,则数据将在可用时写入底层流或文件。如果创建多个条目(例如通过调用CreateFromDirectory方法),则数据将在所有条目创建后写入底层流或文件。

来源:MSDN

我认为您无法直接在OutputStream中创建zip文件,因为它是网络流且不支持寻址:

流可以支持寻址。寻址是指查询和修改流中的当前位置。寻址功能取决于流所具有的后备存储类型。例如,网络流没有统一的当前位置概念,因此通常不支持寻址。

另一个选择可能是先写入到内存流,然后使用OutputStream.Write方法发送zip文件。
MemoryStream ZipInMemory = new MemoryStream();

    using (ZipArchive UpdateArchive = new ZipArchive(ZipInMemory, ZipArchiveMode.Update))
    {
        ZipArchiveEntry Zipentry = UpdateArchive.CreateEntry("filename.txt");

        foreach (ZipArchiveEntry entry in UpdateArchive.Entries)
        {
            using (StreamWriter writer = new StreamWriter(entry.Open()))
            {
                writer.WriteLine("Information about this package.");
                writer.WriteLine("========================");
            }
        }
    }
    byte[] buffer = ZipInMemory.GetBuffer();
    Response.AppendHeader("content-disposition", "attachment; filename=Zip_" + DateTime.Now.ToString() + ".zip");
    Response.AppendHeader("content-length", buffer.Length.ToString());
    Response.ContentType = "application/x-compressed";
    Response.OutputStream.Write(buffer, 0, buffer.Length);

编辑:根据评论和进一步阅读,您可能正在创建大型Zip文件,因此内存流可能会导致问题。

在这种情况下,我建议您在Web服务器上创建zip文件,然后使用Response.WriteFile输出文件。


1
换句话说,直接将它写入outputStream是不可能的吗?就像我之前说的,memoryStream不是一个选项,因为文件大小会变化并且可能会变得非常大。 - Daniel Sørensen
我正在尝试使用response.filter来寻找解决方法,但根据MSDN的说法,你不能这样做:流可以支持查找。查找是指查询和修改流中的当前位置。查找能力取决于流所具有的后备存储类型。例如,网络流没有当前位置的统一概念,因此通常不支持查找。 - Carlos Ferreira
我有点困惑,你的引号不是意味着它应该与ZipArchiveMode.Create一起工作吗? - svick
你是对的。问题发生在更新zip归档文件时,要更新归档文件,流必须支持寻址。 - Carlos Ferreira
3
ZipArchiveMode.Create 也会出现这个问题。 - Doug Domeny

6

对于2014年2月2日svick的回答,我做了一些改进。我发现需要实现Stream抽象类的更多方法和属性,并将pos成员声明为long类型。之后它就像魔法一样工作了。我没有对这个类进行过广泛的测试,但它可以用于返回HttpResponse中的ZipArchive。我认为我已经正确实现了Seek和Read方法,但可能需要进行一些微调。

class PositionWrapperStream : Stream
{
    private readonly Stream wrapped;

    private long pos = 0;

    public PositionWrapperStream(Stream wrapped)
    {
        this.wrapped = wrapped;
    }

    public override bool CanSeek
    {
        get { return false; }
    }

    public override bool CanWrite
    {
        get { return true; }
    }

    public override long Position
    {
        get { return pos; }
        set { throw new NotSupportedException(); }
    }

    public override bool CanRead
    {
        get { return wrapped.CanRead; }
    }

    public override long Length
    {
        get { return wrapped.Length; }
    }

    public override void Write(byte[] buffer, int offset, int count)
    {
        pos += count;
        wrapped.Write(buffer, offset, count);
    }

    public override void Flush()
    {
        wrapped.Flush();
    }

    protected override void Dispose(bool disposing)
    {
        wrapped.Dispose();
        base.Dispose(disposing);
    }

    public override long Seek(long offset, SeekOrigin origin)
    {
        switch (origin)
        {
            case SeekOrigin.Begin:
                pos = 0;
                break;
            case SeekOrigin.End:
                pos = Length - 1;
                break;
        }
        pos += offset;
        return wrapped.Seek(offset, origin);
    }

    public override void SetLength(long value)
    {
        wrapped.SetLength(value);
    }

    public override int Read(byte[] buffer, int offset, int count)
    {
        pos += offset;
        int result = wrapped.Read(buffer, offset, count);
        pos += count;
        return result;
    }
}

看起来很奇怪 - CanSeek 返回false,但同时实现了 Seek(...) 方法。此外,我想在 Response.OutputStream 中没有必要进行读取。因此,我更倾向于将 CanRead 属性返回 false 并且不实现 Read(...) 方法。 - Andrey Burykin
1
一旦我在我的实现中让它工作了,我就停下来了。但你可能是对的。 - flayman

0

这个问题在谷歌搜索中仍然会出现,所以我会为.NET Core 5/6/7添加一个答案。

看起来在.NET Core中,您可以简单地将Reponse.Body提供给ZipArchive以保存结果。您不需要中间缓冲区或MemoryStream,这将使您的内存使用量翻倍。

我测试了一下,在IIS服务器上的.NET 6下运行良好,但是在Windows和Linux上的Kestrel上失败,因为有这个错误:https://github.com/dotnet/runtime/issues/1560

解决方法是使用Response.BodyWriter.AsStream()而不是直接写入流(直到修复该错误):

//in your controller:
public async Task DownloadZip()
{
    Response.ContentType = "application/zip";
    Response.Headers.Append("Content-Disposition", "attachment;filename=archive.zip");

    using (var z = new ZipArchive(
        Response.BodyWriter.AsStream(), //<-- this
        ZipArchiveMode.Create,
        true))
    {
        //do you thing to add files to archive
    }
}

哥们,太感谢你这个更新了!太好用了! - vklu4itesvet

0

假设这不是一个MVC应用程序,你可以轻松地使用FileStreamResult类。

我目前正在使用ZipArchiveMemoryStream创建的,所以我知道它是有效的。

考虑到这一点,请看一下FileStreamResult.WriteFile()方法:

protected override void WriteFile(HttpResponseBase response)
{
    // grab chunks of data and write to the output stream
    Stream outputStream = response.OutputStream;
    using (FileStream)
    {
        byte[] buffer = newbyte[_bufferSize];
        while (true)
        {
            int bytesRead = FileStream.Read(buffer, 0, _bufferSize);
            if (bytesRead == 0)
            {
                // no more data
                break;
            }
            outputStream.Write(buffer, 0, bytesRead);
        }
    }
}

(在 CodePlex 上的整个 FileStreamResult)

这是我生成并返回 ZipArchive 的方法。
你应该没有问题将 FSR 替换为上面的 WriteFile 方法的实质部分,其中 FileStream 变成了下面代码中的 resultStream

var resultStream = new MemoryStream();

using (var zipArchive = new ZipArchive(resultStream, ZipArchiveMode.Create, true))
{
    foreach (var doc in req)
    {
        var fileName = string.Format("Install.Rollback.{0}.v{1}.docx", doc.AppName, doc.Version);
        var xmlData = doc.GetXDocument();
        var fileStream = WriteWord.BuildFile(templatePath, xmlData);

        var docZipEntry = zipArchive.CreateEntry(fileName, CompressionLevel.Optimal);
        using (var entryStream = docZipEntry.Open())
        {
            fileStream.CopyTo(entryStream);
        }
    }
}
resultStream.Position = 0;

// add the Response Header for downloading the file
var cd = new ContentDisposition
    {
        FileName = string.Format(
            "{0}.{1}.{2}.{3}.Install.Rollback.Documents.zip",
            DateTime.Now.Year, DateTime.Now.Month, DateTime.Now.Day, (long)DateTime.Now.TimeOfDay.TotalSeconds),
        // always prompt the user for downloading, set to true if you want 
        // the browser to try to show the file inline
        Inline = false,
    };
Response.AppendHeader("Content-Disposition", cd.ToString());

// stuff the zip package into a FileStreamResult
var fsr = new FileStreamResult(resultStream, MediaTypeNames.Application.Zip);    
return fsr;

最后,如果您将要编写大量的流(或在任何给定时间编写更多的流),那么您可能需要考虑使用匿名管道将数据写入输出流,在将其写入zip文件中的基础流之后立即进行。因为您将在服务器上将所有文件内容保存在内存中。类似问题的答案结尾有一个很好的解释如何做到这一点。


0

这是 svick 的答案的简化版本,用于将服务器端文件压缩并通过 OutputStream 发送:

using (var outputStream = new PositionWrapperStream(Response.OutputStream))
using (var archive = new ZipArchive(outputStream, ZipArchiveMode.Create, false))
{
    var entry = archive.CreateEntryFromFile(fullPathOfFileOnDisk, fileNameAppearingInZipArchive);
}

(如果这看起来很明显,对我来说并不是!)

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