diff --git a/src/Microsoft.AspNetCore.Mvc.Razor/RazorTextWriter.cs b/src/Microsoft.AspNetCore.Mvc.Razor/RazorTextWriter.cs index 6c204a37e5..e82f690c6e 100644 --- a/src/Microsoft.AspNetCore.Mvc.Razor/RazorTextWriter.cs +++ b/src/Microsoft.AspNetCore.Mvc.Razor/RazorTextWriter.cs @@ -98,15 +98,9 @@ namespace Microsoft.AspNetCore.Mvc.Razor /// public override void Write(IHtmlContent value) { - var htmlTextWriter = TargetWriter as HtmlTextWriter; - if (htmlTextWriter == null) - { - value.WriteTo(TargetWriter, HtmlEncoder); - } - else - { - htmlTextWriter.Write(value); - } + // Perf: We don't special case 'TargetWriter is HtmlTextWriter' here, because want to delegate to the + // IHtmlContent if it wants to 'flatten' itself or not. This is an important optimization for TagHelpers. + value.WriteTo(TargetWriter, HtmlEncoder); } /// diff --git a/src/Microsoft.AspNetCore.Mvc.Razor/RazorView.cs b/src/Microsoft.AspNetCore.Mvc.Razor/RazorView.cs index b35c256a4d..d823f62370 100644 --- a/src/Microsoft.AspNetCore.Mvc.Razor/RazorView.cs +++ b/src/Microsoft.AspNetCore.Mvc.Razor/RazorView.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.Buffers; using System.Collections.Generic; using System.Diagnostics; using System.Linq; @@ -233,7 +234,10 @@ namespace Microsoft.AspNetCore.Mvc.Razor if (bodyWriter.IsBuffering) { // Only copy buffered content to the Output if we're currently buffering. - await bodyWriter.Buffer.WriteToAsync(context.Writer, _htmlEncoder); + using (var writer = _bufferScope.CreateWriter(context.Writer)) + { + await bodyWriter.Buffer.WriteToAsync(writer, _htmlEncoder); + } } } diff --git a/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Internal/IViewBufferScope.cs b/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Internal/IViewBufferScope.cs index 9b46ce27cc..6d90277737 100644 --- a/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Internal/IViewBufferScope.cs +++ b/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Internal/IViewBufferScope.cs @@ -1,6 +1,8 @@ // 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.IO; + namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Internal { /// @@ -13,5 +15,19 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Internal /// /// The . ViewBufferValue[] GetSegment(); + + /// + /// Returns a that can be reused. + /// + /// The . + void ReturnSegment(ViewBufferValue[] segment); + + /// + /// Creates a that will delegate to the provided + /// . + /// + /// The . + /// A . + ViewBufferTextWriter CreateWriter(TextWriter writer); } } diff --git a/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Internal/MemoryPoolViewBufferScope.cs b/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Internal/MemoryPoolViewBufferScope.cs index 02a11e7695..362f5ab72e 100644 --- a/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Internal/MemoryPoolViewBufferScope.cs +++ b/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Internal/MemoryPoolViewBufferScope.cs @@ -4,6 +4,7 @@ using System; using System.Buffers; using System.Collections.Generic; +using System.IO; namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Internal { @@ -13,18 +14,25 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Internal public class MemoryPoolViewBufferScope : IViewBufferScope, IDisposable { public static readonly int SegmentSize = 512; - private readonly ArrayPool _pool; + private readonly ArrayPool _viewBufferPool; + private readonly ArrayPool _charPool; + private List _available; private List _leased; private bool _disposed; /// /// Initializes a new instance of . /// - /// The for creating - /// instances. - public MemoryPoolViewBufferScope(ArrayPool pool) + /// + /// The for creating instances. + /// + /// + /// The for creating instances. + /// + public MemoryPoolViewBufferScope(ArrayPool viewBufferPool, ArrayPool charPool) { - _pool = pool; + _viewBufferPool = viewBufferPool; + _charPool = charPool; } /// @@ -42,20 +50,57 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Internal ViewBufferValue[] segment = null; + // Reuse pages that have been returned before going back to the memory pool. + if (_available != null && _available.Count > 0) + { + segment = _available[_available.Count - 1]; + _available.RemoveAt(_available.Count - 1); + return segment; + } + try { - segment = _pool.Rent(SegmentSize); + segment = _viewBufferPool.Rent(SegmentSize); _leased.Add(segment); } catch when (segment != null) { - _pool.Return(segment); + _viewBufferPool.Return(segment); throw; } return segment; } + /// + public void ReturnSegment(ViewBufferValue[] segment) + { + if (segment == null) + { + throw new ArgumentNullException(nameof(segment)); + } + + Array.Clear(segment, 0, segment.Length); + + if (_available == null) + { + _available = new List(); + } + + _available.Add(segment); + } + + /// + public ViewBufferTextWriter CreateWriter(TextWriter writer) + { + if (writer == null) + { + throw new ArgumentNullException(nameof(writer)); + } + + return new ViewBufferTextWriter(_charPool, writer); + } + /// public void Dispose() { @@ -70,7 +115,7 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Internal for (var i = 0; i < _leased.Count; i++) { - _pool.Return(_leased[i]); + _viewBufferPool.Return(_leased[i]); } _leased.Clear(); diff --git a/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Internal/ViewBuffer.cs b/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Internal/ViewBuffer.cs index 137e9e8ada..b6fcb0ed1f 100644 --- a/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Internal/ViewBuffer.cs +++ b/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Internal/ViewBuffer.cs @@ -2,9 +2,11 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using System.Buffers; using System.Collections.Generic; using System.Diagnostics; using System.IO; +using System.Text; using System.Text.Encodings.Web; using System.Threading.Tasks; using Microsoft.AspNetCore.Html; @@ -35,17 +37,14 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Internal _bufferScope = bufferScope; _name = name; + + Pages = new List(); } /// /// Gets the backing buffer. /// - public IList BufferSegments { get; private set; } - - /// - /// Gets the count of entries in the last element of . - /// - public int CurrentCount { get; private set; } + public IList Pages { get; } /// public IHtmlContentBuilder Append(string unencoded) @@ -67,7 +66,48 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Internal return this; } - AppendValue(new ViewBufferValue(content)); + // Perf: special case ViewBuffers so we can 'combine' them. + var otherBuffer = content as ViewBuffer; + if (otherBuffer == null) + { + AppendValue(new ViewBufferValue(content)); + return this; + } + + for (var i = 0; i < otherBuffer.Pages.Count; i++) + { + var otherPage = otherBuffer.Pages[i]; + var currentPage = Pages.Count == 0 ? null : Pages[Pages.Count - 1]; + + // If the other page is less or equal to than half full, let's copy it's to the current page if + // possible. + var isLessThanHalfFull = 2 * otherPage.Count <= otherPage.Capacity; + if (isLessThanHalfFull && + currentPage != null && + currentPage.Capacity - currentPage.Count >= otherPage.Count) + { + // We have room, let's copy the items. + Array.Copy( + sourceArray: otherPage.Buffer, + sourceIndex: 0, + destinationArray: currentPage.Buffer, + destinationIndex: currentPage.Count, + length: otherPage.Count); + + currentPage.Count += otherPage.Count; + + // Now we can return this page, and it can be reused in the scope of this request. + _bufferScope.ReturnSegment(otherPage.Buffer); + } + else + { + // Otherwise, let's just take the the page from the other buffer. + Pages.Add(otherPage); + } + + } + + otherBuffer.Clear(); return this; } @@ -86,35 +126,37 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Internal private void AppendValue(ViewBufferValue value) { - ViewBufferValue[] segment; - if (BufferSegments == null) + var page = GetCurrentPage(); + page.Append(value); + } + + private ViewBufferPage GetCurrentPage() + { + ViewBufferPage page; + if (Pages.Count == 0) { - BufferSegments = new List(1); - segment = _bufferScope.GetSegment(); - BufferSegments.Add(segment); + page = new ViewBufferPage(_bufferScope.GetSegment()); + Pages.Add(page); } else { - segment = BufferSegments[BufferSegments.Count - 1]; - if (CurrentCount == segment.Length) + page = Pages[Pages.Count - 1]; + if (page.IsFull) { - segment = _bufferScope.GetSegment(); - BufferSegments.Add(segment); - CurrentCount = 0; + page = new ViewBufferPage(_bufferScope.GetSegment()); + Pages.Add(page); } } - segment[CurrentCount] = value; - CurrentCount++; + return page; } /// public IHtmlContentBuilder Clear() { - if (BufferSegments != null) + if (Pages != null) { - CurrentCount = 0; - BufferSegments = null; + Pages.Clear(); } return this; @@ -123,7 +165,7 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Internal /// public void WriteTo(TextWriter writer, HtmlEncoder encoder) { - if (BufferSegments == null) + if (Pages == null) { return; } @@ -135,14 +177,12 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Internal return; } - for (var i = 0; i < BufferSegments.Count; i++) + for (var i = 0; i < Pages.Count; i++) { - var segment = BufferSegments[i]; - var count = i == BufferSegments.Count - 1 ? CurrentCount : segment.Length; - - for (var j = 0; j < count; j++) + var page = Pages[i]; + for (var j = 0; j < page.Count; j++) { - var value = segment[j]; + var value = page.Buffer[j]; var valueAsString = value.Value as string; if (valueAsString != null) @@ -169,7 +209,7 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Internal /// A which will complete once content has been written. public async Task WriteToAsync(TextWriter writer, HtmlEncoder encoder) { - if (BufferSegments == null) + if (Pages == null) { return; } @@ -181,14 +221,12 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Internal return; } - for (var i = 0; i < BufferSegments.Count; i++) + for (var i = 0; i < Pages.Count; i++) { - var segment = BufferSegments[i]; - var count = i == BufferSegments.Count - 1 ? CurrentCount : segment.Length; - - for (var j = 0; j < count; j++) + var page = Pages[i]; + for (var j = 0; j < page.Count; j++) { - var value = segment[j]; + var value = page.Buffer[j]; var valueAsString = value.Value as string; if (valueAsString != null) @@ -208,6 +246,7 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Internal if (valueAsHtmlContent != null) { valueAsHtmlContent.WriteTo(writer, encoder); + await writer.FlushAsync(); continue; } } diff --git a/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Internal/ViewBufferPage.cs b/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Internal/ViewBufferPage.cs new file mode 100644 index 0000000000..b561e71825 --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Internal/ViewBufferPage.cs @@ -0,0 +1,23 @@ +// 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. + +namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Internal +{ + public class ViewBufferPage + { + public ViewBufferPage(ViewBufferValue[] buffer) + { + Buffer = buffer; + } + + public ViewBufferValue[] Buffer { get; } + + public int Capacity => Buffer.Length; + + public int Count { get; set; } + + public bool IsFull => Count == Capacity; + + public void Append(ViewBufferValue value) => Buffer[Count++] = value; + } +} diff --git a/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Internal/ViewBufferTextWriter.cs b/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Internal/ViewBufferTextWriter.cs new file mode 100644 index 0000000000..43110e459d --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Internal/ViewBufferTextWriter.cs @@ -0,0 +1,211 @@ +// 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.Buffers; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Internal +{ + public class ViewBufferTextWriter : TextWriter + { + public const int PageSize = 1024; + + private readonly TextWriter _inner; + private readonly List _pages; + private readonly ArrayPool _pool; + + private int _currentPage; + private int _currentIndex; // The next 'free' character + + public ViewBufferTextWriter(ArrayPool pool, TextWriter inner) + { + _pool = pool; + _inner = inner; + _pages = new List(); + } + + public override Encoding Encoding => _inner.Encoding; + + public override void Flush() + { + // Don't do anything. We'll call FlushAsync. + } + + public override async Task FlushAsync() + { + if (_pages.Count == 0) + { + return; + } + + for (var i = 0; i <= _currentPage; i++) + { + var page = _pages[i]; + + var count = i == _currentPage ? _currentIndex : page.Length; + if (count > 0) + { + await _inner.WriteAsync(page, 0, count); + } + } + + // Return all but one of the pages. This way if someone writes a large chunk of + // content, we can return those buffers and avoid holding them for the whole + // page's lifetime. + for (var i = _pages.Count - 1; i > 0; i--) + { + var page = _pages[i]; + + try + { + _pages.RemoveAt(i); + } + finally + { + _pool.Return(page); + } + } + + _currentPage = 0; + _currentIndex = 0; + } + + public override void Write(char value) + { + var page = GetCurrentPage(); + page[_currentIndex++] = value; + } + + public override void Write(char[] buffer) + { + Write(buffer, 0, buffer.Length); + } + + public override void Write(char[] buffer, int index, int count) + { + while (count > 0) + { + var page = GetCurrentPage(); + var copyLength = Math.Min(count, page.Length - _currentIndex); + Debug.Assert(copyLength > 0); + + Array.Copy( + buffer, + index, + page, + _currentIndex, + copyLength); + + _currentIndex += copyLength; + index += copyLength; + + count -= copyLength; + } + } + + public override void Write(string value) + { + var index = 0; + var count = value.Length; + + while (count > 0) + { + var page = GetCurrentPage(); + var copyLength = Math.Min(count, page.Length - _currentIndex); + Debug.Assert(copyLength > 0); + + value.CopyTo( + index, + page, + _currentIndex, + copyLength); + + _currentIndex += copyLength; + index += copyLength; + + count -= copyLength; + } + } + + public override Task WriteAsync(char value) + { + return _inner.WriteAsync(value); + } + + public override Task WriteAsync(char[] buffer, int index, int count) + { + return _inner.WriteAsync(buffer, index, count); + } + + public override Task WriteAsync(string value) + { + return _inner.WriteAsync(value); + } + + private char[] GetCurrentPage() + { + char[] page = null; + if (_pages.Count == 0) + { + Debug.Assert(_currentPage == 0); + Debug.Assert(_currentIndex == 0); + + try + { + page = _pool.Rent(PageSize); + _pages.Add(page); + } + catch when (page != null) + { + _pool.Return(page); + throw; + } + + return page; + } + + Debug.Assert(_pages.Count > _currentPage); + page = _pages[_currentPage]; + + if (_currentIndex == page.Length) + { + // Current page is full. + _currentPage++; + _currentIndex = 0; + + if (_pages.Count == _currentPage) + { + try + { + page = _pool.Rent(PageSize); + _pages.Add(page); + } + catch when (page != null) + { + _pool.Return(page); + throw; + } + } + } + + return page; + } + + protected override void Dispose(bool disposing) + { + base.Dispose(disposing); + + for (var i = 0; i < _pages.Count; i++) + { + _pool.Return(_pages[i]); + } + + _pages.Clear(); + } + } +} diff --git a/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Internal/ViewBufferValue.cs b/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Internal/ViewBufferValue.cs index 28f4e61e4a..12b32e8caf 100644 --- a/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Internal/ViewBufferValue.cs +++ b/src/Microsoft.AspNetCore.Mvc.ViewFeatures/Internal/ViewBufferValue.cs @@ -1,6 +1,9 @@ // 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.Diagnostics; +using System.IO; +using System.Text.Encodings.Web; using Microsoft.AspNetCore.Html; namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Internal @@ -8,6 +11,7 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Internal /// /// Encapsulates a string or value. /// + [DebuggerDisplay("{DebuggerToString()}")] public struct ViewBufferValue { /// @@ -32,5 +36,27 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Internal /// Gets the value. /// public object Value { get; } + + private string DebuggerToString() + { + using (var writer = new StringWriter()) + { + var valueAsString = Value as string; + if (valueAsString != null) + { + writer.Write(valueAsString); + return writer.ToString(); + } + + var valueAsContent = Value as IHtmlContent; + if (valueAsContent != null) + { + valueAsContent.WriteTo(writer, HtmlEncoder.Default); + return writer.ToString(); + } + + return "(null)"; + } + } } } diff --git a/test/Microsoft.AspNetCore.Mvc.Razor.Test/RazorTextWriterTest.cs b/test/Microsoft.AspNetCore.Mvc.Razor.Test/RazorTextWriterTest.cs index 2e87bbc503..ecb384a9f2 100644 --- a/test/Microsoft.AspNetCore.Mvc.Razor.Test/RazorTextWriterTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.Razor.Test/RazorTextWriterTest.cs @@ -62,7 +62,7 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Test writer.Write(2.718m); // Assert - Assert.Null(buffer.BufferSegments); + Assert.Null(buffer.Pages); foreach (var item in expected) { unbufferedWriter.Verify(v => v.Write(item), Times.Once()); @@ -90,7 +90,6 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Test await writer.WriteLineAsync(buffer1); // Assert - Assert.Null(buffer.BufferSegments); unbufferedWriter.Verify(v => v.Write('x'), Times.Once()); unbufferedWriter.Verify(v => v.Write(buffer1, 1, 2), Times.Once()); unbufferedWriter.Verify(v => v.Write(buffer1, 0, 4), Times.Once()); @@ -117,7 +116,6 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Test await writer.WriteLineAsync("gh"); // Assert - Assert.Null(buffer.BufferSegments); unbufferedWriter.Verify(v => v.Write("a"), Times.Once()); unbufferedWriter.Verify(v => v.WriteLine("ab"), Times.Once()); unbufferedWriter.Verify(v => v.WriteAsync("ef"), Times.Once()); @@ -160,7 +158,6 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Test writer.WriteLine(3L); // Assert - Assert.Null(buffer.BufferSegments); unbufferedWriter.Verify(v => v.Write("False"), Times.Once()); unbufferedWriter.Verify(v => v.Write("1.1"), Times.Once()); unbufferedWriter.Verify(v => v.Write("3"), Times.Once()); @@ -227,8 +224,8 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Test private static object[] GetValues(ViewBuffer buffer) { - return buffer.BufferSegments - .SelectMany(c => c) + return buffer.Pages + .SelectMany(c => c.Buffer) .Select(d => d.Value) .TakeWhile(d => d != null) .ToArray(); diff --git a/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Internal/TestViewBufferScope.cs b/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Internal/TestViewBufferScope.cs index e437ace6ef..c9163e9d5a 100644 --- a/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Internal/TestViewBufferScope.cs +++ b/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Internal/TestViewBufferScope.cs @@ -1,6 +1,10 @@ // 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.Buffers; +using System.Collections.Generic; +using System.IO; + namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Internal { public class TestViewBufferScope : IViewBufferScope @@ -13,6 +17,18 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Internal _bufferSize = bufferSize; } + public IList ReturnedBuffers { get; } = new List(); + public ViewBufferValue[] GetSegment() => new ViewBufferValue[_bufferSize]; + + public void ReturnSegment(ViewBufferValue[] segment) + { + ReturnedBuffers.Add(segment); + } + + public ViewBufferTextWriter CreateWriter(TextWriter writer) + { + return new ViewBufferTextWriter(ArrayPool.Shared, writer); + } } } diff --git a/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Internal/ViewBufferTest.cs b/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Internal/ViewBufferTest.cs index af3f0687d6..f46f385b29 100644 --- a/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Internal/ViewBufferTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/Internal/ViewBufferTest.cs @@ -1,6 +1,7 @@ // 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.Collections.Generic; using System.IO; using System.Linq; using System.Threading.Tasks; @@ -24,9 +25,9 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Internal buffer.Append("Hello world"); // Assert - var segment = Assert.Single(buffer.BufferSegments); - Assert.Equal(1, buffer.CurrentCount); - Assert.Equal("Hello world", segment[0].Value); + var page = Assert.Single(buffer.Pages); + Assert.Equal(1, page.Count); + Assert.Equal("Hello world", page.Buffer[0].Value); } [Fact] @@ -40,9 +41,9 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Internal buffer.AppendHtml(content); // Assert - var segment = Assert.Single(buffer.BufferSegments); - Assert.Equal(1, buffer.CurrentCount); - Assert.Same(content, segment[0].Value); + var page = Assert.Single(buffer.Pages); + Assert.Equal(1, page.Count); + Assert.Same(content, page.Buffer[0].Value); } [Fact] @@ -56,14 +57,14 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Internal buffer.AppendHtml(value); // Assert - var segment = Assert.Single(buffer.BufferSegments); - Assert.Equal(1, buffer.CurrentCount); - var htmlString = Assert.IsType(segment[0].Value); + var page = Assert.Single(buffer.Pages); + Assert.Equal(1, page.Count); + var htmlString = Assert.IsType(page.Buffer[0].Value); Assert.Equal("Hello world", htmlString.ToString()); } [Fact] - public void Append_CreatesNewSegments_WhenCurrentSegmentIsFull() + public void Append_CreatesNewPages_WhenCurrentPageIsFull() { // Arrange var buffer = new ViewBuffer(new TestViewBufferScope(), "some-name"); @@ -78,12 +79,12 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Internal buffer.Append("world"); // Assert - Assert.Equal(2, buffer.CurrentCount); - Assert.Collection(buffer.BufferSegments, - segment => Assert.Equal(expected, segment.Select(v => v.Value)), - segment => + Assert.Equal(2, buffer.Pages.Count); + Assert.Collection(buffer.Pages, + page => Assert.Equal(expected, page.Buffer.Select(v => v.Value)), + page => { - var array = segment; + var array = page.Buffer; Assert.Equal("Hello", array[0].Value); Assert.Equal("world", array[1].Value); }); @@ -106,9 +107,9 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Internal buffer.Append("world"); // Assert - var segment = Assert.Single(buffer.BufferSegments); - Assert.Equal(1, buffer.CurrentCount); - Assert.Equal("world", segment[0].Value); + var page = Assert.Single(buffer.Pages); + Assert.Equal(1, page.Count); + Assert.Equal("world", page.Buffer[0].Value); } [Fact] @@ -224,5 +225,220 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Internal // Assert Assert.Equal(expected, writer.ToString()); } + + [Fact] + public void AppendHtml_ViewBuffer_TakesPage_IfOriginalIsEmpty() + { + // Arrange + var scope = new TestViewBufferScope(4); + + var original = new ViewBuffer(scope, "original"); + var other = new ViewBuffer(scope, "other"); + + other.Append("Hi"); + + var page = other.Pages[0]; + + // Act + original.AppendHtml(other); + + // Assert + Assert.Empty(other.Pages); // Page was taken + Assert.Same(page, Assert.Single(original.Pages)); + } + + [Fact] + public void AppendHtml_ViewBuffer_TakesPage_IfCurrentPageInOriginalIsFull() + { + // Arrange + var scope = new TestViewBufferScope(4); + + var original = new ViewBuffer(scope, "original"); + var other = new ViewBuffer(scope, "other"); + + for (var i = 0; i < 4; i++) + { + original.Append($"original-{i}"); + } + + other.Append("Hi"); + + var page = other.Pages[0]; + + // Act + original.AppendHtml(other); + + // Assert + Assert.Empty(other.Pages); // Page was taken + Assert.Equal(2, original.Pages.Count); + Assert.Same(page, original.Pages[1]); + } + + [Fact] + public void AppendHtml_ViewBuffer_TakesPage_IfCurrentPageDoesNotHaveCapacity() + { + // Arrange + var scope = new TestViewBufferScope(4); + + var original = new ViewBuffer(scope, "original"); + var other = new ViewBuffer(scope, "other"); + + for (var i = 0; i < 3; i++) + { + original.Append($"original-{i}"); + } + + // With two items, we'd try to copy the items, but there's no room in the current page. + // So we just take over the page. + for (var i = 0; i < 2; i++) + { + other.Append($"other-{i}"); + } + + var page = other.Pages[0]; + + // Act + original.AppendHtml(other); + + // Assert + Assert.Empty(other.Pages); // Page was taken + Assert.Equal(2, original.Pages.Count); + Assert.Same(page, original.Pages[1]); + } + + [Fact] + public void AppendHtml_ViewBuffer_CopiesItems_IfCurrentPageHasRoom() + { + // Arrange + var scope = new TestViewBufferScope(4); + + var original = new ViewBuffer(scope, "original"); + var other = new ViewBuffer(scope, "other"); + + for (var i = 0; i < 2; i++) + { + original.Append($"original-{i}"); + } + + // With two items, this is half full so we try to copy the items. + for (var i = 0; i < 2; i++) + { + other.Append($"other-{i}"); + } + + var page = other.Pages[0]; + + // Act + original.AppendHtml(other); + + // Assert + Assert.Empty(other.Pages); // Other is cleared + Assert.Contains(page.Buffer, scope.ReturnedBuffers); // Buffer was returned + + Assert.Collection( + Assert.Single(original.Pages).Buffer, + item => Assert.Equal("original-0", item.Value), + item => Assert.Equal("original-1", item.Value), + item => Assert.Equal("other-0", item.Value), + item => Assert.Equal("other-1", item.Value)); + } + + [Fact] + public void AppendHtml_ViewBuffer_CanAddToTakenPage() + { + // Arrange + var scope = new TestViewBufferScope(4); + + var original = new ViewBuffer(scope, "original"); + var other = new ViewBuffer(scope, "other"); + + for (var i = 0; i < 3; i++) + { + original.Append($"original-{i}"); + } + + // More than half full, so we take the page + for (var i = 0; i < 3; i++) + { + other.Append($"other-{i}"); + } + + var page = other.Pages[0]; + original.AppendHtml(other); + + // Act + original.Append("after-merge"); + + // Assert + Assert.Empty(other.Pages); // Other is cleared + + Assert.Equal(2, original.Pages.Count); + Assert.Collection( + original.Pages[0].Buffer, + item => Assert.Equal("original-0", item.Value), + item => Assert.Equal("original-1", item.Value), + item => Assert.Equal("original-2", item.Value), + item => Assert.Null(item.Value)); + Assert.Collection( + original.Pages[1].Buffer, + item => Assert.Equal("other-0", item.Value), + item => Assert.Equal("other-1", item.Value), + item => Assert.Equal("other-2", item.Value), + item => Assert.Equal("after-merge", item.Value)); + } + + [Fact] + public void AppendHtml_ViewBuffer_MultiplePages() + { + // Arrange + var scope = new TestViewBufferScope(4); + + var original = new ViewBuffer(scope, "original"); + var other = new ViewBuffer(scope, "other"); + + for (var i = 0; i < 2; i++) + { + original.Append($"original-{i}"); + } + + for (var i = 0; i < 9; i++) + { + other.Append($"other-{i}"); + } + + var pages = new List(other.Pages); + + // Act + original.AppendHtml(other); + + // Assert + Assert.Empty(other.Pages); // Other is cleared + + Assert.Equal(4, original.Pages.Count); + Assert.Collection( + original.Pages[0].Buffer, + item => Assert.Equal("original-0", item.Value), + item => Assert.Equal("original-1", item.Value), + item => Assert.Null(item.Value), + item => Assert.Null(item.Value)); + Assert.Collection( + original.Pages[1].Buffer, + item => Assert.Equal("other-0", item.Value), + item => Assert.Equal("other-1", item.Value), + item => Assert.Equal("other-2", item.Value), + item => Assert.Equal("other-3", item.Value)); + Assert.Collection( + original.Pages[2].Buffer, + item => Assert.Equal("other-4", item.Value), + item => Assert.Equal("other-5", item.Value), + item => Assert.Equal("other-6", item.Value), + item => Assert.Equal("other-7", item.Value)); + Assert.Collection( + original.Pages[3].Buffer, + item => Assert.Equal("other-8", item.Value), + item => Assert.Null(item.Value), + item => Assert.Null(item.Value), + item => Assert.Null(item.Value)); + } } } diff --git a/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/ViewBufferTextWriterTest.cs b/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/ViewBufferTextWriterTest.cs new file mode 100644 index 0000000000..0ff27fc1c1 --- /dev/null +++ b/test/Microsoft.AspNetCore.Mvc.ViewFeatures.Test/ViewBufferTextWriterTest.cs @@ -0,0 +1,176 @@ +// 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.Buffers; +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Internal +{ + public class ViewBufferTextWriterTest + { + private static readonly char[] Content; + + static ViewBufferTextWriterTest() + { + Content = new char[4 * ViewBufferTextWriter.PageSize]; + for (var i = 0; i < Content.Length; i++) + { + Content[i] = (char)((i % 26) + 'A'); + } + } + + [Fact] + public async Task Write_Char() + { + // Arrange + var pool = new TestArrayPool(); + var inner = new StringWriter(); + + var writer = new ViewBufferTextWriter(pool, inner); + + // Act + for (var i = 0; i < Content.Length; i++) + { + writer.Write(Content[i]); + } + + await writer.FlushAsync(); + + // Assert + Assert.Equal(Content, inner.ToString().ToCharArray()); + } + + [Fact] + public async Task Write_CharArray() + { + // Arrange + var pool = new TestArrayPool(); + var inner = new StringWriter(); + + var writer = new ViewBufferTextWriter(pool, inner); + + // These numbers chosen to hit boundary conditions in buffer lengths + Assert.Equal(4096, Content.Length); // Update these numbers if this changes. + var chunkSizes = new int[] { 3, 1021, 1023, 1023, 1, 1, 1024 }; + + // Act + var offset = 0; + foreach (var chunkSize in chunkSizes) + { + var chunk = new char[chunkSize]; + for (var j = 0; j < chunkSize; j++) + { + chunk[j] = Content[offset + j]; + } + + writer.Write(chunk); + offset += chunkSize; + } + + await writer.FlushAsync(); + + // Assert + var array = inner.ToString().ToCharArray(); + for (var i = 0; i < Content.Length; i++) + { + Assert.Equal(Content[i], array[i]); + } + + Assert.Equal(Content, inner.ToString().ToCharArray()); + } + + [Fact] + public async Task Write_CharArray_Bounded() + { + // Arrange + var pool = new TestArrayPool(); + var inner = new StringWriter(); + + var writer = new ViewBufferTextWriter(pool, inner); + + // These numbers chosen to hit boundary conditions in buffer lengths + Assert.Equal(4096, Content.Length); // Update these numbers if this changes. + var chunkSizes = new int[] { 3, 1021, 1023, 1023, 1, 1, 1024 }; + + // Act + var offset = 0; + foreach (var chunkSize in chunkSizes) + { + writer.Write(Content, offset, chunkSize); + offset += chunkSize; + } + + await writer.FlushAsync(); + + // Assert + Assert.Equal(Content, inner.ToString().ToCharArray()); + } + + [Fact] + public async Task Write_String() + { + // Arrange + var pool = new TestArrayPool(); + var inner = new StringWriter(); + + var writer = new ViewBufferTextWriter(pool, inner); + + // These numbers chosen to hit boundary conditions in buffer lengths + Assert.Equal(4096, Content.Length); // Update these numbers if this changes. + var chunkSizes = new int[] { 3, 1021, 1023, 1023, 1, 1, 1024 }; + + // Act + var offset = 0; + foreach (var chunkSize in chunkSizes) + { + var chunk = new string(Content, offset, chunkSize); + writer.Write(chunk); + offset += chunkSize; + } + + await writer.FlushAsync(); + + // Assert + Assert.Equal(Content, inner.ToString().ToCharArray()); + } + + [Fact] + public async Task FlushAsync_ReturnsPages() + { + // Arrange + var pool = new TestArrayPool(); + var inner = new StringWriter(); + + var writer = new ViewBufferTextWriter(pool, inner); + + for (var i = 0; i < Content.Length; i++) + { + writer.Write(Content[i]); + } + + // Act + await writer.FlushAsync(); + + // Assert + Assert.Equal(3, pool.Returned.Count); + } + + private class TestArrayPool: ArrayPool + { + public IList Returned { get; } = new List(); + + public override char[] Rent(int minimumLength) + { + return new char[minimumLength]; + } + + public override void Return(char[] buffer, bool clearArray = false) + { + Returned.Add(buffer); + } + } + } +}