zoukankan      html  css  js  c++  java
  • ASP.NET Core 问题排查:Request.EnableRewind 后第一次读取不到 Request.Body

    实际应用场景是将用户上传的文件依次保存到阿里云 OSS 与腾讯云 COS ,实现方式是在启用 Request.EnableRewind() 的情况下通过 Request.Body 读取流,并依次通过 2 个 StreamContent 分别上传到阿里云 OSS 与 腾讯云 COS ,在集成测试中可以正常上传(用的是 TestServer 启动站点),而部署到服务器上通过浏览器上传却出现了奇怪的问题 —— 第一个 StreamContent 上传后的文件大小总是0,而第二个 StreamContent 上传正常。上传文件大小为 0 时,对应的 Request.Body.Length 也为 0 。(注:如果不使用 Request.EnableRewind ,Request.Body 只能被读取一次)

    而如果在第一个 StreamContent 读取 Request.Body 之前先通过 MemoryStream 进行一次流的 Copy 操作,就能正常读取。

    using (var ms = new MemoryStream())
    {
        await Request.Body.CopyToAsync(ms);
    }

    好奇怪的问题!要牺牲第一个流,才能让后面的 StreamContent 从 Request.Body 中读到数据。 为什么会这样?

    先从 Request.EnableRewind() 下手,通过它的实现源码知道了 EnableRewind 之后 Request.Body 被替换为 FileBufferingReadStream ,所以 StreamContent 实际读取的是 FileBufferingReadStream ,问题可能与 FileBufferingReadStream 有关。

    public static HttpRequest EnableRewind(this HttpRequest request, int bufferThreshold = DefaultBufferThreshold, long? bufferLimit = null)
    {
        //..
        var body = request.Body;
        if (!body.CanSeek)
        {
            var fileStream = new FileBufferingReadStream(body, bufferThreshold, bufferLimit, _getTempDirectory);
            request.Body = fileStream;
            request.HttpContext.Response.RegisterForDispose(fileStream);
        }
        return request;
    }

    向前进,查看 FileBufferingReadStream 的实现源码。

    在构造函数中 _buffer 的长度被设置为 0 :

    if (memoryThreshold < _maxRentedBufferSize)
    {
        _rentedBuffer = bytePool.Rent(memoryThreshold);
        _buffer = new MemoryStream(_rentedBuffer);
        _buffer.SetLength(0);
    }
    else
    {
        _buffer = new MemoryStream();
    }

    FileBufferingReadStream 的长度实际就是 _buffer 的长度:

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

    ReadAsync 读取流的代码(已移除不相关的代码):

    public override async Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
    {
        ThrowIfDisposed();
        if (_buffer.Position < _buffer.Length || _completelyBuffered)
        {
            // Just read from the buffer
            return await _buffer.ReadAsync(buffer, offset, (int)Math.Min(count, _buffer.Length - _buffer.Position), cancellationToken);
        }
    
        int read = await _inner.ReadAsync(buffer, offset, count, cancellationToken);
        //...
        if (read > 0)
        {
            await _buffer.WriteAsync(buffer, offset, read, cancellationToken);
        }
        //...
        return read;
    }

    从 FileBufferingReadStream 的实现代码中没有发现问题。

    只有 StreamContent 才会出现这个问题吗?写了个简单的 ASP.NET Core 程序验证了一下:

    public async Task<IActionResult> Index()
    {
        Request.EnableRewind();
    
        Request.Body.Seek(0, 0);
        Console.WriteLine("First Read Request.Body");
        await Request.Body.CopyToAsync(Console.OpenStandardOutput());
        Console.WriteLine();
    
        Request.Body.Seek(0, 0);
        Console.WriteLine("Second Read Request.Body");
        await Request.Body.CopyToAsync(Console.OpenStandardOutput());
        Console.WriteLine();
    
        Request.Body.Seek(0, 0);
        using (var sr = new StreamReader(Request.Body))
        {
            return Ok(await sr.ReadToEndAsync());
        }
    }

    控制台输出流(System.ConsolePal+WindowsConsoleStream)没这个问题。

    是 StreamContent 的问题吗?用下面的代码验证一下 

    public async Task<IActionResult> Index()
    {
        Request.EnableRewind();
        var streamContent = new StreamContent(Request.Body);
        return Ok(await streamContent.ReadAsStringAsync());
    }

    奇怪了,StreamContnent 也没问题,只是剩下唯一的嫌疑对象 —— HttpClient 。

    写测试代码进行验证,站点A的代码(监听于5000端口)

    public async Task<IActionResult> Index()
    {
        Request.EnableRewind();
        var streamContent = new StreamContent(Request.Body);
        var httpClient = new HttpClient();
        var response = await httpClient.PostAsync("http://localhost:5002", streamContent);            
        return Ok(await response.Content.ReadAsStringAsync());
    }

    站点B的代码(监听于5002端口)

    public async Task<IActionResult> Index()
    {
        using (var ms = new MemoryStream())
        {
            await Request.Body.CopyToAsync(ms);
            return Ok(ms.Length);
        }
    }

    站点 A 启用 EnableRewind 并直接将 Request.Body 流 POST 到站点 B ,模拟实际应用场景。

    测试得到的返回值是 0 ,问题重现了。

    为了进一步验证是否是 HttpClient 的问题,将 HttpClient 改为 WebRequest 。

    public async Task<IActionResult> Index()
    {
        Request.EnableRewind();
        var request = WebRequest.CreateHttp("http://localhost:5002");
        request.Method = "POST";
        using (var requestStream = await request.GetRequestStreamAsync())
        {
            await Request.Body.CopyToAsync(requestStream);
        }
        using (var response = await request.GetResponseAsync())
        {
            using (var sr = new StreamReader(response.GetResponseStream()))
            {
                return Ok(await sr.ReadToEndAsync());
            }
        }
    }

    测试结果显示 WebRequest 没这个问题,果然与 HttpClient 有关。

    向 HttpClient 的源代码进军。。。

    从 HttpClient.SendAsync 到 HttpMessageInvoker.SendAsync 再到 HttpMessageHandler.SendAsync ,默认用的是 SocketsHttpHandler ,从 SocketsHttpHandler.SendAsync 到 HttpConnectionHandler.SendAsync 到 HttpConnectionPoolManager.SendAsync 。。。翻山越岭,长途跋涉,来到了 HttpConnection 的 SendAsyncCore 方法。

    public async Task<HttpResponseMessage> SendAsyncCore(HttpRequestMessage request, CancellationToken cancellationToken)
    {
        //..
        await SendRequestContentAsync(request, CreateRequestContentStream(request), cancellationToken).ConfigureAwait(false);
        //...
    }

    原来是调用 SendRequestContentAsync 方法发送请求内容的

    private async Task SendRequestContentAsync(HttpRequestMessage request, HttpContentWriteStream stream, CancellationToken cancellationToken)
    {
        // Now that we're sending content, prohibit retries on this connection.
        _canRetry = false;
    
        // Copy all of the data to the server.
        await request.Content.CopyToAsync(stream, _transportContext, cancellationToken).ConfigureAwait(false);
    
        // Finish the content; with a chunked upload, this includes writing the terminating chunk.
        await stream.FinishAsync().ConfigureAwait(false);
    
        // Flush any content that might still be buffered.
        await FlushAsync().ConfigureAwait(false);
    }

    从 request.Content.CopyToAsync 追踪到 HttpConnection 的  WriteAsync 方法

    private async Task WriteAsync(ReadOnlyMemory<byte> source)
    {
        int remaining = _writeBuffer.Length - _writeOffset;
    
        if (source.Length <= remaining)
        {
            // Fits in current write buffer.  Just copy and return.
            WriteToBuffer(source);
            return;
        }
    
        if (_writeOffset != 0)
        {
            // Fit what we can in the current write buffer and flush it.
            WriteToBuffer(source.Slice(0, remaining));
            source = source.Slice(remaining);
            await FlushAsync().ConfigureAwait(false);
        }
    
        if (source.Length >= _writeBuffer.Length)
        {
            // Large write.  No sense buffering this.  Write directly to stream.
            await WriteToStreamAsync(source).ConfigureAwait(false);
        }
        else
        {
            // Copy remainder into buffer
            WriteToBuffer(source);
        }
    }

    看这部分代码实在毫无头绪,于是采用笨方法——手工打点在控制台显示信息,在 WriteToStreamAsync 进行打点

    private ValueTask WriteToStreamAsync(ReadOnlyMemory<byte> source)
    {
        if (NetEventSource.IsEnabled) Trace($"Writing {source.Length} bytes.");
        Console.WriteLine($"{_stream} Writing {source.Length} bytes.");
        Console.WriteLine("source text: " + System.Text.Encoding.Default.GetString(source.ToArray()));
        return _stream.WriteAsync(source);
    }

    编译 System.Net.Http 解决方案,将编译输出的 corefxinWindows_NT.AnyCPU.DebugSystem.Net.Http etcoreappSystem.Net.Http.dll 复制到 C:Program FilesdotnetsharedMicrosoft.NETCore.App2.1.2 文件夹中,然后就可以使用自己编译的 System.Net.Http.dll 运行 ASP.NET Core 程序。

    运行测试站点(见之前的站点A与站点B的代码,站点 A 将 Request.Body 流中的内容通过 HttpClient POST 到站点 B ),站点 A 的控制台显示了下面的打点信息:

    System.Net.Sockets.NetworkStream Writing 10 bytes.
    source text: POST / HTT
    System.Net.Sockets.NetworkStream Writing 10 bytes.
    source text: P/1.1
    Con
    System.Net.Sockets.NetworkStream Writing 10 bytes.
    source text: tent-Lengt
    System.Net.Sockets.NetworkStream Writing 10 bytes.
    source text: h: 0
    Host
    System.Net.Sockets.NetworkStream Writing 10 bytes.
    source text: : localhos
    _writeBuffer.Length: 10
    _writeOffset: 10
    remaining: 0
    source.Length: 4
    System.Net.Sockets.NetworkStream Writing 10 bytes.
    source text: t:5002
    
    
    System.Net.Sockets.NetworkStream Writing 4 bytes.
    source text: test

    将上面的 source text 内容连接起来,到下面的 http 请求内容:

    POST / HTTP/1.1
    Content-Length: 0
    Host: localhost:5002
    
    
    test

    立马就发现了问题:Content-Length: 0 ,原来是 Content-Length 惹的祸,怎么会是 0 ?

    继续打点。。。

    找到了 "Content-Length: 0" 是  StreamContent 中的 TryComputeLength 方法引起的 

    protected internal override bool TryComputeLength(out long length)
    {
        if (_content.CanSeek)
        {
            length = _content.Length - _start;
    return true; } else { length = 0; return false; } }

    上面的代码中 _content.Length 的值为 0 (在博文的开头我们提到过 FileBufferingReadStream 在未被读取时 Length 的值为 0 ),于是 length 为 0 并返回 true ,所以生成了 "Content-Length: 0" 请求头。

    如果当 length 为 0 时,让 TryComputeLength 返回 false ,这样就不会生成 "Content-Length: 0" 请求头,是不是可以解决问题呢?

    protected internal override bool TryComputeLength(out long length)
    {
        if (_content.CanSeek)
        {
            length = _content.Length - _start;
            return length > 0;
        }
        else
        {
            length = 0;
            return false;
        }
    }

    这样会产生下面的请求内容:

    POST / HTTP/1.1
    Transfer-Encoding: chunked
    Host: localhost:5002
    
    4
    test
    0



    这样的请求内容在示例程序服务端就可以正常读取到 Request.Body ,但是无法将文件上传到阿里云 OSS 与腾讯云 COS ,应该是 "Transfer-Encoding: chunked" 请求头的原因。

    后来改为从 HttpAbstractions 下手,修改了 BufferingHelper.cs 与 FileBufferingReadStream.cs 的代码,终于解决了这个问题。

    给 FileBufferingReadStream.cs 添加一个私有字段 _innerLength ,在 Request.EnableRewind 时通过构造函数将 Request.ContentLength 的值传给 _innerLength 。

    var fileStream = new FileBufferingReadStream(body, request.ContentLength, bufferThreshold, bufferLimit, _getTempDirectory);

    在 FileBufferingReadStream 的 Length 属性中,如果流还没被读取过,就返回 _innerLength 的值。

    public override long Length
    {
        get
        {
            var useInnerLength = _innerLength.HasValue && _innerLength > 0 
                && !_completelyBuffered && _buffer.Position == 0;
            return useInnerLength ?_innerLength.Value : _buffer.Length;
        }
    }

    修改 HttpAbstractions 的源代码后,需要将编译生成的下面5个文件都复制到 C:Program FilesdotnetsharedMicrosoft.NETCore.App2.1.2 文件夹中。

    Microsoft.AspNetCore.Http.Abstractions.dll
    Microsoft.AspNetCore.Http.dll
    Microsoft.AspNetCore.Http.Features.dll
    Microsoft.Net.Http.Headers.dll
    Microsoft.AspNetCore.WebUtilities.dll

    如果用的是 Linux ,需要复制到 /usr/share/dotnet/shared/Microsoft.AspNetCore.App/2.1.2/ 目录中。 

    后来发现基于 request.ContentLength 的解决方法不适用于 chunked requests ,将 CanSeek 属性改为下面的实现(原先是直接返回true)

    public override bool CanSeek
    {
        get { return Length > 0; }
    }

    这样第一读 Request.Body 正常,但之后继续读会出现下面的错误:

    System.ObjectDisposedException: Cannot access a disposed object.
    Object name: 'FileBufferingReadStream'.
       at WebsiteA.FileBufferingReadStream.ThrowIfDisposed()
  • 相关阅读:
    多表联合查询,利用 concat 模糊搜索
    order by 中利用 case when 排序
    Quartz.NET 3.0.7 + MySql 动态调度作业+动态切换版本+多作业引用同一程序集不同版本+持久化+集群(一)
    ASP.NET Core 2.2 基础知识(十八) 托管和部署 概述
    ASP.NET Core 2.2 基础知识(十七) SignalR 一个极其简陋的聊天室
    ASP.NET Core 2.2 基础知识(十六) SignalR 概述
    ASP.NET Core 2.2 基础知识(十五) Swagger
    ASP.NET Core 2.2 基础知识(十四) WebAPI Action返回类型(未完待续)
    linux磁盘管理 磁盘查看操作
    linux磁盘管理 文件挂载
  • 原文地址:https://www.cnblogs.com/dudu/p/9505292.html
Copyright © 2011-2022 走看看