在HttpClient中,是否可能在解压缩之前访问压缩数据?

75

我正在开发Google Cloud Storage .NET客户端库。有三个功能(在.NET、我的客户端库和存储服务之间)以令人不愉快的方式组合在一起:

  • 下载文件(在Google Cloud Storage术语中为对象)时,服务器会包含存储数据的哈希值。然后,我的客户端代码会根据已下载的数据验证该哈希值。

  • Google Cloud Storage的另一个功能是用户可以设置对象的Content-Encoding,并且当请求包含匹配的Accept-Encoding时,该功能包含在下载时的标头中。(暂时忽略请求不包含它时的行为...)

  • HttpClientHandler可以自动透明地解压缩gzip(或deflate)内容。

当这三者结合在一起时,我们就会遇到麻烦。以下是一个简短但完整的程序,演示了这一点,但没有使用我的客户端库(并访问了一个公共可访问的文件):

using System;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;

class Program
{
    static async Task Main()
    {
        string url = "https://www.googleapis.com/download/storage/v1/b/"
            + "storage-library-test-bucket/o/gzipped-text.txt?alt=media";
        var handler = new HttpClientHandler
        {
            AutomaticDecompression = DecompressionMethods.GZip
        };
        var client = new HttpClient(handler);

        var response = await client.GetAsync(url);
        byte[] content = await response.Content.ReadAsByteArrayAsync();
        string text = Encoding.UTF8.GetString(content);
        Console.WriteLine($"Content: {text}");

        var hashHeader = response.Headers.GetValues("X-Goog-Hash").FirstOrDefault();
        Console.WriteLine($"Hash header: {hashHeader}");

        using (var md5 = MD5.Create())
        {
            var md5Hash = md5.ComputeHash(content);
            var md5HashBase64 = Convert.ToBase64String(md5Hash);
            Console.WriteLine($"MD5 of content: {md5HashBase64}");
        }
    }
}

.NET Core 项目文件:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>netcoreapp2.0</TargetFramework>
    <LangVersion>7.1</LangVersion>
  </PropertyGroup>
</Project>

输出:

Content: hello world
Hash header: crc32c=T1s5RQ==,md5=xhF4M6pNFRDQnvaRRNVnkA==
MD5 of content: XrY7u+Ae7tCTyyK7j1rNww==

正如您所看到的,内容的MD5与X-Goog-Hash头部分的MD5不同。(在我的客户端库中,我使用crc32c哈希,但显示相同的行为。)

这不是HttpClientHandler中的错误 - 这是预期的,但验证哈希时很麻烦。基本上,我需要在解压缩之前和之后检查内容。我找不到任何方法来做到这一点。

为了澄清我的要求,我知道如何在HttpClient中防止解压缩,并在从流中读取时进行解压缩,但我需要能够在不更改使用HttpClient的代码的情况下执行此操作的能力。(有许多处理响应的代码,我只想在一个中心位置进行更改。)

我有一个计划,我已经完成了原型,至少目前为止它是有效的,但有点丑陋。它涉及创建一个三层处理程序:

  • HttpClientHandler,禁用自动解压缩。
  • 一个新的处理程序,它将内容流替换为一个新的Stream子类,该子类委托给原始内容流,但在读取数据时对其进行哈希。
  • 一个仅基于Microsoft DecompressionHandler代码的解压缩处理程序。

虽然这有效,但是有以下缺点:

  • 开源许可证:检查我需要做什么才能基于MIT许可的Microsoft代码在我的存储库中创建新文件
  • 实际上分叉了MS代码,这意味着我可能应该定期检查其中是否发现了任何错误
  • Microsoft代码使用程序集的内部成员,因此它不能像预期的那样轻松移植。

如果Microsoft公开DecompressionHandler,那将有很大帮助-但这可能比我需要的时间范围更长。

如果可能,我正在寻找替代方法 - 我错过了某些东西,使我可以在解压缩之前获取内容。我不想重新发明HttpClient - 例如,响应通常是分块的,我不想涉及那方面的事情。我正在寻找一个非常特定的拦截点。


在存储方面,对我来说,这个压缩部分听起来有点像“我真的有一个未压缩的文件,但如果我可以将其压缩并让浏览器的解压缩部分自动解压缩它,那就太好了”。如果是这样,将解压缩后的内容的哈希值存储/提供服务难道不是更合理吗?听起来这只是服务器空间和CPU优化,避免在服务器端进行压缩步骤。我在这里错过了什么吗?由于这个原因,很多客户端库不会有完全相同的问题吗? - Lasse V. Karlsen
另外一个问题(我提出来,这个问题更多是针对客户端处理器,而不是针对你的代码),为什么处理器实现忽略了将自动解压缩设置为“无”,它仍然会解压缩。 - Lasse V. Karlsen
1
@LasseVågsætherKarlsen:如果您仍然从GCS获取数据,那么这不是HttpClientHandler在执行此操作,而是GCS。如果您请求一个Content-Encoding为gzip的文件,但没有指定Accept-Encoding: gzip,则它会为您解压缩,使用无Content-Encoding标头提供解压缩后的内容。(并仍包括压缩文件的哈希值。我知道,这很有问题...我不想涉及所有可能的怪癖在这个问题中,但如果您认为我应该提到,请告诉我。) - Jon Skeet
4
简单来说,这个哈希似乎是被设计成不可验证的,这听起来对我来说有点毫无意义。 - Lasse V. Karlsen
1
@zaitsman:通常我更相信在网络上看到的内容,而不是源代码 :) 我大多数测试都在.NET Core上运行,但在Windows上 - 这绝对可以禁用压缩。 - Jon Skeet
显示剩余18条评论
3个回答

16

看了 @Michael 的做法,我得到了我缺少的提示。获取压缩内容后,您可以使用 CryptoStreamGZipStreamStreamReader 读取响应而无需将其加载到内存中超过所需的量。 CryptoStream 将在解压缩和读取压缩内容时对其进行哈希处理。将 StreamReader 替换为 FileStream,您可以使用最少的内存将数据写入文件 :)

using System;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;

class Program
{
    static async Task Main()
    {
        string url = "https://www.googleapis.com/download/storage/v1/b/"
            + "storage-library-test-bucket/o/gzipped-text.txt?alt=media";
        var handler = new HttpClientHandler
        {
            AutomaticDecompression = DecompressionMethods.None
        };
        var client = new HttpClient(handler);
        client.DefaultRequestHeaders.Add("Accept-Encoding", "gzip");

        var response = await client.GetAsync(url);
        var hashHeader = response.Headers.GetValues("X-Goog-Hash").FirstOrDefault();
        Console.WriteLine($"Hash header: {hashHeader}");
        string text = null;
        using (var md5 = MD5.Create())
        {
            using (var cryptoStream = new CryptoStream(await response.Content.ReadAsStreamAsync(), md5, CryptoStreamMode.Read))
            {
                using (var gzipStream = new GZipStream(cryptoStream, CompressionMode.Decompress))
                {
                    using (var streamReader = new StreamReader(gzipStream, Encoding.UTF8))
                    {
                        text = streamReader.ReadToEnd();
                    }
                }
                Console.WriteLine($"Content: {text}");
                var md5HashBase64 = Convert.ToBase64String(md5.Hash);
                Console.WriteLine($"MD5 of content: {md5HashBase64}");
            }
        }
    }
}

输出:

Hash header: crc32c=T1s5RQ==,md5=xhF4M6pNFRDQnvaRRNVnkA==
Content: hello world
MD5 of content: xhF4M6pNFRDQnvaRRNVnkA==

答案版本2

在阅读Jon的回答和更新后,我有了以下版本。基本上是相同的想法,但我将流媒体移到了一个特殊的HttpContent中进行注入。虽然不够优美,但想法还在那里。

using System;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;

class Program
{
    static async Task Main()
    {
        string url = "https://www.googleapis.com/download/storage/v1/b/"
            + "storage-library-test-bucket/o/gzipped-text.txt?alt=media";
        var handler = new HttpClientHandler
        {
            AutomaticDecompression = DecompressionMethods.None
        };
        var client = new HttpClient(new Intercepter(handler));
        client.DefaultRequestHeaders.Add("Accept-Encoding", "gzip");

        var response = await client.GetAsync(url);
        var hashHeader = response.Headers.GetValues("X-Goog-Hash").FirstOrDefault();
        Console.WriteLine($"Hash header: {hashHeader}");
        HttpContent content1 = response.Content;
        byte[] content = await content1.ReadAsByteArrayAsync();
        string text = Encoding.UTF8.GetString(content);
        Console.WriteLine($"Content: {text}");
        var md5Hash = ((HashingContent)content1).Hash;
        var md5HashBase64 = Convert.ToBase64String(md5Hash);
        Console.WriteLine($"MD5 of content: {md5HashBase64}");
    }

