diff --git a/samples/ResponseCompressionSample/Startup.cs b/samples/ResponseCompressionSample/Startup.cs index 98af8aea6b..17dd97bd39 100644 --- a/samples/ResponseCompressionSample/Startup.cs +++ b/samples/ResponseCompressionSample/Startup.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using System.IO; using System.Threading.Tasks; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; @@ -26,6 +27,15 @@ namespace ResponseCompressionSample { app.UseResponseCompression(); + app.Map("/testfile1kb.txt", fileApp => + { + fileApp.Run(context => + { + context.Response.ContentType = "text/plain"; + return context.Response.SendFileAsync("testfile1kb.txt"); + }); + }); + app.Map("/trickle", trickleApp => { trickleApp.Run(async context => @@ -57,6 +67,7 @@ namespace ResponseCompressionSample { options.UseConnectionLogging(); }) + // .UseWebListener() .ConfigureLogging(factory => { factory.AddConsole(LogLevel.Debug); diff --git a/samples/ResponseCompressionSample/project.json b/samples/ResponseCompressionSample/project.json index fb7bb30810..07bfa7363d 100644 --- a/samples/ResponseCompressionSample/project.json +++ b/samples/ResponseCompressionSample/project.json @@ -2,9 +2,13 @@ "dependencies": { "Microsoft.AspNetCore.ResponseCompression": "0.1.0-*", "Microsoft.AspNetCore.Server.Kestrel": "1.1.0-*", + "Microsoft.AspNetCore.Server.WebListener": "1.1.0-*", "Microsoft.Extensions.Logging.Console": "1.1.0-*" }, "buildOptions": { + "copyToOutput": [ + "testfile1kb.txt" + ], "emitEntryPoint": true }, "frameworks": { diff --git a/samples/ResponseCompressionSample/testfile1kb.txt b/samples/ResponseCompressionSample/testfile1kb.txt new file mode 100644 index 0000000000..24baa4c608 --- /dev/null +++ b/samples/ResponseCompressionSample/testfile1kb.txt @@ -0,0 +1 @@ +aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.ResponseCompression/BodyWrapperStream.cs b/src/Microsoft.AspNetCore.ResponseCompression/BodyWrapperStream.cs index 3b31b1f357..1b82521668 100644 --- a/src/Microsoft.AspNetCore.ResponseCompression/BodyWrapperStream.cs +++ b/src/Microsoft.AspNetCore.ResponseCompression/BodyWrapperStream.cs @@ -6,6 +6,7 @@ using System.IO; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Http.Features; using Microsoft.Net.Http.Headers; @@ -14,30 +15,27 @@ namespace Microsoft.AspNetCore.ResponseCompression /// /// Stream wrapper that create specific compression stream only if necessary. /// - internal class BodyWrapperStream : Stream, IHttpBufferingFeature + internal class BodyWrapperStream : Stream, IHttpBufferingFeature, IHttpSendFileFeature { private readonly HttpResponse _response; - private readonly Stream _bodyOriginalStream; - private readonly IResponseCompressionProvider _provider; - private readonly ICompressionProvider _compressionProvider; - private readonly IHttpBufferingFeature _innerBufferFeature; + private readonly IHttpSendFileFeature _innerSendFileFeature; private bool _compressionChecked = false; - private Stream _compressionStream = null; internal BodyWrapperStream(HttpResponse response, Stream bodyOriginalStream, IResponseCompressionProvider provider, ICompressionProvider compressionProvider, - IHttpBufferingFeature innerBufferFeature) + IHttpBufferingFeature innerBufferFeature, IHttpSendFileFeature innerSendFileFeature) { _response = response; _bodyOriginalStream = bodyOriginalStream; _provider = provider; _compressionProvider = compressionProvider; _innerBufferFeature = innerBufferFeature; + _innerSendFileFeature = innerSendFileFeature; } protected override void Dispose(bool disposing) @@ -216,5 +214,50 @@ namespace Microsoft.AspNetCore.ResponseCompression _innerBufferFeature?.DisableResponseBuffering(); } + + // The IHttpSendFileFeature feature will only be registered if _innerSendFileFeature exists. + public Task SendFileAsync(string path, long offset, long? count, CancellationToken cancellation) + { + OnWrite(); + + if (_compressionStream != null) + { + return InnerSendFileAsync(path, offset, count, cancellation); + } + + return _innerSendFileFeature.SendFileAsync(path, offset, count, cancellation); + } + + private async Task InnerSendFileAsync(string path, long offset, long? count, CancellationToken cancellation) + { + cancellation.ThrowIfCancellationRequested(); + + var fileInfo = new FileInfo(path); + if (offset < 0 || offset > fileInfo.Length) + { + throw new ArgumentOutOfRangeException(nameof(offset), offset, string.Empty); + } + if (count.HasValue && + (count.Value < 0 || count.Value > fileInfo.Length - offset)) + { + throw new ArgumentOutOfRangeException(nameof(count), count, string.Empty); + } + + int bufferSize = 1024 * 16; + + var fileStream = new FileStream( + path, + FileMode.Open, + FileAccess.Read, + FileShare.ReadWrite, + bufferSize: bufferSize, + options: FileOptions.Asynchronous | FileOptions.SequentialScan); + + using (fileStream) + { + fileStream.Seek(offset, SeekOrigin.Begin); + await StreamCopyOperation.CopyToAsync(fileStream, _compressionStream, count, cancellation); + } + } } } diff --git a/src/Microsoft.AspNetCore.ResponseCompression/ResponseCompressionMiddleware.cs b/src/Microsoft.AspNetCore.ResponseCompression/ResponseCompressionMiddleware.cs index 6e2e784bc5..692d77f643 100644 --- a/src/Microsoft.AspNetCore.ResponseCompression/ResponseCompressionMiddleware.cs +++ b/src/Microsoft.AspNetCore.ResponseCompression/ResponseCompressionMiddleware.cs @@ -68,10 +68,16 @@ namespace Microsoft.AspNetCore.ResponseCompression var bodyStream = context.Response.Body; var originalBufferFeature = context.Features.Get(); + var originalSendFileFeature = context.Features.Get(); - var bodyWrapperStream = new BodyWrapperStream(context.Response, bodyStream, _provider, compressionProvider, originalBufferFeature); + var bodyWrapperStream = new BodyWrapperStream(context.Response, bodyStream, _provider, compressionProvider, + originalBufferFeature, originalSendFileFeature); context.Response.Body = bodyWrapperStream; context.Features.Set(bodyWrapperStream); + if (originalSendFileFeature != null) + { + context.Features.Set(bodyWrapperStream); + } try { @@ -84,6 +90,10 @@ namespace Microsoft.AspNetCore.ResponseCompression { context.Response.Body = bodyStream; context.Features.Set(originalBufferFeature); + if (originalSendFileFeature != null) + { + context.Features.Set(originalSendFileFeature); + } } } } diff --git a/src/Microsoft.AspNetCore.ResponseCompression/project.json b/src/Microsoft.AspNetCore.ResponseCompression/project.json index 3cea610ec4..cc3a281c15 100644 --- a/src/Microsoft.AspNetCore.ResponseCompression/project.json +++ b/src/Microsoft.AspNetCore.ResponseCompression/project.json @@ -16,9 +16,8 @@ ] }, "dependencies": { - "Microsoft.AspNetCore.Http.Abstractions": "1.1.0-*", + "Microsoft.AspNetCore.Http.Extensions": "1.1.0-*", "Microsoft.Extensions.Options": "1.1.0-*", - "Microsoft.Net.Http.Headers": "1.1.0-*", "NETStandard.Library": "1.6.1-*" }, "frameworks": { diff --git a/test/Microsoft.AspNetCore.ResponseCompression.Tests/ResponseCompressionMiddlewareTest.cs b/test/Microsoft.AspNetCore.ResponseCompression.Tests/ResponseCompressionMiddlewareTest.cs index f76f56d704..8f468d8b03 100644 --- a/test/Microsoft.AspNetCore.ResponseCompression.Tests/ResponseCompressionMiddlewareTest.cs +++ b/test/Microsoft.AspNetCore.ResponseCompression.Tests/ResponseCompressionMiddlewareTest.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.IO; using System.Net.Http; using System.Threading; using System.Threading.Tasks; @@ -579,6 +580,168 @@ namespace Microsoft.AspNetCore.ResponseCompression.Tests } } + [Fact] + public async Task SendFileAsync_OnlySetIfFeatureAlreadyExists() + { + var builder = new WebHostBuilder() + .ConfigureServices(services => + { + services.AddResponseCompression(TextPlain); + }) + .Configure(app => + { + app.UseResponseCompression(); + app.Run(context => + { + context.Response.Headers[HeaderNames.ContentMD5] = "MD5"; + context.Response.ContentType = TextPlain; + context.Response.ContentLength = 1024; + var sendFile = context.Features.Get(); + Assert.Null(sendFile); + return Task.FromResult(0); + }); + }); + + var server = new TestServer(builder); + var client = server.CreateClient(); + + var request = new HttpRequestMessage(HttpMethod.Get, ""); + request.Headers.AcceptEncoding.ParseAdd("gzip"); + + var response = await client.SendAsync(request); + response.EnsureSuccessStatusCode(); + } + + [Fact] + public async Task SendFileAsync_DifferentContentType_NotBypassed() + { + FakeSendFileFeature fakeSendFile = null; + + var builder = new WebHostBuilder() + .ConfigureServices(services => + { + services.AddResponseCompression(TextPlain); + }) + .Configure(app => + { + app.Use((context, next) => + { + fakeSendFile = new FakeSendFileFeature(context.Response.Body); + context.Features.Set(fakeSendFile); + return next(); + }); + app.UseResponseCompression(); + app.Run(context => + { + context.Response.Headers[HeaderNames.ContentMD5] = "MD5"; + context.Response.ContentType = "custom/type"; + context.Response.ContentLength = 1024; + var sendFile = context.Features.Get(); + Assert.NotNull(sendFile); + return sendFile.SendFileAsync("testfile1kb.txt", 0, null, CancellationToken.None); + }); + }); + + var server = new TestServer(builder); + var client = server.CreateClient(); + + var request = new HttpRequestMessage(HttpMethod.Get, ""); + request.Headers.AcceptEncoding.ParseAdd("gzip"); + + var response = await client.SendAsync(request); + + CheckResponseNotCompressed(response, expectedBodyLength: 1024); + + Assert.True(fakeSendFile.Invoked); + } + + [Fact] + public async Task SendFileAsync_FirstWrite_CompressesAndFlushes() + { + FakeSendFileFeature fakeSendFile = null; + + var builder = new WebHostBuilder() + .ConfigureServices(services => + { + services.AddResponseCompression(TextPlain); + }) + .Configure(app => + { + app.Use((context, next) => + { + fakeSendFile = new FakeSendFileFeature(context.Response.Body); + context.Features.Set(fakeSendFile); + return next(); + }); + app.UseResponseCompression(); + app.Run(context => + { + context.Response.Headers[HeaderNames.ContentMD5] = "MD5"; + context.Response.ContentType = TextPlain; + context.Response.ContentLength = 1024; + var sendFile = context.Features.Get(); + Assert.NotNull(sendFile); + return sendFile.SendFileAsync("testfile1kb.txt", 0, null, CancellationToken.None); + }); + }); + + var server = new TestServer(builder); + var client = server.CreateClient(); + + var request = new HttpRequestMessage(HttpMethod.Get, ""); + request.Headers.AcceptEncoding.ParseAdd("gzip"); + + var response = await client.SendAsync(request); + + CheckResponseCompressed(response, expectedBodyLength: 34); + + Assert.False(fakeSendFile.Invoked); + } + + [Fact] + public async Task SendFileAsync_AfterFirstWrite_CompressesAndFlushes() + { + FakeSendFileFeature fakeSendFile = null; + + var builder = new WebHostBuilder() + .ConfigureServices(services => + { + services.AddResponseCompression(TextPlain); + }) + .Configure(app => + { + app.Use((context, next) => + { + fakeSendFile = new FakeSendFileFeature(context.Response.Body); + context.Features.Set(fakeSendFile); + return next(); + }); + app.UseResponseCompression(); + app.Run(async context => + { + context.Response.Headers[HeaderNames.ContentMD5] = "MD5"; + context.Response.ContentType = TextPlain; + var sendFile = context.Features.Get(); + Assert.NotNull(sendFile); + + await context.Response.WriteAsync(new string('a', 100)); + await sendFile.SendFileAsync("testfile1kb.txt", 0, null, CancellationToken.None); + }); + }); + + var server = new TestServer(builder); + var client = server.CreateClient(); + + var request = new HttpRequestMessage(HttpMethod.Get, ""); + request.Headers.AcceptEncoding.ParseAdd("gzip"); + + var response = await client.SendAsync(request); + + CheckResponseCompressed(response, expectedBodyLength: 40); + + Assert.False(fakeSendFile.Invoked); + } + private Task InvokeMiddleware(int uncompressedBodyLength, string[] requestAcceptEncodings, string responseType, Action addResponseAction = null) { var builder = new WebHostBuilder() @@ -593,6 +756,7 @@ namespace Microsoft.AspNetCore.ResponseCompression.Tests { context.Response.Headers[HeaderNames.ContentMD5] = "MD5"; context.Response.ContentType = responseType; + Assert.Null(context.Features.Get()); if (addResponseAction != null) { addResponseAction(context.Response); @@ -628,5 +792,32 @@ namespace Microsoft.AspNetCore.ResponseCompression.Tests Assert.Empty(response.Content.Headers.ContentEncoding); Assert.Equal(expectedBodyLength, response.Content.Headers.ContentLength); } + + private class FakeSendFileFeature : IHttpSendFileFeature + { + private readonly Stream _innerBody; + + public FakeSendFileFeature(Stream innerBody) + { + _innerBody = innerBody; + } + + public bool Invoked { get; set; } + + public async Task SendFileAsync(string path, long offset, long? count, CancellationToken cancellation) + { + // This implementation should only be delegated to if compression is disabled. + Invoked = true; + using (var file = new FileStream(path, FileMode.Open)) + { + file.Seek(offset, SeekOrigin.Begin); + if (count.HasValue) + { + throw new NotImplementedException("Not implemented for testing"); + } + await file.CopyToAsync(_innerBody, 81920, cancellation); + } + } + } } } diff --git a/test/Microsoft.AspNetCore.ResponseCompression.Tests/project.json b/test/Microsoft.AspNetCore.ResponseCompression.Tests/project.json index 26238aab92..af9226646a 100644 --- a/test/Microsoft.AspNetCore.ResponseCompression.Tests/project.json +++ b/test/Microsoft.AspNetCore.ResponseCompression.Tests/project.json @@ -1,6 +1,9 @@ { "version": "1.1.0-*", "buildOptions": { + "copyToOutput": [ + "testfile1kb.txt" + ], "warningsAsErrors": true }, "dependencies": { diff --git a/test/Microsoft.AspNetCore.ResponseCompression.Tests/testfile1kb.txt b/test/Microsoft.AspNetCore.ResponseCompression.Tests/testfile1kb.txt new file mode 100644 index 0000000000..24baa4c608 --- /dev/null +++ b/test/Microsoft.AspNetCore.ResponseCompression.Tests/testfile1kb.txt @@ -0,0 +1 @@ +aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa \ No newline at end of file