简体   繁体   English

如何在仍被编写的同时在ASP.NET Core中提供文件服务

[英]How to serve a file in ASP.NET Core while it's still being written

I have a log file that's being continuously written to by a background service. 我有一个由后台服务不断写入的日志文件。 Users need to be able to download the file so far. 到目前为止,用户需要能够下载文件。 When I return an MVC FileResult , I get an InvalidOperationException due to Content-Length mismatch, presumably because some content has been written to the file while it has been served. 当我返回MVC FileResult ,由于Content-Length不匹配,我收到了InvalidOperationException,大概是因为在提供文件时已将某些内容写入了文件。 There is a file served, and it's mostly OK, but it usually has an incomplete last line. 提供了一个文件,大多数情况下都可以,但是最后一行通常不完整。

The background service is doing essentially this: 后台服务实际上是在这样做:

var stream = new FileStream(evidenceFilePath, FileMode.Append, FileAccess.Write, FileShare.Read);
while (true) // obviously it isn't actually this, but it does happen a lot!
{
    var content = "log content\r\n";
    stream.Write(Encoding.UTF8.GetBytes(content);
}

Here are some of the variations on the controller action (all have the same result): 以下是控制器操作的一些变体(所有结果都相同):

public IActionResult DownloadLog1()
{
    return PhysicalFile("C:\\path\\to\\the\\file.txt", "text/plain", enableRangeProcessing: false); // also tried using true
}

public IActionResult DownloadLog2()
{
    var stream = new FileStream("C:\\path\\to\\the\\file.txt", FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
    return File(stream, "text/plain", enableRangeProcessing: false); // also tried true
}

Here's the exception I get when I try either of the above: 这是我尝试以上任一方法时遇到的异常:

System.InvalidOperationException: Response Content-Length mismatch: too many bytes written (216072192 of 216059904).
   at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http.HttpProtocol.ThrowTooManyBytesWritten(Int32 count)
   at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http.HttpProtocol.VerifyAndUpdateWrite(Int32 count)
   at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http.HttpProtocol.WriteAsync(ReadOnlyMemory`1 data, CancellationToken cancellationToken)
   at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http.HttpResponseStream.WriteAsync(Byte[] buffer, Int32 offset, Int32 count, CancellationToken cancellationToken)
   at Microsoft.AspNetCore.Http.Extensions.StreamCopyOperation.CopyToAsync(Stream source, Stream destination, Nullable`1 count, Int32 bufferSize, CancellationToken cancel)
   at Microsoft.AspNetCore.Mvc.Infrastructure.FileResultExecutorBase.WriteFileAsync(HttpContext context, Stream fileStream, RangeItemHeaderValue range, Int64 rangeLength)
   at Microsoft.AspNetCore.Mvc.Internal.ResourceInvoker.InvokeResultAsync(IActionResult result)
   at Microsoft.AspNetCore.Mvc.Internal.ResourceInvoker.InvokeNextResultFilterAsync[TFilter,TFilterAsync]()
   at Microsoft.AspNetCore.Mvc.Internal.ResourceInvoker.Rethrow(ResultExecutedContext context)
   at Microsoft.AspNetCore.Mvc.Internal.ResourceInvoker.ResultNext[TFilter,TFilterAsync](State& next, Scope& scope, Object& state, Boolean& isCompleted)
   at Microsoft.AspNetCore.Mvc.Internal.ResourceInvoker.InvokeResultFilters()
   at Microsoft.AspNetCore.Mvc.Internal.ResourceInvoker.InvokeNextResourceFilter()
   at Microsoft.AspNetCore.Mvc.Internal.ResourceInvoker.Rethrow(ResourceExecutedContext context)
   at Microsoft.AspNetCore.Mvc.Internal.ResourceInvoker.Next(State& next, Scope& scope, Object& state, Boolean& isCompleted)
   at Microsoft.AspNetCore.Mvc.Internal.ResourceInvoker.InvokeFilterPipelineAsync()
   at Microsoft.AspNetCore.Mvc.Internal.ResourceInvoker.InvokeAsync()
   at Microsoft.AspNetCore.Routing.EndpointMiddleware.Invoke(HttpContext httpContext)
   at Microsoft.AspNetCore.Routing.EndpointRoutingMiddleware.Invoke(HttpContext httpContext)
   at Microsoft.AspNetCore.Builder.RouterMiddleware.Invoke(HttpContext httpContext)
   at Microsoft.AspNetCore.Session.SessionMiddleware.Invoke(HttpContext context)
   at Microsoft.AspNetCore.Session.SessionMiddleware.Invoke(HttpContext context)
   at Microsoft.AspNetCore.StaticFiles.StaticFileMiddleware.Invoke(HttpContext context)
   at Microsoft.AspNetCore.StaticFiles.StaticFileMiddleware.Invoke(HttpContext context)
   at Microsoft.AspNetCore.Authentication.AuthenticationMiddleware.Invoke(HttpContext context)
   at Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddleware.Invoke(HttpContext context)
   at Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddleware.Invoke(HttpContext context)
   at Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http.HttpProtocol.ProcessRequests[TContext](IHttpApplication`1 application)

I don't mind the exception too much, but I'd prefer it if it didn't happen. 我不太介意例外,但是如果没有发生,我更喜欢它。 I do need to fix the incomplete last line problem though. 我确实需要修复不完整的最后一行问题。 The most obvious solution to me is to keep track of the number of bytes that have definitely been written to the file and somehow only serve those first n bytes. 对我来说,最明显的解决方案是跟踪肯定已写入文件的字节数,并且以某种方式仅处理前n个字节。 I don't see any easy way to do that with FileResult and the various helper methods that construct it though. 我看不到使用FileResult和构造它的各种帮助程序方法的简便方法。 The file can get pretty large (up to around 500MB), so it doesn't seem practical to buffer in memory. 该文件可能会变得非常大(最大约500MB),因此在内存中进行缓冲似乎不切实际。

I ended up writing a custom ActionResult and IActionResultExecutor to match, which are heavily based on the MVC FileStreamResult and FileStreamResultExecutor : 我最终编写了一个自定义的ActionResult和IActionResultExecutor进行匹配,它们很大程度上基于MVC FileStreamResultFileStreamResultExecutor

public class PartialFileStreamResult : FileResult
{
    Stream stream;
    long bytes;

    /// <summary>
    /// Creates a new <see cref="PartialFileStreamResult"/> instance with
    /// the provided <paramref name="fileStream"/> and the
    /// provided <paramref name="contentType"/>, which will download the first <paramref name="bytes"/>.
    /// </summary>
    /// <param name="stream">The stream representing the file</param>
    /// <param name="contentType">The Content-Type header for the response</param>
    /// <param name="bytes">The number of bytes to send from the start of the file</param>
    public PartialFileStreamResult(Stream stream, string contentType, long bytes)
        : base(contentType)
    {
        this.stream = stream ?? throw new ArgumentNullException(nameof(stream));
        if (bytes == 0)
        {
            throw new ArgumentOutOfRangeException(nameof(bytes), "Invalid file length");
        }
        this.bytes = bytes;
    }

    /// <summary>
    /// Gets or sets the stream representing the file to download.
    /// </summary>
    public Stream Stream
    {
        get => stream;
        set => stream = value ?? throw new ArgumentNullException(nameof(stream));
    }

    /// <summary>
    /// Gets or sets the number of bytes to send from the start of the file.
    /// </summary>
    public long Bytes
    {
        get => bytes;
        set
        {
            if (value == 0)
            {
                throw new ArgumentOutOfRangeException(nameof(bytes), "Invalid file length");
            }
            bytes = value;
        }
    }

    /// <inheritdoc />
    public override Task ExecuteResultAsync(ActionContext context)
    {
        if (context == null)
        {
            throw new ArgumentNullException(nameof(context));
        }
        var executor = context.HttpContext.RequestServices.GetRequiredService<IActionResultExecutor<PartialFileStreamResult>>();
        return executor.ExecuteAsync(context, this);
    }
}

public class PartialFileStreamResultExecutor : FileResultExecutorBase, IActionResultExecutor<PartialFileStreamResult>
{
    public PartialFileStreamResultExecutor(ILoggerFactory loggerFactory)
        : base(CreateLogger<PartialFileStreamResultExecutor>(loggerFactory))
    {
    }

    public async Task ExecuteAsync(ActionContext context, PartialFileStreamResult result)
    {
        if (context == null)
        {
            throw new ArgumentNullException(nameof(context));
        }

        if (result == null)
        {
            throw new ArgumentNullException(nameof(result));
        }

        using (result.Stream)
        {
            long length = result.Bytes;
            var (range, rangeLength, serveBody) = SetHeadersAndLog(context, result, length, result.EnableRangeProcessing);
            if (!serveBody) return;

            try
            {
                var outputStream = context.HttpContext.Response.Body;
                if (range == null)
                {
                    await StreamCopyOperation.CopyToAsync(result.Stream, outputStream, length, bufferSize: BufferSize, cancel: context.HttpContext.RequestAborted);
                }
                else
                {
                    result.Stream.Seek(range.From.Value, SeekOrigin.Begin);
                    await StreamCopyOperation.CopyToAsync(result.Stream, outputStream, rangeLength, BufferSize, context.HttpContext.RequestAborted);
                }
            }
            catch (OperationCanceledException)
            {
                // Don't throw this exception, it's most likely caused by the client disconnecting.
                // However, if it was cancelled for any other reason we need to prevent empty responses.
                context.HttpContext.Abort();
            }
        }
    }
}

I could have done some more work to add additional constructor overloads to set some of the optional parameters (eg download file name, etc) but this is adequate for what I need. 我本来可以做更多的工作来添加其他构造函数重载来设置一些可选参数(例如下载文件名等),但这足以满足我的需要。

You need to add the IActionResultExecutor in Startup.ConfigureServices: 您需要在Startup.ConfigureServices中添加IActionResultExecutor:

services.AddTransient<IActionResultExecutor<PartialFileStreamResult>, PartialFileStreamResultExecutor>();

My controller action therefore turned into: 因此,我的控制器操作变为:

[HttpGet]
public IActionResult DownloadLog()
{
    var (path, bytes) = GetThePathAndTheNumberOfBytesIKnowHaveBeenFlushed();

    var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); // this ensures that the file can be read while it's still being written
    return new PartialFileStreamResult(stream, "text/plain", bytes);
}

Files are unmanaged resources. 文件是非托管资源。

So when you access an unmanaged resource, like a file, it is opened via a handle. 因此,当您访问非托管资源(如文件)时,将通过句柄打开它。 In case of file it is open_file_handle (recollecting from memory). 如果是文件,则为open_file_handle(从内存中回收)。

So, best way i can suggest (very generic) to write a log entry: 因此,我可以建议(非常通用)编写日志条目的最佳方法:

Open file, 打开文件,

Write file, 写文件

Close file, 关闭文件

Dispose if applicable 处置(如果适用)

In nutshell don't keep the stream open. 简而言之,不要让流保持打开状态。

Secondly for controller you can look at MSDN example to serve file via controller. 其次,对于控制器,您可以查看通过控制器提供文件的MSDN示例。

Well, you're going to likely have issues with file locking, in general, so you'll need to plan and compensate for that. 好吧,一般而言,您可能会遇到文件锁定问题,因此您需要进行计划和补偿。 However, your immediate issue here is easier to solve. 但是,您这里遇到的直接问题更容易解决。 The problem boils down to returning a stream. 问题归结为返回流。 That stream is being written to as the response is being returned, so the content-length that was calculated is wrong by the time the response body is created. 该流是在返回响应时写入的,因此在创建响应主体时,计算出的内容长度是错误的。

What you need to do is capture the log in a point in time, namely by reading it into a byte[] . 您需要做的是在某个时间点捕获日志,即将其读取为byte[] Then, you can return that, instead of the stream, and the content-length will be calculated properly because the byte[] will not change after it's been read to. 然后,您可以返回它,而不是返回流,并且可以正确计算content-length,因为byte[]在被读取后不会改变。

using (var stream = new FileStream("C:\\path\\to\\the\\file.txt", FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
using (var ms = new MemoryStream())
{
    await stream.CopyToAsync(ms);
    return File(ms.ToArray(), "text/plain");
}

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM