Browse Source

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.
David Fowler 4 years ago
parent
commit
0b8a066f58

+ 8 - 0
src/Http/WebUtilities/src/FileBufferingReadStream.cs

@@ -163,6 +163,14 @@ namespace Microsoft.AspNetCore.WebUtilities
             _tempFileDirectory = tempFileDirectory;
         }
 
+        /// <summary>
+        /// The maximum amount of memory in bytes to allocate before switching to a file on disk.
+        /// </summary>
+        /// <remarks>
+        /// Defaults to 32kb.
+        /// </remarks>
+        public int MemoryThreshold => _memoryThreshold;
+
         /// <summary>
         /// Gets a value that determines if the contents are buffered entirely in memory.
         /// </summary>

+ 8 - 0
src/Http/WebUtilities/src/FileBufferingWriteStream.cs

@@ -61,6 +61,14 @@ namespace Microsoft.AspNetCore.WebUtilities
             PagedByteBuffer = new PagedByteBuffer(ArrayPool<byte>.Shared);
         }
 
+        /// <summary>
+        /// The maximum amount of memory in bytes to allocate before switching to a file on disk.
+        /// </summary>
+        /// <remarks>
+        /// Defaults to 32kb.
+        /// </remarks>
+        public int MemoryThreshold => _memoryThreshold;
+
         /// <inheritdoc />
         public override bool CanRead => false;
 

+ 2 - 0
src/Http/WebUtilities/src/PublicAPI.Unshipped.txt

@@ -1,5 +1,7 @@
 #nullable enable
 *REMOVED*static Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseNullableQuery(string! queryString) -> System.Collections.Generic.Dictionary<string!, Microsoft.Extensions.Primitives.StringValues>?
+Microsoft.AspNetCore.WebUtilities.FileBufferingReadStream.MemoryThreshold.get -> int
+Microsoft.AspNetCore.WebUtilities.FileBufferingWriteStream.MemoryThreshold.get -> int
 static Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseNullableQuery(string? queryString) -> System.Collections.Generic.Dictionary<string!, Microsoft.Extensions.Primitives.StringValues>?
 *REMOVED*static Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(string! queryString) -> System.Collections.Generic.Dictionary<string!, Microsoft.Extensions.Primitives.StringValues>!
 static Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(string? queryString) -> System.Collections.Generic.Dictionary<string!, Microsoft.Extensions.Primitives.StringValues>!

+ 4 - 3
src/Mvc/Mvc.Core/test/Formatters/JsonOutputFormatterTestBase.cs

@@ -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;
@@ -182,14 +182,15 @@ namespace Microsoft.AspNetCore.Mvc.Formatters
             object outputValue,
             Type outputType,
             string contentType = "application/xml; charset=utf-8",
