From ec560bdfe0899082ddad15a5edf3cd9b45881e91 Mon Sep 17 00:00:00 2001 From: Sebastien Ros Date: Mon, 14 Mar 2016 18:12:05 -0700 Subject: [PATCH] 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 --- .../DistributedCacheTagHelperFormatter.cs | 47 + ...tributedCacheTagHelperFormattingContext.cs | 18 + .../Cache/DistributedCacheTagHelperService.cs | 116 +++ .../Cache/DistributedCacheTagHelperStorage.cs | 54 + .../IDistributedCacheTagHelperFormatter.cs | 29 + .../IDistributedCacheTagHelperService.cs | 26 + .../IDistributedCacheTagHelperStorage.cs | 31 + .../CacheTagHelper.cs | 349 ++----- .../CacheTagHelperBase.cs | 312 ++++++ .../TagHelperExtensions.cs | 40 + .../DistributedCacheTagHelper.cs | 123 +++ .../MvcServiceCollectionExtensions.cs | 1 + src/Microsoft.AspNetCore.Mvc/project.json | 1 + .../CacheTagHelperTest.cs | 203 +++- .../DistributedCacheTagHelperTest.cs | 986 ++++++++++++++++++ 15 files changed, 2031 insertions(+), 305 deletions(-) create mode 100644 src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/DistributedCacheTagHelperFormatter.cs create mode 100644 src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/DistributedCacheTagHelperFormattingContext.cs create mode 100644 src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/DistributedCacheTagHelperService.cs create mode 100644 src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/DistributedCacheTagHelperStorage.cs create mode 100644 src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/IDistributedCacheTagHelperFormatter.cs create mode 100644 src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/IDistributedCacheTagHelperService.cs create mode 100644 src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/IDistributedCacheTagHelperStorage.cs create mode 100644 src/Microsoft.AspNetCore.Mvc.TagHelpers/CacheTagHelperBase.cs create mode 100644 src/Microsoft.AspNetCore.Mvc.TagHelpers/DependencyInjection/TagHelperExtensions.cs create mode 100644 src/Microsoft.AspNetCore.Mvc.TagHelpers/DistributedCacheTagHelper.cs create mode 100644 test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/DistributedCacheTagHelperTest.cs diff --git a/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/DistributedCacheTagHelperFormatter.cs b/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/DistributedCacheTagHelperFormatter.cs new file mode 100644 index 0000000000..ba9a0fb416 --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/DistributedCacheTagHelperFormatter.cs @@ -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 +{ + /// + /// Implements by serializing the content + /// in UTF8. + /// + public class DistributedCacheTagHelperFormatter : IDistributedCacheTagHelperFormatter + { + + /// + public Task 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); + } + + /// + public Task DeserializeAsync(byte[] value) + { + if (value == null) + { + throw new ArgumentNullException(nameof(value)); + } + + var content = Encoding.UTF8.GetString(value); + return Task.FromResult(new HtmlString(content)); + } + } +} diff --git a/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/DistributedCacheTagHelperFormattingContext.cs b/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/DistributedCacheTagHelperFormattingContext.cs new file mode 100644 index 0000000000..19e8f908e6 --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/DistributedCacheTagHelperFormattingContext.cs @@ -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 +{ + /// + /// Represents an object containing the information to serialize with . + /// + public class DistributedCacheTagHelperFormattingContext + { + /// + /// Gets the instance. + /// + public HtmlString Html { get; set; } + } +} diff --git a/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/DistributedCacheTagHelperService.cs b/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/DistributedCacheTagHelperService.cs new file mode 100644 index 0000000000..91b420c248 --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/DistributedCacheTagHelperService.cs @@ -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 +{ + /// + /// Implements and ensure + /// multiple concurrent requests are gated. + /// + public class DistributedCacheTagHelperService : IDistributedCacheTagHelperService + { + private readonly IDistributedCacheTagHelperStorage _storage; + private readonly IDistributedCacheTagHelperFormatter _formatter; + private readonly HtmlEncoder _htmlEncoder; + private readonly ConcurrentDictionary> _workers; + + public DistributedCacheTagHelperService( + IDistributedCacheTagHelperStorage storage, + IDistributedCacheTagHelperFormatter formatter, + HtmlEncoder HtmlEncoder + ) + { + _formatter = formatter; + _storage = storage; + _htmlEncoder = HtmlEncoder; + + _workers = new ConcurrentDictionary>(); + } + + /// + public async Task ProcessContentAsync(TagHelperOutput output, string key, DistributedCacheEntryOptions options) + { + IHtmlContent content = null; + + while (content == null) + { + Task result = null; + + // Is there any request already processing the value? + if (!_workers.TryGetValue(key, out result)) + { + var tcs = new TaskCompletionSource(); + + _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 worker; + _workers.TryRemove(key, out worker); + } + } + else + { + content = await result; + } + } + + return content; + } + } +} diff --git a/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/DistributedCacheTagHelperStorage.cs b/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/DistributedCacheTagHelperStorage.cs new file mode 100644 index 0000000000..2409f9d5ea --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/DistributedCacheTagHelperStorage.cs @@ -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 +{ + /// + /// Implements by storing the content + /// in using as the store. + /// + public class DistributedCacheTagHelperStorage : IDistributedCacheTagHelperStorage + { + private readonly IDistributedCache _distributedCache; + + /// + /// Creates a new . + /// + /// The to use. + public DistributedCacheTagHelperStorage(IDistributedCache distributedCache) + { + _distributedCache = distributedCache; + } + + /// + public Task GetAsync(string key) + { + if (key == null) + { + throw new ArgumentNullException(nameof(key)); + } + + return _distributedCache.GetAsync(key); + } + + /// + 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); + } + } +} diff --git a/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/IDistributedCacheTagHelperFormatter.cs b/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/IDistributedCacheTagHelperFormatter.cs new file mode 100644 index 0000000000..b1cce8ea88 --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/IDistributedCacheTagHelperFormatter.cs @@ -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 +{ + /// + /// An implementation of this interface provides a service to + /// serialize html fragments for being store by + /// + public interface IDistributedCacheTagHelperFormatter + { + /// + /// Serializes some html content. + /// + /// The to serialize. + /// The serialized result. + Task SerializeAsync(DistributedCacheTagHelperFormattingContext context); + + /// + /// Deserialize some html content. + /// + /// The value to deserialize. + /// The deserialized content, null otherwise. + Task DeserializeAsync(byte[] value); + } +} diff --git a/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/IDistributedCacheTagHelperService.cs b/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/IDistributedCacheTagHelperService.cs new file mode 100644 index 0000000000..5843dbd38c --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/IDistributedCacheTagHelperService.cs @@ -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 +{ + /// + /// An implementation of this interface provides a service to process + /// the content or fetches it from cache for distributed cache tag helpers. + /// + public interface IDistributedCacheTagHelperService + { + /// + /// Processes the html content of a distributed cache tag helper. + /// + /// The . + /// The key in the storage. + /// The . + /// A cached or new content for the cache tag helper. + Task ProcessContentAsync(TagHelperOutput output, string key, DistributedCacheEntryOptions options); + } +} diff --git a/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/IDistributedCacheTagHelperStorage.cs b/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/IDistributedCacheTagHelperStorage.cs new file mode 100644 index 0000000000..2d29c436d7 --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.TagHelpers/Cache/IDistributedCacheTagHelperStorage.cs @@ -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 +{ + /// + /// An implementation of this interface provides a service to + /// cache distributed html fragments from the <distributed-cache> + /// tag helper. + /// + public interface IDistributedCacheTagHelperStorage + { + /// + /// Gets the content from the cache and deserializes it. + /// + /// The unique key to use in the cache. + /// The stored value if it exists, null otherwise. + Task GetAsync(string key); + + /// + /// Sets the content in the cache and serialized it. + /// + /// The unique key to use in the cache. + /// The value to cache. + /// The cache entry options. + Task SetAsync(string key, byte[] value, DistributedCacheEntryOptions options); + } +} diff --git a/src/Microsoft.AspNetCore.Mvc.TagHelpers/CacheTagHelper.cs b/src/Microsoft.AspNetCore.Mvc.TagHelpers/CacheTagHelper.cs index f6f455f12b..8d629b4bce 100644 --- a/src/Microsoft.AspNetCore.Mvc.TagHelpers/CacheTagHelper.cs +++ b/src/Microsoft.AspNetCore.Mvc.TagHelpers/CacheTagHelper.cs @@ -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 /// /// implementation targeting <cache> elements. /// - public class CacheTagHelper : TagHelper + public class CacheTagHelper : CacheTagHelperBase { /// /// Prefix used by instances when creating entries in . /// 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[] { ',' }; /// /// Creates a new . /// /// The . /// The to use. - public CacheTagHelper(IMemoryCache memoryCache, HtmlEncoder htmlEncoder) + public CacheTagHelper(IMemoryCache memoryCache, HtmlEncoder htmlEncoder) : base(htmlEncoder) { MemoryCache = memoryCache; - HtmlEncoder = htmlEncoder; - } - - /// - public override int Order - { - get - { - return -1000; - } } /// @@ -65,85 +40,12 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers /// protected IMemoryCache MemoryCache { get; } - /// - /// Gets the which encodes the content to be cached. - /// - protected HtmlEncoder HtmlEncoder { get; } - - /// - /// Gets or sets the for the current executing View. - /// - [HtmlAttributeNotBound] - [ViewContext] - public ViewContext ViewContext { get; set; } - - /// - /// Gets or sets a to vary the cached result by. - /// - [HtmlAttributeName(VaryByAttributeName)] - public string VaryBy { get; set; } - - /// - /// Gets or sets the name of a HTTP request header to vary the cached result by. - /// - [HtmlAttributeName(VaryByHeaderAttributeName)] - public string VaryByHeader { get; set; } - - /// - /// Gets or sets a comma-delimited set of query parameters to vary the cached result by. - /// - [HtmlAttributeName(VaryByQueryAttributeName)] - public string VaryByQuery { get; set; } - - /// - /// Gets or sets a comma-delimited set of route data parameters to vary the cached result by. - /// - [HtmlAttributeName(VaryByRouteAttributeName)] - public string VaryByRoute { get; set; } - - /// - /// Gets or sets a comma-delimited set of cookie names to vary the cached result by. - /// - [HtmlAttributeName(VaryByCookieAttributeName)] - public string VaryByCookie { get; set; } - - /// - /// Gets or sets a value that determines if the cached result is to be varied by the Identity for the logged in - /// . - /// - [HtmlAttributeName(VaryByUserAttributeName)] - public bool VaryByUser { get; set; } - - /// - /// Gets or sets the exact the cache entry should be evicted. - /// - [HtmlAttributeName(ExpiresOnAttributeName)] - public DateTimeOffset? ExpiresOn { get; set; } - - /// - /// Gets or sets the duration, from the time the cache entry was added, when it should be evicted. - /// - [HtmlAttributeName(ExpiresAfterAttributeName)] - public TimeSpan? ExpiresAfter { get; set; } - - /// - /// Gets or sets the duration from last access that the cache entry should be evicted. - /// - [HtmlAttributeName(ExpiresSlidingAttributeName)] - public TimeSpan? ExpiresSliding { get; set; } - /// /// Gets or sets the policy for the cache entry. /// [HtmlAttributeName(CachePriorityAttributeName)] public CacheItemPriority? Priority { get; set; } - /// - /// Gets or sets the value which determines if the tag helper is enabled or not. - /// - [HtmlAttributeName(EnabledAttributeName)] - public bool Enabled { get; set; } = true; - /// 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 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(); + + 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 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( - StringBuilder builder, - string keyName, - string value, - TSourceCollection sourceCollection, - Func 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 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 Tokenize(string value) - { - var values = value.Split(AttributeSeparator, StringSplitOptions.RemoveEmptyEntries); - if (values.Length == 0) - { - return values; + content.WriteTo(writer, HtmlEncoder); } - var trimmedValues = new List(); - - 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 diff --git a/src/Microsoft.AspNetCore.Mvc.TagHelpers/CacheTagHelperBase.cs b/src/Microsoft.AspNetCore.Mvc.TagHelpers/CacheTagHelperBase.cs new file mode 100644 index 0000000000..929f257e56 --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.TagHelpers/CacheTagHelperBase.cs @@ -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 +{ + /// + /// base implementation for caching elements. + /// + 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[] { ',' }; + + /// + /// Creates a new . + /// + /// The to use. + public CacheTagHelperBase(HtmlEncoder htmlEncoder) + { + HtmlEncoder = htmlEncoder; + } + + /// + public override int Order + { + get + { + return -1000; + } + } + + /// + /// Gets the which encodes the content to be cached. + /// + protected HtmlEncoder HtmlEncoder { get; } + + /// + /// Gets or sets the for the current executing View. + /// + [HtmlAttributeNotBound] + [ViewContext] + public ViewContext ViewContext { get; set; } + + /// + /// Gets or sets a to vary the cached result by. + /// + [HtmlAttributeName(VaryByAttributeName)] + public string VaryBy { get; set; } + + /// + /// Gets or sets the name of a HTTP request header to vary the cached result by. + /// + [HtmlAttributeName(VaryByHeaderAttributeName)] + public string VaryByHeader { get; set; } + + /// + /// Gets or sets a comma-delimited set of query parameters to vary the cached result by. + /// + [HtmlAttributeName(VaryByQueryAttributeName)] + public string VaryByQuery { get; set; } + + /// + /// Gets or sets a comma-delimited set of route data parameters to vary the cached result by. + /// + [HtmlAttributeName(VaryByRouteAttributeName)] + public string VaryByRoute { get; set; } + + /// + /// Gets or sets a comma-delimited set of cookie names to vary the cached result by. + /// + [HtmlAttributeName(VaryByCookieAttributeName)] + public string VaryByCookie { get; set; } + + /// + /// Gets or sets a value that determines if the cached result is to be varied by the Identity for the logged in + /// . + /// + [HtmlAttributeName(VaryByUserAttributeName)] + public bool VaryByUser { get; set; } + + /// + /// Gets or sets the exact the cache entry should be evicted. + /// + [HtmlAttributeName(ExpiresOnAttributeName)] + public DateTimeOffset? ExpiresOn { get; set; } + + /// + /// Gets or sets the duration, from the time the cache entry was added, when it should be evicted. + /// + [HtmlAttributeName(ExpiresAfterAttributeName)] + public TimeSpan? ExpiresAfter { get; set; } + + /// + /// Gets or sets the duration from last access that the cache entry should be evicted. + /// + [HtmlAttributeName(ExpiresSlidingAttributeName)] + public TimeSpan? ExpiresSliding { get; set; } + + /// + /// Gets or sets the value which determines if the tag helper is enabled or not. + /// + [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 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( + StringBuilder builder, + string keyName, + string value, + TSourceCollection sourceCollection, + Func 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 Tokenize(string value) + { + var values = value.Split(AttributeSeparator, StringSplitOptions.RemoveEmptyEntries); + if (values.Length == 0) + { + return values; + } + + var trimmedValues = new List(); + + 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(")"); + } + + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Mvc.TagHelpers/DependencyInjection/TagHelperExtensions.cs b/src/Microsoft.AspNetCore.Mvc.TagHelpers/DependencyInjection/TagHelperExtensions.cs new file mode 100644 index 0000000000..4895085d0f --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.TagHelpers/DependencyInjection/TagHelperExtensions.cs @@ -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 +{ + /// + /// Extension methods for configuring Razor cache tag helpers. + /// + public static class TagHelperServicesExtensions + { + /// + /// Adds MVC cache tag helper services to the application. + /// + /// The . + /// The . + public static IMvcCoreBuilder AddCacheTagHelper(this IMvcCoreBuilder builder) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + builder.Services.TryAddTransient(); + builder.Services.TryAddTransient(); + builder.Services.TryAddSingleton(); + + // Required default services for cache tag helpers + builder.Services.TryAddSingleton(); + builder.Services.TryAddSingleton(); + + return builder; + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Mvc.TagHelpers/DistributedCacheTagHelper.cs b/src/Microsoft.AspNetCore.Mvc.TagHelpers/DistributedCacheTagHelper.cs new file mode 100644 index 0000000000..94cb218b99 --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.TagHelpers/DistributedCacheTagHelper.cs @@ -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 +{ + /// + /// implementation targeting <distributed-cache> elements. + /// + [HtmlTargetElement("distributed-cache", Attributes = NameAttributeName)] + public class DistributedCacheTagHelper : CacheTagHelperBase + { + private readonly IDistributedCacheTagHelperService _distributedCacheService; + + /// + /// Prefix used by instances when creating entries in . + /// + public static readonly string CacheKeyPrefix = nameof(DistributedCacheTagHelper); + + private const string NameAttributeName = "name"; + + /// + /// Creates a new . + /// + /// The . + /// The . + public DistributedCacheTagHelper( + IDistributedCacheTagHelperService distributedCacheService, + HtmlEncoder htmlEncoder) + : base(htmlEncoder) + { + _distributedCacheService = distributedCacheService; + } + + /// + /// Gets the instance used to cache workers. + /// + protected IMemoryCache MemoryCache { get; } + + /// + /// Gets or sets a unique name to discriminate cached entries. + /// + [HtmlAttributeName(NameAttributeName)] + public string Name { get; set; } + + /// + 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; + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Mvc/MvcServiceCollectionExtensions.cs b/src/Microsoft.AspNetCore.Mvc/MvcServiceCollectionExtensions.cs index 36c876bdaa..0dfe509e96 100644 --- a/src/Microsoft.AspNetCore.Mvc/MvcServiceCollectionExtensions.cs +++ b/src/Microsoft.AspNetCore.Mvc/MvcServiceCollectionExtensions.cs @@ -35,6 +35,7 @@ namespace Microsoft.Extensions.DependencyInjection builder.AddFormatterMappings(); builder.AddViews(); builder.AddRazorViewEngine(); + builder.AddCacheTagHelper(); // +1 order builder.AddDataAnnotations(); // +1 order diff --git a/src/Microsoft.AspNetCore.Mvc/project.json b/src/Microsoft.AspNetCore.Mvc/project.json index 3f368436c5..994c10a995 100644 --- a/src/Microsoft.AspNetCore.Mvc/project.json +++ b/src/Microsoft.AspNetCore.Mvc/project.json @@ -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-*", diff --git a/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/CacheTagHelperTest.cs b/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/CacheTagHelperTest.cs index eca0adfd4e..fe8eaa7a87 100644 --- a/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/CacheTagHelperTest.cs +++ b/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/CacheTagHelperTest.cs @@ -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(), new HtmlTestEncoder()) { @@ -249,14 +250,12 @@ namespace Microsoft.AspNetCore.Mvc.TagHelpers var id = "unique-id"; var childContent = "original-child-content"; var cache = new Mock(); - cache.CallBase = true; var value = new DefaultTagHelperContent().SetContent("ok"); cache.Setup(c => c.Set( /*key*/ It.IsAny(), /*value*/ value, /*optons*/ It.IsAny())) - .Returns(value) - .Verifiable(); + .Returns(value); object cacheResult; cache.Setup(c => c.TryGetValue(It.IsAny(), 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(); - cache.CallBase = true; var value = new DefaultTagHelperContent().SetContent("ok"); cache.Setup(c => c.CreateLinkingScope()).Returns(new Mock().Object); cache.Setup(c => c.Set( /*key*/ It.IsAny(), /*value*/ It.IsAny(), /*options*/ It.IsAny())) - .Returns(value) - .Verifiable(); + .Returns(value); object cacheResult; cache.Setup(c => c.TryGetValue(It.IsAny(), 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(), /*value*/ It.IsAny(), /*options*/ It.IsAny()), - 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(); 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(); 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(); 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 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); + }); + + 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); + }); + + 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(() => 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()); diff --git a/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/DistributedCacheTagHelperTest.cs b/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/DistributedCacheTagHelperTest.cs new file mode 100644 index 0000000000..32e4b320ca --- /dev/null +++ b/test/Microsoft.AspNetCore.Mvc.TagHelpers.Test/DistributedCacheTagHelperTest.cs @@ -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(), + 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(), + 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(), + 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(), + 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(), + 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(), + 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(), + 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(), + 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(), + 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(); + var value = Encoding.UTF8.GetBytes("ok"); + storage.Setup(c => c.SetAsync( + /*key*/ It.IsAny(), + /*value*/ value, + /*options*/ It.IsAny())); + storage.Setup(c => c.GetAsync(It.IsAny())) + .Returns(Task.FromResult(value)); + var tagHelperContext = GetTagHelperContext(); + var service = new DistributedCacheTagHelperService( + storage.Object, + Mock.Of(), + 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(), + /*content*/ value, + /*options*/ It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ProcessAsync_ReturnsCachedValue_IfEnabled() + { + // Arrange + var childContent = "original-child-content"; + var storage = new Mock(); + var value = Encoding.UTF8.GetBytes(childContent); + storage.Setup(c => c.SetAsync( + /*key*/ It.IsAny(), + /*value*/ value, + /*options*/ It.IsAny())); + storage.Setup(c => c.GetAsync(It.IsAny())) + .Returns(Task.FromResult(null)); + var service = new DistributedCacheTagHelperService( + storage.Object, + Mock.Of(), + 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() + ), + Times.Once); + + storage.Verify(c => c.SetAsync( + /*key*/ It.IsAny(), + /*value*/ It.IsAny(), + /*options*/ It.IsAny()), + 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(), + new HtmlTestEncoder()); + var tagHelperContext1 = GetTagHelperContext(); + var tagHelperOutput1 = GetTagHelperOutput(childContent: childContent1); + tagHelperOutput1.PreContent.Append(""); + tagHelperOutput1.PostContent.SetContent(""); + 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(""); + tagHelperOutput2.PostContent.SetContent(""); + 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(), + 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(), + 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(), + 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(); + clock.SetupGet(p => p.UtcNow) + .Returns(() => currentTime); + var storage = GetStorage(new MemoryCacheOptions { Clock = clock.Object }); + var service = new DistributedCacheTagHelperService( + storage, + Mock.Of(), + new HtmlTestEncoder() + ); + var tagHelperContext1 = GetTagHelperContext(); + var tagHelperOutput1 = GetTagHelperOutput(childContent: childContent1); + tagHelperOutput1.PreContent.SetContent(""); + tagHelperOutput1.PostContent.SetContent(""); + 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(""); + tagHelperOutput2.PostContent.SetContent(""); + 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(); + clock.SetupGet(p => p.UtcNow) + .Returns(() => currentTime); + var storage = GetStorage(new MemoryCacheOptions { Clock = clock.Object }); + var service = new DistributedCacheTagHelperService( + storage, + Mock.Of(), + new HtmlTestEncoder() + ); + var tagHelperContext1 = GetTagHelperContext(); + var tagHelperOutput1 = GetTagHelperOutput(childContent: childContent1); + tagHelperOutput1.PreContent.SetContent(""); + tagHelperOutput1.PostContent.SetContent(""); + 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(""); + tagHelperOutput2.PostContent.SetContent(""); + 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(); + clock.SetupGet(p => p.UtcNow) + .Returns(() => currentTime); + var storage = GetStorage(new MemoryCacheOptions { Clock = clock.Object }); + var service = new DistributedCacheTagHelperService( + storage, + Mock.Of(), + new HtmlTestEncoder() + ); + var tagHelperContext1 = GetTagHelperContext(); + var tagHelperOutput1 = GetTagHelperOutput(childContent: childContent1); + tagHelperOutput1.PreContent.SetContent(""); + tagHelperOutput1.PostContent.SetContent(""); + 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(""); + tagHelperOutput2.PostContent.SetContent(""); + 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); + }); + + 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); + }); + + 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(() => 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 = "some content"; + 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(), + new ViewDataDictionary(new EmptyModelMetadataProvider()), + Mock.Of(), + TextWriter.Null, + new HtmlHelperOptions()); + } + + private static TagHelperContext GetTagHelperContext() + { + return new TagHelperContext( + allAttributes: new TagHelperAttributeList(), + items: new Dictionary(), + 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); + }); + } + + 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(); + } + } +}