Skip to content

Commit 0b8a066

Browse files
authored
Add option to the JSON.NET output formatter (#32747)
* Add option to the JSON.NET output formatter - Add an option to increase the buffer threshold for writing to the output. We have several long standing performance issues with the JSON.NET output formatter and this addresses one of them by making the buffer threshold before going to disk configurable.
1 parent 4dd2a88 commit 0b8a066

File tree

11 files changed

+150
-16
lines changed

11 files changed

+150
-16
lines changed

src/Http/WebUtilities/src/FileBufferingReadStream.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,14 @@ public FileBufferingReadStream(
163163
_tempFileDirectory = tempFileDirectory;
164164
}
165165

166+
/// <summary>
167+
/// The maximum amount of memory in bytes to allocate before switching to a file on disk.
168+
/// </summary>
169+
/// <remarks>
170+
/// Defaults to 32kb.
171+
/// </remarks>
172+
public int MemoryThreshold => _memoryThreshold;
173+
166174
/// <summary>
167175
/// Gets a value that determines if the contents are buffered entirely in memory.
168176
/// </summary>

src/Http/WebUtilities/src/FileBufferingWriteStream.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,14 @@ public FileBufferingWriteStream(
6161
PagedByteBuffer = new PagedByteBuffer(ArrayPool<byte>.Shared);
6262
}
6363

64+
/// <summary>
65+
/// The maximum amount of memory in bytes to allocate before switching to a file on disk.
66+
/// </summary>
67+
/// <remarks>
68+
/// Defaults to 32kb.
69+
/// </remarks>
70+
public int MemoryThreshold => _memoryThreshold;
71+
6472
/// <inheritdoc />
6573
public override bool CanRead => false;
6674

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
#nullable enable
22
*REMOVED*static Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseNullableQuery(string! queryString) -> System.Collections.Generic.Dictionary<string!, Microsoft.Extensions.Primitives.StringValues>?
3+
Microsoft.AspNetCore.WebUtilities.FileBufferingReadStream.MemoryThreshold.get -> int
4+
Microsoft.AspNetCore.WebUtilities.FileBufferingWriteStream.MemoryThreshold.get -> int
35
static Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseNullableQuery(string? queryString) -> System.Collections.Generic.Dictionary<string!, Microsoft.Extensions.Primitives.StringValues>?
46
*REMOVED*static Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(string! queryString) -> System.Collections.Generic.Dictionary<string!, Microsoft.Extensions.Primitives.StringValues>!
57
static Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(string? queryString) -> System.Collections.Generic.Dictionary<string!, Microsoft.Extensions.Primitives.StringValues>!

src/Mvc/Mvc.Core/test/Formatters/JsonOutputFormatterTestBase.cs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright (c) .NET Foundation. All rights reserved.
1+
// Copyright (c) .NET Foundation. All rights reserved.
22
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
33

44
using System;
@@ -182,14 +182,15 @@ protected static OutputFormatterWriteContext GetOutputFormatterContext(
182182
object outputValue,
183183
Type outputType,
184184
string contentType = "application/xml; charset=utf-8",
185-
Stream responseStream = null)
185+
Stream responseStream = null,
186+
Func<Stream, Encoding, TextWriter> writerFactory = null)
186187
{
187188
var mediaTypeHeaderValue = MediaTypeHeaderValue.Parse(contentType);
188189

189190
var actionContext = GetActionContext(mediaTypeHeaderValue, responseStream);
190191
return new OutputFormatterWriteContext(
191192
actionContext.HttpContext,
192-
new TestHttpResponseStreamWriterFactory().CreateWriter,
193+
writerFactory ?? new TestHttpResponseStreamWriterFactory().CreateWriter,
193194
outputType,
194195
outputValue)
195196
{

src/Mvc/Mvc.NewtonsoftJson/src/DependencyInjection/NewtonsoftJsonMvcOptionsSetup.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ public NewtonsoftJsonMvcOptionsSetup(
6060
public void Configure(MvcOptions options)
6161
{
6262
options.OutputFormatters.RemoveType<SystemTextJsonOutputFormatter>();
63-
options.OutputFormatters.Add(new NewtonsoftJsonOutputFormatter(_jsonOptions.SerializerSettings, _charPool, options));
63+
options.OutputFormatters.Add(new NewtonsoftJsonOutputFormatter(_jsonOptions.SerializerSettings, _charPool, options, _jsonOptions));
6464

6565
options.InputFormatters.RemoveType<SystemTextJsonInputFormatter>();
6666
// Register JsonPatchInputFormatter before JsonInputFormatter, otherwise

src/Mvc/Mvc.NewtonsoftJson/src/MvcNewtonsoftJsonOptions.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,16 @@ public class MvcNewtonsoftJsonOptions : IEnumerable<ICompatibilitySwitch>
4949
/// <value>Defaults to 30Kb.</value>
5050
public int InputFormatterMemoryBufferThreshold { get; set; } = 1024 * 30;
5151

52+
/// <summary>
53+
/// Gets the maximum size to buffer in memory when <see cref="MvcOptions.SuppressOutputFormatterBuffering"/> is not set.
54+
/// <para>
55+
/// <see cref="NewtonsoftJsonOutputFormatter"/> buffers the output stream by default, buffering up to a certain amount in memory, before buffering to disk.
56+
/// This option configures the size in bytes that MVC will buffer in memory, before switching to disk.
57+
/// </para>
58+
/// </summary>
59+
/// <value>Defaults to 30Kb.</value>
60+
public int OutputFormatterMemoryBufferThreshold { get; set; } = 1024 * 30;
61+
5262
IEnumerator<ICompatibilitySwitch> IEnumerable<ICompatibilitySwitch>.GetEnumerator() => _switches.GetEnumerator();
5363

5464
IEnumerator IEnumerable.GetEnumerator() => _switches.GetEnumerator();

src/Mvc/Mvc.NewtonsoftJson/src/NewtonsoftJsonOutputFormatter.cs

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
using Microsoft.AspNetCore.WebUtilities;
1212
using Microsoft.Extensions.DependencyInjection;
1313
using Microsoft.Extensions.Logging;
14+
using Microsoft.Extensions.Options;
1415
using Newtonsoft.Json;
1516

1617
namespace Microsoft.AspNetCore.Mvc.Formatters
@@ -22,6 +23,7 @@ public class NewtonsoftJsonOutputFormatter : TextOutputFormatter
2223
{
2324
private readonly IArrayPool<char> _charPool;
2425
private readonly MvcOptions _mvcOptions;
26+
private MvcNewtonsoftJsonOptions? _jsonOptions;
2527
private readonly AsyncEnumerableReader _asyncEnumerableReaderFactory;
2628
private JsonSerializerSettings? _serializerSettings;
2729
private ILogger? _logger;
@@ -36,10 +38,30 @@ public class NewtonsoftJsonOutputFormatter : TextOutputFormatter
3638
/// </param>
3739
/// <param name="charPool">The <see cref="ArrayPool{Char}"/>.</param>
3840
/// <param name="mvcOptions">The <see cref="MvcOptions"/>.</param>
41+
[Obsolete("This constructor is obsolete and will be removed in a future version.")]
3942
public NewtonsoftJsonOutputFormatter(
4043
JsonSerializerSettings serializerSettings,
4144
ArrayPool<char> charPool,
42-
MvcOptions mvcOptions)
45+
MvcOptions mvcOptions) : this(serializerSettings, charPool, mvcOptions, jsonOptions: null)
46+
{
47+
}
48+
49+
/// <summary>
50+
/// Initializes a new <see cref="NewtonsoftJsonOutputFormatter"/> instance.
51+
/// </summary>
52+
/// <param name="serializerSettings">
53+
/// The <see cref="JsonSerializerSettings"/>. Should be either the application-wide settings
54+
/// (<see cref="MvcNewtonsoftJsonOptions.SerializerSettings"/>) or an instance
55+
/// <see cref="JsonSerializerSettingsProvider.CreateSerializerSettings"/> initially returned.
56+
/// </param>
57+
/// <param name="charPool">The <see cref="ArrayPool{Char}"/>.</param>
58+
/// <param name="mvcOptions">The <see cref="MvcOptions"/>.</param>
59+
/// <param name="jsonOptions">The <see cref="MvcNewtonsoftJsonOptions"/>.</param>
60+
public NewtonsoftJsonOutputFormatter(
61+
JsonSerializerSettings serializerSettings,
62+
ArrayPool<char> charPool,
63+
MvcOptions mvcOptions,
64+
MvcNewtonsoftJsonOptions? jsonOptions)
4365
{
4466
if (serializerSettings == null)
4567
{
@@ -54,6 +76,7 @@ public NewtonsoftJsonOutputFormatter(
5476
SerializerSettings = serializerSettings;
5577
_charPool = new JsonArrayPool<char>(charPool);
5678
_mvcOptions = mvcOptions ?? throw new ArgumentNullException(nameof(mvcOptions));
79+
_jsonOptions = jsonOptions;
5780

5881
SupportedEncodings.Add(Encoding.UTF8);
5982
SupportedEncodings.Add(Encoding.Unicode);
@@ -135,13 +158,16 @@ public override async Task WriteResponseBodyAsync(OutputFormatterWriteContext co
135158
throw new ArgumentNullException(nameof(selectedEncoding));
136159
}
137160

161+
// Compat mode for derived options
162+
_jsonOptions ??= context.HttpContext.RequestServices.GetRequiredService<IOptions<MvcNewtonsoftJsonOptions>>().Value;
163+
138164
var response = context.HttpContext.Response;
139165

140166
var responseStream = response.Body;
141167
FileBufferingWriteStream? fileBufferingWriteStream = null;
142168
if (!_mvcOptions.SuppressOutputFormatterBuffering)
143169
{
144-
fileBufferingWriteStream = new FileBufferingWriteStream();
170+
fileBufferingWriteStream = new FileBufferingWriteStream(_jsonOptions.OutputFormatterMemoryBufferThreshold);
145171
responseStream = fileBufferingWriteStream;
146172
}
147173

src/Mvc/Mvc.NewtonsoftJson/src/PublicAPI.Unshipped.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,11 @@
3434
Microsoft.AspNetCore.Mvc.Formatters.NewtonsoftJsonInputFormatter.NewtonsoftJsonInputFormatter(Microsoft.Extensions.Logging.ILogger! logger, Newtonsoft.Json.JsonSerializerSettings! serializerSettings, System.Buffers.ArrayPool<char>! charPool, Microsoft.Extensions.ObjectPool.ObjectPoolProvider! objectPoolProvider, Microsoft.AspNetCore.Mvc.MvcOptions! options, Microsoft.AspNetCore.Mvc.MvcNewtonsoftJsonOptions! jsonOptions) -> void
3535
Microsoft.AspNetCore.Mvc.Formatters.NewtonsoftJsonInputFormatter.SerializerSettings.get -> Newtonsoft.Json.JsonSerializerSettings!
3636
Microsoft.AspNetCore.Mvc.Formatters.NewtonsoftJsonOutputFormatter.NewtonsoftJsonOutputFormatter(Newtonsoft.Json.JsonSerializerSettings! serializerSettings, System.Buffers.ArrayPool<char>! charPool, Microsoft.AspNetCore.Mvc.MvcOptions! mvcOptions) -> void
37+
Microsoft.AspNetCore.Mvc.Formatters.NewtonsoftJsonOutputFormatter.NewtonsoftJsonOutputFormatter(Newtonsoft.Json.JsonSerializerSettings! serializerSettings, System.Buffers.ArrayPool<char>! charPool, Microsoft.AspNetCore.Mvc.MvcOptions! mvcOptions, Microsoft.AspNetCore.Mvc.MvcNewtonsoftJsonOptions? jsonOptions) -> void
3738
Microsoft.AspNetCore.Mvc.Formatters.NewtonsoftJsonOutputFormatter.SerializerSettings.get -> Newtonsoft.Json.JsonSerializerSettings!
3839
Microsoft.AspNetCore.Mvc.Formatters.NewtonsoftJsonPatchInputFormatter.NewtonsoftJsonPatchInputFormatter(Microsoft.Extensions.Logging.ILogger! logger, Newtonsoft.Json.JsonSerializerSettings! serializerSettings, System.Buffers.ArrayPool<char>! charPool, Microsoft.Extensions.ObjectPool.ObjectPoolProvider! objectPoolProvider, Microsoft.AspNetCore.Mvc.MvcOptions! options, Microsoft.AspNetCore.Mvc.MvcNewtonsoftJsonOptions! jsonOptions) -> void
40+
Microsoft.AspNetCore.Mvc.MvcNewtonsoftJsonOptions.OutputFormatterMemoryBufferThreshold.get -> int
41+
Microsoft.AspNetCore.Mvc.MvcNewtonsoftJsonOptions.OutputFormatterMemoryBufferThreshold.set -> void
3942
Microsoft.AspNetCore.Mvc.MvcNewtonsoftJsonOptions.SerializerSettings.get -> Newtonsoft.Json.JsonSerializerSettings!
4043
override Microsoft.AspNetCore.Mvc.Formatters.NewtonsoftJsonInputFormatter.ReadRequestBodyAsync(Microsoft.AspNetCore.Mvc.Formatters.InputFormatterContext! context, System.Text.Encoding! encoding) -> System.Threading.Tasks.Task<Microsoft.AspNetCore.Mvc.Formatters.InputFormatterResult!>!
4144
override Microsoft.AspNetCore.Mvc.Formatters.NewtonsoftJsonOutputFormatter.WriteResponseBodyAsync(Microsoft.AspNetCore.Mvc.Formatters.OutputFormatterWriteContext! context, System.Text.Encoding! selectedEncoding) -> System.Threading.Tasks.Task!

src/Mvc/Mvc.NewtonsoftJson/test/NewtonsoftJsonOutputFormatterTest.cs

Lines changed: 82 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,12 @@
66
using System.Collections.Generic;
77
using System.IO;
88
using System.Linq;
9+
using System.Reflection;
910
using System.Text;
1011
using System.Threading;
1112
using System.Threading.Tasks;
13+
using Microsoft.AspNetCore.WebUtilities;
14+
using Microsoft.Extensions.DependencyInjection;
1215
using Moq;
1316
using Newtonsoft.Json;
1417
using Newtonsoft.Json.Linq;
@@ -21,7 +24,7 @@ public class NewtonsoftJsonOutputFormatterTest : JsonOutputFormatterTestBase
2124
{
2225
protected override TextOutputFormatter GetOutputFormatter()
2326
{
24-
return new NewtonsoftJsonOutputFormatter(new JsonSerializerSettings(), ArrayPool<char>.Shared, new MvcOptions());
27+
return new NewtonsoftJsonOutputFormatter(new JsonSerializerSettings(), ArrayPool<char>.Shared, new MvcOptions(), new MvcNewtonsoftJsonOptions());
2528
}
2629

2730
[Fact]
@@ -46,6 +49,79 @@ public void Constructor_UsesSerializerSettings()
4649
Assert.Same(serializerSettings, jsonFormatter.SerializerSettings);
4750
}
4851

52+
[Fact]
53+
public async Task MvcJsonOptionsAreUsedToSetBufferThresholdFromServices()
54+
{
55+
// Arrange
56+
var person = new User() { FullName = "John", age = 35 };
57+
Stream writeStream = null;
58+
var outputFormatterContext = GetOutputFormatterContext(person, typeof(User), writerFactory: (stream, encoding) =>
59+
{
60+
writeStream = stream;
61+
return StreamWriter.Null;
62+
});
63+
64+
var services = new ServiceCollection()
65+
.AddOptions()
66+
.Configure<MvcNewtonsoftJsonOptions>(o =>
67+
{
68+
o.OutputFormatterMemoryBufferThreshold = 1;
69+
})
70+
.BuildServiceProvider();
71+
72+
outputFormatterContext.HttpContext.RequestServices = services;
73+
74+
var settings = new JsonSerializerSettings
75+
{
76+
ContractResolver = new CamelCasePropertyNamesContractResolver(),
77+
Formatting = Formatting.Indented,
78+
};
79+
var expectedOutput = JsonConvert.SerializeObject(person, settings);
80+
#pragma warning disable CS0618 // Type or member is obsolete
81+
var jsonFormatter = new NewtonsoftJsonOutputFormatter(settings, ArrayPool<char>.Shared, new MvcOptions());
82+
#pragma warning restore CS0618 // Type or member is obsolete
83+
84+
// Act
85+
await jsonFormatter.WriteResponseBodyAsync(outputFormatterContext, Encoding.UTF8);
86+
87+
// Assert
88+
Assert.IsType<FileBufferingWriteStream>(writeStream);
89+
90+
Assert.Equal(1, ((FileBufferingWriteStream)writeStream).MemoryThreshold);
91+
}
92+
93+
[Fact]
94+
public async Task MvcJsonOptionsAreUsedToSetBufferThreshold()
95+
{
96+
// Arrange
97+
var person = new User() { FullName = "John", age = 35 };
98+
Stream writeStream = null;
99+
var outputFormatterContext = GetOutputFormatterContext(person, typeof(User), writerFactory: (stream, encoding) =>
100+
{
101+
writeStream = stream;
102+
return StreamWriter.Null;
103+
});
104+
105+
var settings = new JsonSerializerSettings
106+
{
107+
ContractResolver = new CamelCasePropertyNamesContractResolver(),
108+
Formatting = Formatting.Indented,
109+
};
110+
var expectedOutput = JsonConvert.SerializeObject(person, settings);
111+
var jsonFormatter = new NewtonsoftJsonOutputFormatter(settings, ArrayPool<char>.Shared, new MvcOptions(), new MvcNewtonsoftJsonOptions()
112+
{
113+
OutputFormatterMemoryBufferThreshold = 2
114+
});
115+
116+
// Act
117+
await jsonFormatter.WriteResponseBodyAsync(outputFormatterContext, Encoding.UTF8);
118+
119+
// Assert
120+
Assert.IsType<FileBufferingWriteStream>(writeStream);
121+
122+
Assert.Equal(2, ((FileBufferingWriteStream)writeStream).MemoryThreshold);
123+
}
124+
49125
[Fact]
50126
public async Task ChangesTo_SerializerSettings_AffectSerialization()
51127
{
@@ -59,7 +135,7 @@ public async Task ChangesTo_SerializerSettings_AffectSerialization()
59135
Formatting = Formatting.Indented,
60136
};
61137
var expectedOutput = JsonConvert.SerializeObject(person, settings);
62-
var jsonFormatter = new NewtonsoftJsonOutputFormatter(settings, ArrayPool<char>.Shared, new MvcOptions());
138+
var jsonFormatter = new NewtonsoftJsonOutputFormatter(settings, ArrayPool<char>.Shared, new MvcOptions(), new MvcNewtonsoftJsonOptions());
63139

64140
// Act
65141
await jsonFormatter.WriteResponseBodyAsync(outputFormatterContext, Encoding.UTF8);
@@ -277,7 +353,7 @@ public async Task WriteToStreamAsync_RoundTripsJToken()
277353
{
278354
// Arrange
279355
var beforeMessage = "Hello World";
280-
var formatter = new NewtonsoftJsonOutputFormatter(new JsonSerializerSettings(), ArrayPool<char>.Shared, new MvcOptions());
356+
var formatter = new NewtonsoftJsonOutputFormatter(new JsonSerializerSettings(), ArrayPool<char>.Shared, new MvcOptions(), new MvcNewtonsoftJsonOptions());
281357
var memStream = new MemoryStream();
282358
var outputFormatterContext = GetOutputFormatterContext(
283359
beforeMessage,
@@ -308,7 +384,7 @@ public async Task WriteToStreamAsync_LargePayload_DoesNotPerformSynchronousWrite
308384
stream.Setup(v => v.FlushAsync(It.IsAny<CancellationToken>())).Returns(Task.CompletedTask);
309385
stream.SetupGet(s => s.CanWrite).Returns(true);
310386

311-
var formatter = new NewtonsoftJsonOutputFormatter(new JsonSerializerSettings(), ArrayPool<char>.Shared, new MvcOptions());
387+
var formatter = new NewtonsoftJsonOutputFormatter(new JsonSerializerSettings(), ArrayPool<char>.Shared, new MvcOptions(), new MvcNewtonsoftJsonOptions());
312388
var outputFormatterContext = GetOutputFormatterContext(
313389
model,
314390
typeof(string),
@@ -363,7 +439,7 @@ public async Task SerializingWithPreserveReferenceHandling()
363439
private class TestableJsonOutputFormatter : NewtonsoftJsonOutputFormatter
364440
{
365441
public TestableJsonOutputFormatter(JsonSerializerSettings serializerSettings)
366-
: base(serializerSettings, ArrayPool<char>.Shared, new MvcOptions())
442+
: base(serializerSettings, ArrayPool<char>.Shared, new MvcOptions(), new MvcNewtonsoftJsonOptions())
367443
{
368444
}
369445

@@ -398,4 +474,4 @@ private class UserWithJsonObject
398474
public string FullName { get; set; }
399475
}
400476
}
401-
}
477+
}

src/Mvc/test/WebSites/BasicWebSite/Controllers/ContentNegotiation/NormalController.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ static NormalController()
2525

2626
public NormalController(ArrayPool<char> charPool)
2727
{
28-
_indentingFormatter = new NewtonsoftJsonOutputFormatter(_indentedSettings, charPool, new MvcOptions());
28+
_indentingFormatter = new NewtonsoftJsonOutputFormatter(_indentedSettings, charPool, new MvcOptions(), new MvcNewtonsoftJsonOptions());
2929
}
3030

3131
public override void OnActionExecuted(ActionExecutedContext context)
@@ -81,4 +81,4 @@ public User CreateUser()
8181
return user;
8282
}
8383
}
84-
}
84+
}

0 commit comments

Comments
 (0)