-            Stream responseStream = null)
+            Stream responseStream = null,
+            Func<Stream, Encoding, TextWriter> writerFactory = null)
         {
             var mediaTypeHeaderValue = MediaTypeHeaderValue.Parse(contentType);
 
             var actionContext = GetActionContext(mediaTypeHeaderValue, responseStream);
             return new OutputFormatterWriteContext(
                 actionContext.HttpContext,
-                new TestHttpResponseStreamWriterFactory().CreateWriter,
+                writerFactory ?? new TestHttpResponseStreamWriterFactory().CreateWriter,
                 outputType,
                 outputValue)
             {

+ 1 - 1
src/Mvc/Mvc.NewtonsoftJson/src/DependencyInjection/NewtonsoftJsonMvcOptionsSetup.cs

@@ -60,7 +60,7 @@ namespace Microsoft.Extensions.DependencyInjection
         public void Configure(MvcOptions options)
         {
             options.OutputFormatters.RemoveType<SystemTextJsonOutputFormatter>();
-            options.OutputFormatters.Add(new NewtonsoftJsonOutputFormatter(_jsonOptions.SerializerSettings, _charPool, options));
+            options.OutputFormatters.Add(new NewtonsoftJsonOutputFormatter(_jsonOptions.SerializerSettings, _charPool, options, _jsonOptions));
 
             options.InputFormatters.RemoveType<SystemTextJsonInputFormatter>();
             // Register JsonPatchInputFormatter before JsonInputFormatter, otherwise

+ 10 - 0
src/Mvc/Mvc.NewtonsoftJson/src/MvcNewtonsoftJsonOptions.cs

@@ -49,6 +49,16 @@ namespace Microsoft.AspNetCore.Mvc
         /// <value>Defaults to 30Kb.</value>
         public int InputFormatterMemoryBufferThreshold { get; set; } = 1024 * 30;
 
+        /// <summary>
+        /// Gets the maximum size to buffer in memory when <see cref="MvcOptions.SuppressOutputFormatterBuffering"/> is not set.
+        /// <para>
+        /// <see cref="NewtonsoftJsonOutputFormatter"/> buffers the output stream by default, buffering up to a certain amount in memory, before buffering to disk.
+        /// This option configures the size in bytes that MVC will buffer in memory, before switching to disk.
+        /// </para>
+        /// </summary>
+        /// <value>Defaults to 30Kb.</value>
+        public int OutputFormatterMemoryBufferThreshold { get; set; } = 1024 * 30;
+
         IEnumerator<ICompatibilitySwitch> IEnumerable<ICompatibilitySwitch>.GetEnumerator() => _switches.GetEnumerator();
 
         IEnumerator IEnumerable.GetEnumerator() => _switches.GetEnumerator();

+ 28 - 2
src/Mvc/Mvc.NewtonsoftJson/src/NewtonsoftJsonOutputFormatter.cs

@@ -11,6 +11,7 @@ using Microsoft.AspNetCore.Mvc.NewtonsoftJson;
 using Microsoft.AspNetCore.WebUtilities;
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
 using Newtonsoft.Json;
 
 namespace Microsoft.AspNetCore.Mvc.Formatters
@@ -22,6 +23,7 @@ namespace Microsoft.AspNetCore.Mvc.Formatters
     {
         private readonly IArrayPool<char> _charPool;
         private readonly MvcOptions _mvcOptions;
+        private MvcNewtonsoftJsonOptions? _jsonOptions;
         private readonly AsyncEnumerableReader _asyncEnumerableReaderFactory;
         private JsonSerializerSettings? _serializerSettings;
         private ILogger? _logger;
@@ -36,10 +38,30 @@ namespace Microsoft.AspNetCore.Mvc.Formatters
         /// </param>
         /// <param name="charPool">The <see cref="ArrayPool{Char}"/>.</param>
         /// <param name="mvcOptions">The <see cref="MvcOptions"/>.</param>
+        [Obsolete("This constructor is obsolete and will be removed in a future version.")]
         public NewtonsoftJsonOutputFormatter(
             JsonSerializerSettings serializerSettings,
             ArrayPool<char> charPool,
-            MvcOptions mvcOptions)
+            MvcOptions mvcOptions) : this(serializerSettings, charPool, mvcOptions, jsonOptions: null)
+        {
+        }
+
+        /// <summary>
+        /// Initializes a new <see cref="NewtonsoftJsonOutputFormatter"/> instance.
+        /// </summary>
+        /// <param name="serializerSettings">
+        /// The <see cref="JsonSerializerSettings"/>. Should be either the application-wide settings
+        /// (<see cref="MvcNewtonsoftJsonOptions.SerializerSettings"/>) or an instance
+        /// <see cref="JsonSerializerSettingsProvider.CreateSerializerSettings"/> initially returned.
+        /// </param>
+        /// <param name="charPool">The <see cref="ArrayPool{Char}"/>.</param>
+        /// <param name="mvcOptions">The <see cref="MvcOptions"/>.</param>
+        /// <param name="jsonOptions">The <see cref="MvcNewtonsoftJsonOptions"/>.</param>
+        public NewtonsoftJsonOutputFormatter(
+            JsonSerializerSettings serializerSettings,
+            ArrayPool<char> charPool,
+            MvcOptions mvcOptions,
+            MvcNewtonsoftJsonOptions? jsonOptions)
         {
             if (serializerSettings == null)
             {
@@ -54,6 +76,7 @@ namespace Microsoft.AspNetCore.Mvc.Formatters
             SerializerSettings = serializerSettings;
             _charPool = new JsonArrayPool<char>(charPool);
             _mvcOptions = mvcOptions ?? throw new ArgumentNullException(nameof(mvcOptions));
+            _jsonOptions = jsonOptions;
 
             SupportedEncodings.Add(Encoding.UTF8);
             SupportedEncodings.Add(Encoding.Unicode);
@@ -135,13 +158,16 @@ namespace Microsoft.AspNetCore.Mvc.Formatters
                 throw new ArgumentNullException(nameof(selectedEncoding));
             }
 
+            // Compat mode for derived options
+            _jsonOptions ??= context.HttpContext.RequestServices.GetRequiredService<IOptions<MvcNewtonsoftJsonOptions>>().Value;
+
             var response = context.HttpContext.Response;
 
             var responseStream = response.Body;
             FileBufferingWriteStream? fileBufferingWriteStream = null;
             if (!_mvcOptions.SuppressOutputFormatterBuffering)
             {
-                fileBufferingWriteStream = new FileBufferingWriteStream();
+                fileBufferingWriteStream = new FileBufferingWriteStream(_jsonOptions.OutputFormatterMemoryBufferThreshold);
                 responseStream = fileBufferingWriteStream;
             }
 

+ 3 - 0
src/Mvc/Mvc.NewtonsoftJson/src/PublicAPI.Unshipped.txt

@@ -34,8 +34,11 @@
 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
 Microsoft.AspNetCore.Mvc.Formatters.NewtonsoftJsonInputFormatter.SerializerSettings.get -> Newtonsoft.Json.JsonSerializerSettings!
 Microsoft.AspNetCore.Mvc.Formatters.NewtonsoftJsonOutputFormatter.NewtonsoftJsonOutputFormatter(Newtonsoft.Json.JsonSerializerSettings! serializerSettings, System.Buffers.ArrayPool<char>! charPool, Microsoft.AspNetCore.Mvc.MvcOptions! mvcOptions) -> void
+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
 Microsoft.AspNetCore.Mvc.Formatters.NewtonsoftJsonOutputFormatter.SerializerSettings.get -> Newtonsoft.Json.JsonSerializerSettings!
 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
+Microsoft.AspNetCore.Mvc.MvcNewtonsoftJsonOptions.OutputFormatterMemoryBufferThreshold.get -> int
+Microsoft.AspNetCore.Mvc.MvcNewtonsoftJsonOptions.OutputFormatterMemoryBufferThreshold.set -> void
 Microsoft.AspNetCore.Mvc.MvcNewtonsoftJsonOptions.SerializerSettings.get -> Newtonsoft.Json.JsonSerializerSettings!
 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!>!
 override Microsoft.AspNetCore.Mvc.Formatters.NewtonsoftJsonOutputFormatter.WriteResponseBodyAsync(Microsoft.AspNetCore.Mvc.Formatters.OutputFormatterWriteContext! context, System.Text.Encoding! selectedEncoding) -> System.Threading.Tasks.Task!

+ 82 - 6
src/Mvc/Mvc.NewtonsoftJson/test/NewtonsoftJsonOutputFormatterTest.cs

@@ -6,9 +6,12 @@ using System.Buffers;
 using System.Collections.Generic;
 using System.IO;
 using System.Linq;
+using System.Reflection;
 using System.Text;
 using System.Threading;
 using System.Threading.Tasks;
+using Microsoft.AspNetCore.WebUtilities;
+using Microsoft.Extensions.DependencyInjection;
 using Moq;
 using Newtonsoft.Json;
 using Newtonsoft.Json.Linq;
@@ -21,7 +24,7 @@ namespace Microsoft.AspNetCore.Mvc.Formatters
     {
         protected override TextOutputFormatter GetOutputFormatter()
         {
-            return new NewtonsoftJsonOutputFormatter(new JsonSerializerSettings(), ArrayPool<char>.Shared, new MvcOptions());
+            return new NewtonsoftJsonOutputFormatter(new JsonSerializerSettings(), ArrayPool<char>.Shared, new MvcOptions(), new MvcNewtonsoftJsonOptions());
         }
 
         [Fact]
@@ -46,6 +49,79 @@ namespace Microsoft.AspNetCore.Mvc.Formatters
             Assert.Same(serializerSettings, jsonFormatter.SerializerSettings);
         }
 
+        [Fact]
+        public async Task MvcJsonOptionsAreUsedToSetBufferThresholdFromServices()
+        {
+            // Arrange
+            var person = new User() { FullName = "John", age = 35 };
+            Stream writeStream = null;
+            var outputFormatterContext = GetOutputFormatterContext(person, typeof(User), writerFactory: (stream, encoding) =>
+            {
+                writeStream = stream;
+                return StreamWriter.Null;
+            });
+
+            var services = new ServiceCollection()
+                    .AddOptions()
+                    .Configure<MvcNewtonsoftJsonOptions>(o =>
+                    {
+                        o.OutputFormatterMemoryBufferThreshold = 1;
+                    })
+                    .BuildServiceProvider();
+
+            outputFormatterContext.HttpContext.RequestServices = services;
+
+            var settings = new JsonSerializerSettings
+            {
+                ContractResolver = new CamelCasePropertyNamesContractResolver(),
+                Formatting = Formatting.Indented,
+            };
+            var expectedOutput = JsonConvert.SerializeObject(person, settings);
+#pragma warning disable CS0618 // Type or member is obsolete
+            var jsonFormatter = new NewtonsoftJsonOutputFormatter(settings, ArrayPool<char>.Shared, new MvcOptions());
+#pragma warning restore CS0618 // Type or member is obsolete
+
+            // Act
+            await jsonFormatter.WriteResponseBodyAsync(outputFormatterContext, Encoding.UTF8);
+
+            // Assert
+            Assert.IsType<FileBufferingWriteStream>(writeStream);
+
+            Assert.Equal(1, ((FileBufferingWriteStream)writeStream).MemoryThreshold);
+        }
+
+        [Fact]
+        public async Task MvcJsonOptionsAreUsedToSetBufferThreshold()
+        {
+            // Arrange
+            var person = new User() { FullName = "John", age = 35 };
+            Stream writeStream = null;
+            var outputFormatterContext = GetOutputFormatterContext(person, typeof(User), writerFactory: (stream, encoding) =>
+            {
+                writeStream = stream;
+                return StreamWriter.Null;
+            });
+
+            var settings = new JsonSerializerSettings
+            {
+                ContractResolver = new CamelCasePropertyNamesContractResolver(),
+                Formatting = Formatting.Indented,
+            };
+            var expectedOutput = JsonConvert.SerializeObject(person, settings);
+            var jsonFormatter = new NewtonsoftJsonOutputFormatter(settings, ArrayPool<char>.Shared, new MvcOptions(), new MvcNewtonsoftJsonOptions()
+            {
+                OutputFormatterMemoryBufferThreshold = 2
+            });
+
+            // Act
+            await jsonFormatter.WriteResponseBodyAsync(outputFormatterContext, Encoding.UTF8);
+
+            // Assert
+            Assert.IsType<FileBufferingWriteStream>(writeStream);
+
+            Assert.Equal(2, ((FileBufferingWriteStream)writeStream).MemoryThreshold);
+        }
+
         [Fact]
         public async Task ChangesTo_SerializerSettings_AffectSerialization()
         {
@@ -59,7 +135,7 @@ namespace Microsoft.AspNetCore.Mvc.Formatters
                 Formatting = Formatting.Indented,
             };
             var expectedOutput = JsonConvert.SerializeObject(person, settings);
-            var jsonFormatter = new NewtonsoftJsonOutputFormatter(settings, ArrayPool<char>.Shared, new MvcOptions());
+            var jsonFormatter = new NewtonsoftJsonOutputFormatter(settings, ArrayPool<char>.Shared, new MvcOptions(), new MvcNewtonsoftJsonOptions());
 
             // Act
             await jsonFormatter.WriteResponseBodyAsync(outputFormatterContext, Encoding.UTF8);
@@ -277,7 +353,7 @@ namespace Microsoft.AspNetCore.Mvc.Formatters
         {
             // Arrange
             var beforeMessage = "Hello World";
-            var formatter = new NewtonsoftJsonOutputFormatter(new JsonSerializerSettings(), ArrayPool<char>.Shared, new MvcOptions());
+            var formatter = new NewtonsoftJsonOutputFormatter(new JsonSerializerSettings(), ArrayPool<char>.Shared, new MvcOptions(), new MvcNewtonsoftJsonOptions());
             var memStream = new MemoryStream();
             var outputFormatterContext = GetOutputFormatterContext(
                 beforeMessage,
@@ -308,7 +384,7 @@ namespace Microsoft.AspNetCore.Mvc.Formatters
             stream.Setup(v => v.FlushAsync(It.IsAny<CancellationToken>())).Returns(Task.CompletedTask);
             stream.SetupGet(s => s.CanWrite).Returns(true);
 
-            var formatter = new NewtonsoftJsonOutputFormatter(new JsonSerializerSettings(), ArrayPool<char>.Shared, new MvcOptions());
+            var formatter = new NewtonsoftJsonOutputFormatter(new JsonSerializerSettings(), ArrayPool<char>.Shared, new MvcOptions(), new MvcNewtonsoftJsonOptions());
             var outputFormatterContext = GetOutputFormatterContext(
                 model,
                 typeof(string),
@@ -363,7 +439,7 @@ namespace Microsoft.AspNetCore.Mvc.Formatters
         private class TestableJsonOutputFormatter : NewtonsoftJsonOutputFormatter
         {
             public TestableJsonOutputFormatter(JsonSerializerSettings serializerSettings)
-                : base(serializerSettings, ArrayPool<char>.Shared, new MvcOptions())
+                : base(serializerSettings, ArrayPool<char>.Shared, new MvcOptions(), new MvcNewtonsoftJsonOptions())
             {
             }
 
@@ -398,4 +474,4 @@ namespace Microsoft.AspNetCore.Mvc.Formatters
             public string FullName { get; set; }
         }
     }
-}
+}

+ 2 - 2
src/Mvc/test/WebSites/BasicWebSite/Controllers/ContentNegotiation/NormalController.cs

@@ -25,7 +25,7 @@ namespace BasicWebSite.Controllers.ContentNegotiation
 
         public NormalController(ArrayPool<char> charPool)
         {
-            _indentingFormatter = new NewtonsoftJsonOutputFormatter(_indentedSettings, charPool, new MvcOptions());
+            _indentingFormatter = new NewtonsoftJsonOutputFormatter(_indentedSettings, charPool, new MvcOptions(), new MvcNewtonsoftJsonOptions());
         }
 
         public override void OnActionExecuted(ActionExecutedContext context)
@@ -81,4 +81,4 @@ namespace BasicWebSite.Controllers.ContentNegotiation
             return user;
         }
     }
-}
+}

+ 2 - 2
src/Mvc/test/WebSites/FormatterWebSite/Controllers/JsonFormatterController.cs

@@ -27,7 +27,7 @@ namespace FormatterWebSite.Controllers
 
         public JsonFormatterController(ArrayPool<char> charPool)
         {
-            _indentingFormatter = new NewtonsoftJsonOutputFormatter(_indentedSettings, charPool, new MvcOptions());
+            _indentingFormatter = new NewtonsoftJsonOutputFormatter(_indentedSettings, charPool, new MvcOptions(), new MvcNewtonsoftJsonOptions());
         }
 
         public IActionResult ReturnsIndentedJson()
@@ -108,4 +108,4 @@ namespace FormatterWebSite.Controllers
             return model;
         }
     }
-}
+}