Buffer rendered CacheTagHelper content strings to a custom TextWriter

Fixes #4893
This commit is contained in:
Pranav K 2016-09-02 16:33:46 -07:00
parent a480378a44
commit 43071319aa
8 changed files with 483 additions and 145 deletions

View File

@ -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.Collections.Generic;
using System.IO;
using System.Text;
using System.Text.Encodings.Web;
@ -9,6 +10,7 @@ using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Html;
using Microsoft.AspNetCore.Mvc.TagHelpers.Cache;
using Microsoft.AspNetCore.Mvc.ViewFeatures.Internal;
using Microsoft.AspNetCore.Razor.TagHelpers;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Primitives;
@ -174,29 +176,62 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
{
var content = await output.GetChildContentAsync();
var stringBuilder = new StringBuilder();
using (var writer = new StringWriter(stringBuilder))
using (var writer = new CharBufferTextWriter())
{
content.WriteTo(writer, HtmlEncoder);
return new CharBufferHtmlContent(writer.Buffer);
}
return new StringBuilderHtmlContent(stringBuilder);
}
private class StringBuilderHtmlContent : IHtmlContent
private class CharBufferTextWriter : TextWriter
{
private readonly StringBuilder _builder;
public StringBuilderHtmlContent(StringBuilder builder)
public CharBufferTextWriter()
{
_builder = builder;
Buffer = new PagedCharBuffer(CharArrayBufferSource.Instance);
}
public override Encoding Encoding => Null.Encoding;
public PagedCharBuffer Buffer { get; }
public override void Write(char value)
{
Buffer.Append(value);
}
public override void Write(char[] buffer, int index, int count)
{
Buffer.Append(buffer, index, count);
}
public override void Write(string value)
{
Buffer.Append(value);
}
}
private class CharBufferHtmlContent : IHtmlContent
{
private readonly PagedCharBuffer _buffer;
public CharBufferHtmlContent(PagedCharBuffer buffer)
{
_buffer = buffer;
}
public void WriteTo(TextWriter writer, HtmlEncoder encoder)
{
for (var i = 0; i < _builder.Length; i++)
var length = _buffer.Length;
if (length == 0)
{
writer.Write(_builder[i]);
return;
}
for (var i = 0; i < _buffer.Pages.Count; i++)
{
var pageLength = Math.Min(length, PagedCharBuffer.PageSize);
writer.Write(_buffer.Pages[i], 0, pageLength);
length -= pageLength;
}
}
}

View File

@ -0,0 +1,21 @@
// 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;
namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Internal
{
public class ArrayPoolBufferSource : ICharBufferSource
{
private readonly ArrayPool<char> _pool;
public ArrayPoolBufferSource(ArrayPool<char> pool)
{
_pool = pool;
}
public char[] Rent(int bufferSize) => _pool.Rent(bufferSize);
public void Return(char[] buffer) => _pool.Return(buffer);
}
}

View File

@ -0,0 +1,17 @@
// 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 CharArrayBufferSource : ICharBufferSource
{
public static readonly CharArrayBufferSource Instance = new CharArrayBufferSource();
public char[] Rent(int bufferSize) => new char[bufferSize];
public void Return(char[] buffer)
{
// Do nothing.
}
}
}

View File

@ -0,0 +1,12 @@
// 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 interface ICharBufferSource
{
char[] Rent(int bufferSize);
void Return(char[] buffer);
}
}

View File

@ -3,8 +3,6 @@
using System;
using System.Buffers;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Text;
using System.Threading.Tasks;
@ -13,20 +11,13 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Internal
{
public class PagedBufferedTextWriter : 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
private readonly PagedCharBuffer _charBuffer;
public PagedBufferedTextWriter(ArrayPool<char> pool, TextWriter inner)
{
_pool = pool;
_charBuffer = new PagedCharBuffer(new ArrayPoolBufferSource(pool));
_inner = inner;
_pages = new List<char[]>();
}
public override Encoding Encoding => _inner.Encoding;
@ -38,47 +29,31 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Internal
public override async Task FlushAsync()
{
if (_pages.Count == 0)
var length = _charBuffer.Length;
if (length == 0)
{
return;
}
for (var i = 0; i <= _currentPage; i++)
var pages = _charBuffer.Pages;
for (var i = 0; i < pages.Count; i++)
{
var page = _pages[i];
var page = pages[i];
var count = i == _currentPage ? _currentIndex : page.Length;
if (count > 0)
var pageLength = Math.Min(length, PagedCharBuffer.PageSize);
if (pageLength != 0)
{
await _inner.WriteAsync(page, 0, count);
await _inner.WriteAsync(page, 0, pageLength);
}
length -= pageLength;
}
// 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;
_charBuffer.Clear();
}
public override void Write(char value)
{
var page = GetCurrentPage();
page[_currentIndex++] = value;
_charBuffer.Append(value);
}
public override void Write(char[] buffer)
@ -88,7 +63,7 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Internal
return;
}
Write(buffer, 0, buffer.Length);
_charBuffer.Append(buffer, 0, buffer.Length);
}
public override void Write(char[] buffer, int index, int count)
@ -98,24 +73,7 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Internal
throw new ArgumentNullException(nameof(buffer));
}
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;
}
_charBuffer.Append(buffer, index, count);
}
public override void Write(string value)
@ -125,26 +83,7 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Internal
return;
}
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;
}
_charBuffer.Append(value);
}
public override Task WriteAsync(char value)
@ -162,65 +101,10 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Internal
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();
_charBuffer.Dispose();
}
}
}

View File

@ -0,0 +1,159 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.Collections.Generic;
using System.Diagnostics;
namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Internal
{
public class PagedCharBuffer : IDisposable
{
public const int PageSize = 1024;
private int _charIndex;
public PagedCharBuffer(ICharBufferSource bufferSource)
{
BufferSource = bufferSource;
}
public ICharBufferSource BufferSource { get; }
public IList<char[]> Pages { get; } = new List<char[]>();
public int Length
{
get
{
var length = _charIndex;
if (Pages.Count > 1)
{
length += PageSize * (Pages.Count - 1);
}
return length;
}
}
private char[] CurrentPage { get; set; }
public void Append(char value)
{
var page = GetCurrentPage();
page[_charIndex++] = value;
}
public void Append(string value)
{
if (value == null)
{
return;
}
var index = 0;
var count = value.Length;
while (count > 0)
{
var page = GetCurrentPage();
var copyLength = Math.Min(count, page.Length - _charIndex);
Debug.Assert(copyLength > 0);
value.CopyTo(
index,
page,
_charIndex,
copyLength);
_charIndex += copyLength;
index += copyLength;
count -= copyLength;
}
}
public void Append(char[] buffer, int index, int count)
{
while (count > 0)
{
var page = GetCurrentPage();
var copyLength = Math.Min(count, page.Length - _charIndex);
Debug.Assert(copyLength > 0);
Array.Copy(
buffer,
index,
page,
_charIndex,
copyLength);
_charIndex += copyLength;
index += copyLength;
count -= copyLength;
}
}
/// <summary>
/// Return all but one of the pages to the <see cref="ICharBufferSource"/>.
/// This way if someone writes a large chunk of content, we can return those buffers and avoid holding them
/// for extended durations.
/// </summary>
public void Clear()
{
for (var i = Pages.Count - 1; i > 0; i--)
{
var page = Pages[i];
try
{
Pages.RemoveAt(i);
}
finally
{
BufferSource.Return(page);
}
}
_charIndex = 0;
}
private char[] GetCurrentPage()
{
if (CurrentPage == null ||
_charIndex == CurrentPage.Length)
{
CurrentPage = NewPage();
_charIndex = 0;
}
return CurrentPage;
}
private char[] NewPage()
{
char[] page = null;
try
{
page = BufferSource.Rent(PageSize);
Pages.Add(page);
}
catch when (page != null)
{
BufferSource.Return(page);
throw;
}
return page;
}
public void Dispose()
{
for (var i = 0; i < Pages.Count; i++)
{
BufferSource.Return(Pages[i]);
}
Pages.Clear();
}
}
}

View File

@ -16,7 +16,7 @@ namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Internal
static PagedBufferedTextWriterTest()
{
Content = new char[4 * PagedBufferedTextWriter.PageSize];
Content = new char[4 * PagedCharBuffer.PageSize];
for (var i = 0; i < Content.Length; i++)
{
Content[i] = (char)((i % 26) + 'A');

View File

@ -0,0 +1,210 @@
// 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.Linq;
using Moq;
using Xunit;
namespace Microsoft.AspNetCore.Mvc.ViewFeatures.Internal
{
public class PagedCharBufferTest
{
[Fact]
public void AppendWithChar_AddsCharacterToPage()
{
// Arrange
var charToAppend = 't';
var buffer = new PagedCharBuffer(new CharArrayBufferSource());
// Act
buffer.Append(charToAppend);
// Assert
var page = Assert.Single(buffer.Pages);
Assert.Equal(1, buffer.Length);
Assert.Equal(charToAppend, page[buffer.Length - 1]);
}
[Fact]
public void AppendWithChar_AddsCharacterToTheLastPage()
{
// Arrange
var stringToAppend = new string('a', PagedCharBuffer.PageSize);
var charToAppend = 't';
var buffer = new PagedCharBuffer(new CharArrayBufferSource());
// Act
buffer.Append(stringToAppend);
buffer.Append(charToAppend);
// Assert
Assert.Collection(buffer.Pages,
page => Assert.Equal(stringToAppend.ToCharArray(), page),
page => Assert.Equal(charToAppend, page[0]));
Assert.Equal(1 + PagedCharBuffer.PageSize, buffer.Length);
}
[Fact]
public void AppendWithChar_AppendsToTheCurrentPageIfItIsNotFull()
{
// Arrange
var stringToAppend = "abc";
var charToAppend = 't';
var buffer = new PagedCharBuffer(new CharArrayBufferSource());
// Act
buffer.Append(stringToAppend);
buffer.Append(charToAppend);
// Assert
var page = Assert.Single(buffer.Pages);
Assert.Equal(new[] { 'a', 'b', 'c', 't' }, page.Take(4));
Assert.Equal(4, buffer.Length);
}
[Fact]
public void AppendWithString_AppendsToPage()
{
// Arrange
var stringToAppend = "abc";
var buffer = new PagedCharBuffer(new CharArrayBufferSource());
// Act
buffer.Append(stringToAppend);
// Assert
Assert.Equal(3, buffer.Length);
var page = Assert.Single(buffer.Pages);
Assert.Equal(new[] { 'a', 'b', 'c' }, page.Take(buffer.Length));
}
[Fact]
public void AppendWithString_AcrossMultiplePages()
{
// Arrange
var length = 2 * PagedCharBuffer.PageSize + 1;
var expected = Enumerable.Repeat('d', PagedCharBuffer.PageSize);
var stringToAppend = new string('d', length);
var buffer = new PagedCharBuffer(new CharArrayBufferSource());
// Act
buffer.Append(stringToAppend);
// Assert
Assert.Equal(length, buffer.Length);
Assert.Collection(
buffer.Pages,
page => Assert.Equal(expected, page),
page => Assert.Equal(expected, page),
page => Assert.Equal('d', page[0]));
}
[Fact]
public void AppendWithString_AppendsToTheCurrentPageIfItIsNotEmpty()
{
// Arrange
var string1 = "abc";
var string2 = "def";
var buffer = new PagedCharBuffer(new CharArrayBufferSource());
// Act
buffer.Append(string1);
buffer.Append(string2);
// Assert
Assert.Equal(6, buffer.Length);
var page = Assert.Single(buffer.Pages);
Assert.Equal(new[] { 'a', 'b', 'c', 'd', 'e', 'f' }, page.Take(buffer.Length));
}
[Fact]
public void AppendWithCharArray_AppendsToPage()
{
// Arrange
var charsToAppend = new[] { 'a', 'b', 'c', 'd' };
var buffer = new PagedCharBuffer(new CharArrayBufferSource());
// Act
buffer.Append(charsToAppend, 1, 3);
// Assert
Assert.Equal(3, buffer.Length);
var page = Assert.Single(buffer.Pages);
Assert.Equal(new[] { 'b', 'c', 'd' }, page.Take(buffer.Length));
}
[Fact]
public void AppendWithCharArray_AppendsToMultiplePages()
{
// Arrange
var ch = 'e';
var length = PagedCharBuffer.PageSize * 2 + 3;
var charsToAppend = Enumerable.Repeat(ch, 2 * length).ToArray();
var expected = Enumerable.Repeat(ch, PagedCharBuffer.PageSize);
var buffer = new PagedCharBuffer(new CharArrayBufferSource());
// Act
buffer.Append(charsToAppend, 0, length);
// Assert
Assert.Equal(length, buffer.Length);
Assert.Collection(buffer.Pages,
page => Assert.Equal(expected, page),
page => Assert.Equal(expected, page),
page =>
{
Assert.Equal(ch, page[0]);
Assert.Equal(ch, page[1]);
Assert.Equal(ch, page[2]);
});
}
[Fact]
public void AppendWithCharArray_AppendsToCurrentPage()
{
// Arrange
var arrayToAppend = new[] { 'c', 'd', 'e' };
var buffer = new PagedCharBuffer(new CharArrayBufferSource());
// Act
buffer.Append("Ab");
buffer.Append(arrayToAppend, 0, arrayToAppend.Length);
// Assert
Assert.Equal(5, buffer.Length);
var page = Assert.Single(buffer.Pages);
Assert.Equal(new[] { 'A', 'b', 'c', 'd', 'e' }, page.Take(buffer.Length));
}
[Fact]
public void Clear_WorksIfBufferHasNotBeenWrittenTo()
{
// Arrange
var buffer = new PagedCharBuffer(new CharArrayBufferSource());
// Act
buffer.Clear();
// Assert
Assert.Equal(0, buffer.Length);
}
[Fact]
public void Clear_ReturnsPagesToBufferSource()
{
// Arrange
var bufferSource = new Mock<ICharBufferSource>();
bufferSource.Setup(s => s.Rent(PagedCharBuffer.PageSize))
.Returns(new char[PagedCharBuffer.PageSize]);
var buffer = new PagedCharBuffer(bufferSource.Object);
// Act
buffer.Append(new string('a', PagedCharBuffer.PageSize * 3 + 4));
buffer.Clear();
// Assert
Assert.Equal(0, buffer.Length);
bufferSource.Verify(s => s.Return(It.IsAny<char[]>()), Times.Exactly(3));
}
}
}