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 解決方案,將編譯輸出的 corefx\bin\Windows_NT.AnyCPU.Debug\System.Net.Http\netcoreapp\System.Net.Http.dll 復制到 C:\Program Files\dotnet\shared\Microsoft.NETCore.App\2.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 Files\dotnet\shared\Microsoft.NETCore.App\2.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()

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM