Как обслуживать файл в ASP.NET Core, пока он еще пишется - PullRequest
0 голосов
/ 26 февраля 2019

У меня есть файл журнала, в который непрерывно записывается фоновая служба.Пользователи должны быть в состоянии загрузить файл до сих пор.Когда я возвращаю MVC FileResult, я получаю InvalidOperationException из-за несоответствия длины содержимого, предположительно потому, что некоторое содержимое было записано в файл во время его обслуживания.Файл обслуживается, и в большинстве случаев он в порядке, но обычно он имеет неполную последнюю строку.

Фоновая служба выполняет по существу следующее:

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);
}

Вот некоторые вариантыдействие контроллера (все имеют одинаковый результат):

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
}

Вот исключение, которое я получаю, когда пытаюсь выполнить одно из указанных выше действий:

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)

Я тоже не против исключениямного, но я бы предпочел, чтобы этого не случилось.Мне нужно исправить неполную проблему с последней строкой.Самым очевидным решением для меня является отслеживание количества байтов, которые однозначно записаны в файл и каким-то образом обслуживают только эти первые n байтов.Я не вижу простого способа сделать это с помощью FileResult и различных вспомогательных методов, которые его создают.Файл может быть довольно большим (до 500 МБ), поэтому не представляется целесообразным буферизовать в памяти.

Ответы [ 3 ]

0 голосов
/ 26 февраля 2019

Что ж, у вас, скорее всего, будут проблемы с блокировкой файлов, поэтому вам нужно спланировать и компенсировать это.Однако вашу непосредственную проблему здесь легче решить.Проблема сводится к возврату потока.Этот поток записывается при возврате ответа, поэтому вычисленная длина содержимого является неправильной к моменту создания тела ответа.

Что вам нужно сделать, это зафиксировать журнал в точкево времени, а именно, читая его в byte[].Затем вы можете вернуть это вместо потока, и длина содержимого будет рассчитана правильно, потому что 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");
}
0 голосов
/ 27 февраля 2019

В итоге я написал собственный ActionResult и IActionResultExecutor для сопоставления, которые в значительной степени основаны на MVC FileStreamResult и FileStreamResultExecutor :

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();
            }
        }
    }
}

Я мог бы иметьпроделал еще некоторую работу по добавлению дополнительных перегрузок конструктора для установки некоторых необязательных параметров (например, имя загружаемого файла и т. д.), но этого вполне достаточно для того, что мне нужно.

Вам необходимо добавить IActionResultExecutor в Startup.ConfigureServices:

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

Поэтому действие моего контроллера превратилось в:

[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);
}
0 голосов
/ 26 февраля 2019

Файлы являются неуправляемыми ресурсами.

Таким образом, когда вы обращаетесь к неуправляемому ресурсу, например к файлу, он открывается через дескриптор.В случае файла это open_file_handle (извлечение из памяти).

Итак, лучший способ, который я могу предложить (очень общий), написать запись в журнале:

Открыть файл,

Записать файл,

Закрыть файл,

Утилизировать, если применимо

В двух словах, не держать поток открытым.

Во-вторых, для контроллера вы можете посмотретьна примере MSDN для подачи файла через контроллер.

...