Skip to content

Commit

Permalink
Use System.Text.Json's IAsyncEnumerable support (#31894)
Browse files Browse the repository at this point in the history
## Description

System.Text.Json recently added support for streaming IAsyncEnumerable types based on an ask from the ASP.NET Core team. This PR enables MVC to leverage this feature.

## Customer Impact
Using IAsyncEnumerable with System.Text.Json will result in data to be streamed rather than buffered and serialized.

## Regression?
- [ ] Yes
- [x] No

[If yes, specify the version the behavior has regressed from]

## Risk
- [ ] High
- [ ] Medium
- [x] Low

[Justify the selection above]
The feature has been well-tested in the runtime. This is simply removing additional buffering that was previously performed by MVC before invoking the serializer.

## Verification
- [x] Manual (required)
- [x] Automated

## Packaging changes reviewed?
- [ ] Yes
- [x] No
- [ ] N/A


Addresses #11558 #23203
  • Loading branch information
pranavkm authored Apr 19, 2021
1 parent 232785c commit af70a2b
Show file tree
Hide file tree
Showing 17 changed files with 441 additions and 424 deletions.
292 changes: 146 additions & 146 deletions eng/Version.Details.xml

Large diffs are not rendered by default.

150 changes: 75 additions & 75 deletions eng/Versions.props

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions global.json
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
{
"sdk": {
"version": "6.0.100-preview.3.21168.19"
"version": "6.0.100-preview.4.21216.8"
},
"tools": {
"dotnet": "6.0.100-preview.3.21168.19",
"dotnet": "6.0.100-preview.4.21216.8",
"runtimes": {
"dotnet/x64": [
"2.1.25",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
<TargetFramework>$(DefaultNetCoreTargetFramework)</TargetFramework>
<RuntimeIdentifier>browser-wasm</RuntimeIdentifier>
<SelfContained>true</SelfContained>
<UseMonoRuntime>true</UseMonoRuntime>
</PropertyGroup>
<ItemGroup>
<Reference Include="Microsoft.AspNetCore.Metadata" />
Expand Down
36 changes: 2 additions & 34 deletions src/Mvc/Mvc.Core/src/Infrastructure/ObjectResultExecutor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,6 @@ namespace Microsoft.AspNetCore.Mvc.Infrastructure
/// </summary>
public class ObjectResultExecutor : IActionResultExecutor<ObjectResult>
{
private readonly AsyncEnumerableReader _asyncEnumerableReaderFactory;

/// <summary>
/// Creates a new <see cref="ObjectResultExecutor"/>.
/// </summary>
Expand Down Expand Up @@ -54,8 +52,6 @@ public ObjectResultExecutor(
FormatterSelector = formatterSelector;
WriterFactory = writerFactory.CreateWriter;
Logger = loggerFactory.CreateLogger<ObjectResultExecutor>();
var options = mvcOptions?.Value ?? throw new ArgumentNullException(nameof(mvcOptions));
_asyncEnumerableReaderFactory = new AsyncEnumerableReader(options);
}

/// <summary>
Expand Down Expand Up @@ -103,23 +99,9 @@ public virtual Task ExecuteAsync(ActionContext context, ObjectResult result)
}

var value = result.Value;

if (value != null && _asyncEnumerableReaderFactory.TryGetReader(value.GetType(), out var reader))
{
return ExecuteAsyncEnumerable(context, result, value, reader);
}

return ExecuteAsyncCore(context, result, objectType, value);
}

private async Task ExecuteAsyncEnumerable(ActionContext context, ObjectResult result, object asyncEnumerable, Func<object, Task<ICollection>> reader)
{
Log.BufferingAsyncEnumerable(Logger, asyncEnumerable);

var enumerated = await reader(asyncEnumerable);
await ExecuteAsyncCore(context, result, enumerated.GetType(), enumerated);
}

private Task ExecuteAsyncCore(ActionContext context, ObjectResult result, Type? objectType, object? value)
{
var formatterContext = new OutputFormatterWriteContext(
Expand Down Expand Up @@ -166,21 +148,7 @@ private static void InferContentTypes(ActionContext context, ObjectResult result
}
}

private static class Log
{
private static readonly Action<ILogger, string?, Exception?> _bufferingAsyncEnumerable = LoggerMessage.Define<string?>(
LogLevel.Debug,
new EventId(1, "BufferingAsyncEnumerable"),
"Buffering IAsyncEnumerable instance of type '{Type}'.",
skipEnabledCheck: true);

public static void BufferingAsyncEnumerable(ILogger logger, object asyncEnumerable)
{
if (logger.IsEnabled(LogLevel.Debug))
{
_bufferingAsyncEnumerable(logger, asyncEnumerable.GetType().FullName, null);
}
}
}
// Removed Log.
// new EventId(1, "BufferingAsyncEnumerable")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -69,12 +69,6 @@ public async Task ExecuteAsync(ActionContext context, JsonResult result)
Log.JsonResultExecuting(_logger, result.Value);

var value = result.Value;
if (value != null && _asyncEnumerableReaderFactory.TryGetReader(value.GetType(), out var reader))
{
Log.BufferingAsyncEnumerable(_logger, value);
value = await reader(value);
}

var objectType = value?.GetType() ?? typeof(object);

// Keep this code in sync with SystemTextJsonOutputFormatter
Expand Down Expand Up @@ -147,11 +141,7 @@ private static class Log
"Executing JsonResult, writing value of type '{Type}'.",
skipEnabledCheck: true);

private static readonly Action<ILogger, string?, Exception?> _bufferingAsyncEnumerable = LoggerMessage.Define<string?>(
LogLevel.Debug,
new EventId(2, "BufferingAsyncEnumerable"),
"Buffering IAsyncEnumerable instance of type '{Type}'.",
skipEnabledCheck: true);
// EventId 2 BufferingAsyncEnumerable

public static void JsonResultExecuting(ILogger logger, object? value)
{
Expand All @@ -161,14 +151,6 @@ public static void JsonResultExecuting(ILogger logger, object? value)
_jsonResultExecuting(logger, type, null);
}
}

public static void BufferingAsyncEnumerable(ILogger logger, object asyncEnumerable)
{
if (logger.IsEnabled(LogLevel.Debug))
{
_bufferingAsyncEnumerable(logger, asyncEnumerable.GetType().FullName, null);
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
Expand Down Expand Up @@ -81,6 +83,36 @@ public async Task WriteResponseBodyAsync_WithNonUtf8Encoding_FormattingErrorsAre
await Assert.ThrowsAsync<TimeZoneNotFoundException>(() => formatter.WriteResponseBodyAsync(outputFormatterContext, Encoding.GetEncoding("utf-16")));
}

[Fact]
public async Task WriteResponseBodyAsync_ForLargeAsyncEnumerable()
{
// Arrange
var expected = new MemoryStream();
await JsonSerializer.SerializeAsync(expected, LargeAsync(), new JsonSerializerOptions(JsonSerializerDefaults.Web));
var formatter = GetOutputFormatter();
var mediaType = MediaTypeHeaderValue.Parse("application/json; charset=utf-8");
var encoding = CreateOrGetSupportedEncoding(formatter, "utf-8", isDefaultEncoding: true);

var body = new MemoryStream();
var actionContext = GetActionContext(mediaType, body);

var asyncEnumerable = LargeAsync();
var outputFormatterContext = new OutputFormatterWriteContext(
actionContext.HttpContext,
new TestHttpResponseStreamWriterFactory().CreateWriter,
asyncEnumerable.GetType(),
asyncEnumerable)
{
ContentType = new StringSegment(mediaType.ToString()),
};

// Act
await formatter.WriteResponseBodyAsync(outputFormatterContext, Encoding.GetEncoding("utf-8"));

// Assert
Assert.Equal(expected.ToArray(), body.ToArray());
}

private class Person
{
public string Name { get; set; }
Expand Down Expand Up @@ -108,5 +140,15 @@ public override void Write(Utf8JsonWriter writer, ThrowingFormatterModel value,
throw new TimeZoneNotFoundException();
}
}

private static async IAsyncEnumerable<int> LargeAsync()
{
await Task.Yield();
// MvcOptions.MaxIAsyncEnumerableBufferLimit is 8192. Pick some value larger than that.
foreach (var i in Enumerable.Range(0, 9000))
{
yield return i;
}
}
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

using System;
Expand Down Expand Up @@ -190,6 +190,19 @@ public async Task Reader_ThrowsIfBufferLimitIsReached()
Assert.Equal(expected, ex.Message);
}

[Fact]
public async Task Reader_ThrowsIfIAsyncEnumerableThrows()
{
// Arrange
var enumerable = ThrowingAsyncEnumerable();
var options = new MvcOptions();
var readerFactory = new AsyncEnumerableReader(options);

// Act & Assert
Assert.True(readerFactory.TryGetReader(enumerable.GetType(), out var reader));
await Assert.ThrowsAsync<TimeZoneNotFoundException>(() => reader(enumerable));
}

public static async IAsyncEnumerable<string> TestEnumerable(int count = 3)
{
await Task.Yield();
Expand Down Expand Up @@ -225,5 +238,18 @@ public IAsyncEnumerator<string> GetAsyncEnumerator(CancellationToken cancellatio
IAsyncEnumerator<object> IAsyncEnumerable<object>.GetAsyncEnumerator(CancellationToken cancellationToken)
=> GetAsyncEnumerator(cancellationToken);
}

private static async IAsyncEnumerable<string> ThrowingAsyncEnumerable()
{
await Task.Yield();
for (var i = 0; i < 10; i++)
{
yield return $"Hello {i}";
if (i == 5)
{
throw new TimeZoneNotFoundException();
}
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -333,7 +333,7 @@ public async Task ExecuteAsync_WithNullValue()
public async Task ExecuteAsync_SerializesAsyncEnumerables()
{
// Arrange
var expected = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(new[] { "Hello", "world" }));
var expected = JsonSerializer.Serialize(new[] { "Hello", "world" });

var context = GetActionContext();
var result = new JsonResult(TestAsyncEnumerable());
Expand All @@ -344,7 +344,7 @@ public async Task ExecuteAsync_SerializesAsyncEnumerables()

// Assert
var written = GetWrittenBytes(context.HttpContext);
Assert.Equal(expected, written);
Assert.Equal(expected, Encoding.UTF8.GetString(written));
}

[Fact]
Expand Down
100 changes: 0 additions & 100 deletions src/Mvc/Mvc.Core/test/Infrastructure/ObjectResultExecutorTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -459,106 +459,6 @@ public async Task ObjectResult_NullValue()
Assert.Null(formatterContext.Object);
}

[Fact]
public async Task ObjectResult_ReadsAsyncEnumerables()
{
// Arrange
var executor = CreateExecutor();
var result = new ObjectResult(AsyncEnumerable());
var formatter = new TestJsonOutputFormatter();
result.Formatters.Add(formatter);

var actionContext = new ActionContext()
{
HttpContext = GetHttpContext(),
};

// Act
await executor.ExecuteAsync(actionContext, result);

// Assert
var formatterContext = formatter.LastOutputFormatterContext;
Assert.Equal(typeof(List<string>), formatterContext.ObjectType);
var value = Assert.IsType<List<string>>(formatterContext.Object);
Assert.Equal(new[] { "Hello 0", "Hello 1", "Hello 2", "Hello 3", }, value);
}

[Fact]
public async Task ObjectResult_Throws_IfEnumerableThrows()
{
// Arrange
var executor = CreateExecutor();
var result = new ObjectResult(AsyncEnumerable(throwError: true));
var formatter = new TestJsonOutputFormatter();
result.Formatters.Add(formatter);

var actionContext = new ActionContext()
{
HttpContext = GetHttpContext(),
};

// Act & Assert
await Assert.ThrowsAsync<TimeZoneNotFoundException>(() => executor.ExecuteAsync(actionContext, result));
}

[Fact]
public async Task ObjectResult_AsyncEnumeration_AtLimit()
{
// Arrange
var count = 24;
var executor = CreateExecutor(options: new MvcOptions { MaxIAsyncEnumerableBufferLimit = count });
var result = new ObjectResult(AsyncEnumerable(count: count));
var formatter = new TestJsonOutputFormatter();
result.Formatters.Add(formatter);

var actionContext = new ActionContext()
{
HttpContext = GetHttpContext(),
};

// Act
await executor.ExecuteAsync(actionContext, result);

// Assert
var formatterContext = formatter.LastOutputFormatterContext;
var value = Assert.IsType<List<string>>(formatterContext.Object);
Assert.Equal(24, value.Count);
}

[Theory]
[InlineData(25)]
[InlineData(1024)]
public async Task ObjectResult_Throws_IfEnumerationExceedsLimit(int count)
{
// Arrange
var executor = CreateExecutor(options: new MvcOptions { MaxIAsyncEnumerableBufferLimit = 24 });
var result = new ObjectResult(AsyncEnumerable(count: count));
var formatter = new TestJsonOutputFormatter();
result.Formatters.Add(formatter);

var actionContext = new ActionContext()
{
HttpContext = GetHttpContext(),
};

// Act & Assert
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => executor.ExecuteAsync(actionContext, result));
}

private static async IAsyncEnumerable<string> AsyncEnumerable(int count = 4, bool throwError = false)
{
await Task.Yield();
for (var i = 0; i < count; i++)
{
yield return $"Hello {i}";
}

if (throwError)
{
throw new TimeZoneNotFoundException();
}
}

private static IServiceCollection CreateServices()
{
var services = new ServiceCollection();
Expand Down
Loading

0 comments on commit af70a2b

Please sign in to comment.