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:
parent
c03aabbff5
commit
ec560bdfe0
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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; }
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 <distributed-cache>
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 <cache> 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
|
||||
|
|
|
|||
|
|
@ -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(")");
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 <distributed-cache> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -35,6 +35,7 @@ namespace Microsoft.Extensions.DependencyInjection
|
|||
builder.AddFormatterMappings();
|
||||
builder.AddViews();
|
||||
builder.AddRazorViewEngine();
|
||||
builder.AddCacheTagHelper();
|
||||
|
||||
// +1 order
|
||||
builder.AddDataAnnotations(); // +1 order
|
||||
|
|
|
|||
|
|
@ -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-*",
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue