Improve buffering of Razor output in MVC
These changes are aimed at significantly improving the performance of MVC/Razor when a large amount of content is in play or a large number of TagHelpers are used. A few issues addressed: - Buffer sync writes after a flush has occurred so that we can write them asyncronously. The issue is that an IHtmlContent can only do sync writes. This is very bad for Kestrel in general. Doing these writes async is better for our overall perf, and the buffer that we use for it is from the pool. - 'Flatten' ViewBuffers when possible. A page with lots of TagHelpers can end up renting a ViewBuffer and only write 2-3 things into it. When a ViewBuffer sees another ViewBuffer we can either steal its pages, or copy data out and 'return' its pages. This lets us use 3-4 buffers for a large Razor page instead of hundreds.
This commit is contained in:
parent
6f885c7113
commit
434da683fc
|
|
@ -98,15 +98,9 @@ namespace Microsoft.AspNetCore.Mvc.Razor
|
|||
/// <inheritdoc />
|
||||
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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
/// <summary>
|
||||
|
|
@ -13,5 +15,19 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Internal
|
|||
/// </summary>
|
||||
/// <returns>The <see cref="ViewBufferValue[]"/>.</returns>
|
||||
ViewBufferValue[] GetSegment();
|
||||
|
||||
/// <summary>
|
||||
/// Returns a <see cref="ViewBufferValue[]"/> that can be reused.
|
||||
/// </summary>
|
||||
/// <param name="segment">The <see cref="ViewBufferValue[]"/>.</param>
|
||||
void ReturnSegment(ViewBufferValue[] segment);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a <see cref="ViewBufferTextWriter"/> that will delegate to the provided
|
||||
/// <paramref name="writer"/>.
|
||||
/// </summary>
|
||||
/// <param name="writer">The <see cref="TextWriter"/>.</param>
|
||||
/// <returns>A <see cref="ViewBufferTextWriter"/>.</returns>
|
||||
ViewBufferTextWriter CreateWriter(TextWriter writer);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ViewBufferValue> _pool;
|
||||
private readonly ArrayPool<ViewBufferValue> _viewBufferPool;
|
||||
private readonly ArrayPool<char> _charPool;
|
||||
private List<ViewBufferValue[]> _available;
|
||||
private List<ViewBufferValue[]> _leased;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of <see cref="MemoryPoolViewBufferScope"/>.
|
||||
/// </summary>
|
||||
/// <param name="pool">The <see cref="ArrayPool{ViewBufferValue}"/> for creating
|
||||
/// <see cref="ViewBufferValue"/> instances.</param>
|
||||
public MemoryPoolViewBufferScope(ArrayPool<ViewBufferValue> pool)
|
||||
/// <param name="viewBufferPool">
|
||||
/// The <see cref="ArrayPool{ViewBufferValue}"/> for creating <see cref="ViewBufferValue"/> instances.
|
||||
/// </param>
|
||||
/// <param name="charPool">
|
||||
/// The <see cref="ArrayPool{char}"/> for creating <see cref="ViewBufferTextWriter"/> instances.
|
||||
/// </param>
|
||||
public MemoryPoolViewBufferScope(ArrayPool<ViewBufferValue> viewBufferPool, ArrayPool<char> charPool)
|
||||
{
|
||||
_pool = pool;
|
||||
_viewBufferPool = viewBufferPool;
|
||||
_charPool = charPool;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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<ViewBufferValue[]>();
|
||||
}
|
||||
|
||||
_available.Add(segment);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ViewBufferTextWriter CreateWriter(TextWriter writer)
|
||||
{
|
||||
if (writer == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(writer));
|
||||
}
|
||||
|
||||
return new ViewBufferTextWriter(_charPool, writer);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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<ViewBufferPage>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the backing buffer.
|
||||
/// </summary>
|
||||
public IList<ViewBufferValue[]> BufferSegments { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the count of entries in the last element of <see cref="BufferSegments"/>.
|
||||
/// </summary>
|
||||
public int CurrentCount { get; private set; }
|
||||
public IList<ViewBufferPage> Pages { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
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<ViewBufferValue[]>(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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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
|
|||
/// <inheritdoc />
|
||||
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
|
|||
/// <returns>A <see cref="Task"/> which will complete once content has been written.</returns>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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<char[]> _pages;
|
||||
private readonly ArrayPool<char> _pool;
|
||||
|
||||
private int _currentPage;
|
||||
private int _currentIndex; // The next 'free' character
|
||||
|
||||
public ViewBufferTextWriter(ArrayPool<char> pool, TextWriter inner)
|
||||
{
|
||||
_pool = pool;
|
||||
_inner = inner;
|
||||
_pages = new List<char[]>();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
|||
/// <summary>
|
||||
/// Encapsulates a string or <see cref="IHtmlContent"/> value.
|
||||
/// </summary>
|
||||
[DebuggerDisplay("{DebuggerToString()}")]
|
||||
public struct ViewBufferValue
|
||||
{
|
||||
/// <summary>
|
||||
|
|
@ -32,5 +36,27 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Internal
|
|||
/// Gets the value.
|
||||
/// </summary>
|
||||
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)";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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<ViewBufferValue[]> ReturnedBuffers { get; } = new List<ViewBufferValue[]>();
|
||||
|
||||
public ViewBufferValue[] GetSegment() => new ViewBufferValue[_bufferSize];
|
||||
|
||||
public void ReturnSegment(ViewBufferValue[] segment)
|
||||
{
|
||||
ReturnedBuffers.Add(segment);
|
||||
}
|
||||
|
||||
public ViewBufferTextWriter CreateWriter(TextWriter writer)
|
||||
{
|
||||
return new ViewBufferTextWriter(ArrayPool<char>.Shared, writer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<HtmlString>(segment[0].Value);
|
||||
var page = Assert.Single(buffer.Pages);
|
||||
Assert.Equal(1, page.Count);
|
||||
var htmlString = Assert.IsType<HtmlString>(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<ViewBufferPage>(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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<char>(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<char>(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<char>(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<char>(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<char>
|
||||
{
|
||||
public IList<char[]> Returned { get; } = new List<char[]>();
|
||||
|
||||
public override char[] Rent(int minimumLength)
|
||||
{
|
||||
return new char[minimumLength];
|
||||
}
|
||||
|
||||
public override void Return(char[] buffer, bool clearArray = false)
|
||||
{
|
||||
Returned.Add(buffer);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue