Refactoring CacheTagHelper

- Introducing a new distributed cache tag helper
- Sharing base implementation for both cache tag helper
- Preventing concurrent execution of cache tag helpers

Fixes #4147 , Fixes #3867
This commit is contained in:
Sebastien Ros 2016-03-14 18:12:05 -07:00
parent c03aabbff5
commit ec560bdfe0
15 changed files with 2031 additions and 305 deletions

View File

@ -0,0 +1,47 @@
// 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.Text;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc.Rendering;
namespace Microsoft.AspNetCore.Mvc.TagHelpers.Cache
{
/// <summary>
/// Implements <see cref="IDistributedCacheTagHelperFormatter"/> by serializing the content
/// in UTF8.
/// </summary>
public class DistributedCacheTagHelperFormatter : IDistributedCacheTagHelperFormatter
{
/// <inheritdoc />
public Task<byte[]> SerializeAsync(DistributedCacheTagHelperFormattingContext context)
{
if (context == null)
{
throw new ArgumentNullException(nameof(context));
}
if (context.Html == null)
{
throw new ArgumentNullException(nameof(context.Html));
}
var serialized = Encoding.UTF8.GetBytes(context.Html.ToString());
return Task.FromResult(serialized);
}
/// <inheritdoc />
public Task<HtmlString> DeserializeAsync(byte[] value)
{
if (value == null)
{
throw new ArgumentNullException(nameof(value));
}
var content = Encoding.UTF8.GetString(value);
return Task.FromResult(new HtmlString(content));
}
}
}

View File

@ -0,0 +1,18 @@
// 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 Microsoft.AspNetCore.Mvc.Rendering;
namespace Microsoft.AspNetCore.Mvc.TagHelpers.Cache
{
/// <summary>
/// Represents an object containing the information to serialize with <see cref="IDistributedCacheTagHelperFormatter" />.
/// </summary>
public class DistributedCacheTagHelperFormattingContext
{
/// <summary>
/// Gets the <see cref="HtmlString"/> instance.
/// </summary>
public HtmlString Html { get; set; }
}
}

View File

@ -0,0 +1,116 @@
// 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.Concurrent;
using System.IO;
using System.Text;
using System.Text.Encodings.Web;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Html;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Razor.TagHelpers;
using Microsoft.Extensions.Caching.Distributed;
namespace Microsoft.AspNetCore.Mvc.TagHelpers.Cache
{
/// <summary>
/// Implements <see cref="IDistributedCacheTagHelperService"/> and ensure
/// multiple concurrent requests are gated.
/// </summary>
public class DistributedCacheTagHelperService : IDistributedCacheTagHelperService
{
private readonly IDistributedCacheTagHelperStorage _storage;
private readonly IDistributedCacheTagHelperFormatter _formatter;
private readonly HtmlEncoder _htmlEncoder;
private readonly ConcurrentDictionary<string, Task<IHtmlContent>> _workers;
public DistributedCacheTagHelperService(
IDistributedCacheTagHelperStorage storage,
IDistributedCacheTagHelperFormatter formatter,
HtmlEncoder HtmlEncoder
)
{
_formatter = formatter;
_storage = storage;
_htmlEncoder = HtmlEncoder;
_workers = new ConcurrentDictionary<string, Task<IHtmlContent>>();
}
/// <inheritdoc />
public async Task<IHtmlContent> ProcessContentAsync(TagHelperOutput output, string key, DistributedCacheEntryOptions options)
{
IHtmlContent content = null;
while (content == null)
{
Task<IHtmlContent> result = null;
// Is there any request already processing the value?
if (!_workers.TryGetValue(key, out result))
{
var tcs = new TaskCompletionSource<IHtmlContent>();
_workers.TryAdd(key, tcs.Task);
try
{
var value = await _storage.GetAsync(key);
if (value == null)
{
var processedContent = await output.GetChildContentAsync();
var stringBuilder = new StringBuilder();
using (var writer = new StringWriter(stringBuilder))
{
processedContent.WriteTo(writer, _htmlEncoder);
}
var formattingContext = new DistributedCacheTagHelperFormattingContext
{
Html = new HtmlString(stringBuilder.ToString())
};
value = await _formatter.SerializeAsync(formattingContext);
await _storage.SetAsync(key, value, options);
content = formattingContext.Html;
}
else
{
content = await _formatter.DeserializeAsync(value);
// If the deserialization fails, it can return null, for instance when the
// value is not in the expected format.
if (content == null)
{
content = await output.GetChildContentAsync();
}
}
tcs.TrySetResult(content);
}
catch
{
tcs.TrySetResult(null);
throw;
}
finally
{
// Remove the worker task from the in-memory cache
Task<IHtmlContent> worker;
_workers.TryRemove(key, out worker);
}
}
else
{
content = await result;
}
}
return content;
}
}
}

View File

@ -0,0 +1,54 @@
// 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.Threading.Tasks;
using Microsoft.Extensions.Caching.Distributed;
namespace Microsoft.AspNetCore.Mvc.TagHelpers.Cache
{
/// <summary>
/// Implements <see cref="IDistributedCacheTagHelperStorage"/> by storing the content
/// in using <see cref="IDistributedCache"/> as the store.
/// </summary>
public class DistributedCacheTagHelperStorage : IDistributedCacheTagHelperStorage
{
private readonly IDistributedCache _distributedCache;
/// <summary>
/// Creates a new <see cref="DistributedCacheTagHelperStorage"/>.
/// </summary>
/// <param name="distributedCache">The <see cref="IDistributedCache"/> to use.</param>
public DistributedCacheTagHelperStorage(IDistributedCache distributedCache)
{
_distributedCache = distributedCache;
}
/// <inheritdoc />
public Task<byte[]> GetAsync(string key)
{
if (key == null)
{
throw new ArgumentNullException(nameof(key));
}
return _distributedCache.GetAsync(key);
}
/// <inheritdoc />
public Task SetAsync(string key, byte[] value, DistributedCacheEntryOptions options)
{
if (key == null)
{
throw new ArgumentNullException(nameof(key));
}
if (value == null)
{
throw new ArgumentNullException(nameof(value));
}
return _distributedCache.SetAsync(key, value, options);
}
}
}

View File

@ -0,0 +1,29 @@
// 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.Threading.Tasks;
using Microsoft.AspNetCore.Mvc.Rendering;
namespace Microsoft.AspNetCore.Mvc.TagHelpers.Cache
{
/// <summary>
/// An implementation of this interface provides a service to
/// serialize html fragments for being store by <see cref="IDistributedCacheTagHelperStorage" />
/// </summary>
public interface IDistributedCacheTagHelperFormatter
{
/// <summary>
/// Serializes some html content.
/// </summary>
/// <param name="context">The <see cref="DistributedCacheTagHelperFormattingContext" /> to serialize.</param>
/// <returns>The serialized result.</returns>
Task<byte[]> SerializeAsync(DistributedCacheTagHelperFormattingContext context);
/// <summary>
/// Deserialize some html content.
/// </summary>
/// <param name="value">The value to deserialize.</param>
/// <returns>The deserialized content, <value>null</value> otherwise.</returns>
Task<HtmlString> DeserializeAsync(byte[] value);
}
}

View File

@ -0,0 +1,26 @@
// 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.Threading.Tasks;
using Microsoft.AspNetCore.Html;
using Microsoft.AspNetCore.Razor.TagHelpers;
using Microsoft.Extensions.Caching.Distributed;
namespace Microsoft.AspNetCore.Mvc.TagHelpers.Cache
{
/// <summary>
/// An implementation of this interface provides a service to process
/// the content or fetches it from cache for distributed cache tag helpers.
/// </summary>
public interface IDistributedCacheTagHelperService
{
/// <summary>
/// Processes the html content of a distributed cache tag helper.
/// </summary>
/// <param name="output">The <see cref="TagHelperOutput" />.</param>
/// <param name="key">The key in the storage.</param>
/// <param name="options">The <see cref="DistributedCacheEntryOptions"/>.</param>
/// <returns>A cached or new content for the cache tag helper.</returns>
Task<IHtmlContent> ProcessContentAsync(TagHelperOutput output, string key, DistributedCacheEntryOptions options);
}
}

View File

@ -0,0 +1,31 @@
// 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.Threading.Tasks;
using Microsoft.Extensions.Caching.Distributed;
namespace Microsoft.AspNetCore.Mvc.TagHelpers.Cache
{
/// <summary>
/// An implementation of this interface provides a service to
/// cache distributed html fragments from the &lt;distributed-cache&gt;
/// tag helper.
/// </summary>
public interface IDistributedCacheTagHelperStorage
{
/// <summary>
/// Gets the content from the cache and deserializes it.
/// </summary>
/// <param name="key">The unique key to use in the cache.</param>
/// <returns>The stored value if it exists, <value>null</value> otherwise.</returns>
Task<byte[]> GetAsync(string key);
/// <summary>
/// Sets the content in the cache and serialized it.
/// </summary>
/// <param name="key">The unique key to use in the cache.</param>
/// <param name="value">The value to cache.</param>
/// <param name="options">The cache entry options.</param>
Task SetAsync(string key, byte[] value, DistributedCacheEntryOptions options);
}
}

View File

@ -2,15 +2,12 @@
// 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.Security.Cryptography;
using System.Text;
using System.Text.Encodings.Web;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Html;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.AspNetCore.Razor.TagHelpers;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Primitives;
@ -20,44 +17,22 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
/// <summary>
/// <see cref="TagHelper"/> implementation targeting &lt;cache&gt; elements.
/// </summary>
public class CacheTagHelper : TagHelper
public class CacheTagHelper : CacheTagHelperBase
{
/// <summary>
/// Prefix used by <see cref="CacheTagHelper"/> instances when creating entries in <see cref="MemoryCache"/>.
/// </summary>
public static readonly string CacheKeyPrefix = nameof(CacheTagHelper);
private const string VaryByAttributeName = "vary-by";
private const string VaryByHeaderAttributeName = "vary-by-header";
private const string VaryByQueryAttributeName = "vary-by-query";
private const string VaryByRouteAttributeName = "vary-by-route";
private const string VaryByCookieAttributeName = "vary-by-cookie";
private const string VaryByUserAttributeName = "vary-by-user";
private const string ExpiresOnAttributeName = "expires-on";
private const string ExpiresAfterAttributeName = "expires-after";
private const string ExpiresSlidingAttributeName = "expires-sliding";
private const string CachePriorityAttributeName = "priority";
private const string CacheKeyTokenSeparator = "||";
private const string EnabledAttributeName = "enabled";
private static readonly char[] AttributeSeparator = new[] { ',' };
/// <summary>
/// Creates a new <see cref="CacheTagHelper"/>.
/// </summary>
/// <param name="memoryCache">The <see cref="IMemoryCache"/>.</param>
/// <param name="htmlEncoder">The <see cref="HtmlEncoder"/> to use.</param>
public CacheTagHelper(IMemoryCache memoryCache, HtmlEncoder htmlEncoder)
public CacheTagHelper(IMemoryCache memoryCache, HtmlEncoder htmlEncoder) : base(htmlEncoder)
{
MemoryCache = memoryCache;
HtmlEncoder = htmlEncoder;
}
/// <inheritdoc />
public override int Order
{
get
{
return -1000;
}
}
/// <summary>
@ -65,85 +40,12 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
/// </summary>
protected IMemoryCache MemoryCache { get; }
/// <summary>
/// Gets the <see cref="System.Text.Encodings.Web.HtmlEncoder"/> which encodes the content to be cached.
/// </summary>
protected HtmlEncoder HtmlEncoder { get; }
/// <summary>
/// Gets or sets the <see cref="ViewContext"/> for the current executing View.
/// </summary>
[HtmlAttributeNotBound]
[ViewContext]
public ViewContext ViewContext { get; set; }
/// <summary>
/// Gets or sets a <see cref="string" /> to vary the cached result by.
/// </summary>
[HtmlAttributeName(VaryByAttributeName)]
public string VaryBy { get; set; }
/// <summary>
/// Gets or sets the name of a HTTP request header to vary the cached result by.
/// </summary>
[HtmlAttributeName(VaryByHeaderAttributeName)]
public string VaryByHeader { get; set; }
/// <summary>
/// Gets or sets a comma-delimited set of query parameters to vary the cached result by.
/// </summary>
[HtmlAttributeName(VaryByQueryAttributeName)]
public string VaryByQuery { get; set; }
/// <summary>
/// Gets or sets a comma-delimited set of route data parameters to vary the cached result by.
/// </summary>
[HtmlAttributeName(VaryByRouteAttributeName)]
public string VaryByRoute { get; set; }
/// <summary>
/// Gets or sets a comma-delimited set of cookie names to vary the cached result by.
/// </summary>
[HtmlAttributeName(VaryByCookieAttributeName)]
public string VaryByCookie { get; set; }
/// <summary>
/// Gets or sets a value that determines if the cached result is to be varied by the Identity for the logged in
/// <see cref="Http.HttpContext.User"/>.
/// </summary>
[HtmlAttributeName(VaryByUserAttributeName)]
public bool VaryByUser { get; set; }
/// <summary>
/// Gets or sets the exact <see cref="DateTimeOffset"/> the cache entry should be evicted.
/// </summary>
[HtmlAttributeName(ExpiresOnAttributeName)]
public DateTimeOffset? ExpiresOn { get; set; }
/// <summary>
/// Gets or sets the duration, from the time the cache entry was added, when it should be evicted.
/// </summary>
[HtmlAttributeName(ExpiresAfterAttributeName)]
public TimeSpan? ExpiresAfter { get; set; }
/// <summary>
/// Gets or sets the duration from last access that the cache entry should be evicted.
/// </summary>
[HtmlAttributeName(ExpiresSlidingAttributeName)]
public TimeSpan? ExpiresSliding { get; set; }
/// <summary>
/// Gets or sets the <see cref="CacheItemPriority"/> policy for the cache entry.
/// </summary>
[HtmlAttributeName(CachePriorityAttributeName)]
public CacheItemPriority? Priority { get; set; }
/// <summary>
/// Gets or sets the value which determines if the tag helper is enabled or not.
/// </summary>
[HtmlAttributeName(EnabledAttributeName)]
public bool Enabled { get; set; } = true;
/// <inheritdoc />
public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
{
@ -156,87 +58,84 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
{
throw new ArgumentNullException(nameof(output));
}
IHtmlContent content = null;
IHtmlContent result = null;
if (Enabled)
{
var key = GenerateKey(context);
if (!MemoryCache.TryGetValue(key, out result))
MemoryCacheEntryOptions options;
while (content == null)
{
// Create an entry link scope and flow it so that any tokens related to the cache entries
// created within this scope get copied to this scope.
using (var link = MemoryCache.CreateLinkingScope())
Task<IHtmlContent> result = null;
if (!MemoryCache.TryGetValue(key, out result))
{
var content = await output.GetChildContentAsync();
var tokenSource = new CancellationTokenSource();
var stringBuilder = new StringBuilder();
using (var writer = new StringWriter(stringBuilder))
// Create an entry link scope and flow it so that any tokens related to the cache entries
// created within this scope get copied to this scope.
options = GetMemoryCacheEntryOptions();
options.AddExpirationToken(new CancellationChangeToken(tokenSource.Token));
var tcs = new TaskCompletionSource<IHtmlContent>();
MemoryCache.Set(key, tcs.Task, options);
try
{
content.WriteTo(writer, HtmlEncoder);
using (var link = MemoryCache.CreateLinkingScope())
{
result = ProcessContentAsync(output);
content = await result;
options.AddEntryLink(link);
}
// The entry is set instead of assigning a value to the
// task so that the expiration options are are not impacted
// by the time it took to compute it.
MemoryCache.Set(key, result, options);
}
catch
{
// Remove the worker task from the cache in case it can't complete.
tokenSource.Cancel();
throw;
}
finally
{
// If an exception occurs, ensure the other awaiters
// render the output by themselves.
tcs.SetResult(null);
}
}
else
{
// There is either some value already cached (as a Task)
// or a worker processing the output. In the case of a worker,
// the result will be null, and the request will try to acquire
// the result from memory another time.
result = new StringBuilderHtmlContent(stringBuilder);
MemoryCache.Set(key, result, GetMemoryCacheEntryOptions(link));
content = await result;
}
}
}
else
{
content = await output.GetChildContentAsync();
}
// Clear the contents of the "cache" element since we don't want to render it.
output.SuppressOutput();
if (Enabled)
{
output.Content.SetContent(result);
}
else
{
result = await output.GetChildContentAsync();
output.Content.SetContent(result);
}
output.Content.SetContent(content);
}
// Internal for unit testing
internal string GenerateKey(TagHelperContext context)
{
var builder = new StringBuilder(CacheKeyPrefix);
builder.Append(CacheKeyTokenSeparator)
.Append(context.UniqueId);
var request = ViewContext.HttpContext.Request;
if (!string.IsNullOrEmpty(VaryBy))
{
builder.Append(CacheKeyTokenSeparator)
.Append(nameof(VaryBy))
.Append(CacheKeyTokenSeparator)
.Append(VaryBy);
}
AddStringCollectionKey(builder, nameof(VaryByCookie), VaryByCookie, request.Cookies, (c, key) => c[key]);
AddStringCollectionKey(builder, nameof(VaryByHeader), VaryByHeader, request.Headers, (c, key) => c[key]);
AddStringCollectionKey(builder, nameof(VaryByQuery), VaryByQuery, request.Query, (c, key) => c[key]);
AddVaryByRouteKey(builder);
if (VaryByUser)
{
builder.Append(CacheKeyTokenSeparator)
.Append(nameof(VaryByUser))
.Append(CacheKeyTokenSeparator)
.Append(ViewContext.HttpContext.User?.Identity?.Name);
}
// The key is typically too long to be useful, so we use a cryptographic hash
// as the actual key (better randomization and key distribution, so small vary
// values will generate dramatically different keys).
using (var sha = SHA256.Create())
{
var contentBytes = Encoding.UTF8.GetBytes(builder.ToString());
var hashedBytes = sha.ComputeHash(contentBytes);
return Convert.ToBase64String(hashedBytes);
}
}
// Internal for unit testing
internal MemoryCacheEntryOptions GetMemoryCacheEntryOptions(IEntryLink entryLink)
internal MemoryCacheEntryOptions GetMemoryCacheEntryOptions()
{
var options = new MemoryCacheEntryOptions();
if (ExpiresOn != null)
@ -259,134 +158,30 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
options.SetPriority(Priority.Value);
}
options.AddEntryLink(entryLink);
return options;
}
private static void AddStringCollectionKey(
StringBuilder builder,
string keyName,
string value,
IDictionary<string, StringValues> sourceCollection)
protected override string GetUniqueId(TagHelperContext context)
{
if (!string.IsNullOrEmpty(value))
{
// keyName(param1=value1|param2=value2)
builder.Append(CacheKeyTokenSeparator)
.Append(keyName)
.Append("(");
var values = Tokenize(value);
// Perf: Avoid allocating enumerator
for (var i = 0; i < values.Count; i++)
{
var item = values[i];
builder.Append(item)
.Append(CacheKeyTokenSeparator)
.Append(sourceCollection[item])
.Append(CacheKeyTokenSeparator);
}
if (values.Count > 0)
{
// Remove the trailing separator
builder.Length -= CacheKeyTokenSeparator.Length;
}
builder.Append(")");
}
return context.UniqueId;
}
private static void AddStringCollectionKey<TSourceCollection>(
StringBuilder builder,
string keyName,
string value,
TSourceCollection sourceCollection,
Func<TSourceCollection, string, StringValues> accessor)
protected override string GetKeyPrefix(TagHelperContext context)
{
if (!string.IsNullOrEmpty(value))
{
// keyName(param1=value1|param2=value2)
builder.Append(CacheKeyTokenSeparator)
.Append(keyName)
.Append("(");
var values = Tokenize(value);
// Perf: Avoid allocating enumerator
for (var i = 0; i < values.Count; i++)
{
var item = values[i];
builder.Append(item)
.Append(CacheKeyTokenSeparator)
.Append(accessor(sourceCollection, item))
.Append(CacheKeyTokenSeparator);
}
if (values.Count > 0)
{
// Remove the trailing separator
builder.Length -= CacheKeyTokenSeparator.Length;
}
builder.Append(")");
}
return CacheKeyPrefix;
}
private void AddVaryByRouteKey(StringBuilder builder)
private async Task<IHtmlContent> ProcessContentAsync(TagHelperOutput output)
{
var tokenFound = false;
var content = await output.GetChildContentAsync();
if (!string.IsNullOrEmpty(VaryByRoute))
var stringBuilder = new StringBuilder();
using (var writer = new StringWriter(stringBuilder))
{
builder.Append(CacheKeyTokenSeparator)
.Append(nameof(VaryByRoute))
.Append("(");
var varyByRoutes = Tokenize(VaryByRoute);
for (var i = 0; i < varyByRoutes.Count; i++)
{
var route = varyByRoutes[i];
tokenFound = true;
builder.Append(route)
.Append(CacheKeyTokenSeparator)
.Append(ViewContext.RouteData.Values[route])
.Append(CacheKeyTokenSeparator);
}
if (tokenFound)
{
builder.Length -= CacheKeyTokenSeparator.Length;
}
builder.Append(")");
}
}
private static IList<string> Tokenize(string value)
{
var values = value.Split(AttributeSeparator, StringSplitOptions.RemoveEmptyEntries);
if (values.Length == 0)
{
return values;
content.WriteTo(writer, HtmlEncoder);
}
var trimmedValues = new List<string>();
for (var i = 0; i < values.Length; i++)
{
var trimmedValue = values[i].Trim();
if (trimmedValue.Length > 0)
{
trimmedValues.Add(trimmedValue);
}
}
return trimmedValues;
return new StringBuilderHtmlContent(stringBuilder);
}
private class StringBuilderHtmlContent : IHtmlContent

View File

@ -0,0 +1,312 @@
// 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.Security.Cryptography;
using System.Text;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.AspNetCore.Razor.TagHelpers;
using Microsoft.Extensions.Primitives;
namespace Microsoft.AspNetCore.Mvc.TagHelpers
{
/// <summary>
/// <see cref="TagHelper"/> base implementation for caching elements.
/// </summary>
public abstract class CacheTagHelperBase : TagHelper
{
private const string VaryByAttributeName = "vary-by";
private const string VaryByHeaderAttributeName = "vary-by-header";
private const string VaryByQueryAttributeName = "vary-by-query";
private const string VaryByRouteAttributeName = "vary-by-route";
private const string VaryByCookieAttributeName = "vary-by-cookie";
private const string VaryByUserAttributeName = "vary-by-user";
private const string ExpiresOnAttributeName = "expires-on";
private const string ExpiresAfterAttributeName = "expires-after";
private const string ExpiresSlidingAttributeName = "expires-sliding";
private const string CacheKeyTokenSeparator = "||";
private const string EnabledAttributeName = "enabled";
private static readonly char[] AttributeSeparator = new[] { ',' };
/// <summary>
/// Creates a new <see cref="CacheTagHelperBase"/>.
/// </summary>
/// <param name="htmlEncoder">The <see cref="HtmlEncoder"/> to use.</param>
public CacheTagHelperBase(HtmlEncoder htmlEncoder)
{
HtmlEncoder = htmlEncoder;
}
/// <inheritdoc />
public override int Order
{
get
{
return -1000;
}
}
/// <summary>
/// Gets the <see cref="System.Text.Encodings.Web.HtmlEncoder"/> which encodes the content to be cached.
/// </summary>
protected HtmlEncoder HtmlEncoder { get; }
/// <summary>
/// Gets or sets the <see cref="ViewContext"/> for the current executing View.
/// </summary>
[HtmlAttributeNotBound]
[ViewContext]
public ViewContext ViewContext { get; set; }
/// <summary>
/// Gets or sets a <see cref="string" /> to vary the cached result by.
/// </summary>
[HtmlAttributeName(VaryByAttributeName)]
public string VaryBy { get; set; }
/// <summary>
/// Gets or sets the name of a HTTP request header to vary the cached result by.
/// </summary>
[HtmlAttributeName(VaryByHeaderAttributeName)]
public string VaryByHeader { get; set; }
/// <summary>
/// Gets or sets a comma-delimited set of query parameters to vary the cached result by.
/// </summary>
[HtmlAttributeName(VaryByQueryAttributeName)]
public string VaryByQuery { get; set; }
/// <summary>
/// Gets or sets a comma-delimited set of route data parameters to vary the cached result by.
/// </summary>
[HtmlAttributeName(VaryByRouteAttributeName)]
public string VaryByRoute { get; set; }
/// <summary>
/// Gets or sets a comma-delimited set of cookie names to vary the cached result by.
/// </summary>
[HtmlAttributeName(VaryByCookieAttributeName)]
public string VaryByCookie { get; set; }
/// <summary>
/// Gets or sets a value that determines if the cached result is to be varied by the Identity for the logged in
/// <see cref="Http.HttpContext.User"/>.
/// </summary>
[HtmlAttributeName(VaryByUserAttributeName)]
public bool VaryByUser { get; set; }
/// <summary>
/// Gets or sets the exact <see cref="DateTimeOffset"/> the cache entry should be evicted.
/// </summary>
[HtmlAttributeName(ExpiresOnAttributeName)]
public DateTimeOffset? ExpiresOn { get; set; }
/// <summary>
/// Gets or sets the duration, from the time the cache entry was added, when it should be evicted.
/// </summary>
[HtmlAttributeName(ExpiresAfterAttributeName)]
public TimeSpan? ExpiresAfter { get; set; }
/// <summary>
/// Gets or sets the duration from last access that the cache entry should be evicted.
/// </summary>
[HtmlAttributeName(ExpiresSlidingAttributeName)]
public TimeSpan? ExpiresSliding { get; set; }
/// <summary>
/// Gets or sets the value which determines if the tag helper is enabled or not.
/// </summary>
[HtmlAttributeName(EnabledAttributeName)]
public bool Enabled { get; set; } = true;
// Internal for unit testing
protected internal string GenerateKey(TagHelperContext context)
{
var builder = new StringBuilder(GetKeyPrefix(context));
builder
.Append(CacheKeyTokenSeparator)
.Append(GetUniqueId(context));
var request = ViewContext.HttpContext.Request;
if (!string.IsNullOrEmpty(VaryBy))
{
builder
.Append(CacheKeyTokenSeparator)
.Append(nameof(VaryBy))
.Append(CacheKeyTokenSeparator)
.Append(VaryBy);
}
AddStringCollectionKey(builder, nameof(VaryByCookie), VaryByCookie, request.Cookies, (c, key) => c[key]);
AddStringCollectionKey(builder, nameof(VaryByHeader), VaryByHeader, request.Headers, (c, key) => c[key]);
AddStringCollectionKey(builder, nameof(VaryByQuery), VaryByQuery, request.Query, (c, key) => c[key]);
AddVaryByRouteKey(builder);
if (VaryByUser)
{
builder
.Append(CacheKeyTokenSeparator)
.Append(nameof(VaryByUser))
.Append(CacheKeyTokenSeparator)
.Append(ViewContext.HttpContext.User?.Identity?.Name);
}
// The key is typically too long to be useful, so we use a cryptographic hash
// as the actual key (better randomization and key distribution, so small vary
// values will generate dramatically different keys).
using (var sha = SHA256.Create())
{
var contentBytes = Encoding.UTF8.GetBytes(builder.ToString());
var hashedBytes = sha.ComputeHash(contentBytes);
return Convert.ToBase64String(hashedBytes);
}
}
protected abstract string GetUniqueId(TagHelperContext context);
protected abstract string GetKeyPrefix(TagHelperContext context);
protected static void AddStringCollectionKey(
StringBuilder builder,
string keyName,
string value,
IDictionary<string, StringValues> sourceCollection)
{
if (string.IsNullOrEmpty(value))
{
return;
}
// keyName(param1=value1|param2=value2)
builder
.Append(CacheKeyTokenSeparator)
.Append(keyName)
.Append("(");
var values = Tokenize(value);
// Perf: Avoid allocating enumerator
for (var i = 0; i < values.Count; i++)
{
var item = values[i];
builder
.Append(item)
.Append(CacheKeyTokenSeparator)
.Append(sourceCollection[item])
.Append(CacheKeyTokenSeparator);
}
if (values.Count > 0)
{
// Remove the trailing separator
builder.Length -= CacheKeyTokenSeparator.Length;
}
builder.Append(")");
}
protected static void AddStringCollectionKey<TSourceCollection>(
StringBuilder builder,
string keyName,
string value,
TSourceCollection sourceCollection,
Func<TSourceCollection, string, StringValues> accessor)
{
if (string.IsNullOrEmpty(value))
{
return;
}
// keyName(param1=value1|param2=value2)
builder
.Append(CacheKeyTokenSeparator)
.Append(keyName)
.Append("(");
var values = Tokenize(value);
// Perf: Avoid allocating enumerator
for (var i = 0; i < values.Count; i++)
{
var item = values[i];
builder
.Append(item)
.Append(CacheKeyTokenSeparator)
.Append(accessor(sourceCollection, item))
.Append(CacheKeyTokenSeparator);
}
if (values.Count > 0)
{
// Remove the trailing separator
builder.Length -= CacheKeyTokenSeparator.Length;
}
builder.Append(")");
}
protected static IList<string> Tokenize(string value)
{
var values = value.Split(AttributeSeparator, StringSplitOptions.RemoveEmptyEntries);
if (values.Length == 0)
{
return values;
}
var trimmedValues = new List<string>();
for (var i = 0; i < values.Length; i++)
{
var trimmedValue = values[i].Trim();
if (trimmedValue.Length > 0)
{
trimmedValues.Add(trimmedValue);
}
}
return trimmedValues;
}
protected void AddVaryByRouteKey(StringBuilder builder)
{
var tokenFound = false;
if (string.IsNullOrEmpty(VaryByRoute))
{
return;
}
builder
.Append(CacheKeyTokenSeparator)
.Append(nameof(VaryByRoute))
.Append("(");
var varyByRoutes = Tokenize(VaryByRoute);
for (var i = 0; i < varyByRoutes.Count; i++)
{
var route = varyByRoutes[i];
tokenFound = true;
builder
.Append(route)
.Append(CacheKeyTokenSeparator)
.Append(ViewContext.RouteData.Values[route])
.Append(CacheKeyTokenSeparator);
}
if (tokenFound)
{
builder.Length -= CacheKeyTokenSeparator.Length;
}
builder.Append(")");
}
}
}

View File

@ -0,0 +1,40 @@
// 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 Microsoft.AspNetCore.Mvc.TagHelpers.Cache;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace Microsoft.Extensions.DependencyInjection
{
/// <summary>
/// Extension methods for configuring Razor cache tag helpers.
/// </summary>
public static class TagHelperServicesExtensions
{
/// <summary>
/// Adds MVC cache tag helper services to the application.
/// </summary>
/// <param name="builder">The <see cref="IMvcCoreBuilder"/>.</param>
/// <returns>The <see cref="IMvcCoreBuilder"/>.</returns>
public static IMvcCoreBuilder AddCacheTagHelper(this IMvcCoreBuilder builder)
{
if (builder == null)
{
throw new ArgumentNullException(nameof(builder));
}
builder.Services.TryAddTransient<IDistributedCacheTagHelperStorage, DistributedCacheTagHelperStorage>();
builder.Services.TryAddTransient<IDistributedCacheTagHelperFormatter, DistributedCacheTagHelperFormatter>();
builder.Services.TryAddSingleton<IDistributedCacheTagHelperService, DistributedCacheTagHelperService>();
// Required default services for cache tag helpers
builder.Services.TryAddSingleton<IDistributedCache, MemoryDistributedCache>();
builder.Services.TryAddSingleton<IMemoryCache, MemoryCache>();
return builder;
}
}
}

View File

@ -0,0 +1,123 @@
// 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.Text.Encodings.Web;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Html;
using Microsoft.AspNetCore.Mvc.TagHelpers.Cache;
using Microsoft.AspNetCore.Razor.TagHelpers;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Caching.Memory;
namespace Microsoft.AspNetCore.Mvc.TagHelpers
{
/// <summary>
/// <see cref="TagHelper"/> implementation targeting &lt;distributed-cache&gt; elements.
/// </summary>
[HtmlTargetElement("distributed-cache", Attributes = NameAttributeName)]
public class DistributedCacheTagHelper : CacheTagHelperBase
{
private readonly IDistributedCacheTagHelperService _distributedCacheService;
/// <summary>
/// Prefix used by <see cref="DistributedCacheTagHelper"/> instances when creating entries in <see cref="IDistributedCacheTagHelperStorage"/>.
/// </summary>
public static readonly string CacheKeyPrefix = nameof(DistributedCacheTagHelper);
private const string NameAttributeName = "name";
/// <summary>
/// Creates a new <see cref="CacheTagHelper"/>.
/// </summary>
/// <param name="distributedCacheService">The <see cref="IDistributedCacheTagHelperService"/>.</param>
/// <param name="htmlEncoder">The <see cref="HtmlEncoder"/>.</param>
public DistributedCacheTagHelper(
IDistributedCacheTagHelperService distributedCacheService,
HtmlEncoder htmlEncoder)
: base(htmlEncoder)
{
_distributedCacheService = distributedCacheService;
}
/// <summary>
/// Gets the <see cref="IMemoryCache"/> instance used to cache workers.
/// </summary>
protected IMemoryCache MemoryCache { get; }
/// <summary>
/// Gets or sets a unique name to discriminate cached entries.
/// </summary>
[HtmlAttributeName(NameAttributeName)]
public string Name { get; set; }
/// <inheritdoc />
public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
{
if (context == null)
{
throw new ArgumentNullException(nameof(context));
}
if (output == null)
{
throw new ArgumentNullException(nameof(output));
}
IHtmlContent content = null;
// Create a cancellation token that will be used
// to release the task from the memory cache.
var tokenSource = new CancellationTokenSource();
if (Enabled)
{
var key = GenerateKey(context);
content = await _distributedCacheService.ProcessContentAsync(output, key, GetDistributedCacheEntryOptions());
}
else
{
content = await output.GetChildContentAsync();
}
// Clear the contents of the "cache" element since we don't want to render it.
output.SuppressOutput();
output.Content.SetContent(content);
}
// Internal for unit testing
internal DistributedCacheEntryOptions GetDistributedCacheEntryOptions()
{
var options = new DistributedCacheEntryOptions();
if (ExpiresOn != null)
{
options.SetAbsoluteExpiration(ExpiresOn.Value);
}
if (ExpiresAfter != null)
{
options.SetAbsoluteExpiration(ExpiresAfter.Value);
}
if (ExpiresSliding != null)
{
options.SetSlidingExpiration(ExpiresSliding.Value);
}
return options;
}
protected override string GetUniqueId(TagHelperContext context)
{
return Name;
}
protected override string GetKeyPrefix(TagHelperContext context)
{
return CacheKeyPrefix;
}
}
}

View File

@ -35,6 +35,7 @@ namespace Microsoft.Extensions.DependencyInjection
builder.AddFormatterMappings();
builder.AddViews();
builder.AddRazorViewEngine();
builder.AddCacheTagHelper();
// +1 order
builder.AddDataAnnotations(); // +1 order

View File

@ -20,6 +20,7 @@
"Microsoft.AspNetCore.Mvc.Formatters.Json": "1.0.0-*",
"Microsoft.AspNetCore.Mvc.Localization": "1.0.0-*",
"Microsoft.AspNetCore.Mvc.Razor": "1.0.0-*",
"Microsoft.AspNetCore.Mvc.TagHelpers": "1.0.0-*",
"Microsoft.AspNetCore.Mvc.ViewFeatures": "1.0.0-*",
"Microsoft.Extensions.Caching.Memory": "1.0.0-*",
"Microsoft.Extensions.DependencyInjection": "1.0.0-*",

View File

@ -11,6 +11,7 @@ using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Html;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Internal;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.ModelBinding;
@ -74,13 +75,13 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
[Theory]
[InlineData("Cookie0", "CacheTagHelper||testid||VaryByCookie(Cookie0||Cookie0Value)")]
[InlineData("Cookie0,Cookie1",
"CacheTagHelper||testid||VaryByCookie(Cookie0||Cookie0Value||Cookie1||Cookie1Value)")]
"CacheTagHelper||testid||VaryByCookie(Cookie0||Cookie0Value||Cookie1||Cookie1Value)")]
[InlineData("Cookie0, Cookie1",
"CacheTagHelper||testid||VaryByCookie(Cookie0||Cookie0Value||Cookie1||Cookie1Value)")]
"CacheTagHelper||testid||VaryByCookie(Cookie0||Cookie0Value||Cookie1||Cookie1Value)")]
[InlineData(" Cookie0, , Cookie1 ",
"CacheTagHelper||testid||VaryByCookie(Cookie0||Cookie0Value||Cookie1||Cookie1Value)")]
"CacheTagHelper||testid||VaryByCookie(Cookie0||Cookie0Value||Cookie1||Cookie1Value)")]
[InlineData(",Cookie0,,Cookie1,",
"CacheTagHelper||testid||VaryByCookie(Cookie0||Cookie0Value||Cookie1||Cookie1Value)")]
"CacheTagHelper||testid||VaryByCookie(Cookie0||Cookie0Value||Cookie1||Cookie1Value)")]
public void GenerateKey_UsesVaryByCookieName(string varyByCookie, string expected)
{
// Arrange
@ -103,9 +104,9 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
[Theory]
[InlineData("Accept-Language", "CacheTagHelper||testid||VaryByHeader(Accept-Language||en-us;charset=utf8)")]
[InlineData("X-CustomHeader,Accept-Encoding, NotAvailable",
"CacheTagHelper||testid||VaryByHeader(X-CustomHeader||Header-Value||Accept-Encoding||utf8||NotAvailable||)")]
"CacheTagHelper||testid||VaryByHeader(X-CustomHeader||Header-Value||Accept-Encoding||utf8||NotAvailable||)")]
[InlineData("X-CustomHeader, , Accept-Encoding, NotAvailable",
"CacheTagHelper||testid||VaryByHeader(X-CustomHeader||Header-Value||Accept-Encoding||utf8||NotAvailable||)")]
"CacheTagHelper||testid||VaryByHeader(X-CustomHeader||Header-Value||Accept-Encoding||utf8||NotAvailable||)")]
public void GenerateKey_UsesVaryByHeader(string varyByHeader, string expected)
{
// Arrange
@ -143,7 +144,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
VaryByQuery = varyByQuery
};
cacheTagHelper.ViewContext.HttpContext.Request.QueryString =
new Http.QueryString("?sortoption=Adorability&Category=cats&sortOrder=");
new QueryString("?sortoption=Adorability&Category=cats&sortOrder=");
// Act
var key = cacheTagHelper.GenerateKey(tagHelperContext);
@ -222,7 +223,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
{
// Arrange
var expected = GetHashedBytes("CacheTagHelper||testid||VaryBy||custom-value||" +
"VaryByHeader(content-type||text/html)||VaryByUser||someuser");
"VaryByHeader(content-type||text/html)||VaryByUser||someuser");
var tagHelperContext = GetTagHelperContext();
var cacheTagHelper = new CacheTagHelper(Mock.Of<IMemoryCache>(), new HtmlTestEncoder())
{
@ -249,14 +250,12 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
var id = "unique-id";
var childContent = "original-child-content";
var cache = new Mock<IMemoryCache>();
cache.CallBase = true;
var value = new DefaultTagHelperContent().SetContent("ok");
cache.Setup(c => c.Set(
/*key*/ It.IsAny<string>(),
/*value*/ value,
/*optons*/ It.IsAny<MemoryCacheEntryOptions>()))
.Returns(value)
.Verifiable();
.Returns(value);
object cacheResult;
cache.Setup(c => c.TryGetValue(It.IsAny<string>(), out cacheResult))
.Returns(false);
@ -289,15 +288,13 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
var id = "unique-id";
var childContent = "original-child-content";
var cache = new Mock<IMemoryCache>();
cache.CallBase = true;
var value = new DefaultTagHelperContent().SetContent("ok");
cache.Setup(c => c.CreateLinkingScope()).Returns(new Mock<IEntryLink>().Object);
cache.Setup(c => c.Set(
/*key*/ It.IsAny<string>(),
/*value*/ It.IsAny<object>(),
/*options*/ It.IsAny<MemoryCacheEntryOptions>()))
.Returns(value)
.Verifiable();
.Returns(value);
object cacheResult;
cache.Setup(c => c.TryGetValue(It.IsAny<string>(), out cacheResult))
.Returns(false);
@ -319,11 +316,13 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
Assert.Empty(tagHelperOutput.PostContent.GetContent());
Assert.True(tagHelperOutput.IsContentModified);
Assert.Equal(childContent, tagHelperOutput.Content.GetContent());
// There are two calls to set (for the TCS and the processed value)
cache.Verify(c => c.Set(
/*key*/ It.IsAny<string>(),
/*value*/ It.IsAny<object>(),
/*options*/ It.IsAny<MemoryCacheEntryOptions>()),
Times.Once);
Times.Exactly(2));
}
[Fact]
@ -342,7 +341,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
VaryByQuery = "key1,key2",
ViewContext = GetViewContext(),
};
cacheTagHelper1.ViewContext.HttpContext.Request.QueryString = new Http.QueryString(
cacheTagHelper1.ViewContext.HttpContext.Request.QueryString = new QueryString(
"?key1=value1&key2=value2");
// Act - 1
@ -364,7 +363,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
VaryByQuery = "key1,key2",
ViewContext = GetViewContext(),
};
cacheTagHelper2.ViewContext.HttpContext.Request.QueryString = new Http.QueryString(
cacheTagHelper2.ViewContext.HttpContext.Request.QueryString = new QueryString(
"?key1=value1&key2=value2");
// Act - 2
@ -439,7 +438,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
};
// Act
var cacheEntryOptions = cacheTagHelper.GetMemoryCacheEntryOptions(new EntryLink());
var cacheEntryOptions = cacheTagHelper.GetMemoryCacheEntryOptions();
// Assert
Assert.Equal(expiresOn, cacheEntryOptions.AbsoluteExpiration);
@ -459,7 +458,8 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
entryLink.SetAbsoluteExpiration(expiresOn);
// Act
var cacheEntryOptions = cacheTagHelper.GetMemoryCacheEntryOptions(entryLink);
var cacheEntryOptions = cacheTagHelper.GetMemoryCacheEntryOptions();
cacheEntryOptions.AddEntryLink(entryLink);
// Assert
Assert.Equal(expiresOn, cacheEntryOptions.AbsoluteExpiration);
@ -481,7 +481,8 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
entryLink.SetAbsoluteExpiration(expiresOn2);
// Act
var cacheEntryOptions = cacheTagHelper.GetMemoryCacheEntryOptions(entryLink);
var cacheEntryOptions = cacheTagHelper.GetMemoryCacheEntryOptions();
cacheEntryOptions.AddEntryLink(entryLink);
// Assert
Assert.Equal(expiresOn2, cacheEntryOptions.AbsoluteExpiration);
@ -499,7 +500,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
};
// Act
var cacheEntryOptions = cacheTagHelper.GetMemoryCacheEntryOptions(new EntryLink());
var cacheEntryOptions = cacheTagHelper.GetMemoryCacheEntryOptions();
// Assert
Assert.Equal(expiresAfter, cacheEntryOptions.AbsoluteExpirationRelativeToNow);
@ -517,7 +518,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
};
// Act
var cacheEntryOptions = cacheTagHelper.GetMemoryCacheEntryOptions(new EntryLink());
var cacheEntryOptions = cacheTagHelper.GetMemoryCacheEntryOptions();
// Assert
Assert.Equal(expiresSliding, cacheEntryOptions.SlidingExpiration);
@ -535,7 +536,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
};
// Act
var cacheEntryOptions = cacheTagHelper.GetMemoryCacheEntryOptions(new EntryLink());
var cacheEntryOptions = cacheTagHelper.GetMemoryCacheEntryOptions();
// Assert
Assert.Equal(priority, cacheEntryOptions.Priority);
@ -557,7 +558,8 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
entryLink.AddExpirationTokens(expected);
// Act
var cacheEntryOptions = cacheTagHelper.GetMemoryCacheEntryOptions(entryLink);
var cacheEntryOptions = cacheTagHelper.GetMemoryCacheEntryOptions();
cacheEntryOptions.AddEntryLink(entryLink);
// Assert
Assert.Equal(expected, cacheEntryOptions.ExpirationTokens.ToArray());
@ -572,7 +574,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
var childContent1 = "original-child-content";
var clock = new Mock<ISystemClock>();
clock.SetupGet(p => p.UtcNow)
.Returns(() => currentTime);
.Returns(() => currentTime);
var cache = new MemoryCache(new MemoryCacheOptions { Clock = clock.Object });
var tagHelperContext1 = GetTagHelperContext(id);
var tagHelperOutput1 = GetTagHelperOutput(childContent: childContent1);
@ -625,7 +627,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
var childContent1 = "original-child-content";
var clock = new Mock<ISystemClock>();
clock.SetupGet(p => p.UtcNow)
.Returns(() => currentTime);
.Returns(() => currentTime);
var cache = new MemoryCache(new MemoryCacheOptions { Clock = clock.Object });
var tagHelperContext1 = GetTagHelperContext(id);
var tagHelperOutput1 = GetTagHelperOutput(childContent: childContent1);
@ -678,7 +680,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
var childContent1 = "original-child-content";
var clock = new Mock<ISystemClock>();
clock.SetupGet(p => p.UtcNow)
.Returns(() => currentTime);
.Returns(() => currentTime);
var cache = new MemoryCache(new MemoryCacheOptions { Clock = clock.Object });
var tagHelperContext1 = GetTagHelperContext(id);
var tagHelperOutput1 = GetTagHelperOutput(childContent: childContent1);
@ -761,7 +763,7 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
// Act - 1
await cacheTagHelper.ProcessAsync(tagHelperContext, tagHelperOutput);
IHtmlContent cachedValue;
Task<IHtmlContent> cachedValue;
var result = cache.TryGetValue(key, out cachedValue);
// Assert - 1
@ -777,6 +779,151 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers
Assert.Null(cachedValue);
}
[Fact]
public async Task ProcessAsync_ComputesValueOnce_WithConcurrentRequests()
{
// Arrange
var id = "unique-id";
var childContent = "some-content";
var resetEvent1 = new ManualResetEvent(false);
var resetEvent2 = new ManualResetEvent(false);
var calls = 0;
var cache = new MemoryCache(new MemoryCacheOptions());
var tagHelperContext1 = GetTagHelperContext(id + 1);
var tagHelperContext2 = GetTagHelperContext(id + 2);
TagHelperOutput tagHelperOutput = new TagHelperOutput(
"cache",
new TagHelperAttributeList(),
getChildContentAsync: (useCachedResult, encoder) =>
{
calls++;
resetEvent2.Set();
var tagHelperContent = new DefaultTagHelperContent();
tagHelperContent.SetHtmlContent(childContent);
return Task.FromResult<TagHelperContent>(tagHelperContent);
});
var cacheTagHelper1 = new CacheTagHelper(cache, new HtmlTestEncoder())
{
ViewContext = GetViewContext(),
Enabled = true
};
var cacheTagHelper2 = new CacheTagHelper(cache, new HtmlTestEncoder())
{
ViewContext = GetViewContext(),
Enabled = true
};
// Act
var task1 = Task.Run(async () =>
{
resetEvent1.WaitOne(5000);
await cacheTagHelper1.ProcessAsync(tagHelperContext1, tagHelperOutput);
});
var task2 = Task.Run(async () =>
{
resetEvent2.WaitOne(5000);
await cacheTagHelper2.ProcessAsync(tagHelperContext1, tagHelperOutput);
});
resetEvent1.Set();
await Task.WhenAll(task1, task2);
// Assert
Assert.Empty(tagHelperOutput.PreContent.GetContent());
Assert.Empty(tagHelperOutput.PostContent.GetContent());
Assert.True(tagHelperOutput.IsContentModified);
Assert.Equal(childContent, tagHelperOutput.Content.GetContent());
Assert.Equal(1, calls);
}
[Fact]
public async Task ProcessAsync_ExceptionInProcessing_DoesntBlockConcurrentRequests()
{
// Arrange
var id = "unique-id";
var childContent = "some-content";
var resetEvent1 = new ManualResetEvent(false);
var resetEvent2 = new ManualResetEvent(false);
var calls = 0;
var cache = new MemoryCache(new MemoryCacheOptions());
var tagHelperContext1 = GetTagHelperContext(id + 1);
var tagHelperContext2 = GetTagHelperContext(id + 2);
var tagHelperOutput1 = new TagHelperOutput(
"cache",
new TagHelperAttributeList(),
getChildContentAsync: (useCachedResult, encoder) =>
{
calls++;
resetEvent2.Set();
throw new Exception();
});
var tagHelperOutput2 = new TagHelperOutput(
"cache",
new TagHelperAttributeList(),
getChildContentAsync: (useCachedResult, encoder) =>
{
calls++;
var tagHelperContent = new DefaultTagHelperContent();
tagHelperContent.SetHtmlContent(childContent);
return Task.FromResult<TagHelperContent>(tagHelperContent);
});
var cacheTagHelper1 = new CacheTagHelper(cache, new HtmlTestEncoder())
{
ViewContext = GetViewContext(),
Enabled = true
};
var cacheTagHelper2 = new CacheTagHelper(cache, new HtmlTestEncoder())
{
ViewContext = GetViewContext(),
Enabled = true
};
// Act
var task1 = Task.Run(async () =>
{
resetEvent1.WaitOne(5000);
await Assert.ThrowsAsync<Exception>(() => cacheTagHelper1.ProcessAsync(tagHelperContext1, tagHelperOutput1));
});
var task2 = Task.Run(async () =>
{
resetEvent2.WaitOne(5000);
await cacheTagHelper2.ProcessAsync(tagHelperContext2, tagHelperOutput2);
});
resetEvent1.Set();
await Task.WhenAll(task1, task2);
// Assert
Assert.Empty(tagHelperOutput1.PreContent.GetContent());
Assert.Empty(tagHelperOutput1.PostContent.GetContent());
Assert.False(tagHelperOutput1.IsContentModified);
Assert.Empty(tagHelperOutput1.Content.GetContent());
Assert.Empty(tagHelperOutput2.PreContent.GetContent());
Assert.Empty(tagHelperOutput2.PostContent.GetContent());
Assert.True(tagHelperOutput2.IsContentModified);
Assert.Equal(childContent, tagHelperOutput2.Content.GetContent());
Assert.Equal(2, calls);
}
private static ViewContext GetViewContext()
{
var actionContext = new ActionContext(new DefaultHttpContext(), new RouteData(), new ActionDescriptor());

View File

@ -0,0 +1,986 @@
// 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.IO;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Internal;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.TagHelpers.Cache;
using Microsoft.AspNetCore.Mvc.ViewEngines;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.AspNetCore.Razor.TagHelpers;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.Caching.Distributed;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Internal;
using Microsoft.Extensions.WebEncoders.Testing;
using Moq;
using Xunit;
namespace Microsoft.AspNetCore.Mvc.TagHelpers
{
public class DistributedCacheTagHelperTest
{
[Fact]
public void GenerateKey_ReturnsKeyBasedOnTagHelperName()
{
// Arrange
var name = "some-name";
var tagHelperContext = GetTagHelperContext();
var cacheTagHelper = new DistributedCacheTagHelper(
Mock.Of<IDistributedCacheTagHelperService>(),
new HtmlTestEncoder())
{
ViewContext = GetViewContext(),
Name = name
};
var expected = GetHashedBytes("DistributedCacheTagHelper||" + name);
// Act
var key = cacheTagHelper.GenerateKey(tagHelperContext);
// Assert
Assert.Equal(expected, key);
}
[Theory]
[InlineData("Vary-By-Value")]
[InlineData("Vary with spaces")]
[InlineData(" Vary with more spaces ")]
public void GenerateKey_UsesVaryByPropertyToGenerateKey(string varyBy)
{
// Arrange
var name = "some-name";
var tagHelperContext = GetTagHelperContext();
var cacheTagHelper = new DistributedCacheTagHelper(
Mock.Of<IDistributedCacheTagHelperService>(),
new HtmlTestEncoder())
{
ViewContext = GetViewContext(),
VaryBy = varyBy,
Name = name
};
var expected = GetHashedBytes("DistributedCacheTagHelper||some-name||VaryBy||" + varyBy);
// Act
var key = cacheTagHelper.GenerateKey(tagHelperContext);
// Assert
Assert.Equal(expected, key);
}
[Theory]
[InlineData("Cookie0", "DistributedCacheTagHelper||some-name||VaryByCookie(Cookie0||Cookie0Value)")]
[InlineData("Cookie0,Cookie1",
"DistributedCacheTagHelper||some-name||VaryByCookie(Cookie0||Cookie0Value||Cookie1||Cookie1Value)")]
[InlineData("Cookie0, Cookie1",
"DistributedCacheTagHelper||some-name||VaryByCookie(Cookie0||Cookie0Value||Cookie1||Cookie1Value)")]
[InlineData(" Cookie0, , Cookie1 ",
"DistributedCacheTagHelper||some-name||VaryByCookie(Cookie0||Cookie0Value||Cookie1||Cookie1Value)")]
[InlineData(",Cookie0,,Cookie1,",
"DistributedCacheTagHelper||some-name||VaryByCookie(Cookie0||Cookie0Value||Cookie1||Cookie1Value)")]
public void GenerateKey_UsesVaryByCookieName(string varyByCookie, string expected)
{
// Arrange
var name = "some-name";
var tagHelperContext = GetTagHelperContext();
var cacheTagHelper = new DistributedCacheTagHelper(
Mock.Of<IDistributedCacheTagHelperService>(),
new HtmlTestEncoder())
{
ViewContext = GetViewContext(),
VaryByCookie = varyByCookie,
Name = name
};
cacheTagHelper.ViewContext.HttpContext.Request.Headers["Cookie"] =
"Cookie0=Cookie0Value;Cookie1=Cookie1Value";
// Act
var key = cacheTagHelper.GenerateKey(tagHelperContext);
// Assert
Assert.Equal(GetHashedBytes(expected), key);
}
[Theory]
[InlineData("Accept-Language", "DistributedCacheTagHelper||some-name||VaryByHeader(Accept-Language||en-us;charset=utf8)")]
[InlineData("X-CustomHeader,Accept-Encoding, NotAvailable",
"DistributedCacheTagHelper||some-name||VaryByHeader(X-CustomHeader||Header-Value||Accept-Encoding||utf8||NotAvailable||)")]
[InlineData("X-CustomHeader, , Accept-Encoding, NotAvailable",
"DistributedCacheTagHelper||some-name||VaryByHeader(X-CustomHeader||Header-Value||Accept-Encoding||utf8||NotAvailable||)")]
public void GenerateKey_UsesVaryByHeader(string varyByHeader, string expected)
{
// Arrange
var name = "some-name";
var tagHelperContext = GetTagHelperContext();
var cacheTagHelper = new DistributedCacheTagHelper(
Mock.Of<IDistributedCacheTagHelperService>(),
new HtmlTestEncoder())
{
ViewContext = GetViewContext(),
VaryByHeader = varyByHeader,
Name = name
};
var headers = cacheTagHelper.ViewContext.HttpContext.Request.Headers;
headers["Accept-Language"] = "en-us;charset=utf8";
headers["Accept-Encoding"] = "utf8";
headers["X-CustomHeader"] = "Header-Value";
// Act
var key = cacheTagHelper.GenerateKey(tagHelperContext);
// Assert
Assert.Equal(GetHashedBytes(expected), key);
}
[Theory]
[InlineData("category", "DistributedCacheTagHelper||some-name||VaryByQuery(category||cats)")]
[InlineData("Category,SortOrder,SortOption",
"DistributedCacheTagHelper||some-name||VaryByQuery(Category||cats||SortOrder||||SortOption||Adorability)")]
[InlineData("Category, SortOrder, SortOption, ",
"DistributedCacheTagHelper||some-name||VaryByQuery(Category||cats||SortOrder||||SortOption||Adorability)")]
public void GenerateKey_UsesVaryByQuery(string varyByQuery, string expected)
{
// Arrange
var name = "some-name";
var tagHelperContext = GetTagHelperContext();
var cacheTagHelper = new DistributedCacheTagHelper(
Mock.Of<IDistributedCacheTagHelperService>(),
new HtmlTestEncoder())
{
ViewContext = GetViewContext(),
VaryByQuery = varyByQuery,
Name = name
};
cacheTagHelper.ViewContext.HttpContext.Request.QueryString =
new QueryString("?sortoption=Adorability&Category=cats&sortOrder=");
// Act
var key = cacheTagHelper.GenerateKey(tagHelperContext);
// Assert
Assert.Equal(GetHashedBytes(expected), key);
}
[Theory]
[InlineData("id", "DistributedCacheTagHelper||some-name||VaryByRoute(id||4)")]
[InlineData("Category,,Id,OptionRouteValue",
"DistributedCacheTagHelper||some-name||VaryByRoute(Category||MyCategory||Id||4||OptionRouteValue||)")]
[InlineData(" Category, , Id, OptionRouteValue, ",
"DistributedCacheTagHelper||some-name||VaryByRoute(Category||MyCategory||Id||4||OptionRouteValue||)")]
public void GenerateKey_UsesVaryByRoute(string varyByRoute, string expected)
{
// Arrange
var name = "some-name";
var tagHelperContext = GetTagHelperContext();
var cacheTagHelper = new DistributedCacheTagHelper(
Mock.Of<IDistributedCacheTagHelperService>(),
new HtmlTestEncoder())
{
ViewContext = GetViewContext(),
VaryByRoute = varyByRoute,
Name = name
};
cacheTagHelper.ViewContext.RouteData.Values["id"] = 4;
cacheTagHelper.ViewContext.RouteData.Values["category"] = "MyCategory";
// Act
var key = cacheTagHelper.GenerateKey(tagHelperContext);
// Assert
Assert.Equal(GetHashedBytes(expected), key);
}
[Fact]
public void GenerateKey_UsesVaryByUser_WhenUserIsNotAuthenticated()
{
// Arrange
var name = "some-name";
var expected = "DistributedCacheTagHelper||some-name||VaryByUser||";
var tagHelperContext = GetTagHelperContext();
var cacheTagHelper = new DistributedCacheTagHelper(
Mock.Of<IDistributedCacheTagHelperService>(),
new HtmlTestEncoder())
{
ViewContext = GetViewContext(),
VaryByUser = true,
Name = name
};
// Act
var key = cacheTagHelper.GenerateKey(tagHelperContext);
// Assert
Assert.Equal(GetHashedBytes(expected), key);
}
[Fact]
public void GenerateKey_UsesVaryByUserAndAuthenticatedUserName()
{
// Arrange
var name = "some-name";
var expected = "DistributedCacheTagHelper||some-name||VaryByUser||test_name";
var tagHelperContext = GetTagHelperContext();
var cacheTagHelper = new DistributedCacheTagHelper(
Mock.Of<IDistributedCacheTagHelperService>(),
new HtmlTestEncoder())
{
ViewContext = GetViewContext(),
VaryByUser = true,
Name = name
};
var identity = new ClaimsIdentity(new[] { new Claim(ClaimsIdentity.DefaultNameClaimType, "test_name") });
cacheTagHelper.ViewContext.HttpContext.User = new ClaimsPrincipal(identity);
// Act
var key = cacheTagHelper.GenerateKey(tagHelperContext);
// Assert
Assert.Equal(GetHashedBytes(expected), key);
}
[Fact]
public void GenerateKey_WithMultipleVaryByOptions_CreatesCombinedKey()
{
// Arrange
var name = "some-name";
var expected = GetHashedBytes("DistributedCacheTagHelper||some-name||VaryBy||custom-value||" +
"VaryByHeader(content-type||text/html)||VaryByUser||someuser");
var tagHelperContext = GetTagHelperContext();
var cacheTagHelper = new DistributedCacheTagHelper(
Mock.Of<IDistributedCacheTagHelperService>(),
new HtmlTestEncoder())
{
ViewContext = GetViewContext(),
VaryByUser = true,
VaryByHeader = "content-type",
VaryBy = "custom-value",
Name = name
};
cacheTagHelper.ViewContext.HttpContext.Request.Headers["Content-Type"] = "text/html";
var identity = new ClaimsIdentity(new[] { new Claim(ClaimsIdentity.DefaultNameClaimType, "someuser") });
cacheTagHelper.ViewContext.HttpContext.User = new ClaimsPrincipal(identity);
// Act
var key = cacheTagHelper.GenerateKey(tagHelperContext);
// Assert
Assert.Equal(expected, key);
}
[Fact]
public async Task ProcessAsync_DoesNotCache_IfDisabled()
{
// Arrange
var childContent = "original-child-content";
var storage = new Mock<IDistributedCacheTagHelperStorage>();
var value = Encoding.UTF8.GetBytes("ok");
storage.Setup(c => c.SetAsync(
/*key*/ It.IsAny<string>(),
/*value*/ value,
/*options*/ It.IsAny<DistributedCacheEntryOptions>()));
storage.Setup(c => c.GetAsync(It.IsAny<string>()))
.Returns(Task.FromResult(value));
var tagHelperContext = GetTagHelperContext();
var service = new DistributedCacheTagHelperService(
storage.Object,
Mock.Of<IDistributedCacheTagHelperFormatter>(),
new HtmlTestEncoder());
var tagHelperOutput = GetTagHelperOutput(
attributes: new TagHelperAttributeList(),
childContent: childContent);
var cacheTagHelper = new DistributedCacheTagHelper(
service,
new HtmlTestEncoder())
{
ViewContext = GetViewContext(),
Enabled = false
};
// Act
await cacheTagHelper.ProcessAsync(tagHelperContext, tagHelperOutput);
// Assert
Assert.Equal(childContent, tagHelperOutput.Content.GetContent());
storage.Verify(c => c.SetAsync(
/*key*/ It.IsAny<string>(),
/*content*/ value,
/*options*/ It.IsAny<DistributedCacheEntryOptions>()),
Times.Never);
}
[Fact]
public async Task ProcessAsync_ReturnsCachedValue_IfEnabled()
{
// Arrange
var childContent = "original-child-content";
var storage = new Mock<IDistributedCacheTagHelperStorage>();
var value = Encoding.UTF8.GetBytes(childContent);
storage.Setup(c => c.SetAsync(
/*key*/ It.IsAny<string>(),
/*value*/ value,
/*options*/ It.IsAny<DistributedCacheEntryOptions>()));
storage.Setup(c => c.GetAsync(It.IsAny<string>()))
.Returns(Task.FromResult<byte[]>(null));
var service = new DistributedCacheTagHelperService(
storage.Object,
Mock.Of<IDistributedCacheTagHelperFormatter>(),
new HtmlTestEncoder());
var tagHelperContext = GetTagHelperContext();
var tagHelperOutput = GetTagHelperOutput(
attributes: new TagHelperAttributeList(),
childContent: childContent);
var cacheTagHelper = new DistributedCacheTagHelper(
service,
new HtmlTestEncoder())
{
ViewContext = GetViewContext(),
Enabled = true,
Name = "some-name"
};
// Act
await cacheTagHelper.ProcessAsync(tagHelperContext, tagHelperOutput);
// Assert
Assert.Empty(tagHelperOutput.PreContent.GetContent());
Assert.Empty(tagHelperOutput.PostContent.GetContent());
Assert.True(tagHelperOutput.IsContentModified);
Assert.Equal(childContent, tagHelperOutput.Content.GetContent());
storage.Verify(c => c.GetAsync(
/*key*/ It.IsAny<string>()
),
Times.Once);
storage.Verify(c => c.SetAsync(
/*key*/ It.IsAny<string>(),
/*value*/ It.IsAny<byte[]>(),
/*options*/ It.IsAny<DistributedCacheEntryOptions>()),
Times.Once);
}
[Fact]
public async Task ProcessAsync_ReturnsCachedValue_IfVaryByParamIsUnchanged()
{
// Arrange - 1
var childContent = "original-child-content";
var storage = GetStorage();
var formatter = GetFormatter();
var tagHelperContext1 = GetTagHelperContext();
var tagHelperOutput1 = GetTagHelperOutput(
attributes: new TagHelperAttributeList(),
childContent: childContent);
var service = new DistributedCacheTagHelperService(
storage,
formatter,
new HtmlTestEncoder());
var cacheTagHelper1 = new DistributedCacheTagHelper(
service,
new HtmlTestEncoder())
{
Enabled = true,
VaryByQuery = "key1,key2",
ViewContext = GetViewContext(),
};
cacheTagHelper1.ViewContext.HttpContext.Request.QueryString = new QueryString(
"?key1=value1&key2=value2");
// Act - 1
await cacheTagHelper1.ProcessAsync(tagHelperContext1, tagHelperOutput1);
// Assert - 1
Assert.Empty(tagHelperOutput1.PreContent.GetContent());
Assert.Empty(tagHelperOutput1.PostContent.GetContent());
Assert.True(tagHelperOutput1.IsContentModified);
Assert.Equal(childContent, tagHelperOutput1.Content.GetContent());
// Arrange - 2
var tagHelperContext2 = GetTagHelperContext();
var tagHelperOutput2 = GetTagHelperOutput(
attributes: new TagHelperAttributeList(),
childContent: "different-content");
var cacheTagHelper2 = new DistributedCacheTagHelper(
service,
new HtmlTestEncoder())
{
Enabled = true,
VaryByQuery = "key1,key2",
ViewContext = GetViewContext(),
};
cacheTagHelper2.ViewContext.HttpContext.Request.QueryString = new QueryString(
"?key1=value1&key2=value2");
// Act - 2
await cacheTagHelper2.ProcessAsync(tagHelperContext2, tagHelperOutput2);
// Assert - 2
Assert.Empty(tagHelperOutput2.PreContent.GetContent());
Assert.Empty(tagHelperOutput2.PostContent.GetContent());
Assert.True(tagHelperOutput2.IsContentModified);
Assert.Equal(childContent, tagHelperOutput2.Content.GetContent());
}
[Fact]
public async Task ProcessAsync_RecalculatesValueIfCacheKeyChanges()
{
// Arrange - 1
var childContent1 = "original-child-content";
var storage = GetStorage();
var service = new DistributedCacheTagHelperService(
storage,
Mock.Of<IDistributedCacheTagHelperFormatter>(),
new HtmlTestEncoder());
var tagHelperContext1 = GetTagHelperContext();
var tagHelperOutput1 = GetTagHelperOutput(childContent: childContent1);
tagHelperOutput1.PreContent.Append("<cache>");
tagHelperOutput1.PostContent.SetContent("</cache>");
var cacheTagHelper1 = new DistributedCacheTagHelper(
service,
new HtmlTestEncoder())
{
VaryByCookie = "cookie1,cookie2",
ViewContext = GetViewContext(),
};
cacheTagHelper1.ViewContext.HttpContext.Request.Headers["Cookie"] = "cookie1=value1;cookie2=value2";
// Act - 1
await cacheTagHelper1.ProcessAsync(tagHelperContext1, tagHelperOutput1);
// Assert - 1
Assert.Empty(tagHelperOutput1.PreContent.GetContent());
Assert.Empty(tagHelperOutput1.PostContent.GetContent());
Assert.True(tagHelperOutput1.IsContentModified);
Assert.Equal(childContent1, tagHelperOutput1.Content.GetContent());
// Arrange - 2
var childContent2 = "different-content";
var tagHelperContext2 = GetTagHelperContext();
var tagHelperOutput2 = GetTagHelperOutput(childContent: childContent2);
tagHelperOutput2.PreContent.SetContent("<cache>");
tagHelperOutput2.PostContent.SetContent("</cache>");
var cacheTagHelper2 = new DistributedCacheTagHelper(
service,
new HtmlTestEncoder())
{
VaryByCookie = "cookie1,cookie2",
ViewContext = GetViewContext(),
};
cacheTagHelper2.ViewContext.HttpContext.Request.Headers["Cookie"] = "cookie1=value1;cookie2=not-value2";
// Act - 2
await cacheTagHelper2.ProcessAsync(tagHelperContext2, tagHelperOutput2);
// Assert - 2
Assert.Empty(tagHelperOutput2.PreContent.GetContent());
Assert.Empty(tagHelperOutput2.PostContent.GetContent());
Assert.True(tagHelperOutput2.IsContentModified);
Assert.Equal(childContent2, tagHelperOutput2.Content.GetContent());
}
[Fact]
public void UpdateCacheEntryOptions_SetsAbsoluteExpiration_IfExpiresOnIsSet()
{
// Arrange
var expiresOn = DateTimeOffset.UtcNow.AddMinutes(4);
var storage = GetStorage();
var service = new DistributedCacheTagHelperService(
storage,
Mock.Of<IDistributedCacheTagHelperFormatter>(),
new HtmlTestEncoder()
);
var cacheTagHelper = new DistributedCacheTagHelper(
service,
new HtmlTestEncoder())
{
ExpiresOn = expiresOn
};
// Act
var cacheEntryOptions = cacheTagHelper.GetDistributedCacheEntryOptions();
// Assert
Assert.Equal(expiresOn, cacheEntryOptions.AbsoluteExpiration);
}
[Fact]
public void UpdateCacheEntryOptions_SetsAbsoluteExpiration_IfExpiresAfterIsSet()
{
// Arrange
var expiresAfter = TimeSpan.FromSeconds(42);
var storage = GetStorage();
var service = new DistributedCacheTagHelperService(
storage,
Mock.Of<IDistributedCacheTagHelperFormatter>(),
new HtmlTestEncoder()
);
var cacheTagHelper = new DistributedCacheTagHelper(
service,
new HtmlTestEncoder())
{
ExpiresAfter = expiresAfter
};
// Act
var cacheEntryOptions = cacheTagHelper.GetDistributedCacheEntryOptions();
// Assert
Assert.Equal(expiresAfter, cacheEntryOptions.AbsoluteExpirationRelativeToNow);
}
[Fact]
public void UpdateCacheEntryOptions_SetsSlidingExpiration_IfExpiresSlidingIsSet()
{
// Arrange
var expiresSliding = TimeSpan.FromSeconds(37);
var storage = GetStorage();
var service = new DistributedCacheTagHelperService(
storage,
Mock.Of<IDistributedCacheTagHelperFormatter>(),
new HtmlTestEncoder()
);
var cacheTagHelper = new DistributedCacheTagHelper(
service,
new HtmlTestEncoder())
{
ExpiresSliding = expiresSliding
};
// Act
var cacheEntryOptions = cacheTagHelper.GetDistributedCacheEntryOptions();
// Assert
Assert.Equal(expiresSliding, cacheEntryOptions.SlidingExpiration);
}
[Fact]
public async Task ProcessAsync_UsesExpiresAfter_ToExpireCacheEntry()
{
// Arrange - 1
var currentTime = new DateTimeOffset(2010, 1, 1, 0, 0, 0, TimeSpan.Zero);
var childContent1 = "original-child-content";
var clock = new Mock<ISystemClock>();
clock.SetupGet(p => p.UtcNow)
.Returns(() => currentTime);
var storage = GetStorage(new MemoryCacheOptions { Clock = clock.Object });
var service = new DistributedCacheTagHelperService(
storage,
Mock.Of<IDistributedCacheTagHelperFormatter>(),
new HtmlTestEncoder()
);
var tagHelperContext1 = GetTagHelperContext();
var tagHelperOutput1 = GetTagHelperOutput(childContent: childContent1);
tagHelperOutput1.PreContent.SetContent("<cache>");
tagHelperOutput1.PostContent.SetContent("</cache>");
var cacheTagHelper1 = new DistributedCacheTagHelper(
service,
new HtmlTestEncoder())
{
ViewContext = GetViewContext(),
ExpiresAfter = TimeSpan.FromMinutes(10)
};
// Act - 1
await cacheTagHelper1.ProcessAsync(tagHelperContext1, tagHelperOutput1);
// Assert - 1
Assert.Empty(tagHelperOutput1.PreContent.GetContent());
Assert.Empty(tagHelperOutput1.PostContent.GetContent());
Assert.True(tagHelperOutput1.IsContentModified);
Assert.Equal(childContent1, tagHelperOutput1.Content.GetContent());
// Arrange - 2
var childContent2 = "different-content";
var tagHelperContext2 = GetTagHelperContext();
var tagHelperOutput2 = GetTagHelperOutput(childContent: childContent2);
tagHelperOutput2.PreContent.SetContent("<cache>");
tagHelperOutput2.PostContent.SetContent("</cache>");
var cacheTagHelper2 = new DistributedCacheTagHelper(
service,
new HtmlTestEncoder())
{
ViewContext = GetViewContext(),
ExpiresAfter = TimeSpan.FromMinutes(10)
};
currentTime = currentTime.AddMinutes(11);
// Act - 2
await cacheTagHelper2.ProcessAsync(tagHelperContext2, tagHelperOutput2);
// Assert - 2
Assert.Empty(tagHelperOutput2.PreContent.GetContent());
Assert.Empty(tagHelperOutput2.PostContent.GetContent());
Assert.True(tagHelperOutput2.IsContentModified);
Assert.Equal(childContent2, tagHelperOutput2.Content.GetContent());
}
[Fact]
public async Task ProcessAsync_UsesExpiresOn_ToExpireCacheEntry()
{
// Arrange - 1
var currentTime = new DateTimeOffset(2010, 1, 1, 0, 0, 0, TimeSpan.Zero);
var childContent1 = "original-child-content";
var clock = new Mock<ISystemClock>();
clock.SetupGet(p => p.UtcNow)
.Returns(() => currentTime);
var storage = GetStorage(new MemoryCacheOptions { Clock = clock.Object });
var service = new DistributedCacheTagHelperService(
storage,
Mock.Of<IDistributedCacheTagHelperFormatter>(),
new HtmlTestEncoder()
);
var tagHelperContext1 = GetTagHelperContext();
var tagHelperOutput1 = GetTagHelperOutput(childContent: childContent1);
tagHelperOutput1.PreContent.SetContent("<distributed-cache>");
tagHelperOutput1.PostContent.SetContent("</distributed-cache>");
var cacheTagHelper1 = new DistributedCacheTagHelper(
service,
new HtmlTestEncoder())
{
ViewContext = GetViewContext(),
ExpiresOn = currentTime.AddMinutes(5)
};
// Act - 1
await cacheTagHelper1.ProcessAsync(tagHelperContext1, tagHelperOutput1);
// Assert - 1
Assert.Empty(tagHelperOutput1.PreContent.GetContent());
Assert.Empty(tagHelperOutput1.PostContent.GetContent());
Assert.True(tagHelperOutput1.IsContentModified);
Assert.Equal(childContent1, tagHelperOutput1.Content.GetContent());
// Arrange - 2
currentTime = currentTime.AddMinutes(5).AddSeconds(2);
var childContent2 = "different-content";
var tagHelperContext2 = GetTagHelperContext();
var tagHelperOutput2 = GetTagHelperOutput(childContent: childContent2);
tagHelperOutput2.PreContent.SetContent("<distributed-cache>");
tagHelperOutput2.PostContent.SetContent("</distributed-cache>");
var cacheTagHelper2 = new DistributedCacheTagHelper(
service,
new HtmlTestEncoder())
{
ViewContext = GetViewContext(),
ExpiresOn = currentTime.AddMinutes(5)
};
// Act - 2
await cacheTagHelper2.ProcessAsync(tagHelperContext2, tagHelperOutput2);
// Assert - 2
Assert.Empty(tagHelperOutput2.PreContent.GetContent());
Assert.Empty(tagHelperOutput2.PostContent.GetContent());
Assert.True(tagHelperOutput2.IsContentModified);
Assert.Equal(childContent2, tagHelperOutput2.Content.GetContent());
}
[Fact]
public async Task ProcessAsync_UsesExpiresSliding_ToExpireCacheEntryWithSlidingExpiration()
{
// Arrange - 1
var currentTime = new DateTimeOffset(2010, 1, 1, 0, 0, 0, TimeSpan.Zero);
var childContent1 = "original-child-content";
var clock = new Mock<ISystemClock>();
clock.SetupGet(p => p.UtcNow)
.Returns(() => currentTime);
var storage = GetStorage(new MemoryCacheOptions { Clock = clock.Object });
var service = new DistributedCacheTagHelperService(
storage,
Mock.Of<IDistributedCacheTagHelperFormatter>(),
new HtmlTestEncoder()
);
var tagHelperContext1 = GetTagHelperContext();
var tagHelperOutput1 = GetTagHelperOutput(childContent: childContent1);
tagHelperOutput1.PreContent.SetContent("<distributed-cache>");
tagHelperOutput1.PostContent.SetContent("</distributed-cache>");
var cacheTagHelper1 = new DistributedCacheTagHelper(
service,
new HtmlTestEncoder())
{
ViewContext = GetViewContext(),
ExpiresSliding = TimeSpan.FromSeconds(30)
};
// Act - 1
await cacheTagHelper1.ProcessAsync(tagHelperContext1, tagHelperOutput1);
// Assert - 1
Assert.Empty(tagHelperOutput1.PreContent.GetContent());
Assert.Empty(tagHelperOutput1.PostContent.GetContent());
Assert.True(tagHelperOutput1.IsContentModified);
Assert.Equal(childContent1, tagHelperOutput1.Content.GetContent());
// Arrange - 2
currentTime = currentTime.AddSeconds(35);
var childContent2 = "different-content";
var tagHelperContext2 = GetTagHelperContext();
var tagHelperOutput2 = GetTagHelperOutput(childContent: childContent2);
tagHelperOutput2.PreContent.SetContent("<distributed-cache>");
tagHelperOutput2.PostContent.SetContent("</distributed-cache>");
var cacheTagHelper2 = new DistributedCacheTagHelper(
service,
new HtmlTestEncoder())
{
ViewContext = GetViewContext(),
ExpiresSliding = TimeSpan.FromSeconds(30)
};
// Act - 2
await cacheTagHelper2.ProcessAsync(tagHelperContext2, tagHelperOutput2);
// Assert - 2
Assert.Empty(tagHelperOutput2.PreContent.GetContent());
Assert.Empty(tagHelperOutput2.PostContent.GetContent());
Assert.True(tagHelperOutput2.IsContentModified);
Assert.Equal(childContent2, tagHelperOutput2.Content.GetContent());
}
[Fact]
public async Task ProcessAsync_ComputesValueOnce_WithConcurrentRequests()
{
// Arrange
var childContent = "some-content";
var resetEvent1 = new ManualResetEvent(false);
var resetEvent2 = new ManualResetEvent(false);
var calls = 0;
var formatter = GetFormatter();
var storage = GetStorage();
var service = new DistributedCacheTagHelperService(
storage,
formatter,
new HtmlTestEncoder()
);
var tagHelperContext1 = GetTagHelperContext();
var tagHelperContext2 = GetTagHelperContext();
TagHelperOutput tagHelperOutput = new TagHelperOutput(
"distributed-cache",
new TagHelperAttributeList(),
getChildContentAsync: (useCachedResult, encoder) =>
{
calls++;
resetEvent2.Set();
var tagHelperContent = new DefaultTagHelperContent();
tagHelperContent.SetHtmlContent(childContent);
return Task.FromResult<TagHelperContent>(tagHelperContent);
});
var cacheTagHelper1 = new DistributedCacheTagHelper(
service,
new HtmlTestEncoder())
{
ViewContext = GetViewContext(),
Enabled = true
};
var cacheTagHelper2 = new DistributedCacheTagHelper(
service,
new HtmlTestEncoder())
{
ViewContext = GetViewContext(),
Enabled = true
};
// Act
var task1 = Task.Run(async () =>
{
resetEvent1.WaitOne(5000);
await cacheTagHelper1.ProcessAsync(tagHelperContext1, tagHelperOutput);
});
var task2 = Task.Run(async () =>
{
resetEvent2.WaitOne(5000);
await cacheTagHelper2.ProcessAsync(tagHelperContext1, tagHelperOutput);
});
resetEvent1.Set();
await Task.WhenAll(task1, task2);
// Assert
Assert.Empty(tagHelperOutput.PreContent.GetContent());
Assert.Empty(tagHelperOutput.PostContent.GetContent());
Assert.True(tagHelperOutput.IsContentModified);
Assert.Equal(childContent, tagHelperOutput.Content.GetContent());
Assert.Equal(1, calls);
}
[Fact]
public async Task ProcessAsync_ExceptionInProcessing_DoesntBlockConcurrentRequests()
{
// Arrange
var childContent = "some-content";
var resetEvent1 = new ManualResetEvent(false);
var resetEvent2 = new ManualResetEvent(false);
var calls = 0;
var formatter = GetFormatter();
var storage = GetStorage();
var service = new DistributedCacheTagHelperService(
storage,
formatter,
new HtmlTestEncoder()
);
var tagHelperContext1 = GetTagHelperContext();
var tagHelperContext2 = GetTagHelperContext();
var tagHelperOutput1 = new TagHelperOutput(
"distributed-cache",
new TagHelperAttributeList(),
getChildContentAsync: (useCachedResult, encoder) =>
{
calls++;
resetEvent2.Set();
throw new Exception();
});
var tagHelperOutput2 = new TagHelperOutput(
"distributed-cache",
new TagHelperAttributeList(),
getChildContentAsync: (useCachedResult, encoder) =>
{
calls++;
var tagHelperContent = new DefaultTagHelperContent();
tagHelperContent.SetHtmlContent(childContent);
return Task.FromResult<TagHelperContent>(tagHelperContent);
});
var cacheTagHelper1 = new DistributedCacheTagHelper(
service,
new HtmlTestEncoder())
{
ViewContext = GetViewContext(),
Enabled = true
};
var cacheTagHelper2 = new DistributedCacheTagHelper(
service,
new HtmlTestEncoder())
{
ViewContext = GetViewContext(),
Enabled = true
};
// Act
var task1 = Task.Run(async () =>
{
resetEvent1.WaitOne(5000);
await Assert.ThrowsAsync<Exception>(() => cacheTagHelper1.ProcessAsync(tagHelperContext1, tagHelperOutput1));
});
var task2 = Task.Run(async () =>
{
resetEvent2.WaitOne(5000);
await cacheTagHelper2.ProcessAsync(tagHelperContext2, tagHelperOutput2);
});
resetEvent1.Set();
await Task.WhenAll(task1, task2);
// Assert
Assert.Empty(tagHelperOutput1.PreContent.GetContent());
Assert.Empty(tagHelperOutput1.PostContent.GetContent());
Assert.False(tagHelperOutput1.IsContentModified);
Assert.Empty(tagHelperOutput1.Content.GetContent());
Assert.Empty(tagHelperOutput2.PreContent.GetContent());
Assert.Empty(tagHelperOutput2.PostContent.GetContent());
Assert.True(tagHelperOutput2.IsContentModified);
Assert.Equal(childContent, tagHelperOutput2.Content.GetContent());
Assert.Equal(2, calls);
}
[Fact]
public async Task Deserialize_DoesntAlterValue_WhenSerialized()
{
// Arrange
var content = "<b>some content</b>";
var formatter = GetFormatter();
var context = new DistributedCacheTagHelperFormattingContext
{
Html = new HtmlString(content)
};
var serialized = await formatter.SerializeAsync(context);
// Act
var deserialized = await formatter.DeserializeAsync(serialized);
// Assert
Assert.Equal(deserialized.ToString(), content);
}
private static ViewContext GetViewContext()
{
var actionContext = new ActionContext(new DefaultHttpContext(), new RouteData(), new ActionDescriptor());
return new ViewContext(actionContext,
Mock.Of<IView>(),
new ViewDataDictionary(new EmptyModelMetadataProvider()),
Mock.Of<ITempDataDictionary>(),
TextWriter.Null,
new HtmlHelperOptions());
}
private static TagHelperContext GetTagHelperContext()
{
return new TagHelperContext(
allAttributes: new TagHelperAttributeList(),
items: new Dictionary<object, object>(),
uniqueId: "testid");
}
private static TagHelperOutput GetTagHelperOutput(
string tagName = "distributed-cache",
TagHelperAttributeList attributes = null,
string childContent = "some child content")
{
attributes = attributes ?? new TagHelperAttributeList { { "attr", "value" } };
return new TagHelperOutput(
tagName,
attributes,
getChildContentAsync: (useCachedResult, encoder) =>
{
var tagHelperContent = new DefaultTagHelperContent();
tagHelperContent.SetHtmlContent(childContent);
return Task.FromResult<TagHelperContent>(tagHelperContent);
});
}
private static string GetHashedBytes(string input)
{
using (var sha = SHA256.Create())
{
var contentBytes = Encoding.UTF8.GetBytes(input);
var hashedBytes = sha.ComputeHash(contentBytes);
return Convert.ToBase64String(hashedBytes);
}
}
private static IDistributedCacheTagHelperStorage GetStorage(MemoryCacheOptions options = null)
{
return new DistributedCacheTagHelperStorage(new MemoryDistributedCache(new MemoryCache(options ?? new MemoryCacheOptions())));
}
private static IDistributedCacheTagHelperFormatter GetFormatter()
{
return new DistributedCacheTagHelperFormatter();
}
}
}