    public class Intercepter : DelegatingHandler
    {
        public Intercepter(HttpMessageHandler innerHandler) : base(innerHandler)
        {
        }

        protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
        {
            var response = await base.SendAsync(request, cancellationToken);
            response.Content = new HashingContent(await response.Content.ReadAsStreamAsync());
            return response;
        }
    }

    public sealed class HashingContent : HttpContent
    {
        private readonly StreamContent streamContent;
        private readonly MD5 mD5;
        private readonly CryptoStream cryptoStream;
        private readonly GZipStream gZipStream;

        public HashingContent(Stream content)
        {
            mD5 = MD5.Create();
            cryptoStream = new CryptoStream(content, mD5, CryptoStreamMode.Read);
            gZipStream = new GZipStream(cryptoStream, CompressionMode.Decompress);
            streamContent = new StreamContent(gZipStream);
        }

        protected override Task SerializeToStreamAsync(Stream stream, TransportContext context) => streamContent.CopyToAsync(stream, context);
        protected override bool TryComputeLength(out long length)
        {
            length = 0;
            return false;
        }

        protected override Task<Stream> CreateContentReadStreamAsync() => streamContent.ReadAsStreamAsync();

        protected override void Dispose(bool disposing)
        {
            try
            {
                if (disposing)
                {
                    streamContent.Dispose();
                    gZipStream.Dispose();
                    cryptoStream.Dispose();
                    mD5.Dispose();
                }
            }
            finally
            {
                base.Dispose(disposing);
            }
        }

        public byte[] Hash => mD5.Hash;
    }
}

如果我的代码只是读取数据,那就没问题了 - 但事实并非如此。(或者至少,在非常不同的地方这么做。)我真的需要保持API相同,使用HttpClient并在读取数据时拦截它 :( 我将在有机会时编辑问题,以使要求更清晰。 - Jon Skeet
@JonSkeet 你真是个棘手的客户!我想这次我搞定了 :) - shmuelie
好的,现在这实际上是我描述的解决方法,只是没有哈希和解压缩之间的分离 - 也没有 DecompressionHandler 所做的头部复制。我很高兴我们最终到达了大致相同的地方,即使它不像我希望的那样无侵入性。 - Jon Skeet
重要的区别是我不使用任何内部内容 :) - shmuelie
@shmulie:是的,但是通过重新实现一些位 - 就像我计划要做的那样。(包括头文件等。) - Jon Skeet

5
我通过以下方式正确获取了标题哈希:
  • 创建一个自定义处理程序,继承 HttpClientHandler
  • 覆盖 SendAsync
  • 使用 base.SendAsync 读取响应的字节
  • 使用 GZipStream 进行压缩
  • 将 Gzip Md5 哈希到 base64(使用您的代码)

问题在于,正如您所说,“在解压缩之前”这个条件在此处并没有得到真正的遵守。

想法是让这个 if 能够按照您的预期工作:https://github.com/dotnet/corefx/blob/master/src/System.Net.Http.WinHttpHandler/src/System/Net/Http/WinHttpResponseParser.cs#L80-L91

它匹配成功了。

class Program
{
    const string url = "https://www.googleapis.com/download/storage/v1/b/storage-library-test-bucket/o/gzipped-text.txt?alt=media";

    static async Task Main()
    {
        //await HashResponseContent(CreateHandler(DecompressionMethods.None));
        //await HashResponseContent(CreateHandler(DecompressionMethods.GZip));
        await HashResponseContent(new MyHandler());

        Console.ReadLine();
    }

    private static HttpClientHandler CreateHandler(DecompressionMethods decompressionMethods)
    {
        return new HttpClientHandler { AutomaticDecompression = decompressionMethods };
    }

    public static async Task HashResponseContent(HttpClientHandler handler)
    {
        //Console.WriteLine($"Using AutomaticDecompression : '{handler.AutomaticDecompression}'");
        //Console.WriteLine($"Using SupportsAutomaticDecompression : '{handler.SupportsAutomaticDecompression}'");
        //Console.WriteLine($"Using Properties : '{string.Join('\n', handler.Properties.Keys.ToArray())}'");

        var client = new HttpClient(handler);

        var response = await client.GetAsync(url);
        byte[] content = await response.Content.ReadAsByteArrayAsync();
        string text = Encoding.UTF8.GetString(content);
        Console.WriteLine($"Content: {text}");

        var hashHeader = response.Headers.GetValues("X-Goog-Hash").FirstOrDefault();
        Console.WriteLine($"Hash header: {hashHeader}");
        byteArrayToMd5(content);

        Console.WriteLine($"=====================================================================");
    }

    public static string byteArrayToMd5(byte[] content)
    {
        using (var md5 = MD5.Create())
        {
            var md5Hash = md5.ComputeHash(content);
            return Convert.ToBase64String(md5Hash);
        }
    }

    public static byte[] Compress(byte[] contentToGzip)
    {
        using (MemoryStream resultStream = new MemoryStream())
        {
            using (MemoryStream contentStreamToGzip = new MemoryStream(contentToGzip))
            {
                using (GZipStream compressionStream = new GZipStream(resultStream, CompressionMode.Compress))
                {
                    contentStreamToGzip.CopyTo(compressionStream);
                }
            }

            return resultStream.ToArray();
        }
    }
}

public class MyHandler : HttpClientHandler
{
    protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        var response = await base.SendAsync(request, cancellationToken);
        var responseContent = await response.Content.ReadAsByteArrayAsync().ConfigureAwait(false);

        Program.byteArrayToMd5(responseContent);

        var compressedResponse = Program.Compress(responseContent);
        var compressedResponseMd5 = Program.byteArrayToMd5(compressedResponse);

        Console.WriteLine($"recompressed response to md5 : {compressedResponseMd5}");

        return response;
    }
}

4
这个方案可行的原因是,我碰巧使用了.NET默认设置来压缩文件内容。但是,有几种不同的压缩方法会生成不同的哈希值。如果gzip是稳定的(即压缩相同的输入总是产生相同的输出),那么这种方法是可行的,但是对于这种情况不起作用。 - Jon Skeet
1
这很奇怪,因为默认值(以及调试时的窥视)似乎会使用false调用该if语句,所以实际上它不应该解压缩。 https://user-images.githubusercontent.com/2266487/32904816-8d42e9c6-caf8-11e7-8d48-0dae061a3772.png - Alexandre Hgs
1
如果库没有发送Accept-Encoding,则服务器会即时解压缩内容。我怀疑在这种情况下正在发生的就是这样 - 然后您使用与原始压缩相同的设置重新压缩它,因此您最终得到相同的哈希值。 - Jon Skeet

5
禁用自动解压缩,手动添加 Accept-Encoding 标头,然后在哈希验证后进行解压缩怎么样?相关技术涉及。
private static async Task Test2()
{
    var url = @"https://www.googleapis.com/download/storage/v1/b/storage-library-test-bucket/o/gzipped-text.txt?alt=media";
    var handler = new HttpClientHandler
    {
        AutomaticDecompression = DecompressionMethods.None
    };
    var client = new HttpClient(handler);
    client.DefaultRequestHeaders.Add("Accept-Encoding", "gzip");

    var response = await client.GetAsync(url);
    var raw = await response.Content.ReadAsByteArrayAsync();

    var hashHeader = response.Headers.GetValues("X-Goog-Hash").FirstOrDefault();
    Debug.WriteLine($"Hash header: {hashHeader}");

    bool match = false;
    using (var md5 = MD5.Create())
    {
        var md5Hash = md5.ComputeHash(raw);
        var md5HashBase64 = Convert.ToBase64String(md5Hash);
        match = hashHeader.EndsWith(md5HashBase64);
        Debug.WriteLine($"MD5 of content: {md5HashBase64}");
    }

    if (match)
    {
        var memInput = new MemoryStream(raw);
        var gz = new GZipStream(memInput, CompressionMode.Decompress);
        var memOutput = new MemoryStream();
        gz.CopyTo(memOutput);
        var text = Encoding.UTF8.GetString(memOutput.ToArray());
        Console.WriteLine($"Content: {text}");
    }
}

2
这基本上是我的原型的简化版,但效率较低。问题在于它会将整个流保留在内存中 - 当这些文件可以是多个千兆字节时。我需要在从内容返回的流中插入散列。 - Jon Skeet
3
如果我们谈论的是千兆字节,那么这种方法是无法使用的,抱歉:( - Michael

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